feat(claude-usage): archive closed days by month, history route, one-shot backfill #57

Merged
maximus merged 4 commits from issue-55-claude-usage-history into main 2026-10-09 01:47:51 +00:00
10 changed files with 1040 additions and 15 deletions
Showing only changes of commit 9084d90609 - Show all commits

View file

@ -1,6 +1,6 @@
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package.json index.js metrics.js claude-usage.js ./ COPY package.json index.js metrics.js claude-usage.js claude-usage-history.js ./
EXPOSE 3001 EXPOSE 3001
USER node USER node
CMD ["node", "index.js"] CMD ["node", "index.js"]

View file

@ -26,6 +26,9 @@ const ROUTES = [
"/defenseurs/state", "/defenseurs/state",
"/reports/scans", "/reports/scans",
"/hosts", "/hosts",
// #55. GET /claude-usage is not listed: POST /claude-usage exists, so the
// "404 on POST" row below does not hold for it (claude-usage.test.js covers it).
"/claude-usage/history",
]; ];
// Ingest token, distinct from the read token on purpose: a read token must not // Ingest token, distinct from the read token on purpose: a read token must not
@ -83,6 +86,7 @@ beforeEach(async () => {
process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(tmpDir, "findings-state.json"); process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(tmpDir, "findings-state.json");
process.env.HOSTS_DIR = path.join(tmpDir, "hosts"); process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
process.env.HOSTS_ALLOWED_IDS = "thinkpad"; process.env.HOSTS_ALLOWED_IDS = "thinkpad";
process.env.CLAUDE_USAGE_PATH = path.join(tmpDir, "claude-usage", "claude-usage.json");
// Configured on purpose: an unset ingest token answers 503 before the header // Configured on purpose: an unset ingest token answers 503 before the header
// is ever read, which would hide the 401 these tests are here to pin. // is ever read, which would hide the 401 these tests are here to pin.
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN; process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
@ -98,6 +102,10 @@ afterEach(async () => {
fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true });
}); });
afterAll(() => {
delete process.env.CLAUDE_USAGE_PATH;
});
describe("auth gate — missing Authorization header", () => { describe("auth gate — missing Authorization header", () => {
test.each(ROUTES)("401 on GET %s with no Authorization header", async (route) => { test.each(ROUTES)("401 on GET %s with no Authorization header", async (route) => {
const { status, body } = await request(route, { auth: null }); const { status, body } = await request(route, { auth: null });
@ -306,6 +314,12 @@ describe("routing", () => {
expect(body.error).toBe("Not found"); expect(body.error).toBe("Not found");
}); });
test("404 (not 401) on POST /claude-usage/history without Authorization", async () => {
const { status, body } = await request("/claude-usage/history", { method: "POST", auth: null });
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
test("404 (not 401) on POST /health without Authorization", async () => { test("404 (not 401) on POST /health without Authorization", async () => {
const { status, body } = await request("/health", { method: "POST", auth: null }); const { status, body } = await request("/health", { method: "POST", auth: null });
expect(status).toBe(404); expect(status).toBe(404);

View file

@ -342,7 +342,7 @@ describe("test-curl.sh", () => {
expect(proxied).toEqual([]); expect(proxied).toEqual([]);
expect(code, stdout + stderr).toBe(0); expect(code, stdout + stderr).toBe(0);
// Every case ran against the API: a count, so a script cut short fails. // Every case ran against the API: a count, so a script cut short fails.
expect(stdout).toMatch(/=== Results: 45 passed, 0 failed ===/); expect(stdout).toMatch(/=== Results: 51 passed, 0 failed ===/);
} finally { } finally {
await new Promise((resolve) => proxy.close(resolve)); await new Promise((resolve) => proxy.close(resolve));
} }
@ -361,7 +361,7 @@ describe("test-curl.sh", () => {
for (const { code, stdout, stderr } of results) { for (const { code, stdout, stderr } of results) {
expect(code, stdout + stderr).toBe(0); expect(code, stdout + stderr).toBe(0);
expect(stdout).toMatch(/=== Results: 45 passed, 0 failed ===/); expect(stdout).toMatch(/=== Results: 51 passed, 0 failed ===/);
} }
} finally { } finally {
for (const port of ports) expect(await released(port)).toBe(true); for (const port of ports) expect(await released(port)).toBe(true);

View file

@ -0,0 +1,581 @@
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
// Claude usage history (issue #55): the closed days of the snapshot, kept past
// its 30-day window in one file per month under <dir of CLAUDE_USAGE_PATH>/history/.
//
// Pinned here:
// * the pure helpers of claude-usage-history.js: the two merge rules, the
// grouping by the month of the date string, the whitelist re-read of a
// month file, and the range rules of the read route;
// * the archive written by POST /claude-usage: every day but the last, the
// latest snapshot winning, an unreadable month never overwritten, and an
// archive failure that never changes the 204;
// * GET /claude-usage/history: defaults, bounds, degraded months;
// * the one-shot import command, run as a real process: the archived date
// wins, and an invalid import file writes nothing.
const TOKEN = "test-token";
const CLAUDE_TOKEN = "test-claude-token";
const MODULE = path.join(__dirname, "..", "claude-usage-history.js");
const FROZEN_NOW = Date.UTC(2026, 9, 8, 19, 0, 0); // 2026-10-08
let tmpDir;
let usagePath;
let historyPath;
let server;
let baseUrl;
function day(date, output = 1) {
return {
date,
models: { "claude-opus-5-5": { input: 1, output, cacheCreation: 0, cacheRead: 0, requests: 1 } },
};
}
function snapshotWith(days) {
return {
schemaVersion: 1,
generatedAt: "2026-10-02T19:00:00.000Z",
timeZone: "America/Toronto",
limits: { status: "ok", fetchedAt: "2026-10-02T19:00:00.000Z", items: [] },
days,
projects: [{ name: "proj", work: 3, cacheRead: 4, requests: 1 }],
};
}
function monthFile(month) {
return path.join(historyPath, `${month}.json`);
}
function readMonthFile(month) {
return JSON.parse(fs.readFileSync(monthFile(month), "utf-8"));
}
function writeMonthFile(month, content) {
fs.mkdirSync(historyPath, { recursive: true });
fs.writeFileSync(monthFile(month), typeof content === "string" ? content : JSON.stringify(content));
}
function freshHistory() {
delete require.cache[require.resolve("../claude-usage-history.js")];
delete require.cache[require.resolve("../claude-usage.js")];
return require("../claude-usage-history.js");
}
function startServer() {
for (const mod of ["../index.js", "../claude-usage.js", "../claude-usage-history.js"]) {
delete require.cache[require.resolve(mod)];
}
const { handler } = require("../index.js");
server = http.createServer(handler);
return new Promise((resolve) => {
server.listen(0, () => {
baseUrl = `http://127.0.0.1:${server.address().port}`;
resolve();
});
});
}
function stopServer() {
return new Promise((resolve) => {
if (!server) return resolve();
server.close(() => resolve());
});
}
async function request(route, { method = "GET", auth = `Bearer ${TOKEN}`, body } = {}) {
const headers = {};
if (auth) headers.Authorization = auth;
if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(`${baseUrl}${route}`, { method, headers, body });
const parsed = await res.json().catch(() => ({}));
return { status: res.status, body: parsed };
}
function push(days) {
return request("/claude-usage", {
method: "POST",
auth: `Bearer ${CLAUDE_TOKEN}`,
body: JSON.stringify(snapshotWith(days)),
});
}
async function history(query = "") {
const realNow = Date.now;
Date.now = () => FROZEN_NOW;
try {
return await request(`/claude-usage/history${query}`);
} finally {
Date.now = realNow;
}
}
// Every file under dir, relative, sorted: what a case may and may not touch.
function tree(dir) {
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir, { recursive: true })
.map(String)
.sort();
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-history-test-"));
usagePath = path.join(tmpDir, "claude-usage", "claude-usage.json");
historyPath = path.join(tmpDir, "claude-usage", "history");
process.env.HEALTH_TOKEN = TOKEN;
process.env.HOSTS_INGEST_TOKEN = "test-ingest-token";
process.env.CLAUDE_USAGE_INGEST_TOKEN = CLAUDE_TOKEN;
process.env.CLAUDE_USAGE_PATH = usagePath;
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
process.env.REPORTS_DIR = path.join(tmpDir, "reports");
process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json");
process.env.DEFENSEURS_STATUS_PATH = path.join(tmpDir, "status.json");
process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(tmpDir, "findings-state.json");
// Closed port: no request may reach the real IdP.
process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration";
await startServer();
});
afterEach(async () => {
vi.restoreAllMocks();
await stopServer();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
afterAll(() => {
delete process.env.CLAUDE_USAGE_INGEST_TOKEN;
delete process.env.CLAUDE_USAGE_PATH;
});
// --- Pure helpers -----------------------------------------------------------
describe("month arithmetic", () => {
test("addMonths crosses years both ways", () => {
const { addMonths } = freshHistory();
expect(addMonths("2026-10", -11)).toBe("2025-11");
expect(addMonths("2026-12", 1)).toBe("2027-01");
expect(addMonths("2026-01", -1)).toBe("2025-12");
expect(addMonths("2026-01", -36)).toBe("2023-01");
});
test("monthSpan counts both ends", () => {
const { monthSpan } = freshHistory();
expect(monthSpan("2026-01", "2026-01")).toBe(1);
expect(monthSpan("2026-01", "2026-12")).toBe(12);
expect(monthSpan("2024-01", "2026-12")).toBe(36);
});
});
describe("mergeDays", () => {
test("the latest value wins by default, result sorted, inputs untouched", () => {
const { mergeDays } = freshHistory();
const existing = [day("2026-09-02", 1), day("2026-09-01", 1)];
const incoming = [day("2026-09-03", 3), day("2026-09-02", 2)];
const before = JSON.stringify([existing, incoming]);
const merged = mergeDays(existing, incoming);
expect(merged.map((d) => [d.date, d.models["claude-opus-5-5"].output])).toEqual([
["2026-09-01", 1],
["2026-09-02", 2],
["2026-09-03", 3],
]);
expect(JSON.stringify([existing, incoming])).toBe(before);
});
test("preferExisting keeps an archived date and adds only the missing ones", () => {
const { mergeDays } = freshHistory();
const merged = mergeDays([day("2026-09-02", 1)], [day("2026-09-02", 9), day("2026-09-01", 9)], {
preferExisting: true,
});
expect(merged.map((d) => [d.date, d.models["claude-opus-5-5"].output])).toEqual([
["2026-09-01", 9],
["2026-09-02", 1],
]);
});
test("a date repeated in the incoming list: the later entry wins", () => {
const { mergeDays } = freshHistory();
const merged = mergeDays([], [day("2026-09-01", 1), day("2026-09-01", 2)]);
expect(merged).toEqual([day("2026-09-01", 2)]);
});
});
describe("groupByMonth", () => {
test("groups by the YYYY-MM prefix of the date string", () => {
const { groupByMonth } = freshHistory();
const months = groupByMonth([day("2026-10-31"), day("2026-11-01"), day("2026-10-01")]);
expect([...months.keys()]).toEqual(["2026-10", "2026-11"]);
expect(months.get("2026-10").map((d) => d.date)).toEqual(["2026-10-31", "2026-10-01"]);
});
});
describe("sanitizeMonthFile — the snapshot's whitelist on the way back", () => {
test("a clean file comes back identical", () => {
const { sanitizeMonthFile } = freshHistory();
const file = { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01"), day("2026-09-02")] };
expect(sanitizeMonthFile(file, "2026-09")).toEqual(file.days);
});
test("unknown fields, __proto__, invalid values and foreign dates are dropped", () => {
const { sanitizeMonthFile } = freshHistory();
const raw =
'{"schemaVersion":1,"month":"2026-09","extra":"x","days":[' +
'{"date":"2026-09-01","extra":"<script>","models":{"__proto__":{"input":1},"claude-opus-5-5":{"input":-1,"output":2,"polluted":true}}},' +
'{"date":"2026-10-01","models":{}},' +
'{"date":"2026-09-31","models":{}},' +
'"not a day",' +
'{"__proto__":{"polluted":true},"date":"2026-09-02","models":{}}' +
"]}";
const days = sanitizeMonthFile(JSON.parse(raw), "2026-09");
expect(days).toEqual([
{ date: "2026-09-01", models: { "claude-opus-5-5": { output: 2 } } },
{ date: "2026-09-02", models: {} },
]);
expect({}.polluted).toBeUndefined();
expect(Object.keys(days[0].models)).toEqual(["claude-opus-5-5"]);
});
test.each([
["an array", []],
["null", null],
["another schemaVersion", { schemaVersion: 2, month: "2026-09", days: [] }],
["no schemaVersion", { month: "2026-09", days: [] }],
["another month", { schemaVersion: 1, month: "2026-08", days: [] }],
["days not an array", { schemaVersion: 1, month: "2026-09", days: {} }],
])("off-format as a whole (%s) -> null", (_label, parsed) => {
const { sanitizeMonthFile } = freshHistory();
expect(sanitizeMonthFile(parsed, "2026-09")).toBeNull();
});
});
describe("parseRange", () => {
const ctx = { lastArchived: "2026-09", currentMonth: "2026-10" };
test.each([
["nothing archived: 12 months up to the current one", null, null, { lastArchived: null, currentMonth: "2026-10" }, { from: "2025-11", to: "2026-10" }],
["12 months up to the last archived month", null, null, ctx, { from: "2025-10", to: "2026-09" }],
["to alone: 12 months up to it", null, "2026-03", ctx, { from: "2025-04", to: "2026-03" }],
["from alone: up to the last archived month", "2026-01", null, ctx, { from: "2026-01", to: "2026-09" }],
["from alone, past the last archived month: to = from", "2026-12", null, ctx, { from: "2026-12", to: "2026-12" }],
["exactly 36 months", "2024-01", "2026-12", ctx, { from: "2024-01", to: "2026-12" }],
])("%s", (_label, from, to, context, expected) => {
const { parseRange } = freshHistory();
expect(parseRange(from, to, context)).toEqual(expected);
});
test.each([
["37 months", "2023-12", "2026-12"],
["from alone, 37 months before the last archived month", "2023-09", null],
["from after to", "2026-09", "2026-08"],
["month 13", "2026-13", null],
["month 00", "2026-00", null],
["a one-digit month", "2026-1", null],
["a full date", "2026-09-01", null],
["an empty value", "", null],
["a traversal", "../etc", null],
["a bad to", null, "2026-9"],
])("error on %s", (_label, from, to) => {
const { parseRange } = freshHistory();
expect(parseRange(from, to, ctx).error).toEqual(expect.any(String));
});
});
// --- POST /claude-usage writes the archive ----------------------------------
describe("POST /claude-usage — the archive", () => {
test("every day but the last is archived, mode 600, no temp file", async () => {
expect((await push([day("2026-09-29"), day("2026-09-30"), day("2026-10-01")])).status).toBe(204);
expect(fs.readdirSync(historyPath)).toEqual(["2026-09.json"]);
expect(readMonthFile("2026-09")).toEqual({
schemaVersion: 1,
month: "2026-09",
days: [day("2026-09-29"), day("2026-09-30")],
});
expect(fs.statSync(monthFile("2026-09")).mode & 0o777).toBe(0o600);
});
test("a one-day snapshot archives nothing and creates no directory", async () => {
expect((await push([day("2026-10-01")])).status).toBe(204);
expect(fs.existsSync(historyPath)).toBe(false);
});
test("a second identical POST leaves the archive byte for byte identical", async () => {
const days = [day("2026-09-29"), day("2026-09-30"), day("2026-10-01"), day("2026-10-02")];
await push(days);
const before = [fs.readFileSync(monthFile("2026-09")), fs.readFileSync(monthFile("2026-10"))];
await push(days);
expect([fs.readFileSync(monthFile("2026-09")), fs.readFileSync(monthFile("2026-10"))]).toEqual(before);
expect(fs.readdirSync(historyPath).sort()).toEqual(["2026-09.json", "2026-10.json"]);
});
test("a later snapshot carrying a day with other values replaces it", async () => {
await push([day("2026-09-29", 1), day("2026-09-30", 1)]);
await push([day("2026-09-29", 7), day("2026-09-30", 1)]);
expect(readMonthFile("2026-09").days).toEqual([day("2026-09-29", 7)]);
});
test("a day that left the window keeps its archived value", async () => {
await push([day("2026-09-28", 5), day("2026-09-29", 5), day("2026-09-30", 5)]);
await push([day("2026-09-29", 6), day("2026-09-30", 6), day("2026-10-01", 6)]);
expect(readMonthFile("2026-09").days).toEqual([day("2026-09-28", 5), day("2026-09-29", 6), day("2026-09-30", 6)]);
});
test("a snapshot straddling two months writes two files, each day in its own", async () => {
await push([day("2026-09-29"), day("2026-09-30"), day("2026-10-01"), day("2026-10-02"), day("2026-10-03")]);
expect(readMonthFile("2026-09").days.map((d) => d.date)).toEqual(["2026-09-29", "2026-09-30"]);
expect(readMonthFile("2026-10").days.map((d) => d.date)).toEqual(["2026-10-01", "2026-10-02"]);
});
test("the excluded day is the last element sent, and a repeated date resolves to its later entry", async () => {
// Out of order and with a repeat: the agent never sends this, the rule
// must still be the one written down.
await push([day("2026-09-30", 1), day("2026-09-29", 1), day("2026-09-30", 2), day("2026-09-28", 1)]);
expect(readMonthFile("2026-09").days).toEqual([day("2026-09-29", 1), day("2026-09-30", 2)]);
});
test("an unreadable month: 204, snapshot written, that month untouched, the other written, logged without content", async () => {
writeMonthFile("2026-09", "{corrupt");
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await push([day("2026-09-29"), day("2026-09-30"), day("2026-10-01"), day("2026-10-02")]);
expect(res.status).toBe(204);
expect(JSON.parse(fs.readFileSync(usagePath, "utf-8")).days).toHaveLength(4);
expect(fs.readFileSync(monthFile("2026-09"), "utf-8")).toBe("{corrupt");
expect(readMonthFile("2026-10").days.map((d) => d.date)).toEqual(["2026-10-01"]);
const lines = error.mock.calls.map((args) => args.join(" "));
expect(lines).toEqual(["[claude-usage] history 2026-09 skipped: unreadable file left untouched (parse_error)"]);
});
test("an off-format month (another schemaVersion) is not overwritten either", async () => {
const foreign = JSON.stringify({ schemaVersion: 2, month: "2026-09", days: [day("2026-09-01")] });
writeMonthFile("2026-09", foreign);
vi.spyOn(console, "error").mockImplementation(() => {});
expect((await push([day("2026-09-29"), day("2026-09-30")])).status).toBe(204);
expect(fs.readFileSync(monthFile("2026-09"), "utf-8")).toBe(foreign);
});
test("history/ cannot be created: 204, snapshot written, logged", async () => {
fs.mkdirSync(path.dirname(usagePath), { recursive: true });
fs.writeFileSync(historyPath, "a file where the directory should be");
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await push([day("2026-09-29"), day("2026-09-30")]);
expect(res.status).toBe(204);
expect(JSON.parse(fs.readFileSync(usagePath, "utf-8")).days).toHaveLength(2);
const lines = error.mock.calls.map((args) => args.join(" "));
expect(lines).toHaveLength(1);
expect(lines[0]).toMatch(/^\[claude-usage\] history not written \(E[A-Z]+\)$/);
});
test("a month whose rename fails: 204, its temp file removed, the other month written, logged", async () => {
const realRename = fs.renameSync;
vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
if (String(to).endsWith("2026-09.json")) {
throw Object.assign(new Error("simulated"), { code: "EIO" });
}
return realRename(from, to);
});
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await push([day("2026-09-30"), day("2026-10-01"), day("2026-10-02")]);
expect(res.status).toBe(204);
expect(fs.readdirSync(historyPath)).toEqual(["2026-10.json"]);
expect(error.mock.calls.map((args) => args.join(" "))).toEqual(["[claude-usage] history 2026-09 not written (EIO)"]);
});
test("a rejected POST archives nothing", async () => {
const res = await request("/claude-usage", {
method: "POST",
auth: `Bearer ${CLAUDE_TOKEN}`,
body: JSON.stringify({ ...snapshotWith([day("2026-09-29"), day("2026-09-30")]), schemaVersion: 2 }),
});
expect(res.status).toBe(400);
expect(fs.existsSync(historyPath)).toBe(false);
});
test("a snapshot that cannot be written archives nothing", async () => {
fs.mkdirSync(path.join(usagePath, "occupied"), { recursive: true });
vi.spyOn(console, "error").mockImplementation(() => {});
expect((await push([day("2026-09-29"), day("2026-09-30")])).status).toBe(500);
expect(fs.existsSync(historyPath)).toBe(false);
});
});
// --- GET /claude-usage/history ----------------------------------------------
describe("GET /claude-usage/history", () => {
test("nothing archived: 200, 12 months up to the current one, no day", async () => {
const res = await history();
expect(res.status).toBe(200);
expect(res.body).toEqual({ from: "2025-11", to: "2026-10", days: [] });
});
test("defaults to 12 months up to the last archived month, days sorted across files", async () => {
writeMonthFile("2026-08", { schemaVersion: 1, month: "2026-08", days: [day("2026-08-31")] });
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01"), day("2026-09-02")] });
writeMonthFile("2025-09", { schemaVersion: 1, month: "2025-09", days: [day("2025-09-30")] });
const res = await history();
expect(res.body).toEqual({
from: "2025-10",
to: "2026-09",
days: [day("2026-08-31"), day("2026-09-01"), day("2026-09-02")],
});
});
test("from and to select the months, an absent month is simply empty", async () => {
writeMonthFile("2026-06", { schemaVersion: 1, month: "2026-06", days: [day("2026-06-15")] });
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01")] });
const res = await history("?from=2026-06&to=2026-08");
expect(res.body).toEqual({ from: "2026-06", to: "2026-08", days: [day("2026-06-15")] });
});
test("an unreadable month is left out and logged, the others are served", async () => {
writeMonthFile("2026-08", "{corrupt");
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01")] });
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await history("?from=2026-08&to=2026-09");
expect(res.status).toBe(200);
expect(res.body.days).toEqual([day("2026-09-01")]);
expect(error.mock.calls.map((args) => args.join(" "))).toEqual([
"[claude-usage] history 2026-08 unreadable, left out (parse_error)",
]);
});
test("the files are re-sanitized on the way out", async () => {
writeMonthFile(
"2026-09",
'{"schemaVersion":1,"month":"2026-09","days":[{"date":"2026-09-01","extra":1,"models":{"__proto__":{"input":1},"claude-opus-5-5":{"output":-1,"input":3}}},{"date":"2026-08-31","models":{}}]}',
);
const res = await history("?from=2026-09&to=2026-09");
expect(res.body.days).toEqual([{ date: "2026-09-01", models: { "claude-opus-5-5": { input: 3 } } }]);
});
test("a stray file name that is not a month is not the default end", async () => {
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-01")] });
writeMonthFile("2026-13", "{}");
fs.writeFileSync(path.join(historyPath, ".2026-12.json.1.2.tmp"), "{}");
expect((await history()).body.to).toBe("2026-09");
});
test.each([
["?from=2026-13"],
["?to=2026-9"],
["?from=2026-09&to=2026-08"],
["?from=2023-12&to=2026-12"],
["?from=../../etc"],
["?from="],
])("400 on %s", async (query) => {
const res = await history(query);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/^Bad request: /);
});
test("36 months exactly is served", async () => {
expect((await history("?from=2024-01&to=2026-12")).status).toBe(200);
});
test("the read token only: no token, the claude ingest token -> 401", async () => {
expect((await request("/claude-usage/history", { auth: null })).status).toBe(401);
expect((await request("/claude-usage/history", { auth: `Bearer ${CLAUDE_TOKEN}` })).status).toBe(401);
});
test("POST /claude-usage/history -> 404, before any auth", async () => {
expect((await request("/claude-usage/history", { method: "POST", auth: null })).status).toBe(404);
});
test("POST then GET: the closed days come back", async () => {
await push([day("2026-09-30"), day("2026-10-01"), day("2026-10-02")]);
const res = await history("?from=2026-09&to=2026-10");
expect(res.body.days).toEqual([day("2026-09-30"), day("2026-10-01")]);
});
});
// --- Import command ---------------------------------------------------------
describe("node claude-usage-history.js --import", () => {
function runImport(args, { input } = {}) {
return spawnSync(process.execPath, [MODULE, ...args], {
env: { PATH: process.env.PATH, CLAUDE_USAGE_PATH: usagePath },
input,
encoding: "utf-8",
});
}
function importFile(content) {
const file = path.join(tmpDir, "backfill.json");
fs.writeFileSync(file, typeof content === "string" ? content : JSON.stringify(content));
return file;
}
test("an archived date is never replaced, absent dates are added, only history/ changes", async () => {
writeMonthFile("2026-09", { schemaVersion: 1, month: "2026-09", days: [day("2026-09-02", 1)] });
const file = importFile({ days: [day("2026-08-31", 9), day("2026-09-01", 9), day("2026-09-02", 9)] });
const before = tree(tmpDir).filter((name) => !name.startsWith(`claude-usage${path.sep}history`));
const result = runImport(["--import", file]);
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
expect(result.stdout).toBe("import: 3 day(s) read, 2 added, 1 already archived, 2 month(s) written\n");
expect(readMonthFile("2026-09").days).toEqual([day("2026-09-01", 9), day("2026-09-02", 1)]);
expect(readMonthFile("2026-08").days).toEqual([day("2026-08-31", 9)]);
expect(fs.statSync(monthFile("2026-08")).mode & 0o777).toBe(0o600);
expect(fs.readdirSync(historyPath).sort()).toEqual(["2026-08.json", "2026-09.json"]);
expect(tree(tmpDir).filter((name) => !name.startsWith(`claude-usage${path.sep}history`))).toEqual(before);
});
test("reads standard input on `-`", () => {
const result = runImport(["--import", "-"], { input: JSON.stringify({ days: [day("2026-07-01")] }) });
expect(result.status).toBe(0);
expect(readMonthFile("2026-07").days).toEqual([day("2026-07-01")]);
});
test.each([
["not JSON", "{nope"],
["no days", JSON.stringify({})],
["days not an array", JSON.stringify({ days: {} })],
["an invalid day", JSON.stringify({ days: [day("2026-07-01"), { date: "2026-02-31", models: {} }] })],
["a repeated date", JSON.stringify({ days: [day("2026-07-01"), day("2026-07-01", 2)] })],
])("an invalid import file (%s) writes nothing, exit 1", (_label, content) => {
const file = importFile(content);
const before = tree(tmpDir);
const result = runImport(["--import", file]);
expect(result.status).toBe(1);
expect(result.stderr).toBe("import: invalid import file, nothing written\n");
expect(tree(tmpDir)).toEqual(before);
});
test("an unreadable month is skipped and left as is, the others are written, exit 1", () => {
writeMonthFile("2026-09", "{corrupt");
const file = importFile({ days: [day("2026-08-31"), day("2026-09-01")] });
const result = runImport(["--import", file]);
expect(result.status).toBe(1);
expect(result.stderr).toBe("import: month 2026-09 skipped, unreadable (parse_error)\n");
expect(fs.readFileSync(monthFile("2026-09"), "utf-8")).toBe("{corrupt");
expect(readMonthFile("2026-08").days).toEqual([day("2026-08-31")]);
});
test("a missing import file: exit 1, nothing written", () => {
const result = runImport(["--import", path.join(tmpDir, "absent.json")]);
expect(result.status).toBe(1);
expect(result.stderr).toBe("import: cannot read the import file (ENOENT)\n");
expect(fs.existsSync(historyPath)).toBe(false);
});
test.each([[[]], [["--import"]], [["--other", "x"]]])("usage error on %j: exit 2", (args) => {
const result = runImport(args);
expect(result.status).toBe(2);
expect(fs.existsSync(historyPath)).toBe(false);
});
});
describe("the server image", () => {
test("the Dockerfile COPY line ships claude-usage-history.js", () => {
const dockerfile = fs.readFileSync(path.join(__dirname, "..", "Dockerfile"), "utf-8");
const copy = dockerfile.split("\n").find((line) => /^\s*COPY\s/.test(line));
expect(copy.trim().split(/\s+/)).toContain("claude-usage-history.js");
});
});

View file

@ -725,8 +725,9 @@ describe("POST /claude-usage — payload", () => {
// A client-supplied receivedAt is ignored: the server stamps it. // A client-supplied receivedAt is ignored: the server stamps it.
expect(new Date(receivedAt).getTime()).toBeGreaterThanOrEqual(before - 1000); expect(new Date(receivedAt).getTime()).toBeGreaterThanOrEqual(before - 1000);
expect(fs.statSync(usagePath).mode & 0o777).toBe(0o600); expect(fs.statSync(usagePath).mode & 0o777).toBe(0o600);
// Atomic write: no temp file left beside the snapshot. // Atomic write: no temp file left beside the snapshot. history/ holds the
expect(fs.readdirSync(path.dirname(usagePath))).toEqual(["claude-usage.json"]); // closed day of this two-day snapshot (#55, claude-usage-history.test.js).
expect(fs.readdirSync(path.dirname(usagePath)).sort()).toEqual(["claude-usage.json", "history"]);
}); });
test("a second push replaces the first", async () => { test("a second push replaces the first", async () => {

View file

@ -205,11 +205,19 @@ function validClaudeUsage() {
schemaVersion: 1, schemaVersion: 1,
timeZone: "America/Toronto", timeZone: "America/Toronto",
limits: { status: "ok", fetchedAt: "2026-09-30T07:30:00.000Z", items: [] }, limits: { status: "ok", fetchedAt: "2026-09-30T07:30:00.000Z", items: [] },
days: [{ date: "2026-09-30", models: { "claude-opus-5-5": { input: 1, output: 2, requests: 1 } } }], // Two days: POST archives the first one into history/ (#55).
days: [
{ date: "2026-09-29", models: { "claude-opus-5-5": { input: 1, output: 1, requests: 1 } } },
{ date: "2026-09-30", models: { "claude-opus-5-5": { input: 1, output: 2, requests: 1 } } },
],
projects: [{ name: "proj", work: 3, cacheRead: 4, requests: 1 }], projects: [{ name: "proj", work: 3, cacheRead: 4, requests: 1 }],
}; };
} }
function historyFixturePath(month) {
return path.join(tmpDir, "claude-usage", "history", `${month}.json`);
}
function claudeUsageFixturePath() { function claudeUsageFixturePath() {
return path.join(tmpDir, "claude-usage", "claude-usage.json"); return path.join(tmpDir, "claude-usage", "claude-usage.json");
} }
@ -295,6 +303,12 @@ function writeFixtures() {
// not under it, so a write there is checked against its own rule. // not under it, so a write there is checked against its own rule.
writeJson(claudeUsageFixturePath(), { ...validClaudeUsage(), receivedAt: new Date().toISOString() }); writeJson(claudeUsageFixturePath(), { ...validClaudeUsage(), receivedAt: new Date().toISOString() });
process.env.CLAUDE_USAGE_PATH = claudeUsageFixturePath(); process.env.CLAUDE_USAGE_PATH = claudeUsageFixturePath();
// An archived month (#55), so GET /claude-usage/history runs its read path.
writeJson(historyFixturePath("2026-09"), {
schemaVersion: 1,
month: "2026-09",
days: [{ date: "2026-09-28", models: {} }],
});
process.env.CLAUDE_USAGE_INGEST_TOKEN = CLAUDE_TOKEN; process.env.CLAUDE_USAGE_INGEST_TOKEN = CLAUDE_TOKEN;
// Closed port: /health must never reach the real IdP. // Closed port: /health must never reach the real IdP.
process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration"; process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration";
@ -352,6 +366,7 @@ describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => {
}, },
], ],
["/claude-usage", (body) => expect(body.snapshot.projects).toHaveLength(1)], ["/claude-usage", (body) => expect(body.snapshot.projects).toHaveLength(1)],
["/claude-usage/history", (body) => expect(body.days).toEqual([{ date: "2026-09-28", models: {} }])],
]; ];
test.each(READ_ROUTES)("GET %s writes nothing, not even after the response", async (route, expectContent) => { test.each(READ_ROUTES)("GET %s writes nothing, not even after the response", async (route, expectContent) => {
@ -397,6 +412,9 @@ describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => {
const writes = drain(); const writes = drain();
expect(writes.map(({ fn }) => fn)).toEqual(expect.arrayContaining(["writeFileSync", "renameSync"])); expect(writes.map(({ fn }) => fn)).toEqual(expect.arrayContaining(["writeFileSync", "renameSync"]));
expect(violations(writes, path.dirname(process.env.CLAUDE_USAGE_PATH))).toEqual([]); expect(violations(writes, path.dirname(process.env.CLAUDE_USAGE_PATH))).toEqual([]);
// The archive (#55) is part of this surface, and the case proves it ran:
// a rename onto the month file of the closed day.
expect(writes.some(({ fn, targets }) => fn === "renameSync" && targets.includes(historyFixturePath("2026-09")))).toBe(true);
expect(foreignCommands()).toEqual([]); expect(foreignCommands()).toEqual([]);
}); });
@ -419,6 +437,11 @@ describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => {
const { status, body } = await send("/claude-usage"); const { status, body } = await send("/claude-usage");
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual({ status: "no_data" }); expect(body).toEqual({ status: "no_data" });
// Same for a corrupt month of the archive, and for a refused range (#55).
real.writeFileSync(historyFixturePath("2026-09"), "{corrupt");
const history = await send("/claude-usage/history?from=2026-09&to=2026-09");
expect([history.status, history.body]).toEqual([200, { from: "2026-09", to: "2026-09", days: [] }]);
expect((await send("/claude-usage/history?from=2026-13")).status).toBe(400);
await settle(); await settle();
expect(drain()).toEqual([]); expect(drain()).toEqual([]);

315
claude-usage-history.js Normal file
View file

@ -0,0 +1,315 @@
// Claude usage history (issue #55): the closed days of the Claude usage
// snapshot, kept past its 30-day window, one file per month.
//
// Layout: <dir of CLAUDE_USAGE_PATH>/history/YYYY-MM.json, each file
// { "schemaVersion": 1, "month": "YYYY-MM", "days": [{ "date", "models" }] }
// with the days sorted oldest first, in the very shape of a `days` entry of the
// snapshot (same per-day cleaner, exported by claude-usage.js).
//
// Two writers, two merge rules:
// - POST /claude-usage (index.js): the LATEST SNAPSHOT WINS. Every day the
// snapshot carries, except the last one (today, still open), replaces the
// archived value. A day leaving the window keeps the last value it had,
// the one the agent froze at D+2.
// - the one-shot import below (`--import`): the ARCHIVED DATE WINS. The live
// archive is the reference; a backfill only fills the dates it lacks.
//
// A month file that cannot be read back is never overwritten: it may hold days
// that left the window long ago, and rewriting it from one snapshot would lose
// them. That month is skipped and reported; the other months are written.
//
// No config and no environment read outside the CLI block at the bottom: every
// function takes the history directory as an argument. Dates are grouped by
// the YYYY-MM prefix of their string, never by a clock computation.
const fs = require("node:fs");
const path = require("node:path");
const { sanitizeDay } = require("./claude-usage.js");
const HISTORY_SCHEMA_VERSION = 1;
const MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
const MONTH_FILE_RE = /^\d{4}-(0[1-9]|1[0-2])\.json$/;
// GET /claude-usage/history: default span, and the widest one it serves.
const DEFAULT_MONTHS = 12;
const MAX_MONTHS = 36;
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isMonth(value) {
return typeof value === "string" && MONTH_RE.test(value);
}
function monthOf(date) {
return date.slice(0, 7);
}
// Calendar arithmetic on the two numbers of the string, no Date involved.
function addMonths(month, n) {
const index = Number(month.slice(0, 4)) * 12 + Number(month.slice(5, 7)) - 1 + n;
const year = Math.floor(index / 12);
return `${String(year).padStart(4, "0")}-${String((index % 12) + 1).padStart(2, "0")}`;
}
// Inclusive: 2026-01 to 2026-12 spans 12 months.
function monthSpan(from, to) {
const index = (m) => Number(m.slice(0, 4)) * 12 + Number(m.slice(5, 7));
return index(to) - index(from) + 1;
}
function byDate(a, b) {
return a.date < b.date ? -1 : a.date > b.date ? 1 : 0;
}
// Replace or add each incoming date (preferExisting: false, the POST rule), or
// add only the dates not there yet (preferExisting: true, the import rule).
// Returns a new array sorted oldest first; neither input is modified.
function mergeDays(existing, incoming, { preferExisting = false } = {}) {
const merged = new Map();
for (const day of existing) merged.set(day.date, day);
for (const day of incoming) {
if (preferExisting && merged.has(day.date)) continue;
merged.set(day.date, day);
}
return [...merged.values()].sort(byDate);
}
// YYYY-MM -> days of that month, in input order.
function groupByMonth(days) {
const months = new Map();
for (const day of days) {
const month = monthOf(day.date);
if (!months.has(month)) months.set(month, []);
months.get(month).push(day);
}
return months;
}
// A parsed month file, re-read through the snapshot's whitelist: the file
// lives on a writable bind-mount and is no more trusted than a payload.
// Returns the clean days, or null when the file is off-format as a whole (it
// is then "unreadable": served without it, and never overwritten). A single
// invalid day, or one dated outside the month, is dropped; on a repeated
// date, the later entry wins.
function sanitizeMonthFile(parsed, month) {
if (!isPlainObject(parsed)) return null;
if (parsed.schemaVersion !== HISTORY_SCHEMA_VERSION) return null;
if (parsed.month !== month || !Array.isArray(parsed.days)) return null;
const days = [];
for (const entry of parsed.days) {
const day = sanitizeDay(entry);
if (day !== undefined && monthOf(day.date) === month) days.push(day);
}
return mergeDays([], days);
}
function historyDir(claudeUsagePath) {
return path.join(path.dirname(claudeUsagePath), "history");
}
function monthPath(dir, month) {
return path.join(dir, `${month}.json`);
}
// { state: "absent" } | { state: "ok", days } | { state: "unreadable", code }
function readMonth(dir, month) {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(monthPath(dir, month), "utf-8"));
} catch (err) {
if (err.code === "ENOENT") return { state: "absent" };
return { state: "unreadable", code: err.code || "parse_error" };
}
const days = sanitizeMonthFile(parsed, month);
if (days === null) return { state: "unreadable", code: "off_format" };
return { state: "ok", days };
}
// Same atomic pattern as writeClaudeUsage() in index.js: temp file in the SAME
// directory (pid-suffixed, so two containers of a rolling redeploy never share
// one), mode 600, then renameSync. A concurrent read sees the old month or the
// new one, never a truncated JSON. No sweep covers these temp files: an orphan
// is harmless, the reader only opens YYYY-MM.json.
function writeMonth(dir, month, days) {
const finalPath = monthPath(dir, month);
const tmpPath = path.join(dir, `.${month}.json.${process.pid}.${Date.now()}.tmp`);
const record = { schemaVersion: HISTORY_SCHEMA_VERSION, month, days };
try {
fs.writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 });
fs.renameSync(tmpPath, finalPath);
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch {
// Best effort — the temp file may never have been created.
}
throw err;
}
}
// Merge `days` into their month files. Each month stands alone: an unreadable
// one is skipped (never overwritten), a failed write is reported, and the
// other months go on. Only a failure to create the directory throws.
//
// Returns { written, skipped, failed, added, kept }: month lists, plus the
// number of dates added and, under preferExisting, of dates already archived.
function archiveDays(dir, days, { preferExisting = false } = {}) {
const report = { written: [], skipped: [], failed: [], added: 0, kept: 0 };
if (!days.length) return report;
fs.mkdirSync(dir, { recursive: true });
for (const [month, incoming] of [...groupByMonth(days)].sort(([a], [b]) => (a < b ? -1 : 1))) {
const current = readMonth(dir, month);
if (current.state === "unreadable") {
report.skipped.push({ month, code: current.code });
continue;
}
const existing = current.state === "ok" ? current.days : [];
const known = new Set(existing.map((day) => day.date));
const merged = mergeDays(existing, incoming, { preferExisting });
try {
writeMonth(dir, month, merged);
} catch (err) {
report.failed.push({ month, code: err.code || "write_error" });
continue;
}
report.written.push(month);
for (const day of incoming) {
if (!known.has(day.date)) report.added += 1;
else if (preferExisting) report.kept += 1;
}
}
return report;
}
// The most recent month that has a file, or null (no directory, no file).
function lastArchivedMonth(dir) {
let names;
try {
names = fs.readdirSync(dir);
} catch {
return null;
}
const months = names.filter((name) => MONTH_FILE_RE.test(name)).map((name) => name.slice(0, 7));
return months.length ? months.sort().at(-1) : null;
}
// Query string of GET /claude-usage/history -> { from, to } or { error }.
// Defaults: `to` is the month of the last archived day (the current month when
// nothing is archived yet), or `from` itself when `from` is later; `from` is
// DEFAULT_MONTHS months back from `to`, `to` included.
function parseRange(fromRaw, toRaw, { lastArchived, currentMonth }) {
if (fromRaw !== null && !isMonth(fromRaw)) return { error: "from must be YYYY-MM" };
if (toRaw !== null && !isMonth(toRaw)) return { error: "to must be YYYY-MM" };
let to = toRaw;
if (to === null) {
to = lastArchived || currentMonth;
if (fromRaw !== null && fromRaw > to) to = fromRaw;
}
const from = fromRaw !== null ? fromRaw : addMonths(to, -(DEFAULT_MONTHS - 1));
if (from > to) return { error: "from must not be after to" };
if (monthSpan(from, to) > MAX_MONTHS) return { error: `range must not exceed ${MAX_MONTHS} months` };
return { from, to };
}
// Every archived day from `from` to `to` (both YYYY-MM, inclusive), oldest
// first. An absent month contributes nothing; an unreadable one is listed in
// `unreadable` and contributes nothing either.
function readHistory(dir, from, to) {
const days = [];
const unreadable = [];
for (let month = from; month <= to; month = addMonths(month, 1)) {
const current = readMonth(dir, month);
if (current.state === "ok") days.push(...current.days);
else if (current.state === "unreadable") unreadable.push({ month, code: current.code });
}
return { days, unreadable };
}
// Import file of the one-shot backfill: { days: [...] }. Strict, since it is
// written once by hand: any entry that is not a clean day, or a repeated date,
// refuses the whole file. Returns the days, or null.
function parseImport(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!isPlainObject(parsed) || !Array.isArray(parsed.days)) return null;
const days = [];
const seen = new Set();
for (const entry of parsed.days) {
const day = sanitizeDay(entry);
if (day === undefined || seen.has(day.date)) return null;
seen.add(day.date);
days.push(day);
}
return days;
}
// `node claude-usage-history.js --import <file|->`, run inside the container
// (docker exec, as `node`): merges a backfill into the live archive, the
// archived dates winning. Prints counts only, never a day's content.
function runImport(argv, env, io) {
if (argv.length !== 2 || argv[0] !== "--import") {
io.err("usage: node claude-usage-history.js --import <file|->");
return 2;
}
let raw;
try {
raw = argv[1] === "-" ? fs.readFileSync(0, "utf-8") : fs.readFileSync(argv[1], "utf-8");
} catch (err) {
io.err(`import: cannot read the import file (${err.code || "read_error"})`);
return 1;
}
const days = parseImport(raw);
if (days === null) {
io.err("import: invalid import file, nothing written");
return 1;
}
const dir = historyDir(env.CLAUDE_USAGE_PATH || "/data/claude-usage/claude-usage.json");
let report;
try {
report = archiveDays(dir, days, { preferExisting: true });
} catch (err) {
io.err(`import: cannot create the history directory (${err.code || "error"})`);
return 1;
}
io.out(
`import: ${days.length} day(s) read, ${report.added} added, ${report.kept} already archived, ` +
`${report.written.length} month(s) written`,
);
for (const { month, code } of report.skipped) io.err(`import: month ${month} skipped, unreadable (${code})`);
for (const { month, code } of report.failed) io.err(`import: month ${month} not written (${code})`);
return report.skipped.length || report.failed.length ? 1 : 0;
}
if (require.main === module) {
process.exitCode = runImport(process.argv.slice(2), process.env, {
out: (line) => console.log(line),
err: (line) => console.error(line),
});
}
module.exports = {
isMonth,
monthOf,
addMonths,
monthSpan,
mergeDays,
groupByMonth,
sanitizeMonthFile,
historyDir,
readMonth,
writeMonth,
archiveDays,
lastArchivedMonth,
parseRange,
readHistory,
parseImport,
runImport,
HISTORY_SCHEMA_VERSION,
DEFAULT_MONTHS,
MAX_MONTHS,
};

View file

@ -113,16 +113,24 @@ function sanitizeModels(value) {
return out; return out;
} }
// One entry of `days`, or undefined when it has no usable date. Also re-reads
// the monthly history files (claude-usage-history.js, #55): an archived day
// goes through the very whitelist of the snapshot it came from.
function sanitizeDay(entry) {
if (!isPlainObject(entry)) return undefined;
const date = cleanDayDate(entry.date);
if (date === undefined) return undefined;
const day = { date };
put(day, "models", sanitizeModels(entry.models));
return day;
}
function sanitizeDays(value) { function sanitizeDays(value) {
if (!Array.isArray(value)) return undefined; if (!Array.isArray(value)) return undefined;
const days = []; const days = [];
for (const entry of value) { for (const entry of value) {
if (!isPlainObject(entry)) continue; const day = sanitizeDay(entry);
const date = cleanDayDate(entry.date); if (day !== undefined) days.push(day);
if (date === undefined) continue;
const day = { date };
put(day, "models", sanitizeModels(entry.models));
days.push(day);
} }
return days.slice(-MAX_DAYS); return days.slice(-MAX_DAYS);
} }
@ -183,6 +191,7 @@ function sanitizeClaudeUsage(payload) {
module.exports = { module.exports = {
sanitizeClaudeUsage, sanitizeClaudeUsage,
sanitizeDay,
isIsoTimestamp, isIsoTimestamp,
CLAUDE_USAGE_MAX_BODY_BYTES, CLAUDE_USAGE_MAX_BODY_BYTES,
SCHEMA_VERSION, SCHEMA_VERSION,

View file

@ -14,6 +14,13 @@ 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, isIsoTimestamp, CLAUDE_USAGE_MAX_BODY_BYTES } = require("./claude-usage.js"); const { sanitizeClaudeUsage, isIsoTimestamp, CLAUDE_USAGE_MAX_BODY_BYTES } = require("./claude-usage.js");
const {
historyDir,
archiveDays,
lastArchivedMonth,
parseRange,
readHistory,
} = require("./claude-usage-history.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;
@ -85,6 +92,10 @@ const CLAUDE_USAGE_INGEST_TOKEN = process.env.CLAUDE_USAGE_INGEST_TOKEN;
const CLAUDE_USAGE_PATH = const CLAUDE_USAGE_PATH =
process.env.CLAUDE_USAGE_PATH || "/data/claude-usage/claude-usage.json"; process.env.CLAUDE_USAGE_PATH || "/data/claude-usage/claude-usage.json";
// Closed days kept past the 30-day window (#55): one file per month, on the
// same mount as the snapshot. Layout and merge rules in claude-usage-history.js.
const CLAUDE_USAGE_HISTORY_DIR = historyDir(CLAUDE_USAGE_PATH);
const claudeStaleRaw = parseInt(process.env.CLAUDE_USAGE_STALE_SECONDS || "", 10); const claudeStaleRaw = parseInt(process.env.CLAUDE_USAGE_STALE_SECONDS || "", 10);
const CLAUDE_USAGE_STALE_SECONDS = const CLAUDE_USAGE_STALE_SECONDS =
Number.isFinite(claudeStaleRaw) && claudeStaleRaw > 0 ? claudeStaleRaw : 2700; Number.isFinite(claudeStaleRaw) && claudeStaleRaw > 0 ? claudeStaleRaw : 2700;
@ -791,13 +802,35 @@ async function handleClaudeUsageIngest(req, res) {
try { try {
writeClaudeUsage(record); writeClaudeUsage(record);
res.removeHeader("Content-Type");
res.writeHead(204);
res.end();
} catch (err) { } catch (err) {
console.error("[claude-usage] failed to persist snapshot:", err.message); console.error("[claude-usage] failed to persist snapshot:", err.message);
res.writeHead(500); res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error" })); res.end(JSON.stringify({ error: "Internal error" }));
return;
}
// The snapshot first, the archive second: an archive failure never costs
// the main data, and never changes the 204.
archiveClosedDays(record.days || []);
res.removeHeader("Content-Type");
res.writeHead(204);
res.end();
}
// Every day of the snapshot except the last one (today, still open), the
// latest snapshot winning (#55). Months and error codes only in the log, never
// a day's content.
function archiveClosedDays(days) {
try {
const report = archiveDays(CLAUDE_USAGE_HISTORY_DIR, days.slice(0, -1));
for (const { month, code } of report.skipped) {
console.error(`[claude-usage] history ${month} skipped: unreadable file left untouched (${code})`);
}
for (const { month, code } of report.failed) {
console.error(`[claude-usage] history ${month} not written (${code})`);
}
} catch (err) {
console.error(`[claude-usage] history not written (${err.code || "error"})`);
} }
} }
@ -825,6 +858,31 @@ function handleClaudeUsageRead(req, res) {
})); }));
} }
// Archived days, `from` to `to` months inclusive (#55). Defaults and bounds in
// parseRange(); an unreadable month is left out and logged, never fatal.
function handleClaudeUsageHistory(req, res, ctx) {
const range = parseRange(
ctx.parsedUrl.searchParams.get("from"),
ctx.parsedUrl.searchParams.get("to"),
{
lastArchived: lastArchivedMonth(CLAUDE_USAGE_HISTORY_DIR),
currentMonth: new Date(Date.now()).toISOString().slice(0, 7),
},
);
if (range.error) {
res.writeHead(400);
res.end(JSON.stringify({ error: `Bad request: ${range.error}` }));
return;
}
const { days, unreadable } = readHistory(CLAUDE_USAGE_HISTORY_DIR, range.from, range.to);
for (const { month, code } of unreadable) {
console.error(`[claude-usage] history ${month} unreadable, left out (${code})`);
}
res.writeHead(200);
res.end(JSON.stringify({ from: range.from, to: range.to, days }));
}
// --- 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
@ -840,6 +898,7 @@ const ROUTES = [
{ 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: "GET", path: "/claude-usage", tokenKind: "read", handle: handleClaudeUsageRead },
{ method: "GET", path: "/claude-usage/history", tokenKind: "read", handle: handleClaudeUsageHistory },
{ method: "POST", path: "/claude-usage", tokenKind: "claude-ingest", handle: handleClaudeUsageIngest }, { method: "POST", path: "/claude-usage", tokenKind: "claude-ingest", handle: handleClaudeUsageIngest },
]; ];

View file

@ -65,6 +65,8 @@ TOKEN="test-token-123"
# only meaningful because the two values cannot be confused. Read from the # only meaningful because the two values cannot be confused. Read from the
# environment, two inherited values that happened to be equal would break them. # environment, two inherited values that happened to be equal would break them.
INGEST_TOKEN="test-ingest-token-456" INGEST_TOKEN="test-ingest-token-456"
# Third token, for POST /claude-usage (#48), fixed for the same reason.
CLAUDE_TOKEN="test-claude-token-789"
TMP_DIR="$(mktemp -d -t vps-health-api.XXXXXX)" TMP_DIR="$(mktemp -d -t vps-health-api.XXXXXX)"
SERVER_PID="" SERVER_PID=""
NOINGEST_PID="" NOINGEST_PID=""
@ -202,6 +204,8 @@ HOSTS_DIR="$TMP_DIR/hosts" \
DEFENSEURS_FINDINGS_STATE_PATH="$TMP_DIR/findings-state.json" \ DEFENSEURS_FINDINGS_STATE_PATH="$TMP_DIR/findings-state.json" \
HOSTS_ALLOWED_IDS="thinkpad,workbench" \ HOSTS_ALLOWED_IDS="thinkpad,workbench" \
HOSTS_INGEST_TOKEN="$INGEST_TOKEN" \ HOSTS_INGEST_TOKEN="$INGEST_TOKEN" \
CLAUDE_USAGE_PATH="$TMP_DIR/claude-usage/claude-usage.json" \
CLAUDE_USAGE_INGEST_TOKEN="$CLAUDE_TOKEN" \
LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \ LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \
node "$(dirname "$0")/index.js" >/dev/null 2>&1 & node "$(dirname "$0")/index.js" >/dev/null 2>&1 &
SERVER_PID=$! SERVER_PID=$!
@ -551,6 +555,25 @@ code=$(http_code "$BASE_URL/defenseurs/state")
code=$(http_code -H "Authorization: Bearer $INGEST_TOKEN" "$BASE_URL/defenseurs/state") code=$(http_code -H "Authorization: Bearer $INGEST_TOKEN" "$BASE_URL/defenseurs/state")
[[ "$code" == "401" ]] && pass "GET /defenseurs/state with ingest token -> 401" || fail "GET /defenseurs/state ingest token -> got $code" [[ "$code" == "401" ]] && pass "GET /defenseurs/state with ingest token -> 401" || fail "GET /defenseurs/state ingest token -> got $code"
# Case 33 : GET /claude-usage/history (#55), the archive of the closed days.
code=$(http_code "$BASE_URL/claude-usage/history")
[[ "$code" == "401" ]] && pass "GET /claude-usage/history no-auth -> 401" || fail "GET /claude-usage/history no-auth -> got $code"
code=$(http_code -H "Authorization: Bearer $CLAUDE_TOKEN" "$BASE_URL/claude-usage/history")
[[ "$code" == "401" ]] && pass "GET /claude-usage/history with the claude ingest token -> 401" || fail "history with ingest token -> got $code"
code=$(http_code -H "Authorization: Bearer $TOKEN" "$BASE_URL/claude-usage/history?from=2026-13")
[[ "$code" == "400" ]] && pass "GET /claude-usage/history?from=2026-13 -> 400" || fail "history bad from -> got $code"
# A two-day snapshot: the first day is closed and lands in history/2026-09.json.
printf '%s' '{"schemaVersion":1,"days":[{"date":"2026-09-29","models":{"claude-opus-5-5":{"input":1,"output":2,"requests":1}}},{"date":"2026-09-30","models":{}}]}' \
> "$TMP_DIR/claude-usage.json"
code=$(http_code -X POST -H "Authorization: Bearer $CLAUDE_TOKEN" -H "Content-Type: application/json" \
--data-binary @"$TMP_DIR/claude-usage.json" "$BASE_URL/claude-usage")
[[ "$code" == "204" ]] && pass "POST /claude-usage two-day snapshot -> 204" || fail "POST /claude-usage -> got $code"
body=$(http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/claude-usage/history?from=2026-09&to=2026-09")
dates=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).days.map(d=>d.date).join(","));});')
[[ "$dates" == "2026-09-29" ]] && pass "GET /claude-usage/history serves the closed day only" || fail "history after push -> $dates"
mode=$(node -e 'console.log((require("fs").statSync(process.argv[1]).mode & 0o777).toString(8))' "$TMP_DIR/claude-usage/history/2026-09.json")
[[ "$mode" == "600" ]] && pass "history month file written in mode 600" || fail "history file mode -> $mode"
echo echo
echo "=== Results: $PASS passed, $FAIL failed ===" echo "=== Results: $PASS passed, $FAIL failed ==="
[[ "$FAIL" == "0" ]] || exit 1 [[ "$FAIL" == "0" ]] || exit 1