diff --git a/README.en.md b/README.en.md
index d652e74..eb1340d 100644
--- a/README.en.md
+++ b/README.en.md
@@ -45,12 +45,11 @@ skeletons. Default region: **eu-west-3** (Paris) đ«đ·.
- An AWS account with permission to create the resources above (Aurora DSQL is available
in eu-west-3 among others).
- **Docker** installed and **running** (buildx; the image is `linux/arm64`).
-- **Terraform â„ 1.14** (an older 1.5.x on your PATH will fail the `required_version` check).
+- **Terraform â„ 1.14**.
- **AWS CLI v2** with an **active session** for your profile â `aws sso login --profile
`
or exported credentials. The principal must be able to create DSQL, S3, ECR, DynamoDB,
SSM, Lambda, API Gateway, CloudFront and IAM roles.
- **make**, `git`, `curl`, `unzip`.
-- Aurora DSQL, Bedrock etc. beyond the above are **not** used by the bare platform.
## Quick start
@@ -94,12 +93,13 @@ make run-local # SPIP on http://localhost:8080 (Apache)
| Task | Doc |
|---|---|
-| Add a plugin (third-party or custom) | [docs/plugins.md](docs/plugins.md) |
-| Upgrade SPIP core | [docs/spip-upgrade.md](docs/spip-upgrade.md) |
-| Add / configure an environment | [docs/environments.md](docs/environments.md) |
-| Initialise the database + admin | [docs/db-bootstrap.md](docs/db-bootstrap.md) |
-| Reset an admin password | [docs/spip-passwords.md](docs/spip-passwords.md) |
-| DSQL, sessions, S3, secrets, tracing, logging, cron | `docs/*.md` |
+| Add a plugin (third-party or custom) | [docs/plugins.md](docs/en/plugins.md) |
+| Upgrade SPIP core | [docs/spip-upgrade.md](docs/en/spip-upgrade.md) |
+| Build the image / Composer deps / shrink-vendor | [docs/build.md](docs/en/build.md) |
+| Add / configure an environment | [docs/environments.md](docs/en/environments.md) |
+| Initialise the database + admin | [docs/db-bootstrap.md](docs/en/db-bootstrap.md) |
+| Reset an admin password | [docs/spip-passwords.md](docs/en/spip-passwords.md) |
+| DSQL, sessions, S3, secrets, tracing, logging, cron | `docs/en/*.md` |
## Security notes
diff --git a/README.md b/README.md
index 2e71f71..d8b8058 100644
--- a/README.md
+++ b/README.md
@@ -45,12 +45,11 @@ plugins et vos squelettes. RĂ©gion par dĂ©faut : **eu-west-3** (Paris) đ«đ·.
- Un compte AWS autorisé à créer les ressources ci-dessus (Aurora DSQL est disponible en
eu-west-3, entre autres).
- **Docker** installé et **démarré** (buildx ; l'image est `linux/arm64`).
-- **Terraform ℠1.14** (un vieux 1.5.x dans le PATH échoue sur le contrÎle `required_version`).
+- **Terraform â„ 1.14**.
- **AWS CLI v2** avec une **session active** pour votre profil â `aws sso login --profile
`
ou des credentials exportés. Le principal doit pouvoir créer DSQL, S3, ECR, DynamoDB, SSM,
Lambda, API Gateway, CloudFront et des rĂŽles IAM.
- **make**, `git`, `curl`, `unzip`.
-- Au-delà , Aurora DSQL, Bedrock, etc. ne sont **pas** utilisés par la plateforme nue.
## Démarrage rapide
@@ -94,14 +93,15 @@ make run-local # SPIP sur http://localhost:8080 (Apache)
| TĂąche | Doc |
|---|---|
-| Ajouter un plugin (tiers ou maison) | [docs/plugins.md](docs/plugins.md) |
-| Mettre Ă jour le cĆur SPIP | [docs/spip-upgrade.md](docs/spip-upgrade.md) |
-| Ajouter / configurer un environnement | [docs/environments.md](docs/environments.md) |
-| Initialiser la base + l'admin | [docs/db-bootstrap.md](docs/db-bootstrap.md) |
-| Réinitialiser un mot de passe admin | [docs/spip-passwords.md](docs/spip-passwords.md) |
-| DSQL, sessions, S3, secrets, tracing, logs, cron | `docs/*.md` |
-
-> Les docs détaillées sous `docs/*.md` sont en anglais pour l'instant.
+| Ajouter un plugin (tiers ou maison) | [docs/plugins.md](docs/fr/plugins.md) |
+| Mettre Ă jour le cĆur SPIP | [docs/spip-upgrade.md](docs/fr/spip-upgrade.md) |
+| Build de l'image / dépendances Composer / shrink-vendor | [docs/build.md](docs/fr/build.md) |
+| Ajouter / configurer un environnement | [docs/environments.md](docs/fr/environments.md) |
+| Initialiser la base + l'admin | [docs/db-bootstrap.md](docs/fr/db-bootstrap.md) |
+| Réinitialiser un mot de passe admin | [docs/spip-passwords.md](docs/fr/spip-passwords.md) |
+| DSQL, sessions, S3, secrets, tracing, logs, cron | `docs/fr/*.md` |
+
+> Les docs détaillées sous `docs/fr/*.md` sont en anglais pour l'instant.
## Notes de sécurité
diff --git a/docs/en/build.md b/docs/en/build.md
new file mode 100644
index 0000000..f770754
--- /dev/null
+++ b/docs/en/build.md
@@ -0,0 +1,77 @@
+# Build & image internals
+
+[Français](../fr/build.md) · **English**
+
+The SPIP runtime is a single **immutable, multi-stage Docker image** (ARM64, PHP 8.5 via
+Bref). `make build` builds it; `make deploy` builds + pushes it to ECR and points the
+Lambda at the new digest. Nothing is installed at runtime â everything is baked here.
+
+## Dockerfile stages (`spip/Dockerfile`)
+
+| Stage | Role |
+|---|---|
+| `spip-core` | Downloads the official SPIP release zip for `SPIP_VERSION` into `/spip-src` (core is **not** vendored â see [spip-upgrade.md](spip-upgrade.md)). |
+| `ext-pgsql` | Compiles the PHP `pgsql` extension â Aurora DSQL speaks the PostgreSQL wire protocol. |
+| `ext-gd` | Compiles the PHP `gd` extension (image resizing/thumbnails). |
+| `vendors` | Runs Composer **on top of** the fetched SPIP core, then shrinks `vendor/`. |
+| `local` | Apache image for `make run-local` (local dev only). |
+| `lambda` | Final image: assembles core + vendor + extensions + overlays + plugins, patches `documents.php` for S3, pre-warms opcache, adds the ADOT collector. |
+
+## Composer dependencies (the `vendors` stage)
+
+SPIP core ships its own `vendor/` in the release zip. On top of it we add the pieces that
+make SPIP run on Lambda:
+
+```
+composer require \
+ bref/bref:^3.0 # PHP runtime for Lambda (FPM)
+ bref/secrets-loader:^1 # resolves bref-ssm:/... env values from SSM at boot
+ aws/aws-sdk-php:^3.0 # DSQL IAM token, S3, DynamoDB sessions, SSM, CloudFrontâŠ
+ open-telemetry/sdk:^1 # tracing
+ open-telemetry/exporter-otlp:^1
+```
+
+Then `spip/scripts/shrink-vendor.sh vendor` runs (see below).
+
+## `shrink-vendor.sh` â why and what
+
+The full `aws/aws-sdk-php` ships data + client classes for **every** AWS service (~hundreds
+of MB). A Lambda image only needs a handful, and a smaller image means faster cold starts
+and cheaper storage. The script:
+
+- **Keeps only the AWS SDK services SPIP uses** (a `KEEP_SERVICES` allow-list) and deletes
+ the rest of `aws-sdk-php/src/data/*` and `src//`.
+- **Strips docs/tests/examples** from the whole `vendor/` tree (`*.md`, `CHANGELOG*`,
+ `LICENSE*`, `README*`, `tests/`, `docs/`, `examples/`).
+- Prints the saved size (`AWS SDK: MB â MB`).
+
+> Note: the current `KEEP_SERVICES` allow-list still contains a few services inherited from
+> the original application (`bedrock-*`, `translate`) that the bare platform does not use.
+> Trim them to `dsql, dynamodb, s3, ssm, sts, cloudfront, ses` for a leaner image, and add
+> back whatever your own plugins need.
+
+To add an SDK service your plugin needs: add it to `KEEP_SERVICES` (and to the `src/`
+`case` allow-list) in `spip/scripts/shrink-vendor.sh`, then rebuild.
+
+## Opcache pre-warm
+
+`compile-opcache.php` (run with `opcache-build.ini`) compiles the PHP files into a
+read-only opcache **file cache** baked into the image (`/bref/opcache`), read at runtime.
+This removes first-request compilation from the cold-start path.
+
+## Observability bits
+
+- **ADOT collector**: `make download-adot` fetches the AWS OTEL collector Lambda extension
+ into `spip/overlay/adot-collector` (git-ignored); the image ships it as a `/opt`
+ extension. Traces flow OTEL â collector â X-Ray. See [tracing.md](tracing.md).
+- **Logs**: `logs_stderr` sends `spip_log()` to stderr â CloudWatch. See [logging.md](logging.md).
+
+## Where to change what
+
+| You want to⊠| Edit |
+|---|---|
+| Bump SPIP | `spip/SPIP_VERSION` (see [spip-upgrade.md](spip-upgrade.md)) |
+| Add a PHP/Composer dependency | the `composer require` line in `spip/Dockerfile` |
+| Keep another AWS SDK service | `KEEP_SERVICES` in `spip/scripts/shrink-vendor.sh` |
+| Add a PHP extension | a new `ext-*` stage in `spip/Dockerfile` |
+| Add a plugin | see [plugins.md](plugins.md) |
diff --git a/docs/cron.md b/docs/en/cron.md
similarity index 98%
rename from docs/cron.md
rename to docs/en/cron.md
index 7952caf..626da98 100644
--- a/docs/cron.md
+++ b/docs/en/cron.md
@@ -1,5 +1,7 @@
# Cron (SPIP Job Queue)
+[Français](../fr/cron.md) · **English**
+
## Problem
SPIP's job queue (`genie`) normally runs inline on every web request â it checks for pending jobs and executes them. On Lambda, this adds latency to every request and is unreliable (Lambda instances are ephemeral).
diff --git a/docs/db-bootstrap.md b/docs/en/db-bootstrap.md
similarity index 96%
rename from docs/db-bootstrap.md
rename to docs/en/db-bootstrap.md
index 6af969f..3ede0a0 100644
--- a/docs/db-bootstrap.md
+++ b/docs/en/db-bootstrap.md
@@ -1,5 +1,7 @@
# Bootstrap the DSQL database for a new environment (SPIP)
+[Français](../fr/db-bootstrap.md) · **English**
+
How to **initialise the SPIP schema** (the `spip_*` tables) on an empty Aurora DSQL
cluster â once per environment (test / prep / prod).
@@ -22,7 +24,7 @@ across accounts, no web wizard, no need to undo the baked `connect.php`.
3. `actualise_plugins_actifs()` + `plugin_installes_meta()` â runs each active plugin's
`*_upgrade()` (your plugin migrations, plus any bundled ones)
4. creates the admin author (`0minirezo`, webmestre) with a **peppered** hash via
- `Spip\Chiffrer\Password::hacher` (see `docs/spip-passwords.md`)
+ `Spip\Chiffrer\Password::hacher` (see `docs/en/spip-passwords.md`)
## Run it (per environment)
@@ -88,8 +90,8 @@ status done
- `spip/scripts/bootstrap-db.php` â the CLI script
- `spip/overlay/config/connect.php` â dynamic DSQL connection (IAM token)
- `spip/overlay/php/prepend.php` â writes `cles.php` from `SPIP_CLES`, sets `_DIR_*`
-- `docs/spip-passwords.md` â peppered admin author hash
-- `docs/dsql.md` â psql connection to the cluster via IAM token
+- `docs/en/spip-passwords.md` â peppered admin author hash
+- `docs/en/dsql.md` â psql connection to the cluster via IAM token
## Technical notes (pitfalls solved)
diff --git a/docs/dsql.md b/docs/en/dsql.md
similarity index 99%
rename from docs/dsql.md
rename to docs/en/dsql.md
index a9821f7..d33e1aa 100644
--- a/docs/dsql.md
+++ b/docs/en/dsql.md
@@ -1,5 +1,7 @@
# Aurora DSQL Driver
+[Français](../fr/dsql.md) · **English**
+
## Overview
SPIP uses a custom PostgreSQL driver (`dsql.php`) to connect to Aurora DSQL. It's a fork of SPIP's experimental PostgreSQL driver (`ecrire/req/pg.exp.php`) with DSQL-specific adaptations.
diff --git a/docs/environments.md b/docs/en/environments.md
similarity index 89%
rename from docs/environments.md
rename to docs/en/environments.md
index ea9c531..b068ad6 100644
--- a/docs/environments.md
+++ b/docs/en/environments.md
@@ -1,5 +1,7 @@
# Environments
+[Français](../fr/environments.md) · **English**
+
An environment = one AWS account (or one account + region) running its own copy of the
two Terraform stacks and the SPIP image. The default region is **eu-west-3** (Paris).
@@ -44,7 +46,7 @@ Fill in:
> If you change `domain_name` on an already-bootstrapped environment, the runtime host
> (prepend.php / `SPIP_PUBLIC_URL`) follows automatically, but the stored `adresse_site`
> meta does not â re-run the bootstrap (or `UPDATE spip_meta SET valeur='https://'
- > WHERE nom='adresse_site'`). See `docs/db-bootstrap.md`.
+ > WHERE nom='adresse_site'`). See `docs/en/db-bootstrap.md`.
## First bring-up (per environment)
@@ -52,12 +54,12 @@ Fill in:
# 1. static stack (DSQL, S3, ECR, DynamoDB, SSM)
make deploy-static ENV=prod AWS_PROFILE=
-# 2. fill the SPIP key material placeholder in SSM (see docs/db-bootstrap.md)
+# 2. fill the SPIP key material placeholder in SSM (see docs/en/db-bootstrap.md)
# 3. build + push image, sync assets, apply app stack
make deploy ENV=prod AWS_PROFILE=
-# 4. initialise the SPIP schema + admin author (see docs/db-bootstrap.md)
+# 4. initialise the SPIP schema + admin author (see docs/en/db-bootstrap.md)
```
## CI/CD
@@ -67,4 +69,4 @@ environment under **GitHub â Settings â Environments** with these variables:
- `AWS_ACCOUNT_ID`, `AWS_REGION`, `CI_ROLE_NAME` (OIDC role to assume)
and commit the matching `var//` files. The workflow assumes an IAM role via GitHub
-OIDC â no long-lived keys.
+OIDC â no long-lived keys.
\ No newline at end of file
diff --git a/docs/logging.md b/docs/en/logging.md
similarity index 98%
rename from docs/logging.md
rename to docs/en/logging.md
index 69f42af..5ecb394 100644
--- a/docs/logging.md
+++ b/docs/en/logging.md
@@ -1,5 +1,7 @@
# Logging
+[Français](../fr/logging.md) · **English**
+
## Architecture
SPIP logs â `logs_stderr` plugin â PHP `error_log()` â stderr â CloudWatch Logs
diff --git a/docs/plugins.md b/docs/en/plugins.md
similarity index 97%
rename from docs/plugins.md
rename to docs/en/plugins.md
index 61ac7ae..5f043b8 100644
--- a/docs/plugins.md
+++ b/docs/en/plugins.md
@@ -1,5 +1,7 @@
# Managing SPIP plugins on Lambda
+[Français](../fr/plugins.md) · **English**
+
SPIP runs as an **immutable Docker image** here. Plugins are baked into the image at
build time â there is no runtime plugin installation. Adding a plugin therefore means:
drop it in the repo, add a COPY in the Dockerfile if needed, rebuild, redeploy.
@@ -17,7 +19,7 @@ spip/
```
SPIP core plugins (`plugins-dist/`) are **not** in this repo â they come from the SPIP
-core fetched at build time (see `docs/spip-upgrade.md`). A few of them are removed in the
+core fetched at build time (see `docs/en/spip-upgrade.md`). A few of them are removed in the
Dockerfile (`bigup`, `forum`, `statistiques`, âŠ) because they don't fit a serverless/
read-mostly deployment.
@@ -92,7 +94,7 @@ fetched SPIP core).
3. Data-model migrations go in `_administrations.php` (SPIP's native schema
versioning â `spip__metas`/`maj_tables`). They run on the first authenticated
- admin visit, or via `spip/scripts/bootstrap-db.php` (see `docs/db-bootstrap.md`).
+ admin visit, or via `spip/scripts/bootstrap-db.php` (see `docs/en/db-bootstrap.md`).
4. Rebuild + deploy.
diff --git a/docs/s3-storage.md b/docs/en/s3-storage.md
similarity index 98%
rename from docs/s3-storage.md
rename to docs/en/s3-storage.md
index bcf75b0..a436098 100644
--- a/docs/s3-storage.md
+++ b/docs/en/s3-storage.md
@@ -1,5 +1,7 @@
# S3 File Storage
+[Français](../fr/s3-storage.md) · **English**
+
## Problem
SPIP stores uploaded files (images, documents) in `IMG/` on the local filesystem. On Lambda, the filesystem is read-only (except `/tmp` which is ephemeral). Files must be stored externally.
diff --git a/docs/secrets.md b/docs/en/secrets.md
similarity index 98%
rename from docs/secrets.md
rename to docs/en/secrets.md
index bfc623f..17ac47a 100644
--- a/docs/secrets.md
+++ b/docs/en/secrets.md
@@ -1,5 +1,7 @@
# Secrets Management (SSM Parameter Store)
+[Français](../fr/secrets.md) · **English**
+
## Architecture
```
diff --git a/docs/sessions.md b/docs/en/sessions.md
similarity index 98%
rename from docs/sessions.md
rename to docs/en/sessions.md
index 6183a80..dc84b46 100644
--- a/docs/sessions.md
+++ b/docs/en/sessions.md
@@ -1,5 +1,7 @@
# Sessions (DynamoDB)
+[Français](../fr/sessions.md) · **English**
+
## Problem
SPIP stores sessions as PHP files in `tmp/sessions/`. On Lambda, `/tmp` is ephemeral and per-instance â sessions are lost on cold starts and not shared across instances.
diff --git a/docs/en/spip-passwords.md b/docs/en/spip-passwords.md
new file mode 100644
index 0000000..cdf428c
--- /dev/null
+++ b/docs/en/spip-passwords.md
@@ -0,0 +1,106 @@
+# Resetting a SPIP password (SPIP-serverless / DSQL / Lambda)
+
+[Français](../fr/spip-passwords.md) · **English**
+
+Procedure to reset a SPIP author's password directly in the database,
+when access to the private area is lost.
+
+## Technical context
+
+- SPIP runs in **Lambda** (Docker image), database **Aurora DSQL** (PostgreSQL).
+- Table prefix: `spip` (SPIP default, configurable via SPIP_TABLE_PREFIX). The authors table is therefore `spip_auteurs`.
+- Modern SPIP 4.x does **not** store a plain bcrypt of the password: the hash is
+ **peppered** with the site's `secret_des_auth` before bcrypt.
+
+## Hashing formula (SPIP 4.x â `Spip\Chiffrer\Password::hacher`)
+
+```php
+$pass_poivre = hash_hmac('sha256', $password_clair, $key); // $key = secret_des_auth (BINAIRE)
+$hash = password_hash($pass_poivre, PASSWORD_DEFAULT); // bcrypt ($2y$âŠ)
+```
+
+Verification at login (`Password::verifier`) does:
+
+```php
+$pass_poivre = hash_hmac('sha256', $password_clair, $key);
+password_verify($pass_poivre, $hash_stocke);
+```
+
+**Pitfall #1** â `Password::verifier` fails if `$secret` is empty. The secret comes from
+the `cles.php` file (populated in the Lambda from the `SPIP_CLES` env var).
+
+**Pitfall #2 (the most important)** â `SpipCles` **base64-decodes** the keys read from
+`cles.php` (`array_map('base64_decode', $json)` in `SpipCles::read()`, and
+`base64_decode()` in `getKey`/`getMetaKey`). The HMAC key is therefore the **decoded
+binary** secret, NOT the base64 string as it appears in SSM/the JSON.
+
+## Procedure
+
+### 1. Retrieve the `secret_des_auth`
+
+The Lambda's `SPIP_CLES` env var points to an SSM parameter:
+
+```bash
+AWS_PROFILE= aws lambda get-function-configuration \
+ --function-name spip-serverless-test-web --region us-east-1 \
+ --query "Environment.Variables.SPIP_CLES" --output text
+# -> bref-ssm:/spip-serverless/test/spip/cles
+
+AWS_PROFILE= aws ssm get-parameter \
+ --name "/spip-serverless/test/spip/cles" --with-decryption --region us-east-1 \
+ --query "Parameter.Value" --output text
+# -> {"secret_du_site": "...", "secret_des_auth": ""}
+```
+
+### 2. Generate the hash (base64-DECODED secret)
+
+```bash
+SECRET_B64='' # secret_des_auth du SSM
+NEWPASS='ChangeMe2026!'
+
+HASH=$(php -r '
+$secret = base64_decode($argv[1]); // IMPORTANT : décoder le base64
+$poivre = hash_hmac("sha256", $argv[2], $secret);
+echo password_hash($poivre, PASSWORD_DEFAULT);
+' "$SECRET_B64" "$NEWPASS")
+
+# Vérifier localement AVANT d'écrire en base
+php -r '
+$secret = base64_decode($argv[1]);
+$poivre = hash_hmac("sha256", $argv[3], $secret);
+echo password_verify($poivre, $argv[2]) ? "VERIFY OK\n" : "VERIFY FAIL\n";
+' "$SECRET_B64" "$HASH" "$NEWPASS"
+```
+
+### 3. Write to the database (DSQL)
+
+```bash
+TOKEN=$(AWS_PROFILE= aws dsql generate-db-connect-admin-auth-token \
+ --hostname .dsql.us-east-1.on.aws --region us-east-1)
+
+PGPASSWORD="$TOKEN" psql \
+ "host=.dsql.us-east-1.on.aws port=5432 dbname=postgres user=admin sslmode=require" \
+ -c "UPDATE spip_auteurs SET pass = '$HASH' WHERE id_auteur = 1 RETURNING id_auteur, login, email"
+```
+
+The current admin author: `id_auteur=1`, login `admin`, email `admin@example.com`,
+status `0minirezo`, `webmestre=oui`.
+
+## Notes / troubleshooting
+
+- Login can be done with the **login** (`admin`) or the **email**.
+- Do NOT touch `alea_actuel` (used to verify old sha256/md5 hashes, with no
+ impact on modern hashes).
+- The repo's `src/âŠ` file must match the SPIP version baked into the Lambda
+ image; check it in the image if needed:
+ `docker run --rm --entrypoint sh spip-serverless:latest -c "grep -n hash_hmac /var/task/ecrire/src/Chiffrer/Password.php"`
+- **Changing the password from the private area**: may fail in a Lambda
+ environment (writing the `cles.php` file / backing up the keys on the read-only
+ `/var/task` filesystem, only `/tmp` is writable). The in-database reset above remains
+ the reliable method.
+
+## Relevant SPIP code paths
+
+- `ecrire/auth/spip.php` â `auth_spip_verifier_pass()` (dispatch based on `strlen($pass)`)
+- `ecrire/src/Chiffrer/Password.php` â `hacher()` / `verifier()`
+- `ecrire/src/Chiffrer/SpipCles.php` â `getSecretAuth()`, `getKey()`, `read()` (base64_decode)
\ No newline at end of file
diff --git a/docs/spip-upgrade.md b/docs/en/spip-upgrade.md
similarity index 91%
rename from docs/spip-upgrade.md
rename to docs/en/spip-upgrade.md
index 1f8e62a..e776a04 100644
--- a/docs/spip-upgrade.md
+++ b/docs/en/spip-upgrade.md
@@ -1,5 +1,7 @@
# Upgrading SPIP core
+[Français](../fr/spip-upgrade.md) · **English**
+
SPIP core is **not vendored** in this repo. It is fetched at build time from the official
archive (`files.spip.net`) at a single pinned version. Upgrading SPIP is therefore just:
**bump the pinned version, rebuild, test on a non-prod environment, promote.**
@@ -15,7 +17,7 @@ The image is **immutable and stateless**. Two clean halves:
So: **all code/version changes are repo â build â deploy; all data/migration changes are
runtime**, triggered by the first admin request (same cold-start mechanism as the initial
-plugin migrations â see `docs/db-bootstrap.md`). You never run a DB migration by hand for
+plugin migrations â see `docs/en/db-bootstrap.md`). You never run a DB migration by hand for
an upgrade; you deploy the image and open the admin once.
## Where the version is pinned
@@ -45,7 +47,7 @@ It is consumed by:
make deploy ENV=test
```
Check the admin (`/ecrire`), a public page, and login.
-5. Commit and let CI promote (see `docs/environments.md`).
+5. Commit and let CI promote (see `docs/en/environments.md`).
Our customisations live **outside** SPIP core, so a core upgrade never touches them:
- `spip/overlay/**` â files that override core at build time (connect.php, mes_options*,
@@ -68,5 +70,5 @@ Riskier â core can add/remove files and change APIs:
- The core upgrade does **not** replay SPIP's own DB migrations: if core bumps its
`spip_version_base`, the SPIP upgrade runs on the first authenticated admin visit (same
- cold-start mechanism as plugin migrations â see `docs/db-bootstrap.md`).
-- Never commit a downloaded zip or `spip/src/` â both are git-ignored.
+ cold-start mechanism as plugin migrations â see `docs/en/db-bootstrap.md`).
+- Never commit a downloaded zip or `spip/src/` â both are git-ignored.
\ No newline at end of file
diff --git a/docs/tracing.md b/docs/en/tracing.md
similarity index 99%
rename from docs/tracing.md
rename to docs/en/tracing.md
index 6273a55..1135999 100644
--- a/docs/tracing.md
+++ b/docs/en/tracing.md
@@ -1,5 +1,7 @@
# OpenTelemetry Tracing (X-Ray)
+[Français](../fr/tracing.md) · **English**
+
## Architecture
```
diff --git a/docs/fr/build.md b/docs/fr/build.md
new file mode 100644
index 0000000..7422fb6
--- /dev/null
+++ b/docs/fr/build.md
@@ -0,0 +1,77 @@
+# Build & rouages de l'image
+
+**Français** · [English](../en/build.md)
+
+Le runtime SPIP est une **image Docker unique, immuable et multi-étages** (ARM64, PHP 8.5
+via Bref). `make build` la construit ; `make deploy` la build + push sur ECR et pointe le
+Lambda sur le nouveau digest. Rien n'est installĂ© au runtime â tout est embarquĂ© ici.
+
+## Ătapes du Dockerfile (`spip/Dockerfile`)
+
+| Ătape | RĂŽle |
+|---|---|
+| `spip-core` | TĂ©lĂ©charge le zip de release SPIP officiel pour `SPIP_VERSION` dans `/spip-src` (le cĆur n'est **pas** vendorisĂ© â voir [spip-upgrade.md](spip-upgrade.md)). |
+| `ext-pgsql` | Compile l'extension PHP `pgsql` â Aurora DSQL parle le protocole PostgreSQL. |
+| `ext-gd` | Compile l'extension PHP `gd` (redimensionnement d'images/vignettes). |
+| `vendors` | Lance Composer **par-dessus** le cĆur SPIP rĂ©cupĂ©rĂ©, puis rĂ©duit `vendor/`. |
+| `local` | Image Apache pour `make run-local` (dev local uniquement). |
+| `lambda` | Image finale : assemble cĆur + vendor + extensions + overlays + plugins, patche `documents.php` pour S3, prĂ©-chauffe l'opcache, ajoute le collecteur ADOT. |
+
+## Dépendances Composer (étape `vendors`)
+
+Le cĆur SPIP embarque dĂ©jĂ son propre `vendor/` dans le zip de release. On y ajoute les
+briques qui font tourner SPIP sur Lambda :
+
+```
+composer require \
+ bref/bref:^3.0 # runtime PHP pour Lambda (FPM)
+ bref/secrets-loader:^1 # résout les valeurs bref-ssm:/... depuis SSM au boot
+ aws/aws-sdk-php:^3.0 # token IAM DSQL, S3, sessions DynamoDB, SSM, CloudFrontâŠ
+ open-telemetry/sdk:^1 # tracing
+ open-telemetry/exporter-otlp:^1
+```
+
+Puis `spip/scripts/shrink-vendor.sh vendor` s'exécute (voir plus bas).
+
+## `shrink-vendor.sh` â pourquoi et quoi
+
+Le paquet complet `aws/aws-sdk-php` embarque les données + classes client de **tous** les
+services AWS (~plusieurs centaines de Mo). Une image Lambda n'en a besoin que de quelques-uns,
+et une image plus petite = cold start plus rapide et stockage moins cher. Le script :
+
+- **Ne garde que les services SDK AWS utilisés par SPIP** (une liste blanche `KEEP_SERVICES`)
+ et supprime le reste de `aws-sdk-php/src/data/*` et `src//`.
+- **Retire docs/tests/exemples** de tout l'arbre `vendor/` (`*.md`, `CHANGELOG*`,
+ `LICENSE*`, `README*`, `tests/`, `docs/`, `examples/`).
+- Affiche la taille Ă©conomisĂ©e (`AWS SDK: MB â MB`).
+
+> Note : la liste blanche `KEEP_SERVICES` actuelle contient encore quelques services hérités
+> de l'application d'origine (`bedrock-*`, `translate`) que la plateforme nue n'utilise pas.
+> Réduis-la à `dsql, dynamodb, s3, ssm, sts, cloudfront, ses` pour une image plus légÚre, et
+> rajoute ce dont tes propres plugins ont besoin.
+
+Pour ajouter un service SDK dont ton plugin a besoin : ajoute-le Ă `KEEP_SERVICES` (et Ă la
+liste `case` de `src/`) dans `spip/scripts/shrink-vendor.sh`, puis rebuild.
+
+## Pré-chauffage de l'opcache
+
+`compile-opcache.php` (lancé avec `opcache-build.ini`) compile les fichiers PHP dans un
+**cache fichier** opcache en lecture seule, embarqué dans l'image (`/bref/opcache`) et lu au
+runtime. Ăa retire la compilation du premier appel du chemin de cold start.
+
+## Briques d'observabilité
+
+- **Collecteur ADOT** : `make download-adot` récupÚre l'extension Lambda du collecteur OTEL
+ AWS dans `spip/overlay/adot-collector` (git-ignoré) ; l'image l'embarque comme extension
+ `/opt`. Les traces vont OTEL â collecteur â X-Ray. Voir [tracing.md](tracing.md).
+- **Logs** : `logs_stderr` envoie `spip_log()` sur stderr â CloudWatch. Voir [logging.md](logging.md).
+
+## OĂč changer quoi
+
+| Tu veux⊠| Ăditer |
+|---|---|
+| Monter SPIP de version | `spip/SPIP_VERSION` (voir [spip-upgrade.md](spip-upgrade.md)) |
+| Ajouter une dépendance PHP/Composer | la ligne `composer require` dans `spip/Dockerfile` |
+| Garder un autre service SDK AWS | `KEEP_SERVICES` dans `spip/scripts/shrink-vendor.sh` |
+| Ajouter une extension PHP | une nouvelle étape `ext-*` dans `spip/Dockerfile` |
+| Ajouter un plugin | voir [plugins.md](plugins.md) |
diff --git a/docs/fr/cron.md b/docs/fr/cron.md
new file mode 100644
index 0000000..72dd7bd
--- /dev/null
+++ b/docs/fr/cron.md
@@ -0,0 +1,80 @@
+# Cron (file de jobs SPIP)
+
+**Français** · [English](../en/cron.md)
+
+## ProblĂšme
+
+La file de jobs de SPIP (`genie`) s'exĂ©cute normalement en ligne Ă chaque requĂȘte web â elle vĂ©rifie la prĂ©sence de jobs en attente et les exĂ©cute. Sur Lambda, cela ajoute de la latence Ă chaque requĂȘte et n'est pas fiable (les instances Lambda sont Ă©phĂ©mĂšres).
+
+## Solution
+
+1. **Bloquer la file sur les requĂȘtes web** â `_DEBUG_BLOCK_QUEUE = true` dans `prepend.php`
+2. **DĂ©clencher via EventBridge** â une rĂšgle planifiĂ©e invoque Lambda toutes les 5 minutes avec `?action=cron`
+
+## Fonctionnement
+
+### RequĂȘtes web (bloquĂ©es)
+`prepend.php` s'exécute avant SPIP et définit :
+```php
+if (empty($_GET['action']) || $_GET['action'] !== 'cron') {
+ define('_DEBUG_BLOCK_QUEUE', true);
+}
+```
+Cela empĂȘche SPIP d'exĂ©cuter le moindre job en attente. Un SELECT lĂ©ger sur `spip_jobs` s'exĂ©cute quand mĂȘme (~3 ms) â c'est SPIP qui vĂ©rifie la prĂ©sence de tĂąches en attente sans les exĂ©cuter.
+
+### RequĂȘtes cron (EventBridge)
+EventBridge invoque Lambda toutes les 5 minutes avec :
+```json
+{
+ "version": "2.0",
+ "rawPath": "/spip.php",
+ "rawQueryString": "action=cron",
+ "queryStringParameters": {"action": "cron"},
+ ...
+}
+```
+Comme `$_GET['action'] === 'cron'`, `_DEBUG_BLOCK_QUEUE` n'est PAS défini, et SPIP traite tous les jobs en attente.
+
+## Ressources Terraform (`iac/spip/app/cron.tf`)
+
+- `aws_cloudwatch_event_rule.spip_cron` â planification : `rate(5 minutes)`
+- `aws_cloudwatch_event_target.spip_cron` â invoque Lambda avec l'Ă©vĂ©nement cron
+- `aws_lambda_permission.eventbridge_cron` â autorise EventBridge Ă invoquer Lambda
+
+## Types de jobs SPIP
+
+Jobs courants qui s'exécutent via le cron :
+- `queue_watch` â surveille la file de jobs elle-mĂȘme
+- `optimiser` â optimisation de la base de donnĂ©es
+- `maintenance` â tĂąches de maintenance gĂ©nĂ©rales
+- `mise_a_jour` â vĂ©rifications de mises Ă jour
+- `revisions_optimiser_revisions` â nettoyage de l'historique des rĂ©visions
+- `medias_nettoyer_repertoire_upload` â nettoyage du rĂ©pertoire d'upload
+- `svp_actualiser_depots` â rafraĂźchissement des infos du dĂ©pĂŽt de plugins
+
+## Modifier la fréquence
+
+Modifiez `iac/spip/app/cron.tf` :
+```hcl
+schedule_expression = "rate(5 minutes)" # Change to "rate(15 minutes)" etc.
+```
+
+## Déclenchement manuel
+
+```bash
+curl https://cms.example.com/spip.php?action=cron
+```
+
+## Supervision
+
+Les invocations du cron apparaissent dans X-Ray sous forme de traces avec :
+- Durée > 3 s (traitement de nombreux jobs)
+- 30 Ă 50 requĂȘtes BD
+- Aucun segment API Gateway (invocation directe de Lambda depuis EventBridge)
+
+## Impact sur les requĂȘtes web
+
+Avant : ~25 requĂȘtes BD par page d'admin, exĂ©cution des jobs comprise
+AprĂšs : ~11 requĂȘtes BD par page d'admin (jobs bloquĂ©s)
+
+Le SELECT restant sur `spip_jobs` (~3 ms) ne peut pas ĂȘtre Ă©liminĂ© sans patcher le cĆur de SPIP (`ecrire/inc/queue.php`).
diff --git a/docs/fr/db-bootstrap.md b/docs/fr/db-bootstrap.md
new file mode 100644
index 0000000..14d339b
--- /dev/null
+++ b/docs/fr/db-bootstrap.md
@@ -0,0 +1,135 @@
+# Initialiser la base DSQL pour un nouvel environnement (SPIP)
+
+**Français** · [English](../en/db-bootstrap.md)
+
+Comment **initialiser le schéma SPIP** (les tables `spip_*`) sur un cluster Aurora DSQL
+vide â une fois par environnement (test / prep / prod).
+
+Méthode : exécuter **`spip/scripts/bootstrap-db.php` dans le conteneur**. Reproductible
+d'un compte à l'autre, sans assistant web, sans avoir à annuler le `connect.php` intégré.
+
+## Pourquoi un script CLI (et non l'assistant / un exec / une route HTTP)
+
+- `config/connect.php` est intĂ©grĂ© Ă l'image â SPIP se croit toujours installĂ© et ne
+ lance jamais l'assistant.
+- Le script CLI charge le noyau SPIP (`inc_version.php`) comme le contrĂŽleur frontal,
+ puis appelle `creer_base()` + les mises à niveau des plugins + la création de l'admin.
+ Pas de HTTP, pas d'authentification, idempotent. Nul besoin de retirer `connect.php` :
+ il ne fait que fournir la connexion au cluster, et `creer_base()` y crée le schéma quoi
+ qu'il en soit.
+
+## Ce que fait le script (idempotent)
+
+1. `creer_base()` â tables du cĆur SPIP (relance `alterer_base`, tolĂšre « already exists »)
+2. metas `version_installee` / `nouvelle_install` (sur une base neuve)
+3. `actualise_plugins_actifs()` + `plugin_installes_meta()` â exĂ©cute le `*_upgrade()` de
+ chaque plugin actif (vos migrations de plugin, plus celles éventuellement fournies)
+4. crée l'auteur admin (`0minirezo`, webmestre) avec un hash **poivré** via
+ `Spip\Chiffrer\Password::hacher` (voir `docs/fr/spip-passwords.md`)
+
+## L'exécuter (par environnement)
+
+Depuis l'**image Lambda** (recommandé : `prepend.php` écrit `cles.php` à partir de
+`SPIP_CLES` et positionne les `_DIR_*`, de sorte que le secret d'authentification est
+disponible pour hacher le mot de passe admin) :
+
+```bash
+ENV=test ; PROFILE= ; REGION=eu-west-3
+CLUSTER=$(cd iac/spip/static && AWS_PROFILE=$PROFILE terraform output -raw dsql_endpoint)
+
+# key material (secret_du_site + secret_des_auth) from SSM
+SPIP_CLES=$(AWS_PROFILE=$PROFILE aws ssm get-parameter --name /spip-serverless/$ENV/spip/cles \
+ --with-decryption --region $REGION --query Parameter.Value --output text)
+
+# temporary credentials for the target account
+eval "$(AWS_PROFILE=$PROFILE aws configure export-credentials --format env)"
+
+docker run --rm \
+ -e SPIP_DSQL_CLUSTER=$CLUSTER \
+ -e SPIP_TABLE_PREFIX=spip \
+ -e SPIP_PUBLIC_URL=https:// \
+ -e SPIP_CLES="$SPIP_CLES" \
+ -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_SESSION_TOKEN \
+ -e AWS_REGION=$REGION \
+ --entrypoint php \
+ .dkr.ecr.$REGION.amazonaws.com/spip-serverless: \
+ -d auto_prepend_file= /var/task/scripts/bootstrap-db.php \
+ --admin-login=admin --admin-email=you@example.org --admin-pass=''
+```
+
+**Important â `-d auto_prepend_file=`** : `prepend.php` (auto_prepend sur Lambda) casse
+`inc_version.php` dans un contexte CLI ; le dĂ©sactiver. Le script réécrit lui-mĂȘme
+`cles.php` Ă partir de `$SPIP_CLES` pour que le hash du mot de passe admin fonctionne.
+
+Prérequis SSM : `/spip-serverless//spip/cles` doit contenir un vrai JSON
+`{"secret_du_site":"","secret_des_auth":""}` (la stack statique le crée
+avec un placeholder `CHANGE_ME_AFTER_CREATION` â Ă renseigner une fois par compte) :
+
+```bash
+SITE=$(openssl rand -base64 32); AUTH=$(openssl rand -base64 32)
+AWS_PROFILE=$PROFILE aws ssm put-parameter --name /spip-serverless/$ENV/spip/cles \
+ --type SecureString --overwrite --region $REGION \
+ --value "$(printf '{"secret_du_site": "%s", "secret_des_auth": "%s"}' "$SITE" "$AUTH")"
+```
+
+Sortie attendue (rapport ligne par ligne) :
+```
+db_connection ok
+creer_base ok
+metas created
+plugins_upgrade ok
+admin created (id_auteur=1)
+status done
+```
+
+## Ordre
+
+1. **test** â exĂ©cuter le script, puis vĂ©rifier `https:///spip.php?page=backend` â 200.
+2. **prep**, puis **prod** â mĂȘme script, changer cluster + credentials + SPIP_CLES + admin.
+
+## Fichiers concernés
+
+- `spip/scripts/bootstrap-db.php` â le script CLI
+- `spip/overlay/config/connect.php` â connexion DSQL dynamique (jeton IAM)
+- `spip/overlay/php/prepend.php` â Ă©crit `cles.php` Ă partir de `SPIP_CLES`, positionne les `_DIR_*`
+- `docs/fr/spip-passwords.md` â hash poivrĂ© de l'auteur admin
+- `docs/fr/dsql.md` â connexion psql au cluster via jeton IAM
+
+## Notes techniques (piÚges résolus)
+
+Exécuter `creer_base()` depuis la CLI sur une base **vide** a nécessité de contourner
+plusieurs comportements de SPIP :
+
+1. **prepend.php casse `inc_version.php` en CLI** â exĂ©cuter avec `php -d auto_prepend_file=`.
+2. **`inc_version.php` sort prĂ©maturĂ©ment sans le noyau SpipLeague** â charger d'abord
+ `vendor/autoload.php` (comme `spip.php`), puis `param('spip.dirs.core')`.
+3. **`spip_connect()` échoue sur une base vide** : `spip_connect_main()` lit le charset
+ depuis `spip_meta` (absent) â renvoie false â chaque `sql_*` rend la page 503
+ `MinipageAdmin` dans une **boucle infinie**. Contourner (comme `install/etape_3.php`)
+ en pré-remplissant la connexion par défaut à la main. L'index de la connexion par
+ défaut est l'entier **`0`** (`$index = $serveur ?: 0`), donc peupler
+ `$GLOBALS['connexions'][0]` (PAS `['']`) avec la description + le jeu de fonctions
+ `$GLOBALS['spip_dsql_functions_1']` + prefix/db/version.
+4. **`_ECRIRE_INSTALL`** défini pour le mode installation.
+5. **`cles.php` absent** (auto_prepend dĂ©sactivĂ©) â le script le réécrit Ă partir de
+ `SPIP_CLES` dans `_DIR_ETC` pour que `SpipCles::getSecretAuth()` renvoie le secret
+ d'authentification.
+
+## PiĂšge SVP / plugin_installes
+
+La page privée « Plugins » (`?exec=admin_plugin`) peut se bloquer sur un environnement
+fraßchement initialisé à cause de **SVP** : `svp_actualiser_paquets_locaux()` fait
+`in_array($x, lire_config('plugin_installes'))`, et quand `plugin_installes` est absent
+(jamais initialisĂ© par le bootstrap CLI) â `in_array(x, null)` â fatal â page bloquĂ©e.
+
+Le script s'en prémunit :
+- `actualise_plugins_actifs()` (rafraĂźchit la meta `plugin`)
+- si `plugin_installes` est absent, l'initialiser Ă `[]`
+- puis `plugin_installes_meta()` â c'est ce que fait la premiĂšre visite admin : il exĂ©cute
+ l'installation/mise Ă niveau de chaque plugin actif ET remplit `plugin_installes`. C'est
+ le dĂ©clencheur fiable pour TOUTES les migrations, contrairement Ă
+ `actualise_plugins_actifs()` seul.
+
+Réparation manuelle (si un env est déjà bloqué) : forcer en base
+`UPDATE spip_meta SET valeur='0.6.2' WHERE nom='svp_base_version';` et insérer un tableau
+`plugin_installes` sérialisé (le copier depuis un env sain).
diff --git a/docs/fr/dsql.md b/docs/fr/dsql.md
new file mode 100644
index 0000000..ed06294
--- /dev/null
+++ b/docs/fr/dsql.md
@@ -0,0 +1,152 @@
+# Pilote Aurora DSQL
+
+**Français** · [English](../en/dsql.md)
+
+## Vue d'ensemble
+
+SPIP utilise un pilote PostgreSQL sur mesure (`dsql.php`) pour se connecter à Aurora DSQL. C'est un fork du pilote PostgreSQL expérimental de SPIP (`ecrire/req/pg.exp.php`) avec des adaptations spécifiques à DSQL.
+
+**Emplacement :** `spip/overlay/ecrire/req/dsql.php`
+
+## Connexion
+
+Configurée dans `spip/overlay/config/connect.php` :
+- GénÚre des jetons d'authentification IAM via `Aws\DSQL\AuthTokenGenerator`
+- Se connecte avec `sslmode=require`
+- Base de données : `postgres` (valeur par défaut de DSQL)
+- Préfixe de table : `spip` (configurable via la variable d'environnement `SPIP_TABLE_PREFIX`)
+
+**Variables d'environnement :**
+```
+SPIP_DSQL_CLUSTER=.dsql.eu-west-3.on.aws
+SPIP_TABLE_PREFIX=spip
+```
+
+## Différences clés par rapport à PostgreSQL standard
+
+### Colonnes IDENTITY (pas de séquences)
+DSQL utilise `GENERATED BY DEFAULT AS IDENTITY` au lieu de `bigserial`/séquences. Le `spip_dsql_insert()` du pilote utilise directement `RETURNING ` au lieu de `RETURNING currval('seq')`.
+
+### CREATE INDEX ASYNC
+DSQL requiert `CREATE INDEX ASYNC` au lieu de `CREATE INDEX`. Le pilote effectue la traduction automatiquement.
+
+### Pas de prise en charge de ALTER TABLE
+DSQL a une prise en charge trÚs limitée de `ALTER TABLE`. Impossible de faire `DROP NOT NULL`, `SET DEFAULT`, etc. Les modifications de schéma peuvent nécessiter la recréation de la table.
+
+### Comparaisons de chaĂźnes sensibles Ă la casse
+DSQL/PostgreSQL est sensible Ă la casse. Le plugin SVP de SPIP stocke les prĂ©fixes en MAJUSCULES (`OTEL`, `S3UPLOAD`). Les requĂȘtes doivent respecter la casse.
+
+### Contraintes NOT NULL
+Contrairement à MySQL, DSQL applique strictement NOT NULL. Le `spip_dsql_cite()` du pilote renvoie `'0'` au lieu de `NULL` pour les colonnes entiÚres afin d'éviter les violations de contrainte.
+
+## Correctifs appliquĂ©s (traduction MySQL â PostgreSQL/DSQL)
+
+### `spip_dsql_frommysql()` â couche de traduction SQL
+
+| Syntaxe MySQL | Traduction DSQL |
+|---|---|
+| `rand()` | `random()` |
+| `TIMESTAMPDIFF(HOUR, a, b)` | `EXTRACT(EPOCH FROM (b - a)) / 3600` |
+| `UNIX_TIMESTAMP()` | `EXTRACT(epoch FROM NOW())` |
+| `DAYOFMONTH(x)` | `EXTRACT(day FROM x)` |
+| `md5(integer)` | `MD5(CAST(integer AS text))` |
+| `IN ('')` sur des colonnes entiĂšres | `IN (NULL)` |
+| `0+column` | `CAST(substring(column, '^ *[0-9]+') as int)` |
+
+### `spip_dsql_countsel()` â correctif PHP 8.4
+`is_resource($r)` â `is_resource($r) \|\| is_object($r)` car le `pg_query` de PHP 8.4 renvoie un objet `PgSql\Result`, pas une ressource.
+
+### `spip_dsql_cite()` â gestion des NULL
+```php
+if (is_null($v)) {
+ return sql_test_int($t) ? '0' : 'NULL';
+}
+```
+
+### ChaĂźne vide sur des colonnes entiĂšres
+```php
+} elseif ($v === '') {
+ return '0';
+}
+```
+
+### SELECT DISTINCT + ORDER BY
+DSQL exige que les expressions ORDER BY figurent dans la liste SELECT lorsqu'on utilise DISTINCT :
+```php
+if ($orderby && preg_match('/\bDISTINCT\b/i', $select)) {
+ // Add ORDER BY expressions (including CASE) to SELECT list
+}
+```
+
+### INSERT RETURNING
+Utilise directement le nom de colonne au lieu de `currval('sequence')` :
+```php
+$seq = spip_dsql_sequence($table, true); // Returns primary key column name
+// RETURNING id_plugin (not RETURNING currval('spip_plugins_id_plugin_seq'))
+```
+
+## Instrumentation OTEL
+
+Les requĂȘtes en base sont tracĂ©es dans `spip_dsql_trace_query()` :
+```php
+$connexion['last'] = $query;
+if (isset($GLOBALS['_otel_tracer'])) {
+ $_span = $GLOBALS['_otel_tracer']->spanBuilder('DSQL')
+ ->setAttribute('db.system', 'postgresql')
+ ->setAttribute('db.statement', substr($query, 0, 200))
+ ->startSpan();
+}
+$r = spip_dsql_query_simple($link, $query);
+if (isset($_span)) { $_span->end(); unset($_span); }
+```
+
+## Registre des fonctions
+
+Le pilote enregistre ces fonctions auprĂšs de la couche d'abstraction SQL de SPIP :
+
+```php
+$GLOBALS['spip_dsql_functions_1'] = [
+ 'query', 'countsel', 'select', 'selectdb', 'count',
+ 'fetch', 'seek', 'free', 'insert', 'insertq', 'insertq_multi',
+ 'update', 'delete', 'replace', 'multi',
+ 'error', 'errno', 'explain',
+ 'optimize', 'repair', 'showtable', 'listdbs',
+ 'in', 'hex', 'quote', 'date_proche',
+ 'table_exists', 'set_charset', 'get_charset',
+];
+```
+
+## Débogage
+
+### Activer la journalisation des requĂȘtes
+Positionner `LOG_LEVEL=DEBUG` â toutes les requĂȘtes sont journalisĂ©es via `spip_log()` sur le canal `dsql`.
+
+### Erreurs courantes
+
+| Erreur | Cause | Correctif |
+|---|---|---|
+| `null value in column "X" violates not-null constraint` | SPIP passe NULL pour une colonne entiĂšre | CorrigĂ© dans `spip_dsql_cite()` â renvoie '0' |
+| `invalid input syntax for type bigint: ""` | ChaĂźne vide dans une clause IN | CorrigĂ© dans `spip_dsql_frommysql()` â `IN('')` â `IN(NULL)` |
+| `SELECT DISTINCT, ORDER BY expressions must appear in select list` | SQL strict de DSQL | CorrigĂ© â ajoute l'expression ORDER BY au SELECT |
+| `Missing a url function for type propres` | `sql_countsel` renvoie 0 | CorrigĂ© â vĂ©rification `is_resource \|\| is_object` |
+
+## Schéma
+
+Les tables utilisent le préfixe `spip_` (configurable). Créées lors de l'installation initiale de SPIP via l'assistant. Tables principales :
+- `spip_articles`, `spip_rubriques` â contenu
+- `spip_auteurs`, `spip_auteurs_liens` â utilisateurs
+- `spip_documents`, `spip_documents_liens` â fichiers
+- `spip_meta` â magasin de configuration clĂ©-valeur
+- `spip_paquets`, `spip_plugins` â registre des plugins
+- `spip_jobs`, `spip_jobs_liens` â file de jobs
+
+## Se connecter manuellement
+
+```bash
+TOKEN=$(aws dsql generate-db-connect-admin-auth-token \
+ --hostname .dsql.eu-west-3.on.aws \
+ --expires-in 3600 --profile --region us-east-1)
+PGPASSWORD="$TOKEN" psql \
+ -h .dsql.eu-west-3.on.aws \
+ -U admin -d postgres
+```
diff --git a/docs/fr/environments.md b/docs/fr/environments.md
new file mode 100644
index 0000000..ffc4800
--- /dev/null
+++ b/docs/fr/environments.md
@@ -0,0 +1,73 @@
+# Environnements
+
+**Français** · [English](../en/environments.md)
+
+Un environnement = un compte AWS (ou un compte + une région) faisant tourner sa propre copie
+des deux stacks Terraform et de l'image SPIP. La région par défaut est **eu-west-3** (Paris).
+
+## Organisation Terraform
+
+```
+iac/spip/
+âââ static/ # DSQL cluster, S3 assets bucket, ECR repo, DynamoDB sessions, SSM key
+âââ app/ # Lambda, API Gateway, CloudFront (+ optional custom domain)
+```
+
+`app` lit les outputs de `static` via `terraform_remote_state`, donc **appliquez `static`
+en premier**.
+
+## Fichiers par environnement
+
+Chaque stack possĂšde `var//` :
+- `values.tfvars` â rĂ©gion, nom d'env, domaine, emplacement du remote-state, âŠ
+- `backend.tfbackend` â oĂč vit l'Ă©tat PROPRE Ă CE stack (bucket S3 + clĂ©)
+
+Un modÚle se trouve dans `var/example/`. Créez un nouvel environnement en le copiant :
+
+```bash
+for stack in static app; do
+ cp -r iac/spip/$stack/var/example iac/spip/$stack/var/prod
+ $EDITOR iac/spip/$stack/var/prod/values.tfvars
+ $EDITOR iac/spip/$stack/var/prod/backend.tfbackend
+done
+```
+
+Renseignez :
+- `aws_region` â p. ex. `eu-west-3`
+- le bucket S3 d'état dans les deux fichiers `backend.tfbackend` (à créer une fois par compte)
+- `static_state_bucket` / `static_state_region` dans le stack app (pointer vers l'état du
+ stack static)
+- optionnellement `domain_name` + `hosted_zone_name` pour un domaine personnalisé. Définir
+ `domain_name` (via Terraform, pas la console CloudFront) pilote tout depuis une seule
+ variable : l'alias CloudFront, un certificat ACM en us-east-1 (validé par DNS contre la
+ zone Route53 `hosted_zone_name`, qui doit exister **dans le mĂȘme compte**), l'enregistrement
+ A Route53, **et** le `SPIP_PUBLIC_URL` du Lambda (afin que SPIP construise ses liens absolus
+ sur ce domaine). Laissez les deux vides pour utiliser le domaine `*.cloudfront.net` par défaut.
+
+ > Si vous changez `domain_name` sur un environnement déjà amorcé (bootstrapped), l'hÎte
+ > runtime (prepend.php / `SPIP_PUBLIC_URL`) suit automatiquement, mais la méta `adresse_site`
+ > stockĂ©e ne suit pas â relancez le bootstrap (ou `UPDATE spip_meta SET valeur='https://'
+ > WHERE nom='adresse_site'`). Voir `docs/fr/db-bootstrap.md`.
+
+## PremiĂšre mise en route (par environnement)
+
+```bash
+# 1. static stack (DSQL, S3, ECR, DynamoDB, SSM)
+make deploy-static ENV=prod AWS_PROFILE=
+
+# 2. fill the SPIP key material placeholder in SSM (see docs/fr/db-bootstrap.md)
+
+# 3. build + push image, sync assets, apply app stack
+make deploy ENV=prod AWS_PROFILE=
+
+# 4. initialise the SPIP schema + admin author (see docs/fr/db-bootstrap.md)
+```
+
+## CI/CD
+
+`.github/workflows/deploy.yml` déploie un environnement par exécution. Configurez chaque
+environnement sous **GitHub â Settings â Environments** avec ces variables :
+- `AWS_ACCOUNT_ID`, `AWS_REGION`, `CI_ROLE_NAME` (rĂŽle OIDC Ă assumer)
+
+et committez les fichiers `var//` correspondants. Le workflow assume un rĂŽle IAM via
+GitHub OIDC â pas de clĂ©s Ă longue durĂ©e de vie.
\ No newline at end of file
diff --git a/docs/fr/logging.md b/docs/fr/logging.md
new file mode 100644
index 0000000..9470f4c
--- /dev/null
+++ b/docs/fr/logging.md
@@ -0,0 +1,112 @@
+# Journalisation
+
+**Français** · [English](../en/logging.md)
+
+## Architecture
+
+Logs SPIP â plugin `logs_stderr` â PHP `error_log()` â stderr â CloudWatch Logs
+
+Le plugin `logs_stderr` (`spip/plugins-vendor/logs_stderr/`) remplace la fonction `inc_log()` de SPIP pour écrire vers stderr au lieu des fichiers `tmp/log/`. Sur Lambda, stderr part directement vers CloudWatch.
+
+## Configuration
+
+Variables d'environnement sur Lambda (définies dans `iac/spip/app/locals.tf`) :
+
+| Variable | Valeurs | Défaut | Description |
+|---|---|---|---|
+| `LOG_LEVEL` | `ERROR`, `WARNING`, `INFO`, `DEBUG` | `WARNING` | Niveau minimal à émettre |
+| `LOG_FORMAT` | `json`, `text` | `json` | Format de sortie |
+
+## Format de sortie
+
+### JSON (par défaut)
+```json
+{"level":"ERROR","channel":"dsql","context":"prive","message":"errcode: 1000 : ..."}
+```
+
+Champs :
+- `level` â ERROR, WARNING, INFO, DEBUG
+- `channel` â canal de log (spip, dsql, base, etc.)
+- `context` â `prive` (admin) ou `public`
+- `message` â message de log
+
+### Texte
+```
+[spip][ERROR][dsql][prive][pid:5] errcode: 1000 : ...
+```
+
+## Filtrage par niveau
+
+Le plugin extrait le niveau à partir du préfixe du message de log SPIP :
+- `ERREUR:`, `ERROR:`, `HS:` â ERROR
+- `WARNING:`, `AVERTISSEMENT:` â WARNING
+- `INFO:`, `!INFO:` â INFO
+- Tout le reste â INFO
+
+Les messages en dessous de `LOG_LEVEL` sont écartés.
+
+## RequĂȘtes CloudWatch Logs Insights
+
+### Toutes les erreurs de la derniĂšre heure
+```
+fields @timestamp, @message
+| filter @message like /\"level\":\"ERROR\"/
+| sort @timestamp desc
+| limit 50
+```
+
+### Erreurs BD
+```
+fields @timestamp, @message
+| filter @message like /\"channel\":\"dsql\"/
+| sort @timestamp desc
+```
+
+### Analyse de motifs
+```
+fields @timestamp, @message
+| filter @message like /spip/
+| pattern @message
+```
+
+## Emplacement du plugin
+
+`spip/plugins-vendor/logs_stderr/` â depuis https://git.spip.net/spip-contrib-extensions/logs_stderr
+
+Fichier clĂ© : `inc/log.php` â remplace `inc_log()` (la fonction de journalisation de SPIP).
+
+## Erreurs PHP
+
+Les erreurs PHP (Fatal, Warning, Notice) partent vers stderr indĂ©pendamment du plugin de log SPIP â elles sont contrĂŽlĂ©es par `error_reporting` dans le PHP ini. Actuellement toutes les erreurs PHP sont journalisĂ©es. Pour supprimer les avertissements, ajoutez au Dockerfile :
+```dockerfile
+RUN printf '...\nerror_reporting=E_ALL & ~E_WARNING & ~E_NOTICE\n' > /opt/bref/etc/php/conf.d/spip-lambda.ini
+```
+
+## Journalisation du pilote DSQL
+
+Le pilote DSQL (`spip/overlay/ecrire/req/dsql.php`) journalise les erreurs SQL via `spip_log()` sur le canal `dsql`. Elles apparaissent ainsi :
+```json
+{"level":"ERROR","channel":"dsql","context":"prive","message":"errcode: 1000 : ","aws.xray.trace_id":"1-abc123-def456@span123"}
+```
+
+## Corrélation avec les traces X-Ray
+
+Les entrées de log incluent automatiquement `aws.xray.trace_id` lorsqu'une trace est active. Format : `@`.
+
+```json
+{"level":"HS","channel":"spip","context":"public","message":"...","aws.xray.trace_id":"1-69fc9929-52095a885168a76063ddeaf6@f725f76da07265d7"}
+```
+
+Cela permet :
+- **Trace â Logs :** dans la console X-Ray, cliquez sur une trace pour voir les entrĂ©es de log associĂ©es
+- **Logs â Trace :** dans CloudWatch Logs, cliquez sur le lien de l'identifiant de trace pour sauter vers la trace
+
+### Fonctionnement
+Le plugin `logs_stderr` vérifie la présence de `$GLOBALS['_otel_root_span']` (défini par la configuration OTEL de `prepend.php`). S'il est présent, il extrait l'identifiant de trace et l'identifiant de span et les ajoute à la sortie JSON.
+
+### RequĂȘte CloudWatch Insights (trouver les logs d'une trace)
+```
+fields @timestamp, @message
+| filter @message like "1-69fc9929-52095a885168a76063ddeaf6"
+| sort @timestamp asc
+```
diff --git a/docs/fr/plugins.md b/docs/fr/plugins.md
new file mode 100644
index 0000000..021d16c
--- /dev/null
+++ b/docs/fr/plugins.md
@@ -0,0 +1,140 @@
+# Gérer les plugins SPIP sur Lambda
+
+**Français** · [English](../en/plugins.md)
+
+SPIP tourne ici sous forme d'**image Docker immuable**. Les plugins sont intégrés à l'image
+au moment du build â il n'y a pas d'installation de plugin Ă l'exĂ©cution. Ajouter un plugin
+signifie donc : le déposer dans le dépÎt, ajouter un COPY dans le Dockerfile si nécessaire,
+reconstruire, redéployer.
+
+## OĂč vivent les plugins dans ce dĂ©pĂŽt
+
+```
+spip/
+âââ plugins/ # OUR custom plugins (source of truth)
+â âââ s3upload/ # presigned-URL uploads to S3
+â âââ sessions_dynamodb/ # DynamoDB session storage (loaded via a squelettes override)
+âââ plugins-vendor/ # third-party plugins vendored into the repo
+â âââ logs_stderr/ # redirect spip_log() to stderr â CloudWatch
+âââ Dockerfile # COPY-s the above into the image
+```
+
+Les plugins du cĆur SPIP (`plugins-dist/`) ne sont **pas** dans ce dĂ©pĂŽt â ils proviennent
+du cĆur SPIP rĂ©cupĂ©rĂ© au moment du build (voir `docs/fr/spip-upgrade.md`). Quelques-uns sont
+retirĂ©s dans le Dockerfile (`bigup`, `forum`, `statistiques`, âŠ) car ils ne conviennent pas
+à un déploiement serverless/majoritairement en lecture.
+
+## Comment l'image mappe les dossiers vers SPIP
+
+| Source | Chemin dans l'image | Activation |
+|---|---|---|
+| `plugins-dist/` rĂ©cupĂ©rĂ© (cĆur SPIP) | `/var/task/plugins-dist/` | toujours actif |
+| `spip/plugins-vendor/*` | `/var/task/plugins-dist/*` | toujours actif |
+| `spip/plugins/s3upload/` | `/var/task/plugins-dist/s3upload/` | toujours actif |
+| `spip/plugins/sessions_dynamodb/` | via `squelettes/inc/session.php` | surcharge, pas un plugin |
+
+Tout ce qui est placĂ© sous `plugins-dist/` est scannĂ© et activĂ© par SPIP au dĂ©marrage Ă
+froid ; SVP l'enregistre en base (`spip_paquets` avec `actif='oui'`) et cĂąble
+automatiquement les déclarations de pipeline du `paquet.xml`.
+
+### `plugins/` vs `plugins-dist/` â pourquoi la distinction disparaĂźt Ă l'exĂ©cution
+
+Dans une installation SPIP **normale**, les deux répertoires ont des sens différents :
+
+- `plugins-dist/` â plugins **livrĂ©s avec le cĆur SPIP**, toujours actifs, sans Ă©tape d'activation.
+- `plugins/` â plugins **que vous avez ajoutĂ©s**, qui doivent ĂȘtre **activĂ©s** (enregistrĂ©s en
+ base via SVP, normalement en cliquant sur « activer » dans l'espace privé).
+
+Cette Ă©tape d'activation est interactive et Ă©crit sur le disque â ni l'un ni l'autre n'est
+possible sur une **Lambda immuable et en lecture seule** qui redescend à zéro. Ce build
+**copie donc délibérément nos propres plugins dans `plugins-dist/`** (voir le Dockerfile),
+oĂč ils sont toujours actifs dĂšs le premier dĂ©marrage Ă froid, sans Ă©tape manuelle.
+
+Par conséquent, à l'**exécution** la distinction `plugins/` vs `plugins-dist/` n'existe
+plus â tout vit dans `/var/task/plugins-dist/`. Dans le **dĂ©pĂŽt**, les dossiers sources
+séparés (`spip/plugins/` = les nÎtres, `spip/plugins-vendor/` = tiers) ne sont conservés
+que pour l'organisation et la provenance ; il n'y a pas de `spip/plugins-dist/` (ce nom
+appartient au cĆur SPIP rĂ©cupĂ©rĂ©).
+
+## Ajouter un plugin TIERS
+
+1. Télécharger le plugin dans `spip/plugins-vendor//` (il doit avoir un
+ `paquet.xml` valide).
+2. Rien d'autre Ă changer â le Dockerfile fait dĂ©jĂ
+ `COPY spip/plugins-vendor/ /var/task/plugins-dist/`.
+3. Reconstruire + déployer. Le plugin est actif au prochain démarrage à froid.
+
+> Ăpinglez la version du plugin (committez la copie vendorisĂ©e) pour que les builds
+> restent reproductibles.
+
+## Ajouter un plugin PERSONNALISĂ (le vĂŽtre)
+
+1. Créer `spip/plugins//` avec au moins un `paquet.xml` :
+
+ ```xml
+
+ My Plugin
+ Your name
+ GPL
+
+
+ ```
+
+2. Ajouter les lignes COPY dans `spip/Dockerfile` (dans l'étape `lambda`), à cÎté de celle
+ de s3upload :
+
+ ```dockerfile
+ COPY spip/plugins// /var/task/plugins-dist//
+ ```
+ et l'ajouter Ă la ligne `rm -rf /var/task/plugins/...` pour que le doublon sous
+ `plugins/` ne soit pas embarqué.
+
+3. Les migrations du modÚle de données vont dans `_administrations.php` (le
+ versionnage de schĂ©ma natif de SPIP â `spip__metas`/`maj_tables`). Elles
+ s'exécutent à la premiÚre visite admin authentifiée, ou via
+ `spip/scripts/bootstrap-db.php` (voir `docs/fr/db-bootstrap.md`).
+
+4. Reconstruire + déployer.
+
+## Exposer une API REST depuis un plugin
+
+Le contrĂŽleur frontal Lambda (`spip/overlay/router.php`) relaie tout vers SPIP. Pour servir
+une API personnalisée sous, par exemple, `/api/*`, ajouter une branche **avant** celle de
+`/ecrire` qui require le point d'entrĂ©e de votre plugin â le routeur documente dĂ©jĂ le
+motif dans un commentaire. Ajoutez ensuite un comportement CloudFront correspondant dans
+`iac/spip/app/cloudfront.tf` si vous voulez un cache par chemin.
+
+## Ordre de chargement des plugins (Lambda)
+
+1. `auto_prepend_file` â `prepend.php` (wrapper de flux S3, OTEL, rĂ©pertoires tmp)
+2. bootstrap SPIP â `ecrire/inc/utils.php`
+3. `config/mes_options.php`
+4. fichiers `_options.php` des plugins (depuis le cache de plugins SPIP dans `/tmp`)
+5. fichiers `_fonctions.php` des plugins
+6. exécution des pipelines
+
+## Cas particuliers dans ce dépÎt
+
+### sessions_dynamodb
+Surcharge `ecrire_fichier()`/`lire_fichier()` pour les fichiers de session, ce qui entre en
+conflit avec le cĆur lorsqu'il est chargĂ© comme un plugin normal. ChargĂ© via une surcharge
+de chemin squelettes Ă la place :
+```dockerfile
+COPY spip/plugins/sessions_dynamodb/inc/session.php /var/task/squelettes/inc/session.php
+```
+
+### s3upload
+- JS servi depuis S3 Ă `/plugins-dist/s3upload/s3upload.js`
+- le pipeline `header_prive` injecte la balise `