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

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

Merged opened this

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

Merged

mason/parley:mason/7-a-table-you-can-open-and-sit-at-schema-s → main 4 commits fork

  • Merged. mason/7-a-table-you-can-open-and-sit-at-schema-s was merged into main as 27f6baa.

Review

Sign in to review this change.
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`.
4242
4343 Identity comes from the forge (`src/who.ts`): `anon`, `user` or `admin`,
4444 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.
4749
4850 To add a concept:
4951
README.md +49 −13
@@ -27,7 +27,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
2727
2828 | file | what it is |
2929 | ---------------------------------- | ------------------------------------------------------------ |
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 |
3131 | `src/board.ts` | the deal, the score and the sworn rule: by RULES.md, no db |
3232 | `src/result.ts` | data or a named failure — the answer shape every door reads |
3333 | `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.
4242 | `src/env.ts` | what the forge told this process |
4343 | `static/app.css`, `static/live.js` | the look, and the list that stays current |
4444
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](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](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
4571 ## Who you are
4672
4773 The forge says who is asking on every request it proxies, and the app
4874 verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers:
4975
5076 - **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.
5482
5583 "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is
5684 `/__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
7098 whose forge grant it carries; a bare script is anonymous.
7199
72100 ```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
76107 ```
77108
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`:
80115
81116 ```sh
82117 claude mcp add --transport http __TITLE__ https://<app>/mcp
@@ -88,14 +123,15 @@ claude mcp add --transport http __TITLE__ https://<app>/mcp
88123 }
89124 ```
90125
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.
94129
95130 The terminal opens with the exact command in `FORGE_APP_SSH` when the app
96131 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge
97132 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.
99135
100136 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or
101137 `preview`), `FORGE_APP_URL`, `FORGE_APP_COMMIT`, `FORGE_APP_SSH` and
src/actions.test.ts +288 −32
@@ -1,8 +1,20 @@
11 import { expect, test } from "bun:test";
2 import type { Database } from "bun:sqlite";
23 import { mkdtempSync } from "node:fs";
34 import { tmpdir } from "node:os";
45 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";
618 import { openDb } from "./db.ts";
719 import type { Who } from "./who.ts";
820
@@ -10,49 +22,293 @@ const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-")));
1022 const anon: Who = { role: "anon" };
1123 const alice: Who = { role: "user", id: "u1", name: "alice" };
1224 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" };
1328 const root: Who = { role: "admin", id: "u0", name: "root" };
1429
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" });
1857 expect(refused.ok).toBe(false);
1958 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);
2873 });
2974
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 });
3378 expect(r.ok).toBe(false);
3479 if (!r.ok) expect(r.error).toBe("invalid");
3580 }
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);
36106 });
37107
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 });
45112 expect(byAnon.ok).toBe(false);
46113 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");
51114
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);
58314 });
src/actions.ts +414 −56
@@ -4,6 +4,16 @@
44 * the terminal are doors onto these and decide nothing themselves — so a
55 * rule written here holds at every door, and a new door is only a new
66 * 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.
717 */
818
919 import type { Database } from "bun:sqlite";
@@ -13,73 +23,421 @@ import type { Who } from "./who.ts";
1323
1424 export type { Failure, Result };
1525
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 {
1730 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;
2140 readonly created_at: string;
22 readonly updated_at: string;
41 readonly started_at: string | null;
42 readonly settled_at: string | null;
2343 }
2444
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;
4155 }
4256
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(
44142 db: Database,
45143 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)));
61162 }
62163
63 export function deleteNote(
164 export function tableState(
64165 db: Database,
65166 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));
81172 }
82173
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[] = [
2424 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
2525 )`,
2626 `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`,
2783 ];
2884
2985 export function openDb(dir = dataDir): Database {
src/http.ts +1 −1
@@ -1,6 +1,6 @@
11 /**
22 * 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.
44 */
55
66 import { createHash } from "node:crypto";
src/mcp.ts +76 −21
@@ -3,11 +3,24 @@
33 * message and answers with JSON (streamable HTTP without the stream —
44 * nothing here needs to push). The tools are the actions, called with the
55 * 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.
79 */
810
911 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";
1124 import { title } from "./env.ts";
1225 import { fields } from "./http.ts";
1326 import type { Who } from "./who.ts";
@@ -23,44 +36,86 @@ interface Tool {
2336 ) => Result<unknown>;
2437 }
2538
39 const tableId = {
40 type: "object",
41 properties: { id: { type: "integer", description: "The table's id." } },
42 required: ["id"],
43 } as const;
44
2645 export const tools: readonly Tool[] = [
2746 {
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.",
3049 inputSchema: {
3150 type: "object",
3251 properties: {
3352 limit: {
3453 type: "integer",
3554 minimum: 1,
36 maximum: 500,
37 description: "At most this many (100).",
55 maximum: 100,
56 description: "At most this many (50).",
3857 },
3958 },
4059 },
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 }),
4361 },
4462 {
45 name: "add_note",
63 name: "table_state",
4664 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 }),
5468 },
5569 {
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.",
5873 inputSchema: {
5974 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"],
6298 },
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 }),
64119 },
65120 ];
66121
src/page.ts +49 −32
@@ -1,10 +1,10 @@
11 /**
22 * 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
44 * arrives, move `layout` to `src/pages/layout.ts` and give each page a file.
55 */
66
7 import { canDelete, type Note } from "./actions.ts";
7 import { canJoin, canLeave, SEATS, TITLE_MAX, type Table } from "./actions.ts";
88 import { commit, description, env, ssh, title, where } from "./env.ts";
99 import { asset, escape } from "./http.ts";
1010 import type { Who } from "./who.ts";
@@ -39,7 +39,7 @@ export function layout(body: string, who: Who): string {
3939 <main>${body}</main>
4040 <footer class="muted">
4141 ${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>
4343 </footer>
4444 </div>
4545 </body>
@@ -49,48 +49,65 @@ export function layout(body: string, who: Who): string {
4949
5050 export function home(input: {
5151 who: Who;
52 notes: readonly Note[];
52 tables: readonly Table[];
5353 notice?: string | undefined;
5454 }): string {
55 const { who, notes, notice } = input;
56 const composer =
55 const { who, tables, notice } = input;
56 const opener =
5757 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>
6265 </form>`;
6366 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>`;
6770 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>
7073 ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`,
7174 who,
7275 );
7376 }
7477
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">
7684 <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>
7988 </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)}
8590 </li>`;
8691
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`;
96113 };
src/result.ts +11 −1
@@ -5,7 +5,17 @@
55 * dragging the database in behind them.
66 */
77
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";
919
1020 export type Result<T> =
1121 | { ok: true; value: T }
src/routes.ts +52 −17
@@ -6,9 +6,11 @@
66
77 import type { Database } from "bun:sqlite";
88 import {
9 addNote,
10 deleteNote,
11 listNotes,
9 joinTable,
10 leaveTable,
11 listTables,
12 openTable,
13 tableState,
1214 type Failure,
1315 type Result,
1416 } from "./actions.ts";
@@ -47,6 +49,7 @@ const STATUS: Record<Failure, number> = {
4749 forbidden: 403,
4850 not_found: 404,
4951 invalid: 422,
52 conflict: 409,
5053 };
5154
5255 /** 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 =>
6265 const done = <T>(result: Result<T>): Response =>
6366 result.ok ? back() : back(result.message);
6467
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
6576 export const routes: readonly Route[] = [
6677 // The page. Forms post and come back; the list stays live over /events.
6778 {
@@ -69,9 +80,9 @@ export const routes: readonly Route[] = [
6980 path: "/",
7081 handle: (c) => {
7182 const { notice, clear } = readNotice(c.request);
72 const notes = listNotes(c.db, c.who);
83 const tables = listTables(c.db, c.who);
7384 return html(
74 home({ who: c.who, notes: notes.ok ? notes.value : [], notice }),
85 home({ who: c.who, tables: tables.ok ? tables.value : [], notice }),
7586 200,
7687 clear,
7788 );
@@ -79,48 +90,72 @@ export const routes: readonly Route[] = [
7990 },
8091 {
8192 method: "POST",
82 path: "/notes",
93 path: "/tables",
8394 handle: async (c) => {
8495 const form = await readForm(c.request);
8596 if (form === undefined) return text("too large", 413);
8697 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 ),
88103 );
89104 },
90105 },
91106 {
92107 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 })),
95115 },
96116
97117 // The JSON API: the same actions, the same identity, plain status codes.
98118 {
99119 method: "GET",
100 path: "/api/notes",
120 path: "/api/tables",
101121 handle: (c) =>
102122 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,
105125 }),
106126 ),
107127 },
108128 {
109129 method: "POST",
110 path: "/api/notes",
130 path: "/api/tables",
111131 handle: async (c) => {
112132 const body = await readJson(c.request);
113133 if (body === undefined) return json({ error: "too_large" }, 413);
134 const given = fields(body);
114135 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 ),
116141 201,
117142 );
118143 },
119144 },
120145 {
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 })),
124159 },
125160
126161 // 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";
12 import { afterAll, expect, test } from "bun:test";
23 import { mkdtempSync } from "node:fs";
34 import { tmpdir } from "node:os";
@@ -7,11 +8,8 @@ import { serve } from "./server.ts";
78 import { sign } from "./who.ts";
89
910 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 });
1513 const base = `http://${server.hostname}:${server.port}`;
1614 afterAll(() => server.stop(true));
1715
@@ -47,22 +45,37 @@ const rpc = (message: unknown, who: Record<string, string> = {}) =>
4745 api("/mcp", { method: "POST", ...jsonBody(message) }, who).then(
4846 (r) => r.json() as Promise<any>,
4947 );
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 };
5062
5163 test("the page knows who you are and offers the right door", async () => {
5264 const guest = await (await fetch(base + "/")).text();
5365 expect(guest).toContain("<h1>");
5466 expect(guest).toContain("/__forge/signin?to=/");
55 expect(guest).not.toContain('action="/notes"');
67 expect(guest).not.toContain('action="/tables"');
5668 const signed = await (await api("/", {}, alice)).text();
5769 expect(signed).toContain("<b>alice</b>");
5870 expect(signed).toContain("/__forge/signout");
59 expect(signed).toContain('action="/notes"');
71 expect(signed).toContain('action="/tables"');
6072 });
6173
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 () => {
6375 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", {
6679 method: "POST",
6780 body: form,
6881 redirect: "manual",
@@ -71,55 +84,126 @@ test("a form post adds as the person; a guest is sent to sign in", async () => {
7184 expect(refused.headers.get("set-cookie")).toContain("notice=");
7285 expect(refused.headers.get("set-cookie")).toContain("SameSite=Lax");
7386 expect(await (await fetch(base + "/")).text()).not.toContain(
74 "hello from a form",
87 "The Wreck of the Fathom",
7588 );
89
7690 const posted = await api(
77 "/notes",
91 "/tables",
7892 { method: "POST", body: form, redirect: "manual" },
7993 alice,
8094 );
8195 expect(posted.status).toBe(303);
8296 expect(posted.headers.get("set-cookie")).toBeNull();
8397 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");
86115 });
87116
88117 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 {
98132 id: number;
99133 }[];
100 expect(listed.map((n) => n.id)).toContain(note.id);
134 expect(listed.map((t) => t.id)).toContain(id);
135
101136 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,
104143 ).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);
114144 expect(
115145 (
116146 await api(
117 "/api/notes",
118 { method: "POST", ...jsonBody({ body: "" }) },
147 "/api/tables",
148 { method: "POST", ...jsonBody({ title: "", seats_max: 9 }) },
119149 alice,
120150 )
121151 ).status,
122152 ).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();
123207 });
124208
125209 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 () =>
132216 expect(hello.result.capabilities).toEqual({ tools: {} });
133217 const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" });
134218 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",
138224 ]);
139225 const call = (name: string, args: unknown, who = {}) =>
140226 rpc(
@@ -146,18 +232,29 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
146232 },
147233 who,
148234 );
149 const refused = await call("add_note", { body: "as nobody" });
235 const refused = await call("open_table", { title: "as nobody" });
150236 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,
157245 );
158 expect(notes.map((n: { id: number }) => n.id)).toContain(note.id);
246 expect(tables.map((t: { id: number }) => t.id)).toContain(table.id);
159247 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,
161258 ).toBeUndefined();
162259 expect(
163260 (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",
173270 const stream = await fetch(base + "/events");
174271 expect(stream.headers.get("content-type")).toBe("text/event-stream");
175272 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 }> => {
177274 let buffer = "";
178275 for (;;) {
179276 const { value } = await reader.read();
180277 buffer += new TextDecoder().decode(value);
181278 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]!) };
183280 }
184281 };
185 await api(
186 "/api/notes",
187 { method: "POST", ...jsonBody({ body: "live" }) },
188 alice,
189 );
282 await openTable(alice, "The Late Bell");
190283 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"]);
192288 await reader.cancel();
193289 const late = await fetch(base + "/events", {
194290 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",
197293 (await late.body!.getReader().read()).value,
198294 );
199295 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 ]);
201307 });
202308
203309 test("writes are rationed per person", async () => {
204310 const flood = as("u9", "flood");
205311 const answers = await Promise.all(
206 Array.from({ length: 40 }, () =>
312 Array.from({ length: 40 }, (_, i) =>
207313 api(
208 "/api/notes",
209 { method: "POST", ...jsonBody({ body: "again" }) },
314 "/api/tables",
315 { method: "POST", ...jsonBody({ title: `again ${i}` }) },
210316 flood,
211317 ),
212318 ),
@@ -223,7 +329,7 @@ test("stands on the security floor", async () => {
223329 );
224330 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
225331 expect(
226 (await fetch(base + "/api/notes")).headers.get("content-security-policy"),
332 (await fetch(base + "/api/tables")).headers.get("content-security-policy"),
227333 ).toContain("default-src");
228334 });
229335
@@ -239,13 +345,15 @@ test("no answer that depends on who is asking is cached", async () => {
239345 // A page, for two people who must not see each other's.
240346 expectPrivate(await api("/", {}, alice));
241347 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));
244352 // A redirect, which carries the notice cookie.
245353 const form = new FormData();
246 form.set("body", "");
354 form.set("title", "");
247355 expectPrivate(
248 await api("/notes", { method: "POST", body: form, redirect: "manual" }),
356 await api("/tables", { method: "POST", body: form, redirect: "manual" }),
249357 );
250358 // A 404, and a refusal.
251359 expectPrivate(await fetch(base + "/nothing-here"));
@@ -272,11 +380,11 @@ test("the event stream keeps its own caching and still streams", async () => {
272380
273381 test("a body is capped by the bytes read, whatever it claims", async () => {
274382 const response = await api(
275 "/notes",
383 "/tables",
276384 {
277385 method: "POST",
278386 headers: { "content-type": "application/x-www-form-urlencoded" },
279 body: "body=" + "x".repeat(1024 * 1024),
387 body: "title=" + "x".repeat(1024 * 1024),
280388 },
281389 alice,
282390 );
@@ -291,9 +399,9 @@ test("HEAD sees the app up", async () => {
291399
292400 test("refuses a cross-site post", async () => {
293401 const form = new FormData();
294 form.set("body", "from another site");
402 form.set("title", "from another site");
295403 const posted = await api(
296 "/notes",
404 "/tables",
297405 {
298406 method: "POST",
299407 body: form,
@@ -308,6 +416,13 @@ test("refuses a cross-site post", async () => {
308416 expect(posted.status).toBe(403);
309417 });
310418
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
311426 test("static files are cached for a year when linked by content", async () => {
312427 const page = await (await fetch(base + "/")).text();
313428 const href = /href="(\/static\/app\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1];
src/tty.ts +76 −38
@@ -1,16 +1,22 @@
11 /**
22 * The app in a terminal. The forge runs this on a pty when someone
33 * `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.
68 */
79
810 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,
1420 } from "./actions.ts";
1521 import { openDb } from "./db.ts";
1622 import { ssh, title, where } from "./env.ts";
@@ -29,13 +35,14 @@ let cursor = 0;
2935 let typing: string | undefined;
3036 let said = "";
3137
32 const notes = (): Note[] => {
33 const listed = listNotes(db, me);
38 const tables = (): Table[] => {
39 const listed = listTables(db, me);
3440 return listed.ok ? listed.value : [];
3541 };
3642
3743 function draw(): void {
38 const rows = notes();
44 const rows = tables();
45 cursor = Math.min(cursor, Math.max(0, rows.length - 1));
3946 const lines = [
4047 `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${me.name}${me.role === "admin" ? " (admin)" : ""}`)}`,
4148 ssh === undefined ? "" : dim(`connect: ${ssh}`),
@@ -45,30 +52,57 @@ function draw(): void {
4552 lines.push(
4653 dim(
4754 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.",
5057 ),
5158 );
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;
5462 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"}`)}`,
5664 );
5765 });
66 const here = rows[cursor];
67 if (here !== undefined) {
68 lines.push("");
69 lines.push(dim(` ${seatsOf(here)}`));
70 }
5871 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`);
6074 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)));
6976 out(`${esc}2J${esc}H${lines.join("\r\n")}`);
7077 }
7178
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
72106 process.stdin.setRawMode?.(true);
73107 process.stdin.resume();
74108 process.stdin.setEncoding("utf8");
@@ -77,13 +111,13 @@ process.stdin.on("data", (key: string) => {
77111 said = "";
78112 if (typing !== undefined) {
79113 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;
82116 typing = undefined;
83117 cursor = 0;
84118 } else if (key === "\x1b") typing = undefined;
85119 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;
87121 draw();
88122 return;
89123 }
@@ -91,23 +125,27 @@ process.stdin.on("data", (key: string) => {
91125 out(`${esc}2J${esc}H`);
92126 process.exit(0);
93127 }
94 const rows = notes();
128 const rows = tables();
95129 if (key === "j" || key === "\x1b[B")
96130 cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1));
97131 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.";
100134 else typing = "";
101135 }
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.";
107139 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;
111149 }
112150 }
113151 draw();
static/app.css +20 −4
@@ -122,12 +122,15 @@ code,
122122 align-items: center;
123123 gap: var(--s2);
124124 }
125 .wrap {
126 flex-wrap: wrap;
127 }
125128 .list {
126129 list-style: none;
127130 margin: 0;
128131 padding: 0;
129132 }
130 .note {
133 .item {
131134 display: flex;
132135 justify-content: space-between;
133136 align-items: flex-start;
@@ -135,13 +138,13 @@ code,
135138 padding: var(--s3) 0;
136139 border-top: 1px solid var(--line);
137140 }
138 .note:first-child {
141 .item:first-child {
139142 border-top: 0;
140143 }
141 .note p {
144 .item p {
142145 overflow-wrap: anywhere;
143146 }
144 .note small {
147 .item small {
145148 font-size: var(--text-sm);
146149 }
147150 .empty {
@@ -173,6 +176,19 @@ footer code {
173176 border: 1px solid var(--line);
174177 border-radius: var(--radius);
175178 }
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(--s2);
184 font-size: var(--text-sm);
185 color: var(--muted);
186 white-space: nowrap;
187 }
188 .seats-pick .input {
189 flex: 0 0 auto;
190 min-width: var(--tap);
191 }
176192 .input::placeholder {
177193 color: var(--muted);
178194 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs