Compare commits

..

No commits in common. "main" and "issue-4-runtime-only-coolify-secrets" have entirely different histories.

45 changed files with 103 additions and 16515 deletions

View file

@ -4,38 +4,3 @@ PORT=3001
# Buildtime ARG leaks the secret in clear in application_deployment_queues.logs.
HEALTH_TOKEN=change-me-to-a-strong-secret
LOGTO_HEALTH_URL=https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration
# Directory read by GET /reports/scans and GET /defenseurs/findings. On Coolify
# it is its own read-only mount (/home/defenseur/defenseurs/reports ->
# /data/defenseurs/reports:ro), nested inside the read-only /data/defenseurs
# mount that holds status.json.
REPORTS_DIR=/data/defenseurs/reports
# --- Workstation snapshots (POST /hosts/<id>, GET /hosts) --------------------
# Directory the API WRITES workstation snapshots into, one <id>.json per host.
# Unlike the two /data/defenseurs mounts (read-only `:ro`; content owned by
# defenseur:defenseur, uid/gid 1001, group-writable but not by gid 1000), this
# one must be writable by uid 1000 (the `node` user the container runs as) or
# every ingest answers 500.
HOSTS_DIR=/data/hosts
# Comma-separated allowlist of host ids. Each entry must match
# ^[a-z0-9][a-z0-9-]{0,31}$ — anything else is logged and dropped at startup.
HOSTS_ALLOWED_IDS=thinkpad
# Bearer token for POST /hosts/<id>. Separate from HEALTH_TOKEN on purpose: a
# workstation agent should be able to write its own snapshot without gaining
# read access to the Defenseurs reports. Same Coolify rule as HEALTH_TOKEN —
# is_runtime=true, is_buildtime=false. Unset -> POST /hosts/<id> answers 503.
HOSTS_INGEST_TOKEN=change-me-to-a-strong-secret
# Age (seconds) past which GET /hosts reports a host as offline. Default 900.
HOSTS_STALE_SECONDS=900
# --- Claude usage snapshot (POST /claude-usage, GET /claude-usage) ---------
# Bearer token for POST /claude-usage, pushed by the ThinkPad agent. Distinct
# from HOSTS_INGEST_TOKEN and HEALTH_TOKEN: each one is refused on the others'
# routes. Same Coolify rule — is_runtime=true, is_buildtime=false. Unset ->
# POST /claude-usage answers 503.
CLAUDE_USAGE_INGEST_TOKEN=change-me-to-a-strong-secret
# File the API WRITES (atomic rename). Its directory must be its own read-write
# mount owned by uid 1000, like HOSTS_DIR.
CLAUDE_USAGE_PATH=/data/claude-usage/claude-usage.json
# Age (seconds) past which GET /claude-usage reports stale: true. Default 2700.
CLAUDE_USAGE_STALE_SECONDS=2700

View file

@ -1,67 +0,0 @@
name: CI
# Until issue #24 nothing verified this repository: every stack was checked by
# hand, by whoever remembered to run the right command. Modelled on
# `roadtrip/.forgejo/workflows/ci.yml` and `simpl-sante/.forgejo/workflows/ci.yml`,
# the workflows known to run on this runner (Forgejo 8.0.3, runner v12.7.0).
# Two forms are avoided because the runner rejects the whole job before its
# first command, the error being readable only in the Forgejo database on the VPS:
#
# - `working-directory` on a `uses:` step (allowed on `run:` only)
# - `defaults:` at job level
#
# No `concurrency:` key: it is not inert on this instance, and a second push
# cancels the first run — whose missing status then reads like a runner failure.
#
# See `la-compagnie-maximus/docs/forgejo-ops.md` and the `diag-forgejo-ci` skill.
on:
# No `branches:` filter, on purpose. In a stacked run each PR targets the
# previous link's branch and only the root one targets `main`: with
# `branches: [main]` every intermediate link would get no status at all,
# indistinguishable from a runner that never saw the commit.
pull_request:
# Merges land as fast-forwards pushed to `main`: a PR trigger alone would miss
# what actually reaches production.
push:
branches: [main]
jobs:
verification:
runs-on: ubuntu
# The Dockerfile's own base image (a vitest pin keeps the two aligned): the
# tests, the socket pass and the boot check all see the musl libc and busybox
# tools production runs on. The job runs as root in this container, unlike
# the image (`USER node`) — a future test relying on an unreadable file
# would be weakened here without failing.
container: node:22-alpine
steps:
- name: Tooling
# Before any `uses:` step: checkout needs git, test-curl.sh needs bash
# and curl. The image already carries node, which runs checkout@v4.
run: |
apk add --no-cache git bash curl
node --version
npm --version
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Dependencies
run: npm ci
- name: Unit tests
run: npm test
- name: Socket-level pass
# Boots its own servers on 3099 and 3098, no outside service needed.
run: bash test-curl.sh
- name: Boot from the COPY file set
# Stands in for a `docker build`: a real build would need the Docker
# socket inside the job, which on this instance-scoped runner makes every
# workflow of every repository root on the production VPS. The script
# starts the CMD from the COPY sources alone and catches the
# MODULE_NOT_FOUND that #11 had to fix by hand.
run: sh scripts/boot-from-copy.sh

104
CLAUDE.md
View file

@ -1,117 +1,29 @@
# VPS Health API
API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime.
API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
## Endpoints
- `GET /health` — CPU, memoire, disque, uptime, logto (`{status, responseTimeMs, error?}`)
- `GET /defenseurs` — contenu de status.json (rapports defenseurs)
- `GET /defenseurs/state` — contenu brut de `findings-state.json` (etat par finding du Sergent : `firstSeen`, `unverified`, historique 30 jours). Meme contrat que `/defenseurs` via le helper partage `serveJsonFile()` : 200 contenu brut, 200 `{status:"no_data"}` si absent ou illisible. Ni parse ni controle de fraicheur ici (le Sergent ecrit en atomique et isole un etat corrompu en `.corrupt-*` ; la peremption est jugee cote dashboard). Consommateur : dashboard admin Vercel (drill-down, en parallele de `/defenseurs`).
- `GET /reports/scans?date=YYYY-MM-DD` — agrege les rapports `defenseur-<agent>_<date>*.json` du jour, format `{ date, count, reports: Report[] }`. Filtre `isScanReport` (exclut `defenseur-auto_*.json`). Date validee par regex (path traversal bloque). Consommateur : `defenseur-auto` workstation cron (remplace le pre-rsync SSH). Exemple : `curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/reports/scans?date=2026-05-07"`.
- `GET /defenseurs/findings?project=X` — findings detailles du Defenseur correspondant. Query params : `project=<name>` (obligatoire, lookup via `agents-map.json`), `category=<deps|secrets|code|acces|infra>` (optionnel, exact match), `severity=<CRITICAL|HIGH|MEDIUM|LOW|INFO>` (optionnel, threshold inclusif vers le haut). Sans `severity` -> MEDIUM+HIGH+CRITICAL (cache LOW+INFO). `severity=LOW` -> LOW+MEDIUM+HIGH+CRITICAL (cache toujours INFO, asymetrie volontaire). `severity=INFO` -> INFO uniquement (opt-in explicite). Reponses : 200 `{ agent, project, timestamp, findings[] }` si report present (sans champ `status`) ; 200 `{ findings: [], status: "no_data" }` si pas de report ; 400 sans project ou param invalide ; 404 projet inconnu ; 500 si `agents-map.json` corrompu. Consommateurs : admin dashboard Vercel (drill-down), futur skill `/analyse-vulnerabilite`. Exemple : `curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/defenseurs/findings?project=la-suite-booking&severity=HIGH"`.
- `GET /hosts` — dernier snapshot de chaque poste de l'allowlist. Format `{ staleAfterSeconds, hosts: [{ id, hostname, uptime, cpu, memory, disk, receivedAt, ageSeconds, online }] }`. `online = ageSeconds <= HOSTS_STALE_SECONDS` (defaut 900), calcule cote serveur. Un poste jamais vu — ou dont le fichier est illisible — degrade en `{ id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true }` sans faire tomber la reponse : c'est ce qui permet a la carte admin d'afficher « agent non installe » pendant la mise en service au lieu de rester muette. Consommateur : dashboard admin Vercel.
- `POST /hosts/<id>` — **premiere des deux surfaces d'ecriture de l'API** (figees par `__tests__/writes.test.js`, sur les chemins nominaux et degrades qu'il exerce). Auth par `HOSTS_INGEST_TOKEN` (pas `HEALTH_TOKEN`). Corps = payload de `GET /health` moins `logto` et `timestamp`. Codes : 204 accepte / 400 JSON illisible ou champ manquant / 401 token absent ou faux / 403 `<id>` bien forme mais hors allowlist / 404 `<id>` hors format / 413 corps > 4 Kio (connexion coupee) / 503 `HOSTS_INGEST_TOKEN` non configure. Exemple : `curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" -H "Content-Type: application/json" --data @snapshot.json "https://health.lacompagniemaximus.com/hosts/thinkpad"`.
- `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/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`, `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.
## Auth
## Routage et auth
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.
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.
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.
- Fail-closed : `HEALTH_TOKEN` absent -> 401 sur les lectures ; chaque token d'ingestion absent -> 503 sur sa seule route, avec un message qui nomme sa propre variable (`HOSTS_INGEST_TOKEN not configured`, `CLAUDE_USAGE_INGEST_TOKEN not configured`)
- `tokenMatches()` (`timingSafeEqual` sur empreintes SHA-256, longueurs egalisees par le hachage) compare **tous les tokens** depuis l'issue #17 : `checkAuth()` n'a qu'une expression de comparaison, seul le token attendu varie. Ne jamais revenir a une egalite de chaine (`` header === `Bearer ${x}` ``) : elle s'arrete au premier octet different et fuit longueur et prefixe par le temps de reponse. Un test de timing serait instable ; la garde est double dans `__tests__/auth.test.js` : un espion sur `crypto.timingSafeEqual` (pose avant un `require` frais, `index.js` destructurant l'import au chargement) exige que chaque requete compare deux empreintes de 32 octets, meme quand le token recu n'a pas la bonne longueur ; et des epingles sur le source de `checkAuth()` et de `tokenMatches()`
- 401 passe avant 403 : un appelant sans token ne peut pas sonder l'allowlist
- Chaque rejet 401/403 est journalise (`[auth] <code> <method> <url> ip=<X-Real-IP> id=<host> reason=<motif>`), valeurs filtrees en ASCII imprimable pour empecher la forge de lignes de log
- **Coolify** : `HEALTH_TOKEN`, `HOSTS_INGEST_TOKEN`, `CLAUDE_USAGE_INGEST_TOKEN` et `BACKUPS_INGEST_TOKEN` doivent etre `is_runtime=true, is_buildtime=false`. Buildtime fait fuiter le secret en clair dans `application_deployment_queues.logs`. Voir `la-compagnie-maximus/docs/coolify-ops.md` section "Secrets en buildtime".
- Bearer token via env `HEALTH_TOKEN`
- Fail-closed : si `HEALTH_TOKEN` non configure, toutes les requetes sont refusees
- **Coolify** : `HEALTH_TOKEN` doit etre `is_runtime=true, is_buildtime=false`. Buildtime fait fuiter le secret en clair dans `application_deployment_queues.logs`. Voir `la-compagnie-maximus/docs/coolify-ops.md` section "Secrets en buildtime".
## Config
- Port : `3001` (env `PORT`)
- `LOGTO_HEALTH_URL` : URL du `.well-known/openid-configuration` (default auth.lacompagniemaximus.com)
- `REPORTS_DIR` : dossier lu par `/reports/scans` et `/defenseurs/findings` (default `/data/defenseurs/reports`)
- `DEFENSEURS_STATUS_PATH` : fichier servi par `/defenseurs` (default `/data/defenseurs/status.json`)
- `DEFENSEURS_FINDINGS_STATE_PATH` : fichier servi par `/defenseurs/state` (default `/data/defenseurs/findings-state.json`)
- `DEFENSEURS_AGENTS_MAP_PATH` : snapshot project->agent ecrit par le Sergent (default `/data/defenseurs/agents-map.json`)
- `HOSTS_DIR` : dossier **ecrit** par `POST /hosts/<id>`, un `<id>.json` par poste (default `/data/hosts`)
- `HOSTS_ALLOWED_IDS` : allowlist separee par virgules (default `thinkpad` — le Pop!_OS est hors scope). Chaque entree est validee au demarrage par la meme regex que la route ; une entree invalide est journalisee et ecartee
- `HOSTS_INGEST_TOKEN` : token d'ingestion, distinct de `HEALTH_TOKEN` (pas de default — absent = 503)
- `HOSTS_STALE_SECONDS` : age au-dela duquel un poste est reporte hors ligne (default 900)
- `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_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) :
- `/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**.
- `/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. Pose (releve du 2026-10-19). 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). Pose (releve du 2026-10-19).
- `/srv/vps-health-api/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). Pose (releve du 2026-10-19).
- 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 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`.
- Bind-mount : `/data/defenseurs/status.json` read-only
## Deploy
**Le merge est le deploiement** depuis le 2026-10-12 : un push sur `main` declenche le webhook Forgejo (hook id 9, route « manuelle » de Coolify, `source_id=null` reste vrai) et Coolify redeploie l'app, en meme temps que le job `push` de la CI et sans l'attendre (la-compagnie-maximus#133, `la-compagnie-maximus/docs/coolify-ops.md`, « Auto-deploy au push sur `main` »). Pas de `watch_paths` : un commit de doc ou un `state: sync` redeploie aussi. Les agents de `agent/` ne partent qu'a la copie sur le poste (`agent/README.md`).
Redeploiement a la main, si besoin :
```bash
curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
"https://coolify.lacompagniemaximus.com/api/v1/deploy?uuid=u8000gsg044wsk0oo0w884ok&force=true"
```
(ou bouton Redeploy dans l'UI Coolify.)
## Documents de reference
- `REVIEW.md` : niveaux de revue (haut/normal/bas) par fichier touche, regles par zone, commandes de verification et pieges, lus par `/pr-review`, `/fix-issue`, `/sprint` et `/pr-brief`. Calibre par `scripts/review-levels.py` (`--check` sans reseau compare ses motifs au tableau Haut).
## 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.
- `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 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/<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__/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__/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 (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__/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-pg-backup-pull.test.js` — agent de tirage des sauvegardes du ThinkPad (21 cas, la-compagnie-maximus#220, PR #59 : `ssh`, `rsync` et `curl` remplaces par de faux binaires en tete de `PATH` qui consignent leur argv ; tirage par le port 2222 avec cle d'hote epinglee, `.age` seulement, `--ignore-existing` et jamais `--delete` ; token d'ingestion dans un fichier d'en-tete, jamais en argv ni en sortie, fichier supprime meme sur un `TERM` ; codes de sortie 1, 2 et 3 ; rotation : 30 dossiers dates gardes, jamais la sauvegarde la plus recente, un dossier date futur ou un nom impossible ignores sans rien supprimer, `rotation_failed` ; aucun `set -x`)
- `__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 ; #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
- `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 (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.
Coolify auto-rebuild depuis push Forgejo. Aucune action manuelle requise.
## Gotchas
- 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 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/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`.
- 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`. `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.
- 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.
- Coolify ignore silencieusement `-v` dans `custom_docker_run_options` — les volumes passent par les Persistent Storages (UI seulement). Lecture seule : suffixer la destination par `:ro`, que Coolify recopie tel quel dans le compose (verifie sur beta.463, #36). Un Directory Mount ne se modifie pas dans l'UI : sauvegarder le dossier hote, supprimer le montage **sans cocher l'effacement du dossier sur le serveur**, puis le recreer en Volume Mount **avec Source Path** (sans Source Path, c'est un volume nomme qui ne lit pas le dossier hote). Sous un parent en `:ro`, le point de montage imbrique doit deja exister cote hote : `/data/defenseurs/reports`, dossier vide root en 755, est a garder, car Docker ne peut pas le creer dans un parent en lecture seule et le conteneur ne demarrerait pas. Ne jamais monter les fichiers Defenseurs un par un : le Sergent ecrit par `rename` atomique, un bind de fichier resterait accroche a l'ancien inode. Details dans `la-compagnie-maximus/docs/coolify-ops.md`.
- Severity threshold est asymetrique : `?severity=LOW` retourne LOW+MEDIUM+HIGH+CRITICAL mais cache INFO. INFO est seulement accessible via `?severity=INFO` explicite (cache le bruit par defaut).
- Le `status.json` est ecrit par le Sergent defenseurs, pas par cette API (read-only)

View file

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

325
README.md
View file

@ -1,325 +0,0 @@
# vps-health-api
Lightweight health monitoring API for the VPS. Node 22, HTTP-native, zero runtime deps.
## Endpoints
Read endpoints require `Authorization: Bearer $HEALTH_TOKEN`. Each write
endpoint has its own token — `$HOSTS_INGEST_TOKEN` for workstation snapshots,
`$CLAUDE_USAGE_INGEST_TOKEN` for the Claude usage snapshot,
`$BACKUPS_INGEST_TOKEN` for the backup pull report — so an agent can push its
data without gaining read access to the Defenseurs reports, and no ingestion
token opens another one's route.
| Method | Path | Token | Description |
|--------|------|-------|-------------|
| GET | `/health` | `HEALTH_TOKEN` | CPU, memory, disk, uptime, Logto reachability |
| GET | `/defenseurs` | `HEALTH_TOKEN` | Defenseurs executive status (status.json) |
| GET | `/defenseurs/findings?project=X` | `HEALTH_TOKEN` | Detailed findings for a project's Defenseur |
| GET | `/defenseurs/state` | `HEALTH_TOKEN` | Sergent's per-finding state (findings-state.json) |
| GET | `/reports/scans?date=YYYY-MM-DD` | `HEALTH_TOKEN` | Aggregated scan reports for a UTC date |
| GET | `/hosts` | `HEALTH_TOKEN` | Latest snapshot of every allowlisted workstation |
| POST | `/hosts/<id>` | `HOSTS_INGEST_TOKEN` | Ingest one workstation snapshot |
| 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 |
| 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
answers `404`, never `401`.
### `POST /hosts/<id>`
Body: the `GET /health` payload minus `logto` and `timestamp` — `hostname`,
`uptime`, `cpu{model,cores,loadAvg,usagePercent}`,
`memory{totalGB,usedGB,freeGB,usagePercent}`, `disk{…same four…}`.
`loadAvg` must hold exactly 3 finite numbers (the 1, 5 and 15-minute
averages); any other length is a `400`.
`<id>` must match `^[a-z0-9][a-z0-9-]{0,31}$` and be listed in
`HOSTS_ALLOWED_IDS`. That regex is the path-traversal control — anything that
fails it never reaches a handler.
| Code | Case |
|------|------|
| 204 | Snapshot accepted and written |
| 400 | Unparsable JSON, missing field, or wrong type |
| 401 | Ingest token missing or wrong |
| 403 | `<id>` well-formed but not in the allowlist |
| 404 | `<id>` outside the format (never reaches the handler) |
| 413 | Body past 4 KiB — connection cut |
| 503 | `HOSTS_INGEST_TOKEN` not configured (fail-closed) |
The stored object is rebuilt field by field from a whitelist (numbers must be
finite, strings are capped at 128 chars, everything else dropped) and written
atomically via a temp file + `rename`. `receivedAt` is stamped by the server on
arrival; a client-supplied timestamp is ignored.
Strings are truncated, not filtered: control characters and markup are kept so
a legitimate value is never mangled. Consumers must escape them when rendering
(React text nodes do; `dangerouslySetInnerHTML` or an `href` would not).
A process killed mid-write can leave a `.<id>.<pid>.<ms>.tmp` file behind.
Startup removes the ones older than an hour; `GET /hosts` never reads them.
Example:
```
curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" \
-H "Content-Type: application/json" --data @snapshot.json \
"https://health.lacompagniemaximus.com/hosts/thinkpad"
```
### `GET /hosts`
```
{ "staleAfterSeconds": 900,
"hosts": [ { "id", "hostname", "uptime", "cpu", "memory", "disk",
"receivedAt", "ageSeconds", "online" } ] }
```
One entry per allowlisted id. `online = ageSeconds <= staleAfterSeconds`,
computed server-side. A host that never checked in — or whose snapshot file is
unreadable — degrades to `{ id, receivedAt: null, ageSeconds: null, online:
false, neverSeen: true }` instead of failing the response, so the dashboard can
say "agent not installed" during commissioning.
### `POST /claude-usage`
Body: the Claude usage snapshot, contract `schemaVersion: 1`, shared with the
workstation agent and the admin dashboard:
```jsonc
{
"schemaVersion": 1,
"generatedAt": "2026-09-27T19:00:00.000Z", // workstation clock, informative
"claudeCodeVersion": "2.1.283",
"timeZone": "America/Toronto",
"limits": {
"status": "ok", // ok | token_expired | http_error | unavailable | disabled
"detail": "http_429", // optional reason code
"fetchedAt": "2026-09-27T19:00:00.000Z", // or null
"items": [ { "kind": "session", "group": "session", "percent": 1,
"severity": "normal", "resetsAt": "2026-09-27T21:30:00Z", "model": null } ]
},
"days": [ { "date": "2026-09-27",
"models": { "claude-opus-5-5": { "input": 2, "output": 1950, "cacheCreation": 43176,
"cacheRead": 33814, "requests": 1 } } } ],
"projects": [ { "name": "simpl-resultat", "work": 123456, "cacheRead": 7890123, "requests": 812 } ]
}
```
The server rebuilds it from a whitelist (`sanitizeClaudeUsage` in
`claude-usage.js`, a pure module the agent can test against). An unknown field
is dropped; an invalid value is dropped (the key is left out), never coerced:
- numbers must be safe integers ≥ 0 (`Number.isSafeInteger`); `percent` 0–100;
- `limits.status` must be in the enum; `limits.detail` must match
`^[a-z0-9_]{1,32}$` (a reason code, never free text);
- `days` ≤ 31 (past that, the most recent 31), `date` `^\d{4}-\d{2}-\d{2}$`
and a real calendar day; `models` ≤ 12 keys matching
`^[a-z0-9][a-z0-9.\-]{0,63}$`; `limits.items` ≤ 12; `projects` ≤ 9;
- timestamps must be full ISO 8601 with an offset; other strings are
truncated to 128 characters.
| Code | Case |
|------|------|
| 204 | Snapshot accepted and written |
| 400 | Unparsable JSON, a body that is not an object, or `schemaVersion` other than 1 |
| 401 | Token missing or wrong — including `HOSTS_INGEST_TOKEN` or `HEALTH_TOKEN` |
| 413 | Body past 64 KiB (65 536 bytes received) — connection cut |
| 503 | `CLAUDE_USAGE_INGEST_TOKEN` not configured (fail-closed) |
`receivedAt` is stamped by the server; the file is written atomically (temp
file with the pid in its name, mode 600, then `rename`).
```
curl -X POST -H "Authorization: Bearer $CLAUDE_USAGE_INGEST_TOKEN" \
-H "Content-Type: application/json" --data @claude-usage.json \
"https://health.lacompagniemaximus.com/claude-usage"
```
### `GET /claude-usage`
```
{ "staleAfterSeconds": 2700, "receivedAt": "…", "ageSeconds": 42,
"stale": false, "snapshot": { …sanitized snapshot… } }
```
The stored file is re-run through the sanitizer on the way out.
`stale = ageSeconds > staleAfterSeconds`, decided by the server only — the
dashboard displays it, it does not recompute it. A missing or unreadable file
(corrupt, off-contract, no usable `receivedAt`) answers
`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`
Serves the Sergent's `findings-state.json` verbatim — per-finding `firstSeen`,
`unverified` status and 30-day history. It is the only source that carries a
finding's age, which is why the admin dashboard's drill-down reads it alongside
`/defenseurs`. The file is not parsed or checked for freshness here: the Sergent
writes it atomically and quarantines unreadable state itself, and staleness is
judged by the reader.
- `200` — raw file content
- `200 { status: "no_data" }` — file absent or unreadable (same contract as `/defenseurs`)
- `401` — missing or invalid token
```
curl -H "Authorization: Bearer $HEALTH_TOKEN" \
"https://health.lacompagniemaximus.com/defenseurs/state"
```
### `GET /defenseurs/findings`
Query params:
- `project` (required) — project name, looked up in `agents-map.json` (e.g. `la-suite-booking`)
- `category` (optional) — exact match, one of `deps|secrets|code|acces|infra`
- `severity` (optional) — threshold, one of `CRITICAL|HIGH|MEDIUM|LOW|INFO`
- default (no param): `MEDIUM`, `HIGH`, `CRITICAL`
- `LOW` returns `LOW`+`MEDIUM`+`HIGH`+`CRITICAL` but still hides `INFO`
- `INFO` returns `INFO` only (explicit opt-in)
Responses:
- `200 { agent, project, timestamp, findings: Finding[] }` — report present (empty `findings` if clean scan; no `status` field)
- `200 { findings: [], status: "no_data" }` — no report on file for the agent
- `400` — missing `project` or invalid `category` / `severity`
- `401` — missing or invalid token
- `404` — unknown project
- `500` — `agents-map.json` unreadable or corrupted
Example:
```
curl -H "Authorization: Bearer $HEALTH_TOKEN" \
"https://health.lacompagniemaximus.com/defenseurs/findings?project=la-suite-booking&severity=HIGH"
```
## Config
| Env var | Default | Purpose |
|---------|---------|---------|
| `PORT` | `3001` | HTTP port |
| `HEALTH_TOKEN` | — | Bearer token for the read routes (fail-closed if missing) |
| `REPORTS_DIR` | `/data/defenseurs/reports` | Scan reports dir |
| `DEFENSEURS_STATUS_PATH` | `/data/defenseurs/status.json` | Served by `/defenseurs` |
| `DEFENSEURS_FINDINGS_STATE_PATH` | `/data/defenseurs/findings-state.json` | Served by `/defenseurs/state` |
| `DEFENSEURS_AGENTS_MAP_PATH` | `/data/defenseurs/agents-map.json` | Project -> agent snapshot |
| `LOGTO_HEALTH_URL` | auth.lacompagniemaximus.com | Logto OIDC discovery URL |
| `HOSTS_DIR` | `/data/hosts` | Where workstation snapshots are **written** |
| `HOSTS_ALLOWED_IDS` | `thinkpad` | Comma-separated allowlist of host ids |
| `HOSTS_INGEST_TOKEN` | — | Bearer token for `POST /hosts/<id>` (503 if missing) |
| `HOSTS_STALE_SECONDS` | `900` | Age past which a host is reported offline |
| `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_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)
Mounted read-only (`:ro`) — written on the host by the Defenseurs Sergent:
- `/data/defenseurs` -> `/data/defenseurs:ro` — holds `status.json`,
`findings-state.json` and `agents-map.json`, all written there directly by
the Sergent. (`/home/defenseur/defenseurs/status.json` is an obsolete decoy
nobody reads.)
- `/home/defenseur/defenseurs/reports` -> `/data/defenseurs/reports:ro` — its
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
Coolify destination path, copied as-is into the generated compose), and Unix
permissions (content owned by `defenseur:defenseur`, uid and gid 1001,
directories 755/775, files 664, while the container runs as uid 1000 with no
supplementary group). The group bits grant write, so the second layer holds
only while the owning group is not gid 1000 (`node` in the image, `ubuntu` on
the host): a `chgrp ubuntu` would drop it silently.
`__tests__/writes.test.js` pins that `node:fs` writes go nowhere but
`HOSTS_DIR` — and, for `POST /claude-usage` and `POST /backups/pull` only, the
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
ingest, rejections, invalid input, a corrupt file, missing files.
Read-write — the API is the writer:
- `<host>/data/hosts/` -> `/data/hosts/` (`HOSTS_DIR`). Must be writable by uid
1000, the `node` user the container runs as, otherwise every ingest 500s.
- `<host>/data/claude-usage/` -> `/data/claude-usage/` (directory of
`CLAUDE_USAGE_PATH`). Same rule: read-write, owned by uid 1000, otherwise
every `POST /claude-usage` 500s. Mount the directory, never the file: the
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
`agent/` holds the zero-dependency collector that runs from cron on a
workstation and feeds `POST /hosts/<id>`. It is not part of the server image —
it is installed by copying files onto the machine it measures. Install steps,
crontab line and failure modes: [`agent/README.md`](agent/README.md).
## Tests
```
npm install
npm test
```

164
REVIEW.md
View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,390 +0,0 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
// Coverage for the PostgreSQL backup pull agent (la-compagnie-maximus#220).
//
// The script runs under a systemd user timer and talks to the VPS over
// rsync/ssh, then reports to POST /backups/pull. Both are replaced here by fake
// binaries on PATH, which record their argv so the tests can check what would
// have crossed the wire — in particular that the ingest token never appears in
// a command line nor in the script's own output (journald keeps it forever).
const SCRIPT = path.join(__dirname, "..", "agent", "pg-backup-pull", "run-pg-backup-pull.sh");
const TOKEN = "ingest-token-0123456789abcdef";
const TODAY = "2026-10-11";
let tmp;
let bin;
function writeExecutable(file, content) {
fs.writeFileSync(file, content, { mode: 0o755 });
}
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pg-backup-pull-"));
bin = path.join(tmp, "bin");
fs.mkdirSync(bin);
fs.writeFileSync(path.join(tmp, "key"), "fake key", { mode: 0o600 });
fs.writeFileSync(path.join(tmp, "known_hosts"), "[100.86.30.21]:2222 ssh-ed25519 AAAA");
// Fake rsync: records argv, then lands two .age files in today's directory.
writeExecutable(
path.join(bin, "rsync"),
`#!/bin/sh
printf '%s\\n' "$@" > "$FAKE_DIR/rsync.argv"
[ -n "\${FAKE_RSYNC_FAIL:-}" ] && exit 23
for last; do :; done
mkdir -p "$last/${TODAY}"
echo x > "$last/${TODAY}/maximus_api.dump.age"
echo x > "$last/${TODAY}/globals.sql.age"
`,
);
// Fake ssh: what rrsync does on the VPS, minus the restriction. Skips the
// options, drops the host, and runs the remote rsync command inside
// $FAKE_OUTBOX. Only reached by the real-rsync case; the fake rsync above
// never calls it, but the script requires ssh on PATH (none in node:22-alpine).
writeExecutable(
path.join(bin, "ssh"),
`#!/bin/sh
while [ $# -gt 0 ]; do
case "$1" in
-p|-i|-o|-l) shift 2 ;;
-*) shift ;;
*) shift; break ;;
esac
done
cd "$FAKE_OUTBOX" && exec "$@"
`,
);
// No flock in the CI's busybox: a pass-through stand-in, only where the host
// has none (no case here races two runs).
if (!["/usr/bin/flock", "/bin/flock"].some((f) => fs.existsSync(f))) {
writeExecutable(path.join(bin, "flock"), "#!/bin/sh\nexit 0\n");
}
// Fake curl: records argv, the header file it was handed and the body.
writeExecutable(
path.join(bin, "curl"),
`#!/bin/sh
printf '%s\\n' "$@" > "$FAKE_DIR/curl.argv"
prev=""
for a; do
case "$prev" in -H) case "$a" in @*) cp "\${a#@}" "$FAKE_DIR/curl.header" ;; esac ;; esac
prev="$a"
done
cat > "$FAKE_DIR/curl.body"
printf '%s' "\${FAKE_CURL_STATUS:-204}"
`,
);
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
function run({ envFile, extraEnv = {} } = {}) {
const dest = path.join(tmp, "dest");
const result = spawnSync("sh", [SCRIPT], {
encoding: "utf8",
env: {
PATH: `${bin}:/usr/bin:/bin`,
HOME: tmp,
FAKE_DIR: tmp,
PG_BACKUP_PULL_ENV_FILE: envFile ?? path.join(tmp, "absent.env"),
PG_BACKUP_PULL_DEST: dest,
PG_BACKUP_PULL_STATE_DIR: path.join(tmp, "state"),
PG_BACKUP_PULL_SSH_KEY: path.join(tmp, "key"),
PG_BACKUP_PULL_KNOWN_HOSTS: path.join(tmp, "known_hosts"),
PG_BACKUP_PULL_TODAY: TODAY,
...extraEnv,
},
});
return { ...result, output: result.stdout + result.stderr, dest };
}
function writeEnv() {
const file = path.join(tmp, "agent.env");
fs.writeFileSync(
file,
`BACKUPS_API_URL=https://health.example.test\nBACKUPS_INGEST_TOKEN=${TOKEN}\n`,
{ mode: 0o600 },
);
return file;
}
const read = (name) => fs.readFileSync(path.join(tmp, name), "utf8");
describe("pull", () => {
test("pulls over port 2222 with a pinned host key, .age files only, never --delete", () => {
const { status } = run();
expect(status).toBe(0);
const argv = read("rsync.argv").split("\n");
expect(argv).toContain("--include=*.age");
expect(argv).toContain("--exclude=*");
// rsync keeps the first matching rule: without `--include=*/` first, the
// dated directories hit `--exclude=*` and nothing crosses, silently.
expect(argv.indexOf("--include=*/")).toBeGreaterThan(-1);
expect(argv.indexOf("--include=*/")).toBeLessThan(argv.indexOf("--exclude=*"));
expect(argv.indexOf("--include=*.age")).toBeLessThan(argv.indexOf("--exclude=*"));
expect(argv).not.toContain("--delete");
expect(argv).toContain("--prune-empty-dirs");
expect(argv).toContain("--ignore-existing");
expect(argv).toContain("pgbackup@100.86.30.21:");
const ssh = argv.find((a) => a.startsWith("ssh "));
expect(ssh).toContain("-p 2222");
expect(ssh).toContain("StrictHostKeyChecking=yes");
expect(ssh).toContain("BatchMode=yes");
expect(ssh).toContain("IdentitiesOnly=yes");
});
test("without a token, still pulls and exits 0, saying the report was skipped", () => {
const { status, output, dest } = run();
expect(status).toBe(0);
expect(output).toContain("report skipped");
expect(fs.existsSync(path.join(dest, TODAY, "maximus_api.dump.age"))).toBe(true);
expect(fs.existsSync(path.join(tmp, "curl.argv"))).toBe(false);
});
test("keeps the destination private", () => {
const { dest } = run();
expect(fs.statSync(dest).mode & 0o777).toBe(0o700);
expect(fs.statSync(path.join(dest, TODAY, "globals.sql.age")).mode & 0o077).toBe(0);
});
test("a failed pull exits 2 and reports pull_failed", () => {
const { status } = run({ envFile: writeEnv(), extraEnv: { FAKE_RSYNC_FAIL: "1" } });
expect(status).toBe(2);
expect(JSON.parse(read("curl.body"))).toMatchObject({ ok: false, files: 0, error: "pull_failed" });
});
test("logs a warning when the newest directory is not today's", () => {
fs.writeFileSync(
path.join(bin, "rsync"),
`#!/bin/sh\nfor last; do :; done\nmkdir -p "$last/2026-10-09"\necho x > "$last/2026-10-09/a.age"\n`,
);
const { status, output } = run();
expect(status).toBe(0);
expect(output).toContain("latest=2026-10-09 files=1");
expect(output).toContain(`warning: no directory for ${TODAY}, latest is 2026-10-09`);
});
test("a dated directory without any .age file logs a warning", () => {
fs.writeFileSync(path.join(bin, "rsync"), `#!/bin/sh\nfor last; do :; done\nmkdir -p "$last/${TODAY}"\n`);
const { status, output } = run();
expect(status).toBe(0);
expect(output).toContain(`latest=${TODAY} files=0`);
expect(output).toContain(`warning: no .age file in ${TODAY}`);
});
test("a partial file left by rsync is not counted", () => {
fs.writeFileSync(
path.join(bin, "rsync"),
`#!/bin/sh\nfor last; do :; done\nmkdir -p "$last/${TODAY}/.rsync-partial"\n` +
`echo x > "$last/${TODAY}/a.age"\necho x > "$last/${TODAY}/.rsync-partial/b.age"\n`,
);
const { output } = run();
expect(output).toContain(`latest=${TODAY} files=1`);
});
test("no warning when today's directory is there", () => {
const { output } = run();
expect(output).toContain(`latest=${TODAY} files=2`);
expect(output).not.toContain("warning");
});
// The real rsync with the script's own filter, against a local outbox through
// the fake ssh: only .age files land, inside their dated directory, and a
// dated directory holding no .age file is not created. Skipped
// where rsync is absent (the CI image); the argv order test above runs there.
test.skipIf(!["/usr/bin/rsync", "/bin/rsync"].some((f) => fs.existsSync(f)))(
"with the real rsync, only .age files cross, inside their dated directory, never an empty one, never over a pulled file",
() => {
fs.rmSync(path.join(bin, "rsync"));
const outbox = path.join(tmp, "outbox");
fs.mkdirSync(path.join(outbox, TODAY), { recursive: true });
fs.writeFileSync(path.join(outbox, TODAY, "maximus_api.dump.age"), "cipher");
fs.writeFileSync(path.join(outbox, TODAY, "maximus_api.dump"), "clear");
fs.writeFileSync(path.join(outbox, "stray.sql"), "clear");
fs.mkdirSync(path.join(outbox, "2026-10-12"));
fs.writeFileSync(path.join(outbox, "2026-10-12", "maximus_api.dump"), "clear");
fs.mkdirSync(path.join(outbox, "2026-10-10"));
fs.writeFileSync(path.join(outbox, "2026-10-10", "globals.sql.age"), "garbage");
const dest = path.join(tmp, "dest");
fs.mkdirSync(path.join(dest, "2026-10-10"), { recursive: true });
fs.writeFileSync(path.join(dest, "2026-10-10", "globals.sql.age"), "good-backup");
const { status, output } = run({ extraEnv: { FAKE_OUTBOX: outbox } });
expect(status).toBe(0);
expect(fs.readdirSync(dest).sort()).toEqual(["2026-10-10", TODAY]);
expect(fs.readdirSync(path.join(dest, TODAY))).toEqual(["maximus_api.dump.age"]);
// A file already pulled is never rewritten by what the VPS serves later.
expect(fs.readFileSync(path.join(dest, "2026-10-10", "globals.sql.age"), "utf8")).toBe("good-backup");
expect(output).toContain(`latest=${TODAY} files=1`);
},
);
test("missing ssh key is an install problem: exit 1, nothing pulled", () => {
fs.rmSync(path.join(tmp, "key"));
const { status } = run();
expect(status).toBe(1);
expect(fs.existsSync(path.join(tmp, "rsync.argv"))).toBe(false);
});
});
describe("report", () => {
test("posts pulledAt, files and ok to /backups/pull", () => {
const { status } = run({ envFile: writeEnv() });
expect(status).toBe(0);
const body = JSON.parse(read("curl.body"));
expect(Object.keys(body).sort()).toEqual(["files", "ok", "pulledAt"]);
expect(body).toMatchObject({ ok: true, files: 2 });
expect(body.pulledAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
expect(read("curl.argv").split("\n")).toContain("https://health.example.test/backups/pull");
});
test("the token travels in a header file, never in argv nor in the output", () => {
const { output } = run({ envFile: writeEnv() });
expect(read("curl.header")).toBe(`Authorization: Bearer ${TOKEN}\n`);
expect(read("curl.argv")).not.toContain(TOKEN.slice(0, 12));
expect(output).not.toContain(TOKEN.slice(0, 12));
expect(fs.readdirSync(path.join(tmp, "state"))).not.toContain("auth.header");
});
// systemd stops a unit with SIGTERM. The fake curl sends it to the script
// while the header file exists; the trap must still remove it.
test("a TERM while the report is in flight still removes the header file", () => {
writeExecutable(
path.join(bin, "curl"),
`#!/bin/sh
cat > /dev/null
# Climb from the parent (the script, or a subshell of it, same cmdline) while
# the parent is still the script: the last one is the script itself.
ppid_of() { sed 's/.*) //' "/proc/$1/stat" | cut -d' ' -f2; }
script=$PPID
while parent=$(ppid_of "$script") && tr '\\0' ' ' < "/proc/$parent/cmdline" | grep -q run-pg-backup-pull; do
script=$parent
done
[ -f "$FAKE_DIR/state/auth.header" ] && echo present > "$FAKE_DIR/header.seen"
kill -TERM "$script"
sleep 1
printf 204
`,
);
const { status } = run({ envFile: writeEnv() });
expect(read("header.seen")).toBe("present\n");
expect(status).toBe(143);
expect(fs.readdirSync(path.join(tmp, "state"))).not.toContain("auth.header");
});
test("a non-2xx answer exits 3", () => {
const { status, output } = run({ envFile: writeEnv(), extraEnv: { FAKE_CURL_STATUS: "401" } });
expect(status).toBe(3);
expect(output).toContain("report failed status=401");
});
});
describe("rotation", () => {
test("keeps 30 dated directories, today included, and ignores other names", () => {
const dest = path.join(tmp, "dest");
for (const d of ["2026-09-11", "2026-09-12", "notes"]) fs.mkdirSync(path.join(dest, d), { recursive: true });
const { status, output } = run();
expect(status).toBe(0);
// The newest of several dated directories is the one counted.
expect(output).toContain(`latest=${TODAY} files=2`);
expect(fs.existsSync(path.join(dest, "2026-09-11"))).toBe(false);
expect(fs.existsSync(path.join(dest, "2026-09-12"))).toBe(true);
expect(fs.existsSync(path.join(dest, "notes"))).toBe(true);
});
// The VPS stopped producing on 2026-09-01 (dead cron, failing pg_dump): its
// outbox yields nothing new. Anchored on today, the rotation would delete the
// last off-site copy; anchored on the newest backup, it keeps it and the 29
// days before, and only removes what is older.
test("never removes the newest backup, however old: the window ends at the newest directory", () => {
const dest = path.join(tmp, "dest");
for (const d of ["2026-08-02", "2026-08-03", "2026-09-01"]) {
fs.mkdirSync(path.join(dest, d), { recursive: true });
fs.writeFileSync(path.join(dest, d, "a.age"), "x");
}
fs.writeFileSync(path.join(bin, "rsync"), "#!/bin/sh\nexit 0\n");
const { status, output } = run({ envFile: writeEnv() });
expect(status).toBe(0);
expect(output).toContain("latest=2026-09-01 files=1");
expect(fs.existsSync(path.join(dest, "2026-09-01", "a.age"))).toBe(true);
expect(fs.existsSync(path.join(dest, "2026-08-03"))).toBe(true);
expect(fs.existsSync(path.join(dest, "2026-08-02"))).toBe(false);
expect(JSON.parse(read("curl.body"))).toMatchObject({ ok: true, files: 1 });
});
// Nothing the VPS sends may delete a copy already here. A future-dated
// directory (VPS clock off, or a VPS gone rogue) would, as the newest name,
// push the rotation window forward over every real backup.
test("a future-dated directory from the VPS removes nothing and is not counted", () => {
const dest = path.join(tmp, "dest");
for (const d of ["2026-09-20", "2026-10-01", "2026-10-10"]) {
fs.mkdirSync(path.join(dest, d), { recursive: true });
fs.writeFileSync(path.join(dest, d, "a.age"), "x");
}
fs.writeFileSync(
path.join(bin, "rsync"),
`#!/bin/sh\nfor last; do :; done\nmkdir -p "$last/2099-01-01"\necho z > "$last/2099-01-01/z.age"\n`,
);
const { status, output } = run({ envFile: writeEnv() });
expect(status).toBe(0);
expect(fs.readdirSync(dest).sort()).toEqual(["2026-09-20", "2026-10-01", "2026-10-10", "2099-01-01"]);
expect(output).toContain(`warning: ignoring 2099-01-01, after ${TODAY}`);
expect(output).toContain("latest=2026-10-10 files=1");
expect(output).not.toContain("rotation: removed");
});
// A name of the dated shape that is no date (2026-13-45; busybox would turn
// 2026-02-30 into 2026-03-02) is ignored: the pass goes on, the directory
// is neither counted nor removed.
test("an impossible dated name is ignored, never stops the pass, never removed", () => {
const dest = path.join(tmp, "dest");
for (const d of ["2026-13-45", "2026-02-30", "2026-01-01"]) {
fs.mkdirSync(path.join(dest, d), { recursive: true });
fs.writeFileSync(path.join(dest, d, "z.age"), "z");
}
const { status, output } = run({ envFile: writeEnv() });
expect(status).toBe(0);
expect(output).toContain("warning: ignoring 2026-13-45, not a valid date");
expect(output).toContain("warning: ignoring 2026-02-30, not a valid date");
expect(output).toContain(`latest=${TODAY} files=2`);
expect(output).toContain("rotation: removed 2026-01-01");
expect(fs.existsSync(path.join(dest, "2026-13-45"))).toBe(true);
expect(fs.existsSync(path.join(dest, "2026-02-30"))).toBe(true);
expect(JSON.parse(read("curl.body"))).toMatchObject({ ok: true, files: 2 });
});
// The failure comes from a fake rm, not from file modes: the CI runs as
// root, which ignores a read-only directory.
test("a directory that cannot be removed fails loudly and reports rotation_failed", () => {
const dest = path.join(tmp, "dest");
fs.mkdirSync(path.join(dest, "2026-09-01"), { recursive: true });
writeExecutable(
path.join(bin, "rm"),
`#!/bin/sh\ncase "$*" in *2026-09-01*) exit 1 ;; esac\nexec /bin/rm "$@"\n`,
);
const { status, output } = run({ envFile: writeEnv() });
expect(status).toBe(1);
expect(output).toContain("rotation: could not remove 2026-09-01");
expect(JSON.parse(read("curl.body"))).toMatchObject({ ok: false, error: "rotation_failed" });
expect(fs.existsSync(path.join(dest, "2026-09-01"))).toBe(true);
});
});
describe("the script itself", () => {
test("never enables shell tracing — `set -x` would echo the token into journald", () => {
const traced = fs
.readFileSync(SCRIPT, "utf8")
.split("\n")
.filter((line) => !line.trim().startsWith("#"))
.filter((line) => /\bset\b[^#\n]*-[a-z]*x/.test(line));
expect(traced).toEqual([]);
});
test("is executable", () => {
expect(fs.statSync(SCRIPT).mode & 0o111).toBeGreaterThan(0);
});
});

View file

@ -1,597 +0,0 @@
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
const {
buildSnapshot,
buildTargetUrl,
postSnapshot,
readConfig,
DEFAULT_TIMEOUT_MS,
EXIT_CONFIG,
EXIT_PUSH,
} = require("../agent/push-metrics.js");
const { collectMetrics } = require("../metrics.js");
const { sanitizeSnapshot } = require("../index.js");
// Coverage for the local workstation agent (issue #15).
//
// Three things are worth stating up front, because they explain why this file
// spends most of its length on failure paths rather than on the happy one.
//
// * THE TOKEN MUST NOT SURVIVE INTO A LOG LINE. run-push.sh pipes the agent's
// output into `logger -t host-agent`; anything printed lands in syslog and
// journald for good. Every failure path here is therefore checked against
// every 5-character-or-longer fragment of the token — message, stack, JSON
// rendering — and the two spawned-process cases check the real stdout and
// stderr of a real run, which is what `logger` would actually swallow.
// * NO QUEUE, NO REPLAY. A failed push is dropped, not retried: the
// assertions count the requests the server saw and expect exactly one.
// * The payload contract is pinned against the server's own
// sanitizeSnapshot(), and one case pushes a REAL collectMetrics() snapshot
// through the REAL ingestion handler. A drift between agent and server
// shows up as a red test rather than as a 400 at 3 a.m. on the ThinkPad.
const AGENT_PATH = path.join(__dirname, "..", "agent", "push-metrics.js");
const RUNNER_PATH = path.join(__dirname, "..", "agent", "run-push.sh");
// Mixed letters and digits, no dictionary word and no run of five digits, so a
// port number or a timestamp in the output can never look like a leak.
const TOKEN = "7f2a9c4e1b6d8035a5e0c3d1";
const HOST_ID = "thinkpad";
const servers = [];
let tmpDir;
function fakeMetrics() {
return {
cpu: {
model: "Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz",
cores: 8,
loadAvg: [0.42, 0.31, 0.25],
usagePercent: 12,
},
memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 },
disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 },
};
}
async function startServer(onRequest) {
const server = http.createServer(onRequest);
servers.push(server);
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
return { server, url: `http://127.0.0.1:${server.address().port}` };
}
// A recording server that answers with the given status. Returns the log of
// what it saw, so "exactly one request" and "the Authorization header was sent"
// are assertions rather than assumptions.
async function startRecordingServer(status = 204) {
const seen = [];
const { url, server } = await startServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
seen.push({
method: req.method,
url: req.url,
headers: req.headers,
body: Buffer.concat(chunks).toString("utf-8"),
});
res.writeHead(status);
res.end();
});
});
return { url, server, seen };
}
async function closeServers() {
while (servers.length) {
const server = servers.pop();
server.closeAllConnections?.();
await new Promise((resolve) => server.close(resolve));
}
}
// Every substring of the token from `min` characters up. A failure message that
// contains none of them cannot contain the token, nor a truncated half of it.
function tokenFragments(token, min = 5) {
const out = [];
for (let len = min; len <= token.length; len++) {
for (let i = 0; i + len <= token.length; i++) out.push(token.slice(i, i + len));
}
return out;
}
function expectNoTokenLeak(text, token = TOKEN) {
const haystack = String(text);
const leaked = tokenFragments(token).filter((frag) => haystack.includes(frag));
// Report the longest match only: every shorter fragment of it also matches,
// and dumping the 200-odd of them buries the failure it is reporting.
const worst = leaked.reduce((a, b) => (b.length > a.length ? b : a), "");
expect(
leaked.length,
`token leaked into the output — longest fragment found: "${worst}"`,
).toBe(0);
}
// Everything a careless `console.error(err)` would print.
function renderError(err) {
return [
err.message,
String(err),
err.stack,
JSON.stringify(err),
JSON.stringify(err, Object.getOwnPropertyNames(err)),
].join("\n");
}
async function pushTo(url, overrides = {}) {
return postSnapshot({
apiUrl: url,
token: TOKEN,
hostId: HOST_ID,
snapshot: buildSnapshot(fakeMetrics()),
timeoutMs: 2000,
...overrides,
});
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-test-"));
});
afterEach(async () => {
await closeServers();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("payload — matches the POST /hosts/<id> contract", () => {
test("the built snapshot passes the server's sanitizeSnapshot() unchanged", () => {
const snapshot = buildSnapshot(fakeMetrics());
const result = sanitizeSnapshot(snapshot);
expect(result.error).toBeUndefined();
expect(result.snapshot).toEqual(snapshot);
});
test("exactly the documented keys, nothing more", () => {
const snapshot = buildSnapshot(fakeMetrics());
expect(Object.keys(snapshot).sort()).toEqual([
"cpu",
"disk",
"hostname",
"memory",
"uptime",
]);
expect(Object.keys(snapshot.cpu).sort()).toEqual([
"cores",
"loadAvg",
"model",
"usagePercent",
]);
for (const key of ["memory", "disk"]) {
expect(Object.keys(snapshot[key]).sort()).toEqual([
"freeGB",
"totalGB",
"usagePercent",
"usedGB",
]);
}
});
test("an extra field added to collectMetrics() does not leave the workstation", () => {
const metrics = fakeMetrics();
metrics.temperatureC = 61;
metrics.cpu.serial = "PF0X1234";
const snapshot = buildSnapshot(metrics);
expect(snapshot.temperatureC).toBeUndefined();
expect(snapshot.cpu.serial).toBeUndefined();
});
test("hostname and uptime come from the host, uptime floored like getHealth()", () => {
const snapshot = buildSnapshot(fakeMetrics());
expect(snapshot.hostname).toBe(os.hostname());
expect(Number.isInteger(snapshot.uptime)).toBe(true);
expect(snapshot.uptime).toBeGreaterThan(0);
});
test("a real collectMetrics() snapshot is accepted by sanitizeSnapshot()", async () => {
const snapshot = buildSnapshot(await collectMetrics());
const result = sanitizeSnapshot(snapshot);
expect(result.error).toBeUndefined();
expect(result.snapshot).toEqual(snapshot);
});
});
describe("delivery — one attempt, no queue", () => {
test("POSTs to /hosts/<id> with the bearer token and resolves on 204", async () => {
const { url, seen } = await startRecordingServer(204);
// Build ONCE and compare against that object. Two calls to buildSnapshot()
// straddling a second boundary differ by 1 on `uptime`, which turns this
// assertion into a one-in-several-runs failure.
const snapshot = buildSnapshot(fakeMetrics());
const result = await pushTo(url, { snapshot });
expect(result.statusCode).toBe(204);
expect(seen).toHaveLength(1);
expect(seen[0].method).toBe("POST");
expect(seen[0].url).toBe(`/hosts/${HOST_ID}`);
expect(seen[0].headers.authorization).toBe(`Bearer ${TOKEN}`);
expect(seen[0].headers["content-type"]).toBe("application/json");
expect(JSON.parse(seen[0].body)).toEqual(snapshot);
});
test("any 2xx counts as delivered", async () => {
const { url } = await startRecordingServer(200);
await expect(pushTo(url)).resolves.toEqual({ statusCode: 200 });
});
test.each([400, 401, 403, 413, 500, 503])("rejects on HTTP %i", async (status) => {
const { url } = await startRecordingServer(status);
await expect(pushTo(url)).rejects.toThrow(`push failed: HTTP ${status}`);
});
test("a rejected push is dropped, never retried", async () => {
const { url, seen } = await startRecordingServer(500);
await expect(pushTo(url)).rejects.toThrow();
await new Promise((resolve) => setTimeout(resolve, 150));
expect(seen).toHaveLength(1);
});
test("reports only err.code when the connection is refused", async () => {
const { url, server } = await startRecordingServer();
await new Promise((resolve) => server.close(resolve));
servers.pop();
await expect(pushTo(url)).rejects.toThrow("push failed: request error (code=ECONNREFUSED)");
});
test("gives up on a server that never answers, as ETIMEDOUT", async () => {
const { url } = await startServer(() => {
/* never responds */
});
const started = Date.now();
await expect(pushTo(url, { timeoutMs: 200 })).rejects.toThrow(
"push failed: request error (code=ETIMEDOUT)",
);
expect(Date.now() - started).toBeLessThan(2000);
});
test("the default timeout is 10s", () => {
expect(DEFAULT_TIMEOUT_MS).toBe(10000);
});
test("a trailing slash on HOSTS_API_URL does not double up", () => {
expect(buildTargetUrl("https://health.example.com/", "thinkpad").href).toBe(
"https://health.example.com/hosts/thinkpad",
);
expect(buildTargetUrl("https://health.example.com", "thinkpad").href).toBe(
"https://health.example.com/hosts/thinkpad",
);
});
test.each([
["ftp://health.example.com", "HOSTS_API_URL must be http:// or https://"],
["not a url", "HOSTS_API_URL is not a valid URL"],
])("refuses %s before opening a socket", async (apiUrl, message) => {
await expect(pushTo(apiUrl)).rejects.toThrow(message);
});
});
describe("the failure path never carries the token", () => {
test("HTTP failure: message, stack and JSON rendering are all clean", async () => {
const { url } = await startRecordingServer(500);
const err = await pushTo(url).catch((e) => e);
expect(err.message).toBe("push failed: HTTP 500");
expectNoTokenLeak(renderError(err));
});
test("transport failure: message, stack and JSON rendering are all clean", async () => {
const { url, server } = await startRecordingServer();
await new Promise((resolve) => server.close(resolve));
servers.pop();
const err = await pushTo(url).catch((e) => e);
expectNoTokenLeak(renderError(err));
});
test("timeout failure: message, stack and JSON rendering are all clean", async () => {
const { url } = await startServer(() => {});
const err = await pushTo(url, { timeoutMs: 200 }).catch((e) => e);
expectNoTokenLeak(renderError(err));
});
test("a token with a trailing newline fails without quoting itself", async () => {
const { url } = await startRecordingServer(204);
const err = await pushTo(url, { token: `${TOKEN}\n` }).catch((e) => e);
expect(err.message).toContain("HOSTS_INGEST_TOKEN contains an invalid character");
expectNoTokenLeak(renderError(err));
});
test("the real process output of a failing run contains no fragment of the token", async () => {
const { url } = await startRecordingServer(500);
const result = await execFileAsync(process.execPath, [AGENT_PATH], {
env: {
PATH: process.env.PATH,
HOSTS_API_URL: url,
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
},
}).catch((e) => e);
expect(result.code).toBe(EXIT_PUSH);
expect(result.stderr).toContain("host-agent: push failed: HTTP 500");
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test("the real process output of an unreachable server contains no fragment of the token", async () => {
const { url, server } = await startRecordingServer();
await new Promise((resolve) => server.close(resolve));
servers.pop();
const result = await execFileAsync(process.execPath, [AGENT_PATH], {
env: {
PATH: process.env.PATH,
HOSTS_API_URL: url,
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
},
}).catch((e) => e);
expect(result.code).toBe(EXIT_PUSH);
expect(result.stderr).toContain("code=ECONNREFUSED");
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
});
describe("configuration", () => {
test.each([
["HOSTS_API_URL", "HOSTS_API_URL is not set"],
["HOSTS_INGEST_TOKEN", "HOSTS_INGEST_TOKEN is not set"],
["HOST_ID", "HOST_ID is not set"],
])("a missing %s is named, not guessed", (missing, message) => {
const env = { HOSTS_API_URL: "https://x.test", HOSTS_INGEST_TOKEN: TOKEN, HOST_ID };
delete env[missing];
const { error, config } = readConfig(env);
expect(config).toBeUndefined();
expect(error.message).toContain(message);
expectNoTokenLeak(renderError(error));
});
test.each(["THINKPAD", "-thinkpad", "think pad", "a".repeat(33), "../thinkpad"])(
"rejects the malformed HOST_ID %j client-side",
(hostId) => {
const { error } = readConfig({
HOSTS_API_URL: "https://x.test",
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID: hostId,
});
expect(error.message).toContain("HOST_ID must match");
},
);
test.each([
["ftp://health.example.com", "HOSTS_API_URL must be http:// or https://"],
["not a url", "HOSTS_API_URL is not a valid URL"],
])("a broken HOSTS_API_URL (%j) is an install error, not a push error", (apiUrl, message) => {
const { error, config } = readConfig({
HOSTS_API_URL: apiUrl,
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
});
expect(config).toBeUndefined();
expect(error.message).toContain(message);
});
test("accepts a well-formed config and defaults the timeout", () => {
const { config } = readConfig({
HOSTS_API_URL: "https://health.example.com",
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
});
expect(config).toEqual({
apiUrl: "https://health.example.com",
token: TOKEN,
hostId: HOST_ID,
timeoutMs: DEFAULT_TIMEOUT_MS,
});
});
test("--dry-run prints the payload and touches no network", async () => {
const { stdout } = await execFileAsync(process.execPath, [AGENT_PATH, "--dry-run"], {
env: { PATH: process.env.PATH },
});
const snapshot = JSON.parse(stdout);
expect(sanitizeSnapshot(snapshot).error).toBeUndefined();
expect(snapshot.hostname).toBe(os.hostname());
});
});
describe("run-push.sh", () => {
function writeEnvFile(lines, mode = 0o600) {
const file = path.join(tmpDir, "maximus-host-agent.env");
fs.writeFileSync(file, `${lines.join("\n")}\n`, { mode });
return file;
}
async function runWrapper(envFile) {
return execFileAsync(RUNNER_PATH, [], {
env: { PATH: process.env.PATH, HOME: tmpDir, HOST_AGENT_ENV_FILE: envFile },
}).catch((e) => e);
}
test("delivers the snapshot end to end", async () => {
const { url, seen } = await startRecordingServer(204);
const envFile = writeEnvFile([
`HOSTS_API_URL=${url}`,
`HOSTS_INGEST_TOKEN=${TOKEN}`,
`HOST_ID=${HOST_ID}`,
`NODE_BIN=${process.execPath}`,
]);
const result = await runWrapper(envFile);
expect(result.stderr).toBe("");
expect(result.stdout).toContain(`host-agent: ok id=${HOST_ID} status=204`);
expect(seen).toHaveLength(1);
expect(seen[0].headers.authorization).toBe(`Bearer ${TOKEN}`);
expect(sanitizeSnapshot(JSON.parse(seen[0].body)).error).toBeUndefined();
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test.each([
[["HOSTS_INGEST_TOKEN", "HOST_ID"], "HOSTS_API_URL is not set"],
[["HOSTS_API_URL", "HOST_ID"], "HOSTS_INGEST_TOKEN is not set"],
[["HOSTS_API_URL", "HOSTS_INGEST_TOKEN"], "HOST_ID is not set"],
])("stops when a variable is missing (%j)", async (present, message) => {
const values = {
HOSTS_API_URL: "https://health.example.com",
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
};
const envFile = writeEnvFile(present.map((key) => `${key}=${values[key]}`));
const result = await runWrapper(envFile);
expect(result.code).toBe(1);
expect(result.stderr).toContain(message);
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test("stops when the env file is absent", async () => {
const missing = path.join(tmpDir, "nope.env");
const result = await runWrapper(missing);
expect(result.code).toBe(1);
expect(result.stderr).toContain(`host-agent: env file not found: ${missing}`);
});
test("warns about a world-readable env file but still runs", async () => {
const { url, seen } = await startRecordingServer(204);
const envFile = writeEnvFile(
[
`HOSTS_API_URL=${url}`,
`HOSTS_INGEST_TOKEN=${TOKEN}`,
`HOST_ID=${HOST_ID}`,
`NODE_BIN=${process.execPath}`,
],
0o644,
);
const result = await runWrapper(envFile);
expect(result.stderr).toContain("is mode 644, expected 600");
expect(seen).toHaveLength(1);
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test("never enables shell tracing — `set -x` would echo the token into syslog", () => {
const script = fs.readFileSync(RUNNER_PATH, "utf-8");
const traced = script
.split("\n")
.filter((line) => !line.trim().startsWith("#"))
.filter((line) => /\bset\b[^#\n]*-[a-z]*x/.test(line));
expect(traced).toEqual([]);
});
test("is executable", () => {
expect(fs.statSync(RUNNER_PATH).mode & 0o111).toBeGreaterThan(0);
});
});
describe("the agent stays out of the server image", () => {
test("the Dockerfile copies files one by one and none of them is the agent", () => {
const dockerfile = fs.readFileSync(path.join(__dirname, "..", "Dockerfile"), "utf-8");
const copies = dockerfile
.split("\n")
.filter((line) => /^\s*(COPY|ADD)\b/i.test(line));
expect(copies).toHaveLength(1);
expect(copies[0]).not.toMatch(/agent/);
// A bare `COPY . .` would drag agent/ in without ever naming it.
expect(copies[0]).not.toMatch(/^\s*COPY\s+\.\s/i);
});
});
describe("against the real ingestion handler", () => {
const savedEnv = {};
const ENV_KEYS = ["HOSTS_INGEST_TOKEN", "HOSTS_DIR", "HOSTS_ALLOWED_IDS", "HEALTH_TOKEN"];
beforeEach(() => {
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
process.env.HOSTS_INGEST_TOKEN = TOKEN;
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
process.env.HOSTS_ALLOWED_IDS = HOST_ID;
process.env.HEALTH_TOKEN = "read-token";
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
});
test("a real snapshot is accepted with 204 and persisted field for field", async () => {
// Fresh import so index.js picks up the env above.
delete require.cache[require.resolve("../index.js")];
const { handler } = require("../index.js");
const { url } = await startServer(handler);
const snapshot = buildSnapshot(await collectMetrics());
const result = await postSnapshot({
apiUrl: url,
token: TOKEN,
hostId: HOST_ID,
snapshot,
timeoutMs: 5000,
});
expect(result.statusCode).toBe(204);
const written = JSON.parse(
fs.readFileSync(path.join(process.env.HOSTS_DIR, `${HOST_ID}.json`), "utf-8"),
);
expect(written.id).toBe(HOST_ID);
expect(typeof written.receivedAt).toBe("string");
delete written.id;
delete written.receivedAt;
expect(written).toEqual(snapshot);
});
test("a wrong ingest token is rejected with 401 and no fragment leaks", async () => {
delete require.cache[require.resolve("../index.js")];
const { handler } = require("../index.js");
const { url } = await startServer(handler);
const err = await postSnapshot({
apiUrl: url,
token: "wrong-token",
hostId: HOST_ID,
snapshot: buildSnapshot(fakeMetrics()),
timeoutMs: 5000,
}).catch((e) => e);
expect(err.message).toBe("push failed: HTTP 401");
expectNoTokenLeak(renderError(err));
});
test("a host id outside the allowlist surfaces as HTTP 403", async () => {
process.env.HOSTS_ALLOWED_IDS = "someone-else";
delete require.cache[require.resolve("../index.js")];
const { handler } = require("../index.js");
const { url } = await startServer(handler);
await expect(
postSnapshot({
apiUrl: url,
token: TOKEN,
hostId: HOST_ID,
snapshot: buildSnapshot(fakeMetrics()),
timeoutMs: 5000,
}),
).rejects.toThrow("push failed: HTTP 403");
});
});

View file

@ -1,371 +0,0 @@
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
// Authentication safety net for the read routes.
//
// findings.test.js and health.test.js cover what each route *returns*; this
// file covers the gate in front of all of them. The small overlap on
// /defenseurs/findings and /health is deliberate: the value of this file is the
// complete route x failure-mode matrix in one place, so a routing/auth refactor
// that only rewires part of the table still fails loudly here.
//
// These tests describe the CURRENT behaviour of index.js. If one of them turns
// red after a refactor, the refactor changed the security contract.
const TOKEN = "test-token";
// Every GET route reachable by the handler. Any new route must be added here.
// POST /hosts/<id> is not a GET, so it gets its own describe block below —
// but it is covered, and any future route must be too.
const ROUTES = [
"/health",
"/defenseurs",
"/defenseurs/findings",
"/defenseurs/state",
"/reports/scans",
"/hosts",
// #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).
"/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
// open the write path, and a write token must not open the read routes.
const INGEST_TOKEN = "test-ingest-token";
let tmpDir;
let server;
let baseUrl;
let handler;
function startServer() {
// Fresh import so module-level constants pick up the stubbed env.
delete require.cache[require.resolve("../index.js")];
({ handler } = require("../index.js"));
server = http.createServer(handler);
return new Promise((resolve) => {
server.listen(0, () => {
const { port } = server.address();
baseUrl = `http://127.0.0.1:${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(path, { method = "GET", auth = `Bearer ${TOKEN}` } = {}) {
const headers = {};
if (auth) headers.Authorization = auth;
const res = await fetch(`${baseUrl}${path}`, { method, headers });
const body = await res.json().catch(() => ({}));
return { status: res.status, body };
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-auth-test-"));
process.env.HEALTH_TOKEN = TOKEN;
// Point every filesystem read at the temp dir so a rejected request can never
// fall back to the host's real /data/defenseurs.
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");
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
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
// is ever read, which would hide the 401 these tests are here to pin.
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
// Closed port: /health must never reach the real IdP. If the auth gate ever
// fails open, the request errors out fast instead of hitting production.
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(() => {
delete process.env.CLAUDE_USAGE_PATH;
delete process.env.BACKUPS_STATUS_PATH;
delete process.env.BACKUPS_PULL_PATH;
});
describe("auth gate — missing Authorization header", () => {
test.each(ROUTES)("401 on GET %s with no Authorization header", async (route) => {
const { status, body } = await request(route, { auth: null });
expect(status).toBe(401);
// Pin the gate itself: auth runs before any query-param validation, so
// /defenseurs/findings and /reports/scans must answer 401, never 400.
expect(body.error).toBe("Unauthorized");
});
});
describe("auth gate — invalid token", () => {
test.each(ROUTES)("401 on GET %s with a wrong bearer token", async (route) => {
const { status, body } = await request(route, { auth: "Bearer wrong-token" });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
});
describe("auth gate — malformed Authorization header", () => {
// The scheme must be exactly `Bearer `, then the token is compared in
// constant time via tokenMatches(), so these near-misses are all rejected.
// /defenseurs stands in for the read routes: it serves the parc-wide
// Defenseurs report and is the costliest to leak.
const MALFORMED = [
["raw token without the Bearer scheme", TOKEN],
["lowercase scheme", `bearer ${TOKEN}`],
["scheme with no token", "Bearer"],
// The separator is exactly one space: `startsWith("Bearer")` followed by
// slice(7) would accept any byte in its place.
["tab instead of the space", `Bearer\t${TOKEN}`],
["any other byte instead of the space", `BearerX${TOKEN}`],
];
test.each(MALFORMED)("401 on GET /defenseurs — %s", async (_label, header) => {
const { status, body } = await request("/defenseurs", { auth: header });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
});
describe("auth gate — near-miss tokens on the read routes", () => {
// A comparison that stops at the first differing byte leaks the token's
// length and prefix through timing (issue #17). These cases pin the
// observable contract — exact match only — on every read route. They would
// also pass against a naive `===`: the timing property itself is guarded by
// the timingSafeEqual spy and the source pins below, not by these.
const NEAR_MISSES = [
["token prefix", `Bearer ${TOKEN.slice(0, -1)}`],
["token with a suffix", `Bearer ${TOKEN}x`],
["double space after the scheme", `Bearer ${TOKEN}`],
];
const CASES = ROUTES.flatMap((route) => NEAR_MISSES.map(([label, header]) => [route, label, header]));
test.each(CASES)("401 on GET %s — %s", async (route, _label, header) => {
const { status, body } = await request(route, { auth: header });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
// `Bearer ` with an empty token cannot be sent over the wire: the HTTP
// parser strips trailing whitespace and the server sees `Bearer`, already
// covered above. The empty-token branch is pinned on the helper directly.
test("tokenMatches rejects an empty token", () => {
const { tokenMatches } = require("../index.js");
expect(tokenMatches("", TOKEN)).toBe(false);
expect(tokenMatches(TOKEN, TOKEN)).toBe(true);
});
});
describe("auth gate — every comparison goes through timingSafeEqual on digests", () => {
// Behavioural guard for issue #17, independent of how the code is written.
// index.js destructures timingSafeEqual at load time, so the spy is set
// BEFORE a fresh require. Each request must then reach timingSafeEqual with
// two 32-byte buffers (SHA-256 digests). That fails for a short-circuiting
// `===` (never called), for a per-kind early return (never called on the
// read path), and for raw strings behind a length check (the GHSA-376v-xgjx-7mfr
// shape: never called when lengths differ, called with non-digests otherwise).
const crypto = require("node:crypto");
let spy;
beforeEach(async () => {
spy = vi.spyOn(crypto, "timingSafeEqual");
await restartServer();
});
afterEach(() => {
spy.mockRestore();
});
function expectDigestComparison() {
expect(spy).toHaveBeenCalledTimes(1);
const [a, b] = spy.mock.calls[0];
expect(Buffer.isBuffer(a) && Buffer.isBuffer(b)).toBe(true);
expect([a.length, b.length]).toEqual([32, 32]);
}
test.each(ROUTES)("GET %s with a shorter wrong token compares two digests", async (route) => {
const { status } = await request(route, { auth: "Bearer x" });
expect(status).toBe(401);
expectDigestComparison();
});
test.each(ROUTES)("GET %s with a same-length wrong token compares two digests", async (route) => {
const sameLength = "x".repeat(TOKEN.length);
const { status } = await request(route, { auth: `Bearer ${sameLength}` });
expect(status).toBe(401);
expectDigestComparison();
});
test("GET /defenseurs with the right token compares two digests", async () => {
const { status } = await request("/defenseurs");
expect(status).toBe(200);
expectDigestComparison();
});
test("POST /hosts/<id> with a shorter wrong token compares two digests", async () => {
const { status } = await request("/hosts/thinkpad", { method: "POST", auth: "Bearer x" });
expect(status).toBe(401);
expectDigestComparison();
});
});
describe("auth gate — source pins: one constant-time comparison", () => {
// Structural complement to the spy above: read index.js, comments stripped,
// and check how checkAuth() and tokenMatches() are written.
const source = fs
.readFileSync(path.join(__dirname, "..", "index.js"), "utf-8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "");
function functionBody(name) {
const start = source.indexOf(`function ${name}(`);
expect(start).toBeGreaterThanOrEqual(0);
const rest = source.slice(start + 1);
const next = rest.search(/^(async\s+)?function\s/m);
return next === -1 ? rest : rest.slice(0, next);
}
// Every equality operator in a body, with its two operands.
function comparisons(body) {
return [...body.matchAll(/([\w."'$]+)\s*(===|!==|==|!=)\s*([\w."'$]+)/g)].map(
([, left, op, right]) => `${left} ${op} ${right}`,
);
}
test("no direct equality against a Bearer string anywhere in index.js", () => {
// Template literal (`Bearer ${x}`) or concatenation ("Bearer " + x), on
// either side of == / ===.
const bearerExpr = String.raw`(\`Bearer [^\`]*\`|["']Bearer ["']\s*\+\s*[\w.]+)`;
const direct = new RegExp(String.raw`[!=]==?\s*${bearerExpr}|${bearerExpr}\s*[!=]==?`);
expect(source).not.toMatch(direct);
});
test("checkAuth() has one tokenMatches call, one success exit, no token equality", () => {
const body = functionBody("checkAuth");
expect(body.match(/tokenMatches\(/g)).toHaveLength(1);
// A single `return null`: no early success exit for one token kind.
expect(body.match(/return\s+null\b/g)).toHaveLength(1);
// Allowlist, both operand orders included: anything else (`want ===
// header`, `expected === token`) is a new comparison on a secret. Since
// #48 the expected token comes from the TOKEN_KINDS table, so the former
// `route.tokenKind === "ingest"` branch is gone: the list only shrank.
expect(comparisons(body).sort()).toEqual(['header === "string"']);
expect(body).not.toMatch(/isIngest\s*\?[^;]*tokenMatches/);
});
test("tokenMatches() hashes both sides before timingSafeEqual", () => {
const body = functionBody("tokenMatches");
expect(body.match(/createHash\("sha256"\)/g)).toHaveLength(2);
expect(body.match(/timingSafeEqual\(/g)).toHaveLength(1);
// No length check and no equality other than the type guards.
expect(body).not.toMatch(/\.length\b/);
expect(comparisons(body).sort()).toEqual(['expected !== "string"', 'provided !== "string"']);
});
});
describe("auth gate — fail-closed when HEALTH_TOKEN is unset", () => {
test("401 on GET /defenseurs even with a well-formed bearer token", async () => {
delete process.env.HEALTH_TOKEN;
await restartServer();
const { status, body } = await request("/defenseurs", { auth: `Bearer ${TOKEN}` });
expect(status).toBe(401);
expect(body.error).toBe("HEALTH_TOKEN not configured");
});
});
describe("routing", () => {
test("404 on an unknown route", async () => {
const { status, body } = await request("/nope");
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
test.each(ROUTES)("404 on POST %s (method not allowed)", async (route) => {
const { status, body } = await request(route, { method: "POST" });
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
// Current ordering: the route/method check runs BEFORE authentication, so an
// unauthenticated caller gets 404 rather than 401 on these. Documented as-is;
// any refactor that flips the order will turn these red on purpose.
test("404 (not 401) on an unknown route without Authorization", async () => {
const { status, body } = await request("/nope", { auth: null });
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
test("404 (not 401) on POST /claude-usage/history without Authorization", async () => {
const { status, body } = await request("/claude-usage/history", { method: "POST", auth: null });
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
test("404 (not 401) on POST /health without Authorization", async () => {
const { status, body } = await request("/health", { method: "POST", auth: null });
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
});
// The ingest path is the only write surface on this service, and the only one
// reachable with a token that is NOT the read token. Both directions of that
// separation are pinned here: a read token must not write, a write token must
// not read. The gate answers before the request body is ever buffered, so an
// unauthenticated caller cannot make the server hold 4 KiB on its behalf.
describe("auth gate — ingest route", () => {
test("401 on POST /hosts/<id> with no Authorization header", async () => {
const { status, body } = await request("/hosts/thinkpad", { method: "POST", auth: null });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
test("401 on POST /hosts/<id> with the read token", async () => {
const { status, body } = await request("/hosts/thinkpad", {
method: "POST",
auth: `Bearer ${TOKEN}`,
});
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
test("401 on GET /hosts with the ingest token", async () => {
const { status, body } = await request("/hosts", { auth: `Bearer ${INGEST_TOKEN}` });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
// An unknown host id never reaches the handler: the route regex is the
// path-traversal control, so a malformed id is a 404, not a 403 — and that
// 404 arrives before authentication, like every other routing decision.
test("404 (not 401, not 403) on POST /hosts/../../etc/passwd", async () => {
const { status } = await request("/hosts/../../etc/passwd", { method: "POST", auth: null });
expect(status).toBe(404);
});
});

View file

@ -1,871 +0,0 @@
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, and the files block of #61 in
// the script's real shapes: a failed encryption keeps the archive's time and
// size, a missing source has neither.
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 },
files: [
{ name: "claude-usage", sizeBytes: 2466, archivedAt: "2026-10-11T03:00:43Z", ok: true },
{ name: "roadtrip-uploads", sizeBytes: 0, archivedAt: null, ok: false, error: "source_missing" },
{ name: "hosts", sizeBytes: 512, archivedAt: "2026-10-11T03:00:44Z", ok: false, error: "encrypt_failed" },
],
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.files[0].path = "/data/claude-usage";
payload.files[1] = JSON.parse(`{"__proto__": {"polluted": true}, ${JSON.stringify(payload.files[1]).slice(1)}`);
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("an invalid offsite retention is dropped, local kept", () => {
const { sanitizeBackupsStatus } = freshModule();
const payload = validStatus();
payload.retentionDays = { local: 7, offsite: "30" };
expect(sanitizeBackupsStatus(payload).status.retentionDays).toEqual({ local: 7 });
payload.retentionDays = { local: 7, offsite: -1 };
expect(sanitizeBackupsStatus(payload).status.retentionDays).toEqual({ local: 7 });
});
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("the file codes are a list of their own, restated here", () => {
const { FILE_ERROR_CODES, MAX_FILES } = freshModule();
expect(FILE_ERROR_CODES).toEqual(["source_missing", "archive_failed", "encrypt_failed", "invalid_name"]);
expect(MAX_FILES).toBe(16);
});
test.each(["source_missing", "archive_failed", "encrypt_failed", "invalid_name"])("the file code %s is kept", (code) => {
const { sanitizeBackupsStatus } = freshModule();
const payload = validStatus();
payload.files[1].error = code;
expect(sanitizeBackupsStatus(payload).status.files[1].error).toBe(code);
});
test("a database code is dropped on a file, a file code on a database", () => {
const { sanitizeBackupsStatus } = freshModule();
const payload = validStatus();
payload.files[1].error = "dump_failed";
payload.files[2].error = "tar: claude-usage/history: file removed before we read it";
payload.databases[1].error = "source_missing";
const { status } = sanitizeBackupsStatus(payload);
expect(status.files[1]).toEqual({ name: "roadtrip-uploads", sizeBytes: 0, archivedAt: null, ok: false });
expect(status.files[2]).toEqual({ name: "hosts", sizeBytes: 512, archivedAt: "2026-10-11T03:00:44Z", ok: false });
expect(status.databases[1]).toEqual({ name: "roadtrip", sizeBytes: 0, dumpedAt: null, ok: false });
});
test("an invalid file value is dropped, never coerced", () => {
const { sanitizeBackupsStatus } = freshModule();
const payload = validStatus();
payload.files[0] = { name: "claude-usage", sizeBytes: -1, archivedAt: "2026-10-11", ok: 1 };
payload.files[1] = { name: "hosts", sizeBytes: "512", archivedAt: "2026-10-11T03:00:44", ok: "false" };
const { status } = sanitizeBackupsStatus(payload);
expect(status.files[0]).toEqual({ name: "claude-usage" });
expect(status.files[1]).toEqual({ name: "hosts" });
});
test("a file name the script would refuse is dropped, the entry kept; a hyphen passes", () => {
const { sanitizeBackupsStatus } = freshModule();
const payload = validStatus();
payload.files = [
{ name: "Bad;Name", sizeBytes: 0, archivedAt: null, ok: false, error: "invalid_name" },
{ name: "x".repeat(64), sizeBytes: 1, archivedAt: null, ok: true },
{ name: "", sizeBytes: 1, archivedAt: null, ok: true },
{ name: "a-b_c-9", sizeBytes: 1, archivedAt: null, ok: true },
];
const { status } = sanitizeBackupsStatus(payload);
expect(status.files[0]).toEqual({ sizeBytes: 0, archivedAt: null, ok: false, error: "invalid_name" });
expect(status.files[1]).not.toHaveProperty("name");
expect(status.files[2]).not.toHaveProperty("name");
expect(status.files[3].name).toBe("a-b_c-9");
expect(status.files).toHaveLength(4);
});
test("files past 16 entries keep the first 16, non-objects are skipped", () => {
const { sanitizeBackupsStatus } = freshModule();
const payload = validStatus();
payload.files = [
"claude-usage",
null,
[],
...Array.from({ length: 20 }, (_, i) => ({ name: `dir-${i}`, sizeBytes: i, archivedAt: null, ok: true })),
];
const { status } = sanitizeBackupsStatus(payload);
expect(status.files).toHaveLength(16);
expect(status.files[0].name).toBe("dir-0");
expect(status.files[15].name).toBe("dir-15");
});
test("an empty files list (FILE_SOURCES empty) is kept as []", () => {
const { sanitizeBackupsStatus } = freshModule();
const payload = { ...validStatus(), files: [] };
expect(sanitizeBackupsStatus(payload).status.files).toEqual([]);
});
test("a status from a script older than #61 has no files key and no error", () => {
const { sanitizeBackupsStatus } = freshModule();
const { files: _dropped, ...older } = validStatus();
expect(sanitizeBackupsStatus(older)).toEqual({ status: older });
});
test("a component that is not an object is dropped", () => {
const { sanitizeBackupsStatus } = freshModule();
const payload = { ...validStatus(), globals: [], mirror: "ok", rotation: null, retentionDays: 7, databases: {}, files: { name: "claude-usage" } };
const { status } = sanitizeBackupsStatus(payload);
expect(status.generatedAt).toBe(validStatus().generatedAt);
for (const key of ["globals", "mirror", "rotation", "retentionDays", "databases", "files"]) {
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("a status without files (script older than #61) is served with no files key and no vpsError", async () => {
const { files: _dropped, ...older } = validStatus();
writeFile(statusPath, older);
const { status, body } = await read();
expect(status).toBe(200);
expect(body).not.toHaveProperty("vpsError");
expect(body.vps).not.toHaveProperty("files");
expect(body.vps.databases).toEqual(older.databases);
});
test("the files block reaches the response as the script wrote it", async () => {
writeFile(statusPath, validStatus());
const { body } = await read();
expect(body.vps.files).toEqual(validStatus().files);
});
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

@ -1,602 +0,0 @@
const fs = require("node:fs");
const path = require("node:path");
const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const { spawn } = require("node:child_process");
// Pins on the CI workflow and on what the boot check assumes of the Dockerfile.
// The runtime stays 0-dependency and vitest ships no YAML parser, so these read
// the files with narrow regexes: each one looks at a single key, never at the
// YAML structure as a whole.
const ROOT = path.join(__dirname, "..");
const CI_PATH = path.join(ROOT, ".forgejo", "workflows", "ci.yml");
const ci = fs.readFileSync(CI_PATH, "utf-8");
const dockerfile = fs.readFileSync(path.join(ROOT, "Dockerfile"), "utf-8");
// Comment lines stripped, so an explanation that quotes a key cannot satisfy
// (or break) a pin.
function stripCommentLines(text) {
return text
.split("\n")
.filter((line) => !/^\s*#/.test(line))
.join("\n");
}
const ciCode = stripCommentLines(ci);
// Lines of a top-level-indented block (` pull_request:`), up to the next key at
// the same or a lower indentation.
function block(text, key) {
const lines = text.split("\n");
const start = lines.findIndex((l) => new RegExp(`^(\\s*)${key}:`).test(l));
if (start === -1) return null;
const indent = lines[start].match(/^(\s*)/)[1].length;
const out = [lines[start]];
for (const line of lines.slice(start + 1)) {
if (line.trim() === "") continue;
if (line.match(/^(\s*)/)[1].length <= indent) break;
out.push(line);
}
return out.join("\n");
}
describe("CI workflow", () => {
test("runs in the Dockerfile's own base image", () => {
const from = dockerfile.match(/^\s*FROM\s+(\S+)/m);
const container = ciCode.match(/^\s*container:\s*["']?([^"'\s]+)["']?\s*$/m);
expect(from).not.toBeNull();
expect(container).not.toBeNull();
expect(container[1]).toBe(from[1]);
});
test("runs the unit tests, the socket pass and the boot check", () => {
expect(ciCode).toMatch(/^\s*run:\s*npm ci\s*$/m);
expect(ciCode).toMatch(/^\s*run:\s*npm test\s*$/m);
expect(ciCode).toMatch(/^\s*run:\s*bash test-curl\.sh\s*$/m);
expect(ciCode).toMatch(/^\s*run:\s*sh scripts\/boot-from-copy\.sh\s*$/m);
});
test("builds every pull request, whatever branch it targets", () => {
// A `branches: [main]` here would leave the intermediate links of a stack
// without any status.
const pr = block(ciCode, "pull_request");
expect(pr).not.toBeNull();
expect(pr).not.toMatch(/branches(-ignore)?:/);
});
test("builds pushes to main", () => {
const push = block(ciCode, "push");
expect(push).not.toBeNull();
expect(push).toMatch(/branches:\s*\[\s*main\s*\]/);
});
test("avoids the two forms the runner rejects before the first command", () => {
// `defaults:` anywhere, and `working-directory` on a `uses:` step.
expect(ciCode).not.toMatch(/^\s*defaults:/m);
const steps = ciCode.split(/^\s*- (?=name:|uses:|run:)/m);
for (const step of steps) {
if (/^\s*uses:|\n\s*uses:/.test(step)) expect(step).not.toMatch(/working-directory:/);
}
});
// `continue-on-error: true` turns a red step green without a trace in the
// commit status. Any literal spelling of the key counts: a block key or a
// list item, but also a flow mapping and a quoted key, which a pin anchored
// at the start of the line let through (#35), and a YAML anchor reused as a
// key, which only a match on the bare word catches. Unanchored and bare on
// purpose: the word in a `run:` script, a step name or a trailing comment
// fails the pin too, loudly. Only full comment lines are exempt, being
// stripped from `ciCode`. An escaped key (`"continue\u002don-error": true`)
// still passes: the pin reads text, not YAML.
const CONTINUE_ON_ERROR = /continue-on-error/;
test("never lets a failing step pass", () => {
expect(ciCode).not.toMatch(CONTINUE_ON_ERROR);
});
// The detector itself, through the same comment stripping as `ciCode`.
// Anchored at the start of the line again, it would miss the flow mapping,
// the quoted key and the anchor; with a colon required right after the word,
// the quoted key and the anchor; with a closing quote allowed before that
// colon, the anchor alone.
test.each([
["a block key", " - name: x\n run: npm test\n continue-on-error: true"],
["a list item", " - continue-on-error: true\n run: npm test"],
["a flow mapping", " - { name: x, run: false, continue-on-error: true }"],
["a quoted key", ' - name: x\n "continue-on-error": true'],
["an anchor reused as a key", " - name: x\n env: { STEP_KEY: &coe continue-on-error }\n *coe : true"],
])("the continue-on-error pin catches %s", (_, step) => {
expect(stripCommentLines(step)).toMatch(CONTINUE_ON_ERROR);
});
test("the continue-on-error pin ignores a full comment line quoting the key", () => {
expect(stripCommentLines(" # never add continue-on-error: true here")).not.toMatch(CONTINUE_ON_ERROR);
});
});
describe("what the boot check assumes of the Dockerfile", () => {
test("the image still drops to the node user, as the script does", () => {
expect(dockerfile).toMatch(/^\s*USER\s+node\s*$/m);
});
test("CMD is in exec form, the only one the script parses", () => {
const cmd = dockerfile.match(/^\s*CMD\s+(.+)$/m);
expect(cmd).not.toBeNull();
const argv = JSON.parse(cmd[1]);
expect(Array.isArray(argv)).toBe(true);
expect(argv.length).toBeGreaterThan(0);
});
});
// Spawns a command from the repository root with `env` over the test's own
// environment; a key set to undefined is dropped (spawn ignores undefined
// values). Async, so a listener in this process keeps answering meanwhile.
function run(command, args, env) {
return new Promise((resolve, reject) => {
const started = Date.now();
const child = spawn(command, args, { cwd: ROOT, env: { ...process.env, ...env } });
let stdout = "";
let stderr = "";
child.stdout.on("data", (d) => (stdout += d));
child.stderr.on("data", (d) => (stderr += d));
child.on("error", reject);
child.on("close", (code) => resolve({ code, stdout, stderr, ms: Date.now() - started }));
});
}
const runBootScript = (env) => run("sh", ["scripts/boot-from-copy.sh"], env);
// Ports nothing listens on, all distinct: the probes are open together, since
// a port closed first could be handed out again to the next probe. They close
// before the script starts: should something take a port in between, the
// script's own check refuses it and the test fails loudly.
async function freePorts(count) {
const probes = [];
for (let i = 0; i < count; i++) {
const probe = http.createServer();
await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve));
probes.push(probe);
}
const ports = probes.map((probe) => probe.address().port);
await Promise.all(probes.map((probe) => new Promise((resolve) => probe.close(resolve))));
return ports;
}
const freePort = async () => (await freePorts(1))[0];
describe("boot-from-copy.sh", () => {
// A listener already on the port: without the pre-check, the wait loop could
// reach this neighbour and assert on a server it never started.
test("refuses a port something else already holds", async () => {
const neighbour = http.createServer((req, res) => res.end("not the API"));
await new Promise((resolve) => neighbour.listen(0, "127.0.0.1", resolve));
try {
const { port } = neighbour.address();
const { code, stderr } = await runBootScript({ BOOT_PORT: String(port) });
expect(code).toBe(1);
expect(stderr).toMatch(new RegExp(`port ${port} is already taken`));
} finally {
await new Promise((resolve) => neighbour.close(resolve));
}
}, 15000);
// Every curl of the script targets the loopback, and a proxy from the
// environment would answer in the server's place. Before #35 only the port
// check had --noproxy: the wait loop and the assertions reached the proxy.
test("never sends a request through a proxy from the environment", async () => {
// Answers everything, as a real proxy would, and records what reaches it.
const proxied = [];
const proxy = http.createServer((req, res) => {
proxied.push(`${req.method} ${req.url}`);
res.end("answered by the proxy");
});
await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve));
try {
const proxyUrl = `http://127.0.0.1:${proxy.address().port}`;
// ALL_PROXY as well, so that unsetting http_proxy alone would not pass.
// no_proxy leaves the loopback out, as an inherited one may: curl reads
// it before NO_PROXY, so a script exporting NO_PROXY alone would still go
// through the proxy. NO_PROXY is dropped, so that nothing the test itself
// inherited decides the outcome.
const env = {
http_proxy: proxyUrl,
ALL_PROXY: proxyUrl,
NO_PROXY: undefined,
no_proxy: "example.invalid",
};
const port = await freePort();
// The proxy is live: a bare curl with the same environment reaches it.
// Without this, "nothing reached the proxy" could pass for nothing.
await run("curl", ["-s", "-o", "/dev/null", "--max-time", "5", `http://127.0.0.1:${port}/`], env);
expect(proxied).toEqual([`GET http://127.0.0.1:${port}/`]);
proxied.length = 0;
const { code, stderr } = await runBootScript({ ...env, BOOT_PORT: String(port) });
expect(proxied).toEqual([]);
expect(code, stderr).toBe(0);
} finally {
await new Promise((resolve) => proxy.close(resolve));
}
}, 20000);
});
// The socket-level pass boots its own two servers. Every case hands it ports
// of its own through TEST_CURL_PORT and TEST_CURL_NOINGEST_PORT (#46): the
// defaults, 3099 and 3098, are never touched here, so a second `npm test` or a
// `bash test-curl.sh` running next to this one is not in the way. The defaults
// are pinned on the source below and exercised by the CI step that runs the
// script bare.
describe("test-curl.sh", () => {
const script = fs.readFileSync(path.join(ROOT, "test-curl.sh"), "utf-8");
const scriptCode = stripCommentLines(script);
const urlOf = (port) => `http://localhost:${port}`;
// One connection attempt: true if something accepts it.
function accepts(port) {
return new Promise((resolve) => {
const socket = net.connect(port, "127.0.0.1");
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("error", () => resolve(false));
});
}
// True once nothing accepts a connection on the port, false after 3 s of
// trying: a server killed by the script's trap takes a moment to let go.
async function released(port) {
for (let i = 0; i < 30; i++) {
if (!(await accepts(port))) return true;
await new Promise((resolve) => setTimeout(resolve, 100));
}
return false;
}
// Throws on a missing port: the script reads an empty variable as unset, and
// the case would drift back to 3099 and 3098 with nothing to show for it.
function runSocketPass(ports, env = {}) {
const [main, noIngest] = ports;
if (!Number.isInteger(main) || !Number.isInteger(noIngest)) {
throw new Error("runSocketPass needs the two ports of the script");
}
return run("bash", ["test-curl.sh"], {
TEST_CURL_PORT: String(main),
TEST_CURL_NOINGEST_PORT: String(noIngest),
...env,
});
}
// Read on the source, not by running the script bare: that run would hold
// 3099 and 3098 during `npm test`, which is what #46 removed.
test("listens on 3099 and 3098 unless the two variables say otherwise", () => {
expect(scriptCode).toMatch(/^MAIN_PORT="\$\{TEST_CURL_PORT:-3099\}"$/m);
expect(scriptCode).toMatch(/^NOINGEST_PORT="\$\{TEST_CURL_NOINGEST_PORT:-3098\}"$/m);
// The two defaults and nothing else: no port written into a launch or a URL.
expect(scriptCode.match(/309[89]/g)).toEqual(["3099", "3098"]);
expect(scriptCode).toMatch(/^PORT="\$MAIN_PORT" \\$/m);
expect(scriptCode).toMatch(/^PORT="\$NOINGEST_PORT" \\$/m);
});
// An overridden URL with the listening port left alone sent every curl away
// from the servers the script had started.
test("derives its two URLs from the ports, never from the environment", () => {
expect(scriptCode.match(/^(BASE_URL|NOINGEST_URL)=.*$/gm)).toEqual([
'BASE_URL="http://localhost:$MAIN_PORT"',
'NOINGEST_URL="http://localhost:$NOINGEST_PORT"',
]);
});
// Same exposure as the boot check, 18 curl lines of it (#41). Before the
// export, a dead proxy made the script exit 7 with no output, and a live one
// would have answered every case in the API's place.
test("never sends a request through a proxy from the environment", async () => {
const proxied = [];
const proxy = http.createServer((req, res) => {
proxied.push(`${req.method} ${req.url}`);
res.end("answered by the proxy");
});
await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve));
try {
const proxyUrl = `http://127.0.0.1:${proxy.address().port}`;
// Same environment as the boot check's case above, for the same reasons.
// FORCE_COLOR on top: node would colour the numbers the cases compare.
// The two URLs point at a port nothing can hold: read from the
// environment, they would fail every case (vite alone already sets
// BASE_URL to "/" in the test process).
const env = {
http_proxy: proxyUrl,
ALL_PROXY: proxyUrl,
NO_PROXY: undefined,
no_proxy: "example.invalid",
FORCE_COLOR: "3",
BASE_URL: "http://127.0.0.1:1",
NOINGEST_URL: "http://127.0.0.1:1",
// Same for the two tokens: one value for both, read from the
// environment, would let each token open the other's routes.
TOKEN: "inherited-token",
INGEST_TOKEN: "inherited-token",
};
// The proxy is live: a bare curl with the same environment reaches it.
const [probed, ...ports] = await freePorts(3);
await run("curl", ["-s", "-o", "/dev/null", "--max-time", "5", `http://127.0.0.1:${probed}/`], env);
expect(proxied).toEqual([`GET http://127.0.0.1:${probed}/`]);
proxied.length = 0;
const { code, stdout, stderr } = await runSocketPass(ports, env);
// On the first try, with no grace: the trap ends on a `wait`, so both
// servers are gone by the time the script exits. On the fixed ports the
// next case found out by failing to listen; with ports of its own,
// nothing else would.
const stillOpen = [await accepts(ports[0]), await accepts(ports[1])];
expect(stillOpen).toEqual([false, false]);
expect(proxied).toEqual([]);
expect(code, stdout + stderr).toBe(0);
// Every case ran against the API: a count, so a script cut short fails.
expect(stdout).toMatch(/=== Results: 57 passed, 0 failed ===/);
} finally {
await new Promise((resolve) => proxy.close(resolve));
}
}, 30000);
// What #46 is for: two passes at the same instant, each on its own ports,
// as two `npm test` side by side. On the fixed ports at least one of the two
// failed every time (review of PR #45).
test("two passes at once both succeed, each on its own ports", async () => {
const ports = await freePorts(4);
try {
const results = await Promise.all([
runSocketPass(ports.slice(0, 2)),
runSocketPass(ports.slice(2)),
]);
for (const { code, stdout, stderr } of results) {
expect(code, stdout + stderr).toBe(0);
expect(stdout).toMatch(/=== Results: 57 passed, 0 failed ===/);
}
} finally {
for (const port of ports) expect(await released(port)).toBe(true);
}
}, 30000);
// Refused before anything starts, with the variable named. Port 0 would
// make node pick a port of its own, and two identical ports pass the "is it
// free" check: both would only end on a message about a server that died.
// Each value on each variable: the two are checked by separate calls.
const badValues = [
["a value that is no number", "abc"],
["port 0", "0"],
["a port above 65535", "65536"],
// Digits first, then anything: without the end anchor of the pattern the
// value got through, and the numeric comparison after it errored out.
["a number followed by letters", "80abc"],
].flatMap(([what, value]) =>
["TEST_CURL_PORT", "TEST_CURL_NOINGEST_PORT"].map((name) => [
`${what} in ${name}`,
() => ({ [name]: value }),
`${name} must be a port number (1-65535), got '${value}'`,
]),
);
test.each([
...badValues,
[
"the same port twice",
([main]) => ({ TEST_CURL_NOINGEST_PORT: String(main) }),
"TEST_CURL_PORT and TEST_CURL_NOINGEST_PORT must differ",
],
])("refuses %s", async (_, override, message) => {
const ports = await freePorts(2);
const { code, stdout, stderr, ms } = await runSocketPass(ports, override(ports));
expect(stderr).toContain(`test-curl.sh: ${message}`);
expect(code).toBe(1);
expect(stdout).not.toMatch(/PASS|FAIL/);
expect(ms).toBeLessThan(3000);
for (const port of ports) expect(await released(port)).toBe(true);
}, 20000);
// The two ends of the range are accepted. Given to both variables, so that
// nothing has to listen on a port fixed in advance: the "must differ"
// refusal comes after the range check, and reaching it shows the value
// passed.
test.each(["1", "65535"])("accepts port %s, the end of the range", async (port) => {
const { code, stdout, stderr } = await run("bash", ["test-curl.sh"], {
TEST_CURL_PORT: port,
TEST_CURL_NOINGEST_PORT: port,
});
expect(stderr).not.toContain("must be a port number");
expect(stderr).toContain(`test-curl.sh: TEST_CURL_PORT and TEST_CURL_NOINGEST_PORT must differ (both ${port})`);
expect(code).toBe(1);
expect(stdout).not.toMatch(/PASS|FAIL/);
}, 20000);
// A real neighbour on one of the two ports, as a stray server would be.
// Without the check up front the wait loop reaches the neighbour before the
// script's own server has died of EADDRINUSE, and the cases run against it
// (review of PR #45). Nothing must start, on either port.
test.each([
["main", 0],
["no-ingest", 1],
])("refuses to run when something else holds the port of the %s server", async (_, held) => {
const ports = await freePorts(2);
const asked = [];
const neighbour = http.createServer((req, res) => {
asked.push(req.url);
res.end("neighbour");
});
await new Promise((resolve, reject) => neighbour.once("error", reject).listen(ports[held], resolve));
try {
const { code, stdout, stderr } = await runSocketPass(ports);
expect(stderr).toContain(`test-curl.sh: ${urlOf(ports[held])} is already taken (curl exit 0)`);
expect(code).toBe(1);
expect(stdout).not.toMatch(/PASS|FAIL/);
// The neighbour saw the probe and nothing else: no case reached it.
expect(asked).toEqual(["/"]);
} finally {
await new Promise((resolve) => neighbour.close(resolve));
}
// The other port was free: no server of the script was left on it.
for (const port of ports) expect(await released(port)).toBe(true);
}, 20000);
// The rule is "anything but connection refused", not "an HTTP answer": an
// occupant that speaks no HTTP holds the port just as well.
test.each([
["closes at once", (socket) => socket.destroy(), /\(curl exit (52|56)\)/],
["accepts and stalls", () => {}, /\(curl exit 28\)/],
])("refuses to run when a listener that %s holds the main port", async (_, onConnection, exitCode) => {
const ports = await freePorts(2);
const sockets = new Set();
const occupant = net.createServer((socket) => {
sockets.add(socket);
socket.on("error", () => {});
onConnection(socket);
});
await new Promise((resolve, reject) => occupant.once("error", reject).listen(ports[0], resolve));
try {
const { code, stdout, stderr } = await runSocketPass(ports);
expect(stderr).toContain(`test-curl.sh: ${urlOf(ports[0])} is already taken`);
expect(stderr).toMatch(exitCode);
expect(code).toBe(1);
expect(stdout).not.toMatch(/PASS|FAIL/);
} finally {
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => occupant.close(resolve));
}
expect(await released(ports[1])).toBe(true);
}, 20000);
// A server that stays alive and never listens: only the deadline can end the
// wait. The fake `node` sleeps for the port named; the script's trap kills it.
test("gives up after 5 s on a server that is alive and never answers", async () => {
const ports = await freePorts(2);
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-fake-node-"));
try {
fs.writeFileSync(
path.join(bin, "node"),
`#!/bin/sh\n[ "$PORT" = "${ports[0]}" ] && exec sleep 30\nexec "${process.execPath}" "$@"\n`,
{ mode: 0o755 },
);
const { code, stdout, stderr, ms } = await runSocketPass(ports, {
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
});
expect(stderr).toContain(`test-curl.sh: no server answered at ${urlOf(ports[0])} within 5 s`);
expect(code).toBe(1);
expect(stdout).not.toMatch(/PASS|FAIL/);
expect(ms).toBeGreaterThan(4000);
expect(ms).toBeLessThan(10000);
expect(await released(ports[1])).toBe(true);
} finally {
fs.rmSync(bin, { recursive: true, force: true });
}
}, 20000);
// A fake `node` first on the PATH stands in for a server that fails to start:
// it exits at once for the port named, and hands over to the real node
// otherwise. The script must name the URL instead of dying silently, for the
// main instance as for the one booted without HOSTS_INGEST_TOKEN.
test.each([
["main", 0],
["no-ingest", 1],
])("says so, and exits 1, when its %s server does not start", async (_, failing) => {
const ports = await freePorts(2);
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-fake-node-"));
try {
fs.writeFileSync(
path.join(bin, "node"),
`#!/bin/sh\n[ "$PORT" = "${ports[failing]}" ] && exit 1\nexec "${process.execPath}" "$@"\n`,
{ mode: 0o755 },
);
const { code, stdout, stderr, ms } = await runSocketPass(ports, {
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
});
expect(stderr).toContain(`test-curl.sh: the server for ${urlOf(ports[failing])} exited before answering`);
expect(code).toBe(1);
expect(stdout).not.toMatch(/PASS|FAIL/);
// At once, not at the end of the 5 s wait: a message that did not stop
// the run would be repeated until the deadline.
expect(ms).toBeLessThan(3000);
// The server that did start is not left behind on its port.
for (const port of ports) expect(await released(port)).toBe(true);
} finally {
fs.rmSync(bin, { recursive: true, force: true });
}
}, 20000);
});
// The boot check only calls /health, so it proves the existence of the modules
// loaded at startup, nothing more. A require inside a route handler would load
// only when that route runs and escape it. Heuristic stand-in for "loaded at
// startup": every require starts at column 0 (a top-level statement), and there
// is no dynamic import. Comment lines are skipped, so an explanation quoting
// require( cannot break the pin.
//
// The pin reads literal calls only, a space before the parenthesis included
// (`require (`, `import (`). Known holes, among others, left open (#35):
// - a function written whole on a column-0 line, such as
// `const lazy = () => require("x");` or a one-line
// `function lazy() { return require("x"); }`;
// - a require( wrapped onto a column-0 line inside a function, which has the
// very shape of the top-level `} = require("node:fs");` that closes
// index.js's multi-line import: telling them apart takes a parser;
// - any indirection, such as an alias of require.
// None of them fits the style of this repository.
describe("runtime modules are all loaded at startup", () => {
const copyLine = dockerfile.match(/^\s*COPY\s+(.+)$/m);
const sources = copyLine ? copyLine[1].trim().split(/\s+/).slice(0, -1) : [];
const runtimeJs = sources.filter((f) => f.endsWith(".js"));
// True when the line may load a module after startup: an indented require,
// or a dynamic import at any column. Comment lines never count.
function loadsLate(line) {
if (/^\s*(\/\/|\/?\*)/.test(line)) return false;
return (/\brequire\s*\(/.test(line) && /^\s/.test(line)) || /\bimport\s*\(/.test(line);
}
test("the COPY line lists runtime modules", () => {
expect(runtimeJs.length).toBeGreaterThan(0);
});
test.each(runtimeJs)("%s requires only at module level", (file) => {
const lines = fs.readFileSync(path.join(ROOT, file), "utf-8").split("\n");
const offenders = [];
lines.forEach((line, i) => {
if (loadsLate(line)) offenders.push(`${file}:${i + 1}: ${line.trim()}`);
});
expect(offenders).toEqual([]);
});
// The detector itself. No runtime file breaks the pin today, so without these
// rows it could weaken, and the two holes closed in #35 reopen, with every
// test green.
test.each([
["an indented require", ' const x = require("./x");'],
["an indented require with a space before the parenthesis", ' const x = require ("./x");'],
["a dynamic import, even at column 0", 'const x = import("./x");'],
["a dynamic import with a space before the parenthesis", 'const x = import ("./x");'],
])("the load-late detector flags %s", (_, line) => {
expect(loadsLate(line)).toBe(true);
});
test.each([
["a top-level require", 'const x = require("./x");'],
["a comment line quoting require", ' // require("./x") here would load late'],
])("the load-late detector lets %s through", (_, line) => {
expect(loadsLate(line)).toBe(false);
});
});

View file

@ -1,611 +0,0 @@
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
// Claude usage history (issue #55): the closed days of the snapshot, kept past
// its 30-day window in one file per month under <dir of CLAUDE_USAGE_PATH>/history/.
//
// Pinned here:
// * the pure helpers of claude-usage-history.js: the two merge rules, the
// grouping by the month of the date string, the whitelist re-read of a
// month file, and the range rules of the read route;
// * the archive written by POST /claude-usage: every day but the last, the
// latest snapshot winning, an unreadable month never overwritten, and an
// archive failure that never changes the 204;
// * GET /claude-usage/history: defaults, bounds, degraded months;
// * the one-shot import command, run as a real process: the archived date
// wins, and an invalid import file writes nothing.
const TOKEN = "test-token";
const CLAUDE_TOKEN = "test-claude-token";
const MODULE = path.join(__dirname, "..", "claude-usage-history.js");
const FROZEN_NOW = Date.UTC(2026, 9, 8, 19, 0, 0); // 2026-10-08
let tmpDir;
let usagePath;
let historyPath;
let server;
let baseUrl;
function day(date, output = 1) {
return {
date,
models: { "claude-opus-5-5": { input: 1, output, cacheCreation: 0, cacheRead: 0, requests: 1 } },
};
}
function snapshotWith(days) {
return {
schemaVersion: 1,
generatedAt: "2026-10-02T19:00:00.000Z",
timeZone: "America/Toronto",
limits: { status: "ok", fetchedAt: "2026-10-02T19:00:00.000Z", items: [] },
days,
projects: [{ name: "proj", work: 3, cacheRead: 4, requests: 1 }],
};
}
function monthFile(month) {
return path.join(historyPath, `${month}.json`);
}
function readMonthFile(month) {
return JSON.parse(fs.readFileSync(monthFile(month), "utf-8"));
}
function writeMonthFile(month, content) {
fs.mkdirSync(historyPath, { recursive: true });
fs.writeFileSync(monthFile(month), typeof content === "string" ? content : JSON.stringify(content));
}
function freshHistory() {
delete require.cache[require.resolve("../claude-usage-history.js")];
delete require.cache[require.resolve("../claude-usage.js")];
return require("../claude-usage-history.js");
}
function startServer() {
for (const mod of ["../index.js", "../claude-usage.js", "../claude-usage-history.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 request(route, { method = "GET", auth = `Bearer ${TOKEN}`, 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(days) {
return request("/claude-usage", {
method: "POST",
auth: `Bearer ${CLAUDE_TOKEN}`,
body: JSON.stringify(snapshotWith(days)),
});
}
async function history(query = "") {
const realNow = Date.now;
Date.now = () => FROZEN_NOW;
try {
return await request(`/claude-usage/history${query}`);
} finally {
Date.now = realNow;
}
}
// Every file under dir, relative, sorted: what a case may and may not touch.
function tree(dir) {
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir, { recursive: true })
.map(String)
.sort();
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-history-test-"));
usagePath = path.join(tmpDir, "claude-usage", "claude-usage.json");
historyPath = path.join(tmpDir, "claude-usage", "history");
process.env.HEALTH_TOKEN = TOKEN;
process.env.HOSTS_INGEST_TOKEN = "test-ingest-token";
process.env.CLAUDE_USAGE_INGEST_TOKEN = CLAUDE_TOKEN;
process.env.CLAUDE_USAGE_PATH = usagePath;
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 () => {
vi.restoreAllMocks();
await stopServer();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
afterAll(() => {
delete process.env.CLAUDE_USAGE_INGEST_TOKEN;
delete process.env.CLAUDE_USAGE_PATH;
});
// --- Pure helpers -----------------------------------------------------------
describe("month arithmetic", () => {
test("addMonths crosses years both ways", () => {
const { addMonths } = freshHistory();
expect(addMonths("2026-10", -11)).toBe("2025-11");
expect(addMonths("2026-12", 1)).toBe("2027-01");
expect(addMonths("2026-01", -1)).toBe("2025-12");
expect(addMonths("2026-01", -36)).toBe("2023-01");
});
test("monthSpan counts both ends", () => {
const { monthSpan } = freshHistory();
expect(monthSpan("2026-01", "2026-01")).toBe(1);
expect(monthSpan("2026-01", "2026-12")).toBe(12);
expect(monthSpan("2024-01", "2026-12")).toBe(36);
});
});
describe("mergeDays", () => {
test("the latest value wins by default, result sorted, inputs untouched", () => {
const { mergeDays } = freshHistory();
const existing = [day("2026-09-02", 1), day("2026-09-01", 1)];
const incoming = [day("2026-09-03", 3), day("2026-09-02", 2)];
const before = JSON.stringify([existing, incoming]);
const merged = mergeDays(existing, incoming);
expect(merged.map((d) => [d.date, d.models["claude-opus-5-5"].output])).toEqual([
["2026-09-01", 1],
["2026-09-02", 2],
["2026-09-03", 3],
]);
expect(JSON.stringify([existing, incoming])).toBe(before);
});
test("preferExisting keeps an archived date and adds only the missing ones", () => {
const { mergeDays } = freshHistory();
const merged = mergeDays([day("2026-09-02", 1)], [day("2026-09-02", 9), day("2026-09-01", 9)], {
preferExisting: true,
});
expect(merged.map((d) => [d.date, d.models["claude-opus-5-5"].output])).toEqual([
["2026-09-01", 9],
["2026-09-02", 1],
]);
});
test("a date repeated in the incoming list: the later entry wins", () => {
const { mergeDays } = freshHistory();
const merged = mergeDays([], [day("2026-09-01", 1), day("2026-09-01", 2)]);
expect(merged).toEqual([day("2026-09-01", 2)]);
});
});
describe("closedDays", () => {
test("drops the most recent date and any date past the reception day", () => {
const { closedDays } = freshHistory();
expect(closedDays([day("2026-10-02"), day("2026-10-01"), day("2026-10-09")], "2026-10-08")).toEqual([day("2026-10-01")]);
expect(closedDays([day("2026-10-08"), day("2026-10-08", 2), day("2026-10-07")], "2026-10-08")).toEqual([day("2026-10-07")]);
expect(closedDays([day("2099-12-31")], "2026-10-08")).toEqual([]);
expect(closedDays([], "2026-10-08")).toEqual([]);
});
});
describe("groupByMonth", () => {
test("groups by the YYYY-MM prefix of the date string", () => {
const { groupByMonth } = freshHistory();
const months = groupByMonth([day("2026-10-31"), day("2026-11-01"), day("2026-10-01")]);
expect([...months.keys()]).toEqual(["2026-10", "2026-11"]);
expect(months.get("2026-10").map((d) => d.date)).toEqual(["2026-10-31", "2026-10-01"]);
});
});
describe("sanitizeMonthFile — the snapshot's whitelist on the way back", () => {
test("a clean file comes back identical", () => {
const { sanitizeMonthFile } = freshHistory();
const file = { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01"), day("2026-09-02")] };
expect(sanitizeMonthFile(file, "2026-09")).toEqual(file.days);
});
test("unknown fields, __proto__, invalid values and foreign dates are dropped", () => {
const { sanitizeMonthFile } = freshHistory();
const raw =
'{"schemaVersion":1,"month":"2026-09","extra":"x","days":[' +
'{"date":"2026-09-01","extra":"<script>","models":{"__proto__":{"input":1},"claude-opus-5-5":{"input":-1,"output":2,"polluted":true}}},' +
'{"date":"2026-10-01","models":{}},' +
'{"date":"2026-09-31","models":{}},' +
'"not a day",' +
'{"__proto__":{"polluted":true},"date":"2026-09-02","models":{}}' +
"]}";
const days = sanitizeMonthFile(JSON.parse(raw), "2026-09");
expect(days).toEqual([
{ date: "2026-09-01", models: { "claude-opus-5-5": { output: 2 } } },
{ date: "2026-09-02", models: {} },
]);
expect({}.polluted).toBeUndefined();
expect(Object.keys(days[0].models)).toEqual(["claude-opus-5-5"]);
});
test.each([
["an array", []],
["null", null],
["another schemaVersion", { schemaVersion: 2, month: "2026-09", days: [] }],
["no schemaVersion", { month: "2026-09", days: [] }],
["another month", { schemaVersion: 1, month: "2026-08", days: [] }],
["days not an array", { schemaVersion: 1, month: "2026-09", days: {} }],
])("off-format as a whole (%s) -> null", (_label, parsed) => {
const { sanitizeMonthFile } = freshHistory();
expect(sanitizeMonthFile(parsed, "2026-09")).toBeNull();
});
});
describe("parseRange", () => {
const ctx = { lastArchived: "2026-09", currentMonth: "2026-10" };
test.each([
["nothing archived: 12 months up to the current one", null, null, { lastArchived: null, currentMonth: "2026-10" }, { from: "2025-11", to: "2026-10" }],
["12 months up to the last archived month", null, null, ctx, { from: "2025-10", to: "2026-09" }],
["to alone: 12 months up to it", null, "2026-03", ctx, { from: "2025-04", to: "2026-03" }],
["from alone: up to the last archived month", "2026-01", null, ctx, { from: "2026-01", to: "2026-09" }],
["from alone, past the last archived month: to = from", "2026-12", null, ctx, { from: "2026-12", to: "2026-12" }],
["exactly 36 months", "2024-01", "2026-12", ctx, { from: "2024-01", to: "2026-12" }],
["a last archived month past the current one: the current month", null, null, { lastArchived: "2099-12", currentMonth: "2026-10" }, { from: "2025-11", to: "2026-10" }],
])("%s", (_label, from, to, context, expected) => {
const { parseRange } = freshHistory();
expect(parseRange(from, to, context)).toEqual(expected);
});
test.each([
["37 months", "2023-12", "2026-12"],
["from alone, 37 months before the last archived month", "2023-09", null],
["from after to", "2026-09", "2026-08"],
["month 13", "2026-13", null],
["month 00", "2026-00", null],
["a one-digit month", "2026-1", null],
["a full date", "2026-09-01", null],
["an empty value", "", null],
["a traversal", "../etc", null],
["a bad to", null, "2026-9"],
])("error on %s", (_label, from, to) => {
const { parseRange } = freshHistory();
expect(parseRange(from, to, ctx).error).toEqual(expect.any(String));
});
});
// --- POST /claude-usage writes the archive ----------------------------------
describe("POST /claude-usage — the archive", () => {
test("every day but the last is archived, mode 600, no temp file", async () => {
expect((await push([day("2026-09-29"), day("2026-09-30"), day("2026-10-01")])).status).toBe(204);
expect(fs.readdirSync(historyPath)).toEqual(["2026-09.json"]);
expect(readMonthFile("2026-09")).toEqual({
schemaVersion: 1,
month: "2026-09",
days: [day("2026-09-29"), day("2026-09-30")],
});
expect(fs.statSync(monthFile("2026-09")).mode & 0o777).toBe(0o600);
});
test("a one-day snapshot archives nothing and creates no directory", async () => {
expect((await push([day("2026-10-01")])).status).toBe(204);
expect(fs.existsSync(historyPath)).toBe(false);
});
test("a second identical POST leaves the archive byte for byte identical", async () => {
const days = [day("2026-09-29"), day("2026-09-30"), day("2026-10-01"), day("2026-10-02")];
await push(days);
const before = [fs.readFileSync(monthFile("2026-09")), fs.readFileSync(monthFile("2026-10"))];
await push(days);
expect([fs.readFileSync(monthFile("2026-09")), fs.readFileSync(monthFile("2026-10"))]).toEqual(before);
expect(fs.readdirSync(historyPath).sort()).toEqual(["2026-09.json", "2026-10.json"]);
});
test("a later snapshot carrying a day with other values replaces it", async () => {
await push([day("2026-09-29", 1), day("2026-09-30", 1)]);
await push([day("2026-09-29", 7), day("2026-09-30", 1)]);
expect(readMonthFile("2026-09").days).toEqual([day("2026-09-29", 7)]);
});
test("a day that left the window keeps its archived value", async () => {
await push([day("2026-09-28", 5), day("2026-09-29", 5), day("2026-09-30", 5)]);
await push([day("2026-09-29", 6), day("2026-09-30", 6), day("2026-10-01", 6)]);
expect(readMonthFile("2026-09").days).toEqual([day("2026-09-28", 5), day("2026-09-29", 6), day("2026-09-30", 6)]);
});
test("a snapshot straddling two months writes two files, each day in its own", async () => {
await push([day("2026-09-29"), day("2026-09-30"), day("2026-10-01"), day("2026-10-02"), day("2026-10-03")]);
expect(readMonthFile("2026-09").days.map((d) => d.date)).toEqual(["2026-09-29", "2026-09-30"]);
expect(readMonthFile("2026-10").days.map((d) => d.date)).toEqual(["2026-10-01", "2026-10-02"]);
});
test("the excluded day is the most recent date, whatever the order sent, and a repeated date resolves to its later entry", async () => {
// Out of order and with a repeat: the agent never sends this, the rule
// must still be the one written down.
await push([day("2026-09-30", 1), day("2026-09-28", 1), day("2026-09-28", 2), day("2026-10-01", 1), day("2026-09-29", 1)]);
expect(readMonthFile("2026-09").days).toEqual([day("2026-09-28", 2), day("2026-09-29", 1), day("2026-09-30", 1)]);
expect(fs.existsSync(monthFile("2026-10"))).toBe(false);
});
test("a day dated past the reception day is never archived, and opens no future month", async () => {
// Today, as the server sees it: the real clock, since receivedAt is stamped on arrival.
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
await push([day(yesterday), day(today), day("2099-12-31")]);
expect(fs.existsSync(monthFile("2099-12"))).toBe(false);
// The future day is dropped first: yesterday is still archived, today is not.
const archived = fs.readdirSync(historyPath).flatMap((name) => readMonthFile(name.slice(0, 7)).days.map((d) => d.date));
expect(archived).toEqual([yesterday]);
});
test("an unreadable month: 204, snapshot written, that month untouched, the other written, logged without content", async () => {
writeMonthFile("2026-09", "{corrupt");
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await push([day("2026-09-29"), day("2026-09-30"), day("2026-10-01"), day("2026-10-02")]);
expect(res.status).toBe(204);
expect(JSON.parse(fs.readFileSync(usagePath, "utf-8")).days).toHaveLength(4);
expect(fs.readFileSync(monthFile("2026-09"), "utf-8")).toBe("{corrupt");
expect(readMonthFile("2026-10").days.map((d) => d.date)).toEqual(["2026-10-01"]);
const lines = error.mock.calls.map((args) => args.join(" "));
expect(lines).toEqual(["[claude-usage] history 2026-09 skipped: unreadable file left untouched (parse_error)"]);
});
test("an off-format month (another schemaVersion) is not overwritten either", async () => {
const foreign = JSON.stringify({ schemaVersion: 2, month: "2026-09", days: [day("2026-09-01")] });
writeMonthFile("2026-09", foreign);
vi.spyOn(console, "error").mockImplementation(() => {});
expect((await push([day("2026-09-29"), day("2026-09-30")])).status).toBe(204);
expect(fs.readFileSync(monthFile("2026-09"), "utf-8")).toBe(foreign);
});
test("history/ cannot be created: 204, snapshot written, logged", async () => {
fs.mkdirSync(path.dirname(usagePath), { recursive: true });
fs.writeFileSync(historyPath, "a file where the directory should be");
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await push([day("2026-09-29"), day("2026-09-30")]);
expect(res.status).toBe(204);
expect(JSON.parse(fs.readFileSync(usagePath, "utf-8")).days).toHaveLength(2);
const lines = error.mock.calls.map((args) => args.join(" "));
expect(lines).toHaveLength(1);
expect(lines[0]).toMatch(/^\[claude-usage\] history not written \(E[A-Z]+\)$/);
});
test("a month whose rename fails: 204, its temp file removed, the other month written, logged", async () => {
const realRename = fs.renameSync;
vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
if (String(to).endsWith("2026-09.json")) {
throw Object.assign(new Error("simulated"), { code: "EIO" });
}
return realRename(from, to);
});
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await push([day("2026-09-30"), day("2026-10-01"), day("2026-10-02")]);
expect(res.status).toBe(204);
expect(fs.readdirSync(historyPath)).toEqual(["2026-10.json"]);
expect(error.mock.calls.map((args) => args.join(" "))).toEqual(["[claude-usage] history 2026-09 not written (EIO)"]);
});
test("a rejected POST archives nothing", async () => {
const res = await request("/claude-usage", {
method: "POST",
auth: `Bearer ${CLAUDE_TOKEN}`,
body: JSON.stringify({ ...snapshotWith([day("2026-09-29"), day("2026-09-30")]), schemaVersion: 2 }),
});
expect(res.status).toBe(400);
expect(fs.existsSync(historyPath)).toBe(false);
});
test("a snapshot that cannot be written archives nothing", async () => {
fs.mkdirSync(path.join(usagePath, "occupied"), { recursive: true });
vi.spyOn(console, "error").mockImplementation(() => {});
expect((await push([day("2026-09-29"), day("2026-09-30")])).status).toBe(500);
expect(fs.existsSync(historyPath)).toBe(false);
});
});
// --- GET /claude-usage/history ----------------------------------------------
describe("GET /claude-usage/history", () => {
test("nothing archived: 200, 12 months up to the current one, no day", async () => {
const res = await history();
expect(res.status).toBe(200);
expect(res.body).toEqual({ from: "2025-11", to: "2026-10", days: [] });
});
test("defaults to 12 months up to the last archived month, days sorted across files", async () => {
writeMonthFile("2026-08", { schemaVersion: 1, month: "2026-08", days: [day("2026-08-31")] });
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01"), day("2026-09-02")] });
writeMonthFile("2025-09", { schemaVersion: 1, month: "2025-09", days: [day("2025-09-30")] });
const res = await history();
expect(res.body).toEqual({
from: "2025-10",
to: "2026-09",
days: [day("2026-08-31"), day("2026-09-01"), day("2026-09-02")],
});
});
test("from and to select the months, an absent month is simply empty", async () => {
writeMonthFile("2026-06", { schemaVersion: 1, month: "2026-06", days: [day("2026-06-15")] });
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01")] });
const res = await history("?from=2026-06&to=2026-08");
expect(res.body).toEqual({ from: "2026-06", to: "2026-08", days: [day("2026-06-15")] });
});
test("an unreadable month is left out and logged, the others are served", async () => {
writeMonthFile("2026-08", "{corrupt");
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01")] });
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await history("?from=2026-08&to=2026-09");
expect(res.status).toBe(200);
expect(res.body.days).toEqual([day("2026-09-01")]);
expect(error.mock.calls.map((args) => args.join(" "))).toEqual([
"[claude-usage] history 2026-08 unreadable, left out (parse_error)",
]);
});
test("the files are re-sanitized on the way out", async () => {
writeMonthFile(
"2026-09",
'{"schemaVersion":1,"month":"2026-09","days":[{"date":"2026-09-01","extra":1,"models":{"__proto__":{"input":1},"claude-opus-5-5":{"output":-1,"input":3}}},{"date":"2026-08-31","models":{}}]}',
);
const res = await history("?from=2026-09&to=2026-09");
expect(res.body.days).toEqual([{ date: "2026-09-01", models: { "claude-opus-5-5": { input: 3 } } }]);
});
test("a month file dated past the current month is not the default end", async () => {
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01")] });
writeMonthFile("2099-12", { schemaVersion: 1, month: "2099-12", days: [day("2099-12-31")] });
const res = await history();
expect(res.body).toEqual({ from: "2025-11", to: "2026-10", days: [day("2026-09-01")] });
});
test("a stray file name that is not a month is not the default end", async () => {
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01")] });
writeMonthFile("2026-13", "{}");
fs.writeFileSync(path.join(historyPath, ".2026-12.json.1.2.tmp"), "{}");
expect((await history()).body.to).toBe("2026-09");
});
test.each([
["?from=2026-13"],
["?to=2026-9"],
["?from=2026-09&to=2026-08"],
["?from=2023-12&to=2026-12"],
["?from=../../etc"],
["?from="],
])("400 on %s", async (query) => {
const res = await history(query);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/^Bad request: /);
});
test("36 months exactly is served", async () => {
expect((await history("?from=2024-01&to=2026-12")).status).toBe(200);
});
test("the read token only: no token, the claude ingest token -> 401", async () => {
expect((await request("/claude-usage/history", { auth: null })).status).toBe(401);
expect((await request("/claude-usage/history", { auth: `Bearer ${CLAUDE_TOKEN}` })).status).toBe(401);
});
test("POST /claude-usage/history -> 404, before any auth", async () => {
expect((await request("/claude-usage/history", { method: "POST", auth: null })).status).toBe(404);
});
test("POST then GET: the closed days come back", async () => {
await push([day("2026-09-30"), day("2026-10-01"), day("2026-10-02")]);
const res = await history("?from=2026-09&to=2026-10");
expect(res.body.days).toEqual([day("2026-09-30"), day("2026-10-01")]);
});
});
// --- Import command ---------------------------------------------------------
describe("node claude-usage-history.js --import", () => {
function runImport(args, { input } = {}) {
return spawnSync(process.execPath, [MODULE, ...args], {
env: { PATH: process.env.PATH, CLAUDE_USAGE_PATH: usagePath },
input,
encoding: "utf-8",
});
}
function importFile(content) {
const file = path.join(tmpDir, "backfill.json");
fs.writeFileSync(file, typeof content === "string" ? content : JSON.stringify(content));
return file;
}
test("an archived date is never replaced, absent dates are added, only history/ changes", async () => {
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-02", 1)] });
const file = importFile({ days: [day("2026-08-31", 9), day("2026-09-01", 9), day("2026-09-02", 9)] });
const before = tree(tmpDir).filter((name) => !name.startsWith(`claude-usage${path.sep}history`));
const result = runImport(["--import", file]);
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
expect(result.stdout).toBe("import: 3 day(s) read, 2 added, 1 already archived, 2 month(s) written\n");
expect(readMonthFile("2026-09").days).toEqual([day("2026-09-01", 9), day("2026-09-02", 1)]);
expect(readMonthFile("2026-08").days).toEqual([day("2026-08-31", 9)]);
expect(fs.statSync(monthFile("2026-08")).mode & 0o777).toBe(0o600);
expect(fs.readdirSync(historyPath).sort()).toEqual(["2026-08.json", "2026-09.json"]);
expect(tree(tmpDir).filter((name) => !name.startsWith(`claude-usage${path.sep}history`))).toEqual(before);
});
test("reads standard input on `-`", () => {
const result = runImport(["--import", "-"], { input: JSON.stringify({ days: [day("2026-07-01")] }) });
expect(result.status).toBe(0);
expect(readMonthFile("2026-07").days).toEqual([day("2026-07-01")]);
});
test.each([
["not JSON", "{nope"],
["no days", JSON.stringify({})],
["days not an array", JSON.stringify({ days: {} })],
["an invalid day", JSON.stringify({ days: [day("2026-07-01"), { date: "2026-02-31", models: {} }] })],
["a repeated date", JSON.stringify({ days: [day("2026-07-01"), day("2026-07-01", 2)] })],
])("an invalid import file (%s) writes nothing, exit 1", (_label, content) => {
const file = importFile(content);
const before = tree(tmpDir);
const result = runImport(["--import", file]);
expect(result.status).toBe(1);
expect(result.stderr).toBe("import: invalid import file, nothing written\n");
expect(tree(tmpDir)).toEqual(before);
});
test("an unreadable month is skipped and left as is, the others are written, exit 1", () => {
writeMonthFile("2026-09", "{corrupt");
const file = importFile({ days: [day("2026-08-31"), day("2026-09-01")] });
const result = runImport(["--import", file]);
expect(result.status).toBe(1);
expect(result.stderr).toBe("import: month 2026-09 skipped, unreadable (parse_error)\n");
expect(fs.readFileSync(monthFile("2026-09"), "utf-8")).toBe("{corrupt");
expect(readMonthFile("2026-08").days).toEqual([day("2026-08-31")]);
});
test("a missing import file: exit 1, nothing written", () => {
const result = runImport(["--import", path.join(tmpDir, "absent.json")]);
expect(result.status).toBe(1);
expect(result.stderr).toBe("import: cannot read the import file (ENOENT)\n");
expect(fs.existsSync(historyPath)).toBe(false);
});
test.each([[[]], [["--import"]], [["--other", "x"]]])("usage error on %j: exit 2", (args) => {
const result = runImport(args);
expect(result.status).toBe(2);
expect(fs.existsSync(historyPath)).toBe(false);
});
});
describe("the server image", () => {
test("the Dockerfile COPY line ships claude-usage-history.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("claude-usage-history.js");
});
});

View file

@ -1,889 +0,0 @@
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
// Claude usage snapshot (issue #48): POST /claude-usage pushed by the ThinkPad
// agent, GET /claude-usage read by the admin dashboard.
//
// Three things are pinned here.
// * The sanitizer, as a pure function (claude-usage.js): the whitelist, the
// contract bounds, and the rule that an invalid value is dropped rather
// than coerced.
// * The gate: a third token kind, `claude-ingest`, isolated both ways from
// the workstation token and from the read token, each ingestion token
// failing closed to a 503 that names its own variable.
// * The round trip: ceiling, atomic write, server-stamped receivedAt, and a
// staleness decided by the server alone.
const TOKEN = "test-token";
const INGEST_TOKEN = "test-ingest-token";
const CLAUDE_TOKEN = "test-claude-token";
// Restated rather than imported, so a silent change to the constant turns the
// matrix red instead of quietly re-drawing the ceiling (same rule as hosts).
const CLAUDE_MAX_BODY_BYTES = 65536;
const HOSTS_MAX_BODY_BYTES = 4096;
const DEFAULT_STALE_SECONDS = 2700;
const FROZEN_NOW = Date.UTC(2026, 8, 27, 19, 0, 0);
let tmpDir;
let usagePath;
let server;
let baseUrl;
function validUsage() {
return {
schemaVersion: 1,
generatedAt: "2026-09-27T19:00:00.000Z",
claudeCodeVersion: "2.1.283",
timeZone: "America/Toronto",
limits: {
status: "ok",
detail: "http_429",
fetchedAt: "2026-09-27T19:00:00.000Z",
items: [
{ kind: "session", group: "session", percent: 1, severity: "normal", resetsAt: "2026-09-27T21:30:00Z", model: null },
{ kind: "weekly_scoped", group: "weekly", percent: 63, severity: "normal", resetsAt: "2026-10-01T07:00:00Z", model: "Fable" },
],
},
days: [
{ date: "2026-09-26", models: {} },
{
date: "2026-09-27",
models: { "claude-opus-5-5": { input: 2, output: 1950, cacheCreation: 43176, cacheRead: 33814, requests: 1 } },
},
],
projects: [
{ name: "simpl-resultat", work: 123456, cacheRead: 7890123, requests: 812 },
{ name: "(autres)", work: 10, cacheRead: 20, requests: 3 },
],
};
}
function freshSanitizer() {
delete require.cache[require.resolve("../claude-usage.js")];
return require("../claude-usage.js");
}
function startServer() {
delete require.cache[require.resolve("../index.js")];
delete require.cache[require.resolve("../claude-usage.js")];
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, headers: extra } = {}) {
const headers = { ...extra };
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 ${CLAUDE_TOKEN}`, body = JSON.stringify(validUsage()) } = {}) {
return request("/claude-usage", { method: "POST", auth, body });
}
function read(auth = `Bearer ${TOKEN}`) {
return request("/claude-usage", { auth });
}
// Same contract as hosts.test.js: the 413 must come back as a status, a dead
// socket is reported as such and never folded into the expected value.
async function pushOversized(route, body, auth) {
try {
const { status } = await request(route, { method: "POST", auth, body });
return 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(base, bytes) {
const padded = { ...base, padding: "" };
const overhead = JSON.stringify(padded).length;
return JSON.stringify({ ...padded, padding: "x".repeat(bytes - overhead) });
}
function writeStored(record) {
fs.mkdirSync(path.dirname(usagePath), { recursive: true });
fs.writeFileSync(usagePath, typeof record === "string" ? record : JSON.stringify(record));
}
async function frozenRead() {
const realNow = Date.now;
Date.now = () => FROZEN_NOW;
try {
return await read();
} finally {
Date.now = realNow;
}
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-claude-usage-test-"));
usagePath = path.join(tmpDir, "claude-usage", "claude-usage.json");
process.env.HEALTH_TOKEN = TOKEN;
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
process.env.CLAUDE_USAGE_INGEST_TOKEN = CLAUDE_TOKEN;
process.env.CLAUDE_USAGE_PATH = usagePath;
delete process.env.CLAUDE_USAGE_STALE_SECONDS;
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 claude-usage config behind.
delete process.env.CLAUDE_USAGE_INGEST_TOKEN;
delete process.env.CLAUDE_USAGE_PATH;
delete process.env.CLAUDE_USAGE_STALE_SECONDS;
});
// --- The sanitizer, as a pure function --------------------------------------
describe("sanitizeClaudeUsage — the contract, kept as is", () => {
test("a valid snapshot comes back identical", () => {
const { sanitizeClaudeUsage } = freshSanitizer();
expect(sanitizeClaudeUsage(validUsage())).toEqual({ snapshot: validUsage() });
});
test("it is idempotent, so GET can re-run it on the stored file", () => {
const { sanitizeClaudeUsage } = freshSanitizer();
const noisy = { ...validUsage(), extra: 1, timeZone: "T".repeat(500) };
const once = sanitizeClaudeUsage(noisy).snapshot;
expect(sanitizeClaudeUsage(once).snapshot).toEqual(once);
});
test("the nullable fields keep an explicit null", () => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = validUsage();
payload.limits.fetchedAt = null;
payload.limits.items[0].resetsAt = null;
const { snapshot } = sanitizeClaudeUsage(payload);
expect(snapshot.limits.fetchedAt).toBeNull();
expect(snapshot.limits.items[0].resetsAt).toBeNull();
expect(snapshot.limits.items[0].model).toBeNull();
});
test("every status of the enum is kept", () => {
const { sanitizeClaudeUsage, LIMIT_STATUSES } = freshSanitizer();
expect(LIMIT_STATUSES).toEqual(["ok", "token_expired", "http_error", "unavailable", "disabled"]);
for (const status of LIMIT_STATUSES) {
const payload = validUsage();
payload.limits.status = status;
expect(sanitizeClaudeUsage(payload).snapshot.limits.status).toBe(status);
}
});
test("the documented detail codes are kept", () => {
const { sanitizeClaudeUsage } = freshSanitizer();
for (const detail of ["no_credentials", "parse_error", "no_version", "timeout", "network", "bad_response", "http_503"]) {
const payload = validUsage();
payload.limits.detail = detail;
expect(sanitizeClaudeUsage(payload).snapshot.limits.detail).toBe(detail);
}
});
test("0 is a legitimate count and 0 and 100 legitimate percents", () => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = validUsage();
payload.limits.items[0].percent = 0;
payload.limits.items[1].percent = 100;
payload.projects[0].work = 0;
const { snapshot } = sanitizeClaudeUsage(payload);
expect(snapshot.limits.items.map((item) => item.percent)).toEqual([0, 100]);
expect(snapshot.projects[0].work).toBe(0);
});
});
describe("sanitizeClaudeUsage — the whole body refused", () => {
test.each([
["an array", []],
["null", null],
["a string", "snapshot"],
["a number", 1],
])("error on %s", (_label, payload) => {
const { sanitizeClaudeUsage } = freshSanitizer();
expect(sanitizeClaudeUsage(payload)).toEqual({ error: "body must be a JSON object" });
});
test.each([
["missing", undefined],
["2", 2],
["the string \"1\"", "1"],
])("error when schemaVersion is %s", (_label, schemaVersion) => {
const { sanitizeClaudeUsage } = freshSanitizer();
expect(sanitizeClaudeUsage({ ...validUsage(), schemaVersion })).toEqual({ error: "schemaVersion must be 1" });
});
});
describe("sanitizeClaudeUsage — invalid values are dropped, not coerced", () => {
test("unknown fields are dropped at every level", () => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = validUsage();
payload.extra = "x";
payload.limits.extra = "x";
payload.limits.items[0].extra = "x";
payload.days[1].extra = "x";
payload.days[1].models["claude-opus-5-5"].extra = 1;
payload.projects[0].extra = "x";
expect(sanitizeClaudeUsage(payload).snapshot).toEqual(validUsage());
});
test("a __proto__ key, top-level or as a model name, never survives", () => {
const { sanitizeClaudeUsage } = freshSanitizer();
const raw = JSON.stringify(validUsage())
.replace('"schemaVersion":1', '"__proto__":{"polluted":true},"schemaVersion":1')
.replace('"models":{}', '"models":{"__proto__":{"input":1}}');
const { snapshot } = sanitizeClaudeUsage(JSON.parse(raw));
expect(Object.keys(snapshot)).not.toContain("__proto__");
expect(Object.keys(snapshot.days[0].models)).toEqual([]);
expect({}.polluted).toBeUndefined();
});
const BAD_NUMBERS = [
["negative", -1],
["past 2^53", 2 ** 53 + 2],
["exactly 2^53 (not a safe integer)", 2 ** 53],
["fractional", 1.5],
["a string", "12"],
["null", null],
];
test.each(BAD_NUMBERS)("a %s model counter is dropped", (_label, value) => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = validUsage();
payload.days[1].models["claude-opus-5-5"].output = value;
const model = sanitizeClaudeUsage(payload).snapshot.days[1].models["claude-opus-5-5"];
expect(model).not.toHaveProperty("output");
expect(model.input).toBe(2);
});
test.each(BAD_NUMBERS)("a %s project counter is dropped", (_label, value) => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = validUsage();
payload.projects[0].work = value;
const project = sanitizeClaudeUsage(payload).snapshot.projects[0];
expect(project).not.toHaveProperty("work");
expect(project.requests).toBe(812);
});
test("a number past 2^53 written in the raw JSON is dropped", () => {
const { sanitizeClaudeUsage } = freshSanitizer();
const raw = JSON.stringify(validUsage()).replace('"requests":812', '"requests":9007199254740993');
expect(sanitizeClaudeUsage(JSON.parse(raw)).snapshot.projects[0]).not.toHaveProperty("requests");
});
test.each([
["over 100", 101],
["negative", -1],
["fractional", 63.5],
])("a percent %s is dropped", (_label, value) => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = validUsage();
payload.limits.items[1].percent = value;
const item = sanitizeClaudeUsage(payload).snapshot.limits.items[1];
expect(item).not.toHaveProperty("percent");
expect(item.kind).toBe("weekly_scoped");
});
test.each([
["an unknown value", "rate_limited"],
["a different case", "OK"],
["a number", 1],
])("a status that is %s is dropped", (_label, status) => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = validUsage();
payload.limits.status = status;
const { limits } = sanitizeClaudeUsage(payload).snapshot;
expect(limits).not.toHaveProperty("status");
expect(limits.items).toHaveLength(2);
});
// `detail` is a reason code, never free text: an upstream response body
// must not ride along to the dashboard.
test.each([
["free text with spaces", "HTTP 500 Internal Server Error"],
["markup", "<html>"],
["uppercase", "HTTP_429"],
["empty", ""],
["33 characters", "a".repeat(33)],
["a number", 429],
])("a detail that is %s is dropped", (_label, detail) => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = validUsage();
payload.limits.detail = detail;
expect(sanitizeClaudeUsage(payload).snapshot.limits).not.toHaveProperty("detail");
});
test("a detail of exactly 32 characters is kept", () => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = validUsage();
payload.limits.detail = "a".repeat(32);
expect(sanitizeClaudeUsage(payload).snapshot.limits.detail).toBe("a".repeat(32));
});
test.each([
["a date without time", "2026-09-27"],
["no offset", "2026-09-27T19:00:00"],
["an impossible instant", "2026-13-45T99:00:00Z"],
["free text", "yesterday"],
["a number", 1759000000000],
])("a timestamp that is %s is dropped", (_label, value) => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = validUsage();
payload.generatedAt = value;
payload.limits.fetchedAt = value;
payload.limits.items[0].resetsAt = value;
const { snapshot } = sanitizeClaudeUsage(payload);
expect(snapshot).not.toHaveProperty("generatedAt");
expect(snapshot.limits).not.toHaveProperty("fetchedAt");
expect(snapshot.limits.items[0]).not.toHaveProperty("resetsAt");
});
test("an offset timestamp is a valid ISO date", () => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = { ...validUsage(), generatedAt: "2026-09-27T15:00:00.000-04:00" };
expect(sanitizeClaudeUsage(payload).snapshot.generatedAt).toBe("2026-09-27T15:00:00.000-04:00");
});
test.each([
["no zero padding", "2026-9-27"],
["a day that does not exist", "2026-02-31"],
["a timestamp", "2026-09-27T00:00:00Z"],
["a number", 20260927],
])("a day whose date is %s is dropped", (_label, date) => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = validUsage();
payload.days[0].date = date;
expect(sanitizeClaudeUsage(payload).snapshot.days.map((day) => day.date)).toEqual(["2026-09-27"]);
});
test.each([
["uppercase", "Claude-Opus"],
["a leading dot", ".hidden"],
["a slash", "claude/opus"],
["65 characters", `a${"b".repeat(64)}`],
])("a model key that is %s is dropped", (_label, key) => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = validUsage();
payload.days[0].models[key] = { input: 1 };
expect(sanitizeClaudeUsage(payload).snapshot.days[0].models).toEqual({});
});
test("a model key of 64 characters is kept", () => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = validUsage();
const key = `a${"b".repeat(63)}`;
payload.days[0].models[key] = { input: 1 };
expect(sanitizeClaudeUsage(payload).snapshot.days[0].models).toEqual({ [key]: { input: 1 } });
});
test("strings are truncated to 128 characters", () => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = validUsage();
payload.claudeCodeVersion = "v".repeat(500);
payload.timeZone = "t".repeat(500);
payload.limits.items[0].kind = "k".repeat(500);
payload.limits.items[1].model = "m".repeat(500);
payload.projects[0].name = "n".repeat(500);
const { snapshot } = sanitizeClaudeUsage(payload);
expect(snapshot.claudeCodeVersion).toBe("v".repeat(128));
expect(snapshot.timeZone).toBe("t".repeat(128));
expect(snapshot.limits.items[0].kind).toBe("k".repeat(128));
expect(snapshot.limits.items[1].model).toBe("m".repeat(128));
expect(snapshot.projects[0].name).toBe("n".repeat(128));
});
test("a section of the wrong type is dropped, the rest kept", () => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = { ...validUsage(), limits: "ok", days: {}, projects: null };
const { snapshot } = sanitizeClaudeUsage(payload);
expect(snapshot).not.toHaveProperty("limits");
expect(snapshot).not.toHaveProperty("days");
expect(snapshot).not.toHaveProperty("projects");
expect(snapshot.timeZone).toBe("America/Toronto");
});
test("entries that are not objects are dropped from every list", () => {
const { sanitizeClaudeUsage } = freshSanitizer();
const payload = validUsage();
payload.days.unshift("2026-09-25", null);
payload.limits.items.push([1]);
payload.projects.push(42);
payload.days[3].models["claude-opus-5-5"] = [1, 2];
const { snapshot } = sanitizeClaudeUsage(payload);
expect(snapshot.days).toHaveLength(2);
expect(snapshot.days[1].models).toEqual({});
expect(snapshot.limits.items).toHaveLength(2);
expect(snapshot.projects).toHaveLength(2);
});
});
describe("sanitizeClaudeUsage — list bounds", () => {
function dayList(count) {
const start = Date.UTC(2026, 7, 1);
return Array.from({ length: count }, (_, i) => ({
date: new Date(start + i * 86400000).toISOString().slice(0, 10),
models: {},
}));
}
test("31 days are kept; past that, the most recent 31", () => {
const { sanitizeClaudeUsage, MAX_DAYS } = freshSanitizer();
expect(MAX_DAYS).toBe(31);
expect(sanitizeClaudeUsage({ ...validUsage(), days: dayList(31) }).snapshot.days).toEqual(dayList(31));
const days = sanitizeClaudeUsage({ ...validUsage(), days: dayList(40) }).snapshot.days;
expect(days).toEqual(dayList(40).slice(-31));
});
test("12 models per day; past that, the first 12", () => {
const { sanitizeClaudeUsage, MAX_MODELS_PER_DAY } = freshSanitizer();
expect(MAX_MODELS_PER_DAY).toBe(12);
const payload = validUsage();
payload.days[0].models = Object.fromEntries(
Array.from({ length: 15 }, (_, i) => [`model-${String(i).padStart(2, "0")}`, { requests: i }]),
);
const models = sanitizeClaudeUsage(payload).snapshot.days[0].models;
expect(Object.keys(models)).toHaveLength(12);
expect(Object.keys(models).at(-1)).toBe("model-11");
});
test("12 limit items; past that, the first 12", () => {
const { sanitizeClaudeUsage, MAX_LIMIT_ITEMS } = freshSanitizer();
expect(MAX_LIMIT_ITEMS).toBe(12);
const payload = validUsage();
payload.limits.items = Array.from({ length: 14 }, (_, i) => ({ kind: `k${i}`, percent: i }));
const items = sanitizeClaudeUsage(payload).snapshot.limits.items;
expect(items).toHaveLength(12);
expect(items.at(-1).kind).toBe("k11");
});
test("9 projects (top 8 plus \"(autres)\"); past that, the first 9", () => {
const { sanitizeClaudeUsage, MAX_PROJECTS } = freshSanitizer();
expect(MAX_PROJECTS).toBe(9);
const payload = validUsage();
payload.projects = Array.from({ length: 11 }, (_, i) => ({ name: `p${i}`, work: i }));
const projects = sanitizeClaudeUsage(payload).snapshot.projects;
expect(projects).toHaveLength(9);
expect(projects.at(-1).name).toBe("p8");
});
test("the body ceiling is 64 KiB", () => {
expect(freshSanitizer().CLAUDE_USAGE_MAX_BODY_BYTES).toBe(CLAUDE_MAX_BODY_BYTES);
});
});
// --- The gate ---------------------------------------------------------------
describe("POST /claude-usage — authentication", () => {
test.each([
["no Authorization header", null],
["a wrong token", "Bearer wrong"],
["the workstation ingest token", `Bearer ${INGEST_TOKEN}`],
["the read token", `Bearer ${TOKEN}`],
["the Claude token without the Bearer scheme", CLAUDE_TOKEN],
["a prefix of the Claude token", `Bearer ${CLAUDE_TOKEN.slice(0, -1)}`],
])("401 with %s, and nothing is written", async (_label, auth) => {
const { status, body } = await push({ auth });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
expect(fs.existsSync(usagePath)).toBe(false);
});
test("the Claude token does not open POST /hosts/<id>", async () => {
const { status, body } = await request("/hosts/thinkpad", {
method: "POST",
auth: `Bearer ${CLAUDE_TOKEN}`,
body: "{}",
});
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
expect(fs.existsSync(path.join(process.env.HOSTS_DIR, "thinkpad.json"))).toBe(false);
});
test.each(["/claude-usage", "/hosts", "/health", "/defenseurs"])(
"the Claude token does not open GET %s",
async (route) => {
const { status, body } = await request(route, { auth: `Bearer ${CLAUDE_TOKEN}` });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
},
);
test("the comparison goes through timingSafeEqual on two digests", async () => {
const crypto = require("node:crypto");
const spy = vi.spyOn(crypto, "timingSafeEqual");
try {
await restartServer();
const { status } = await push({ auth: "Bearer x" });
expect(status).toBe(401);
expect(spy).toHaveBeenCalledTimes(1);
const [a, b] = spy.mock.calls[0];
expect([a.length, b.length]).toEqual([32, 32]);
} finally {
spy.mockRestore();
}
});
});
describe("GET /claude-usage — authentication", () => {
test.each([
["no Authorization header", null],
["a wrong token", "Bearer wrong"],
["the workstation ingest token", `Bearer ${INGEST_TOKEN}`],
])("401 with %s", async (_label, auth) => {
const { status, body } = await read(auth);
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
test("401 when HEALTH_TOKEN is unset (fail-closed)", async () => {
delete process.env.HEALTH_TOKEN;
await restartServer();
const { status, body } = await read();
expect(status).toBe(401);
expect(body.error).toBe("HEALTH_TOKEN not configured");
});
});
describe("fail-closed — each ingestion token names its own variable", () => {
test("503 naming CLAUDE_USAGE_INGEST_TOKEN when it is unset", async () => {
delete process.env.CLAUDE_USAGE_INGEST_TOKEN;
await restartServer();
for (const auth of [null, `Bearer ${CLAUDE_TOKEN}`, `Bearer ${INGEST_TOKEN}`]) {
const { status, body } = await push({ auth });
expect(status).toBe(503);
expect(body.error).toBe("CLAUDE_USAGE_INGEST_TOKEN not configured");
}
expect(fs.existsSync(usagePath)).toBe(false);
});
test("the other routes are unaffected when CLAUDE_USAGE_INGEST_TOKEN is unset", async () => {
delete process.env.CLAUDE_USAGE_INGEST_TOKEN;
await restartServer();
expect((await read()).status).toBe(200);
expect((await request("/hosts", { auth: `Bearer ${TOKEN}` })).status).toBe(200);
});
test("/hosts keeps its 503 naming HOSTS_INGEST_TOKEN, and /claude-usage keeps working", async () => {
delete process.env.HOSTS_INGEST_TOKEN;
await restartServer();
const hosts = await request("/hosts/thinkpad", { method: "POST", auth: `Bearer ${CLAUDE_TOKEN}`, body: "{}" });
expect(hosts.status).toBe(503);
expect(hosts.body.error).toBe("HOSTS_INGEST_TOKEN not configured");
expect((await push()).status).toBe(204);
});
});
describe("routing", () => {
test.each([
["PUT", "/claude-usage"],
["DELETE", "/claude-usage"],
["POST", "/claude-usage/"],
["POST", "/claude-usage/x"],
["GET", "/claude-usage/../hosts/thinkpad.json"],
])("404 (not 401) on %s %s without Authorization", async (method, route) => {
const { status, body } = await request(route, { method });
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
});
describe("rejections leave an audit trail", () => {
let warn;
beforeEach(() => {
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
warn.mockRestore();
});
test("a 401 on POST /claude-usage is logged", async () => {
await push({ auth: "Bearer wrong" });
const line = warn.mock.calls.at(-1)[0];
expect(line).toContain("[auth] 401 POST /claude-usage");
expect(line).toContain("id=-");
expect(line).toContain("reason=missing or invalid bearer token");
});
test("a 401 on GET /claude-usage is logged", async () => {
await read(null);
expect(warn.mock.calls.at(-1)[0]).toContain("[auth] 401 GET /claude-usage");
});
test("a 503 names its cause in the log", async () => {
delete process.env.CLAUDE_USAGE_INGEST_TOKEN;
await restartServer();
await push();
expect(warn.mock.calls.at(-1)[0]).toContain("reason=claude usage ingest token not configured");
});
});
// --- Ingestion --------------------------------------------------------------
describe("POST /claude-usage — the 64 KiB ceiling", () => {
const claudeAuth = `Bearer ${CLAUDE_TOKEN}`;
test("a body of exactly 65 536 bytes is accepted, the padding dropped", async () => {
const body = bodyOfExactly(validUsage(), CLAUDE_MAX_BODY_BYTES);
expect(Buffer.byteLength(body)).toBe(CLAUDE_MAX_BODY_BYTES);
const { status } = await push({ body });
expect(status).toBe(204);
expect(fs.readFileSync(usagePath, "utf-8")).not.toContain("padding");
});
test("a body of 65 537 bytes is refused with 413 and nothing is written", async () => {
const body = bodyOfExactly(validUsage(), CLAUDE_MAX_BODY_BYTES + 1);
expect(await pushOversized("/claude-usage", body, claudeAuth)).toBe(413);
expect(fs.existsSync(usagePath)).toBe(false);
});
test("POST /hosts/<id> keeps its own 4 KiB ceiling: 413 at 4 097 bytes", async () => {
const hostBase = {
hostname: "thinkpad-x1",
uptime: 4242,
cpu: { model: "Intel Core i7", cores: 8, loadAvg: [0.5, 0.4, 0.3], usagePercent: 12 },
memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 },
disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 },
};
const body = bodyOfExactly(hostBase, HOSTS_MAX_BODY_BYTES + 1);
expect(await pushOversized("/hosts/thinkpad", body, `Bearer ${INGEST_TOKEN}`)).toBe(413);
});
});
describe("POST /claude-usage — payload", () => {
test.each([
["truncated JSON", "{not json"],
["an empty body", ""],
])("400 on %s, nothing written", async (_label, body) => {
const res = await push({ body });
expect(res.status).toBe(400);
expect(res.body.error).toBe("Bad request: body must be valid JSON");
expect(fs.existsSync(usagePath)).toBe(false);
});
test("400 on a JSON array", async () => {
const res = await push({ body: "[]" });
expect(res.status).toBe(400);
expect(res.body.error).toBe("Bad request: body must be a JSON object");
});
test("400 on another schemaVersion", async () => {
const res = await push({ body: JSON.stringify({ ...validUsage(), schemaVersion: 2 }) });
expect(res.status).toBe(400);
expect(res.body.error).toBe("Bad request: schemaVersion must be 1");
expect(fs.existsSync(usagePath)).toBe(false);
});
test("204, the file holds the sanitized snapshot plus a server receivedAt, mode 600", async () => {
const before = Date.now();
const sent = { ...validUsage(), extra: "dropped", receivedAt: "2000-01-01T00:00:00.000Z" };
const res = await push({ body: JSON.stringify(sent) });
expect(res.status).toBe(204);
const stored = JSON.parse(fs.readFileSync(usagePath, "utf-8"));
const { receivedAt, ...snapshot } = stored;
expect(snapshot).toEqual(validUsage());
// A client-supplied receivedAt is ignored: the server stamps it.
expect(new Date(receivedAt).getTime()).toBeGreaterThanOrEqual(before - 1000);
expect(fs.statSync(usagePath).mode & 0o777).toBe(0o600);
// Atomic write: no temp file left beside the snapshot. history/ holds the
// closed day of this two-day snapshot (#55, claude-usage-history.test.js).
expect(fs.readdirSync(path.dirname(usagePath)).sort()).toEqual(["claude-usage.json", "history"]);
});
test("a second push replaces the first", async () => {
await push();
const second = { ...validUsage(), claudeCodeVersion: "2.1.284" };
expect((await push({ body: JSON.stringify(second) })).status).toBe(204);
expect(JSON.parse(fs.readFileSync(usagePath, "utf-8")).claudeCodeVersion).toBe("2.1.284");
});
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. Only the cleanup in writeJsonAtomic() removes the
// temp file (#54: the mkdir case below never reaches it).
fs.mkdirSync(path.join(usagePath, "occupied"), { recursive: true });
const error = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const res = await push();
expect(res.status).toBe(500);
expect(res.body.error).toBe("Internal error");
} finally {
error.mockRestore();
}
expect(fs.readdirSync(path.dirname(usagePath))).toEqual(["claude-usage.json"]);
expect(fs.readdirSync(usagePath)).toEqual(["occupied"]);
});
test("500 when the snapshot cannot be written, and no temp file is left", async () => {
// The parent of the file is a regular file: mkdir fails, nothing lands.
const blocker = path.join(tmpDir, "blocker");
fs.writeFileSync(blocker, "");
process.env.CLAUDE_USAGE_PATH = path.join(blocker, "claude-usage.json");
await restartServer();
const error = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const res = await push();
expect(res.status).toBe(500);
expect(res.body.error).toBe("Internal error");
} finally {
error.mockRestore();
}
});
});
// --- Reading ----------------------------------------------------------------
describe("GET /claude-usage", () => {
test("no file -> { status: \"no_data\" }", async () => {
const res = await read();
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: "no_data" });
});
test("POST then GET: identical snapshot, receivedAt, ageSeconds, stale", async () => {
const sent = { ...validUsage(), extra: "dropped" };
expect((await push({ body: JSON.stringify(sent) })).status).toBe(204);
const stored = JSON.parse(fs.readFileSync(usagePath, "utf-8"));
const res = await read();
expect(res.status).toBe(200);
expect(Object.keys(res.body).sort()).toEqual(["ageSeconds", "receivedAt", "snapshot", "stale", "staleAfterSeconds"]);
expect(res.body.snapshot).toEqual(validUsage());
expect(res.body.receivedAt).toBe(stored.receivedAt);
expect(res.body.ageSeconds).toBeGreaterThanOrEqual(0);
expect(res.body.ageSeconds).toBeLessThan(5);
expect(res.body.stale).toBe(false);
expect(res.body.staleAfterSeconds).toBe(DEFAULT_STALE_SECONDS);
});
test.each([
[DEFAULT_STALE_SECONDS - 1, false],
[DEFAULT_STALE_SECONDS, false],
[DEFAULT_STALE_SECONDS + 1, true],
])("aged %i s -> stale %s (server clock, exact boundary)", async (age, stale) => {
writeStored({ ...validUsage(), receivedAt: new Date(FROZEN_NOW - age * 1000).toISOString() });
const res = await frozenRead();
expect(res.body.ageSeconds).toBe(age);
expect(res.body.stale).toBe(stale);
});
test("CLAUDE_USAGE_STALE_SECONDS moves the boundary", async () => {
process.env.CLAUDE_USAGE_STALE_SECONDS = "60";
await restartServer();
writeStored({ ...validUsage(), receivedAt: new Date(FROZEN_NOW - 61 * 1000).toISOString() });
const res = await frozenRead();
expect(res.body.staleAfterSeconds).toBe(60);
expect(res.body.stale).toBe(true);
});
test.each(["0", "-5", "abc"])("an invalid CLAUDE_USAGE_STALE_SECONDS (%s) falls back to 2700", async (value) => {
process.env.CLAUDE_USAGE_STALE_SECONDS = value;
await restartServer();
writeStored({ ...validUsage(), receivedAt: new Date(FROZEN_NOW).toISOString() });
expect((await frozenRead()).body.staleAfterSeconds).toBe(DEFAULT_STALE_SECONDS);
});
test("a receivedAt in the future reads as age 0, not negative", async () => {
writeStored({ ...validUsage(), receivedAt: new Date(FROZEN_NOW + 60000).toISOString() });
const res = await frozenRead();
expect(res.body.ageSeconds).toBe(0);
expect(res.body.stale).toBe(false);
});
test("the stored file is re-sanitized on the way out", async () => {
const raw = validUsage();
raw.extra = "<script>";
raw.limits.detail = "HTTP 500 body";
raw.projects[0].work = -1;
writeStored({ ...raw, receivedAt: new Date(FROZEN_NOW).toISOString() });
const { body } = await frozenRead();
expect(body.snapshot).not.toHaveProperty("extra");
expect(body.snapshot.limits).not.toHaveProperty("detail");
expect(body.snapshot.projects[0]).not.toHaveProperty("work");
expect(body.snapshot).not.toHaveProperty("receivedAt");
});
describe("degrades to no_data", () => {
let error;
beforeEach(() => {
error = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
error.mockRestore();
});
test.each([
["a corrupt file", "{corrupt"],
["an array", "[]"],
["another schemaVersion", JSON.stringify({ ...validUsage(), schemaVersion: 2, receivedAt: "2026-09-27T19:00:00.000Z" })],
["no receivedAt", JSON.stringify(validUsage())],
["an unusable receivedAt", JSON.stringify({ ...validUsage(), receivedAt: "never" })],
// new Date() reads all three; the server only ever stamps a full ISO
// timestamp with an offset, so anything else was not written by it (#54).
["a receivedAt of \"1\"", JSON.stringify({ ...validUsage(), receivedAt: "1" })],
["a date-only receivedAt", JSON.stringify({ ...validUsage(), receivedAt: "2026-10-07" })],
["a receivedAt without an offset", JSON.stringify({ ...validUsage(), receivedAt: "2026-10-07T12:00:00" })],
])("on %s", async (_label, raw) => {
writeStored(raw);
const res = await read();
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: "no_data" });
});
test("when the path is a directory", async () => {
fs.mkdirSync(usagePath, { recursive: true });
const res = await read();
expect(res.body).toEqual({ status: "no_data" });
});
});
});
describe("the server image", () => {
test("the Dockerfile COPY line ships claude-usage.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("claude-usage.js");
});
});

View file

@ -1,202 +0,0 @@
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TOKEN = "test-token";
function makeFinding(id, severity, category) {
return {
id,
severity,
category,
title: `${id} title`,
description: `${id} desc`,
location: `${id}/loc`,
recommendation: `${id} reco`,
firstSeen: "2026-05-01T00:00:00.000Z",
status: "open",
};
}
const ALL_FINDINGS = [
makeFinding("c1", "CRITICAL", "deps"),
makeFinding("h1", "HIGH", "secrets"),
makeFinding("m1", "MEDIUM", "deps"),
makeFinding("l1", "LOW", "code"),
makeFinding("i1", "INFO", "infra"),
];
let tmpDir;
let reportsDir;
let agentsMapPath;
let server;
let baseUrl;
let handler;
function writeReport(agent, timestamp, findings, subdir = "") {
const dir = subdir ? path.join(reportsDir, subdir) : reportsDir;
fs.mkdirSync(dir, { recursive: true });
const safeTs = timestamp.replace(/[:.]/g, "-");
const file = path.join(dir, `defenseur-${agent}_${safeTs}.json`);
fs.writeFileSync(
file,
JSON.stringify({ agent: `defenseur-${agent}`, timestamp, findings }),
);
return file;
}
function writeAgentsMap(map) {
fs.writeFileSync(agentsMapPath, JSON.stringify(map));
}
function writeRawAgentsMap(raw) {
fs.writeFileSync(agentsMapPath, raw);
}
function startServer() {
// Fresh import so module-level constants pick up the stubbed env.
delete require.cache[require.resolve("../index.js")];
({ handler } = require("../index.js"));
server = http.createServer(handler);
return new Promise((resolve) => {
server.listen(0, () => {
const { port } = server.address();
baseUrl = `http://127.0.0.1:${port}`;
resolve();
});
});
}
function stopServer() {
return new Promise((resolve) => {
if (!server) return resolve();
server.close(() => resolve());
});
}
async function get(path, { auth = `Bearer ${TOKEN}` } = {}) {
const headers = {};
if (auth) headers.Authorization = auth;
const res = await fetch(`${baseUrl}${path}`, { headers });
const body = await res.json().catch(() => ({}));
return { status: res.status, body };
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-test-"));
reportsDir = path.join(tmpDir, "reports");
fs.mkdirSync(reportsDir, { recursive: true });
agentsMapPath = path.join(tmpDir, "agents-map.json");
process.env.HEALTH_TOKEN = TOKEN;
process.env.REPORTS_DIR = reportsDir;
process.env.DEFENSEURS_AGENTS_MAP_PATH = agentsMapPath;
writeAgentsMap({ "la-suite-booking": "defenseur-booking" });
await startServer();
});
afterEach(async () => {
await stopServer();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("GET /defenseurs/findings", () => {
test("401 when no Authorization header", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { status } = await get("/defenseurs/findings?project=la-suite-booking", { auth: null });
expect(status).toBe(401);
});
test("401 on invalid token", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { status } = await get("/defenseurs/findings?project=la-suite-booking", { auth: "Bearer wrong" });
expect(status).toBe(401);
});
test("400 when project param is missing", async () => {
const { status, body } = await get("/defenseurs/findings");
expect(status).toBe(400);
expect(body.error).toMatch(/project/);
});
test("404 on unknown project", async () => {
const { status } = await get("/defenseurs/findings?project=unknown-x");
expect(status).toBe(404);
});
test("200 default (no severity) returns MEDIUM+HIGH+CRITICAL, hides LOW+INFO", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
expect(status).toBe(200);
expect(body.agent).toBe("defenseur-booking");
expect(body.project).toBe("la-suite-booking");
expect(body.timestamp).toBe("2026-05-12T00:00:00.000Z");
expect(body.findings).toHaveLength(3);
expect(body.findings.map((f) => f.severity).sort()).toEqual(["CRITICAL", "HIGH", "MEDIUM"]);
expect(body.status).toBeUndefined();
});
test("200 ?category=deps filters by category exact match", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking&category=deps");
expect(status).toBe(200);
expect(body.findings.map((f) => f.id).sort()).toEqual(["c1", "m1"]);
});
test("200 ?severity=HIGH returns HIGH+CRITICAL only", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=HIGH");
expect(body.findings.map((f) => f.severity).sort()).toEqual(["CRITICAL", "HIGH"]);
});
test("200 ?severity=LOW returns LOW+MEDIUM+HIGH+CRITICAL but hides INFO", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=LOW");
expect(body.findings.map((f) => f.severity).sort()).toEqual(["CRITICAL", "HIGH", "LOW", "MEDIUM"]);
expect(body.findings.some((f) => f.severity === "INFO")).toBe(false);
});
test("200 ?severity=INFO returns INFO only", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=INFO");
expect(body.findings.map((f) => f.severity)).toEqual(["INFO"]);
});
test("200 ?category=deps&severity=CRITICAL returns just c1", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
const { body } = await get("/defenseurs/findings?project=la-suite-booking&category=deps&severity=CRITICAL");
expect(body.findings.map((f) => f.id)).toEqual(["c1"]);
});
test("200 picks latest report (mixed top-level + archive)", async () => {
writeReport("booking", "2026-05-10T00:00:00.000Z", [makeFinding("old", "HIGH", "deps")], "archive");
writeReport("booking", "2026-05-12T00:00:00.000Z", [makeFinding("new", "HIGH", "deps")]);
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=HIGH");
expect(body.timestamp).toBe("2026-05-12T00:00:00.000Z");
expect(body.findings.map((f) => f.id)).toEqual(["new"]);
});
test("200 report present + findings:[] (scan clean) returns no status field", async () => {
writeReport("booking", "2026-05-12T00:00:00.000Z", []);
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
expect(status).toBe(200);
expect(body.findings).toEqual([]);
expect(body.status).toBeUndefined();
expect(body.agent).toBe("defenseur-booking");
});
test("200 report absent returns {findings:[], status:'no_data'}", async () => {
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
expect(status).toBe(200);
expect(body).toEqual({ findings: [], status: "no_data" });
});
test("500 on corrupt agents-map.json", async () => {
writeRawAgentsMap("{not valid json");
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
expect(status).toBe(500);
expect(body.error).toBe("Internal error");
});
});

View file

@ -1,53 +0,0 @@
{
"five_hour": { "utilization": 1.0, "resets_at": "2026-09-27T21:30:00.512345+00:00" },
"seven_day": { "utilization": 50.0, "resets_at": "2026-10-01T07:00:00.512345+00:00" },
"seven_day_oauth_apps": null,
"seven_day_opus": null,
"seven_day_sonnet": null,
"seven_day_fable": null,
"iguana_necktie": null,
"extra_usage": null,
"opaque_a": null,
"opaque_b": null,
"opaque_c": null,
"opaque_d": null,
"opaque_e": null,
"opaque_f": null,
"opaque_g": null,
"opaque_h": null,
"opaque_i": null,
"opaque_j": null,
"opaque_k": null,
"opaque_l": null,
"opaque_m": null,
"opaque_n": null,
"limits": [
{
"kind": "session",
"group": "session",
"percent": 1,
"severity": "normal",
"resets_at": "2026-09-27T21:30:00Z",
"scope": null,
"is_active": true
},
{
"kind": "weekly_all",
"group": "weekly",
"percent": 50,
"severity": "normal",
"resets_at": "2026-10-01T07:00:00Z",
"scope": null,
"is_active": true
},
{
"kind": "weekly_scoped",
"group": "weekly",
"percent": 63,
"severity": "normal",
"resets_at": "2026-10-01T07:00:00Z",
"scope": { "model": { "display_name": "Fable" } },
"is_active": true
}
]
}

View file

@ -1,7 +0,0 @@
{"type":"user","message":{"role":"user","content":"synthetic fixture","usage":{"input_tokens":999,"output_tokens":999}},"cwd":"/home/tester/claude-code/alpha","timestamp":"2026-10-03T14:59:00.000Z","version":"2.1.280"}
{"type":"assistant","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":10,"output_tokens":8,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}},"requestId":"req_A","cwd":"/home/tester/claude-code/alpha","timestamp":"2026-10-03T15:00:00.000Z","version":"2.1.280"}
{"type":"assistant","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":10,"output_tokens":1024,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}},"requestId":"req_A","cwd":"/home/tester/claude-code/alpha","timestamp":"2026-10-03T15:00:01.000Z","version":"2.1.280"}
{"type":"assistant","message":{"id":"msg_S","model":"<synthetic>","usage":{"input_tokens":0,"output_tokens":7777,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}},"requestId":"req_S","cwd":"/home/tester/claude-code/alpha","timestamp":"2026-10-03T16:00:00.000Z","version":"2.1.280"}
{"type":"assistant","message":{"id":"msg_T","model":"claude-opus-5-5","usage":{"input_tok
{"type":"assistant","message":{"id":"msg_B","model":"claude-sonnet-5","usage":{"input_tokens":5,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":200}},"requestId":"req_B","cwd":"/home/tester/claude-code/alpha","timestamp":"2026-10-04T12:00:00.000Z","version":"2.1.283"}

View file

@ -1,4 +0,0 @@
{"type":"assistant","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":10,"output_tokens":2048,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}},"requestId":"req_A","cwd":"/home/tester/claude-code/alpha","timestamp":"2026-10-03T15:00:02.000Z","version":"2.1.280","isSidechain":true}
{"type":"assistant","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":10,"output_tokens":512,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}},"requestId":"req_A","cwd":"/home/tester/claude-code/alpha","timestamp":"2026-10-03T15:00:03.000Z","version":"2.1.280","isSidechain":true}
{"type":"assistant","message":{"id":"msg_C","model":"claude-opus-5-5","usage":{"input_tokens":3,"output_tokens":30,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}},"requestId":"req_C","cwd":"/home/tester/claude-code/.spikes/idea","timestamp":"2026-10-02T10:00:00.000Z","version":"2.1.281","isSidechain":true}
not json at all

View file

@ -1,139 +0,0 @@
const http = require("node:http");
const { setTimeout: delay } = require("node:timers/promises");
const { getCpuPercent, getDisk, collectMetrics } = require("../metrics.js");
const TOKEN = "test-token";
let logtoServer;
let logtoUrl;
let logtoDelayMs;
let server;
let baseUrl;
let handler;
// Stand-in for the Logto .well-known endpoint, with a settable delay so we can
// simulate a slow IdP without touching the network.
function startLogtoStub() {
logtoDelayMs = 0;
logtoServer = http.createServer(async (req, res) => {
if (logtoDelayMs > 0) await delay(logtoDelayMs);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ issuer: "http://127.0.0.1/oidc" }));
});
return new Promise((resolve) => {
logtoServer.listen(0, "127.0.0.1", () => {
const { port } = logtoServer.address();
logtoUrl = `http://127.0.0.1:${port}/oidc/.well-known/openid-configuration`;
resolve();
});
});
}
function startServer() {
// Fresh import so module-level constants pick up the stubbed env.
delete require.cache[require.resolve("../index.js")];
({ handler } = require("../index.js"));
server = http.createServer(handler);
return new Promise((resolve) => {
server.listen(0, () => {
const { port } = server.address();
baseUrl = `http://127.0.0.1:${port}`;
resolve();
});
});
}
function close(target) {
return new Promise((resolve) => {
if (!target) return resolve();
target.close(() => resolve());
});
}
async function get(path, { auth = `Bearer ${TOKEN}` } = {}) {
const headers = {};
if (auth) headers.Authorization = auth;
const res = await fetch(`${baseUrl}${path}`, { headers });
const body = await res.json().catch(() => ({}));
return { status: res.status, body };
}
beforeEach(async () => {
await startLogtoStub();
process.env.HEALTH_TOKEN = TOKEN;
process.env.LOGTO_HEALTH_URL = logtoUrl;
await startServer();
});
afterEach(async () => {
await close(server);
await close(logtoServer);
});
describe("metrics module", () => {
test("exposes getCpuPercent, getDisk and collectMetrics", () => {
expect(typeof getCpuPercent).toBe("function");
expect(typeof getDisk).toBe("function");
expect(typeof collectMetrics).toBe("function");
});
test("getDisk returns the four numeric disk fields", () => {
const disk = getDisk();
expect(Object.keys(disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
for (const value of Object.values(disk)) expect(typeof value).toBe("number");
});
test("collectMetrics returns the cpu/memory/disk slice of /health", async () => {
const metrics = await collectMetrics();
expect(Object.keys(metrics)).toEqual(["cpu", "memory", "disk"]);
expect(Object.keys(metrics.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]);
expect(Object.keys(metrics.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
expect(metrics.cpu.cores).toBeGreaterThan(0);
expect(Array.isArray(metrics.cpu.loadAvg)).toBe(true);
});
});
describe("GET /health", () => {
test("401 on invalid token", async () => {
const { status } = await get("/health", { auth: "Bearer wrong" });
expect(status).toBe(401);
});
test("200 returns the documented payload, field for field", async () => {
const { status, body } = await get("/health");
expect(status).toBe(200);
// Key order is part of the contract consumed by the admin dashboard.
expect(Object.keys(body)).toEqual([
"timestamp",
"hostname",
"uptime",
"cpu",
"memory",
"disk",
"logto",
]);
expect(Object.keys(body.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]);
expect(Object.keys(body.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
expect(Object.keys(body.disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
expect(body.logto.status).toBe("up");
expect(typeof body.logto.responseTimeMs).toBe("number");
expect(typeof body.uptime).toBe("number");
expect(typeof body.hostname).toBe("string");
expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp);
});
// Latency regression guard. Metrics collection (500ms CPU sample) and the
// Logto check must run concurrently: serializing them would make /health take
// 500ms + the Logto response time. With a 1200ms Logto, concurrent lands at
// ~1200ms while serialized lands at ~1700ms — only the former clears 1.5s.
test("stays under 1.5s with a slow Logto (metrics stay concurrent)", async () => {
logtoDelayMs = 1200;
const start = performance.now();
const { status, body } = await get("/health");
const elapsed = performance.now() - start;
expect(status).toBe(200);
expect(body.logto.status).toBe("up");
expect(elapsed).toBeLessThan(1500);
}, 15000);
});

File diff suppressed because it is too large Load diff

View file

@ -1,133 +0,0 @@
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
// GET /defenseurs/state serves the Sergent's findings-state.json, and shares
// serveJsonFile() with GET /defenseurs. Both routes are covered here: the
// helper extraction must leave /defenseurs byte-identical to what it served.
const TOKEN = "test-token";
const INGEST_TOKEN = "test-ingest-token";
// Deliberately non-canonical JSON (odd spacing, key order, trailing newline):
// a handler that parsed and re-serialized the file would not reproduce it.
const STATE_RAW =
'{ "schemaVersion":1, "findings": {"a-deps-1": {"firstSeen":"2026-09-01T00:00:00.000Z"}},"history":[] }\n';
const STATUS_RAW = '{"timestamp":"2026-09-24T00:00:00.000Z", "agents": [] }\n';
let tmpDir;
let statePath;
let statusPath;
let server;
let baseUrl;
function startServer() {
// Fresh import so module-level constants pick up the stubbed env.
delete require.cache[require.resolve("../index.js")];
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 request(route, { method = "GET", auth = `Bearer ${TOKEN}` } = {}) {
const headers = {};
if (auth) headers.Authorization = auth;
const res = await fetch(`${baseUrl}${route}`, { method, headers });
return { status: res.status, text: await res.text() };
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-state-test-"));
statePath = path.join(tmpDir, "findings-state.json");
statusPath = path.join(tmpDir, "status.json");
process.env.HEALTH_TOKEN = TOKEN;
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
process.env.DEFENSEURS_FINDINGS_STATE_PATH = statePath;
process.env.DEFENSEURS_STATUS_PATH = statusPath;
await startServer();
});
afterEach(async () => {
await stopServer();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("GET /defenseurs/state", () => {
test("200 with the file content byte for byte when present", async () => {
fs.writeFileSync(statePath, STATE_RAW);
const { status, text } = await request("/defenseurs/state");
expect(status).toBe(200);
expect(text).toBe(STATE_RAW);
});
test("200 no_data when the file is absent", async () => {
const { status, text } = await request("/defenseurs/state");
expect(status).toBe(200);
expect(JSON.parse(text)).toEqual({ status: "no_data" });
});
test("serves findings-state.json, never status.json", async () => {
fs.writeFileSync(statusPath, STATUS_RAW);
const { status, text } = await request("/defenseurs/state");
expect(status).toBe(200);
expect(JSON.parse(text)).toEqual({ status: "no_data" });
});
test("401 with no Authorization header", async () => {
fs.writeFileSync(statePath, STATE_RAW);
const { status, text } = await request("/defenseurs/state", { auth: null });
expect(status).toBe(401);
expect(text).not.toContain("schemaVersion");
});
test("401 with a wrong bearer token", async () => {
fs.writeFileSync(statePath, STATE_RAW);
const { status, text } = await request("/defenseurs/state", { auth: "Bearer wrong" });
expect(status).toBe(401);
expect(text).not.toContain("schemaVersion");
});
test("401 with the ingest token", async () => {
fs.writeFileSync(statePath, STATE_RAW);
const { status, text } = await request("/defenseurs/state", {
auth: `Bearer ${INGEST_TOKEN}`,
});
expect(status).toBe(401);
expect(text).not.toContain("schemaVersion");
});
test("404 (not 401) on POST without Authorization", async () => {
const { status } = await request("/defenseurs/state", { method: "POST", auth: null });
expect(status).toBe(404);
});
});
describe("GET /defenseurs — unchanged by the serveJsonFile extraction", () => {
test("200 with status.json byte for byte when present", async () => {
fs.writeFileSync(statusPath, STATUS_RAW);
const { status, text } = await request("/defenseurs");
expect(status).toBe(200);
expect(text).toBe(STATUS_RAW);
});
test("200 no_data when status.json is absent", async () => {
const { status, text } = await request("/defenseurs");
expect(status).toBe(200);
expect(JSON.parse(text)).toEqual({ status: "no_data" });
});
});

View file

@ -1,618 +0,0 @@
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const child_process = require("node:child_process");
const { fileURLToPath } = require("node:url");
// The API's write surfaces are HOSTS_DIR (issue #36), since #48 the
// directory of CLAUDE_USAGE_PATH, written by POST /claude-usage only, and since
// #58 the directory of BACKUPS_PULL_PATH, written by POST /backups/pull only.
// 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
// (`:ro` on the Coolify destination). A write added one day under REPORTS_DIR
// or /data/defenseurs would not fail in the other test files: their fixtures
// live in a writable temp dir, and CI runs as root, so a chmod-555 fixture
// would prove nothing either. It would fail in production, as a 500 (EROFS)
// on the route that tried it. This file moves that failure to CI, for the
// paths it exercises: boot, the nominal reads and ingest, and the degraded
// ones (rejections, invalid input, a corrupt file, missing files).
//
// Every node:fs function that can write is spied BEFORE a fresh require,
// because the runtime modules destructure their fs functions at load time
// (same technique as the timingSafeEqual spy in auth.test.js). Every call is
// recorded, whoever makes it, then passed through to the real function. No
// attribution by call stack (#40): a function handed as-is to an asynchronous
// mechanism, `process.nextTick(writeFileSync, file, data)` or
// `res.on("finish", writeFileSync.bind(null, file, data))`, runs with no
// runtime frame on its stack, and a stack filter let it through. The price is
// that a write made by the test runner itself while a case runs would fail the
// case: the right way to fail. A shell command can write too, out of the fs
// spies' sight, so child_process is spied the same way and the set of commands
// is pinned to the two read-only ones metrics.js runs.
//
// The test edits its own fixtures in the degraded case. Those edits go through
// `real`, the functions taken before any spy exists, so the spies never have
// to look away.
//
// Limits: a path no case takes stays out of sight, and so does a write
// deferred past settle() (a timer longer than 20 ms).
//
// Boot is exercised by calling the exported sweepStaleTempFiles(), which is
// what the `require.main === module` block runs before listen. That block
// never runs in-process, so its content is pinned at the end of this file.
const REPO_ROOT = path.resolve(__dirname, "..");
const TOKEN = "test-token";
const INGEST_TOKEN = "test-ingest-token";
const CLAUDE_TOKEN = "test-claude-token";
const BACKUPS_TOKEN = "test-backups-token";
const ORPHAN = ".thinkpad.4242.1700000000000.tmp";
// The real functions, taken at load time, before any spy: the test's own
// fixture edits go through them and stay out of the logs. Only single-call
// functions belong here. `rmSync(dir, { recursive: true })` would not do: it
// calls the public fs.rmdirSync internally, which is the spy by then.
const real = { writeFileSync: fs.writeFileSync, rmSync: fs.rmSync, renameSync: fs.renameSync };
// Runtime file = under the repo root, outside node_modules and __tests__.
// Used to purge the module cache.
function isRuntimeFile(file) {
const rel = path.relative(REPO_ROOT, file);
if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return false;
const top = rel.split(path.sep)[0];
return top !== "node_modules" && top !== "__tests__";
}
// Write APIs of node:fs, by the argument(s) naming what gets written. Each is
// spied in its callback, Sync and fs.promises forms, when that form exists.
const O_WRITE =
fs.constants.O_WRONLY | fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_APPEND;
function opensForWriting(flags) {
if (flags === undefined || flags === null || typeof flags === "function") return false; // "r"
if (typeof flags === "number") return (flags & O_WRITE) !== 0;
return /[wa+]/.test(String(flags));
}
// mkdtemp's argument is a prefix: `<dir>/x` creates `<dir>/xXXXXXX` below
// <dir>, but `<dir>` alone creates the sibling `<dir>XXXXXX`. Checking the
// prefix plus one character tells the two apart.
function mkdtempTarget(prefix) {
if (prefix instanceof URL) return `${fileURLToPath(prefix)}X`;
if (typeof prefix === "string" || Buffer.isBuffer(prefix)) return `${prefix.toString()}X`;
return prefix;
}
const at = (...indexes) => (args) => indexes.map((i) => args[i]);
const WRITE_APIS = [
// The destination is the first argument.
...[
"writeFile", "appendFile", "unlink", "mkdir", "rm", "rmdir", "truncate",
"chmod", "lchmod", "chown", "lchown", "utimes", "lutimes", "createWriteStream",
].map((name) => [name, at(0)]),
["mkdtemp", (args) => [mkdtempTarget(args[0])]],
// Only the destination (second argument) is written.
...["copyFile", "cp", "link", "symlink"].map((name) => [name, at(1)]),
// Both ends of a rename change.
["rename", at(0, 1)],
// Metadata changes through a file descriptor: the target cannot be proven,
// so the fd itself is recorded and fails the path rule.
...["fchmod", "fchown", "futimes"].map((name) => [name, at(0)]),
// An open is a write only when its flags say so.
["open", (args) => (opensForWriting(args[1]) ? [args[0]] : null)],
];
// The two commands metrics.js runs, to read /proc/stat and the root disk.
const ALLOWED_COMMANDS = ["df -k /", "head -1 /proc/stat"];
const CHILD_PROCESS_APIS = ["exec", "execSync", "execFile", "execFileSync", "spawn", "spawnSync", "fork"];
// `exec("cmd")` and `execFile("file", ["arg"])` both come out as one string.
function commandOf(args) {
return (Array.isArray(args[1]) ? [args[0], ...args[1]] : [args[0]]).map(String).join(" ");
}
let log = [];
let commandLog = [];
function drain() {
const out = log;
log = [];
return out;
}
function foreignCommands() {
return commandLog.filter((command) => !ALLOWED_COMMANDS.includes(command));
}
function installWriteSpies() {
for (const [name, targetsOf] of WRITE_APIS) {
for (const [owner, key] of [
[fs, name],
[fs, `${name}Sync`],
[fs.promises, name],
]) {
// lchmod is an own property holding undefined on Linux.
if (typeof owner[key] !== "function") continue;
const original = owner[key];
vi.spyOn(owner, key).mockImplementation(function (...args) {
const targets = targetsOf(args);
if (targets) log.push({ fn: key, targets });
return original.apply(this, args);
});
}
}
}
function installCommandSpies() {
for (const key of CHILD_PROCESS_APIS) {
if (typeof child_process[key] !== "function") continue;
const original = child_process[key];
vi.spyOn(child_process, key).mockImplementation(function (...args) {
commandLog.push(commandOf(args));
return original.apply(this, args);
});
}
}
// Every runtime module, not only index.js: a module left in the cache would
// keep the functions it destructured under a previous test's spies, which
// vi.restoreAllMocks() has turned back into silent pass-throughs.
function purgeRuntimeModules() {
for (const file of Object.keys(require.cache)) {
if (isRuntimeFile(file)) delete require.cache[file];
}
}
// Equal to the allowed directory or below it — HOSTS_DIR unless the case names
// another one (only POST /claude-usage does, for its own directory, #48).
// path.relative rather than startsWith, so a sibling such as `<tmp>/hosts-evil`
// does not pass. A file descriptor, or any argument that is not a path, cannot
// be proven and fails.
function toPath(arg) {
if (typeof arg === "string") return path.resolve(arg);
if (Buffer.isBuffer(arg)) return path.resolve(arg.toString());
if (arg instanceof URL) return path.resolve(fileURLToPath(arg));
return null;
}
function violations(writes, allowedDir = process.env.HOSTS_DIR) {
const hostsDir = path.resolve(allowedDir);
return writes.flatMap(({ fn, targets }) =>
targets
.filter((arg) => {
const target = toPath(arg);
if (target === null) return true;
const rel = path.relative(hostsDir, target);
return rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
})
.map((arg) => `${fn}(${typeof arg === "string" ? arg : typeof arg})`),
);
}
function validSnapshot() {
return {
hostname: "thinkpad-x1",
uptime: 4242,
cpu: { model: "Intel Core i7", cores: 8, loadAvg: [0.5, 0.4, 0.3], usagePercent: 12 },
memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 },
disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 },
};
}
function validClaudeUsage() {
return {
schemaVersion: 1,
timeZone: "America/Toronto",
limits: { status: "ok", fetchedAt: "2026-09-30T07:30:00.000Z", items: [] },
// Two days: POST archives the first one into history/ (#55).
days: [
{ date: "2026-09-29", models: { "claude-opus-5-5": { input: 1, output: 1, requests: 1 } } },
{ date: "2026-09-30", models: { "claude-opus-5-5": { input: 1, output: 2, requests: 1 } } },
],
projects: [{ name: "proj", work: 3, cacheRead: 4, requests: 1 }],
};
}
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) {
return path.join(tmpDir, "claude-usage", "history", `${month}.json`);
}
function claudeUsageFixturePath() {
return path.join(tmpDir, "claude-usage", "claude-usage.json");
}
function writeJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(value));
}
// One macrotask more, so a write deferred past res.end() is caught too.
function settle() {
return new Promise((resolve) => setTimeout(resolve, 20));
}
let tmpDir;
let server;
let baseUrl;
let bootWrites;
// A transport failure (a connection cut before a 413 is read) comes back as
// "transport-error", so it fails an assertion instead of passing for a code.
async function send(route, { method = "GET", auth = `Bearer ${TOKEN}`, body } = {}) {
const headers = auth ? { Authorization: auth } : {};
if (body !== undefined) headers["Content-Type"] = "application/json";
try {
const res = await fetch(`${baseUrl}${route}`, { method, headers, body });
const text = await res.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
// Not JSON: the status alone is asserted.
}
return { status: res.status, body: json };
} catch {
return { status: "transport-error", body: null };
}
}
// Created before the spies: nothing here is recorded.
function writeFixtures() {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-writes-test-"));
const defenseurs = path.join(tmpDir, "defenseurs");
const reports = path.join(defenseurs, "reports");
const hosts = path.join(tmpDir, "hosts");
const report = (timestamp) => ({
agent: "defenseur-proj",
timestamp,
findings: [{ severity: "HIGH", category: "deps", title: "vulnerable dependency" }],
});
writeJson(path.join(defenseurs, "status.json"), {
schemaVersion: 1,
timestamp: "2026-09-30T07:30:00.000Z",
agents: [{ name: "defenseur-proj", status: "ok" }],
});
writeJson(path.join(defenseurs, "findings-state.json"), {
schemaVersion: 1,
updatedAt: "2026-09-30T07:30:00.000Z",
findings: { "proj:deps:1": { firstSeen: "2026-09-29T07:30:00.000Z" } },
});
writeJson(path.join(defenseurs, "agents-map.json"), { proj: "defenseur-proj" });
writeJson(path.join(reports, "defenseur-proj_2026-09-30T07-30-00-000Z.json"), report("2026-09-30T07:30:00.000Z"));
writeJson(path.join(reports, "archive", "defenseur-proj_2026-09-29T07-30-00-000Z.json"), report("2026-09-29T07:30:00.000Z"));
// A stored snapshot, so GET /hosts runs its real read path, not neverSeen.
writeJson(path.join(hosts, "thinkpad.json"), { ...validSnapshot(), receivedAt: new Date().toISOString() });
// An orphan temp file older than an hour: the boot sweep must remove it,
// which gives every test a write it can see (proof its spies are live).
const orphan = path.join(hosts, ORPHAN);
fs.writeFileSync(orphan, "{}");
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
fs.utimesSync(orphan, twoHoursAgo, twoHoursAgo);
process.env.HEALTH_TOKEN = TOKEN;
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
process.env.REPORTS_DIR = reports;
process.env.DEFENSEURS_STATUS_PATH = path.join(defenseurs, "status.json");
process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(defenseurs, "findings-state.json");
process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(defenseurs, "agents-map.json");
process.env.HOSTS_DIR = hosts;
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
// The Claude usage snapshot (#48): its own directory, beside HOSTS_DIR and
// not under it, so a write there is checked against its own rule.
writeJson(claudeUsageFixturePath(), { ...validClaudeUsage(), receivedAt: new Date().toISOString() });
process.env.CLAUDE_USAGE_PATH = claudeUsageFixturePath();
// An archived month (#55), so GET /claude-usage/history runs its read path.
writeJson(historyFixturePath("2026-09"), {
schemaVersion: 1,
month: "2026-09",
days: [{ date: "2026-09-28", models: {} }],
});
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.
process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration";
}
beforeEach(async () => {
writeFixtures();
log = [];
commandLog = [];
installWriteSpies();
installCommandSpies();
purgeRuntimeModules();
const { handler, sweepStaleTempFiles } = require("../index.js");
sweepStaleTempFiles();
server = http.createServer(handler);
await new Promise((resolve) => server.listen(0, resolve));
baseUrl = `http://127.0.0.1:${server.address().port}`;
bootWrites = drain();
});
afterEach(async () => {
vi.restoreAllMocks();
await new Promise((resolve) => server.close(() => resolve()));
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => {
test("boot: module load and the temp-file sweep write only under HOSTS_DIR", () => {
expect(bootWrites.map(({ fn }) => fn)).toContain("unlinkSync");
expect(fs.existsSync(path.join(process.env.HOSTS_DIR, ORPHAN))).toBe(false);
expect(violations(bootWrites)).toEqual([]);
expect(foreignCommands()).toEqual([]);
});
// Each route with the content that proves its fixture was really read: a
// missing mount answers 200 no_data, which must not pass for a clean read.
const READ_ROUTES = [
["/health", (body) => expect(body.cpu).toBeDefined()],
["/defenseurs", (body) => expect(body.agents).toHaveLength(1)],
["/defenseurs/state", (body) => expect(Object.keys(body.findings)).toHaveLength(1)],
[
"/defenseurs/findings?project=proj",
(body) => {
expect(body.agent).toBe("defenseur-proj");
expect(body.findings).toHaveLength(1);
},
],
["/reports/scans?date=2026-09-30", (body) => expect(body.count).toBe(1)],
[
"/hosts",
(body) => {
const host = body.hosts.find(({ id }) => id === "thinkpad");
expect(host.neverSeen).toBeUndefined();
expect(host.receivedAt).toEqual(expect.any(String));
},
],
["/claude-usage", (body) => expect(body.snapshot.projects).toHaveLength(1)],
["/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) => {
// This test's own spies are live: they saw the boot sweep.
expect(bootWrites).not.toHaveLength(0);
const { status, body } = await send(route);
expect(status).toBe(200);
expect(body.status).not.toBe("no_data");
expectContent(body);
await settle();
expect(drain()).toEqual([]);
expect(foreignCommands()).toEqual([]);
});
test("POST /hosts/<id> writes, and only under HOSTS_DIR", async () => {
const { status } = await send("/hosts/thinkpad", {
method: "POST",
auth: `Bearer ${INGEST_TOKEN}`,
body: JSON.stringify(validSnapshot()),
});
expect(status).toBe(204);
await settle();
const writes = drain();
expect(writes.map(({ fn }) => fn)).toEqual(expect.arrayContaining(["writeFileSync", "renameSync"]));
expect(violations(writes)).toEqual([]);
expect(foreignCommands()).toEqual([]);
});
// The second write surface (#48). Its directory is the only one this case
// allows: a write under HOSTS_DIR from this route would fail it too.
test("POST /claude-usage writes, and only in the directory of CLAUDE_USAGE_PATH", async () => {
const { status } = await send("/claude-usage", {
method: "POST",
auth: `Bearer ${CLAUDE_TOKEN}`,
body: JSON.stringify(validClaudeUsage()),
});
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.CLAUDE_USAGE_PATH))).toEqual([]);
// The archive (#55) is part of this surface, and the case proves it ran:
// a rename onto the month file of the closed day.
expect(writes.some(({ fn, targets }) => fn === "renameSync" && targets.includes(historyFixturePath("2026-09")))).toBe(true);
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 () => {
expect(bootWrites).not.toHaveLength(0);
const claude = `Bearer ${CLAUDE_TOKEN}`;
const rejected = [
[401, { auth: null, body: "{}" }],
[401, { auth: `Bearer ${INGEST_TOKEN}`, body: JSON.stringify(validClaudeUsage()) }],
[400, { auth: claude, body: "{not json" }],
[400, { auth: claude, body: JSON.stringify({ ...validClaudeUsage(), schemaVersion: 2 }) }],
[413, { auth: claude, body: "x".repeat(66000) }],
];
for (const [expected, options] of rejected) {
const { status } = await send("/claude-usage", { method: "POST", ...options });
expect([options.body.length, status]).toEqual([options.body.length, expected]);
}
// A corrupt stored snapshot: where a "repair it on read" write would go.
real.writeFileSync(process.env.CLAUDE_USAGE_PATH, "{corrupt");
const { status, body } = await send("/claude-usage");
expect(status).toBe(200);
expect(body).toEqual({ status: "no_data" });
// Same for a corrupt month of the archive, and for a refused range (#55).
real.writeFileSync(historyFixturePath("2026-09"), "{corrupt");
const history = await send("/claude-usage/history?from=2026-09&to=2026-09");
expect([history.status, history.body]).toEqual([200, { from: "2026-09", to: "2026-09", days: [] }]);
expect((await send("/claude-usage/history?from=2026-13")).status).toBe(400);
await settle();
expect(drain()).toEqual([]);
expect(foreignCommands()).toEqual([]);
});
// The branches the nominal cases never take, and where the most plausible
// future writes would land: quarantining a corrupt file, recreating a
// missing one, logging a rejection or a refused payload to disk.
test("rejections, invalid input, corrupt and missing files write nothing outside HOSTS_DIR", async () => {
expect(bootWrites).not.toHaveLength(0);
const ingest = `Bearer ${INGEST_TOKEN}`;
const rejected = [
[401, "/defenseurs", { auth: null }],
[401, "/defenseurs", { auth: "Bearer wrong" }],
[401, "/hosts", { auth: ingest }],
[404, "/nope", {}],
[400, "/defenseurs/findings", {}],
[400, "/defenseurs/findings?project=proj&severity=BOGUS", {}],
[404, "/defenseurs/findings?project=unknown", {}],
[400, "/reports/scans?date=../../etc", {}],
[401, "/hosts/thinkpad", { method: "POST", auth: "Bearer wrong", body: "{}" }],
[403, "/hosts/laptop", { method: "POST", auth: ingest, body: JSON.stringify(validSnapshot()) }],
[400, "/hosts/thinkpad", { method: "POST", auth: ingest, body: "{not json" }],
[400, "/hosts/thinkpad", { method: "POST", auth: ingest, body: JSON.stringify({ hostname: "x" }) }],
[413, "/hosts/thinkpad", { method: "POST", auth: ingest, body: "x".repeat(5000) }],
];
for (const [expected, route, options] of rejected) {
const { status } = await send(route, options);
expect([options.method || "GET", route, status]).toEqual([options.method || "GET", route, expected]);
}
// A corrupt agents-map.json: where a quarantine rename would go.
real.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, "{corrupt");
expect((await send("/defenseurs/findings?project=proj")).status).toBe(500);
// Missing Defenseurs files and a corrupt snapshot: where a "recreate it"
// write would go. Each route must degrade, with the content that says so.
// The reports directory is moved away rather than removed: one call.
real.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, JSON.stringify({ proj: "defenseur-proj" }));
real.rmSync(process.env.DEFENSEURS_STATUS_PATH);
real.rmSync(process.env.DEFENSEURS_FINDINGS_STATE_PATH);
real.renameSync(process.env.REPORTS_DIR, path.join(tmpDir, "reports-moved-away"));
real.writeFileSync(path.join(process.env.HOSTS_DIR, "thinkpad.json"), "{corrupt");
const degraded = [
["/defenseurs", (body) => expect(body.status).toBe("no_data")],
["/defenseurs/state", (body) => expect(body.status).toBe("no_data")],
["/defenseurs/findings?project=proj", (body) => expect(body).toEqual({ findings: [], status: "no_data" })],
["/reports/scans?date=2026-09-30", (body) => expect(body.count).toBe(0)],
["/hosts", (body) => expect(body.hosts.find(({ id }) => id === "thinkpad").neverSeen).toBe(true)],
];
for (const [route, expectContent] of degraded) {
const { status, body } = await send(route);
expect([route, status]).toEqual([route, 200]);
expectContent(body);
}
await settle();
expect(violations(drain())).toEqual([]);
expect(foreignCommands()).toEqual([]);
});
});
describe("shell commands — only the two read-only metrics ones (#36)", () => {
// Positive control for the child_process spies: /health must show exactly
// the two commands. The other cases only check that nothing else ran.
test("GET /health runs exactly `head -1 /proc/stat` and `df -k /`", async () => {
const { status } = await send("/health");
expect(status).toBe(200);
expect([...new Set(commandLog)].sort()).toEqual([...ALLOWED_COMMANDS].sort());
});
});
describe("fs writes — the entrypoint adds no write of its own", () => {
// The block below never runs in-process: the tests above stand in for it by
// calling the exported sweep. This pin keeps that stand-in honest. A new call
// in the block (a PID file, a marker) must be looked at, then allowed here.
// The comparison is an exact set, so a block that cannot be found or comes
// back empty fails instead of passing vacuously.
test("the main block only sweeps, creates the server and listens", () => {
const source = fs.readFileSync(path.join(REPO_ROOT, "index.js"), "utf-8");
const start = source.indexOf("if (require.main === module) {");
expect(start).toBeGreaterThan(-1);
const block = source.slice(start, source.indexOf("\n}\n", start) + 2);
const calls = [...block.matchAll(/([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/g)]
.map((match) => match[1])
.filter((name) => name !== "if");
expect([...new Set(calls)].sort()).toEqual(
["console.log", "http.createServer", "server.listen", "sweepStaleTempFiles"].sort(),
);
});
});

View file

@ -1,465 +0,0 @@
# Workstation agent
Collects one snapshot of the machine it runs on and pushes it to
`POST /hosts/<id>` on the health API. Cron runs it every 5 minutes; the
dashboard reads the result through `GET /hosts`.
Zero dependencies — `node:http` / `node:https` and the `metrics.js` already
shared with the server. Node 22+.
| File | Role |
|------|------|
| `push-metrics.js` | Collect, build the payload, POST once, exit |
| `run-push.sh` | Cron wrapper: load the env file, check the install, exec node |
This directory is **not** part of the server image: the `Dockerfile` copies
`package.json index.js metrics.js` one by one, and a test pins that it stays
that way. The agent ships by copying files onto a workstation, never by deploy.
## What it deliberately does not do
**No queue, no replay.** One run is one attempt. A push that fails is dropped,
logged, and the process exits non-zero — the next beat is five minutes away and
a heartbeat from five minutes ago describes a machine that no longer exists.
Nothing is spooled to disk, so nothing can ever be replayed to make the
dashboard show a past that is no longer true.
**No secret in the logs.** `run-push.sh` is meant to be piped into `logger`, so
everything the agent prints ends up in syslog and journald permanently. The
failure path therefore reports **only** `err.code` and the HTTP status — never
an error object, never the request options, never a response body, and never a
header. That is also why `run-push.sh` must never gain a `set -x`: the shell
would echo the token as it sources the env file.
The cost is real and accepted: a `400` tells you the server rejected the
payload, not why. The reason is in the server's logs, and `--dry-run` (below)
shows you the exact payload that was sent.
## Install on a new workstation
### 1. Freeze a copy
The cron must point at a copy, **never at the git working tree**. The server is
deployed by a manual trigger, so the checked-out repo drifts freely: a branch
checkout in `~/claude-code/vps-health-api` would silently change — or break —
the heartbeat of the workstation, and nothing would say so.
`push-metrics.js` loads `metrics.js` from one directory up, so the copy keeps
the repo layout:
```
mkdir -p ~/.local/share/maximus-host-agent/agent
cp metrics.js ~/.local/share/maximus-host-agent/
cp agent/push-metrics.js agent/run-push.sh ~/.local/share/maximus-host-agent/agent/
chmod +x ~/.local/share/maximus-host-agent/agent/run-push.sh
```
Re-run those three `cp` after any change to the agent — that copy step *is* the
deployment.
### 2. Write the env file
`~/.config/maximus-host-agent.env`, mode `600` (the agent warns on any other
mode). Use `printf`, not a heredoc: a trailing newline or space inside the token
value is the most common install failure.
```
HOSTS_API_URL=https://health.lacompagniemaximus.com
HOSTS_INGEST_TOKEN=<the ingest token>
HOST_ID=thinkpad
# NODE_BIN=/usr/bin/node # only if cron cannot find node (see below)
# HOST_AGENT_TIMEOUT_MS=10000 # push timeout, wall-clock; default 10000
```
```
chmod 600 ~/.config/maximus-host-agent.env
```
`HOSTS_INGEST_TOKEN` is the write-only token: it can push snapshots for an
allowlisted id and nothing else. It is deliberately *not* `HEALTH_TOKEN`, so
that the two can be rotated independently and so a workstation that only pushes
never needs the read token.
Do not read more into that separation than it gives you. On the ThinkPad it
buys no containment at all: that machine already stores `HEALTH_TOKEN` in
cleartext for the `defenseur-auto` cron, so losing the laptop compromises both
tokens. **Rotate them together** — see `secret-rotation-ops.md`.
`HOST_ID` must match `^[a-z0-9][a-z0-9-]{0,31}$` and be listed in the server's
`HOSTS_ALLOWED_IDS`, otherwise the push comes back `403`.
### 3. Dry run
Collect and print the payload without touching the network or needing any
config:
```
node ~/.local/share/maximus-host-agent/agent/push-metrics.js --dry-run
```
Then one real push, by hand, before installing the cron:
```
~/.local/share/maximus-host-agent/agent/run-push.sh
# host-agent: ok id=thinkpad status=204 hostname=thinkpad-x1
```
Confirm the server side (from a machine that holds `HEALTH_TOKEN`):
```
curl -H "Authorization: Bearer $HEALTH_TOKEN" \
https://health.lacompagniemaximus.com/hosts
```
The entry for your id should show `online: true` and a small `ageSeconds`.
### 4. Install the cron
```
crontab -e
```
```
*/5 * * * * $HOME/.local/share/maximus-host-agent/agent/run-push.sh 2>&1 | logger -t host-agent
```
Read what it did:
```
journalctl -t host-agent --since -1h
```
If the line works by hand but produces nothing under cron, it is almost always
`node`: cron's `PATH` is `/usr/bin:/bin`, and a node installed by nvm or under
`/usr/local` is not on it. Set `NODE_BIN` to an absolute path in the env file —
the wrapper says so explicitly rather than failing silently.
## One id per workstation
The server keeps **one file per host id** and the last write wins. Two
workstations configured with the same `HOST_ID` therefore overwrite each other
every five minutes, and nothing anywhere reports an error: the dashboard shows
one host that looks perfectly healthy while its numbers come from whichever
machine pushed last.
The signal to watch for is the `hostname` field in `GET /hosts` **changing
between polls** while the id stays the same — plus CPU and memory that jump
around without pattern. If you see that, two machines are sharing an id.
Give every workstation its own id and add it to `HOSTS_ALLOWED_IDS` on the
server before installing the agent on it.
## Exit codes
| Code | Meaning |
|------|---------|
| 0 | Snapshot accepted (server answered 2xx, normally `204`) |
| 1 | Install problem: env file missing, a variable unset, malformed `HOST_ID`, node not found, `metrics.js` not found next to the agent |
| 2 | The push did not land: network error (`code=…`) or a non-2xx answer (`HTTP …`) |
A `1` needs a human on the workstation. A `2` usually fixes itself on the next
beat; a `2` that repeats for an hour means the API or the link is down.
## Payload
Exactly the `GET /health` body minus `logto` and `timestamp`:
```
{ hostname, uptime,
cpu: { model, cores, loadAvg, usagePercent },
memory: { totalGB, usedGB, freeGB, usagePercent },
disk: { totalGB, usedGB, freeGB, usagePercent } }
```
`loadAvg` is exactly three numbers (1, 5 and 15 minutes), as `os.loadavg()`
returns them; the server answers `400` to any other length.
Sharing `metrics.js` with the server keeps the two in step, but it is not the
guarantee — the frozen copy can lag behind a deployed server. The real contract
is the server's own validation, and `__tests__/agent.test.js` checks the agent's
payload against it (including a real snapshot through the real handler).
## claude-usage agent (`agent/claude-usage/`)
A second, independent agent on the ThinkPad. Every 15 minutes it reads the
Claude plan limits, aggregates the local Claude Code transcripts over the last
30 Toronto days, and pushes one `schemaVersion: 1` snapshot to
`POST /claude-usage`; the admin dashboard reads it through `GET /claude-usage`.
It lives in this repo, next to the server, because the snapshot contract and
its sanitizer (`claude-usage.js`) are tested together:
`__tests__/agent-claude-usage.test.js` builds a real snapshot from fixtures and
checks that `sanitizeClaudeUsage()` keeps it unchanged. Like the host agent, it
never enters the server image.
| File | Role |
|------|------|
| `aggregate.js` | Read `~/.claude/projects/**/*.jsonl`, build `days` and `projects` |
| `limits.js` | One GET to Anthropic's OAuth usage endpoint, mapped to `limits.items` |
| `state.js` | Local cache: frozen days and last good limits |
| `push-claude-usage.js` | Entry point: aggregate, limits, write the cache, push once, exit |
| `run-claude-usage.sh` | Cron wrapper: lock, load the env file, check the install, exec node |
### What it deliberately does not do
**It never refreshes the OAuth token and never writes
`~/.claude/.credentials.json`.** Refresh tokens are single use: refreshing from
here would log Claude Code out. The file is only read; an expired token gives
`limits.status: "token_expired"` and no call at all, until Claude Code itself
refreshes it.
**The Anthropic URL is pinned.** It is a constant in `limits.js`, no
environment variable changes it, and redirects are refused: the same process
holds the OAuth bearer and a configurable push URL, and the bearer must only
ever reach `https://api.anthropic.com`. `CLAUDE_USAGE_API_URL` must be
`https://` (the agent exits 1 otherwise).
**One call, one push, no retry.** The usage endpoint rate-limits hard and sends
no `Retry-After`. When it fails, the snapshot carries the failure status
(`http_error`, `unavailable`, ...) with the last good items and their
`fetchedAt`, so the dashboard can show how old they are.
**No secret in the logs.** Same rule as the host agent: the output goes to
`logger -t claude-usage`, so failures print fixed templates plus an HTTP status
or an error code — never an error object, a response body, or a header — and
`run-claude-usage.sh` must never gain a `set -x`.
### Install
Freeze a copy — never point the cron at the git working tree:
```
mkdir -p ~/.local/share/maximus-claude-usage
cp agent/claude-usage/aggregate.js agent/claude-usage/limits.js agent/claude-usage/state.js agent/claude-usage/push-claude-usage.js agent/claude-usage/run-claude-usage.sh ~/.local/share/maximus-claude-usage/
chmod +x ~/.local/share/maximus-claude-usage/run-claude-usage.sh
```
Re-run that `cp` after any change to the agent: the copy step *is* the
deployment. The code lives in `~/.local/share/`, the cache in
`~/.local/state/maximus-claude-usage/` (`state.json` and `run.lock`, created on
the first run): wiping the copy never loses the cache, and vice versa.
Env file `~/.config/maximus-claude-usage.env`, mode `600` (the wrapper warns on
any other mode). Write it with `printf`, not a heredoc: a trailing newline in
the token is refused with exit 1.
```
CLAUDE_USAGE_API_URL=https://health.lacompagniemaximus.com
CLAUDE_USAGE_INGEST_TOKEN=<the claude-usage ingest token>
# CLAUDE_USAGE_LIMITS=off # skip the Anthropic call entirely
# NODE_BIN=/usr/bin/node # only if cron cannot find node
```
```
chmod 600 ~/.config/maximus-claude-usage.env
```
`CLAUDE_USAGE_INGEST_TOKEN` is its own token: neither `HOSTS_INGEST_TOKEN` nor
`HEALTH_TOKEN` works on `POST /claude-usage`, and it opens nothing else.
Dry run — prints the snapshot, pushes nothing, leaves the cache untouched, needs
no env file (it still calls the Anthropic endpoint once unless
`CLAUDE_USAGE_LIMITS=off`):
```
CLAUDE_USAGE_LIMITS=off node ~/.local/share/maximus-claude-usage/push-claude-usage.js --dry-run
```
Then one real run by hand, before installing the cron (the first one scans every
transcript of the window and can take a while; the next ones only read files
written since yesterday):
```
~/.local/share/maximus-claude-usage/run-claude-usage.sh
# claude-usage: ok status=204 limits=ok bytes=...
```
Crontab line:
```
*/15 * * * * $HOME/.local/share/maximus-claude-usage/run-claude-usage.sh 2>&1 | logger -t claude-usage
```
```
journalctl -t claude-usage --since -1h
```
A beat that finds the previous one still running (`flock` on
`~/.local/state/maximus-claude-usage/run.lock`) logs
`a run is already in progress, skipping` and exits 0.
### Exit codes
| Code | Meaning |
|------|---------|
| 0 | Snapshot accepted (2xx), or a skipped beat (lock held) |
| 1 | Install problem: env file missing, a variable unset, URL not `https://`, token with an invalid character, node or flock not found |
| 2 | The push did not land (network or non-2xx), or the snapshot exceeded 64 KiB (`payload_too_large`, nothing sent) |
The limits failing is **not** an exit code: the snapshot is still pushed, with
`limits.status` saying why.
### Unreadable transcripts
A day is frozen in `state.json` two days after it ends, and never recomputed
afterwards. A beat that cannot read part of `~/.claude/projects` (a directory it
cannot list, a transcript removed between the walk and its `stat`, a stream
error) therefore freezes **no** new day: it still pushes its snapshot, exits as
usual, and logs only a count:
```
claude-usage: warning: read_errors=N, no day frozen this beat
```
The next clean beat freezes the days that were waiting. A file that stays
unreadable (left at `chmod 000`, for instance) blocks freezing on every beat:
each run then rescans the whole 30-day window, slower but correct, and this
log line is the only sign. Fix the file's permissions. To force a full
recount after the fact, `rm ~/.local/state/maximus-claude-usage/state.json`.
### Backfilling the history (one-shot, #55)
The server keeps every closed day in `history/YYYY-MM.json` from the first push
after the deploy of #55. The days that already left the 30-day window then only
live in the transcripts, which Claude Code deletes as they age: this backfill
rebuilds them once. It runs from the repo, never from the cron install, and the
agent itself does not change.
1. On the workstation, from the repo:
```bash
node agent/claude-usage/backfill-history.js --out ~/claude-usage-backfill.json
# claude-usage backfill: N day(s) from YYYY-MM-DD to YYYY-MM-DD, 24 window(s), read_errors=0
```
It reads `~/.claude/projects` only (never `~/.claude/.credentials.json`),
opens no connection, and writes only the `--out` file, in mode 600, refusing
to overwrite it. It goes back `--max-windows` windows of 28 days (default 24,
about 22 months; each window takes a few seconds), and stops at D-2: the
last closed days are archived by the cron's next push anyway. Exit 1 with
`read_errors=N`: some transcript could not be read, rerun before importing.
2. Copy it to the VPS and merge it inside the container, as `node` (the
container's user, owner of the mount):
```bash
scp ~/claude-usage-backfill.json ubuntu@vps-b0826277.tail3c811f.ts.net:/tmp/
ssh ubuntu@vps-b0826277.tail3c811f.ts.net
CTR=$(docker ps --format '{{.Names}}' | grep u8000gsg044wsk0oo0w884ok | head -1)
docker exec -i "$CTR" node claude-usage-history.js --import - < /tmp/claude-usage-backfill.json
# import: N day(s) read, A added, K already archived, M month(s) written
rm /tmp/claude-usage-backfill.json
```
**An already archived date always wins**: the import only adds the dates
the archive lacks, so running it twice, or after the live archive has grown,
changes nothing that is there. An import file with one invalid entry or a
repeated date is refused whole (exit 1, nothing written). A month file the
server cannot read back is skipped and left as is (exit 1, named on stderr).
3. Check through the API, with the read token:
`GET /claude-usage/history?from=<first month>&to=<last month>`.
Then delete `~/claude-usage-backfill.json` on the workstation.
## PostgreSQL backup pull (`agent/pg-backup-pull/`)
A third, independent agent on the Lenovo. Once a day it pulls the
age-encrypted dumps of the shared PostgreSQL cluster from the VPS, keeps 30
days of them in `~/backups/postgres/`, and reports the pull to
`POST /backups/pull`. The VPS side (`backup-postgres.sh`, 03:00 UTC), the
`status.json` contract and the restore procedure live in
`la-compagnie-maximus/docs/postgres-ops.md`, section Backup
(la-compagnie-maximus#220).
| File | Role |
|------|------|
| `run-pg-backup-pull.sh` | rsync over ssh, rotation, report; POSIX `sh`, no node |
| `maximus-pg-backup-pull.service` | systemd user unit, oneshot, retries on failure |
| `maximus-pg-backup-pull.timer` | `OnCalendar=daily`, `Persistent=true` |
**The only departure from the cron convention of the other agents is the
timer.** A cron line missed while the laptop is off is simply lost;
`Persistent=true` runs the missed pass at the next boot. That catch-up run can
start before Tailscale is up, hence `Restart=on-failure` every 15 minutes (8
tries at most in 4 hours).
**What it never does.** It never decrypts: the age private key is not on this
machine (Vaultwarden plus a paper copy). It never deletes on the VPS side, and
rsync only lets directories and `*.age` files through, so a misconfigured
outbox cannot land a clear dump here. The key it uses
(`~/.ssh/pgbackup_pull_ed25519`) can only run `rrsync -ro` on the outbox, from
the Lenovo's Tailscale IP, on the classic sshd at `100.86.30.21:2222`; the VPS
host key is pinned in `~/.ssh/known_hosts_pgbackup` (no trust on first use).
### Install
```
mkdir -p ~/.local/share/maximus-pg-backup-pull ~/.config/systemd/user
cp agent/pg-backup-pull/run-pg-backup-pull.sh ~/.local/share/maximus-pg-backup-pull/
chmod +x ~/.local/share/maximus-pg-backup-pull/run-pg-backup-pull.sh
cp agent/pg-backup-pull/maximus-pg-backup-pull.{service,timer} ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now maximus-pg-backup-pull.timer
```
As for the other agents, the copy step *is* the deployment: never point the
unit at the git working tree. The user manager must run without an open
session: `loginctl show-user $USER -p Linger` must say `yes`.
The env file `~/.config/maximus-pg-backup-pull.env` (mode `600`) is optional
until the ingest route is deployed. Without it the pull runs and the report is
skipped, with one log line. Then, written with `printf`:
```
BACKUPS_API_URL=https://health.lacompagniemaximus.com
BACKUPS_INGEST_TOKEN=<the backups ingest token>
```
The token reaches curl through a header file created `0600` and removed right
after, never through argv. An `EXIT` trap removes it too when the run is
stopped mid-report (systemd's `SIGTERM` is turned into an exit first).
### Check
```
systemctl --user start maximus-pg-backup-pull.service
journalctl --user -t pg-backup-pull --since -1h
systemctl --user list-timers maximus-pg-backup-pull.timer
```
A pass whose newest dated directory is not today's (UTC) logs
`warning: no directory for <today>, latest is <date>`: the VPS stopped
producing, or the pass ran before its 03:00 UTC run. A dated directory with no
`.age` file is never created (`--prune-empty-dirs`), and one found empty logs
`warning: no .age file in <date>`. The report still says `ok` with the newest
directory's count (partial files in `.rsync-partial/` not counted); freshness
is judged on the VPS side by `GET /backups`.
**Nothing the VPS sends may delete or alter a copy already here.**
- **No overwrite.** A file already pulled is never rewritten
(`--ignore-existing`): the VPS writes each dump to a `.tmp` and renames it,
so what lands is whole.
- **Directory names are checked.** A name is dated only if `date` gives it
back unchanged: `2026-13-45` or `2026-02-30` is logged and ignored, never
counted, never removed, and the pass goes on. A dated name after today
(VPS clock off, or a VPS gone rogue) is logged and ignored the same way.
- **Rotation is anchored on the newest valid backup not after today.** It
keeps that directory and the 29 days before it. Not on today alone: if the
VPS stops producing (dead cron, failing `pg_dump`) or this machine's clock
jumps ahead, the last off-site copy stays put instead of aging out after
30 days. Not on the newest name alone: a future-dated directory would push
the window over every real backup.
The script sticks to what busybox offers (no `find -printf`, no
`date -d "... days"`): the CI runs its tests in `node:22-alpine`.
### Exit codes
| Code | Meaning |
|------|---------|
| 0 | Pulled, rotated, reported (or report skipped without a token) |
| 1 | Install problem (missing key, known_hosts, binary) or rotation failure |
| 2 | The pull failed (`pull_failed` reported) |
| 3 | Pulled and rotated, but the report did not land |

View file

@ -1,509 +0,0 @@
// Pure aggregation of Claude Code usage from the local transcripts (issue #49).
//
// Reads ~/.claude/projects/**/*.jsonl (subagent transcripts included) and
// builds the `days` series and the `projects` top of the POST /claude-usage
// contract (schemaVersion 1). No network, no runtime dependency, nothing
// written: the caller (the claude-usage agent) owns the frozen-days state and
// the delivery.
//
// Properties that are load-bearing and easy to break:
//
// * DATES ARE STRINGS. A day is the America/Toronto calendar date as
// "YYYY-MM-DD"; shifting by n days goes through Date.UTC on the parts.
// Never "now - 86 400 000": DST days last 23 or 25 hours, and a
// millisecond offset lands on the wrong date around the change.
// * ONE REQUEST, ONE COUNT. Claude Code writes the same API response several
// times (one line per content block, and again in subagent transcripts),
// with identical input/cache counters and a growing output_tokens. Lines
// are deduplicated on (message.id, requestId) across ALL files, keeping the
// line with the largest output_tokens; on equal output, the earliest line,
// so the choice never depends on the order the files are read in.
// * FROZEN DAYS ARE DROPPED AFTER DEDUPLICATION, NOT BEFORE. A request whose
// lines straddle midnight belongs to the day of its best line. The beat
// that froze a day saw all its lines (a day freezes at D+2) and made that
// choice; filtering the frozen day's lines first would leave the later
// line alone and count the request a second time on the open day (#54).
// Known limits: the window start is still filtered per line, so a request
// straddling the window's first midnight lands on the window's first day;
// and the mtime threshold may skip a subagent transcript holding the only
// copy of a straddling request's earlier line, in which case a later beat
// sees fewer lines of it than the beat that froze the day.
// * READ ERRORS ARE COUNTED. An unreadable directory, a failed stat or a
// stream error skips that input but is reported in `readErrors`: the
// agent then freezes nothing on that beat, rather than freezing a day
// with lines missing (#54). A missing root is an empty scan, not an error.
// * THE SCAN THRESHOLD. Frozen days are never recomputed. A file whose mtime
// (Toronto day) is before the oldest open day of the window cannot hold a
// line on an open day, so it is not even opened: this is what keeps a run
// from re-reading ~210 MB of transcripts every time. The threshold comes
// from the open days, never from the frozen keys: a stray frozen day (in
// the future, or after a gap) must not hide an open day's files.
// * NO PROTOTYPE. Names read from transcripts or the state file key plain
// maps built by dict(), so `__proto__` or `constructor` stay ordinary keys.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const readline = require("node:readline");
const TIME_ZONE = "America/Toronto";
const WINDOW_DAYS = 30;
const TOP_PROJECTS = 8;
const MAX_MODELS_PER_DAY = 12;
const OTHER_MODEL = "other";
const OTHER_PROJECTS = "(autres)";
const MONOREPO_PROJECT = "(monorepo)";
const UNKNOWN_PROJECT = "(autre)";
const DEFENSEUR_AUTO_PROJECT = "defenseurs";
const SYNTHETIC_MODEL = "<synthetic>";
// Same bounds as the server's sanitizer: a model key outside this pattern
// would be dropped there, so it is folded into `other` here instead.
const MODEL_KEY_RE = /^[a-z0-9][a-z0-9.\-]{0,63}$/;
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
const MAX_STRING = 128;
// --- Dates ------------------------------------------------------------------
const dayFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone: TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
// Toronto calendar day of an instant (Date or epoch ms), as "YYYY-MM-DD".
// Built from formatToParts so the result does not depend on the en-CA
// separator chosen by the ICU build.
function torontoDay(instant) {
const parts = {};
for (const p of dayFormatter.formatToParts(instant)) parts[p.type] = p.value;
return `${parts.year}-${parts.month}-${parts.day}`;
}
// "YYYY-MM-DD" shifted by n calendar days (n may be negative). Pure calendar
// arithmetic on the parts: the time zone and DST never enter into it.
function shiftDay(day, n) {
const [y, m, d] = day.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);
}
// The WINDOW_DAYS dates ending on `today`, oldest first.
function windowDays(today) {
const days = [];
for (let i = WINDOW_DAYS - 1; i >= 0; i--) days.push(shiftDay(today, -i));
return days;
}
function isDay(value) {
if (typeof value !== "string" || !DAY_RE.test(value)) return false;
// Rejects 2026-02-30 and friends: a real date round-trips unchanged.
return shiftDay(value, 0) === value;
}
// --- Project mapping --------------------------------------------------------
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// Collapses repeated slashes and drops trailing ones, so "<home>/claude-code//x"
// cannot yield an empty project name.
function stripTrailingSlashes(p) {
const collapsed = p.replace(/\/{2,}/g, "/");
return collapsed.length > 1 ? collapsed.replace(/\/+$/, "") : collapsed;
}
// Project name from a transcript line's `cwd`, rules applied in order:
// 1. <home>/claude-code/<a>/... -> <a> (.spikes and .archive -> <a>/<b>)
// 2. <home>/claude-code exactly -> (monorepo)
// 3. /tmp/spike-defenseur-auto-* -> defenseurs
// 4. /tmp/claude-<uid>/<home-slug>-claude-code-<p>/... -> <p>
// 5. anything else -> (autre)
function mapProject(cwd, homedir) {
if (typeof cwd !== "string" || cwd === "") return UNKNOWN_PROJECT;
const dir = stripTrailingSlashes(cwd);
const home = stripTrailingSlashes(String(homedir || ""));
const monorepo = `${home}/claude-code`;
if (home && dir.startsWith(`${monorepo}/`)) {
const parts = dir.slice(monorepo.length + 1).split("/");
const a = parts[0];
if ((a === ".spikes" || a === ".archive") && parts[1]) {
return `${a}/${parts[1]}`.slice(0, MAX_STRING);
}
return a.slice(0, MAX_STRING);
}
if (home && dir === monorepo) return MONOREPO_PROJECT;
if (dir.startsWith("/tmp/spike-defenseur-auto-")) return DEFENSEUR_AUTO_PROJECT;
if (home) {
// Claude Code names its per-project temp dir after the cwd, slashes turned
// into dashes: /home/max/claude-code/foo -> -home-max-claude-code-foo.
const slug = `${home.replace(/\//g, "-")}-claude-code-`;
const re = new RegExp(`^/tmp/claude-\\d+/${escapeRe(slug)}([^/]+)(?:/|$)`);
const m = re.exec(dir);
if (m) return m[1].slice(0, MAX_STRING);
}
return UNKNOWN_PROJECT;
}
// --- Counters ---------------------------------------------------------------
function count(value) {
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
}
// Every map keyed by a name read from a transcript or the state file (model,
// project, day) has no prototype: a project named `__proto__` or
// `constructor` is then a plain key, never a write into Object.prototype.
function dict() {
return Object.create(null);
}
function emptyModel() {
return { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, requests: 0 };
}
function emptyProject() {
return { work: 0, cacheRead: 0, requests: 0 };
}
function addModel(target, src) {
target.input += count(src.input);
target.output += count(src.output);
target.cacheCreation += count(src.cacheCreation);
target.cacheRead += count(src.cacheRead);
target.requests += count(src.requests);
}
function addProject(target, src) {
target.work += count(src.work);
target.cacheRead += count(src.cacheRead);
target.requests += count(src.requests);
}
function modelWork(m) {
return m.input + m.output + m.cacheCreation;
}
// At most MAX_MODELS_PER_DAY keys: beyond that, the least consuming models
// (by work tokens, then cache reads, then name for a stable order) are merged
// into `other`. Idempotent: a day already capped comes back unchanged.
function capModels(models) {
const names = Object.keys(models).filter((k) => k !== OTHER_MODEL);
const hasOther = Object.prototype.hasOwnProperty.call(models, OTHER_MODEL);
if (names.length + (hasOther ? 1 : 0) <= MAX_MODELS_PER_DAY) return models;
names.sort((a, b) => {
const ma = models[a];
const mb = models[b];
return (
modelWork(mb) - modelWork(ma) ||
mb.cacheRead - ma.cacheRead ||
(a < b ? -1 : a > b ? 1 : 0)
);
});
const out = dict();
for (const name of names.slice(0, MAX_MODELS_PER_DAY - 1)) out[name] = models[name];
const other = emptyModel();
if (hasOther) addModel(other, models[OTHER_MODEL]);
for (const name of names.slice(MAX_MODELS_PER_DAY - 1)) addModel(other, models[name]);
out[OTHER_MODEL] = other;
return out;
}
// Frozen days come from the agent's state file: re-read through the same
// counters, never trusted verbatim.
function normalizeDay(day) {
const models = dict();
const projects = dict();
const srcModels = day && typeof day.models === "object" && day.models ? day.models : {};
const srcProjects =
day && typeof day.projects === "object" && day.projects ? day.projects : {};
for (const [name, m] of Object.entries(srcModels)) {
if (!m || typeof m !== "object") continue;
const key = MODEL_KEY_RE.test(name) ? name : OTHER_MODEL;
if (!models[key]) models[key] = emptyModel();
addModel(models[key], m);
}
for (const [name, p] of Object.entries(srcProjects)) {
if (!p || typeof p !== "object") continue;
const key = name.slice(0, MAX_STRING);
if (!projects[key]) projects[key] = emptyProject();
addProject(projects[key], p);
}
return { models, projects };
}
// --- Transcript scan --------------------------------------------------------
// Every *.jsonl under root, recursively (subagents/ included). Symlinks are
// neither followed nor read. A missing root is an empty scan, not an error;
// any other directory that cannot be listed is reported through onError.
async function listTranscripts(root, onError = () => {}) {
const out = [];
const stack = [root];
while (stack.length) {
const dir = stack.pop();
let entries;
try {
entries = await fs.promises.readdir(dir, { withFileTypes: true });
} catch (err) {
if (!(dir === root && err && err.code === "ENOENT")) onError();
continue;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) stack.push(full);
else if (e.isFile() && e.name.endsWith(".jsonl")) out.push(full);
}
}
out.sort();
return out;
}
// Calls onLine for each line of the file. Resolves once the file is consumed,
// with true when it was read to the end; a read error (file removed mid-run,
// permission) resolves with false: one unreadable transcript must not sink
// the whole snapshot, but the caller must know the scan is incomplete.
function readLines(file, openFile, onLine) {
return new Promise((resolve) => {
let stream;
try {
stream = openFile(file, { encoding: "utf-8" });
} catch {
resolve(false);
return;
}
let done = false;
let rl = null;
// The flag is set by the error handlers only, before finish(): neither a
// 'close' after an error nor the destroy() below can turn it back.
let failed = false;
const finish = () => {
if (done) return;
done = true;
if (rl) rl.close();
if (typeof stream.destroy === "function") stream.destroy();
resolve(!failed);
};
const fail = () => {
failed = true;
finish();
};
// Listen on both: readline forwards the input's error to the interface,
// and an unlistened 'error' there is an uncaught exception that kills the
// process (and the interface then never emits 'close').
stream.on("error", fail);
rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
rl.on("error", fail);
rl.on("line", onLine);
rl.on("close", finish);
});
}
/**
* Aggregate the transcripts under `root`.
*
* @param {object} opts
* @param {string} opts.root transcripts root (~/.claude/projects)
* @param {object} [opts.frozenDays] { "YYYY-MM-DD": { models, projects } } — never recomputed
* @param {string} [opts.today] Toronto date "YYYY-MM-DD" (default: now)
* @param {string} [opts.homedir] home used by the project mapping (default: os.homedir())
* @param {Function} [opts.openFile] (path, options) => Readable — injectable for tests
* @returns {Promise<{
* openDays: Object<string, { models: Object<string, {input, output, cacheCreation, cacheRead, requests}>,
* projects: Object<string, {work, cacheRead, requests}> }>,
* days: Array<{ date: string, models: Object<string, {input, output, cacheCreation, cacheRead, requests}> }>,
* projects: Array<{ name: string, work: number, cacheRead: number, requests: number }>,
* claudeCodeVersion: string | null,
* readErrors: number, unreadable directories, stats and transcripts; 0 on a complete scan
* }>}
*/
async function aggregate({
root,
frozenDays = {},
today = torontoDay(Date.now()),
homedir = os.homedir(),
openFile = (file, options) => fs.createReadStream(file, options),
} = {}) {
if (!isDay(today)) throw new Error("aggregate: today must be a YYYY-MM-DD date");
const frozen = dict();
if (frozenDays && typeof frozenDays === "object") {
for (const [day, value] of Object.entries(frozenDays)) {
if (isDay(day)) frozen[day] = value;
}
}
const window = windowDays(today);
const windowStart = window[0];
const openDayList = window.filter((d) => !Object.prototype.hasOwnProperty.call(frozen, d));
// Scan threshold: the oldest open day of the window. With the agent's
// contiguous frozen prefix this is the day after the most recent frozen day;
// computed from the open days rather than from the frozen keys, so a frozen
// day in the future or a gap in the frozen run can never push the threshold
// past an open day and silently empty it. A file last written before the
// window cannot hold a line inside it. Every day frozen: nothing to read.
const threshold = openDayList.length ? openDayList[0] : shiftDay(today, 1);
// (message.id, requestId) -> best line so far.
const requests = new Map();
let anonymous = 0;
let latest = null; // { ts, version }
const onLine = (line) => {
// Cheap prefilter: a useful line always carries "usage". Saves a
// JSON.parse on the user/tool lines, the bulk of the ~210 MB.
if (!line.includes('"usage"')) return;
let obj;
try {
obj = JSON.parse(line);
} catch {
return; // truncated or garbled line: skipped
}
if (!obj || obj.type !== "assistant") return;
const message = obj.message;
if (!message || typeof message !== "object") return;
const usage = message.usage;
if (!usage || typeof usage !== "object") return;
const ts = typeof obj.timestamp === "string" ? Date.parse(obj.timestamp) : NaN;
if (!Number.isFinite(ts)) return;
if (typeof obj.version === "string" && obj.version && (!latest || ts > latest.ts)) {
latest = { ts, version: obj.version.slice(0, MAX_STRING) };
}
const model = message.model;
if (model === SYNTHETIC_MODEL) return;
const day = torontoDay(ts);
if (day < windowStart || day > today) return;
// Frozen days are NOT filtered here: see FROZEN DAYS ARE DROPPED AFTER
// DEDUPLICATION in the header.
const entry = {
ts,
day,
model: typeof model === "string" && MODEL_KEY_RE.test(model) ? model : OTHER_MODEL,
project: mapProject(obj.cwd, homedir),
input: count(usage.input_tokens),
output: count(usage.output_tokens),
cacheCreation: count(usage.cache_creation_input_tokens),
cacheRead: count(usage.cache_read_input_tokens),
};
const id = typeof message.id === "string" ? message.id : "";
const reqId = typeof obj.requestId === "string" ? obj.requestId : "";
// A line without its identifiers cannot be matched with its duplicates:
// it counts as its own request rather than being dropped.
const key = id && reqId ? `${id}\u0000${reqId}` : `\u0001${anonymous++}`;
const prev = requests.get(key);
if (!prev || entry.output > prev.output || (entry.output === prev.output && entry.ts < prev.ts)) {
requests.set(key, entry);
}
};
let readErrors = 0;
const onError = () => {
readErrors += 1;
};
for (const file of await listTranscripts(root, onError)) {
let stat;
try {
stat = await fs.promises.stat(file);
} catch {
// ENOENT included: a transcript removed between the walk and the stat
// may have held lines of a day about to be frozen.
onError();
continue;
}
if (torontoDay(stat.mtimeMs) < threshold) continue;
if (!(await readLines(file, openFile, onLine))) onError();
}
// Open days: every non-frozen day of the window, empty ones included, so
// the caller can freeze them all (an unfrozen empty day would hold the
// threshold back and every run would re-read the whole window).
const openDays = dict();
for (const day of openDayList) openDays[day] = { models: dict(), projects: dict() };
for (const r of requests.values()) {
// A request whose best line sits on a frozen day was counted there.
if (Object.prototype.hasOwnProperty.call(frozen, r.day)) continue;
const bucket = openDays[r.day];
if (!bucket.models[r.model]) bucket.models[r.model] = emptyModel();
addModel(bucket.models[r.model], { ...r, requests: 1 });
if (!bucket.projects[r.project]) bucket.projects[r.project] = emptyProject();
addProject(bucket.projects[r.project], {
work: r.input + r.output + r.cacheCreation,
cacheRead: r.cacheRead,
requests: 1,
});
}
// Merged 30-day series and project totals over the window.
const days = [];
const projectTotals = dict();
for (const date of window) {
const source = Object.prototype.hasOwnProperty.call(frozen, date)
? normalizeDay(frozen[date])
: openDays[date];
days.push({ date, models: capModels(source.models) });
for (const [name, p] of Object.entries(source.projects)) {
if (!projectTotals[name]) projectTotals[name] = emptyProject();
addProject(projectTotals[name], p);
}
}
return {
openDays,
days,
projects: topProjects(projectTotals),
claudeCodeVersion: latest ? latest.version : null,
readErrors,
};
}
// Top TOP_PROJECTS by work tokens over the window, then one "(autres)" entry
// aggregating the rest when it has any volume. The sum of `work` over the
// returned list always equals the window total.
function topProjects(totals) {
const list = Object.entries(totals)
.map(([name, p]) => ({ name, work: p.work, cacheRead: p.cacheRead, requests: p.requests }))
.filter((p) => p.work > 0 || p.cacheRead > 0 || p.requests > 0)
.sort(
(a, b) =>
b.work - a.work ||
b.cacheRead - a.cacheRead ||
(a.name < b.name ? -1 : a.name > b.name ? 1 : 0),
);
const top = list.slice(0, TOP_PROJECTS);
const rest = list.slice(TOP_PROJECTS);
if (rest.length) {
const other = { name: OTHER_PROJECTS, work: 0, cacheRead: 0, requests: 0 };
for (const p of rest) addProject(other, p);
top.push(other);
}
return top;
}
module.exports = {
aggregate,
mapProject,
torontoDay,
shiftDay,
windowDays,
capModels,
topProjects,
listTranscripts,
TIME_ZONE,
WINDOW_DAYS,
TOP_PROJECTS,
MAX_MODELS_PER_DAY,
OTHER_MODEL,
OTHER_PROJECTS,
};

View file

@ -1,138 +0,0 @@
#!/usr/bin/env node
// One-shot backfill of the Claude usage history (issue #55).
//
// Run by hand from the repo, on the workstation that holds the transcripts.
// It is NOT copied into the cron install: the agent itself never changes.
//
// node agent/claude-usage/backfill-history.js --out <file> [--root <dir>] [--max-windows <n>]
//
// Rebuilds the closed days from ~/.claude/projects with the agent's own
// aggregate(), on past windows, and writes { days: [...] } to <file> in the
// archive's day format. The file then goes to the server and is merged with
// `node claude-usage-history.js --import`, where an already archived date
// always wins (see agent/README.md, "Backfilling the history").
//
// What it never does: read ~/.claude/.credentials.json, open a network
// connection, or write anything but <file>, which it refuses to overwrite.
// Output: counts only, never a path or a day's content.
//
// WINDOWS. aggregate() reads a 30-day window ending on `today`. Each window
// keeps only its 28 inner days and the next one steps back 28 days: every
// kept day D has D-1 and D+1 in the same window, so a request whose lines
// straddle a midnight is deduplicated by the window that keeps it, exactly as
// the agent does inside its own window, and is never counted on both sides of
// a window boundary. The first window keeps nothing after D-2, the last day
// the agent may have frozen: an open day never enters the archive this way.
//
// STOP. A fixed number of windows, --max-windows (default 24, about 22
// months), then the leading empty days are trimmed. No guess from file
// mtimes: an mtime is a file's LAST write, and a resumed session keeps lines
// far older than it, so "past the oldest mtime" is no proof of "past the
// oldest line". A full scan of ~600 MB takes a few seconds per window.
//
// Each window calls aggregate() with no frozen days, and only { date, models }
// of its days is kept: no project, no limit, nothing else of the snapshot.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { aggregate, torontoDay, shiftDay } = require("./aggregate.js");
const PREFIX = "claude-usage backfill:";
const KEPT_DAYS = 28;
const DEFAULT_MAX_WINDOWS = 24;
function isEmptyDay(day) {
return Object.keys(day.models).length === 0;
}
/**
* @param {object} opts
* @param {string} opts.root transcripts root
* @param {string} [opts.today] Toronto date "YYYY-MM-DD" (default: now)
* @param {number} [opts.maxWindows] hard cap on the number of windows
* @param {Function} [opts.openFile] passed through to aggregate(), for tests
* @returns {Promise<{ days: Array<{date, models}>, windows: number, readErrors: number }>}
*/
async function backfill({ root, today = torontoDay(Date.now()), maxWindows = DEFAULT_MAX_WINDOWS, openFile } = {}) {
const collected = [];
let readErrors = 0;
let windows = 0;
let keptEnd = shiftDay(today, -2);
while (windows < maxWindows) {
const result = await aggregate({
root,
frozenDays: {},
today: shiftDay(keptEnd, 1),
...(openFile ? { openFile } : {}),
});
windows += 1;
readErrors += result.readErrors;
// result.days: keptEnd-28 .. keptEnd+1; the inner 28 are kept.
const kept = result.days.slice(1, 1 + KEPT_DAYS);
collected.unshift(...kept);
keptEnd = shiftDay(kept[0].date, -1);
}
const first = collected.findIndex((day) => !isEmptyDay(day));
const days = first === -1 ? [] : collected.slice(first).map(({ date, models }) => ({ date, models }));
return { days, windows, readErrors };
}
function parseArgs(argv) {
const opts = { out: null, root: null, maxWindows: DEFAULT_MAX_WINDOWS };
for (let i = 0; i < argv.length; i += 2) {
const [flag, value] = [argv[i], argv[i + 1]];
if (value === undefined) return { error: `missing value after ${flag}` };
if (flag === "--out") opts.out = value;
else if (flag === "--root") opts.root = value;
else if (flag === "--max-windows") {
if (!/^[1-9]\d{0,2}$/.test(value)) return { error: "--max-windows must be a number from 1 to 999" };
opts.maxWindows = Number(value);
} else return { error: `unknown option ${flag}` };
}
if (!opts.out) return { error: "--out <file> is required" };
return { opts };
}
async function main(argv = process.argv.slice(2), env = process.env, io = { out: console.log, err: console.error }) {
const parsed = parseArgs(argv);
if (parsed.error) {
io.err(`${PREFIX} ${parsed.error}`);
io.err("usage: node agent/claude-usage/backfill-history.js --out <file> [--root <dir>] [--max-windows <n>]");
return 2;
}
const { out, maxWindows } = parsed.opts;
const root = parsed.opts.root || path.join(env.HOME || os.homedir(), ".claude", "projects");
const result = await backfill({ root, maxWindows });
try {
// wx: never overwrite, a second run must not clobber a file already sent.
fs.writeFileSync(out, `${JSON.stringify({ days: result.days })}\n`, { flag: "wx", mode: 0o600 });
} catch (err) {
io.err(`${PREFIX} output not written (code=${err.code || "write_error"})`);
return 1;
}
const span = result.days.length ? ` from ${result.days[0].date} to ${result.days.at(-1).date}` : "";
io.out(`${PREFIX} ${result.days.length} day(s)${span}, ${result.windows} window(s), read_errors=${result.readErrors}`);
if (result.readErrors > 0) {
io.err(`${PREFIX} warning: read_errors=${result.readErrors}, some days may be incomplete; rerun before importing`);
return 1;
}
return 0;
}
if (require.main === module) {
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
console.error(`${PREFIX} unexpected failure (code=${(err && err.code) || "error"})`);
process.exitCode = 1;
},
);
}
module.exports = { backfill, main, parseArgs, KEPT_DAYS, DEFAULT_MAX_WINDOWS };

View file

@ -1,290 +0,0 @@
// Claude plan limits for the claude-usage agent (issue #50).
//
// One GET to Anthropic's (undocumented) OAuth usage endpoint, with the access
// token Claude Code already stored in ~/.claude/.credentials.json. Returns the
// `limits` block of the POST /claude-usage contract (schemaVersion 1), minus
// the "last good values" fallback, which state.js owns.
//
// Properties that are load-bearing and easy to break:
//
// * THE CREDENTIALS FILE IS READ, NEVER WRITTEN. Refresh tokens are single
// use: refreshing here would log Claude Code out. An expired token means
// `token_expired` and no call at all. The file is only ever passed to
// fs.readFileSync (no flag), and __tests__/agent-claude-usage.test.js
// spies on every writing function of node:fs to prove it.
// * THE BEARER ONLY GOES TO USAGE_URL. The same process also holds a
// configurable push URL; the OAuth token must never follow it. The URL is
// a constant (no environment variable changes it) and redirects are
// refused (`redirect: "error"`), so the token cannot be bounced elsewhere.
// * `detail` IS AN ENUM. It comes from DETAILS below and nothing else, never
// from an error message or a response body: both can carry upstream text,
// and this value ends up in the admin dashboard.
// * ONE CALL, NO RETRY. The endpoint rate-limits hard and sends no
// Retry-After; a failed call waits for the next cron beat (15 min).
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
const ANTHROPIC_BETA = "oauth-2025-04-20";
const LIMITS_TIMEOUT_MS = 10000;
// Far beyond the ~2 KB the endpoint answers. Counted in bytes while reading:
// past it the stream is cancelled, neither read further nor parsed (#54).
const MAX_RESPONSE_BYTES = 256 * 1024;
const MAX_ITEMS = 12;
const MAX_STRING = 128;
const DETAILS = {
NO_CREDENTIALS: "no_credentials",
PARSE_ERROR: "parse_error",
NO_VERSION: "no_version",
TIMEOUT: "timeout",
NETWORK: "network",
BAD_RESPONSE: "bad_response",
};
// Same shape as the server sanitizer's timestamp check: a value outside it
// would be dropped there, so it is normalized (or nulled) here instead.
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
function defaultCredentialsPath(home = os.homedir()) {
return path.join(home, ".claude", ".credentials.json");
}
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function failure(status, detail) {
const out = { status, fetchedAt: null, items: [] };
if (detail) out.detail = detail;
return out;
}
function cleanString(value) {
return typeof value === "string" ? value.slice(0, MAX_STRING) : null;
}
// The contract wants an integer 0-100. Upstream sends a number that can be
// fractional (utilization) and could overshoot 100 once a limit is exceeded.
function cleanPercent(value) {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
return Math.min(100, Math.max(0, Math.round(value)));
}
// ISO string kept as sent when it already has the contract's shape; any other
// parseable string, or an epoch number (seconds or milliseconds), is
// re-rendered with toISOString; anything else is null.
function cleanResetsAt(value) {
if (typeof value === "string") {
if (value.length <= MAX_STRING && ISO_TIMESTAMP_RE.test(value) && !Number.isNaN(Date.parse(value))) {
return value;
}
const ms = Date.parse(value);
return Number.isNaN(ms) ? null : new Date(ms).toISOString();
}
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
const ms = value < 1e11 ? value * 1000 : value;
const date = new Date(ms);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
return null;
}
function item({ kind, group, percent, severity, resetsAt, model }) {
const out = { kind, group };
if (percent !== undefined) out.percent = percent;
if (typeof severity === "string") out.severity = severity;
out.resetsAt = resetsAt;
out.model = model;
return out;
}
// `limits[]` entries: { kind, group, percent, severity, resets_at,
// scope: { model: { display_name } } | null, is_active }. Built field by field,
// so no upstream key reaches the snapshot unless it is listed here.
function itemsFromLimits(list) {
const items = [];
for (const entry of list) {
if (items.length >= MAX_ITEMS) break;
if (!isPlainObject(entry) || typeof entry.kind !== "string" || !entry.kind) continue;
const scope = isPlainObject(entry.scope) ? entry.scope : null;
const model = scope && isPlainObject(scope.model) ? cleanString(scope.model.display_name) : null;
items.push(
item({
kind: entry.kind.slice(0, MAX_STRING),
group: typeof entry.group === "string" ? entry.group.slice(0, MAX_STRING) : entry.kind.slice(0, MAX_STRING),
percent: cleanPercent(entry.percent),
severity: cleanString(entry.severity),
resetsAt: cleanResetsAt(entry.resets_at),
model,
}),
);
}
return items;
}
// Older shape: five_hour / seven_day { utilization, resets_at }.
function itemsFromWindows(body) {
const items = [];
const windows = [
["five_hour", "session", "session"],
["seven_day", "weekly_all", "weekly"],
];
for (const [key, kind, group] of windows) {
const w = body[key];
if (!isPlainObject(w)) continue;
const percent = cleanPercent(w.utilization);
if (percent === undefined) continue;
items.push(item({ kind, group, percent, severity: null, resetsAt: cleanResetsAt(w.resets_at), model: null }));
}
return items;
}
function parseUsage(body) {
if (!isPlainObject(body)) return [];
if (Array.isArray(body.limits)) {
const items = itemsFromLimits(body.limits);
if (items.length) return items;
}
return itemsFromWindows(body);
}
// Read the OAuth credentials. Never throws, never logs the content.
function readCredentials(credentialsPath, readFile) {
let raw;
try {
raw = readFile(credentialsPath);
} catch (err) {
if (err && err.code === "ENOENT") return { detail: DETAILS.NO_CREDENTIALS };
return { detail: DETAILS.PARSE_ERROR };
}
let parsed;
try {
// Claude Code rewrites this file in place: a half-written JSON is a normal
// occurrence, and the next beat will read the finished one.
parsed = JSON.parse(String(raw));
} catch {
return { detail: DETAILS.PARSE_ERROR };
}
const oauth = isPlainObject(parsed) && isPlainObject(parsed.claudeAiOauth) ? parsed.claudeAiOauth : null;
if (!oauth || typeof oauth.accessToken !== "string" || !oauth.accessToken) {
return { detail: DETAILS.NO_CREDENTIALS };
}
return { accessToken: oauth.accessToken, expiresAt: oauth.expiresAt };
}
async function readBody(res) {
if (!res.body || typeof res.body.getReader !== "function") throw new Error("no body");
const reader = res.body.getReader();
const chunks = [];
let total = 0;
for (;;) {
// A timeout surfaces here as a TimeoutError/AbortError, mapped by the caller.
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_RESPONSE_BYTES) {
reader.cancel().catch(() => {});
throw new Error("too large");
}
chunks.push(value);
}
return JSON.parse(Buffer.concat(chunks).toString("utf-8"));
}
function discardBody(res) {
try {
if (res && res.body && typeof res.body.cancel === "function") res.body.cancel().catch(() => {});
} catch {
// nothing to release
}
}
/**
* Fetch the plan limits once.
*
* @param {object} opts
* @param {object} [opts.env] process environment (only CLAUDE_USAGE_LIMITS is read)
* @param {string} [opts.credentialsPath] ~/.claude/.credentials.json by default
* @param {Function} [opts.readFile] (path) => string, read-only — injectable for tests
* @param {Function} [opts.fetch] fetch implementation — injectable for tests
* @param {string|null} opts.claudeCodeVersion version for the User-Agent (from the transcripts)
* @param {number} [opts.now] epoch ms
* @param {number} [opts.timeoutMs]
* @returns {Promise<{status: string, detail?: string, fetchedAt: string|null, items: object[]}>}
*/
async function fetchLimits({
env = process.env,
credentialsPath = defaultCredentialsPath(),
readFile = (file) => fs.readFileSync(file, "utf-8"),
fetch: fetchImpl = globalThis.fetch,
claudeCodeVersion = null,
now = Date.now(),
timeoutMs = LIMITS_TIMEOUT_MS,
} = {}) {
if (String(env.CLAUDE_USAGE_LIMITS || "").trim().toLowerCase() === "off") {
return failure("disabled");
}
const creds = readCredentials(credentialsPath, readFile);
if (creds.detail) return failure("unavailable", creds.detail);
// Never refreshed: the refresh token is single use and belongs to Claude
// Code. A missing expiresAt is left to the server to judge (a 401 then).
if (typeof creds.expiresAt === "number" && Number.isFinite(creds.expiresAt) && creds.expiresAt <= now) {
return failure("token_expired");
}
if (typeof claudeCodeVersion !== "string" || !claudeCodeVersion) {
return failure("unavailable", DETAILS.NO_VERSION);
}
let res;
try {
res = await fetchImpl(USAGE_URL, {
method: "GET",
headers: {
Authorization: `Bearer ${creds.accessToken}`,
"anthropic-beta": ANTHROPIC_BETA,
"User-Agent": `claude-code/${claudeCodeVersion}`,
Accept: "application/json",
},
redirect: "error",
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const name = err && err.name;
return failure("unavailable", name === "TimeoutError" || name === "AbortError" ? DETAILS.TIMEOUT : DETAILS.NETWORK);
}
const status = res && Number.isInteger(res.status) ? res.status : 0;
if (status < 200 || status > 299) {
discardBody(res);
return failure("http_error", status >= 100 && status <= 599 ? `http_${status}` : DETAILS.BAD_RESPONSE);
}
let body;
try {
body = await readBody(res);
} catch (err) {
const name = err && err.name;
if (name === "TimeoutError" || name === "AbortError") return failure("unavailable", DETAILS.TIMEOUT);
return failure("unavailable", DETAILS.BAD_RESPONSE);
}
const items = parseUsage(body);
if (!items.length) return failure("unavailable", DETAILS.BAD_RESPONSE);
return { status: "ok", fetchedAt: new Date(now).toISOString(), items };
}
module.exports = {
fetchLimits,
parseUsage,
defaultCredentialsPath,
USAGE_URL,
ANTHROPIC_BETA,
LIMITS_TIMEOUT_MS,
DETAILS,
};

View file

@ -1,244 +0,0 @@
#!/usr/bin/env node
// claude-usage agent entry point (issue #50). Runs from the ThinkPad's
// crontab every 15 minutes through run-claude-usage.sh:
//
// 1. aggregate the local transcripts (aggregate.js), frozen days excluded;
// 2. read the plan limits once (limits.js);
// 3. update and write the local cache (state.js) BEFORE pushing, so a failed
// push never costs the next beat a full rescan;
// 4. assemble the schemaVersion 1 snapshot, measure it, push it once.
//
// Zero runtime dependencies (Node 22 global fetch). Same two rules as
// agent/push-metrics.js, for the same reason — the output goes to
// `logger -t claude-usage`, so into syslog and journald for good:
//
// * NO QUEUE, NO REPLAY. One beat = one attempt; a failure exits non-zero.
// * THE OUTPUT NEVER CARRIES A SECRET. Neither the ingest token nor the
// OAuth token is printed. Failures are rebuilt from fixed templates plus an
// HTTP status or a sanitized error code — never an error object, a message
// from the HTTP stack (undici quotes an invalid header value in it), the
// request options or a response body.
const os = require("node:os");
const path = require("node:path");
const { aggregate, torontoDay, TIME_ZONE } = require("./aggregate.js");
const { fetchLimits, defaultCredentialsPath } = require("./limits.js");
const {
loadState,
saveState,
nextState,
limitsForSnapshot,
defaultStateDir,
stateFilePath,
} = require("./state.js");
const SCHEMA_VERSION = 1;
// Same ceiling as the server (CLAUDE_USAGE_MAX_BODY_BYTES): a larger body
// would be refused with a 413, so it is not even sent.
const MAX_BODY_BYTES = 65536;
const PUSH_TIMEOUT_MS = 10000;
const EXIT_OK = 0;
const EXIT_CONFIG = 1;
const EXIT_PUSH = 2;
const PREFIX = "claude-usage:";
// A token is printable ASCII without spaces. Checked up front: fetch rejects
// an invalid header value with a message that QUOTES it.
const TOKEN_RE = /^[\x21-\x7e]+$/;
// What may be printed of an error: a system or undici code (ECONNREFUSED,
// UND_ERR_CONNECT_TIMEOUT: upper case only) or an error class name
// (TimeoutError, TypeError). Anything else, whatever field it came from, is
// replaced by UNKNOWN rather than trusted.
const ERROR_CODE_RE = /^[A-Z][A-Z0-9_]{1,39}$/;
const ERROR_NAME_RE = /^[A-Z][A-Za-z]{0,30}Error$/;
function safeCode(err) {
const codes = [err && err.cause && err.cause.code, err && err.code];
for (const c of codes) {
if (typeof c === "string" && ERROR_CODE_RE.test(c)) return c;
}
const name = err && err.name;
return typeof name === "string" && ERROR_NAME_RE.test(name) ? name : "UNKNOWN";
}
function readConfig(env) {
const apiUrl = String(env.CLAUDE_USAGE_API_URL || "").trim();
const token = env.CLAUDE_USAGE_INGEST_TOKEN || "";
if (!apiUrl) return { error: "CLAUDE_USAGE_API_URL is not set" };
if (!token) return { error: "CLAUDE_USAGE_INGEST_TOKEN is not set" };
if (!TOKEN_RE.test(token)) {
return {
error:
"CLAUDE_USAGE_INGEST_TOKEN contains an invalid character (a trailing newline or space in the env file is the usual cause)",
};
}
let target;
try {
target = new URL(`${apiUrl.replace(/\/+$/, "")}/claude-usage`);
} catch {
return { error: "CLAUDE_USAGE_API_URL is not a valid URL" };
}
// The ingest token travels in this request: https only, no exception.
if (target.protocol !== "https:") return { error: "CLAUDE_USAGE_API_URL must be https://" };
return { config: { target: target.href, token } };
}
function buildSnapshot({ now, aggregated, limits }) {
const snapshot = {
schemaVersion: SCHEMA_VERSION,
generatedAt: new Date(now).toISOString(),
};
// Left out rather than null: the contract types it as a string, and the
// server drops a null there anyway.
if (aggregated.claudeCodeVersion) snapshot.claudeCodeVersion = aggregated.claudeCodeVersion;
snapshot.timeZone = TIME_ZONE;
snapshot.limits = limits;
snapshot.days = aggregated.days;
snapshot.projects = aggregated.projects;
return snapshot;
}
async function pushSnapshot({ target, token, body, fetchImpl }) {
let res;
try {
res = await fetchImpl(target, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body,
redirect: "error",
signal: AbortSignal.timeout(PUSH_TIMEOUT_MS),
});
} catch (err) {
return { error: `push failed: request error (code=${safeCode(err)})` };
}
// The response body is never read, never logged.
try {
if (res.body && typeof res.body.cancel === "function") res.body.cancel().catch(() => {});
} catch {
// nothing to release
}
const status = Number.isInteger(res.status) ? res.status : 0;
if (status >= 200 && status <= 299) return { status };
return { error: `push failed: HTTP ${status}` };
}
/**
* @param {string[]} argv
* @param {object} env
* @param {object} deps injectable for tests: home, fetch, now, readFile, openFile, log, logError
* @returns {Promise<number>} exit code
*/
async function main(argv = process.argv.slice(2), env = process.env, deps = {}) {
const home = deps.home || os.homedir();
const fetchImpl = deps.fetch || globalThis.fetch;
const now = deps.now !== undefined ? deps.now : Date.now();
const log = deps.log || ((line) => console.log(line));
const logError = deps.logError || ((line) => console.error(line));
const dryRun = argv.includes("--dry-run");
// Configuration first: a full scan costs seconds, no point spending them to
// then discover a typo in the env file. A dry run needs no configuration.
let config = null;
if (!dryRun) {
const result = readConfig(env);
if (result.error) {
logError(`${PREFIX} configuration error: ${result.error}`);
return EXIT_CONFIG;
}
config = result.config;
}
const stateFile = stateFilePath(deps.stateDir || defaultStateDir(home));
const state = loadState(stateFile);
const today = torontoDay(now);
const aggregated = await aggregate({
root: deps.transcriptsRoot || path.join(home, ".claude", "projects"),
frozenDays: state.frozenDays,
today,
homedir: home,
...(deps.openFile ? { openFile: deps.openFile } : {}),
});
const limits = await fetchLimits({
env,
credentialsPath: deps.credentialsPath || defaultCredentialsPath(home),
...(deps.readFile ? { readFile: deps.readFile } : {}),
fetch: fetchImpl,
claudeCodeVersion: aggregated.claudeCodeVersion,
now,
});
// An incomplete scan freezes nothing. Only a count is printed: a path
// would put a project name in syslog for good.
const complete = aggregated.readErrors === 0;
if (!complete) {
logError(`${PREFIX} warning: read_errors=${aggregated.readErrors}, no day frozen this beat`);
}
const updated = nextState(state, { openDays: aggregated.openDays, today, limits, freeze: complete });
// Cache first. A dry run is read-only: it shows what would be sent and
// leaves the cache exactly as the cron left it.
if (!dryRun) {
try {
saveState(stateFile, updated);
} catch (err) {
// Not fatal: the next beat rescans, which is slow but correct.
logError(`${PREFIX} warning: state not saved (code=${safeCode(err)})`);
}
}
const snapshot = buildSnapshot({ now, aggregated, limits: limitsForSnapshot(limits, updated) });
const body = JSON.stringify(snapshot);
const bytes = Buffer.byteLength(body, "utf-8");
if (dryRun) log(JSON.stringify(snapshot, null, 2));
if (bytes > MAX_BODY_BYTES) {
logError(`${PREFIX} payload_too_large bytes=${bytes} max=${MAX_BODY_BYTES}`);
return EXIT_PUSH;
}
if (dryRun) return EXIT_OK;
const pushed = await pushSnapshot({ target: config.target, token: config.token, body, fetchImpl });
const summary = `limits=${snapshot.limits.status}${snapshot.limits.detail ? ` detail=${snapshot.limits.detail}` : ""} bytes=${bytes}`;
if (pushed.error) {
logError(`${PREFIX} ${pushed.error} ${summary}`);
return EXIT_PUSH;
}
log(`${PREFIX} ok status=${pushed.status} ${summary}`);
return EXIT_OK;
}
if (require.main === module) {
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
// Nothing should land here. The code only, never the object or its
// message: either could carry a request option.
console.error(`${PREFIX} unexpected failure (code=${safeCode(err)})`);
process.exitCode = EXIT_PUSH;
},
);
}
module.exports = {
main,
readConfig,
buildSnapshot,
pushSnapshot,
MAX_BODY_BYTES,
PUSH_TIMEOUT_MS,
EXIT_OK,
EXIT_CONFIG,
EXIT_PUSH,
};

View file

@ -1,85 +0,0 @@
#!/bin/sh
#
# Cron wrapper for push-claude-usage.js: take the lock, load the secrets, check
# the install, hand over to node. Every fifteen minutes, unattended.
#
# NEVER add `set -x`. This script is meant to be piped into `logger`, and the
# shell would echo the `. "$ENV_FILE"` expansion — the ingest token — straight
# into /var/log/syslog and journald, where it would stay. Same reason nothing
# below ever echoes a variable that holds a secret: the checks print the NAME of
# what is missing, never its value.
#
# Suggested crontab line (see agent/README.md):
# */15 * * * * $HOME/.local/share/maximus-claude-usage/run-claude-usage.sh 2>&1 | logger -t claude-usage
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
AGENT="$SCRIPT_DIR/push-claude-usage.js"
# Overridable for testing only; cron uses the default.
ENV_FILE="${CLAUDE_USAGE_ENV_FILE:-$HOME/.config/maximus-claude-usage.env}"
STATE_DIR="$HOME/.local/state/maximus-claude-usage"
LOCK_FILE="$STATE_DIR/run.lock"
if ! command -v flock >/dev/null 2>&1; then
echo "claude-usage: flock not found (util-linux)" >&2
exit 1
fi
mkdir -p "$STATE_DIR"
chmod 700 "$STATE_DIR"
# One run at a time. The first full scan of ~210 MB of transcripts can outlast
# the 15-minute beat; a second run would only redo the same work and race the
# state file. fd 9 stays open through `exec`, so node holds the lock until it
# exits. A beat that finds the lock taken is not an error: exit 0.
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "claude-usage: a run is already in progress, skipping"
exit 0
fi
if [ ! -f "$ENV_FILE" ]; then
echo "claude-usage: env file not found: $ENV_FILE" >&2
exit 1
fi
# A warning, not a refusal: a wrong mode bit is worth shouting about, but it is
# not worth silencing the whole agent over.
FILE_MODE=$(stat -c "%a" "$ENV_FILE" 2>/dev/null || echo "")
if [ -n "$FILE_MODE" ] && [ "$FILE_MODE" != "600" ]; then
echo "claude-usage: warning: $ENV_FILE is mode $FILE_MODE, expected 600" >&2
fi
# shellcheck source=/dev/null
. "$ENV_FILE"
if [ -z "${CLAUDE_USAGE_API_URL:-}" ]; then
echo "claude-usage: CLAUDE_USAGE_API_URL is not set in $ENV_FILE" >&2
exit 1
fi
if [ -z "${CLAUDE_USAGE_INGEST_TOKEN:-}" ]; then
echo "claude-usage: CLAUDE_USAGE_INGEST_TOKEN is not set in $ENV_FILE" >&2
exit 1
fi
if [ ! -f "$AGENT" ]; then
echo "claude-usage: agent not found: $AGENT" >&2
exit 1
fi
# cron's PATH is famously short (/usr/bin:/bin), and a node installed through
# nvm or /usr/local lives outside it. Set NODE_BIN in the env file when
# `command -v node` comes up empty under cron but works in a login shell.
NODE_BIN="${NODE_BIN:-node}"
if ! command -v "$NODE_BIN" >/dev/null 2>&1; then
echo "claude-usage: node not found (NODE_BIN=$NODE_BIN); set NODE_BIN in $ENV_FILE" >&2
exit 1
fi
# CLAUDE_USAGE_LIMITS is optional (`off` skips the Anthropic call). Exported
# too, otherwise setting it in the env file silently does nothing.
export CLAUDE_USAGE_API_URL CLAUDE_USAGE_INGEST_TOKEN CLAUDE_USAGE_LIMITS
exec "$NODE_BIN" "$AGENT" "$@"

View file

@ -1,167 +0,0 @@
// Local cache of the claude-usage agent (issue #50):
// ~/.local/state/maximus-claude-usage/state.json.
//
// Two things live here, both rebuilt from scratch when the file is lost:
//
// * frozenDays — every Toronto day strictly before yesterday, empty days
// included, as { models, projects } (the shape of an aggregate() open
// day). A frozen day is never recomputed, and aggregate() skips every
// transcript last written before the day after the newest frozen day: this
// is what keeps a beat from re-reading the whole 30-day window. An empty
// day left unfrozen would hold that threshold back forever.
// Yesterday stays open: a session running past midnight still writes
// yesterday's lines into a transcript touched today.
// * lastGoodLimits — the last `ok` limits with their fetchedAt, served when
// the current call fails, so the dashboard shows the last known values and
// how old they are instead of nothing.
//
// The file holds no secret. It is still written mode 600, atomically (temp file
// suffixed with the pid in the same directory, then rename), so a concurrent
// reader sees the old state or the new one, never a truncated JSON.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { shiftDay } = require("./aggregate.js");
const STATE_VERSION = 1;
const RETENTION_DAYS = 35;
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
function defaultStateDir(home = os.homedir()) {
return path.join(home, ".local", "state", "maximus-claude-usage");
}
function stateFilePath(stateDir) {
return path.join(stateDir, "state.json");
}
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isDay(value) {
return typeof value === "string" && DAY_RE.test(value) && shiftDay(value, 0) === value;
}
function emptyState() {
return { version: STATE_VERSION, frozenDays: {}, lastGoodLimits: null };
}
// Any problem — missing file, unreadable, truncated JSON, wrong shape — gives
// the empty state: the beat then does a full scan and rebuilds the cache.
// Entries are kept only in their expected shape; their counters are re-read
// defensively by aggregate() anyway.
function loadState(file, { readFile = (f) => fs.readFileSync(f, "utf-8") } = {}) {
let parsed;
try {
parsed = JSON.parse(String(readFile(file)));
} catch {
return emptyState();
}
if (!isPlainObject(parsed)) return emptyState();
const state = emptyState();
if (isPlainObject(parsed.frozenDays)) {
for (const [day, value] of Object.entries(parsed.frozenDays)) {
if (!isDay(day) || !isPlainObject(value)) continue;
state.frozenDays[day] = {
models: isPlainObject(value.models) ? value.models : {},
projects: isPlainObject(value.projects) ? value.projects : {},
};
}
}
const last = parsed.lastGoodLimits;
if (
isPlainObject(last) &&
typeof last.fetchedAt === "string" &&
!Number.isNaN(Date.parse(last.fetchedAt)) &&
Array.isArray(last.items)
) {
state.lastGoodLimits = { fetchedAt: last.fetchedAt, items: last.items.filter(isPlainObject) };
}
return state;
}
/**
* Next state after one beat. Pure.
*
* @param {object} state current state (loadState)
* @param {object} opts
* @param {object} opts.openDays aggregate().openDays
* @param {string} opts.today Toronto "YYYY-MM-DD"
* @param {object} opts.limits fetchLimits() result
* @param {boolean} [opts.freeze] false: freeze no new day (incomplete scan)
*/
function nextState(state, { openDays = {}, today, limits = null, freeze = true }) {
const yesterday = shiftDay(today, -1);
const oldest = shiftDay(today, -RETENTION_DAYS);
const frozenDays = {};
for (const [day, value] of Object.entries(state.frozenDays || {})) {
if (day >= oldest) frozenDays[day] = value;
}
// freeze: false after a scan that hit a read error (#54): a day frozen
// with lines missing would stay wrong for RETENTION_DAYS. The next clean
// beat freezes it instead.
for (const [day, value] of Object.entries(freeze ? openDays || {} : {})) {
if (!isDay(day) || day >= yesterday || day < oldest) continue;
frozenDays[day] = { models: value.models || {}, projects: value.projects || {} };
}
const lastGoodLimits =
limits && limits.status === "ok"
? { fetchedAt: limits.fetchedAt, items: limits.items }
: state.lastGoodLimits || null;
return { version: STATE_VERSION, frozenDays, lastGoodLimits };
}
// The `limits` block of the snapshot: the current result when it is `ok`,
// otherwise its status and detail with the last good items and their date.
function limitsForSnapshot(limits, state) {
if (limits.status === "ok") {
return { status: "ok", fetchedAt: limits.fetchedAt, items: limits.items };
}
const out = { status: limits.status };
if (limits.detail) out.detail = limits.detail;
const last = state && state.lastGoodLimits;
out.fetchedAt = last ? last.fetchedAt : null;
out.items = last ? last.items : [];
return out;
}
// Atomic write: temp file in the same directory (rename is only atomic within
// one file system), mode 600, then rename over the old file.
function saveState(file, state) {
const dir = path.dirname(file);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const tmp = `${file}.${process.pid}.tmp`;
try {
fs.writeFileSync(tmp, `${JSON.stringify(state)}\n`, { mode: 0o600 });
// writeFileSync only applies `mode` when it creates the file: a temp file
// left behind by a killed run would keep its old bits otherwise.
fs.chmodSync(tmp, 0o600);
fs.renameSync(tmp, file);
} catch (err) {
try {
fs.unlinkSync(tmp);
} catch {
// already gone
}
throw err;
}
}
module.exports = {
loadState,
saveState,
nextState,
limitsForSnapshot,
emptyState,
defaultStateDir,
stateFilePath,
RETENTION_DAYS,
STATE_VERSION,
};

View file

@ -1,15 +0,0 @@
# Daily pull of the encrypted PostgreSQL backups (la-compagnie-maximus#220).
# Installed as a systemd *user* unit, see agent/README.md.
[Unit]
Description=Pull the encrypted PostgreSQL backups from the VPS
# A run caught up at boot may start before Tailscale is up: retry a few times.
StartLimitIntervalSec=4h
StartLimitBurst=8
[Service]
Type=oneshot
ExecStart=%h/.local/share/maximus-pg-backup-pull/run-pg-backup-pull.sh
SyslogIdentifier=pg-backup-pull
Nice=10
Restart=on-failure
RestartSec=15min

View file

@ -1,13 +0,0 @@
# Daily, after the VPS run (03:00 UTC). Persistent=true catches up a run missed
# while the workstation was off, as soon as it is back (needs `loginctl
# enable-linger` for the user manager to run without an open session).
[Unit]
Description=Daily pull of the encrypted PostgreSQL backups
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=15min
[Install]
WantedBy=timers.target

View file

@ -1,225 +0,0 @@
#!/bin/sh
#
# Daily pull of the encrypted PostgreSQL backups from the VPS onto this
# workstation (la-compagnie-maximus#220). Run by the systemd user timer
# maximus-pg-backup-pull.timer, unattended.
#
# The VPS (backup-postgres.sh, 03:00 UTC) age-encrypts one copy of every dump
# into /home/pgbackup/outbox/<date>/. This script fetches that outbox over the
# classic sshd on 100.86.30.21:2222, where the key can only run `rrsync -ro`.
# It never decrypts anything: the age private key is not on this machine.
#
# NEVER add `set -x`. The output goes to journald, and the shell would echo the
# `. "$ENV_FILE"` expansion — the ingest token — into it for good. Nothing below
# ever echoes a variable that holds a secret.
set -eu
# Overridable for testing only; the timer uses the defaults.
ENV_FILE="${PG_BACKUP_PULL_ENV_FILE:-$HOME/.config/maximus-pg-backup-pull.env}"
DEST="${PG_BACKUP_PULL_DEST:-$HOME/backups/postgres}"
STATE_DIR="${PG_BACKUP_PULL_STATE_DIR:-$HOME/.local/state/maximus-pg-backup-pull}"
SSH_KEY="${PG_BACKUP_PULL_SSH_KEY:-$HOME/.ssh/pgbackup_pull_ed25519}"
KNOWN_HOSTS="${PG_BACKUP_PULL_KNOWN_HOSTS:-$HOME/.ssh/known_hosts_pgbackup}"
TODAY="${PG_BACKUP_PULL_TODAY:-$(date -u +%F)}"
SOURCE="pgbackup@100.86.30.21:"
SSH_PORT=2222
RETENTION_DAYS=30
RSYNC_TIMEOUT_SECONDS=900
POST_TIMEOUT_SECONDS=20
EXIT_INSTALL=1
EXIT_PULL=2
EXIT_REPORT=3
log() { echo "pg-backup-pull: $*"; }
for bin in rsync ssh flock timeout; do
if ! command -v "$bin" >/dev/null 2>&1; then
log "$bin not found"
exit "$EXIT_INSTALL"
fi
done
for file in "$SSH_KEY" "$KNOWN_HOSTS"; do
if [ ! -f "$file" ]; then
log "missing $file"
exit "$EXIT_INSTALL"
fi
done
mkdir -p "$STATE_DIR" "$DEST"
chmod 700 "$STATE_DIR" "$DEST"
exec 9>"$STATE_DIR/run.lock"
if ! flock -n 9; then
log "a run is already in progress, skipping"
exit 0
fi
# The ingest token sits in this file for the length of one curl call. The trap
# removes it on any exit, a kill by systemd included: the signal traps turn
# TERM/INT/HUP into a plain exit, which runs the EXIT trap (dash does not run
# it on a signal by itself).
header_file="$STATE_DIR/auth.header"
trap 'rm -f "$header_file"' EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
# The env file is optional until the ingest route is deployed: without a token,
# the pull still runs and only the report to the health API is skipped.
BACKUPS_API_URL=""
BACKUPS_INGEST_TOKEN=""
if [ -f "$ENV_FILE" ]; then
FILE_MODE=$(stat -c "%a" "$ENV_FILE" 2>/dev/null || echo "")
if [ -n "$FILE_MODE" ] && [ "$FILE_MODE" != "600" ]; then
log "warning: $ENV_FILE is mode $FILE_MODE, expected 600"
fi
# shellcheck source=/dev/null
. "$ENV_FILE"
fi
report() {
# report <ok:true|false> <files> [error-code]
if [ -z "$BACKUPS_INGEST_TOKEN" ] || [ -z "$BACKUPS_API_URL" ]; then
log "no BACKUPS_INGEST_TOKEN/BACKUPS_API_URL in $ENV_FILE, report skipped"
return 0
fi
if ! command -v curl >/dev/null 2>&1; then
log "curl not found, report skipped"
return 1
fi
pulled_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
if [ -n "${3:-}" ]; then
body=$(printf '{"pulledAt":"%s","files":%s,"ok":%s,"error":"%s"}' "$pulled_at" "$2" "$1" "$3")
else
body=$(printf '{"pulledAt":"%s","files":%s,"ok":%s}' "$pulled_at" "$2" "$1")
fi
# The token goes through a header file, never through argv (visible in ps).
(umask 077 && printf 'Authorization: Bearer %s\n' "$BACKUPS_INGEST_TOKEN" > "$header_file")
status=$(printf '%s' "$body" | curl -sS -o /dev/null -w '%{http_code}' \
--max-time "$POST_TIMEOUT_SECONDS" \
-H @"$header_file" -H 'Content-Type: application/json' \
--data-binary @- "$BACKUPS_API_URL/backups/pull" 2>/dev/null) || status="000"
rm -f "$header_file"
case "$status" in
2??) log "report ok status=$status"; return 0 ;;
*) log "report failed status=$status"; return 1 ;;
esac
}
# --- Pull ---------------------------------------------------------------------
# Only directories and .age files cross: a misconfigured outbox can never land
# a clear dump here. No --delete: the VPS keeps 7 days, this machine keeps 30.
# --prune-empty-dirs: a dated directory with no .age file (encryption failed on
# the VPS) is not created here, so it cannot pass for today's backup.
# --ignore-existing: a file already pulled is never rewritten, whatever the VPS
# serves under its name later. The VPS writes each dump to a .tmp and renames
# it (the .tmp never passes the filter), so what lands here is already whole.
if ! timeout "$RSYNC_TIMEOUT_SECONDS" rsync -rt --prune-empty-dirs --ignore-existing --partial-dir=.rsync-partial \
--include='*/' --include='*.age' --exclude='*' \
-e "ssh -p $SSH_PORT -i $SSH_KEY -o IdentitiesOnly=yes -o UserKnownHostsFile=$KNOWN_HOSTS -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
"$SOURCE" "$DEST/"; then
log "pull failed"
report false 0 pull_failed || true
exit "$EXIT_PULL"
fi
chmod -R go-rwx "$DEST"
# Nothing the VPS sends may delete or alter a copy already here: directory
# names come from the outbox, so they are checked before they steer anything.
#
# Plain shell over the glob, no `find -printf` nor `date -d "... days"`: both
# are GNU-only, and the CI runs this script under busybox (node:22-alpine).
#
# A name is dated only if `date` gives it back unchanged: GNU date refuses
# 2026-13-45, busybox turns 2026-02-30 into 2026-03-02. Anything else is never
# picked as latest, never removed, and must not stop the pass.
is_dated() {
case "$1" in
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;;
*) return 1 ;;
esac
[ "$(date -u -d "$1" +%F 2>/dev/null)" = "$1" ]
}
# YYYY-MM-DD as a YYYYMMDD integer: `[ a \< b ]` is not POSIX, and /bin/sh is dash.
day_number() { echo "$1" | tr -d -; }
# latest is the newest dated directory not after today: a future name (VPS
# clock off, or a VPS gone rogue) is kept but steers neither the report nor the
# rotation below.
latest=""
for dir in "$DEST"/*/; do
[ -d "$dir" ] || continue
name=$(basename "$dir")
# Only names of the dated shape are logged: they hold digits and dashes, so a
# name from the VPS cannot forge a journal line.
case "$name" in [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;; *) continue ;; esac
if ! is_dated "$name"; then
log "warning: ignoring $name, not a valid date"
continue
fi
if [ "$(day_number "$name")" -gt "$(day_number "$TODAY")" ]; then
log "warning: ignoring $name, after $TODAY"
continue
fi
if [ -z "$latest" ] || [ "$(day_number "$name")" -gt "$(day_number "$latest")" ]; then
latest="$name"
fi
done
files=0
if [ -n "$latest" ]; then
# -maxdepth 1: a partial file left in .rsync-partial/ is not a backup.
files=$(find "$DEST/$latest" -maxdepth 1 -type f -name '*.age' | wc -l | tr -d ' ')
fi
log "pull ok latest=${latest:-none} files=$files"
# The report counts the newest local directory: if the VPS stops producing, a
# pass still says ok on an old one. GET /backups judges freshness on the VPS
# side; this line names the gap in the journal.
if [ "${latest:-}" != "$TODAY" ]; then
log "warning: no directory for $TODAY, latest is ${latest:-none}"
fi
if [ -n "$latest" ] && [ "$files" -eq 0 ]; then
log "warning: no .age file in $latest"
fi
# --- Rotation (fails loudly) --------------------------------------------------
# Anchored on latest, the newest backup held here that is not after today. Not
# on today alone: when the VPS stops producing (dead cron, failing pg_dump), or
# this machine's clock jumps ahead, a today-based cutoff would delete the last
# off-site copy. Not on the newest name alone: a future-dated directory from
# the VPS would push the window forward and delete every real backup. This
# keeps latest and the 29 days before it; a future or invalid name is newer
# than the cutoff or not dated, so it is never removed.
# Epoch arithmetic: `date -d YYYY-MM-DD` and `date -d @N` are read by GNU date
# and busybox alike.
rotation_ok=true
cutoff=""
if [ -n "$latest" ]; then
latest_epoch=$(date -u -d "$latest" +%s)
cutoff=$(date -u -d "@$((latest_epoch - (RETENTION_DAYS - 1) * 86400))" +%F)
fi
# No latest means no dated directory: the loop below then compares nothing.
for dir in "$DEST"/*/; do
[ -d "$dir" ] || continue
name=$(basename "$dir")
is_dated "$name" || continue
if [ "$(day_number "$name")" -lt "$(day_number "$cutoff")" ]; then
if rm -rf -- "$dir"; then
log "rotation: removed $name"
else
log "rotation: could not remove $name"
rotation_ok=false
fi
fi
done
if [ "$rotation_ok" != true ]; then
report false "$files" rotation_failed || true
exit "$EXIT_INSTALL"
fi
if ! report true "$files"; then
exit "$EXIT_REPORT"
fi

View file

@ -1,309 +0,0 @@
#!/usr/bin/env node
// Local workstation agent: collect one snapshot and POST it to /hosts/<id>.
// Runs from cron every 5 minutes on the ThinkPad. Zero runtime dependencies —
// node:http / node:https only, same posture as the server it talks to.
//
// Two properties are load-bearing and easy to break by accident.
//
// * NO QUEUE, NO REPLAY. One run = one attempt. A missed beat is replaced by
// the next one five minutes later; a stale heartbeat has no value, and a
// spool file would only ever let the dashboard display a past that is no
// longer true. Every failure exits non-zero and stops there.
// * THE FAILURE PATH NEVER PRINTS A REQUEST OBJECT. run-push.sh pipes this
// process' output into `logger -t host-agent`, so anything written here
// lands in /var/log/syslog and journald permanently. An error object or an
// options bag carrying the Authorization header would persist the ingest
// token on disk, in cleartext, forever. Failures are therefore rebuilt from
// a fixed template plus err.code or the HTTP status — nothing else.
// __tests__/agent.test.js asserts no fragment of the token survives into
// the process output.
const os = require("node:os");
const http = require("node:http");
const https = require("node:https");
const path = require("node:path");
const DEFAULT_TIMEOUT_MS = 10000;
// Exit codes are the only signal cron has. 1 means "the install is wrong"
// (fix the env file), 2 means "the push did not land" (network or server).
const EXIT_CONFIG = 1;
const EXIT_PUSH = 2;
// Mirrors HOST_ID_PATTERN in index.js. Checked client-side so a typo in the env
// file fails with a readable message instead of an opaque 404 from the server.
const HOST_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
// --- Payload ----------------------------------------------------------------
function pickUsage(value) {
return {
totalGB: value.totalGB,
usedGB: value.usedGB,
freeGB: value.freeGB,
usagePercent: value.usagePercent,
};
}
// Build the POST /hosts/<id> body: the GET /health payload minus `logto` and
// `timestamp`. Fields are copied one by one rather than spread, so a future
// addition to collectMetrics() cannot silently widen what leaves the
// workstation. `uptime` is floored exactly like getHealth() does.
function buildSnapshot(metrics) {
return {
hostname: os.hostname(),
uptime: Math.floor(os.uptime()),
cpu: {
model: metrics.cpu.model,
cores: metrics.cpu.cores,
loadAvg: metrics.cpu.loadAvg,
usagePercent: metrics.cpu.usagePercent,
},
memory: pickUsage(metrics.memory),
disk: pickUsage(metrics.disk),
};
}
// --- Failure messages -------------------------------------------------------
//
// The ONLY three places a failure string is built. Keep it that way: every
// caller below reports through one of these, none of them accepts an error
// object, and none of them is given the token or the request options.
function configFailure(reason) {
return new Error(`configuration error: ${reason}`);
}
function transportFailure(code) {
return new Error(`push failed: request error (code=${code || "UNKNOWN"})`);
}
function httpFailure(statusCode) {
return new Error(`push failed: HTTP ${statusCode}`);
}
// --- Config -----------------------------------------------------------------
function readConfig(env) {
const apiUrl = (env.HOSTS_API_URL || "").trim();
const token = env.HOSTS_INGEST_TOKEN || "";
const hostId = (env.HOST_ID || "").trim();
if (!apiUrl) return { error: configFailure("HOSTS_API_URL is not set") };
if (!token) return { error: configFailure("HOSTS_INGEST_TOKEN is not set") };
if (!hostId) return { error: configFailure("HOST_ID is not set") };
if (!HOST_ID_RE.test(hostId)) {
return {
error: configFailure("HOST_ID must match ^[a-z0-9][a-z0-9-]{0,31}$"),
};
}
// Shape-check the URL here too, so a broken env file exits 1 ("fix the
// install") rather than 2 ("the network is having a bad day"). postSnapshot
// repeats the check because it is also called directly by the tests.
let target;
try {
target = buildTargetUrl(apiUrl, hostId);
} catch {
return { error: configFailure("HOSTS_API_URL is not a valid URL") };
}
if (target.protocol !== "http:" && target.protocol !== "https:") {
return { error: configFailure("HOSTS_API_URL must be http:// or https://") };
}
const timeoutRaw = parseInt(env.HOST_AGENT_TIMEOUT_MS || "", 10);
const timeoutMs =
Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : DEFAULT_TIMEOUT_MS;
return { config: { apiUrl, token, hostId, timeoutMs } };
}
// --- Delivery ---------------------------------------------------------------
function buildTargetUrl(apiUrl, hostId) {
const base = String(apiUrl).replace(/\/+$/, "");
return new URL(`${base}/hosts/${hostId}`);
}
// One attempt, hard-bounded in wall-clock time. Resolves to { statusCode } on
// 2xx, rejects with a template message otherwise. No retry lives here or above.
function postSnapshot({ apiUrl, token, hostId, snapshot, timeoutMs = DEFAULT_TIMEOUT_MS }) {
return new Promise((resolve, reject) => {
let target;
try {
target = buildTargetUrl(apiUrl, hostId);
} catch {
reject(configFailure("HOSTS_API_URL is not a valid URL"));
return;
}
const client =
target.protocol === "https:" ? https : target.protocol === "http:" ? http : null;
if (!client) {
reject(configFailure("HOSTS_API_URL must be http:// or https://"));
return;
}
const body = Buffer.from(JSON.stringify(snapshot), "utf-8");
let settled = false;
let req = null;
const finish = (fn, arg) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn(arg);
};
// Wall-clock ceiling over the whole exchange: DNS, connect, TLS, response.
// The `timeout` request option only covers socket inactivity, which can add
// up past the budget on a flaky link. destroy() surfaces as an 'error' with
// the code below, so the timeout reports as ETIMEDOUT and nothing else.
const timer = setTimeout(() => {
if (settled || !req) return;
req.destroy(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }));
}, timeoutMs);
try {
req = client.request(
target,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": body.length,
Authorization: `Bearer ${token}`,
},
},
(res) => {
const { statusCode } = res;
// Drain without reading: the response body is never inspected and
// never logged. A 400 says the payload was rejected; the reason is in
// the server's logs, where it can be read without a token nearby.
res.resume();
res.on("end", () => {
if (statusCode >= 200 && statusCode < 300) {
finish(resolve, { statusCode });
} else {
finish(reject, httpFailure(statusCode));
}
});
res.on("error", (err) => finish(reject, transportFailure(err && err.code)));
},
);
} catch (err) {
// request() throws synchronously on an unusable header — in practice a
// token with a trailing newline, the classic env-file mistake. Node's
// ERR_INVALID_CHAR names the header but never quotes its value; it is
// still converted here rather than propagated, so no error object from
// the HTTP stack can ever reach a console call.
if (err && err.code === "ERR_INVALID_CHAR") {
finish(
reject,
configFailure(
"HOSTS_INGEST_TOKEN contains an invalid character (a trailing newline in the env file is the usual cause)",
),
);
} else {
finish(reject, transportFailure(err && err.code));
}
return;
}
// err.code and NOTHING else. err.message can carry the resolved address,
// and the error object dragged into a console call would carry the whole
// request — headers included.
req.on("error", (err) => finish(reject, transportFailure(err && err.code)));
req.end(body);
});
}
// --- Entry point ------------------------------------------------------------
// metrics.js is shared with the server and lives one directory up. The frozen
// install copy must keep that layout (see README) — when it does not, say so in
// one line rather than let cron mail a MODULE_NOT_FOUND stack.
function loadCollectMetrics() {
try {
return require(path.join(__dirname, "..", "metrics.js")).collectMetrics;
} catch (err) {
if (err && err.code === "MODULE_NOT_FOUND") {
throw configFailure(
"metrics.js not found one level above the agent — the install copy must keep the repo layout (see agent/README.md)",
);
}
throw err;
}
}
async function main(argv = process.argv.slice(2), env = process.env) {
// Dry run: collect and print the payload, touch no network, need no config.
// This is what commissioning runs first, before the env file even exists.
const dryRun = argv.includes("--dry-run");
// Config first: the CPU sample below costs 500ms, and there is no point
// spending it to then discover HOST_ID is a typo.
let config = null;
if (!dryRun) {
const result = readConfig(env);
if (result.error) {
console.error(`host-agent: ${result.error.message}`);
return EXIT_CONFIG;
}
config = result.config;
}
let snapshot;
try {
const collectMetrics = loadCollectMetrics();
snapshot = buildSnapshot(await collectMetrics());
} catch (err) {
console.error(`host-agent: ${err.message}`);
return EXIT_CONFIG;
}
if (dryRun) {
console.log(JSON.stringify(snapshot, null, 2));
return 0;
}
try {
const { statusCode } = await postSnapshot({ ...config, snapshot });
console.log(
`host-agent: ok id=${config.hostId} status=${statusCode} hostname=${snapshot.hostname}`,
);
return 0;
} catch (err) {
console.error(`host-agent: ${err.message}`);
return EXIT_PUSH;
}
}
if (require.main === module) {
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
// Nothing should reach here — every path above is handled. Print the
// message only, never the error object: a stack rendered by console.error
// is fine, but an object argument is what leaks headers.
console.error(`host-agent: unexpected failure: ${err && err.message}`);
process.exitCode = EXIT_PUSH;
},
);
}
module.exports = {
buildSnapshot,
buildTargetUrl,
postSnapshot,
readConfig,
main,
DEFAULT_TIMEOUT_MS,
EXIT_CONFIG,
EXIT_PUSH,
HOST_ID_RE,
};

View file

@ -1,70 +0,0 @@
#!/bin/sh
#
# Cron wrapper for push-metrics.js: load the secrets, check the install, hand
# over to node. Every five minutes, forever, unattended.
#
# NEVER add `set -x`. This script is meant to be piped into `logger`, and the
# shell would echo the `. "$ENV_FILE"` expansion — the ingest token — straight
# into /var/log/syslog and journald, where it would stay. Same reason nothing
# below ever echoes a variable that holds a secret: the checks print the NAME of
# what is missing, never its value.
#
# Suggested crontab line (see README.md):
# */5 * * * * $HOME/.local/share/maximus-host-agent/agent/run-push.sh 2>&1 | logger -t host-agent
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
AGENT="$SCRIPT_DIR/push-metrics.js"
# Overridable for testing only; cron uses the default.
ENV_FILE="${HOST_AGENT_ENV_FILE:-$HOME/.config/maximus-host-agent.env}"
if [ ! -f "$ENV_FILE" ]; then
echo "host-agent: env file not found: $ENV_FILE" >&2
exit 1
fi
# A warning, not a refusal: a wrong mode bit is worth shouting about, but it is
# not worth silencing the heartbeat of the whole workstation over.
FILE_MODE=$(stat -c "%a" "$ENV_FILE" 2>/dev/null || echo "")
if [ -n "$FILE_MODE" ] && [ "$FILE_MODE" != "600" ]; then
echo "host-agent: warning: $ENV_FILE is mode $FILE_MODE, expected 600" >&2
fi
# shellcheck source=/dev/null
. "$ENV_FILE"
if [ -z "${HOSTS_API_URL:-}" ]; then
echo "host-agent: HOSTS_API_URL is not set in $ENV_FILE" >&2
exit 1
fi
if [ -z "${HOSTS_INGEST_TOKEN:-}" ]; then
echo "host-agent: HOSTS_INGEST_TOKEN is not set in $ENV_FILE" >&2
exit 1
fi
if [ -z "${HOST_ID:-}" ]; then
echo "host-agent: HOST_ID is not set in $ENV_FILE" >&2
exit 1
fi
if [ ! -f "$AGENT" ]; then
echo "host-agent: agent not found: $AGENT" >&2
exit 1
fi
# cron's PATH is famously short (/usr/bin:/bin), and a node installed through
# nvm or /usr/local lives outside it. Set NODE_BIN in the env file when
# `command -v node` comes up empty under cron but works in a login shell.
NODE_BIN="${NODE_BIN:-node}"
if ! command -v "$NODE_BIN" >/dev/null 2>&1; then
echo "host-agent: node not found (NODE_BIN=$NODE_BIN); set NODE_BIN in $ENV_FILE" >&2
exit 1
fi
# HOST_AGENT_TIMEOUT_MS is optional and read by push-metrics.js. It must be
# exported too, otherwise setting it in the env file silently does nothing and
# the agent always runs on its 10s default.
export HOSTS_API_URL HOSTS_INGEST_TOKEN HOST_ID HOST_AGENT_TIMEOUT_MS
exec "$NODE_BIN" "$AGENT" "$@"

View file

@ -1,204 +0,0 @@
// 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"];
// The host directories the VPS script archives (FILE_SOURCES, issue #61): a
// list of its own, exactly what the script can write for a file entry, so a
// database code never passes on a file and the reverse.
const FILE_ERROR_CODES = ["source_missing", "archive_failed", "encrypt_failed", "invalid_name"];
// One directory today (claude-usage); the bound only caps a runaway file.
const MAX_FILES = 16;
// 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}$/;
// The script's own file pattern, hyphen allowed. The script sets no length;
// the 63 cap is this API's bound, as for databases.
const FILE_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;
}
function cleanFileName(value) {
return typeof value === "string" && FILE_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;
}
// archivedAt is null when no archive was made (source_missing, archive_failed),
// and set on an encrypt_failed entry, whose tar did succeed.
function sanitizeFile(value) {
const out = {};
put(out, "name", cleanFileName(value.name));
put(out, "sizeBytes", cleanCount(value.sizeBytes));
putNullable(out, "archivedAt", value.archivedAt, cleanTimestamp);
put(out, "ok", cleanBoolean(value.ok));
put(out, "error", cleanCode(value.error, FILE_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);
}
if (Array.isArray(payload.files)) {
status.files = payload.files.filter(isPlainObject).slice(0, MAX_FILES).map(sanitizeFile);
}
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,
MAX_FILES,
VPS_ERROR_CODES,
FILE_ERROR_CODES,
PULL_ERROR_CODES,
};

View file

@ -1,330 +0,0 @@
// Claude usage history (issue #55): the closed days of the Claude usage
// snapshot, kept past its 30-day window, one file per month.
//
// Layout: <dir of CLAUDE_USAGE_PATH>/history/YYYY-MM.json, each file
// { "schemaVersion": 1, "month": "YYYY-MM", "days": [{ "date", "models" }] }
// with the days sorted oldest first, in the very shape of a `days` entry of the
// snapshot (same per-day cleaner, exported by claude-usage.js).
//
// Two writers, two merge rules:
// - POST /claude-usage (index.js): the LATEST SNAPSHOT WINS. Every day the
// snapshot carries, except the last one (today, still open), replaces the
// archived value. A day leaving the window keeps the last value it had,
// the one the agent froze at D+2.
// - the one-shot import below (`--import`): the ARCHIVED DATE WINS. The live
// archive is the reference; a backfill only fills the dates it lacks.
//
// A month file that cannot be read back is never overwritten: it may hold days
// that left the window long ago, and rewriting it from one snapshot would lose
// them. That month is skipped and reported; the other months are written.
//
// No config and no environment read outside the CLI block at the bottom: every
// function takes the history directory as an argument. Dates are grouped by
// the YYYY-MM prefix of their string, never by a clock computation.
const fs = require("node:fs");
const path = require("node:path");
const { sanitizeDay } = require("./claude-usage.js");
const HISTORY_SCHEMA_VERSION = 1;
const MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
const MONTH_FILE_RE = /^\d{4}-(0[1-9]|1[0-2])\.json$/;
// GET /claude-usage/history: default span, and the widest one it serves.
const DEFAULT_MONTHS = 12;
const MAX_MONTHS = 36;
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isMonth(value) {
return typeof value === "string" && MONTH_RE.test(value);
}
function monthOf(date) {
return date.slice(0, 7);
}
// Calendar arithmetic on the two numbers of the string, no Date involved.
function addMonths(month, n) {
const index = Number(month.slice(0, 4)) * 12 + Number(month.slice(5, 7)) - 1 + n;
const year = Math.floor(index / 12);
return `${String(year).padStart(4, "0")}-${String((index % 12) + 1).padStart(2, "0")}`;
}
// Inclusive: 2026-01 to 2026-12 spans 12 months.
function monthSpan(from, to) {
const index = (m) => Number(m.slice(0, 4)) * 12 + Number(m.slice(5, 7));
return index(to) - index(from) + 1;
}
function byDate(a, b) {
return a.date < b.date ? -1 : a.date > b.date ? 1 : 0;
}
// Replace or add each incoming date (preferExisting: false, the POST rule), or
// add only the dates not there yet (preferExisting: true, the import rule).
// Returns a new array sorted oldest first; neither input is modified.
function mergeDays(existing, incoming, { preferExisting = false } = {}) {
const merged = new Map();
for (const day of existing) merged.set(day.date, day);
for (const day of incoming) {
if (preferExisting && merged.has(day.date)) continue;
merged.set(day.date, day);
}
return [...merged.values()].sort(byDate);
}
// The days POST /claude-usage archives: every day of the snapshot except the
// most recent date (today, still open), whatever the order they were sent in,
// and never a date past `receivedDay` (the server's UTC date of reception,
// never behind the workstation's Toronto date): a day from a skewed clock
// would otherwise open a future month file that the read route would take
// as its default end.
function closedDays(days, receivedDay) {
const dated = days.filter((day) => day.date <= receivedDay);
if (!dated.length) return [];
const latest = dated.reduce((max, day) => (day.date > max ? day.date : max), dated[0].date);
return dated.filter((day) => day.date !== latest);
}
// YYYY-MM -> days of that month, in input order.
function groupByMonth(days) {
const months = new Map();
for (const day of days) {
const month = monthOf(day.date);
if (!months.has(month)) months.set(month, []);
months.get(month).push(day);
}
return months;
}
// A parsed month file, re-read through the snapshot's whitelist: the file
// lives on a writable bind-mount and is no more trusted than a payload.
// Returns the clean days, or null when the file is off-format as a whole (it
// is then "unreadable": served without it, and never overwritten). A single
// invalid day, or one dated outside the month, is dropped; on a repeated
// date, the later entry wins.
function sanitizeMonthFile(parsed, month) {
if (!isPlainObject(parsed)) return null;
if (parsed.schemaVersion !== HISTORY_SCHEMA_VERSION) return null;
if (parsed.month !== month || !Array.isArray(parsed.days)) return null;
const days = [];
for (const entry of parsed.days) {
const day = sanitizeDay(entry);
if (day !== undefined && monthOf(day.date) === month) days.push(day);
}
return mergeDays([], days);
}
function historyDir(claudeUsagePath) {
return path.join(path.dirname(claudeUsagePath), "history");
}
function monthPath(dir, month) {
return path.join(dir, `${month}.json`);
}
// { state: "absent" } | { state: "ok", days } | { state: "unreadable", code }
function readMonth(dir, month) {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(monthPath(dir, month), "utf-8"));
} catch (err) {
if (err.code === "ENOENT") return { state: "absent" };
return { state: "unreadable", code: err.code || "parse_error" };
}
const days = sanitizeMonthFile(parsed, month);
if (days === null) return { state: "unreadable", code: "off_format" };
return { state: "ok", days };
}
// 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
// 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
// is harmless, the reader only opens YYYY-MM.json.
function writeMonth(dir, month, days) {
const finalPath = monthPath(dir, month);
const tmpPath = path.join(dir, `.${month}.json.${process.pid}.${Date.now()}.tmp`);
const record = { schemaVersion: HISTORY_SCHEMA_VERSION, month, days };
try {
fs.writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 });
fs.renameSync(tmpPath, finalPath);
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch {
// Best effort — the temp file may never have been created.
}
throw err;
}
}
// Merge `days` into their month files. Each month stands alone: an unreadable
// one is skipped (never overwritten), a failed write is reported, and the
// other months go on. Only a failure to create the directory throws.
//
// Returns { written, skipped, failed, added, kept }: month lists, plus the
// number of dates added and, under preferExisting, of dates already archived.
function archiveDays(dir, days, { preferExisting = false } = {}) {
const report = { written: [], skipped: [], failed: [], added: 0, kept: 0 };
if (!days.length) return report;
fs.mkdirSync(dir, { recursive: true });
for (const [month, incoming] of [...groupByMonth(days)].sort(([a], [b]) => (a < b ? -1 : 1))) {
const current = readMonth(dir, month);
if (current.state === "unreadable") {
report.skipped.push({ month, code: current.code });
continue;
}
const existing = current.state === "ok" ? current.days : [];
const known = new Set(existing.map((day) => day.date));
const merged = mergeDays(existing, incoming, { preferExisting });
try {
writeMonth(dir, month, merged);
} catch (err) {
report.failed.push({ month, code: err.code || "write_error" });
continue;
}
report.written.push(month);
for (const day of incoming) {
if (!known.has(day.date)) report.added += 1;
else if (preferExisting) report.kept += 1;
}
}
return report;
}
// The most recent month that has a file, or null (no directory, no file).
function lastArchivedMonth(dir) {
let names;
try {
names = fs.readdirSync(dir);
} catch {
return null;
}
const months = names.filter((name) => MONTH_FILE_RE.test(name)).map((name) => name.slice(0, 7));
return months.length ? months.sort().at(-1) : null;
}
// Query string of GET /claude-usage/history -> { from, to } or { error }.
// Defaults: `to` is the month of the last archived day (the current month when
// nothing is archived yet, or when the last file is dated past it), or `from`
// itself when `from` is later; `from` is DEFAULT_MONTHS months back from `to`,
// `to` included.
function parseRange(fromRaw, toRaw, { lastArchived, currentMonth }) {
if (fromRaw !== null && !isMonth(fromRaw)) return { error: "from must be YYYY-MM" };
if (toRaw !== null && !isMonth(toRaw)) return { error: "to must be YYYY-MM" };
let to = toRaw;
if (to === null) {
to = lastArchived && lastArchived <= currentMonth ? lastArchived : currentMonth;
if (fromRaw !== null && fromRaw > to) to = fromRaw;
}
const from = fromRaw !== null ? fromRaw : addMonths(to, -(DEFAULT_MONTHS - 1));
if (from > to) return { error: "from must not be after to" };
if (monthSpan(from, to) > MAX_MONTHS) return { error: `range must not exceed ${MAX_MONTHS} months` };
return { from, to };
}
// Every archived day from `from` to `to` (both YYYY-MM, inclusive), oldest
// first. An absent month contributes nothing; an unreadable one is listed in
// `unreadable` and contributes nothing either.
function readHistory(dir, from, to) {
const days = [];
const unreadable = [];
for (let month = from; month <= to; month = addMonths(month, 1)) {
const current = readMonth(dir, month);
if (current.state === "ok") days.push(...current.days);
else if (current.state === "unreadable") unreadable.push({ month, code: current.code });
}
return { days, unreadable };
}
// Import file of the one-shot backfill: { days: [...] }. Strict, since it is
// written once by hand: any entry that is not a clean day, or a repeated date,
// refuses the whole file. Returns the days, or null.
function parseImport(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!isPlainObject(parsed) || !Array.isArray(parsed.days)) return null;
const days = [];
const seen = new Set();
for (const entry of parsed.days) {
const day = sanitizeDay(entry);
if (day === undefined || seen.has(day.date)) return null;
seen.add(day.date);
days.push(day);
}
return days;
}
// `node claude-usage-history.js --import <file|->`, run inside the container
// (docker exec, as `node`): merges a backfill into the live archive, the
// archived dates winning. Prints counts only, never a day's content.
function runImport(argv, env, io) {
if (argv.length !== 2 || argv[0] !== "--import") {
io.err("usage: node claude-usage-history.js --import <file|->");
return 2;
}
let raw;
try {
raw = argv[1] === "-" ? fs.readFileSync(0, "utf-8") : fs.readFileSync(argv[1], "utf-8");
} catch (err) {
io.err(`import: cannot read the import file (${err.code || "read_error"})`);
return 1;
}
const days = parseImport(raw);
if (days === null) {
io.err("import: invalid import file, nothing written");
return 1;
}
const dir = historyDir(env.CLAUDE_USAGE_PATH || "/data/claude-usage/claude-usage.json");
let report;
try {
report = archiveDays(dir, days, { preferExisting: true });
} catch (err) {
io.err(`import: cannot create the history directory (${err.code || "error"})`);
return 1;
}
io.out(
`import: ${days.length} day(s) read, ${report.added} added, ${report.kept} already archived, ` +
`${report.written.length} month(s) written`,
);
for (const { month, code } of report.skipped) io.err(`import: month ${month} skipped, unreadable (${code})`);
for (const { month, code } of report.failed) io.err(`import: month ${month} not written (${code})`);
return report.skipped.length || report.failed.length ? 1 : 0;
}
if (require.main === module) {
process.exitCode = runImport(process.argv.slice(2), process.env, {
out: (line) => console.log(line),
err: (line) => console.error(line),
});
}
module.exports = {
isMonth,
monthOf,
addMonths,
monthSpan,
mergeDays,
groupByMonth,
closedDays,
sanitizeMonthFile,
historyDir,
readMonth,
writeMonth,
archiveDays,
lastArchivedMonth,
parseRange,
readHistory,
parseImport,
runImport,
HISTORY_SCHEMA_VERSION,
DEFAULT_MONTHS,
MAX_MONTHS,
};

View file

@ -1,204 +0,0 @@
// Claude usage snapshot (POST /claude-usage, GET /claude-usage), contract
// `schemaVersion: 1`, shared with the ThinkPad agent and the admin dashboard.
//
// Pure module: no I/O, no config, no side effects. index.js wires the routes
// and owns the file; the workstation agent can test its own snapshot against
// this very sanitizer before it ever sends it.
//
// Whitelist rebuild, field by field. The stored object is read back by GET
// /claude-usage and ends up in the admin dashboard's React tree, so nothing
// from the payload is kept verbatim:
// - an unknown field is dropped;
// - an invalid value is dropped (the key is left out), never coerced. That
// covers a negative number, a number past 2^53, a `detail` outside its
// regex and a `status` outside its enum;
// - a list past its bound keeps its first entries (`days` keeps its LAST
// ones: the contract orders them oldest first, so today survives);
// - strings are truncated to 128 characters, not filtered: safety rests on
// the consumer escaping at render time, as for the host snapshots.
// Only two things refuse the whole body: a body that is not a JSON object, and
// a `schemaVersion` other than 1 (a future contract must not be silently
// reshaped into this one).
const SCHEMA_VERSION = 1;
// Worst case within the bounds below is about 46 KB, hence 64 KiB and not 32.
const CLAUDE_USAGE_MAX_BODY_BYTES = 65536;
const MAX_DAYS = 31;
const MAX_MODELS_PER_DAY = 12;
const MAX_LIMIT_ITEMS = 12;
const MAX_PROJECTS = 9;
const MAX_STRING_LENGTH = 128;
const LIMIT_STATUSES = ["ok", "token_expired", "http_error", "unavailable", "disabled"];
const DAY_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const MODEL_KEY_RE = /^[a-z0-9][a-z0-9.\-]{0,63}$/;
// A machine-readable reason code, never free text: a free-form detail could
// carry an upstream response body all the way to the dashboard.
const DETAIL_RE = /^[a-z0-9_]{1,32}$/;
// Full ISO 8601 timestamp with an explicit offset, as Date#toISOString() and
// the upstream API write them.
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
const MODEL_COUNTERS = ["input", "output", "cacheCreation", "cacheRead", "requests"];
const PROJECT_COUNTERS = ["work", "cacheRead", "requests"];
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function cleanString(value) {
if (typeof value !== "string") return undefined;
return value.slice(0, MAX_STRING_LENGTH);
}
function cleanCount(value) {
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
}
function cleanPercent(value) {
const count = cleanCount(value);
return count !== undefined && count <= 100 ? count : undefined;
}
function cleanTimestamp(value) {
if (typeof value !== "string" || value.length > MAX_STRING_LENGTH) return undefined;
if (!ISO_TIMESTAMP_RE.test(value) || Number.isNaN(Date.parse(value))) return undefined;
return value;
}
// Also used by index.js on the stored receivedAt of both snapshot files: the
// server only ever stamps Date#toISOString(), and new Date() alone would
// accept "1" or a bare date (#54).
function isIsoTimestamp(value) {
return cleanTimestamp(value) !== undefined;
}
// The calendar check catches what the regex lets through (2026-02-31).
function cleanDayDate(value) {
if (typeof value !== "string" || !DAY_DATE_RE.test(value)) return undefined;
const parsed = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) return undefined;
return value;
}
// 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.
function putNullable(out, key, raw, clean) {
if (raw === null) out[key] = null;
else put(out, key, clean(raw));
}
function sanitizeCounters(value, keys) {
const out = {};
for (const key of keys) put(out, key, cleanCount(value[key]));
return out;
}
function sanitizeModels(value) {
if (!isPlainObject(value)) return undefined;
const out = {};
let kept = 0;
for (const key of Object.keys(value)) {
if (kept >= MAX_MODELS_PER_DAY) break;
if (!MODEL_KEY_RE.test(key) || !isPlainObject(value[key])) continue;
out[key] = sanitizeCounters(value[key], MODEL_COUNTERS);
kept += 1;
}
return out;
}
// One entry of `days`, or undefined when it has no usable date. Also re-reads
// the monthly history files (claude-usage-history.js, #55): an archived day
// goes through the very whitelist of the snapshot it came from.
function sanitizeDay(entry) {
if (!isPlainObject(entry)) return undefined;
const date = cleanDayDate(entry.date);
if (date === undefined) return undefined;
const day = { date };
put(day, "models", sanitizeModels(entry.models));
return day;
}
function sanitizeDays(value) {
if (!Array.isArray(value)) return undefined;
const days = [];
for (const entry of value) {
const day = sanitizeDay(entry);
if (day !== undefined) days.push(day);
}
return days.slice(-MAX_DAYS);
}
function sanitizeLimitItem(value) {
const item = {};
put(item, "kind", cleanString(value.kind));
put(item, "group", cleanString(value.group));
put(item, "percent", cleanPercent(value.percent));
put(item, "severity", cleanString(value.severity));
putNullable(item, "resetsAt", value.resetsAt, cleanTimestamp);
putNullable(item, "model", value.model, cleanString);
return item;
}
function sanitizeLimits(value) {
if (!isPlainObject(value)) return undefined;
const limits = {};
put(limits, "status", LIMIT_STATUSES.includes(value.status) ? value.status : undefined);
put(limits, "detail", typeof value.detail === "string" && DETAIL_RE.test(value.detail) ? value.detail : undefined);
putNullable(limits, "fetchedAt", value.fetchedAt, cleanTimestamp);
if (Array.isArray(value.items)) {
limits.items = value.items.filter(isPlainObject).slice(0, MAX_LIMIT_ITEMS).map(sanitizeLimitItem);
}
return limits;
}
function sanitizeProjects(value) {
if (!Array.isArray(value)) return undefined;
return value
.filter(isPlainObject)
.slice(0, MAX_PROJECTS)
.map((entry) => {
const project = {};
put(project, "name", cleanString(entry.name));
Object.assign(project, sanitizeCounters(entry, PROJECT_COUNTERS));
return project;
});
}
// Returns { snapshot } or { error }. Idempotent: a sanitized snapshot comes
// back unchanged, which is what lets GET re-run it on the stored file.
function sanitizeClaudeUsage(payload) {
if (!isPlainObject(payload)) return { error: "body must be a JSON object" };
if (payload.schemaVersion !== SCHEMA_VERSION) {
return { error: `schemaVersion must be ${SCHEMA_VERSION}` };
}
const snapshot = { schemaVersion: SCHEMA_VERSION };
put(snapshot, "generatedAt", cleanTimestamp(payload.generatedAt));
put(snapshot, "claudeCodeVersion", cleanString(payload.claudeCodeVersion));
put(snapshot, "timeZone", cleanString(payload.timeZone));
put(snapshot, "limits", sanitizeLimits(payload.limits));
put(snapshot, "days", sanitizeDays(payload.days));
put(snapshot, "projects", sanitizeProjects(payload.projects));
return { snapshot };
}
module.exports = {
sanitizeClaudeUsage,
sanitizeDay,
isIsoTimestamp,
CLAUDE_USAGE_MAX_BODY_BYTES,
SCHEMA_VERSION,
MAX_DAYS,
MAX_MODELS_PER_DAY,
MAX_LIMIT_ITEMS,
MAX_PROJECTS,
MAX_STRING_LENGTH,
LIMIT_STATUSES,
};

1191
index.js

File diff suppressed because it is too large Load diff

View file

@ -1,84 +0,0 @@
const os = require("node:os");
const { execSync } = require("node:child_process");
const { setTimeout: delay } = require("node:timers/promises");
// Host metrics collection (CPU / memory / disk), extracted from index.js so the
// same code can be reused by the local workstation agent. Pure host probing:
// no HTTP, no config, no side effects — the caller owns the response shape.
function readProcStat() {
try {
const line = execSync("head -1 /proc/stat", { encoding: "utf-8" }).trim();
const parts = line.split(/\s+/).slice(1).map(Number);
const idle = parts[3] + parts[4];
const total = parts.reduce((a, b) => a + b, 0);
return { idle, total };
} catch {
return null;
}
}
async function getCpuPercent() {
const t1 = readProcStat();
if (!t1) return 0;
const { idle: idle1, total: total1 } = t1;
// Sample over 500ms without blocking the event loop, so other async work
// (e.g. the Logto healthcheck) can run concurrently.
await delay(500);
const t2 = readProcStat();
if (!t2) return 0;
const dIdle = t2.idle - idle1;
const dTotal = t2.total - total1;
if (dTotal === 0) return 0;
return Math.round((1 - dIdle / dTotal) * 100);
}
function getDisk() {
try {
// Alpine df doesn't support --output, use standard POSIX format
const out = execSync("df -k /", { encoding: "utf-8" });
const parts = out.trim().split("\n")[1].trim().split(/\s+/);
// df -k columns: Filesystem, 1K-blocks, Used, Available, Use%, Mounted
const totalGB = +(parseInt(parts[1], 10) / 1e6).toFixed(1);
const usedGB = +(parseInt(parts[2], 10) / 1e6).toFixed(1);
const freeGB = +(parseInt(parts[3], 10) / 1e6).toFixed(1);
const usagePercent = totalGB > 0 ? Math.round((usedGB / totalGB) * 100) : 0;
return { totalGB, usedGB, freeGB, usagePercent };
} catch {
return { totalGB: 0, usedGB: 0, freeGB: 0, usagePercent: 0 };
}
}
// Collect CPU, memory and disk into the `{ cpu, memory, disk }` slice of the
// /health payload. Awaiting getCpuPercent() first is deliberate: the 500ms
// sample is the only await here, so a caller running this inside a
// Promise.all() keeps its other work (the Logto check) fully concurrent.
// Everything else is cheap and synchronous.
async function collectMetrics() {
const cpus = os.cpus();
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const cpuUsagePercent = await getCpuPercent();
return {
cpu: {
model: cpus[0]?.model?.trim() || "unknown",
cores: cpus.length,
loadAvg: os.loadavg().map((l) => +l.toFixed(2)),
usagePercent: cpuUsagePercent,
},
memory: {
totalGB: +(totalMem / 1e9).toFixed(1),
usedGB: +(usedMem / 1e9).toFixed(1),
freeGB: +(freeMem / 1e9).toFixed(1),
usagePercent: Math.round((usedMem / totalMem) * 100),
},
disk: getDisk(),
};
}
module.exports = { getCpuPercent, getDisk, collectMetrics };

1428
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,10 +4,6 @@
"description": "Lightweight VPS health monitoring endpoint",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "vitest run"
},
"devDependencies": {
"vitest": "^2.1.8"
"start": "node index.js"
}
}

View file

@ -1,162 +0,0 @@
#!/bin/sh
# boot-from-copy.sh — start the API from exactly the files the image would hold.
#
# The Dockerfile COPY lists runtime files one by one. A module missing from that
# list passes every test (the tests run from the full checkout) and only crashes
# the container at redeploy, with MODULE_NOT_FOUND. That is what #11 had to fix
# by hand for metrics.js.
#
# A real `docker build` would need the Docker socket inside the CI job, which on
# the shared instance-scoped runner makes every workflow of every repository
# root on the production VPS. So this script rebuilds the image's file set
# instead: it copies the COPY sources into an empty directory, runs the CMD
# there, and asserts the server answers. In CI it runs in `node:22-alpine`, the
# image's own base.
#
# Usage : sh scripts/boot-from-copy.sh (from the repository root)
# BOOT_PORT=<port> sh scripts/boot-from-copy.sh (default port 3097)
# Exit 0 if the server boots and answers, 1 otherwise — including when the port
# is already held by something else (pick another one with BOOT_PORT).
set -eu
# Every curl below targets this machine's loopback, and a proxy from the
# environment (http_proxy, ALL_PROXY) would answer in its place: the port check
# would probe the proxy instead of the port, the wait loop and the assertions
# would talk to the proxy instead of the server started here. Both spellings,
# since curl reads the lowercase one first and an inherited no_proxy would win.
export NO_PROXY='*' no_proxy='*'
PORT="${BOOT_PORT:-3097}"
TOKEN="boot-from-copy-token"
DOCKERFILE="Dockerfile"
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ -f "$DOCKERFILE" ] || fail "no $DOCKERFILE here — run from the repository root"
# --- Parse COPY -------------------------------------------------------------
# Only the plain shell form is understood. Anything else (flags, JSON array,
# several COPY lines) fails loudly rather than being half-parsed into a
# check that verifies nothing.
COPY_LINES=$(grep -cE '^[[:space:]]*COPY[[:space:]]' "$DOCKERFILE" || true)
[ "$COPY_LINES" = "1" ] || fail "expected exactly one COPY line, found $COPY_LINES"
COPY_ARGS=$(grep -E '^[[:space:]]*COPY[[:space:]]' "$DOCKERFILE" | sed -E 's/^[[:space:]]*COPY[[:space:]]+//')
case "$COPY_ARGS" in
--*) fail "COPY flags are not supported by this check: $COPY_ARGS" ;;
\[*) fail "COPY JSON form is not supported by this check: $COPY_ARGS" ;;
esac
# shellcheck disable=SC2086 # word splitting is the point: one word per source
set -- $COPY_ARGS
[ "$#" -ge 2 ] || fail "COPY needs at least one source and a destination: $COPY_ARGS"
# --- Parse CMD --------------------------------------------------------------
# Taken from the Dockerfile rather than hardcoded, so the check follows the
# entrypoint if it ever changes. Exec (JSON array) form only.
CMD_LINE=$(grep -E '^[[:space:]]*CMD[[:space:]]' "$DOCKERFILE" | tail -1 | sed -E 's/^[[:space:]]*CMD[[:space:]]+//')
CMD_SH=$(printf '%s' "$CMD_LINE" | node -e '
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
let a;
try { a = JSON.parse(s); } catch { process.exit(1); }
if (!Array.isArray(a) || !a.length || !a.every((x) => /^[A-Za-z0-9._\/-]+$/.test(x))) process.exit(1);
process.stdout.write(a.join(" "));
});
') || fail "CMD must be an exec-form JSON array of plain words: $CMD_LINE"
# --- Port must be free -----------------------------------------------------
# If something already listens on $PORT, our server dies of EADDRINUSE, but the
# wait loop below can reach the neighbour first and the assertions would then
# check a server we never started. So refuse up front: the port counts as free
# only when curl gets "connection refused" (exit 7). Any other exit — an HTTP
# answer (0), a listener that accepts and stalls (28), a non-HTTP service
# (52, 56) — means it is taken. No proxy answers in the port's place: no_proxy
# and NO_PROXY are exported at the top.
command -v curl >/dev/null 2>&1 || fail "curl is required"
RC=0
curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$PORT/" || RC=$?
[ "$RC" = "7" ] || fail "port $PORT is already taken (curl exit $RC); set BOOT_PORT to a free port"
# --- Build the image's file set ---------------------------------------------
APP_DIR=$(mktemp -d)
LOG="$APP_DIR.log"
SERVER_PID=""
trap '[ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null; rm -rf "$APP_DIR" "$LOG"; true' EXIT
N=0
while [ "$#" -gt 1 ]; do
[ -f "$1" ] || fail "COPY source $1 does not exist in the repository"
cp "$1" "$APP_DIR/"
N=$((N + 1))
shift
done
[ "$N" -gt 0 ] || fail "no COPY source found"
echo "Image file set ($N): $(ls "$APP_DIR" | tr '\n' ' ')"
# mktemp creates the directory 0700: the `node` user could not even enter it
# and the failure would read EACCES instead of the MODULE_NOT_FOUND we look for.
chmod 755 "$APP_DIR"
chmod 644 "$APP_DIR"/*
# --- Start ------------------------------------------------------------------
# The image runs as `node`. In the CI container the job is root, so drop to
# `node` like the image does; on a workstation, run as whoever we are.
# LOGTO_HEALTH_URL points at a closed local port: no external network needed,
# the Logto check reports "down" and /health still answers 200.
# busybox `su` cannot preserve the environment, hence the inline variables.
ENV_INLINE="PORT=$PORT HEALTH_TOKEN=$TOKEN LOGTO_HEALTH_URL=http://127.0.0.1:9/"
if [ "$(id -u)" = "0" ]; then
id node >/dev/null 2>&1 || fail "running as root but no 'node' user to drop to"
su node -s /bin/sh -c "cd '$APP_DIR' && exec env $ENV_INLINE $CMD_SH" >"$LOG" 2>&1 &
else
(cd "$APP_DIR" && exec env $ENV_INLINE $CMD_SH) >"$LOG" 2>&1 &
fi
SERVER_PID=$!
UP=""
i=0
while [ "$i" -lt 50 ]; do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "--- server log ---" >&2
cat "$LOG" >&2
fail "the server died at startup (see its log above)"
fi
if curl -s -o /dev/null "http://127.0.0.1:$PORT/health"; then
UP=yes
break
fi
sleep 0.2
i=$((i + 1))
done
if [ -z "$UP" ]; then
echo "--- server log ---" >&2
cat "$LOG" >&2
fail "the server did not answer on :$PORT within 10 s"
fi
# --- Assert -----------------------------------------------------------------
CODE=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/health")
[ "$CODE" = "401" ] || fail "GET /health without token: expected 401, got $CODE"
echo "ok GET /health without token -> 401"
BODY=$(curl -s -w '\n%{http_code}' -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:$PORT/health")
CODE=$(printf '%s' "$BODY" | tail -1)
[ "$CODE" = "200" ] || fail "GET /health with token: expected 200, got $CODE"
echo "ok GET /health with token -> 200"
# metrics.js swallows a failed `df` parse and reports zeros. Production runs on
# Alpine, whose busybox df is what this catches. In CI the figure is the job
# container's overlay root, not the production disk: a failure here means the
# parsing broke, never that a disk is full.
printf '%s' "$BODY" | sed '$d' | node -e '
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
const b = JSON.parse(s);
if (!(b.disk && b.disk.totalGB > 0)) { console.error("disk:", JSON.stringify(b.disk)); process.exit(1); }
if (!(b.memory && b.memory.totalGB > 0)) { console.error("memory:", JSON.stringify(b.memory)); process.exit(1); }
});
' || fail "/health reported empty disk or memory metrics"
echo "ok /health disk and memory metrics are non-zero"
echo "boot-from-copy: PASS"

View file

@ -1,290 +0,0 @@
#!/usr/bin/env python3
"""Replay the review levels of REVIEW.md on the pull requests that landed (#63).
Usage:
python3 scripts/review-levels.py [N ...] (default: every PR of LANDED)
python3 scripts/review-levels.py --check (no network)
The first form reads the Forgejo API (token in $FORGEJO_TOKEN, else
~/.forgejo-token; read-only calls) and prints each PR with its level and the
files that set it, then the distribution. --check compares the patterns of
this script with the first column of the Haut table of REVIEW.md and exits 1
if they differ: a PR that changes the Niveaux section updates this script in
the same diff.
Adapted from simpl-resultat/scripts/review-levels.py. The file list comes from
the PR's .diff, never from /pulls/{n}/files: that route answers 500 on a PR
whose base branch is gone, which is every link of a stack merged by
fast-forward. Three PRs (EMPTY_DIFF) have an empty .diff and are read from
the local clone: run the script from the repository. The content conditions of the Haut table are approximated from
the diff: index.js routing and auth, side effects of the shipped modules
(disk writes, the temp-file sweep, body caps, shell commands, outbound calls,
require.main), guard tests (an existing line changed or removed),
package.json (anything but a version), package-lock.json (a package added or
removed, an origin changed), .gitignore (a line removed). package.json
knows the block of a package line only when the block's opening line is in
the hunk: a bump deep inside a large "dependencies" block reads as normal,
where the table says high (impossible today, the runtime has none).
"""
import itertools
import json
import os
import re
import subprocess
import sys
import urllib.request
API = "https://git.lacompagniemaximus.com/api/v1/repos/maximus/vps-health-api"
REVIEW_MD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "REVIEW.md")
# Every PR whose head reached main, frozen by number. Forgejo flags only part
# of them as merged: a stack merged by fast-forward (#18-#23, #31, #34, #37)
# shows as closed. #32 (a red-CI demo) never landed.
LANDED = [2, 5, 6, 8, 9, 18, 19, 20, 21, 22, 23, 26, 28, 30, 31, 34, 37, 39, 42, 43,
44, 45, 47, 51, 52, 53, 56, 57, 59, 60, 62]
# Three PRs whose .diff comes back empty: once fast-forwarded into main, their
# merge_base equals their head. Each is one commit, read from the local clone.
EMPTY_DIFF = {
18: ("39a55fad93414b8e0049724f351710bfe1eb7a77", "79cb813767cfb53282d5fe6f386af3d740bf7f7e"),
31: ("2b35b78a0beb04dc9ae92c13e42b9ff09fd2d147", "4e7879da6ca5163d37d11b9c5803cf2bed640b59"),
34: ("4e7879da6ca5163d37d11b9c5803cf2bed640b59", "156ca818ca37441a2166d112251529ac32783afc"),
}
# REVIEW.md, table Haut: rows without a condition.
HIGH_ALWAYS = [
"agent/pg-backup-pull/**",
"agent/claude-usage/{limits,push-claude-usage}.js", "agent/push-metrics.js", "agent/**/*.sh",
"Dockerfile",
".forgejo/workflows/**",
"vitest.config.js", "scripts/boot-from-copy.sh",
".claude/settings.json", ".claude/hooks/**", ".mcp.json",
]
SHIPPED = ["{index,metrics,claude-usage,claude-usage-history,backups}.js"]
GUARDS = ["__tests__/{auth,writes}.test.js"]
LOW = ["**/*.md", "LICENSE"]
NEVER_LOW = ["CLAUDE.md", "REVIEW.md", "agent/README.md", ".claude/**"]
AUTH = re.compile(r"\b(ROUTES|TOKEN_KINDS|resolveRoute|checkAuth|tokenMatches|logRejection|"
r"safeLogValue|handler|\w*_TOKEN)\b")
EFFECTS = re.compile(
r"\b(writeFile\w*|writeSync|createWriteStream|appendFile\w*|rename\w*|mkdir\w*|unlink\w*|"
r"rmSync|rmdir\w*|chmod\w*|chown\w*|copyFile\w*|symlink\w*|truncate\w*|writeJsonAtomic|"
r"writeHostSnapshot|sweepStaleTempFiles|TEMP_FILE_RE|readBody|\w*MAX_BODY_BYTES|"
r"child_process|execSync|execFile\w*|spawn\w*|fetch|https?\.(request|get))\b"
r"|require\.main")
def token():
return os.environ.get("FORGEJO_TOKEN") or open(os.path.expanduser("~/.forgejo-token")).read().strip()
def get(path, raw=False):
req = urllib.request.Request(API + path, headers={"Authorization": "token " + token()})
data = urllib.request.urlopen(req).read()
return data if raw else json.loads(data)
def expand_braces(pat):
m = re.search(r"\{([^{}]*)\}", pat)
if not m:
return [pat]
return list(itertools.chain.from_iterable(
expand_braces(pat[:m.start()] + alt + pat[m.end():]) for alt in m.group(1).split(",")))
def glob_re(pat):
# '**/' spans zero or more directories, '*' and '?' never cross '/'
i, out = 0, ""
while i < len(pat):
if pat.startswith("**/", i):
out += "(?:.*/)?"; i += 3; continue
if pat.startswith("**", i):
out += ".*"; i += 2; continue
c = pat[i]
out += "[^/]*" if c == "*" else "[^/]" if c == "?" else re.escape(c)
i += 1
return re.compile("^" + out + "$")
def match(pats, path):
# '{a,b}' alternates, wildcards allowed inside
return any(glob_re(p).match(path) for pat in pats for p in expand_braces(pat))
def parse_diff(diff):
"""[{filename, previous_filename, status, block}] from a git diff."""
files = []
for block in re.split(r"(?m)^diff --git ", diff)[1:]:
head = block.split("\n@@", 1)[0]
m = re.match(r"a/(\S+) b/(\S+)", block)
old, new = m.group(1), m.group(2)
status = "changed"
if re.search(r"(?m)^new file mode", head):
status = "added"
elif re.search(r"(?m)^deleted file mode", head):
status = "deleted"
elif re.search(r"(?m)^rename from", head) or old != new:
status = "renamed"
files.append({"filename": new, "previous_filename": old if old != new else None,
"status": status, "block": block})
return files
def meaningful(text):
t = text.strip()
return t != "" and not t.startswith(("#", "//", "*", "/*"))
def changed(block):
return [l for l in block.split("\n")
if l[:1] in "+-" and not l.startswith(("+++", "---")) and meaningful(l[1:])]
def touches(block, pattern):
# a hunk whose enclosing function (the @@ context) or a changed line matches
for hunk in re.split(r"(?m)^(?=@@)", block)[1:]:
if pattern.search(hunk.split("\n", 1)[0]) or any(pattern.search(l) for l in changed(hunk)):
return True
return False
def package_json_changes(block):
# anything but "version" and a bump of a package listed in the same block;
# any line of "dependencies" counts, since the runtime has none
before, after = {}, {}
for hunk in re.split(r"(?m)^@@", block)[1:]:
section = None
for l in hunk.split("\n")[1:]:
if l.startswith(("+++", "---")) or l[:1] not in " +-":
continue
m = re.match(r'^[ +-]\s*"([^"]+)":\s*\{', l)
if m:
section = m.group(1)
if l[:1] == " " or not meaningful(l[1:]):
if re.match(r"^[ +-]\s*\}", l):
section = None
continue
if section == "dependencies":
return True
m = re.match(r'^([+-])\s*"([^"]+)":\s*(.*?),?\s*$', l)
if not m:
return True
if m.group(3) == "{":
continue
(before if m.group(1) == "-" else after)[(section, m.group(2))] = m.group(3)
if re.match(r"^[+-]\s*\}", l):
section = None
for key in set(before) | set(after):
if key[1] == "version" and key[0] is None:
continue
if key in before and key in after and all(re.match(r'^"[\^~]?\d', v) for v in (before[key], after[key])):
continue
return True
return False
def origin_of(url):
m = re.match(r"^([a-z+]+)://([^/]+)", url)
return m.group(1) + "://" + m.group(2) if m else url
def lock_adds_or_removes(block):
if any(re.match(r'^[+-]\s+"node_modules/[^"]+": \{', l) for l in block.split("\n")):
return True
for hunk in re.split(r"(?m)^@@", block)[1:]:
hl = hunk.split("\n")
before, after = set(), set()
for l in hl:
m = re.match(r'^([+-])\s+"resolved": "([^"]+)"', l)
if m:
(before if m.group(1) == "-" else after).add(origin_of(m.group(2)))
if after - before:
return True
if any(re.match(r'^\+\s+"integrity": ', l) for l in hl) and not any(re.match(r'^\+\s+"version": ', l) for l in hl):
return True
return False
def review_level(files):
triggers = []
for f in files:
b = f["block"]
for path in filter(None, {f["filename"], f["previous_filename"]}):
if match(HIGH_ALWAYS, path):
triggers.append(path)
if path == "index.js" and touches(b, AUTH):
triggers.append(path + " (routing or auth)")
if match(SHIPPED, path) and (f["status"] != "changed" or touches(b, EFFECTS)):
triggers.append(path + " (side effect)")
if match(GUARDS, path) and [l for l in changed(b) if l.startswith("-")]:
triggers.append(path + " (existing line changed)")
if path == "package.json" and package_json_changes(b):
triggers.append(path)
if path == "package-lock.json" and lock_adds_or_removes(b):
triggers.append(path)
if path == ".gitignore" and [l for l in changed(b) if l.startswith("-")]:
triggers.append(path + " (line removed)")
if path == "REVIEW.md":
triggers.append(path + " (check the « Niveaux » section by hand)")
parts = path.split("/")
new_hidden = f["status"] == "added" and any(
x.startswith(".") for x in parts[:-1]) and parts[0] not in (".forgejo", ".claude")
if (f["status"] != "changed" and len(parts) == 1 and not path.endswith(".md")) or new_hidden:
triggers.append(path + " (" + f["status"] + " at top level)")
paths = [f["filename"] for f in files]
if triggers:
return "haut", sorted(set(triggers))
if all(match(LOW, p) and not match(NEVER_LOW, p) for p in paths):
return "bas", []
return "normal", []
CONDITIONAL = {
"index.js", "{index,metrics,claude-usage,claude-usage-history,backups}.js",
"__tests__/{auth,writes}.test.js", "package.json", "package-lock.json", ".gitignore",
"REVIEW.md",
}
def check():
"""Compare this script's patterns with the first column of the Haut table."""
text = open(REVIEW_MD, encoding="utf-8").read()
table = text.split("### Haut", 1)[1].split("\n\n", 2)[1]
documented = set()
for row in table.split("\n"):
if not row.startswith("| ") or row.startswith("| Motif") or row.startswith("| fichier à la racine"):
continue
documented.update(re.findall(r"`([^`]+)`", row.split(" | ")[0]))
coded = set(HIGH_ALWAYS) | CONDITIONAL
for label, extra in (("REVIEW.md only", documented - coded), ("script only", coded - documented)):
for pattern in sorted(extra):
print(f"{label}: {pattern}")
if documented != coded:
return 1
print(f"ok: {len(coded)} patterns, identical to the Haut table of REVIEW.md")
return 0
def main():
if sys.argv[1:] == ["--check"]:
sys.exit(check())
numbers = [int(a) for a in sys.argv[1:]] or LANDED
rows = []
for n in numbers:
if n in EMPTY_DIFF:
diff = subprocess.run(["git", "diff", *EMPTY_DIFF[n]], capture_output=True, text=True, check=True).stdout
else:
diff = get(f"/pulls/{n}.diff", raw=True).decode("utf-8", "replace")
files = parse_diff(diff)
if not files:
sys.exit(f"#{n}: empty diff, the level would read as bas")
level, triggers = review_level(files)
rows.append((n, level))
print(f"#{n}\t{level}\t{'; '.join(triggers)}")
counts = {lvl: sum(1 for r in rows if r[1] == lvl) for lvl in ("bas", "normal", "haut")}
print(f"{len(rows)} PRs: {counts} -> haut {100 * counts['haut'] / len(rows):.1f} %")
if __name__ == "__main__":
main()

View file

@ -1,612 +0,0 @@
#!/usr/bin/env bash
# test-curl.sh — manual smoke test for vps-health-api endpoints.
#
# Spins up the server against a temporary REPORTS_DIR and HOSTS_DIR populated
# with fixtures, then runs curl against each endpoint and checks status codes +
# payload shape.
#
# This is the socket-level pass, not a substitute for `npm test`: vitest covers
# the logic, this script covers what only a real client and a real TCP socket
# can show — a 413 answered before the connection is cut, the two tokens
# refusing each other's routes, and the 503 a server booted without
# HOSTS_INGEST_TOKEN gives on the ingestion path.
#
# Usage :
# bash test-curl.sh
# TEST_CURL_PORT=<port> TEST_CURL_NOINGEST_PORT=<port> bash test-curl.sh
#
# The two servers listen on 3099 and 3098 unless these variables name other
# ports. The URLs follow from the ports and are not read from the environment.
#
# Exit 0 if all cases pass, exit 1 otherwise.
set -euo pipefail
# Every curl below targets this machine's loopback, and a proxy from the
# environment (http_proxy, ALL_PROXY) would answer in its place: the wait loops
# and the cases would talk to the proxy instead of the servers started here.
# Both spellings, since curl reads the lowercase one first and an inherited
# no_proxy would win. Same line as scripts/boot-from-copy.sh.
export NO_PROXY='*' no_proxy='*'
# Several cases compare what `node -e 'console.log(...)'` prints. Under
# FORCE_COLOR node colours a number even into a pipe, and "3" stops matching.
unset FORCE_COLOR
# The ports the two servers listen on. Names of their own rather than PORT: a
# PORT inherited from the shell would move the servers without a word.
MAIN_PORT="${TEST_CURL_PORT:-3099}"
# Second instance, booted WITHOUT HOSTS_INGEST_TOKEN, for the fail-closed case.
NOINGEST_PORT="${TEST_CURL_NOINGEST_PORT:-3098}"
# Refused here, with the cause named. Port 0 would make node listen on a port
# of its own choosing, and two identical ports pass require_free: both would
# only end on a wait_for message that says nothing of the value given.
require_port() {
if ! [[ "$2" =~ ^[1-9][0-9]{0,4}$ ]] || [ "$2" -gt 65535 ]; then
echo "test-curl.sh: $1 must be a port number (1-65535), got '$2'" >&2
exit 1
fi
}
require_port TEST_CURL_PORT "$MAIN_PORT"
require_port TEST_CURL_NOINGEST_PORT "$NOINGEST_PORT"
if [ "$MAIN_PORT" = "$NOINGEST_PORT" ]; then
echo "test-curl.sh: TEST_CURL_PORT and TEST_CURL_NOINGEST_PORT must differ (both $MAIN_PORT)" >&2
exit 1
fi
# Derived, never read from the environment: a URL overridden on its own would
# send every curl below somewhere else than the servers started here.
BASE_URL="http://localhost:$MAIN_PORT"
NOINGEST_URL="http://localhost:$NOINGEST_PORT"
# Fixed, like the URLs: the script starts its own servers, so nothing outside
# has a say in the tokens they expect.
TOKEN="test-token-123"
# Deliberately different from TOKEN — every compartmentalisation case below is
# only meaningful because the two values cannot be confused. Read from the
# environment, two inherited values that happened to be equal would break them.
INGEST_TOKEN="test-ingest-token-456"
# Third token, for POST /claude-usage (#48), fixed for the same reason.
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)"
SERVER_PID=""
NOINGEST_PID=""
# Each kill carries its own `|| true`: under `set -e` a kill that fails (the
# first server already dead) would end the trap there and leave the second
# server running on its port. The final `wait` holds the exit until both are
# gone: a run started right after this one must find the two ports free.
trap 'rm -rf "$TMP_DIR"; [ -n "$SERVER_PID" ] && { kill "$SERVER_PID" 2>/dev/null || true; }; [ -n "$NOINGEST_PID" ] && { kill "$NOINGEST_PID" 2>/dev/null || true; }; wait 2>/dev/null || true' EXIT
# Both ports must be free before anything starts. With a listener already on
# one of them, the server started here dies of EADDRINUSE, but only after the
# wait loop has had time to reach the neighbour: the cases would then run
# against a server this script never started. Same rule as
# scripts/boot-from-copy.sh: free means "connection refused" (curl exit 7); an
# HTTP answer (0), a listener that stalls (28) or a non-HTTP service (52, 56)
# all mean taken.
require_free() {
local rc=0
curl -s -o /dev/null --max-time 2 "$1/" || rc=$?
if [ "$rc" != "7" ]; then
echo "test-curl.sh: $1 is already taken (curl exit $rc)" >&2
exit 1
fi
}
require_free "$BASE_URL"
require_free "$NOINGEST_URL"
# Fixtures :
# - 3 scan reports on 2026-05-07 (booking, simpl-liste, maximus)
# - 1 defenseur-auto run report on 2026-05-07 (must be filtered out)
# - 1 booking scan report on 2026-05-06 (must be excluded by date filter)
# - 1 archived scan report on 2026-05-04 (sergent rotated it post-07:30 UTC)
# - 1 archived scan report on 2026-05-07 used to assert top-level priority
# when the same filename also exists in REPORTS_DIR (defensive dedupe).
mkdir -p "$TMP_DIR/reports"
mkdir -p "$TMP_DIR/reports/archive"
cat > "$TMP_DIR/reports/defenseur-booking_2026-05-07T05-30-11-249Z.json" <<'JSON'
{
"agent": "defenseur-booking",
"timestamp": "2026-05-07T05:30:11.249Z",
"project": "la-suite-booking",
"checksRun": 16,
"checksPassed": 14,
"findings": []
}
JSON
cat > "$TMP_DIR/reports/defenseur-simpl-liste_2026-05-07T05-32-04-512Z.json" <<'JSON'
{
"agent": "defenseur-simpl-liste",
"timestamp": "2026-05-07T05:32:04.512Z",
"project": "simpl-liste",
"checksRun": 12,
"checksPassed": 11,
"findings": []
}
JSON
cat > "$TMP_DIR/reports/defenseur-maximus_2026-05-07T05-00-12-100Z.json" <<'JSON'
{
"agent": "defenseur-maximus",
"timestamp": "2026-05-07T05:00:12.100Z",
"project": "la-compagnie-maximus",
"checksRun": 8,
"checksPassed": 8,
"findings": []
}
JSON
cat > "$TMP_DIR/reports/defenseur-auto_2026-05-07.json" <<'JSON'
{
"agent": "defenseur-auto",
"timestamp": "2026-05-07T07:00:00.000Z",
"status": "ok",
"actions": [],
"skipped": [],
"totalCostUsd": 0
}
JSON
cat > "$TMP_DIR/reports/defenseur-booking_2026-05-06T05-30-00-000Z.json" <<'JSON'
{
"agent": "defenseur-booking",
"timestamp": "2026-05-06T05:30:00.000Z",
"project": "la-suite-booking",
"checksRun": 16,
"checksPassed": 16,
"findings": []
}
JSON
# Archived scan report (sergent renameSync at 07:30 UTC moves files here).
cat > "$TMP_DIR/reports/archive/defenseur-vps_2026-05-04T05-15-00-000Z.json" <<'JSON'
{
"agent": "defenseur-vps",
"timestamp": "2026-05-04T05:15:00.000Z",
"project": "vps",
"checksRun": 10,
"checksPassed": 10,
"findings": []
}
JSON
# Same filename present at top-level (already created above) AND in archive/.
# Top-level wins (more recent — the archive copy is the stale one). The
# archive copy carries agent="defenseur-maximus-STALE" so the dedupe
# regression case can detect a leak.
cat > "$TMP_DIR/reports/archive/defenseur-maximus_2026-05-07T05-00-12-100Z.json" <<'JSON'
{
"agent": "defenseur-maximus-STALE",
"timestamp": "2026-05-07T05:00:12.100Z",
"project": "la-compagnie-maximus",
"checksRun": 1,
"checksPassed": 0,
"findings": []
}
JSON
# HOSTS_DIR is created by the server on first write; the parent must exist and
# be writable — the same requirement the /data/hosts mount carries in prod.
mkdir -p "$TMP_DIR/hosts"
# Sergent findings state, served verbatim by GET /defenseurs/state. The spacing
# is deliberately non-canonical: a server that parsed and re-serialised the file
# would not reproduce it byte for byte.
printf '%s\n' '{ "schemaVersion":1, "findings": {"a-deps-1": {"firstSeen":"2026-09-01T00:00:00.000Z"}},"history":[] }' \
> "$TMP_DIR/findings-state.json"
# Boot the server with the temp REPORTS_DIR and HOSTS_DIR.
PORT="$MAIN_PORT" \
HEALTH_TOKEN="$TOKEN" \
REPORTS_DIR="$TMP_DIR/reports" \
HOSTS_DIR="$TMP_DIR/hosts" \
DEFENSEURS_FINDINGS_STATE_PATH="$TMP_DIR/findings-state.json" \
HOSTS_ALLOWED_IDS="thinkpad,workbench" \
HOSTS_INGEST_TOKEN="$INGEST_TOKEN" \
CLAUDE_USAGE_PATH="$TMP_DIR/claude-usage/claude-usage.json" \
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" \
node "$(dirname "$0")/index.js" >/dev/null 2>&1 &
SERVER_PID=$!
# Second instance with HOSTS_INGEST_TOKEN unset, to observe the fail-closed 503
# on the ingestion path. Its HOSTS_DIR is separate so a stray write cannot
# pollute the fixtures of the main instance.
mkdir -p "$TMP_DIR/hosts-noingest"
PORT="$NOINGEST_PORT" \
HEALTH_TOKEN="$TOKEN" \
REPORTS_DIR="$TMP_DIR/reports" \
HOSTS_DIR="$TMP_DIR/hosts-noingest" \
DEFENSEURS_FINDINGS_STATE_PATH="$TMP_DIR/absent-findings-state.json" \
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" \
node "$(dirname "$0")/index.js" >/dev/null 2>&1 &
NOINGEST_PID=$!
# Wait for both servers to be ready. A server that died or never answers stops
# the run here, with a message: without it the script went on to the first bare
# curl, which `set -e` turned into an exit with no output at all. The deadline
# runs on the clock, not on a number of tries: a try can last up to a second
# against a listener that accepts and stalls.
#
# The process is checked on both sides of the probe. require_free above is what
# keeps a neighbour out; the second check only narrows what is left, a listener
# that takes the port between require_free and the server's own listen.
wait_for() {
local deadline=$((SECONDS + 5))
while [ "$SECONDS" -lt "$deadline" ]; do
if ! kill -0 "$2" 2>/dev/null; then
echo "test-curl.sh: the server for $1 exited before answering" >&2
exit 1
fi
if curl -s -o /dev/null --max-time 1 "$1/health" 2>/dev/null; then
kill -0 "$2" 2>/dev/null && return 0
echo "test-curl.sh: $1 answered, but the server started for it is gone" >&2
exit 1
fi
sleep 0.1
done
echo "test-curl.sh: no server answered at $1 within 5 s" >&2
exit 1
}
wait_for "$BASE_URL" "$SERVER_PID"
wait_for "$NOINGEST_URL" "$NOINGEST_PID"
PASS=0
FAIL=0
fail() {
echo "FAIL: $1"
FAIL=$((FAIL+1))
}
pass() {
echo "PASS: $1"
PASS=$((PASS+1))
}
# `set -e` is on and the 413 case ends with the server calling req.destroy()
# once the response is flushed — curl can therefore read the status line and
# still exit non-zero on the reset that follows. Wrapping every probe keeps a
# transport-level hiccup a FAIL line instead of a silent early exit; a curl
# that truly failed reports "000", which no case accepts.
http_code() {
curl -s -o /dev/null -w '%{http_code}' "$@" || true
}
http_body() {
curl -s "$@" || true
}
# Case 1 : no auth -> 401
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/reports/scans?date=2026-05-07")
[[ "$code" == "401" ]] && pass "no-auth -> 401" || fail "no-auth -> got $code"
# Case 2 : wrong token -> 401
code=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer wrong" "$BASE_URL/reports/scans?date=2026-05-07")
[[ "$code" == "401" ]] && pass "wrong-token -> 401" || fail "wrong-token -> got $code"
# Case 3 : valid token + date 2026-05-07 -> 200, count=3, sorted asc, no auto report
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/reports/scans?date=2026-05-07")
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
agents=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).reports.map(r=>r.agent).join(","));});')
[[ "$count" == "3" ]] && pass "valid date 2026-05-07 -> count=3" || fail "valid date 2026-05-07 -> count=$count"
[[ "$agents" == "defenseur-maximus,defenseur-booking,defenseur-simpl-liste" ]] \
&& pass "sort by timestamp asc + auto filtered" \
|| fail "sort/filter mismatch -> $agents"
# Case 4 : invalid date format -> 400
code=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $TOKEN" "$BASE_URL/reports/scans?date=hello")
[[ "$code" == "400" ]] && pass "invalid date -> 400" || fail "invalid date -> got $code"
# Case 5 : missing date param -> 400
code=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $TOKEN" "$BASE_URL/reports/scans")
[[ "$code" == "400" ]] && pass "missing date -> 400" || fail "missing date -> got $code"
# Case 6 : valid token + unknown date (no fixtures) -> 200 count=0
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/reports/scans?date=2025-01-01")
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
[[ "$count" == "0" ]] && pass "unknown date -> count=0" || fail "unknown date -> count=$count"
# Case 7 : valid token + date 2026-05-06 -> 200 count=1
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/reports/scans?date=2026-05-06")
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
[[ "$count" == "1" ]] && pass "date 2026-05-06 -> count=1" || fail "date 2026-05-06 -> count=$count"
# Case 8 : path traversal -> 400 (regex blocks)
code=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $TOKEN" \
"$BASE_URL/reports/scans?date=../../../etc/passwd")
[[ "$code" == "400" ]] && pass "path traversal -> 400" || fail "path traversal -> got $code"
# Case 9 : POST -> 404 (GET-only)
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: Bearer $TOKEN" \
"$BASE_URL/reports/scans?date=2026-05-07")
[[ "$code" == "404" ]] && pass "POST -> 404" || fail "POST -> got $code"
# Case 10 : wrong path -> 404
code=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $TOKEN" "$BASE_URL/reports/nope")
[[ "$code" == "404" ]] && pass "wrong path -> 404" || fail "wrong path -> got $code"
# Case 11 : archive-only date -> 200 count=1, returns the archived report.
# Reproduces the post-07:30 UTC window (sergent rotated all reports out of
# REPORTS_DIR into REPORTS_DIR/archive).
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/reports/scans?date=2026-05-04")
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
agent=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).reports[0]?.agent||"");});')
[[ "$count" == "1" ]] && pass "archive-only date 2026-05-04 -> count=1" || fail "archive-only date 2026-05-04 -> count=$count"
[[ "$agent" == "defenseur-vps" ]] && pass "archive report agent matches" || fail "archive report agent mismatch -> $agent"
# Case 12 : top-level + archive same filename -> top-level wins (defensive
# dedupe). The archive copy carries agent="defenseur-maximus-STALE" — if we
# see that string in the response we picked the wrong copy.
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/reports/scans?date=2026-05-07")
stale=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const a=JSON.parse(s).reports.map(r=>r.agent);console.log(a.includes("defenseur-maximus-STALE")?"yes":"no");});')
[[ "$stale" == "no" ]] && pass "top-level priority over archive (no STALE)" || fail "archive copy leaked -> reports include STALE"
# Also assert count is still 3 — no duplication of the maximus report.
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
[[ "$count" == "3" ]] && pass "no dedupe duplication on 2026-05-07" || fail "dedupe duplication -> count=$count"
# Case 13 : missing archive/ subdir is OK (silent skip). Remove the directory
# and re-query 2026-05-07 — should still return the 3 top-level reports.
rm -rf "$TMP_DIR/reports/archive"
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/reports/scans?date=2026-05-07")
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
[[ "$count" == "3" ]] && pass "missing archive/ -> still count=3 from top-level" || fail "missing archive/ -> count=$count"
# And the archive-only date now collapses to 0 silently.
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/reports/scans?date=2026-05-04")
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
[[ "$count" == "0" ]] && pass "missing archive/ + archive-only date -> count=0" || fail "missing archive/ archive-only -> count=$count"
# ---------------------------------------------------------------------------
# Workstation snapshots — POST /hosts/<id> and GET /hosts (issue #16)
#
# Allowlist for this run is "thinkpad,workbench"; `laptop` is the well-formed
# id that is deliberately NOT on it, and `ThinkPad` the malformed one.
# ---------------------------------------------------------------------------
# Fixtures for the ingestion cases.
cat > "$TMP_DIR/snapshot.json" <<'JSON'
{
"hostname": "thinkpad-x1",
"uptime": 123456,
"cpu": { "model": "Intel Core i7", "cores": 8, "loadAvg": [0.4, 0.5, 0.6], "usagePercent": 12.5 },
"memory": { "totalGB": 16, "usedGB": 7.5, "freeGB": 8.5, "usagePercent": 46.9 },
"disk": { "totalGB": 512, "usedGB": 210, "freeGB": 302, "usagePercent": 41 }
}
JSON
# Same shape, plus an unknown key and a __proto__ that must never reach the
# persisted file (which GET /hosts feeds straight into the admin's React tree),
# and a hostname well past the 128-character cap.
node -e '
const payload = {
hostname: "h".repeat(300),
uptime: 42,
cpu: { model: "M", cores: 4, loadAvg: [0, 0, 0], usagePercent: 1 },
memory: { totalGB: 8, usedGB: 4, freeGB: 4, usagePercent: 50 },
disk: { totalGB: 100, usedGB: 50, freeGB: 50, usagePercent: 50 },
injected: "should not survive",
};
// Spliced in as text on purpose: written as `__proto__:` in an object literal
// it would SET the prototype and JSON.stringify would emit nothing, leaving
// this case asserting against a payload that never carried the key.
const hostile = "{\"__proto__\":{\"polluted\":true}," + JSON.stringify(payload).slice(1);
require("fs").writeFileSync(process.argv[1], hostile);
' "$TMP_DIR/hostile.json"
# Body over the 4 KiB ceiling — padding lives in a legitimate field so the
# request is rejected on size alone, not on shape.
node -e '
const payload = {
hostname: "x".repeat(5000),
uptime: 1,
cpu: { model: "M", cores: 1, loadAvg: [0, 0, 0], usagePercent: 0 },
memory: { totalGB: 1, usedGB: 0, freeGB: 1, usagePercent: 0 },
disk: { totalGB: 1, usedGB: 0, freeGB: 1, usagePercent: 0 },
};
require("fs").writeFileSync(process.argv[1], JSON.stringify(payload));
' "$TMP_DIR/oversize.json"
# Case 14 : GET /hosts without auth -> 401
code=$(http_code "$BASE_URL/hosts")
[[ "$code" == "401" ]] && pass "GET /hosts no-auth -> 401" || fail "GET /hosts no-auth -> got $code"
# Case 15 : GET /hosts with a wrong token -> 401
code=$(http_code -H "Authorization: Bearer wrong" "$BASE_URL/hosts")
[[ "$code" == "401" ]] && pass "GET /hosts wrong-token -> 401" || fail "GET /hosts wrong-token -> got $code"
# Case 16 : the ingest token must NOT open a read route.
code=$(http_code -H "Authorization: Bearer $INGEST_TOKEN" "$BASE_URL/hosts")
[[ "$code" == "401" ]] && pass "GET /hosts with ingest token -> 401" || fail "GET /hosts with ingest token -> got $code"
# Case 17 : before any push, every allowlisted id is present and neverSeen.
body=$(http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/hosts")
ids=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).hosts.map(h=>h.id).join(","));});')
never=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).hosts.every(h=>h.neverSeen===true&&h.online===false)?"yes":"no");});')
stale=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).staleAfterSeconds);});')
[[ "$ids" == "thinkpad,workbench" ]] && pass "GET /hosts lists the allowlist sorted" || fail "GET /hosts ids -> $ids"
[[ "$never" == "yes" ]] && pass "unpushed hosts degrade to neverSeen + offline" || fail "neverSeen degradation -> $never"
[[ "$stale" == "900" ]] && pass "staleAfterSeconds defaults to 900" || fail "staleAfterSeconds -> $stale"
# Case 18 : POST /hosts/<id> without auth -> 401
code=$(http_code -X POST -H "Content-Type: application/json" \
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/thinkpad")
[[ "$code" == "401" ]] && pass "POST /hosts/thinkpad no-auth -> 401" || fail "POST /hosts/thinkpad no-auth -> got $code"
# Case 19 : the read token must NOT open the ingestion route.
code=$(http_code -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/thinkpad")
[[ "$code" == "401" ]] && pass "POST /hosts/thinkpad with read token -> 401" || fail "POST with read token -> got $code"
# Case 20 : 401 comes before 403 — an unauthenticated caller cannot probe the
# allowlist by watching the status code change.
code=$(http_code -X POST -H "Content-Type: application/json" \
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/laptop")
[[ "$code" == "401" ]] && pass "POST unknown id no-auth -> 401 (not 403)" || fail "POST unknown id no-auth -> got $code"
# Case 21 : well-formed id, valid token, not on the allowlist -> 403
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/laptop")
[[ "$code" == "403" ]] && pass "POST /hosts/laptop authenticated -> 403" || fail "POST /hosts/laptop -> got $code"
# Case 22 : malformed id never reaches the handler — the route regex is the
# first control, so it answers 404 rather than 403.
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/ThinkPad")
[[ "$code" == "404" ]] && pass "POST malformed id -> 404" || fail "POST malformed id -> got $code"
# Case 23 : traversal attempt collapses to a path the router does not know.
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/../../etc/passwd")
[[ "$code" == "404" ]] && pass "POST traversal -> 404" || fail "POST traversal -> got $code"
# Case 24 : the ingestion path is POST-only.
code=$(http_code -H "Authorization: Bearer $TOKEN" "$BASE_URL/hosts/thinkpad")
[[ "$code" == "404" ]] && pass "GET /hosts/thinkpad -> 404 (POST-only)" || fail "GET /hosts/thinkpad -> got $code"
# Case 25 : unreadable JSON -> 400
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
-d 'not json at all' "$BASE_URL/hosts/thinkpad")
[[ "$code" == "400" ]] && pass "POST unreadable JSON -> 400" || fail "POST unreadable JSON -> got $code"
# Case 26 : valid JSON, missing field -> 400
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
-d '{"hostname":"x"}' "$BASE_URL/hosts/thinkpad")
[[ "$code" == "400" ]] && pass "POST missing field -> 400" || fail "POST missing field -> got $code"
# Case 27 : body over 4 KiB -> 413. The server answers, THEN destroys the
# socket, so curl can exit 55/56 on the reset after having read the status line;
# http_code absorbs that. `-H "Expect:"` suppresses the 100-continue handshake
# curl adds on larger uploads (it changes WHEN the body is sent, and with it
# which side notices the reset first), and --max-time keeps a half-closed
# socket from hanging the run. A curl that gets nothing at all reports 000,
# which fails the case rather than passing it silently.
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
-H "Expect:" --http1.1 --max-time 10 \
--data-binary @"$TMP_DIR/oversize.json" "$BASE_URL/hosts/thinkpad")
[[ "$code" == "413" ]] && pass "POST oversize body -> 413" || fail "POST oversize body -> got $code"
# Case 28 : the happy path -> 204 with no body.
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/thinkpad")
[[ "$code" == "204" ]] && pass "POST valid snapshot -> 204" || fail "POST valid snapshot -> got $code"
# Case 29 : the snapshot is now readable, fresh, and stamped by the server.
body=$(http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/hosts")
host=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const h=JSON.parse(s).hosts.find(x=>x.id==="thinkpad");console.log([h.hostname,h.online,h.ageSeconds<60,h.neverSeen===undefined].join("|"));});')
[[ "$host" == "thinkpad-x1|true|true|true" ]] \
&& pass "GET /hosts reflects the push (online, fresh, no neverSeen)" \
|| fail "GET /hosts after push -> $host"
# The other allowlisted id is untouched — one file per host, no cross-talk.
other=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).hosts.find(x=>x.id==="workbench").neverSeen===true?"yes":"no");});')
[[ "$other" == "yes" ]] && pass "a push for one id leaves the others neverSeen" || fail "cross-talk between ids -> $other"
# Case 30 : hostile payload is rebuilt from the whitelist — the unknown key and
# the __proto__ are dropped, the long hostname is cut to 128 characters.
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
--data-binary @"$TMP_DIR/hostile.json" "$BASE_URL/hosts/workbench")
[[ "$code" == "204" ]] && pass "POST hostile payload -> 204 (accepted, sanitised)" || fail "POST hostile payload -> got $code"
body=$(http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/hosts")
clean=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const h=JSON.parse(s).hosts.find(x=>x.id==="workbench");const own=Object.keys(h);console.log([h.hostname.length,own.includes("injected"),own.includes("polluted")].join("|"));});')
[[ "$clean" == "128|false|false" ]] \
&& pass "hostile payload sanitised (128-char cap, unknown keys dropped)" \
|| fail "sanitisation -> $clean"
# And on disk: the persisted file carries the whitelist and nothing else.
keys=$(node -e 'const r=require("fs").readFileSync(process.argv[1],"utf8");console.log(Object.keys(JSON.parse(r)).sort().join(","));' "$TMP_DIR/hosts/workbench.json")
[[ "$keys" == "cpu,disk,hostname,id,memory,receivedAt,uptime" ]] \
&& pass "persisted file holds the whitelist only" \
|| fail "persisted keys -> $keys"
# Case 31 : fail-closed. On an instance booted without HOSTS_INGEST_TOKEN the
# ingestion path answers 503 — never 204, never a silently dropped snapshot.
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
--data-binary @"$TMP_DIR/snapshot.json" "$NOINGEST_URL/hosts/thinkpad")
[[ "$code" == "503" ]] && pass "no HOSTS_INGEST_TOKEN -> POST answers 503" || fail "fail-closed ingest -> got $code"
# Reads keep working on that instance: the two tokens are independent.
code=$(http_code -H "Authorization: Bearer $TOKEN" "$NOINGEST_URL/hosts")
[[ "$code" == "200" ]] && pass "no HOSTS_INGEST_TOKEN -> GET /hosts still 200" || fail "read path on fail-closed instance -> got $code"
# Case 32 : GET /defenseurs/state serves findings-state.json byte for byte.
# Compared through files so the trailing newline is part of the comparison
# (command substitution would strip it).
http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/defenseurs/state" > "$TMP_DIR/state-served.json"
cmp -s "$TMP_DIR/state-served.json" "$TMP_DIR/findings-state.json" \
&& pass "GET /defenseurs/state -> file served byte for byte" \
|| fail "GET /defenseurs/state -> body differs from the file"
# Absent file -> 200 no_data, the same contract as /defenseurs.
body=$(http_body -H "Authorization: Bearer $TOKEN" "$NOINGEST_URL/defenseurs/state")
[[ "$body" == '{"status":"no_data"}' ]] \
&& pass "GET /defenseurs/state, file absent -> no_data" \
|| fail "GET /defenseurs/state absent -> $body"
code=$(http_code "$BASE_URL/defenseurs/state")
[[ "$code" == "401" ]] && pass "GET /defenseurs/state no-auth -> 401" || fail "GET /defenseurs/state no-auth -> got $code"
# A read route: the ingest token must not open it.
code=$(http_code -H "Authorization: Bearer $INGEST_TOKEN" "$BASE_URL/defenseurs/state")
[[ "$code" == "401" ]] && pass "GET /defenseurs/state with ingest token -> 401" || fail "GET /defenseurs/state ingest token -> got $code"
# Case 33 : GET /claude-usage/history (#55), the archive of the closed days.
code=$(http_code "$BASE_URL/claude-usage/history")
[[ "$code" == "401" ]] && pass "GET /claude-usage/history no-auth -> 401" || fail "GET /claude-usage/history no-auth -> got $code"
code=$(http_code -H "Authorization: Bearer $CLAUDE_TOKEN" "$BASE_URL/claude-usage/history")
[[ "$code" == "401" ]] && pass "GET /claude-usage/history with the claude ingest token -> 401" || fail "history with ingest token -> got $code"
code=$(http_code -H "Authorization: Bearer $TOKEN" "$BASE_URL/claude-usage/history?from=2026-13")
[[ "$code" == "400" ]] && pass "GET /claude-usage/history?from=2026-13 -> 400" || fail "history bad from -> got $code"
# A two-day snapshot: the first day is closed and lands in history/2026-09.json.
printf '%s' '{"schemaVersion":1,"days":[{"date":"2026-09-29","models":{"claude-opus-5-5":{"input":1,"output":2,"requests":1}}},{"date":"2026-09-30","models":{}}]}' \
> "$TMP_DIR/claude-usage.json"
code=$(http_code -X POST -H "Authorization: Bearer $CLAUDE_TOKEN" -H "Content-Type: application/json" \
--data-binary @"$TMP_DIR/claude-usage.json" "$BASE_URL/claude-usage")
[[ "$code" == "204" ]] && pass "POST /claude-usage two-day snapshot -> 204" || fail "POST /claude-usage -> got $code"
body=$(http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/claude-usage/history?from=2026-09&to=2026-09")
dates=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).days.map(d=>d.date).join(","));});')
[[ "$dates" == "2026-09-29" ]] && pass "GET /claude-usage/history serves the closed day only" || fail "history after push -> $dates"
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"
# 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 "=== Results: $PASS passed, $FAIL failed ==="
[[ "$FAIL" == "0" ]] || exit 1

View file

@ -1,7 +0,0 @@
module.exports = {
test: {
environment: "node",
globals: true,
include: ["__tests__/**/*.test.js"],
},
};