From e1224e0df418c9927181dfb507506e5dc17a417a Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 4 Oct 2026 09:16:05 -0400 Subject: [PATCH] feat(claude-usage): add POST and GET /claude-usage behind a third token kind - 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/, 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 --- .env.example | 12 +++ Dockerfile | 2 +- __tests__/auth.test.js | 6 +- claude-usage.js | 187 +++++++++++++++++++++++++++++++++++++++ index.js | 193 ++++++++++++++++++++++++++++++++++++++--- 5 files changed, 383 insertions(+), 17 deletions(-) create mode 100644 claude-usage.js diff --git a/.env.example b/.env.example index fa768f6..76dc155 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,15 @@ HOSTS_ALLOWED_IDS=thinkpad HOSTS_INGEST_TOKEN=change-me-to-a-strong-secret # Age (seconds) past which GET /hosts reports a host as offline. Default 900. HOSTS_STALE_SECONDS=900 + +# --- Claude usage snapshot (POST /claude-usage, GET /claude-usage) --------- +# Bearer token for POST /claude-usage, pushed by the ThinkPad agent. Distinct +# from HOSTS_INGEST_TOKEN and HEALTH_TOKEN: each one is refused on the others' +# routes. Same Coolify rule — is_runtime=true, is_buildtime=false. Unset -> +# POST /claude-usage answers 503. +CLAUDE_USAGE_INGEST_TOKEN=change-me-to-a-strong-secret +# File the API WRITES (atomic rename). Its directory must be its own read-write +# mount owned by uid 1000, like HOSTS_DIR. +CLAUDE_USAGE_PATH=/data/claude-usage/claude-usage.json +# Age (seconds) past which GET /claude-usage reports stale: true. Default 2700. +CLAUDE_USAGE_STALE_SECONDS=2700 diff --git a/Dockerfile b/Dockerfile index 8def940..1324920 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM node:22-alpine WORKDIR /app -COPY package.json index.js metrics.js ./ +COPY package.json index.js metrics.js claude-usage.js ./ EXPOSE 3001 USER node CMD ["node", "index.js"] diff --git a/__tests__/auth.test.js b/__tests__/auth.test.js index 7173738..e5549c7 100644 --- a/__tests__/auth.test.js +++ b/__tests__/auth.test.js @@ -257,8 +257,10 @@ describe("auth gate — source pins: one constant-time comparison", () => { // A single `return null`: no early success exit for one token kind. expect(body.match(/return\s+null\b/g)).toHaveLength(1); // Allowlist, both operand orders included: anything else (`want === - // header`, `expected === token`) is a new comparison on a secret. - expect(comparisons(body).sort()).toEqual(['header === "string"', 'route.tokenKind === "ingest"']); + // header`, `expected === token`) is a new comparison on a secret. Since + // #48 the expected token comes from the TOKEN_KINDS table, so the former + // `route.tokenKind === "ingest"` branch is gone: the list only shrank. + expect(comparisons(body).sort()).toEqual(['header === "string"']); expect(body).not.toMatch(/isIngest\s*\?[^;]*tokenMatches/); }); diff --git a/claude-usage.js b/claude-usage.js new file mode 100644 index 0000000..7097c7c --- /dev/null +++ b/claude-usage.js @@ -0,0 +1,187 @@ +// Claude usage snapshot (POST /claude-usage, GET /claude-usage), contract +// `schemaVersion: 1`, shared with the ThinkPad agent and the admin dashboard. +// +// Pure module: no I/O, no config, no side effects. index.js wires the routes +// and owns the file; the workstation agent can test its own snapshot against +// this very sanitizer before it ever sends it. +// +// Whitelist rebuild, field by field. The stored object is read back by GET +// /claude-usage and ends up in the admin dashboard's React tree, so nothing +// from the payload is kept verbatim: +// - an unknown field is dropped; +// - an invalid value is dropped (the key is left out), never coerced. That +// covers a negative number, a number past 2^53, a `detail` outside its +// regex and a `status` outside its enum; +// - a list past its bound keeps its first entries (`days` keeps its LAST +// ones: the contract orders them oldest first, so today survives); +// - strings are truncated to 128 characters, not filtered: safety rests on +// the consumer escaping at render time, as for the host snapshots. +// Only two things refuse the whole body: a body that is not a JSON object, and +// a `schemaVersion` other than 1 (a future contract must not be silently +// reshaped into this one). + +const SCHEMA_VERSION = 1; + +// Worst case within the bounds below is about 46 KB, hence 64 KiB and not 32. +const CLAUDE_USAGE_MAX_BODY_BYTES = 65536; + +const MAX_DAYS = 31; +const MAX_MODELS_PER_DAY = 12; +const MAX_LIMIT_ITEMS = 12; +const MAX_PROJECTS = 9; +const MAX_STRING_LENGTH = 128; + +const LIMIT_STATUSES = ["ok", "token_expired", "http_error", "unavailable", "disabled"]; +const DAY_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const MODEL_KEY_RE = /^[a-z0-9][a-z0-9.\-]{0,63}$/; +// A machine-readable reason code, never free text: a free-form detail could +// carry an upstream response body all the way to the dashboard. +const DETAIL_RE = /^[a-z0-9_]{1,32}$/; +// Full ISO 8601 timestamp with an explicit offset, as Date#toISOString() and +// the upstream API write them. +const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/; + +const MODEL_COUNTERS = ["input", "output", "cacheCreation", "cacheRead", "requests"]; +const PROJECT_COUNTERS = ["work", "cacheRead", "requests"]; + +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function cleanString(value) { + if (typeof value !== "string") return undefined; + return value.slice(0, MAX_STRING_LENGTH); +} + +function cleanCount(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : undefined; +} + +function cleanPercent(value) { + const count = cleanCount(value); + return count !== undefined && count <= 100 ? count : undefined; +} + +function cleanTimestamp(value) { + if (typeof value !== "string" || value.length > MAX_STRING_LENGTH) return undefined; + if (!ISO_TIMESTAMP_RE.test(value) || Number.isNaN(Date.parse(value))) return undefined; + return value; +} + +// The calendar check catches what the regex lets through (2026-02-31). +function cleanDayDate(value) { + if (typeof value !== "string" || !DAY_DATE_RE.test(value)) return undefined; + const parsed = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) return undefined; + return value; +} + +// Copy `key` into `out` only when the cleaner kept a value. +function put(out, key, value) { + if (value !== undefined) out[key] = value; +} + +// `null` is a legitimate value for the fields the contract declares nullable. +function putNullable(out, key, raw, clean) { + if (raw === null) out[key] = null; + else put(out, key, clean(raw)); +} + +function sanitizeCounters(value, keys) { + const out = {}; + for (const key of keys) put(out, key, cleanCount(value[key])); + return out; +} + +function sanitizeModels(value) { + if (!isPlainObject(value)) return undefined; + const out = {}; + let kept = 0; + for (const key of Object.keys(value)) { + if (kept >= MAX_MODELS_PER_DAY) break; + if (!MODEL_KEY_RE.test(key) || !isPlainObject(value[key])) continue; + out[key] = sanitizeCounters(value[key], MODEL_COUNTERS); + kept += 1; + } + return out; +} + +function sanitizeDays(value) { + if (!Array.isArray(value)) return undefined; + const days = []; + for (const entry of value) { + if (!isPlainObject(entry)) continue; + const date = cleanDayDate(entry.date); + if (date === undefined) continue; + const day = { date }; + put(day, "models", sanitizeModels(entry.models)); + days.push(day); + } + return days.slice(-MAX_DAYS); +} + +function sanitizeLimitItem(value) { + const item = {}; + put(item, "kind", cleanString(value.kind)); + put(item, "group", cleanString(value.group)); + put(item, "percent", cleanPercent(value.percent)); + put(item, "severity", cleanString(value.severity)); + putNullable(item, "resetsAt", value.resetsAt, cleanTimestamp); + putNullable(item, "model", value.model, cleanString); + return item; +} + +function sanitizeLimits(value) { + if (!isPlainObject(value)) return undefined; + const limits = {}; + put(limits, "status", LIMIT_STATUSES.includes(value.status) ? value.status : undefined); + put(limits, "detail", typeof value.detail === "string" && DETAIL_RE.test(value.detail) ? value.detail : undefined); + putNullable(limits, "fetchedAt", value.fetchedAt, cleanTimestamp); + if (Array.isArray(value.items)) { + limits.items = value.items.filter(isPlainObject).slice(0, MAX_LIMIT_ITEMS).map(sanitizeLimitItem); + } + return limits; +} + +function sanitizeProjects(value) { + if (!Array.isArray(value)) return undefined; + return value + .filter(isPlainObject) + .slice(0, MAX_PROJECTS) + .map((entry) => { + const project = {}; + put(project, "name", cleanString(entry.name)); + Object.assign(project, sanitizeCounters(entry, PROJECT_COUNTERS)); + return project; + }); +} + +// Returns { snapshot } or { error }. Idempotent: a sanitized snapshot comes +// back unchanged, which is what lets GET re-run it on the stored file. +function sanitizeClaudeUsage(payload) { + if (!isPlainObject(payload)) return { error: "body must be a JSON object" }; + if (payload.schemaVersion !== SCHEMA_VERSION) { + return { error: `schemaVersion must be ${SCHEMA_VERSION}` }; + } + + const snapshot = { schemaVersion: SCHEMA_VERSION }; + put(snapshot, "generatedAt", cleanTimestamp(payload.generatedAt)); + put(snapshot, "claudeCodeVersion", cleanString(payload.claudeCodeVersion)); + put(snapshot, "timeZone", cleanString(payload.timeZone)); + put(snapshot, "limits", sanitizeLimits(payload.limits)); + put(snapshot, "days", sanitizeDays(payload.days)); + put(snapshot, "projects", sanitizeProjects(payload.projects)); + return { snapshot }; +} + +module.exports = { + sanitizeClaudeUsage, + CLAUDE_USAGE_MAX_BODY_BYTES, + SCHEMA_VERSION, + MAX_DAYS, + MAX_MODELS_PER_DAY, + MAX_LIMIT_ITEMS, + MAX_PROJECTS, + MAX_STRING_LENGTH, + LIMIT_STATUSES, +}; diff --git a/index.js b/index.js index 0143ffe..8fb7769 100644 --- a/index.js +++ b/index.js @@ -13,6 +13,7 @@ const { const path = require("node:path"); const { createHash, timingSafeEqual } = require("node:crypto"); const { collectMetrics } = require("./metrics.js"); +const { sanitizeClaudeUsage, CLAUDE_USAGE_MAX_BODY_BYTES } = require("./claude-usage.js"); const PORT = parseInt(process.env.PORT || "3001", 10); const TOKEN = process.env.HEALTH_TOKEN; @@ -75,6 +76,19 @@ function parseAllowedIds(raw) { const HOSTS_ALLOWED_IDS = parseAllowedIds(process.env.HOSTS_ALLOWED_IDS || "thinkpad"); +// --- Claude usage snapshot (POST /claude-usage, GET /claude-usage) ---------- + +// Its own token, never the workstations' one: rotated on its own, and a leaked +// host token must not be able to overwrite the usage snapshot (or the reverse). +// The sanitizer and the contract bounds live in claude-usage.js. +const CLAUDE_USAGE_INGEST_TOKEN = process.env.CLAUDE_USAGE_INGEST_TOKEN; +const CLAUDE_USAGE_PATH = + process.env.CLAUDE_USAGE_PATH || "/data/claude-usage/claude-usage.json"; + +const claudeStaleRaw = parseInt(process.env.CLAUDE_USAGE_STALE_SECONDS || "", 10); +const CLAUDE_USAGE_STALE_SECONDS = + Number.isFinite(claudeStaleRaw) && claudeStaleRaw > 0 ? claudeStaleRaw : 2700; + // Severity filter. Asymmetric rule (issue #3): // - no threshold -> MEDIUM+HIGH+CRITICAL (default hides noise LOW+INFO) // - threshold "INFO" -> INFO only (explicit opt-in) @@ -258,7 +272,7 @@ function logRejection(req, status, reason, id) { ); } -// Constant-time bearer comparison, used for BOTH tokens (read and ingest). Both +// Constant-time bearer comparison, used for EVERY token (read, both ingests). Both // sides are hashed first, so the buffers are always 32 bytes: timingSafeEqual // never throws on mismatched lengths, and the length of the real token cannot // leak. Passing the raw strings to timingSafeEqual would leak it @@ -270,13 +284,14 @@ function tokenMatches(provided, expected) { return timingSafeEqual(a, b); } -// Read the request body with a hard 4 KiB ceiling. The counter runs on the -// bytes actually received, never on Content-Length: a client can lie in the -// header, and chunked transfer-encoding omits it altogether. +// Read the request body with a hard ceiling of `maxBytes`, set per route: 4 KiB +// for a host snapshot, 64 KiB for the Claude usage snapshot. The counter runs +// on the bytes actually received, never on Content-Length: a client can lie in +// the header, and chunked transfer-encoding omits it altogether. // // Resolves to the raw string, or to null when the response has already been // written (413) or the socket died — in which case the caller must not answer. -function readBody(req, res) { +function readBody(req, res, maxBytes) { return new Promise((resolve) => { const chunks = []; let size = 0; @@ -285,7 +300,7 @@ function readBody(req, res) { req.on("data", (chunk) => { if (settled) return; size += chunk.length; - if (size > MAX_BODY_BYTES) { + if (size > maxBytes) { settled = true; req.pause(); res.writeHead(413); @@ -493,6 +508,60 @@ function readHostSnapshot(id) { return { ...result.snapshot, receivedAt }; } +// --- Claude usage helpers --------------------------------------------------- + +// Same atomic pattern as writeHostSnapshot(): temp file in the SAME directory +// (suffixed with the pid, so two containers of a rolling redeploy never share +// one), mode 600, then renameSync. A concurrent GET /claude-usage sees the old +// snapshot or the new one, never a truncated JSON. +function writeClaudeUsage(record) { + const dir = path.dirname(CLAUDE_USAGE_PATH); + mkdirSync(dir, { recursive: true }); + const tmpPath = path.join( + dir, + `.${path.basename(CLAUDE_USAGE_PATH)}.${process.pid}.${Date.now()}.tmp`, + ); + try { + writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 }); + renameSync(tmpPath, CLAUDE_USAGE_PATH); + } catch (err) { + try { + unlinkSync(tmpPath); + } catch { + // Best effort — the temp file may never have been created. + } + throw err; + } +} + +// Read the stored snapshot back through the same sanitizer: the file lives on +// a writable bind-mount and is no more trusted than the payload was. Missing, +// unparsable, off-contract or without a usable server timestamp -> null, which +// GET /claude-usage turns into { status: "no_data" }. +function readClaudeUsage() { + let parsed; + try { + parsed = JSON.parse(readFileSync(CLAUDE_USAGE_PATH, "utf-8")); + } catch (err) { + if (err.code !== "ENOENT") console.error("[claude-usage] failed to read snapshot:", err.message); + return null; + } + + const result = sanitizeClaudeUsage(parsed); + if (result.error) { + console.error(`[claude-usage] snapshot rejected on read: ${result.error}`); + return null; + } + + const receivedAt = typeof parsed.receivedAt === "string" ? parsed.receivedAt : null; + if (!receivedAt || Number.isNaN(new Date(receivedAt).getTime())) { + console.error("[claude-usage] snapshot has no usable receivedAt"); + return null; + } + + return { snapshot: result.snapshot, receivedAt }; +} + // --- Route handlers --------------------------------------------------------- async function handleHealth(req, res) { @@ -661,7 +730,7 @@ async function handleHostIngest(req, res, ctx) { return; } - const raw = await readBody(req, res); + const raw = await readBody(req, res, MAX_BODY_BYTES); if (raw === null) return; // 413 already answered, or the socket is gone. let payload; @@ -696,10 +765,70 @@ async function handleHostIngest(req, res, ctx) { } } +async function handleClaudeUsageIngest(req, res) { + const raw = await readBody(req, res, CLAUDE_USAGE_MAX_BODY_BYTES); + if (raw === null) return; // 413 already answered, or the socket is gone. + + let payload; + try { + payload = JSON.parse(raw); + } catch { + res.writeHead(400); + res.end(JSON.stringify({ error: "Bad request: body must be valid JSON" })); + return; + } + + const result = sanitizeClaudeUsage(payload); + if (result.error) { + res.writeHead(400); + res.end(JSON.stringify({ error: `Bad request: ${result.error}` })); + return; + } + + // Server-stamped, like the host snapshots: generatedAt is the workstation's + // clock and stays informative, freshness is judged on receivedAt only. + const record = { ...result.snapshot, receivedAt: new Date().toISOString() }; + + try { + writeClaudeUsage(record); + res.removeHeader("Content-Type"); + res.writeHead(204); + res.end(); + } catch (err) { + console.error("[claude-usage] failed to persist snapshot:", err.message); + res.writeHead(500); + res.end(JSON.stringify({ error: "Internal error" })); + } +} + +// Staleness is decided here and only here: the dashboard displays `stale`, it +// does not recompute it. +function handleClaudeUsageRead(req, res) { + const stored = readClaudeUsage(); + if (!stored) { + res.writeHead(200); + res.end(JSON.stringify({ status: "no_data" })); + return; + } + + const ageSeconds = Math.max( + 0, + Math.floor((Date.now() - new Date(stored.receivedAt).getTime()) / 1000), + ); + res.writeHead(200); + res.end(JSON.stringify({ + staleAfterSeconds: CLAUDE_USAGE_STALE_SECONDS, + receivedAt: stored.receivedAt, + ageSeconds, + stale: ageSeconds > CLAUDE_USAGE_STALE_SECONDS, + snapshot: stored.snapshot, + })); +} + // --- Routing ---------------------------------------------------------------- // Route descriptors. `tokenKind` is the ONLY thing that varies between the read -// routes and the ingestion route: authentication itself happens once, in the +// routes and the ingestion routes: authentication itself happens once, in the // single gate below. Adding a route here therefore cannot accidentally ship // unauthenticated — which is exactly what a per-branch check invites. const ROUTES = [ @@ -710,8 +839,45 @@ const ROUTES = [ { method: "GET", path: "/reports/scans", tokenKind: "read", handle: handleReportsScans }, { method: "GET", path: "/hosts", tokenKind: "read", handle: handleHostsList }, { method: "POST", pattern: HOST_ROUTE_RE, tokenKind: "ingest", handle: handleHostIngest }, + { method: "GET", path: "/claude-usage", tokenKind: "read", handle: handleClaudeUsageRead }, + { method: "POST", path: "/claude-usage", tokenKind: "claude-ingest", handle: handleClaudeUsageIngest }, ]; +// tokenKind -> expected token, and what to answer when that token is not set. +// The read token fails closed to 401, as it always has; each ingestion token +// fails closed to 503 naming its own variable, so a misconfigured deployment +// reports its fault instead of blaming the caller. One entry per token: two +// ingestion routes never share one, so neither can open the other. +const TOKEN_KINDS = Object.freeze({ + __proto__: null, + read: { + token: TOKEN, + missingStatus: 401, + missingError: "HEALTH_TOKEN not configured", + missingReason: "health token not configured", + }, + ingest: { + token: HOSTS_INGEST_TOKEN, + missingStatus: 503, + missingError: "HOSTS_INGEST_TOKEN not configured", + missingReason: "ingest token not configured", + }, + "claude-ingest": { + token: CLAUDE_USAGE_INGEST_TOKEN, + missingStatus: 503, + missingError: "CLAUDE_USAGE_INGEST_TOKEN not configured", + missingReason: "claude usage ingest token not configured", + }, +}); + +// A descriptor naming a kind the table does not know is a programming error: +// refuse to start rather than discover it as a crash on the first request. +for (const route of ROUTES) { + if (!Object.hasOwn(TOKEN_KINDS, route.tokenKind)) { + throw new Error(`route ${route.method} ${route.path || route.pattern} has unknown tokenKind`); + } +} + // Deny by default: no descriptor match -> null -> 404. function resolveRoute(method, pathname) { for (const route of ROUTES) { @@ -728,15 +894,14 @@ function resolveRoute(method, pathname) { // THE single authentication checkpoint. Returns null when the caller may // proceed, otherwise the rejection to write. Only the expected token varies -// per descriptor. +// per descriptor, looked up in TOKEN_KINDS; every kind goes through the same +// tokenMatches() call below. function checkAuth(req, route) { - const isIngest = route.tokenKind === "ingest"; - const expected = isIngest ? HOSTS_INGEST_TOKEN : TOKEN; + const kind = TOKEN_KINDS[route.tokenKind]; + const expected = kind.token; if (!expected) { - return isIngest - ? { status: 503, body: { error: "HOSTS_INGEST_TOKEN not configured" }, reason: "ingest token not configured" } - : { status: 401, body: { error: "HEALTH_TOKEN not configured" }, reason: "health token not configured" }; + return { status: kind.missingStatus, body: { error: kind.missingError }, reason: kind.missingReason }; } const header = req.headers["authorization"];