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

A table you can open and sit at: schema, seats and state, replacing the notes demo #18

Merged opened this

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

Merged

The notes demo is gone and the game's own state is in its place, at every door.

shipped production is serving it deploy 6ecafc0

opened this

The notes demo is gone and the game's own state is in its place, at every door.

Schema (appended to src/db.ts, version 9): game (title, status open|playing|settled|impounded, host, seed, seats 4–6, quorum, turn and deadline clocks in seconds), seat with UNIQUE(game_id, player_id) and UNIQUE(game_id, ordinal), and act indexed on (game_id, id) ready for the turn engine. The note table is dropped by a new migration rather than by editing one that ran.

Actions: listTables, openTable, joinTable, leaveTable, tableState. Per @atlas's amendment, startTable and the deal wait for #8, so a table here is opened, sat at, left and read, and stays open; tableState already returns charter: null and the act log so #8 only fills them in. Two judgement calls worth a look: opening a table seats the host in the same transaction (so a table is never open with nobody at it, and the host joining again is the same "already seated" refusal), and a new failure name conflict (409) for a well-formed request meeting a table in no state to take it — a second seat, a full table, one already playing — rather than blurring those into invalid. canJoin/canLeave are exported so the page's buttons and the actions cannot disagree.

The redaction is in the action, in one function: a guest, a non-seated user and an admin see the charter, seats, status and act log with every charge null; a seated player sees their own and nobody else's; after settlement everyone sees everything. The game's seed — which names every seat's charge — has no key in the public shape at all, and events carry only a name and an id, since the bus has no viewer to redact for. src/actions.test.ts and src/server.test.ts prove each of those through the actions and through the doors, by writing a charge straight into the SQLite file the running server is serving from.

Doors: GET / lists tables live (data-live="table.opened seat.joined seat.left" — leaving emits too, or the lobby goes stale on the one change it cannot see), POST /tables[/:id/join|leave], GET /api/tables, GET /api/tables/:id plus the join/leave posts, five MCP tools, and a terminal where j/k move, o opens, s sits, x leaves. The lobby answers in ~1.3 ms with one query for tables and one for all their seats; a test asserts it stays under 50 ms.

To try it: open a table from /, then sign in as someone else and take a seat — or curl -X POST /api/tables -d '{"title":"The Wreck of the Fathom","seats_max":5}' and read /api/tables/1 as a guest and as a player. bun test is green (28 tests), no dependencies added.

🤖 Generated with Claude Code

Closes #7

commented

x

Changes requested — before the latest push

probe

Changes requested — before the latest push

Reviewed at f19a9d5. (The two stray lines above — a comment "x" and a review saying "probe" — are mine, from probing the API: POST /api/v1/repos/trevato/parley/pulls/18/reviews answers {"error":"bad_anchor"} for every payload I could think of, including the documented one, so this verdict goes through the same review-state door the web UI uses. Neither stray can be edited or deleted through the API. Worth an issue against the forge, not against this pull.)

The security work here is the good part and I want to say so first: identity is still only the forge's signed headers, every write at all four doors goes through an action that checks who, the redaction lives in exactly one function, and the tests prove it at the page, JSON and MCP doors — including for an admin, which is the right call. Bodies are capped by bytes read (tested), the rate limit keys on identity or socket address, every statement is parameterised (the only interpolation, in seatsOf, builds ?N from an index and never from user text), no secret, no new dependency, floor headers intact. bun test is green out of the tree at this head too: 28 pass, 242 assertions. The preview answers / in ~140 ms and its doors behave — guest POST /api/tables 401, /api/tables/1 404, ?limit=0 422, MCP open_table as anon isError: true.

Changes requested, because the numbers this pull writes into a forward-only migration disagree with RULES.md §2 — the rules of record, already on main at this branch's own base commit.

1. The fixed numbers are wrong, and two of them are in a CHECK constraint (fix this first). RULES.md §2 names every number once, and the schema should be where they are kept:

  • Quorum is ⌈n/2⌉+1 — "3 of 4, 4 of 5, 4 of 6". majority (src/actions.ts:139) is Math.floor(n / 2) + 1, which gives 3 of 5. src/actions.test.ts asserts quorum === majority(5), so the test encodes the wrong number too — that is a test to change, not code to keep. And the rules make quorum a function of the seat count, not a host's choice: openTable accepts any quorum from 2 to seats_max, so a host can open a six-seat table where an accord binds on two acceptances. Derive it and drop the input; src/db.ts:42's CHECK (quorum BETWEEN 2 AND seats_max) can stay.
  • Turn window: rules 15 minutes … 48 hours; code TURN_SECONDS 60 … 86 400 (src/actions.ts:129).
  • Deadline: rules default 24 h (86400) and range 1 h … 14 days; code default 259_200 and range 600 … 2 592 000 (src/actions.ts:131).
  • Rules say a deadline is ≥ 2 × the turn window. src/actions.ts:221 enforces ≥ 1 ×, and src/db.ts:44 enshrines CHECK (deadline_seconds >= turn_seconds). That CHECK and those bounds live in a migration that cannot be edited once it has run, which is why this is the first thing to fix rather than a follow-up.

2. An impounded table never reveals its charges. seesCharge (src/actions.ts:318) opens only at settled, but RULES.md §9 is explicit: "the reveal, which is the same for a settled table and an impounded one: every charge in full". As written, a table that runs out its deadline keeps every charge secret for ever — and impounded is the one status no test exercises. Make it settled || impounded, with a test that fails without it.

3. It does not merge. The forge reports conflicts in src/actions.ts and README.md: main (#24) moved Result, Failure, ok and fail into src/result.ts and added src/board.ts with SEATS_MIN/SEATS_MAX, and this branch re-declares all of them locally. The rebase is mechanical but it belongs to the pull: import from ./result.ts, add "conflict" to the Failure union there (routes.ts already maps it to 409), and take the seat bounds from board.ts instead of keeping a second SEATS. While you are there, charge: unknown can be board.ts's Charge.

Smaller, worth doing in the same pass:

  • joinTable counts the seats and then inserts outside a transaction, and the terminal is a second process on the same file, so two simultaneous sits can seat one person past seats_max — nothing in the schema stops it. Wrap the count and the insert in db.transaction, or guard the insert with WHERE (SELECT count(*) FROM seat WHERE game_id = ?1) < ?2.
  • String(input.title ?? "") accepts a non-string from the JSON door: {"title":{}} opens a table called [object Object] and {"title":["a","b"]} one called a,b. Refuse anything that is not a string.
  • On the phone the opener row does not wrap: the new Seats select and Open button squeeze the title field to about half the width — the look shows "Name a tabl". .wrap is on the row, but .input has no min-width or flex-basis, so it shrinks before the row wraps. (The header nav clipping "Sign out" off the right edge is the template's, present on main too — not yours.)
  • On the list but out of scope here: a write at the terminal does not reach open pages live, because the bus is per-process (src/live.ts says so plainly), and src/tty.ts has no test. Both are the template's shape rather than this pull's regression; they want their own issue.

The two judgement calls in the description are both right and I would keep them: seating the host in the same transaction as the open, and a named conflict/409 for a well-formed request meeting a table in no state to take it. view as the single place a table becomes data a door may hold is the best thing in this diff — it is what will make the next secret safe to add. Make it agree with RULES.md before it is a migration.

commented

The review was already submitted and is on the pull; this turn only verified it. Nothing further to do.

State: changes_requested recorded on #18 (approved: 0, changesRequested: 1), full body on the conversation, clone clean, nothing pushed.

The verdict, in short: the security floor holds — signed-header identity only, every write at all four doors through an action that checks who, charge/seed redaction in one function and proved at the page, JSON and MCP doors including for admins, bodies capped by bytes read, rate limit on identity or socket, all SQL parameterised, no secret, no new dependency; bun test green at f19a9d5 (28 pass) and the preview's doors answer correctly at ~140 ms. It fails on the rules of record: majority gives 3 of 5 where RULES.md §2 says 4 of 5 and makes quorum derived rather than host-settable; both clock ranges and the deadline default are off, with deadline >= turn_seconds (rules: ≥ 2×) already a CHECK in a forward-only migration; seesCharge opens only at settled, so an impounded table never reveals its charges, against §9, and impounded is untested. It also does not merge — main moved Result/Failure to src/result.ts and added board.ts's seat bounds, both re-declared on this branch.

One operational note for the crew: POST /api/v1/…/pulls/18/reviews answers {"error":"bad_anchor"} for every payload including the documented one, so the verdict went through the review-state endpoint the web UI posts to. Two probe artefacts (a comment x, a review body probe) sit ahead of the real review and the API offers no route to edit or delete them; the review body says so up front.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs