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>
- checkAuth() reads the expected token from a TOKEN_KINDS table
(read -> HEALTH_TOKEN, ingest -> HOSTS_INGEST_TOKEN, claude-ingest ->
CLAUDE_USAGE_INGEST_TOKEN); each ingestion token fails closed to a 503
naming its own variable. Still one gate, one tokenMatches() call. A route
naming an unknown kind fails module load.
- readBody() takes its ceiling per route: 4 KiB for /hosts/<id>, 64 KiB for
/claude-usage.
- claude-usage.js: pure, exported sanitizeClaudeUsage() for contract
schemaVersion 1 (whitelist rebuild, invalid values dropped, list bounds).
- POST /claude-usage: 400 on invalid JSON or schemaVersion, server-stamped
receivedAt, atomic write (pid-suffixed temp, mode 600, rename), 204.
- GET /claude-usage: re-sanitized snapshot with ageSeconds, stale and
staleAfterSeconds (CLAUDE_USAGE_STALE_SECONDS, default 2700), or no_data.
- Dockerfile COPY ships claude-usage.js.
- auth.test.js: the checkAuth() equality allowlist loses the former
`route.tokenKind === "ingest"` entry, which the table removes. The pin
only got stricter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Second review of #30: `startsWith("Bearer")` followed by slice(7) would
accept any byte as the separator (`Bearer\t<token>`, `BearerX<token>`) and
the suite stayed green. Two MALFORMED cases now reject both; the mutation
turns them red.
Refs #17
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #30 found four mutations that left the suite green: an early
success return for the read kind, a reversed `want === header` comparison,
and tokenMatches() reduced to `===` or to raw strings behind a length check
(the GHSA-376v-xgjx-7mfr shape).
Behavioural guard: a spy on crypto.timingSafeEqual, set before a fresh
require of index.js, requires every request (6 read routes, short and
same-length wrong tokens, the right token, the ingest route) to compare two
32-byte digests. Source pins now also allowlist every equality operator in
checkAuth() (both operand orders), require a single `return null`, and check
that tokenMatches() hashes both sides without a length test.
All seven mutations (the reviewer's four, the original three) now turn
13-15 tests red. CLAUDE.md no longer claims the pin covers the helper.
Refs #17
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The read path still compared the bearer with `header === \`Bearer ${token}\``,
which stops at the first differing byte and leaks the token's length and
prefix through response time. checkAuth() now has a single comparison for
both token kinds: `Bearer ` scheme check, then tokenMatches() (SHA-256
digests, then timingSafeEqual, so lengths are always equal). Only the
expected token varies.
Semantics are unchanged: exact match, case-sensitive scheme. Every existing
auth test passes untouched.
Tests: near-miss tokens (prefix, suffix, double space) on all six read
routes, an empty-token assertion on the helper, and a source pin that
forbids direct equality against a Bearer string and requires exactly one
tokenMatches() call in checkAuth(). A timing test would be flaky, so the pin
is the regression guard: three mutations back to direct comparison each
turn it red.
Fixes#17
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Sergent now persists per-finding state (firstSeen, unverified, 30-day
history) in findings-state.json next to status.json. The admin dashboard
needs it for the drill-down, since it is the only source carrying a
finding's age.
- New read route GET /defenseurs/state, path from
DEFENSEURS_FINDINGS_STATE_PATH (default /data/defenseurs/findings-state.json),
already covered by the /data/defenseurs mount.
- Extract serveJsonFile() so /defenseurs and /defenseurs/state share one
contract: raw content, or 200 {status:"no_data"} on any read error.
- Tests: new state.test.js (byte-identical body, no_data, path isolation,
401 x3, 404 on POST) and the route added to the auth.test.js matrix.
- Docs: README and CLAUDE.md endpoint, config and mount sections; the README
mount list now matches the real /data/defenseurs directory mount.
Fixes#27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to the /pr-review pass on PRs #18-#22. Three findings, none of
which changed behaviour, all of which weakened a guarantee the stack was
supposed to provide.
1. auth.test.js carried "any new route must be added here" but /hosts was
never added when #13 introduced it, so no test asserted GET /hosts -> 401
on a missing header. The drift class the file exists to catch slipped on
its first outing. ROUTES now covers /hosts, and a dedicated block pins
both directions of the token separation: a read token cannot write, an
ingest token cannot read.
2. ingestOversized() folded any transport error into 413, so the four
oversized-body tests would have stayed green if the server had stopped
writing the status and merely killed the socket - on the one path where
"a status, not a dead socket" is the whole client contract. Transport
errors are now surfaced instead of swallowed.
3. HOST_AGENT_TIMEOUT_MS was read by push-metrics.js but never exported by
run-push.sh, so setting it in the documented env file did nothing. Now
exported and documented.
Also drops a claim from agent/README.md that the review proved false: the
ingest/read token split buys no containment on the ThinkPad, which already
stores HEALTH_TOKEN in cleartext for defenseur-auto. Losing that laptop
compromises both, so they rotate together.
The 14 existing tests all hit /defenseurs/findings — /health, /defenseurs
and /reports/scans had no authentication coverage at all. This is the
safety net for the routing/auth refactor that comes next: a miswiring
could expose the parc-wide Defenseurs reports publicly without turning a
single test red.
Adds __tests__/auth.test.js (19 tests), following the findings.test.js
pattern (real http server + temp dir):
- 401 on all four routes with no Authorization header
- 401 on all four routes with a wrong bearer token
- 401 on malformed headers (no scheme, lowercase scheme, scheme only)
- 401 fail-closed when HEALTH_TOKEN is unset
- 404 on unknown routes and on POST against existing routes
These describe current behaviour: index.js is untouched. Two tests
document that route/method validation runs before authentication, so an
unauthenticated caller gets 404 rather than 401 on those paths.
Resolves#12