feat(server): archive closed Claude usage days by month, serve them on /claude-usage/history
POST /claude-usage now merges every day of the sanitized snapshot except the last one (today, still open) into <dir of CLAUDE_USAGE_PATH>/history/ YYYY-MM.json, after the snapshot itself is written. The latest snapshot wins, so a day leaving the 30-day window keeps the value the agent froze at D+2. Each month file is written atomically (same-dir temp, mode 600, rename). A month file that cannot be read back is never overwritten, and an archive failure is logged (months and error codes only) without changing the 204. GET /claude-usage/history?from=YYYY-MM&to=YYYY-MM (read token) serves the archived days, defaulting to the 12 months up to the last archived month, 400 on a bad format, from > to or a span over 36 months. Month files are re-read through the snapshot's per-day whitelist, now exported from claude-usage.js as sanitizeDay(). The new module also carries the one-shot import command used by the backfill (`node claude-usage-history.js --import <file|->`), where an already archived date always wins. Refs #55 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f694677a1a
commit
9084d90609
10 changed files with 1040 additions and 15 deletions
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
581
__tests__/claude-usage-history.test.js
Normal file
581
__tests__/claude-usage-history.test.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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
315
claude-usage-history.js
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
65
index.js
65
index.js
|
|
@ -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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
23
test-curl.sh
23
test-curl.sh
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue