Review follow-up on #60: only `local` was checked with an invalid value,
so copying `retentionDays.offsite` unvalidated left the suite green.
Also name all four tokens in the Coolify runtime-only rule of CLAUDE.md.
Refs #58
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Anchoring the rotation on the newest directory name let the VPS steer
it: one future-dated directory (VPS clock off, or a VPS gone rogue)
pushed the window forward and deleted every real backup, with exit 0
and an ok report. Two more inputs from the outbox could do harm: rsync
-rt rewrote an already pulled .age whenever the VPS served another
version, and an impossible dated name (2026-13-45) failed `date -d`
under set -e, stopping every later pass for good.
- latest is the newest valid dated directory not after today; the
rotation keeps it and the 29 days before it;
- a name is dated only if `date` gives it back unchanged (busybox turns
2026-02-30 into 2026-03-02); an invalid or future name is logged,
never counted, never removed, and the pass goes on;
- --ignore-existing: a pulled file is never rewritten. The VPS writes a
.tmp and renames it, and the filter drops the .tmp.
Refs maximus/la-compagnie-maximus#220
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rotation cutoff was TODAY minus 29 days. When the VPS stops
producing (dead cron, failing pg_dump) or this machine's clock jumps
ahead, the last off-site copy aged out after 30 days, the pass exited 0
and the report said ok on a directory it had just deleted. The cutoff
now comes from the newest dated directory held here, which is never
removed.
Review suggestions:
- --prune-empty-dirs: a dated directory with no .age file is not
created, and one found empty logs a warning;
- files counts the dated directory only (-maxdepth 1), not partial
files left in .rsync-partial/;
- an EXIT trap removes the header file holding the ingest token, with
TERM/INT/HUP turned into an exit so a stop by systemd runs it.
Refs maximus/la-compagnie-maximus#220
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CI runs the tests in node:22-alpine, where 9 of the 12 cases failed:
no ssh on PATH, and busybox has neither `find -printf` nor
`date -d "... days"`. The newest dated directory is now found by a loop
over the glob, and the rotation cutoff by epoch arithmetic, which GNU
date and busybox both read. The tests add a fake ssh, and a fake flock
where the host has none.
The rotation_failed case failed the pass through a read-only directory,
which root ignores: it now uses a fake rm.
Review suggestions: the rsync argv must put --include=*/ before
--exclude=* (without it nothing crosses and the pass still says ok), and
a case runs the real rsync through the fake ssh when rsync is present.
A pass whose newest directory is not today's logs a warning.
Refs maximus/la-compagnie-maximus#220
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POSIX sh agent run by a systemd user timer (Persistent=true, retries on
failure): rsync over the classic sshd on 100.86.30.21:2222 with a pinned
host key, .age files only, 30-day rotation that fails loudly, and a
report to POST /backups/pull when the ingest token is configured (sent
through a header file, never argv). Never decrypts.
Refs maximus/la-compagnie-maximus#220
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of PR #57, suggestions 1 and 2:
- the excluded open day is now the most recent date of the snapshot,
not its last element, so the rule no longer depends on the order the
days were sent in (the sanitizer does not sort them);
- a day dated past the server's UTC reception day is never archived, and
GET /claude-usage/history no longer takes a month file dated past the
current month as its default end. A skewed workstation clock could
otherwise open history/2099-12.json and leave the default range empty.
Refs #55
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agent/claude-usage/backfill-history.js runs by hand from the repo (never
from the cron install, the agent is unchanged). It rebuilds the closed days
with the agent's own aggregate() on past windows and writes { days } to the
--out file, which the server merges with `claude-usage-history.js --import`
(an already archived date wins).
Each 30-day window keeps only its 28 inner days and the next steps back 28
days, so a request straddling a window boundary is deduplicated by the
window that keeps it and counted once. The first window stops at D-2. The
walk is bounded by --max-windows (default 24) rather than guessed from file
mtimes, which say nothing of the oldest line a resumed session holds.
It never reads the credentials file, opens no connection, writes only the
--out file (mode 600, never overwritten) and prints counts only.
Refs #55
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>
Review of #56: making ENOENT an empty scan on every directory, not only the
root, left the suite green. A vanished subdirectory is now a pinned read
error. The header also names the other known limit of the midnight fix:
the mtime threshold may skip a subagent transcript holding the only copy
of a straddling request's earlier line.
Refs #54
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- aggregate: frozen days are dropped after deduplication, not before. A
request straddling midnight whose best line sat on a day frozen since
was counted again on the open day. On equal output the earliest line now
wins (it was the first one read), so the choice no longer depends on
which files a beat reads.
- aggregate: unreadable directories, failed stats (ENOENT included) and
stream errors are counted in `readErrors`; a missing root is still an
empty scan. The beat then freezes no new day (nextState freeze: false),
still pushes, and logs `read_errors=N` with no path.
- limits: the response is read chunk by chunk and counted in bytes; past
256 KiB the stream is cancelled instead of being read in full and
measured in characters.
- state: a test now covers the chmodSync of a leftover temp file.
Each fix was checked by mutation (13 mutations, 13 red).
Refs #54
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
readClaudeUsage() and readHostSnapshot() validated the stored receivedAt
with new Date(), which accepts "1" or a bare date. Both now go through
isIsoTimestamp(), exported by claude-usage.js and built on the sanitizer's
own check, so no regex is duplicated.
The existing 500 tests failed at mkdir, before the temp file existed, so
removing the unlinkSync() cleanup of either writer left the suite green. Two
new cases make the rename fail after the temp write (the target is a
non-empty directory) and require that no temp file is left.
Refs #54
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
limits.js reads the Claude Code OAuth credentials read-only and calls the
pinned Anthropic usage endpoint once (redirect refused, 10 s timeout, no
retry, never a refresh); detail codes come from a fixed enum only.
state.js freezes every Toronto day before yesterday, empty ones included,
prunes past 35 days, keeps the last good limits, and writes atomically in
mode 600. push-claude-usage.js writes the cache before pushing, refuses a
non-https URL (exit 1) and a body over 64 KiB (exit 2, nothing sent), and
prints no token. run-claude-usage.sh takes a non-blocking flock so a beat
that overlaps the previous one exits 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The threshold came from the most recent frozen key: a frozen day in the
future, or a gap in the frozen run, pushed it past open days whose files
were then never read, and the agent would have frozen them empty for good.
It is now the oldest open day of the window (the same day as before for the
agent's contiguous frozen prefix).
Project, model and day maps are now Object.create(null): a project named
__proto__ or constructor wrote NaN counters onto Object.prototype and
dropped out of the top.
Resolves#49
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pure, dependency-free module for the claude-usage agent. It walks
~/.claude/projects/**/*.jsonl (subagent transcripts included), keeps
assistant lines with message.usage, drops <synthetic> and truncated
lines, deduplicates on (message.id, requestId) across all files at the
largest output_tokens, and buckets by America/Toronto day computed as
strings only (DST-safe).
Returns { openDays, days, projects, claudeCodeVersion }: the recomputed
non-frozen days, the merged 30-day series (at most 12 models per day,
the rest folded into "other"), the top 8 projects by work tokens plus
"(autres)", and the version of the newest line. Files whose mtime day
is before the day after the most recent frozen day are never opened.
Resolves#49
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- __tests__/claude-usage.test.js (119 cases): sanitizer contract and
idempotence, dropped values (unknown fields, negative, past 2^53,
detail outside its regex, status outside the enum, bad dates, model
keys), list bounds, token isolation both ways, 503 naming each variable,
413 at 65 537 bytes and /hosts/<id> still 413 at 4 097, atomic mode-600
write, POST then GET round trip, exact staleness boundary under a frozen
clock, no_data degradation, rejection logs, COPY line.
- writes.test.js: POST /claude-usage may write only in the directory of
CLAUDE_USAGE_PATH, its rejections write nothing, GET /claude-usage joins
the read routes.
- README.md and CLAUDE.md: both routes, the three env vars, the
/data/claude-usage read-write mount owned by uid 1000, the token table.
Resolves#48
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>
Non-blocking points of the second review of PR #47: refusing 65535, and
dropping the end anchor of the pattern, both left the suite green.
- Ports 1 and 65535 must be accepted. Given to both variables at once,
so the run stops on the "must differ" refusal, which comes after the
range check, and nothing listens on a port fixed in advance.
- "80abc" must be refused, on each variable.
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>
A node:fs function handed as-is to an asynchronous mechanism
(process.nextTick(writeFileSync, ...), a bound listener, a .then) runs with
no runtime frame on its stack, so the stack filter let the write through on
a path the suite does exercise.
The spies now record every call. The test's own fixture edits go through
the real functions captured before any spy exists. The recursive rmSync of
the reports directory becomes a rename: rmSync recurses through the public
fs.rmdirSync, which is the spy by then.
Fixes#40
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two promises of sweepStaleTempFiles() were held by no test:
- lstat, not stat: a symlink named like a temp file is neither followed
nor removed. The link is aged too, so the isFile() guard is what keeps it.
- the ^ anchor of TEMP_FILE_RE: a temp name behind a prefix is never touched.
Each new case goes red under its mutation (statSync, anchor removed,
isFile() dropped); index.js itself is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No runtime file breaks the "require at column 0" pin, so nothing
exercised its detector: on a03e4d4, bringing either regex back to its
form on main, or deleting the require or the import clause, left the 19
cases green, and the two holes this PR closes could reopen unnoticed.
The rule moves into loadsLate(line), which the pin on index.js and
metrics.js now calls, and six rows check it directly: an indented
require and a dynamic import at any column are flagged, each with and
without a space before the parenthesis; a top-level require and a
comment line quoting require are let through.
Measured on the new tree: either regex back to its main form turns 1
row red, removing the require or the import clause turns 2 rows red,
removing the column condition turns 3 red (the two real files and the
top-level row), removing the comment skip turns 1 red.
ci.test.js 19 -> 25 cases, vitest 347 -> 353.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The self-check comment of the continue-on-error detector said that
requiring a colon after the word loses the anchor row only. That holds
when a closing quote may sit before the colon; with the colon right
after the word, the quoted-key row goes too. Measured: 2 rows red with
the colon right after the word, 1 with a closing quote allowed, 3 with
the original anchored regex.
The port check of boot-from-copy.sh named NO_PROXY as what keeps a
proxy out, while curl reads no_proxy first: it now names both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The example of a key that still passes the continue-on-error pin had
lost its u002d escape sequence on the way into ci.test.js and CLAUDE.md:
both read as the plain quoted key, which the pin catches (the "a quoted
key" self-check row holds that very text). CLAUDE.md thus said the
detector was checked on the quoted key, then that the same key passed.
The example now carries the escape, written through a backslash built
at runtime so that no tool decodes it: `grep -c u002d` gives 1 per
file, PyYAML reads the key as continue-on-error, the pin misses it.
The list of known holes of the column-0 require pin now opens with
"among others" ("entre autres" in CLAUDE.md): a line break or a comment
between require and its parenthesis, and require?.(, pass as well, as
the review of 92bfb42 measured.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three findings of the review, each first measured green on the previous
tree (18/18), then red on this one.
The proxy test dropped the inherited no_proxy, so a script exporting
NO_PROXY alone stayed green, although curl reads the lowercase variable
first and an inherited no_proxy would win. The test now sets no_proxy
to a list that leaves the loopback out. Red when the script exports
NO_PROXY alone, and on the script from main.
The column-0 require pin missed a space before the parenthesis:
an indented `require (` and an `import (` both passed. The regexes
now accept whitespace there. Red on each.
The continue-on-error detector required a colon after the word, so a
YAML anchor reused as a key (`&coe continue-on-error`, then
`*coe : true`) passed, while PyYAML reads it as the key. The detector
is now the bare word, with a self-check row for the anchor; red when
the colon is required again, three rows red with the original anchored
regex. An escaped key still passes, since the pin reads text and not
YAML: the comment and CLAUDE.md now claim any literal spelling, no
longer any spelling, and list the indirections the require pin cannot
follow.
ci.test.js 18 -> 19 cases, vitest 346 -> 347.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PR #34 review listed two forms that pass the "require at column 0"
heuristic: a function written whole on a column-0 line, and a require(
wrapped onto a column-0 line inside a function. Both stay open: neither
fits the style of the repository, and the second has the very shape of
the legitimate `} = require("node:fs");` that closes index.js's
multi-line import, so only a parser could tell them apart. They are now
written next to the pin and in CLAUDE.md, rather than only in a closed
PR and its follow-up issue.
CLAUDE.md also records the proxy bypass of boot-from-copy.sh, the wider
continue-on-error pin and its self-check, and the new counts (ci.test.js
12 -> 18 cases, vitest 340 -> 346).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pin was anchored at the start of the line, so a step written as a
flow mapping, `- { name: x, run: false, continue-on-error: true }`,
passed it: 12/12 green with that step appended to ci.yml. A quoted key,
`"continue-on-error": true`, escaped it the same way.
The detector is now unanchored and accepts a closing quote before the
colon. It still runs on `ciCode`, so full comment lines may quote the
key; anything else that quotes it (a run: script, a step name, a
trailing comment) now fails the pin, loudly, by design.
The detector is checked on its own: through the same comment stripping
as `ciCode`, it must catch a block key, a list item, a flow mapping and
a quoted key, and ignore a full comment line. Proven by mutation: the
flow-style and quoted-key steps appended to ci.yml each turn the pin
red; the old anchored regex turns the flow-mapping and quoted-key rows
red; dropping the quote class turns the quoted-key row red; disabling
the comment stripping turns the comment row red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the port pre-check of scripts/boot-from-copy.sh carried --noproxy.
The wait loop and the two assertions still honoured http_proxy, so on a
workstation with a proxy configured the script talked to the proxy
instead of the server it had just started, and failed loudly.
Export NO_PROXY='*' and no_proxy='*' at the top of the script, which
covers every curl call, and drop the now-redundant --noproxy.
The new test boots the script for real behind a fake proxy that answers
everything and records what reaches it. It first proves the proxy is
live (a bare curl with the same environment reaches it), then requires
that nothing of the boot check reaches it and that the script passes.
Red on the previous script (the wait loop and the first assertion went
through the proxy), red again when the export is removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs promised that writes.test.js pins every write. They now say what
it covers: node:fs writes and shell commands, on the paths it exercises.
They also name what keeps the second layer up: the Defenseurs content is
group-writable, and only the owning group (defenseur, gid 1001, not the
gid 1000 of node and ubuntu) keeps the container out, so a chgrp ubuntu
would drop that layer silently.
Fixes#36
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of PR #39: the spies only watched authenticated requests on
complete fixtures, so a write on an error path passed the whole suite
(reviewer mutations R1, R2, R3 and R11: quarantining a corrupt
agents-map.json, recreating a missing status.json, logging a 401/403 or a
refused payload to disk).
A new case walks rejections (401/403/404), invalid input (400/413), a
corrupt agents-map.json, missing Defenseurs files and a corrupt snapshot,
requires the degraded content (no_data, neverSeen), and requires no write
outside HOSTS_DIR. child_process is now spied the same way as node:fs and
the set of shell commands is pinned to the two read-only ones metrics.js
runs, with GET /health as positive control.
R1, R2, R3 and R11 went from 0 to 1 red each; R4 (touch through
execFileSync) is now caught by this file. A write deferred by a timer
longer than 20 ms (R8b) stays out of sight, as the header says.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both Defenseurs bind mounts are `:ro` since 2026-09-30, set as a suffix
on the Coolify destination path; the reports Directory Mount was recreated
as a Volume Mount with a Source Path to take it. CLAUDE.md, README.md and
.env.example now describe the two layers (mount mode and Unix
permissions), the three checks to run after a redeploy, and the Coolify
gotchas met on the way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two Defenseurs mounts are now read-only in the container, so a write
added one day under REPORTS_DIR or /data/defenseurs would fail in
production as a 500 (EROFS) while every other test stays green: their
fixtures are writable temp dirs, and CI runs as root.
writes.test.js spies every node:fs write API (callback, Sync and
fs.promises forms) before a fresh require, after purging all runtime
modules from the require cache, and records the calls whose stack goes
through a runtime file. It covers boot (the temp-file sweep), the six GET
routes (no write at all, one macrotask after the response included) and
POST /hosts/<id> (writes only under HOSTS_DIR). Each case proves its own
spies are live, and the require.main block is pinned since it never runs
in-process.
Mutation proofs: a write at module load, in the scan collector, through an
fs function not imported today, in the ingest rename or in metrics.js, a
dead spy, and a new call in the main block each turn the expected case
red. With the cache purge narrowed to index.js, the metrics.js write stays
green.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-ups from the review of the workstation monitoring stack (#18-#23):
- loadAvg must hold exactly 3 finite numbers (1/5/15-min averages). The
admin card joins them, so a shorter array rendered a partial line and a
longer one was silently truncated; both are now a 400, and a stored
snapshot off that format degrades to neverSeen on read.
- sweepStaleTempFiles() runs at startup, before listen, and removes the
`.<id>.<pid>.<ms>.tmp` files a process killed mid-write leaves behind,
once older than an hour. Exact name format only, regular files only
(lstat), best effort: a missing HOSTS_DIR stays silent, other failures
are logged and never stop the server.
- Document that cleanString keeps control characters on purpose (safety
rests on the consumer escaping at render time), and that frozenRequest
does not freeze `new Date()`, which stamps receivedAt on POST.
Fixes#25
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Forbid `continue-on-error` in ci.yml: that mutation left every test green,
and a failing step would turn the commit status green in silence.
- boot-from-copy.sh refuses a port already held by something else (any curl
exit other than 7). Without it, the wait loop could reach the neighbour and
assert on a server it never started — the new test reproduces exactly that
when the check is removed.
- Pin every require of the COPY'd runtime modules at column 0, with no
dynamic import: the boot check only calls /health, so it only proves the
modules loaded at startup.
Fixes#33
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing verified this repository until now. The workflow runs on every pull
request (no branches filter, so intermediate stack links get a status) and on
pushes to main, in node:22-alpine, the Dockerfile's own base: npm ci, npm test,
the socket-level pass, then scripts/boot-from-copy.sh.
The script stands in for a docker build, which would need the Docker socket in
the instance-scoped runner and so root on the production VPS for every
workflow. It starts the CMD from the COPY sources alone and fails on the
MODULE_NOT_FOUND that #11 had to fix by hand. __tests__/ci.test.js pins the
workflow and what the script assumes of the Dockerfile.
Refs #24
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>
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>