Track the PostgreSQL cluster backups (la-compagnie-maximus#220) the same
way as /claude-usage: freshness is decided by the server.
- GET /backups (read token) serves { vps, lenovo }: the status.json the
VPS script writes, rebuilt through a whitelist (closed error codes,
rotation block included), and the last workstation pull report. Each
block gets ageSeconds, stale and staleAfterSeconds from the server
(BACKUPS_STALE_SECONDS, 36 h): VPS on generatedAt, workstation on the
server-stamped receivedAt. A broken file gives a null block plus
vpsError/lenovoError, never a 500; no_data only when both are absent.
- POST /backups/pull: fourth token kind, backups-ingest, on
BACKUPS_INGEST_TOKEN (503 naming it when unset). Strict validation of
{ pulledAt, files, ok, error? }, server receivedAt, atomic write in
mode 600 through writeJsonAtomic(), now shared with /claude-usage.
Answers 204 and 413 like the two other ingest routes (the agent only
checks for a 2xx), where the issue said 200 and 400.
- backups.js joins the Dockerfile COPY line. Tests: backups.test.js
(101 cases), auth/writes pins extended, 6 socket cases in test-curl.sh.
Refs #58
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /claude-usage now merges every day of the sanitized snapshot except
the last one (today, still open) into <dir of CLAUDE_USAGE_PATH>/history/
YYYY-MM.json, after the snapshot itself is written. The latest snapshot
wins, so a day leaving the 30-day window keeps the value the agent froze
at D+2. Each month file is written atomically (same-dir temp, mode 600,
rename). A month file that cannot be read back is never overwritten, and
an archive failure is logged (months and error codes only) without
changing the 204.
GET /claude-usage/history?from=YYYY-MM&to=YYYY-MM (read token) serves the
archived days, defaulting to the 12 months up to the last archived month,
400 on a bad format, from > to or a span over 36 months. Month files are
re-read through the snapshot's per-day whitelist, now exported from
claude-usage.js as sanitizeDay().
The new module also carries the one-shot import command used by the
backfill (`node claude-usage-history.js --import <file|->`), where an
already archived date always wins.
Refs #55
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Non-blocking points of the review of PR #47.
- TOKEN and INGEST_TOKEN are no longer read from the environment, like
the URLs: two inherited values that happened to be equal broke the
compartmentalisation cases. Pinned in the proxy case, which now runs
with one inherited value for both.
- The refusal cases run each bad value (not a number, 0, above 65535)
on each of the two variables.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test-curl.sh booted its servers on 3099 and 3098, hardcoded, and
ci.test.js runs it whole on every `npm test`: two runs side by side got
in each other's way, at least one of them failing every time.
- TEST_CURL_PORT and TEST_CURL_NOINGEST_PORT choose the ports, 3099 and
3098 staying the defaults. Names of their own rather than PORT, which
a dev shell often exports already.
- BASE_URL and NOINGEST_URL are derived from the ports and no longer
read from the environment: overridden alone, they sent every curl away
from the servers the script had started.
- A value outside 1-65535 and two identical ports are refused up front,
with the variable named.
- ci.test.js gives every case free ports of its own (freePorts() keeps
its probes open together so they are distinct), and no case listens on
3099 or 3098 any more. New cases: two passes at once, the four
refusals, source pins on the defaults and on the derived URLs, and a
first-try connection check that pins the `wait` of the trap, which the
fixed ports used to pin by accident.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Second review of PR #45, non-blocking: two behaviours were right by hand
and held by no test.
- a listener on 3099 that closes at once, then one that accepts and stalls:
the port rule is "anything but connection refused", not "an HTTP answer"
- a server that stays alive and never listens: the wait ends on its
deadline, with a message and exit 1
Also moves the port check above the "Fixtures" comment it had split from
its mkdir lines, and counts four protections in CLAUDE.md, not three.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of PR #45: wait_for did not keep a neighbour out. With a listener
already on 3099, the server started by the script only dies of EADDRINUSE
after the wait loop has reached the neighbour, and the cases then ran
against it.
- require_free checks both ports before anything starts: free means
"connection refused" (curl exit 7), as in scripts/boot-from-copy.sh
- wait_for runs on a clock deadline, and checks the process again after a
successful probe
- the EXIT trap waits for the servers it killed, so a run started right
after finds the two ports free
ci.test.js adds a real neighbour on each port, and bounds the two
failed-start cases to 3 s: a message that does not stop the run is now red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Behind a proxy from the environment, test-curl.sh sent its 18 curl lines to
the proxy instead of the servers it boots: with a dead proxy it exited 7
after 11 s without a line of output.
- export NO_PROXY and no_proxy, as scripts/boot-from-copy.sh does
- unset FORCE_COLOR: node colours a number even into a pipe, and 8 cases
compared "3" against a coloured "3"
- wait_for checks the server process before the port and stops the run with
a message when a server died or never answered
- the EXIT trap guards each kill: under set -e, a failed kill on the first
server ended the trap and left the second one running on 3098
ci.test.js runs the whole script three times: behind a fake proxy that
answers everything, and with a fake node that fails for each port in turn.
Fixes#41
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four cases on top of the 41 from #26: the file served byte for byte
(non-canonical fixture, compared with cmp so the trailing newline counts),
no_data when absent, 401 without a token and 401 with the ingest token.
Refs #27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test-curl.sh predates vitest and still claimed to be the authoritative suite
for one endpoint. It is now the socket-level pass, and its header says so:
vitest covers the logic, this script covers what only a real client and a
real TCP connection can show.
Twenty-four cases for POST /hosts/<id> and GET /hosts, chosen for what they
prove rather than for coverage: the 413 answered before the connection is
cut, the two tokens refusing each other's routes in both directions, 401
landing ahead of 403 so an unauthenticated caller cannot probe the allowlist,
the route regex turning a malformed id and a traversal attempt into 404
before any handler runs, and the ingest-persist-read round trip closing on a
GET that shows the pushed hostname online.
Two cases needed care to be worth anything:
The hostile payload splices "__proto__" into the JSON as text. Written as
__proto__: in an object literal it would set the prototype and JSON.stringify
would emit nothing, leaving the case asserting against a payload that never
carried the key. It now checks the response and the persisted file, whose key
set must be exactly the whitelist.
The 413 case runs under set -e while the server destroys the socket right
after flushing the response, so curl can exit 55/56 having already read the
status line. Every probe goes through an http_code helper that absorbs that;
a curl which truly got nothing reports 000, which fails the case rather than
passing it quietly. -H "Expect:" also suppresses the 100-continue handshake,
which otherwise changes which side notices the reset first.
The fail-closed 503 needs a server booted without HOSTS_INGEST_TOKEN, so the
script now runs a second instance on 3098 for it, and confirms reads still
answer 200 there — the two tokens are independent, including in absence.
The EXIT trap dereferenced an unset SERVER_PID under set -u, which made it
error out instead of cleaning up when anything failed before the boot. Both
PIDs are initialised and the kills guarded.
CLAUDE.md: the vitest count was stale (244 -> 251, auth.test.js 19 -> 26) and
test-curl.sh was undocumented.
Refs #16
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sergent renameSync() rotates reports/ -> reports/archive/ at 07:30 UTC daily,
so for ~22h per day the only copy of a fresh scan lives in archive/. The
handler now scans both directories and concatenates with top-level priority
on filename collision. archive/ missing is a silent skip.
Tests : 17/17 in test-curl.sh (11 existing + 6 new for archive coverage).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the SSH/rsync canal between Max's workstation cron and the VPS
for fetching defenseur scan reports. The defenseur-auto orchestrator now
pulls reports/defenseur-X_<date>*.json over HTTPS, reusing HEALTH_TOKEN.
The handler mirrors the style of index.js (HTTP native, no framework),
includes the same isScanReport guard as defenseurs/src/report.ts (filters
out defenseur-auto_*.json run reports), and validates the date param
against /^\d{4}-\d{2}-\d{2}$/ to short-circuit path traversal before any
filesystem access.
Validated by test-curl.sh — 11 cases covering auth, validation, date
filter, isScanReport filter, sort order, GET-only and 404 paths.
Spike: ~/claude-code/.spikes/archived/endpoint-reports-sur-vps-health-api-pour/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>