From 38a8bb037fe61914bdc3a9f2467fdac3bd45d266 Mon Sep 17 00:00:00 2001 From: PDgit12 Date: Tue, 7 Jul 2026 13:39:09 +0530 Subject: [PATCH] feat(dashboard): X-ray view off the G1 ledger + hub hardening + output-side proof line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dashboard: exported pure xrayState (per-source events/raw/stored/saved sums, legacy events bucket under mcp) + optional DashboardDeps.xray getter; PAGE gains an X-ray panel (per-source table + the exact receipt text); index.ts wires readSessionMark + activity.since + buildReceipt — SAME math as the stop receipt, one aggregation, two surfaces - hub server: per-address fixed-window rate limit (60/10s default, injectable clock, bounded bucket map, /health exempt, gate before auth) -> 429; board bounded at 500 entries (newest kept, writeAtomic rewrite only at cap). Timing-safe auth + 1MB body cap were already present — unchanged. - hub client: mirrorToHub 3 attempts (250/1000ms backoff), fetchHubBoard 2 attempts (300ms); network/5xx retry only — 4xx never retried - receipt: output-side PROOF line — actual output tokens written this session (transcript snapshot delta) + terse-mode-ON note; explicitly never counted in 'avoided' (no provable counterfactual — honest-math rule holds) Live-verified: dashboard /api/state bySource sums exact vs seeded ledger and its receipt string matches the stop hook's ('avoided 9.0k' both surfaces). verify EXIT=0 (596 tests). --- src/dashboard.ts | 47 ++++++++++++++++ src/engine/receipt.ts | 21 +++++++- src/hooks/index.ts | 26 +++++++-- src/hub/client.ts | 78 +++++++++++++++++++-------- src/hub/server.ts | 50 ++++++++++++++++- src/index.ts | 20 ++++++- tests/dashboard.test.ts | 58 +++++++++++++++++++- tests/hub.test.ts | 117 ++++++++++++++++++++++++++++++++++++++++ tests/receipt.test.ts | 25 +++++++++ tests/security.test.ts | 13 +++++ 10 files changed, 424 insertions(+), 31 deletions(-) diff --git a/src/dashboard.ts b/src/dashboard.ts index e0288af..80a612b 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -34,6 +34,34 @@ export interface DashboardDeps { agents?: () => AgentRollup[]; /** Optional: the compounding wiki-brain (leg 5). */ wiki?: WikiStore; + /** Optional: G1 activity-ledger X-ray — SAME math as the stop-hook receipt. */ + xray?: () => XrayState | null; +} + +/** Per-source rollup of the current session's activity events, plus the exact + * receipt text (buildReceipt) — the dashboard shows the SAME honest numbers + * the stop-hook prints, never a parallel aggregation. */ +export interface XrayState { + bySource: Record; + receipt: string; + sessionStart: string | null; + trimmed: boolean; +} + +/** Pure reduce over session events — one bucket per source; absent `source` + * is a legacy (pre-G1) event and counts as "mcp". */ +export function xrayState(events: ActivityEvent[], receipt: string, sessionStart: string | null, trimmed: boolean): XrayState { + const bySource: XrayState["bySource"] = {}; + for (const e of events) { + const key = e.source ?? "mcp"; + const b = bySource[key] ?? { events: 0, raw: 0, stored: 0, saved: 0 }; + b.events += 1; + b.raw += e.rawTokens ?? 0; + b.stored += e.storedTokens ?? 0; + b.saved += e.saved ?? 0; + bySource[key] = b; + } + return { bySource, receipt, sessionStart, trimmed }; } /** Knowledge-graph summary: file count + the highest-fanout files (blast radius). */ @@ -150,6 +178,7 @@ export function dashboardState(deps: DashboardDeps): Record { recentLearnings: learnings.slice(-5).reverse().map((l) => ({ date: l.date, summary: l.summary.slice(0, 160) })), knowledge: deps.knowledge ? knowledgeSummary(deps.knowledge) : null, wiki: deps.wiki ? wikiState(deps.wiki) : null, + xray: deps.xray?.() ?? null, skills: deps.skills ? deps.skills.list().map((s) => ({ name: s.name, uses: s.uses, triggers: s.triggers.slice(0, 6), updatedAt: s.updatedAt })) : null, @@ -200,6 +229,13 @@ const PAGE = `
Knowledge graph (top blast radius)
Skills
Recent learnings
+
X-ray — G1 activity ledger (same math as the stop receipt)
+
no session data
+ +
Team board
Wiki-brain (browsable · click a page or a [[link]])
no wiki yet — ingest with knitbrain_wiki_ingest
@@ -300,6 +336,17 @@ async function tick() { (s.recentLearnings.length ? s.recentLearnings.map(l => \`\${esc(l.date)}\${esc(l.summary)}\`).join("") : "—"); document.getElementById("board").innerHTML = "whowhenfinding" + (s.board.length ? s.board.map(b => \`\${esc(b.author)}\${esc(b.ts.slice(11,19))}\${esc(b.summary)}\`).join("") : "—"); + if (s.xray) { + document.getElementById("xray-empty").style.display = "none"; + document.getElementById("xray-wrap").style.display = ""; + const rows = Object.entries(s.xray.bySource); + document.getElementById("xray").innerHTML = "sourceeventsraw→storedsaved" + + (rows.length ? rows.map(([src, b]) => \`\${esc(src)}\${b.events}\${b.raw.toLocaleString()} → \${b.stored.toLocaleString()}\${b.saved.toLocaleString()}\`).join("") : "—"); + document.getElementById("xray-receipt").textContent = s.xray.receipt; + } else { + document.getElementById("xray-empty").style.display = ""; + document.getElementById("xray-wrap").style.display = "none"; + } if (s.wiki) { wikiPages = s.wiki.pages || []; wikiEdges = s.wiki.edges || []; diff --git a/src/engine/receipt.ts b/src/engine/receipt.ts index a87f1eb..c50c362 100644 --- a/src/engine/receipt.ts +++ b/src/engine/receipt.ts @@ -16,6 +16,9 @@ export interface SessionMark { savedAtStart: number; usedAtStart: number; retrievalsAtStart: number; + /** Output-side: cumulative model output tokens at session start (transcript + * probe) — lets the receipt PROVE actual output written this session. */ + outputAtStart?: number; reads: Record; redirects: Record; } @@ -41,7 +44,7 @@ function loadMark(root: string): SessionMark | null { * zero-point so the receipt can report SESSION deltas, not lifetime totals. */ export function markSessionStart( root: string, - snap: { savedTokens: number; usedTokens: number; retrievals: number }, + snap: { savedTokens: number; usedTokens: number; retrievals: number; outputTokens?: number }, ): void { mkdirSync(root, { recursive: true }); const mark: SessionMark = { @@ -49,6 +52,7 @@ export function markSessionStart( savedAtStart: snap.savedTokens, usedAtStart: snap.usedTokens, retrievalsAtStart: snap.retrievals, + ...(snap.outputTokens !== undefined ? { outputAtStart: snap.outputTokens } : {}), reads: {}, redirects: {}, }; @@ -182,6 +186,10 @@ export interface ReceiptInput { eventsTrimmed: boolean; /** Lifetime TOIN retrieval count (caller sums feedback stats). */ retrievalsTotal: number; + /** Output-side: cumulative model output tokens NOW (transcript probe). */ + outputTokensNow?: number; + /** Whether a terse/caveman output mode is detectably active. */ + terseActive?: boolean; /** Injected clock for tests. */ now?: () => number; } @@ -251,6 +259,17 @@ export function buildReceipt(i: ReceiptInput): string { // G6 forecast: only when it's a plan-billed session, we have a marker, the // session has run long enough to trust a burn rate, and there's something // to project (avoided > 0) — otherwise it's noise or a divide-by-garbage. + // Output side: we can PROVE what was written and that terse was on — but a + // verbose counterfactual is unknowable, so nothing here enters 'avoided'. + if (mark?.outputAtStart !== undefined && i.outputTokensNow !== undefined) { + const written = Math.max(0, i.outputTokensNow - mark.outputAtStart); + if (written > 0) { + lines.push( + `output written: ~${fmtTokens(written)} tok${i.terseActive ? " with terse mode ON (real output-side savings; no provable counterfactual — not counted in avoided)" : ""}`, + ); + } + } + // G5 dollars: api-billing only — plan users' cost is quota, never shown $. if (meter.billingMode === "api" && avoided > 0 && meter.model) { const rate = RATE_PER_MTOK.find((r) => r.match.test(meter.model!)); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 7049635..1bba63b 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -20,7 +20,7 @@ import { createWikiStore } from "../engine/wiki.js"; import { createActivityLog } from "../engine/activity.js"; import { createFeedback } from "../engine/feedback.js"; import { markSessionStart, readSessionMark, recordRead, recordRedirect, buildReceipt } from "../engine/receipt.js"; -import { currentContextTokens, currentContextModel } from "../engine/usage.js"; +import { currentContextTokens, currentContextModel, readProjectUsage } from "../engine/usage.js"; import { ccrRoot, memoryRoot, meterRoot, wikiRoot, loopStatePath, activityRoot, feedbackRoot } from "../paths.js"; import { decideLoopStop } from "./stop.js"; import { decidePostToolUse, type PostToolUseInput } from "./posttooluse.js"; @@ -232,7 +232,13 @@ async function main(): Promise { const retrievals = createFeedback(feedbackRoot()) .stats() .reduce((sum, s) => sum + s.retrievals, 0); - markSessionStart(meterRoot(), { savedTokens: r.savedTokens, usedTokens: r.usedTokens, retrievals }); + const outputTokens = readProjectUsage(process.cwd())?.outputTokens; + markSessionStart(meterRoot(), { + savedTokens: r.savedTokens, + usedTokens: r.usedTokens, + retrievals, + ...(outputTokens !== undefined ? { outputTokens } : {}), + }); } catch { /* never break session start */ } @@ -278,7 +284,21 @@ async function main(): Promise { const retrievalsTotal = createFeedback(feedbackRoot()) .stats() .reduce((sum, s) => sum + s.retrievals, 0); - const receipt = buildReceipt({ meter: meterReading, mark, events, eventsTrimmed: trimmed, retrievalsTotal }); + // Output-side proof: actual tokens written + terse-active detection + // (env or the project's terse rules file) — shown, never counted. + const outputTokensNow = readProjectUsage(process.cwd())?.outputTokens; + const terseActive = + (process.env["KNITBRAIN_TERSE"] ?? "") !== "" || + existsSync(".claude/rules/knitbrain.md"); + const receipt = buildReceipt({ + meter: meterReading, + mark, + events, + eventsTrimmed: trimmed, + retrievalsTotal, + ...(outputTokensNow !== undefined ? { outputTokensNow } : {}), + terseActive, + }); const out = adaptOutput(platform, "stop", { systemMessage: receipt }); if (out) process.stdout.write(JSON.stringify(out)); } catch { diff --git a/src/hub/client.ts b/src/hub/client.ts index 9af61e9..c743eee 100644 --- a/src/hub/client.ts +++ b/src/hub/client.ts @@ -38,39 +38,71 @@ export function loadHubConfig(): HubConfig | null { * Mirror a board posting to the hub — FIRE-AND-FORGET. Never throws, never * blocks: a dead/unreachable hub must never slow local work. */ +/** Backoff schedule between attempts (ms) — index i is the wait before attempt i+2. */ +const MIRROR_BACKOFF_MS = [250, 1000]; + export function mirrorToHub( cfg: HubConfig, entry: { author: string; summary: string; original: string }, ): void { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 2000); - void fetch(`${cfg.url.replace(/\/+$/, "")}/board`, { - method: "POST", - headers: { "content-type": "application/json", authorization: `Bearer ${cfg.token}` }, - body: JSON.stringify({ ...entry, author: cfg.member || entry.author }), - signal: controller.signal, - }) - .catch(() => {}) - .finally(() => clearTimeout(timer)); + void (async () => { + const url = `${cfg.url.replace(/\/+$/, "")}/board`; + const body = JSON.stringify({ ...entry, author: cfg.member || entry.author }); + for (let attempt = 0; attempt < 3; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2000); + try { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${cfg.token}` }, + body, + signal: controller.signal, + }); + clearTimeout(timer); + if (res.ok) return; + // 4xx (bad token, body too large, etc.) will never succeed on retry. + if (res.status >= 400 && res.status < 500) return; + // else: 5xx — fall through to retry. + } catch { + clearTimeout(timer); + // network error / abort (timeout) — fall through to retry. + } + const wait = MIRROR_BACKOFF_MS[attempt]; + if (wait !== undefined) await new Promise((r) => setTimeout(r, wait)); + } + // fire-and-forget: all errors swallowed after the final attempt. + })(); } +/** Backoff between the two fetchHubBoard attempts (ms). */ +const BOARD_FETCH_BACKOFF_MS = 300; + /** Best-effort hub board fetch (for the dashboard's merged view). */ export async function fetchHubBoard( cfg: HubConfig, timeoutMs = 1500, ): Promise> { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetch(`${cfg.url.replace(/\/+$/, "")}/board`, { - headers: { authorization: `Bearer ${cfg.token}` }, - signal: controller.signal, - }); - if (!res.ok) return []; - return (await res.json()) as Array<{ id: string; author: string; summary: string; ts: string }>; - } catch { - return []; - } finally { - clearTimeout(timer); + const url = `${cfg.url.replace(/\/+$/, "")}/board`; + for (let attempt = 0; attempt < 2; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + headers: { authorization: `Bearer ${cfg.token}` }, + signal: controller.signal, + }); + if (!res.ok) { + // 4xx will never succeed on retry; 5xx falls through to retry. + if (res.status >= 400 && res.status < 500) return []; + } else { + return (await res.json()) as Array<{ id: string; author: string; summary: string; ts: string }>; + } + } catch { + // network error / abort (timeout) — fall through to retry. + } finally { + clearTimeout(timer); + } + if (attempt === 0) await new Promise((r) => setTimeout(r, BOARD_FETCH_BACKOFF_MS)); } + return []; } diff --git a/src/hub/server.ts b/src/hub/server.ts index b1edba2..3b0a8fc 100644 --- a/src/hub/server.ts +++ b/src/hub/server.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { join } from "node:path"; import { containsSecret } from "../engine/cleanse.js"; +import { writeAtomic } from "../atomic.js"; /** * Knit Brain HUB — the shared-sessions server a team points at. @@ -30,6 +31,15 @@ export interface Hub { /** Cap request bodies so one authenticated client can't exhaust hub memory. */ const MAX_BODY_BYTES = 1_000_000; +/** Default fixed-window rate limit: 60 req / 10s per remote address. */ +const DEFAULT_RATE_LIMIT = { max: 60, windowMs: 10_000 }; + +/** Default board size cap (entries) before the rare rewrite-to-newest-N kicks in. */ +const MAX_BOARD_ENTRIES = 500; + +/** Bound the rate-limit address map itself — a botnet of distinct IPs must not grow this unbounded. */ +const MAX_RATE_LIMIT_ENTRIES = 1000; + // Credential detection shared with the brain-write cleanse (single source in // engine/cleanse.ts). A board original is stored byte-exact for the team, so we // REJECT (never silently scrub — that would corrupt the original) a posting that @@ -57,7 +67,13 @@ function readBody(req: IncomingMessage): Promise { }); } -export function createHub(root: string): Hub { +export function createHub( + root: string, + opts?: { now?: () => number; rateLimit?: { max: number; windowMs: number }; maxEntries?: number }, +): Hub { + const now = opts?.now ?? Date.now; + const rateLimit = opts?.rateLimit ?? DEFAULT_RATE_LIMIT; + const maxEntries = opts?.maxEntries ?? MAX_BOARD_ENTRIES; mkdirSync(root, { recursive: true }); const tokenPath = join(root, "token.txt"); // Append-only log: one JSON entry per line. Posting is O(1) (append a line) @@ -100,6 +116,34 @@ export function createHub(root: string): Hub { const append = (entry: HubEntry): void => { writeFileSync(boardPath, JSON.stringify(entry) + "\n", { encoding: "utf8", flag: "a" }); }; + // Sole write-amplification point: appends are O(1), but once the log passes + // maxEntries we rewrite it down to the newest maxEntries (rare — only at cap). + const trimBoard = (): void => { + const all = load(); + if (all.length <= maxEntries) return; + const kept = all.slice(all.length - maxEntries); + writeAtomic(boardPath, kept.map((e) => JSON.stringify(e)).join("\n") + "\n"); + }; + + // Fixed-window rate limiter, per remote address. Map insertion order gives + // us oldest-inserted-first eviction for free (bounded regardless of window + // resets) — a simple Map, not an LRU, since we only ever need "oldest key". + const rateBuckets = new Map(); + const rateLimited = (addr: string): boolean => { + const t = now(); + let bucket = rateBuckets.get(addr); + if (!bucket || t - bucket.windowStart >= rateLimit.windowMs) { + bucket = { windowStart: t, count: 0 }; + rateBuckets.delete(addr); // re-insert at the end (fresh window = fresh recency) + rateBuckets.set(addr, bucket); + } + bucket.count++; + if (rateBuckets.size > MAX_RATE_LIMIT_ENTRIES) { + const oldestKey = rateBuckets.keys().next().value; + if (oldestKey !== undefined) rateBuckets.delete(oldestKey); + } + return bucket.count > rateLimit.max; + }; // SECURITY: constant-time comparison — no timing oracle on the team token. const expected = createHash("sha256").update(`Bearer ${token}`).digest(); @@ -118,6 +162,9 @@ export function createHub(root: string): Hub { if (req.method === "GET" && req.url === "/health") { return json(res, 200, { status: "healthy", server: "knitbrain-hub" }); } + if (rateLimited(req.socket.remoteAddress ?? "?")) { + return json(res, 429, { error: "rate-limited" }); + } if (!authed(req)) return json(res, 401, { error: "missing or invalid token" }); if (req.method === "POST" && req.url === "/board") { @@ -143,6 +190,7 @@ export function createHub(root: string): Hub { ts: new Date().toISOString(), }; append(entry); + trimBoard(); return json(res, 200, { id: entry.id }); } if (req.method === "GET" && req.url === "/board") { diff --git a/src/index.ts b/src/index.ts index d00c0ab..4c79c33 100644 --- a/src/index.ts +++ b/src/index.ts @@ -186,13 +186,17 @@ usage: knitbrain const { fetchPlatformQuota } = await import("./engine/quota.js"); const { createActivityLog } = await import("./engine/activity.js"); const { createWikiStore } = await import("./engine/wiki.js"); + const { readSessionMark, buildReceipt } = await import("./engine/receipt.js"); + const { xrayState } = await import("./dashboard.js"); const activityLog = createActivityLog(paths.activityRoot()); + const meter = createMeter(paths.meterRoot(), { realUsage: () => currentContextTokens(), realModel: () => currentContextModel() }); + const feedback = createFeedback(paths.feedbackRoot()); const srv = createDashboardServer({ ccr, memory: createMemory(paths.memoryRoot()), - feedback: createFeedback(paths.feedbackRoot()), + feedback, team: createTeamBoard(paths.teamRoot(), ccr), - meter: createMeter(paths.meterRoot(), { realUsage: () => currentContextTokens(), realModel: () => currentContextModel() }), + meter, // Project scope: the directory the dashboard was started in. knowledge: createKnowledge(process.cwd(), paths.knowledgeRoot()), skills: createSkillsStore(paths.skillsRoot()), @@ -206,6 +210,18 @@ usage: knitbrain agents: () => activityLog.rollup(), // The compounding wiki-brain (leg 5). wiki: createWikiStore(paths.wikiRoot()), + // G1 X-ray: SAME honest math as the stop-hook receipt — no parallel aggregation. + xray: () => { + try { + const mark = readSessionMark(paths.meterRoot()); + const { events, trimmed } = mark ? activityLog.since(mark.startTs) : { events: [], trimmed: false }; + const retrievalsTotal = feedback.stats().reduce((n, s) => n + s.retrievals, 0); + const receipt = buildReceipt({ meter: meter.read(), mark, events, eventsTrimmed: trimmed, retrievalsTotal }); + return xrayState(events, receipt, mark?.startTs ?? null, trimmed); + } catch { + return null; + } + }, }); const port = Number(process.env["KNITBRAIN_DASHBOARD_PORT"] ?? 8790); srv.listen(port, "127.0.0.1", () => { diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 1b382d6..ecf8a8f 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -12,7 +12,8 @@ import { createSkillsStore } from "../src/engine/skills.js"; import { createTeamBoard } from "../src/engine/teams.js"; import { createMeter } from "../src/engine/meter.js"; import { createWikiStore } from "../src/engine/wiki.js"; -import { createDashboardServer, dashboardState, renderMarkdown, type DashboardDeps } from "../src/dashboard.js"; +import { createDashboardServer, dashboardState, renderMarkdown, xrayState, type DashboardDeps, type XrayState } from "../src/dashboard.js"; +import type { ActivityEvent } from "../src/engine/activity.js"; describe("dashboard (rung 17)", () => { let root: string; @@ -119,6 +120,61 @@ describe("dashboard (rung 17)", () => { expect(api.wiki.recent.length).toBeGreaterThan(0); // log present }); + // G1 X-ray: pure aggregation function, no server involved. + it("xrayState sums per-source rollups exactly, bucketing source-less legacy events under mcp", () => { + const events: ActivityEvent[] = [ + { ts: "t1", agent: "a", tool: "read", summary: "s1", saved: 10, source: "mcp", rawTokens: 100, storedTokens: 20 }, + { ts: "t2", agent: "a", tool: "read", summary: "s2", saved: 5, source: "mcp", rawTokens: 50, storedTokens: 10 }, + { ts: "t3", agent: "a", tool: "hook", summary: "s3", saved: 7, source: "hook", rawTokens: 30, storedTokens: 3 }, + { ts: "t4", agent: "a", tool: "proxy", summary: "s4", saved: 2, source: "proxy", rawTokens: 40, storedTokens: 8 }, + // legacy event, no source field at all — must bucket under "mcp", not a separate "undefined" key. + { ts: "t5", agent: "a", tool: "read", summary: "s5", saved: 1 }, + ]; + const x = xrayState(events, "RECEIPT TEXT", "2026-01-01T00:00:00Z", true); + expect(x.receipt).toBe("RECEIPT TEXT"); + expect(x.sessionStart).toBe("2026-01-01T00:00:00Z"); + expect(x.trimmed).toBe(true); + expect(Object.keys(x.bySource).sort()).toEqual(["hook", "mcp", "proxy"]); + // mcp = the two explicit "mcp" events + the one source-less legacy event + expect(x.bySource["mcp"]).toEqual({ events: 3, raw: 150, stored: 30, saved: 16 }); + expect(x.bySource["hook"]).toEqual({ events: 1, raw: 30, stored: 3, saved: 7 }); + expect(x.bySource["proxy"]).toEqual({ events: 1, raw: 40, stored: 8, saved: 2 }); + }); + + it("xrayState defaults raw/stored to 0 when absent from an event", () => { + const events: ActivityEvent[] = [{ ts: "t1", agent: "a", tool: "x", summary: "no numbers", saved: 0, source: "hook" }]; + const x = xrayState(events, "r", null, false); + expect(x.bySource["hook"]).toEqual({ events: 1, raw: 0, stored: 0, saved: 0 }); + expect(x.sessionStart).toBeNull(); + expect(x.trimmed).toBe(false); + }); + + it("dashboardState/api exposes deps.xray via a getter; absent xray → null (back-compat)", async () => { + const fixture: XrayState = { + bySource: { mcp: { events: 3, raw: 150, stored: 30, saved: 16 }, hook: { events: 1, raw: 30, stored: 3, saved: 7 } }, + receipt: "session receipt fixture", + sessionStart: "2026-01-01T00:00:00Z", + trimmed: false, + }; + const s = dashboardState({ ...deps, xray: () => fixture }); + expect(s["xray"]).toEqual(fixture); + + // back-compat: deps with no xray getter at all → null, not a throw. + const s2 = dashboardState(deps); + expect(s2["xray"]).toBeNull(); + + srv = createDashboardServer({ ...deps, xray: () => fixture }); + const port: number = await new Promise((r) => + srv!.listen(0, "127.0.0.1", () => r((srv!.address() as { port: number }).port)), + ); + const api = (await (await fetch(`http://127.0.0.1:${port}/api/state`)).json()) as { + xray: { receipt: string; bySource: XrayState["bySource"] } | null; + }; + expect(typeof api.xray?.receipt).toBe("string"); + expect(api.xray?.receipt).toBe("session receipt fixture"); + expect(api.xray?.bySource).toEqual(fixture.bySource); + }); + it("serves the page and the JSON API over HTTP", async () => { srv = createDashboardServer(deps); const port: number = await new Promise((r) => diff --git a/tests/hub.test.ts b/tests/hub.test.ts index f5e835e..463b300 100644 --- a/tests/hub.test.ts +++ b/tests/hub.test.ts @@ -126,4 +126,121 @@ describe("team hub (rung 18)", () => { rmSync(root2, { recursive: true, force: true }); } }); + + describe("rate limiting (fixed window, per remote address)", () => { + it("allows max requests per window, 429s the next; /health stays exempt; a new window allows again", async () => { + let clock = 1_000_000; // ms, mutable so we can move it forward for the "new window" phase + const rl = createHub(join(root, "hub-rl"), { now: () => clock, rateLimit: { max: 3, windowMs: 10_000 } }); + const port = await listen(rl.server); + const url2 = `http://127.0.0.1:${port}`; + try { + const auth = { authorization: `Bearer ${rl.token}` }; + expect((await fetch(`${url2}/board`, { headers: auth })).status).toBe(200); + expect((await fetch(`${url2}/board`, { headers: auth })).status).toBe(200); + expect((await fetch(`${url2}/board`, { headers: auth })).status).toBe(200); + expect((await fetch(`${url2}/board`, { headers: auth })).status).toBe(429); + // /health is checked before the rate limiter and must remain open even while limited. + expect((await fetch(`${url2}/health`)).status).toBe(200); + // Move the injected clock past the window — a fresh window resets the count. + clock += 11_000; + expect((await fetch(`${url2}/board`, { headers: auth })).status).toBe(200); + } finally { + await new Promise((r) => rl.server.close(() => r())); + } + }); + }); + + describe("board eviction (maxEntries)", () => { + it("keeps only the newest maxEntries; oldest evicted, newest present", async () => { + const ev = createHub(join(root, "hub-evict"), { maxEntries: 5 }); + const port = await listen(ev.server); + const url2 = `http://127.0.0.1:${port}`; + try { + const auth = { authorization: `Bearer ${ev.token}`, "content-type": "application/json" }; + for (let i = 0; i < 8; i++) { + await fetch(`${url2}/board`, { + method: "POST", + headers: auth, + body: JSON.stringify({ author: `u${i}`, summary: `summary-${i}`, original: `finding ${i}` }), + }); + } + const list = (await (await fetch(`${url2}/board`, { headers: auth })).json()) as Array<{ summary: string }>; + expect(list.length).toBe(5); + expect(list.some((e) => e.summary === "summary-0")).toBe(false); // first-posted evicted + expect(list.some((e) => e.summary === "summary-7")).toBe(true); // last-posted present + } finally { + await new Promise((r) => ev.server.close(() => r())); + } + }); + }); + + describe("client retry behavior", () => { + it("mirrorToHub retries a 500 and lands the entry on the eventual 2xx (verified via authed GET on the real hub)", async () => { + // A flaky proxy in front of a REAL hub: fails the first POST with a 500 + // (never reaching the hub), forwards every subsequent request untouched. + // This proves both the retry (entry lands) and that the landed entry is + // readable back off the real hub's own board — no side-channel assertion. + const real = createHub(join(root, "hub-flaky-real")); + const realPort = await listen(real.server); + const { createServer, request } = await import("node:http"); + let calls = 0; + const proxy = createServer((req, res) => { + calls++; + if (calls === 1) { + req.on("data", () => {}); + req.on("end", () => { + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "boom" })); + }); + return; + } + const upstream = request( + { host: "127.0.0.1", port: realPort, path: req.url, method: req.method, headers: req.headers }, + (upRes) => { + res.writeHead(upRes.statusCode ?? 502, upRes.headers); + upRes.pipe(res); + }, + ); + req.pipe(upstream); + }); + const proxyPort = await listen(proxy); + try { + const cfg: HubConfig = { url: `http://127.0.0.1:${proxyPort}`, token: real.token, member: "carol" }; + mirrorToHub(cfg, { author: "ignored", summary: "flaky mirror", original: "flaky content" }); + // First attempt fails immediately (500, never reaches the hub), backoff 250ms, second attempt lands. + await sleep(2000); + expect(calls).toBeGreaterThanOrEqual(2); + const board = (await ( + await fetch(`http://127.0.0.1:${realPort}/board`, { headers: { authorization: `Bearer ${real.token}` } }) + ).json()) as Array<{ author: string; summary: string }>; + expect(board.some((e) => e.author === "carol" && e.summary === "flaky mirror")).toBe(true); + } finally { + await new Promise((r) => proxy.close(() => r())); + await new Promise((r) => real.server.close(() => r())); + } + }); + + it("mirrorToHub does NOT retry on a 4xx (e.g. wrong token) — exactly one request reaches the server", async () => { + const { createServer } = await import("node:http"); + let calls = 0; + const rejecting = createServer((req, res) => { + calls++; + req.on("data", () => {}); + req.on("end", () => { + res.writeHead(401, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "missing or invalid token" })); + }); + }); + const port = await listen(rejecting); + try { + const cfg: HubConfig = { url: `http://127.0.0.1:${port}`, token: "wrong", member: "carol" }; + mirrorToHub(cfg, { author: "ignored", summary: "s", original: "o" }); + // Wait well past the 250ms/1000ms backoff windows a retry would use — a 4xx must not retry at all. + await sleep(1500); + expect(calls).toBe(1); + } finally { + await new Promise((r) => rejecting.close(() => r())); + } + }); + }); }); diff --git a/tests/receipt.test.ts b/tests/receipt.test.ts index 96c8d70..7723ccc 100644 --- a/tests/receipt.test.ts +++ b/tests/receipt.test.ts @@ -220,3 +220,28 @@ describe("G5 dollar conversion (api-billing only)", () => { expect(r).not.toContain("$"); }); }); + +describe("output-side proof line (terse impact, honestly bounded)", () => { + const meter = () => ({ + usedTokens: 10_000, windowTokens: 1_000_000, usedPct: 1, savedTokens: 5_000, + optimizationPct: 5, estimated: false, cacheCold: false, status: "ok" as const, + advice: "", billingMode: "plan" as const, + }); + const mark = { startTs: new Date(0).toISOString(), savedAtStart: 0, usedAtStart: 0, retrievalsAtStart: 0, outputAtStart: 40_000, reads: {}, redirects: {} }; + + it("prints actual output written + terse-ON note, and never counts it as avoided", async () => { + const { buildReceipt } = await import("../src/engine/receipt.js"); + const r = buildReceipt({ meter: meter(), mark, events: [], eventsTrimmed: false, retrievalsTotal: 0, outputTokensNow: 52_500, terseActive: true }); + expect(r).toContain("output written: ~12.5k tok"); + expect(r).toContain("terse mode ON"); + expect(r).toContain("not counted in avoided"); + }); + + it("silent when no output snapshot exists (old marks — back-compat)", async () => { + const { buildReceipt } = await import("../src/engine/receipt.js"); + const oldMark = { ...mark } as Record; + delete oldMark["outputAtStart"]; + const r = buildReceipt({ meter: meter(), mark: oldMark as never, events: [], eventsTrimmed: false, retrievalsTotal: 0, outputTokensNow: 52_500, terseActive: true }); + expect(r).not.toContain("output written"); + }); +}); diff --git a/tests/security.test.ts b/tests/security.test.ts index 03a456b..ffea2b0 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -77,5 +77,18 @@ describe("security regressions", () => { await new Promise((r) => server.close(() => r())); } }); + + it("rate-limits rapid-fire requests from one address (denial-of-service floor)", async () => { + const { server, token } = createHub(join(root, "hub-rl-sec"), { rateLimit: { max: 2, windowMs: 10_000 } }); + try { + const url = `http://127.0.0.1:${await listen(server)}`; + const auth = { authorization: `Bearer ${token}` }; + expect((await fetch(`${url}/board`, { headers: auth })).status).toBe(200); + expect((await fetch(`${url}/board`, { headers: auth })).status).toBe(200); + expect((await fetch(`${url}/board`, { headers: auth })).status).toBe(429); // 3rd request denied + } finally { + await new Promise((r) => server.close(() => r())); + } + }); }); });