diff --git a/CLAUDE.md b/CLAUDE.md index c3ef7e3..dd65305 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +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/` — **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 `` bien forme mais hors allowlist / 404 `` 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/` 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 `....tmp` dans le meme dossier, mode 600, `renameSync`), patron de `writeHostSnapshot`. **Archive** (#55) : une fois le snapshot ecrit, tous ses jours **sauf le dernier** (aujourd'hui, encore ouvert) sont fusionnes dans `/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...tmp`, mode 600, `renameSync`). Un echec d'archivage ne change pas le 204 : il est journalise (`[claude-usage] history ...`, mois et code d'erreur seulement, jamais le contenu). Un fichier mensuel illisible ou hors format n'est **jamais ecrase** (il garderait des jours sortis de la fenetre) : ce mois est saute et journalise, les autres s'ecrivent. Un snapshot d'un seul jour n'archive rien et ne cree pas le dossier. +- `POST /claude-usage` — **seconde surface d'ecriture** (issue #48) : snapshot de consommation Claude pousse par l'agent du ThinkPad, contrat `schemaVersion: 1` partage avec le dashboard (`la-compagnie-maximus`, `spec-plan-suivi-claude.md`). Auth par `CLAUDE_USAGE_INGEST_TOKEN`, son propre token : ni `HOSTS_INGEST_TOKEN` ni `HEALTH_TOKEN` n'y passent, et lui n'ouvre ni `POST /hosts/` 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 `....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 `/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...tmp`, mode 600, `renameSync`). Un echec d'archivage ne change pas le 204 : il est journalise (`[claude-usage] history ...`, 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 `from` s'il est plus tard), `from` vaut `to` moins 11 mois (12 mois, bornes comprises). 400 si un format est invalide (mois `01`-`12`, chaine vide comprise), si `from > to`, ou au-dela de 36 mois. Un mois absent est vide ; un mois illisible est omis et journalise ; chaque fichier est **re-sanitise** a la lecture par le nettoyeur de jour du snapshot (`sanitizeDay`, exporte par `claude-usage.js`), et un jour date hors de son mois est jete. Rattrapage ponctuel des jours deja sortis de la fenetre : `agent/claude-usage/backfill-history.js` sur le poste, puis `docker exec -i node claude-usage-history.js --import - < fichier`, ou **une date deja archivee gagne** (procedure dans `agent/README.md`). +- `GET /claude-usage/history?from=YYYY-MM&to=YYYY-MM` — jours archives (#55), token de lecture. `{ from, to, days }`, jours tries du plus ancien au plus recent. Les deux parametres sont optionnels : `to` vaut par defaut le mois du dernier fichier archive (le mois courant UTC si rien n'est archive ou si ce fichier est date apres le mois courant, ou `from` s'il est plus tard), `from` vaut `to` moins 11 mois (12 mois, bornes comprises). 400 si un format est invalide (mois `01`-`12`, chaine vide comprise), si `from > to`, ou au-dela de 36 mois. Un mois absent est vide ; un mois illisible est omis et journalise ; chaque fichier est **re-sanitise** a la lecture par le nettoyeur de jour du snapshot (`sanitizeDay`, exporte par `claude-usage.js`), et un jour date hors de son mois est jete. Rattrapage ponctuel des jours deja sortis de la fenetre : `agent/claude-usage/backfill-history.js` sur le poste, puis `docker exec -i node claude-usage-history.js --import - < fichier`, ou **une date deja archivee gagne** (procedure dans `agent/README.md`). ## Routage et auth @@ -69,9 +69,9 @@ 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//status`), l'API Actions repondant 404 sur cette instance. -- `npm test` (vitest) — 727 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/` 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` (72 cas, issue #55 : arithmetique des mois ; `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, 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, nom de fichier parasite ignore, 400, token de lecture seul, 404 sur POST, aller-retour ; import lance en vrai processus : date archivee jamais remplacee, seul `history/` change, entree standard, fichier invalide (pas du JSON, sans `days`, jour invalide, date repetee) qui n'ecrit rien, mois illisible saute, fichier absent, erreurs d'usage ; `claude-usage-history.js` dans le `COPY`) + - `__tests__/claude-usage-history.test.js` — archive des jours clos et `GET /claude-usage/history` (76 cas, issue #55 : arithmetique des mois ; `closedDays` (date la plus recente et dates posterieures a la reception ecartees) ; `mergeDays` avec ses deux regles (dernier gagne, date archivee gagne), resultat trie, entrees intactes ; regroupement par le prefixe de la chaine ; relecture d'un fichier mensuel par la liste blanche (champ inconnu, `__proto__`, valeur invalide, date hors du mois, fichier hors format -> illisible) ; `parseRange` (defauts, 36 mois pile, 37 refuses, `from` seul trop ancien, formats) ; POST : tout sauf le dernier jour, mode 600, rien pour un snapshot d'un jour, second POST identique octet pour octet, jour remplace, jour sorti de la fenetre garde, deux mois, ordre d'envoi et date repetee, jour date apres la reception jamais archive, mois illisible ou hors format jamais ecrase (204, snapshot ecrit, autre mois ecrit, journal sans contenu), dossier impossible a creer, `rename` en echec sur un mois (temporaire supprime, l'autre mois ecrit), rien d'archive sur un POST refuse ou un snapshot non ecrit ; route : defauts, selection, mois illisible omis et journalise, re-sanitisation, fichier mensuel futur et nom de fichier parasite ignores comme fin par defaut, 400, token de lecture seul, 404 sur POST, aller-retour ; import lance en vrai processus : date archivee jamais remplacee, seul `history/` change, entree standard, fichier invalide (pas du JSON, sans `days`, jour invalide, date repetee) qui n'ecrit rien, mois illisible saute, fichier absent, erreurs d'usage ; `claude-usage-history.js` dans le `COPY`) - `__tests__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu) - `__tests__/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) diff --git a/__tests__/claude-usage-history.test.js b/__tests__/claude-usage-history.test.js index 8e0c04b..0898f47 100644 --- a/__tests__/claude-usage-history.test.js +++ b/__tests__/claude-usage-history.test.js @@ -207,6 +207,16 @@ describe("mergeDays", () => { }); }); +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(); @@ -265,6 +275,7 @@ describe("parseRange", () => { ["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); @@ -333,11 +344,23 @@ describe("POST /claude-usage — the archive", () => { expect(readMonthFile("2026-10").days.map((d) => d.date)).toEqual(["2026-10-01", "2026-10-02"]); }); - test("the excluded day is the last element sent, and a repeated date resolves to its later entry", async () => { + 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-29", 1), day("2026-09-30", 2), day("2026-09-28", 1)]); - expect(readMonthFile("2026-09").days).toEqual([day("2026-09-29", 1), day("2026-09-30", 2)]); + 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 () => { @@ -454,6 +477,13 @@ describe("GET /claude-usage/history", () => { 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", "{}"); diff --git a/claude-usage-history.js b/claude-usage-history.js index 402b4c8..6e3e60f 100644 --- a/claude-usage-history.js +++ b/claude-usage-history.js @@ -75,6 +75,19 @@ function mergeDays(existing, incoming, { preferExisting = false } = {}) { 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(); @@ -196,14 +209,15 @@ function lastArchivedMonth(dir) { // 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 `from` itself when `from` is later; `from` is -// DEFAULT_MONTHS months back from `to`, `to` included. +// 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 || currentMonth; + to = lastArchived && lastArchived <= currentMonth ? lastArchived : currentMonth; if (fromRaw !== null && fromRaw > to) to = fromRaw; } const from = fromRaw !== null ? fromRaw : addMonths(to, -(DEFAULT_MONTHS - 1)); @@ -299,6 +313,7 @@ module.exports = { monthSpan, mergeDays, groupByMonth, + closedDays, sanitizeMonthFile, historyDir, readMonth, diff --git a/index.js b/index.js index c7c29d7..3847d4b 100644 --- a/index.js +++ b/index.js @@ -16,6 +16,7 @@ const { collectMetrics } = require("./metrics.js"); const { sanitizeClaudeUsage, isIsoTimestamp, CLAUDE_USAGE_MAX_BODY_BYTES } = require("./claude-usage.js"); const { historyDir, + closedDays, archiveDays, lastArchivedMonth, parseRange, @@ -811,18 +812,18 @@ async function handleClaudeUsageIngest(req, res) { // The snapshot first, the archive second: an archive failure never costs // the main data, and never changes the 204. - archiveClosedDays(record.days || []); + archiveClosedDays(record.days || [], record.receivedAt.slice(0, 10)); res.removeHeader("Content-Type"); res.writeHead(204); res.end(); } -// Every day of the snapshot except the last one (today, still open), the -// latest snapshot winning (#55). Months and error codes only in the log, never -// a day's content. -function archiveClosedDays(days) { +// 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, days.slice(0, -1)); + 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})`); }