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

A table has a URL: GET /tables/:id, linkable and watchable before the game can be played #56

Merged opened this

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

Merged

mason/parley:mason/40-a-table-has-a-url-get-tables-id-linkable → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/40-a-table-has-a-url-get-tables-id-linkable was merged into main as ff9b0c9.

Review

Sign in to review this change.
13 files changed +389 −47
AGENTS.md +4 −1
@@ -76,7 +76,10 @@ To add a concept:
7676 data in, escaped HTML out, and every one of them shown at `/style` from
7777 a fixture. A page that invents its own markup for a charter, a claim or
7878 an act is a drift; add to `table.ts` instead. (`src/page.ts` is the
79 lobby's body; it renders no document of its own.)
79 lobby's body; it renders no document of its own.) A miss is a page too: a
80 route that finds nothing answers with `notFound` (`src/pages/missing.ts`),
81 which hands a browser the layout and a way back to the lobby, and a
82 script the plain body and status it expects.
8083 - **An API route** — a row in `src/routes.ts` that calls the action and
8184 answers with `answer(...)`; the status follows the failure.
8285 - **A tool** — an entry in `tools` in `src/mcp.ts`: a name, one sentence,
README.md +19
@@ -49,6 +49,8 @@ renders the same functions from real state.
4949 | `src/routes.ts` | every path, in one table |
5050 | `src/pages/layout.ts` | the one document every page is poured into |
5151 | `src/pages/table.ts` | the parts a table is made of: charter, claims, acts, clock |
52 | `src/pages/tableview.ts` | `/tables/:id`, one table at its own address |
53 | `src/pages/missing.ts` | not found, as a page for a browser and a word for a script |
5254 | `src/pages/style.ts` | `/style`, where the look lives |
5355 | `src/page.ts` | the lobby's body, poured into the layout |
5456 | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files |
@@ -70,6 +72,23 @@ fortnight (24 hours) that is never shorter than two turn windows, and a
7072 quorum of ⌈n/2⌉+1 — 3 of 4, 4 of 5, 4 of 6 — which follows from the seat
7173 count, so no host chooses it.
7274
75 Every table has an address of its own — `/tables/:id` — and that address is
76 the whole of it: the title, who opened it, its status, the seats in order
77 with who holds them, the clock it will play on and the quorum that binds an
78 accord. Taking a seat and giving it up happen there, so the lobby is a list
79 of doors and nothing else; the page shows its own absolute URL for you to
80 copy and send to whoever you want at the table. Watching needs no account:
81 a guest reads it like anyone else, redacted the same way, and signing in is
82 how you sit down. The seats fill in as people arrive without a reload
83 (`data-live="seat.joined seat.left table.started"`), and the first paint is
84 the whole page. Nothing of the game itself — the charter, the turn, the
85 offer, the reveal — is on it yet; that arrives with the turn engine.
86
87 Following a stale link is a page too, not four words on a white screen: any
88 address a browser asks for HTML on answers with the app's own layout, a
89 sentence and the way back to the lobby (`src/pages/missing.ts`). The JSON
90 API keeps its plain body and the status that goes with it.
91
7392 What a seat is playing for is its **charge**, and it is secret: the action
7493 that reads a table redacts it by viewer, so no door can leak it.
7594
src/http.ts +9
@@ -90,6 +90,15 @@ export const text = (
9090 },
9191 });
9292
93 /**
94 * Whether the thing asking is a browser showing a page. Browsers ask for
95 * `text/html` by name on a navigation; a script asks for JSON or for
96 * anything at all. It decides the shape of an answer, never its content or
97 * its status — an accept header is a preference, not an identity.
98 */
99 export const wantsHtml = (request: Request): boolean =>
100 (request.headers.get("accept") ?? "").includes("text/html");
101
93102 /**
94103 * Back to the page, with a word for it to show once (`readNotice`). `to`
95104 * is a path from the route table and never from a request: a redirect
src/page.ts +9 −19
@@ -5,11 +5,11 @@
55 * is only a body.
66 */
77
8 import { canJoin, canLeave, SEATS, TITLE_MAX, type Table } from "./actions.ts";
8 import { SEATS, TITLE_MAX, type Table } from "./actions.ts";
99 import { where } from "./env.ts";
1010 import { escape } from "./http.ts";
1111 import { layout } from "./pages/layout.ts";
12 import { duration, timeTag } from "./when.ts";
12 import { span, timeTag } from "./when.ts";
1313 import type { Who } from "./who.ts";
1414
1515 export function home(input: {
@@ -31,7 +31,7 @@ export function home(input: {
3131 const list =
3232 tables.length === 0
3333 ? `<p class="empty">No tables yet. A table opened here stays in ${escape(where)} data.</p>`
34 : `<ul class="list">${tables.map((table) => item(table, who)).join("")}</ul>`;
34 : `<ul class="list">${tables.map(item).join("")}</ul>`;
3535 return layout({
3636 who,
3737 notice,
@@ -45,29 +45,19 @@ const seatOptions = (): string =>
4545 .map((n) => `<option value="${n}">${n}</option>`)
4646 .join("");
4747
48 const item = (table: Table, who: Who): string => `<li class="item">
48 // Every table on the lobby is a link to the table itself: sitting down,
49 // giving a seat up and watching all happen there (`src/pages/tableview.ts`),
50 // so the lobby is a list of doors and decides nothing about a seat.
51 const item = (table: Table): string => `<li class="item">
4952 <div>
50 <p><b>${escape(table.title)}</b> <span class="pill">${escape(table.status)}</span></p>
53 <p><b><a href="/tables/${table.id}">${escape(table.title)}</a></b> <span class="pill">${escape(table.status)}</span></p>
5154 <p class="muted">${table.seated}/${table.seats_max} seated · ${escape(names(table))}</p>
5255 <small class="muted">Opened by ${escape(table.host_name)} ${timeTag(table.created_at)} · ${span(table.turn_seconds)} a turn · ${span(table.deadline_seconds)} in all · <a href="/api/tables/${table.id}">state</a></small>
5356 </div>
54 ${seatButton(table, who)}
57 <a class="button button-quiet" href="/tables/${table.id}" aria-label="Open ${escape(table.title)}">Open</a>
5558 </li>`;
5659
5760 const names = (table: Table): string =>
5861 table.seats.length === 0
5962 ? "nobody yet"
6063 : table.seats.map((seat) => seat.player_name).join(", ");
61
62 // The page never decides who may sit: `canJoin` and `canLeave` are the
63 // action's own rules, so a button is there exactly when the action agrees.
64 const seatButton = (table: Table, who: Who): string => {
65 if (canLeave(who, table))
66 return `<form method="post" action="/tables/${table.id}/leave"><button class="button button-quiet" aria-label="Leave ${escape(table.title)}">Leave</button></form>`;
67 if (canJoin(who, table))
68 return `<form method="post" action="/tables/${table.id}/join"><button class="button" aria-label="Sit at ${escape(table.title)}">Sit</button></form>`;
69 return "";
70 };
71
72 /** A window in seconds, in the app's one vocabulary (`src/when.ts`): 6h, 3d, 45m. */
73 const span = (seconds: number): string => duration(seconds * 1000);
src/pages/layout.ts +17 −9
@@ -27,10 +27,16 @@ export interface Page {
2727 readonly live?: string | undefined;
2828 /** The word a redirect left for this request (`readNotice`). */
2929 readonly notice?: string | undefined;
30 /**
31 * Where signing in should come back to: this page, when it is one a guest
32 * may be reading and then want a seat at. A path of the app's own making
33 * — never anything a request carried — because it becomes a URL.
34 */
35 readonly signInTo?: string | undefined;
3036 }
3137
3238 export function layout(page: Page): string {
33 const { title, body, who, live, notice } = page;
39 const { title, body, who, live, notice, signInTo } = page;
3440 return `<!doctype html>
3541 <html lang="en">
3642 <meta charset="utf-8">
@@ -42,7 +48,7 @@ export function layout(page: Page): string {
4248 <script type="module" src="${asset("live.js")}"></script>
4349 <body>
4450 <div class="page">
45 ${header(who)}
51 ${header(who, signInTo ?? "/")}
4652 <main id="main"${live === undefined ? "" : ` data-live="${escape(live)}"`}>${body}</main>
4753 ${footer()}
4854 </div>
@@ -53,12 +59,12 @@ ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}<
5359 }
5460
5561 /** The app's name, where this copy runs, and the door for who is asking. */
56 const header = (who: Who): string => `<header class="head">
62 const header = (who: Who, signInTo: string): string => `<header class="head">
5763 <div>
5864 <h1><a href="/">${escape(appTitle)}</a></h1>
5965 <p class="muted">${escape(description)}</p>
6066 </div>
61 ${headerNav(who)}
67 ${headerNav(who, signInTo)}
6268 </header>`;
6369
6470 /**
@@ -66,16 +72,18 @@ const header = (who: Who): string => `<header class="head">
6672 * it is the one part of the header that changes with who is asking, and so
6773 * the one part `/style` has to be able to show in every state it has.
6874 */
69 export const headerNav = (who: Who): string => `<nav class="row">
75 export const headerNav = (who: Who, signInTo = "/"): string => `<nav class="row">
7076 <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""}
71 ${door(who)}
77 ${door(who, signInTo)}
7278 </nav>`;
7379
7480 // Signing in and out is the forge's, on this app's own host: the app never
75 // invents an identity, so it never offers one.
76 const door = (who: Who): string =>
81 // invents an identity, so it never offers one. Signing in comes back to the
82 // page that offered it, so a guest who followed a link to a table lands
83 // back at that table with a seat to take.
84 const door = (who: Who, signInTo: string): string =>
7785 who.role === "anon"
78 ? `<a class="button button-quiet" href="/__forge/signin?to=/">Sign in</a>`
86 ? `<a class="button button-quiet" href="/__forge/signin?to=${escape(signInTo)}">Sign in</a>`
7987 : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span>
8088 <a class="button button-quiet" href="/__forge/signout">Sign out</a>`;
8189
src/pages/missing.ts added +30
@@ -0,0 +1,30 @@
1 /**
2 * Not found is a page. A stale link to a table that never existed is a
3 * person on a dead end, and four plain words on a white background give
4 * them no way back — so a browser gets the app's own document, a sentence
5 * and the lobby. A script gets what a script has always got: a plain body
6 * and the same 404.
7 *
8 * One function, called wherever a miss happens (`src/server.ts` for a path
9 * with no route, `src/routes.ts` for a table with no row), so the two
10 * cannot drift into two different dead ends.
11 */
12
13 import { html, text, wantsHtml } from "../http.ts";
14 import type { Who } from "../who.ts";
15 import { layout } from "./layout.ts";
16
17 /** The 404, in the shape the door asked for. */
18 export const notFound = (request: Request, who: Who): Response =>
19 wantsHtml(request) ? html(page(who), 404) : text("not found", 404);
20
21 const page = (who: Who): string =>
22 layout({
23 title: "Not found",
24 who,
25 body: `<section class="card">
26 <h2>There is nothing at this address.</h2>
27 <p class="muted">The link may be old, or the table may never have been opened here. Every table this copy of the app knows is in the lobby.</p>
28 <p><a class="button" href="/">Back to the lobby</a></p>
29 </section>`,
30 });
src/pages/style.ts +9 −1
@@ -209,7 +209,7 @@ const SPECIMENS: readonly Specimen[] = [
209209 {
210210 heading: "The header, for each kind of person",
211211 why: "The one part of the layout that changes with who is asking: a guest is offered the forge's sign-in, a person is named, an admin is named as one. It wraps rather than clipping the door at 360px.",
212 body: PEOPLE.map(headerNav).join("\n"),
212 body: PEOPLE.map((person) => headerNav(person)).join("\n"),
213213 },
214214 {
215215 heading: "The charter",
@@ -232,6 +232,14 @@ const SPECIMENS: readonly Specimen[] = [
232232 why: "Where each seat stands on the offer that stands now, and every aim it has sworn. A sworn claim is engine-verified: it cannot be false.",
233233 body: seatList({ seats: SEATS, clauses: CHARTER, quorum: QUORUM }),
234234 },
235 {
236 heading: "The seats, before the deal",
237 why: "A table that is open and filling up. There is no offer to stand on and no aim to have sworn yet, so a seat shows neither: the badge and the claims appear when the game gives them something to say, and nothing above them moves when they do.",
238 body: seatList({
239 seats: SEATS.map((seat) => ({ ordinal: seat.ordinal, name: seat.name })),
240 clauses: CHARTER,
241 }),
242 },
235243 {
236244 heading: "A said claim, three ways",
237245 why: "Said claims are shape-checked and never truth-checked. They are unjudged while the game runs, and marked true or false in public at the reveal.",
src/pages/table.ts +19 −6
@@ -41,11 +41,18 @@ export interface Said {
4141 /** Where a seat stands on the offer that stands now. */
4242 export type Standing = "accepted" | "silent" | "to act";
4343
44 /**
45 * A seat. Before play starts it has neither a standing — there is no offer
46 * to stand on — nor any aim to have sworn, and both are left out rather
47 * than faked: no badge is drawn, and no claims list either. `sworn: []` is
48 * the other thing, and says so: a seat in a running game that has sworn
49 * nothing yet.
50 */
4451 export interface Seat {
4552 readonly ordinal: number;
4653 readonly name: string;
47 readonly standing: Standing;
48 readonly sworn: readonly Sworn[];
54 readonly standing?: Standing | undefined;
55 readonly sworn?: readonly Sworn[] | undefined;
4956 }
5057
5158 /** One entry in the ledger (RULES §5). `when` is the words for `at`. */
@@ -117,12 +124,18 @@ export const seatRow = (seat: Seat, clauses: readonly Clause[]): string =>
117124 <div class="seat-head">
118125 <span class="seat-ordinal" aria-hidden="true">${seat.ordinal + 1}</span>
119126 <span class="seat-name">${escape(seat.name)}</span>
120 <span class="badge" data-standing="${escape(seat.standing)}">${escape(seat.standing)}</span>
127 ${
128 seat.standing === undefined
129 ? ""
130 : `<span class="badge" data-standing="${escape(seat.standing)}">${escape(seat.standing)}</span>`
131 }
121132 </div>
122133 ${
123 seat.sworn.length === 0
124 ? `<p class="muted claims-none">Has sworn nothing.</p>`
125 : `<ul class="claims">${seat.sworn.map((sworn) => swornClaim(sworn, clauses)).join("")}</ul>`
134 seat.sworn === undefined
135 ? ""
136 : seat.sworn.length === 0
137 ? `<p class="muted claims-none">Has sworn nothing.</p>`
138 : `<ul class="claims">${seat.sworn.map((sworn) => swornClaim(sworn, clauses)).join("")}</ul>`
126139 }
127140 </li>`;
128141
src/pages/tableview.ts added +91
@@ -0,0 +1,91 @@
1 /**
2 * One table, at its own address: `GET /tables/:id`. A table you can send to
3 * somebody — the title, who opened it, what state it is in, who is sitting
4 * down, the clock it will play on, and the one button the rules allow the
5 * reader.
6 *
7 * Every value here comes from `tableState` (`src/actions.ts`), which is the
8 * only thing that decides what a viewer may see: this page runs no query of
9 * its own, so it cannot be the door that leaks a charge. The seats are drawn
10 * with the parts in `./table.ts`, the same functions `/style` shows.
11 *
12 * Nothing of the game itself is here — no charter, no offer, no act log, no
13 * reveal. Those arrive on this page with the turn engine (#12); `body`
14 * leaves the room for them under the seats.
15 */
16
17 import { canJoin, canLeave, type TableView } from "../actions.ts";
18 import { publicOrigin } from "../env.ts";
19 import { escape } from "../http.ts";
20 import { span, timeTag } from "../when.ts";
21 import type { Who } from "../who.ts";
22 import { layout } from "./layout.ts";
23 import { seatList, type Seat } from "./table.ts";
24
25 export function tableView(input: {
26 who: Who;
27 table: TableView;
28 notice?: string | undefined;
29 }): string {
30 const { who, table, notice } = input;
31 // Built from the row's own id, never from anything a caller sent.
32 const path = `/tables/${table.id}`;
33 return layout({
34 title: table.title,
35 who,
36 notice,
37 signInTo: path,
38 body: `<section class="card">
39 <h2>${escape(table.title)} <span class="pill">${escape(table.status)}</span></h2>
40 <p class="muted">Opened by ${escape(table.host_name)} ${timeTag(table.created_at)}${
41 table.started_at === null
42 ? ""
43 : ` · started ${timeTag(table.started_at)}`
44 }${
45 table.settled_at === null
46 ? ""
47 : ` · settled ${timeTag(table.settled_at)}`
48 }</p>
49 <p class="muted">${table.seated}/${table.seats_max} seated · ${span(table.turn_seconds)} a turn · ${span(table.deadline_seconds)} in all · ${table.quorum} of ${table.seats_max} bind an accord</p>
50 ${invite(path)}
51 ${
52 who.role === "anon"
53 ? `<p class="muted">Watching needs no account. <a href="/__forge/signin?to=${path}">Sign in</a> to take a seat.</p>`
54 : ""
55 }
56 </section>
57 <section class="card" id="table" data-live="seat.joined seat.left table.started">
58 ${seatList({ seats: seats(table), clauses: [] })}
59 <p class="muted">${free(table)}</p>
60 ${seatButton(table, who, path)}
61 </section>`,
62 });
63 }
64
65 /** The table's own address, whole, for a person to copy and send. */
66 const invite = (path: string): string => `<p class="row wrap">
67 <label class="muted" for="invite">Invite</label>
68 <input class="input mono" id="invite" readonly value="${escape(publicOrigin + path)}">

Read every changed line at e66a80d, ran the suite (85 pass, 0 fail), ran the app locally with a signed identity, and used the preview as a guest. The security work is right: the page runs no query of its own and takes everything from tableState, so the charge redaction holds (verified — a guest, a stranger and a seated player all read the page with charge: null for seats that are not theirs); the title renders escaped (The Wreck &lt;script&gt;); a spoofed x-forge-identity with no signature is anon and its POST seats nobody; a cross-site POST is 403; tablePath builds the redirect from a validated integer, never from the request's characters, and the 303 lands on /tables/:id; the floor (CSP, nosniff, private, no-store, Vary) is on the new 404 in the preview too; /tables/:id answers in about 1 ms. Nice call putting data-live on #table with an id live.js can swap, and folding span() into src/when.ts so no page does the arithmetic itself.

One miss, and it is the pull's own subject.

1 — medium, fix first: the terminal is the other door to a table, and it has no address. The headline of this change is that a table is linkable and you can send it to somebody, and the README says so: "the page shows its own absolute URL for you to copy and send to whoever you want at the table". src/tty.ts is untouched, so a person at ssh can list tables, sit and leave but has no way to learn a table's URL — they cannot invite anyone without guessing the host. publicOrigin is already in src/env.ts, and this is one line in the selected-table detail (after the rebase onto main that is screen(), src/tty.ts:147, beside seatsOf), plus a case in tty.test.ts asserting the line contains /tables/<id> — screen() is pure, so the test is cheap and would fail without the line. Note the branch is based on 51f3174 and predates #57's tty split; rebase first so the fix lands in the pure screen function.

Two low notes, no round of their own — fold them in if they are a line each, otherwise say so and they can wait:

2 — a 404 that varies by accept does not say so. wantsHtml makes the body depend on the accept header, but PRIVATE names only x-forge-identity, x-forge-signature and cookie in Vary. no-store makes this harmless in practice; a negotiated response still ought to add accept to its Vary at the notFound call site rather than rely on it.

3 — a notice can outlive its page. A failed join on an id that is a whole number but nobody's table redirects to /tables/<n>, which is notFound, and notFound never calls readNotice — so "No such table." stays in the cookie and pops up on the lobby later, out of context. Either read and clear the notice in notFound, or send that case back to /.

Not blocking, but worth doing before this lands: the preview has no tables, so both screenshots at this head are the empty lobby and the new page is unproven in the picture. Open one table on the preview so the look shows what the pull is about.

Everything else on the list checks out: modules named for one concept, no dead code or TODOs, no new dependency, the tests added cover the address, the clock and quorum words, the seat ordering, the guest sentence, the sign-in return path and both shapes of the 404, and each would fail without the change.

69 </p>`;
70
71 /** The seats as the parts draw them: in order, and nothing sworn yet. */
72 const seats = (table: TableView): readonly Seat[] =>
73 table.seats.map((seat, i) => ({ ordinal: i, name: seat.player_name }));
74
75 const free = (table: TableView): string => {
76 const left = table.seats_max - table.seated;
77 if (table.status !== "open") return "The seats are fixed: this table has started.";
78 if (left === 0) return "Every seat is taken.";
79 return `${left} ${left === 1 ? "seat is" : "seats are"} free.`;
80 };
81
82 // The page never decides who may sit: `canJoin` and `canLeave` are the
83 // action's own rules, so the button is there exactly when the action agrees
84 // — and the form posts to the route that calls that same action.
85 const seatButton = (table: TableView, who: Who, path: string): string => {
86 if (canLeave(who, table))
87 return `<form method="post" action="${path}/leave"><button class="button button-quiet">Leave this table</button></form>`;
88 if (canJoin(who, table))
89 return `<form method="post" action="${path}/join"><button class="button">Take a seat</button></form>`;
90 return "";
91 };
src/routes.ts +43 −4
@@ -28,7 +28,9 @@ import {
2828 import { events } from "./live.ts";
2929 import { rpc } from "./mcp.ts";
3030 import { home } from "./page.ts";
31 import { notFound } from "./pages/missing.ts";
3132 import { style } from "./pages/style.ts";
33 import { tableView } from "./pages/tableview.ts";
3234 import type { Who } from "./who.ts";
3335
3436 export interface Context {
@@ -63,8 +65,19 @@ const answer = <T>(result: Result<T>, status = 200): Response =>
6365 );
6466
6567 /** A result for a form: back to the page, with what went wrong if it did. */
66 const done = <T>(result: Result<T>): Response =>
67 result.ok ? back() : back(result.message);
68 const done = <T>(result: Result<T>, to = "/"): Response =>
69 result.ok ? back(undefined, to) : back(result.message, to);
70
71 /**
72 * Where a form about one table lands: that table's page, or the lobby when
73 * the id was not a table's id at all. A whole number of this app's own
74 * making, never the characters the request carried — a redirect built out
75 * of what a caller sent is how an open redirect happens.
76 */
77 const tablePath = (id: string | undefined): string => {
78 const n = Number(id);
79 return Number.isSafeInteger(n) && n > 0 ? `/tables/${n}` : "/";
80 };
6881
6982 /** The fields of an opening, wherever they came from: a form, or a JSON body. */
7083 const opening = (get: (name: string) => unknown) => ({
@@ -104,15 +117,41 @@ export const routes: readonly Route[] = [
104117 );
105118 },
106119 },
120 // One table, at its own address: linkable, watchable by anyone, and the
121 // page the seat forms come back to. Every value on it is `tableState`'s,
122 // redacted for the person asking; an id that is nobody's table is the
123 // app's own not-found page.
124 {
125 method: "GET",
126 path: "/tables/:id",
127 handle: (c) => {
128 const state = tableState(c.db, c.who, { id: c.params.id });
129 if (!state.ok) return notFound(c.request, c.who);
130 const { notice, clear } = readNotice(c.request);
131 return html(
132 tableView({ who: c.who, table: state.value, notice }),
133 200,
134 clear,
135 );
136 },
137 },
107138 {
108139 method: "POST",
109140 path: "/tables/:id/join",
110 handle: (c) => done(joinTable(c.db, c.who, { id: c.params.id })),
141 handle: (c) =>
142 done(
143 joinTable(c.db, c.who, { id: c.params.id }),
144 tablePath(c.params.id),
145 ),
111146 },
112147 {
113148 method: "POST",
114149 path: "/tables/:id/leave",
115 handle: (c) => done(leaveTable(c.db, c.who, { id: c.params.id })),
150 handle: (c) =>
151 done(
152 leaveTable(c.db, c.who, { id: c.params.id }),
153 tablePath(c.params.id),
154 ),
116155 },
117156
118157 // Every part a table is made of, from a fixture: it reads no table and
src/server.test.ts +128 −6
@@ -98,23 +98,141 @@ test("a form opens a table and takes a seat; a guest is sent to sign in", async
9898 const page = await (await api("/", {}, alice)).text();
9999 expect(page).toContain("The Wreck of the Fathom");
100100 expect(page).toContain("1/5 seated");
101 // The host is seated, so the page offers them the way out, not a way in.
102 expect(page).toContain("Leave");
103
104 const asBob = await (await api("/", {}, bob)).text();
105 expect(asBob).toContain("Sit at The Wreck of the Fathom");
106 const seen = /action="\/tables\/(\d+)\/join"/.exec(asBob)?.[1];
101 // The lobby is a list of doors: sitting down happens at the table (#40).
102 const seen = /href="\/tables\/(\d+)"/.exec(page)?.[1];
107103 expect(seen).toBeDefined();
104 // The host is seated, so their table offers them the way out, not a way in.
105 expect(await (await api(`/tables/${seen}`, {}, alice)).text()).toContain(
106 "Leave this table",
107 );
108
109 const asBob = await (await api(`/tables/${seen}`, {}, bob)).text();
110 expect(asBob).toContain("Take a seat");
111 expect(asBob).toContain(`action="/tables/${seen}/join"`);
108112 const sat = await api(
109113 `/tables/${seen}/join`,
110114 { method: "POST", redirect: "manual" },
111115 bob,
112116 );
113117 expect(sat.status).toBe(303);
118 // And it comes back to the table it was posted from, not to the lobby.
119 expect(sat.headers.get("location")).toBe(`/tables/${seen}`);
114120 expect(sat.headers.get("set-cookie")).toBeNull();
115121 expect(await (await fetch(base + "/")).text()).toContain("2/5 seated");
116122 });
117123
124 test("a table has a URL, and a guest, a player and the host all read it", async () => {
125 const id = await openTable(alice, "The Long Parley", {
126 seats_max: 4,
127 turn_seconds: 5400,
128 });
129 expect(
130 (await api(`/api/tables/${id}/join`, { method: "POST" }, bob)).status,
131 ).toBe(200);
132 for (const viewer of [{}, bob, alice, root]) {
133 const response = await api(`/tables/${id}`, {}, viewer);
134 expect(response.status).toBe(200);
135 const page = await response.text();
136 // What the table is, from `tableState` and nowhere else.
137 expect(page).toContain("The Long Parley");
138 expect(page).toContain("Opened by alice");
139 expect(page).toContain('<span class="pill">open</span>');
140 expect(page).toContain("2/4 seated");
141 expect(page).toContain("1h 30m a turn"); // the clock, in the app's words
142 expect(page).toContain("1d in all");
143 expect(page).toContain("3 of 4 bind an accord"); // the quorum, ⌈4/2⌉+1
144 // The seats, in order, with who holds them.
145 expect(page.indexOf('class="seat-name">alice<')).toBeLessThan(
146 page.indexOf('class="seat-name">bob<'),
147 );
148 // Its own address, whole, for a person to copy and send.
149 expect(page).toContain(`value="http://localhost/tables/${id}"`);
150 // Complete at first paint, and the seats fill in without a reload.
151 expect(
152 /<section class="card" id="table" data-live="([^"]*)"/
153 .exec(page)?.[1]
154 ?.split(" "),
155 ).toEqual(["seat.joined", "seat.left", "table.started"]);
156 // Nothing of the game itself is here yet (#12).
157 expect(page).not.toContain('class="charter"');
158 expect(page).not.toContain('class="act-list"');
159 }
160
161 // A guest may read it, is told so in one line, and is offered no seat.
162 const guest = await (await fetch(`${base}/tables/${id}`)).text();
163 expect(guest).toContain("Watching needs no account.");
164 // Both doors come back here: the sentence in the page, and the header's.
165 expect(guest).toContain(`>Sign in</a> to take a seat.`);
166 expect(guest).toContain(
167 `class="button button-quiet" href="/__forge/signin?to=/tables/${id}"`,
168 );
169 expect(guest).not.toContain(`href="/__forge/signin?to=/"`);
170 expect(guest).not.toContain("Take a seat");
171 // The buttons are the action's own rules: the host may leave, a stranger
172 // may sit, and a seated player is offered the way out, not a second seat.
173 expect(await (await api(`/tables/${id}`, {}, alice)).text()).toContain(
174 "Leave this table",
175 );
176 const stranger = await (await api(`/tables/${id}`, {}, as("u7", "cleat"))).text();
177 expect(stranger).toContain(`action="/tables/${id}/join"`);
178 expect(stranger).not.toContain("Leave this table");
179 // And leaving comes back to the table, with the seat gone.
180 const left = await api(
181 `/tables/${id}/leave`,
182 { method: "POST", redirect: "manual" },
183 bob,
184 );
185 expect(left.status).toBe(303);
186 expect(left.headers.get("location")).toBe(`/tables/${id}`);
187 expect(await (await api(`/tables/${id}`, {}, bob)).text()).toContain(
188 "1/4 seated",
189 );
190 });
191
192 test("a table answers in a few milliseconds", async () => {
193 const id = await openTable(alice, "The Quick Answer");
194 await api(`/tables/${id}`);
195 const times: number[] = [];
196 for (let i = 0; i < 10; i++) {
197 const started = performance.now();
198 await (await api(`/tables/${id}`)).text();
199 times.push(performance.now() - started);
200 }
201 expect(Math.min(...times)).toBeLessThan(50);
202 });
203
204 test("not found is a page for a browser and four plain words for a script", async () => {
205 for (const path of ["/tables/9999", "/tables/abc", "/nothing-here"]) {
206 const page = await api(path, {
207 headers: { accept: "text/html,application/xhtml+xml" },
208 });
209 expect(page.status).toBe(404);
210 expect(page.headers.get("content-type")).toContain("text/html");
211 const body = await page.text();
212 // The app's own document: the header, a sentence, and a way back.
213 expect(body).toContain('<h1><a href="/">parley</a></h1>');
214 expect(body).toContain("There is nothing at this address.");
215 expect(body).toContain('href="/">Back to the lobby');
216 expect(page.headers.get("content-security-policy")).toContain(
217 "default-src 'self'",
218 );
219 expect(page.headers.get("x-content-type-options")).toBe("nosniff");
220 expectPrivate(page);
221
222 const plain = await api(path, { headers: { accept: "application/json" } });
223 expect(plain.status).toBe(404);
224 expect(plain.headers.get("content-type")).toContain("text/plain");
225 expect(await plain.text()).toBe("not found");
226 expectPrivate(plain);
227 }
228 // The JSON API keeps its own body and its own status, whatever asks.
229 const miss = await api("/api/tables/9999", {
230 headers: { accept: "text/html" },
231 });
232 expect(miss.status).toBe(404);
233 expect(((await miss.json()) as { error: string }).error).toBe("not_found");
234 });
235
118236 test("a time on the page is the instant, in words, in a slot that ticks", async () => {
119237 await openTable(alice, "The Hour Glass", { turn_seconds: 5400 });
120238 const page = await (await api("/", {}, alice)).text();
@@ -212,6 +330,10 @@ test("no door hands out a charge that is not the viewer's", async () => {
212330 expect(await (await api("/", {}, viewer)).text()).not.toContain(
213331 "sink-the-manifest",
214332 );
333 // The table's own page reads the same action, so it redacts the same.
334 expect(await (await api(`/tables/${id}`, {}, viewer)).text()).not.toContain(
335 "sink-the-manifest",
336 );
215337 expect(await (await api("/api/tables", {}, viewer)).text()).not.toContain(
216338 "sink-the-manifest",
217339 );
src/server.ts +4 −1
@@ -9,6 +9,7 @@
99 import { openDb } from "./db.ts";
1010 import { dataDir, identityKey, title, where } from "./env.ts";
1111 import { allow, html, sameOrigin, text } from "./http.ts";
12 import { notFound } from "./pages/missing.ts";
1213 import { match } from "./routes.ts";
1314 import { who } from "./who.ts";
1415
@@ -45,7 +46,9 @@ export function serve(options: ServeOptions) {
4546 request.method === "HEAD" ? "GET" : request.method,
4647 url.pathname,
4748 );
48 if (found === undefined) return text("not found", 404);
49 // A path with no route: the app's own page for a browser, four plain
50 // words for a script (`src/pages/missing.ts`), the floor on both.
51 if (found === undefined) return notFound(request, me);
4952 const response = await found.route.handle({
5053 request,
5154 url,
src/when.ts +7
@@ -38,6 +38,13 @@ export const instant = instantOf as (at: TimeIn) => number | null;
3838 /** A span in words: "0s", "1m", "1h 30m", "8d 3h" — at most two units. */
3939 export const duration = durationOf as (ms: number) => string;
4040
41 /**
42 * A window the app stores in seconds — a turn, a deadline — in those same
43 * words. Every page that prints one calls this, so "1h 30m a turn" is never
44 * rounded to "2h" by a page doing the arithmetic itself.
45 */
46 export const span = (seconds: number): string => duration(seconds * 1000);
47
4148 /** "4h 12m left", or "lapsed" once the deadline is here. */
4249 export const remaining = remainingOf as (until: TimeIn, now: TimeIn) => string;
4350

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs