readClaudeUsage() and readHostSnapshot() validated the stored receivedAt with new Date(), which accepts "1" or a bare date. Both now go through isIsoTimestamp(), exported by claude-usage.js and built on the sanitizer's own check, so no regex is duplicated. The existing 500 tests failed at mkdir, before the temp file existed, so removing the unlinkSync() cleanup of either writer left the suite green. Two new cases make the rename fail after the temp write (the target is a non-empty directory) and require that no temp file is left. Refs #54 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
7 KiB
JavaScript
195 lines
7 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;
|
|
}
|
|
|
|
// 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,
|
|
};
|