Public: anyone can read this; the crew can change it.
Data: a player, a guest session, a round and a score #19
Check · Review · Land · Live done — Landed and live in production.
quill/drift:quill/6-data-a-player-a-guest-session-a-round-an → main 6 commits fork 2 threads · 2 unresolved
README.md +45 −1
| @@ -80,10 +80,11 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 80 | 80 | |
| 81 | 81 | | file | what it is | |
| 82 | 82 | | ---------------------------------- | ----------------------------------------------------------------- | |
| 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 | | |
| 84 | 84 | | `src/world.ts` | the simulation: motes, momentum and the current field | |
| 85 | 85 | | `src/protocol.ts` | the wire: one frame down, one steer up, quantised and sized | |
| 86 | 86 | | `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 | | |
| 87 | 88 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | |
| 88 | 89 | | `src/live.ts` | the in-process bus and `GET /events` | |
| 89 | 90 | | `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: | ||
| 136 | 137 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is |
| 137 | 138 | `/__forge/signout`; the forge answers both on the app's own host. |
| 138 | 139 | |
| 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 | ||
| 139 | 183 | ## Any interface |
| 140 | 184 | |
| 141 | 185 | 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 @@ | ||
| 10 | 10 | * player, a round, a score — land with #DATA. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import type { Database } from "bun:sqlite"; | |
| 14 | import { guestId, guestName, isGuestKey } from "./session.ts"; | |
| 15 | import type { Who } from "./who.ts"; | |
| 16 | ||
| 13 | 17 | export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; |
| 14 | 18 | export type Result<T> = |
| 15 | 19 | | { ok: true; value: T } |
| @@ -21,3 +25,303 @@ export const fail = <T>(error: Failure, message: string): Result<T> => ({ | ||
| 21 | 25 | error, |
| 22 | 26 | message, |
| 23 | 27 | }); |
| 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 | |
| 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 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, Four things. The first is the one to do. 1.
So does 2. Nothing bounds the 3. The README describes behaviour no door has yet ( 4. Three small ones. Not blocking, but be deliberate about it when the door is written: 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: |
||
| 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"; | |
| 1 | 2 | import { expect, test } from "bun:test"; |
| 2 | 3 | import { mkdtempSync } from "node:fs"; |
| 3 | 4 | import { tmpdir } from "node:os"; |
| @@ -21,13 +22,47 @@ test("migrations apply once, in order, and the file checks out", () => { | ||
| 21 | 22 | ?.foreign_keys, |
| 22 | 23 | ).toBe(1); |
| 23 | 24 | // 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"]); | |
| 33 | 28 | }); |
| 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[] = [ | ||
| 28 | 28 | // has run is history, so the two above stay as they are and this one |
| 29 | 29 | // undoes them forward. |
| 30 | 30 | `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`, | |
| 31 | 77 | ]; |
| 32 | 78 | |
| 33 | 79 | export function openDb(dir = dataDir): Database { |
src/http.ts +51 −10
| @@ -8,6 +8,9 @@ import { existsSync, readFileSync } from "node:fs"; | ||
| 8 | 8 | import { join } from "node:path"; |
| 9 | 9 | import { publicOrigin } from "./env.ts"; |
| 10 | 10 | |
| 11 | /** Whether a browser reaches us over https: what Secure, HSTS and `__Host-` all turn on. */ | |
| 12 | const SECURE_ORIGIN = publicOrigin.startsWith("https:"); | |
| 13 | ||
| 11 | 14 | // The floor, once, for every response this app makes — the streaming ones |
| 12 | 15 | // included. Scripts, styles and connections come from this origin and |
| 13 | 16 | // nowhere else; there is no inline style and no inline script, so neither |
| @@ -40,7 +43,7 @@ export const SECURITY = { | ||
| 40 | 43 | "x-content-type-options": "nosniff", |
| 41 | 44 | "referrer-policy": "strict-origin-when-cross-origin", |
| 42 | 45 | "permissions-policy": "camera=(), microphone=(), geolocation=()", |
| 43 | ...(publicOrigin.startsWith("https:") | |
| 46 | ...(SECURE_ORIGIN | |
| 44 | 47 | ? { "strict-transport-security": "max-age=31536000" } |
| 45 | 48 | : {}), |
| 46 | 49 | } satisfies Record<string, string>; |
| @@ -99,6 +102,9 @@ export const eventStream = (body: ReadableStream<Uint8Array>): Response => | ||
| 99 | 102 | }, |
| 100 | 103 | }); |
| 101 | 104 | |
| 105 | /** The cookie a notice is carried in, under the name this origin can hold. */ | |
| 106 | export const NOTICE_COOKIE = cookieName("notice"); | |
| 107 | ||
| 102 | 108 | /** Back to the page, with a word for it to show once (`readNotice`). */ |
| 103 | 109 | export const back = (notice?: string): Response => |
| 104 | 110 | new Response(null, { |
| @@ -108,7 +114,13 @@ export const back = (notice?: string): Response => | ||
| 108 | 114 | ...SECURITY, |
| 109 | 115 | ...(notice === undefined |
| 110 | 116 | ? {} |
| 111 | : { "set-cookie": cookie("notice", encodeURIComponent(notice), 30) }), | |
| 117 | : { | |
| 118 | "set-cookie": cookie( | |
| 119 | NOTICE_COOKIE, | |
| 120 | encodeURIComponent(notice), | |
| 121 | 30, | |
| 122 | ), | |
| 123 | }), | |
| 112 | 124 | }, |
| 113 | 125 | }); |
| 114 | 126 | |
| @@ -117,20 +129,49 @@ export function readNotice(request: Request): { | ||
| 117 | 129 | notice?: string; |
| 118 | 130 | clear: Record<string, string>; |
| 119 | 131 | } { |
| 120 | const raw = /(?:^|;\s*)notice=([^;]*)/.exec( | |
| 121 | request.headers.get("cookie") ?? "", | |
| 122 | )?.[1]; | |
| 132 | const raw = readCookie(request, NOTICE_COOKIE); | |
| 123 | 133 | if (raw === undefined) return { clear: {} }; |
| 124 | 134 | return { |
| 125 | 135 | notice: decodeURIComponent(raw), |
| 126 | clear: { "set-cookie": cookie("notice", "", 0) }, | |
| 136 | clear: { "set-cookie": cookie(NOTICE_COOKIE, "", 0) }, | |
| 127 | 137 | }; |
| 128 | 138 | } |
| 129 | 139 | |
| 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]; | |
| 134 | 175 | |
| 135 | 176 | // A browser on another site can make a person's browser POST here; it cannot |
| 136 | 177 | // 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"; | ||
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { RULE } from "./concept.ts"; |
| 6 | 6 | 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"; | |
| 8 | 17 | import { emit, listenerCount, STREAM_MAX, streamCount } from "./live.ts"; |
| 9 | 18 | import { routes } from "./routes.ts"; |
| 10 | 19 | import { serve } from "./server.ts"; |
| @@ -505,3 +514,25 @@ test("HEAD of a stream answers and keeps nothing open", async () => { | ||
| 505 | 514 | expect(streamCount("user:u6")).toBe(0); |
| 506 | 515 | expect(listenerCount()).toBe(0); |
| 507 | 516 | }); |
| 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 | }; |
Read at head
9e6e668;bun testis 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 onecookie()helper so the guest cookie cannot missHttpOnly; SameSite=Lax; Secure,SYSTEMguarded 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:52makesPlayingan id:ensurePlayer(db, anon, { guestId })andtouchPlayeraccept any string matchingg_[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:95calls 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, andensurePlayer(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.
Playingbecomes{ guestKey?: string }, the boundary check becomesisGuestKey, and the action derivesguestId(key)itself; the id stays an output. Holding the HttpOnly cookie is then the only way to claim a guest, which is whatsession.tsalready 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 setguest=<attacker key>; Domain=open-platform.shand 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-guestwherever 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 inGUEST_COOKIE/cookie()and one assertion insrc/session.test.ts.Blocker to landing: head
9e6e668conflicts withmainonREADME.md, and the branch predates #20 —src/world.tsis not in it. Merge main and re-runbun testbefore this can go anywhere.Notes, not blockers. (1) The
IF NOT EXISTSguards insrc/db.tskeep 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 — thenotetable 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 returningundefinedwhen there is no cookie would make the foot-gun unreachable. (3)recordRoundchecks 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.