feat(backups): GET /backups and POST /backups/pull #60

Merged
maximus merged 2 commits from issue-58-backups-routes into main 2026-10-12 23:15:44 +00:00
12 changed files with 1302 additions and 35 deletions
Showing only changes of commit 1b619249e2 - Show all commits

View file

@ -15,13 +15,15 @@ API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime.
- `POST /claude-usage` — **seconde surface d'ecriture** (issue #48) : snapshot de consommation Claude pousse par l'agent du ThinkPad, contrat `schemaVersion: 1` partage avec le dashboard (`la-compagnie-maximus`, `spec-plan-suivi-claude.md`). Auth par `CLAUDE_USAGE_INGEST_TOKEN`, son propre token : ni `HOSTS_INGEST_TOKEN` ni `HEALTH_TOKEN` n'y passent, et lui n'ouvre ni `POST /hosts/<id>` ni aucune lecture. Le corps passe par `sanitizeClaudeUsage()`, module pur `claude-usage.js` (l'agent teste son snapshot contre le vrai sanitizer) : reconstruction par liste blanche, champ inconnu jete, **valeur invalide jetee (cle omise), jamais forcee** — nombres `Number.isSafeInteger` >= 0, `percent` 0-100, `limits.status` dans l'enum `ok|token_expired|http_error|unavailable|disabled`, `limits.detail` `^[a-z0-9_]{1,32}$` (code, jamais texte libre : un corps de reponse amont ne doit pas voyager jusqu'au dashboard), dates ISO completes avec decalage, `date` de jour `^\d{4}-\d{2}-\d{2}$` et jour reel, cles de `models` `^[a-z0-9][a-z0-9.\-]{0,63}$`, autres chaines tronquees a 128. Listes bornees : `days` <= 31 (au-dela, les 31 plus recents, le contrat les ordonnant du plus ancien a aujourd'hui), `models` <= 12 par jour, `limits.items` <= 12, `projects` <= 9 (au-dela, les premiers). Seuls deux cas refusent le corps entier : pas un objet JSON, ou `schemaVersion` different de 1. Codes : 204 / 400 JSON illisible, pas un objet ou `schemaVersion` != 1 / 401 / 413 corps > 64 Kio (65 536 octets recus) / 503 `CLAUDE_USAGE_INGEST_TOKEN` non configure. `receivedAt` pose par le serveur, ecriture atomique (temporaire `.<nom>.<pid>.<ms>.tmp` dans le meme dossier, mode 600, `renameSync`), patron de `writeHostSnapshot`. **Archive** (#55) : une fois le snapshot ecrit, tous ses jours **sauf la date la plus recente** (aujourd'hui, encore ouvert, quel que soit l'ordre d'envoi) et sauf toute date posterieure au jour UTC de reception (horloge du poste dereglee : elle ouvrirait un fichier mensuel futur) sont fusionnes dans `<dossier de CLAUDE_USAGE_PATH>/history/YYYY-MM.json` (`{ schemaVersion: 1, month, days: [{ date, models }] }`, jours tries), module `claude-usage-history.js`. Regle : **le dernier snapshot gagne**, un jour sorti de la fenetre garde la valeur que l'agent a figee a J+2 ; conservation illimitee. Un ou deux fichiers mensuels par POST, chacun en ecriture atomique (temporaire `.YYYY-MM.json.<pid>.<ms>.tmp`, mode 600, `renameSync`). Un echec d'archivage ne change pas le 204 : il est journalise (`[claude-usage] history <mois> ...`, mois et code d'erreur seulement, jamais le contenu). Un fichier mensuel illisible ou hors format n'est **jamais ecrase** (il garderait des jours sortis de la fenetre) : ce mois est saute et journalise, les autres s'ecrivent. Un snapshot d'un seul jour n'archive rien et ne cree pas le dossier. - `POST /claude-usage` — **seconde surface d'ecriture** (issue #48) : snapshot de consommation Claude pousse par l'agent du ThinkPad, contrat `schemaVersion: 1` partage avec le dashboard (`la-compagnie-maximus`, `spec-plan-suivi-claude.md`). Auth par `CLAUDE_USAGE_INGEST_TOKEN`, son propre token : ni `HOSTS_INGEST_TOKEN` ni `HEALTH_TOKEN` n'y passent, et lui n'ouvre ni `POST /hosts/<id>` ni aucune lecture. Le corps passe par `sanitizeClaudeUsage()`, module pur `claude-usage.js` (l'agent teste son snapshot contre le vrai sanitizer) : reconstruction par liste blanche, champ inconnu jete, **valeur invalide jetee (cle omise), jamais forcee** — nombres `Number.isSafeInteger` >= 0, `percent` 0-100, `limits.status` dans l'enum `ok|token_expired|http_error|unavailable|disabled`, `limits.detail` `^[a-z0-9_]{1,32}$` (code, jamais texte libre : un corps de reponse amont ne doit pas voyager jusqu'au dashboard), dates ISO completes avec decalage, `date` de jour `^\d{4}-\d{2}-\d{2}$` et jour reel, cles de `models` `^[a-z0-9][a-z0-9.\-]{0,63}$`, autres chaines tronquees a 128. Listes bornees : `days` <= 31 (au-dela, les 31 plus recents, le contrat les ordonnant du plus ancien a aujourd'hui), `models` <= 12 par jour, `limits.items` <= 12, `projects` <= 9 (au-dela, les premiers). Seuls deux cas refusent le corps entier : pas un objet JSON, ou `schemaVersion` different de 1. Codes : 204 / 400 JSON illisible, pas un objet ou `schemaVersion` != 1 / 401 / 413 corps > 64 Kio (65 536 octets recus) / 503 `CLAUDE_USAGE_INGEST_TOKEN` non configure. `receivedAt` pose par le serveur, ecriture atomique (temporaire `.<nom>.<pid>.<ms>.tmp` dans le meme dossier, mode 600, `renameSync`), patron de `writeHostSnapshot`. **Archive** (#55) : une fois le snapshot ecrit, tous ses jours **sauf la date la plus recente** (aujourd'hui, encore ouvert, quel que soit l'ordre d'envoi) et sauf toute date posterieure au jour UTC de reception (horloge du poste dereglee : elle ouvrirait un fichier mensuel futur) sont fusionnes dans `<dossier de CLAUDE_USAGE_PATH>/history/YYYY-MM.json` (`{ schemaVersion: 1, month, days: [{ date, models }] }`, jours tries), module `claude-usage-history.js`. Regle : **le dernier snapshot gagne**, un jour sorti de la fenetre garde la valeur que l'agent a figee a J+2 ; conservation illimitee. Un ou deux fichiers mensuels par POST, chacun en ecriture atomique (temporaire `.YYYY-MM.json.<pid>.<ms>.tmp`, mode 600, `renameSync`). Un echec d'archivage ne change pas le 204 : il est journalise (`[claude-usage] history <mois> ...`, mois et code d'erreur seulement, jamais le contenu). Un fichier mensuel illisible ou hors format n'est **jamais ecrase** (il garderait des jours sortis de la fenetre) : ce mois est saute et journalise, les autres s'ecrivent. Un snapshot d'un seul jour n'archive rien et ne cree pas le dossier.
- `GET /claude-usage` — `{ staleAfterSeconds, receivedAt, ageSeconds, stale, snapshot }`, ou 200 `{ status: "no_data" }` si le fichier est absent ou illisible (corrompu, hors contrat, sans `receivedAt` en date ISO complete avec decalage, meme regle que `GET /hosts` depuis #54). Le fichier est **re-sanitise** a la lecture (`sanitizeClaudeUsage` est idempotent). `stale = ageSeconds > CLAUDE_USAGE_STALE_SECONDS`, decide par le serveur seul : le dashboard ne le recalcule pas. Consommateur : dashboard admin Vercel. - `GET /claude-usage` — `{ staleAfterSeconds, receivedAt, ageSeconds, stale, snapshot }`, ou 200 `{ status: "no_data" }` si le fichier est absent ou illisible (corrompu, hors contrat, sans `receivedAt` en date ISO complete avec decalage, meme regle que `GET /hosts` depuis #54). Le fichier est **re-sanitise** a la lecture (`sanitizeClaudeUsage` est idempotent). `stale = ageSeconds > CLAUDE_USAGE_STALE_SECONDS`, decide par le serveur seul : le dashboard ne le recalcule pas. Consommateur : dashboard admin Vercel.
- `GET /claude-usage/history?from=YYYY-MM&to=YYYY-MM` — jours archives (#55), token de lecture. `{ from, to, days }`, jours tries du plus ancien au plus recent. Les deux parametres sont optionnels : `to` vaut par defaut le mois du dernier fichier archive (le mois courant UTC si rien n'est archive ou si ce fichier est date apres le mois courant, ou `from` s'il est plus tard), `from` vaut `to` moins 11 mois (12 mois, bornes comprises). 400 si un format est invalide (mois `01`-`12`, chaine vide comprise), si `from > to`, ou au-dela de 36 mois. Un mois absent est vide ; un mois illisible est omis et journalise ; chaque fichier est **re-sanitise** a la lecture par le nettoyeur de jour du snapshot (`sanitizeDay`, exporte par `claude-usage.js`), et un jour date hors de son mois est jete. Rattrapage ponctuel des jours deja sortis de la fenetre : `agent/claude-usage/backfill-history.js` sur le poste, puis `docker exec -i <conteneur> node claude-usage-history.js --import - < fichier`, ou **une date deja archivee gagne** (procedure dans `agent/README.md`). - `GET /claude-usage/history?from=YYYY-MM&to=YYYY-MM` — jours archives (#55), token de lecture. `{ from, to, days }`, jours tries du plus ancien au plus recent. Les deux parametres sont optionnels : `to` vaut par defaut le mois du dernier fichier archive (le mois courant UTC si rien n'est archive ou si ce fichier est date apres le mois courant, ou `from` s'il est plus tard), `from` vaut `to` moins 11 mois (12 mois, bornes comprises). 400 si un format est invalide (mois `01`-`12`, chaine vide comprise), si `from > to`, ou au-dela de 36 mois. Un mois absent est vide ; un mois illisible est omis et journalise ; chaque fichier est **re-sanitise** a la lecture par le nettoyeur de jour du snapshot (`sanitizeDay`, exporte par `claude-usage.js`), et un jour date hors de son mois est jete. Rattrapage ponctuel des jours deja sortis de la fenetre : `agent/claude-usage/backfill-history.js` sur le poste, puis `docker exec -i <conteneur> node claude-usage-history.js --import - < fichier`, ou **une date deja archivee gagne** (procedure dans `agent/README.md`).
- `POST /backups/pull` — **troisieme surface d'ecriture** (issue #58) : compte rendu du tirage quotidien des sauvegardes PostgreSQL chiffrees par l'agent du ThinkPad (`agent/pg-backup-pull/`, la-compagnie-maximus#220). Auth par `BACKUPS_INGEST_TOKEN`, son propre token (aucun des trois autres n'y passe, lui n'ouvre rien d'autre). Corps `{ pulledAt, files, ok, error? }` valide **strictement** par `validateBackupsPull()` (module pur `backups.js`) : `pulledAt` ISO complet avec decalage, `files` entier sur >= 0, `ok` booleen, `error` exige si `ok` est faux et refuse sinon, dans `pull_failed|rotation_failed`. Tout champ invalide ou manquant -> 400 (contrairement a `/claude-usage`, qui jette la valeur : le corps n'a que quatre champs, un agent casse doit l'apprendre) ; un champ inconnu, `receivedAt` du client compris, est jete. Codes : 204 / 400 / 401 / 413 corps > 1 Kio (1 024 octets recus ; l'issue disait 400, garde 413 comme les deux autres ingestions, l'agent n'examine qu'un 2xx) / 503 `BACKUPS_INGEST_TOKEN not configured`. `receivedAt` pose par le serveur, ecriture atomique par `writeJsonAtomic()` (temporaire `.pull.json.<pid>.<ms>.tmp`, mode 600, `renameSync`), le meme helper que `POST /claude-usage`.
- `GET /backups` — `{ vps, lenovo, vpsError?, lenovoError? }`, token de lecture (#58). `vps` = `status.json` ecrit par `backup-postgres.sh` sur le VPS (contrat `schemaVersion: 1` : `generatedAt`, `ok`, `expectedCount`, `databases[]`, `globals`, `mirror`, `rotation`, `retentionDays`), reconstruit par `sanitizeBackupsStatus()` en liste blanche : champ inconnu jete, valeur invalide jetee (jamais forcee), `null` garde sur `dumpedAt` et `uploadedAt`, `error` hors de la liste fermee (`dump_failed`, `dump_empty`, `restore_list_failed`, `invalid_name`, `encrypt_failed`, `mirror_upload_failed`, `mirror_purge_failed`, `rotation_failed`) jete en gardant `ok: false` (un stderr de `pg_dump`/`rclone` ne doit jamais atteindre le dashboard), nom de base hors `^[a-z0-9_]{1,63}$` jete (une entree `invalid_name` peut porter n'importe quel texte), `databases` borne a 64. `lenovo` = dernier `POST /backups/pull`, revalide a la lecture. Chaque bloc recoit du **serveur** `ageSeconds`, `stale` (`ageSeconds > BACKUPS_STALE_SECONDS`) et `staleAfterSeconds` : age du VPS sur `generatedAt`, du poste sur `receivedAt`, **jamais** sur `pulledAt`. La carte `/admin` et `/warmup` lisent `stale` tel quel. Fichier present mais inutilisable -> bloc `null` + `vpsError`/`lenovoError` (`unreadable`, `invalid_json`, `unsupported_schema`, `invalid_contract`), l'autre bloc servi, jamais de 500 ; `{ status: "no_data" }` seulement si les deux fichiers sont absents. Le journal ne porte que des codes, ni contenu ni chemin.
## Routage et auth ## Routage et auth
Ordre **non negociable**, fige par deux tests `404 (not 401)` dans `__tests__/auth.test.js` : Ordre **non negociable**, fige par deux tests `404 (not 401)` dans `__tests__/auth.test.js` :
1. La requete est resolue en descripteur `{ method, path|pattern, tokenKind, handle }` via la table `ROUTES` d'`index.js`. Aucun match -> **404 immediat** (refus par defaut), avant toute verification de token. 1. La requete est resolue en descripteur `{ method, path|pattern, tokenKind, handle }` via la table `ROUTES` d'`index.js`. Aucun match -> **404 immediat** (refus par defaut), avant toute verification de token.
2. `checkAuth()` — **un seul point de controle**, dont seul le token attendu varie, lu dans la table `TOKEN_KINDS` (issue #48) : `"read"` -> `HEALTH_TOKEN`, `"ingest"` -> `HOSTS_INGEST_TOKEN`, `"claude-ingest"` -> `CLAUDE_USAGE_INGEST_TOKEN`. Chaque entree porte aussi sa reponse quand le token manque. Un descripteur de `ROUTES` qui nomme un `tokenKind` absent de la table fait echouer le chargement du module (refus de demarrer plutot qu'un crash a la premiere requete). Ne jamais disperser le controle dans les branches : c'est ainsi qu'une route finit non protegee. Un nouveau token = une entree de la table, jamais un `if` dans `checkAuth()` (l'epingle de `auth.test.js` n'y admet plus que `typeof header === "string"` comme egalite). 2. `checkAuth()` — **un seul point de controle**, dont seul le token attendu varie, lu dans la table `TOKEN_KINDS` (issue #48) : `"read"` -> `HEALTH_TOKEN`, `"ingest"` -> `HOSTS_INGEST_TOKEN`, `"claude-ingest"` -> `CLAUDE_USAGE_INGEST_TOKEN`, `"backups-ingest"` -> `BACKUPS_INGEST_TOKEN` (#58). Chaque entree porte aussi sa reponse quand le token manque. Un descripteur de `ROUTES` qui nomme un `tokenKind` absent de la table fait echouer le chargement du module (refus de demarrer plutot qu'un crash a la premiere requete). Ne jamais disperser le controle dans les branches : c'est ainsi qu'une route finit non protegee. Un nouveau token = une entree de la table, jamais un `if` dans `checkAuth()` (l'epingle de `auth.test.js` n'y admet plus que `typeof header === "string"` comme egalite).
3. Dispatch vers le handler. 3. Dispatch vers le handler.
Consequence voulue : un appelant non authentifie sur une route inconnue voit 404, pas 401. Ne pas « corriger » cet ordre en mettant l'auth d'abord. Consequence voulue : un appelant non authentifie sur une route inconnue voit 404, pas 401. Ne pas « corriger » cet ordre en mettant l'auth d'abord.
@ -47,13 +49,19 @@ Consequence voulue : un appelant non authentifie sur une route inconnue voit 404
- `CLAUDE_USAGE_INGEST_TOKEN` : token de `POST /claude-usage`, distinct des deux autres (pas de default — absent = 503 nommant la variable). Meme regle Coolify que les autres tokens : `is_runtime=true, is_buildtime=false` - `CLAUDE_USAGE_INGEST_TOKEN` : token de `POST /claude-usage`, distinct des deux autres (pas de default — absent = 503 nommant la variable). Meme regle Coolify que les autres tokens : `is_runtime=true, is_buildtime=false`
- `CLAUDE_USAGE_PATH` : fichier **ecrit** par `POST /claude-usage` et lu par `GET /claude-usage` (default `/data/claude-usage/claude-usage.json`) - `CLAUDE_USAGE_PATH` : fichier **ecrit** par `POST /claude-usage` et lu par `GET /claude-usage` (default `/data/claude-usage/claude-usage.json`)
- `CLAUDE_USAGE_STALE_SECONDS` : age au-dela duquel `GET /claude-usage` repond `stale: true` (default 2700, soit 45 min ; une valeur non numerique ou <= 0 retombe sur le default) - `CLAUDE_USAGE_STALE_SECONDS` : age au-dela duquel `GET /claude-usage` repond `stale: true` (default 2700, soit 45 min ; une valeur non numerique ou <= 0 retombe sur le default)
- `BACKUPS_STATUS_PATH` : `status.json` des sauvegardes, **lu** par `GET /backups` (default `/data/backups-status/status.json`)
- `BACKUPS_PULL_PATH` : fichier **ecrit** par `POST /backups/pull` et lu par `GET /backups` (default `/data/backups-pull/pull.json`)
- `BACKUPS_INGEST_TOKEN` : token de `POST /backups/pull`, distinct des trois autres (pas de default — absent = 503 nommant la variable). Meme regle Coolify : `is_runtime=true, is_buildtime=false`
- `BACKUPS_STALE_SECONDS` : age au-dela duquel un bloc de `GET /backups` repond `stale: true` (default 129600, soit 36 h : un passage quotidien manque est tolere, deux non ; non numerique ou <= 0 -> default)
- Montages Coolify (Persistent Storages, UI seulement — aucun endpoint API) : - Montages Coolify (Persistent Storages, UI seulement — aucun endpoint API) :
- `/data/defenseurs` (host) -> `/data/defenseurs:ro` : `status.json` + `findings-state.json` + `agents-map.json`, ecrits directement la par le Sergent. `/home/defenseur/defenseurs/status.json` est un leurre obsolete lu par personne. **Monte en lecture seule** depuis le 2026-09-30 (issue #36). - `/data/defenseurs` (host) -> `/data/defenseurs:ro` : `status.json` + `findings-state.json` + `agents-map.json`, ecrits directement la par le Sergent. `/home/defenseur/defenseurs/status.json` est un leurre obsolete lu par personne. **Monte en lecture seule** depuis le 2026-09-30 (issue #36).
- `/home/defenseur/defenseurs/reports` (host) -> `/data/defenseurs/reports:ro` : rapports de scan (+ sous-dir `archive/`), montage imbrique dans le precedent. Pose le 2026-07-15 (issue #10), recree le 2026-09-30 en Volume Mount avec Source Path pour recevoir le `:ro` (#36). **Monte en lecture seule**. - `/home/defenseur/defenseurs/reports` (host) -> `/data/defenseurs/reports:ro` : rapports de scan (+ sous-dir `archive/`), montage imbrique dans le precedent. Pose le 2026-07-15 (issue #10), recree le 2026-09-30 en Volume Mount avec Source Path pour recevoir le `:ro` (#36). **Monte en lecture seule**.
- `/data/hosts` (host) -> `/data/hosts` : snapshots des postes. **Lecture-ecriture** — doit appartenir a l'uid 1000 (`node`, l'utilisateur du conteneur), sinon chaque ingestion repond 500. - `/data/hosts` (host) -> `/data/hosts` : snapshots des postes. **Lecture-ecriture** — doit appartenir a l'uid 1000 (`node`, l'utilisateur du conteneur), sinon chaque ingestion repond 500.
- `/data/claude-usage` (host) -> `/data/claude-usage` : snapshot de consommation Claude (issue #48). **Lecture-ecriture**, proprietaire uid 1000, meme regle que `/data/hosts` (sinon chaque `POST /claude-usage` repond 500). Monter le **dossier**, jamais le fichier seul : le snapshot est remplace par `rename`, un bind de fichier resterait accroche a l'ancien inode. A poser par Max avant le premier push de l'agent. Le sous-dossier `history/` (#55) vit sur ce meme montage : rien a poser, le serveur le cree au premier POST de plus d'un jour. - `/data/claude-usage` (host) -> `/data/claude-usage` : snapshot de consommation Claude (issue #48). **Lecture-ecriture**, proprietaire uid 1000, meme regle que `/data/hosts` (sinon chaque `POST /claude-usage` repond 500). Monter le **dossier**, jamais le fichier seul : le snapshot est remplace par `rename`, un bind de fichier resterait accroche a l'ancien inode. A poser par Max avant le premier push de l'agent. Le sous-dossier `history/` (#55) vit sur ce meme montage : rien a poser, le serveur le cree au premier POST de plus d'un jour.
- `/var/backups/postgres-status` (host) -> `/data/backups-status:ro` : `status.json` ecrit par `backup-postgres.sh` (#58), root, dossier 755, fichier 644, lisible par l'uid 1000. **Lecture seule**. Monter le **dossier** : le script remplace le fichier par `rename`. Sans ce montage, `GET /backups` repond avec `vps: null` (pas d'erreur). A poser par Max avant le premier deploy.
- `/data/backups-pull` (host) -> `/data/backups-pull` : compte rendu du tirage (#58). **Lecture-ecriture**, proprietaire uid 1000, le dossier et non le fichier, meme regle que `/data/claude-usage` (sinon chaque `POST /backups/pull` repond 500, `GET /backups` continue de servir le bloc VPS). A poser par Max avant le premier deploy.
- Deux couches tiennent l'API hors des fichiers Defenseurs. (1) Le mode du montage : `:ro` est un suffixe de la destination dans Coolify, recopie tel quel dans le compose genere (`'/data/defenseurs:/data/defenseurs:ro'`). (2) Les droits Unix : le contenu appartient a `defenseur:defenseur` (uid et gid 1001), dossiers en 755/775, fichiers en 664, et le conteneur tourne en uid 1000 (`node`, gid 1000, sans groupe supplementaire), donc `test -w` y echoue meme sans `:ro`. Ce sont les bits de groupe qui donnent l'ecriture : cette couche tient parce que le groupe proprietaire n'est pas le gid 1000 (groupe de `node` dans l'image, et de `ubuntu` sur l'hote). Un `chgrp ubuntu` ou un `chmod o+w` la ferait tomber sans bruit. Le `:ro` ne vaut que pour la vue du conteneur : le Sergent ecrit cote hote comme avant. - Deux couches tiennent l'API hors des fichiers Defenseurs. (1) Le mode du montage : `:ro` est un suffixe de la destination dans Coolify, recopie tel quel dans le compose genere (`'/data/defenseurs:/data/defenseurs:ro'`). (2) Les droits Unix : le contenu appartient a `defenseur:defenseur` (uid et gid 1001), dossiers en 755/775, fichiers en 664, et le conteneur tourne en uid 1000 (`node`, gid 1000, sans groupe supplementaire), donc `test -w` y echoue meme sans `:ro`. Ce sont les bits de groupe qui donnent l'ecriture : cette couche tient parce que le groupe proprietaire n'est pas le gid 1000 (groupe de `node` dans l'image, et de `ubuntu` sur l'hote). Un `chgrp ubuntu` ou un `chmod o+w` la ferait tomber sans bruit. Le `:ro` ne vaut que pour la vue du conteneur : le Sergent ecrit cote hote comme avant.
- Verification apres tout redeploy, par trois vues : `docker inspect <conteneur> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} RW={{.RW}}{{println}}{{end}}'` (`RW=false` sur les deux montages Defenseurs, `RW=true` sur `/data/hosts` et `/data/claude-usage`) ; `/proc/mounts` dans le conteneur (vue du noyau) ; et le **contenu** des routes de lecture, pas seulement leur code, car un montage manquant repond 200 `no_data`. - Verification apres tout redeploy, par trois vues : `docker inspect <conteneur> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} RW={{.RW}}{{println}}{{end}}'` (`RW=false` sur les deux montages Defenseurs et sur `/data/backups-status`, `RW=true` sur `/data/hosts`, `/data/claude-usage` et `/data/backups-pull`) ; `/proc/mounts` dans le conteneur (vue du noyau) ; et le **contenu** des routes de lecture, pas seulement leur code, car un montage manquant repond 200 `no_data`.
## Deploy ## Deploy
@ -69,31 +77,32 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
## Tests ## Tests
- **CI** : `.forgejo/workflows/ci.yml` (issue #24), sur toute PR (pas de filtre `branches:`, pour que les maillons d'une pile soient batis) et sur `push` vers `main`. Un seul job en `container: node:22-alpine`, la base du Dockerfile : `npm ci`, `npm test`, `bash test-curl.sh`, puis `sh scripts/boot-from-copy.sh`. Verdict a lire sur le statut de commit (`GET /repos/maximus/vps-health-api/commits/<sha>/status`), l'API Actions repondant 404 sur cette instance. - **CI** : `.forgejo/workflows/ci.yml` (issue #24), sur toute PR (pas de filtre `branches:`, pour que les maillons d'une pile soient batis) et sur `push` vers `main`. Un seul job en `container: node:22-alpine`, la base du Dockerfile : `npm ci`, `npm test`, `bash test-curl.sh`, puis `sh scripts/boot-from-copy.sh`. Verdict a lire sur le statut de commit (`GET /repos/maximus/vps-health-api/commits/<sha>/status`), l'API Actions repondant 404 sur cette instance.
- `npm test` (vitest) — 731 cas - `npm test` (vitest) — 865 cas
- `__tests__/backups.test.js` — `GET /backups`, `POST /backups/pull` et les deux validateurs de `backups.js` (101 cas, issue #58 : contrat du script VPS rendu tel quel, `rotation` et `retentionDays` compris, et idempotence ; `null` garde ; champ inconnu jete a chaque niveau, `__proto__` compris ; code d'erreur hors liste jete avec `ok: false` garde, et chacun des huit codes garde ; valeur invalide jetee, jamais forcee ; nom de base refuse jete, l'entree gardee ; borne de 64 ; refus entier (pas un objet, `schemaVersion` != 1, `generatedAt` absent ou incomplet) ; corps du poste strict (16 refus) ; 503 nommant la variable, 401 avec chacun des trois autres tokens, le token des sauvegardes n'ouvrant rien d'autre, `404 (not 401)` sur `GET /backups/pull` et `POST /backups`, espion `timingSafeEqual` ; 204 et fichier en mode 600, `receivedAt` du client ignore, 400 qui laisse l'ancien fichier en place, 204 a 1 024 octets et 413 a 1 025, temporaire supprime quand le `rename` echoue et journal sans chemin ; lecture : `no_data`, chaque bloc seul, fraicheur a la seconde pres sous horloge figee (36 h pile frais, + 1 s perime), age du poste sur `receivedAt` et non `pulledAt`, `generatedAt` futur -> 0, `BACKUPS_STALE_SECONDS` et son repli ; fichier casse de chaque cote -> bloc `null` + code, l'autre servi, et jamais masque derriere `no_data` ; dossier a la place du fichier -> `unreadable`, `EISDIR` journalise sans chemin ; `backups.js` dans le `COPY`)
- `__tests__/claude-usage.test.js` — `POST`/`GET /claude-usage` et `sanitizeClaudeUsage()` (123 cas, issue #48 : contrat garde tel quel et idempotence ; corps entier refuse seulement hors objet ou `schemaVersion` != 1 ; valeurs invalides **jetees, pas forcees** (champ inconnu a chaque niveau, `__proto__`, nombre negatif, au-dela de 2^53, fractionnaire, `percent` hors 0-100, `status` hors enum, `detail` hors regex, dates ISO, jour inexistant, cle de modele) ; bornes de liste ; cloisonnement des trois tokens dans les deux sens ; 503 nommant chacun sa variable ; espion `timingSafeEqual` ; 413 a 65 537 octets et 204 a 65 536, `/hosts/<id>` toujours 413 a 4 097 ; ecriture atomique en mode 600, et nettoyage du temporaire quand le `rename` echoue apres son ecriture (#54 : cible = dossier non vide) ; aller-retour POST puis GET ; peremption a la seconde pres sous horloge figee ; re-sanitisation et degradation en `no_data` a la lecture, `receivedAt` compris s'il n'est pas une date ISO complete avec decalage (#54 : `"1"`, date seule, heure sans decalage) ; journalisation des rejets ; `claude-usage.js` dans le `COPY`) - `__tests__/claude-usage.test.js` — `POST`/`GET /claude-usage` et `sanitizeClaudeUsage()` (123 cas, issue #48 : contrat garde tel quel et idempotence ; corps entier refuse seulement hors objet ou `schemaVersion` != 1 ; valeurs invalides **jetees, pas forcees** (champ inconnu a chaque niveau, `__proto__`, nombre negatif, au-dela de 2^53, fractionnaire, `percent` hors 0-100, `status` hors enum, `detail` hors regex, dates ISO, jour inexistant, cle de modele) ; bornes de liste ; cloisonnement des trois tokens dans les deux sens ; 503 nommant chacun sa variable ; espion `timingSafeEqual` ; 413 a 65 537 octets et 204 a 65 536, `/hosts/<id>` toujours 413 a 4 097 ; ecriture atomique en mode 600, et nettoyage du temporaire quand le `rename` echoue apres son ecriture (#54 : cible = dossier non vide) ; aller-retour POST puis GET ; peremption a la seconde pres sous horloge figee ; re-sanitisation et degradation en `no_data` a la lecture, `receivedAt` compris s'il n'est pas une date ISO complete avec decalage (#54 : `"1"`, date seule, heure sans decalage) ; journalisation des rejets ; `claude-usage.js` dans le `COPY`)
- `__tests__/claude-usage-history.test.js` — archive des jours clos et `GET /claude-usage/history` (76 cas, issue #55 : arithmetique des mois ; `closedDays` (date la plus recente et dates posterieures a la reception ecartees) ; `mergeDays` avec ses deux regles (dernier gagne, date archivee gagne), resultat trie, entrees intactes ; regroupement par le prefixe de la chaine ; relecture d'un fichier mensuel par la liste blanche (champ inconnu, `__proto__`, valeur invalide, date hors du mois, fichier hors format -> illisible) ; `parseRange` (defauts, 36 mois pile, 37 refuses, `from` seul trop ancien, formats) ; POST : tout sauf le dernier jour, mode 600, rien pour un snapshot d'un jour, second POST identique octet pour octet, jour remplace, jour sorti de la fenetre garde, deux mois, ordre d'envoi et date repetee, jour date apres la reception jamais archive, mois illisible ou hors format jamais ecrase (204, snapshot ecrit, autre mois ecrit, journal sans contenu), dossier impossible a creer, `rename` en echec sur un mois (temporaire supprime, l'autre mois ecrit), rien d'archive sur un POST refuse ou un snapshot non ecrit ; route : defauts, selection, mois illisible omis et journalise, re-sanitisation, fichier mensuel futur et nom de fichier parasite ignores comme fin par defaut, 400, token de lecture seul, 404 sur POST, aller-retour ; import lance en vrai processus : date archivee jamais remplacee, seul `history/` change, entree standard, fichier invalide (pas du JSON, sans `days`, jour invalide, date repetee) qui n'ecrit rien, mois illisible saute, fichier absent, erreurs d'usage ; `claude-usage-history.js` dans le `COPY`) - `__tests__/claude-usage-history.test.js` — archive des jours clos et `GET /claude-usage/history` (76 cas, issue #55 : arithmetique des mois ; `closedDays` (date la plus recente et dates posterieures a la reception ecartees) ; `mergeDays` avec ses deux regles (dernier gagne, date archivee gagne), resultat trie, entrees intactes ; regroupement par le prefixe de la chaine ; relecture d'un fichier mensuel par la liste blanche (champ inconnu, `__proto__`, valeur invalide, date hors du mois, fichier hors format -> illisible) ; `parseRange` (defauts, 36 mois pile, 37 refuses, `from` seul trop ancien, formats) ; POST : tout sauf le dernier jour, mode 600, rien pour un snapshot d'un jour, second POST identique octet pour octet, jour remplace, jour sorti de la fenetre garde, deux mois, ordre d'envoi et date repetee, jour date apres la reception jamais archive, mois illisible ou hors format jamais ecrase (204, snapshot ecrit, autre mois ecrit, journal sans contenu), dossier impossible a creer, `rename` en echec sur un mois (temporaire supprime, l'autre mois ecrit), rien d'archive sur un POST refuse ou un snapshot non ecrit ; route : defauts, selection, mois illisible omis et journalise, re-sanitisation, fichier mensuel futur et nom de fichier parasite ignores comme fin par defaut, 400, token de lecture seul, 404 sur POST, aller-retour ; import lance en vrai processus : date archivee jamais remplacee, seul `history/` change, entree standard, fichier invalide (pas du JSON, sans `days`, jour invalide, date repetee) qui n'ecrit rien, mois illisible saute, fichier absent, erreurs d'usage ; `claude-usage-history.js` dans le `COPY`)
- `__tests__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu) - `__tests__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
- `__tests__/state.test.js` — `/defenseurs/state` + non-regression `/defenseurs` apres extraction de `serveJsonFile()` (9 cas : corps identique octet pour octet sur un JSON non canonique, no_data, isolation des deux chemins, 401 sans token / mauvais token / token d'ingestion, 404 sur POST) - `__tests__/state.test.js` — `/defenseurs/state` + non-regression `/defenseurs` apres extraction de `serveJsonFile()` (9 cas : corps identique octet pour octet sur un JSON non canonique, no_data, isolation des deux chemins, 401 sans token / mauvais token / token d'ingestion, 404 sur POST)
- `__tests__/health.test.js` — module `metrics.js` + `/health` (6 cas : exports, payload champ pour champ, garde de latence) - `__tests__/health.test.js` — module `metrics.js` + `/health` (6 cas : exports, payload champ pour champ, garde de latence)
- `__tests__/auth.test.js` — filet de securite auth sur les routes de lecture (76 cas : matrice route x mode d'echec, en-tetes malformes (dont le separateur `Bearer` autre qu'un espace), quasi-rates du token sur les 7 routes de lecture (`/claude-usage/history` comprise, #55), fail-closed, **3 cas `404 (not 401)` qui figent l'ordre routage -> auth**, **un espion sur `timingSafeEqual`** qui exige deux empreintes de 32 octets a chaque requete, et **des epingles sur le source** : aucune egalite contre une chaine `Bearer`, un seul `tokenMatches()` et un seul `return null` dans `checkAuth()`, egalites limitees a une liste blanche, `tokenMatches()` qui hache les deux cotes sans test de longueur) - `__tests__/auth.test.js` — filet de securite auth sur les routes de lecture (84 cas, `GET /backups` entre dans la matrice depuis #58 : matrice route x mode d'echec, en-tetes malformes (dont le separateur `Bearer` autre qu'un espace), quasi-rates du token sur les 7 routes de lecture (`/claude-usage/history` comprise, #55), fail-closed, **3 cas `404 (not 401)` qui figent l'ordre routage -> auth**, **un espion sur `timingSafeEqual`** qui exige deux empreintes de 32 octets a chaque requete, et **des epingles sur le source** : aucune egalite contre une chaine `Bearer`, un seul `tokenMatches()` et un seul `return null` dans `checkAuth()`, egalites limitees a une liste blanche, `tokenMatches()` qui hache les deux cotes sans test de longueur)
- `__tests__/hosts.test.js` — matrice exhaustive des endpoints postes (177 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, `loadAvg` a exactement 3 elements, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture (dont un `receivedAt` hors ISO complet, #54), temporaire supprime quand le `rename` echoue (#54), **balayage des `.tmp` orphelins** au demarrage, prouve sur le vrai point d'entree, symlink ni suivi ni supprime, ancre `^` de `TEMP_FILE_RE`) - `__tests__/hosts.test.js` — matrice exhaustive des endpoints postes (177 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, `loadAvg` a exactement 3 elements, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture (dont un `receivedAt` hors ISO complet, #54), temporaire supprime quand le `rename` echoue (#54), **balayage des `.tmp` orphelins** au demarrage, prouve sur le vrai point d'entree, symlink ni suivi ni supprime, ancre `^` de `TEMP_FILE_RE`)
- `__tests__/agent.test.js` — agent poste (50 cas : contrat de payload verifie contre `sanitizeSnapshot()`, une seule tentative sans rejeu, timeout, codes de sortie, wrapper shell, et **le token absent de toute sortie d'echec, fragments de 5 caracteres compris**) - `__tests__/agent.test.js` — agent poste (50 cas : contrat de payload verifie contre `sanitizeSnapshot()`, une seule tentative sans rejeu, timeout, codes de sortie, wrapper shell, et **le token absent de toute sortie d'echec, fragments de 5 caracteres compris**)
- `__tests__/agent-claude-usage.test.js` — agent claude-usage du ThinkPad (136 cas). Agregation des transcripts, #49 (52 cas) : jours Toronto et changement d'heure, regles de projet, deduplication des requetes, 30 jours, top 8 + `(autres)`, 12 modeles, seuil de scan pris sur le plus ancien jour ouvert (jour fige futur, trou dans la serie figee, tout fige), noms `__proto__` et `constructor` sans ecriture sur `Object.prototype` ; #54 : requete a cheval sur minuit comptee une seule fois (jours figes ecartes apres la deduplication, egalite d'`output` gagnee par la ligne la plus ancienne quel que soit l'ordre de lecture), erreurs de lecture comptees (`readdir`, `ENOENT` d'un sous-dossier compris, `stat` y compris `ENOENT`, flux, ouverture), racine absente non comptee. Agent, #50 (74 cas) : reponse anonymisee de l'endpoint (`__tests__/fixtures/claude-usage/oauth-usage.json`) vers les items, repli `five_hour`/`seven_day`, tout `null` -> `unavailable` ; `fetch` (double) jamais appele sur jeton expire, `off`, credentials tronques, absents ou sans jeton, version absente ; 429 -> `http_error`/`http_429` avec les dernieres limites valides et leur `fetchedAt` ; corps hostiles -> `detail` dans l'enum ; bearer OAuth envoye a la seule URL Anthropic epinglee, aucune variable d'env ne la change (epingle sur le source), `redirect: "error"` ; **espions sur toutes les ecritures de `node:fs`** : le fichier de credentials n'est que lu ; aucun fragment de 5 caracteres des deux jetons dans le snapshot, l'etat ni la sortie (dont un vrai processus) ; deux passages autour d'un jour vide (fige, puis aucun fichier sous le seuil ouvert) ; `state.json` corrompu, elagage a 35 jours, ecriture atomique en 600, temporaire laisse en 644 ramene a 600 (#54) ; passage avec erreur de lecture : aucun jour fige, snapshot envoye, `read_errors=N` sans chemin, le passage suivant fige (#54) ; reponse de l'endpoint comptee en octets pendant la lecture, 256 Kio pile acceptes, + 1 octet en caracteres de 2 octets refuse, flux sans fin annule (#54) ; snapshot sur fixtures inchange par `sanitizeClaudeUsage` ; `http:` -> 1, > 64 Kio -> 2 sans envoi ; `run-claude-usage.sh` lance deux fois en parallele (faux `node`, HOME temporaire) : le second sort 0 sans travailler. Rattrapage ponctuel, #55 (10 cas) : fenetres passees contigues jusqu'a J-2, jours vides de tete retires, requete a cheval sur la frontiere de deux fenetres comptee une fois (meilleure ligne avant ou apres minuit), rien d'autre que `{ date, models }`, borne `--max-windows`, erreurs de lecture additionnees ; `main` n'ecrit que `--out` (mode 600, jamais ecrase), aucun `fetch`, aucun acces aux credentials, sortie en compteurs seulement, fichier accepte par le parseur d'import du serveur, erreurs d'usage - `__tests__/agent-claude-usage.test.js` — agent claude-usage du ThinkPad (136 cas). Agregation des transcripts, #49 (52 cas) : jours Toronto et changement d'heure, regles de projet, deduplication des requetes, 30 jours, top 8 + `(autres)`, 12 modeles, seuil de scan pris sur le plus ancien jour ouvert (jour fige futur, trou dans la serie figee, tout fige), noms `__proto__` et `constructor` sans ecriture sur `Object.prototype` ; #54 : requete a cheval sur minuit comptee une seule fois (jours figes ecartes apres la deduplication, egalite d'`output` gagnee par la ligne la plus ancienne quel que soit l'ordre de lecture), erreurs de lecture comptees (`readdir`, `ENOENT` d'un sous-dossier compris, `stat` y compris `ENOENT`, flux, ouverture), racine absente non comptee. Agent, #50 (74 cas) : reponse anonymisee de l'endpoint (`__tests__/fixtures/claude-usage/oauth-usage.json`) vers les items, repli `five_hour`/`seven_day`, tout `null` -> `unavailable` ; `fetch` (double) jamais appele sur jeton expire, `off`, credentials tronques, absents ou sans jeton, version absente ; 429 -> `http_error`/`http_429` avec les dernieres limites valides et leur `fetchedAt` ; corps hostiles -> `detail` dans l'enum ; bearer OAuth envoye a la seule URL Anthropic epinglee, aucune variable d'env ne la change (epingle sur le source), `redirect: "error"` ; **espions sur toutes les ecritures de `node:fs`** : le fichier de credentials n'est que lu ; aucun fragment de 5 caracteres des deux jetons dans le snapshot, l'etat ni la sortie (dont un vrai processus) ; deux passages autour d'un jour vide (fige, puis aucun fichier sous le seuil ouvert) ; `state.json` corrompu, elagage a 35 jours, ecriture atomique en 600, temporaire laisse en 644 ramene a 600 (#54) ; passage avec erreur de lecture : aucun jour fige, snapshot envoye, `read_errors=N` sans chemin, le passage suivant fige (#54) ; reponse de l'endpoint comptee en octets pendant la lecture, 256 Kio pile acceptes, + 1 octet en caracteres de 2 octets refuse, flux sans fin annule (#54) ; snapshot sur fixtures inchange par `sanitizeClaudeUsage` ; `http:` -> 1, > 64 Kio -> 2 sans envoi ; `run-claude-usage.sh` lance deux fois en parallele (faux `node`, HOME temporaire) : le second sort 0 sans travailler. Rattrapage ponctuel, #55 (10 cas) : fenetres passees contigues jusqu'a J-2, jours vides de tete retires, requete a cheval sur la frontiere de deux fenetres comptee une fois (meilleure ligne avant ou apres minuit), rien d'autre que `{ date, models }`, borne `--max-windows`, erreurs de lecture additionnees ; `main` n'ecrit que `--out` (mode 600, jamais ecrase), aucun `fetch`, aucun acces aux credentials, sortie en compteurs seulement, fichier accepte par le parseur d'import du serveur, erreurs d'usage
- `__tests__/ci.test.js` — epingles du workflow et des scripts de CI (49 cas : image du `container:` identique au `FROM` du Dockerfile, les quatre etapes presentes, aucun filtre `branches:` sous `pull_request`, `push` limite a `main`, ni `defaults:` ni `working-directory` sur une etape `uses:`, **aucun `continue-on-error`, sous toute ecriture litterale** (une etape rouge passerait au vert en silence ; detecteur sur le mot nu, sans ancrage ni deux-points exiges, lui-meme verifie sur la cle en bloc, en element de liste, en style flow, entre guillemets et via une ancre YAML ; seules les lignes de commentaire entieres en sont exemptees ; une cle ecrite avec une sequence d'echappement, `"continue\u002don-error"`, passe encore : l'epingle lit du texte, pas du YAML), ce que le boot check suppose du Dockerfile : `USER node`, `CMD` en forme exec, le **refus d'un port deja occupe** (voisin HTTP reel, script lance en `spawn` asynchrone), **aucune requete par un proxy de l'environnement** (boot complet derriere un faux proxy qui repond a tout, sa vivacite prouvee d'abord par un `curl` nu, avec un `no_proxy` qui laisse la boucle locale hors liste : exporter `NO_PROXY` seul ne suffit pas), **`test-curl.sh` lance en entier** (#41 et #46, 22 cas, chacun sur des ports libres qui lui sont propres, passes par `TEST_CURL_PORT` et `TEST_CURL_NOINGEST_PORT` : derriere le meme faux proxy avec `FORCE_COLOR=3`, avec `BASE_URL` et `NOINGEST_URL` pointes sur un port mort, et avec `TOKEN` et `INGEST_TOKEN` herites d'une meme valeur, rien n'atteint le proxy, les 51 cas passent, le nombre etant ecrit en dur dans le test, et les deux ports sont refuses des la premiere tentative a la sortie du script ; **deux passages au meme instant** sur quatre ports libres, 51 cas chacun ; les **refus de port** : valeur non numerique, port 0, port au-dela de 65535, nombre suivi de lettres (`80abc`), chacun sur chacune des deux variables, puis deux ports identiques, et les **deux bornes acceptees**, 1 et 65535, donnees aux deux variables a la fois pour ne rien faire ecouter sur un port fixe (atteindre le refus « must differ » montre que la valeur a passe le controle de plage), message sur stderr et code 1 avant tout lancement ; un **voisin HTTP reel** sur le port principal, puis sur le second : refus avant tout lancement, le voisin n'ayant vu que la sonde `/` ; un **occupant non HTTP** sur le port principal, qui ferme aussitot (code curl 52 ou 56) puis qui accepte sans repondre (28) : meme refus, la regle etant « tout code autre que 7 » ; un **serveur vivant qui n'ecoute jamais** : message d'echeance et code 1 apres les 5 s d'attente ; puis un faux `node` en tete de `PATH` qui sort en 1 pour le port principal, puis le second : message sur stderr, code 1 en moins de 3 s, aucun serveur laisse sur son port ; et deux **epingles sur le source** du script : defauts 3099 et 3098, seules occurrences de ces nombres hors commentaires, et URLs derivees des ports sans lecture de l'environnement. Plus aucun cas n'ecoute sur 3099 ni 3098 : les defauts ne sont exerces que par l'etape CI « Socket-level pass » et par un `bash test-curl.sh` a la main. Les ports viennent de `freePorts()`, qui ouvre ses sondes ensemble puis les ferme avant le lancement : un port repris dans l'intervalle fait echouer le cas avec le message de `require_free`, sans cas lance contre un mauvais serveur), et **des `require` en colonne 0 seulement, sans `import()`**, dans chaque `.js` du `COPY`. Cette derniere epingle est une heuristique sur les appels litteraux, espace avant la parenthese comprise (`require (`, `import (`). Son detecteur, `loadsLate()`, est lui-meme verifie sur six lignes : `require` indente et `import` a toute colonne, avec ou sans espace, signales ; `require` de premier niveau et ligne de commentaire laisses passer. Trous connus, entre autres, laisses ouverts (#35) : une fonction ecrite entiere sur une ligne en colonne 0 (`const lazy = () => require("x");`), un `require(` renvoye a la ligne en colonne 0 dans une fonction (de meme forme que le `} = require("node:fs");` legitime d'`index.js`), et toute indirection, comme un alias de `require`) - `__tests__/ci.test.js` — epingles du workflow et des scripts de CI (49 cas : image du `container:` identique au `FROM` du Dockerfile, les quatre etapes presentes, aucun filtre `branches:` sous `pull_request`, `push` limite a `main`, ni `defaults:` ni `working-directory` sur une etape `uses:`, **aucun `continue-on-error`, sous toute ecriture litterale** (une etape rouge passerait au vert en silence ; detecteur sur le mot nu, sans ancrage ni deux-points exiges, lui-meme verifie sur la cle en bloc, en element de liste, en style flow, entre guillemets et via une ancre YAML ; seules les lignes de commentaire entieres en sont exemptees ; une cle ecrite avec une sequence d'echappement, `"continue\u002don-error"`, passe encore : l'epingle lit du texte, pas du YAML), ce que le boot check suppose du Dockerfile : `USER node`, `CMD` en forme exec, le **refus d'un port deja occupe** (voisin HTTP reel, script lance en `spawn` asynchrone), **aucune requete par un proxy de l'environnement** (boot complet derriere un faux proxy qui repond a tout, sa vivacite prouvee d'abord par un `curl` nu, avec un `no_proxy` qui laisse la boucle locale hors liste : exporter `NO_PROXY` seul ne suffit pas), **`test-curl.sh` lance en entier** (#41 et #46, 22 cas, chacun sur des ports libres qui lui sont propres, passes par `TEST_CURL_PORT` et `TEST_CURL_NOINGEST_PORT` : derriere le meme faux proxy avec `FORCE_COLOR=3`, avec `BASE_URL` et `NOINGEST_URL` pointes sur un port mort, et avec `TOKEN` et `INGEST_TOKEN` herites d'une meme valeur, rien n'atteint le proxy, les 57 cas passent, le nombre etant ecrit en dur dans le test, et les deux ports sont refuses des la premiere tentative a la sortie du script ; **deux passages au meme instant** sur quatre ports libres, 51 cas chacun ; les **refus de port** : valeur non numerique, port 0, port au-dela de 65535, nombre suivi de lettres (`80abc`), chacun sur chacune des deux variables, puis deux ports identiques, et les **deux bornes acceptees**, 1 et 65535, donnees aux deux variables a la fois pour ne rien faire ecouter sur un port fixe (atteindre le refus « must differ » montre que la valeur a passe le controle de plage), message sur stderr et code 1 avant tout lancement ; un **voisin HTTP reel** sur le port principal, puis sur le second : refus avant tout lancement, le voisin n'ayant vu que la sonde `/` ; un **occupant non HTTP** sur le port principal, qui ferme aussitot (code curl 52 ou 56) puis qui accepte sans repondre (28) : meme refus, la regle etant « tout code autre que 7 » ; un **serveur vivant qui n'ecoute jamais** : message d'echeance et code 1 apres les 5 s d'attente ; puis un faux `node` en tete de `PATH` qui sort en 1 pour le port principal, puis le second : message sur stderr, code 1 en moins de 3 s, aucun serveur laisse sur son port ; et deux **epingles sur le source** du script : defauts 3099 et 3098, seules occurrences de ces nombres hors commentaires, et URLs derivees des ports sans lecture de l'environnement. Plus aucun cas n'ecoute sur 3099 ni 3098 : les defauts ne sont exerces que par l'etape CI « Socket-level pass » et par un `bash test-curl.sh` a la main. Les ports viennent de `freePorts()`, qui ouvre ses sondes ensemble puis les ferme avant le lancement : un port repris dans l'intervalle fait echouer le cas avec le message de `require_free`, sans cas lance contre un mauvais serveur), et **des `require` en colonne 0 seulement, sans `import()`**, dans chaque `.js` du `COPY`. Cette derniere epingle est une heuristique sur les appels litteraux, espace avant la parenthese comprise (`require (`, `import (`). Son detecteur, `loadsLate()`, est lui-meme verifie sur six lignes : `require` indente et `import` a toute colonne, avec ou sans espace, signales ; `require` de premier niveau et ligne de commentaire laisses passer. Trous connus, entre autres, laisses ouverts (#35) : une fonction ecrite entiere sur une ligne en colonne 0 (`const lazy = () => require("x");`), un `require(` renvoye a la ligne en colonne 0 dans une fonction (de meme forme que le `} = require("node:fs");` legitime d'`index.js`), et toute indirection, comme un alias de `require`)
- `__tests__/writes.test.js` — surfaces d'ecriture : `HOSTS_DIR`, et le dossier de `CLAUDE_USAGE_PATH` pour le seul `POST /claude-usage`, archive `history/` comprise (#55) (#48 : `violations()` prend le dossier autorise du cas, `HOSTS_DIR` par defaut ; deux cas dedies, `POST /claude-usage` n'ecrit que dans son dossier, et ses rejets 401/400/413 et la lecture d'un fichier corrompu n'ecrivent rien du tout ; `GET /claude-usage` entre dans les routes de lecture ; #55 : le cas POST exige le `rename` du fichier mensuel du jour clos, `GET /claude-usage/history` entre dans les routes de lecture, et un mois corrompu ou une plage refusee n'ecrivent rien) (15 cas : espions sur toutes les fonctions d'ecriture de `node:fs`, formes callback, Sync et `fs.promises`, et sur `child_process`, poses avant un `require` frais apres purge du cache de **tous** les modules runtime (un `metrics.js` reste en cache garderait les espions restaures d'un test precedent) ; demarrage par le balayage des `.tmp` ; les huit routes GET n'ecrivent rien, meme une macrotache apres la reponse, contenu reel exige ; `POST /hosts/<id>` n'ecrit que sous `HOSTS_DIR` ; **chemins degrades** : rejets 401/403/404, entrees 400/413, `agents-map.json` corrompu, fichiers Defenseurs absents, snapshot corrompu, avec le contenu degrade exige (`no_data`, `neverSeen`) ; **commandes shell** figees sur `head -1 /proc/stat` et `df -k /`, `/health` servant de temoin ; chaque cas prouve que ses espions sont vivants ; **tout appel est compte, sans attribution par pile** (#40 : une fonction `node:fs` passee telle quelle a `process.nextTick`, a un ecouteur ou a un `.then` s'execute sans frame runtime sur sa pile, et un filtre par pile la laissait passer) ; les retouches de fixtures du cas degrade passent par les fonctions reelles capturees avant la pose des espions, jamais par un `rmSync` recursif, qui appelle en interne le `fs.rmdirSync` public, donc l'espion ; **epingle sur le bloc `require.main`**, ensemble exact des appels, puisqu'il ne tourne jamais dans le processus de test. Limites : un chemin qu'aucun cas n'emprunte, et une ecriture differee au-dela de 20 ms. Contrepartie assumee : une ecriture du lanceur de tests lui-meme pendant un cas le ferait rougir) - `__tests__/writes.test.js` — surfaces d'ecriture : `HOSTS_DIR`, et le dossier de `CLAUDE_USAGE_PATH` pour le seul `POST /claude-usage`, archive `history/` comprise (#55) (#48 : `violations()` prend le dossier autorise du cas, `HOSTS_DIR` par defaut ; deux cas dedies, `POST /claude-usage` n'ecrit que dans son dossier, et ses rejets 401/400/413 et la lecture d'un fichier corrompu n'ecrivent rien du tout ; `GET /claude-usage` entre dans les routes de lecture ; #55 : le cas POST exige le `rename` du fichier mensuel du jour clos, `GET /claude-usage/history` entre dans les routes de lecture, et un mois corrompu ou une plage refusee n'ecrivent rien ; #58 : le dossier de `BACKUPS_PULL_PATH` pour le seul `POST /backups/pull`, `GET /backups` entre dans les routes de lecture, ses rejets 401/400/413 et deux fichiers corrompus puis absents n'ecrivent rien) (18 cas : espions sur toutes les fonctions d'ecriture de `node:fs`, formes callback, Sync et `fs.promises`, et sur `child_process`, poses avant un `require` frais apres purge du cache de **tous** les modules runtime (un `metrics.js` reste en cache garderait les espions restaures d'un test precedent) ; demarrage par le balayage des `.tmp` ; les neuf routes GET n'ecrivent rien, meme une macrotache apres la reponse, contenu reel exige ; `POST /hosts/<id>` n'ecrit que sous `HOSTS_DIR` ; **chemins degrades** : rejets 401/403/404, entrees 400/413, `agents-map.json` corrompu, fichiers Defenseurs absents, snapshot corrompu, avec le contenu degrade exige (`no_data`, `neverSeen`) ; **commandes shell** figees sur `head -1 /proc/stat` et `df -k /`, `/health` servant de temoin ; chaque cas prouve que ses espions sont vivants ; **tout appel est compte, sans attribution par pile** (#40 : une fonction `node:fs` passee telle quelle a `process.nextTick`, a un ecouteur ou a un `.then` s'execute sans frame runtime sur sa pile, et un filtre par pile la laissait passer) ; les retouches de fixtures du cas degrade passent par les fonctions reelles capturees avant la pose des espions, jamais par un `rmSync` recursif, qui appelle en interne le `fs.rmdirSync` public, donc l'espion ; **epingle sur le bloc `require.main`**, ensemble exact des appels, puisqu'il ne tourne jamais dans le processus de test. Limites : un chemin qu'aucun cas n'emprunte, et une ecriture differee au-dela de 20 ms. Contrepartie assumee : une ecriture du lanceur de tests lui-meme pendant un cas le ferait rougir)
- Runtime reste 0-dep ; vitest en devDep uniquement - Runtime reste 0-dep ; vitest en devDep uniquement
- `sh scripts/boot-from-copy.sh` — demarre le `CMD` depuis les seules sources du `COPY`, copiees dans un dossier vide, et exige 401 sans token, 200 avec, et des metriques disque/memoire non nulles. Tient lieu de `docker build` en CI : un vrai build demanderait le socket Docker dans le runner d'instance, donc root sur le VPS de prod pour tout workflow de tout depot. Il n'accepte qu'une ligne `COPY` en forme shell sans option, et un `CMD` en forme exec ; toute autre forme echoue bruyamment plutot que de verifier du vide. Port 3097 par defaut (`BOOT_PORT` pour en changer) ; il refuse de demarrer si le port est deja pris (tout code de sortie curl autre que 7, « connexion refusee »), sans quoi ses assertions pourraient interroger un voisin. Il exporte `NO_PROXY='*'` et `no_proxy='*'` en tete : chaque `curl` vise la boucle locale, et un `http_proxy` de l'environnement repondrait a la place du port comme du serveur demarre. Les deux graphies, car curl lit d'abord la minuscule : un `no_proxy` herite l'emporterait sur un `NO_PROXY` exporte seul. Il n'appelle que `/health` : il ne prouve que les modules charges au demarrage, d'ou l'epingle « `require` en colonne 0 » dans `ci.test.js`. - `sh scripts/boot-from-copy.sh` — demarre le `CMD` depuis les seules sources du `COPY`, copiees dans un dossier vide, et exige 401 sans token, 200 avec, et des metriques disque/memoire non nulles. Tient lieu de `docker build` en CI : un vrai build demanderait le socket Docker dans le runner d'instance, donc root sur le VPS de prod pour tout workflow de tout depot. Il n'accepte qu'une ligne `COPY` en forme shell sans option, et un `CMD` en forme exec ; toute autre forme echoue bruyamment plutot que de verifier du vide. Port 3097 par defaut (`BOOT_PORT` pour en changer) ; il refuse de demarrer si le port est deja pris (tout code de sortie curl autre que 7, « connexion refusee »), sans quoi ses assertions pourraient interroger un voisin. Il exporte `NO_PROXY='*'` et `no_proxy='*'` en tete : chaque `curl` vise la boucle locale, et un `http_proxy` de l'environnement repondrait a la place du port comme du serveur demarre. Les deux graphies, car curl lit d'abord la minuscule : un `no_proxy` herite l'emporterait sur un `NO_PROXY` exporte seul. Il n'appelle que `/health` : il ne prouve que les modules charges au demarrage, d'ou l'epingle « `require` en colonne 0 » dans `ci.test.js`.
- `bash test-curl.sh` — passe au niveau socket, complementaire et non redondante (51 cas, dont 6 sur `/claude-usage/history` depuis #55 : 401 sans token et avec le token d'ingestion Claude, 400, POST d'un snapshot de deux jours puis le seul jour clos servi, fichier mensuel en mode 600). Vitest couvre la logique ; ce script couvre ce que seuls un vrai client et un vrai TCP peuvent montrer : le 413 rendu **avant** que la connexion soit coupee, les deux tokens qui se refusent mutuellement leurs routes, le fichier servi octet pour octet par `/defenseurs/state`, et le 503 d'une instance demarree sans `HOSTS_INGEST_TOKEN` (elle tourne sur un second port, le temps du script). Il boote ses propres serveurs sur 3099 et 3098 — aucun service exterieur requis. Les deux ports se changent par `TEST_CURL_PORT` et `TEST_CURL_NOINGEST_PORT` (#46), ce qui laisse tourner deux passages cote a cote ; une valeur hors 1-65535 ou deux ports identiques sont refuses avec un message, avant tout lancement. Des noms propres au script plutot que `PORT`, qu'un shell de dev exporte souvent deja. `BASE_URL` et `NOINGEST_URL` sont derives des ports et ne se lisent plus dans l'environnement : surcharges seuls, ils envoyaient les `curl` ailleurs que sur les serveurs demarres. `TOKEN` et `INGEST_TOKEN` sont fixes pour la meme raison : deux valeurs egales heritees cassaient les cas de cloisonnement. Quatre protections contre l'environnement (#41) : `NO_PROXY='*'` et `no_proxy='*'` exportes, comme dans `boot-from-copy.sh` (sans eux, derriere un proxy mort, le script sortait en code 7 apres 11 s sans une ligne) ; `unset FORCE_COLOR`, car sous cette variable `node -e 'console.log(3)'` colore le nombre meme dans un tube et 8 cas echouent ; `require_free`, qui exige avant tout lancement que les deux ports refusent la connexion (code curl 7, meme regle que `boot-from-copy.sh`) : c'est lui qui ecarte un voisin deja en place, car le serveur du script ne meurt de son `EADDRINUSE` qu'apres que la boucle d'attente a eu le temps de joindre le voisin ; et `wait_for`, qui arrete le script avec un message si le serveur meurt ou ne repond pas dans les 5 s (borne par horloge), en verifiant le processus (`kill -0`) avant et apres la sonde. Le `trap EXIT` protege chaque `kill` par `|| true` (sous `set -e`, un `kill` en echec sur le premier serveur deja mort terminait le trap et laissait le second tourner sur son port) et finit par un `wait`, pour qu'un passage lance juste apres trouve les ports libres. Ce `wait` n'est epingle que par une tentative de connexion unique a la sortie du script : sans lui, le cas rougit le plus souvent, pas a chaque fois (4 passages sur 5 mesures). Non epingles : l'export de la majuscule `NO_PROXY`, que curl ne lit qu'apres la minuscule, et le second `kill -0` de `wait_for`, qui ne couvre que la fenetre residuelle entre `require_free` et le `listen` du serveur. - `bash test-curl.sh` — passe au niveau socket, complementaire et non redondante (57 cas, dont 6 sur `GET /backups` et `POST /backups/pull` depuis #58 : 503 de l'instance sans `BACKUPS_INGEST_TOKEN` (pose vide pour qu'un export du shell ne s'y glisse pas), 401 sans token et avec le token Claude, 413 a 1 025 octets rendu avant la coupure, 204 puis relecture avec la fraicheur du serveur ; 6 sur `/claude-usage/history` depuis #55 : 401 sans token et avec le token d'ingestion Claude, 400, POST d'un snapshot de deux jours puis le seul jour clos servi, fichier mensuel en mode 600). Vitest couvre la logique ; ce script couvre ce que seuls un vrai client et un vrai TCP peuvent montrer : le 413 rendu **avant** que la connexion soit coupee, les deux tokens qui se refusent mutuellement leurs routes, le fichier servi octet pour octet par `/defenseurs/state`, et le 503 d'une instance demarree sans `HOSTS_INGEST_TOKEN` (elle tourne sur un second port, le temps du script). Il boote ses propres serveurs sur 3099 et 3098 — aucun service exterieur requis. Les deux ports se changent par `TEST_CURL_PORT` et `TEST_CURL_NOINGEST_PORT` (#46), ce qui laisse tourner deux passages cote a cote ; une valeur hors 1-65535 ou deux ports identiques sont refuses avec un message, avant tout lancement. Des noms propres au script plutot que `PORT`, qu'un shell de dev exporte souvent deja. `BASE_URL` et `NOINGEST_URL` sont derives des ports et ne se lisent plus dans l'environnement : surcharges seuls, ils envoyaient les `curl` ailleurs que sur les serveurs demarres. `TOKEN` et `INGEST_TOKEN` sont fixes pour la meme raison : deux valeurs egales heritees cassaient les cas de cloisonnement. Quatre protections contre l'environnement (#41) : `NO_PROXY='*'` et `no_proxy='*'` exportes, comme dans `boot-from-copy.sh` (sans eux, derriere un proxy mort, le script sortait en code 7 apres 11 s sans une ligne) ; `unset FORCE_COLOR`, car sous cette variable `node -e 'console.log(3)'` colore le nombre meme dans un tube et 8 cas echouent ; `require_free`, qui exige avant tout lancement que les deux ports refusent la connexion (code curl 7, meme regle que `boot-from-copy.sh`) : c'est lui qui ecarte un voisin deja en place, car le serveur du script ne meurt de son `EADDRINUSE` qu'apres que la boucle d'attente a eu le temps de joindre le voisin ; et `wait_for`, qui arrete le script avec un message si le serveur meurt ou ne repond pas dans les 5 s (borne par horloge), en verifiant le processus (`kill -0`) avant et apres la sonde. Le `trap EXIT` protege chaque `kill` par `|| true` (sous `set -e`, un `kill` en echec sur le premier serveur deja mort terminait le trap et laissait le second tourner sur son port) et finit par un `wait`, pour qu'un passage lance juste apres trouve les ports libres. Ce `wait` n'est epingle que par une tentative de connexion unique a la sortie du script : sans lui, le cas rougit le plus souvent, pas a chaque fois (4 passages sur 5 mesures). Non epingles : l'export de la majuscule `NO_PROXY`, que curl ne lit qu'apres la minuscule, et le second `kill -0` de `wait_for`, qui ne couvre que la fenetre residuelle entre `require_free` et le `listen` du serveur.
## Gotchas ## Gotchas
- Pas d'Express — HTTP natif Node.js uniquement - Pas d'Express — HTTP natif Node.js uniquement
- Le `COPY` du Dockerfile liste les fichiers un par un (`package.json index.js metrics.js claude-usage.js claude-usage-history.js`) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un `MODULE_NOT_FOUND` que `npm test` ne voit pas (seul `scripts/boot-from-copy.sh`, en CI, le rattrape). Un `require` place dans un handler de route echapperait au boot check (qui n'appelle que `/health`) : garder tous les `require` au niveau module, une epingle de `ci.test.js` l'exige. Corollaire : `agent/` n'entre PAS dans l'image — c'est du code de poste, livre par copie de fichiers (voir `agent/README.md`), et un test epingle que le `COPY` ne le mentionne jamais. - Le `COPY` du Dockerfile liste les fichiers un par un (`package.json index.js metrics.js claude-usage.js claude-usage-history.js backups.js`) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un `MODULE_NOT_FOUND` que `npm test` ne voit pas (seul `scripts/boot-from-copy.sh`, en CI, le rattrape). Un `require` place dans un handler de route echapperait au boot check (qui n'appelle que `/health`) : garder tous les `require` au niveau module, une epingle de `ci.test.js` l'exige. Corollaire : `agent/` n'entre PAS dans l'image — c'est du code de poste, livre par copie de fichiers (voir `agent/README.md`), et un test epingle que le `COPY` ne le mentionne jamais.
- `agent/` tourne sur le ThinkPad, pas sur le VPS, et sa sortie part dans `logger -t host-agent` : tout ce qu'il imprime finit dans `/var/log/syslog` et journald pour de bon. Le chemin d'erreur ne rend donc que `err.code` et le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete. Et jamais de `set -x` dans `run-push.sh` : le shell echoerait le token en sourcant le fichier d'env. - `agent/` tourne sur le ThinkPad, pas sur le VPS, et sa sortie part dans `logger -t host-agent` : tout ce qu'il imprime finit dans `/var/log/syslog` et journald pour de bon. Le chemin d'erreur ne rend donc que `err.code` et le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete. Et jamais de `set -x` dans `run-push.sh` : le shell echoerait le token en sourcant le fichier d'env.
- `agent/claude-usage/` (#50, `logger -t claude-usage`, cron 15 min) tient deux secrets dans le meme processus : le jeton OAuth de Claude Code et `CLAUDE_USAGE_INGEST_TOKEN`. Il ne rafraichit **jamais** le jeton OAuth et n'ecrit **jamais** `~/.claude/.credentials.json` (refresh token a usage unique : Claude Code serait deconnecte) ; jeton expire -> `token_expired`, aucun appel. L'URL Anthropic est une constante de `limits.js` (aucune variable d'env, `redirect: "error"`) pour que le bearer OAuth ne suive jamais l'URL de push configurable ; `detail` ne sort que de l'enum, jamais d'un message d'erreur ou d'un corps de reponse. Memes regles de sortie que l'agent poste (codes seulement, pas de `set -x` dans `run-claude-usage.sh`). Un jour se fige a J+2 et n'est plus jamais recalcule : un passage qui a rencontre une erreur de lecture (`readErrors > 0`) ne fige donc aucun nouveau jour et journalise `read_errors=N`, sans chemin (#54) ; et les jours figes sont ecartes **apres** la deduplication, jamais avant, sinon une requete a cheval sur minuit est comptee deux fois. - `agent/claude-usage/` (#50, `logger -t claude-usage`, cron 15 min) tient deux secrets dans le meme processus : le jeton OAuth de Claude Code et `CLAUDE_USAGE_INGEST_TOKEN`. Il ne rafraichit **jamais** le jeton OAuth et n'ecrit **jamais** `~/.claude/.credentials.json` (refresh token a usage unique : Claude Code serait deconnecte) ; jeton expire -> `token_expired`, aucun appel. L'URL Anthropic est une constante de `limits.js` (aucune variable d'env, `redirect: "error"`) pour que le bearer OAuth ne suive jamais l'URL de push configurable ; `detail` ne sort que de l'enum, jamais d'un message d'erreur ou d'un corps de reponse. Memes regles de sortie que l'agent poste (codes seulement, pas de `set -x` dans `run-claude-usage.sh`). Un jour se fige a J+2 et n'est plus jamais recalcule : un passage qui a rencontre une erreur de lecture (`readErrors > 0`) ne fige donc aucun nouveau jour et journalise `read_errors=N`, sans chemin (#54) ; et les jours figes sont ecartes **apres** la deduplication, jamais avant, sinon une requete a cheval sur minuit est comptee deux fois.
- `getHealth()` garde `Promise.all([collectMetrics(), getLogtoHealth()])` : l'echantillon CPU de 500 ms et le check Logto (timeout 3 s) sont deliberement concurrents. Les serialiser ferait monter le p99 de `/health` a ~3,5 s. Garde de non-regression : le test de latence dans `__tests__/health.test.js`. - `getHealth()` garde `Promise.all([collectMetrics(), getLogtoHealth()])` : l'echantillon CPU de 500 ms et le check Logto (timeout 3 s) sont deliberement concurrents. Les serialiser ferait monter le p99 de `/health` a ~3,5 s. Garde de non-regression : le test de latence dans `__tests__/health.test.js`.
- L'API n'est plus read-only depuis l'issue #13. `status.json`, `agents-map.json` et les rapports de scan restent ecrits par le Sergent defenseurs et lus seulement ici ; en revanche `POST /hosts/<id>` ecrit dans `HOSTS_DIR`, et `POST /claude-usage` (#48) dans le dossier de `CLAUDE_USAGE_PATH`. Consequence : ces deux montages-la doivent etre en lecture-ecriture pour l'uid 1000, alors que les deux montages Defenseurs sont en `:ro` (#36). Une ecriture ajoutee un jour sous `REPORTS_DIR` ou `/data/defenseurs` passerait tous les autres tests (fixtures inscriptibles, CI en root) et tomberait en prod en 500 `EROFS` : `__tests__/writes.test.js` exige que toute ecriture par `node:fs` vise `HOSTS_DIR` (ou, pour le seul cas `POST /claude-usage`, le dossier de `CLAUDE_USAGE_PATH`), et que les seules commandes shell soient les deux lectures de `metrics.js`, sur chaque chemin qu'il exerce (demarrage, lectures et ingestion nominales, rejets, entrees invalides, fichier corrompu, fichiers absents). Un nouveau chemin qui ecrit doit y entrer comme cas. - L'API n'est plus read-only depuis l'issue #13. `status.json`, `agents-map.json` et les rapports de scan restent ecrits par le Sergent defenseurs et lus seulement ici ; en revanche `POST /hosts/<id>` ecrit dans `HOSTS_DIR`, `POST /claude-usage` (#48) dans le dossier de `CLAUDE_USAGE_PATH`, et `POST /backups/pull` (#58) dans celui de `BACKUPS_PULL_PATH`. Consequence : ces trois montages-la doivent etre en lecture-ecriture pour l'uid 1000, alors que les deux montages Defenseurs et celui de `status.json` des sauvegardes sont en `:ro` (#36, #58). Une ecriture ajoutee un jour sous `REPORTS_DIR` ou `/data/defenseurs` passerait tous les autres tests (fixtures inscriptibles, CI en root) et tomberait en prod en 500 `EROFS` : `__tests__/writes.test.js` exige que toute ecriture par `node:fs` vise `HOSTS_DIR` (ou, pour le seul cas `POST /claude-usage`, le dossier de `CLAUDE_USAGE_PATH`, et pour le seul cas `POST /backups/pull`, celui de `BACKUPS_PULL_PATH`), et que les seules commandes shell soient les deux lectures de `metrics.js`, sur chaque chemin qu'il exerce (demarrage, lectures et ingestion nominales, rejets, entrees invalides, fichier corrompu, fichiers absents). Un nouveau chemin qui ecrit doit y entrer comme cas.
- L'ecriture des snapshots passe par un fichier temporaire dans le **meme** repertoire puis `renameSync` — `rename` est atomique dans un systeme de fichiers, donc un `GET /hosts` concurrent voit l'ancien snapshot ou le nouveau, jamais un JSON tronque. Ne pas « simplifier » en `writeFileSync` direct. Un processus tue entre les deux laisse un `.<id>.<pid>.<ms>.tmp` : `sweepStaleTempFiles()` les supprime au demarrage, avant `listen`, s'ils ont plus d'une heure (plus jeunes, un autre conteneur peut etre en train d'ecrire pendant un redeploy). Il ne touche que ce format de nom exact et que des fichiers reguliers (`lstat`) ; changer le nom du fichier temporaire impose de changer `TEMP_FILE_RE`. `writeClaudeUsage()` suit le meme patron (temporaire `.claude-usage.json.<pid>.<ms>.tmp`), mais **aucun balayage** ne couvre son dossier : un orphelin y resterait, inoffensif (`GET /claude-usage` ne lit que le fichier final) ; un seul fichier ecrit toutes les quelques minutes ne justifiait pas d'elargir `sweepStaleTempFiles()` et le bloc `require.main` epingle. Meme chose pour `history/` (#55) : temporaires `.YYYY-MM.json.<pid>.<ms>.tmp`, aucun balayage, un orphelin y est inoffensif (la route ne lit que `YYYY-MM.json`). - L'ecriture des snapshots passe par un fichier temporaire dans le **meme** repertoire puis `renameSync` — `rename` est atomique dans un systeme de fichiers, donc un `GET /hosts` concurrent voit l'ancien snapshot ou le nouveau, jamais un JSON tronque. Ne pas « simplifier » en `writeFileSync` direct. Un processus tue entre les deux laisse un `.<id>.<pid>.<ms>.tmp` : `sweepStaleTempFiles()` les supprime au demarrage, avant `listen`, s'ils ont plus d'une heure (plus jeunes, un autre conteneur peut etre en train d'ecrire pendant un redeploy). Il ne touche que ce format de nom exact et que des fichiers reguliers (`lstat`) ; changer le nom du fichier temporaire impose de changer `TEMP_FILE_RE`. `writeJsonAtomic()` suit le meme patron pour `POST /claude-usage` et `POST /backups/pull` (temporaires `.claude-usage.json.<pid>.<ms>.tmp` et `.pull.json.<pid>.<ms>.tmp`), mais **aucun balayage** ne couvre son dossier : un orphelin y resterait, inoffensif (`GET /claude-usage` ne lit que le fichier final) ; un seul fichier ecrit toutes les quelques minutes ne justifiait pas d'elargir `sweepStaleTempFiles()` et le bloc `require.main` epingle. Meme chose pour `history/` (#55) : temporaires `.YYYY-MM.json.<pid>.<ms>.tmp`, aucun balayage, un orphelin y est inoffensif (la route ne lit que `YYYY-MM.json`).
- L'objet persiste est **reconstruit champ par champ** depuis une liste blanche (nombres via `Number.isFinite`, chaines plafonnees a 128 caracteres) — jamais le payload verbatim. Le fichier est relu par `GET /hosts` et finit dans l'arbre React de l'admin : une cle `__proto__` ou un `hostname` de 4 Kio ne doit jamais arriver la. La relecture repasse par la meme liste blanche, le fichier sur disque n'etant pas plus digne de confiance que le payload. `loadAvg` doit avoir exactement 3 nombres (la carte admin les joint par « / ») : un fichier ancien hors format degrade en `neverSeen`. Les chaines sont tronquees, **pas filtrees** (caracteres de controle conserves) : la surete repose sur l'echappement du consommateur, React aujourd'hui. - L'objet persiste est **reconstruit champ par champ** depuis une liste blanche (nombres via `Number.isFinite`, chaines plafonnees a 128 caracteres) — jamais le payload verbatim. Le fichier est relu par `GET /hosts` et finit dans l'arbre React de l'admin : une cle `__proto__` ou un `hostname` de 4 Kio ne doit jamais arriver la. La relecture repasse par la meme liste blanche, le fichier sur disque n'etant pas plus digne de confiance que le payload. `loadAvg` doit avoir exactement 3 nombres (la carte admin les joint par « / ») : un fichier ancien hors format degrade en `neverSeen`. Les chaines sont tronquees, **pas filtrees** (caracteres de controle conserves) : la surete repose sur l'echappement du consommateur, React aujourd'hui.
- Le plafond de corps est passe par route a `readBody(req, res, maxBytes)` : 4 Kio (`MAX_BODY_BYTES`) pour `/hosts/<id>`, 64 Kio (`CLAUDE_USAGE_MAX_BODY_BYTES`, exporte par `claude-usage.js`) pour `/claude-usage`, le pire cas realiste estime par la spec pesant ~46 Ko (un corps qui sature toutes les bornes a la fois, cles de 64 caracteres et compteurs a 16 chiffres partout, depasserait 64 Kio et recevrait 413 : le plafond d'octets prime sur les bornes de liste). Il compte les octets **recus**, pas le `Content-Length` : un client peut mentir dans l'en-tete, et le transfert chunke l'omet. Au-dela -> 413 puis `req.destroy()` une fois la reponse ecoulee (detruire avant le flush laisserait le client avec une erreur reseau au lieu d'un code). - Le plafond de corps est passe par route a `readBody(req, res, maxBytes)` : 4 Kio (`MAX_BODY_BYTES`) pour `/hosts/<id>`, 64 Kio (`CLAUDE_USAGE_MAX_BODY_BYTES`, exporte par `claude-usage.js`) pour `/claude-usage`, le pire cas realiste estime par la spec pesant ~46 Ko (un corps qui sature toutes les bornes a la fois, cles de 64 caracteres et compteurs a 16 chiffres partout, depasserait 64 Kio et recevrait 413 : le plafond d'octets prime sur les bornes de liste). Il compte les octets **recus**, pas le `Content-Length` : un client peut mentir dans l'en-tete, et le transfert chunke l'omet. Au-dela -> 413 puis `req.destroy()` une fois la reponse ecoulee (detruire avant le flush laisserait le client avec une erreur reseau au lieu d'un code).
- `agents-map.json` doit etre present sur le VPS avant le deploy : verifier via `ssh ubuntu@vps 'ls /data/defenseurs/agents-map.json'` (PAS `/home/defenseur/...`, leurre obsolete). Sinon `/defenseurs/findings` retourne 500. - `agents-map.json` doit etre present sur le VPS avant le deploy : verifier via `ssh ubuntu@vps 'ls /data/defenseurs/agents-map.json'` (PAS `/home/defenseur/...`, leurre obsolete). Sinon `/defenseurs/findings` retourne 500.

View file

@ -1,6 +1,6 @@
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package.json index.js metrics.js claude-usage.js claude-usage-history.js ./ COPY package.json index.js metrics.js claude-usage.js claude-usage-history.js backups.js ./
EXPOSE 3001 EXPOSE 3001
USER node USER node
CMD ["node", "index.js"] CMD ["node", "index.js"]

View file

@ -6,9 +6,10 @@ Lightweight health monitoring API for the VPS. Node 22, HTTP-native, zero runtim
Read endpoints require `Authorization: Bearer $HEALTH_TOKEN`. Each write Read endpoints require `Authorization: Bearer $HEALTH_TOKEN`. Each write
endpoint has its own token — `$HOSTS_INGEST_TOKEN` for workstation snapshots, endpoint has its own token — `$HOSTS_INGEST_TOKEN` for workstation snapshots,
`$CLAUDE_USAGE_INGEST_TOKEN` for the Claude usage snapshot — so an agent can `$CLAUDE_USAGE_INGEST_TOKEN` for the Claude usage snapshot,
push its data without gaining read access to the Defenseurs reports, and `$BACKUPS_INGEST_TOKEN` for the backup pull report — so an agent can push its
neither ingestion token opens the other route. data without gaining read access to the Defenseurs reports, and no ingestion
token opens another one's route.
| Method | Path | Token | Description | | Method | Path | Token | Description |
|--------|------|-------|-------------| |--------|------|-------|-------------|
@ -21,6 +22,8 @@ neither ingestion token opens the other route.
| POST | `/hosts/<id>` | `HOSTS_INGEST_TOKEN` | Ingest one workstation snapshot | | POST | `/hosts/<id>` | `HOSTS_INGEST_TOKEN` | Ingest one workstation snapshot |
| GET | `/claude-usage` | `HEALTH_TOKEN` | Latest Claude usage snapshot, with server-side staleness | | GET | `/claude-usage` | `HEALTH_TOKEN` | Latest Claude usage snapshot, with server-side staleness |
| POST | `/claude-usage` | `CLAUDE_USAGE_INGEST_TOKEN` | Ingest the Claude usage snapshot | | POST | `/claude-usage` | `CLAUDE_USAGE_INGEST_TOKEN` | Ingest the Claude usage snapshot |
| GET | `/backups` | `HEALTH_TOKEN` | PostgreSQL backups: VPS status and last workstation pull, with server-side staleness |
| POST | `/backups/pull` | `BACKUPS_INGEST_TOKEN` | Ingest the workstation's pull report |
Routing resolves before authentication: an unknown path or a wrong method Routing resolves before authentication: an unknown path or a wrong method
answers `404`, never `401`. answers `404`, never `401`.
@ -149,6 +152,53 @@ dashboard displays it, it does not recompute it. A missing or unreadable file
(corrupt, off-contract, no usable `receivedAt`) answers (corrupt, off-contract, no usable `receivedAt`) answers
`200 { "status": "no_data" }`. `200 { "status": "no_data" }`.
### `POST /backups/pull`
Sent once a day by the workstation pull agent
([`agent/pg-backup-pull/`](agent/README.md)) after it fetched the encrypted
dumps (la-compagnie-maximus#220). Body, validated strictly (`backups.js`):
```
{ "pulledAt": "<ISO 8601 with offset>", "files": <integer >= 0>, "ok": true|false, "error"?: "pull_failed"|"rotation_failed" }
```
`error` is required when `ok` is `false` and refused when it is `true`; any
other code is a 400. Unknown fields, a client `receivedAt` among them, are
dropped: the server stamps `receivedAt` itself. The report is written
atomically (temp file in the same directory, mode 600, `rename`).
| Code | Meaning |
|------|---------|
| 204 | Accepted |
| 400 | Not JSON, not an object, a field missing or invalid, an error code outside the list |
| 401 | Missing or wrong token (the three other tokens included) |
| 413 | Body over 1 KiB (bytes received, not `Content-Length`); same answer as the other ingest routes |
| 503 | `BACKUPS_INGEST_TOKEN` not configured (fail-closed) |
### `GET /backups`
```
{ "vps": { …status.json, whitelisted…, "ageSeconds": 31000, "stale": false, "staleAfterSeconds": 129600 } | null,
"lenovo": { "pulledAt": "…", "files": 9, "ok": true, "receivedAt": "…", "ageSeconds": 28000, "stale": false, "staleAfterSeconds": 129600 } | null,
"vpsError"?: "<code>", "lenovoError"?: "<code>" }
```
- `vps` is the `status.json` written by the VPS script (`backup-postgres.sh`,
contract `schemaVersion: 1`: `generatedAt`, `ok`, `expectedCount`,
`databases[]`, `globals`, `mirror`, `rotation`, `retentionDays`), rebuilt
through a whitelist: an unknown field is dropped, an invalid value is dropped
rather than coerced, an `error` outside the closed list of codes is dropped
(the entry keeps `ok: false`), a database name the script refused is dropped.
Its age runs from `generatedAt`.
- `lenovo` is the last `POST /backups/pull`, aged on the server's `receivedAt`,
never on the workstation's `pulledAt`.
- `stale = ageSeconds > staleAfterSeconds` (`BACKUPS_STALE_SECONDS`, 36 h),
decided by the server only: the admin card and `/warmup` display it.
- A file that is there but unusable gives a `null` block plus `vpsError` or
`lenovoError` (`unreadable`, `invalid_json`, `unsupported_schema`,
`invalid_contract`), never a 500, and the other block is still served.
`200 { "status": "no_data" }` only when both files are absent.
### `GET /defenseurs/state` ### `GET /defenseurs/state`
Serves the Sergent's `findings-state.json` verbatim — per-finding `firstSeen`, Serves the Sergent's `findings-state.json` verbatim — per-finding `firstSeen`,
@ -212,6 +262,10 @@ curl -H "Authorization: Bearer $HEALTH_TOKEN" \
| `CLAUDE_USAGE_INGEST_TOKEN` | — | Bearer token for `POST /claude-usage` (503 if missing) | | `CLAUDE_USAGE_INGEST_TOKEN` | — | Bearer token for `POST /claude-usage` (503 if missing) |
| `CLAUDE_USAGE_PATH` | `/data/claude-usage/claude-usage.json` | Where the Claude usage snapshot is **written** | | `CLAUDE_USAGE_PATH` | `/data/claude-usage/claude-usage.json` | Where the Claude usage snapshot is **written** |
| `CLAUDE_USAGE_STALE_SECONDS` | `2700` | Age past which `GET /claude-usage` reports `stale: true` | | `CLAUDE_USAGE_STALE_SECONDS` | `2700` | Age past which `GET /claude-usage` reports `stale: true` |
| `BACKUPS_STATUS_PATH` | `/data/backups-status/status.json` | VPS backup status, **read** by `GET /backups` |
| `BACKUPS_PULL_PATH` | `/data/backups-pull/pull.json` | Where the workstation pull report is **written** |
| `BACKUPS_INGEST_TOKEN` | — | Bearer token for `POST /backups/pull` (503 if missing) |
| `BACKUPS_STALE_SECONDS` | `129600` | Age past which a `GET /backups` block reports `stale: true` (non-numeric or <= 0: default) |
## Bind-mounts (Coolify) ## Bind-mounts (Coolify)
@ -223,6 +277,11 @@ Mounted read-only (`:ro`) — written on the host by the Defenseurs Sergent:
nobody reads.) nobody reads.)
- `/home/defenseur/defenseurs/reports` -> `/data/defenseurs/reports:ro` — its - `/home/defenseur/defenseurs/reports` -> `/data/defenseurs/reports:ro` — its
own mount, nested inside the previous one. own mount, nested inside the previous one.
- `/var/backups/postgres-status` -> `/data/backups-status:ro` — the
`status.json` written by the VPS backup script (root, directory 755, file
644, so uid 1000 can read it). Mount the directory, never the file: the
script replaces it by `rename`. Without this mount `GET /backups` still
answers, with `vps: null`.
Two layers keep the API out of these files: the `:ro` mode (a suffix on the Two layers keep the API out of these files: the `:ro` mode (a suffix on the
Coolify destination path, copied as-is into the generated compose), and Unix Coolify destination path, copied as-is into the generated compose), and Unix
@ -233,8 +292,8 @@ only while the owning group is not gid 1000 (`node` in the image, `ubuntu` on
the host): a `chgrp ubuntu` would drop it silently. the host): a `chgrp ubuntu` would drop it silently.
`__tests__/writes.test.js` pins that `node:fs` writes go nowhere but `__tests__/writes.test.js` pins that `node:fs` writes go nowhere but
`HOSTS_DIR` — and, for `POST /claude-usage` only, the directory of `HOSTS_DIR` — and, for `POST /claude-usage` and `POST /backups/pull` only, the
`CLAUDE_USAGE_PATH` — and that the only shell commands are the two read-only directory of `CLAUDE_USAGE_PATH` and of `BACKUPS_PULL_PATH` respectively — and that the only shell commands are the two read-only
ones in `metrics.js`, on every path it exercises: boot, nominal reads and ones in `metrics.js`, on every path it exercises: boot, nominal reads and
ingest, rejections, invalid input, a corrupt file, missing files. ingest, rejections, invalid input, a corrupt file, missing files.
@ -246,6 +305,10 @@ Read-write — the API is the writer:
`CLAUDE_USAGE_PATH`). Same rule: read-write, owned by uid 1000, otherwise `CLAUDE_USAGE_PATH`). Same rule: read-write, owned by uid 1000, otherwise
every `POST /claude-usage` 500s. Mount the directory, never the file: the every `POST /claude-usage` 500s. Mount the directory, never the file: the
snapshot is replaced by `rename`, which a file bind would not follow. snapshot is replaced by `rename`, which a file bind would not follow.
- `<host>/data/backups-pull/` -> `/data/backups-pull/` (directory of
`BACKUPS_PULL_PATH`). Same rule: read-write, owned by uid 1000, the directory
and not the file. Not writable, every `POST /backups/pull` answers 500 while
`GET /backups` keeps serving the VPS block.
## Workstation agent ## Workstation agent

View file

@ -29,6 +29,9 @@ const ROUTES = [
// #55. GET /claude-usage is not listed: POST /claude-usage exists, so the // #55. GET /claude-usage is not listed: POST /claude-usage exists, so the
// "404 on POST" row below does not hold for it (claude-usage.test.js covers it). // "404 on POST" row below does not hold for it (claude-usage.test.js covers it).
"/claude-usage/history", "/claude-usage/history",
// #58. POST /backups does not exist (the write is POST /backups/pull), so
// the "404 on POST" row holds.
"/backups",
]; ];
// Ingest token, distinct from the read token on purpose: a read token must not // Ingest token, distinct from the read token on purpose: a read token must not
@ -87,6 +90,8 @@ beforeEach(async () => {
process.env.HOSTS_DIR = path.join(tmpDir, "hosts"); process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
process.env.HOSTS_ALLOWED_IDS = "thinkpad"; process.env.HOSTS_ALLOWED_IDS = "thinkpad";
process.env.CLAUDE_USAGE_PATH = path.join(tmpDir, "claude-usage", "claude-usage.json"); process.env.CLAUDE_USAGE_PATH = path.join(tmpDir, "claude-usage", "claude-usage.json");
process.env.BACKUPS_STATUS_PATH = path.join(tmpDir, "backups-status", "status.json");
process.env.BACKUPS_PULL_PATH = path.join(tmpDir, "backups-pull", "pull.json");
// Configured on purpose: an unset ingest token answers 503 before the header // Configured on purpose: an unset ingest token answers 503 before the header
// is ever read, which would hide the 401 these tests are here to pin. // is ever read, which would hide the 401 these tests are here to pin.
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN; process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
@ -104,6 +109,8 @@ afterEach(async () => {
afterAll(() => { afterAll(() => {
delete process.env.CLAUDE_USAGE_PATH; delete process.env.CLAUDE_USAGE_PATH;
delete process.env.BACKUPS_STATUS_PATH;
delete process.env.BACKUPS_PULL_PATH;
}); });
describe("auth gate — missing Authorization header", () => { describe("auth gate — missing Authorization header", () => {

757
__tests__/backups.test.js Normal file
View file

@ -0,0 +1,757 @@
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
// PostgreSQL backups (issue #58): GET /backups read by the admin dashboard and
// /warmup, POST /backups/pull sent by the workstation pull agent.
//
// Four things are pinned here.
// * The two validators, as pure functions (backups.js): status.json goes
// through a whitelist rebuild where an invalid value is dropped, never
// coerced; the pull body is validated strictly, every bad field a 400.
// * The gate: a fourth token kind, `backups-ingest`, isolated both ways from
// the three others, failing closed to a 503 that names its variable.
// * The read: no_data only when both files are absent, a broken file giving
// a null block plus its error code (never a 500), and the freshness of
// each block decided by the server (VPS on generatedAt, workstation on the
// server-stamped receivedAt).
// * The write: ceiling, atomic write in mode 600, server receivedAt.
const TOKEN = "test-token";
const INGEST_TOKEN = "test-ingest-token";
const CLAUDE_TOKEN = "test-claude-token";
const BACKUPS_TOKEN = "test-backups-token";
// Restated rather than imported, so a silent change to a constant turns the
// matrix red instead of quietly re-drawing it.
const BACKUPS_MAX_BODY_BYTES = 1024;
const DEFAULT_STALE_SECONDS = 129600;
const FROZEN_NOW = Date.UTC(2026, 9, 11, 12, 0, 0);
let tmpDir;
let statusPath;
let pullPath;
let server;
let baseUrl;
// What backup-postgres.sh writes, rotation block included (issue comment of
// 2026-10-10), one failed database among them.
function validStatus() {
return {
schemaVersion: 1,
generatedAt: "2026-10-11T03:04:12Z",
ok: false,
expectedCount: 2,
databases: [
{ name: "maximus_api", sizeBytes: 812345, dumpedAt: "2026-10-11T03:00:41Z", ok: true },
{ name: "roadtrip", sizeBytes: 0, dumpedAt: null, ok: false, error: "dump_failed" },
],
globals: { sizeBytes: 4096, ok: true },
mirror: { ok: true, uploadedAt: "2026-10-11T03:04:10Z" },
rotation: { ok: true },
retentionDays: { local: 7, offsite: 30 },
};
}
// What run-pg-backup-pull.sh sends.
function validPull() {
return { pulledAt: "2026-10-11T04:10:00Z", files: 9, ok: true };
}
function freshModule() {
delete require.cache[require.resolve("../backups.js")];
return require("../backups.js");
}
function startServer() {
for (const mod of ["../index.js", "../backups.js", "../claude-usage.js"]) {
delete require.cache[require.resolve(mod)];
}
const { handler } = require("../index.js");
server = http.createServer(handler);
return new Promise((resolve) => {
server.listen(0, () => {
baseUrl = `http://127.0.0.1:${server.address().port}`;
resolve();
});
});
}
function stopServer() {
return new Promise((resolve) => {
if (!server) return resolve();
server.close(() => resolve());
});
}
async function restartServer() {
await stopServer();
await startServer();
}
async function request(route, { method = "GET", auth, body } = {}) {
const headers = {};
if (auth) headers.Authorization = auth;
if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(`${baseUrl}${route}`, { method, headers, body });
const parsed = await res.json().catch(() => ({}));
return { status: res.status, body: parsed };
}
function push({ auth = `Bearer ${BACKUPS_TOKEN}`, body = JSON.stringify(validPull()) } = {}) {
return request("/backups/pull", { method: "POST", auth, body });
}
function read(auth = `Bearer ${TOKEN}`) {
return request("/backups", { auth });
}
async function frozenRead() {
const realNow = Date.now;
Date.now = () => FROZEN_NOW;
try {
return await read();
} finally {
Date.now = realNow;
}
}
// The 413 must come back as a status; a dead socket is reported as such.
async function pushOversized(body) {
try {
return (await push({ body })).status;
} catch (err) {
return `transport error instead of a status: ${err.cause?.code || err.code || err.message}`;
}
}
// A JSON body of exactly `bytes` bytes around a payload that is otherwise valid.
function bodyOfExactly(bytes) {
const padded = { ...validPull(), padding: "" };
const overhead = JSON.stringify(padded).length;
return JSON.stringify({ ...padded, padding: "x".repeat(bytes - overhead) });
}
function writeFile(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, typeof value === "string" ? value : JSON.stringify(value));
}
function iso(ms) {
return new Date(ms).toISOString();
}
// Silences the expected [backups] log lines and hands them back.
async function withQuietErrors(fn) {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const result = await fn();
return { result, lines: error.mock.calls.map((args) => args.join(" ")) };
} finally {
error.mockRestore();
}
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-backups-test-"));
statusPath = path.join(tmpDir, "backups-status", "status.json");
pullPath = path.join(tmpDir, "backups-pull", "pull.json");
process.env.HEALTH_TOKEN = TOKEN;
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
process.env.CLAUDE_USAGE_INGEST_TOKEN = CLAUDE_TOKEN;
process.env.BACKUPS_INGEST_TOKEN = BACKUPS_TOKEN;
process.env.BACKUPS_STATUS_PATH = statusPath;
process.env.BACKUPS_PULL_PATH = pullPath;
delete process.env.BACKUPS_STALE_SECONDS;
process.env.CLAUDE_USAGE_PATH = path.join(tmpDir, "claude-usage", "claude-usage.json");
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
process.env.REPORTS_DIR = path.join(tmpDir, "reports");
process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json");
process.env.DEFENSEURS_STATUS_PATH = path.join(tmpDir, "status.json");
process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(tmpDir, "findings-state.json");
// Closed port: no request may reach the real IdP.
process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration";
await startServer();
});
afterEach(async () => {
await stopServer();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
afterAll(() => {
// Other files reuse the worker: leave no backups config behind.
for (const name of [
"BACKUPS_INGEST_TOKEN",
"BACKUPS_STATUS_PATH",
"BACKUPS_PULL_PATH",
"BACKUPS_STALE_SECONDS",
"CLAUDE_USAGE_INGEST_TOKEN",
"CLAUDE_USAGE_PATH",
]) {
delete process.env[name];
}
});
// --- status.json, as a pure function ----------------------------------------
describe("sanitizeBackupsStatus — the whitelist", () => {
test("the status the VPS script writes comes back identical, rotation and retentionDays included", () => {
const { sanitizeBackupsStatus } = freshModule();
expect(sanitizeBackupsStatus(validStatus())).toEqual({ status: validStatus() });
});
test("it is idempotent, so the read path can run it on any stored file", () => {
const { sanitizeBackupsStatus } = freshModule();
const noisy = { ...validStatus(), extra: 1, expectedCount: -1 };
const once = sanitizeBackupsStatus(noisy).status;
expect(sanitizeBackupsStatus(once).status).toEqual(once);
});
test("the nullable fields keep an explicit null", () => {
const { sanitizeBackupsStatus } = freshModule();
const payload = validStatus();
payload.mirror = { ok: false, uploadedAt: null, error: "mirror_upload_failed" };
const { status } = sanitizeBackupsStatus(payload);
expect(status.databases[1].dumpedAt).toBeNull();
expect(status.mirror).toEqual({ ok: false, uploadedAt: null, error: "mirror_upload_failed" });
});
test("an unknown field is dropped at every level, __proto__ included", () => {
const { sanitizeBackupsStatus } = freshModule();
const payload = JSON.parse(JSON.stringify(validStatus()));
payload.stderr = "pg_dump: connection to host 10.0.0.5 failed";
payload.databases[0].host = "10.0.0.5";
payload.globals.path = "/var/backups/postgres/globals.sql";
payload.mirror.endpoint = "http://minio:9000";
payload.rotation.removed = ["2026-10-01"];
payload.retentionDays.mirror = 7;
const hostile = JSON.parse(`{"__proto__": {"polluted": true}, ${JSON.stringify(payload).slice(1)}`);
const { status } = sanitizeBackupsStatus(hostile);
expect(status).toEqual(validStatus());
expect(Object.getPrototypeOf(status)).toBe(Object.prototype);
expect({}.polluted).toBeUndefined();
});
test("an error code outside the closed list is dropped, ok:false is kept", () => {
const { sanitizeBackupsStatus } = freshModule();
const payload = validStatus();
payload.databases[1].error = "pg_dump: FATAL: password authentication failed";
payload.globals = { sizeBytes: 0, ok: false, error: "Dump_Failed" };
payload.mirror = { ok: false, uploadedAt: null, error: "rclone: 403 Forbidden" };
payload.rotation = { ok: false, error: "rm: cannot remove" };
const { status } = sanitizeBackupsStatus(payload);
expect(status.databases[1]).toEqual({ name: "roadtrip", sizeBytes: 0, dumpedAt: null, ok: false });
expect(status.globals).toEqual({ sizeBytes: 0, ok: false });
expect(status.mirror).toEqual({ ok: false, uploadedAt: null });
expect(status.rotation).toEqual({ ok: false });
});
test.each([
"dump_failed",
"dump_empty",
"restore_list_failed",
"invalid_name",
"encrypt_failed",
"mirror_upload_failed",
"mirror_purge_failed",
"rotation_failed",
])("the code %s is kept", (code) => {
const { sanitizeBackupsStatus } = freshModule();
const payload = validStatus();
payload.databases[1].error = code;
payload.rotation = { ok: false, error: code };
const { status } = sanitizeBackupsStatus(payload);
expect(status.databases[1].error).toBe(code);
expect(status.rotation.error).toBe(code);
});
test("an invalid value is dropped, never coerced", () => {
const { sanitizeBackupsStatus } = freshModule();
const payload = validStatus();
payload.ok = "true";
payload.expectedCount = 2.5;
payload.databases[0] = { name: "maximus_api", sizeBytes: -1, dumpedAt: "2026-10-11", ok: 1 };
payload.globals.sizeBytes = 2 ** 53;
payload.mirror.uploadedAt = "2026-10-11T03:04:10";
payload.retentionDays = { local: "7", offsite: 30 };
const { status } = sanitizeBackupsStatus(payload);
expect(status).not.toHaveProperty("ok");
expect(status).not.toHaveProperty("expectedCount");
expect(status.databases[0]).toEqual({ name: "maximus_api" });
expect(status.globals).toEqual({ ok: true });
expect(status.mirror).toEqual({ ok: true });
expect(status.retentionDays).toEqual({ offsite: 30 });
});
test("a database name the VPS script refused is dropped, the entry and its code kept", () => {
const { sanitizeBackupsStatus } = freshModule();
const payload = validStatus();
payload.databases[1] = { name: "Bad Name <script>", sizeBytes: 0, dumpedAt: null, ok: false, error: "invalid_name" };
payload.databases.push({ name: "x".repeat(64), sizeBytes: 1, dumpedAt: null, ok: true });
const { status } = sanitizeBackupsStatus(payload);
expect(status.databases[1]).toEqual({ sizeBytes: 0, dumpedAt: null, ok: false, error: "invalid_name" });
expect(status.databases[2]).not.toHaveProperty("name");
expect(status.databases).toHaveLength(3);
});
test("databases past 64 entries keep the first 64, non-objects are skipped", () => {
const { sanitizeBackupsStatus, MAX_DATABASES } = freshModule();
expect(MAX_DATABASES).toBe(64);
const payload = validStatus();
payload.databases = [
"not an object",
null,
...Array.from({ length: 70 }, (_, i) => ({ name: `db_${i}`, sizeBytes: i, dumpedAt: null, ok: true })),
];
const { status } = sanitizeBackupsStatus(payload);
expect(status.databases).toHaveLength(64);
expect(status.databases[0].name).toBe("db_0");
expect(status.databases[63].name).toBe("db_63");
});
test("a component that is not an object is dropped", () => {
const { sanitizeBackupsStatus } = freshModule();
const payload = { ...validStatus(), globals: [], mirror: "ok", rotation: null, retentionDays: 7, databases: {} };
const { status } = sanitizeBackupsStatus(payload);
for (const key of ["globals", "mirror", "rotation", "retentionDays", "databases"]) {
expect(status).not.toHaveProperty(key);
}
});
test.each([
["an array", [], "invalid_contract"],
["null", null, "invalid_contract"],
["a string", "status", "invalid_contract"],
["schemaVersion 2", { ...validStatus(), schemaVersion: 2 }, "unsupported_schema"],
["schemaVersion \"1\"", { ...validStatus(), schemaVersion: "1" }, "unsupported_schema"],
["no schemaVersion", { ...validStatus(), schemaVersion: undefined }, "unsupported_schema"],
["no generatedAt", { ...validStatus(), generatedAt: undefined }, "invalid_contract"],
["a bare-date generatedAt", { ...validStatus(), generatedAt: "2026-10-11" }, "invalid_contract"],
["a generatedAt without offset", { ...validStatus(), generatedAt: "2026-10-11T03:04:12" }, "invalid_contract"],
])("refused as a whole: %s -> %s", (_label, payload, code) => {
const { sanitizeBackupsStatus } = freshModule();
expect(sanitizeBackupsStatus(payload)).toEqual({ error: code });
});
});
// --- The pull body, as a pure function --------------------------------------
describe("validateBackupsPull — strict", () => {
test("the bodies the agent sends pass, and only the four fields are kept", () => {
const { validateBackupsPull } = freshModule();
expect(validateBackupsPull(validPull())).toEqual({ pull: validPull() });
for (const error of ["pull_failed", "rotation_failed"]) {
const body = { pulledAt: "2026-10-11T04:10:00Z", files: 0, ok: false, error };
expect(validateBackupsPull(body)).toEqual({ pull: body });
}
const noisy = { ...validPull(), receivedAt: "2000-01-01T00:00:00Z", host: "max-ThinkPad-T490" };
expect(validateBackupsPull(noisy)).toEqual({ pull: validPull() });
});
test.each([
["an array", []],
["null", null],
["no pulledAt", { files: 9, ok: true }],
["a bare-date pulledAt", { ...validPull(), pulledAt: "2026-10-11" }],
["a pulledAt that is not a date", { ...validPull(), pulledAt: "2026-02-31T00:00:00Z!" }],
["no files", { pulledAt: "2026-10-11T04:10:00Z", ok: true }],
["negative files", { ...validPull(), files: -1 }],
["fractional files", { ...validPull(), files: 1.5 }],
["files as a string", { ...validPull(), files: "9" }],
["files past 2^53", { ...validPull(), files: 2 ** 53 }],
["no ok", { pulledAt: "2026-10-11T04:10:00Z", files: 9 }],
["ok as a string", { ...validPull(), ok: "true" }],
["ok:false without an error", { ...validPull(), ok: false }],
["ok:false with a code outside the list", { ...validPull(), ok: false, error: "dump_failed" }],
["ok:false with free text", { ...validPull(), ok: false, error: "rsync: connection unexpectedly closed" }],
["ok:true with an error", { ...validPull(), error: "pull_failed" }],
])("refused: %s", (_label, payload) => {
const { validateBackupsPull } = freshModule();
expect(validateBackupsPull(payload).error).toEqual(expect.any(String));
});
});
// --- The gate ---------------------------------------------------------------
describe("POST /backups/pull — authentication", () => {
test("503 naming its own variable when BACKUPS_INGEST_TOKEN is not set", async () => {
delete process.env.BACKUPS_INGEST_TOKEN;
await restartServer();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const res = await push();
expect(res.status).toBe(503);
expect(res.body).toEqual({ error: "BACKUPS_INGEST_TOKEN not configured" });
} finally {
warn.mockRestore();
}
expect(fs.existsSync(pullPath)).toBe(false);
});
test("the other routes still answer when BACKUPS_INGEST_TOKEN is not set", async () => {
delete process.env.BACKUPS_INGEST_TOKEN;
await restartServer();
expect((await read()).status).toBe(200);
});
test.each([
["no Authorization header", null],
["a wrong token", "Bearer wrong-token"],
["the read token", `Bearer ${TOKEN}`],
["the hosts ingest token", `Bearer ${INGEST_TOKEN}`],
["the Claude usage ingest token", `Bearer ${CLAUDE_TOKEN}`],
["the backups token without the Bearer scheme", BACKUPS_TOKEN],
])("401 with %s, nothing written", async (_label, auth) => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const res = await push({ auth });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "Unauthorized" });
} finally {
warn.mockRestore();
}
expect(fs.existsSync(pullPath)).toBe(false);
});
test("each rejection is logged without the token", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await push({ auth: `Bearer ${CLAUDE_TOKEN}` });
const lines = warn.mock.calls.map((args) => args.join(" "));
expect(lines).toEqual([expect.stringMatching(/^\[auth\] 401 POST \/backups\/pull /)]);
expect(lines.join("\n")).not.toContain(CLAUDE_TOKEN);
} finally {
warn.mockRestore();
}
});
test.each([
["GET /backups", "/backups", "GET"],
["GET /hosts", "/hosts", "GET"],
["GET /claude-usage", "/claude-usage", "GET"],
["POST /hosts/thinkpad", "/hosts/thinkpad", "POST"],
["POST /claude-usage", "/claude-usage", "POST"],
])("the backups token opens nothing else: 401 on %s", async (_label, route, method) => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const body = method === "POST" ? "{}" : undefined;
const res = await request(route, { method, auth: `Bearer ${BACKUPS_TOKEN}`, body });
expect(res.status).toBe(401);
} finally {
warn.mockRestore();
}
});
test.each([
["no token", null],
["the backups ingest token", `Bearer ${BACKUPS_TOKEN}`],
])("401 on GET /backups with %s", async (_label, auth) => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
expect((await read(auth)).status).toBe(401);
} finally {
warn.mockRestore();
}
});
// Routing comes first: an unauthenticated caller on a route that does not
// exist sees 404, never 401.
test.each([
["GET", "/backups/pull"],
["POST", "/backups"],
["PUT", "/backups/pull"],
["GET", "/backups/"],
])("404 (not 401) on %s %s without a token", async (method, route) => {
const res = await request(route, { method });
expect(res.status).toBe(404);
});
test("the comparison goes through timingSafeEqual on two digests", async () => {
const crypto = require("node:crypto");
const spy = vi.spyOn(crypto, "timingSafeEqual");
try {
await restartServer();
expect((await push({ auth: "Bearer x" })).status).toBe(401);
expect(spy).toHaveBeenCalled();
for (const [a, b] of spy.mock.calls) {
expect([a.length, b.length]).toEqual([32, 32]);
}
} finally {
spy.mockRestore();
}
});
});
// --- Writing ----------------------------------------------------------------
describe("POST /backups/pull — validation and write", () => {
test("204, the file holds the four fields plus a server receivedAt, mode 600", async () => {
const before = Date.now();
const sent = { ...validPull(), extra: "dropped", receivedAt: "2000-01-01T00:00:00.000Z" };
const res = await push({ body: JSON.stringify(sent) });
expect(res.status).toBe(204);
const { receivedAt, ...stored } = JSON.parse(fs.readFileSync(pullPath, "utf-8"));
expect(stored).toEqual(validPull());
// The client's receivedAt is ignored: the server stamps its own.
expect(new Date(receivedAt).getTime()).toBeGreaterThanOrEqual(before - 1000);
expect(new Date(receivedAt).getTime()).toBeLessThanOrEqual(Date.now());
expect(fs.statSync(pullPath).mode & 0o777).toBe(0o600);
// Atomic write: no temp file left beside the report.
expect(fs.readdirSync(path.dirname(pullPath))).toEqual(["pull.json"]);
});
test("204 on a failed pull report, its code stored", async () => {
const body = { pulledAt: "2026-10-11T04:10:00Z", files: 0, ok: false, error: "pull_failed" };
expect((await push({ body: JSON.stringify(body) })).status).toBe(204);
const { receivedAt, ...stored } = JSON.parse(fs.readFileSync(pullPath, "utf-8"));
expect(stored).toEqual(body);
expect(receivedAt).toEqual(expect.any(String));
});
test("a second report replaces the first", async () => {
await push();
expect((await push({ body: JSON.stringify({ ...validPull(), files: 10 }) })).status).toBe(204);
expect(JSON.parse(fs.readFileSync(pullPath, "utf-8")).files).toBe(10);
});
test.each([
["not JSON", "{not json", "Bad request: body must be valid JSON"],
["a JSON array", "[]", "Bad request: body must be a JSON object"],
["a missing field", JSON.stringify({ pulledAt: "2026-10-11T04:10:00Z", ok: true }), "Bad request: files must be a non-negative integer"],
[
"an error code outside the list",
JSON.stringify({ ...validPull(), ok: false, error: "dump_failed" }),
"Bad request: error must be one of pull_failed,rotation_failed when ok is false",
],
])("400 on %s, nothing written", async (_label, body, message) => {
const res = await push({ body });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: message });
expect(fs.existsSync(pullPath)).toBe(false);
});
test("400 leaves the previous report in place", async () => {
await push();
const before = fs.readFileSync(pullPath, "utf-8");
expect((await push({ body: JSON.stringify({ ...validPull(), files: -1 }) })).status).toBe(400);
expect(fs.readFileSync(pullPath, "utf-8")).toBe(before);
});
// 413, not the 400 the issue named: same answer as the two other ingest
// routes, from the same readBody(). The agent only checks for a 2xx.
test("204 at exactly 1024 bytes, 413 at 1025, nothing written", async () => {
expect(Buffer.byteLength(bodyOfExactly(BACKUPS_MAX_BODY_BYTES))).toBe(1024);
expect(await pushOversized(bodyOfExactly(BACKUPS_MAX_BODY_BYTES))).toBe(204);
fs.rmSync(pullPath);
expect(await pushOversized(bodyOfExactly(BACKUPS_MAX_BODY_BYTES + 1))).toBe(413);
expect(fs.existsSync(pullPath)).toBe(false);
});
test("500 when the rename fails after the temp file is written, and the temp file is removed", async () => {
// The target is a non-empty directory: mkdir and the temp write succeed,
// renameSync fails, and only writeJsonAtomic() can remove the temp file.
fs.mkdirSync(path.join(pullPath, "occupied"), { recursive: true });
const { result, lines } = await withQuietErrors(() => push());
expect(result.status).toBe(500);
expect(result.body).toEqual({ error: "Internal error" });
expect(fs.readdirSync(path.dirname(pullPath))).toEqual(["pull.json"]);
// The log names the error code, never the path.
expect(lines).toEqual([expect.stringMatching(/^\[backups\] failed to persist pull report \(E[A-Z]+\)$/)]);
expect(lines.join("\n")).not.toContain(tmpDir);
});
});
// --- Reading ----------------------------------------------------------------
describe("GET /backups", () => {
test("no file at all -> { status: \"no_data\" }", async () => {
const res = await read();
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: "no_data" });
});
test("both blocks, each with the server's freshness, under a frozen clock", async () => {
writeFile(statusPath, validStatus());
writeFile(pullPath, { ...validPull(), receivedAt: "2026-10-11T04:10:05.000Z" });
const res = await frozenRead();
expect(res.status).toBe(200);
expect(res.body).toEqual({
vps: {
...validStatus(),
// 2026-10-11T03:04:12Z -> 12:00:00Z
ageSeconds: 8 * 3600 + 55 * 60 + 48,
stale: false,
staleAfterSeconds: DEFAULT_STALE_SECONDS,
},
lenovo: {
...validPull(),
receivedAt: "2026-10-11T04:10:05.000Z",
ageSeconds: 7 * 3600 + 49 * 60 + 55,
stale: false,
staleAfterSeconds: DEFAULT_STALE_SECONDS,
},
});
});
test("the workstation is aged on the server's receivedAt, never on its pulledAt", async () => {
// pulledAt from a clock three days behind; received an hour ago.
writeFile(pullPath, { ...validPull(), pulledAt: iso(FROZEN_NOW - 3 * 86400 * 1000), receivedAt: iso(FROZEN_NOW - 3600 * 1000) });
const { body } = await frozenRead();
expect(body.lenovo.ageSeconds).toBe(3600);
expect(body.lenovo.stale).toBe(false);
});
test("POST then GET: what the agent sent, with the receivedAt the server stamped", async () => {
expect((await push()).status).toBe(204);
const { receivedAt } = JSON.parse(fs.readFileSync(pullPath, "utf-8"));
const { status, body } = await read();
expect(status).toBe(200);
expect(body.vps).toBeNull();
expect(body).not.toHaveProperty("vpsError");
expect(body.lenovo).toEqual({
...validPull(),
receivedAt,
ageSeconds: expect.any(Number),
stale: false,
staleAfterSeconds: DEFAULT_STALE_SECONDS,
});
expect(body.lenovo.ageSeconds).toBeLessThanOrEqual(1);
});
test("VPS only: lenovo is null, with no lenovoError", async () => {
writeFile(statusPath, validStatus());
const { body } = await read();
expect(body.lenovo).toBeNull();
expect(body).not.toHaveProperty("lenovoError");
expect(body.vps.generatedAt).toBe("2026-10-11T03:04:12Z");
});
test.each([
["the VPS block", () => writeFile(statusPath, { ...validStatus(), generatedAt: iso(FROZEN_NOW - DEFAULT_STALE_SECONDS * 1000) }), "vps"],
["the workstation block", () => writeFile(pullPath, { ...validPull(), receivedAt: iso(FROZEN_NOW - DEFAULT_STALE_SECONDS * 1000) }), "lenovo"],
])("%s: fresh at exactly 36 h, stale one second later", async (_label, write, key) => {
write();
const fresh = (await frozenRead()).body[key];
expect([fresh.ageSeconds, fresh.stale]).toEqual([DEFAULT_STALE_SECONDS, false]);
const realNow = Date.now;
Date.now = () => FROZEN_NOW + 1000;
try {
const stale = (await read()).body[key];
expect([stale.ageSeconds, stale.stale]).toEqual([DEFAULT_STALE_SECONDS + 1, true]);
} finally {
Date.now = realNow;
}
});
test("a generatedAt in the future gives an age of 0, not a negative one", async () => {
writeFile(statusPath, { ...validStatus(), generatedAt: iso(FROZEN_NOW + 3600 * 1000) });
const { body } = await frozenRead();
expect([body.vps.ageSeconds, body.vps.stale]).toEqual([0, false]);
});
test("BACKUPS_STALE_SECONDS moves the threshold", async () => {
process.env.BACKUPS_STALE_SECONDS = "60";
await restartServer();
writeFile(statusPath, { ...validStatus(), generatedAt: iso(FROZEN_NOW - 61 * 1000) });
const { body } = await frozenRead();
expect([body.vps.staleAfterSeconds, body.vps.stale]).toEqual([60, true]);
});
test.each(["abc", "0", "-5", ""])("BACKUPS_STALE_SECONDS=%j falls back to the default", async (value) => {
process.env.BACKUPS_STALE_SECONDS = value;
await restartServer();
writeFile(statusPath, validStatus());
const { body } = await frozenRead();
expect(body.vps.staleAfterSeconds).toBe(DEFAULT_STALE_SECONDS);
});
test("an unknown field in status.json is not returned", async () => {
const hostile = validStatus();
hostile.stderr = "pg_dump: password authentication failed for user postgres";
hostile.databases[0].path = "/var/backups/postgres/2026-10-11/maximus_api.dump";
hostile.mirror.error = "rclone: AccessDenied for key AKIA";
writeFile(statusPath, hostile);
const { body } = await read();
const text = JSON.stringify(body);
for (const leak of ["stderr", "password", "/var/backups", "AKIA"]) {
expect(text).not.toContain(leak);
}
expect(body.vps.databases[0]).toEqual(validStatus().databases[0]);
});
test.each([
["not JSON", "{corrupt", "invalid_json"],
["schemaVersion 2", JSON.stringify({ ...validStatus(), schemaVersion: 2 }), "unsupported_schema"],
["no generatedAt", JSON.stringify({ ...validStatus(), generatedAt: undefined }), "invalid_contract"],
["a JSON array", "[]", "invalid_contract"],
])("status.json %s: vps null + vpsError %s, the workstation still served, never a 500", async (_label, content, code) => {
writeFile(statusPath, content);
writeFile(pullPath, { ...validPull(), receivedAt: new Date().toISOString() });
const { result, lines } = await withQuietErrors(() => read());
expect(result.status).toBe(200);
expect(result.body.vps).toBeNull();
expect(result.body.vpsError).toBe(code);
expect(result.body.lenovo.files).toBe(9);
expect(lines).toHaveLength(1);
expect(lines[0]).toMatch(/^\[backups\] status\.json /);
});
test("status.json broken and no pull file: the error is reported, not hidden behind no_data", async () => {
writeFile(statusPath, "{corrupt");
const { result } = await withQuietErrors(() => read());
expect(result.body).toEqual({ vps: null, lenovo: null, vpsError: "invalid_json" });
});
test("status.json that cannot be read (a directory): vpsError unreadable, the code logged, not the path", async () => {
fs.mkdirSync(statusPath, { recursive: true });
const { result, lines } = await withQuietErrors(() => read());
expect(result.status).toBe(200);
expect(result.body).toEqual({ vps: null, lenovo: null, vpsError: "unreadable" });
expect(lines).toEqual(["[backups] status.json unreadable (EISDIR)"]);
});
test.each([
["not JSON", "{corrupt", "invalid_json"],
["no receivedAt", JSON.stringify(validPull()), "invalid_contract"],
["a receivedAt that is not a full ISO date", JSON.stringify({ ...validPull(), receivedAt: "1" }), "invalid_contract"],
["an invalid field", JSON.stringify({ ...validPull(), files: -1, receivedAt: new Date().toISOString() }), "invalid_contract"],
])("pull.json %s: lenovo null + lenovoError %s, the VPS still served", async (_label, content, code) => {
writeFile(statusPath, validStatus());
writeFile(pullPath, content);
const { result } = await withQuietErrors(() => read());
expect(result.status).toBe(200);
expect(result.body.lenovo).toBeNull();
expect(result.body.lenovoError).toBe(code);
expect(result.body.vps.expectedCount).toBe(2);
});
test("an unknown field in pull.json is not returned", async () => {
writeFile(pullPath, { ...validPull(), receivedAt: new Date().toISOString(), note: "hello" });
const { body } = await read();
expect(body.lenovo).not.toHaveProperty("note");
});
test("GET /backups writes nothing, even on a corrupt file", async () => {
writeFile(statusPath, "{corrupt");
writeFile(pullPath, "{corrupt");
await withQuietErrors(() => read());
expect(fs.readFileSync(statusPath, "utf-8")).toBe("{corrupt");
expect(fs.readdirSync(path.dirname(statusPath))).toEqual(["status.json"]);
expect(fs.readdirSync(path.dirname(pullPath))).toEqual(["pull.json"]);
});
});
describe("packaging", () => {
test("the Dockerfile COPY line ships backups.js", () => {
const dockerfile = fs.readFileSync(path.join(__dirname, "..", "Dockerfile"), "utf-8");
const copy = dockerfile.split("\n").find((line) => /^\s*COPY\s/.test(line));
expect(copy.trim().split(/\s+/)).toContain("backups.js");
});
});

View file

@ -342,7 +342,7 @@ describe("test-curl.sh", () => {
expect(proxied).toEqual([]); expect(proxied).toEqual([]);
expect(code, stdout + stderr).toBe(0); expect(code, stdout + stderr).toBe(0);
// Every case ran against the API: a count, so a script cut short fails. // Every case ran against the API: a count, so a script cut short fails.
expect(stdout).toMatch(/=== Results: 51 passed, 0 failed ===/); expect(stdout).toMatch(/=== Results: 57 passed, 0 failed ===/);
} finally { } finally {
await new Promise((resolve) => proxy.close(resolve)); await new Promise((resolve) => proxy.close(resolve));
} }
@ -361,7 +361,7 @@ describe("test-curl.sh", () => {
for (const { code, stdout, stderr } of results) { for (const { code, stdout, stderr } of results) {
expect(code, stdout + stderr).toBe(0); expect(code, stdout + stderr).toBe(0);
expect(stdout).toMatch(/=== Results: 51 passed, 0 failed ===/); expect(stdout).toMatch(/=== Results: 57 passed, 0 failed ===/);
} }
} finally { } finally {
for (const port of ports) expect(await released(port)).toBe(true); for (const port of ports) expect(await released(port)).toBe(true);

View file

@ -739,7 +739,7 @@ describe("POST /claude-usage — payload", () => {
test("500 when the rename fails after the temp file is written, and the temp file is removed", async () => { test("500 when the rename fails after the temp file is written, and the temp file is removed", async () => {
// The target is a non-empty directory: mkdir and the temp write succeed, // The target is a non-empty directory: mkdir and the temp write succeed,
// renameSync fails. Only the cleanup in writeClaudeUsage() removes the // renameSync fails. Only the cleanup in writeJsonAtomic() removes the
// temp file (#54: the mkdir case below never reaches it). // temp file (#54: the mkdir case below never reaches it).
fs.mkdirSync(path.join(usagePath, "occupied"), { recursive: true }); fs.mkdirSync(path.join(usagePath, "occupied"), { recursive: true });
const error = vi.spyOn(console, "error").mockImplementation(() => {}); const error = vi.spyOn(console, "error").mockImplementation(() => {});

View file

@ -5,10 +5,13 @@ const path = require("node:path");
const child_process = require("node:child_process"); const child_process = require("node:child_process");
const { fileURLToPath } = require("node:url"); const { fileURLToPath } = require("node:url");
// The API's write surfaces are HOSTS_DIR (issue #36) and, since #48, the // The API's write surfaces are HOSTS_DIR (issue #36), since #48 the
// directory of CLAUDE_USAGE_PATH, written by POST /claude-usage only. Every // directory of CLAUDE_USAGE_PATH, written by POST /claude-usage only, and since
// case below allows HOSTS_DIR alone, except the POST /claude-usage one, which // #58 the directory of BACKUPS_PULL_PATH, written by POST /backups/pull only.
// allows its own directory alone. // Every case below allows HOSTS_DIR alone, except the two POST cases of those
// routes, each of which allows its own directory alone. The directory of
// BACKUPS_STATUS_PATH is a read-only mount in production, like the Defenseurs
// ones: nothing may ever write there.
// //
// Since 2026-09-30 the two Defenseurs mounts are read-only in the container // Since 2026-09-30 the two Defenseurs mounts are read-only in the container
// (`:ro` on the Coolify destination). A write added one day under REPORTS_DIR // (`:ro` on the Coolify destination). A write added one day under REPORTS_DIR
@ -47,6 +50,7 @@ const REPO_ROOT = path.resolve(__dirname, "..");
const TOKEN = "test-token"; const TOKEN = "test-token";
const INGEST_TOKEN = "test-ingest-token"; const INGEST_TOKEN = "test-ingest-token";
const CLAUDE_TOKEN = "test-claude-token"; const CLAUDE_TOKEN = "test-claude-token";
const BACKUPS_TOKEN = "test-backups-token";
const ORPHAN = ".thinkpad.4242.1700000000000.tmp"; const ORPHAN = ".thinkpad.4242.1700000000000.tmp";
// The real functions, taken at load time, before any spy: the test's own // The real functions, taken at load time, before any spy: the test's own
@ -214,6 +218,24 @@ function validClaudeUsage() {
}; };
} }
function validBackupsStatus() {
return {
schemaVersion: 1,
generatedAt: "2026-10-11T03:04:12Z",
ok: true,
expectedCount: 1,
databases: [{ name: "maximus_api", sizeBytes: 812345, dumpedAt: "2026-10-11T03:00:41Z", ok: true }],
globals: { sizeBytes: 4096, ok: true },
mirror: { ok: true, uploadedAt: "2026-10-11T03:04:10Z" },
rotation: { ok: true },
retentionDays: { local: 7, offsite: 30 },
};
}
function validBackupsPull() {
return { pulledAt: "2026-10-11T04:10:00Z", files: 9, ok: true };
}
function historyFixturePath(month) { function historyFixturePath(month) {
return path.join(tmpDir, "claude-usage", "history", `${month}.json`); return path.join(tmpDir, "claude-usage", "history", `${month}.json`);
} }
@ -310,6 +332,13 @@ function writeFixtures() {
days: [{ date: "2026-09-28", models: {} }], days: [{ date: "2026-09-28", models: {} }],
}); });
process.env.CLAUDE_USAGE_INGEST_TOKEN = CLAUDE_TOKEN; process.env.CLAUDE_USAGE_INGEST_TOKEN = CLAUDE_TOKEN;
// The backups (#58): status.json on what is a read-only mount in production,
// and the pull report in its own writable directory.
writeJson(path.join(tmpDir, "backups-status", "status.json"), validBackupsStatus());
writeJson(path.join(tmpDir, "backups-pull", "pull.json"), { ...validBackupsPull(), receivedAt: new Date().toISOString() });
process.env.BACKUPS_STATUS_PATH = path.join(tmpDir, "backups-status", "status.json");
process.env.BACKUPS_PULL_PATH = path.join(tmpDir, "backups-pull", "pull.json");
process.env.BACKUPS_INGEST_TOKEN = BACKUPS_TOKEN;
// Closed port: /health must never reach the real IdP. // Closed port: /health must never reach the real IdP.
process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration"; process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration";
} }
@ -367,6 +396,13 @@ describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => {
], ],
["/claude-usage", (body) => expect(body.snapshot.projects).toHaveLength(1)], ["/claude-usage", (body) => expect(body.snapshot.projects).toHaveLength(1)],
["/claude-usage/history", (body) => expect(body.days).toEqual([{ date: "2026-09-28", models: {} }])], ["/claude-usage/history", (body) => expect(body.days).toEqual([{ date: "2026-09-28", models: {} }])],
[
"/backups",
(body) => {
expect(body.vps.databases).toHaveLength(1);
expect(body.lenovo.files).toBe(9);
},
],
]; ];
test.each(READ_ROUTES)("GET %s writes nothing, not even after the response", async (route, expectContent) => { test.each(READ_ROUTES)("GET %s writes nothing, not even after the response", async (route, expectContent) => {
@ -418,6 +454,52 @@ describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => {
expect(foreignCommands()).toEqual([]); expect(foreignCommands()).toEqual([]);
}); });
// The third write surface (#58), same rule: its own directory alone.
test("POST /backups/pull writes, and only in the directory of BACKUPS_PULL_PATH", async () => {
const { status } = await send("/backups/pull", {
method: "POST",
auth: `Bearer ${BACKUPS_TOKEN}`,
body: JSON.stringify(validBackupsPull()),
});
expect(status).toBe(204);
await settle();
const writes = drain();
expect(writes.map(({ fn }) => fn)).toEqual(expect.arrayContaining(["writeFileSync", "renameSync"]));
expect(violations(writes, path.dirname(process.env.BACKUPS_PULL_PATH))).toEqual([]);
expect(foreignCommands()).toEqual([]);
});
test("POST /backups/pull rejections, and GET /backups on corrupt files, write nothing at all", async () => {
expect(bootWrites).not.toHaveLength(0);
const backups = `Bearer ${BACKUPS_TOKEN}`;
const rejected = [
[401, { auth: null, body: "{}" }],
[401, { auth: `Bearer ${CLAUDE_TOKEN}`, body: JSON.stringify(validBackupsPull()) }],
[400, { auth: backups, body: "{not json" }],
[400, { auth: backups, body: JSON.stringify({ ...validBackupsPull(), ok: false, error: "dump_failed" }) }],
[413, { auth: backups, body: "x".repeat(2000) }],
];
for (const [expected, options] of rejected) {
const { status } = await send("/backups/pull", { method: "POST", ...options });
expect([options.body.length, status]).toEqual([options.body.length, expected]);
}
// Corrupt files on both sides: where a quarantine or a repair would go.
real.writeFileSync(process.env.BACKUPS_STATUS_PATH, "{corrupt");
real.writeFileSync(process.env.BACKUPS_PULL_PATH, "{corrupt");
const { status, body } = await send("/backups");
expect(status).toBe(200);
expect(body).toEqual({ vps: null, lenovo: null, vpsError: "invalid_json", lenovoError: "invalid_json" });
// Both absent: where a "recreate it" write would go.
real.rmSync(process.env.BACKUPS_STATUS_PATH);
real.rmSync(process.env.BACKUPS_PULL_PATH);
expect((await send("/backups")).body).toEqual({ status: "no_data" });
await settle();
expect(drain()).toEqual([]);
expect(foreignCommands()).toEqual([]);
});
test("POST /claude-usage rejections and invalid input write nothing at all", async () => { test("POST /claude-usage rejections and invalid input write nothing at all", async () => {
expect(bootWrites).not.toHaveLength(0); expect(bootWrites).not.toHaveLength(0);
const claude = `Bearer ${CLAUDE_TOKEN}`; const claude = `Bearer ${CLAUDE_TOKEN}`;

171
backups.js Normal file
View file

@ -0,0 +1,171 @@
// PostgreSQL backups (GET /backups, POST /backups/pull), issue #58, contract
// `schemaVersion: 1` shared with the VPS script (la-compagnie-maximus,
// scripts/backup/backup-postgres.sh) and the workstation pull agent
// (agent/pg-backup-pull/run-pg-backup-pull.sh).
//
// Pure module: no I/O, no config, no side effects. index.js wires the routes,
// reads status.json and owns the pull file.
//
// Two inputs, two rules:
// - status.json is written by the VPS script and only read here. It goes
// through a whitelist rebuild, as the Claude usage snapshot does: an
// unknown field is dropped, an invalid value is dropped (key left out),
// never coerced. Only what makes the file unusable refuses it as a whole:
// not an object, a `schemaVersion` other than 1, or no usable `generatedAt`
// (the age, hence `stale`, is computed from it).
// - the pull report is sent by the workstation agent, whose body is four
// fixed fields. It is validated strictly: any invalid or missing field is a
// 400, so a broken agent is told so instead of being half-stored.
// Error codes come from closed lists on both sides: a free-form `error` could
// carry pg_dump or rclone stderr (hosts, keys) all the way to the dashboard.
const { isIsoTimestamp } = require("./claude-usage.js");
const SCHEMA_VERSION = 1;
// The pull body is four short fields, under 120 bytes in practice.
const BACKUPS_MAX_BODY_BYTES = 1024;
// One cluster holds about ten databases; this bound is only there so a runaway
// file cannot make the response arbitrarily large.
const MAX_DATABASES = 64;
const VPS_ERROR_CODES = [
"dump_failed",
"dump_empty",
"restore_list_failed",
"invalid_name",
"encrypt_failed",
"mirror_upload_failed",
"mirror_purge_failed",
"rotation_failed",
];
const PULL_ERROR_CODES = ["pull_failed", "rotation_failed"];
// Same pattern as the VPS script's own check. A name it rejected is reported
// with `invalid_name` and may hold arbitrary text: that name is dropped here.
const DATABASE_NAME_RE = /^[a-z0-9_]{1,63}$/;
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function cleanCount(value) {
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
}
function cleanBoolean(value) {
return typeof value === "boolean" ? value : undefined;
}
function cleanTimestamp(value) {
return isIsoTimestamp(value) ? value : undefined;
}
function cleanCode(value, codes) {
return codes.includes(value) ? value : undefined;
}
function cleanDatabaseName(value) {
return typeof value === "string" && DATABASE_NAME_RE.test(value) ? value : undefined;
}
// Copy `key` into `out` only when the cleaner kept a value.
function put(out, key, value) {
if (value !== undefined) out[key] = value;
}
// `null` is a legitimate value for the fields the contract declares nullable
// (a failed dump has no dumpedAt, a failed upload no uploadedAt).
function putNullable(out, key, raw, clean) {
if (raw === null) out[key] = null;
else put(out, key, clean(raw));
}
// { ok, error? } plus the given extra fields, for globals, mirror and rotation.
function sanitizeComponent(value, extra) {
if (!isPlainObject(value)) return undefined;
const out = {};
extra(out, value);
put(out, "ok", cleanBoolean(value.ok));
put(out, "error", cleanCode(value.error, VPS_ERROR_CODES));
return out;
}
function sanitizeDatabase(value) {
const out = {};
put(out, "name", cleanDatabaseName(value.name));
put(out, "sizeBytes", cleanCount(value.sizeBytes));
putNullable(out, "dumpedAt", value.dumpedAt, cleanTimestamp);
put(out, "ok", cleanBoolean(value.ok));
put(out, "error", cleanCode(value.error, VPS_ERROR_CODES));
return out;
}
function sanitizeRetention(value) {
if (!isPlainObject(value)) return undefined;
const out = {};
put(out, "local", cleanCount(value.local));
put(out, "offsite", cleanCount(value.offsite));
return out;
}
// Returns { status } or { error: <code> }, the code going out as `vpsError`.
// Idempotent: a sanitized status comes back unchanged.
function sanitizeBackupsStatus(payload) {
if (!isPlainObject(payload)) return { error: "invalid_contract" };
if (payload.schemaVersion !== SCHEMA_VERSION) return { error: "unsupported_schema" };
const generatedAt = cleanTimestamp(payload.generatedAt);
if (generatedAt === undefined) return { error: "invalid_contract" };
const status = { schemaVersion: SCHEMA_VERSION, generatedAt };
put(status, "ok", cleanBoolean(payload.ok));
put(status, "expectedCount", cleanCount(payload.expectedCount));
if (Array.isArray(payload.databases)) {
status.databases = payload.databases.filter(isPlainObject).slice(0, MAX_DATABASES).map(sanitizeDatabase);
}
put(status, "globals", sanitizeComponent(payload.globals, (out, value) => {
put(out, "sizeBytes", cleanCount(value.sizeBytes));
}));
put(status, "mirror", sanitizeComponent(payload.mirror, (out, value) => {
putNullable(out, "uploadedAt", value.uploadedAt, cleanTimestamp);
}));
put(status, "rotation", sanitizeComponent(payload.rotation, () => {}));
put(status, "retentionDays", sanitizeRetention(payload.retentionDays));
return { status };
}
// Returns { pull } or { error: <message> }. Strict: every field must be there
// and valid. Anything else in the body, a client `receivedAt` included, is
// dropped: the server stamps its own.
function validateBackupsPull(payload) {
if (!isPlainObject(payload)) return { error: "body must be a JSON object" };
if (!isIsoTimestamp(payload.pulledAt)) {
return { error: "pulledAt must be an ISO 8601 timestamp with an offset" };
}
if (cleanCount(payload.files) === undefined) {
return { error: "files must be a non-negative integer" };
}
if (typeof payload.ok !== "boolean") return { error: "ok must be a boolean" };
const pull = { pulledAt: payload.pulledAt, files: payload.files, ok: payload.ok };
if (payload.ok) {
if (payload.error !== undefined) return { error: "error is only allowed when ok is false" };
} else {
if (!PULL_ERROR_CODES.includes(payload.error)) {
return { error: `error must be one of ${PULL_ERROR_CODES.join(",")} when ok is false` };
}
pull.error = payload.error;
}
return { pull };
}
module.exports = {
sanitizeBackupsStatus,
validateBackupsPull,
BACKUPS_MAX_BODY_BYTES,
SCHEMA_VERSION,
MAX_DATABASES,
VPS_ERROR_CODES,
PULL_ERROR_CODES,
};

View file

@ -139,7 +139,7 @@ function readMonth(dir, month) {
return { state: "ok", days }; return { state: "ok", days };
} }
// Same atomic pattern as writeClaudeUsage() in index.js: temp file in the SAME // Same atomic pattern as writeJsonAtomic() in index.js: temp file in the SAME
// directory (pid-suffixed, so two containers of a rolling redeploy never share // directory (pid-suffixed, so two containers of a rolling redeploy never share
// one), mode 600, then renameSync. A concurrent read sees the old month or the // one), mode 600, then renameSync. A concurrent read sees the old month or the
// new one, never a truncated JSON. No sweep covers these temp files: an orphan // new one, never a truncated JSON. No sweep covers these temp files: an orphan

167
index.js
View file

@ -22,6 +22,7 @@ const {
parseRange, parseRange,
readHistory, readHistory,
} = require("./claude-usage-history.js"); } = require("./claude-usage-history.js");
const { sanitizeBackupsStatus, validateBackupsPull, BACKUPS_MAX_BODY_BYTES } = require("./backups.js");
const PORT = parseInt(process.env.PORT || "3001", 10); const PORT = parseInt(process.env.PORT || "3001", 10);
const TOKEN = process.env.HEALTH_TOKEN; const TOKEN = process.env.HEALTH_TOKEN;
@ -101,6 +102,22 @@ const claudeStaleRaw = parseInt(process.env.CLAUDE_USAGE_STALE_SECONDS || "", 10
const CLAUDE_USAGE_STALE_SECONDS = const CLAUDE_USAGE_STALE_SECONDS =
Number.isFinite(claudeStaleRaw) && claudeStaleRaw > 0 ? claudeStaleRaw : 2700; Number.isFinite(claudeStaleRaw) && claudeStaleRaw > 0 ? claudeStaleRaw : 2700;
// --- PostgreSQL backups (GET /backups, POST /backups/pull) ------------------
// status.json is written by the VPS script (read-only mount); the pull report
// is written here, by POST /backups/pull, with its own token. The sanitizer and
// the error-code lists live in backups.js.
const BACKUPS_STATUS_PATH =
process.env.BACKUPS_STATUS_PATH || "/data/backups-status/status.json";
const BACKUPS_PULL_PATH = process.env.BACKUPS_PULL_PATH || "/data/backups-pull/pull.json";
const BACKUPS_INGEST_TOKEN = process.env.BACKUPS_INGEST_TOKEN;
// 36 h: the VPS runs daily at 03:00 UTC and the pull timer daily, so one missed
// run is tolerated and two are not.
const backupsStaleRaw = parseInt(process.env.BACKUPS_STALE_SECONDS || "", 10);
const BACKUPS_STALE_SECONDS =
Number.isFinite(backupsStaleRaw) && backupsStaleRaw > 0 ? backupsStaleRaw : 129600;
// Severity filter. Asymmetric rule (issue #3): // Severity filter. Asymmetric rule (issue #3):
// - no threshold -> MEDIUM+HIGH+CRITICAL (default hides noise LOW+INFO) // - no threshold -> MEDIUM+HIGH+CRITICAL (default hides noise LOW+INFO)
// - threshold "INFO" -> INFO only (explicit opt-in) // - threshold "INFO" -> INFO only (explicit opt-in)
@ -520,22 +537,19 @@ function readHostSnapshot(id) {
return { ...result.snapshot, receivedAt }; return { ...result.snapshot, receivedAt };
} }
// --- Claude usage helpers --------------------------------------------------- // --- Single-file snapshots (Claude usage, backups pull) ---------------------
// Same atomic pattern as writeHostSnapshot(): temp file in the SAME directory // Same atomic pattern as writeHostSnapshot(): temp file in the SAME directory
// (suffixed with the pid, so two containers of a rolling redeploy never share // (suffixed with the pid, so two containers of a rolling redeploy never share
// one), mode 600, then renameSync. A concurrent GET /claude-usage sees the old // one), mode 600, then renameSync. A concurrent reader sees the old file or the
// snapshot or the new one, never a truncated JSON. // new one, never a truncated JSON. Temp name: `.<basename>.<pid>.<ms>.tmp`.
function writeClaudeUsage(record) { function writeJsonAtomic(filePath, record) {
const dir = path.dirname(CLAUDE_USAGE_PATH); const dir = path.dirname(filePath);
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
const tmpPath = path.join( const tmpPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
dir,
`.${path.basename(CLAUDE_USAGE_PATH)}.${process.pid}.${Date.now()}.tmp`,
);
try { try {
writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 }); writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 });
renameSync(tmpPath, CLAUDE_USAGE_PATH); renameSync(tmpPath, filePath);
} catch (err) { } catch (err) {
try { try {
unlinkSync(tmpPath); unlinkSync(tmpPath);
@ -574,6 +588,67 @@ function readClaudeUsage() {
return { snapshot: result.snapshot, receivedAt }; return { snapshot: result.snapshot, receivedAt };
} }
// --- Backups helpers --------------------------------------------------------
// Read a JSON file, telling "absent" (null) from "there but unusable"
// ({ error }): GET /backups reports the second as vpsError / lenovoError
// instead of hiding a broken file behind no_data. Error codes only, never the
// file content nor err.message (which would carry the path).
function readJsonFile(filePath, label) {
let raw;
try {
raw = readFileSync(filePath, "utf-8");
} catch (err) {
if (err.code === "ENOENT") return null;
console.error(`[backups] ${label} unreadable (${err.code || "error"})`);
return { error: "unreadable" };
}
try {
return { value: JSON.parse(raw) };
} catch {
console.error(`[backups] ${label} is not valid JSON`);
return { error: "invalid_json" };
}
}
// The VPS status, rebuilt through the whitelist. null when absent.
function readBackupsStatus() {
const read = readJsonFile(BACKUPS_STATUS_PATH, "status.json");
if (read === null || read.error) return read;
const result = sanitizeBackupsStatus(read.value);
if (result.error) {
console.error(`[backups] status.json rejected on read (${result.error})`);
return { error: result.error };
}
return { block: result.status, since: result.status.generatedAt };
}
// The last pull report. The file lives on a writable bind-mount: it is
// re-validated on the way out, receivedAt included, like the host snapshots.
function readBackupsPull() {
const read = readJsonFile(BACKUPS_PULL_PATH, "pull.json");
if (read === null || read.error) return read;
const result = validateBackupsPull(read.value);
const receivedAt = read.value?.receivedAt;
if (result.error || !isIsoTimestamp(receivedAt)) {
console.error("[backups] pull.json rejected on read");
return { error: "invalid_contract" };
}
return { block: { ...result.pull, receivedAt }, since: receivedAt };
}
// Staleness is decided here and only here, as for /claude-usage: the admin
// card and /warmup display `stale`, they do not recompute it.
function withFreshness(block, since, now) {
const ageSeconds = Math.max(0, Math.floor((now - new Date(since).getTime()) / 1000));
return {
...block,
ageSeconds,
stale: ageSeconds > BACKUPS_STALE_SECONDS,
staleAfterSeconds: BACKUPS_STALE_SECONDS,
};
}
// --- Route handlers --------------------------------------------------------- // --- Route handlers ---------------------------------------------------------
async function handleHealth(req, res) { async function handleHealth(req, res) {
@ -802,7 +877,7 @@ async function handleClaudeUsageIngest(req, res) {
const record = { ...result.snapshot, receivedAt: new Date().toISOString() }; const record = { ...result.snapshot, receivedAt: new Date().toISOString() };
try { try {
writeClaudeUsage(record); writeJsonAtomic(CLAUDE_USAGE_PATH, record);
} catch (err) { } catch (err) {
console.error("[claude-usage] failed to persist snapshot:", err.message); console.error("[claude-usage] failed to persist snapshot:", err.message);
res.writeHead(500); res.writeHead(500);
@ -884,6 +959,68 @@ function handleClaudeUsageHistory(req, res, ctx) {
res.end(JSON.stringify({ from: range.from, to: range.to, days })); res.end(JSON.stringify({ from: range.from, to: range.to, days }));
} }
// { vps, lenovo } with freshness, the VPS aged on generatedAt and the
// workstation on the server-stamped receivedAt (#58). A file that is there but
// unusable gives a null block plus its error code, never a 500: one broken
// side must not hide the other. no_data only when both files are absent.
function handleBackupsRead(req, res) {
const vps = readBackupsStatus();
const lenovo = readBackupsPull();
if (vps === null && lenovo === null) {
res.writeHead(200);
res.end(JSON.stringify({ status: "no_data" }));
return;
}
const now = Date.now();
const body = {
vps: vps?.block ? withFreshness(vps.block, vps.since, now) : null,
lenovo: lenovo?.block ? withFreshness(lenovo.block, lenovo.since, now) : null,
};
if (vps?.error) body.vpsError = vps.error;
if (lenovo?.error) body.lenovoError = lenovo.error;
res.writeHead(200);
res.end(JSON.stringify(body));
}
async function handleBackupsPullIngest(req, res) {
const raw = await readBody(req, res, BACKUPS_MAX_BODY_BYTES);
if (raw === null) return; // 413 already answered, or the socket is gone.
let payload;
try {
payload = JSON.parse(raw);
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: "Bad request: body must be valid JSON" }));
return;
}
const result = validateBackupsPull(payload);
if (result.error) {
res.writeHead(400);
res.end(JSON.stringify({ error: `Bad request: ${result.error}` }));
return;
}
// Server-stamped: pulledAt is the workstation's clock and stays informative,
// freshness is judged on receivedAt only. A client receivedAt never got
// past validateBackupsPull().
const record = { ...result.pull, receivedAt: new Date().toISOString() };
try {
writeJsonAtomic(BACKUPS_PULL_PATH, record);
} catch (err) {
console.error(`[backups] failed to persist pull report (${err.code || "error"})`);
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error" }));
return;
}
res.removeHeader("Content-Type");
res.writeHead(204);
res.end();
}
// --- Routing ---------------------------------------------------------------- // --- Routing ----------------------------------------------------------------
// Route descriptors. `tokenKind` is the ONLY thing that varies between the read // Route descriptors. `tokenKind` is the ONLY thing that varies between the read
@ -901,6 +1038,8 @@ const ROUTES = [
{ method: "GET", path: "/claude-usage", tokenKind: "read", handle: handleClaudeUsageRead }, { method: "GET", path: "/claude-usage", tokenKind: "read", handle: handleClaudeUsageRead },
{ method: "GET", path: "/claude-usage/history", tokenKind: "read", handle: handleClaudeUsageHistory }, { method: "GET", path: "/claude-usage/history", tokenKind: "read", handle: handleClaudeUsageHistory },
{ method: "POST", path: "/claude-usage", tokenKind: "claude-ingest", handle: handleClaudeUsageIngest }, { method: "POST", path: "/claude-usage", tokenKind: "claude-ingest", handle: handleClaudeUsageIngest },
{ method: "GET", path: "/backups", tokenKind: "read", handle: handleBackupsRead },
{ method: "POST", path: "/backups/pull", tokenKind: "backups-ingest", handle: handleBackupsPullIngest },
]; ];
// tokenKind -> expected token, and what to answer when that token is not set. // tokenKind -> expected token, and what to answer when that token is not set.
@ -928,6 +1067,12 @@ const TOKEN_KINDS = Object.freeze({
missingError: "CLAUDE_USAGE_INGEST_TOKEN not configured", missingError: "CLAUDE_USAGE_INGEST_TOKEN not configured",
missingReason: "claude usage ingest token not configured", missingReason: "claude usage ingest token not configured",
}, },
"backups-ingest": {
token: BACKUPS_INGEST_TOKEN,
missingStatus: 503,
missingError: "BACKUPS_INGEST_TOKEN not configured",
missingReason: "backups ingest token not configured",
},
}); });
// A descriptor naming a kind the table does not know is a programming error: // A descriptor naming a kind the table does not know is a programming error:

View file

@ -67,6 +67,8 @@ TOKEN="test-token-123"
INGEST_TOKEN="test-ingest-token-456" INGEST_TOKEN="test-ingest-token-456"
# Third token, for POST /claude-usage (#48), fixed for the same reason. # Third token, for POST /claude-usage (#48), fixed for the same reason.
CLAUDE_TOKEN="test-claude-token-789" CLAUDE_TOKEN="test-claude-token-789"
# Fourth token, for POST /backups/pull (#58), fixed for the same reason.
BACKUPS_TOKEN="test-backups-token-012"
TMP_DIR="$(mktemp -d -t vps-health-api.XXXXXX)" TMP_DIR="$(mktemp -d -t vps-health-api.XXXXXX)"
SERVER_PID="" SERVER_PID=""
NOINGEST_PID="" NOINGEST_PID=""
@ -206,6 +208,9 @@ HOSTS_ALLOWED_IDS="thinkpad,workbench" \
HOSTS_INGEST_TOKEN="$INGEST_TOKEN" \ HOSTS_INGEST_TOKEN="$INGEST_TOKEN" \
CLAUDE_USAGE_PATH="$TMP_DIR/claude-usage/claude-usage.json" \ CLAUDE_USAGE_PATH="$TMP_DIR/claude-usage/claude-usage.json" \
CLAUDE_USAGE_INGEST_TOKEN="$CLAUDE_TOKEN" \ CLAUDE_USAGE_INGEST_TOKEN="$CLAUDE_TOKEN" \
BACKUPS_STATUS_PATH="$TMP_DIR/backups-status/status.json" \
BACKUPS_PULL_PATH="$TMP_DIR/backups-pull/pull.json" \
BACKUPS_INGEST_TOKEN="$BACKUPS_TOKEN" \
LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \ LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \
node "$(dirname "$0")/index.js" >/dev/null 2>&1 & node "$(dirname "$0")/index.js" >/dev/null 2>&1 &
SERVER_PID=$! SERVER_PID=$!
@ -220,6 +225,9 @@ REPORTS_DIR="$TMP_DIR/reports" \
HOSTS_DIR="$TMP_DIR/hosts-noingest" \ HOSTS_DIR="$TMP_DIR/hosts-noingest" \
DEFENSEURS_FINDINGS_STATE_PATH="$TMP_DIR/absent-findings-state.json" \ DEFENSEURS_FINDINGS_STATE_PATH="$TMP_DIR/absent-findings-state.json" \
HOSTS_ALLOWED_IDS="thinkpad" \ HOSTS_ALLOWED_IDS="thinkpad" \
BACKUPS_STATUS_PATH="$TMP_DIR/backups-noingest/status.json" \
BACKUPS_PULL_PATH="$TMP_DIR/backups-noingest/pull.json" \
BACKUPS_INGEST_TOKEN= \
LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \ LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \
node "$(dirname "$0")/index.js" >/dev/null 2>&1 & node "$(dirname "$0")/index.js" >/dev/null 2>&1 &
NOINGEST_PID=$! NOINGEST_PID=$!
@ -574,6 +582,31 @@ dates=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("en
mode=$(node -e 'console.log((require("fs").statSync(process.argv[1]).mode & 0o777).toString(8))' "$TMP_DIR/claude-usage/history/2026-09.json") mode=$(node -e 'console.log((require("fs").statSync(process.argv[1]).mode & 0o777).toString(8))' "$TMP_DIR/claude-usage/history/2026-09.json")
[[ "$mode" == "600" ]] && pass "history month file written in mode 600" || fail "history file mode -> $mode" [[ "$mode" == "600" ]] && pass "history month file written in mode 600" || fail "history file mode -> $mode"
# Case 34 : the backups routes (#58). Fail-closed 503 on the instance without
# BACKUPS_INGEST_TOKEN (set empty there, so an exported one cannot leak in).
pull='{"pulledAt":"2026-10-11T04:10:00Z","files":9,"ok":true}'
code=$(printf '%s' "$pull" | http_code -X POST -H "Authorization: Bearer $BACKUPS_TOKEN" -H "Content-Type: application/json" \
--data-binary @- "$NOINGEST_URL/backups/pull")
[[ "$code" == "503" ]] && pass "no BACKUPS_INGEST_TOKEN -> POST /backups/pull answers 503" || fail "fail-closed backups ingest -> got $code"
code=$(http_code "$BASE_URL/backups")
[[ "$code" == "401" ]] && pass "GET /backups no-auth -> 401" || fail "GET /backups no-auth -> got $code"
# The tokens refuse each other's routes: the Claude token on the backups write.
code=$(printf '%s' "$pull" | http_code -X POST -H "Authorization: Bearer $CLAUDE_TOKEN" -H "Content-Type: application/json" \
--data-binary @- "$BASE_URL/backups/pull")
[[ "$code" == "401" ]] && pass "POST /backups/pull with the claude ingest token -> 401" || fail "backups pull with claude token -> got $code"
# Past 1 KiB: the 413 is rendered before the connection is cut.
code=$(head -c 1025 /dev/zero | tr '\0' 'x' | http_code -X POST -H "Authorization: Bearer $BACKUPS_TOKEN" \
-H "Content-Type: application/json" --data-binary @- "$BASE_URL/backups/pull")
[[ "$code" == "413" ]] && pass "POST /backups/pull 1025 bytes -> 413" || fail "backups pull oversized -> got $code"
code=$(printf '%s' "$pull" | http_code -X POST -H "Authorization: Bearer $BACKUPS_TOKEN" -H "Content-Type: application/json" \
--data-binary @- "$BASE_URL/backups/pull")
[[ "$code" == "204" ]] && pass "POST /backups/pull -> 204" || fail "POST /backups/pull -> got $code"
# Read back: no status.json on this instance, so vps is null and lenovo holds
# the report, aged by the server.
body=$(http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/backups")
seen=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const b=JSON.parse(s);console.log([b.vps,b.lenovo.files,b.lenovo.stale,typeof b.lenovo.receivedAt].join("|"));});')
[[ "$seen" == "|9|false|string" ]] && pass "GET /backups serves the pull report with server freshness" || fail "GET /backups after push -> $seen"
echo echo
echo "=== Results: $PASS passed, $FAIL failed ===" echo "=== Results: $PASS passed, $FAIL failed ==="
[[ "$FAIL" == "0" ]] || exit 1 [[ "$FAIL" == "0" ]] || exit 1