- 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/<id>, 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 <noreply@anthropic.com>
187 lines
6.8 KiB
JavaScript
187 lines
6.8 KiB
JavaScript
// 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,
|
|
};
|