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

A hosted forge's owner can now download one month of that forge's ledger as a signed bill. `bun scripts/ledger-verify.ts --key <pub> <bill>` checks it with no database. The new test file, `gate.ts`, `tsc`, `design-lint` and the route, OpenAPI, ledger and forges tests pass here. I did not run the full suite. A bill covers only the ledger in the child's own data directory, so a hosted child's runs t…

shipped production is serving it deploy 71ce7cd

opened this

A hosted forge's owner can now download one month of that forge's ledger as a signed bill. bun scripts/ledger-verify.ts --key <pub> <bill> checks it with no database. The new test file, gate.ts, tsc, design-lint and the route, OpenAPI, ledger and forges tests pass here. I did not run the full suite. A bill covers only the ledger in the child's own data directory, so a hosted child's runs that land in the host's spool are missing from it (see "Left out").

What changed

  • GET /api/v1/forges/:name/bill?month=YYYY-MM and GET /forges/:name/bill return the bill. The month defaults to the current one.
  • The owner's row on /forges has a "Download this month's bill" link. Only the owner sees it.
  • Format: line 1 is a JSON header {bill, forge, month, key, signature}; every later line is one ledger entry exactly as the spool holds it.
  • Signature: the forge signs the forge name, the month and the entry lines with its host key (SSHSIG, namespace forge-bill). The code is in src/core/ledger_bill.ts; the access rule is in src/actions/forge_bill.ts.
  • Access: only the person who made the child (ownerId) gets a bill. A stranger, a signed-out caller, an unknown forge or an unfinished child all get the same 404. A bad month gets 422.
  • Verifier: bun scripts/ledger-verify.ts --key "<ssh-ed25519 line>" <bill> checks the chain inside the month and the signature against the pinned key. It prints ok N entries $X, where X is the sum of usd_tokens, or broken at line N: <why> and exits 1.
  • Tests: test/forge-bill.test.ts covers

- the owner's download verifying; - a stranger and a signed-out caller getting 404; - a changed entry failing at its line; - another key, a renamed forge and a dropped entry failing; - the script's output; - the link showing only to the owner.

  • I added the route to route_marks.ts, an OpenAPI route file and its index line, and a section in docs/features/cost-ledger.md.

Left out

  • Where the entries come from: a bill reads <child data>/ledger/<month>.jsonl. A hosted child's runs that land in the host's own spool are not yet picked out by repository id, so they are missing from its bill. I did not do that filtering and said so in the docs.
  • First entry's prev: it is not checked against the previous month, because the bill holds a single month.
  • Signing at download time: the bill is signed when it is downloaded, with the host key; it does not use #12113's git commits. Verification needs only the public key.
  • No terminal screen or MCP tool: this is a file download, not a new page.
  • Owner rule: "claimed owner" is taken as the person on the host who made the child. A host admin who did not make it gets 404.

Try it: sign in as the person who made a forge, open /forges and download the bill. Then run bun scripts/ledger-verify.ts --key "$(cat <data>/ssh/host_ed25519.pub)" <file>.

Closes #12276

4 earlier comments · 1 event
commented

A hosted forge's owner can now download one month of that forge's ledger as a signed bill. bun scripts/ledger-verify.ts --key <pub> <bill> checks it with no database. The new test file, gate.ts, tsc, design-lint and the route, OpenAPI, ledger and forges tests pass here. I did not run the full suite. A bill covers only the ledger in the child's own data directory, so a hosted child's runs that land in the host's spool are missing from it (see "Left out").

What changed

  • GET /api/v1/forges/:name/bill?month=YYYY-MM and GET /forges/:name/bill return the bill. The month defaults to the current one.
  • The owner's row on /forges has a "Download this month's bill" link. Only the owner sees it.
  • Format: line 1 is a JSON header {bill, forge, month, key, signature}; every later line is one ledger entry exactly as the spool holds it.
  • Signature: the forge signs the forge name, the month and the entry lines with its host key (SSHSIG, namespace forge-bill). The code is in src/core/ledger_bill.ts; the access rule is in src/actions/forge_bill.ts.
  • Access: only the person who made the child (ownerId) gets a bill. A stranger, a signed-out caller, an unknown forge or an unfinished child all get the same 404. A bad month gets 422.
  • Verifier: bun scripts/ledger-verify.ts --key "<ssh-ed25519 line>" <bill> checks the chain inside the month and the signature against the pinned key. It prints ok N entries $X, where X is the sum of usd_tokens, or broken at line N: <why> and exits 1.
  • Tests: test/forge-bill.test.ts covers

- the owner's download verifying; - a stranger and a signed-out caller getting 404; - a changed entry failing at its line; - another key, a renamed forge and a dropped entry failing; - the script's output; - the link showing only to the owner.

  • I added the route to route_marks.ts, an OpenAPI route file and its index line, and a section in docs/features/cost-ledger.md.

Left out

  • Where the entries come from: a bill reads <child data>/ledger/<month>.jsonl. A hosted child's runs that land in the host's own spool are not yet picked out by repository id, so they are missing from its bill. I did not do that filtering and said so in the docs.
  • First entry's prev: it is not checked against the previous month, because the bill holds a single month.
  • Signing at download time: the bill is signed when it is downloaded, with the host key; it does not use #12113's git commits. Verification needs only the public key.
  • No terminal screen or MCP tool: this is a file download, not a new page.
  • Owner rule: "claimed owner" is taken as the person on the host who made the child. A host admin who did not make it gets 404.

Try it: sign in as the person who made a forge, open /forges and download the bill. Then run bun scripts/ledger-verify.ts --key "$(cat <data>/ssh/host_ed25519.pub)" <file>.

commented

This fixes the failed check on the bill download: the gate is now green locally.

  • Comment fix: the bill route's comment in src/http/api/forges.ts had landed in the middle of the client-list comment and ran 102 columns. I rewrapped it under 100 and put each comment back above its own route.
  • Looks rule: the same check log also flagged that src/web/pages/forges.ts changed with no Looks: line. /forges isn't in app.looks, so I added a No look: line with the reason to the fix commit's message instead of adding a new page to the list.
  • Not run: the tests. Locally I ran bun scripts/gate.ts and bun x tsc --noEmit, both clean; only a comment moved, so I left the full check to the forge.
  • Try it: sign in as the person who made a forge, open /forges and download "this month's bill" (or GET /api/v1/forges/<name>/bill?month=YYYY-MM). Then run bun scripts/ledger-verify.ts --key <host_ed25519.pub line> <file>.
commented

I merged main into the branch and pushed; the one conflict, in src/web/pages/forges.ts, is resolved.

Both sides had edited the start of the /forges handler. I kept both. From main, the ?sort= handling (sortForges) and archivedForges. From my side, the mine set of forges the signed-in person created, which decides who sees the bill link.

The gate and bun x tsc --noEmit are clean after the merge. scripts/fast.ts called the diff too broad to name tests because scripts/gate.ts changed on main, so I picked tests myself. I ran test/forge-bill.test.ts and the test/forges-*.test.ts files: 45 pass, 2 skip (no Chromium here for the phone-layout check), 0 fail.

An earlier test command of mine used a wrong directory name, so it ran the whole suite in the background. I couldn't stop it from here; the forge stops it when I finish. Its output isn't part of these results.

Try it: sign in as the person who made a forge, open /forges and download "this month's bill" (or GET /api/v1/forges/<name>/bill?month=YYYY-MM). Then run bun scripts/ledger-verify.ts --key <host_ed25519.pub line> <file>.

Changes requested — before the latest push

Changes needed, in one round:

  1. FIRST: the bill is incomplete. It reads only the child's own directory ledger, so the runs the child's work lands in the host's spool are missing. A bill that omits what the client is charged for is wrong, and box 1 asks for that month's entries. Pick the child's entries out of the host spool by repository and include them, with a test.
  2. The owner is the person who made the child (ownerId), not the claimed owner the issue names. Use the claim, and test that the claimer gets the bill and the maker does not if they differ.
  3. The page door is admin-only (/forges), so a non-admin owner has no link. Put the download where a client reaches it, and test a non-admin owner.
  4. No terminal door: add a src/tui/ screen or entry for the bill, per AGENTS.md, gated as the page is.
  5. Design: the row's meta puts a script path and flag on the page; say it in plain words (docs/WRITING.md, principle 8), and keep the how-to in docs/features/cost-ledger.md.
  6. forgesPage now lists every child on each load for one link; derive ownership from the rows already loaded. The bill read is unbounded in size; cap it.
  7. Tests: add the non-admin owner, the host-spool entries, and the first-entry prev behaviour.
commented

A hosted forge's claimant can now download a signed bill for one month, and it includes the runs the host spool holds for that forge. I did not run the full suite. bun scripts/gate.ts, bun x tsc --noEmit, bun scripts/design-lint.ts and 29 related test files pass here, including the new tests in test/forge-bill.test.ts.

What changed, point by point

  1. Host spool. The bill is the child's own ledger followed by the lines in the host's ledger/<month>.jsonl whose repo is one of the host repositories the child follows (child.apps). The header is now {bill, forge, month, own, key, signature}, where own counts the child's own entries. The signature covers own, so the count cannot be changed.

- The child's own entries must chain. Spool entries are picked out of a chain shared with other forges, so the verifier checks each one's hash on its own and the signature vouches for the set. - A test shows the other forges' entries are left out.

  1. Owner is the claimant. "Claimed owner" is the first person admin in the child's own database, matched to the caller by lowercased email on both accounts (claimsBill in src/actions/forge_bill.ts). The maker no longer gets the bill. A test covers a claimant who differs from the maker: the claimant gets the file and the maker gets 404.
  2. Where a client reaches it. The bill link is a block on /settings, which any signed-in account sees, in src/web/pages/bills.ts. It appears only for forges that account claimed. An admin who claimed a forge also sees it on that forge's /forges row. Tests cover a non-admin claimant and an admin on /forges.
  3. Terminal door. src/tui/screens/bills.ts is a new "Your forges' bills" palette entry. It is offered only to a claimant and shows each forge's month entries and total, plus the download path. It cannot save the file; the download stays on the web.
  4. Wording. The row now reads "What this forge cost this month, signed by this forge." The ledger-verify how-to is only in docs/features/cost-ledger.md.
  5. Row data and size cap.

- /forges no longer calls listChildren for the link. Ownership comes from rows already loaded, using a new billTo field on ClientRow. It is the claimant's address, so listed() strips it from /api/v1/forges and the MCP forges tool. A test checks the API output carries neither the field nor the address. - Files are read in chunks, and a month over 4 MiB of entries returns 413 rather than being cut.

  1. Tests. Non-admin claimant, the host-spool entries, the first-entry prev behaviour, the 413 cap, and the terminal gate.

Left out or to look at

  • The first entry's prev is still not checked, because a bill holds one month. A test now states this.
  • The claimant match trusts the email on both accounts. If a host lets someone register with an address they have not verified, they could match a claimant. I did not add a verified-email requirement.
  • /settings and the terminal entry read the rows the last pass kept in memory, so a forge claimed within the last minute may not show there yet.
  • The route and OpenAPI entries are unchanged apart from the 413 answer.

Try it Sign in as the account whose email is the child's first admin. Open /settings and use "Download this month's bill". Then run bun scripts/ledger-verify.ts --key "<host_ed25519.pub>" acme-2026-11.bill.jsonl.

commented

I merged main into this branch and pushed it. The one conflict was in src/web/pages/forges.ts, in the ../../spawn/clients.ts import block. I kept this branch's claimsBill and billRow imports and main's new monthWords import.

The merge also tripped two gate checks, both caused by my own earlier work. I fixed them in a second commit:

  • Comment width: four comment lines were over 100 columns. They are in src/actions/forge_bill.ts, src/spawn/clients.ts, src/spawn/person_activity.ts and test/forge-bill.test.ts.
  • Both-doors rule: the gate now wants a docs/features/<name>.md how-to for each new page, and src/web/pages/bills.ts had none. I added docs/features/forge-bill.md, which covers the web link, the API and MCP tools, and the terminal bills screen.

bun scripts/gate.ts and bun x tsc --noEmit are clean after the merge, and bun scripts/design-lint.ts passed on the merge commit. I ran 88 tests across 13 files and all passed. That covers the bill, the forges-page suites, the cost ledger, the TUI forges screen and the account rows.

scripts/fast.ts could not name the tests, because the merge changed scripts/ledger-verify.ts and the diff is too broad to bound. The forge's check runs the full suite on this push.

Approved

All four earlier asks are met: the owner is the claimant (first person admin in the child's db, matched by email; the maker gets 404, tested), host-spool entries are included and the read is chunked and capped at 4 MiB with 413, the link is on /settings for non-admins with plain wording, and /forges uses already-loaded rows with billTo stripped from API and MCP output. The terminal screen, feature doc and tests are in. Non-blocking: the claimant match trusts unverified emails on both sides, so require a verified email if the host allows unverified sign-up; docs/features/forge-bill.md says the MCP forge tools serve the file, but they only list forges, so drop that clause.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs