feat(claude-usage): POST et GET /claude-usage (#48) #52
5 changed files with 383 additions and 17 deletions
12
.env.example
12
.env.example
|
|
@ -27,3 +27,15 @@ HOSTS_ALLOWED_IDS=thinkpad
|
||||||
HOSTS_INGEST_TOKEN=change-me-to-a-strong-secret
|
HOSTS_INGEST_TOKEN=change-me-to-a-strong-secret
|
||||||
# Age (seconds) past which GET /hosts reports a host as offline. Default 900.
|
# Age (seconds) past which GET /hosts reports a host as offline. Default 900.
|
||||||
HOSTS_STALE_SECONDS=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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json index.js metrics.js ./
|
COPY package.json index.js metrics.js claude-usage.js ./
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
USER node
|
USER node
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.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.
|
// A single `return null`: no early success exit for one token kind.
|
||||||
expect(body.match(/return\s+null\b/g)).toHaveLength(1);
|
expect(body.match(/return\s+null\b/g)).toHaveLength(1);
|
||||||
// Allowlist, both operand orders included: anything else (`want ===
|
// Allowlist, both operand orders included: anything else (`want ===
|
||||||
// header`, `expected === token`) is a new comparison on a secret.
|
// header`, `expected === token`) is a new comparison on a secret. Since
|
||||||
expect(comparisons(body).sort()).toEqual(['header === "string"', 'route.tokenKind === "ingest"']);
|
// #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/);
|
expect(body).not.toMatch(/isIngest\s*\?[^;]*tokenMatches/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
187
claude-usage.js
Normal file
187
claude-usage.js
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
193
index.js
193
index.js
|
|
@ -13,6 +13,7 @@ const {
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { createHash, timingSafeEqual } = require("node:crypto");
|
const { createHash, timingSafeEqual } = require("node:crypto");
|
||||||
const { collectMetrics } = require("./metrics.js");
|
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 PORT = parseInt(process.env.PORT || "3001", 10);
|
||||||
const TOKEN = process.env.HEALTH_TOKEN;
|
const TOKEN = process.env.HEALTH_TOKEN;
|
||||||
|
|
@ -75,6 +76,19 @@ function parseAllowedIds(raw) {
|
||||||
|
|
||||||
const HOSTS_ALLOWED_IDS = parseAllowedIds(process.env.HOSTS_ALLOWED_IDS || "thinkpad");
|
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):
|
// Severity filter. Asymmetric rule (issue #3):
|
||||||
// - no threshold -> MEDIUM+HIGH+CRITICAL (default hides noise LOW+INFO)
|
// - no threshold -> MEDIUM+HIGH+CRITICAL (default hides noise LOW+INFO)
|
||||||
// - threshold "INFO" -> INFO only (explicit opt-in)
|
// - 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
|
// 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
|
// never throws on mismatched lengths, and the length of the real token cannot
|
||||||
// leak. Passing the raw strings to timingSafeEqual would leak it
|
// leak. Passing the raw strings to timingSafeEqual would leak it
|
||||||
|
|
@ -270,13 +284,14 @@ function tokenMatches(provided, expected) {
|
||||||
return timingSafeEqual(a, b);
|
return timingSafeEqual(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the request body with a hard 4 KiB ceiling. The counter runs on the
|
// Read the request body with a hard ceiling of `maxBytes`, set per route: 4 KiB
|
||||||
// bytes actually received, never on Content-Length: a client can lie in the
|
// for a host snapshot, 64 KiB for the Claude usage snapshot. The counter runs
|
||||||
// header, and chunked transfer-encoding omits it altogether.
|
// 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
|
// 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.
|
// 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) => {
|
return new Promise((resolve) => {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let size = 0;
|
let size = 0;
|
||||||
|
|
@ -285,7 +300,7 @@ function readBody(req, res) {
|
||||||
req.on("data", (chunk) => {
|
req.on("data", (chunk) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
size += chunk.length;
|
size += chunk.length;
|
||||||
if (size > MAX_BODY_BYTES) {
|
if (size > maxBytes) {
|
||||||
settled = true;
|
settled = true;
|
||||||
req.pause();
|
req.pause();
|
||||||
res.writeHead(413);
|
res.writeHead(413);
|
||||||
|
|
@ -493,6 +508,60 @@ function readHostSnapshot(id) {
|
||||||
return { ...result.snapshot, receivedAt };
|
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 ---------------------------------------------------------
|
// --- Route handlers ---------------------------------------------------------
|
||||||
|
|
||||||
async function handleHealth(req, res) {
|
async function handleHealth(req, res) {
|
||||||
|
|
@ -661,7 +730,7 @@ async function handleHostIngest(req, res, ctx) {
|
||||||
return;
|
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.
|
if (raw === null) return; // 413 already answered, or the socket is gone.
|
||||||
|
|
||||||
let payload;
|
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 ----------------------------------------------------------------
|
// --- Routing ----------------------------------------------------------------
|
||||||
|
|
||||||
// Route descriptors. `tokenKind` is the ONLY thing that varies between the read
|
// 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
|
// single gate below. Adding a route here therefore cannot accidentally ship
|
||||||
// unauthenticated — which is exactly what a per-branch check invites.
|
// unauthenticated — which is exactly what a per-branch check invites.
|
||||||
const ROUTES = [
|
const ROUTES = [
|
||||||
|
|
@ -710,8 +839,45 @@ const ROUTES = [
|
||||||
{ method: "GET", path: "/reports/scans", tokenKind: "read", handle: handleReportsScans },
|
{ method: "GET", path: "/reports/scans", tokenKind: "read", handle: handleReportsScans },
|
||||||
{ method: "GET", path: "/hosts", tokenKind: "read", handle: handleHostsList },
|
{ method: "GET", path: "/hosts", tokenKind: "read", handle: handleHostsList },
|
||||||
{ method: "POST", pattern: HOST_ROUTE_RE, tokenKind: "ingest", handle: handleHostIngest },
|
{ 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.
|
// Deny by default: no descriptor match -> null -> 404.
|
||||||
function resolveRoute(method, pathname) {
|
function resolveRoute(method, pathname) {
|
||||||
for (const route of ROUTES) {
|
for (const route of ROUTES) {
|
||||||
|
|
@ -728,15 +894,14 @@ function resolveRoute(method, pathname) {
|
||||||
|
|
||||||
// THE single authentication checkpoint. Returns null when the caller may
|
// THE single authentication checkpoint. Returns null when the caller may
|
||||||
// proceed, otherwise the rejection to write. Only the expected token varies
|
// 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) {
|
function checkAuth(req, route) {
|
||||||
const isIngest = route.tokenKind === "ingest";
|
const kind = TOKEN_KINDS[route.tokenKind];
|
||||||
const expected = isIngest ? HOSTS_INGEST_TOKEN : TOKEN;
|
const expected = kind.token;
|
||||||
|
|
||||||
if (!expected) {
|
if (!expected) {
|
||||||
return isIngest
|
return { status: kind.missingStatus, body: { error: kind.missingError }, reason: kind.missingReason };
|
||||||
? { 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" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const header = req.headers["authorization"];
|
const header = req.headers["authorization"];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue