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
Check · Review · Land · Live done — Landed and live in production.
mason/parley:mason/7-a-table-you-can-open-and-sit-at-schema-s → main 4 commits fork
13 files
changed +1281
−287
AGENTS.md +4 −2
| @@ -42,8 +42,10 @@ action calls; both speak the same named failures, from `src/result.ts`. | ||
| 42 | 42 | |
| 43 | 43 | Identity comes from the forge (`src/who.ts`): `anon`, `user` or `admin`, |
| 44 | 44 | verified by signature, never by a cookie the app invents. An action that |
| 45 | needs a person checks `who.role`; ownership is a column (`author_id`) and | |
| 46 | the check that reads it, in the action. | |
| 45 | needs a person checks `who.role`; ownership is a column (`host_id`, | |
| 46 | `seat.player_id`) and the check that reads it, in the action. Secret state | |
| 47 | is a column too (`seat.charge_json`, `game.seed`): it is redacted where it | |
| 48 | is read, in the action, so no door can be the one that forgets. | |
| 47 | 49 | |
| 48 | 50 | To add a concept: |
| 49 | 51 |
README.md +49 −13
| @@ -27,7 +27,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 27 | 27 | |
| 28 | 28 | | file | what it is | |
| 29 | 29 | | ---------------------------------- | ------------------------------------------------------------ | |
| 30 | | `src/actions.ts` | list, add, delete — and who may | | |
| 30 | | `src/actions.ts` | open, sit, leave, read a table — and who may see what | | |
| 31 | 31 | | `src/board.ts` | the deal, the score and the sworn rule: by RULES.md, no db | |
| 32 | 32 | | `src/result.ts` | data or a named failure — the answer shape every door reads | |
| 33 | 33 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| @@ -42,15 +42,43 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 42 | 42 | | `src/env.ts` | what the forge told this process | |
| 43 | 43 | | `static/app.css`, `static/live.js` | the look, and the list that stays current | |
| 44 | 44 | |
| 45 | ## The table | |
| 46 | ||
| 47 | A table is a game. Someone signed in opens one — a title, four to six | |
| 48 | seats, a turn window and a deadline — and takes the first seat by doing | |
| 49 | so; anyone else with a forge account sits down while seats are left, and | |
| 50 | may give the seat up again until play starts. The fixed numbers are | |
| 51 | [](RULES.md) §2 and not the app's to invent: a turn window of 15 | |
| 52 | minutes to 48 hours (6 hours by default), a deadline of an hour to a | |
| 53 | fortnight (24 hours) that is never shorter than two turn windows, and a | |
| 54 | quorum of ⌈n/2⌉+1 — 3 of 4, 4 of 5, 4 of 6 — which follows from the seat | |
| 55 | count, so no host chooses it. | |
| 56 | ||
| 57 | What a seat is playing for is its **charge**, and it is secret: the action | |
| 58 | that reads a table redacts it by viewer, so no door can leak it. | |
| 59 | ||
| 60 | - a guest or someone not seated sees the charter, the seats, the status | |
| 61 | and the whole act log — and no charge at all; | |
| 62 | - a seated player sees their own charge and nobody else's; | |
| 63 | - once the table is settled, everyone sees every charge. | |
| 64 | ||
| 65 | The game's `seed` is the charges of every seat written another way, so it | |
| 66 | never leaves `src/actions.ts` either. What a charter and a charge contain, | |
| 67 | and what an accord pays, are [](RULES.md) and `src/board.ts`; | |
| 68 | dealing them onto a table and playing turns arrive with the turn engine, so | |
| 69 | a table today is opened, sat at, left and read, and stays `open`. | |
| 70 | ||
| 45 | 71 | ## Who you are |
| 46 | 72 | |
| 47 | 73 | The forge says who is asking on every request it proxies, and the app |
| 48 | 74 | verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers: |
| 49 | 75 | |
| 50 | 76 | - **anon** — anyone. Reads. |
| 51 | - **user** — signed in to the forge and able to see this app. Adds notes, as | |
| 52 | themselves, and removes their own. | |
| 53 | - **admin** — able to write this repository. Removes any note. | |
| 77 | - **user** — signed in to the forge and able to see this app. Opens tables, | |
| 78 | takes one seat at any of them, and gives their own seat up. | |
| 79 | - **admin** — able to write this repository. Writing the repository is not | |
| 80 | sight of a secret: an admin who holds no seat reads a table exactly as a | |
| 81 | guest does, charges and all redacted. | |
| 54 | 82 | |
| 55 | 83 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is |
| 56 | 84 | `/__forge/signout`; the forge answers both on the app's own host. |
| @@ -70,13 +98,20 @@ The same actions, the same rules, at every door. A script is the person | ||
| 70 | 98 | whose forge grant it carries; a bare script is anonymous. |
| 71 | 99 | |
| 72 | 100 | sh |
| 73 | curl https://<app>/api/notes | |
| 74 | curl -X POST https://<app>/api/notes -H 'content-type: application/json' -d '{"body":"hi"}' | |
| 75 | curl -X DELETE https://<app>/api/notes/1 | |
| 101 | curl https://<app>/api/tables | |
| 102 | curl https://<app>/api/tables/1 | |
| 103 | curl -X POST https://<app>/api/tables -H 'content-type: application/json' \ | |
| 104 | -d '{"title":"The Wreck of the Fathom","seats_max":5}' | |
| 105 | curl -X POST https://<app>/api/tables/1/join | |
| 106 | curl -X POST https://<app>/api/tables/1/leave | |
| 76 | 107 | |
| 77 | 108 | |
| 78 | As an MCP server, for Claude Code or Claude Desktop — `list_notes`, | |
| 79 | `add_note`, `delete_note`: | |
| 109 | A refusal answers with its name and the status that matches it: | |
| 110 | `signin_required` 401, `forbidden` 403, `not_found` 404, `conflict` 409 | |
| 111 | (the table was in no state to take the request) and `invalid` 422. | |
| 112 | ||
| 113 | As an MCP server, for Claude Code or Claude Desktop — `list_tables`, | |
| 114 | `table_state`, `open_table`, `join_table`, `leave_table`: | |
| 80 | 115 | |
| 81 | 116 | sh |
| 82 | 117 | claude mcp add --transport http __TITLE__ https://<app>/mcp |
| @@ -88,14 +123,15 @@ claude mcp add --transport http __TITLE__ https://<app>/mcp | ||
| 88 | 123 | } |
| 89 | 124 | |
| 90 | 125 | |
| 91 | The page stays current over `/events`: a note someone else adds or removes | |
| 92 | appears without a reload. Without JavaScript it is the same page, one | |
| 93 | reload behind. | |
| 126 | The page stays current over `/events`: a table someone else opens, sits at | |
| 127 | or leaves appears without a reload. Without JavaScript it is the same page, | |
| 128 | one reload behind. | |
| 94 | 129 | |
| 95 | 130 | The terminal opens with the exact command in `FORGE_APP_SSH` when the app |
| 96 | 131 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge |
| 97 | 132 | the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on |
| 98 | a real pty over the same data, as the account at the keyboard. | |
| 133 | a real pty over the same data, as the account at the keyboard: j/k move | |
| 134 | between tables, o opens one, s sits down, x gives the seat up, q quits. | |
| 99 | 135 | |
| 100 | 136 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or |
| 101 | 137 | `preview`), `FORGE_APP_URL`, `FORGE_APP_COMMIT`, `FORGE_APP_SSH` and |
src/actions.test.ts +288 −32
| @@ -1,8 +1,20 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import type { Database } from "bun:sqlite"; | |
| 2 | 3 | import { mkdtempSync } from "node:fs"; |
| 3 | 4 | import { tmpdir } from "node:os"; |
| 4 | 5 | import { join } from "node:path"; |
| 5 | import { addNote, deleteNote, listNotes } from "./actions.ts"; | |
| 6 | import { | |
| 7 | canJoin, | |
| 8 | canLeave, | |
| 9 | joinTable, | |
| 10 | leaveTable, | |
| 11 | listTables, | |
| 12 | openTable, | |
| 13 | quorumFor, | |
| 14 | tableState, | |
| 15 | TITLE_MAX, | |
| 16 | type TableView, | |
| 17 | } from "./actions.ts"; | |
| 6 | 18 | import { openDb } from "./db.ts"; |
| 7 | 19 | import type { Who } from "./who.ts"; |
| 8 | 20 | |
| @@ -10,49 +22,293 @@ const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-"))); | ||
| 10 | 22 | const anon: Who = { role: "anon" }; |
| 11 | 23 | const alice: Who = { role: "user", id: "u1", name: "alice" }; |
| 12 | 24 | const bob: Who = { role: "user", id: "u2", name: "bob" }; |
| 25 | const carol: Who = { role: "user", id: "u3", name: "carol" }; | |
| 26 | const dave: Who = { role: "user", id: "u4", name: "dave" }; | |
| 27 | const erin: Who = { role: "user", id: "u5", name: "erin" }; | |
| 13 | 28 | const root: Who = { role: "admin", id: "u0", name: "root" }; |
| 14 | 29 | |
| 15 | test("anyone reads; only a signed-in person adds, as themselves", () => { | |
| 16 | expect(listNotes(db, anon)).toEqual({ ok: true, value: [] }); | |
| 17 | const refused = addNote(db, anon, { body: "hello" }); | |
| 30 | /** A table opened by `host`, or the test's own failure. */ | |
| 31 | const open = (host: Who, input: Record<string, unknown> = {}): TableView => { | |
| 32 | const opened = openTable(db, host, { title: "The Wreck", ...input }); | |
| 33 | if (!opened.ok) throw new Error(`${opened.error}: ${opened.message}`); | |
| 34 | return opened.value; | |
| 35 | }; | |
| 36 | ||
| 37 | /** What the turn engine will do in #8, done here by hand so redaction can be tested. */ | |
| 38 | const deal = (id: number, playerId: string, charge: unknown): void => { | |
| 39 | db.query( | |
| 40 | "UPDATE seat SET charge_json = ?3 WHERE game_id = ?1 AND player_id = ?2", | |
| 41 | ).run(id, playerId, JSON.stringify(charge)); | |
| 42 | }; | |
| 43 | const setStatus = (id: number, status: string): void => { | |
| 44 | db.query("UPDATE game SET status = ?2 WHERE id = ?1").run(id, status); | |
| 45 | }; | |
| 46 | const seatOf = (state: TableView, name: string) => | |
| 47 | state.seats.find((seat) => seat.player_name === name)!; | |
| 48 | const at = (id: number, who: Who): TableView => { | |
| 49 | const state = tableState(db, who, { id }); | |
| 50 | if (!state.ok) throw new Error(state.message); | |
| 51 | return state.value; | |
| 52 | }; | |
| 53 | ||
| 54 | test("anyone reads the tables; only a signed-in person opens one, and sits at it", () => { | |
| 55 | expect(listTables(db, anon)).toEqual({ ok: true, value: [] }); | |
| 56 | const refused = openTable(db, anon, { title: "The Wreck" }); | |
| 18 | 57 | expect(refused.ok).toBe(false); |
| 19 | 58 | if (!refused.ok) expect(refused.error).toBe("signin_required"); |
| 20 | const added = addNote(db, alice, { body: " hello " }); | |
| 21 | expect(added.ok).toBe(true); | |
| 22 | if (!added.ok) return; | |
| 23 | expect(added.value.body).toBe("hello"); | |
| 24 | expect(added.value.author_id).toBe("u1"); | |
| 25 | expect(added.value.author_name).toBe("alice"); | |
| 26 | const all = listNotes(db, anon); | |
| 27 | expect(all.ok && all.value.length).toBe(1); | |
| 59 | expect(listTables(db, anon)).toEqual({ ok: true, value: [] }); | |
| 60 | ||
| 61 | const table = open(alice, { title: " The Wreck of the Fathom " }); | |
| 62 | expect(table.title).toBe("The Wreck of the Fathom"); | |
| 63 | expect(table.status).toBe("open"); | |
| 64 | expect(table.host_id).toBe("u1"); | |
| 65 | // Opening a table is sitting down at it: the host holds seat one. | |
| 66 | expect(table.seated).toBe(1); | |
| 67 | expect(table.seats[0]?.player_name).toBe("alice"); | |
| 68 | expect(table.your_seat).toBe(table.seats[0]!.id); | |
| 69 | expect(at(table.id, bob).your_seat).toBeNull(); | |
| 70 | ||
| 71 | const listed = listTables(db, anon); | |
| 72 | expect(listed.ok && listed.value.length).toBe(1); | |
| 28 | 73 | }); |
| 29 | 74 | |
| 30 | test("a note is one to five hundred characters", () => { | |
| 31 | for (const body of ["", " ", "x".repeat(501)]) { | |
| 32 | const r = addNote(db, alice, { body }); | |
| 75 | test("a table is named in one line, seats four to six, and keeps two sane clocks", () => { | |
| 76 | for (const title of ["", " ", "x".repeat(TITLE_MAX + 1), "two\nlines"]) { | |
| 77 | const r = openTable(db, alice, { title }); | |
| 33 | 78 | expect(r.ok).toBe(false); |
| 34 | 79 | if (!r.ok) expect(r.error).toBe("invalid"); |
| 35 | 80 | } |
| 81 | for (const seats_max of [3, 7, 4.5, "many"]) { | |
| 82 | const r = openTable(db, alice, { title: "The Wreck", seats_max }); | |
| 83 | expect(r.ok).toBe(false); | |
| 84 | if (!r.ok) expect(r.error).toBe("invalid"); | |
| 85 | } | |
| 86 | for (const input of [ | |
| 87 | { turn_seconds: 60 }, // under the quarter hour | |
| 88 | { turn_seconds: 172_801 }, | |
| 89 | { deadline_seconds: 5 }, | |
| 90 | { deadline_seconds: 1_209_601 }, | |
| 91 | { turn_seconds: 86_400, deadline_seconds: 86_400 }, // under two windows | |
| 92 | ]) { | |
| 93 | const r = openTable(db, alice, { title: "The Wreck", ...input }); | |
| 94 | expect(r.ok).toBe(false); | |
| 95 | if (!r.ok) expect(r.error).toBe("invalid"); | |
| 96 | } | |
| 97 | // Unsaid is not invalid: RULES.md §2 says what a table gets by default. | |
| 98 | const table = open(alice, { seats_max: 5 }); | |
| 99 | expect(table.seats_max).toBe(5); | |
| 100 | expect(table.turn_seconds).toBe(21_600); | |
| 101 | expect(table.deadline_seconds).toBe(86_400); | |
| 102 | // A quorum is ceil(n/2)+1 and no host chooses it: 3 of 4, 4 of 5, 4 of 6. | |
| 103 | expect([4, 5, 6].map((n) => quorumFor(n))).toEqual([3, 4, 4]); | |
| 104 | expect(table.quorum).toBe(4); | |
| 105 | expect(open(alice, { seats_max: 4, quorum: 2 }).quorum).toBe(3); | |
| 36 | 106 | }); |
| 37 | 107 | |
| 38 | test("only the author or an admin removes a note", () => { | |
| 39 | const mine = addNote(db, alice, { body: "mine" }); | |
| 40 | if (!mine.ok) throw new Error(mine.message); | |
| 41 | const byBob = deleteNote(db, bob, { id: mine.value.id }); | |
| 42 | expect(byBob.ok).toBe(false); | |
| 43 | if (!byBob.ok) expect(byBob.error).toBe("forbidden"); | |
| 44 | const byAnon = deleteNote(db, anon, { id: mine.value.id }); | |
| 108 | test("a seat is one forge identity, once, while the table is open and has room", () => { | |
| 109 | const table = open(alice, { seats_max: 4 }); | |
| 110 | ||
| 111 | const byAnon = joinTable(db, anon, { id: table.id }); | |
| 45 | 112 | expect(byAnon.ok).toBe(false); |
| 46 | 113 | if (!byAnon.ok) expect(byAnon.error).toBe("signin_required"); |
| 47 | expect(deleteNote(db, alice, { id: mine.value.id }).ok).toBe(true); | |
| 48 | const gone = deleteNote(db, alice, { id: mine.value.id }); | |
| 49 | expect(gone.ok).toBe(false); | |
| 50 | if (!gone.ok) expect(gone.error).toBe("not_found"); | |
| 51 | 114 | |
| 52 | const bobs = addNote(db, bob, { body: "bob's" }); | |
| 53 | if (!bobs.ok) throw new Error(bobs.message); | |
| 54 | expect(deleteNote(db, root, { id: bobs.value.id }).ok).toBe(true); | |
| 55 | const odd = deleteNote(db, root, { id: 1.5 }); | |
| 56 | expect(odd.ok).toBe(false); | |
| 57 | if (!odd.ok) expect(odd.error).toBe("invalid"); | |
| 115 | expect(joinTable(db, bob, { id: table.id }).ok).toBe(true); | |
| 116 | ||
| 117 | const twice = joinTable(db, bob, { id: table.id }); | |
| 118 | expect(twice.ok).toBe(false); | |
| 119 | if (!twice.ok) { | |
| 120 | expect(twice.error).toBe("conflict"); | |
| 121 | expect(twice.message).toContain("already hold a seat"); | |
| 122 | } | |
| 123 | // The host is seated by opening, so joining their own table is that too. | |
| 124 | const host = joinTable(db, alice, { id: table.id }); | |
| 125 | expect(host.ok).toBe(false); | |
| 126 | if (!host.ok) expect(host.error).toBe("conflict"); | |
| 127 | ||
| 128 | expect(joinTable(db, carol, { id: table.id }).ok).toBe(true); | |
| 129 | const full = joinTable(db, dave, { id: table.id }); | |
| 130 | expect(full.ok).toBe(true); | |
| 131 | if (full.ok) expect(full.value.seated).toBe(4); | |
| 132 | ||
| 133 | const late = joinTable(db, erin, { id: table.id }); | |
| 134 | expect(late.ok).toBe(false); | |
| 135 | if (!late.ok) { | |
| 136 | expect(late.error).toBe("conflict"); | |
| 137 | expect(late.message).toContain("full"); | |
| 138 | } | |
| 139 | // Seats are distinct places in the order, and no two are the same. | |
| 140 | const ordinals = at(table.id, anon).seats.map((seat) => seat.ordinal); | |
| 141 | expect(new Set(ordinals).size).toBe(4); | |
| 142 | ||
| 143 | const missing = joinTable(db, bob, { id: 9_999 }); | |
| 144 | expect(missing.ok).toBe(false); | |
| 145 | if (!missing.ok) expect(missing.error).toBe("not_found"); | |
| 146 | }); | |
| 147 | ||
| 148 | test("a seat at a table that is playing or over is refused, by name", () => { | |
| 149 | const table = open(alice, { seats_max: 4 }); | |
| 150 | setStatus(table.id, "playing"); | |
| 151 | const playing = joinTable(db, bob, { id: table.id }); | |
| 152 | expect(playing.ok).toBe(false); | |
| 153 | if (!playing.ok) { | |
| 154 | expect(playing.error).toBe("conflict"); | |
| 155 | expect(playing.message).toContain("already playing"); | |
| 156 | } | |
| 157 | setStatus(table.id, "settled"); | |
| 158 | const over = joinTable(db, bob, { id: table.id }); | |
| 159 | expect(over.ok).toBe(false); | |
| 160 | if (!over.ok) expect(over.error).toBe("conflict"); | |
| 161 | }); | |
| 162 | ||
| 163 | test("a seat can be given up before the table starts, and only your own", () => { | |
| 164 | const table = open(alice, { seats_max: 4 }); | |
| 165 | expect(joinTable(db, bob, { id: table.id }).ok).toBe(true); | |
| 166 | ||
| 167 | const byAnon = leaveTable(db, anon, { id: table.id }); | |
| 168 | expect(byAnon.ok).toBe(false); | |
| 169 | if (!byAnon.ok) expect(byAnon.error).toBe("signin_required"); | |
| 170 | ||
| 171 | const notSeated = leaveTable(db, carol, { id: table.id }); | |
| 172 | expect(notSeated.ok).toBe(false); | |
| 173 | if (!notSeated.ok) { | |
| 174 | expect(notSeated.error).toBe("not_found"); | |
| 175 | expect(notSeated.message).toContain("do not hold a seat"); | |
| 176 | } | |
| 177 | ||
| 178 | const left = leaveTable(db, bob, { id: table.id }); | |
| 179 | expect(left.ok).toBe(true); | |
| 180 | if (left.ok) expect(left.value.seated).toBe(1); | |
| 181 | // The place is free again, and the table takes someone else. | |
| 182 | expect(joinTable(db, carol, { id: table.id }).ok).toBe(true); | |
| 183 | ||
| 184 | setStatus(table.id, "playing"); | |
| 185 | const started = leaveTable(db, alice, { id: table.id }); | |
| 186 | expect(started.ok).toBe(false); | |
| 187 | if (!started.ok) { | |
| 188 | expect(started.error).toBe("conflict"); | |
| 189 | expect(started.message).toContain("fixed once the table starts"); | |
| 190 | } | |
| 191 | }); | |
| 192 | ||
| 193 | test("a charge is the seat's own: a guest and a stranger see none of them", () => { | |
| 194 | const table = open(alice, { seats_max: 4 }); | |
| 195 | expect(joinTable(db, bob, { id: table.id }).ok).toBe(true); | |
| 196 | deal(table.id, "u1", { aims: ["alice's secret"] }); | |
| 197 | deal(table.id, "u2", { aims: ["bob's secret"] }); | |
| 198 | setStatus(table.id, "playing"); | |
| 199 | ||
| 200 | for (const stranger of [anon, carol, root]) { | |
| 201 | const state = at(table.id, stranger); | |
| 202 | // The charter, the seats, the status and the log — everything but the charges. | |
| 203 | expect(state.status).toBe("playing"); | |
| 204 | expect(state.seats.map((seat) => seat.player_name)).toEqual([ | |
| 205 | "alice", | |
| 206 | "bob", | |
| 207 | ]); | |
| 208 | expect(state.acts).toEqual([]); | |
| 209 | expect(state.seats.every((seat) => seat.charge === null)).toBe(true); | |
| 210 | expect(JSON.stringify(state)).not.toContain("secret"); | |
| 211 | } | |
| 212 | ||
| 213 | const mine = at(table.id, alice); | |
| 214 | expect(seatOf(mine, "alice").charge).toEqual({ aims: ["alice's secret"] }); | |
| 215 | expect(seatOf(mine, "bob").charge).toBeNull(); | |
| 216 | const theirs = at(table.id, bob); | |
| 217 | expect(seatOf(theirs, "bob").charge).toEqual({ aims: ["bob's secret"] }); | |
| 218 | expect(seatOf(theirs, "alice").charge).toBeNull(); | |
| 219 | ||
| 220 | // The lobby is the same read, so it redacts the same way. | |
| 221 | const listed = listTables(db, carol); | |
| 222 | expect(listed.ok).toBe(true); | |
| 223 | if (listed.ok) expect(JSON.stringify(listed.value)).not.toContain("secret"); | |
| 224 | ||
| 225 | // Once it is settled, the hands are on the table for everyone. | |
| 226 | setStatus(table.id, "settled"); | |
| 227 | for (const viewer of [anon, carol, alice]) { | |
| 228 | const state = at(table.id, viewer); | |
| 229 | expect(seatOf(state, "alice").charge).toEqual({ | |
| 230 | aims: ["alice's secret"], | |
| 231 | }); | |
| 232 | expect(seatOf(state, "bob").charge).toEqual({ aims: ["bob's secret"] }); | |
| 233 | } | |
| 234 | }); | |
| 235 | ||
| 236 | test("the seed never leaves the action, whoever is asking", () => { | |
| 237 | const table = open(alice); | |
| 238 | db.query("UPDATE game SET seed = 'the-seed-of-it-all' WHERE id = ?1").run( | |
| 239 | table.id, | |
| 240 | ); | |
| 241 | for (const viewer of [anon, alice, root]) { | |
| 242 | expect(JSON.stringify(at(table.id, viewer))).not.toContain("seed"); | |
| 243 | const listed = listTables(db, viewer); | |
| 244 | expect(listed.ok && JSON.stringify(listed.value)).not.toContain("seed"); | |
| 245 | } | |
| 246 | }); | |
| 247 | ||
| 248 | test("the page's buttons and the actions agree about who may sit", () => { | |
| 249 | const table = open(alice, { seats_max: 4 }); | |
| 250 | const asBob = at(table.id, bob); | |
| 251 | expect(canJoin(bob, asBob)).toBe(true); | |
| 252 | expect(canLeave(bob, asBob)).toBe(false); | |
| 253 | expect(canJoin(anon, at(table.id, anon))).toBe(false); | |
| 254 | ||
| 255 | expect(joinTable(db, bob, { id: table.id }).ok).toBe(true); | |
| 256 | const seated = at(table.id, bob); | |
| 257 | expect(canJoin(bob, seated)).toBe(false); | |
| 258 | expect(canLeave(bob, seated)).toBe(true); | |
| 259 | expect(joinTable(db, bob, { id: table.id }).ok).toBe(false); | |
| 260 | ||
| 261 | setStatus(table.id, "playing"); | |
| 262 | const playing = at(table.id, bob); | |
| 263 | expect(canJoin(bob, playing)).toBe(false); | |
| 264 | expect(canLeave(bob, playing)).toBe(false); | |
| 265 | }); | |
| 266 | ||
| 267 | test("a table is read by whole-number id, and a missing one is not found", () => { | |
| 268 | // `Number(true)` is 1 and `Number([])` is 0: neither names a table. | |
| 269 | for (const id of ["nope", 1.5, 9_999, true, [], {}, null, undefined]) { | |
| 270 | const r = tableState(db, anon, { id }); | |
| 271 | expect(r.ok).toBe(false); | |
| 272 | if (!r.ok) expect(r.error).toBe("not_found"); | |
| 273 | } | |
| 274 | const bad = listTables(db, anon, { limit: 0 }); | |
| 275 | expect(bad.ok).toBe(false); | |
| 276 | if (!bad.ok) expect(bad.error).toBe("invalid"); | |
| 277 | expect(listTables(db, anon, { limit: "5" }).ok).toBe(true); | |
| 278 | }); | |
| 279 | ||
| 280 | test("the schema keeps the promises the actions rest on", () => { | |
| 281 | const table = open(alice, { seats_max: 4 }); | |
| 282 | const seats = (db as Database) | |
| 283 | .query<{ id: number }, [number]>("SELECT id FROM seat WHERE game_id = ?1") | |
| 284 | .all(table.id); | |
| 285 | // Two seats in the same place at the same table is not a state that exists. | |
| 286 | expect(() => | |
| 287 | db | |
| 288 | .query( | |
| 289 | "INSERT INTO seat (game_id, player_id, player_name, ordinal) VALUES (?1, 'u9', 'nine', 0)", | |
| 290 | ) | |
| 291 | .run(table.id), | |
| 292 | ).toThrow(); | |
| 293 | // Nor is the same person twice. | |
| 294 | expect(() => | |
| 295 | db | |
| 296 | .query( | |
| 297 | "INSERT INTO seat (game_id, player_id, player_name, ordinal) VALUES (?1, 'u1', 'alice', 9)", | |
| 298 | ) | |
| 299 | .run(table.id), | |
| 300 | ).toThrow(); | |
| 301 | // A table's acts and seats go when the table does. | |
| 302 | db.query( | |
| 303 | "INSERT INTO act (game_id, seat_id, kind) VALUES (?1, ?2, 'offer')", | |
| 304 | ).run(table.id, seats[0]!.id); | |
| 305 | expect(at(table.id, anon).acts.map((act) => act.kind)).toEqual(["offer"]); | |
| 306 | db.query("DELETE FROM game WHERE id = ?1").run(table.id); | |
| 307 | expect( | |
| 308 | db | |
| 309 | .query<{ n: number }, [number]>( | |
| 310 | "SELECT count(*) AS n FROM act WHERE game_id = ?1", | |
| 311 | ) | |
| 312 | .get(table.id)!.n, | |
| 313 | ).toBe(0); | |
| 58 | 314 | }); |
src/actions.ts +414 −56
| @@ -4,6 +4,16 @@ | ||
| 4 | 4 | * the terminal are doors onto these and decide nothing themselves — so a |
| 5 | 5 | * rule written here holds at every door, and a new door is only a new |
| 6 | 6 | * way to call. |
| 7 | * | |
| 8 | * A table is a game: a host opens one, four to six people sit at it, and | |
| 9 | * what each seat is playing for is secret. Two things never leave this | |
| 10 | * module unredacted — a seat's `charge_json` and the game's `seed`, which | |
| 11 | * is the charge of every seat written another way. `view` is the only way | |
| 12 | * a table becomes data a door may hold, and it redacts by viewer. | |
| 13 | * | |
| 14 | * What an action emits is a name and an id, never a table: the event bus is | |
| 15 | * read by every open page at once, and the redaction that makes a table safe | |
| 16 | * to hand out is the viewer's, which the bus does not have. | |
| 7 | 17 | */ |
| 8 | 18 | |
| 9 | 19 | import type { Database } from "bun:sqlite"; |
| @@ -13,73 +23,421 @@ import type { Who } from "./who.ts"; | ||
| 13 | 23 | |
| 14 | 24 | export type { Failure, Result }; |
| 15 | 25 | |
| 16 | export interface Note { | |
| 26 | export type Status = "open" | "playing" | "settled" | "impounded"; | |
| 27 | ||
| 28 | /** A row of `game`, as it is stored. Secret: `seed`. */ | |
| 29 | interface GameRow { | |
| 17 | 30 | readonly id: number; |
| 18 | readonly body: string; | |
| 19 | readonly author_id: string; | |
| 20 | readonly author_name: string; | |
| 31 | readonly title: string; | |
| 32 | readonly status: Status; | |
| 33 | readonly host_id: string; | |
| 34 | readonly host_name: string; | |
| 35 | readonly seed: string | null; | |
| 36 | readonly seats_max: number; | |
| 37 | readonly quorum: number; | |
| 38 | readonly turn_seconds: number; | |
| 39 | readonly deadline_seconds: number; | |
| 21 | 40 | readonly created_at: string; |
| 22 | readonly updated_at: string; | |
| 41 | readonly started_at: string | null; | |
| 42 | readonly settled_at: string | null; | |
| 23 | 43 | } |
| 24 | 44 | |
| 25 | export const BODY_MAX = 500; | |
| 26 | ||
| 27 | export function listNotes( | |
| 28 | db: Database, | |
| 29 | _who: Who, | |
| 30 | input: { limit?: number } = {}, | |
| 31 | ): Result<Note[]> { | |
| 32 | const limit = Math.min( | |
| 33 | Math.max(Math.trunc(input.limit ?? 100) || 100, 1), | |
| 34 | 500, | |
| 35 | ); | |
| 36 | return ok( | |
| 37 | db | |
| 38 | .query<Note, [number]>("SELECT * FROM note ORDER BY id DESC LIMIT ?1") | |
| 39 | .all(limit), | |
| 40 | ); | |
| 45 | /** A row of `seat`, as it is stored. Secret: `charge_json`. */ | |
| 46 | interface SeatRow { | |
| 47 | readonly id: number; | |
| 48 | readonly game_id: number; | |
| 49 | readonly player_id: string; | |
| 50 | readonly player_name: string; | |
| 51 | readonly ordinal: number; | |
| 52 | readonly charge_json: string | null; | |
| 53 | readonly score: number | null; | |
| 54 | readonly joined_at: string; | |
| 41 | 55 | } |
| 42 | 56 | |
| 43 | export function addNote( | |
| 57 | interface ActRow { | |
| 58 | readonly id: number; | |
| 59 | readonly game_id: number; | |
| 60 | readonly seat_id: number | null; | |
| 61 | readonly kind: string; | |
| 62 | readonly accord_json: string | null; | |
| 63 | readonly at: string; | |
| 64 | } | |
| 65 | ||
| 66 | /** A seat as a viewer may see it: `charge` is null unless it is theirs to read. */ | |
| 67 | export interface Seat { | |
| 68 | readonly id: number; | |
| 69 | readonly ordinal: number; | |
| 70 | readonly player_id: string; | |
| 71 | readonly player_name: string; | |
| 72 | readonly joined_at: string; | |
| 73 | readonly score: number | null; | |
| 74 | readonly charge: unknown | null; | |
| 75 | } | |
| 76 | ||
| 77 | /** An act as anyone may see it: the log is public, and always was. */ | |
| 78 | export interface Act { | |
| 79 | readonly id: number; | |
| 80 | readonly seat_id: number | null; | |
| 81 | readonly kind: string; | |
| 82 | readonly accord: unknown | null; | |
| 83 | readonly at: string; | |
| 84 | } | |
| 85 | ||
| 86 | /** A table as a viewer may see it. No `seed`, and no charge but their own. */ | |
| 87 | export interface Table { | |
| 88 | readonly id: number; | |
| 89 | readonly title: string; | |
| 90 | readonly status: Status; | |
| 91 | readonly host_id: string; | |
| 92 | readonly host_name: string; | |
| 93 | readonly seats_max: number; | |
| 94 | readonly quorum: number; | |
| 95 | readonly turn_seconds: number; | |
| 96 | readonly deadline_seconds: number; | |
| 97 | readonly created_at: string; | |
| 98 | readonly started_at: string | null; | |
| 99 | readonly settled_at: string | null; | |
| 100 | readonly seats: readonly Seat[]; | |
| 101 | readonly seated: number; | |
| 102 | /** The viewer's own seat id at this table, or null: the page asks this, not `who`. */ | |
| 103 | readonly your_seat: number | null; | |
| 104 | } | |
| 105 | ||
| 106 | /** A table with everything a player reads to take a turn. */ | |
| 107 | export interface TableView extends Table { | |
| 108 | /** The public clauses being argued over, dealt from the seed when play starts (#8). */ | |
| 109 | readonly charter: unknown | null; | |
| 110 | readonly acts: readonly Act[]; | |
| 111 | } | |
| 112 | ||
| 113 | export const TITLE_MAX = 60; | |
| 114 | ||
| 115 | // The fixed numbers are RULES.md §2, not this module's opinion: seats 4-6, | |
| 116 | // a turn window of 15 minutes to 48 hours (6 hours by default) and a | |
| 117 | // deadline of an hour to a fortnight (a day by default) that is never | |
| 118 | // shorter than two turn windows. | |
| 119 | export const SEATS = { min: 4, max: 6 } as const; | |
| 120 | export const TURN_SECONDS = { | |
| 121 | min: 900, | |
| 122 | max: 172_800, | |
| 123 | default: 21_600, | |
| 124 | } as const; | |
| 125 | export const DEADLINE_SECONDS = { | |
| 126 | min: 3_600, | |
| 127 | max: 1_209_600, | |
| 128 | default: 86_400, | |
| 129 | } as const; | |
| 130 | const LIST_LIMIT = { min: 1, max: 100, default: 50 } as const; | |
| 131 | ||
| 132 | /** | |
| 133 | * How many acceptances bind an accord: ceil(n/2)+1 — 3 of 4, 4 of 5, 4 of 6 | |
| 134 | * (RULES.md §2). It follows from the seat count, so no host chooses it; the | |
| 135 | * column stores what the rule says for the table's size. | |
| 136 | */ | |
| 137 | export const quorumFor = (seats: number): number => Math.ceil(seats / 2) + 1; | |
| 138 | ||
| 139 | // ---------------------------------------------------------------- reading | |
| 140 | ||
| 141 | export function listTables( | |
| 44 | 142 | db: Database, |
| 45 | 143 | who: Who, |
| 46 | input: { body: string }, | |
| 47 | ): Result<Note> { | |
| 48 | if (who.role === "anon") | |
| 49 | return fail("signin_required", "Sign in to add a note."); | |
| 50 | const body = input.body.trim(); | |
| 51 | if (body.length === 0 || body.length > BODY_MAX) | |
| 52 | return fail("invalid", `A note is 1 to ${BODY_MAX} characters.`); | |
| 53 | const note = db | |
| 54 | .query< | |
| 55 | Note, | |
| 56 | [string, string, string] | |
| 57 | >("INSERT INTO note (body, author_id, author_name) VALUES (?1, ?2, ?3) RETURNING *") | |
| 58 | .get(body, who.id, who.name)!; | |
| 59 | emit("note.added", note); | |
| 60 | return ok(note); | |
| 144 | input: { limit?: unknown } = {}, | |
| 145 | ): Result<Table[]> { | |
| 146 | const limit = whole(input.limit, LIST_LIMIT); | |
| 147 | if (limit === undefined) | |
| 148 | return fail( | |
| 149 | "invalid", | |
| 150 | `A limit is a whole number from ${LIST_LIMIT.min} to ${LIST_LIMIT.max}.`, | |
| 151 | ); | |
| 152 | const games = db | |
| 153 | .query<GameRow, [number]>("SELECT * FROM game ORDER BY id DESC LIMIT ?1") | |
| 154 | .all(limit); | |
| 155 | // One query for the seats of every table listed: the lobby is the page | |
| 156 | // most likely to ask the database the same question in a loop. | |
| 157 | const seats = seatsOf( | |
| 158 | db, | |
| 159 | games.map((game) => game.id), | |
| 160 | ); | |
| 161 | return ok(games.map((game) => table(game, seats.get(game.id) ?? [], who))); | |
| 61 | 162 | } |
| 62 | 163 | |
| 63 | export function deleteNote( | |
| 164 | export function tableState( | |
| 64 | 165 | db: Database, |
| 65 | 166 | who: Who, |
| 66 | input: { id: number }, | |
| 67 | ): Result<{ id: number }> { | |
| 68 | if (who.role === "anon") | |
| 69 | return fail("signin_required", "Sign in to remove a note."); | |
| 70 | if (!Number.isInteger(input.id)) | |
| 71 | return fail("invalid", "A note id is a whole number."); | |
| 72 | const note = db | |
| 73 | .query<Note, [number]>("SELECT * FROM note WHERE id = ?1") | |
| 74 | .get(input.id); | |
| 75 | if (note === null) return fail("not_found", "No such note."); | |
| 76 | if (!canDelete(who, note)) | |
| 77 | return fail("forbidden", "Only the author or an admin can remove a note."); | |
| 78 | db.query("DELETE FROM note WHERE id = ?1").run(note.id); | |
| 79 | emit("note.removed", { id: note.id }); | |
| 80 | return ok({ id: note.id }); | |
| 167 | input: { id: unknown }, | |
| 168 | ): Result<TableView> { | |
| 169 | const game = find(db, input.id); | |
| 170 | if (game === undefined) return fail("not_found", "No such table."); | |
| 171 | return ok(view(db, game, who)); | |
| 81 | 172 | } |
| 82 | 173 | |
| 83 | /** Whether `who` may remove `note`. The page and the terminal draw from this, so the rule lives once. */ | |
| 84 | export const canDelete = (who: Who, note: Note): boolean => | |
| 85 | who.role === "admin" || (who.role === "user" && who.id === note.author_id); | |
| 174 | // ---------------------------------------------------------------- writing | |
| 175 | ||
| 176 | export function openTable( | |
| 177 | db: Database, | |
| 178 | who: Who, | |
| 179 | input: { | |
| 180 | title: unknown; | |
| 181 | seats_max?: unknown; | |
| 182 | turn_seconds?: unknown; | |
| 183 | deadline_seconds?: unknown; | |
| 184 | }, | |
| 185 | ): Result<TableView> { | |
| 186 | if (who.role === "anon") | |
| 187 | return fail("signin_required", "Sign in to open a table."); | |
| 188 | const title = String(input.title ?? "").trim(); | |
| 189 | if (title.length === 0 || title.length > TITLE_MAX) | |
| 190 | return fail("invalid", `A title is 1 to ${TITLE_MAX} characters.`); | |
| 191 | // One line of plain text: a control character is nobody's table name, | |
| 192 | // and the terminal draws these. | |
| 193 | if (/[\u0000-\u001f\u007f]/.test(title)) | |
| 194 | return fail("invalid", "A title is one line of text."); | |
| 195 | const seats_max = whole(input.seats_max, { ...SEATS, default: SEATS.min }); | |
| 196 | if (seats_max === undefined) | |
| 197 | return fail("invalid", `A table seats ${SEATS.min} to ${SEATS.max}.`); | |
| 198 | const turn_seconds = whole(input.turn_seconds, TURN_SECONDS); | |
| 199 | if (turn_seconds === undefined) | |
| 200 | return fail( | |
| 201 | "invalid", | |
| 202 | `A turn window is ${TURN_SECONDS.min} to ${TURN_SECONDS.max} seconds.`, | |
| 203 | ); | |
| 204 | const deadline_seconds = whole(input.deadline_seconds, DEADLINE_SECONDS); | |
| 205 | if (deadline_seconds === undefined) | |
| 206 | return fail( | |
| 207 | "invalid", | |
| 208 | `A deadline is ${DEADLINE_SECONDS.min} to ${DEADLINE_SECONDS.max} seconds.`, | |
| 209 | ); | |
| 210 | if (deadline_seconds < 2 * turn_seconds) | |
| 211 | return fail("invalid", "A deadline is at least two turn windows long."); | |
| 212 | // Not the host's to choose: the rule says what a quorum is for this size. | |
| 213 | const quorum = quorumFor(seats_max); | |
| 214 | ||
| 215 | // Opening a table is sitting down at it: the host takes the first seat in | |
| 216 | // the same transaction, so a table is never open with nobody at it. | |
| 217 | const opened = db.transaction(() => { | |
| 218 | const game = db | |
| 219 | .query<GameRow, [string, string, string, number, number, number, number]>( | |
| 220 | `INSERT INTO game (title, host_id, host_name, seats_max, quorum, turn_seconds, deadline_seconds) | |
| 221 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING *`, | |
| 222 | ) | |
| 223 | .get( | |
| 224 | title, | |
| 225 | who.id, | |
| 226 | who.name, | |
| 227 | seats_max, | |
| 228 | quorum, | |
| 229 | turn_seconds, | |
| 230 | deadline_seconds, | |
| 231 | )!; | |
| 232 | sit(db, game, who); | |
| 233 | return game; | |
| 234 | })(); | |
| 235 | const state = view(db, opened, who); | |
| 236 | emit("table.opened", { table: opened.id, title: opened.title }); | |
| 237 | emit("seat.joined", { table: opened.id, player_name: who.name }); | |
| 238 | return ok(state); | |
| 239 | } | |
| 240 | ||
| 241 | export function joinTable( | |
| 242 | db: Database, | |
| 243 | who: Who, | |
| 244 | input: { id: unknown }, | |
| 245 | ): Result<TableView> { | |
| 246 | if (who.role === "anon") | |
| 247 | return fail("signin_required", "Sign in to take a seat."); | |
| 248 | const game = find(db, input.id); | |
| 249 | if (game === undefined) return fail("not_found", "No such table."); | |
| 250 | if (game.status !== "open") | |
| 251 | return fail( | |
| 252 | "conflict", | |
| 253 | game.status === "playing" | |
| 254 | ? "This table is already playing." | |
| 255 | : "This table is over.", | |
| 256 | ); | |
| 257 | const seats = seatRows(db, game.id); | |
| 258 | if (seats.some((seat) => seat.player_id === who.id)) | |
| 259 | return fail("conflict", "You already hold a seat at this table."); | |
| 260 | if (seats.length >= game.seats_max) | |
| 261 | return fail("conflict", "This table is full."); | |
| 262 | sit(db, game, who); | |
| 263 | const state = view(db, game, who); | |
| 264 | emit("seat.joined", { table: game.id, player_name: who.name }); | |
| 265 | return ok(state); | |
| 266 | } | |
| 267 | ||
| 268 | export function leaveTable( | |
| 269 | db: Database, | |
| 270 | who: Who, | |
| 271 | input: { id: unknown }, | |
| 272 | ): Result<TableView> { | |
| 273 | if (who.role === "anon") | |
| 274 | return fail("signin_required", "Sign in to leave a table."); | |
| 275 | const game = find(db, input.id); | |
| 276 | if (game === undefined) return fail("not_found", "No such table."); | |
| 277 | if (game.status !== "open") | |
| 278 | return fail("conflict", "A seat is fixed once the table starts."); | |
| 279 | const seat = db | |
| 280 | .query<SeatRow, [number, string]>( | |
| 281 | "SELECT * FROM seat WHERE game_id = ?1 AND player_id = ?2", | |
| 282 | ) | |
| 283 | .get(game.id, who.id); | |
| 284 | if (seat === null) | |
| 285 | return fail("not_found", "You do not hold a seat at this table."); | |
| 286 | db.query("DELETE FROM seat WHERE id = ?1").run(seat.id); | |
| 287 | const state = view(db, game, who); | |
| 288 | emit("seat.left", { table: game.id, player_name: who.name }); | |
| 289 | return ok(state); | |
| 290 | } | |
| 291 | ||
| 292 | // -------------------------------------------------------------- the rules | |
| 293 | // The page draws its buttons from these, so "can I?" is answered once and | |
| 294 | // the action's refusal and the button's absence can never disagree. | |
| 295 | ||
| 296 | export const canJoin = (who: Who, table: Table): boolean => | |
| 297 | who.role !== "anon" && | |
| 298 | table.status === "open" && | |
| 299 | table.your_seat === null && | |
| 300 | table.seated < table.seats_max; | |
| 301 | ||
| 302 | export const canLeave = (who: Who, table: Table): boolean => | |
| 303 | who.role !== "anon" && table.status === "open" && table.your_seat !== null; | |
| 304 | ||
| 305 | /** | |
| 306 | * Whether `who` may read what `seat` is playing for. A charge is the game: | |
| 307 | * its holder sees it from the moment it is dealt, everyone else only once | |
| 308 | * the table has settled and the hands are on the table. | |
| 309 | */ | |
| 310 | const seesCharge = (who: Who, game: GameRow, seat: SeatRow): boolean => | |
| 311 | game.status === "settled" || | |
| 312 | (who.role !== "anon" && who.id === seat.player_id); | |
| 313 | ||
| 314 | // ------------------------------------------------------------- the shapes | |
| 315 | ||
| 316 | /** A game and its seats as `who` may see them. The one place a charge is redacted. */ | |
| 317 | function table(game: GameRow, seats: readonly SeatRow[], who: Who): Table { | |
| 318 | return { | |
| 319 | id: game.id, | |
| 320 | title: game.title, | |
| 321 | status: game.status, | |
| 322 | host_id: game.host_id, | |
| 323 | host_name: game.host_name, | |
| 324 | seats_max: game.seats_max, | |
| 325 | quorum: game.quorum, | |
| 326 | turn_seconds: game.turn_seconds, | |
| 327 | deadline_seconds: game.deadline_seconds, | |
| 328 | created_at: game.created_at, | |
| 329 | started_at: game.started_at, | |
| 330 | settled_at: game.settled_at, | |
| 331 | seats: seats.map((seat) => ({ | |
| 332 | id: seat.id, | |
| 333 | ordinal: seat.ordinal, | |
| 334 | player_id: seat.player_id, | |
| 335 | player_name: seat.player_name, | |
| 336 | joined_at: seat.joined_at, | |
| 337 | score: seat.score, | |
| 338 | charge: seesCharge(who, game, seat) ? parse(seat.charge_json) : null, | |
| 339 | })), | |
| 340 | seated: seats.length, | |
| 341 | your_seat: | |
| 342 | seats.find((seat) => who.role !== "anon" && seat.player_id === who.id) | |
| 343 | ?.id ?? null, | |
| 344 | }; | |
| 345 | } | |
| 346 | ||
| 347 | /** The whole table as `who` may see it: seats, the act log, and the charter. */ | |
| 348 | function view(db: Database, game: GameRow, who: Who): TableView { | |
| 349 | return { | |
| 350 | ...table(game, seatRows(db, game.id), who), | |
| 351 | // Dealt from the seed when play starts; until then there is nothing to | |
| 352 | // argue over yet (#8 fills this in through src/board.ts). | |
| 353 | charter: null, | |
| 354 | acts: db | |
| 355 | .query<ActRow, [number]>( | |
| 356 | "SELECT * FROM act WHERE game_id = ?1 ORDER BY id", | |
| 357 | ) | |
| 358 | .all(game.id) | |
| 359 | .map((act) => ({ | |
| 360 | id: act.id, | |
| 361 | seat_id: act.seat_id, | |
| 362 | kind: act.kind, | |
| 363 | accord: parse(act.accord_json), | |
| 364 | at: act.at, | |
| 365 | })), | |
| 366 | }; | |
| 367 | } | |
| 368 | ||
| 369 | // ------------------------------------------------------------ the plumbing | |
| 370 | ||
| 371 | const find = (db: Database, id: unknown): GameRow | undefined => { | |
| 372 | // A whole number, or the text of one. Nothing else is an id: `Number(true)` | |
| 373 | // is 1, and a table is not named by a boolean somebody sent us. | |
| 374 | if (typeof id !== "number" && typeof id !== "string") return undefined; | |
| 375 | const n = Number(id); | |
| 376 | if (!Number.isInteger(n)) return undefined; | |
| 377 | return ( | |
| 378 | db.query<GameRow, [number]>("SELECT * FROM game WHERE id = ?1").get(n) ?? | |
| 379 | undefined | |
| 380 | ); | |
| 381 | }; | |
| 382 | ||
| 383 | const seatRows = (db: Database, gameId: number): SeatRow[] => | |
| 384 | db | |
| 385 | .query<SeatRow, [number]>( | |
| 386 | "SELECT * FROM seat WHERE game_id = ?1 ORDER BY ordinal", | |
| 387 | ) | |
| 388 | .all(gameId); | |
| 389 | ||
| 390 | /** The seats of many tables, by table, in one query. */ | |
| 391 | function seatsOf(db: Database, gameIds: number[]): Map<number, SeatRow[]> { | |
| 392 | const by = new Map<number, SeatRow[]>(); | |
| 393 | if (gameIds.length === 0) return by; | |
| 394 | const holes = gameIds.map((_, i) => `?${i + 1}`).join(", "); | |
| 395 | for (const seat of db | |
| 396 | .query<SeatRow, number[]>( | |
| 397 | `SELECT * FROM seat WHERE game_id IN (${holes}) ORDER BY ordinal`, | |
| 398 | ) | |
| 399 | .all(...gameIds)) { | |
| 400 | const seats = by.get(seat.game_id); | |
| 401 | if (seats === undefined) by.set(seat.game_id, [seat]); | |
| 402 | else seats.push(seat); | |
| 403 | } | |
| 404 | return by; | |
| 405 | } | |
| 406 | ||
| 407 | /** | |
| 408 | * Seat `who` at the next free place in the order. Ordinals may leave gaps | |
| 409 | * while people come and go; the order is renumbered when play starts. | |
| 410 | */ | |
| 411 | const sit = ( | |
| 412 | db: Database, | |
| 413 | game: GameRow, | |
| 414 | who: { id: string; name: string }, | |
| 415 | ): void => { | |
| 416 | db.query<SeatRow, [number, string, string]>( | |
| 417 | `INSERT INTO seat (game_id, player_id, player_name, ordinal) | |
| 418 | VALUES (?1, ?2, ?3, (SELECT coalesce(max(ordinal), -1) + 1 FROM seat WHERE game_id = ?1))`, | |
| 419 | ).run(game.id, who.id, who.name); | |
| 420 | }; | |
| 421 | ||
| 422 | /** A whole number inside its bounds, its default when unsaid, `undefined` when neither. */ | |
| 423 | function whole( | |
| 424 | value: unknown, | |
| 425 | bounds: { min: number; max: number; default: number }, | |
| 426 | ): number | undefined { | |
| 427 | if (value === undefined || value === null || value === "") | |
| 428 | return bounds.default; | |
| 429 | const n = typeof value === "number" ? value : Number(String(value).trim()); | |
| 430 | if (!Number.isInteger(n) || n < bounds.min || n > bounds.max) | |
| 431 | return undefined; | |
| 432 | return n; | |
| 433 | } | |
| 434 | ||
| 435 | /** JSON written by this app, read back. Never a place a stranger's bytes land. */ | |
| 436 | const parse = (json: string | null): unknown => { | |
| 437 | if (json === null) return null; | |
| 438 | try { | |
| 439 | return JSON.parse(json); | |
| 440 | } catch { | |
| 441 | return null; | |
| 442 | } | |
| 443 | }; |
src/db.ts +56
| @@ -24,6 +24,62 @@ export const migrations: string[] = [ | ||
| 24 | 24 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| 25 | 25 | )`, |
| 26 | 26 | `CREATE INDEX note_author ON note (author_id)`, |
| 27 | ||
| 28 | // The game. A table is opened by a host, sat at by four to six players, | |
| 29 | // and played out as a ledger of acts. `seed` is what the board is dealt | |
| 30 | // from once play starts, and is secret until the reveal: it names every | |
| 31 | // seat's charge. `quorum` is how many seats must accept an accord for it | |
| 32 | // to bind; the two clocks are in seconds, so an hour is an hour wherever | |
| 33 | // the reader is. | |
| 34 | `CREATE TABLE game ( | |
| 35 | id INTEGER PRIMARY KEY, | |
| 36 | title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 60), | |
| 37 | status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'playing', 'settled', 'impounded')), | |
| 38 | host_id TEXT NOT NULL, | |
| 39 | host_name TEXT NOT NULL, | |
| 40 | seed TEXT, | |
| 41 | seats_max INTEGER NOT NULL CHECK (seats_max BETWEEN 4 AND 6), | |
| 42 | quorum INTEGER NOT NULL CHECK (quorum BETWEEN 2 AND seats_max), | |
| 43 | turn_seconds INTEGER NOT NULL CHECK (turn_seconds > 0), | |
| 44 | deadline_seconds INTEGER NOT NULL CHECK (deadline_seconds >= turn_seconds), | |
| 45 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 46 | started_at TEXT, | |
| 47 | settled_at TEXT | |
| 48 | )`, | |
| 49 | `CREATE INDEX game_status ON game (status, id)`, | |
| 50 | ||
| 51 | // A seat is one forge identity at one table: at most one per person, at | |
| 52 | // most one per place in the order. `charge_json` is that seat's secret | |
| 53 | // aims — the redaction that keeps it secret lives in `tableState`. | |
| 54 | `CREATE TABLE seat ( | |
| 55 | id INTEGER PRIMARY KEY, | |
| 56 | game_id INTEGER NOT NULL REFERENCES game (id) ON DELETE CASCADE, | |
| 57 | player_id TEXT NOT NULL, | |
| 58 | player_name TEXT NOT NULL, | |
| 59 | ordinal INTEGER NOT NULL, | |
| 60 | charge_json TEXT, | |
| 61 | score INTEGER, | |
| 62 | joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 63 | UNIQUE (game_id, player_id), | |
| 64 | UNIQUE (game_id, ordinal) | |
| 65 | )`, | |
| 66 | `CREATE INDEX seat_player ON seat (player_id)`, | |
| 67 | ||
| 68 | // Every act, in the order it was made: the turn engine reads this and | |
| 69 | // stores nothing twice. `seat_id` is null for an act the table itself | |
| 70 | // made (a deal, an impound). | |
| 71 | `CREATE TABLE act ( | |
| 72 | id INTEGER PRIMARY KEY, | |
| 73 | game_id INTEGER NOT NULL REFERENCES game (id) ON DELETE CASCADE, | |
| 74 | seat_id INTEGER REFERENCES seat (id) ON DELETE SET NULL, | |
| 75 | kind TEXT NOT NULL, | |
| 76 | accord_json TEXT, | |
| 77 | at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) | |
| 78 | )`, | |
| 79 | `CREATE INDEX act_game ON act (game_id, id)`, | |
| 80 | ||
| 81 | // The demo the app was born from. Its rows were never anyone's game. | |
| 82 | `DROP TABLE note`, | |
| 27 | 83 | ]; |
| 28 | 84 | |
| 29 | 85 | export function openDb(dir = dataDir): Database { |
src/http.ts +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The floor every response stands on, and the few things a route needs to |
| 3 | * read a request safely. Nothing in here knows what a note is. | |
| 3 | * read a request safely. Nothing in here knows what a table is. | |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import { createHash } from "node:crypto"; |
src/mcp.ts +76 −21
| @@ -3,11 +3,24 @@ | ||
| 3 | 3 | * message and answers with JSON (streamable HTTP without the stream — |
| 4 | 4 | * nothing here needs to push). The tools are the actions, called with the |
| 5 | 5 | * same identity the page gets, so an agent speaking MCP through the forge |
| 6 | * is the person whose grant it carries, and a bare one is anonymous. | |
| 6 | * is the person whose grant it carries, and a bare one is anonymous. An | |
| 7 | * agent that holds a seat reads its own charge here and no one else's: | |
| 8 | * the redaction is in the action, not in this door. | |
| 7 | 9 | */ |
| 8 | 10 | |
| 9 | 11 | import type { Database } from "bun:sqlite"; |
| 10 | import { addNote, deleteNote, listNotes, type Result } from "./actions.ts"; | |
| 12 | import { | |
| 13 | DEADLINE_SECONDS, | |
| 14 | joinTable, | |
| 15 | leaveTable, | |
| 16 | listTables, | |
| 17 | openTable, | |
| 18 | SEATS, | |
| 19 | tableState, | |
| 20 | TITLE_MAX, | |
| 21 | TURN_SECONDS, | |
| 22 | type Result, | |
| 23 | } from "./actions.ts"; | |
| 11 | 24 | import { title } from "./env.ts"; |
| 12 | 25 | import { fields } from "./http.ts"; |
| 13 | 26 | import type { Who } from "./who.ts"; |
| @@ -23,44 +36,86 @@ interface Tool { | ||
| 23 | 36 | ) => Result<unknown>; |
| 24 | 37 | } |
| 25 | 38 | |
| 39 | const tableId = { | |
| 40 | type: "object", | |
| 41 | properties: { id: { type: "integer", description: "The table's id." } }, | |
| 42 | required: ["id"], | |
| 43 | } as const; | |
| 44 | ||
| 26 | 45 | export const tools: readonly Tool[] = [ |
| 27 | 46 | { |
| 28 | name: "list_notes", | |
| 29 | description: "The notes, newest first.", | |
| 47 | name: "list_tables", | |
| 48 | description: "The tables, newest first, with who is seated at each.", | |
| 30 | 49 | inputSchema: { |
| 31 | 50 | type: "object", |
| 32 | 51 | properties: { |
| 33 | 52 | limit: { |
| 34 | 53 | type: "integer", |
| 35 | 54 | minimum: 1, |
| 36 | maximum: 500, | |
| 37 | description: "At most this many (100).", | |
| 55 | maximum: 100, | |
| 56 | description: "At most this many (50).", | |
| 38 | 57 | }, |
| 39 | 58 | }, |
| 40 | 59 | }, |
| 41 | run: (db, who, args) => | |
| 42 | listNotes(db, who, { limit: Number(args.limit ?? 100) }), | |
| 60 | run: (db, who, args) => listTables(db, who, { limit: args.limit }), | |
| 43 | 61 | }, |
| 44 | 62 | { |
| 45 | name: "add_note", | |
| 63 | name: "table_state", | |
| 46 | 64 | description: |
| 47 | "Add a note as the signed-in person. Anonymous callers cannot.", | |
| 48 | inputSchema: { | |
| 49 | type: "object", | |
| 50 | properties: { body: { type: "string", maxLength: 500 } }, | |
| 51 | required: ["body"], | |
| 52 | }, | |
| 53 | run: (db, who, args) => addNote(db, who, { body: String(args.body ?? "") }), | |
| 65 | "One table: its charter, seats, status and act log — and your own charge if you hold a seat.", | |
| 66 | inputSchema: tableId, | |
| 67 | run: (db, who, args) => tableState(db, who, { id: args.id }), | |
| 54 | 68 | }, |
| 55 | 69 | { |
| 56 | name: "delete_note", | |
| 57 | description: "Remove a note by id. Only its author or an admin can.", | |
| 70 | name: "open_table", | |
| 71 | description: | |
| 72 | "Open a table and take the first seat. Anonymous callers cannot.", | |
| 58 | 73 | inputSchema: { |
| 59 | 74 | type: "object", |
| 60 | properties: { id: { type: "integer" } }, | |
| 61 | required: ["id"], | |
| 75 | properties: { | |
| 76 | title: { type: "string", minLength: 1, maxLength: TITLE_MAX }, | |
| 77 | seats_max: { | |
| 78 | type: "integer", | |
| 79 | minimum: SEATS.min, | |
| 80 | maximum: SEATS.max, | |
| 81 | description: `Seats at the table, ${SEATS.min} to ${SEATS.max} (${SEATS.min}).`, | |
| 82 | }, | |
| 83 | turn_seconds: { | |
| 84 | type: "integer", | |
| 85 | minimum: TURN_SECONDS.min, | |
| 86 | maximum: TURN_SECONDS.max, | |
| 87 | description: "The turn window in seconds (6 hours by default).", | |
| 88 | }, | |
| 89 | deadline_seconds: { | |
| 90 | type: "integer", | |
| 91 | minimum: DEADLINE_SECONDS.min, | |
| 92 | maximum: DEADLINE_SECONDS.max, | |
| 93 | description: | |
| 94 | "The whole game's clock in seconds (24 hours by default), never less than two turn windows.", | |
| 95 | }, | |
| 96 | }, | |
| 97 | required: ["title"], | |
| 62 | 98 | }, |
| 63 | run: (db, who, args) => deleteNote(db, who, { id: Number(args.id) }), | |
| 99 | run: (db, who, args) => | |
| 100 | openTable(db, who, { | |
| 101 | title: args.title, | |
| 102 | seats_max: args.seats_max, | |
| 103 | turn_seconds: args.turn_seconds, | |
| 104 | deadline_seconds: args.deadline_seconds, | |
| 105 | }), | |
| 106 | }, | |
| 107 | { | |
| 108 | name: "join_table", | |
| 109 | description: | |
| 110 | "Take a seat at an open table. One seat per person, while seats are left.", | |
| 111 | inputSchema: tableId, | |
| 112 | run: (db, who, args) => joinTable(db, who, { id: args.id }), | |
| 113 | }, | |
| 114 | { | |
| 115 | name: "leave_table", | |
| 116 | description: "Give up your seat. Only before the table starts.", | |
| 117 | inputSchema: tableId, | |
| 118 | run: (db, who, args) => leaveTable(db, who, { id: args.id }), | |
| 64 | 119 | }, |
| 65 | 120 | ]; |
| 66 | 121 |
src/page.ts +49 −32
| @@ -1,10 +1,10 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The page, rendered on the server: the first paint is complete HTML, and |
| 3 | * `static/live.js` keeps the notes fresh afterwards. When a second page | |
| 3 | * `static/live.js` keeps the table list fresh afterwards. When a second page | |
| 4 | 4 | * arrives, move `layout` to `src/pages/layout.ts` and give each page a file. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { canDelete, type Note } from "./actions.ts"; | |
| 7 | import { canJoin, canLeave, SEATS, TITLE_MAX, type Table } from "./actions.ts"; | |
| 8 | 8 | import { commit, description, env, ssh, title, where } from "./env.ts"; |
| 9 | 9 | import { asset, escape } from "./http.ts"; |
| 10 | 10 | import type { Who } from "./who.ts"; |
| @@ -39,7 +39,7 @@ export function layout(body: string, who: Who): string { | ||
| 39 | 39 | <main>${body}</main> |
| 40 | 40 | <footer class="muted"> |
| 41 | 41 | ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} |
| 42 | <p>Also at <a href="/api/notes">/api/notes</a> and <code>POST /mcp</code>, with the same rules.</p> | |
| 42 | <p>Also at <a href="/api/tables">/api/tables</a> and <code>POST /mcp</code>, with the same rules.</p> | |
| 43 | 43 | </footer> |
| 44 | 44 | </div> |
| 45 | 45 | </body> |
| @@ -49,48 +49,65 @@ export function layout(body: string, who: Who): string { | ||
| 49 | 49 | |
| 50 | 50 | export function home(input: { |
| 51 | 51 | who: Who; |
| 52 | notes: readonly Note[]; | |
| 52 | tables: readonly Table[]; | |
| 53 | 53 | notice?: string | undefined; |
| 54 | 54 | }): string { |
| 55 | const { who, notes, notice } = input; | |
| 56 | const composer = | |
| 55 | const { who, tables, notice } = input; | |
| 56 | const opener = | |
| 57 | 57 | who.role === "anon" |
| 58 | ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=/">Sign in</a> to add a note.</p>` | |
| 59 | : `<form class="row" method="post" action="/notes"> | |
| 60 | <input class="input" name="body" placeholder="Write a note" aria-label="New note" required maxlength="500" autocomplete="off" autofocus> | |
| 61 | <button class="button">Add</button> | |
| 58 | ? `<p class="muted">Watching as a guest. <a href="/__forge/signin?to=/">Sign in</a> to open a table or take a seat.</p>` | |
| 59 | : `<form class="row wrap" method="post" action="/tables"> | |
| 60 | <input class="input" name="title" placeholder="Name a table" aria-label="Table title" required maxlength="${TITLE_MAX}" autocomplete="off"> | |
| 61 | <label class="seats-pick">Seats | |
| 62 | <select class="input" name="seats_max" aria-label="Seats at the table">${seatOptions()}</select> | |
| 63 | </label> | |
| 64 | <button class="button">Open</button> | |
| 62 | 65 | </form>`; |
| 63 | 66 | const list = |
| 64 | notes.length === 0 | |
| 65 | ? `<p class="empty">No notes yet. Notes written here stay in ${escape(where)} data.</p>` | |
| 66 | : `<ul class="list">${notes.map((note) => item(note, who)).join("")}</ul>`; | |
| 67 | tables.length === 0 | |
| 68 | ? `<p class="empty">No tables yet. A table opened here stays in ${escape(where)} data.</p>` | |
| 69 | : `<ul class="list">${tables.map((table) => item(table, who)).join("")}</ul>`; | |
| 67 | 70 | return layout( |
| 68 | `<section class="card">${composer}</section> | |
| 69 | <section id="notes" data-live="note.added note.removed">${list}</section> | |
| 71 | `<section class="card">${opener}</section> | |
| 72 | <section id="tables" data-live="table.opened seat.joined seat.left">${list}</section> | |
| 70 | 73 | ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`, |
| 71 | 74 | who, |
| 72 | 75 | ); |
| 73 | 76 | } |
| 74 | 77 | |
| 75 | const item = (note: Note, who: Who): string => `<li class="note"> | |
| 78 | const seatOptions = (): string => | |
| 79 | Array.from({ length: SEATS.max - SEATS.min + 1 }, (_, i) => SEATS.min + i) | |
| 80 | .map((n) => `<option value="${n}">${n}</option>`) | |
| 81 | .join(""); | |
| 82 | ||
| 83 | const item = (table: Table, who: Who): string => `<li class="item"> | |
| 76 | 84 | <div> |
| 77 | <p>${escape(note.body)}</p> | |
| 78 | <small class="muted">${escape(note.author_name)} · <time datetime="${escape(note.created_at)}">${ago(note.created_at)}</time></small> | |
| 85 | <p><b>${escape(table.title)}</b> <span class="pill">${escape(table.status)}</span></p> | |
| 86 | <p class="muted">${table.seated}/${table.seats_max} seated · ${escape(names(table))}</p> | |
| 87 | <small class="muted">Opened by ${escape(table.host_name)} · ${duration(table.turn_seconds)} a turn · ${duration(table.deadline_seconds)} in all · <a href="/api/tables/${table.id}">state</a></small> | |
| 79 | 88 | </div> |
| 80 | ${ | |
| 81 | canDelete(who, note) | |
| 82 | ? `<form method="post" action="/notes/${note.id}/delete"><button class="button button-quiet" aria-label="Remove this note">Remove</button></form>` | |
| 83 | : "" | |
| 84 | } | |
| 89 | ${seatButton(table, who)} | |
| 85 | 90 | </li>`; |
| 86 | 91 | |
| 87 | const ago = (iso: string): string => { | |
| 88 | const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000); | |
| 89 | if (s < 60) return "just now"; | |
| 90 | if (s < 3600) return `${Math.floor(s / 60)}m ago`; | |
| 91 | if (s < 86400) return `${Math.floor(s / 3600)}h ago`; | |
| 92 | return new Date(iso).toLocaleDateString("en", { | |
| 93 | month: "short", | |
| 94 | day: "numeric", | |
| 95 | }); | |
| 92 | const names = (table: Table): string => | |
| 93 | table.seats.length === 0 | |
| 94 | ? "nobody yet" | |
| 95 | : table.seats.map((seat) => seat.player_name).join(", "); | |
| 96 | ||
| 97 | // The page never decides who may sit: `canJoin` and `canLeave` are the | |
| 98 | // action's own rules, so a button is there exactly when the action agrees. | |
| 99 | const seatButton = (table: Table, who: Who): string => { | |
| 100 | if (canLeave(who, table)) | |
| 101 | return `<form method="post" action="/tables/${table.id}/leave"><button class="button button-quiet" aria-label="Leave ${escape(table.title)}">Leave</button></form>`; | |
| 102 | if (canJoin(who, table)) | |
| 103 | return `<form method="post" action="/tables/${table.id}/join"><button class="button" aria-label="Sit at ${escape(table.title)}">Sit</button></form>`; | |
| 104 | return ""; | |
| 105 | }; | |
| 106 | ||
| 107 | /** Seconds as a person would say them: 6h, 3d, 45m. */ | |
| 108 | const duration = (seconds: number): string => { | |
| 109 | if (seconds >= 86_400) return `${Math.round(seconds / 86_400)}d`; | |
| 110 | if (seconds >= 3_600) return `${Math.round(seconds / 3_600)}h`; | |
| 111 | if (seconds >= 60) return `${Math.round(seconds / 60)}m`; | |
| 112 | return `${seconds}s`; | |
| 96 | 113 | }; |
src/result.ts +11 −1
| @@ -5,7 +5,17 @@ | ||
| 5 | 5 | * dragging the database in behind them. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; | |
| 8 | export type Failure = | |
| 9 | | "signin_required" | |
| 10 | | "forbidden" | |
| 11 | | "not_found" | |
| 12 | | "invalid" | |
| 13 | /** | |
| 14 | * The request was well formed and the thing it asked for was in no state | |
| 15 | * to take it: a seat at a full table, a second seat at the same one, a | |
| 16 | * table that is already playing. | |
| 17 | */ | |
| 18 | | "conflict"; | |
| 9 | 19 | |
| 10 | 20 | export type Result<T> = |
| 11 | 21 | | { ok: true; value: T } |
src/routes.ts +52 −17
| @@ -6,9 +6,11 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import { |
| 9 | addNote, | |
| 10 | deleteNote, | |
| 11 | listNotes, | |
| 9 | joinTable, | |
| 10 | leaveTable, | |
| 11 | listTables, | |
| 12 | openTable, | |
| 13 | tableState, | |
| 12 | 14 | type Failure, |
| 13 | 15 | type Result, |
| 14 | 16 | } from "./actions.ts"; |
| @@ -47,6 +49,7 @@ const STATUS: Record<Failure, number> = { | ||
| 47 | 49 | forbidden: 403, |
| 48 | 50 | not_found: 404, |
| 49 | 51 | invalid: 422, |
| 52 | conflict: 409, | |
| 50 | 53 | }; |
| 51 | 54 | |
| 52 | 55 | /** A result as JSON: the value, or `{ error, message }` with the matching status. */ |
| @@ -62,6 +65,14 @@ const answer = <T>(result: Result<T>, status = 200): Response => | ||
| 62 | 65 | const done = <T>(result: Result<T>): Response => |
| 63 | 66 | result.ok ? back() : back(result.message); |
| 64 | 67 | |
| 68 | /** The fields of an opening, wherever they came from: a form, or a JSON body. */ | |
| 69 | const opening = (get: (name: string) => unknown) => ({ | |
| 70 | title: String(get("title") ?? ""), | |
| 71 | seats_max: get("seats_max"), | |
| 72 | turn_seconds: get("turn_seconds"), | |
| 73 | deadline_seconds: get("deadline_seconds"), | |
| 74 | }); | |
| 75 | ||
| 65 | 76 | export const routes: readonly Route[] = [ |
| 66 | 77 | // The page. Forms post and come back; the list stays live over /events. |
| 67 | 78 | { |
| @@ -69,9 +80,9 @@ export const routes: readonly Route[] = [ | ||
| 69 | 80 | path: "/", |
| 70 | 81 | handle: (c) => { |
| 71 | 82 | const { notice, clear } = readNotice(c.request); |
| 72 | const notes = listNotes(c.db, c.who); | |
| 83 | const tables = listTables(c.db, c.who); | |
| 73 | 84 | return html( |
| 74 | home({ who: c.who, notes: notes.ok ? notes.value : [], notice }), | |
| 85 | home({ who: c.who, tables: tables.ok ? tables.value : [], notice }), | |
| 75 | 86 | 200, |
| 76 | 87 | clear, |
| 77 | 88 | ); |
| @@ -79,48 +90,72 @@ export const routes: readonly Route[] = [ | ||
| 79 | 90 | }, |
| 80 | 91 | { |
| 81 | 92 | method: "POST", |
| 82 | path: "/notes", | |
| 93 | path: "/tables", | |
| 83 | 94 | handle: async (c) => { |
| 84 | 95 | const form = await readForm(c.request); |
| 85 | 96 | if (form === undefined) return text("too large", 413); |
| 86 | 97 | return done( |
| 87 | addNote(c.db, c.who, { body: String(form.get("body") ?? "") }), | |
| 98 | openTable( | |
| 99 | c.db, | |
| 100 | c.who, | |
| 101 | opening((name) => form.get(name) ?? undefined), | |
| 102 | ), | |
| 88 | 103 | ); |
| 89 | 104 | }, |
| 90 | 105 | }, |
| 91 | 106 | { |
| 92 | 107 | method: "POST", |
| 93 | path: "/notes/:id/delete", | |
| 94 | handle: (c) => done(deleteNote(c.db, c.who, { id: Number(c.params.id) })), | |
| 108 | path: "/tables/:id/join", | |
| 109 | handle: (c) => done(joinTable(c.db, c.who, { id: c.params.id })), | |
| 110 | }, | |
| 111 | { | |
| 112 | method: "POST", | |
| 113 | path: "/tables/:id/leave", | |
| 114 | handle: (c) => done(leaveTable(c.db, c.who, { id: c.params.id })), | |
| 95 | 115 | }, |
| 96 | 116 | |
| 97 | 117 | // The JSON API: the same actions, the same identity, plain status codes. |
| 98 | 118 | { |
| 99 | 119 | method: "GET", |
| 100 | path: "/api/notes", | |
| 120 | path: "/api/tables", | |
| 101 | 121 | handle: (c) => |
| 102 | 122 | answer( |
| 103 | listNotes(c.db, c.who, { | |
| 104 | limit: Number(c.url.searchParams.get("limit") ?? 100), | |
| 123 | listTables(c.db, c.who, { | |
| 124 | limit: c.url.searchParams.get("limit") ?? undefined, | |
| 105 | 125 | }), |
| 106 | 126 | ), |
| 107 | 127 | }, |
| 108 | 128 | { |
| 109 | 129 | method: "POST", |
| 110 | path: "/api/notes", | |
| 130 | path: "/api/tables", | |
| 111 | 131 | handle: async (c) => { |
| 112 | 132 | const body = await readJson(c.request); |
| 113 | 133 | if (body === undefined) return json({ error: "too_large" }, 413); |
| 134 | const given = fields(body); | |
| 114 | 135 | return answer( |
| 115 | addNote(c.db, c.who, { body: String(fields(body).body ?? "") }), | |
| 136 | openTable( | |
| 137 | c.db, | |
| 138 | c.who, | |
| 139 | opening((name) => given[name]), | |
| 140 | ), | |
| 116 | 141 | 201, |
| 117 | 142 | ); |
| 118 | 143 | }, |
| 119 | 144 | }, |
| 120 | 145 | { |
| 121 | method: "DELETE", | |
| 122 | path: "/api/notes/:id", | |
| 123 | handle: (c) => answer(deleteNote(c.db, c.who, { id: Number(c.params.id) })), | |
| 146 | method: "GET", | |
| 147 | path: "/api/tables/:id", | |
| 148 | handle: (c) => answer(tableState(c.db, c.who, { id: c.params.id })), | |
| 149 | }, | |
| 150 | { | |
| 151 | method: "POST", | |
| 152 | path: "/api/tables/:id/join", | |
| 153 | handle: (c) => answer(joinTable(c.db, c.who, { id: c.params.id })), | |
| 154 | }, | |
| 155 | { | |
| 156 | method: "POST", | |
| 157 | path: "/api/tables/:id/leave", | |
| 158 | handle: (c) => answer(leaveTable(c.db, c.who, { id: c.params.id })), | |
| 124 | 159 | }, |
| 125 | 160 | |
| 126 | 161 | // Live events, and the MCP server (the tools are the actions too). |
src/server.test.ts +185 −70
| @@ -1,3 +1,4 @@ | ||
| 1 | import { Database } from "bun:sqlite"; | |
| 1 | 2 | import { afterAll, expect, test } from "bun:test"; |
| 2 | 3 | import { mkdtempSync } from "node:fs"; |
| 3 | 4 | import { tmpdir } from "node:os"; |
| @@ -7,11 +8,8 @@ import { serve } from "./server.ts"; | ||
| 7 | 8 | import { sign } from "./who.ts"; |
| 8 | 9 | |
| 9 | 10 | const KEY = "a-key-only-the-forge-holds"; |
| 10 | const server = serve({ | |
| 11 | port: 0, | |
| 12 | dataDir: mkdtempSync(join(tmpdir(), "app-")), | |
| 13 | identityKey: KEY, | |
| 14 | }); | |
| 11 | const dataDir = mkdtempSync(join(tmpdir(), "app-")); | |
| 12 | const server = serve({ port: 0, dataDir, identityKey: KEY }); | |
| 15 | 13 | const base = `http://${server.hostname}:${server.port}`; |
| 16 | 14 | afterAll(() => server.stop(true)); |
| 17 | 15 | |
| @@ -47,22 +45,37 @@ const rpc = (message: unknown, who: Record<string, string> = {}) => | ||
| 47 | 45 | api("/mcp", { method: "POST", ...jsonBody(message) }, who).then( |
| 48 | 46 | (r) => r.json() as Promise<any>, |
| 49 | 47 | ); |
| 48 | /** A table over the API, as a person, and its id. */ | |
| 49 | const openTable = async ( | |
| 50 | who: Record<string, string>, | |
| 51 | title: string, | |
| 52 | more: Record<string, unknown> = {}, | |
| 53 | ): Promise<number> => { | |
| 54 | const created = await api( | |
| 55 | "/api/tables", | |
| 56 | { method: "POST", ...jsonBody({ title, ...more }) }, | |
| 57 | who, | |
| 58 | ); | |
| 59 | expect(created.status).toBe(201); | |
| 60 | return ((await created.json()) as { id: number }).id; | |
| 61 | }; | |
| 50 | 62 | |
| 51 | 63 | test("the page knows who you are and offers the right door", async () => { |
| 52 | 64 | const guest = await (await fetch(base + "/")).text(); |
| 53 | 65 | expect(guest).toContain("<h1>"); |
| 54 | 66 | expect(guest).toContain("/__forge/signin?to=/"); |
| 55 | expect(guest).not.toContain('action="/notes"'); | |
| 67 | expect(guest).not.toContain('action="/tables"'); | |
| 56 | 68 | const signed = await (await api("/", {}, alice)).text(); |
| 57 | 69 | expect(signed).toContain("<b>alice</b>"); |
| 58 | 70 | expect(signed).toContain("/__forge/signout"); |
| 59 | expect(signed).toContain('action="/notes"'); | |
| 71 | expect(signed).toContain('action="/tables"'); | |
| 60 | 72 | }); |
| 61 | 73 | |
| 62 | test("a form post adds as the person; a guest is sent to sign in", async () => { | |
| 74 | test("a form opens a table and takes a seat; a guest is sent to sign in", async () => { | |
| 63 | 75 | const form = new FormData(); |
| 64 | form.set("body", "hello from a form"); | |
| 65 | const refused = await api("/notes", { | |
| 76 | form.set("title", "The Wreck of the Fathom"); | |
| 77 | form.set("seats_max", "5"); | |
| 78 | const refused = await api("/tables", { | |
| 66 | 79 | method: "POST", |
| 67 | 80 | body: form, |
| 68 | 81 | redirect: "manual", |
| @@ -71,55 +84,126 @@ test("a form post adds as the person; a guest is sent to sign in", async () => { | ||
| 71 | 84 | expect(refused.headers.get("set-cookie")).toContain("notice="); |
| 72 | 85 | expect(refused.headers.get("set-cookie")).toContain("SameSite=Lax"); |
| 73 | 86 | expect(await (await fetch(base + "/")).text()).not.toContain( |
| 74 | "hello from a form", | |
| 87 | "The Wreck of the Fathom", | |
| 75 | 88 | ); |
| 89 | ||
| 76 | 90 | const posted = await api( |
| 77 | "/notes", | |
| 91 | "/tables", | |
| 78 | 92 | { method: "POST", body: form, redirect: "manual" }, |
| 79 | 93 | alice, |
| 80 | 94 | ); |
| 81 | 95 | expect(posted.status).toBe(303); |
| 82 | 96 | expect(posted.headers.get("set-cookie")).toBeNull(); |
| 83 | 97 | const page = await (await api("/", {}, alice)).text(); |
| 84 | expect(page).toContain("hello from a form"); | |
| 85 | expect(page).toContain("alice ·"); | |
| 98 | expect(page).toContain("The Wreck of the Fathom"); | |
| 99 | expect(page).toContain("1/5 seated"); | |
| 100 | // The host is seated, so the page offers them the way out, not a way in. | |
| 101 | expect(page).toContain("Leave"); | |
| 102 | ||
| 103 | const asBob = await (await api("/", {}, bob)).text(); | |
| 104 | expect(asBob).toContain("Sit at The Wreck of the Fathom"); | |
| 105 | const seen = /action="\/tables\/(\d+)\/join"/.exec(asBob)?.[1]; | |
| 106 | expect(seen).toBeDefined(); | |
| 107 | const sat = await api( | |
| 108 | `/tables/${seen}/join`, | |
| 109 | { method: "POST", redirect: "manual" }, | |
| 110 | bob, | |
| 111 | ); | |
| 112 | expect(sat.status).toBe(303); | |
| 113 | expect(sat.headers.get("set-cookie")).toBeNull(); | |
| 114 | expect(await (await fetch(base + "/")).text()).toContain("2/5 seated"); | |
| 86 | 115 | }); |
| 87 | 116 | |
| 88 | 117 | test("the JSON API is the same actions with the same identity", async () => { |
| 89 | const created = await api( | |
| 90 | "/api/notes", | |
| 91 | { method: "POST", ...jsonBody({ body: "over the api" }) }, | |
| 92 | alice, | |
| 93 | ); | |
| 94 | expect(created.status).toBe(201); | |
| 95 | const note = (await created.json()) as { id: number; author_name: string }; | |
| 96 | expect(note.author_name).toBe("alice"); | |
| 97 | const listed = (await (await fetch(base + "/api/notes")).json()) as { | |
| 118 | const id = await openTable(alice, "The Salvage Hearing"); | |
| 119 | const state = (await (await fetch(`${base}/api/tables/${id}`)).json()) as { | |
| 120 | host_name: string; | |
| 121 | seated: number; | |
| 122 | seats: unknown[]; | |
| 123 | charter: unknown; | |
| 124 | acts: unknown[]; | |
| 125 | }; | |
| 126 | expect(state.host_name).toBe("alice"); | |
| 127 | expect(state.seated).toBe(1); | |
| 128 | expect(state.charter).toBeNull(); | |
| 129 | expect(state.acts).toEqual([]); | |
| 130 | ||
| 131 | const listed = (await (await fetch(base + "/api/tables")).json()) as { | |
| 98 | 132 | id: number; |
| 99 | 133 | }[]; |
| 100 | expect(listed.map((n) => n.id)).toContain(note.id); | |
| 134 | expect(listed.map((t) => t.id)).toContain(id); | |
| 135 | ||
| 101 | 136 | expect( |
| 102 | (await api("/api/notes", { method: "POST", ...jsonBody({ body: "anon" }) })) | |
| 103 | .status, | |
| 137 | ( | |
| 138 | await api("/api/tables", { | |
| 139 | method: "POST", | |
| 140 | ...jsonBody({ title: "as nobody" }), | |
| 141 | }) | |
| 142 | ).status, | |
| 104 | 143 | ).toBe(401); |
| 105 | expect( | |
| 106 | (await api(`/api/notes/${note.id}`, { method: "DELETE" }, bob)).status, | |
| 107 | ).toBe(403); | |
| 108 | expect( | |
| 109 | (await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status, | |
| 110 | ).toBe(200); | |
| 111 | expect( | |
| 112 | (await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status, | |
| 113 | ).toBe(404); | |
| 114 | 144 | expect( |
| 115 | 145 | ( |
| 116 | 146 | await api( |
| 117 | "/api/notes", | |
| 118 | { method: "POST", ...jsonBody({ body: "" }) }, | |
| 147 | "/api/tables", | |
| 148 | { method: "POST", ...jsonBody({ title: "", seats_max: 9 }) }, | |
| 119 | 149 | alice, |
| 120 | 150 | ) |
| 121 | 151 | ).status, |
| 122 | 152 | ).toBe(422); |
| 153 | expect((await api(`/api/tables/${id}/join`, { method: "POST" })).status).toBe( | |
| 154 | 401, | |
| 155 | ); | |
| 156 | expect( | |
| 157 | (await api(`/api/tables/${id}/join`, { method: "POST" }, bob)).status, | |
| 158 | ).toBe(200); | |
| 159 | // A second seat at the same table is a conflict, and says so. | |
| 160 | const twice = await api(`/api/tables/${id}/join`, { method: "POST" }, bob); | |
| 161 | expect(twice.status).toBe(409); | |
| 162 | expect(((await twice.json()) as { error: string }).error).toBe("conflict"); | |
| 163 | expect( | |
| 164 | (await api(`/api/tables/${id}/leave`, { method: "POST" }, bob)).status, | |
| 165 | ).toBe(200); | |
| 166 | expect((await api("/api/tables/9999", {}, alice)).status).toBe(404); | |
| 167 | expect((await api("/api/tables?limit=0", {}, alice)).status).toBe(422); | |
| 168 | }); | |
| 169 | ||
| 170 | test("no door hands out a charge that is not the viewer's", async () => { | |
| 171 | const id = await openTable(alice, "The Sealed Manifest"); | |
| 172 | expect( | |
| 173 | (await api(`/api/tables/${id}/join`, { method: "POST" }, bob)).status, | |
| 174 | ).toBe(200); | |
| 175 | // Stand in for the deal the turn engine will make (#8): a charge in the | |
| 176 | // column, written straight to the file the server is serving from. | |
| 177 | const db = new Database(join(dataDir, "app.db")); | |
| 178 | db.query( | |
| 179 | "UPDATE seat SET charge_json = ?3 WHERE game_id = ?1 AND player_id = ?2", | |
| 180 | ).run(id, "u2", JSON.stringify({ aims: ["sink-the-manifest"] })); | |
| 181 | db.close(); | |
| 182 | ||
| 183 | for (const viewer of [{}, alice, root]) { | |
| 184 | const state = await (await api(`/api/tables/${id}`, {}, viewer)).text(); | |
| 185 | expect(state).not.toContain("sink-the-manifest"); | |
| 186 | expect( | |
| 187 | (JSON.parse(state) as { seats: { charge: unknown }[] }).seats.every( | |
| 188 | (seat) => seat.charge === null, | |
| 189 | ), | |
| 190 | ).toBe(true); | |
| 191 | expect(await (await api("/", {}, viewer)).text()).not.toContain( | |
| 192 | "sink-the-manifest", | |
| 193 | ); | |
| 194 | expect(await (await api("/api/tables", {}, viewer)).text()).not.toContain( | |
| 195 | "sink-the-manifest", | |
| 196 | ); | |
| 197 | } | |
| 198 | const mine = (await (await api(`/api/tables/${id}`, {}, bob)).json()) as { | |
| 199 | seats: { player_name: string; charge: unknown }[]; | |
| 200 | }; | |
| 201 | expect(mine.seats.find((seat) => seat.player_name === "bob")?.charge).toEqual( | |
| 202 | { aims: ["sink-the-manifest"] }, | |
| 203 | ); | |
| 204 | expect( | |
| 205 | mine.seats.find((seat) => seat.player_name === "alice")?.charge, | |
| 206 | ).toBeNull(); | |
| 123 | 207 | }); |
| 124 | 208 | |
| 125 | 209 | test("the MCP server lists the tools and calls them as the caller", async () => { |
| @@ -132,9 +216,11 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 132 | 216 | expect(hello.result.capabilities).toEqual({ tools: {} }); |
| 133 | 217 | const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" }); |
| 134 | 218 | expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([ |
| 135 | "list_notes", | |
| 136 | "add_note", | |
| 137 | "delete_note", | |
| 219 | "list_tables", | |
| 220 | "table_state", | |
| 221 | "open_table", | |
| 222 | "join_table", | |
| 223 | "leave_table", | |
| 138 | 224 | ]); |
| 139 | 225 | const call = (name: string, args: unknown, who = {}) => |
| 140 | 226 | rpc( |
| @@ -146,18 +232,29 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 146 | 232 | }, |
| 147 | 233 | who, |
| 148 | 234 | ); |
| 149 | const refused = await call("add_note", { body: "as nobody" }); | |
| 235 | const refused = await call("open_table", { title: "as nobody" }); | |
| 150 | 236 | expect(refused.result.isError).toBe(true); |
| 151 | const added = await call("add_note", { body: "over mcp" }, root); | |
| 152 | expect(added.result.isError).toBeUndefined(); | |
| 153 | const note = JSON.parse(added.result.content[0].text); | |
| 154 | expect(note.author_name).toBe("root"); | |
| 155 | const notes = JSON.parse( | |
| 156 | (await call("list_notes", {})).result.content[0].text, | |
| 237 | const opened = await call("open_table", { title: "The Long Tow" }, root); | |
| 238 | expect(opened.result.isError).toBeUndefined(); | |
| 239 | const table = JSON.parse(opened.result.content[0].text); | |
| 240 | expect(table.host_name).toBe("root"); | |
| 241 | expect(table.seated).toBe(1); | |
| 242 | ||
| 243 | const tables = JSON.parse( | |
| 244 | (await call("list_tables", {})).result.content[0].text, | |
| 157 | 245 | ); |
| 158 | expect(notes.map((n: { id: number }) => n.id)).toContain(note.id); | |
| 246 | expect(tables.map((t: { id: number }) => t.id)).toContain(table.id); | |
| 159 | 247 | expect( |
| 160 | (await call("delete_note", { id: note.id }, root)).result.isError, | |
| 248 | (await call("join_table", { id: table.id }, bob)).result.isError, | |
| 249 | ).toBeUndefined(); | |
| 250 | const state = JSON.parse( | |
| 251 | (await call("table_state", { id: table.id }, bob)).result.content[0].text, | |
| 252 | ); | |
| 253 | expect(state.seats.every((s: { charge: unknown }) => s.charge === null)).toBe( | |
| 254 | true, | |
| 255 | ); | |
| 256 | expect( | |
| 257 | (await call("leave_table", { id: table.id }, bob)).result.isError, | |
| 161 | 258 | ).toBeUndefined(); |
| 162 | 259 | expect( |
| 163 | 260 | (await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code, |
| @@ -173,22 +270,21 @@ test("a change is on the event stream, and a late client hears what it missed", | ||
| 173 | 270 | const stream = await fetch(base + "/events"); |
| 174 | 271 | expect(stream.headers.get("content-type")).toBe("text/event-stream"); |
| 175 | 272 | const reader = stream.body!.getReader(); |
| 176 | const next = async (): Promise<{ id: string; type: string }> => { | |
| 273 | const next = async (): Promise<{ id: string; type: string; data: any }> => { | |
| 177 | 274 | let buffer = ""; |
| 178 | 275 | for (;;) { |
| 179 | 276 | const { value } = await reader.read(); |
| 180 | 277 | buffer += new TextDecoder().decode(value); |
| 181 | 278 | const frame = /id: (\d+)\ndata: (.*)\n\n/.exec(buffer); |
| 182 | if (frame) return { id: frame[1]!, type: JSON.parse(frame[2]!).type }; | |
| 279 | if (frame) return { id: frame[1]!, ...JSON.parse(frame[2]!) }; | |
| 183 | 280 | } |
| 184 | 281 | }; |
| 185 | await api( | |
| 186 | "/api/notes", | |
| 187 | { method: "POST", ...jsonBody({ body: "live" }) }, | |
| 188 | alice, | |
| 189 | ); | |
| 282 | await openTable(alice, "The Late Bell"); | |
| 190 | 283 | const heard = await next(); |
| 191 | expect(heard.type).toBe("note.added"); | |
| 284 | expect(heard.type).toBe("table.opened"); | |
| 285 | // Every open page reads this stream, and it has no viewer to redact for: | |
| 286 | // an event says what happened and to which table, and carries no state. | |
| 287 | expect(Object.keys(heard.data).sort()).toEqual(["table", "title"]); | |
| 192 | 288 | await reader.cancel(); |
| 193 | 289 | const late = await fetch(base + "/events", { |
| 194 | 290 | headers: { "last-event-id": String(Number(heard.id) - 1) }, |
| @@ -197,16 +293,26 @@ test("a change is on the event stream, and a late client hears what it missed", | ||
| 197 | 293 | (await late.body!.getReader().read()).value, |
| 198 | 294 | ); |
| 199 | 295 | expect(replay).toContain(`id: ${heard.id}\n`); |
| 200 | expect(replay).toContain("note.added"); | |
| 296 | expect(replay).toContain("table.opened"); | |
| 297 | }); | |
| 298 | ||
| 299 | test("the page region carries the live types it is refreshed by", async () => { | |
| 300 | const page = await (await fetch(base + "/")).text(); | |
| 301 | const region = /<section id="tables" data-live="([^"]*)"/.exec(page)?.[1]; | |
| 302 | expect(region?.split(" ")).toEqual([ | |
| 303 | "table.opened", | |
| 304 | "seat.joined", | |
| 305 | "seat.left", | |
| 306 | ]); | |
| 201 | 307 | }); |
| 202 | 308 | |
| 203 | 309 | test("writes are rationed per person", async () => { |
| 204 | 310 | const flood = as("u9", "flood"); |
| 205 | 311 | const answers = await Promise.all( |
| 206 | Array.from({ length: 40 }, () => | |
| 312 | Array.from({ length: 40 }, (_, i) => | |
| 207 | 313 | api( |
| 208 | "/api/notes", | |
| 209 | { method: "POST", ...jsonBody({ body: "again" }) }, | |
| 314 | "/api/tables", | |
| 315 | { method: "POST", ...jsonBody({ title: `again ${i}` }) }, | |
| 210 | 316 | flood, |
| 211 | 317 | ), |
| 212 | 318 | ), |
| @@ -223,7 +329,7 @@ test("stands on the security floor", async () => { | ||
| 223 | 329 | ); |
| 224 | 330 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 225 | 331 | expect( |
| 226 | (await fetch(base + "/api/notes")).headers.get("content-security-policy"), | |
| 332 | (await fetch(base + "/api/tables")).headers.get("content-security-policy"), | |
| 227 | 333 | ).toContain("default-src"); |
| 228 | 334 | }); |
| 229 | 335 | |
| @@ -239,13 +345,15 @@ test("no answer that depends on who is asking is cached", async () => { | ||
| 239 | 345 | // A page, for two people who must not see each other's. |
| 240 | 346 | expectPrivate(await api("/", {}, alice)); |
| 241 | 347 | expectPrivate(await fetch(base + "/")); |
| 242 | // A JSON answer. | |
| 243 | expectPrivate(await api("/api/notes", {}, alice)); | |
| 348 | // A JSON answer, and one table's redacted state. | |
| 349 | const id = await openTable(alice, "The Unstored Answer"); | |
| 350 | expectPrivate(await api("/api/tables", {}, alice)); | |
| 351 | expectPrivate(await api(`/api/tables/${id}`, {}, alice)); | |
| 244 | 352 | // A redirect, which carries the notice cookie. |
| 245 | 353 | const form = new FormData(); |
| 246 | form.set("body", ""); | |
| 354 | form.set("title", ""); | |
| 247 | 355 | expectPrivate( |
| 248 | await api("/notes", { method: "POST", body: form, redirect: "manual" }), | |
| 356 | await api("/tables", { method: "POST", body: form, redirect: "manual" }), | |
| 249 | 357 | ); |
| 250 | 358 | // A 404, and a refusal. |
| 251 | 359 | expectPrivate(await fetch(base + "/nothing-here")); |
| @@ -272,11 +380,11 @@ test("the event stream keeps its own caching and still streams", async () => { | ||
| 272 | 380 | |
| 273 | 381 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 274 | 382 | const response = await api( |
| 275 | "/notes", | |
| 383 | "/tables", | |
| 276 | 384 | { |
| 277 | 385 | method: "POST", |
| 278 | 386 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 279 | body: "body=" + "x".repeat(1024 * 1024), | |
| 387 | body: "title=" + "x".repeat(1024 * 1024), | |
| 280 | 388 | }, |
| 281 | 389 | alice, |
| 282 | 390 | ); |
| @@ -291,9 +399,9 @@ test("HEAD sees the app up", async () => { | ||
| 291 | 399 | |
| 292 | 400 | test("refuses a cross-site post", async () => { |
| 293 | 401 | const form = new FormData(); |
| 294 | form.set("body", "from another site"); | |
| 402 | form.set("title", "from another site"); | |
| 295 | 403 | const posted = await api( |
| 296 | "/notes", | |
| 404 | "/tables", | |
| 297 | 405 | { |
| 298 | 406 | method: "POST", |
| 299 | 407 | body: form, |
| @@ -308,6 +416,13 @@ test("refuses a cross-site post", async () => { | ||
| 308 | 416 | expect(posted.status).toBe(403); |
| 309 | 417 | }); |
| 310 | 418 | |
| 419 | test("the lobby answers in a few milliseconds", async () => { | |
| 420 | await fetch(base + "/"); | |
| 421 | const started = performance.now(); | |
| 422 | for (let i = 0; i < 20; i++) await fetch(base + "/"); | |
| 423 | expect((performance.now() - started) / 20).toBeLessThan(50); | |
| 424 | }); | |
| 425 | ||
| 311 | 426 | test("static files are cached for a year when linked by content", async () => { |
| 312 | 427 | const page = await (await fetch(base + "/")).text(); |
| 313 | 428 | const href = /href="(\/static\/app\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1]; |
src/tty.ts +76 −38
| @@ -1,16 +1,22 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The app in a terminal. The forge runs this on a pty when someone |
| 3 | 3 | * `ssh`es to the app, over the same data as the page and through the same |
| 4 | * actions, as the account at the keyboard. j/k move, a adds, d removes the | |
| 5 | * note under the cursor, q leaves. Plain ANSI, no dependencies. | |
| 4 | * actions, as the account at the keyboard. j/k move between tables, o opens | |
| 5 | * one, s sits down, x gives the seat up, q leaves. Plain ANSI, no | |
| 6 | * dependencies. What a person can do here is what they can do on the page — | |
| 7 | * the actions say so, not this file. | |
| 6 | 8 | */ |
| 7 | 9 | |
| 8 | 10 | import { |
| 9 | addNote, | |
| 10 | canDelete, | |
| 11 | deleteNote, | |
| 12 | listNotes, | |
| 13 | type Note, | |
| 11 | canJoin, | |
| 12 | canLeave, | |
| 13 | joinTable, | |
| 14 | leaveTable, | |
| 15 | listTables, | |
| 16 | openTable, | |
| 17 | tableState, | |
| 18 | TITLE_MAX, | |
| 19 | type Table, | |
| 14 | 20 | } from "./actions.ts"; |
| 15 | 21 | import { openDb } from "./db.ts"; |
| 16 | 22 | import { ssh, title, where } from "./env.ts"; |
| @@ -29,13 +35,14 @@ let cursor = 0; | ||
| 29 | 35 | let typing: string | undefined; |
| 30 | 36 | let said = ""; |
| 31 | 37 | |
| 32 | const notes = (): Note[] => { | |
| 33 | const listed = listNotes(db, me); | |
| 38 | const tables = (): Table[] => { | |
| 39 | const listed = listTables(db, me); | |
| 34 | 40 | return listed.ok ? listed.value : []; |
| 35 | 41 | }; |
| 36 | 42 | |
| 37 | 43 | function draw(): void { |
| 38 | const rows = notes(); | |
| 44 | const rows = tables(); | |
| 45 | cursor = Math.min(cursor, Math.max(0, rows.length - 1)); | |
| 39 | 46 | const lines = [ |
| 40 | 47 | `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${me.name}${me.role === "admin" ? " (admin)" : ""}`)}`, |
| 41 | 48 | ssh === undefined ? "" : dim(`connect: ${ssh}`), |
| @@ -45,30 +52,57 @@ function draw(): void { | ||
| 45 | 52 | lines.push( |
| 46 | 53 | dim( |
| 47 | 54 | me.role === "anon" |
| 48 | ? "No notes yet." | |
| 49 | : "No notes yet. Press a to add one.", | |
| 55 | ? "No tables yet." | |
| 56 | : "No tables yet. Press o to open one.", | |
| 50 | 57 | ), |
| 51 | 58 | ); |
| 52 | rows.forEach((note, i) => { | |
| 53 | const body = i === cursor ? bold(note.body) : note.body; | |
| 59 | rows.forEach((table, i) => { | |
| 60 | const here = i === cursor; | |
| 61 | const name = here ? bold(table.title) : table.title; | |
| 54 | 62 | lines.push( |
| 55 | `${i === cursor ? "▸" : " "} ${body} ${dim(`${note.author_name} · ${note.created_at.slice(0, 16).replace("T", " ")}`)}`, | |
| 63 | `${here ? "▸" : " "} ${name} ${dim(`${table.status} · ${table.seated}/${table.seats_max} seated · host ${table.host_name}${table.your_seat === null ? "" : " · you are seated"}`)}`, | |
| 56 | 64 | ); |
| 57 | 65 | }); |
| 66 | const here = rows[cursor]; | |
| 67 | if (here !== undefined) { | |
| 68 | lines.push(""); | |
| 69 | lines.push(dim(` ${seatsOf(here)}`)); | |
| 70 | } | |
| 58 | 71 | lines.push(""); |
| 59 | if (typing !== undefined) lines.push(`New note: ${typing}${esc}7m ${esc}0m`); | |
| 72 | if (typing !== undefined) | |
| 73 | lines.push(`Table name: ${typing}${esc}7m ${esc}0m`); | |
| 60 | 74 | else if (said !== "") lines.push(said); |
| 61 | else | |
| 62 | lines.push( | |
| 63 | dim( | |
| 64 | me.role === "anon" | |
| 65 | ? "j/k move · q quit · sign in with a key the forge knows to add" | |
| 66 | : "j/k move · a add · d delete · q quit", | |
| 67 | ), | |
| 68 | ); | |
| 75 | else lines.push(dim(keys(here))); | |
| 69 | 76 | out(`${esc}2J${esc}H${lines.join("\r\n")}`); |
| 70 | 77 | } |
| 71 | 78 | |
| 79 | /** The selected table's seats, and your own charge when there is one to see. */ | |
| 80 | function seatsOf(table: Table): string { | |
| 81 | const state = tableState(db, me, { id: table.id }); | |
| 82 | if (!state.ok) return state.message; | |
| 83 | const seats = state.value.seats; | |
| 84 | if (seats.length === 0) return "nobody seated yet"; | |
| 85 | return seats | |
| 86 | .map( | |
| 87 | (seat) => | |
| 88 | `${seat.ordinal + 1}. ${seat.player_name}${seat.charge === null ? "" : ` (your charge: ${JSON.stringify(seat.charge)})`}`, | |
| 89 | ) | |
| 90 | .join(" "); | |
| 91 | } | |
| 92 | ||
| 93 | const keys = (table: Table | undefined): string => { | |
| 94 | if (me.role === "anon") | |
| 95 | return "j/k move · q quit · sign in with a key the forge knows to play"; | |
| 96 | const can = [ | |
| 97 | "j/k move", | |
| 98 | "o open", | |
| 99 | ...(table !== undefined && canJoin(me, table) ? ["s sit"] : []), | |
| 100 | ...(table !== undefined && canLeave(me, table) ? ["x leave"] : []), | |
| 101 | "q quit", | |
| 102 | ]; | |
| 103 | return can.join(" · "); | |
| 104 | }; | |
| 105 | ||
| 72 | 106 | process.stdin.setRawMode?.(true); |
| 73 | 107 | process.stdin.resume(); |
| 74 | 108 | process.stdin.setEncoding("utf8"); |
| @@ -77,13 +111,13 @@ process.stdin.on("data", (key: string) => { | ||
| 77 | 111 | said = ""; |
| 78 | 112 | if (typing !== undefined) { |
| 79 | 113 | if (key === "\r") { |
| 80 | const added = addNote(db, me, { body: typing }); | |
| 81 | if (!added.ok) said = added.message; | |
| 114 | const opened = openTable(db, me, { title: typing }); | |
| 115 | if (!opened.ok) said = opened.message; | |
| 82 | 116 | typing = undefined; |
| 83 | 117 | cursor = 0; |
| 84 | 118 | } else if (key === "\x1b") typing = undefined; |
| 85 | 119 | else if (key === "\x7f") typing = typing.slice(0, -1); |
| 86 | else if (key >= " ") typing += key; | |
| 120 | else if (key >= " " && typing.length < TITLE_MAX) typing += key; | |
| 87 | 121 | draw(); |
| 88 | 122 | return; |
| 89 | 123 | } |
| @@ -91,23 +125,27 @@ process.stdin.on("data", (key: string) => { | ||
| 91 | 125 | out(`${esc}2J${esc}H`); |
| 92 | 126 | process.exit(0); |
| 93 | 127 | } |
| 94 | const rows = notes(); | |
| 128 | const rows = tables(); | |
| 95 | 129 | if (key === "j" || key === "\x1b[B") |
| 96 | 130 | cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1)); |
| 97 | 131 | if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); |
| 98 | if (key === "a") { | |
| 99 | if (me.role === "anon") said = "Sign in to add a note."; | |
| 132 | if (key === "o") { | |
| 133 | if (me.role === "anon") said = "Sign in to open a table."; | |
| 100 | 134 | else typing = ""; |
| 101 | 135 | } |
| 102 | if (key === "d") { | |
| 103 | const note = rows[cursor]; | |
| 104 | if (note === undefined) said = "Nothing to remove."; | |
| 105 | else if (!canDelete(me, note)) | |
| 106 | said = "Only the author or an admin can remove a note."; | |
| 136 | const here = rows[cursor]; | |
| 137 | if (key === "s") { | |
| 138 | if (here === undefined) said = "No table to sit at."; | |
| 107 | 139 | else { |
| 108 | const removed = deleteNote(db, me, { id: note.id }); | |
| 109 | if (!removed.ok) said = removed.message; | |
| 110 | cursor = Math.min(cursor, Math.max(0, rows.length - 2)); | |
| 140 | const joined = joinTable(db, me, { id: here.id }); | |
| 141 | if (!joined.ok) said = joined.message; | |
| 142 | } | |
| 143 | } | |
| 144 | if (key === "x") { | |
| 145 | if (here === undefined) said = "No table to leave."; | |
| 146 | else { | |
| 147 | const left = leaveTable(db, me, { id: here.id }); | |
| 148 | if (!left.ok) said = left.message; | |
| 111 | 149 | } |
| 112 | 150 | } |
| 113 | 151 | draw(); |
static/app.css +20 −4
| @@ -122,12 +122,15 @@ code, | ||
| 122 | 122 | align-items: center; |
| 123 | 123 | gap: var(); |
| 124 | 124 | } |
| 125 | .wrap { | |
| 126 | flex-wrap: wrap; | |
| 127 | } | |
| 125 | 128 | .list { |
| 126 | 129 | list-style: none; |
| 127 | 130 | margin: 0; |
| 128 | 131 | padding: 0; |
| 129 | 132 | } |
| 130 | .note { | |
| 133 | .item { | |
| 131 | 134 | display: flex; |
| 132 | 135 | justify-content: space-between; |
| 133 | 136 | align-items: flex-start; |
| @@ -135,13 +138,13 @@ code, | ||
| 135 | 138 | padding: var() 0; |
| 136 | 139 | border-top: 1px solid var(); |
| 137 | 140 | } |
| 138 | .note:first-child { | |
| 141 | .item:first-child { | |
| 139 | 142 | border-top: 0; |
| 140 | 143 | } |
| 141 | .note p { | |
| 144 | .item p { | |
| 142 | 145 | overflow-wrap: anywhere; |
| 143 | 146 | } |
| 144 | .note small { | |
| 147 | .item small { | |
| 145 | 148 | font-size: var(); |
| 146 | 149 | } |
| 147 | 150 | .empty { |
| @@ -173,6 +176,19 @@ footer code { | ||
| 173 | 176 | border: 1px solid var(); |
| 174 | 177 | border-radius: var(); |
| 175 | 178 | } |
| 179 | /* Seats: the label and its select stay one control, and wrap as one. */ | |
| 180 | .seats-pick { | |
| 181 | display: flex; | |
| 182 | align-items: center; | |
| 183 | gap: var(); | |
| 184 | font-size: var(); | |
| 185 | color: var(); | |
| 186 | white-space: nowrap; | |
| 187 | } | |
| 188 | .seats-pick .input { | |
| 189 | flex: 0 0 auto; | |
| 190 | min-width: var(); | |
| 191 | } | |
| 176 | 192 | .input::placeholder { |
| 177 | 193 | color: var(); |
| 178 | 194 | } |
new above
offline