diff --git a/CLAUDE.md b/CLAUDE.md index 9a4d2d3..9be9dba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime. - `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 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...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`, `files[]`, `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. `files[]` (#61, une entree par dossier hote archive par le script, la-compagnie-maximus#295 : `{ name, sizeBytes, archivedAt, ok, error? }`, `archivedAt` `null` garde) a sa **propre** liste fermee (`source_missing`, `archive_failed`, `encrypt_failed`, `invalid_name`) : un code de base est jete sur un fichier et l'inverse. Nom hors `^[a-z0-9_-]{1,63}$` jete (tiret admis, borne 63 propre a l'API), `files` borne a 16, liste vide gardee, absent (script anterieur) -> pas de cle et pas d'erreur. `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. +- `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`, `files[]`, `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. `files[]` (#61, une entree par dossier hote archive par le script, la-compagnie-maximus#295 : `{ name, sizeBytes, archivedAt, ok, error? }`, `archivedAt` `null` garde) a sa **propre** liste fermee (`source_missing`, `archive_failed`, `encrypt_failed`, `invalid_name`) : un code propre aux bases (`dump_failed`, ...) est jete sur un fichier, un code propre aux fichiers (`source_missing`, `archive_failed`) sur une base ; `encrypt_failed` et `invalid_name`, communs aux deux listes, passent des deux cotes. Nom hors `^[a-z0-9_-]{1,63}$` jete (tiret admis, borne 63 propre a l'API), `files` borne a 16, liste vide gardee, absent (script anterieur) -> pas de cle et pas d'erreur. `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 @@ -78,7 +78,7 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \ - **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//status`), l'API Actions repondant 404 sur cette instance. - `npm test` (vitest) — 879 cas - - `__tests__/backups.test.js` — `GET /backups`, `POST /backups/pull` et les deux validateurs de `backups.js` (115 cas, issue #58 : contrat du script VPS rendu tel quel, `rotation`, `retentionDays` et `files` (#61) 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 ; #61 : chacun des quatre codes de fichier garde, code de base jete sur un fichier et l'inverse, valeur invalide jetee, nom refuse jete l'entree gardee et tiret admis, borne de 16, liste vide gardee, `files` absent sans erreur, de bout en bout par la route ; 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__/backups.test.js` — `GET /backups`, `POST /backups/pull` et les deux validateurs de `backups.js` (115 cas, issue #58 : contrat du script VPS rendu tel quel, `rotation`, `retentionDays` et `files` (#61) 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 ; #61 : chacun des quatre codes de fichier garde, code propre aux bases jete sur un fichier et code propre aux fichiers jete sur une base, valeur invalide jetee, nom refuse jete l'entree gardee et tiret admis, borne de 16, liste vide gardee, `files` absent sans erreur, de bout en bout par la route ; 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/` 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__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)