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

Data: a player, a guest session, a round and a score #19

Merged opened this

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

Merged

quill/drift:quill/6-data-a-player-a-guest-session-a-round-an → main 6 commits fork 2 threads · 2 unresolved

  • Merged. quill/6-data-a-player-a-guest-session-a-round-an was merged into main as b98061e.

Review

Sign in to review this change.
9 files changed +1140 −21
README.md +45 −1
@@ -80,10 +80,11 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
8080
8181 | file | what it is |
8282 | ---------------------------------- | ----------------------------------------------------------------- |
83 | `src/actions.ts` | every operation, and who may — empty until #DATA |
83 | `src/actions.ts` | every operation, and who may: players, rounds, the ladder |
8484 | `src/world.ts` | the simulation: motes, momentum and the current field |
8585 | `src/protocol.ts` | the wire: one frame down, one steer up, quantised and sized |
8686 | `src/who.ts` | who is asking, from the forge's signed headers |
87 | `src/session.ts` | the guest key in a cookie, and the player id it derives |
8788 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
8889 | `src/live.ts` | the in-process bus and `GET /events` |
8990 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` — none yet |
@@ -136,6 +137,49 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers:
136137 "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is
137138 `/__forge/signout`; the forge answers both on the app's own host.
138139
140 ### Playing as a guest
141
142 *Written, not yet wired: `src/session.ts` and the player actions are in the
143 app, with their tests, but no door issues the cookie yet — that lands with
144 the socket that admits a swimmer to the world. What follows is how guests
145 work the moment one does.*
146
147 Nobody has to sign in to swim. A visitor the forge cannot name is given a
148 **guest key** — 32 random bytes in an `HttpOnly; SameSite=Lax` cookie — and
149 a handle the app generates, like *Quiet Tide 47*. It is title case with
150 spaces, which no forge username is, so a guest is never mistaken for a
151 person the forge knows, and no name a client sent is ever stored.
152
153 Over https the cookie is named `__Host-guest`. Every app and every preview
154 on this forge lives under one parent domain, and cookies do not respect the
155 host boundary that CSP and CORS do: without the prefix a neighbour could set
156 `guest=…; Domain=<the parent>` and have you swim as a guest whose key they
157 hold. A browser stores a `__Host-` cookie only from the exact host that set
158 it, over https, with `Path=/` and no `Domain` at all. On localhost, where no
159 browser would store the prefix, the name is plain `guest`.
160
161 The cookie is the only thing that makes a guest the same person twice.
162 Clear it, use a private window, or open the app on another device and you
163 are a new player with a new handle — that is the honest answer, not a bug:
164 the app has nothing else to recognise you by. Sign in and that stops being
165 true: a guest who signs in keeps what they scored, because the guest's rows
166 move to their forge player and the guest is let go, leaving one identity.
167
168 The key itself never leaves the browser. What the world, the database and
169 the ladder see is a one-way derivation of it (`src/session.ts`) — and the
170 actions take the key, never that id: they derive the id themselves, so the
171 id is only ever an output. That is the point. The id is published, in every
172 frame the world sends and every row of the ladder, and if offering it back
173 were enough to claim a guest then reading one frame would be enough to play
174 as anybody in it, and to walk off with their score at sign-in. Holding the
175 cookie is the only way to be a guest. Only forge players appear on the
176 ladder; a guest sees their own round.
177
178 A guest who scored nothing and has not been seen for seven days — the
179 ladder's own window — is forgotten, so the table holds the people playing
180 rather than every cookie ever sent. A guest with a score stays: a finished
181 round still refers to them.
182
139183 ## Any interface
140184
141185 The same actions, the same rules, at every door. A script is the person
src/actions.test.ts added +321
@@ -0,0 +1,321 @@
1 import type { Database } from "bun:sqlite";
2 import { expect, test } from "bun:test";
3 import { mkdtempSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import {
7 ensurePlayer,
8 ladder,
9 recordRound,
10 type Result,
11 sweepGuests,
12 SYSTEM,
13 touchPlayer,
14 } from "./actions.ts";
15 import { openDb } from "./db.ts";
16 import { guestId, guestName, newGuestKey } from "./session.ts";
17 import type { Who } from "./who.ts";
18
19 const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-")));
20 const anon: Who = { role: "anon" };
21 const alice: Who = { role: "user", id: "u1", name: "alice" };
22 const bob: Who = { role: "user", id: "u2", name: "bob" };
23 const root: Who = { role: "admin", id: "u0", name: "root" };
24
25 /** The value, or the failure as an error — a test should not limp on. */
26 const value = <T>(r: Result<T>): T => {
27 if (!r.ok) throw new Error(`${r.error}: ${r.message}`);
28 return r.value;
29 };
30 /** A guest as a door holds one: the secret in the cookie, and the id it derives. */
31 const guest = (): { key: string; id: string } => {
32 const key = newGuestKey();
33 return { key, id: guestId(key) };
34 };
35
36 test("a forge person and a guest are different players, and a guest is named by us", () => {
37 const g = guest();
38 const mine = value(ensurePlayer(db, anon, { guestKey: g.key }));
39 // The action takes the secret and derives the id itself: the id it returns
40 // is publishable, and nothing that was published can be offered back.
41 expect(mine.id).toBe(g.id);
42 expect(mine.id).not.toBe(g.key);
43 expect(mine.kind).toBe("guest");
44 expect(mine.name).toBe(guestName(g.id));
45 const hers = value(ensurePlayer(db, alice, {}));
46 expect(hers.id).toBe("u1");
47 expect(hers.kind).toBe("forge");
48 expect(hers.name).toBe("alice");
49 expect(count(db)).toBe(2);
50 });
51
52 test("the same guest key twice is one player", () => {
53 const g = guest();
54 const first = value(ensurePlayer(db, anon, { guestKey: g.key }));
55 const before = count(db);
56 const second = value(ensurePlayer(db, anon, { guestKey: g.key }));
57 expect(second.id).toBe(first.id);
58 expect(second.created_at).toBe(first.created_at);
59 expect(count(db)).toBe(before);
60 // And two keys are two people.
61 value(ensurePlayer(db, anon, { guestKey: guest().key }));
62 expect(count(db)).toBe(before + 1);
63 });
64
65 test("a malformed guest key is refused rather than stored", () => {
66 const before = count(db);
67 for (const junk of [
68 "",
69 "nonsense",
70 "g_short",
71 // A well-formed guest *id* is the one that matters: the id is what the
72 // world publishes, so offering it as a key must get nowhere — otherwise
73 // reading one frame would be enough to play as anybody in it.
74 guest().id,
75 "u1",
76 "'; DROP TABLE player; --",
77 `${newGuestKey()}=`,
78 ]) {
79 const r = ensurePlayer(db, anon, { guestKey: junk });
80 expect(r.ok).toBe(false);
81 if (!r.ok) expect(r.error).toBe("invalid");
82 // Signed in or not, junk never becomes an id.
83 expect(ensurePlayer(db, alice, { guestKey: junk }).ok).toBe(false);
84 expect(touchPlayer(db, anon, { guestKey: junk }).ok).toBe(false);
85 }
86 expect(ensurePlayer(db, anon, {}).ok).toBe(false);
87 expect(count(db)).toBe(before);
88 });
89
90 test("signing in while holding a guest cookie leaves one identity, the forge one", () => {
91 const g = guest();
92 value(ensurePlayer(db, anon, { guestKey: g.key }));
93 const round = value(
94 recordRound(db, SYSTEM, {
95 outcome: "gate",
96 scores: [{ player_id: g.id, points: 7 }],
97 }),
98 );
99 const carol: Who = { role: "user", id: "u9", name: "carol" };
100 // Only the holder of the cookie can be absorbed: the guest's id, which any
101 // other player has seen, buys nothing here.
102 const byId = ensurePlayer(db, carol, { guestKey: g.id });
103 expect(byId.ok).toBe(false);
104 expect(rows(db, "SELECT * FROM player WHERE id = ?1", g.id).length).toBe(1);
105
106 const merged = value(ensurePlayer(db, carol, { guestKey: g.key }));
107 expect(merged.id).toBe("u9");
108 expect(merged.kind).toBe("forge");
109 expect(rows(db, "SELECT * FROM player WHERE id = ?1", g.id).length).toBe(0);
110 // What the guest scored is the person's now, on the same round.
111 expect(rows(db, "SELECT * FROM score WHERE player_id = ?1", "u9")).toEqual([
112 { round_id: round.round.id, player_id: "u9", points: 7 },
113 ]);
114 // Doing it twice is not an error, and does not double anything.
115 expect(ensurePlayer(db, carol, { guestKey: g.key }).ok).toBe(true);
116 expect(rows(db, "SELECT * FROM score WHERE player_id = ?1", "u9").length).toBe(
117 1,
118 );
119 });
120
121 test("a player is marked present only once they exist", () => {
122 const g = guest();
123 const missing = touchPlayer(db, anon, { guestKey: g.key });
124 expect(missing.ok).toBe(false);
125 if (!missing.ok) expect(missing.error).toBe("not_found");
126 const made = value(ensurePlayer(db, anon, { guestKey: g.key }));
127 const seen = value(touchPlayer(db, anon, { guestKey: g.key }));
128 expect(seen.id).toBe(made.id);
129 expect(seen.last_seen_at >= made.last_seen_at).toBe(true);
130 expect(touchPlayer(db, bob, {}).ok).toBe(false);
131 value(ensurePlayer(db, bob, {}));
132 expect(value(touchPlayer(db, bob, {})).id).toBe("u2");
133 });
134
135 test("a round is recorded by the game, never by a door", () => {
136 const before = rows(db, "SELECT * FROM round").length;
137 const impostor: Who = { role: "admin", id: SYSTEM.id, name: SYSTEM.name };
138 for (const who of [anon, alice, root, impostor]) {
139 const r = recordRound(db, who, { outcome: "gate" });
140 expect(r.ok).toBe(false);
141 if (!r.ok) expect(r.error).toBe("forbidden");
142 }
143 for (const outcome of ["", "Gate!", "x".repeat(33), "drop table"]) {
144 const r = recordRound(db, SYSTEM, { outcome });
145 expect(r.ok).toBe(false);
146 if (!r.ok) expect(r.error).toBe("invalid");
147 }
148 value(ensurePlayer(db, alice, {}));
149 const bad = recordRound(db, SYSTEM, {
150 outcome: "gate",
151 scores: [{ player_id: "u1", points: 1.5 }],
152 });
153 expect(bad.ok).toBe(false);
154 if (!bad.ok) expect(bad.error).toBe("invalid");
155 const nobody = recordRound(db, SYSTEM, {
156 outcome: "gate",
157 scores: [{ player_id: "u404", points: 1 }],
158 });
159 expect(nobody.ok).toBe(false);
160 if (!nobody.ok) expect(nobody.error).toBe("not_found");
161 // Nothing half-written: every refusal left the tables as they were.
162 expect(rows(db, "SELECT * FROM round").length).toBe(before);
163 expect(rows(db, "SELECT * FROM score WHERE player_id = ?1", "u404").length).toBe(
164 0,
165 );
166 // The list itself is a shape like any other: a 20 Hz loop gets a failure
167 // it can log, never an exception. A player named twice would otherwise
168 // reach the primary key and throw out of the transaction.
169 for (const scores of [
170 [
171 { player_id: "u1", points: 1 },
172 { player_id: "u1", points: 2 },
173 ],
174 {} as never,
175 "u1" as never,
176 [null as never],
177 [{ points: 1 } as never],
178 ]) {
179 let r: ReturnType<typeof recordRound>;
180 expect(() => {
181 r = recordRound(db, SYSTEM, { outcome: "gate", scores });
182 }).not.toThrow();
183 expect(r!.ok).toBe(false);
184 if (!r!.ok) expect(r!.error).toBe("invalid");
185 }
186
187 // And a round the game records lands whole.
188 const good = value(
189 recordRound(db, SYSTEM, {
190 outcome: "clock",
191 scores: [{ player_id: "u1", points: 3 }],
192 }),
193 );
194 expect(good.scores).toBe(1);
195 expect(good.round.outcome).toBe("clock");
196 expect(rows(db, "SELECT * FROM round").length).toBe(before + 1);
197 });
198
199 test("a guest who left nothing behind is forgotten, and minting pays for it", () => {
200 const fresh = openDb(mkdtempSync(join(tmpdir(), "app-sweep-")));
201 const stale = guest();
202 const scored = guest();
203 const recent = guest();
204 for (const g of [stale, scored, recent])
205 value(ensurePlayer(fresh, anon, { guestKey: g.key }));
206 value(ensurePlayer(fresh, alice, {}));
207 value(
208 recordRound(fresh, SYSTEM, {
209 outcome: "gate",
210 scores: [{ player_id: scored.id, points: 1 }],
211 }),
212 );
213 // Two of them stopped playing a fortnight ago.
214 const long = new Date(Date.now() - 14 * 86_400_000).toISOString();
215 fresh
216 .query("UPDATE player SET last_seen_at = ?2 WHERE id IN (?1, ?3)")
217 .run(stale.id, long, scored.id);
218
219 const swept = value(sweepGuests(fresh, SYSTEM, {}));
220 expect(swept.forgotten).toBe(1);
221 const left = rows(fresh, "SELECT id FROM player ORDER BY id").map(
222 (r) => (r as { id: string }).id,
223 );
224 expect(left).toContain(scored.id); // a score is a reason to keep them
225 expect(left).toContain(recent.id); // so is having been here today
226 expect(left).toContain("u1"); // and a forge player is never a guest
227 expect(left).not.toContain(stale.id);
228
229 // Minting sweeps too, so the table stays bounded before any loop exists.
230 fresh.query("UPDATE player SET last_seen_at = ?2 WHERE id = ?1").run(
231 recent.id,
232 long,
233 );
234 value(ensurePlayer(fresh, anon, { guestKey: guest().key }));
235 expect(
236 rows(fresh, "SELECT id FROM player WHERE id = ?1", recent.id).length,
237 ).toBe(0);
238
239 // Housekeeping is the game's, not a door's.
240 for (const who of [anon, alice, root]) {
241 const r = sweepGuests(fresh, who, {});
242 expect(r.ok).toBe(false);
243 if (!r.ok) expect(r.error).toBe("forbidden");
244 }
245 });
246
247 test("the ladder sums a window, ranks it, and has no guests on it", () => {
248 const fresh = openDb(mkdtempSync(join(tmpdir(), "app-ladder-")));
249 const g = guest();
250 value(ensurePlayer(fresh, anon, { guestKey: g.key }));
251 value(ensurePlayer(fresh, alice, {}));
252 value(ensurePlayer(fresh, bob, {}));
253 const now = Date.parse("2026-09-17T12:00:00.000Z");
254 const at = (daysAgo: number) => new Date(now - daysAgo * 86_400_000).toISOString();
255 const round = (daysAgo: number, scores: [string, number][]) =>
256 value(
257 recordRound(fresh, SYSTEM, {
258 outcome: "gate",
259 started_at: at(daysAgo),
260 ended_at: at(daysAgo),
261 scores: scores.map(([player_id, points]) => ({ player_id, points })),
262 }),
263 );
264 round(1, [
265 ["u1", 10],
266 ["u2", 4],
267 [g.id, 900],
268 ]);
269 round(2, [
270 ["u2", 9],
271 [g.id, 900],
272 ]);
273 round(30, [["u1", 1000]]);
274
275 const week = value(ladder(fresh, anon, { now }));
276 expect(week.map((r) => r.name)).toEqual(["bob", "alice"]);
277 expect(week[0]).toEqual({
278 rank: 1,
279 player_id: "u2",
280 name: "bob",
281 points: 13,
282 best: 9,
283 rounds: 2,
284 });
285 expect(week[1]!.points).toBe(10);
286 // The guest scored more than anyone and is not on it.
287 expect(week.some((r) => r.player_id === g.id)).toBe(false);
288
289 // A wider window reaches the month-old round; a narrower one drops the
290 // round from two days ago and everything in it.
291 expect(value(ladder(fresh, anon, { days: 60, now }))[0]!.name).toBe("alice");
292 expect(
293 value(ladder(fresh, anon, { days: 1, now })).map((r) => [r.name, r.points]),
294 ).toEqual([
295 ["alice", 10],
296 ["bob", 4],
297 ]);
298 // The window and the limit are clamped, never trusted.
299 expect(value(ladder(fresh, anon, { days: -5, limit: 1, now })).length).toBe(1);
300 expect(value(ladder(fresh, anon, { days: 1e9, limit: 1e9, now })).length).toBe(
301 2,
302 );
303
304 // A tie goes to whoever got there first: dana matches bob's 13 today,
305 // bob's first round was two days ago.
306 value(ensurePlayer(fresh, { role: "user", id: "u3", name: "dana" }, {}));
307 round(0, [["u3", 13]]);
308 expect(value(ladder(fresh, anon, { now })).map((r) => r.name)).toEqual([
309 "bob",
310 "dana",
311 "alice",
312 ]);
313 });
314
315 const count = (db: Database): number =>
316 db.query<{ n: number }, []>("SELECT count(*) AS n FROM player").get()!.n;
317 const rows = (
318 db: Database,
319 sql: string,
320 ...args: string[]
321 ): unknown[] => db.query(sql).all(...args);
src/actions.ts +304
@@ -10,6 +10,10 @@
1010 * player, a round, a score — land with #DATA.
1111 */
1212
13 import type { Database } from "bun:sqlite";
14 import { guestId, guestName, isGuestKey } from "./session.ts";
15 import type { Who } from "./who.ts";
16
1317 export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid";
1418 export type Result<T> =
1519 | { ok: true; value: T }
@@ -21,3 +25,303 @@ export const fail = <T>(error: Failure, message: string): Result<T> => ({
2125 error,
2226 message,
2327 });
28
29 // --- Players, rounds and the ladder -----------------------------------------
30 //
31 // The durable half of the game. The sea is not here: a 20 Hz tick belongs in
32 // memory, and only what a finished round leaves behind is written down.
33
34 export interface Player {
35 readonly id: string;
36 readonly name: string;
37 readonly kind: "forge" | "guest";
38 readonly created_at: string;
39 readonly last_seen_at: string;
40 }
41
42 export interface LadderRow {
43 readonly rank: number;
44 readonly player_id: string;
45 readonly name: string;
46 readonly points: number;
47 readonly best: number;
48 readonly rounds: number;
49 }
50
51 /**
52 * The guest *key* from the cookie (`session.ts`), when the person asking has
outdated

Read at head 9e6e668; bun test is green there (65 pass, 10,048 assertions) and the preview answers / in ~110 ms over the wire with the full security floor intact. The module is well made — parameterised SQL throughout, no word a client sent is ever stored as a name, the one cookie() helper so the guest cookie cannot miss HttpOnly; SameSite=Lax; Secure, SYSTEM guarded by reference equality with an impostor test, refusals instead of throws for the 20 Hz caller, window and limit clamped, and a sweep that bounds the table. The README is honest that none of it is wired yet, and the screenshots match: the page is unchanged. One security finding, one blocker to landing, three notes.

*Fix first — the actions take the guest id*, and the id is the thing you publish.** src/actions.ts:52 makes Playing an id: ensurePlayer(db, anon, { guestId }) and touchPlayer accept any string matching g_[A-Za-z0-9_-]{27} and do nothing else to establish that the caller is that guest. But the id is deliberately public — src/session.ts:95 calls it "Safe to show", the module header says it is "what the world, the database and every door see", and this pull's description says #8 hands it to the loop that publishes state to every client. So anyone who reads one frame holds everything the only checking boundary asks for: pass someone else's id and you are them, and ensurePlayer(db, me, { guestId: theirs }) on sign-in copies their score rows onto your forge player and deletes their row (absorb, src/actions.ts). "Safe to show" and "enough to act as" cannot both be true of one string. Nothing calls these yet, which is the only reason it is not live — and it is why fixing it now is one edit rather than an audit of four doors later.

The fix: take the key, never the id. Playing becomes { guestKey?: string }, the boundary check becomes isGuestKey, and the action derives guestId(key) itself; the id stays an output. Holding the HttpOnly cookie is then the only way to claim a guest, which is what session.ts already means. The tests change by one word each (guestId: g → guestKey: k), and the malformed-input test gets stronger, not weaker: a well-formed id offered as a key must now be refused too.

Second — guest= is a shared-domain cookie here. Every app and every preview on this forge lives under *.open-platform.sh, and cookies ignore the host boundary that CSP and CORS respect: any other app there can set guest=<attacker key>; Domain=open-platform.sh and the browser will send it to drift. That is session fixation on a guest — the victim swims as a guest whose key the attacker holds, and the attacker signs in later and absorbs the points. Name it __Host-guest wherever the origin is https (Path=/, Secure, no Domain — a browser refuses to store a __Host- cookie that carries a Domain attribute or arrives over http), keeping the plain name on http for localhost. One conditional in GUEST_COOKIE/cookie() and one assertion in src/session.test.ts.

Blocker to landing: head 9e6e668 conflicts with main on README.md, and the branch predates #20 — src/world.ts is not in it. Merge main and re-run bun test before this can go anywhere.

Notes, not blockers. (1) The IF NOT EXISTS guards in src/db.ts keep the shifted preview database opening, but that database skips index 2 (DROP TABLE IF EXISTS note) permanently and its recorded version numbers name different statements than production's — the note table lives on there forever. Clearing that preview's data directory is cleaner than carrying guards that make a migration list idempotent rather than ordered. (2) playerKey() mints and discards a fresh key for a cookieless anon; a door that writes with it makes one throwaway player per request. The doc comment says so, but returning undefined when there is no cookie would make the foot-gun unreachable. (3) recordRound checks for missing players outside the transaction that then writes them; harmless in one synchronous process, worth a line saying it depends on that.

Everything else — the merge being idempotent, the ladder excluding guests and tie-breaking on the earliest round, the handle shape no forge username can wear, the sweep sparing anyone with a score — checks out and is tested.

53 * no forge name. The key, never the id: the id is published — the world puts
54 * it in every frame and the ladder prints it — so anything that accepted an
55 * id would let whoever read one frame play as that guest, and have their
56 * sign-in absorb that guest's score. Holding the `HttpOnly` cookie is the
57 * only way to claim a guest, which is the whole of what the cookie means.
58 * The id is an output of these actions and never an input to them.
59 */
60 export interface Playing {
61 readonly guestKey?: string | undefined;
62 }
63
64 const nowIso = (at: number = Date.now()): string => new Date(at).toISOString();
65 const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
66 /** How far back the ladder sums. The page will say it out loud (#LADDER). */
67 export const LADDER_DAYS = 7;
68 /** How long a guest who scored nothing is remembered: the ladder's own window, so the two cannot drift. */
69 export const GUEST_MEMORY = LADDER_DAYS * 86_400_000;
70
71 /**
72 * The player this person is, created if this is their first time and kept
73 * current if it is not. A forge player carries the forge's name; a guest
74 * carries a handle generated from their id, because a name a client sent is
75 * a name a client chose.
76 *
77 * Signing in while holding a guest cookie leaves one identity: whatever the
78 * guest scored moves to the forge player and the guest row goes, so the
79 * person who played anonymously this morning is the same person on the
80 * ladder this afternoon — and there is only one of them.
81 *
82 * That merge is driven by a cookie the client supplies, and it is the one
83 * thing here that destroys a row. A door that calls it with `guestKey` on a
84 * plain `GET` lets anyone who can plant a cookie in someone's browser have
85 * that person's sign-in absorb it — points only, but a door should mean it:
86 * pass `guestKey` where the person is joining the world, not on every page.
87 */
88 export function ensurePlayer(
89 db: Database,
90 who: Who,
91 input: Playing = {},
92 ): Result<Player> {
93 const key = input.guestKey;
94 if (key !== undefined && !isGuestKey(key))
95 return fail("invalid", "That is not a guest key.");
96 // The id is derived here and nowhere else, so no caller can hand one in.
97 const guest = key === undefined ? undefined : guestId(key);
98 if (who.role === "anon") {
99 if (guest === undefined)
100 return fail("invalid", "A guest plays with a session key.");
101 // A guest key is a string a client sends, so minting a row is the one
102 // way a client grows this table. Every mint pays for the last: the
103 // guests who left nothing behind and have not been seen inside the
104 // ladder's window are forgotten here, which bounds the table at the
105 // people actually playing. A cheap index range, usually empty.
106 forget(db, nowIso(Date.now() - GUEST_MEMORY));
107 return ok(upsert(db, guest, guestName(guest), "guest"));
108 }
109 return ok(
110 db.transaction(() => {
111 const player = upsert(db, who.id, who.name, "forge");
112 return guest === undefined || guest === player.id
113 ? player
114 : absorb(db, guest, player);
115 })(),
116 );
117 }
118
119 /** The same player, marked present now. It creates nobody: `ensurePlayer` does that. */
120 export function touchPlayer(
121 db: Database,
122 who: Who,
123 input: Playing = {},
124 ): Result<Player> {
125 let id: string;
126 if (who.role === "anon") {
127 const key = input.guestKey;
128 if (key === undefined)
129 return fail("invalid", "A guest plays with a session key.");
130 if (!isGuestKey(key)) return fail("invalid", "That is not a guest key.");
131 id = guestId(key);
132 } else id = who.id;
133 const player = db
134 .query<
135 Player,
136 [string, string]
137 >("UPDATE player SET last_seen_at = ?2 WHERE id = ?1 RETURNING *")
138 .get(id, nowIso());
139 return player === null ? fail("not_found", "No such player.") : ok(player);
140 }
141
142 /**
143 * The last `days` of scores, best first, top `limit`. Only the forge's
144 * players are on it: a guest is anonymous by construction and their handle
145 * would mean nothing tomorrow. Ties go to whoever got there first.
146 */
147 export function ladder(
148 db: Database,
149 _who: Who,
150 input: { days?: number; limit?: number; now?: number } = {},
151 ): Result<LadderRow[]> {
152 const days = clamp(input.days ?? LADDER_DAYS, 1, 365);
153 const limit = clamp(input.limit ?? 20, 1, 100);
154 const now = Number.isFinite(input.now) ? (input.now as number) : Date.now();
155 const since = nowIso(now - days * 86_400_000);
156 const rows = db
157 .query<Omit<LadderRow, "rank">, [string, number]>(
158 `SELECT p.id AS player_id, p.name AS name,
159 sum(s.points) AS points, max(s.points) AS best, count(*) AS rounds
160 FROM round r
161 JOIN score s ON s.round_id = r.id
162 JOIN player p ON p.id = s.player_id
163 WHERE r.ended_at IS NOT NULL AND r.ended_at >= ?1 AND p.kind = 'forge'
164 GROUP BY p.id
165 ORDER BY points DESC, min(r.ended_at) ASC
166 LIMIT ?2`,
167 )
168 .all(since, limit);
169 return ok(rows.map((row, i) => ({ rank: i + 1, ...row })));
170 }
171
172 /**
173 * The round loop's own identity. It is a thing in this process, not a
174 * person, and no door can present it: a door builds its `Who` from the
175 * forge's headers, and that is never this object.
176 */
177 export const SYSTEM = {
178 role: "admin",
179 id: "drift:loop",
180 name: "drift",
181 } as const satisfies Who;
182
183 export interface RoundRecord {
184 readonly id: number;
185 readonly started_at: string;
186 readonly ended_at: string | null;
187 readonly outcome: string | null;
188 }
189
190 /**
191 * What a finished round leaves behind, written once: the round and every
192 * score in one transaction, so the ladder never reads half of one. Called
193 * by the loop that owns the world (#8), never by a door.
194 */
195 export function recordRound(
196 db: Database,
197 who: Who,
198 input: {
199 outcome: string;
200 started_at?: string;
201 ended_at?: string;
202 scores?: readonly { player_id: string; points: number }[];
203 },
204 ): Result<{ round: RoundRecord; scores: number }> {
205 if (who !== SYSTEM)
206 return fail("forbidden", "A round is recorded by the game, not by a door.");
207 if (!/^[a-z][a-z_]{0,31}$/.test(input.outcome ?? ""))
208 return fail("invalid", "An outcome is a short lowercase word.");
209 const started = input.started_at ?? nowIso();
210 const ended = input.ended_at ?? nowIso();
211 if (!ISO.test(started) || !ISO.test(ended))
212 return fail("invalid", "A time is an ISO 8601 instant in UTC.");
213 // The caller is a 20 Hz loop: everything it can get wrong comes back as a
214 // failure it can log, never as an exception that takes the loop down. That
215 // includes the shape of the list itself and a player named twice, which
216 // the primary key would otherwise refuse mid-transaction.
217 const scores = input.scores ?? [];
218 if (!Array.isArray(scores)) return fail("invalid", "Scores are a list.");
219 const named = new Set<string>();
220 for (const s of scores) {
221 if (
222 typeof s !== "object" ||
223 s === null ||
224 typeof s.player_id !== "string" ||
225 s.player_id === ""
226 )
227 return fail("invalid", "A score belongs to a player.");
228 if (!Number.isInteger(s.points) || s.points < 0 || s.points > 1_000_000)
229 return fail("invalid", "Points are a whole number from 0 to 1,000,000.");
230 if (named.has(s.player_id))
231 return fail("invalid", "A player scores once in a round.");
232 named.add(s.player_id);
233 }
234 // Read before the write, not inside it: one process, one synchronous
235 // writer, and nothing between this query and the transaction that can
236 // delete a player — `sweepGuests` and `absorb` are calls on this same
237 // thread, and neither is one of them. Were a second writer ever to open
238 // this file, this check would have to move inside the transaction.
239 const missing = scores.find(
240 (s) =>
241 db
242 .query<{ id: string }, [string]>("SELECT id FROM player WHERE id = ?1")
243 .get(s.player_id) === null,
244 );
245 if (missing !== undefined) return fail("not_found", "No such player.");
246 return ok(
247 db.transaction(() => {
248 const round = db
249 .query<
250 RoundRecord,
251 [string, string, string]
outdated

Read the whole diff at e9b305b, ran bun test on that exact tree (33 pass, and the rules bite: removing p.kind = 'forge' from the ladder query fails one test, loosening isGuestKey fails three), probed the actions directly, and used the preview — / answers 200 with the full security floor, unchanged, as a data-only change should.

The identity work is right, and it is the part I came to look at. The guest key is a bearer secret that is never an id, guestId is a one-way derivation validated by shape at both boundaries, the name is always the forge's or one the app generated, a guest cookie can never override who, the cookie goes out through the one cookie() helper so it cannot miss HttpOnly/SameSite/Secure, and every query is parameterised. Deriving the id instead of using the key as the issue wrote it is a better call than the issue's, and the merge on sign-in is transactional and idempotent. No secret, no shell, no SQL built from text, no new dependency.

Four things. The first is the one to do.

1. recordRound throws instead of failing — this line and the loop below it (src/actions.ts:251-277). Every action here promises data or a named failure, and this one keeps that promise for every field except the shape of scores itself. Two entries for the same player in one round hit PRIMARY KEY (round_id, player_id) and the exception escapes the transaction to the caller:

recordRound(db, SYSTEM, { outcome: "gate", scores: [
  { player_id: "u1", points: 1 },
  { player_id: "u1", points: 2 },
]})
// UNIQUE constraint failed: score.round_id, score.player_id

So does scores: {} ("{} is not iterable"). The rollback is clean — I checked, no half-written round — but #8 is a 20 Hz loop, and a round that accumulated points per event rather than per player takes the loop down instead of getting a refusal it can log. Fix: in this validation pass, refuse a non-array scores and a repeated player_id, both fail("invalid", …), with a test that fails without it.

2. Nothing bounds the player table (src/actions.ts:148). A guest cookie is a string the client sends; any well-shaped 43-character key mints a row through ensurePlayer, and the door that will call this is a GET of the arena, which is not rationed the way writes are. One client can therefore grow player without limit, and nothing ever removes a guest who scored nothing and never came back. The table is this module's, so the guard belongs here rather than in #7: prune guests with no rows in score and last_seen_at older than a few days (the ladder window is the natural horizon), or have the door mint the row only for a session it actually admitted to the world. Say which in the pull, and test it.

3. The README describes behaviour no door has yet (README.md, "Playing as a guest"). "Nobody has to sign in to swim… is given a guest key" reads as true of the deployed app; at this head no response sets that cookie, as the pull body itself says. One sentence — that the cookie starts being issued when the arena shell (#7) and the socket (#9) land — keeps the README honest about what is real versus wired.

4. Three small ones. readGuestKey (src/session.ts:80) hardcodes guest= in its regex while GUEST_COOKIE sits right above it — rename the constant and the reader quietly stops reading. session() only sets the cookie when none was carried (src/session.ts:113), so a guest's year never slides however often they play; setting it every time costs a header. And guestName (src/session.ts:53) draws from 64×64 = 4096 handles, so a world holding ~90 guests is about even money to show two Quiet Tides — a third word, or two characters of the id, makes a name mean one swimmer.

Not blocking, but be deliberate about it when the door is written: ensurePlayer performs a destructive merge (moves score rows, deletes a row) driven by a cookie the client supplies, and it will be reached on a GET. Someone who can plant a guest cookie in another person's browser makes that person's sign-in absorb it. Points only, so the impact is small — just don't let it be the first thing GET / does without thinking about it. The layering is worth a thought for later too: actions.ts now reaches http.ts transitively because session.ts holds both the id derivation (domain) and the Set-Cookie (transport) — two concepts in a module named for one.

To be clear about weight: nothing here is a vulnerability, and I would not hold the pull for #3 or #4 alone. #1 and #2 are what I want back — a writer that refuses instead of throwing, and a table that cannot be grown without bound by a string the client sends.

(This supersedes my earlier plain comment with the same findings: POST …/reviews refuses a verdict without an anchor, and it took me until now to find the shape it wants. The stray ok and x comments above are mine from probing that endpoint; there is no delete route, so please ignore them.)

252 >("INSERT INTO round (started_at, ended_at, outcome) VALUES (?1, ?2, ?3) RETURNING *")
253 .get(started, ended, input.outcome)!;
254 const insert = db.query<
255 null,
256 [number, string, number]
257 >("INSERT INTO score (round_id, player_id, points) VALUES (?1, ?2, ?3)");
258 for (const s of scores) insert.run(round.id, s.player_id, s.points);
259 return { round, scores: scores.length };
260 })(),
261 );
262 }
263
264 /**
265 * Forget the guests who left nothing behind. A guest is a cookie, and a
266 * cookie is a string a client sends: without this the `player` table grows
267 * for as long as someone keeps sending new ones. A guest with a score is
268 * kept — the ladder and a finished round still refer to them — and so is
269 * anyone seen inside the window. `ensurePlayer` does this as it mints;
270 * the round loop can also call it between rounds.
271 */
272 export function sweepGuests(
273 db: Database,
274 who: Who,
275 input: { days?: number; now?: number } = {},
276 ): Result<{ forgotten: number }> {
277 if (who !== SYSTEM)
278 return fail("forbidden", "Housekeeping belongs to the game, not a door.");
279 const days = clamp(input.days ?? LADDER_DAYS, 1, 365);
280 const now = Number.isFinite(input.now) ? (input.now as number) : Date.now();
281 return ok({ forgotten: forget(db, nowIso(now - days * 86_400_000)) });
282 }
283
284 /** Guests last seen before `before` who have no score. Returns how many went. */
285 const forget = (db: Database, before: string): number =>
286 db
287 .query(
288 `DELETE FROM player
289 WHERE kind = 'guest' AND last_seen_at < ?1
290 AND NOT EXISTS (SELECT 1 FROM score WHERE score.player_id = player.id)`,
291 )
292 .run(before).changes;
293
294 const clamp = (value: number, low: number, high: number): number =>
295 Math.min(Math.max(Math.trunc(value) || low, low), high);
296
297 /** The row for `id`, created with this name and kind, or brought up to date. */
298 const upsert = (
299 db: Database,
300 id: string,
301 name: string,
302 kind: "forge" | "guest",
303 ): Player =>
304 db
305 .query<
306 Player,
307 [string, string, string]
308 >(`INSERT INTO player (id, name, kind) VALUES (?1, ?2, ?3)
309 ON CONFLICT (id) DO UPDATE SET name = excluded.name
310 RETURNING *`)
311 .get(id, name, kind)!;
312
313 /** Move a guest's scores onto `player` and let the guest go. One person, one row. */
314 const absorb = (db: Database, guest: string, player: Player): Player => {
315 if (
316 db.query<{ id: string }, [string]>("SELECT id FROM player WHERE id = ?1")
317 .get(guest) === null
318 )
319 return player;
320 db.query(
321 `INSERT INTO score (round_id, player_id, points)
322 SELECT round_id, ?2, points FROM score WHERE player_id = ?1
323 ON CONFLICT (round_id, player_id) DO UPDATE SET points = points + excluded.points`,
324 ).run(guest, player.id);
325 db.query("DELETE FROM player WHERE id = ?1").run(guest);
326 return player;
327 };
src/db.test.ts +44 −9
@@ -1,3 +1,4 @@
1 import { Database } from "bun:sqlite";
12 import { expect, test } from "bun:test";
23 import { mkdtempSync } from "node:fs";
34 import { tmpdir } from "node:os";
@@ -21,13 +22,47 @@ test("migrations apply once, in order, and the file checks out", () => {
2122 ?.foreign_keys,
2223 ).toBe(1);
2324 // The scaffold's table left the way a table leaves here: forward, in a
24 // migration of its own, on a database that already had it.
25 expect(
26 db
27 .query<
28 { name: string },
29 []
30 >("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> 'schema_version'")
31 .all(),
32 ).toEqual([]);
25 // migration of its own, on a database that already had it. What stands in
26 // its place is the game's own, and nothing else.
27 expect(tables(db)).toEqual(["player", "round", "score"]);
3328 });
29
30 test("a database that ran the game's tables at other numbers catches up", () => {
31 // This pull's preview applied the six game migrations as 3–8, before the
32 // scaffold's drop landed on main and pushed them to 4–9: it has every
33 // table the list makes and never took the drop. The guards are what let
34 // such a file finish the list instead of trying to create what is already
35 // there — and the drop at the end is what stops it carrying `note`
36 // forever, so that a shifted database ends up holding exactly what a fresh
37 // one does, whatever its version rows are called.
38 const dir = mkdtempSync(join(tmpdir(), "app-db-shifted-"));
39 const early = new Database(join(dir, "app.db"));
40 early.exec(
41 "CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))",
42 );
43 const skippedDrops = migrations.filter((sql) => /^DROP TABLE/.test(sql));
44 migrations
45 .filter((sql) => !skippedDrops.includes(sql))
46 .forEach((sql, i) => {
47 early.exec(sql);
48 early.query("INSERT INTO schema_version (version) VALUES (?1)").run(i + 1);
49 });
50 expect(check(early).version).toBe(migrations.length - skippedDrops.length);
51 expect(early.query("SELECT count(*) AS n FROM note").get()).toEqual({ n: 0 });
52 early.close();
53
54 const db = openDb(dir);
55 expect(check(db)).toEqual({ version: migrations.length, integrity: "ok" });
56 expect(db.query("SELECT count(*) AS n FROM player").get()).toEqual({ n: 0 });
57 expect(tables(db)).toEqual(["player", "round", "score"]);
58 });
59
60 /** Every table the app made, in one list: no sqlite internals, no bookkeeping. */
61 const tables = (db: Database): string[] =>
62 db
63 .query<
64 { name: string },
65 []
66 >("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> 'schema_version' ORDER BY name")
67 .all()
68 .map((t) => t.name);
src/db.ts +46
@@ -28,6 +28,52 @@ export const migrations: string[] = [
2828 // has run is history, so the two above stay as they are and this one
2929 // undoes them forward.
3030 `DROP TABLE IF EXISTS note`,
31 // The six below arrived on a branch that forked before that drop, so they
32 // ran as 3–8 on this pull's preview and run as 4–9 everywhere else. A
33 // database one short of the list re-runs the last statement, and the
34 // guards are what make that harmless. The skew is in the numbering only:
35 // migration 10 below leaves every database holding the same objects.
36 //
37 // Who is playing. A forge player's id is the forge's; a guest's is derived
38 // from the key in their cookie (`session.ts`), so the secret itself is
39 // never an id and never leaves the browser. A name is the forge's or a
40 // handle this app generated — never a word a client sent.
41 `CREATE TABLE IF NOT EXISTS player (
42 id TEXT PRIMARY KEY,
43 name TEXT NOT NULL,
44 kind TEXT NOT NULL CHECK (kind IN ('forge','guest')),
45 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
46 last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
47 )`,
48 // What a finished round leaves behind. The sea itself is never here: a
49 // 20 Hz tick lives in memory, and only the result of a round is durable.
50 `CREATE TABLE IF NOT EXISTS round (
51 id INTEGER PRIMARY KEY,
52 started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
53 ended_at TEXT,
54 outcome TEXT
55 )`,
56 `CREATE TABLE IF NOT EXISTS score (
57 round_id INTEGER NOT NULL REFERENCES round (id) ON DELETE CASCADE,
58 player_id TEXT NOT NULL REFERENCES player (id) ON DELETE CASCADE,
59 points INTEGER NOT NULL CHECK (points >= 0),
60 PRIMARY KEY (round_id, player_id)
61 )`,
62 // The ladder sums the last seven days: it starts from the rounds that
63 // ended inside the window, and the rest is primary keys.
64 `CREATE INDEX IF NOT EXISTS round_ended ON round (ended_at)`,
65 // A player's own scores: what the merge on sign-in moves, and what says
66 // whether a guest left anything behind worth keeping.
67 `CREATE INDEX IF NOT EXISTS score_player ON score (player_id)`,
68 // The guests to forget: the oldest end of one kind, found without
69 // reading the players who are still here.
70 `CREATE INDEX IF NOT EXISTS player_guest ON player (kind, last_seen_at)`,
71 // And the scaffold's table goes for good. A database that took the drop at
72 // index 2 has nothing to do here; the one that skipped it — the preview
73 // whose numbering shifted — loses `note` now rather than carrying it
74 // forever. After this the schemas are identical everywhere, whatever the
75 // version rows are called.
76 `DROP TABLE IF EXISTS note`,
3177 ];
3278
3379 export function openDb(dir = dataDir): Database {
src/http.ts +51 −10
@@ -8,6 +8,9 @@ import { existsSync, readFileSync } from "node:fs";
88 import { join } from "node:path";
99 import { publicOrigin } from "./env.ts";
1010
11 /** Whether a browser reaches us over https: what Secure, HSTS and `__Host-` all turn on. */
12 const SECURE_ORIGIN = publicOrigin.startsWith("https:");
13
1114 // The floor, once, for every response this app makes — the streaming ones
1215 // included. Scripts, styles and connections come from this origin and
1316 // nowhere else; there is no inline style and no inline script, so neither
@@ -40,7 +43,7 @@ export const SECURITY = {
4043 "x-content-type-options": "nosniff",
4144 "referrer-policy": "strict-origin-when-cross-origin",
4245 "permissions-policy": "camera=(), microphone=(), geolocation=()",
43 ...(publicOrigin.startsWith("https:")
46 ...(SECURE_ORIGIN
4447 ? { "strict-transport-security": "max-age=31536000" }
4548 : {}),
4649 } satisfies Record<string, string>;
@@ -99,6 +102,9 @@ export const eventStream = (body: ReadableStream<Uint8Array>): Response =>
99102 },
100103 });
101104
105 /** The cookie a notice is carried in, under the name this origin can hold. */
106 export const NOTICE_COOKIE = cookieName("notice");
107
102108 /** Back to the page, with a word for it to show once (`readNotice`). */
103109 export const back = (notice?: string): Response =>
104110 new Response(null, {
@@ -108,7 +114,13 @@ export const back = (notice?: string): Response =>
108114 ...SECURITY,
109115 ...(notice === undefined
110116 ? {}
111 : { "set-cookie": cookie("notice", encodeURIComponent(notice), 30) }),
117 : {
118 "set-cookie": cookie(
119 NOTICE_COOKIE,
120 encodeURIComponent(notice),
121 30,
122 ),
123 }),
112124 },
113125 });
114126
@@ -117,20 +129,49 @@ export function readNotice(request: Request): {
117129 notice?: string;
118130 clear: Record<string, string>;
119131 } {
120 const raw = /(?:^|;\s*)notice=([^;]*)/.exec(
121 request.headers.get("cookie") ?? "",
122 )?.[1];
132 const raw = readCookie(request, NOTICE_COOKIE);
123133 if (raw === undefined) return { clear: {} };
124134 return {
125135 notice: decodeURIComponent(raw),
126 clear: { "set-cookie": cookie("notice", "", 0) },
136 clear: { "set-cookie": cookie(NOTICE_COOKIE, "", 0) },
127137 };
128138 }
129139
130 // Every cookie the app sets itself is HttpOnly and SameSite=Lax, and Secure
131 // wherever the public address is https.
132 const cookie = (name: string, value: string, maxAge: number): string =>
133 `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`;
140 /**
141 * The name a cookie of ours wears here. Every app and every preview on this
142 * forge lives under one parent domain, and cookies do not respect the host
143 * boundary that CSP and CORS do: a sibling app can set
144 * `name=…; Domain=<parent>` and the browser will hand it to us as if we had
145 * set it — session fixation from next door. The `__Host-` prefix closes
146 * that: a browser refuses to store such a cookie if it carries a `Domain`,
147 * is not `Path=/`, or did not arrive over https, and hands it back only to
148 * the exact host that set it. So wherever the public address is https, that
149 * is the name; on plain http (localhost) no browser would store the prefix
150 * at all, and the bare name stands.
151 */
152 export function cookieName(name: string): string {
153 return SECURE_ORIGIN ? `__Host-${name}` : name;
154 }
155
156 // Every cookie the app sets itself is HttpOnly and SameSite=Lax, Path=/ and
157 // Secure wherever the public address is https — which is also what makes the
158 // `__Host-` name above storable. One definition, so a cookie added later
159 // cannot miss the floor (`session.ts` sets the guest key with it).
160 export const cookie = (name: string, value: string, maxAge: number): string =>
161 `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${SECURE_ORIGIN ? "; Secure" : ""}`;
162
163 /**
164 * The raw value of one of our cookies on this request, or nothing. Built
165 * from the name it is given, so renaming a cookie cannot leave a reader
166 * looking for the old one.
167 */
168 export const readCookie = (
169 request: Request,
170 name: string,
171 ): string | undefined =>
172 new RegExp(
173 `(?:^|;\\s*)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]*)`,
174 ).exec(request.headers.get("cookie") ?? "")?.[1];
134175
135176 // A browser on another site can make a person's browser POST here; it cannot
136177 // make that request look same-origin. Browsers say where a request came from
src/server.test.ts +32 −1
@@ -4,7 +4,16 @@ import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { RULE } from "./concept.ts";
66 import { publicOrigin } from "./env.ts";
7 import { asset, SECURITY, sameOrigin, socketOrigin } from "./http.ts";
7 import {
8 asset,
9 back,
10 cookieName,
11 NOTICE_COOKIE,
12 readNotice,
13 sameOrigin,
14 SECURITY,
15 socketOrigin,
16 } from "./http.ts";
817 import { emit, listenerCount, STREAM_MAX, streamCount } from "./live.ts";
918 import { routes } from "./routes.ts";
1019 import { serve } from "./server.ts";
@@ -505,3 +514,25 @@ test("HEAD of a stream answers and keeps nothing open", async () => {
505514 expect(streamCount("user:u6")).toBe(0);
506515 expect(listenerCount()).toBe(0);
507516 });
517
518 test("the app's own cookies round-trip and carry no Domain", () => {
519 // The notice is the other cookie this app sets. It goes through the same
520 // name and the same reader as the guest key, so a sibling app on the
521 // forge's parent domain cannot plant either one where the origin is https
522 // — and the reader still finds what `back` wrote.
523 const setCookie = back("careful")!.headers.get("set-cookie")!;
524 expect(setCookie).toContain(`${NOTICE_COOKIE}=`);
525 expect(setCookie).toContain("HttpOnly");
526 expect(setCookie.toLowerCase()).not.toContain("domain=");
527 expect(cookieName("notice")).toBe(
528 publicOrigin.startsWith("https:") ? "__Host-notice" : "notice",
529 );
530
531 const carried = new Request(base, {
532 headers: { cookie: setCookie.split(";")[0]! },
533 });
534 const { notice, clear } = readNotice(carried);
535 expect(notice).toBe("careful");
536 expect(clear["set-cookie"]).toContain("Max-Age=0");
537 expect(readNotice(new Request(base)).notice).toBe(undefined);
538 });
src/session.test.ts added +157
@@ -0,0 +1,157 @@
1 import { expect, test } from "bun:test";
2 import { join } from "node:path";
3 import {
4 GUEST_COOKIE,
5 GUEST_MAX_AGE,
6 guestId,
7 guestName,
8 isGuestId,
9 isGuestKey,
10 newGuestKey,
11 playerKey,
12 readGuestKey,
13 session,
14 } from "./session.ts";
15 import type { Who } from "./who.ts";
16
17 const anon: Who = { role: "anon" };
18 const alice: Who = { role: "user", id: "u1", name: "alice" };
19 const holding = (cookie: string): Request =>
20 new Request("http://localhost/", { headers: { cookie } });
21
22 test("a guest key is 32 random bytes, base64url, and never twice the same", () => {
23 const keys = new Set(Array.from({ length: 200 }, newGuestKey));
24 expect(keys.size).toBe(200);
25 for (const key of keys) {
26 expect(key).toMatch(/^[A-Za-z0-9_-]{43}$/);
27 expect(Buffer.from(key, "base64url").length).toBe(32);
28 expect(isGuestKey(key)).toBe(true);
29 }
30 });
31
32 test("a key of the wrong shape is refused before it is ever used", () => {
33 for (const bad of [
34 "",
35 "short",
36 `${newGuestKey()}=`,
37 "../../etc/passwd",
38 "'; DROP TABLE player; --",
39 "a".repeat(44),
40 "a".repeat(42),
41 `${"a".repeat(42)}+`,
42 ]) {
43 expect(isGuestKey(bad)).toBe(false);
44 expect(readGuestKey(holding(`${GUEST_COOKIE}=${bad}`))).toBe(undefined);
45 }
46 // Not a string at all: a key arriving as JSON, a header, or anything else.
47 for (const bad of [undefined, null, 42, {}, [newGuestKey()]])
48 expect(isGuestKey(bad)).toBe(false);
49 expect(readGuestKey(holding("notice=hi"))).toBe(undefined);
50 });
51
52 test("the id is derived from the key, one way, and is not the key", () => {
53 const key = newGuestKey();
54 const id = guestId(key);
55 expect(id).not.toBe(key);
56 expect(id).not.toContain(key);
57 expect(isGuestId(id)).toBe(true);
58 expect(isGuestKey(id)).toBe(false);
59 expect(guestId(key)).toBe(id);
60 expect(guestId(newGuestKey())).not.toBe(id);
61 expect(isGuestId("g_" + "a".repeat(26))).toBe(false);
62 expect(isGuestId(key)).toBe(false);
63 });
64
65 test("a guest's handle is the app's words, stable, and unlike a username", () => {
66 const id = guestId(newGuestKey());
67 expect(guestName(id)).toBe(guestName(id));
68 expect(guestName(id)).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+ \d\d$/);
69 // A name means one swimmer: three hundred fixed keys — a world far
70 // bigger than a roster — and no two handles the same.
71 const keyed = (n: number) => {
72 const bytes = Buffer.alloc(32);
73 bytes.writeUInt32BE(n);
74 return bytes.toString("base64url");
75 };
76 const names = new Set(
77 Array.from({ length: 300 }, (_, i) => guestName(guestId(keyed(i)))),
78 );
79 expect(names.size).toBe(300);
80 });
81
82 test("a new guest is given a cookie; the one they carry is honoured", () => {
83 const fresh = session(new Request("http://localhost/"), anon);
84 expect(fresh.kind).toBe("guest");
85 const setCookie = fresh.headers["set-cookie"]!;
86 expect(setCookie).toContain(`${GUEST_COOKIE}=`);
87 expect(setCookie).toContain("HttpOnly");
88 expect(setCookie).toContain("SameSite=Lax");
89 expect(setCookie).toContain("Path=/");
90 // Never a Domain: a cookie scoped to the forge's parent domain would be
91 // one every app there could read and set.
92 expect(setCookie.toLowerCase()).not.toContain("domain=");
93 // The cookie carries the secret; the id the app hands around does not.
94 const key = new RegExp(`${GUEST_COOKIE}=([^;]+)`).exec(setCookie)![1]!;
95 expect(isGuestKey(key)).toBe(true);
96 expect(fresh.key).toBe(guestId(key));
97 expect(fresh.name).toBe(guestName(fresh.key));
98
99 // The key they carry is honoured, and set again: a guest who keeps
100 // playing keeps their year rather than expiring on a fixed date.
101 const again = session(holding(`${GUEST_COOKIE}=${key}`), anon);
102 expect(again.key).toBe(fresh.key);
103 expect(again.name).toBe(fresh.name);
104 expect(again.headers["set-cookie"]).toContain(`${GUEST_COOKIE}=${key}`);
105 expect(again.headers["set-cookie"]).toContain(`Max-Age=${GUEST_MAX_AGE}`);
106 });
107
108 test("a malformed cookie is not believed: it is replaced, not used", () => {
109 const s = session(holding(`${GUEST_COOKIE}=nonsense`), anon);
110 expect(isGuestId(s.key)).toBe(true);
111 expect(s.key).not.toBe(guestId("nonsense"));
112 expect(s.headers["set-cookie"]).toContain(`${GUEST_COOKIE}=`);
113 });
114
115 test("a signed-in person is the forge's id and name, and is given no cookie", () => {
116 const key = newGuestKey();
117 const s = session(holding(`${GUEST_COOKIE}=${key}`), alice);
118 expect(s).toEqual({ kind: "forge", key: "u1", name: "alice", headers: {} });
119 expect(playerKey(holding(`${GUEST_COOKIE}=${key}`), alice)).toBe("u1");
120 expect(playerKey(holding(`${GUEST_COOKIE}=${key}`), anon)).toBe(guestId(key));
121 });
122
123 test("the id of a browser carrying no key is nothing, not a fresh one", () => {
124 // `playerKey` reads; it never mints. A key minted for a request that is
125 // never told it would leave a player nobody can ever be again.
126 expect(playerKey(new Request("http://localhost/"), anon)).toBe(undefined);
127 expect(playerKey(holding(`${GUEST_COOKIE}=nonsense`), anon)).toBe(undefined);
128 expect(playerKey(new Request("http://localhost/"), alice)).toBe("u1");
129 });
130
131 test("over https the guest cookie is Secure and a name only this host can set", () => {
132 const read = (url: string) =>
133 Bun.spawnSync({
134 cmd: [
135 process.execPath,
136 "-e",
137 'import("./src/session.ts").then((m) => console.log(m.GUEST_COOKIE, "|", m.session(new Request("http://x/"), { role: "anon" }).headers["set-cookie"]))',
138 ],
139 cwd: join(import.meta.dir, ".."),
140 env: { ...process.env, FORGE_APP_URL: url },
141 })
142 .stdout.toString()
143 .trim();
144 // Every app and preview on this forge shares a parent domain, and a
145 // sibling could otherwise plant `guest=…; Domain=<parent>` and fixate a
146 // guest session. A browser stores a `__Host-` cookie only from the exact
147 // host, over https, with Path=/ and no Domain at all.
148 const secure = read("https://drift.example");
149 expect(secure).toContain("; Secure");
150 expect(secure.startsWith("__Host-guest | __Host-guest=")).toBe(true);
151 // On plain http no browser would store the prefix, so localhost keeps the
152 // bare name and the cookie still works there.
153 const plain = read("http://localhost:8080");
154 expect(plain).not.toContain("; Secure");
155 expect(plain).not.toContain("__Host-");
156 expect(plain.startsWith("guest | guest=")).toBe(true);
157 });
src/session.ts added +140
@@ -0,0 +1,140 @@
1 /**
2 * Who this browser is when the forge has nobody to name. A visitor who is
3 * not signed in still has to be the same swimmer between two requests, so
4 * the app gives them one thing: a guest key of 32 random bytes in an
5 * HttpOnly cookie. It is a bearer secret — whoever holds it is that guest —
6 * so it is never the player's id and never reaches a page: the id is a
7 * one-way derivation of it (`guestId`), and that is what the world, the
8 * database and every door see.
9 *
10 * playerKey(request, who) the forge id for a signed-in person, the
11 * guest id otherwise — always safe to show
12 * session(request, who) the same, plus the Set-Cookie when the key
13 * is new or the old one was malformed
14 *
15 * A cleared cookie is a new guest, which is the honest answer: the cookie
16 * is the only thing that makes a guest the same person twice.
17 */
18
19 import { createHash, randomBytes } from "node:crypto";
20 import { cookie, cookieName, readCookie } from "./http.ts";
21 import type { Who } from "./who.ts";
22
23 /**
24 * `__Host-guest` wherever a browser reaches us over https, `guest` on plain
25 * http. The prefix is not decoration: every app on this forge shares one
26 * parent domain, and without it a sibling app could plant
27 * `guest=<its own key>; Domain=<parent>` and have someone swim as a guest
28 * whose key it holds — then sign in later and absorb the points
29 * (`cookieName` in `http.ts` says how the prefix stops that).
30 */
31 export const GUEST_COOKIE = cookieName("guest");
32 /** A year: long enough that a guest keeps their handle, short enough to expire. */
33 export const GUEST_MAX_AGE = 365 * 24 * 60 * 60;
34
35 /** 32 random bytes, base64url: 43 characters, no padding. */
36 export const newGuestKey = (): string => randomBytes(32).toString("base64url");
37
38 /** The shape of a key, checked before it is hashed, stored or believed. */
39 export const isGuestKey = (value: unknown): value is string =>
40 typeof value === "string" && /^[A-Za-z0-9_-]{43}$/.test(value);
41
42 /** The shape of an id, checked before it is used as one. */
43 export const isGuestId = (value: unknown): value is string =>
44 typeof value === "string" && /^g_[A-Za-z0-9_-]{27}$/.test(value);
45
46 /**
47 * The durable id of the guest holding `key`. One way: the id can be shown,
48 * logged and stored, and it does not give the key back.
49 */
50 export const guestId = (key: string): string =>
51 `g_${createHash("sha256").update(`drift/guest/${key}`).digest("base64url").slice(0, 27)}`;
52
53 /**
54 * A guest's name: two words and a mark, from their id, so the same guest
55 * reads the same way twice and no client ever names anybody. Title Case
56 * with spaces, which no forge username is — a guest can never be mistaken
57 * for a person the forge knows. The mark is what makes a name mean one
58 * swimmer: two words alone are 4096 handles, and a busy world would show
59 * two Quiet Tides; with it there are 409,600, and a roster of 24 collides
60 * about once in fourteen thousand rounds.
61 */
62 export function guestName(id: string): string {
63 const h = createHash("sha256").update(`drift/name/${id}`).digest();
64 const mark = String(((h[2]! << 8) | h[3]!) % 100).padStart(2, "0");
65 return `${ADJECTIVES[h[0]! % ADJECTIVES.length]} ${NOUNS[h[1]! % NOUNS.length]} ${mark}`;
66 }
67
68 const ADJECTIVES = [
69 "Quiet", "Pale", "Slow", "Bright", "Salt", "Grey", "Deep", "Late",
70 "Amber", "Cold", "Still", "Far", "Low", "Green", "Idle", "Lost",
71 "Blue", "Soft", "Wide", "First", "Last", "Long", "Small", "Sharp",
72 "Thin", "Warm", "Wild", "Young", "Old", "High", "Near", "Odd",
73 "Loose", "Clear", "Dark", "Fair", "Fast", "Glass", "Hushed", "Iron",
74 "Keen", "Light", "Mild", "North", "Open", "Plain", "Quick", "Rough",
75 "South", "Tall", "Under", "Vast", "West", "East", "Bare", "Calm",
76 "Dim", "Even", "Faint", "Gentle", "Hollow", "Inner", "Just", "Kind",
77 ] as const;
78
79 const NOUNS = [
80 "Tide", "Wake", "Drift", "Current", "Eddy", "Swell", "Shoal", "Surf",
81 "Spray", "Foam", "Bloom", "Gate", "Reef", "Rip", "Lane", "Ripple",
82 "Crest", "Trough", "Fathom", "Keel", "Anchor", "Beacon", "Buoy", "Cove",
83 "Dune", "Ferry", "Gull", "Harbour", "Inlet", "Jetty", "Kelp", "Lagoon",
84 "Mist", "Net", "Oar", "Pier", "Quay", "Raft", "Sail", "Tern",
85 "Undertow", "Vessel", "Wave", "Yard", "Bank", "Channel", "Delta", "Estuary",
86 "Fjord", "Gulf", "Haven", "Isle", "Lee", "Marsh", "Narrows", "Ocean",
87 "Pool", "Reach", "Sound", "Strait", "Trench", "Wash", "Basin", "Bight",
88 ] as const;
89
90 /** The guest key this request carries, or nothing when it carries none of the right shape. */
91 export function readGuestKey(request: Request): string | undefined {
92 const raw = readCookie(request, GUEST_COOKIE);
93 return isGuestKey(raw) ? raw : undefined;
94 }
95
96 export interface Session {
97 /** `forge` when the forge named this person, `guest` when only the cookie did. */
98 readonly kind: "forge" | "guest";
99 /** The player id: the forge's id, or the guest id. Safe to show. */
100 readonly key: string;
101 /** The name to store: the forge's, or a generated handle. Never a client's word. */
102 readonly name: string;
103 /** The cookie to set: a guest's key, minted or renewed. Empty for a signed-in person. */
104 readonly headers: Record<string, string>;
105 }
106
107 /**
108 * Who is playing, and what the response owes the browser. A signed-in
109 * person is their forge identity and is given no cookie; anyone else is
110 * the guest their cookie names, or a new one.
111 */
112 export function session(request: Request, who: Who): Session {
113 if (who.role !== "anon")
114 return { kind: "forge", key: who.id, name: who.name, headers: {} };
115 // The key is minted when there is none, or none of the right shape, and
116 // set again every time either way: a guest who keeps playing keeps their
117 // year, and the cookie only expires on someone who stopped.
118 const key = readGuestKey(request) ?? newGuestKey();
119 const id = guestId(key);
120 return {
121 kind: "guest",
122 key: id,
123 name: guestName(id),
124 headers: { "set-cookie": cookie(GUEST_COOKIE, key, GUEST_MAX_AGE) },
125 };
126 }
127
128 /**
129 * The id this request already plays as: the forge's for a signed-in person,
130 * the guest id for a browser carrying a key, and nothing at all for a first
131 * visit. It never mints one. A key minted here would be thrown away with the
132 * response — the browser was never told it — so a door that wrote with it
133 * would leave a fresh orphan player behind on every request. The door that
134 * wants a guest to exist calls `session` and sets the cookie it returns.
135 */
136 export const playerKey = (request: Request, who: Who): string | undefined => {
137 if (who.role !== "anon") return who.id;
138 const key = readGuestKey(request);
139 return key === undefined ? undefined : guestId(key);
140 };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs