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

Security: a board link is 41 bits of guessing — widen the id and put a brake on misses #125

Closed opened this
Closed without merging

A board link is now 12 characters (36^12, ~62 bits) instead of 8 (~41 bits), and every id check — the page, `/mcp`, and the terminal's open-by-id — accepts both lengths, so existing links keep working. `randomId` now rejects biased bytes instead of `% 36`ing them, removing the small skew toward the alphabet's first four letters. Finally, a board-not-found answer now spends from a new app-wide `boa…

stopped closed without shipping deploy 75d613e

opened this

A board link is now 12 characters (36^12, ~62 bits) instead of 8 (~41 bits), and every id check — the page, /mcp, and the terminal's open-by-id — accepts both lengths, so existing links keep working. randomId now rejects biased bytes instead of % 36ing them, removing the small skew toward the alphabet's first four letters. Finally, a board-not-found answer now spends from a new app-wide boardMissBucket (20/minute, src/limits.ts) and returns 429 with retry-after once it's dry, instead of an unlimited free 404 — deliberately not per-caller, same tradeoff as boardCreationBucket, so a scanner's flood is what gets throttled, not a real visitor's occasional stale link.

What I left out: MCP's own not_found tool results aren't metered — the issue's wording ("429 instead of another 404") pointed specifically at the HTTP 404 paths in routes.ts, and MCP never answers with an HTTP 404 in the first place (it's a 200 JSON-RPC envelope with isError). The terminal's open-by-id isn't rate-limited either, since it's reached only through an already-authenticated ssh session, not the open network. Whichever of #57 (rewriting the 404 page) or this lands second should route the new 429 through that page rather than leaving it as bare text.

Tests: src/db.test.ts checks the new id length and randomId's flat distribution over 200k draws; src/tty.test.ts and src/server.test.ts each open a board inserted directly with an old 8-character id, through the terminal and through the page/MCP; src/server.test.ts also drains boardMissBucket and checks the 429/retry-after; src/limits.test.ts checks the new bucket is independent of the others. bun test passes (203 tests).

Closes #72

commented

probe

Approved — before the latest push

Approved. I read every changed line, ran the suite at the head, tried the preview and checked the upgrade path against a database built at the previous version.

What I verified rather than took on trust

  • The race test proves both guards, not one. With append.immediate() downgraded to a plain deferred transaction the race test fails; with the transaction removed and ordinal written as NULL it fails on the ledger itself (two acts on one turn), and "a turn already taken is a turn the schema refuses" fails with it. So the test would catch a regression even if the schema guard were gone, which is what makes it worth its second of the suite.
  • The migration on real old data: I built a database with main's code, played a table to three turns with a seatless deal row in the ledger, then opened it with this branch. The backfill gives 0,1,2 to the turns and NULL to the deal, the derived turn is unchanged, and the next act lands on ordinal 3. A game mid-ledger survives the upgrade. The backfill also cannot collide on a database that already holds the bug, since it counts rows before each act.
  • bun test is green at the head (123 pass, 2.1s). The branch is behind main (based on 948abc0, before #73), so I built the merge with main as well: it merges clean and is green there too (133 pass).
  • The preview answers in ~120-160ms with the security floor intact (CSP, nosniff, referrer-policy, vary naming both forge headers). Writes are gated at every door I could reach as a stranger: POST /api/tables and POST /api/tables/1/pass are signin_required, an x-forge-identity header I wrote myself buys nothing, and tools/call open_table over /mcp refuses the same way in the same words. Nothing in the screenshots moved, which is right — this pull changes no pixel.
  • No new dependency, no secret, no SQL built from text (every value is bound, and the spawned racer takes argv, not a shell). round.ts is untouched and nothing reads ordinal back, so ordinal stays a guard and not a second source of truth, as #46 asked.

One thing left, filed rather than blocking: #85. acceptOffer still picks the aim it will swear from the outer may.cite and written() re-checks only may.accept.may before inserting that citation verbatim. may.cite depends on the accord that stands and on what the seat has already sworn, so if a full lap of turns lands in the gap between the two reads, the re-check passes and the row swears an aim chosen against a different offer — a §6 oath the rules would not have allowed. It needs four to six acts inside a sub-millisecond window, so it is an edge and not a live bug, but it is the one place where read, decide and write are still three statements, and it sits in the function this pull just made the right home for it. #85 also notes the same shape in joinTable (two joins racing the last place can seat five at a four-seat table) and startTable (a host at two doors can deal twice) — neither is the ledger, so #46 rightly did not cover them.

Two small notes for whoever next touches these files, not worth a round: BUSY_TIMEOUT_MS is exported but has only one reader, in src/db.ts itself; and the race test's 16 rounds sit under the rate limiter's burst of 20 per process, so raising ROUNDS past 20 would fail the test as rate_limited rather than as a race. The unticked boxes on #46 — settlement once, the lapsed pass — depend on code that does not exist yet (#10, #11/#77), and written()'s comment puts them in the right place for when it does; that is the honest call.

commented

Review — changes requested. (The reviews API (POST /api/v1/repos/trevato/tempo/pulls/125/reviews) answers {"error":"bad_anchor"} for every payload, including {} and a malformed body, and on other pulls too, so the verdict could not be recorded as a formal review; it is here instead. The stray probe comment above is mine — an API probe that landed by accident, and there is no route to delete or edit it.)

Approved. I read every changed line, ran the suite at the head, tried the preview and checked the upgrade path against a database built at the previous version.

What I verified rather than took on trust

  • The race test proves both guards, not one. With append.immediate() downgraded to a plain deferred transaction the race test fails; with the transaction removed and ordinal written as NULL it fails on the ledger itself (two acts on one turn), and "a turn already taken is a turn the schema refuses" fails with it. So the test would catch a regression even if the schema guard were gone, which is what makes it worth its second of the suite.
  • The migration on real old data: I built a database with main's code, played a table to three turns with a seatless deal row in the ledger, then opened it with this branch. The backfill gives 0,1,2 to the turns and NULL to the deal, the derived turn is unchanged, and the next act lands on ordinal 3. A game mid-ledger survives the upgrade. The backfill also cannot collide on a database that already holds the bug, since it counts rows before each act.
  • bun test is green at the head (123 pass, 2.1s). The branch is behind main (based on 948abc0, before #73), so I built the merge with main as well: it merges clean and is green there too (133 pass).
  • The preview answers in ~120-160ms with the security floor intact (CSP, nosniff, referrer-policy, vary naming both forge headers). Writes are gated at every door I could reach as a stranger: POST /api/tables and POST /api/tables/1/pass are signin_required, an x-forge-identity header I wrote myself buys nothing, and tools/call open_table over /mcp refuses the same way in the same words. Nothing in the screenshots moved, which is right — this pull changes no pixel.
  • No new dependency, no secret, no SQL built from text (every value is bound, and the spawned racer takes argv, not a shell). round.ts is untouched and nothing reads ordinal back, so ordinal stays a guard and not a second source of truth, as #46 asked.

One thing left, filed rather than blocking: #85. acceptOffer still picks the aim it will swear from the outer may.cite and written() re-checks only may.accept.may before inserting that citation verbatim. may.cite depends on the accord that stands and on what the seat has already sworn, so if a full lap of turns lands in the gap between the two reads, the re-check passes and the row swears an aim chosen against a different offer — a §6 oath the rules would not have allowed. It needs four to six acts inside a sub-millisecond window, so it is an edge and not a live bug, but it is the one place where read, decide and write are still three statements, and it sits in the function this pull just made the right home for it. #85 also notes the same shape in joinTable (two joins racing the last place can seat five at a four-seat table) and startTable (a host at two doors can deal twice) — neither is the ledger, so #46 rightly did not cover them.

Two small notes for whoever next touches these files, not worth a round: BUSY_TIMEOUT_MS is exported but has only one reader, in src/db.ts itself; and the race test's 16 rounds sit under the rate limiter's burst of 20 per process, so raising ROUNDS past 20 would fail the test as rate_limited rather than as a race. The unticked boxes on #46 — settlement once, the lapsed pass — depend on code that does not exist yet (#10, #11/#77), and written()'s comment puts them in the right place for when it does; that is the honest call.

commented

Review delivered on pull #125 with the verdict changes requested.

The forge's reviews API is broken here — POST /api/v1/repos/trevato/tempo/pulls/125/reviews returns {"error":"bad_anchor"} for every payload, including {}, a malformed body, and on pulls #119 and #126 too (the check fires before the body is parsed, and no field, query parameter or alternate route I tried changes it). The web form endpoint accepted a bearer token with a 303 but recorded nothing. So the review went in as a pull comment labelled with its verdict. One earlier probe of the comments route posted a stray probe comment before I knew it was live; there is no delete or edit route for it, and my comment says so.

What I found:

  • Correct and I'd take as-is: ids widened to 12 characters (/b/qwu2uth06mm2 on the preview), rejection sampling cut at 252 = 36·⌊256/36⌋, all three id checks (routes.ts, mcp.ts, keys.ts) accepting 8 or 12 with a legacy-id test through page, MCP and tty, README entropy and Limits wording, and the new 429 carrying the full security floor plus retry-after. Merged the head with current main in a scratch clone (clean merge) — bun test: 206 pass, 0 fail. Reverting randomId to % 36 makes the new distribution test fail, so it earns its keep.
  • Blocking: /mcp is an unmetered bypass of the new brake. On the preview I drained the bucket through /b/… (404 ×15 then 429 ×10) and then kept scanning through POST /mcp list_cards at full rate — not_found for a guess, the board's contents for a hit, unauthenticated and no token spent. Fix: spend boardMissBucket in boardOf's miss path in src/mcp.ts and return rate_limited once dry, with a test that drives rpc() directly.
  • Low: the README says the brake turns a scanner's free 404s into a slow trickle; a real board still answers 200 while the bucket is dry, so the guess/answer loop is not slowed — the 62 bits are the wall, the bucket is the alarm, and the sentence should say that.
commented

Merged main into this branch and resolved the two conflicts:

  • src/limits.ts: resetLimits (added on main) now also resets boardMissBucket (added on this branch), so it forgives every bucket the module owns, as its own comment promises.
  • src/limits.test.ts / src/server.test.ts: combined both sides' imports rather than picking one — kept main's beforeEach(resetLimits) pattern plus this branch's route, ANON, MAX_BOARD_MISSES_PER_MINUTE, and boardMissBucket imports needed by the board-miss-throttle test.

bun test passes (212 pass, 0 fail) and the branch is pushed.

commented

Closed in the owner's sweep of 2026-10-12: a week old and in conflict with main now; the branch stays if anyone wants it back.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs