# 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`. Each write endpoint has its own token — `$HOSTS_INGEST_TOKEN` for workstation snapshots, `$CLAUDE_USAGE_INGEST_TOKEN` for the Claude usage snapshot, `$BACKUPS_INGEST_TOKEN` for the backup pull report — so an agent can push its data without gaining read access to the Defenseurs reports, and no ingestion token opens another one's route. | 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 | `/defenseurs/state` | `HEALTH_TOKEN` | Sergent's per-finding state (findings-state.json) | | 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/` | `HOSTS_INGEST_TOKEN` | Ingest one workstation snapshot | | GET | `/claude-usage` | `HEALTH_TOKEN` | Latest Claude usage snapshot, with server-side staleness | | POST | `/claude-usage` | `CLAUDE_USAGE_INGEST_TOKEN` | Ingest the Claude usage snapshot | | GET | `/backups` | `HEALTH_TOKEN` | PostgreSQL backups: VPS status and last workstation pull, with server-side staleness | | POST | `/backups/pull` | `BACKUPS_INGEST_TOKEN` | Ingest the workstation's pull report | Routing resolves before authentication: an unknown path or a wrong method answers `404`, never `401`. ### `POST /hosts/` Body: the `GET /health` payload minus `logto` and `timestamp` — `hostname`, `uptime`, `cpu{model,cores,loadAvg,usagePercent}`, `memory{totalGB,usedGB,freeGB,usagePercent}`, `disk{…same four…}`. `loadAvg` must hold exactly 3 finite numbers (the 1, 5 and 15-minute averages); any other length is a `400`. `` 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 | `` well-formed but not in the allowlist | | 404 | `` 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. Strings are truncated, not filtered: control characters and markup are kept so a legitimate value is never mangled. Consumers must escape them when rendering (React text nodes do; `dangerouslySetInnerHTML` or an `href` would not). A process killed mid-write can leave a `....tmp` file behind. Startup removes the ones older than an hour; `GET /hosts` never reads them. 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. ### `POST /claude-usage` Body: the Claude usage snapshot, contract `schemaVersion: 1`, shared with the workstation agent and the admin dashboard: ```jsonc { "schemaVersion": 1, "generatedAt": "2026-09-27T19:00:00.000Z", // workstation clock, informative "claudeCodeVersion": "2.1.283", "timeZone": "America/Toronto", "limits": { "status": "ok", // ok | token_expired | http_error | unavailable | disabled "detail": "http_429", // optional reason code "fetchedAt": "2026-09-27T19:00:00.000Z", // or null "items": [ { "kind": "session", "group": "session", "percent": 1, "severity": "normal", "resetsAt": "2026-09-27T21:30:00Z", "model": null } ] }, "days": [ { "date": "2026-09-27", "models": { "claude-opus-5-5": { "input": 2, "output": 1950, "cacheCreation": 43176, "cacheRead": 33814, "requests": 1 } } } ], "projects": [ { "name": "simpl-resultat", "work": 123456, "cacheRead": 7890123, "requests": 812 } ] } ``` The server rebuilds it from a whitelist (`sanitizeClaudeUsage` in `claude-usage.js`, a pure module the agent can test against). An unknown field is dropped; an invalid value is dropped (the key is left out), never coerced: - numbers must be safe integers ≥ 0 (`Number.isSafeInteger`); `percent` 0–100; - `limits.status` must be in the enum; `limits.detail` must match `^[a-z0-9_]{1,32}$` (a reason code, never free text); - `days` ≤ 31 (past that, the most recent 31), `date` `^\d{4}-\d{2}-\d{2}$` and a real calendar day; `models` ≤ 12 keys matching `^[a-z0-9][a-z0-9.\-]{0,63}$`; `limits.items` ≤ 12; `projects` ≤ 9; - timestamps must be full ISO 8601 with an offset; other strings are truncated to 128 characters. | Code | Case | |------|------| | 204 | Snapshot accepted and written | | 400 | Unparsable JSON, a body that is not an object, or `schemaVersion` other than 1 | | 401 | Token missing or wrong — including `HOSTS_INGEST_TOKEN` or `HEALTH_TOKEN` | | 413 | Body past 64 KiB (65 536 bytes received) — connection cut | | 503 | `CLAUDE_USAGE_INGEST_TOKEN` not configured (fail-closed) | `receivedAt` is stamped by the server; the file is written atomically (temp file with the pid in its name, mode 600, then `rename`). ``` curl -X POST -H "Authorization: Bearer $CLAUDE_USAGE_INGEST_TOKEN" \ -H "Content-Type: application/json" --data @claude-usage.json \ "https://health.lacompagniemaximus.com/claude-usage" ``` ### `GET /claude-usage` ``` { "staleAfterSeconds": 2700, "receivedAt": "…", "ageSeconds": 42, "stale": false, "snapshot": { …sanitized snapshot… } } ``` The stored file is re-run through the sanitizer on the way out. `stale = ageSeconds > staleAfterSeconds`, decided by the server only — the dashboard displays it, it does not recompute it. A missing or unreadable file (corrupt, off-contract, no usable `receivedAt`) answers `200 { "status": "no_data" }`. ### `POST /backups/pull` Sent once a day by the workstation pull agent ([`agent/pg-backup-pull/`](agent/README.md)) after it fetched the encrypted dumps (la-compagnie-maximus#220). Body, validated strictly (`backups.js`): ``` { "pulledAt": "", "files": = 0>, "ok": true|false, "error"?: "pull_failed"|"rotation_failed" } ``` `error` is required when `ok` is `false` and refused when it is `true`; any other code is a 400. Unknown fields, a client `receivedAt` among them, are dropped: the server stamps `receivedAt` itself. The report is written atomically (temp file in the same directory, mode 600, `rename`). | Code | Meaning | |------|---------| | 204 | Accepted | | 400 | Not JSON, not an object, a field missing or invalid, an error code outside the list | | 401 | Missing or wrong token (the three other tokens included) | | 413 | Body over 1 KiB (bytes received, not `Content-Length`); same answer as the other ingest routes | | 503 | `BACKUPS_INGEST_TOKEN` not configured (fail-closed) | ### `GET /backups` ``` { "vps": { …status.json, whitelisted…, "ageSeconds": 31000, "stale": false, "staleAfterSeconds": 129600 } | null, "lenovo": { "pulledAt": "…", "files": 9, "ok": true, "receivedAt": "…", "ageSeconds": 28000, "stale": false, "staleAfterSeconds": 129600 } | null, "vpsError"?: "", "lenovoError"?: "" } ``` - `vps` is the `status.json` written by the VPS script (`backup-postgres.sh`, contract `schemaVersion: 1`: `generatedAt`, `ok`, `expectedCount`, `databases[]`, `globals`, `mirror`, `rotation`, `retentionDays`), rebuilt through a whitelist: an unknown field is dropped, an invalid value is dropped rather than coerced, an `error` outside the closed list of codes is dropped (the entry keeps `ok: false`), a database name the script refused is dropped. Its age runs from `generatedAt`. - `lenovo` is the last `POST /backups/pull`, aged on the server's `receivedAt`, never on the workstation's `pulledAt`. - `stale = ageSeconds > staleAfterSeconds` (`BACKUPS_STALE_SECONDS`, 36 h), decided by the server only: the admin card and `/warmup` display it. - A file that is there but unusable gives a `null` block plus `vpsError` or `lenovoError` (`unreadable`, `invalid_json`, `unsupported_schema`, `invalid_contract`), never a 500, and the other block is still served. `200 { "status": "no_data" }` only when both files are absent. ### `GET /defenseurs/state` Serves the Sergent's `findings-state.json` verbatim — per-finding `firstSeen`, `unverified` status and 30-day history. It is the only source that carries a finding's age, which is why the admin dashboard's drill-down reads it alongside `/defenseurs`. The file is not parsed or checked for freshness here: the Sergent writes it atomically and quarantines unreadable state itself, and staleness is judged by the reader. - `200` — raw file content - `200 { status: "no_data" }` — file absent or unreadable (same contract as `/defenseurs`) - `401` — missing or invalid token ``` curl -H "Authorization: Bearer $HEALTH_TOKEN" \ "https://health.lacompagniemaximus.com/defenseurs/state" ``` ### `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 - `500` — `agents-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_STATUS_PATH` | `/data/defenseurs/status.json` | Served by `/defenseurs` | | `DEFENSEURS_FINDINGS_STATE_PATH` | `/data/defenseurs/findings-state.json` | Served by `/defenseurs/state` | | `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/` (503 if missing) | | `HOSTS_STALE_SECONDS` | `900` | Age past which a host is reported offline | | `CLAUDE_USAGE_INGEST_TOKEN` | — | Bearer token for `POST /claude-usage` (503 if missing) | | `CLAUDE_USAGE_PATH` | `/data/claude-usage/claude-usage.json` | Where the Claude usage snapshot is **written** | | `CLAUDE_USAGE_STALE_SECONDS` | `2700` | Age past which `GET /claude-usage` reports `stale: true` | | `BACKUPS_STATUS_PATH` | `/data/backups-status/status.json` | VPS backup status, **read** by `GET /backups` | | `BACKUPS_PULL_PATH` | `/data/backups-pull/pull.json` | Where the workstation pull report is **written** | | `BACKUPS_INGEST_TOKEN` | — | Bearer token for `POST /backups/pull` (503 if missing) | | `BACKUPS_STALE_SECONDS` | `129600` | Age past which a `GET /backups` block reports `stale: true` (non-numeric or <= 0: default) | ## Bind-mounts (Coolify) Mounted read-only (`:ro`) — written on the host by the Defenseurs Sergent: - `/data/defenseurs` -> `/data/defenseurs:ro` — holds `status.json`, `findings-state.json` and `agents-map.json`, all written there directly by the Sergent. (`/home/defenseur/defenseurs/status.json` is an obsolete decoy nobody reads.) - `/home/defenseur/defenseurs/reports` -> `/data/defenseurs/reports:ro` — its own mount, nested inside the previous one. - `/var/backups/postgres-status` -> `/data/backups-status:ro` — the `status.json` written by the VPS backup script (root, directory 755, file 644, so uid 1000 can read it). Mount the directory, never the file: the script replaces it by `rename`. Without this mount `GET /backups` still answers, with `vps: null`. Two layers keep the API out of these files: the `:ro` mode (a suffix on the Coolify destination path, copied as-is into the generated compose), and Unix permissions (content owned by `defenseur:defenseur`, uid and gid 1001, directories 755/775, files 664, while the container runs as uid 1000 with no supplementary group). The group bits grant write, so the second layer holds only while the owning group is not gid 1000 (`node` in the image, `ubuntu` on the host): a `chgrp ubuntu` would drop it silently. `__tests__/writes.test.js` pins that `node:fs` writes go nowhere but `HOSTS_DIR` — and, for `POST /claude-usage` and `POST /backups/pull` only, the directory of `CLAUDE_USAGE_PATH` and of `BACKUPS_PULL_PATH` respectively — and that the only shell commands are the two read-only ones in `metrics.js`, on every path it exercises: boot, nominal reads and ingest, rejections, invalid input, a corrupt file, missing files. Read-write — the API is the writer: - `/data/hosts/` -> `/data/hosts/` (`HOSTS_DIR`). Must be writable by uid 1000, the `node` user the container runs as, otherwise every ingest 500s. - `/data/claude-usage/` -> `/data/claude-usage/` (directory of `CLAUDE_USAGE_PATH`). Same rule: read-write, owned by uid 1000, otherwise every `POST /claude-usage` 500s. Mount the directory, never the file: the snapshot is replaced by `rename`, which a file bind would not follow. - `/data/backups-pull/` -> `/data/backups-pull/` (directory of `BACKUPS_PULL_PATH`). Same rule: read-write, owned by uid 1000, the directory and not the file. Not writable, every `POST /backups/pull` answers 500 while `GET /backups` keeps serving the VPS block. ## Workstation agent `agent/` holds the zero-dependency collector that runs from cron on a workstation and feeds `POST /hosts/`. It is not part of the server image — it is installed by copying files onto the machine it measures. Install steps, crontab line and failure modes: [`agent/README.md`](agent/README.md). ## Tests ``` npm install npm test ```