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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12276-a-client-downloads-their-month-s-ledger → main 6 commits fork 4 threads · 4 unresolved
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 | ||
| 44 | 44 | bun scripts/ledger-verify.ts --git ledger-clone --key "ssh-ed25519 AAAA…" |
| 45 | 45 | |
| 46 | 46 | 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 @@ | ||
| 4 | 4 | * the same walk over the `ledger` branch of a clone, plus the signature of |
| 5 | 5 | * every commit on it; no database. `--key` pins the signer; without it the |
| 6 | 6 | * 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. | |
| 7 | 10 | */ |
| 11 | import { readFileSync } from "node:fs"; | |
| 8 | 12 | import { join } from "node:path"; |
| 9 | 13 | import { verifyDir, verifyFiles } from "../src/core/ledger.ts"; |
| 14 | import { verifyBill } from "../src/core/ledger_bill.ts"; | |
| 10 | 15 | import { checkMessage, splitCommit } from "../src/ssh/sshsig.ts"; |
| 11 | 16 | |
| 12 | 17 | function git(repo: string, args: string[]): string | undefined { |
| @@ -46,7 +51,22 @@ if (import.meta.main) { | ||
| 46 | 51 | return i === -1 ? undefined : args[i + 1]; |
| 47 | 52 | }; |
| 48 | 53 | 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) { | |
| 50 | 70 | const verdict = verifyGit(repo, flag("--branch") ?? "ledger", flag("--key")); |
| 51 | 71 | if (verdict.ok) console.log(`ok ${verdict.entries} entries, ${verdict.commits} signed commits, key ${verdict.key}`); |
| 52 | 72 | 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, | |
| 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"; | ||
| 98 | 98 | export { route as getApiV1ForgeEconomics } from "./getApiV1ForgeEconomics.ts"; |
| 99 | 99 | export { route as getApiV1ForgeView } from "./getApiV1ForgeView.ts"; |
| 100 | 100 | export { route as getApiV1Forges } from "./getApiV1Forges.ts"; |
| 101 | export { route as getApiV1ForgesNameBill } from "./getApiV1ForgesNameBill.ts"; | |
| 101 | 102 | export { route as getApiV1ForgesNamePlan } from "./getApiV1ForgesNamePlan.ts"; |
| 102 | 103 | export { route as getApiV1ForgesNameSandbox } from "./getApiV1ForgesNameSandbox.ts"; |
| 103 | 104 | 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"; | ||
| 10 | 10 | import { usdWords } from "../../spawn/credit.ts"; |
| 11 | 11 | import { fundingOf, fundingWords, refreshFunding } from "../../spawn/funding.ts"; |
| 12 | 12 | 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"; | |
| 14 | 14 | import { reissueDoor, spawnDoor } from "../../spawn/door.ts"; |
| 15 | 15 | import { checkMove } from "../../spawn/move_check.ts"; |
| 16 | 16 | import { moveToCell } from "../../spawn/move_cell.ts"; |
| 17 | 17 | import { sizesView } from "../../spawn/sizes.ts"; |
| 18 | 18 | import { templatesView } from "../../spawn/templates.ts"; |
| 19 | import { billDownload } from "./forge_bill.ts"; | |
| 19 | 20 | import { crossOrigin, type Ctx, type Handler } from "../context.ts"; |
| 20 | 21 | import { holds, weakEtag } from "../etag.ts"; |
| 21 | 22 | import { apiError, body, forbidden, json, requireScope } from "../respond.ts"; |
| @@ -50,6 +51,9 @@ export function forgeRoutes(router: Router<Handler>): void { | ||
| 50 | 51 | router.add("GET", "/api/v1/forges/sizes", (ctx) => publicTable(ctx, "sizes", sizesView)); |
| 51 | 52 | router.add("GET", "/api/v1/forges/templates", (ctx) => publicTable(ctx, "templates", templatesView)); |
| 52 | 53 | |
| 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); | |
| 53 | 57 | // The client list (docs/SPAWN.md): an admin's, the rows /forges and the MCP |
| 54 | 58 | // `forges` tool show, what needs attention first. |
| 55 | 59 | router.add("GET", "/api/v1/forges", async (ctx) => { |
| @@ -64,8 +68,8 @@ export function forgeRoutes(router: Router<Handler>): void { | ||
| 64 | 68 | return rows.map((row) => { |
| 65 | 69 | const c = kept.get(row.name); |
| 66 | 70 | 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 }) }; | |
| 69 | 73 | }); |
| 70 | 74 | }); |
| 71 | 75 | 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 | ||
| 3 | 3 | import { REFUSED_SHOWN, listRefusals, mayReadRefusals } from "../../../core/first_ask_refusals.ts"; |
| 4 | 4 | import { publicOrigin } from "../../../core/public_origin.ts"; |
| 5 | 5 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 6 | import { clientRows } from "../../../spawn/clients.ts"; | |
| 6 | import { clientRows, listed } from "../../../spawn/clients.ts"; | |
| 7 | 7 | import { archiveForge, archivedForges, restoreForge } from "../../../spawn/archive.ts"; |
| 8 | 8 | import { CHILD_NAME } from "../../../spawn/placement.ts"; |
| 9 | 9 | import { checkMove } from "../../../spawn/move_check.ts"; |
| @@ -98,7 +98,7 @@ export const tool: Tool = { | ||
| 98 | 98 | forges.length === 0 |
| 99 | 99 | ? "This forge has sent no forges yet." |
| 100 | 100 | : `${forges.length} forge${forges.length === 1 ? "" : "s"} sent from here.`, |
| 101 | forges, | |
| 101 | forges: forges.map(listed), | |
| 102 | 102 | }); |
| 103 | 103 | }, |
| 104 | 104 | }; |
src/http/route_marks.ts +1
| @@ -209,6 +209,7 @@ export const ROUTES: Readonly<Record<string, Mark>> = { | ||
| 209 | 209 | "/forges": "admin", |
| 210 | 210 | "/forges/refused": "admin", |
| 211 | 211 | "/forges/name/:name": "open", |
| 212 | "/forges/:name/bill": "open", | |
| 212 | 213 | "/forges/:name/claim-link": "admin", |
| 213 | 214 | "/forges/:name/revoke-link": "admin", |
| 214 | 215 | "/forges/:name/undo-link": "admin", |
src/http/server.ts +2
| @@ -300,6 +300,7 @@ import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/page | ||
| 300 | 300 | import { familyPage } from "../web/pages/family.ts"; |
| 301 | 301 | import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeArchiveConfirm, forgeArchiveSubmit, forgeRestoreSubmit, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit, |
| 302 | 302 | forgeOwnCodeSubmit, forgeSandboxSubmit, forgesPage } from "../web/pages/forges.ts"; |
| 303 | import { billDownload } from "./api/forge_bill.ts"; | |
| 303 | 304 | import { forgesRefusedPage } from "../web/pages/forges-refused.ts"; |
| 304 | 305 | import { forgeReachAllow, forgeReachLater, forgeReachRevoke } from "../web/pages/hosted-forges.ts"; |
| 305 | 306 | import { forgeItSubmit } from "../web/pages/forge-it.ts"; |
| @@ -908,6 +909,7 @@ export function buildRouter(): Router<Handler> { | ||
| 908 | 909 | router.add("POST", "/forges/:name/restore", forgeRestoreSubmit); |
| 909 | 910 | router.add("GET", "/family", familyPage); |
| 910 | 911 | router.add("GET", "/forges/name/:name", forgeName); |
| 912 | router.add("GET", "/forges/:name/bill", billDownload); | |
| 911 | 913 | router.add("POST", "/forges/:name/claim-link", forgeLinkSubmit); |
| 912 | 914 | router.add("POST", "/forges/:name/revoke-link", forgeLinkRevoke); |
| 913 | 915 | router.add("POST", "/forges/:name/undo-link", forgeLinkUndo); |
src/spawn/clients.ts +13 −1
| @@ -33,7 +33,7 @@ import { lastStop, stopSentence } from "./stopped.ts"; | ||
| 33 | 33 | import { forgeUse, keepBytes, type ForgeUse } from "./usage.ts"; |
| 34 | 34 | import { childPerson } from "./child_db.ts"; |
| 35 | 35 | import { readNotices } from "./archive.ts"; |
| 36 | import { personReading } from "./person_activity.ts"; | |
| 36 | import { billTo, personReading } from "./person_activity.ts"; | |
| 37 | 37 | import { archiveProposal, type ArchiveProposal } from "./archive_proposal.ts"; |
| 38 | 38 | import { appLinkLines, type AppLinkLine } from "./app_links.ts"; |
| 39 | 39 | |
| @@ -65,6 +65,11 @@ export interface ClientRow { | ||
| 65 | 65 | /** Who claimed it and when; null while unclaimed or unread. */ |
| 66 | 66 | readonly claimedBy?: string | null; |
| 67 | 67 | 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; | |
| 68 | 73 | /** |
| 69 | 74 | * What keeping it costs on disk, bytes (its data directory's last reading); null before the |
| 70 | 75 | * first. |
| @@ -292,6 +297,7 @@ export async function clientRows(app: App, options: { probe?: boolean } = {}): P | ||
| 292 | 297 | lastActivityAt: people[i]?.lastAt ?? null, |
| 293 | 298 | claimedBy: people[i]?.claimedBy ?? null, |
| 294 | 299 | claimedAt: people[i]?.claimedAt ?? null, |
| 300 | billTo: billTo(people[i]?.claimantEmail), | |
| 295 | 301 | noticedAt: notices[c.name] ?? null, |
| 296 | 302 | keepBytes: keepBytes(childDirOf(app, c.bodyId)) ?? null, |
| 297 | 303 | awake: childAwake(app, c.name), |
| @@ -621,3 +627,9 @@ function stoppedWords(app: App, c: { bodyId: string; name: string }): string | n | ||
| 621 | 627 | const stop = lastStop(app, c.bodyId); |
| 622 | 628 | return stop === undefined ? null : stopSentence(stop); |
| 623 | 629 | } |
| 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 { | ||
| 64 | 64 | const s = (x: unknown): string | null => (typeof x === "string" && x !== "" ? x : null); |
| 65 | 65 | return { lastAt: s(r["lastAt"]), claimedBy: s(r["claimedBy"]), claimedAt: s(r["claimedAt"]), claimantEmail: s(r["claimantEmail"]) }; |
| 66 | 66 | } |
| 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"; | ||
| 18 | 18 | import { entry as dotfiles } from "./dotfiles.ts"; |
| 19 | 19 | import { entry as devices } from "./devices.ts"; |
| 20 | 20 | import { entry as bets } from "./bets.ts"; |
| 21 | import { entry as bills } from "./bills.ts"; | |
| 21 | 22 | import { entry as billing } from "./billing.ts"; |
| 22 | 23 | import { entry as lapsed } from "./lapsed.ts"; |
| 23 | 24 | import { entry as codes } from "./codes.ts"; |
| @@ -119,6 +120,7 @@ export interface PaletteEntry { | ||
| 119 | 120 | |
| 120 | 121 | /** In the order the palette offers them; an entry follows the ones it names in `after`. */ |
| 121 | 122 | export const ENTRIES: readonly PaletteEntry[] = [ |
| 123 | bills, | |
| 122 | 124 | sentinel, |
| 123 | 125 | boards, |
| 124 | 126 | drift, |
src/web/pages/account.ts +2 −1
| @@ -83,6 +83,7 @@ import { | ||
| 83 | 83 | } from "../ui.ts"; |
| 84 | 84 | import { STOPPED_ONE, myShells, stopIdleShells, stopShell, stoppedIdleSays } from "../../machines/shell.ts"; |
| 85 | 85 | import { DOTFILES_ERRORS, dotfilesRepoOf, setDotfilesRepo } from "../../machines/dotfiles.ts"; |
| 86 | import { billsBlock } from "./bills.ts"; | |
| 86 | 87 | import { everydayLine } from "./everyday.ts"; |
| 87 | 88 | import { passkeyJs } from "../passkey-client.ts"; |
| 88 | 89 | import { THEME_NAMES, THEMES, type ThemeName } from "../../design/themes.ts"; |
| @@ -586,7 +587,7 @@ export function accountPage(ctx: Ctx): Response { | ||
| 586 | 587 | ctx, |
| 587 | 588 | "profile", |
| 588 | 589 | "Settings", |
| 589 | html`${plan}${pair}${profile}${edge}${parent}${notify}`, | |
| 590 | html`${plan}${pair}${profile}${edge}${parent}${notify}${billsBlock(ctx.app, user)}`, | |
| 590 | 591 | 200, |
| 591 | 592 | // The everyday form makes its account with a passkey (#1890). |
| 592 | 593 | 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 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Ctx } from "../../http/context.ts"; |
| 10 | import { claimsBill } from "../../actions/forge_bill.ts"; | |
| 11 | import { billRow } from "./bills.ts"; | |
| 10 | 12 | import { ATTENTION_WORDS, firstScreenOf, CLAIM_WORDS, claimState, memoryOf, servedRows, dropServed, creditWords, graceWords, runningWords, spendWords, monthWords, type ClientRow } from "../../spawn/clients.ts"; |
| 11 | 13 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 12 | 14 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| @@ -63,7 +65,7 @@ function spendNote(r: ClientRow): Html | null { | ||
| 63 | 65 | : html`<span data-spend="ok">${s.words}</span>`; |
| 64 | 66 | } |
| 65 | 67 | |
| 66 | export function forgeRow(r: ClientRow, now: number, reach?: ForgeReach): Html { | |
| 68 | export function forgeRow(r: ClientRow, now: number, reach?: ForgeReach, bill = false): Html { | |
| 67 | 69 | const state = claimState(r); |
| 68 | 70 | const meta = [ |
| 69 | 71 | r.for === null ? null : html`for ${r.for}`, |
| @@ -104,6 +106,8 @@ export function forgeRow(r: ClientRow, now: number, reach?: ForgeReach): Html { | ||
| 104 | 106 | // What it asked to reach and what it may: answered here, first under its name. |
| 105 | 107 | ...forgeReachRows(reach, `/forges#forge-${r.name}`), |
| 106 | 108 | ...(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)] : []), | |
| 107 | 111 | // Each app it was sent, and whether it still follows it: its owner may have severed. |
| 108 | 112 | ...(r.appLinks ?? []).map((l) => row({ title: html`${appLinkWords(l)}`, data: { "app-link": l.state } })), |
| 109 | 113 | ...(r.reach?.proofs ?? []).map((p) => |
| @@ -247,7 +251,7 @@ export async function forgesPage(ctx: Ctx): Promise<Response> { | ||
| 247 | 251 | ${btn("Archive selected", { size: "sm", title: "Pick forges with the boxes; the next page says what happens before anything does" })} |
| 248 | 252 | </form> |
| 249 | 253 | <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)))} | |
| 251 | 255 | </ul> |
| 252 | 256 | </section>`} |
| 253 | 257 | ${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 | }); |
new above
offline
Owner is ownerId (the maker), not the claimed owner the issue asks for. Check the claim.