feat(claude-usage): archive closed days by month, history route, one-shot backfill #57
14 changed files with 1443 additions and 25 deletions
22
CLAUDE.md
22
CLAUDE.md
|
|
@ -12,8 +12,9 @@ API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime.
|
|||
|
||||
- `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`.
|
||||
- `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`).
|
||||
|
||||
## Routage et auth
|
||||
|
||||
|
|
@ -50,7 +51,7 @@ Consequence voulue : un appelant non authentifie sur une route inconnue voit 404
|
|||
- `/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. A poser par Max avant le premier push de l'agent.
|
||||
- `/data/claude-usage` (host) -> `/data/claude-usage` : snapshot de consommation Claude (issue #48). **Lecture-ecriture**, proprietaire uid 1000, meme regle que `/data/hosts` (sinon chaque `POST /claude-usage` repond 500). Monter le **dossier**, jamais le fichier seul : le snapshot est remplace par `rename`, un bind de fichier resterait accroche a l'ancien inode. A poser par Max avant le premier push de l'agent. Le sous-dossier `history/` (#55) vit sur ce meme montage : rien a poser, le serveur le cree au premier POST de plus d'un jour.
|
||||
- Deux couches tiennent l'API hors des fichiers Defenseurs. (1) Le mode du montage : `:ro` est un suffixe de la destination dans Coolify, recopie tel quel dans le compose genere (`'/data/defenseurs:/data/defenseurs:ro'`). (2) Les droits Unix : le contenu appartient a `defenseur:defenseur` (uid et gid 1001), dossiers en 755/775, fichiers en 664, et le conteneur tourne en uid 1000 (`node`, gid 1000, sans groupe supplementaire), donc `test -w` y echoue meme sans `:ro`. Ce sont les bits de groupe qui donnent l'ecriture : cette couche tient parce que le groupe proprietaire n'est pas le gid 1000 (groupe de `node` dans l'image, et de `ubuntu` sur l'hote). Un `chgrp ubuntu` ou un `chmod o+w` la ferait tomber sans bruit. Le `:ro` ne vaut que pour la vue du conteneur : le Sergent ecrit cote hote comme avant.
|
||||
- Verification apres tout redeploy, par trois vues : `docker inspect <conteneur> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} RW={{.RW}}{{println}}{{end}}'` (`RW=false` sur les deux montages Defenseurs, `RW=true` sur `/data/hosts` et `/data/claude-usage`) ; `/proc/mounts` dans le conteneur (vue du noyau) ; et le **contenu** des routes de lecture, pas seulement leur code, car un montage manquant repond 200 `no_data`.
|
||||
|
||||
|
|
@ -68,30 +69,31 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
|
|||
## 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) — 634 cas
|
||||
- `npm test` (vitest) — 731 cas
|
||||
- `__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 (67 cas : matrice route x mode d'echec, en-tetes malformes (dont le separateur `Bearer` autre qu'un espace), quasi-rates du token sur les 6 routes de lecture, fail-closed, **2 cas `404 (not 401)` qui figent l'ordre routage -> auth**, **un espion sur `timingSafeEqual`** qui exige deux empreintes de 32 octets a chaque requete, et **des epingles sur le source** : aucune egalite contre une chaine `Bearer`, un seul `tokenMatches()` et un seul `return null` dans `checkAuth()`, egalites limitees a une liste blanche, `tokenMatches()` qui hache les deux cotes sans test de longueur)
|
||||
- `__tests__/auth.test.js` — filet de securite auth sur les routes de lecture (76 cas : matrice route x mode d'echec, en-tetes malformes (dont le separateur `Bearer` autre qu'un espace), quasi-rates du token sur les 7 routes de lecture (`/claude-usage/history` comprise, #55), fail-closed, **3 cas `404 (not 401)` qui figent l'ordre routage -> auth**, **un espion sur `timingSafeEqual`** qui exige deux empreintes de 32 octets a chaque requete, et **des epingles sur le source** : aucune egalite contre une chaine `Bearer`, un seul `tokenMatches()` et un seul `return null` dans `checkAuth()`, egalites limitees a une liste blanche, `tokenMatches()` qui hache les deux cotes sans test de longueur)
|
||||
- `__tests__/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 (126 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
|
||||
- `__tests__/ci.test.js` — epingles du workflow et des scripts de CI (48 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 45 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, 45 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` (#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) (14 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 six routes GET n'ecrivent rien, meme une macrotache apres la reponse, contenu reel exige ; `POST /hosts/<id>` n'ecrit que sous `HOSTS_DIR` ; **chemins degrades** : rejets 401/403/404, entrees 400/413, `agents-map.json` corrompu, fichiers Defenseurs absents, snapshot corrompu, avec le contenu degrade exige (`no_data`, `neverSeen`) ; **commandes shell** figees sur `head -1 /proc/stat` et `df -k /`, `/health` servant de temoin ; chaque cas prouve que ses espions sont vivants ; **tout appel est compte, sans attribution par pile** (#40 : une fonction `node:fs` passee telle quelle a `process.nextTick`, a un ecouteur ou a un `.then` s'execute sans frame runtime sur sa pile, et un filtre par pile la laissait passer) ; les retouches de fixtures du cas degrade passent par les fonctions reelles capturees avant la pose des espions, jamais par un `rmSync` recursif, qui appelle en interne le `fs.rmdirSync` public, donc l'espion ; **epingle sur le bloc `require.main`**, ensemble exact des appels, puisqu'il ne tourne jamais dans le processus de test. Limites : un chemin qu'aucun cas n'emprunte, et une ecriture differee au-dela de 20 ms. Contrepartie assumee : une ecriture du lanceur de tests lui-meme pendant un cas le ferait rougir)
|
||||
- `__tests__/agent-claude-usage.test.js` — agent claude-usage du ThinkPad (136 cas). Agregation des transcripts, #49 (52 cas) : jours Toronto et changement d'heure, regles de projet, deduplication des requetes, 30 jours, top 8 + `(autres)`, 12 modeles, seuil de scan pris sur le plus ancien jour ouvert (jour fige futur, trou dans la serie figee, tout fige), noms `__proto__` et `constructor` sans ecriture sur `Object.prototype` ; #54 : requete a cheval sur minuit comptee une seule fois (jours figes ecartes apres la deduplication, egalite d'`output` gagnee par la ligne la plus ancienne quel que soit l'ordre de lecture), erreurs de lecture comptees (`readdir`, `ENOENT` d'un sous-dossier compris, `stat` y compris `ENOENT`, flux, ouverture), racine absente non comptee. Agent, #50 (74 cas) : reponse anonymisee de l'endpoint (`__tests__/fixtures/claude-usage/oauth-usage.json`) vers les items, repli `five_hour`/`seven_day`, tout `null` -> `unavailable` ; `fetch` (double) jamais appele sur jeton expire, `off`, credentials tronques, absents ou sans jeton, version absente ; 429 -> `http_error`/`http_429` avec les dernieres limites valides et leur `fetchedAt` ; corps hostiles -> `detail` dans l'enum ; bearer OAuth envoye a la seule URL Anthropic epinglee, aucune variable d'env ne la change (epingle sur le source), `redirect: "error"` ; **espions sur toutes les ecritures de `node:fs`** : le fichier de credentials n'est que lu ; aucun fragment de 5 caracteres des deux jetons dans le snapshot, l'etat ni la sortie (dont un vrai processus) ; deux passages autour d'un jour vide (fige, puis aucun fichier sous le seuil ouvert) ; `state.json` corrompu, elagage a 35 jours, ecriture atomique en 600, temporaire laisse en 644 ramene a 600 (#54) ; passage avec erreur de lecture : aucun jour fige, snapshot envoye, `read_errors=N` sans chemin, le passage suivant fige (#54) ; reponse de l'endpoint comptee en octets pendant la lecture, 256 Kio pile acceptes, + 1 octet en caracteres de 2 octets refuse, flux sans fin annule (#54) ; snapshot sur fixtures inchange par `sanitizeClaudeUsage` ; `http:` -> 1, > 64 Kio -> 2 sans envoi ; `run-claude-usage.sh` lance deux fois en parallele (faux `node`, HOME temporaire) : le second sort 0 sans travailler. Rattrapage ponctuel, #55 (10 cas) : fenetres passees contigues jusqu'a J-2, jours vides de tete retires, requete a cheval sur la frontiere de deux fenetres comptee une fois (meilleure ligne avant ou apres minuit), rien d'autre que `{ date, models }`, borne `--max-windows`, erreurs de lecture additionnees ; `main` n'ecrit que `--out` (mode 600, jamais ecrase), aucun `fetch`, aucun acces aux credentials, sortie en compteurs seulement, fichier accepte par le parseur d'import du serveur, erreurs d'usage
|
||||
- `__tests__/ci.test.js` — epingles du workflow et des scripts de CI (49 cas : image du `container:` identique au `FROM` du Dockerfile, les quatre etapes presentes, aucun filtre `branches:` sous `pull_request`, `push` limite a `main`, ni `defaults:` ni `working-directory` sur une etape `uses:`, **aucun `continue-on-error`, sous toute ecriture litterale** (une etape rouge passerait au vert en silence ; detecteur sur le mot nu, sans ancrage ni deux-points exiges, lui-meme verifie sur la cle en bloc, en element de liste, en style flow, entre guillemets et via une ancre YAML ; seules les lignes de commentaire entieres en sont exemptees ; une cle ecrite avec une sequence d'echappement, `"continue\u002don-error"`, passe encore : l'epingle lit du texte, pas du YAML), ce que le boot check suppose du Dockerfile : `USER node`, `CMD` en forme exec, le **refus d'un port deja occupe** (voisin HTTP reel, script lance en `spawn` asynchrone), **aucune requete par un proxy de l'environnement** (boot complet derriere un faux proxy qui repond a tout, sa vivacite prouvee d'abord par un `curl` nu, avec un `no_proxy` qui laisse la boucle locale hors liste : exporter `NO_PROXY` seul ne suffit pas), **`test-curl.sh` lance en entier** (#41 et #46, 22 cas, chacun sur des ports libres qui lui sont propres, passes par `TEST_CURL_PORT` et `TEST_CURL_NOINGEST_PORT` : derriere le meme faux proxy avec `FORCE_COLOR=3`, avec `BASE_URL` et `NOINGEST_URL` pointes sur un port mort, et avec `TOKEN` et `INGEST_TOKEN` herites d'une meme valeur, rien n'atteint le proxy, les 51 cas passent, le nombre etant ecrit en dur dans le test, et les deux ports sont refuses des la premiere tentative a la sortie du script ; **deux passages au meme instant** sur quatre ports libres, 51 cas chacun ; les **refus de port** : valeur non numerique, port 0, port au-dela de 65535, nombre suivi de lettres (`80abc`), chacun sur chacune des deux variables, puis deux ports identiques, et les **deux bornes acceptees**, 1 et 65535, donnees aux deux variables a la fois pour ne rien faire ecouter sur un port fixe (atteindre le refus « must differ » montre que la valeur a passe le controle de plage), message sur stderr et code 1 avant tout lancement ; un **voisin HTTP reel** sur le port principal, puis sur le second : refus avant tout lancement, le voisin n'ayant vu que la sonde `/` ; un **occupant non HTTP** sur le port principal, qui ferme aussitot (code curl 52 ou 56) puis qui accepte sans repondre (28) : meme refus, la regle etant « tout code autre que 7 » ; un **serveur vivant qui n'ecoute jamais** : message d'echeance et code 1 apres les 5 s d'attente ; puis un faux `node` en tete de `PATH` qui sort en 1 pour le port principal, puis le second : message sur stderr, code 1 en moins de 3 s, aucun serveur laisse sur son port ; et deux **epingles sur le source** du script : defauts 3099 et 3098, seules occurrences de ces nombres hors commentaires, et URLs derivees des ports sans lecture de l'environnement. Plus aucun cas n'ecoute sur 3099 ni 3098 : les defauts ne sont exerces que par l'etape CI « Socket-level pass » et par un `bash test-curl.sh` a la main. Les ports viennent de `freePorts()`, qui ouvre ses sondes ensemble puis les ferme avant le lancement : un port repris dans l'intervalle fait echouer le cas avec le message de `require_free`, sans cas lance contre un mauvais serveur), et **des `require` en colonne 0 seulement, sans `import()`**, dans chaque `.js` du `COPY`. Cette derniere epingle est une heuristique sur les appels litteraux, espace avant la parenthese comprise (`require (`, `import (`). Son detecteur, `loadsLate()`, est lui-meme verifie sur six lignes : `require` indente et `import` a toute colonne, avec ou sans espace, signales ; `require` de premier niveau et ligne de commentaire laisses passer. Trous connus, entre autres, laisses ouverts (#35) : une fonction ecrite entiere sur une ligne en colonne 0 (`const lazy = () => require("x");`), un `require(` renvoye a la ligne en colonne 0 dans une fonction (de meme forme que le `} = require("node:fs");` legitime d'`index.js`), et toute indirection, comme un alias de `require`)
|
||||
- `__tests__/writes.test.js` — surfaces d'ecriture : `HOSTS_DIR`, et le dossier de `CLAUDE_USAGE_PATH` pour le seul `POST /claude-usage`, archive `history/` comprise (#55) (#48 : `violations()` prend le dossier autorise du cas, `HOSTS_DIR` par defaut ; deux cas dedies, `POST /claude-usage` n'ecrit que dans son dossier, et ses rejets 401/400/413 et la lecture d'un fichier corrompu n'ecrivent rien du tout ; `GET /claude-usage` entre dans les routes de lecture ; #55 : le cas POST exige le `rename` du fichier mensuel du jour clos, `GET /claude-usage/history` entre dans les routes de lecture, et un mois corrompu ou une plage refusee n'ecrivent rien) (15 cas : espions sur toutes les fonctions d'ecriture de `node:fs`, formes callback, Sync et `fs.promises`, et sur `child_process`, poses avant un `require` frais apres purge du cache de **tous** les modules runtime (un `metrics.js` reste en cache garderait les espions restaures d'un test precedent) ; demarrage par le balayage des `.tmp` ; les huit routes GET n'ecrivent rien, meme une macrotache apres la reponse, contenu reel exige ; `POST /hosts/<id>` n'ecrit que sous `HOSTS_DIR` ; **chemins degrades** : rejets 401/403/404, entrees 400/413, `agents-map.json` corrompu, fichiers Defenseurs absents, snapshot corrompu, avec le contenu degrade exige (`no_data`, `neverSeen`) ; **commandes shell** figees sur `head -1 /proc/stat` et `df -k /`, `/health` servant de temoin ; chaque cas prouve que ses espions sont vivants ; **tout appel est compte, sans attribution par pile** (#40 : une fonction `node:fs` passee telle quelle a `process.nextTick`, a un ecouteur ou a un `.then` s'execute sans frame runtime sur sa pile, et un filtre par pile la laissait passer) ; les retouches de fixtures du cas degrade passent par les fonctions reelles capturees avant la pose des espions, jamais par un `rmSync` recursif, qui appelle en interne le `fs.rmdirSync` public, donc l'espion ; **epingle sur le bloc `require.main`**, ensemble exact des appels, puisqu'il ne tourne jamais dans le processus de test. Limites : un chemin qu'aucun cas n'emprunte, et une ecriture differee au-dela de 20 ms. Contrepartie assumee : une ecriture du lanceur de tests lui-meme pendant un cas le ferait rougir)
|
||||
- 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 (45 cas). Vitest couvre la logique ; ce script couvre ce que seuls un vrai client et un vrai TCP peuvent montrer : le 413 rendu **avant** que la connexion soit coupee, les deux tokens qui se refusent mutuellement leurs routes, le fichier servi octet pour octet par `/defenseurs/state`, et le 503 d'une instance demarree sans `HOSTS_INGEST_TOKEN` (elle tourne sur un second port, le temps du script). Il boote ses propres serveurs sur 3099 et 3098 — aucun service exterieur requis. Les deux ports se changent par `TEST_CURL_PORT` et `TEST_CURL_NOINGEST_PORT` (#46), ce qui laisse tourner deux passages cote a cote ; une valeur hors 1-65535 ou deux ports identiques sont refuses avec un message, avant tout lancement. Des noms propres au script plutot que `PORT`, qu'un shell de dev exporte souvent deja. `BASE_URL` et `NOINGEST_URL` sont derives des ports et ne se lisent plus dans l'environnement : surcharges seuls, ils envoyaient les `curl` ailleurs que sur les serveurs demarres. `TOKEN` et `INGEST_TOKEN` sont fixes pour la meme raison : deux valeurs egales heritees cassaient les cas de cloisonnement. Quatre protections contre l'environnement (#41) : `NO_PROXY='*'` et `no_proxy='*'` exportes, comme dans `boot-from-copy.sh` (sans eux, derriere un proxy mort, le script sortait en code 7 apres 11 s sans une ligne) ; `unset FORCE_COLOR`, car sous cette variable `node -e 'console.log(3)'` colore le nombre meme dans un tube et 8 cas echouent ; `require_free`, qui exige avant tout lancement que les deux ports refusent la connexion (code curl 7, meme regle que `boot-from-copy.sh`) : c'est lui qui ecarte un voisin deja en place, car le serveur du script ne meurt de son `EADDRINUSE` qu'apres que la boucle d'attente a eu le temps de joindre le voisin ; et `wait_for`, qui arrete le script avec un message si le serveur meurt ou ne repond pas dans les 5 s (borne par horloge), en verifiant le processus (`kill -0`) avant et apres la sonde. Le `trap EXIT` protege chaque `kill` par `|| true` (sous `set -e`, un `kill` en echec sur le premier serveur deja mort terminait le trap et laissait le second tourner sur son port) et finit par un `wait`, pour qu'un passage lance juste apres trouve les ports libres. Ce `wait` n'est epingle que par une tentative de connexion unique a la sortie du script : sans lui, le cas rougit le plus souvent, pas a chaque fois (4 passages sur 5 mesures). Non epingles : l'export de la majuscule `NO_PROXY`, que curl ne lit qu'apres la minuscule, et le second `kill -0` de `wait_for`, qui ne couvre que la fenetre residuelle entre `require_free` et le `listen` du serveur.
|
||||
- `bash test-curl.sh` — passe au niveau socket, complementaire et non redondante (51 cas, dont 6 sur `/claude-usage/history` depuis #55 : 401 sans token et avec le token d'ingestion Claude, 400, POST d'un snapshot de deux jours puis le seul jour clos servi, fichier mensuel en mode 600). Vitest couvre la logique ; ce script couvre ce que seuls un vrai client et un vrai TCP peuvent montrer : le 413 rendu **avant** que la connexion soit coupee, les deux tokens qui se refusent mutuellement leurs routes, le fichier servi octet pour octet par `/defenseurs/state`, et le 503 d'une instance demarree sans `HOSTS_INGEST_TOKEN` (elle tourne sur un second port, le temps du script). Il boote ses propres serveurs sur 3099 et 3098 — aucun service exterieur requis. Les deux ports se changent par `TEST_CURL_PORT` et `TEST_CURL_NOINGEST_PORT` (#46), ce qui laisse tourner deux passages cote a cote ; une valeur hors 1-65535 ou deux ports identiques sont refuses avec un message, avant tout lancement. Des noms propres au script plutot que `PORT`, qu'un shell de dev exporte souvent deja. `BASE_URL` et `NOINGEST_URL` sont derives des ports et ne se lisent plus dans l'environnement : surcharges seuls, ils envoyaient les `curl` ailleurs que sur les serveurs demarres. `TOKEN` et `INGEST_TOKEN` sont fixes pour la meme raison : deux valeurs egales heritees cassaient les cas de cloisonnement. Quatre protections contre l'environnement (#41) : `NO_PROXY='*'` et `no_proxy='*'` exportes, comme dans `boot-from-copy.sh` (sans eux, derriere un proxy mort, le script sortait en code 7 apres 11 s sans une ligne) ; `unset FORCE_COLOR`, car sous cette variable `node -e 'console.log(3)'` colore le nombre meme dans un tube et 8 cas echouent ; `require_free`, qui exige avant tout lancement que les deux ports refusent la connexion (code curl 7, meme regle que `boot-from-copy.sh`) : c'est lui qui ecarte un voisin deja en place, car le serveur du script ne meurt de son `EADDRINUSE` qu'apres que la boucle d'attente a eu le temps de joindre le voisin ; et `wait_for`, qui arrete le script avec un message si le serveur meurt ou ne repond pas dans les 5 s (borne par horloge), en verifiant le processus (`kill -0`) avant et apres la sonde. Le `trap EXIT` protege chaque `kill` par `|| true` (sous `set -e`, un `kill` en echec sur le premier serveur deja mort terminait le trap et laissait le second tourner sur son port) et finit par un `wait`, pour qu'un passage lance juste apres trouve les ports libres. Ce `wait` n'est epingle que par une tentative de connexion unique a la sortie du script : sans lui, le cas rougit le plus souvent, pas a chaque fois (4 passages sur 5 mesures). Non epingles : l'export de la majuscule `NO_PROXY`, que curl ne lit qu'apres la minuscule, et le second `kill -0` de `wait_for`, qui ne couvre que la fenetre residuelle entre `require_free` et le `listen` du serveur.
|
||||
|
||||
## 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`) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un `MODULE_NOT_FOUND` que `npm test` ne voit pas (seul `scripts/boot-from-copy.sh`, en CI, le rattrape). Un `require` place dans un handler de route echapperait au boot check (qui n'appelle que `/health`) : garder tous les `require` au niveau module, une epingle de `ci.test.js` l'exige. Corollaire : `agent/` n'entre PAS dans l'image — c'est du code de poste, livre par copie de fichiers (voir `agent/README.md`), et un test epingle que le `COPY` ne le mentionne jamais.
|
||||
- Le `COPY` du Dockerfile liste les fichiers un par un (`package.json index.js metrics.js claude-usage.js claude-usage-history.js`) : 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`, et `POST /claude-usage` (#48) dans le dossier de `CLAUDE_USAGE_PATH`. Consequence : ces deux montages-la doivent etre en lecture-ecriture pour l'uid 1000, alors que les deux montages Defenseurs sont en `:ro` (#36). Une ecriture ajoutee un jour sous `REPORTS_DIR` ou `/data/defenseurs` passerait tous les autres tests (fixtures inscriptibles, CI en root) et tomberait en prod en 500 `EROFS` : `__tests__/writes.test.js` exige que toute ecriture par `node:fs` vise `HOSTS_DIR` (ou, pour le seul cas `POST /claude-usage`, le dossier de `CLAUDE_USAGE_PATH`), et que les seules commandes shell soient les deux lectures de `metrics.js`, sur chaque chemin qu'il exerce (demarrage, lectures et ingestion nominales, rejets, entrees invalides, fichier corrompu, fichiers absents). Un nouveau chemin qui ecrit doit y entrer comme cas.
|
||||
- L'ecriture des snapshots passe par un fichier temporaire dans le **meme** repertoire puis `renameSync` — `rename` est atomique dans un systeme de fichiers, donc un `GET /hosts` concurrent voit l'ancien snapshot ou le nouveau, jamais un JSON tronque. Ne pas « simplifier » en `writeFileSync` direct. Un processus tue entre les deux laisse un `.<id>.<pid>.<ms>.tmp` : `sweepStaleTempFiles()` les supprime au demarrage, avant `listen`, s'ils ont plus d'une heure (plus jeunes, un autre conteneur peut etre en train d'ecrire pendant un redeploy). Il ne touche que ce format de nom exact et que des fichiers reguliers (`lstat`) ; changer le nom du fichier temporaire impose de changer `TEMP_FILE_RE`. `writeClaudeUsage()` suit le meme patron (temporaire `.claude-usage.json.<pid>.<ms>.tmp`), mais **aucun balayage** ne couvre son dossier : un orphelin y resterait, inoffensif (`GET /claude-usage` ne lit que le fichier final) ; un seul fichier ecrit toutes les quelques minutes ne justifiait pas d'elargir `sweepStaleTempFiles()` et le bloc `require.main` epingle.
|
||||
- L'ecriture des snapshots passe par un fichier temporaire dans le **meme** repertoire puis `renameSync` — `rename` est atomique dans un systeme de fichiers, donc un `GET /hosts` concurrent voit l'ancien snapshot ou le nouveau, jamais un JSON tronque. Ne pas « simplifier » en `writeFileSync` direct. Un processus tue entre les deux laisse un `.<id>.<pid>.<ms>.tmp` : `sweepStaleTempFiles()` les supprime au demarrage, avant `listen`, s'ils ont plus d'une heure (plus jeunes, un autre conteneur peut etre en train d'ecrire pendant un redeploy). Il ne touche que ce format de nom exact et que des fichiers reguliers (`lstat`) ; changer le nom du fichier temporaire impose de changer `TEMP_FILE_RE`. `writeClaudeUsage()` suit le meme patron (temporaire `.claude-usage.json.<pid>.<ms>.tmp`), mais **aucun balayage** ne couvre son dossier : un orphelin y resterait, inoffensif (`GET /claude-usage` ne lit que le fichier final) ; un seul fichier ecrit toutes les quelques minutes ne justifiait pas d'elargir `sweepStaleTempFiles()` et le bloc `require.main` epingle. Meme chose pour `history/` (#55) : temporaires `.YYYY-MM.json.<pid>.<ms>.tmp`, aucun balayage, un orphelin y est inoffensif (la route ne lit que `YYYY-MM.json`).
|
||||
- L'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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json index.js metrics.js claude-usage.js ./
|
||||
COPY package.json index.js metrics.js claude-usage.js claude-usage-history.js ./
|
||||
EXPOSE 3001
|
||||
USER node
|
||||
CMD ["node", "index.js"]
|
||||
|
|
|
|||
|
|
@ -1953,3 +1953,165 @@ describe("run-claude-usage.sh", () => {
|
|||
expect(script).toMatch(/flock -n 9/);
|
||||
});
|
||||
});
|
||||
|
||||
// --- One-shot history backfill (#55) ----------------------------------------
|
||||
|
||||
describe("backfill-history.js", () => {
|
||||
const { backfill, main } = require("../agent/claude-usage/backfill-history.js");
|
||||
const MTIME = "2026-10-07T12:00:00Z";
|
||||
|
||||
// Toronto noon of a day (EDT in this range): one line per call.
|
||||
const noon = (date) => `${date}T16:00:00Z`;
|
||||
|
||||
function outputOn(result, date) {
|
||||
const found = result.days.find((d) => d.date === date);
|
||||
return found ? Object.values(found.models).reduce((s, m) => s + m.output, 0) : undefined;
|
||||
}
|
||||
|
||||
function writeSpread() {
|
||||
writeTranscript(
|
||||
"a/spread.jsonl",
|
||||
[
|
||||
line({ ts: noon("2026-08-01"), output: 1 }),
|
||||
line({ ts: noon("2026-09-08"), output: 2 }),
|
||||
line({ ts: noon("2026-09-09"), output: 4 }),
|
||||
line({ ts: noon("2026-10-06"), output: 8 }),
|
||||
line({ ts: noon("2026-10-07"), output: 16 }), // D-1: may still be open
|
||||
line({ ts: noon("2026-10-08"), output: 32 }), // today
|
||||
// One request straddling the 09-08/09-09 midnight, the boundary between
|
||||
// the first two windows, both lines with the same output: counted once,
|
||||
// on the day of the earliest line.
|
||||
line({ ts: "2026-09-09T03:59:00Z", id: "msg_straddle", req: "req_straddle", output: 64 }),
|
||||
line({ ts: "2026-09-09T04:01:00Z", id: "msg_straddle", req: "req_straddle", output: 64 }),
|
||||
// Same boundary, best line after midnight: counted once, on 09-09. A
|
||||
// window ending on 09-08 without its one-day margin would see only the
|
||||
// earlier line and count it a second time on 09-08.
|
||||
line({ ts: "2026-09-09T03:58:00Z", id: "msg_late", req: "req_late", output: 256 }),
|
||||
line({ ts: "2026-09-09T04:02:00Z", id: "msg_late", req: "req_late", output: 512 }),
|
||||
],
|
||||
MTIME,
|
||||
);
|
||||
}
|
||||
|
||||
it("covers contiguous past windows up to D-2, the straddling request once, and nothing else of the snapshot", async () => {
|
||||
writeSpread();
|
||||
const result = await backfill({ root: tmpDir, today: "2026-10-08", maxWindows: 3 });
|
||||
expect(result.windows).toBe(3);
|
||||
expect(result.readErrors).toBe(0);
|
||||
|
||||
const dates = result.days.map((d) => d.date);
|
||||
expect(dates[0]).toBe("2026-08-01"); // leading empty days trimmed
|
||||
expect(dates.at(-1)).toBe("2026-10-06"); // D-2
|
||||
for (let i = 1; i < dates.length; i++) expect(dates[i]).toBe(shiftDay(dates[i - 1], 1));
|
||||
|
||||
expect(outputOn(result, "2026-08-01")).toBe(1);
|
||||
expect(outputOn(result, "2026-09-08")).toBe(2 + 64);
|
||||
expect(outputOn(result, "2026-09-09")).toBe(4 + 512);
|
||||
expect(outputOn(result, "2026-10-06")).toBe(8);
|
||||
expect(outputOn(result, "2026-10-07")).toBeUndefined();
|
||||
for (const d of result.days) expect(Object.keys(d).sort()).toEqual(["date", "models"]);
|
||||
expect(result).not.toHaveProperty("projects");
|
||||
});
|
||||
|
||||
it("stops at --max-windows: an older day is left out", async () => {
|
||||
writeSpread();
|
||||
writeTranscript("b/old.jsonl", [line({ ts: noon("2026-06-01"), output: 128 })], MTIME);
|
||||
expect(outputOn(await backfill({ root: tmpDir, today: "2026-10-08", maxWindows: 3 }), "2026-06-01")).toBeUndefined();
|
||||
const wider = await backfill({ root: tmpDir, today: "2026-10-08", maxWindows: 5 });
|
||||
expect(wider.days[0].date).toBe("2026-06-01");
|
||||
expect(outputOn(wider, "2026-06-01")).toBe(128);
|
||||
});
|
||||
|
||||
it("no transcript at all: no day", async () => {
|
||||
expect((await backfill({ root: path.join(tmpDir, "absent"), today: "2026-10-08", maxWindows: 2 })).days).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts read errors across windows", async () => {
|
||||
writeSpread();
|
||||
const { Readable } = require("node:stream");
|
||||
const result = await backfill({
|
||||
root: tmpDir,
|
||||
today: "2026-10-08",
|
||||
maxWindows: 2,
|
||||
openFile: () =>
|
||||
new Readable({
|
||||
read() {
|
||||
this.destroy(Object.assign(new Error("boom"), { code: "EIO" }));
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(result.readErrors).toBe(2);
|
||||
});
|
||||
|
||||
describe("main", () => {
|
||||
let home;
|
||||
let io;
|
||||
|
||||
beforeEach(() => {
|
||||
home = path.join(tmpDir, "home");
|
||||
fs.mkdirSync(path.join(home, ".claude", "projects", "p"), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, ".claude", ".credentials.json"), '{"claudeAiOauth":{"accessToken":"sk-ant-oat-SECRET"}}');
|
||||
const recent = path.join(home, ".claude", "projects", "p", "t.jsonl");
|
||||
const twoDaysAgo = shiftDay(torontoDay(Date.now()), -2);
|
||||
fs.writeFileSync(recent, `${JSON.stringify(line({ ts: `${twoDaysAgo}T16:00:00Z`, output: 5 }))}\n`);
|
||||
io = { lines: [], out: (l) => io.lines.push(["out", l]), err: (l) => io.lines.push(["err", l]) };
|
||||
});
|
||||
|
||||
it("writes { days } to --out only, mode 600, no network, credentials never opened", async () => {
|
||||
const out = path.join(tmpDir, "backfill.json");
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const touched = [];
|
||||
for (const fn of ["readFileSync", "openSync", "createReadStream", "writeFileSync", "renameSync", "mkdirSync", "appendFileSync"]) {
|
||||
const realFn = fs[fn];
|
||||
vi.spyOn(fs, fn).mockImplementation(function (target, ...rest) {
|
||||
touched.push([fn, String(target)]);
|
||||
return realFn.call(this, target, ...rest);
|
||||
});
|
||||
}
|
||||
for (const fn of ["readFile", "open", "writeFile"]) {
|
||||
const realFn = fs.promises[fn];
|
||||
vi.spyOn(fs.promises, fn).mockImplementation(function (target, ...rest) {
|
||||
touched.push([fn, String(target)]);
|
||||
return realFn.call(this, target, ...rest);
|
||||
});
|
||||
}
|
||||
|
||||
const code = await main(["--out", out, "--max-windows", "2"], { HOME: home }, io);
|
||||
expect(io.lines.filter(([kind]) => kind === "err")).toEqual([]);
|
||||
expect(code).toBe(0);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(touched.filter(([, target]) => target.includes(".credentials"))).toEqual([]);
|
||||
const writes = touched.filter(([fn]) => /write|rename|mkdir|append/i.test(fn));
|
||||
expect(writes).toEqual([["writeFileSync", out]]);
|
||||
|
||||
expect(fs.statSync(out).mode & 0o777).toBe(0o600);
|
||||
const written = JSON.parse(fs.readFileSync(out, "utf-8"));
|
||||
expect(Object.keys(written)).toEqual(["days"]);
|
||||
expect(written.days).toHaveLength(1);
|
||||
expect(Object.values(written.days[0].models)[0].output).toBe(5);
|
||||
// The written file passes the very import parser of the server.
|
||||
const { parseImport } = require("../claude-usage-history.js");
|
||||
expect(parseImport(fs.readFileSync(out, "utf-8"))).toEqual(written.days);
|
||||
// Counts only on the way out: no path, no token, no day content.
|
||||
expect(io.lines).toEqual([["out", expect.stringMatching(/^claude-usage backfill: 1 day\(s\) from \d{4}-\d{2}-\d{2} to \d{4}-\d{2}-\d{2}, 2 window\(s\), read_errors=0$/)]]);
|
||||
});
|
||||
|
||||
it("refuses to overwrite an existing --out file", async () => {
|
||||
const out = path.join(tmpDir, "backfill.json");
|
||||
fs.writeFileSync(out, "keep me");
|
||||
expect(await main(["--out", out, "--max-windows", "1"], { HOME: home }, io)).toBe(1);
|
||||
expect(fs.readFileSync(out, "utf-8")).toBe("keep me");
|
||||
expect(io.lines).toEqual([["err", "claude-usage backfill: output not written (code=EEXIST)"]]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[[]],
|
||||
[["--out"]],
|
||||
[["--out", "x", "--max-windows", "0"]],
|
||||
[["--out", "x", "--nope", "1"]],
|
||||
])("usage error on %j: exit 2, nothing written", async (argv) => {
|
||||
expect(await main(argv, { HOME: home }, io)).toBe(2);
|
||||
expect(fs.existsSync(path.join(process.cwd(), "x"))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ const ROUTES = [
|
|||
"/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",
|
||||
];
|
||||
|
||||
// Ingest token, distinct from the read token on purpose: a read token must not
|
||||
|
|
@ -83,6 +86,7 @@ beforeEach(async () => {
|
|||
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");
|
||||
// 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;
|
||||
|
|
@ -98,6 +102,10 @@ afterEach(async () => {
|
|||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.CLAUDE_USAGE_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 });
|
||||
|
|
@ -306,6 +314,12 @@ describe("routing", () => {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -342,7 +342,7 @@ describe("test-curl.sh", () => {
|
|||
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: 45 passed, 0 failed ===/);
|
||||
expect(stdout).toMatch(/=== Results: 51 passed, 0 failed ===/);
|
||||
} finally {
|
||||
await new Promise((resolve) => proxy.close(resolve));
|
||||
}
|
||||
|
|
@ -361,7 +361,7 @@ describe("test-curl.sh", () => {
|
|||
|
||||
for (const { code, stdout, stderr } of results) {
|
||||
expect(code, stdout + stderr).toBe(0);
|
||||
expect(stdout).toMatch(/=== Results: 45 passed, 0 failed ===/);
|
||||
expect(stdout).toMatch(/=== Results: 51 passed, 0 failed ===/);
|
||||
}
|
||||
} finally {
|
||||
for (const port of ports) expect(await released(port)).toBe(true);
|
||||
|
|
|
|||
611
__tests__/claude-usage-history.test.js
Normal file
611
__tests__/claude-usage-history.test.js
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -725,8 +725,9 @@ describe("POST /claude-usage — payload", () => {
|
|||
// 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.
|
||||
expect(fs.readdirSync(path.dirname(usagePath))).toEqual(["claude-usage.json"]);
|
||||
// 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 () => {
|
||||
|
|
|
|||
|
|
@ -205,11 +205,19 @@ function validClaudeUsage() {
|
|||
schemaVersion: 1,
|
||||
timeZone: "America/Toronto",
|
||||
limits: { status: "ok", fetchedAt: "2026-09-30T07:30:00.000Z", items: [] },
|
||||
days: [{ date: "2026-09-30", models: { "claude-opus-5-5": { input: 1, output: 2, requests: 1 } } }],
|
||||
// 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 historyFixturePath(month) {
|
||||
return path.join(tmpDir, "claude-usage", "history", `${month}.json`);
|
||||
}
|
||||
|
||||
function claudeUsageFixturePath() {
|
||||
return path.join(tmpDir, "claude-usage", "claude-usage.json");
|
||||
}
|
||||
|
|
@ -295,6 +303,12 @@ function writeFixtures() {
|
|||
// 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;
|
||||
// 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";
|
||||
|
|
@ -352,6 +366,7 @@ describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => {
|
|||
},
|
||||
],
|
||||
["/claude-usage", (body) => expect(body.snapshot.projects).toHaveLength(1)],
|
||||
["/claude-usage/history", (body) => expect(body.days).toEqual([{ date: "2026-09-28", models: {} }])],
|
||||
];
|
||||
|
||||
test.each(READ_ROUTES)("GET %s writes nothing, not even after the response", async (route, expectContent) => {
|
||||
|
|
@ -397,6 +412,9 @@ describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => {
|
|||
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([]);
|
||||
});
|
||||
|
||||
|
|
@ -419,6 +437,11 @@ describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => {
|
|||
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([]);
|
||||
|
|
|
|||
|
|
@ -316,3 +316,48 @@ 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.
|
||||
|
|
|
|||
138
agent/claude-usage/backfill-history.js
Normal file
138
agent/claude-usage/backfill-history.js
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
#!/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 };
|
||||
330
claude-usage-history.js
Normal file
330
claude-usage-history.js
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
// 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 writeClaudeUsage() 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,
|
||||
};
|
||||
|
|
@ -113,16 +113,24 @@ function sanitizeModels(value) {
|
|||
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) {
|
||||
if (!isPlainObject(entry)) continue;
|
||||
const date = cleanDayDate(entry.date);
|
||||
if (date === undefined) continue;
|
||||
const day = { date };
|
||||
put(day, "models", sanitizeModels(entry.models));
|
||||
days.push(day);
|
||||
const day = sanitizeDay(entry);
|
||||
if (day !== undefined) days.push(day);
|
||||
}
|
||||
return days.slice(-MAX_DAYS);
|
||||
}
|
||||
|
|
@ -183,6 +191,7 @@ function sanitizeClaudeUsage(payload) {
|
|||
|
||||
module.exports = {
|
||||
sanitizeClaudeUsage,
|
||||
sanitizeDay,
|
||||
isIsoTimestamp,
|
||||
CLAUDE_USAGE_MAX_BODY_BYTES,
|
||||
SCHEMA_VERSION,
|
||||
|
|
|
|||
66
index.js
66
index.js
|
|
@ -14,6 +14,14 @@ const path = require("node:path");
|
|||
const { createHash, timingSafeEqual } = require("node:crypto");
|
||||
const { collectMetrics } = require("./metrics.js");
|
||||
const { sanitizeClaudeUsage, isIsoTimestamp, CLAUDE_USAGE_MAX_BODY_BYTES } = require("./claude-usage.js");
|
||||
const {
|
||||
historyDir,
|
||||
closedDays,
|
||||
archiveDays,
|
||||
lastArchivedMonth,
|
||||
parseRange,
|
||||
readHistory,
|
||||
} = require("./claude-usage-history.js");
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "3001", 10);
|
||||
const TOKEN = process.env.HEALTH_TOKEN;
|
||||
|
|
@ -85,6 +93,10 @@ const CLAUDE_USAGE_INGEST_TOKEN = process.env.CLAUDE_USAGE_INGEST_TOKEN;
|
|||
const CLAUDE_USAGE_PATH =
|
||||
process.env.CLAUDE_USAGE_PATH || "/data/claude-usage/claude-usage.json";
|
||||
|
||||
// Closed days kept past the 30-day window (#55): one file per month, on the
|
||||
// same mount as the snapshot. Layout and merge rules in claude-usage-history.js.
|
||||
const CLAUDE_USAGE_HISTORY_DIR = historyDir(CLAUDE_USAGE_PATH);
|
||||
|
||||
const claudeStaleRaw = parseInt(process.env.CLAUDE_USAGE_STALE_SECONDS || "", 10);
|
||||
const CLAUDE_USAGE_STALE_SECONDS =
|
||||
Number.isFinite(claudeStaleRaw) && claudeStaleRaw > 0 ? claudeStaleRaw : 2700;
|
||||
|
|
@ -791,13 +803,35 @@ async function handleClaudeUsageIngest(req, res) {
|
|||
|
||||
try {
|
||||
writeClaudeUsage(record);
|
||||
res.removeHeader("Content-Type");
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
} catch (err) {
|
||||
console.error("[claude-usage] failed to persist snapshot:", err.message);
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ error: "Internal error" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// The snapshot first, the archive second: an archive failure never costs
|
||||
// the main data, and never changes the 204.
|
||||
archiveClosedDays(record.days || [], record.receivedAt.slice(0, 10));
|
||||
res.removeHeader("Content-Type");
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
}
|
||||
|
||||
// Every day of the snapshot except the most recent one (today, still open)
|
||||
// and any dated past the reception day, the latest snapshot winning (#55).
|
||||
// Months and error codes only in the log, never a day's content.
|
||||
function archiveClosedDays(days, receivedDay) {
|
||||
try {
|
||||
const report = archiveDays(CLAUDE_USAGE_HISTORY_DIR, closedDays(days, receivedDay));
|
||||
for (const { month, code } of report.skipped) {
|
||||
console.error(`[claude-usage] history ${month} skipped: unreadable file left untouched (${code})`);
|
||||
}
|
||||
for (const { month, code } of report.failed) {
|
||||
console.error(`[claude-usage] history ${month} not written (${code})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[claude-usage] history not written (${err.code || "error"})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -825,6 +859,31 @@ function handleClaudeUsageRead(req, res) {
|
|||
}));
|
||||
}
|
||||
|
||||
// Archived days, `from` to `to` months inclusive (#55). Defaults and bounds in
|
||||
// parseRange(); an unreadable month is left out and logged, never fatal.
|
||||
function handleClaudeUsageHistory(req, res, ctx) {
|
||||
const range = parseRange(
|
||||
ctx.parsedUrl.searchParams.get("from"),
|
||||
ctx.parsedUrl.searchParams.get("to"),
|
||||
{
|
||||
lastArchived: lastArchivedMonth(CLAUDE_USAGE_HISTORY_DIR),
|
||||
currentMonth: new Date(Date.now()).toISOString().slice(0, 7),
|
||||
},
|
||||
);
|
||||
if (range.error) {
|
||||
res.writeHead(400);
|
||||
res.end(JSON.stringify({ error: `Bad request: ${range.error}` }));
|
||||
return;
|
||||
}
|
||||
|
||||
const { days, unreadable } = readHistory(CLAUDE_USAGE_HISTORY_DIR, range.from, range.to);
|
||||
for (const { month, code } of unreadable) {
|
||||
console.error(`[claude-usage] history ${month} unreadable, left out (${code})`);
|
||||
}
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({ from: range.from, to: range.to, days }));
|
||||
}
|
||||
|
||||
// --- Routing ----------------------------------------------------------------
|
||||
|
||||
// Route descriptors. `tokenKind` is the ONLY thing that varies between the read
|
||||
|
|
@ -840,6 +899,7 @@ const ROUTES = [
|
|||
{ method: "GET", path: "/hosts", tokenKind: "read", handle: handleHostsList },
|
||||
{ method: "POST", pattern: HOST_ROUTE_RE, tokenKind: "ingest", handle: handleHostIngest },
|
||||
{ method: "GET", path: "/claude-usage", tokenKind: "read", handle: handleClaudeUsageRead },
|
||||
{ method: "GET", path: "/claude-usage/history", tokenKind: "read", handle: handleClaudeUsageHistory },
|
||||
{ method: "POST", path: "/claude-usage", tokenKind: "claude-ingest", handle: handleClaudeUsageIngest },
|
||||
];
|
||||
|
||||
|
|
|
|||
23
test-curl.sh
23
test-curl.sh
|
|
@ -65,6 +65,8 @@ TOKEN="test-token-123"
|
|||
# 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"
|
||||
TMP_DIR="$(mktemp -d -t vps-health-api.XXXXXX)"
|
||||
SERVER_PID=""
|
||||
NOINGEST_PID=""
|
||||
|
|
@ -202,6 +204,8 @@ 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" \
|
||||
LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \
|
||||
node "$(dirname "$0")/index.js" >/dev/null 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
|
@ -551,6 +555,25 @@ code=$(http_code "$BASE_URL/defenseurs/state")
|
|||
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"
|
||||
|
||||
echo
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
[[ "$FAIL" == "0" ]] || exit 1
|
||||
|
|
|
|||
Loading…
Reference in a new issue