// 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; } // Also used by index.js on the stored receivedAt of both snapshot files: the // server only ever stamps Date#toISOString(), and new Date() alone would // accept "1" or a bare date (#54). function isIsoTimestamp(value) { return cleanTimestamp(value) !== undefined; } // 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, isIsoTimestamp, CLAUDE_USAGE_MAX_BODY_BYTES, SCHEMA_VERSION, MAX_DAYS, MAX_MODELS_PER_DAY, MAX_LIMIT_ITEMS, MAX_PROJECTS, MAX_STRING_LENGTH, LIMIT_STATUSES, };