Public: anyone can read this; the crew can change it.

A client downloads their month's ledger and the forge's public key as their bill, and ledger-verify --key checks it #12371

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/12276-a-client-downloads-their-month-s-ledger → main 6 commits fork 4 threads · 4 unresolved

  • Merged. scout/12276-a-client-downloads-their-month-s-ledger was merged into main as f057c8a.

Review

Sign in to review this change.
20 files changed +642 −10
docs/features/cost-ledger.md +17
@@ -44,3 +44,20 @@ The jsonl is the spool; git is truth. `src/core/ledger_git.ts` commits the chain
4444 bun scripts/ledger-verify.ts --git ledger-clone --key "ssh-ed25519 AAAA…"
4545
4646 Walks the `ledger` branch, checks every commit's signature and the whole hash chain, and prints `ok N entries, M signed commits, key …`, or `broken at <file>:<line>: <why>` / `broken at <commit>: bad signature …` and exits 1. `--key` pins the signer (the forge's `host_ed25519.pub`); without it the commits must agree on one key, which is printed — compare it yourself. `--branch` names another branch.
47
48 ## A client's bill
49
50 The person who claimed a hosted forge (the first person admin in its own database, matched to a signed-in account here by email address) downloads one month of what it cost as one signed file. The link is on their settings page (`/settings`, any account, admin or not), on the forge's row in `/forges` for an admin who claimed it, and in the terminal under "Your forges' bills", which shows the month's totals. The API is `GET /api/v1/forges/:name/bill?month=YYYY-MM`, and `/forges/:name/bill` serves the same file; the month defaults to this one. Anyone else, signed in or not, gets 404, including the person who made the forge if they did not claim it. A month over 4 MiB of entries gets 413 and is never cut.
51
52 The file (`src/core/ledger_bill.ts`, access in `src/actions/forge_bill.ts`):
53
54 - Line 1 is a JSON header `{bill, forge, month, own, key, signature}`.
55 - The next `own` lines are the entries of the forge's own ledger directory, a hash chain.
56 - The lines after them are the entries in this forge's spool whose `repo` is one of the apps the forge follows. They come out of a chain shared with other forges, so each is checked on its own.
57 - This forge signs the forge name, the month, `own` and all entry lines with its SSH host key (namespace `forge-bill`).
58
59 To check a bill:
60
61 bun scripts/ledger-verify.ts --key "ssh-ed25519 AAAA…" <bill>
62
63 It prints `ok N entries $X` (the sum of `usd_tokens`), or `broken at line N: <why>` and exits 1 when an entry changed, the own chain is cut, or the signature is not by that key. The first own entry's `prev` is not checked, because the bill holds one month. The address match trusts the email on both accounts.
docs/features/forge-bill.md added +10
@@ -0,0 +1,10 @@
1 # A client's monthly bill
2
3 The claimant of a hosted forge downloads that month's bill for it: on the web, the forge's row in `/forges` has a
4 "Download this month's bill" link (`/forges/<name>/bill`), and the same file is served by the API and the MCP forge
5 tools. In the terminal (`src/tui/screens/bills.ts`) the same bill is a screen. The bill is owed to the claimant's
6 address, holds the host spool's entries for the forge, and is capped in size.
7
8 Try it: open `/forges`, press **Download this month's bill** on a claimed forge.
9
10 The ledger the bill is cut from is described in `docs/features/cost-ledger.md`; pinned by `test/forge-bill.test.ts`.
scripts/ledger-verify.ts +21 −1
@@ -4,9 +4,14 @@
44 * the same walk over the `ledger` branch of a clone, plus the signature of
55 * every commit on it; no database. `--key` pins the signer; without it the
66 * commits must agree on one key, which is printed so it can be compared.
7 * `bun scripts/ledger-verify.ts --key "<ssh-ed25519 line>" <bill>` — a client's downloaded bill
8 * (one month and the forge's key): the chain inside it and the signature; no database. Prints
9 * `ok N entries $X` or the first broken line.
710 */
11 import { readFileSync } from "node:fs";
812 import { join } from "node:path";
913 import { verifyDir, verifyFiles } from "../src/core/ledger.ts";
14 import { verifyBill } from "../src/core/ledger_bill.ts";
1015 import { checkMessage, splitCommit } from "../src/ssh/sshsig.ts";
1116
1217 function git(repo: string, args: string[]): string | undefined {
@@ -46,7 +51,22 @@ if (import.meta.main) {
4651 return i === -1 ? undefined : args[i + 1];
4752 };
4853 const repo = flag("--git");
49 if (repo !== undefined) {
54 const pub = flag("--key");
55 const bill = repo === undefined && pub !== undefined ? args.filter((a, i) => a !== "--key" && args[i - 1] !== "--key")[0] : undefined;
56 if (bill !== undefined) {
57 let text: string | undefined;
58 try {
59 text = readFileSync(bill, "utf8");
60 } catch {
61 text = undefined;
62 }
63 const verdict = text === undefined ? ({ ok: false, at: bill, why: "cannot read the file" } as const) : verifyBill(text, pub);
64 if (verdict.ok) console.log(`ok ${verdict.entries} entries $${verdict.usd}`);
65 else {
66 console.log(`broken at ${verdict.at}: ${verdict.why}`);
67 process.exit(1);
68 }
69 } else if (repo !== undefined) {
5070 const verdict = verifyGit(repo, flag("--branch") ?? "ledger", flag("--key"));
5171 if (verdict.ok) console.log(`ok ${verdict.entries} entries, ${verdict.commits} signed commits, key ${verdict.key}`);
5272 else {
src/actions/forge_bill.ts added +59
@@ -0,0 +1,59 @@
1 /**
2 * A client's bill (#12276): a hosted forge's month of ledger, signed by this
3 * forge. Only the person who claimed the child may download it: the account
4 * there that is its first person admin, matched to the caller by the address
5 * on both. The person who made it, a stranger, a signed-out caller, a name that
6 * is no child's and a child that is not finished all get the same `not_found`.
7 */
8
9 import { join } from "node:path";
10 import type { App } from "../app.ts";
11 import { isMonth, makeBill, monthEntries } from "../core/ledger_bill.ts";
12 import { getRepo } from "../core/repos.ts";
13 import type { User } from "../core/users.ts";
14 import { err, ok, type Result } from "../kernel/result.ts";
15 import { loadOrCreateHostKey } from "../ssh/keys.ts";
16 import { childPerson } from "../spawn/child_db.ts";
17 import { billTo } from "../spawn/person_activity.ts";
18 import { childDirOf, childNamed } from "./spawn.ts";
19
20 export type BillError = "not_found" | "invalid_month" | "too_large";
21
22 /** Whether this person is who claimed the forge. Agents and organisations never are. */
23 export const claimsBill = (user: User, to: string | null | undefined): boolean =>
24 user.kind === "user" && to !== null && to !== undefined && to === billTo(user.email);
25
26 export async function readBill(
27 app: App,
outdated

Owner is ownerId (the maker), not the claimed owner the issue asks for. Check the claim.

28 user: User | undefined,
29 name: string,
30 month: string | null,
31 now = new Date(),
32 ): Promise<Result<{ file: string; month: string; text: string }, BillError>> {
33 if (user === undefined) return err("not_found");
outdated

Only the child's own directory is read; its runs in the host spool are missing from the bill, and the whole file is read unbounded. Include them and cap the size.

34 const child = await childNamed(app, name);
35 if (child === undefined || !child.done) return err("not_found");
36 const dir = childDirOf(app, child.bodyId);
37 const person = await childPerson(dir);
38 if (!claimsBill(user, billTo(person?.claimantEmail))) return err("not_found");
39 const asked = month ?? now.toISOString().slice(0, 7);
40 if (!isMonth(asked)) return err("invalid_month");
41 // The apps it follows are repositories here: what its work cost lands in this forge's spool
42 // under them.
43 const repos = new Set<string>();
44 for (const ref of child.apps) {
45 const [o, n] = ref.split("/");
46 const repo = o === undefined || n === undefined ? undefined : getRepo(app.db, o, n);
47 if (repo !== undefined) repos.add(repo.id);
48 }
49 const picked = monthEntries(join(dir, "ledger"), join(app.config.dataDir, "ledger"), asked, repos);
50 if (picked === undefined) return err("too_large");
51 const text = makeBill({
52 forge: child.name,
53 month: asked,
54 entries: picked.entries,
55 own: picked.own,
56 key: loadOrCreateHostKey(join(app.config.dataDir, "ssh")),
57 });
58 return ok({ file: `${child.name}-${asked}.bill.jsonl`, month: asked, text });
59 }
src/contract/openapi/routes/getApiV1ForgesNameBill.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/forges/{name}/bill",
6 summary: "One month (?month=YYYY-MM, default this month) of a hosted forge's cost ledger and this forge's public key, signed, as one newline-delimited JSON file; `bun scripts/ledger-verify.ts --key <pub> <file>` checks it (the forge's owner only)",
7 auth: true,
8 role: "member",
9 refusals: { not_found: "the caller is not the person who owns the forge, or there is no such forge (404)", invalid_month: "month is not YYYY-MM (422)" },
10 };
src/contract/openapi/routes/index.ts +1
@@ -98,6 +98,7 @@ export { route as getApiV1Forge } from "./getApiV1Forge.ts";
9898 export { route as getApiV1ForgeEconomics } from "./getApiV1ForgeEconomics.ts";
9999 export { route as getApiV1ForgeView } from "./getApiV1ForgeView.ts";
100100 export { route as getApiV1Forges } from "./getApiV1Forges.ts";
101 export { route as getApiV1ForgesNameBill } from "./getApiV1ForgesNameBill.ts";
101102 export { route as getApiV1ForgesNamePlan } from "./getApiV1ForgesNamePlan.ts";
102103 export { route as getApiV1ForgesNameSandbox } from "./getApiV1ForgesNameSandbox.ts";
103104 export { route as getApiV1ForgesSizes } from "./getApiV1ForgesSizes.ts";
src/core/ledger_bill.ts added +154
@@ -0,0 +1,154 @@
1 /**
2 * A client's bill: one month of their forge's cost ledger and the hosting
3 * forge's public key, in one file (#12276). Line 1 is a JSON header
4 * `{bill, forge, month, own, key, signature}`; every later line is one ledger
5 * entry, byte for byte as the spool holds it. The signature is the forge's
6 * SSH key's (namespace `forge-bill`) over `forge`, `month` and the entry
7 * lines, so neither the entries nor the name and month they were sold under
8 * can change. `verifyBill` needs no database: the hash chain inside the month
9 * (the first entry's `prev` is whatever the month before ended on), then the
10 * signature, then — when a key is pinned — that it is that key.
11 */
12
13 import { closeSync, openSync, readSync } from "node:fs";
14 import { join } from "node:path";
15 import type { HostKey } from "../ssh/keys.ts";
16 import { checkMessage, signMessage } from "../ssh/sshsig.ts";
17 import { hashOf } from "./ledger.ts";
18
19 export const BILL_NAMESPACE = "forge-bill";
20 export const isMonth = (s: string): boolean => /^\d{4}-(0[1-9]|1[0-2])$/.test(s);
21
22 /** The most a bill may hold, in bytes of entries; a month past it is refused, never cut. */
23 export const MAX_BILL_BYTES = 4 * 1024 * 1024;
24
25 const signedText = (forge: string, month: string, own: number, entries: string): string => `${forge}\n${month}\n${own}\n${entries}`;
26
27 /**
28 * The whole lines of one file that `keep` accepts, read in chunks so a large file is never held
29 * whole. Undefined when what is kept passes `room` bytes; empty when there is no such file.
30 */
31 function pickLines(path: string, keep: (line: string) => boolean, room: number): { text: string; lines: number } | undefined {
32 let fd: number;
33 try {
34 fd = openSync(path, "r");
35 } catch {
36 return { text: "", lines: 0 };
37 }
38 const out: string[] = [];
39 let bytes = 0;
40 let rest = Buffer.alloc(0);
41 const chunk = Buffer.alloc(256 * 1024);
42 try {
43 for (;;) {
44 const n = readSync(fd, chunk, 0, chunk.length, null);
45 if (n === 0) break;
46 rest = Buffer.concat([rest, chunk.subarray(0, n)]);
47 let at: number;
48 while ((at = rest.indexOf(0x0a)) >= 0) {
49 const line = rest.subarray(0, at).toString("utf8");
50 rest = rest.subarray(at + 1);
51 if (line === "" || !keep(line)) continue;
52 bytes += Buffer.byteLength(line) + 1;
53 if (bytes > room) return undefined;
54 out.push(line);
55 }
56 // A line that never ends is not an entry; it cannot grow past the room either.
57 if (rest.length > room) return undefined;
58 }
59 } finally {
60 closeSync(fd);
61 }
62 return { text: out.map((l) => `${l}\n`).join(""), lines: out.length };
63 }
64
65 const repoOf = (line: string): unknown => {
66 try {
67 return (JSON.parse(line) as { repo?: unknown }).repo;
68 } catch {
69 return undefined;
70 }
71 };
72
73 /**
74 * A month's entries for a bill: every line of the forge's own ledger directory, then the lines of
75 * this forge's spool whose `repo` is one of `repos` (the apps it follows here). `own` counts the
76 * first group. Undefined when together they pass `MAX_BILL_BYTES`.
77 */
78 export function monthEntries(
79 ownDir: string,
80 spoolDir: string,
81 month: string,
82 repos: ReadonlySet<string>,
83 ): { entries: string; own: number } | undefined {
84 if (!isMonth(month)) return { entries: "", own: 0 };
85 const own = pickLines(join(ownDir, `${month}.jsonl`), () => true, MAX_BILL_BYTES);
86 if (own === undefined) return undefined;
87 const host = repos.size === 0 ? { text: "", lines: 0 } : pickLines(join(spoolDir, `${month}.jsonl`), (l) => {
88 const r = repoOf(l);
89 return typeof r === "string" && repos.has(r);
90 }, MAX_BILL_BYTES - own.text.length);
91 if (host === undefined) return undefined;
92 return { entries: own.text + host.text, own: own.lines };
93 }
94
95 export function makeBill(input: { forge: string; month: string; entries: string; own?: number; key: HostKey }): string {
96 const { forge, month, entries, key } = input;
97 const own = input.own ?? entries.split("\n").filter((l) => l !== "").length;
98 const signature = signMessage(key, signedText(forge, month, own, entries), BILL_NAMESPACE);
99 return `${JSON.stringify({ bill: 1, forge, month, own, key: key.publicKey.line, signature })}\n${entries}`;
100 }
101
102 export type BillVerdict =
103 | { ok: true; entries: number; usd: number; key: string }
104 | { ok: false; at: string; why: string };
105
106 const sameKey = (a: string, b: string): boolean => a.trim().split(/\s+/).slice(0, 2).join(" ") === b.trim().split(/\s+/).slice(0, 2).join(" ");
107
108 export function verifyBill(text: string, pinned?: string): BillVerdict {
109 const lines = text.split("\n");
110 let head: Record<string, unknown>;
111 try {
112 const v: unknown = JSON.parse(lines[0] ?? "");
113 if (v === null || typeof v !== "object" || Array.isArray(v)) throw new Error("not an object");
114 head = v as Record<string, unknown>;
115 } catch {
116 return { ok: false, at: "line 1", why: "not a bill header" };
117 }
118 const { forge, month, key, signature, own } = head;
119 if (head["bill"] !== 1 || typeof own !== "number" || !Number.isInteger(own) || own < 0 || typeof forge !== "string" || typeof month !== "string" || typeof key !== "string" || typeof signature !== "string")
120 return { ok: false, at: "line 1", why: "not a bill header" };
121 let prev: string | undefined;
122 let n = 0;
123 let usd = 0;
124 for (let i = 1; i < lines.length; i++) {
125 const line = lines[i] ?? "";
126 if (line === "") continue;
127 const at = `line ${i + 1}`;
128 let entry: Record<string, unknown>;
129 try {
130 const v: unknown = JSON.parse(line);
131 if (v === null || typeof v !== "object" || Array.isArray(v)) throw new Error("not an object");
132 entry = v as Record<string, unknown>;
133 } catch {
134 return { ok: false, at, why: "not an entry" };
135 }
136 if (typeof entry["prev"] !== "string" || typeof entry["hash"] !== "string") return { ok: false, at, why: "not an entry" };
137 // The forge's own entries form a chain (its first `prev` is the month before's end, not held
138 // here). The host-spool entries after them are picked out of a chain shared with other forges,
139 // so each is checked on its own and the signature vouches for the set.
140 if (n < own && prev !== undefined && entry["prev"] !== prev) return { ok: false, at, why: "prev is not the previous entry's hash" };
141 const { hash, ...body } = entry;
142 if (hashOf(body as Parameters<typeof hashOf>[0]) !== hash) return { ok: false, at, why: "hash does not match the entry" };
143 prev = hash as string;
144 n++;
145 const cost = entry["usd_tokens"];
146 if (typeof cost === "number" && Number.isFinite(cost)) usd += cost;
147 }
148 const entries = lines.slice(1).join("\n");
149 const checked = checkMessage(signature, signedText(forge, month, own, entries), BILL_NAMESPACE);
150 if (!checked.ok) return { ok: false, at: "line 1", why: `bad signature: ${checked.why}` };
151 if (!sameKey(checked.key, key)) return { ok: false, at: "line 1", why: "signed by a different key than the bill names" };
152 if (pinned !== undefined && !sameKey(checked.key, pinned)) return { ok: false, at: "line 1", why: "signed by a different key than expected" };
153 return { ok: true, entries: n, usd: Math.round(usd * 1e6) / 1e6, key: checked.key };
154 }
src/http/api/forge_bill.ts added +30
@@ -0,0 +1,30 @@
1 /**
2 * GET /api/v1/forges/:name/bill?month=YYYY-MM and GET /forges/:name/bill: one
3 * month of a hosted forge's ledger and this forge's public key, as a file
4 * (`core/ledger_bill.ts`). Its claimant only; anyone else is told 404.
5 */
6
7 import { readBill } from "../../actions/forge_bill.ts";
8 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
9 import type { Handler } from "../context.ts";
10 import { apiError, notFound, requireScope } from "../respond.ts";
11
12 export const billDownload: Handler = async (ctx) => {
13 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
14 if (ctx.auth.user === undefined) return notFound();
15 const denied = requireScope(ctx, "ro");
16 if (denied) return denied;
17 const read = await readBill(ctx.app, ctx.auth.user, ctx.params["name"] ?? "", ctx.url.searchParams.get("month"));
18 if (!read.ok) {
19 if (read.error === "not_found") return notFound();
20 return read.error === "too_large" ? apiError("too_large", 413, "that month is too large for one bill") : apiError("invalid_month", 422, "month is YYYY-MM");
21 }
22 return new Response(read.value.text, {
23 headers: {
24 "content-type": "application/x-ndjson; charset=utf-8",
25 "content-disposition": `attachment; filename="${read.value.file}"`,
26 "cache-control": "private, no-store",
27 vary: "authorization, cookie",
28 },
29 });
30 };
src/http/api/forges.ts +7 −3
@@ -10,12 +10,13 @@ import { listChildren } from "../../actions/spawn.ts";
1010 import { usdWords } from "../../spawn/credit.ts";
1111 import { fundingOf, fundingWords, refreshFunding } from "../../spawn/funding.ts";
1212 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
13 import { clientRows } from "../../spawn/clients.ts";
13 import { clientRows, listed } from "../../spawn/clients.ts";
1414 import { reissueDoor, spawnDoor } from "../../spawn/door.ts";
1515 import { checkMove } from "../../spawn/move_check.ts";
1616 import { moveToCell } from "../../spawn/move_cell.ts";
1717 import { sizesView } from "../../spawn/sizes.ts";
1818 import { templatesView } from "../../spawn/templates.ts";
19 import { billDownload } from "./forge_bill.ts";
1920 import { crossOrigin, type Ctx, type Handler } from "../context.ts";
2021 import { holds, weakEtag } from "../etag.ts";
2122 import { apiError, body, forbidden, json, requireScope } from "../respond.ts";
@@ -50,6 +51,9 @@ export function forgeRoutes(router: Router<Handler>): void {
5051 router.add("GET", "/api/v1/forges/sizes", (ctx) => publicTable(ctx, "sizes", sizesView));
5152 router.add("GET", "/api/v1/forges/templates", (ctx) => publicTable(ctx, "templates", templatesView));
5253
54 // One month of a hosted forge's ledger and this forge's key, for its owner
55 // (`core/ledger_bill.ts`).
56 router.add("GET", "/api/v1/forges/:name/bill", billDownload);
5357 // The client list (docs/SPAWN.md): an admin's, the rows /forges and the MCP
5458 // `forges` tool show, what needs attention first.
5559 router.add("GET", "/api/v1/forges", async (ctx) => {
@@ -64,8 +68,8 @@ export function forgeRoutes(router: Router<Handler>): void {
6468 return rows.map((row) => {
6569 const c = kept.get(row.name);
6670 return c === undefined
67 ? row
68 : { ...row, bodyId: c.bodyId, done: c.done, createdAt: c.createdAt, placement: c.placement, placementNote: c.placementNote, ...(c.template === undefined ? {} : { template: c.template }) };
71 ? listed(row)
72 : { ...listed(row), bodyId: c.bodyId, done: c.done, createdAt: c.createdAt, placement: c.placement, placementNote: c.placementNote, ...(c.template === undefined ? {} : { template: c.template }) };
6973 });
7074 });
7175 return timePhase(ctx.request, "render", () => json({ items, next_cursor: null }));
src/http/mcp/tools/forges.ts +2 −2
@@ -3,7 +3,7 @@ import { CODE_ERRORS, makeCode, planCode, readCodes, revoke } from "../../../act
33 import { REFUSED_SHOWN, listRefusals, mayReadRefusals } from "../../../core/first_ask_refusals.ts";
44 import { publicOrigin } from "../../../core/public_origin.ts";
55 import { scopeAllows } from "../../../core/tokens.ts";
6 import { clientRows } from "../../../spawn/clients.ts";
6 import { clientRows, listed } from "../../../spawn/clients.ts";
77 import { archiveForge, archivedForges, restoreForge } from "../../../spawn/archive.ts";
88 import { CHILD_NAME } from "../../../spawn/placement.ts";
99 import { checkMove } from "../../../spawn/move_check.ts";
@@ -98,7 +98,7 @@ export const tool: Tool = {
9898 forges.length === 0
9999 ? "This forge has sent no forges yet."
100100 : `${forges.length} forge${forges.length === 1 ? "" : "s"} sent from here.`,
101 forges,
101 forges: forges.map(listed),
102102 });
103103 },
104104 };
src/http/route_marks.ts +1
@@ -209,6 +209,7 @@ export const ROUTES: Readonly<Record<string, Mark>> = {
209209 "/forges": "admin",
210210 "/forges/refused": "admin",
211211 "/forges/name/:name": "open",
212 "/forges/:name/bill": "open",
212213 "/forges/:name/claim-link": "admin",
213214 "/forges/:name/revoke-link": "admin",
214215 "/forges/:name/undo-link": "admin",
src/http/server.ts +2
@@ -300,6 +300,7 @@ import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/page
300300 import { familyPage } from "../web/pages/family.ts";
301301 import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeArchiveConfirm, forgeArchiveSubmit, forgeRestoreSubmit, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit,
302302 forgeOwnCodeSubmit, forgeSandboxSubmit, forgesPage } from "../web/pages/forges.ts";
303 import { billDownload } from "./api/forge_bill.ts";
303304 import { forgesRefusedPage } from "../web/pages/forges-refused.ts";
304305 import { forgeReachAllow, forgeReachLater, forgeReachRevoke } from "../web/pages/hosted-forges.ts";
305306 import { forgeItSubmit } from "../web/pages/forge-it.ts";
@@ -908,6 +909,7 @@ export function buildRouter(): Router<Handler> {
908909 router.add("POST", "/forges/:name/restore", forgeRestoreSubmit);
909910 router.add("GET", "/family", familyPage);
910911 router.add("GET", "/forges/name/:name", forgeName);
912 router.add("GET", "/forges/:name/bill", billDownload);
911913 router.add("POST", "/forges/:name/claim-link", forgeLinkSubmit);
912914 router.add("POST", "/forges/:name/revoke-link", forgeLinkRevoke);
913915 router.add("POST", "/forges/:name/undo-link", forgeLinkUndo);
src/spawn/clients.ts +13 −1
@@ -33,7 +33,7 @@ import { lastStop, stopSentence } from "./stopped.ts";
3333 import { forgeUse, keepBytes, type ForgeUse } from "./usage.ts";
3434 import { childPerson } from "./child_db.ts";
3535 import { readNotices } from "./archive.ts";
36 import { personReading } from "./person_activity.ts";
36 import { billTo, personReading } from "./person_activity.ts";
3737 import { archiveProposal, type ArchiveProposal } from "./archive_proposal.ts";
3838 import { appLinkLines, type AppLinkLine } from "./app_links.ts";
3939
@@ -65,6 +65,11 @@ export interface ClientRow {
6565 /** Who claimed it and when; null while unclaimed or unread. */
6666 readonly claimedBy?: string | null;
6767 readonly claimedAt?: string | null;
68 /**
69 * Who a bill is owed to (the claimant's address, lowercased); never sent out of the process:
70 * `listed` takes it off.
71 */
72 readonly billTo?: string | null;
6873 /**
6974 * What keeping it costs on disk, bytes (its data directory's last reading); null before the
7075 * first.
@@ -292,6 +297,7 @@ export async function clientRows(app: App, options: { probe?: boolean } = {}): P
292297 lastActivityAt: people[i]?.lastAt ?? null,
293298 claimedBy: people[i]?.claimedBy ?? null,
294299 claimedAt: people[i]?.claimedAt ?? null,
300 billTo: billTo(people[i]?.claimantEmail),
295301 noticedAt: notices[c.name] ?? null,
296302 keepBytes: keepBytes(childDirOf(app, c.bodyId)) ?? null,
297303 awake: childAwake(app, c.name),
@@ -621,3 +627,9 @@ function stoppedWords(app: App, c: { bodyId: string; name: string }): string | n
621627 const stop = lastStop(app, c.bodyId);
622628 return stop === undefined ? null : stopSentence(stop);
623629 }
630
631 /** A row as an API or a tool says it: without the address its bill is owed to. */
632 export function listed(r: ClientRow): Omit<ClientRow, "billTo"> {
633 const { billTo: _kept, ...rest } = r;
634 return rest;
635 }
src/spawn/person_activity.ts +6
@@ -64,3 +64,9 @@ export function readingOf(v: unknown): PersonReading | undefined {
6464 const s = (x: unknown): string | null => (typeof x === "string" && x !== "" ? x : null);
6565 return { lastAt: s(r["lastAt"]), claimedBy: s(r["claimedBy"]), claimedAt: s(r["claimedAt"]), claimantEmail: s(r["claimantEmail"]) };
6666 }
67
68 /**
69 * The address a bill is owed to (the claimant's, lowercased);
70 * null when nobody claimed or it is unsaid.
71 */
72 export const billTo = (email: string | null | undefined): string | null => (email === null || email === undefined || email === "" ? null : email.trim().toLowerCase());
src/tui/screens/bills.ts added +70
@@ -0,0 +1,70 @@
1 /**
2 * The monthly bills in the terminal (#12276): each forge this person claimed,
3 * what it cost this month and where to download the signed file. Gated as
4 * the page is: only the forge's claimant sees a forge here (`claimsBill`).
5 * The file itself is downloaded from the web door; a terminal cannot save it.
6 */
7
8 import { readBill } from "../../actions/forge_bill.ts";
9 import { verifyBill } from "../../core/ledger_bill.ts";
10 import { billNames } from "../../web/pages/bills.ts";
11 import type { Ctx, Screen } from "../app/app.ts";
12 import { binding } from "../app/keys.ts";
13 import { style } from "../term/style.ts";
14 import type { Session } from "../session.ts";
15 import { Scroll, runAsync } from "./common.ts";
16 import type { PaletteEntry } from "./entries.ts";
17
18 /** One line per forge: its name, month, entries and dollars, or why there is none. */
19 export async function billLines(session: Session, now = new Date()): Promise<string[]> {
20 const user = session.user;
21 const names = user === undefined ? [] : billNames(session.app, user);
22 if (user === undefined || names.length === 0) return ["You have not claimed a forge here."];
23 const lines: string[] = [];
24 for (const name of names) {
25 const read = await readBill(session.app, user, name, null, now);
26 if (!read.ok) {
27 lines.push(`${name}: this month's bill could not be made (${read.error === "too_large" ? "too large" : "not available"})`);
28 continue;
29 }
30 const v = verifyBill(read.value.text);
31 lines.push(v.ok ? `${name} ${read.value.month} ${v.entries} entries $${v.usd}` : `${name}: ${v.why}`);
32 lines.push(` download: /forges/${name}/bill`);
33 }
34 return lines;
35 }
36
37 export function billsScreen(session: Session, ctx: Ctx): Screen {
38 const scroll = new Scroll();
39 let lines: string[] = ["Reading…"];
40 runAsync(ctx, async () => {
41 lines = await billLines(session);
42 });
43 return {
44 title: "Your forges' bills",
45 keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")],
46 render(area, s) {
47 const c = ctx.theme.c;
48 const from = scroll.window(area.h, lines.length);
49 for (let i = 0; i < area.h; i++) {
50 const line = lines[from + i];
51 if (line !== undefined) s.text(area.x + 1, area.y + i, line, style(c.fg, c.bg), area.x + area.w);
52 }
53 },
54 key(e) {
55 if (e.key === "esc" || e.key === "q") {
56 ctx.pop();
57 return true;
58 }
59 return scroll.key(e, lines.length);
60 },
61 };
62 }
63
64 export const entry: PaletteEntry = {
65 id: "bills",
66 title: "Your forges' bills",
67 hint: "this month's cost of each forge you claimed",
68 offered: (session) => session.user !== undefined && session.user.kind === "user" && billNames(session.app, session.user).length > 0,
69 run: (session, ctx) => ctx.push(billsScreen(session, ctx)),
70 };
src/tui/screens/entries.ts +2
@@ -18,6 +18,7 @@ import { entry as appTemplates } from "./app_templates.ts";
1818 import { entry as dotfiles } from "./dotfiles.ts";
1919 import { entry as devices } from "./devices.ts";
2020 import { entry as bets } from "./bets.ts";
21 import { entry as bills } from "./bills.ts";
2122 import { entry as billing } from "./billing.ts";
2223 import { entry as lapsed } from "./lapsed.ts";
2324 import { entry as codes } from "./codes.ts";
@@ -119,6 +120,7 @@ export interface PaletteEntry {
119120
120121 /** In the order the palette offers them; an entry follows the ones it names in `after`. */
121122 export const ENTRIES: readonly PaletteEntry[] = [
123 bills,
122124 sentinel,
123125 boards,
124126 drift,
src/web/pages/account.ts +2 −1
@@ -83,6 +83,7 @@ import {
8383 } from "../ui.ts";
8484 import { STOPPED_ONE, myShells, stopIdleShells, stopShell, stoppedIdleSays } from "../../machines/shell.ts";
8585 import { DOTFILES_ERRORS, dotfilesRepoOf, setDotfilesRepo } from "../../machines/dotfiles.ts";
86 import { billsBlock } from "./bills.ts";
8687 import { everydayLine } from "./everyday.ts";
8788 import { passkeyJs } from "../passkey-client.ts";
8889 import { THEME_NAMES, THEMES, type ThemeName } from "../../design/themes.ts";
@@ -586,7 +587,7 @@ export function accountPage(ctx: Ctx): Response {
586587 ctx,
587588 "profile",
588589 "Settings",
589 html`${plan}${pair}${profile}${edge}${parent}${notify}`,
590 html`${plan}${pair}${profile}${edge}${parent}${notify}${billsBlock(ctx.app, user)}`,
590591 200,
591592 // The everyday form makes its account with a passkey (#1890).
592593 pair ? passkeyJs : undefined,
src/web/pages/bills.ts added +36
@@ -0,0 +1,36 @@
1 /**
2 * The monthly bill's links (#12276): one row for a forge, and a block of
3 * them on the settings page for the person who claimed those forges, admin
4 * or not. Read from the rows the last pass kept in memory; it reads no child.
5 */
6
7 import { claimsBill } from "../../actions/forge_bill.ts";
8 import type { User } from "../../core/users.ts";
9 import { servedNow } from "../../spawn/clients.ts";
10 import type { App } from "../../app.ts";
11 import { html, type Html } from "../html.ts";
12 import { row } from "../ui.ts";
13
14 /** The row that downloads this month's bill for a forge. */
15 export const billRow = (name: string): Html =>
16 row({
17 title: html`<a href="/forges/${name}/bill" download>Download this month's bill</a>`,
18 meta: html`What this forge cost this month, signed by this forge.`,
19 data: { bill: "" },
20 });
21
22 /** The forges this person claimed that are finished; their names. */
23 export const billNames = (app: App, user: User): string[] =>
24 servedNow(app)
25 .filter((r) => r.made && claimsBill(user, r.billTo))
26 .map((r) => r.name);
27
28 /** A block for the settings page; nothing when this person claimed no forge. */
29 export function billsBlock(app: App, user: User): Html | null {
30 const names = billNames(app, user);
31 if (names.length === 0) return null;
32 return html`<section class="settings-block" data-bills>
33 <h2>Your forges' bills</h2>
34 ${names.map((n) => html`<h3>${n}</h3>${billRow(n)}`)}
35 </section>`;
36 }
src/web/pages/forges.ts +6 −2
@@ -7,6 +7,8 @@
77 */
88
99 import type { Ctx } from "../../http/context.ts";
10 import { claimsBill } from "../../actions/forge_bill.ts";
11 import { billRow } from "./bills.ts";
1012 import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, memoryOf, servedRows, dropServed, creditWords, graceWords, runningWords, spendWords, monthWords, type ClientRow } from "../../spawn/clients.ts";
1113 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1214 import { notFoundPage, pageHead } from "../chrome.ts";
@@ -63,7 +65,7 @@ function spendNote(r: ClientRow): Html | null {
6365 : html`<span data-spend="ok">${s.words}</span>`;
6466 }
6567
66 export function forgeRow(r: ClientRow, now: number, reach?: ForgeReach): Html {
68 export function forgeRow(r: ClientRow, now: number, reach?: ForgeReach, bill = false): Html {
6769 const state = claimState(r);
6870 const meta = [
6971 r.for === null ? null : html`for ${r.for}`,
@@ -104,6 +106,8 @@ export function forgeRow(r: ClientRow, now: number, reach?: ForgeReach): Html {
104106 // What it asked to reach and what it may: answered here, first under its name.
105107 ...forgeReachRows(reach, `/forges#forge-${r.name}`),
106108 ...(firstScreenOf(r) === null ? [] : [row({ title: html`${firstScreenOf(r)}`, data: { "first-screen": "" } })]),
109 // The month's ledger and this forge's key, as one file, for the person who made it (#12276).
110 ...(bill && r.made ? [billRow(r.name)] : []),
107111 // Each app it was sent, and whether it still follows it: its owner may have severed.
108112 ...(r.appLinks ?? []).map((l) => row({ title: html`${appLinkWords(l)}`, data: { "app-link": l.state } })),
109113 ...(r.reach?.proofs ?? []).map((p) =>
@@ -247,7 +251,7 @@ export async function forgesPage(ctx: Ctx): Promise<Response> {
247251 ${btn("Archive selected", { size: "sm", title: "Pick forges with the boxes; the next page says what happens before anything does" })}
248252 </form>
249253 <ul class="list setting-list">
250 ${rows.map((r) => forgeRow(r, Date.now(), reachOf.get(r.name)))}
254 ${rows.map((r) => forgeRow(r, Date.now(), reachOf.get(r.name), claimsBill(user, r.billTo)))}
251255 </ul>
252256 </section>`}
253257 ${sealedLine(ctx)}
test/forge-bill.test.ts added +193
@@ -0,0 +1,193 @@
1 import { afterAll, beforeAll, expect, test } from "bun:test";
2 import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createRepo } from "../src/actions/repos.ts";
6 import { createApp } from "../src/app.ts";
7 import { dropServed, servedRows } from "../src/spawn/clients.ts";
8 import { GENESIS, hashOf } from "../src/core/ledger.ts";
9 import { makeBill, verifyBill } from "../src/core/ledger_bill.ts";
10 import { createSession } from "../src/core/sessions.ts";
11 import { createUser, getUserByName } from "../src/core/users.ts";
12 import { serve } from "../src/http/server.ts";
13 import { Session } from "../src/tui/session.ts";
14 import { billLines, entry } from "../src/tui/screens/bills.ts";
15 import { loadOrCreateHostKey } from "../src/ssh/keys.ts";
16
17 let dir = "";
18 let app: ReturnType<typeof createApp>;
19 let servers: Awaited<ReturnType<typeof serve>>;
20 let maker = "";
21 let claimant = "";
22 let stranger = "";
23 let pub = "";
24 let repoId = "";
25
26 function entries(n: number, repo = "x", from = GENESIS): string {
27 let prev = from;
28 let out = "";
29 for (let i = 0; i < n; i++) {
30 const body = { run: `r${i}`, kind: "check", repo, endedAt: "2026-11-05T10:00:00.000Z", usd_tokens: 1.25, prev };
31 prev = hashOf(body);
32 out += `${JSON.stringify({ ...body, hash: prev })}\n`;
33 }
34 return out;
35 }
36
37 async function person(name: string, admin: boolean): Promise<{ id: string; cookie: string }> {
38 const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase", isAdmin: admin });
39 if (!made.ok) throw new Error(made.error);
40 return { id: made.value.id, cookie: `forge_session=${createSession(app.db, made.value.id).token}` };
41 }
42
43 beforeAll(async () => {
44 dir = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), "forge-bill-"));
45 app = createApp({ dataDir: dir, port: 0 });
46 // The maker is a host admin; the claimant is an ordinary person; neither is the other.
47 const a = await person("alice", true);
48 const b = await person("bob", false);
49 const c = await person("carol", true);
50 maker = a.cookie;
51 claimant = b.cookie;
52 stranger = c.cookie;
53 await person("dora", true);
54 const repo = await createRepo(app, getUserByName(app.db, "dora")!, { name: "shop", visibility: "public" });
55 if (!repo.ok) throw new Error(String(repo.error));
56 repoId = repo.value.id;
57 await mkdir(join(dir, "spawn"), { recursive: true });
58 await writeFile(join(dir, "spawn", "acme.json"), JSON.stringify({ name: "acme", bodyId: "b1", done: true, ownerId: a.id, apps: ["dora/shop"] }));
59 // The child's own database: bob claimed it (its first person admin).
60 const child = createApp({ dataDir: join(dir, "children", "b1") });
61 const made = await createUser(child.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase", isAdmin: true });
62 if (!made.ok) throw new Error(made.error);
63 child.close();
64 await mkdir(join(dir, "children", "b1", "ledger"), { recursive: true });
65 await writeFile(join(dir, "children", "b1", "ledger", "2026-11.jsonl"), entries(3, "own"));
66 // The host's spool holds this child's work under its repository, and other forges' work between.
67 await mkdir(join(dir, "ledger"), { recursive: true });
68 await writeFile(join(dir, "ledger", "2026-11.jsonl"), entries(2, repoId) + entries(2, "someone-elses"));
69 pub = loadOrCreateHostKey(join(dir, "ssh")).publicKey.line;
70 servers = await serve(app);
71 await servedRows(app);
72 });
73
74 afterAll(async () => {
75 servers.stop();
76 app.close();
77 await rm(dir, { recursive: true, force: true });
78 });
79
80 const get = (path: string, cookie?: string) => fetch(`${servers.url}${path}`, { headers: cookie ? { cookie } : {} });
81
82 test("the claimant downloads the month with the host spool's entries, and it verifies against the forge's key", async () => {
83 const res = await get("/api/v1/forges/acme/bill?month=2026-11", claimant);
84 expect(res.status).toBe(200);
85 expect(res.headers.get("content-disposition")).toContain("acme-2026-11.bill.jsonl");
86 const text = await res.text();
87 // Three of its own and the two the spool holds under its repository; the other forge's two are
88 // not in it.
89 expect(verifyBill(text, pub)).toMatchObject({ ok: true, entries: 5, usd: 6.25 });
90 expect(text).not.toContain("someone-elses");
91 expect(text).toContain(repoId);
92 const page = await get("/forges/acme/bill?month=2026-11", claimant);
93 expect(await page.text()).toBe(text);
94 });
95
96 test("the person who made the child is not its claimant: they, a stranger and a signed-out caller get 404", async () => {
97 expect((await get("/api/v1/forges/acme/bill?month=2026-11", maker)).status).toBe(404);
98 expect((await get("/api/v1/forges/acme/bill?month=2026-11", stranger)).status).toBe(404);
99 expect((await get("/api/v1/forges/acme/bill?month=2026-11")).status).toBe(404);
100 expect((await get("/forges/acme/bill", stranger)).status).toBe(404);
101 expect((await get("/api/v1/forges/nope/bill", claimant)).status).toBe(404);
102 expect((await get("/api/v1/forges/acme/bill?month=nov", claimant)).status).toBe(422);
103 });
104
105 test("a non-admin claimant finds the link on their settings page; the maker and a stranger do not", async () => {
106 const mine = await (await get("/settings", claimant)).text();
107 expect(mine).toContain('href="/forges/acme/bill"');
108 expect(mine).not.toContain("ledger-verify");
109 expect(await (await get("/settings", maker)).text()).not.toContain("/forges/acme/bill");
110 expect(await (await get("/settings", stranger)).text()).not.toContain("/forges/acme/bill");
111 });
112
113 test("an admin who claimed it sees the link on /forges; an admin who did not does not", async () => {
114 // Carol claims a second child.
115 const carol = getUserByName(app.db, "carol")!;
116 await writeFile(join(dir, "spawn", "beta.json"), JSON.stringify({ name: "beta", bodyId: "b2", done: true, ownerId: carol.id }));
117 const child = createApp({ dataDir: join(dir, "children", "b2") });
118 const made = await createUser(child.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase", isAdmin: true });
119 if (!made.ok) throw new Error(made.error);
120 child.close();
121 dropServed(app);
122 await servedRows(app);
123 const hers = await (await get("/forges", stranger)).text();
124 expect(hers).toContain('href="/forges/beta/bill"');
125 expect(hers).not.toContain('href="/forges/acme/bill"');
126 const his = await (await get("/forges", maker)).text();
127 expect(his).not.toContain("/forges/beta/bill");
128 expect(his).not.toContain("/forges/acme/bill");
129 });
130
131 test("the client list never carries the address a bill is owed to", async () => {
132 const text = await (await get("/api/v1/forges", maker)).text();
133 expect(text).not.toContain("billTo");
134 expect(text).not.toContain("[email protected]");
135 });
136
137 test("a month too large for one bill is refused with 413, never cut", async () => {
138 await writeFile(join(dir, "children", "b1", "ledger", "2026-10.jsonl"), `${JSON.stringify({ run: "r", kind: "check", prev: "p", hash: "h", pad: "x".repeat(1024) })}\n`.repeat(4200));
139 expect((await get("/api/v1/forges/acme/bill?month=2026-10", claimant)).status).toBe(413);
140 });
141
142 test("a bill's first entry may continue the month before; entries picked from the spool are each checked on their own", () => {
143 const key = loadOrCreateHostKey(join(dir, "ssh"));
144 const continued = entries(2, "own", "a".repeat(64));
145 expect(verifyBill(makeBill({ forge: "acme", month: "2026-11", entries: continued, key }), pub)).toMatchObject({ ok: true, entries: 2 });
146 // Spool entries do not chain to each other here, but a changed one still fails at its line.
147 const spool = entries(1, "r1") + entries(1, "r1", "b".repeat(64));
148 const bill = makeBill({ forge: "acme", month: "2026-11", entries: entries(1, "own") + spool, own: 1, key });
149 expect(verifyBill(bill, pub)).toMatchObject({ ok: true, entries: 3 });
150 const lines = bill.split("\n");
151 lines[3] = lines[3]!.replace('"usd_tokens":1.25', '"usd_tokens":9');
152 expect(verifyBill(lines.join("\n"), pub)).toMatchObject({ ok: false, at: "line 4" });
153 // The count of its own entries is signed: moving it cannot hide a cut in the chain.
154 expect(verifyBill(bill.replace('"own":1', '"own":3'), pub)).toMatchObject({ ok: false });
155 });
156
157 test("one changed entry breaks the bill at its line; another key and another name break it too", async () => {
158 const key = loadOrCreateHostKey(join(dir, "ssh"));
159 const bill = makeBill({ forge: "acme", month: "2026-11", entries: entries(3), key });
160 expect(verifyBill(bill, pub).ok).toBe(true);
161 const lines = bill.split("\n");
162 lines[2] = lines[2]!.replace('"usd_tokens":1.25', '"usd_tokens":0.01');
163 expect(verifyBill(lines.join("\n"), pub)).toEqual({ ok: false, at: "line 3", why: "hash does not match the entry" });
164 const other = loadOrCreateHostKey(await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), "bill-key-")));
165 expect(verifyBill(bill, other.publicKey.line)).toMatchObject({ ok: false, at: "line 1" });
166 expect(verifyBill(bill.replace('"forge":"acme"', '"forge":"evil"'), pub)).toMatchObject({ ok: false, at: "line 1" });
167 const dropped = [lines[0], bill.split("\n")[1], bill.split("\n")[3], ""].join("\n");
168 expect(verifyBill(dropped, pub)).toMatchObject({ ok: false, at: "line 3" });
169 });
170
171 test("the script prints ok N entries $X, or the first broken line", async () => {
172 const good = join(dir, "good.bill");
173 const bad = join(dir, "bad.bill");
174 const bill = makeBill({ forge: "acme", month: "2026-11", entries: entries(2), key: loadOrCreateHostKey(join(dir, "ssh")) });
175 await writeFile(good, bill);
176 await writeFile(bad, bill.replace('"run":"r1"', '"run":"r9"'));
177 const run = (file: string) => Bun.spawnSync(["bun", "scripts/ledger-verify.ts", "--key", pub, file], { stdout: "pipe" });
178 const ok = run(good);
179 expect(ok.stdout.toString().trim()).toBe("ok 2 entries $2.5");
180 const broken = run(bad);
181 expect(broken.exitCode).toBe(1);
182 expect(broken.stdout.toString()).toContain("broken at line 3");
183 });
184
185 test("the terminal's bills screen lists the claimant's forge and offers nothing to the maker", async () => {
186 const as = (name: string) => new Session(app, { user: getUserByName(app.db, name)!, sshUsername: name, remoteAddress: "127.0.0.1" });
187 const lines = await billLines(as("bob"), new Date("2026-11-20T00:00:00Z"));
188 expect(lines[0]).toBe("acme 2026-11 5 entries $6.25");
189 expect(lines[1]).toContain("/forges/acme/bill");
190 expect(entry.offered(as("bob"))).toBe(true);
191 expect(entry.offered(as("alice"))).toBe(false);
192 expect(await billLines(as("alice"))).toEqual(["You have not claimed a forge here."]);
193 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs