# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Projet

**wp-back** — plugin WordPress commercial de backup, concurrent de WP Time Capsule (backups incrémentaux, restores incrémentaux, staging, updates sécurisés). Différence clé : le stockage est sur **notre compte Wasabi**, pas celui du client — d'où un backend SaaS qui médiatise tout accès au stockage, avec metering et quotas par site.

Spec d'architecture complète (décisions validées, flux, sécurité, roadmap) : `docs/superpowers/specs/2026-07-23-wp-back-architecture-design.md`. **La lire avant tout travail sur le moteur de backup ou l'API.**

## Architecture (résumé)

- `plugin/` — plugin WordPress (PHP 8.1+). Moteur incrémental : index local de fichiers (sha256), upload content-addressed vers `s/{site_id}/blobs/{sha256}`, snapshots = manifests JSON gzippés (eux-mêmes stockés comme blobs). Le restore incrémental est un diff manifest ↔ disque. Scan/hash/upload/restore par tranches relançables (Action Scheduler) — jamais supposer qu'une requête PHP survit à un gros site.
- `api/` — backend SaaS sur Cloudflare Workers (TypeScript, Hono, wrangler, pnpm) + D1. Rôle : activation de licences, broker de presigned URLs Wasabi, metering, quotas, rétention/GC. (Stripe : pas encore implémenté.)
- Wasabi : buckets poolés, un préfixe par site.

### Modules `api/src`

- `index.ts` — entrée Worker : `fetch` (Hono) + `scheduled` (cron `0 3 * * *` → GC).
- `app.ts` — routage et middlewares ; erreurs 404/500 uniformes via `lib/errors`.
- `routes/sites.ts` — `POST /v1/sites/activate` : licence + URL → `site_id` + JWT de site (rate-limité par IP).
- `routes/storage.ts` — `blobs/sign-upload` (réservation + quota), `blobs/confirm` (HEAD Wasabi, crédit de l'usage), `blobs/sign-download`.
- `routes/snapshots.ts` — `POST /v1/snapshots/commit` (relit et valide le manifest depuis Wasabi avant d'écrire la snapshot) et `GET /v1/snapshots`.
- `routes/usage.ts` — `GET /v1/usage` : octets utilisés, quota, rétention.
- `middleware/auth.ts` — JWT HS256 + contrôle `token_version` (une rotation invalide les anciens tokens).
- `middleware/rate-limit.ts` — fenêtre glissante 60 s en mémoire d'isolate (par site, et par IP sur `/activate`).
- `lib/wasabi.ts` — `blobKey()` (seule fabrique de clés S3), presign PUT/GET (TTL 900 s), HEAD/GET/DELETE.
- `gc.ts` — cron : expiration par rétention puis sweep des blobs non référencés.
- `migrations/0001_init.sql` — D1 : `licenses`, `buckets`, `sites`, `blobs`, `snapshots`, `usage_log`.

### Modules `plugin/src`

- `Plugin.php` / `Install.php` — bootstrap des hooks ; `dbDelta` des tables `wpb_files` (PK `path_hash`) et `wpb_jobs`.
- `Api/Client.php` — client HTTP de l'API (retry borné, `ApiException` portant le code d'erreur applicatif).
- `Backup/BackupRunner.php` — machine à états `scan → hash → upload → db → manifest → done`, persistée en base, pilotée par Action Scheduler, avec mutex + heartbeat.
- `Backup/FileScanner.php` — parcours d'`ABSPATH` par lots, curseur = dernier chemin trié (reprise stateless), pruning des dossiers exclus et des symlinks.
- `Backup/Hasher.php` — sha256 en streaming par mini-lots à budget temps ; rafraîchit size/mtime.
- `Backup/Uploader.php` — `sign-upload → PUT présigné → confirm` par lots ; dédup côté serveur, reprise idempotente.
- `Backup/DbDumper.php` — dump DB par table (`CHECKSUM TABLE` pour détecter le changement), SQL gzippé chunké poussé comme blob. Écarte les vues, nos tables `wpb_*` et **toutes** les tables `{$prefix}actionscheduler_%`. Les `INSERT` sont mono-ligne et bornés en octets (`MAX_STATEMENT_BYTES`, sous `max_allowed_packet`) ; les valeurs non-UTF-8 sortent en littéral hex `0x…` non quoté.
- `Backup/ManifestBuilder.php` — construit le manifest, l'uploade comme blob, puis committe la snapshot **en dernier**. Accepte une garde (`?callable $guard`) évaluée juste avant le commit, après l'upload : c'est par là que le BackupRunner ferme la fenêtre « un restore démarre pendant l'envoi du manifest ».
- `Backup/WpdbFileIndex.php` / `WpdbJobStore.php` — persistance (interfaces `FileIndexInterface` / `JobStoreInterface`, doublées en mémoire dans les tests).
- `Restore/RestoreRunner.php` — machine à états `fetch_manifest → plan → download → apply_files → import_db → done` : mode maintenance **avec laissez-passer étroit pour les seules requêtes qui exécutent le restore** (celles qui portent le secret du restore en cours, WP-CLI, et wp-cron dont les tâches dues sont alors restreintes à la file Action Scheduler — sans ce laissez-passer la chaîne de tranches s'auto-étrangle), remplacements atomiques, vérification sha256 avant chaque rename, `wp-config.php` et `.maintenance` jamais touchés. Expose aussi `resumeIfStale()` (point d'entrée du watchdog).
- `Restore/SqlSplitter.php` — découpe du dump en statements, quote/backtick-safe.
- `Schedule.php` — **unique** planificateur du backup récurrent (hook `wpb_scheduled_backup`), défaut daily @ 2 h, posé sur `init` (AS n'a pas de data store avant). Porte aussi l'**unique** watchdog récurrent `wpb_restore_watchdog` (10 min → `RestoreRunner::resumeIfStale()`) et le handler POST des réglages (fréquence / heure / exclusions custom sanitizées).
- `Admin/SettingsPage.php` / `Admin/SnapshotsPage.php` — activation de licence, usage/quota, réglages de planification, liste des snapshots, déclenchement du restore (`do_action('wpb_start_restore', $manifest_sha256)`).
- `Admin/JobStatusPanel.php` — rendu du dernier job (backup ou restore) : phase, compteurs, message d'échec, fichiers skippés. Logique **pure** (aucun accès au store, aucune écriture), tout échappé ; détection d'un échec 401 **structurelle** (`error_status`/`error_code` posés par les runners), jamais par heuristique sur le texte.
- `Admin/ApiCache.php` — cache des lectures API affichées en admin (transient) : fenêtre de fraîcheur courte, puis repli sur la dernière valeur connue quand l'API est injoignable (marquée « données du … »). Lectures d'affichage uniquement.
- `Support/Paths.php` — normalisation des chemins, rejet des traversées `..`, préfixes exclus.

## Invariants non négociables

- Le plugin ne détient **jamais** de credentials S3 — uniquement un token de site (JWT) et des presigned URLs éphémères émises par l'API.
- Le backend construit **toutes** les clés S3 ; le client ne fournit jamais de chemin. Pas de LIST/DELETE côté plugin.
- Modèle de menace : le site WordPress est considéré compromis. Un site ne peut ni lire hors de son préfixe, ni détruire son historique (suppressions uniquement via le GC backend).

## Invariants vérifiés en review

Déclinaison **vérifiable** de la charte ci-dessus, augmentée des garanties issues des fixes de review et du durcissement pré-prod. C'est la liste qui fait foi : chaque point cite le fichier qui le tient et a un test associé. **Une modification qui en casse un est une régression bloquante** — lire le test avant de toucher au fichier.

- **Commit-last du manifest** (`Backup/ManifestBuilder.php`) — ne jamais committer une snapshot avant que tous ses blobs soient `stored` ; ne jamais committer s'il reste des rows `changed`/`hashed`/`uploading`.
- **Clés S3 côté backend uniquement** (`api/src/lib/wasabi.ts`) — ne jamais construire une clé S3 ailleurs que dans `blobKey()`, et ne jamais en retirer les gardes `SITE_ID_RE` / `SHA256_RE` (elles ferment une traversée de chemin via la normalisation `new URL`).
- **Quota comptant les réservations pending** (`api/src/routes/storage.ts`, `sign-upload`) — ne jamais calculer le quota sur `storage_bytes_used` seul : les blobs `pending` du site (hors sha courant) doivent être ajoutés, sinon un site dépasse son quota par réservations parallèles.
- **Confirm sérialisé** (`api/src/routes/storage.ts`, `confirm`) — ne jamais créditer `storage_bytes_used` sans l'`UPDATE ... WHERE status <> 'stored'` conditionnel préalable : le crédit n'a lieu que si `changes > 0`, sinon un double confirm double-compte.
- **Confirm fail-closed sur la taille** (`api/src/routes/storage.ts`) — ne jamais accepter un objet dont le `HEAD` Wasabi contredit la taille déclarée : il est supprimé, sa réservation purgée, et le client reçoit 409 `size_mismatch`.
- **GC fail-closed** (`api/src/gc.ts`) — ne jamais balayer un site dont un manifest est illisible ou de shape invalide (`assertValidManifest` → `continue`) ; ne jamais expirer la dernière snapshot `complete` d'un site ; ne jamais supprimer un blob dans les 24 h suivant son `created_at` (grâce anti-course avec un upload/dédup en cours), et toujours supprimer la ligne D1 avant l'objet, par `DELETE ... AND created_at = ?`.
- **Validation du manifest avant restore destructif** (`Restore/RestoreRunner.php`) — ne jamais dériver la liste des fichiers surnuméraires d'un manifest non validé : un manifest vide ou de shape inattendue effacerait tout `ABSPATH` (`InvalidManifestException` avant toute écriture).
- **Intégrité au restore** — ne jamais écrire un blob dont le contenu téléchargé ne hashe pas vers le `sha256` attendu (vérifié à la descente **et** juste avant le rename), et ne jamais appliquer une entrée dont le chemin sort d'`ABSPATH` (`Paths::absolute` rejette `..`, l'entrée est skippée sans faire échouer le restore).
- **Garde anti-empoisonnement du store content-addressed** (`Backup/Uploader.php`, `Backup/Hasher.php`) — ne jamais uploader sans revérifier size+mtime juste avant le réseau : un fichier modifié depuis son hash repart en `changed`, jamais d'octets stockés sous un sha périmé. Corollaire : le `stat` se fait **avant** le hash, pas après.
- **Échappement SQL newline-safe** (`Backup/DbDumper.php` ↔ `Restore/SqlSplitter.php`) — ne jamais émettre d'octets `\n`/`\r`/`\x1a` bruts dans une valeur dumpée : le contrat « INSERT mono-ligne » est ce qui rend le split du restore sûr. Le splitter, lui, ne coupe jamais sur un `;` dans un littéral quoté ou un identifiant backtické.
- **Transitions de jobs gardées** (`Backup/BackupRunner.php`, `Restore/RestoreRunner.php`) — ne jamais écrire un état de job sans `WHERE status = 'running'` (`updateIfRunning` / `finishIfRunning` / `recoverStale`) : une tranche zombie ne doit pouvoir ni ressusciter un job terminal, ni produire un second commit.
- **Ownership du lock** — ne jamais relâcher `wpb_backup_lock` / le lock restore sans vérifier qu'on en est le propriétaire : sinon une tranche zombie libère le verrou d'une chaîne vivante.
- **Erreurs d'import SQL fatales** (`Restore/RestoreRunner.php`) — ne jamais avaler une erreur de `$wpdb->query()` pendant l'import : un restore partiellement importé doit échouer bruyamment, pas se terminer en `done`.
- **File Action Scheduler jamais dumpée** (`Backup/DbDumper.php`) — ne jamais faire entrer une table `{$prefix}actionscheduler_%` dans un dump : c'est la file qui pilote le restore lui-même ; la restaurer ferait disparaître l'action en cours (chaîne morte, site à moitié restauré) et ressusciterait des actions périmées. Corollaire produit **assumé** : les actions planifiées ne sont pas restaurées — ce n'est pas un bug à « corriger ».
- **`wp-config.php` hors de portée du restore** (`Restore/RestoreRunner.php`) — ne jamais le réappliquer NI le supprimer : la config vivante (credentials DB, salts) ne doit pas être écrasée par celle de la snapshot, sinon la requête suivante ne se connecte plus et le restore meurt sans reprise possible. Le fichier reste dans le backup, restaurable à la main.
- **Écritures confinées à l'`ABSPATH` résolu** (`Restore/RestoreRunner.php`) — ne jamais écrire sans avoir vérifié que le chemin **résolu** (`realpath`, séparateur compris) reste sous `ABSPATH` : `Paths::absolute` rejette les `..` mais ne résout pas les liens, or un symlink-dossier pré-planté par un site compromis suffirait à faire écrire un manifest empoisonné hors racine.
- **INSERT bornés + binaire en hex** (`Backup/DbDumper.php`) — ne jamais émettre un `INSERT` non borné en octets (au-delà de `max_allowed_packet` l'import est rejeté et le restore échoue) ni quoter des octets non-UTF-8 (littéral `0x…` obligatoire, sinon corruption sous `SET NAMES utf8mb4`). C'est, avec le mono-ligne, ce qui rend le split du restore sûr.
- **Laissez-passer du mode maintenance** (`Restore/RestoreRunner.php`, `enterMaintenance`) — le `.maintenance` posé par le restore doit laisser passer les **seules** requêtes qui font avancer le restore, et rien d'autre : sans laissez-passer il tue le loopback qui exécute la tranche suivante (le restore n'avance plus que par à-coups) ; trop large, il rouvre le site en plein remplacement de fichiers / import DB. Trois porteurs, et trois seulement : les requêtes qui présentent le **secret du restore en cours** (restreintes à `DOING_AJAX`, l'endpoint du loopback) ; **WP-CLI** ; **wp-cron**, dont les tâches dues sont alors restreintes à la file Action Scheduler (`pre_get_ready_cron_jobs`) puisque `wp-cron.php` est public. Ne **jamais** conditionner le laissez-passer à des paramètres de requête publics (`action`, `nonce`) : ils sont forgeables par n'importe qui et le nonce est invérifiable à ce stade du bootstrap (aucune base chargée).

  **Où vit le secret — et pourquoi pas dans le fichier.** `.maintenance` est **lisible par HTTP** sur une configuration Apache par défaut : il n'a pas d'extension `.php` (aucun serveur ne le passe à l'interpréteur) et le `.htaccess` standard de WordPress ne réécrit pas une requête dont le fichier existe (`RewriteCond %{REQUEST_FILENAME} !-f`) — Apache sert donc `GET /.maintenance` **tel quel**, source comprise ; Debian/Ubuntu ne denient que `^\.ht`, et nginx dépend d'un `location ~ /\.` absent de beaucoup de recettes. **Tout ce qu'on écrit dans ce fichier est public pendant la durée du restore.** Il ne porte donc que le **sha256** du secret ; le secret brut vit dans l'**état du job** (`{$prefix}wpb_jobs`, écartée du dump par `DbDumper::tables()` — c'est ce qui la fait survivre à `import_db`, comme pour la reprise après crash). Le comparateur est un `hash_equals` sur les empreintes. Le secret est injecté dans l'URL du loopback Action Scheduler par le filtre `as_async_request_queue_runner_query_args` : ce filtre tourne sur `shutdown`, WordPress chargé, il peut donc relire l'état du job quand la requête n'a hérité que de l'empreinte. Ne **jamais** stocker le secret dans une option (`wp_options` est détruite et réécrite par `import_db`) ni le réécrire dans le fichier.

  Ce fichier est du PHP `require`é par WordPress à **chaque** requête du site — y compris quand il est orphelin — tant qu'il existe : le garder trivial, borner tout effet de bord à la fraîcheur de son timestamp, et n'y toucher qu'avec une prudence maximale. Il est aussi **hors de portée du backup et du restore** (`Paths::EXCLUDED_EXACT`, garde d'`applyFile`, garde d'`extraneousFiles`) : jamais sauvegardé, jamais réappliqué, jamais supprimé — un verrou de snapshot réappliqué par-dessus le verrou vivant rouvrirait le front en plein `apply_files`.
- **Exclusion mutuelle backup ↔ restore** (`Backup/BackupRunner.php`, `Restore/RestoreRunner.php`) — ne jamais laisser un backup progresser pendant un restore vivant : il committerait la snapshot d'un site à moitié restauré comme point de restauration valide. Asymétrie voulue « restore prioritaire » : le backup cède (start + step), le nouveau restore est refusé. « Vivant » = `running` **ET** heartbeat frais, pour qu'un job crashé ne rende jamais l'autre impossible à jamais. La vérification est refaite **jusqu'au dernier instant** : à l'entrée de la tranche, avant `buildAndCommit()`, et via la garde passée au builder juste avant le commit — toute vérification plus précoce laisse la durée d'un upload réseau au restore pour démarrer.

## Commandes

Environnement : `pnpm` vient des shims mise — si introuvable, `export PATH="$HOME/.local/share/mise/shims:$PATH"`. Le PHP local est un **8.5** Homebrew alors que la cible du plugin est **8.1** : ne jamais utiliser de syntaxe > 8.1, le CLI local ne la signalera pas. `composer` s'utilise nu (`sfw` ne couvre pas composer) ; les dépendances JS passent par `sfw` (`sfw pnpm add <pkg>`).

`api/` (54 tests) :

- `pnpm -C api dev` — wrangler dev.
- `pnpm -C api test` — Vitest sur `@cloudflare/vitest-pool-workers` (Miniflare) ; les migrations D1 sont appliquées automatiquement par `test/apply-migrations.ts`, aucune préparation manuelle.
- `pnpm -C api test test/gc.test.ts` — un seul fichier (l'argument est un filtre de chemin).
- `pnpm -C api lint` (oxlint) · `pnpm -C api format` (oxfmt) · `pnpm -C api typecheck` (`tsc --noEmit`). Pas d'ESLint/Prettier.
- `cd api && CI=1 pnpm exec wrangler d1 migrations apply wp-back-db --local` — le `CI=1` est nécessaire : sans lui, wrangler ouvre un prompt interactif de confirmation qui bloque en environnement agent.
- `cd api && pnpm exec wrangler d1 execute wp-back-db --local --file=seed/dev.sql` — seed dev (licence `WPB-DEV-0000-0000` + un bucket). Non idempotent : rejoué, il échoue sur la contrainte UNIQUE.
- `pnpm -C api deploy` — wrangler deploy. Secrets requis en prod : `JWT_SECRET`, `WASABI_ACCESS_KEY_ID`, `WASABI_SECRET_ACCESS_KEY` (`wrangler secret put`). `database_id` dans `wrangler.jsonc` est encore le placeholder `local-dev-only`.

`plugin/` :

- `cd plugin && composer install` — installe Action Scheduler + PHPUnit dans `vendor/`.
- `cd plugin && vendor/bin/phpunit` — suite unitaire complète (`tests/Unit`, WordPress stubbé, aucune base requise). C'est la sortie de cette commande qui donne le compte de tests courant — ne pas le figer ici, il périme à chaque lot.
- `cd plugin && vendor/bin/phpunit tests/Unit/Restore/SqlSplitterTest.php` — un seul fichier ; `vendor/bin/phpunit --filter SqlSplitterTest` pour filtrer par nom de classe ou de méthode.
- `php -l plugin/src/<fichier>.php` — contrôle syntaxique rapide.
- `.wp-env.json` existe (PHP 8.2, plugin monté à la racine) mais aucun test d'intégration wp-env n'est encore écrit — cf. `docs/backlog-pre-prod.md`.

## Conventions spécifiques au projet

- Monorepo pnpm pour la partie TypeScript ; le plugin PHP reste sans étape de build lourde (pas encore d'étape de packaging vendor + zip — cf. backlog).
- V1 = backup + restore uniquement. Staging (V1.1) et Updates (V1.2) s'appuient sur le moteur de snapshots — ne pas leur créer de mécanisme parallèle.
- DB incrémentale V1 = granularité table (`CHECKSUM TABLE` par table, dump chunké 1000 lignes). La granularité ligne est une itération future, pas un prérequis.
- Les symlinks (dossiers comme fichiers) ne sont jamais suivis par le scan : ils sont **absents des backups V1**. C'est une décision produit, pas un oubli.
- Le plugin est déclenché hors `is_admin()` (les tranches tournent en cron / WP-CLI) ; toute logique métier reste testable sans WordPress (interfaces + doublures en mémoire dans `plugin/tests/Support`).
- Plans d'implémentation d'origine : `docs/superpowers/plans/`. Backlog de durcissement pré-prod (fiabilité restore, exploitation, distribution, API) : `docs/backlog-pre-prod.md`.
