The suite that shipped with the implementation pinned the decisions that
were expensive to get wrong. This turns it into the exhaustive matrix the
surface deserves: POST /hosts/<id> is the only publicly writable endpoint
on the service, so a mistake there is an intrusion rather than an outage.
__tests__/hosts.test.js goes from 22 to 155 cases (suite 61 -> 194):
- auth: nine near-miss Authorization headers, the read token refused on
the write route, the ingest token refused on all five read routes, and
the two tokens proven independent — either one unset leaves the other
path working
- rejection ordering: 404 (routing) beats 503 (ingest token unset) beats
403 (outside the allowlist), and a malformed id answers 404 whatever
token it carries, so route existence stays unenumerable
- routing: eleven more malformed ids, every one of them a 404. Widening
HOST_ROUTE_RE to `^/hosts/(.+)$` turns sixteen of them red, which is
what makes the narrow pattern a traversal control rather than a comment
- payload: twenty-two field mutations, eight bodies that are not a JSON
object, an Infinity only a raw body can express, and the 4 KiB ceiling
pinned from both sides — 4096 accepted, 4097 and 8 KiB refused
- persistence: 0600 mode, whitelist rebuild, 128-character cap, loadAvg
sliced to three, the route id winning over an id in the body, an
overwrite leaving no temp debris, and the 500 path when HOSTS_DIR
cannot be created
- freshness: 899/900/901 exact against a frozen Date.now() instead of the
wall clock, plus a custom HOSTS_STALE_SECONDS and its fallbacks
- listing: eight ways a snapshot file can be corrupt, each degrading its
own entry while a healthy neighbour keeps its data
The 4 KiB hostname of the acceptance criteria is covered twice, because
the body ceiling fires before the sanitiser ever sees it: 4096 characters
are refused with 413, and 3500 characters (which fit) are truncated to
128 with the long value absent from the file.
index.js is untouched. No defect surfaced, and the two mutations used to
prove the net bites were reverted.
Resolves#14
Open the API's first write surface. Workstations push their CPU / memory /
disk snapshot, the admin dashboard reads it back with a server-computed
freshness.
Routing moves from an exact path list to a descriptor table, keeping ONE
authentication checkpoint:
resolveRoute() -> no match means an immediate 404, deny by default
checkAuth() -> the single gate; only the expected token varies per
descriptor (HEALTH_TOKEN for reads, HOSTS_INGEST_TOKEN
for ingestion)
dispatch
The routing-before-auth ordering is preserved on purpose: an unknown path or
a wrong method still answers 404, never 401, exactly as before. The two
`404 (not 401)` tests added in #12 pin that ordering and still pass.
Security controls on the new write path:
- The route regex ^/hosts/([a-z0-9][a-z0-9-]{0,31})$, POST-only, IS the path
traversal control. URL() normalises /hosts/../../etc/passwd to /etc/passwd
and encoded traversals fail the match, so every hostile id lands on 404.
403 is reserved for well-formed ids outside the allowlist.
- 401 wins over 403, so an unauthenticated caller cannot probe the allowlist.
- tokenMatches() compares SHA-256 digests with timingSafeEqual. Scoped to the
ingestion path only; realigning HEALTH_TOKEN touches every read route in
production and is tracked separately.
- Body capped at 4 KiB by counting received bytes, never Content-Length:
a client can lie in the header and chunked encoding omits it. Past the cap,
413 then req.destroy() once the response has flushed.
- The persisted object is rebuilt field by field from a whitelist — finite
numbers, strings capped at 128 chars, everything else dropped — because the
file is read back by GET /hosts and ends up in the admin React tree. The
read path re-runs the same whitelist: the bind-mount is writable, so the
file on disk earns no more trust than the payload did.
- Snapshots are written to a temp file in the same directory then renamed, so
a concurrent GET /hosts can never observe a truncated JSON.
- HOSTS_ALLOWED_IDS entries are validated at startup with the same regex;
rejects are logged and dropped rather than becoming file paths.
- Every 401/403 is logged with X-Real-IP, the host id and the reason. Log
values are filtered to printable ASCII so a crafted header cannot forge
extra log lines.
- receivedAt is stamped by the server on arrival; a client-supplied timestamp
is discarded by the whitelist. online = ageSeconds <= HOSTS_STALE_SECONDS.
Runtime stays zero-dependency — node:crypto is a builtin and no new module
file was added, so the Dockerfile's explicit COPY list is unchanged.
Tests: 61 (39 existing untouched + 22 new). __tests__/hosts.test.js is smoke
coverage of the decisions that would be silent to regress; the exhaustive
matrix is issue #14.
Docs: .env.example gains HOSTS_DIR / HOSTS_ALLOWED_IDS / HOSTS_INGEST_TOKEN /
HOSTS_STALE_SECONDS; CLAUDE.md and README.md document the endpoints, the
routing/auth ordering and the config. The CLAUDE.md "read-only" gotcha is
corrected — the API now writes, and HOSTS_DIR must be writable by uid 1000.
Resolves#13