The host side of the backups-pull mount is /srv/vps-health-api/backups-pull (docker inspect, 2026-10-19), and the three mounts marked "to set up" are in place. The test list gains agent-pg-backup-pull.test.js, missing until now. REVIEW.md stated the rotation was anchored on the workstation's date: it is anchored on the newest valid backup not after today. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
22 KiB
REVIEW.md — VPS Health API
Règles de revue des PR de ce dépôt : niveau de risque, ce qui compte dans chaque zone, commandes de vérification et pièges connus. Ce fichier complète CLAUDE.md sans le remplacer : une zone en reprend ce qui sert à la revue, avec sa source.
À partir de #63, /pr-review lit ce fichier sur main, y compris pour une PR empilée : une PR qui le modifie est jugée par la version de main, et ses nouvelles règles valent pour les PR suivantes.
L'API est exposée sur Internet (health.lacompagniemaximus.com), porte quatre tokens et trois surfaces d'écriture, et deux agents de ce dépôt tournent sur le ThinkPad avec un jeton OAuth de Claude Code et la copie hors site des sauvegardes PostgreSQL. Le merge est le déploiement : depuis le 2026-10-12, un push sur main déclenche le webhook Forgejo (hook id 9) et Coolify redéploie l'app, en même temps que la CI du push et sans l'attendre (la-compagnie-maximus/docs/coolify-ops.md, « Auto-deploy au push sur main »). La CI des PR tourne avant le merge ; celle de main, non. Les agents de agent/, eux, ne partent qu'à la copie manuelle sur le poste (agent/README.md) : leur merge ne déploie rien, mais la copie suivante prendra main tel quel.
Niveaux
Le niveau fixe la profondeur de la revue, que /pr-review règle pour chaque niveau. Il se calcule sur les fichiers que touche le diff, jamais sur l'auteur (Max, /fix-issue, /autopilot) :
- un fichier désigné par une ligne du tableau Haut dont la condition est remplie est de niveau haut ; sinon, un fichier désigné par Bas est de niveau bas ; sinon il est normal ;
- la PR prend le niveau le plus haut de ses fichiers, et la revue l'annonce avec le fichier qui le fixe ;
- un fichier renommé, déplacé ou supprimé se juge sur son ancien et sur son nouveau chemin : un fichier de niveau haut déplacé reste haut, et la PR qui le déplace met ce tableau à jour ;
- une condition qu'on ne sait pas trancher à la lecture du diff compte comme remplie.
Motifs : chemins depuis la racine. * ne traverse jamais un / ; **/ vaut zéro, un ou plusieurs dossiers (**/*.md couvre CLAUDE.md, à la racine) ; {a,b} liste des variantes.
Haut
| Motif | Condition | Zone |
|---|---|---|
index.js |
le diff touche le routage ou l'authentification : ROUTES (une route ajoutée comprise), TOKEN_KINDS, resolveRoute, checkAuth, tokenMatches, handler, logRejection, safeLogValue, ou une constante de token (*_TOKEN) |
Routage et authentification |
code livré : {index,metrics,claude-usage,claude-usage-history,backups}.js |
fichier ajouté, renommé ou supprimé, ou diff qui ajoute ou change un effet de bord : écriture disque (writeFile*, rename*, mkdir*, unlink*, rmSync, chmod*, writeJsonAtomic, writeHostSnapshot), le balayage des temporaires (sweepStaleTempFiles, TEMP_FILE_RE), un plafond de corps (readBody, *MAX_BODY_BYTES), une commande shell (child_process, execSync, spawn*), un appel sortant (fetch, http.request), le bloc require.main |
Surfaces d'écriture et montages ; Image et démarrage |
Dockerfile |
toujours : le COPY liste les modules un par un, USER node donne l'uid 1000 dont dépendent les montages |
Image et démarrage |
agent/pg-backup-pull/** |
toujours : la rotation efface des dossiers de la seule copie hors site | Agents du poste |
agent/claude-usage/{limits,push-claude-usage}.js, agent/push-metrics.js, agent/**/*.sh |
toujours : ils tiennent le jeton OAuth de Claude Code ou un token d'ingestion, et leur sortie part dans syslog | Agents du poste |
.forgejo/workflows/** |
toujours : le runner linux-builder est un service de l'hôte de production, à portée instance, et le job push de main tourne pendant le déploiement |
CI et gardes |
vitest.config.js, scripts/boot-from-copy.sh |
toujours : ils décident quels tests tournent et si le COPY est vérifié |
CI et gardes |
__tests__/{auth,writes}.test.js |
le diff change ou retire une ligne existante hors commentaire ; un cas ajouté sans rien toucher d'autre reste normal | CI et gardes |
package.json |
le diff change autre chose que le champ version de l'app ou la version d'un paquet déjà listé : paquet ajouté ou retiré, bloc dependencies (le runtime est sans dépendance), scripts, main, type, overrides |
Dépendances |
package-lock.json |
le diff ajoute ou retire un paquet (une clé "node_modules/<nom>" qui apparaît ou disparaît), change l'origine d'un paquet (hôte ou schéma de resolved) ou un integrity sans nouvelle version |
Dépendances |
.gitignore |
le diff retire ou assouplit une ligne (.env et .env.local en dépendent) |
Agents du poste, Dépendances |
fichier à la racine du dépôt, hors *.md |
ajouté, renommé ou supprimé, comme tout fichier d'un dossier caché nouveau : une configuration nouvelle peut changer le build Coolify, la CI ou le chargement de vitest sans motif qui la nomme | Image et démarrage, CI et gardes |
.claude/settings.json, .mcp.json |
toujours : hooks, permissions et serveurs MCP décident de ce que les agents exécutent sur le poste, sans CI pour le voir | CI et gardes |
REVIEW.md |
le diff touche la section « Niveaux » | Règles de revue |
Restent au moins normaux, et leurs règles de zone s'appliquent quand même :
- les handlers de lecture d'
index.jset les nettoyeurs (sanitizeSnapshot,sanitizeClaudeUsage,sanitizeDay,sanitizeBackupsStatus,validateBackupsPull) tant que le diff ne touche ni le routage ni un effet de bord : ce qu'ils laissent passer finit dans l'arbre React de l'admin, mais échappé ; agent/claude-usage/{aggregate,state,backfill-history}.js: un jour figé à J+2 ne se recalcule plus, ce qui se juge avec la mutation du niveau normal ;test-curl.sh,__tests__/ci.test.jset le reste de__tests__/**: un affaiblissement d'épingle deci.test.jsn'ouvre rien que le tableau ne couvre déjà, le workflow etboot-from-copy.shétant de niveau haut ;- une montée de version d'un paquet déjà présent (vitest, seul paquet direct, en devDependency), correctif d'advisory compris (décision de Max du 2026-10-17 pour simpl-resultat, reprise ici) ;
scripts/review-levels.py;CLAUDE.md,REVIEW.mdhors de la section « Niveaux »,agent/README.mdet.claude/**:CLAUDE.mdporte le contrat des routes et les montages Coolify,agent/README.mdl'installation des agents et de leurs fichiers de secrets ; ce sont des sources de ce fichier, jamais de niveau bas.
Bas
Une PR est de niveau bas quand tous ses fichiers sont désignés par **/*.md, docs/**, LICENSE ou reports/**, hors des exceptions de la liste précédente. Exemples : README.md, STATE.md, spec-*.md.
Normal
Tout le reste : tests et fixtures, test-curl.sh, .env.example (noms de variables seulement), les modules de l'agent qui ne tiennent aucun secret, une modification d'index.js limitée à un handler de lecture.
Calibration
Rejouée le 2026-10-19 (#63) sur les 31 PR dont la tête a atteint main, de #2 à #62. Forgejo n'en marque qu'une partie comme mergées : une pile intégrée en avance rapide (#18 à #23, #31, #34, #37) apparaît fermée. #32 (démonstration d'une CI rouge) n'a jamais été intégrée. La liste est figée dans le script (LANDED).
| Ensemble | Bas | Normal | Haut |
|---|---|---|---|
| 31 PR intégrées, #2 à #62 (2026-04-22 au 2026-10-13) | 0 | 11 | 20 (65 %) |
| 17 PR depuis la CI, #31 à #62 (depuis le 2026-09-27) | 0 | 6 | 11 (65 %) |
Niveau haut sur les 31 : routage et authentification (#9, #20, #28, #30, #52, #57, #60), surfaces d'écriture et appels sortants (#2, #18, #37, #56), agents du poste (#22, #23, #53, #56, #59), CI et gardes (#9, #31, #34, #42, #44), fichier racine ajouté (#6). Les 11 PR normales sont de la documentation qui touche CLAUDE.md (#5, #39), des tests seuls ou la passe test-curl.sh (#19, #21, #26, #43, #45, #47), une route de lecture (#8), l'agrégation des transcripts (#51) et le nettoyeur de files[] (#62). Aucune PR n'est de niveau bas : chaque PR de documentation a touché CLAUDE.md.
L'écart à la cible de 20 % du modèle de simpl-resultat est voulu et justifié dans la PR qui ferme #63 : l'historique du dépôt est celui de la construction de ses surfaces sensibles (quatre tokens, trois routes d'écriture, deux agents qui tiennent un secret ou la copie hors site), chacune livrée par la PR qui l'a créée, et le merge part en production sans attendre la CI. Les PR qui ne font qu'étendre une surface existante, une route de lecture ou un test restent normales. Le script qui calcule ces niveaux est scripts/review-levels.py (API Forgejo, lecture seule, plus le clone local pour trois PR au diff vide ; python3 scripts/review-levels.py 60 62 pour quelques PR) ; python3 scripts/review-levels.py --check vérifie, sans réseau, que ses motifs sont ceux du tableau Haut. Il n'approche qu'une partie des conditions et ne sert qu'à la calibration.
Une PR qui modifie cette section dit, pour chaque motif retiré ou assoupli, ce qu'il protégeait, met scripts/review-levels.py à jour dans le même diff et le rejoue sur les mêmes PR.
Ce qui compte par zone
Chaque règle cite sa source. Elles valent quel que soit le niveau : une PR normale qui touche une zone les respecte aussi.
Routage et authentification
- Ordre non négociable : routage, puis
checkAuth(), puis handler. Une route inconnue répond 404 avant tout contrôle de token ; ne jamais « corriger » en mettant l'auth d'abord. —CLAUDE.md« Routage et auth », trois cas404 (not 401)de__tests__/auth.test.js - Un seul point de contrôle. Un nouveau token est une entrée de
TOKEN_KINDS, jamais unifdanscheckAuth(); untokenKindinconnu fait échouer le chargement du module. —CLAUDE.md« Routage et auth », issue #48 - Toute comparaison de token passe par
tokenMatches()(timingSafeEqualsur empreintes SHA-256). Une égalité de chaîne contreBearer …fuit longueur et préfixe ; les épingles sur le source et l'espiontimingSafeEqualdeauth.test.jsle gardent. —CLAUDE.md, PR #30 (issue #17), GHSA-376v-xgjx-7mfr cité dansindex.js - Cloisonnement : chaque route d'écriture a son propre token, qui n'ouvre rien d'autre, et aucun autre token ne l'ouvre. Une PR qui ajoute une route l'entre dans la matrice des deux sens (
auth.test.jspour une lecture, le fichier de tests de la route pour une écriture). —CLAUDE.md« Routage et auth », issues #48 et #58 - Fail-closed :
HEALTH_TOKENabsent donne 401, un token d'ingestion absent donne 503 sur sa seule route, avec un message qui nomme sa variable. 401 passe avant 403 : un appelant sans token ne sonde pas l'allowlist. —CLAUDE.md« Routage et auth » - Chaque rejet 401/403 est journalisé avec des valeurs filtrées en ASCII imprimable (
safeLogValue) ; jamais un en-têteAuthorizationni un corps dans le journal. —CLAUDE.md« Routage et auth » - Un nouveau token se déclare dans Coolify en
is_runtime=true, is_buildtime=false, jumeau preview compris : en buildtime, il sort en clair dansapplication_deployment_queues.logs. La PR le rappelle dans ses étapes de déploiement. —CLAUDE.md« Routage et auth »,coolify-ops.md« Secrets en buildtime », incident #4
Surfaces d'écriture et montages
- L'API n'écrit que dans
HOSTS_DIR, le dossier deCLAUDE_USAGE_PATH(pourPOST /claude-usage, archivehistory/comprise) et celui deBACKUPS_PULL_PATH(pourPOST /backups/pull). Les montages Défenseurs et celui du statut des sauvegardes sont en:ro: une écriture ajoutée ailleurs passe les tests (fixtures inscriptibles, CI en root) et tombe en production en 500EROFS. Un nouveau chemin qui écrit entre comme cas dans__tests__/writes.test.js. —CLAUDE.md« Gotchas », PR #39 (REQUEST_CHANGES : la garantie dépassait la couverture), #44 - Écriture atomique : temporaire dans le même dossier, mode 600,
renameSync. Ne jamais « simplifier » enwriteFileSyncdirect. Changer le nom d'un temporaire impose de changerTEMP_FILE_RE; le balayage ne touche que des fichiers réguliers (lstat) de plus d'une heure. —CLAUDE.md« Gotchas », PR #37 et #43 - Une archive mensuelle illisible ou hors format n'est jamais écrasée : elle garde des jours sortis de la fenêtre. —
CLAUDE.md(POST /claude-usage, archive #55) - Le plafond de corps compte les octets reçus, jamais le
Content-Length, et le 413 est rendu avantreq.destroy(). —CLAUDE.md« Gotchas » - Un nouveau montage se pose dans l'UI de Coolify par Max, sur le dossier et jamais sur le fichier (le fichier est remplacé par
rename), avec le bon propriétaire (uid 1000 pour un montage en écriture). La PR le dit dans ses étapes de déploiement, puisque le merge déploie : un montage absent au moment du merge donne 500 sur chaque ingestion, ou 200no_datasur une lecture. —CLAUDE.md« Config » (montages),coolify-ops.md(relevé du 2026-10-16 :backups-pullcréé enroot:root)
Contrats servis au dashboard
- Tout objet persisté ou servi est reconstruit champ par champ depuis une liste blanche, et revalidé à la lecture : le fichier sur disque n'est pas plus digne de confiance que le corps reçu. Un champ inconnu,
__proto__compris, est jeté. —CLAUDE.md« Gotchas »,claude-usage.js,backups.js - Une valeur invalide est jetée, jamais forcée (sauf
POST /backups/pull, validé strictement : 400). —CLAUDE.md(POST /claude-usage,POST /backups/pull) - Aucun texte libre venu d'un amont n'atteint le dashboard :
limits.detailsuit^[a-z0-9_]{1,32}$, les codes d'erreur des sauvegardes suivent deux listes fermées (une pour les bases, une pour les fichiers,encrypt_failedetinvalid_namecommuns). Un code ajouté se justifie et se teste des deux côtés. —CLAUDE.md(GET /backups), PR #62 - La fraîcheur (
stale,online,ageSeconds) est décidée par le serveur seul, surreceivedAtougeneratedAt, jamais sur une date fournie par le client (pulledAt). Le dashboard et/warmuplisentstaletel quel. —CLAUDE.md(GET /hosts,GET /backups) - Un contrat partagé avec
la-compagnie-maximus(schemaVersion: 1) ne change pas sans la PR correspondante côté dashboard. —CLAUDE.md(POST /claude-usage,spec-plan-suivi-claude.md)
Image et démarrage
- Tout nouveau module runtime entre dans le
COPYduDockerfile; tous lesrequirerestent au niveau module, en colonne 0, sansimport(): le boot check n'appelle que/health. —CLAUDE.md« Gotchas », épingle de__tests__/ci.test.js, issue #11 - Runtime sans dépendance, HTTP natif : pas d'Express. —
CLAUDE.md agent/n'entre jamais dans l'image. —CLAUDE.md« Gotchas », épingle deci.test.jsgetHealth()gardePromise.allsur la mesure CPU et le check Logto. —CLAUDE.md« Gotchas », test de latence de__tests__/health.test.js
Agents du poste
- Aucun secret dans une sortie : tout ce qu'impriment les agents part dans syslog et journald (
logger -t …). Le chemin d'erreur ne rend queerr.codeet le code HTTP, jamais un objet d'erreur, des options de requête, un en-tête ou un corps de réponse ; jamais deset -xdans unrun-*.sh. Les tests exigent l'absence de tout fragment de 5 caractères des tokens. —CLAUDE.md« Gotchas »,agent/README.md« No secret in the logs » - L'agent claude-usage ne rafraîchit jamais le jeton OAuth et n'écrit jamais
~/.claude/.credentials.json(refresh token à usage unique : Claude Code serait déconnecté). L'URL Anthropic reste une constante delimits.js, sans variable d'environnement, avecredirect: "error", pour que le bearer OAuth ne suive jamais l'URL de push configurable. —CLAUDE.md« Gotchas »,agent/README.md, PR #53 - Un passage qui a rencontré une erreur de lecture ne fige aucun jour ; les jours figés sont écartés après la déduplication. —
CLAUDE.md« Gotchas », PR #51 (REQUEST_CHANGES : seuil de scan qui rendait vides des jours ouverts), #56 - Rien de ce que livre le VPS ne détruit ni n'altère la copie hors site : la rotation s'ancre sur la sauvegarde valide la plus récente qui ne dépasse pas aujourd'hui, et garde ce dossier et les 29 jours qui le précèdent (ni sur aujourd'hui seul, qui ferait vieillir la dernière copie quand le VPS cesse de produire, ni sur le nom le plus récent seul, qu'un dossier daté dans le futur pousserait au-delà de toutes les vraies sauvegardes) ; un fichier déjà rapatrié n'est jamais réécrit (
--ignore-existing, jamais--delete) ; un nom daté impossible ou futur est journalisé et ignoré. —agent/README.md(« Rotation is anchored… », « No overwrite »), PR #59 (trois passes REQUEST_CHANGES : rotation qui vidait la copie, VPS qui pouvait l'effacer par un dossier futur ou l'écraser parrsync) - Le token d'ingestion passe à
curlpar un fichier d'en-tête en 0600, jamais en argv ; fichiers d'environnement en 600. —agent/README.md - Le cron pointe vers une copie figée, jamais vers le clone. —
agent/README.md, mémoire « ThinkPad = cette machine »
CI et gardes
- Aucun filtre
branches:souspull_request(chaque maillon d'une pile a sa CI) ;pushlimité àmain. Nidefaults:niworking-directorysur une étapeuses:(le runner rejette le job) ; pas deconcurrency:. — en-tête de.forgejo/workflows/ci.yml,ci.test.js - Aucun
continue-on-error, sous aucune graphie littérale. —ci.test.js, PR #42 (REQUEST_CHANGES : exemple de clé échappée décodé en route) - L'image du
container:reste celle duFROMduDockerfile. —ci.test.js boot-from-copy.shettest-curl.shrefusent un port déjà pris et ignorent les proxys de l'environnement (NO_PROXYetno_proxy). —CLAUDE.md« Tests », PR #45 (REQUEST_CHANGES :wait_forn'écartait pas un voisin), #47- Le workflow n'utilise aucun secret du dépôt. Un
secrets.*ou unpull_request_targetajouté se lit comme du code exécuté sur l'hôte de production. —.forgejo/workflows/ci.yml,forgejo-ops.md(runnerlinux-builder, portée instance)
Règles de revue
- Un changement qui rend faux un fait cité ici (routes, tokens, montages, commandes de la CI, chemins du tableau Haut, mode de déploiement) met ce fichier à jour dans la même PR. —
CLAUDE.md
Commandes de vérification
Le workflow fait foi ; ce tableau le résume au 2026-10-19.
| Workflow | Déclencheur | Commandes |
|---|---|---|
ci.yml |
toute PR ; push sur main (en même temps que le déploiement) |
dans node:22-alpine : apk add git bash curl, npm ci, npm test, bash test-curl.sh, sh scripts/boot-from-copy.sh |
Le verdict se lit sur le statut de commit (GET /repos/maximus/vps-health-api/commits/<sha>/status, contextes CI / verification (pull_request) et (push)) : l'API Actions répond 404 sur cette instance. — CLAUDE.md « Tests »
En local, depuis la racine du worktree, sans variable d'environnement à poser (chaque test fixe ses tokens et ses dossiers) :
npm ci && npm test
bash test-curl.sh # ports 3099 et 3098 ; TEST_CURL_PORT et TEST_CURL_NOINGEST_PORT pour en changer
sh scripts/boot-from-copy.sh # port 3097 ; BOOT_PORT pour en changer
python3 scripts/review-levels.py --check
- Deux revues en parallèle prennent chacune leurs propres ports pour
test-curl.shetboot-from-copy.sh: les deux scripts refusent un port pris, mais un refus n'est pas un vert. - La CI tourne en root, l'image en uid 1000 : un test qui s'appuie sur des droits de fichiers (
chmod 0500) ne prouve rien en CI. Le rejouer en utilisateur normal, ou injecter l'échec autrement. — PR #59 - Le conteneur de CI est busybox :
date -drelatif etfind -printfy échouent. Un script de l'agent testé en CI doit rester portable. — PR #59 - Une mutation se rejoue sur une copie hors dépôt de la tête, suite complète relancée à chaque fois, comme dans les revues de #39, #42 et #59.
Pièges de revue
- Le merge déploie, la CI de
mainarrive après. Une PR empilée se juge sur son propre statut de commit ; le tip cumulé de la pile ne passe en CI qu'au push surmain, donc déjà en production. Le rejouer en local avant le merge. —coolify-ops.md,ci.yml - Une PR qui exige une action de Max sur le VPS (montage, variable Coolify, propriétaire d'un dossier,
agents-map.json) doit être mergée après l'action, pas avant : sinon la route répond 500 ouno_datadès le déploiement. La revue vérifie que la PR dit l'ordre. —CLAUDE.md« Config », « Gotchas » - Un montage manquant répond 200
no_data. Après un redéploiement, contrôler le contenu des routes de lecture, pas leur code, et les trois vues des montages (docker inspect,/proc/mounts, contenu). —CLAUDE.md« Config » - La garantie écrite dépasse la couverture.
CLAUDE.mdet les en-têtes de tests affirment beaucoup (« figé par », « exige ») ; chaque affirmation nouvelle se vérifie par une mutation qui fait rougir le test cité. — PR #39, #45 - Séquences d'échappement décodées en route. Un
-tapé dans un outil arrive décodé dans le fichier : vérifier dans le fichier (grep,od -c), jamais dans l'outil d'édition. — PR #42, mémoire « Escapes decoded in tool calls » - Stack intégrée en avance rapide. Forgejo marque ces PR « fermées » et non « mergées »,
/pulls/{n}/filesrépond 500 une fois la branche de base supprimée, et une PR dont la tête est déjà dansmaina un.diffvide. — calibration de #63 /home/defenseur/defenseurs/status.jsonest un leurre : le Sergent écrit sous/data/defenseurs/. —CLAUDE.md« Config », « Gotchas »- La sévérité est asymétrique :
?severity=LOWcache INFO, que seul?severity=INFOrend. Ce n'est pas un défaut. —CLAUDE.md« Gotchas »