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
Check · Review · Land · Live done — Landed and live in production.
mason/parley:mason/40-a-table-has-a-url-get-tables-id-linkable → main 3 commits fork 1 thread · 1 unresolved
13 files
changed +389
−47
AGENTS.md +4 −1
| @@ -76,7 +76,10 @@ To add a concept: | ||
| 76 | 76 | data in, escaped HTML out, and every one of them shown at `/style` from |
| 77 | 77 | a fixture. A page that invents its own markup for a charter, a claim or |
| 78 | 78 | 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. | |
| 80 | 83 | - **An API route** — a row in `src/routes.ts` that calls the action and |
| 81 | 84 | answers with `answer(...)`; the status follows the failure. |
| 82 | 85 | - **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. | ||
| 49 | 49 | | `src/routes.ts` | every path, in one table | |
| 50 | 50 | | `src/pages/layout.ts` | the one document every page is poured into | |
| 51 | 51 | | `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 | | |
| 52 | 54 | | `src/pages/style.ts` | `/style`, where the look lives | |
| 53 | 55 | | `src/page.ts` | the lobby's body, poured into the layout | |
| 54 | 56 | | `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 | ||
| 70 | 72 | quorum of ⌈n/2⌉+1 — 3 of 4, 4 of 5, 4 of 6 — which follows from the seat |
| 71 | 73 | count, so no host chooses it. |
| 72 | 74 | |
| 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 | ||
| 73 | 92 | What a seat is playing for is its **charge**, and it is secret: the action |
| 74 | 93 | that reads a table redacts it by viewer, so no door can leak it. |
| 75 | 94 |
src/http.ts +9
| @@ -90,6 +90,15 @@ export const text = ( | ||
| 90 | 90 | }, |
| 91 | 91 | }); |
| 92 | 92 | |
| 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 | ||
| 93 | 102 | /** |
| 94 | 103 | * Back to the page, with a word for it to show once (`readNotice`). `to` |
| 95 | 104 | * is a path from the route table and never from a request: a redirect |
src/page.ts +9 −19
| @@ -5,11 +5,11 @@ | ||
| 5 | 5 | * is only a body. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { canJoin, canLeave, SEATS, TITLE_MAX, type Table } from "./actions.ts"; | |
| 8 | import { SEATS, TITLE_MAX, type Table } from "./actions.ts"; | |
| 9 | 9 | import { where } from "./env.ts"; |
| 10 | 10 | import { escape } from "./http.ts"; |
| 11 | 11 | import { layout } from "./pages/layout.ts"; |
| 12 | import { duration, timeTag } from "./when.ts"; | |
| 12 | import { span, timeTag } from "./when.ts"; | |
| 13 | 13 | import type { Who } from "./who.ts"; |
| 14 | 14 | |
| 15 | 15 | export function home(input: { |
| @@ -31,7 +31,7 @@ export function home(input: { | ||
| 31 | 31 | const list = |
| 32 | 32 | tables.length === 0 |
| 33 | 33 | ? `<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>`; | |
| 35 | 35 | return layout({ |
| 36 | 36 | who, |
| 37 | 37 | notice, |
| @@ -45,29 +45,19 @@ const seatOptions = (): string => | ||
| 45 | 45 | .map((n) => `<option value="${n}">${n}</option>`) |
| 46 | 46 | .join(""); |
| 47 | 47 | |
| 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"> | |
| 49 | 52 | <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> | |
| 51 | 54 | <p class="muted">${table.seated}/${table.seats_max} seated · ${escape(names(table))}</p> |
| 52 | 55 | <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> |
| 53 | 56 | </div> |
| 54 | ${seatButton(table, who)} | |
| 57 | <a class="button button-quiet" href="/tables/${table.id}" aria-label="Open ${escape(table.title)}">Open</a> | |
| 55 | 58 | </li>`; |
| 56 | 59 | |
| 57 | 60 | const names = (table: Table): string => |
| 58 | 61 | table.seats.length === 0 |
| 59 | 62 | ? "nobody yet" |
| 60 | 63 | : 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 { | ||
| 27 | 27 | readonly live?: string | undefined; |
| 28 | 28 | /** The word a redirect left for this request (`readNotice`). */ |
| 29 | 29 | 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; | |
| 30 | 36 | } |
| 31 | 37 | |
| 32 | 38 | export function layout(page: Page): string { |
| 33 | const { title, body, who, live, notice } = page; | |
| 39 | const { title, body, who, live, notice, signInTo } = page; | |
| 34 | 40 | return `<!doctype html> |
| 35 | 41 | <html lang="en"> |
| 36 | 42 | <meta charset="utf-8"> |
| @@ -42,7 +48,7 @@ export function layout(page: Page): string { | ||
| 42 | 48 | <script type="module" src="${asset("live.js")}"></script> |
| 43 | 49 | <body> |
| 44 | 50 | <div class="page"> |
| 45 | ${header(who)} | |
| 51 | ${header(who, signInTo ?? "/")} | |
| 46 | 52 | <main id="main"${live === undefined ? "" : ` data-live="${escape(live)}"`}>${body}</main> |
| 47 | 53 | ${footer()} |
| 48 | 54 | </div> |
| @@ -53,12 +59,12 @@ ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}< | ||
| 53 | 59 | } |
| 54 | 60 | |
| 55 | 61 | /** 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"> | |
| 57 | 63 | <div> |
| 58 | 64 | <h1><a href="/">${escape(appTitle)}</a></h1> |
| 59 | 65 | <p class="muted">${escape(description)}</p> |
| 60 | 66 | </div> |
| 61 | ${headerNav(who)} | |
| 67 | ${headerNav(who, signInTo)} | |
| 62 | 68 | </header>`; |
| 63 | 69 | |
| 64 | 70 | /** |
| @@ -66,16 +72,18 @@ const header = (who: Who): string => `<header class="head"> | ||
| 66 | 72 | * it is the one part of the header that changes with who is asking, and so |
| 67 | 73 | * the one part `/style` has to be able to show in every state it has. |
| 68 | 74 | */ |
| 69 | export const headerNav = (who: Who): string => `<nav class="row"> | |
| 75 | export const headerNav = (who: Who, signInTo = "/"): string => `<nav class="row"> | |
| 70 | 76 | <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)} | |
| 72 | 78 | </nav>`; |
| 73 | 79 | |
| 74 | 80 | // 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 => | |
| 77 | 85 | 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>` | |
| 79 | 87 | : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span> |
| 80 | 88 | <a class="button button-quiet" href="/__forge/signout">Sign out</a>`; |
| 81 | 89 |
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[] = [ | ||
| 209 | 209 | { |
| 210 | 210 | heading: "The header, for each kind of person", |
| 211 | 211 | 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"), | |
| 213 | 213 | }, |
| 214 | 214 | { |
| 215 | 215 | heading: "The charter", |
| @@ -232,6 +232,14 @@ const SPECIMENS: readonly Specimen[] = [ | ||
| 232 | 232 | 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.", |
| 233 | 233 | body: seatList({ seats: SEATS, clauses: CHARTER, quorum: QUORUM }), |
| 234 | 234 | }, |
| 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 | }, | |
| 235 | 243 | { |
| 236 | 244 | heading: "A said claim, three ways", |
| 237 | 245 | 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 { | ||
| 41 | 41 | /** Where a seat stands on the offer that stands now. */ |
| 42 | 42 | export type Standing = "accepted" | "silent" | "to act"; |
| 43 | 43 | |
| 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 | */ | |
| 44 | 51 | export interface Seat { |
| 45 | 52 | readonly ordinal: number; |
| 46 | 53 | readonly name: string; |
| 47 | readonly standing: Standing; | |
| 48 | readonly sworn: readonly Sworn[]; | |
| 54 | readonly standing?: Standing | undefined; | |
| 55 | readonly sworn?: readonly Sworn[] | undefined; | |
| 49 | 56 | } |
| 50 | 57 | |
| 51 | 58 | /** 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 => | ||
| 117 | 124 | <div class="seat-head"> |
| 118 | 125 | <span class="seat-ordinal" aria-hidden="true">${seat.ordinal + 1}</span> |
| 119 | 126 | <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 | } | |
| 121 | 132 | </div> |
| 122 | 133 | ${ |
| 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>` | |
| 126 | 139 | } |
| 127 | 140 | </li>`; |
| 128 | 141 |
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)}"> | |
| 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 { | ||
| 28 | 28 | import { events } from "./live.ts"; |
| 29 | 29 | import { rpc } from "./mcp.ts"; |
| 30 | 30 | import { home } from "./page.ts"; |
| 31 | import { notFound } from "./pages/missing.ts"; | |
| 31 | 32 | import { style } from "./pages/style.ts"; |
| 33 | import { tableView } from "./pages/tableview.ts"; | |
| 32 | 34 | import type { Who } from "./who.ts"; |
| 33 | 35 | |
| 34 | 36 | export interface Context { |
| @@ -63,8 +65,19 @@ const answer = <T>(result: Result<T>, status = 200): Response => | ||
| 63 | 65 | ); |
| 64 | 66 | |
| 65 | 67 | /** 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 | }; | |
| 68 | 81 | |
| 69 | 82 | /** The fields of an opening, wherever they came from: a form, or a JSON body. */ |
| 70 | 83 | const opening = (get: (name: string) => unknown) => ({ |
| @@ -104,15 +117,41 @@ export const routes: readonly Route[] = [ | ||
| 104 | 117 | ); |
| 105 | 118 | }, |
| 106 | 119 | }, |
| 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 | }, | |
| 107 | 138 | { |
| 108 | 139 | method: "POST", |
| 109 | 140 | 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 | ), | |
| 111 | 146 | }, |
| 112 | 147 | { |
| 113 | 148 | method: "POST", |
| 114 | 149 | 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 | ), | |
| 116 | 155 | }, |
| 117 | 156 | |
| 118 | 157 | // 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 | ||
| 98 | 98 | const page = await (await api("/", {}, alice)).text(); |
| 99 | 99 | expect(page).toContain("The Wreck of the Fathom"); |
| 100 | 100 | 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]; | |
| 107 | 103 | 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"`); | |
| 108 | 112 | const sat = await api( |
| 109 | 113 | `/tables/${seen}/join`, |
| 110 | 114 | { method: "POST", redirect: "manual" }, |
| 111 | 115 | bob, |
| 112 | 116 | ); |
| 113 | 117 | 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}`); | |
| 114 | 120 | expect(sat.headers.get("set-cookie")).toBeNull(); |
| 115 | 121 | expect(await (await fetch(base + "/")).text()).toContain("2/5 seated"); |
| 116 | 122 | }); |
| 117 | 123 | |
| 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 | ||
| 118 | 236 | test("a time on the page is the instant, in words, in a slot that ticks", async () => { |
| 119 | 237 | await openTable(alice, "The Hour Glass", { turn_seconds: 5400 }); |
| 120 | 238 | 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 () => { | ||
| 212 | 330 | expect(await (await api("/", {}, viewer)).text()).not.toContain( |
| 213 | 331 | "sink-the-manifest", |
| 214 | 332 | ); |
| 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 | ); | |
| 215 | 337 | expect(await (await api("/api/tables", {}, viewer)).text()).not.toContain( |
| 216 | 338 | "sink-the-manifest", |
| 217 | 339 | ); |
src/server.ts +4 −1
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | import { openDb } from "./db.ts"; |
| 10 | 10 | import { dataDir, identityKey, title, where } from "./env.ts"; |
| 11 | 11 | import { allow, html, sameOrigin, text } from "./http.ts"; |
| 12 | import { notFound } from "./pages/missing.ts"; | |
| 12 | 13 | import { match } from "./routes.ts"; |
| 13 | 14 | import { who } from "./who.ts"; |
| 14 | 15 | |
| @@ -45,7 +46,9 @@ export function serve(options: ServeOptions) { | ||
| 45 | 46 | request.method === "HEAD" ? "GET" : request.method, |
| 46 | 47 | url.pathname, |
| 47 | 48 | ); |
| 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); | |
| 49 | 52 | const response = await found.route.handle({ |
| 50 | 53 | request, |
| 51 | 54 | url, |
src/when.ts +7
| @@ -38,6 +38,13 @@ export const instant = instantOf as (at: TimeIn) => number | null; | ||
| 38 | 38 | /** A span in words: "0s", "1m", "1h 30m", "8d 3h" — at most two units. */ |
| 39 | 39 | export const duration = durationOf as (ms: number) => string; |
| 40 | 40 | |
| 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 | ||
| 41 | 48 | /** "4h 12m left", or "lapsed" once the deadline is here. */ |
| 42 | 49 | export const remaining = remainingOf as (until: TimeIn, now: TimeIn) => string; |
| 43 | 50 |
new above
offline
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 fromtableState, so the charge redaction holds (verified — a guest, a stranger and a seated player all read the page withcharge: nullfor seats that are not theirs); the title renders escaped (The Wreck <script>); a spoofedx-forge-identitywith no signature is anon and its POST seats nobody; a cross-site POST is 403;tablePathbuilds 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/:idanswers in about 1 ms. Nice call puttingdata-liveon#tablewith an idlive.jscan swap, and foldingspan()intosrc/when.tsso 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.tsis untouched, so a person atsshcan list tables, sit and leave but has no way to learn a table's URL — they cannot invite anyone without guessing the host.publicOriginis already insrc/env.ts, and this is one line in the selected-table detail (after the rebase onto main that isscreen(),src/tty.ts:147, besideseatsOf), plus a case intty.test.tsasserting the line contains/tables/<id>—screen()is pure, so the test is cheap and would fail without the line. Note the branch is based on51f3174and 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
acceptdoes not say so.wantsHtmlmakes the body depend on theacceptheader, butPRIVATEnames onlyx-forge-identity,x-forge-signatureandcookieinVary.no-storemakes this harmless in practice; a negotiated response still ought to addacceptto itsVaryat thenotFoundcall 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 isnotFound, andnotFoundnever callsreadNotice— so "No such table." stays in the cookie and pops up on the lobby later, out of context. Either read and clear the notice innotFound, 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.