No description
Find a file
le king fu 56ee580167 test(hosts): grow the workstation API smoke pass into a full matrix
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
2026-08-16 12:05:31 -04:00
__tests__ test(hosts): grow the workstation API smoke pass into a full matrix 2026-08-16 12:05:31 -04:00
.env.example feat: ingest and serve workstation snapshots (POST /hosts/<id>, GET /hosts) 2026-08-16 11:49:36 -04:00
.gitignore feat: initial vps-health-api service 2026-02-26 20:48:09 -05:00
CLAUDE.md feat: ingest and serve workstation snapshots (POST /hosts/<id>, GET /hosts) 2026-08-16 11:49:36 -04:00
Dockerfile refactor: extract CPU/RAM/disk collection into metrics.js 2026-08-16 11:30:03 -04:00
index.js feat: ingest and serve workstation snapshots (POST /hosts/<id>, GET /hosts) 2026-08-16 11:49:36 -04:00
metrics.js refactor: extract CPU/RAM/disk collection into metrics.js 2026-08-16 11:30:03 -04:00
package-lock.json feat(defenseurs): add GET /defenseurs/findings?project=X route 2026-05-12 20:56:56 -04:00
package.json feat(defenseurs): add GET /defenseurs/findings?project=X route 2026-05-12 20:56:56 -04:00
README.md feat: ingest and serve workstation snapshots (POST /hosts/<id>, GET /hosts) 2026-08-16 11:49:36 -04:00
test-curl.sh feat(reports): scan archive/ subdir as fallback to handle post-07:30 UTC window 2026-05-10 16:53:14 -04:00
vitest.config.js feat(defenseurs): add GET /defenseurs/findings?project=X route 2026-05-12 20:56:56 -04:00

vps-health-api

Lightweight health monitoring API for the VPS. Node 22, HTTP-native, zero runtime deps.

Endpoints

Read endpoints require Authorization: Bearer $HEALTH_TOKEN. The single write endpoint uses its own token, $HOSTS_INGEST_TOKEN, so a workstation agent can push its snapshot without gaining read access to the Defenseurs reports.

Method Path Token Description
GET /health HEALTH_TOKEN CPU, memory, disk, uptime, Logto reachability
GET /defenseurs HEALTH_TOKEN Defenseurs executive status (status.json)
GET /defenseurs/findings?project=X HEALTH_TOKEN Detailed findings for a project's Defenseur
GET /reports/scans?date=YYYY-MM-DD HEALTH_TOKEN Aggregated scan reports for a UTC date
GET /hosts HEALTH_TOKEN Latest snapshot of every allowlisted workstation
POST /hosts/<id> HOSTS_INGEST_TOKEN Ingest one workstation snapshot

Routing resolves before authentication: an unknown path or a wrong method answers 404, never 401.

POST /hosts/<id>

Body: the GET /health payload minus logto and timestamphostname, uptime, cpu{model,cores,loadAvg,usagePercent}, memory{totalGB,usedGB,freeGB,usagePercent}, disk{…same four…}.

<id> must match ^[a-z0-9][a-z0-9-]{0,31}$ and be listed in HOSTS_ALLOWED_IDS. That regex is the path-traversal control — anything that fails it never reaches a handler.

Code Case
204 Snapshot accepted and written
400 Unparsable JSON, missing field, or wrong type
401 Ingest token missing or wrong
403 <id> well-formed but not in the allowlist
404 <id> outside the format (never reaches the handler)
413 Body past 4 KiB — connection cut
503 HOSTS_INGEST_TOKEN not configured (fail-closed)

The stored object is rebuilt field by field from a whitelist (numbers must be finite, strings are capped at 128 chars, everything else dropped) and written atomically via a temp file + rename. receivedAt is stamped by the server on arrival; a client-supplied timestamp is ignored.

Example:

curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" \
  -H "Content-Type: application/json" --data @snapshot.json \
  "https://health.lacompagniemaximus.com/hosts/thinkpad"

GET /hosts

{ "staleAfterSeconds": 900,
  "hosts": [ { "id", "hostname", "uptime", "cpu", "memory", "disk",
               "receivedAt", "ageSeconds", "online" } ] }

One entry per allowlisted id. online = ageSeconds <= staleAfterSeconds, computed server-side. A host that never checked in — or whose snapshot file is unreadable — degrades to { id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true } instead of failing the response, so the dashboard can say "agent not installed" during commissioning.

GET /defenseurs/findings

Query params:

  • project (required) — project name, looked up in agents-map.json (e.g. la-suite-booking)
  • category (optional) — exact match, one of deps|secrets|code|acces|infra
  • severity (optional) — threshold, one of CRITICAL|HIGH|MEDIUM|LOW|INFO
    • default (no param): MEDIUM, HIGH, CRITICAL
    • LOW returns LOW+MEDIUM+HIGH+CRITICAL but still hides INFO
    • INFO returns INFO only (explicit opt-in)

Responses:

  • 200 { agent, project, timestamp, findings: Finding[] } — report present (empty findings if clean scan; no status field)
  • 200 { findings: [], status: "no_data" } — no report on file for the agent
  • 400 — missing project or invalid category / severity
  • 401 — missing or invalid token
  • 404 — unknown project
  • 500agents-map.json unreadable or corrupted

Example:

curl -H "Authorization: Bearer $HEALTH_TOKEN" \
  "https://health.lacompagniemaximus.com/defenseurs/findings?project=la-suite-booking&severity=HIGH"

Config

Env var Default Purpose
PORT 3001 HTTP port
HEALTH_TOKEN Bearer token for the read routes (fail-closed if missing)
REPORTS_DIR /data/defenseurs/reports Scan reports dir
DEFENSEURS_AGENTS_MAP_PATH /data/defenseurs/agents-map.json Project -> agent snapshot
LOGTO_HEALTH_URL auth.lacompagniemaximus.com Logto OIDC discovery URL
HOSTS_DIR /data/hosts Where workstation snapshots are written
HOSTS_ALLOWED_IDS thinkpad Comma-separated allowlist of host ids
HOSTS_INGEST_TOKEN Bearer token for POST /hosts/<id> (503 if missing)
HOSTS_STALE_SECONDS 900 Age past which a host is reported offline

Bind-mounts (Coolify)

Read-only for the API — written by the Defenseurs Sergent:

  • /home/defenseur/defenseurs/status.json -> /data/defenseurs/status.json
  • /home/defenseur/defenseurs/reports/ -> /data/defenseurs/reports/
  • /home/defenseur/defenseurs/agents-map.json -> /data/defenseurs/agents-map.json

Read-write — the API is the writer:

  • <host>/data/hosts/ -> /data/hosts/ (HOSTS_DIR). Must be writable by uid 1000, the node user the container runs as, otherwise every ingest 500s.

Tests

npm install
npm test