Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
Security: the leaderboard is forgeable — inherited from cs unchanged #45
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/38-security-the-leaderboard-is-forgeable-in → main 3 commits fork
17 files
changed +738
−54
README.md +54 −13
| @@ -1,11 +1,12 @@ | ||
| 1 | # cs | |
| 1 | # cs-engine | |
| 2 | 2 | |
| 3 | A Counter-Strike-shaped defusal shooter that runs in the browser, and the | |
| 4 | leaderboard of the matches people played. Five against five on one map: | |
| 5 | bomb rounds, a buy menu and an economy, bots that walk to a site and shoot | |
| 6 | back. Next door at `/range` is a second game built only out of this one's | |
| 7 | engine — so far its plumbing: the fixed step, the controls and a readout of | |
| 8 | both. | |
| 3 | A browser game engine with two games on it. The first, at `/`, is **cs**: a | |
| 4 | Counter-Strike-shaped defusal shooter, five against five on one map — bomb | |
| 5 | rounds, a buy menu and an economy, bots that walk to a site and shoot back — | |
| 6 | and the leaderboard of the matches people played. The second, at `/range`, is | |
| 7 | built only out of the engine the shooter was taken apart into, and so far it is | |
| 8 | that plumbing: the fixed step, the controls and a readout of both. The engine in | |
| 9 | `src/engine/` is the point of the repository; the shooter is what proves it. | |
| 9 | 10 | |
| 10 | 11 | sh |
| 11 | 12 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here |
| @@ -40,8 +41,41 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 40 | 41 | - **Simulated.** The opponents are bots, and the match runs entirely in your |
| 41 | 42 | browser: there is no multiplayer and no server-side authority. Scores are |
| 42 | 43 | client-reported, so the server checks them against what a match can produce |
| 43 | (kills fit in the rounds, headshots fit in the kills) and rejects the rest. | |
| 44 | Treat the leaderboard as a scoreboard among friends, not as a ranking. | |
| 44 | (kills fit in the rounds, headshots fit in the kills, the clock fits in the | |
| 45 | rounds) and rejects the rest. Treat the leaderboard as a scoreboard among | |
| 46 | friends, not as a ranking. | |
| 47 | ||
| 48 | ## Who a score belongs to | |
| 49 | ||
| 50 | The numbers in a result are the browser's word; the name on it is not. Whoever | |
| 51 | is signed in to the forge arrives with two headers on every proxied request — | |
| 52 | `x-forge-identity` (`<userId>|<username>|<role>|<expiresMs>`) and | |
| 53 | `x-forge-signature`, an HMAC of it under `FORGE_APP_IDENTITY_KEY` — and | |
| 54 | `src/who.ts` recomputes that signature and compares it in constant time. | |
| 55 | A result posted with a verified identity is recorded under **that** name, | |
| 56 | whatever the name field said, and a result posted without one is a **guest**. | |
| 57 | ||
| 58 | - A guest picks their own name and nobody checked it, so it is shown as | |
| 59 | `name (guest)` everywhere it appears — the leaderboard, the match feed and | |
| 60 | the terminal — and the standings group by identity and name together. A guest | |
| 61 | typing someone else's name gets their own row, never theirs. | |
| 62 | - A match belongs to whoever started it: a signed-in player's from any browser | |
| 63 | they are on, a guest's from the browser session that began it. Anyone else | |
| 64 | posting that match id is refused with a 409. | |
| 65 | - Writes are rate-limited: forty results back to back and one more every two | |
| 66 | seconds, keyed by identity for a signed-in player and by socket address and | |
| 67 | session cookie for a guest. A guest is limited twice, because neither key | |
| 68 | alone is enough: behind the forge's proxy the address alone would be one | |
| 69 | bucket for every guest on the internet, and the cookie alone is something the | |
| 70 | caller writes and can rotate, so the address carries a second, wider bucket | |
| 71 | underneath (two hundred back to back, four a second sustained). Signed-in | |
| 72 | players are not behind that one, so a flood of guests cannot lock them out. | |
| 73 | Over either is a 429 with a `Retry-After`. A real match posts once a round | |
| 74 | scored, so a player never sees any of it. | |
| 75 | - With no `FORGE_APP_IDENTITY_KEY` in the environment there is nothing to | |
| 76 | verify against, so everyone is a guest and the start-up log says so. That is | |
| 77 | the only sign-in this app has: no password, no cookie of its own that names a | |
| 78 | person, nothing a page can set. | |
| 45 | 79 | |
| 46 | 80 | ## Playing |
| 47 | 81 | |
| @@ -168,7 +202,13 @@ page shows is the loop and the controls themselves, as numbers that move. | ||
| 168 | 202 | moved; a failure names the first second at which the two runs part and prints |
| 169 | 203 | the constants to paste. |
| 170 | 204 | - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the |
| 171 | schema and its queries, `src/result.ts` validates what a browser may claim. | |
| 205 | schema and its queries, `src/result.ts` validates what a browser may claim, | |
| 206 | `src/who.ts` verifies the forge's signed identity headers, `src/limit.ts` is a | |
| 207 | token bucket per caller (the clock is a parameter, so its test does not wait), | |
| 208 | and `src/bind.ts` decides the socket: `PORT` and `HOST` are the forge's, but | |
| 209 | `HOST` is also what a shell sets to the machine's name, so only an address — | |
| 210 | an IPv4 or IPv6 literal, or `localhost` — is bound and anything else is | |
| 211 | ignored with a line in the log rather than failing to resolve at start-up. | |
| 172 | 212 | - `src/pages/shell.ts` — the document every game is served inside, and nothing |
| 173 | 213 | about any one game: `page({ title, description, heading, env, pull, commit, |
| 174 | 214 | script, ssh, terminalLead, stageHtml, sectionsHtml, footer, extraStyle })` |
| @@ -239,9 +279,10 @@ command: nothing, quit, or delete this match). Both are plain data in and out, | ||
| 239 | 279 | so their tests press keys and read lines with no terminal and no database — |
| 240 | 280 | with the escapes taken off by `plain()`, so an expectation is a sentence. |
| 241 | 281 | |
| 242 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or | |
| 243 | `preview`), `FORGE_APP_URL`, `FORGE_APP_SSH` and `FORGE_APP_COMMIT` when it | |
| 244 | starts the app. `forge.json` says how. | |
| 282 | The forge sets `HOST`, `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` | |
| 283 | or `preview`), `FORGE_APP_URL`, `FORGE_APP_SSH`, `FORGE_APP_COMMIT` and | |
| 284 | `FORGE_APP_IDENTITY_KEY` when it starts the app. Nothing else is read from the | |
| 285 | environment, and no secret is in the repository. `forge.json` says how. | |
| 245 | 286 | |
| 246 | 287 | ## The bots |
| 247 | 288 |
package.json +1 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | "name": "cs", | |
| 2 | "name": "cs-engine", | |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "scripts": { |
src/bind.test.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | /** | |
| 2 | * What the server binds, given an environment. The case that matters is a | |
| 3 | * shell's ambient `HOST`: a machine name is not an address, and the app starts | |
| 4 | * on the loopback rather than failing to resolve it. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { bind, isAddress } from "./bind.ts"; | |
| 9 | ||
| 10 | test("the forge's HOST and PORT are taken as they are", () => { | |
| 11 | expect(bind({ HOST: "0.0.0.0", PORT: "8080" })).toEqual({ hostname: "0.0.0.0", port: 8080 }); | |
| 12 | expect(bind({ HOST: "::", PORT: "3000" })).toEqual({ hostname: "::", port: 3000 }); | |
| 13 | expect(bind({ HOST: "127.0.0.1", PORT: "0" })).toEqual({ hostname: "127.0.0.1", port: 0 }); | |
| 14 | expect(bind({ HOST: "localhost", PORT: "65535" })).toEqual({ hostname: "localhost", port: 65535 }); | |
| 15 | }); | |
| 16 | ||
| 17 | test("an ambient HOST is not an address, and is ignored rather than bound", () => { | |
| 18 | const listen = bind({ HOST: "vespers-laptop.local", PORT: "8080" }); | |
| 19 | expect(listen.hostname).toBe("127.0.0.1"); | |
| 20 | expect(listen.port).toBe(8080); | |
| 21 | expect(listen.unusable).toBe("vespers-laptop.local"); | |
| 22 | for (const host of ["example.com", "999.1.1.1", "0.0.0.0 ", "", "-", "10.0.0"]) | |
| 23 | expect(bind({ HOST: host }).hostname).toBe("127.0.0.1"); | |
| 24 | expect(isAddress("vespers-laptop.local")).toBe(false); | |
| 25 | expect(isAddress("fe80::1")).toBe(true); | |
| 26 | }); | |
| 27 | ||
| 28 | test("no HOST is the loopback, quietly: nothing was ignored", () => { | |
| 29 | expect(bind({})).toEqual({ hostname: "127.0.0.1", port: 8080 }); | |
| 30 | expect(bind({ PORT: "9000" }).unusable).toBeUndefined(); | |
| 31 | }); | |
| 32 | ||
| 33 | test("a port that is not a port falls back rather than binding something else", () => { | |
| 34 | for (const port of ["", " ", "eighty", "-1", "70000", "80.5"]) expect(bind({ PORT: port }).port).toBe(8080); | |
| 35 | }); |
src/bind.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | /** | |
| 2 | * The socket the server listens on. `PORT` and `HOST` are the forge's, but | |
| 3 | * `HOST` is also an ambient variable a shell hands out — zsh and csh set it to | |
| 4 | * the machine's name — and binding to a name that does not resolve here is a | |
| 5 | * start-up that fails for no reason a person can see. So only an address is | |
| 6 | * taken: an IPv4 or IPv6 literal, or `localhost`. Anything else is not | |
| 7 | * something the forge set, and the loopback default stands. | |
| 8 | */ | |
| 9 | ||
| 10 | export const DEFAULT_HOSTNAME = "127.0.0.1"; | |
| 11 | export const DEFAULT_PORT = 8080; | |
| 12 | ||
| 13 | const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/; | |
| 14 | const IPV6 = /^[0-9a-fA-F:]{2,45}$/; // "::", "::1", a full address — colons and hex digits only | |
| 15 | ||
| 16 | /** True when a string is an address to bind, rather than a name to resolve. */ | |
| 17 | export const isAddress = (host: string): boolean => | |
| 18 | host === "localhost" || | |
| 19 | (IPV4.test(host) && host.split(".").every((part) => Number(part) <= 255)) || | |
| 20 | (IPV6.test(host) && host.includes(":")); | |
| 21 | ||
| 22 | /** What to bind, given the environment. `unusable` is what was ignored, for the log. */ | |
| 23 | export function bind(env: Record<string, string | undefined>): { | |
| 24 | hostname: string; | |
| 25 | port: number; | |
| 26 | unusable?: string; | |
| 27 | } { | |
| 28 | const host = env.HOST; | |
| 29 | const port = env.PORT ?? ""; | |
| 30 | const listen = { | |
| 31 | hostname: host !== undefined && isAddress(host) ? host : DEFAULT_HOSTNAME, | |
| 32 | port: /^\d{1,5}$/.test(port) && Number(port) <= 65535 ? Number(port) : DEFAULT_PORT, | |
| 33 | }; | |
| 34 | return host === undefined || isAddress(host) ? listen : { ...listen, unusable: host }; | |
| 35 | } |
src/db.test.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** | |
| 2 | * The table, its owners and the migration onto a database that was written | |
| 3 | * before identities existed. Each test gets its own directory, so these are | |
| 4 | * real SQLite files and not a mock of one. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { Database } from "bun:sqlite"; | |
| 9 | import { mkdtempSync } from "node:fs"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { displayName, openDatabase, type Player, recentMatches, saveMatch, standings } from "./db.ts"; | |
| 13 | import type { Result } from "./result.ts"; | |
| 14 | ||
| 15 | const dir = (): string => mkdtempSync(join(tmpdir(), "db-")); | |
| 16 | ||
| 17 | const result = (over: Partial<Result> = {}): Result => ({ | |
| 18 | matchId: crypto.randomUUID(), | |
| 19 | name: "ignored — the player says the name", | |
| 20 | rounds: 5, scoreMine: 3, scoreTheirs: 2, | |
| 21 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, | |
| 22 | ...over, | |
| 23 | }); | |
| 24 | ||
| 25 | const guest = (name: string, session = "s1"): Player => ({ userId: "", session, name }); | |
| 26 | const player = (id: string, name: string, session = "s1"): Player => ({ userId: id, session, name }); | |
| 27 | ||
| 28 | test("a guest and a signed-in player of the same name are two rows, and only one is a guest", () => { | |
| 29 | const db = openDatabase(dir()); | |
| 30 | expect(saveMatch(db, player("u-1", "kestrel"), result())).toBe(true); | |
| 31 | expect(saveMatch(db, guest("kestrel", "s2"), result({ kills: 25 }))).toBe(true); | |
| 32 | const rows = standings(db); | |
| 33 | expect(rows.length).toBe(2); | |
| 34 | expect(rows.map(displayName).sort()).toEqual(["kestrel", "kestrel (guest)"]); | |
| 35 | // The same player's second match joins their own row rather than making one. | |
| 36 | saveMatch(db, player("u-1", "kestrel", "s3"), result()); | |
| 37 | expect(standings(db).filter((s) => s.user_id === "u-1")[0]?.matches).toBe(2); | |
| 38 | }); | |
| 39 | ||
| 40 | test("a match belongs to its owner: another identity, and another browser, are refused", () => { | |
| 41 | const db = openDatabase(dir()); | |
| 42 | const id = crypto.randomUUID(); | |
| 43 | expect(saveMatch(db, player("u-1", "kestrel"), result({ matchId: id }))).toBe(true); | |
| 44 | expect(saveMatch(db, player("u-2", "thief"), result({ matchId: id, kills: 25 }))).toBe(false); | |
| 45 | expect(saveMatch(db, guest("thief", "s9"), result({ matchId: id, kills: 25 }))).toBe(false); | |
| 46 | // The owner may update it from any browser; their identity is the claim. | |
| 47 | expect(saveMatch(db, player("u-1", "kestrel", "another-browser"), result({ matchId: id, kills: 11 }))).toBe(true); | |
| 48 | expect(recentMatches(db)[0]?.kills).toBe(11); | |
| 49 | ||
| 50 | const mine = crypto.randomUUID(); | |
| 51 | expect(saveMatch(db, guest("anon", "s1"), result({ matchId: mine }))).toBe(true); | |
| 52 | expect(saveMatch(db, guest("anon", "s2"), result({ matchId: mine, kills: 25 }))).toBe(false); | |
| 53 | expect(saveMatch(db, guest("anon", "s1"), result({ matchId: mine, kills: 12 }))).toBe(true); | |
| 54 | }); | |
| 55 | ||
| 56 | test("a database written before identities existed keeps its matches and calls them guests", () => { | |
| 57 | const home = dir(); | |
| 58 | // The schema as it was: no user_id at all. | |
| 59 | const old = new Database(join(home, "app.db")); | |
| 60 | old.exec(`CREATE TABLE match ( | |
| 61 | id TEXT PRIMARY KEY, session TEXT NOT NULL, name TEXT NOT NULL, | |
| 62 | kills INTEGER NOT NULL, deaths INTEGER NOT NULL, headshots INTEGER NOT NULL, | |
| 63 | damage INTEGER NOT NULL, rounds INTEGER NOT NULL, score_mine INTEGER NOT NULL, | |
| 64 | score_theirs INTEGER NOT NULL, finished INTEGER NOT NULL, seconds INTEGER NOT NULL, | |
| 65 | created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| 66 | updated_at TEXT NOT NULL DEFAULT (datetime('now')))`); | |
| 67 | old.exec(`INSERT INTO match (id, session, name, kills, deaths, headshots, damage, rounds, | |
| 68 | score_mine, score_theirs, finished, seconds) VALUES ('m1', 's1', 'veteran', 9, 4, 3, 1200, 5, 3, 2, 1, 420)`); | |
| 69 | old.close(); | |
| 70 | ||
| 71 | const db = openDatabase(home); | |
| 72 | const rows = standings(db); | |
| 73 | expect(rows.length).toBe(1); | |
| 74 | expect(displayName(rows[0]!)).toBe("veteran (guest)"); | |
| 75 | // And the migrated database takes new rows as usual. | |
| 76 | expect(saveMatch(db, player("u-1", "kestrel"), result())).toBe(true); | |
| 77 | expect(standings(db).length).toBe(2); | |
| 78 | }); | |
| 79 | ||
| 80 | test("opening a database twice migrates once and breaks nothing", () => { | |
| 81 | const home = dir(); | |
| 82 | openDatabase(home).close(); | |
| 83 | const db = openDatabase(home); | |
| 84 | expect(saveMatch(db, guest("anon"), result())).toBe(true); | |
| 85 | expect(standings(db).length).toBe(1); | |
| 86 | }); |
src/db.ts +60 −12
| @@ -2,6 +2,12 @@ | ||
| 2 | 2 | * The match records, in SQLite under FORGE_APP_DATA. One row per match, keyed |
| 3 | 3 | * by the id the browser generated for it, so a match that is still being |
| 4 | 4 | * played updates its own row instead of filling the table with fragments. |
| 5 | * | |
| 6 | * Every row also carries who it belongs to: `user_id` is the forge identity | |
| 7 | * that posted it, empty for a guest, and `session` is the browser that started | |
| 8 | * it. The leaderboard groups by that pair, never by the name alone, so a guest | |
| 9 | * typing someone else's name gets their own row and the name they picked is | |
| 10 | * shown as the guest name it is. | |
| 5 | 11 | */ |
| 6 | 12 | |
| 7 | 13 | import { Database } from "bun:sqlite"; |
| @@ -12,6 +18,8 @@ import type { Result } from "./result.ts"; | ||
| 12 | 18 | export type MatchRow = { |
| 13 | 19 | id: string; |
| 14 | 20 | session: string; |
| 21 | /** The forge identity that posted it, or "" for a guest. */ | |
| 22 | user_id: string; | |
| 15 | 23 | name: string; |
| 16 | 24 | kills: number; |
| 17 | 25 | deaths: number; |
| @@ -27,6 +35,8 @@ export type MatchRow = { | ||
| 27 | 35 | |
| 28 | 36 | export type Standing = { |
| 29 | 37 | name: string; |
| 38 | /** The forge identity these matches were played under, or "" for a guest. */ | |
| 39 | user_id: string; | |
| 30 | 40 | matches: number; |
| 31 | 41 | kills: number; |
| 32 | 42 | deaths: number; |
| @@ -43,6 +53,7 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 43 | 53 | db.exec(`CREATE TABLE IF NOT EXISTS match ( |
| 44 | 54 | id TEXT PRIMARY KEY, |
| 45 | 55 | session TEXT NOT NULL, |
| 56 | user_id TEXT NOT NULL DEFAULT '', | |
| 46 | 57 | name TEXT NOT NULL, |
| 47 | 58 | kills INTEGER NOT NULL, |
| 48 | 59 | deaths INTEGER NOT NULL, |
| @@ -56,20 +67,51 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 56 | 67 | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
| 57 | 68 | updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
| 58 | 69 | )`); |
| 70 | // A table written before identities existed has every column but this one: | |
| 71 | // add it rather than ask a person to delete their leaderboard. Those rows | |
| 72 | // played as guests, which is what the default already says. | |
| 73 | const columns = db.query<{ name: string }, []>("PRAGMA table_info(match)").all().map((c) => c.name); | |
| 74 | if (!columns.includes("user_id")) db.exec("ALTER TABLE match ADD COLUMN user_id TEXT NOT NULL DEFAULT ''"); | |
| 59 | 75 | db.exec("CREATE INDEX IF NOT EXISTS match_by_time ON match (updated_at DESC)"); |
| 60 | 76 | return db; |
| 61 | 77 | } |
| 62 | 78 | |
| 79 | /** Who a match is written under: a verified forge identity, or a browser. */ | |
| 80 | export type Player = { | |
| 81 | /** The forge identity's id, or "" for a guest. */ | |
| 82 | userId: string; | |
| 83 | /** The browser that started the match. */ | |
| 84 | session: string; | |
| 85 | /** The name to record: the forge's username, or what a guest typed. */ | |
| 86 | name: string; | |
| 87 | }; | |
| 88 | ||
| 63 | 89 | /** |
| 64 | * Write a result. A match belongs to the session that started it: another | |
| 65 | * session sending the same id is ignored rather than allowed to overwrite it. | |
| 90 | * The name to print. A guest chose theirs and nobody checked it, so it never | |
| 91 | * appears as a bare name next to a signed-in player's: it is said to be a | |
| 92 | * guest wherever it is shown, on the page and in the terminal alike. | |
| 66 | 93 | */ |
| 67 | export function saveMatch(db: Database, session: string, result: Result): boolean { | |
| 68 | const existing = db.query<{ session: string }, [string]>("SELECT session FROM match WHERE id = ?1").get(result.matchId); | |
| 69 | if (existing !== null && existing.session !== session) return false; | |
| 94 | export const displayName = (row: { name: string; user_id: string }): string => | |
| 95 | row.user_id === "" ? `${row.name} (guest)` : row.name; | |
| 96 | ||
| 97 | /** | |
| 98 | * Write a result. A match belongs to whoever started it: a signed-in player's | |
| 99 | * match is theirs whichever browser they are on, and a guest's is that | |
| 100 | * browser's. Anyone else sending the same id is refused rather than allowed to | |
| 101 | * overwrite it — including a guest who found the id of a signed-in player's | |
| 102 | * match, and a signed-in player who found a guest's. | |
| 103 | */ | |
| 104 | export function saveMatch(db: Database, player: Player, result: Result): boolean { | |
| 105 | const existing = db | |
| 106 | .query<{ session: string; user_id: string }, [string]>("SELECT session, user_id FROM match WHERE id = ?1") | |
| 107 | .get(result.matchId); | |
| 108 | if (existing !== null) { | |
| 109 | if (existing.user_id !== player.userId) return false; | |
| 110 | if (player.userId === "" && existing.session !== player.session) return false; | |
| 111 | } | |
| 70 | 112 | db.query( |
| 71 | `INSERT INTO match (id, session, name, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, seconds) | |
| 72 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) | |
| 113 | `INSERT INTO match (id, session, user_id, name, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, seconds) | |
| 114 | VALUES (?1, ?2, ?13, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) | |
| 73 | 115 | ON CONFLICT(id) DO UPDATE SET |
| 74 | 116 | name = excluded.name, kills = excluded.kills, deaths = excluded.deaths, |
| 75 | 117 | headshots = excluded.headshots, damage = excluded.damage, rounds = excluded.rounds, |
| @@ -77,23 +119,29 @@ export function saveMatch(db: Database, session: string, result: Result): boolea | ||
| 77 | 119 | finished = excluded.finished, seconds = excluded.seconds, |
| 78 | 120 | updated_at = datetime('now')`, |
| 79 | 121 | ).run( |
| 80 | result.matchId, session, result.name, result.kills, result.deaths, result.headshots, | |
| 122 | result.matchId, player.session, player.name, result.kills, result.deaths, result.headshots, | |
| 81 | 123 | result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0, result.seconds, |
| 124 | player.userId, | |
| 82 | 125 | ); |
| 83 | 126 | return true; |
| 84 | 127 | } |
| 85 | 128 | |
| 86 | 129 | export const recentMatches = (db: Database, limit = 10): MatchRow[] => |
| 87 | 130 | db.query<MatchRow, [number]>( |
| 88 | `SELECT id, session, name, kills, deaths, headshots, damage, rounds, | |
| 131 | `SELECT id, session, user_id, name, kills, deaths, headshots, damage, rounds, | |
| 89 | 132 | score_mine, score_theirs, finished, seconds, updated_at |
| 90 | 133 | FROM match WHERE rounds > 0 ORDER BY updated_at DESC LIMIT ?1`, |
| 91 | 134 | ).all(limit); |
| 92 | 135 | |
| 93 | /** The table people actually look at: one row a name, best first. */ | |
| 136 | /** | |
| 137 | * The table people actually look at: one row a player, best first. A player is | |
| 138 | * a forge identity, or — for the guests, who are nobody in particular — the | |
| 139 | * name they typed. Grouping by the name alone is how a guest calling | |
| 140 | * themselves someone else lands in that someone's row. | |
| 141 | */ | |
| 94 | 142 | export const standings = (db: Database, limit = 10): Standing[] => |
| 95 | 143 | db.query<Standing, [number]>( |
| 96 | `SELECT name, | |
| 144 | `SELECT name, user_id, | |
| 97 | 145 | COUNT(*) AS matches, |
| 98 | 146 | SUM(kills) AS kills, |
| 99 | 147 | SUM(deaths) AS deaths, |
| @@ -102,7 +150,7 @@ export const standings = (db: Database, limit = 10): Standing[] => | ||
| 102 | 150 | SUM(CASE WHEN finished = 1 AND score_mine > score_theirs THEN 1 ELSE 0 END) AS wins, |
| 103 | 151 | SUM(rounds) AS rounds |
| 104 | 152 | FROM match WHERE rounds > 0 |
| 105 | GROUP BY name | |
| 153 | GROUP BY user_id, name | |
| 106 | 154 | ORDER BY kills DESC, damage DESC |
| 107 | 155 | LIMIT ?1`, |
| 108 | 156 | ).all(limit); |
src/limit.test.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** | |
| 2 | * The clock is a parameter, so a burst, a refill and an hour of silence all | |
| 3 | * happen in the same millisecond of real time. | |
| 4 | */ | |
| 5 | ||
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { createLimiter } from "./limit.ts"; | |
| 8 | ||
| 9 | test("the burst is free and the next one is not", () => { | |
| 10 | const limit = createLimiter({ burst: 3, per: 1000 }); | |
| 11 | expect([limit.take("a", 0), limit.take("a", 0), limit.take("a", 0), limit.take("a", 0)]) | |
| 12 | .toEqual([true, true, true, false]); | |
| 13 | }); | |
| 14 | ||
| 15 | test("one more token every `per` milliseconds, and never more than the burst", () => { | |
| 16 | const limit = createLimiter({ burst: 3, per: 1000 }); | |
| 17 | for (let i = 0; i < 3; i++) limit.take("a", 0); | |
| 18 | expect(limit.take("a", 999)).toBe(false); | |
| 19 | expect(limit.take("a", 1000)).toBe(true); | |
| 20 | expect(limit.take("a", 1000)).toBe(false); | |
| 21 | // An hour of quiet earns three thousand tokens and keeps three. | |
| 22 | expect([limit.take("a", 3_600_000), limit.take("a", 3_600_000), limit.take("a", 3_600_000), | |
| 23 | limit.take("a", 3_600_000)]).toEqual([true, true, true, false]); | |
| 24 | }); | |
| 25 | ||
| 26 | test("one key spending its tokens leaves another's alone", () => { | |
| 27 | const limit = createLimiter({ burst: 2, per: 1000 }); | |
| 28 | expect([limit.take("a", 0), limit.take("a", 0), limit.take("a", 0)]).toEqual([true, true, false]); | |
| 29 | expect(limit.take("b", 0)).toBe(true); | |
| 30 | }); | |
| 31 | ||
| 32 | test("a caller that keeps knocking stays locked out rather than earning while it knocks", () => { | |
| 33 | const limit = createLimiter({ burst: 1, per: 1000 }); | |
| 34 | expect(limit.take("a", 0)).toBe(true); | |
| 35 | for (let t = 100; t < 1000; t += 100) expect(limit.take("a", t)).toBe(false); | |
| 36 | expect(limit.take("a", 1000)).toBe(true); | |
| 37 | }); | |
| 38 | ||
| 39 | test("the table is bounded, whatever a caller invents for a key", () => { | |
| 40 | const limit = createLimiter({ burst: 2, per: 1000, ceiling: 8 }); | |
| 41 | for (let i = 0; i < 400; i++) limit.take(`key-${i}`, 0); | |
| 42 | expect(limit.size()).toBeLessThanOrEqual(8); | |
| 43 | // Forgetting a key gives back a burst; it never gives away more than that. | |
| 44 | expect([limit.take("key-0", 0), limit.take("key-0", 0), limit.take("key-0", 0)]).toEqual([true, true, false]); | |
| 45 | }); |
src/limit.ts added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | /** | |
| 2 | * How often one caller may write. A token bucket per key: `burst` writes are | |
| 3 | * free, then one more every `per` milliseconds. Plain data in, a boolean out, | |
| 4 | * and the clock is a parameter, so a test does not wait for a second to pass. | |
| 5 | * | |
| 6 | * The keys come from outside, so the table is bounded: past `ceiling` keys the | |
| 7 | * buckets that have refilled to full — callers who stopped — are swept, and if | |
| 8 | * that frees nothing the table is dropped rather than grown. Forgetting a | |
| 9 | * bucket only ever gives someone back their burst; remembering an unbounded | |
| 10 | * number of them is how a rate limiter becomes the denial of service. | |
| 11 | */ | |
| 12 | ||
| 13 | export type LimiterOptions = { | |
| 14 | /** Writes allowed back to back. */ | |
| 15 | burst: number; | |
| 16 | /** Milliseconds to earn one more. */ | |
| 17 | per: number; | |
| 18 | /** How many keys are remembered before the table is swept. */ | |
| 19 | ceiling?: number; | |
| 20 | }; | |
| 21 | ||
| 22 | export type Limiter = { | |
| 23 | /** True when this key may write now, and then the token is spent. */ | |
| 24 | take(key: string, now?: number): boolean; | |
| 25 | /** How many keys are remembered. For the tests and nothing else. */ | |
| 26 | size(): number; | |
| 27 | }; | |
| 28 | ||
| 29 | export function createLimiter({ burst, per, ceiling = 10_000 }: LimiterOptions): Limiter { | |
| 30 | const tokens = new Map<string, { left: number; at: number }>(); | |
| 31 | return { | |
| 32 | take(key, now = Date.now()) { | |
| 33 | const bucket = tokens.get(key) ?? { left: burst, at: now }; | |
| 34 | const earned = Math.max(0, now - bucket.at) / per; | |
| 35 | const left = Math.min(burst, bucket.left + earned); | |
| 36 | // A refusal writes nothing: the bucket keeps the one anchor it was | |
| 37 | // filled from, so a caller that knocks a hundred times a second earns | |
| 38 | // exactly what one that waits does, instead of a hundred roundings of it. | |
| 39 | if (left < 1) return false; | |
| 40 | if (tokens.size >= ceiling && !tokens.has(key)) sweep(tokens, { burst, per, ceiling }, now); | |
| 41 | tokens.set(key, { left: left - 1, at: now }); | |
| 42 | return true; | |
| 43 | }, | |
| 44 | size: () => tokens.size, | |
| 45 | }; | |
| 46 | } | |
| 47 | ||
| 48 | type Bucket = { left: number; at: number }; | |
| 49 | ||
| 50 | /** | |
| 51 | * A bucket that has earned all its tokens back says the same thing as no | |
| 52 | * bucket at all, so it is forgotten. If that frees nothing — every key still | |
| 53 | * holds a spent bucket — the table is emptied instead of grown. | |
| 54 | */ | |
| 55 | function sweep(tokens: Map<string, Bucket>, { burst, per, ceiling }: Required<LimiterOptions>, now: number): void { | |
| 56 | for (const [key, bucket] of tokens) { | |
| 57 | if (bucket.left + Math.max(0, now - bucket.at) / per >= burst) tokens.delete(key); | |
| 58 | } | |
| 59 | if (tokens.size >= ceiling) tokens.clear(); | |
| 60 | } |
src/pages/game.ts +33 −9
| @@ -10,8 +10,9 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | 12 | import type { Database } from "bun:sqlite"; |
| 13 | import { headshotShare, ratio, recentMatches, standings } from "../db.ts"; | |
| 13 | import { displayName, headshotShare, ratio, recentMatches, standings } from "../db.ts"; | |
| 14 | 14 | import { CARD } from "../client/weights.ts"; |
| 15 | import type { Who } from "../who.ts"; | |
| 15 | 16 | import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| 16 | 17 | |
| 17 | 18 | export { escape }; |
| @@ -22,13 +23,13 @@ export function statsHtml(db: Database): string { | ||
| 22 | 23 | const recent = recentMatches(db, 8); |
| 23 | 24 | const rows = table |
| 24 | 25 | .map( |
| 25 | (s, i) => `<tr><td>${i + 1}</td><td>${escape(s.name)}</td><td>${s.matches}</td><td>${s.kills}</td>` + | |
| 26 | (s, i) => `<tr><td>${i + 1}</td><td>${escape(displayName(s))}</td><td>${s.matches}</td><td>${s.kills}</td>` + | |
| 26 | 27 | `<td>${ratio(s.kills, s.deaths)}</td><td>${headshotShare(s.headshots, s.kills)}</td><td>${s.wins}</td></tr>`, |
| 27 | 28 | ) |
| 28 | 29 | .join(""); |
| 29 | 30 | const feed = recent |
| 30 | 31 | .map( |
| 31 | (m) => `<li><b>${escape(m.name)}</b> ${m.score_mine}–${m.score_theirs} ` + | |
| 32 | (m) => `<li><b>${escape(displayName(m))}</b> ${m.score_mine}–${m.score_theirs} ` + | |
| 32 | 33 | `<span>${m.kills}k ${m.deaths}d · ${m.damage} damage${m.finished === 1 ? "" : " · in progress"}</span> ` + |
| 33 | 34 | `<time>${escape(m.updated_at)}</time></li>`, |
| 34 | 35 | ) |
| @@ -42,13 +43,34 @@ ${table.length === 0 | ||
| 42 | 43 | } |
| 43 | 44 | |
| 44 | 45 | /** What the shell needs that this page cannot know: the environment it runs in. */ |
| 45 | export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">; | |
| 46 | export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh"> & { | |
| 47 | /** The forge identity the request carried, verified in `src/who.ts`. */ | |
| 48 | who: Who; | |
| 49 | }; | |
| 46 | 50 | |
| 47 | const MENU = ` | |
| 51 | /** | |
| 52 | * Where a guest goes to become someone. The forge serves its sign-in on this | |
| 53 | * origin, so the link carries `rel="noreferrer"`: `Referrer-Policy` is decided | |
| 54 | * per origin and a same-origin navigation would hand it this whole URL. | |
| 55 | */ | |
| 56 | const SIGN_IN = "/__forge/signin?to=%2F"; | |
| 57 | ||
| 58 | /** | |
| 59 | * The menu, which says under which name the result will be recorded. A signed | |
| 60 | * in player's name is the forge's and the field only shows it; a guest picks | |
| 61 | * theirs and the page says plainly that it is listed as a guest's. | |
| 62 | */ | |
| 63 | const menu = (visitor: Who): string => ` | |
| 48 | 64 | <h2>Defuse or detonate</h2> |
| 49 | 65 | <p>Five against five on <b>de_forge</b>. Bomb rounds, CS movement and economy, bots that push the sites.</p> |
| 66 | <p class="hint">${visitor.role === "anon" | |
| 67 | ? `You are playing as a guest, so the leaderboard lists your score as <b>a guest's</b>. | |
| 68 | <a href="${SIGN_IN}" rel="noreferrer">Sign in to the forge</a> to play under your own name.` | |
| 69 | : `Signed in as <b>${escape(visitor.name)}</b>. Your results are recorded under that name.`}</p> | |
| 50 | 70 | <div class="row"> |
| 51 | <label>Name<input id="name" maxlength="24" placeholder="you" autocomplete="nickname"></label> | |
| 71 | <label>Name<input id="name" maxlength="24" placeholder="you" autocomplete="nickname"${ | |
| 72 | visitor.role === "anon" ? "" : ` value="${escape(visitor.name)}" readonly` | |
| 73 | }></label> | |
| 52 | 74 | <label>Match<select id="length"><option value="short">Short — first to 5</option><option value="long">Competitive — first to 13</option></select></label> |
| 53 | 75 | <label>Side<select id="side"><option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select></label> |
| 54 | 76 | <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label> |
| @@ -64,7 +86,7 @@ const PAUSE = ` | ||
| 64 | 86 | <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div> |
| 65 | 87 | `; |
| 66 | 88 | |
| 67 | export function gamePage(db: Database, options: PageOptions): string { | |
| 89 | export function gamePage(db: Database, { who: visitor, ...options }: PageOptions): string { | |
| 68 | 90 | return page({ |
| 69 | 91 | ...options, |
| 70 | 92 | title: "cs — a browser defusal shooter", |
| @@ -87,7 +109,7 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 87 | 109 | ], |
| 88 | 110 | ], |
| 89 | 111 | overlays: [ |
| 90 | { id: "menu", html: MENU }, | |
| 112 | { id: "menu", html: menu(visitor) }, | |
| 91 | 113 | { id: "pause", html: PAUSE, hidden: true }, |
| 92 | 114 | { id: "buy", html: "", hidden: true }, |
| 93 | 115 | ], |
| @@ -111,7 +133,9 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 111 | 133 | bot's own skill still decides how fast it can turn, how steady its hands are and how long it |
| 112 | 134 | takes to react, so there is still a ladder from fodder to uncomfortable.</p> |
| 113 | 135 | <p>The match runs entirely in your browser; the leaderboard here is what your browser |
| 114 | reported about it.</p> | |
| 136 | reported about it, checked against what a match can produce. The name on a row is not | |
| 137 | the browser's word, though: signed in to the forge, your results are recorded under | |
| 138 | that name, and everyone else plays as a guest and is listed as one.</p> | |
| 115 | 139 | <p>The engine underneath — the loop, the controls, the grid, the meshes, the textures and |
| 116 | 140 | the sounds — is a package, and <a href="/range">the range</a> is a second game being built |
| 117 | 141 | out of it that shares no line with this one. Today it is that engine's loop and controls |
src/result.ts +13 −1
| @@ -22,6 +22,14 @@ export type Result = { | ||
| 22 | 22 | const MAX_ROUNDS = 30; |
| 23 | 23 | const KILLS_PER_ROUND = 5; // five enemies is all there are |
| 24 | 24 | |
| 25 | /** | |
| 26 | * The longest a round can last on the clock the game keeps: 12 s of freeze | |
| 27 | * time, 115 s of round, 40 s of bomb and 5 s at the end, rounded up. A match | |
| 28 | * that reports more seconds than its rounds can hold is not a match — and the | |
| 29 | * round being played is not scored yet, so one round's worth is always spare. | |
| 30 | */ | |
| 31 | const SECONDS_PER_ROUND = 180; | |
| 32 | ||
| 25 | 33 | const integer = (value: unknown, max: number): number | null => { |
| 26 | 34 | if (typeof value !== "number" || !Number.isFinite(value)) return null; |
| 27 | 35 | const n = Math.floor(value); |
| @@ -48,7 +56,7 @@ export function parseResult(value: unknown): Result | null { | ||
| 48 | 56 | const deaths = integer(body.deaths, MAX_ROUNDS); |
| 49 | 57 | const headshots = integer(body.headshots, MAX_ROUNDS * KILLS_PER_ROUND); |
| 50 | 58 | const damage = integer(body.damage, MAX_ROUNDS * KILLS_PER_ROUND * 200); |
| 51 | const seconds = integer(body.seconds, 60 * 120); | |
| 59 | const seconds = integer(body.seconds, MAX_ROUNDS * SECONDS_PER_ROUND); | |
| 52 | 60 | if ( |
| 53 | 61 | rounds === null || scoreMine === null || scoreTheirs === null || kills === null || |
| 54 | 62 | deaths === null || headshots === null || damage === null || seconds === null |
| @@ -59,6 +67,10 @@ export function parseResult(value: unknown): Result | null { | ||
| 59 | 67 | if (headshots > kills) return null; |
| 60 | 68 | if (deaths > rounds) return null; |
| 61 | 69 | if (kills > rounds * KILLS_PER_ROUND) return null; |
| 70 | // The clock is bounded by the rounds it is spread over, plus the one being | |
| 71 | // played: without this, a three-round match could claim two hours of playing | |
| 72 | // time and sit at the top of anything sorted by it. | |
| 73 | if (seconds > (rounds + 1) * SECONDS_PER_ROUND) return null; | |
| 62 | 74 | return { |
| 63 | 75 | matchId, |
| 64 | 76 | name: cleanName(body.name), |
src/server.test.ts +112 −3
| @@ -4,13 +4,23 @@ import { tmpdir } from "node:os"; | ||
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { bundle, STATIC_DIR } from "../scripts/build.ts"; |
| 6 | 6 | import { parseResult } from "./result.ts"; |
| 7 | import { sign } from "./who.ts"; | |
| 7 | 8 | import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts"; |
| 8 | 9 | |
| 9 | 10 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); |
| 10 | 11 | process.env.PORT = "0"; |
| 11 | const { server } = await import("./server.ts"); | |
| 12 | // The test is the forge: it holds the same key and signs identities with it. | |
| 13 | const KEY = "the-key-the-forge-signs-with"; | |
| 14 | process.env.FORGE_APP_IDENTITY_KEY = KEY; | |
| 15 | const { server, MATCH_LIMIT, GUEST_ADDRESS_LIMIT } = await import("./server.ts"); | |
| 12 | 16 | const base = `http://${server.hostname}:${server.port}`; |
| 13 | 17 | |
| 18 | /** The two headers the forge puts on a request it proxies for a signed-in person. */ | |
| 19 | const as = (id: string, name: string, role = "user"): Record<string, string> => { | |
| 20 | const identity = `${id}|${name}|${role}|${Date.now() + 60_000}`; | |
| 21 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 22 | }; | |
| 23 | ||
| 14 | 24 | const result = (over: Record<string, unknown> = {}): Record<string, unknown> => ({ |
| 15 | 25 | matchId: crypto.randomUUID(), |
| 16 | 26 | name: "tester", |
| @@ -19,10 +29,10 @@ const result = (over: Record<string, unknown> = {}): Record<string, unknown> => | ||
| 19 | 29 | ...over, |
| 20 | 30 | }); |
| 21 | 31 | |
| 22 | const post = (body: unknown, cookie = ""): Promise<Response> => | |
| 32 | const post = (body: unknown, cookie = "", headers: Record<string, string> = {}): Promise<Response> => | |
| 23 | 33 | fetch(base + "/api/match", { |
| 24 | 34 | method: "POST", |
| 25 | headers: { "content-type": "application/json", ...(cookie === "" ? {} : { cookie }) }, | |
| 35 | headers: { "content-type": "application/json", ...(cookie === "" ? {} : { cookie }), ...headers }, | |
| 26 | 36 | body: JSON.stringify(body), |
| 27 | 37 | }); |
| 28 | 38 | |
| @@ -163,6 +173,89 @@ test("another browser cannot overwrite a match it did not play", async () => { | ||
| 163 | 173 | expect(await (await fetch(base + "/")).text()).not.toContain("thief"); |
| 164 | 174 | }); |
| 165 | 175 | |
| 176 | test("a signed-in player is recorded under the forge's name, not the one the browser typed", async () => { | |
| 177 | const response = await post(result({ name: "totally-not-me" }), "", as("u-42", "kestrel")); | |
| 178 | expect(response.status).toBe(200); | |
| 179 | const body = await response.text(); | |
| 180 | expect(body).toContain("kestrel"); | |
| 181 | expect(body).not.toContain("totally-not-me"); | |
| 182 | // And no "(guest)" against a name the forge vouched for. | |
| 183 | expect(body).not.toContain("kestrel (guest)"); | |
| 184 | }); | |
| 185 | ||
| 186 | test("a guest never lands in a named player's row, whatever name they type", async () => { | |
| 187 | await post(result({ name: "kestrel", kills: 25, rounds: 5, scoreMine: 5, scoreTheirs: 0, deaths: 1, headshots: 20 })); | |
| 188 | const page = await (await fetch(base + "/")).text(); | |
| 189 | // Two rows: the signed-in kestrel, and a guest who picked the same name. | |
| 190 | expect(page).toContain("kestrel (guest)"); | |
| 191 | expect(page).toContain("<td>kestrel</td>"); | |
| 192 | const rows = page.match(/<td>kestrel[^<]*<\/td>/g) ?? []; | |
| 193 | expect(rows.sort()).toEqual(["<td>kestrel (guest)</td>", "<td>kestrel</td>"]); | |
| 194 | }); | |
| 195 | ||
| 196 | test("an identity the forge did not sign is a guest", async () => { | |
| 197 | const identity = `u-9|impostor|admin|${Date.now() + 60_000}`; | |
| 198 | const forged = await post(result({ name: "impostor" }), "", { | |
| 199 | "x-forge-identity": identity, | |
| 200 | "x-forge-signature": sign(identity, "a key I made up"), | |
| 201 | }); | |
| 202 | expect(forged.status).toBe(200); | |
| 203 | expect(await forged.text()).toContain("impostor (guest)"); | |
| 204 | }); | |
| 205 | ||
| 206 | test("a match belongs to the identity that started it, not to whoever knows its id", async () => { | |
| 207 | const id = crypto.randomUUID(); | |
| 208 | expect((await post(result({ matchId: id, name: "kestrel" }), "", as("u-42", "kestrel"))).status).toBe(200); | |
| 209 | // Another signed-in player, and a guest, both bounce off it. | |
| 210 | expect((await post(result({ matchId: id, kills: 20 }), "", as("u-43", "thief2"))).status).toBe(409); | |
| 211 | expect((await post(result({ matchId: id, kills: 20 }))).status).toBe(409); | |
| 212 | // The owner may still update it, from a browser with no cookie of its own. | |
| 213 | const again = await post(result({ matchId: id, kills: 11 }), "", as("u-42", "kestrel")); | |
| 214 | expect(again.status).toBe(200); | |
| 215 | expect(await again.text()).not.toContain("thief2"); | |
| 216 | }); | |
| 217 | ||
| 218 | test("a match is capped at the seconds its rounds can hold", async () => { | |
| 219 | expect((await post(result({ rounds: 1, scoreMine: 1, scoreTheirs: 0, kills: 2, deaths: 1, headshots: 0, seconds: 7200 }))).status).toBe(422); | |
| 220 | expect((await post(result({ seconds: 5 * 60 * 60 }))).status).toBe(422); | |
| 221 | expect((await post(result({ seconds: -10 }))).status).toBe(422); | |
| 222 | // A long match that really did take its time is still fine. | |
| 223 | expect((await post(result({ rounds: 25, scoreMine: 13, scoreTheirs: 12, kills: 30, deaths: 20, headshots: 9, seconds: 3000 }))).status).toBe(200); | |
| 224 | }); | |
| 225 | ||
| 226 | test("one caller cannot write the leaderboard as fast as it likes", async () => { | |
| 227 | const flooder = as("u-flood", "flooder"); | |
| 228 | let refused: Response | undefined; | |
| 229 | for (let i = 0; i <= MATCH_LIMIT.burst; i++) { | |
| 230 | const response = await post(result({ name: "flooder" }), "", flooder); | |
| 231 | if (response.status === 429) { refused = response; break; } | |
| 232 | } | |
| 233 | expect(refused?.status).toBe(429); | |
| 234 | expect(refused?.headers.get("retry-after")).toBe(String(MATCH_LIMIT.per / 1000)); | |
| 235 | expect(refused?.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 236 | // Somebody else's bucket is their own: the flood does not lock the app. | |
| 237 | expect((await post(result({ name: "bystander" }), "", as("u-44", "bystander"))).status).toBe(200); | |
| 238 | }); | |
| 239 | ||
| 240 | test("the page says who you are playing as", async () => { | |
| 241 | const guest = await (await fetch(base + "/")).text(); | |
| 242 | expect(guest).toContain("playing as a guest"); | |
| 243 | expect(guest).not.toContain("readonly"); | |
| 244 | // And a way to stop being one, which does not carry this URL with it. | |
| 245 | expect(guest).toContain(`<a href="/__forge/signin?to=%2F" rel="noreferrer">`); | |
| 246 | ||
| 247 | const signedIn = await (await fetch(base + "/", { headers: as("u-42", "kestrel") })).text(); | |
| 248 | expect(signedIn).toContain("Signed in as <b>kestrel</b>"); | |
| 249 | expect(signedIn).not.toContain("__forge/signin"); | |
| 250 | expect(signedIn).toContain(`value="kestrel" readonly`); | |
| 251 | }); | |
| 252 | ||
| 253 | test("a name the forge signed is escaped like any other", async () => { | |
| 254 | const page = await (await fetch(base + "/", { headers: as("u-45", "<script>alert(1)</script>") })).text(); | |
| 255 | expect(page).not.toContain("<script>alert(1)"); | |
| 256 | expect(page).toContain("<script>alert(1)"); | |
| 257 | }); | |
| 258 | ||
| 166 | 259 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 167 | 260 | const response = await fetch(base + "/api/match", { |
| 168 | 261 | method: "POST", |
| @@ -228,3 +321,19 @@ test("a result parses to exactly what was sent, and nothing else", () => { | ||
| 228 | 321 | expect(parseResult(null)).toBe(null); |
| 229 | 322 | expect(parseResult("no")).toBe(null); |
| 230 | 323 | }); |
| 324 | ||
| 325 | // Last in the file on purpose: it spends the bucket every guest on this | |
| 326 | // address shares, so any guest POST after it would be answered with a 429. | |
| 327 | test("a guest cannot buy a fresh bucket by rewriting the cookie it was given", async () => { | |
| 328 | // The per-caller bucket is keyed by the session cookie, which a caller | |
| 329 | // writes itself — so the socket address carries a second one underneath. | |
| 330 | let refused: Response | undefined; | |
| 331 | for (let i = 0; i <= GUEST_ADDRESS_LIMIT.burst; i++) { | |
| 332 | const fresh = `cs_session=${i.toString(16).padStart(32, "0")}`; | |
| 333 | const response = await post(result({ name: "flooding-guest" }), fresh); | |
| 334 | if (response.status === 429) { refused = response; break; } | |
| 335 | } | |
| 336 | expect(refused?.status).toBe(429); | |
| 337 | // And a signed-in player is not behind that bucket: guests cannot lock them out. | |
| 338 | expect((await post(result({ name: "steady" }), "", as("u-50", "steady"))).status).toBe(200); | |
| 339 | }); |
src/server.ts +74 −9
| @@ -4,15 +4,24 @@ | ||
| 4 | 4 | * The game itself runs in the page; this server renders the first paint, |
| 5 | 5 | * serves each game's bundled script, and keeps match results in SQLite under |
| 6 | 6 | * FORGE_APP_DATA. Production and each preview get their own directory. |
| 7 | * | |
| 8 | * A result is the browser's word for the numbers and nobody's word for the | |
| 9 | * name, so the name is not taken from it: `src/who.ts` verifies the forge's | |
| 10 | * signed identity headers and a result is recorded under that, or as a guest's. | |
| 11 | * `src/limit.ts` holds how fast one caller may write, and `src/bind.ts` decides | |
| 12 | * what socket this listens on. | |
| 7 | 13 | */ |
| 8 | 14 | |
| 9 | 15 | import { existsSync, readdirSync } from "node:fs"; |
| 10 | 16 | import { join } from "node:path"; |
| 11 | 17 | import { ENTRIES, isBundleOf, STATIC_DIR } from "../scripts/build.ts"; |
| 12 | import { openDatabase, saveMatch } from "./db.ts"; | |
| 18 | import { bind } from "./bind.ts"; | |
| 19 | import { openDatabase, type Player, saveMatch } from "./db.ts"; | |
| 20 | import { createLimiter } from "./limit.ts"; | |
| 13 | 21 | import { escape, gamePage, statsHtml } from "./pages/game.ts"; |
| 14 | 22 | import { rangePage } from "./pages/range.ts"; |
| 15 | import { parseResult } from "./result.ts"; | |
| 23 | import { cleanName, parseResult } from "./result.ts"; | |
| 24 | import { who, type Who } from "./who.ts"; | |
| 16 | 25 | |
| 17 | 26 | const db = openDatabase(); |
| 18 | 27 | |
| @@ -21,6 +30,10 @@ const pull = process.env.FORGE_APP_PULL; | ||
| 21 | 30 | const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7); |
| 22 | 31 | const ssh = process.env.FORGE_APP_SSH; |
| 23 | 32 | |
| 33 | // The key the forge signs identities with. Without it nobody is anybody here: | |
| 34 | // every visitor is a guest, and a guest's score is labelled as one. | |
| 35 | const identityKey = process.env.FORGE_APP_IDENTITY_KEY; | |
| 36 | ||
| 24 | 37 | // The security floor every response stands on. Scripts and styles come from |
| 25 | 38 | // this origin (inline styles allowed, inline scripts not), nothing frames us, |
| 26 | 39 | // browsers never guess a content type, and referrers stay on this site. |
| @@ -111,6 +124,45 @@ const newSession = (): string => | ||
| 111 | 124 | const sessionCookie = (session: string): string => |
| 112 | 125 | `${SESSION_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${secure ? "; Secure" : ""}`; |
| 113 | 126 | |
| 127 | // How often one caller may write a result. A real match posts once a round | |
| 128 | // scored, at most thirty times over twenty minutes or more, so a burst of | |
| 129 | // forty and one more every two seconds is invisible to a player and a wall to | |
| 130 | // a script. A signed-in player is limited as themselves, which is the only key | |
| 131 | // here nobody can change at will. | |
| 132 | export const MATCH_LIMIT = { burst: 40, per: 2000 }; | |
| 133 | const matchLimit = createLimiter(MATCH_LIMIT); | |
| 134 | ||
| 135 | // A guest is limited twice, because neither key alone is enough. The session | |
| 136 | // cookie tells two guests behind the forge's proxy apart — without it they | |
| 137 | // would share one bucket — but a caller writes its own cookie, so rotating it | |
| 138 | // would buy a fresh bucket every time. The socket address is the one thing a | |
| 139 | // caller cannot choose, so it carries a second, wider bucket underneath: four | |
| 140 | // results a second sustained, two hundred back to back. A flood from one | |
| 141 | // address is stopped there; signed-in players are not behind that bucket at | |
| 142 | // all, so a flood of guests can never lock them out. | |
| 143 | export const GUEST_ADDRESS_LIMIT = { burst: 200, per: 250 }; | |
| 144 | const guestAddressLimit = createLimiter(GUEST_ADDRESS_LIMIT); | |
| 145 | ||
| 146 | const limitKey = (visitor: Who, session: string | null, address: string | undefined): string => | |
| 147 | visitor.role === "anon" ? `guest:${address ?? "?"}|${session ?? "-"}` : `user:${visitor.id}`; | |
| 148 | ||
| 149 | /** True when this caller may write now. The token is spent by asking. */ | |
| 150 | const mayWrite = (visitor: Who, session: string | null, address: string | undefined): boolean => { | |
| 151 | if (visitor.role === "anon" && !guestAddressLimit.take(`net:${address ?? "?"}`)) return false; | |
| 152 | return matchLimit.take(limitKey(visitor, session, address)); | |
| 153 | }; | |
| 154 | ||
| 155 | /** | |
| 156 | * Who a result is recorded as. A verified forge identity plays under its own | |
| 157 | * name and the browser's claim is ignored; everyone else is a guest, whose | |
| 158 | * name is theirs to pick and is shown as a guest's wherever it appears. So | |
| 159 | * nobody who is not signed in can ever land in a signed-in player's row. | |
| 160 | */ | |
| 161 | const playerOf = (visitor: Who, session: string, claimed: string): Player => | |
| 162 | visitor.role === "anon" | |
| 163 | ? { userId: "", session, name: cleanName(claimed) } | |
| 164 | : { userId: visitor.id, session, name: cleanName(visitor.name) }; | |
| 165 | ||
| 114 | 166 | // The built bundles, one per entry, each named by its content so it can be |
| 115 | 167 | // cached for a year. Resolved once at start-up and by exact name: with a second |
| 116 | 168 | // game in static/, "the first file that looks like a bundle" is the wrong game. |
| @@ -130,15 +182,24 @@ const withoutBodyIfHead = (request: Request, response: Response): Response => | ||
| 130 | 182 | |
| 131 | 183 | const isRead = (request: Request): boolean => request.method === "GET" || request.method === "HEAD"; |
| 132 | 184 | |
| 185 | const listen = bind(process.env); | |
| 186 | ||
| 133 | 187 | export const server = Bun.serve({ |
| 134 | port: Number(process.env.PORT ?? 8080), | |
| 135 | hostname: process.env.HOST ?? "127.0.0.1", | |
| 136 | async fetch(request) { | |
| 188 | port: listen.port, | |
| 189 | hostname: listen.hostname, | |
| 190 | async fetch(request, listener) { | |
| 137 | 191 | const url = new URL(request.url); |
| 138 | 192 | if (request.method === "POST" && !sameOrigin(request)) |
| 139 | 193 | return new Response("forbidden", { status: 403, headers: SECURITY }); |
| 140 | 194 | |
| 141 | 195 | if (request.method === "POST" && url.pathname === "/api/match") { |
| 196 | const visitor = who(request, identityKey); | |
| 197 | const held = readSession(request); | |
| 198 | if (!mayWrite(visitor, held, listener.requestIP(request)?.address)) | |
| 199 | return new Response("slow down", { | |
| 200 | status: 429, | |
| 201 | headers: { ...SECURITY, "retry-after": String(Math.ceil(MATCH_LIMIT.per / 1000)) }, | |
| 202 | }); | |
| 142 | 203 | const body = await readBody(request); |
| 143 | 204 | if (body === undefined) return new Response("too large", { status: 413, headers: SECURITY }); |
| 144 | 205 | let parsed: unknown; |
| @@ -149,8 +210,8 @@ export const server = Bun.serve({ | ||
| 149 | 210 | } |
| 150 | 211 | const result = parseResult(parsed); |
| 151 | 212 | if (result === null) return new Response("bad result", { status: 422, headers: SECURITY }); |
| 152 | const session = readSession(request) ?? newSession(); | |
| 153 | const stored = saveMatch(db, session, result); | |
| 213 | const session = held ?? newSession(); | |
| 214 | const stored = saveMatch(db, playerOf(visitor, session, result.name), result); | |
| 154 | 215 | if (!stored) return new Response("not yours", { status: 409, headers: SECURITY }); |
| 155 | 216 | return html(statsHtml(db), 200, { "set-cookie": sessionCookie(session) }); |
| 156 | 217 | } |
| @@ -158,7 +219,7 @@ export const server = Bun.serve({ | ||
| 158 | 219 | // The game, and a health check on HEAD sees the app up. |
| 159 | 220 | if (url.pathname === "/" && isRead(request)) { |
| 160 | 221 | const session = readSession(request); |
| 161 | const page = gamePage(db, { env, pull, commit, script, ssh }); | |
| 222 | const page = gamePage(db, { env, pull, commit, script, ssh, who: who(request, identityKey) }); | |
| 162 | 223 | return withoutBodyIfHead( |
| 163 | 224 | request, |
| 164 | 225 | html(page, 200, session === null ? { "set-cookie": sessionCookie(newSession()) } : {}), |
| @@ -193,6 +254,10 @@ export const server = Bun.serve({ | ||
| 193 | 254 | }, |
| 194 | 255 | }); |
| 195 | 256 | |
| 257 | if (listen.unusable !== undefined) | |
| 258 | console.log(`HOST=${listen.unusable} is not an address to bind; listening on ${listen.hostname} instead`); | |
| 259 | ||
| 196 | 260 | console.log( |
| 197 | `cs listening on http://${server.hostname}:${server.port} (${env}) serving ${Object.values(scripts).join(", ")}`, | |
| 261 | `cs listening on http://${server.hostname}:${server.port} (${env}) serving ${Object.values(scripts).join(", ")}` + | |
| 262 | `${identityKey ? "" : " — no FORGE_APP_IDENTITY_KEY: every visitor plays as a guest"}`, | |
| 198 | 263 | ); |
src/terminal/keys.test.ts +3 −2
| @@ -16,6 +16,7 @@ const CARD = { trainedAt: "2026-09-14", decisions: 1, envSteps: 1, updates: 1, s | ||
| 16 | 16 | const match = (id: string, name: string): MatchRow => ({ |
| 17 | 17 | id, |
| 18 | 18 | session: "s", |
| 19 | user_id: "u-1", | |
| 19 | 20 | name, |
| 20 | 21 | kills: 7, |
| 21 | 22 | deaths: 4, |
| @@ -31,8 +32,8 @@ const match = (id: string, name: string): MatchRow => ({ | ||
| 31 | 32 | |
| 32 | 33 | const data: Data = { |
| 33 | 34 | standings: [ |
| 34 | { name: "ace", matches: 3, kills: 20, deaths: 10, headshots: 5, damage: 1800, wins: 2, rounds: 9 }, | |
| 35 | { name: "bo", matches: 1, kills: 4, deaths: 6, headshots: 1, damage: 400, wins: 0, rounds: 3 }, | |
| 35 | { name: "ace", user_id: "u-1", matches: 3, kills: 20, deaths: 10, headshots: 5, damage: 1800, wins: 2, rounds: 9 }, | |
| 36 | { name: "bo", user_id: "", matches: 1, kills: 4, deaths: 6, headshots: 1, damage: 400, wins: 0, rounds: 3 }, | |
| 36 | 37 | ], |
| 37 | 38 | matches: [match("m1", "ace"), match("m2", "bo"), match("m3", "cy")], |
| 38 | 39 | total: 4, |
src/terminal/screen.test.ts +14 −1
| @@ -22,12 +22,15 @@ const CARD: Card = { | ||
| 22 | 22 | baselineWinRate: 38, |
| 23 | 23 | }; |
| 24 | 24 | |
| 25 | // A fixture is a signed-in player unless a test asks for a guest: `user_id` | |
| 26 | // is what tells the two apart, and a guest's name is drawn as a guest's. | |
| 25 | 27 | const standing = (name: string, over: Partial<Standing> = {}): Standing => |
| 26 | ({ name, matches: 3, kills: 20, deaths: 10, headshots: 5, damage: 1800, wins: 2, rounds: 9, ...over }); | |
| 28 | ({ name, user_id: "u-1", matches: 3, kills: 20, deaths: 10, headshots: 5, damage: 1800, wins: 2, rounds: 9, ...over }); | |
| 27 | 29 | |
| 28 | 30 | const match = (id: string, name: string, over: Partial<MatchRow> = {}): MatchRow => ({ |
| 29 | 31 | id, |
| 30 | 32 | session: "s", |
| 33 | user_id: "u-1", | |
| 31 | 34 | name, |
| 32 | 35 | kills: 7, |
| 33 | 36 | deaths: 4, |
| @@ -98,6 +101,16 @@ test("the match list keeps its columns and marks the ones still being played", ( | ||
| 98 | 101 | expect(lines[6]).toBe(" bo 3-2 7/4 950 2026-09-16 10:00:00 (live)"); |
| 99 | 102 | }); |
| 100 | 103 | |
| 104 | test("a guest is named as one on both lists, so no row is read as a signed-in player's", () => { | |
| 105 | const guest = data({ | |
| 106 | standings: [standing("kestrel"), standing("kestrel", { user_id: "" })], | |
| 107 | matches: [match("m1", "kestrel"), match("m2", "kestrel", { user_id: "" })], | |
| 108 | }); | |
| 109 | expect(text(view(), guest)[5]).toStartWith("▸ kestrel "); | |
| 110 | expect(text(view(), guest)[6]).toStartWith(" kestrel (guest)"); | |
| 111 | expect(text(view({ tab: "matches" }), guest)[6]).toStartWith(" kestrel (guest)"); | |
| 112 | }); | |
| 113 | ||
| 101 | 114 | test("an empty database says so on both lists", () => { |
| 102 | 115 | const empty = data({ standings: [], matches: [] }); |
| 103 | 116 | expect(text(view(), empty)[5]).toBe(" nothing played yet"); |
src/terminal/screen.ts +3 −3
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import type { Card } from "../client/weights.ts"; |
| 12 | 12 | import type { MatchRow, Standing } from "../db.ts"; |
| 13 | import { headshotShare, ratio } from "../db.ts"; | |
| 13 | import { displayName, headshotShare, ratio } from "../db.ts"; | |
| 14 | 14 | |
| 15 | 15 | export type Tab = "standings" | "matches" | "policy"; |
| 16 | 16 | |
| @@ -82,7 +82,7 @@ function standingsLines(table: Standing[], cursor: number): string[] { | ||
| 82 | 82 | ]; |
| 83 | 83 | if (table.length === 0) lines.push(`${esc}2m nothing played yet${esc}0m`); |
| 84 | 84 | table.forEach((s, i) => { |
| 85 | const line = `${pad(s.name, 26)}${pad(String(s.matches), 9)}${pad(String(s.kills), 7)}` + | |
| 85 | const line = `${pad(displayName(s), 26)}${pad(String(s.matches), 9)}${pad(String(s.kills), 7)}` + | |
| 86 | 86 | `${pad(ratio(s.kills, s.deaths), 7)}${pad(headshotShare(s.headshots, s.kills), 6)}${s.wins}`; |
| 87 | 87 | lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`); |
| 88 | 88 | }); |
| @@ -95,7 +95,7 @@ function matchLines(matches: MatchRow[], cursor: number): string[] { | ||
| 95 | 95 | ]; |
| 96 | 96 | if (matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`); |
| 97 | 97 | matches.forEach((m, i) => { |
| 98 | const line = `${pad(m.name, 22)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` + | |
| 98 | const line = `${pad(displayName(m), 22)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` + | |
| 99 | 99 | `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${m.updated_at}` + |
| 100 | 100 | `${m.finished === 1 ? "" : " (live)"}`; |
| 101 | 101 | lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`); |
src/who.test.ts added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | /** | |
| 2 | * The test is the forge: it signs an identity with the same key the app is | |
| 3 | * given and then takes it apart one field at a time. Everything that cannot be | |
| 4 | * proved is anon, and anon is the only answer a caller can force. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { sign, who } from "./who.ts"; | |
| 9 | ||
| 10 | const KEY = "a-key-only-the-forge-and-the-app-have"; | |
| 11 | const NOW = 1_800_000_000_000; | |
| 12 | ||
| 13 | const asked = (identity: string, signature = sign(identity, KEY)): Request => | |
| 14 | new Request("http://app.test/api/match", { | |
| 15 | headers: { "x-forge-identity": identity, "x-forge-signature": signature }, | |
| 16 | }); | |
| 17 | ||
| 18 | test("a signed, unexpired identity is the person it names", () => { | |
| 19 | const visitor = who(asked(`u-7|vesper|user|${NOW + 1000}`), KEY, NOW); | |
| 20 | expect(visitor).toEqual({ role: "user", id: "u-7", name: "vesper" }); | |
| 21 | expect(who(asked(`u-1|root|admin|${NOW + 1000}`), KEY, NOW).role).toBe("admin"); | |
| 22 | }); | |
| 23 | ||
| 24 | test("a name with a pipe in it arrives whole", () => { | |
| 25 | expect(who(asked(`u-7|a|b|user|${NOW + 1000}`), KEY, NOW)).toEqual({ role: "user", id: "u-7", name: "a|b" }); | |
| 26 | }); | |
| 27 | ||
| 28 | test("anything that does not verify is a guest", () => { | |
| 29 | const good = `u-7|vesper|user|${NOW + 1000}`; | |
| 30 | // No headers at all. | |
| 31 | expect(who(new Request("http://app.test/"), KEY, NOW).role).toBe("anon"); | |
| 32 | // A signature for a different identity, and one that is simply wrong. | |
| 33 | expect(who(asked(good, sign(`u-8|thief|user|${NOW + 1000}`, KEY)), KEY, NOW).role).toBe("anon"); | |
| 34 | expect(who(asked(good, "not-a-signature"), KEY, NOW).role).toBe("anon"); | |
| 35 | // The right identity signed with a key that is not ours. | |
| 36 | expect(who(asked(good, sign(good, "guessed")), KEY, NOW).role).toBe("anon"); | |
| 37 | // Expired, and expiring exactly now. | |
| 38 | expect(who(asked(`u-7|vesper|user|${NOW - 1}`), KEY, NOW).role).toBe("anon"); | |
| 39 | expect(who(asked(`u-7|vesper|user|${NOW}`), KEY, NOW).role).toBe("anon"); | |
| 40 | // Shapes: no role, an invented role, an empty id or name, a junk expiry. | |
| 41 | for (const identity of [ | |
| 42 | "u-7|vesper", `u-7|vesper|owner|${NOW + 1}`, `|vesper|user|${NOW + 1}`, `u-7||user|${NOW + 1}`, | |
| 43 | "u-7|vesper|user|soon", "", "|||", | |
| 44 | ]) expect(who(asked(identity), KEY, NOW).role).toBe("anon"); | |
| 45 | // An identity long enough to be a nuisance is not even hashed. | |
| 46 | expect(who(asked(`u-7|${"n".repeat(600)}|user|${NOW + 1}`), KEY, NOW).role).toBe("anon"); | |
| 47 | }); | |
| 48 | ||
| 49 | test("with no key set nobody is anybody: the app cannot check, so it does not believe", () => { | |
| 50 | const identity = `u-7|vesper|user|${NOW + 1000}`; | |
| 51 | expect(who(asked(identity), undefined, NOW).role).toBe("anon"); | |
| 52 | expect(who(asked(identity), "", NOW).role).toBe("anon"); | |
| 53 | }); |
src/who.ts added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | /** | |
| 2 | * Who is asking. Identity is the forge's to assert and ours to verify. | |
| 3 | * | |
| 4 | * The forge signs every request it proxies to the app: | |
| 5 | * | |
| 6 | * x-forge-identity: <userId>|<username>|<role>|<expiresMs> | |
| 7 | * x-forge-signature: base64url(HMAC-SHA256(FORGE_APP_IDENTITY_KEY, that string)) | |
| 8 | * | |
| 9 | * `role` is `user` (signed in) or `admin`. The forge strips those two headers | |
| 10 | * from whatever a client sends, so a signature that verifies is the only trust | |
| 11 | * there is: no headers, no key, a bad signature, a shape that does not parse or | |
| 12 | * a stamp in the past all mean `anon`. Nothing here reads a cookie — the app's | |
| 13 | * own cookie names a browser, which is a different job from naming a person. | |
| 14 | */ | |
| 15 | ||
| 16 | import { createHmac, timingSafeEqual } from "node:crypto"; | |
| 17 | ||
| 18 | export type Who = { role: "anon" } | { role: "user" | "admin"; id: string; name: string }; | |
| 19 | ||
| 20 | export const IDENTITY_HEADER = "x-forge-identity"; | |
| 21 | export const SIGNATURE_HEADER = "x-forge-signature"; | |
| 22 | ||
| 23 | export const ANON: Who = { role: "anon" }; | |
| 24 | ||
| 25 | /** The signature the forge computes. Exported so a test can be the forge. */ | |
| 26 | export const sign = (identity: string, key: string): string => | |
| 27 | createHmac("sha256", key).update(identity).digest("base64url"); | |
| 28 | ||
| 29 | /** The forge's headers, verified. Anything we cannot prove is `{ role: "anon" }`. */ | |
| 30 | export function who(request: Request, key: string | undefined, now = Date.now()): Who { | |
| 31 | const identity = request.headers.get(IDENTITY_HEADER); | |
| 32 | const signature = request.headers.get(SIGNATURE_HEADER); | |
| 33 | if (!key || identity === null || signature === null || identity.length > 512) return ANON; | |
| 34 | if (!same(signature, sign(identity, key))) return ANON; | |
| 35 | ||
| 36 | // id is everything before the first "|", the expiry everything after the | |
| 37 | // last, and the role the field just before that — so a name that contains a | |
| 38 | // "|" is carried intact instead of desyncing the fields around it. | |
| 39 | const first = identity.indexOf("|"); | |
| 40 | const last = identity.lastIndexOf("|"); | |
| 41 | const secondLast = first === -1 || first === last ? -1 : identity.lastIndexOf("|", last - 1); | |
| 42 | if (secondLast <= first) return ANON; | |
| 43 | ||
| 44 | const id = identity.slice(0, first); | |
| 45 | const name = identity.slice(first + 1, secondLast); | |
| 46 | const role = identity.slice(secondLast + 1, last); | |
| 47 | const expires = identity.slice(last + 1); | |
| 48 | if (role !== "user" && role !== "admin") return ANON; | |
| 49 | if (id === "" || name === "" || !/^\d{1,15}$/.test(expires) || Number(expires) <= now) return ANON; | |
| 50 | return { role, id, name }; | |
| 51 | } | |
| 52 | ||
| 53 | const same = (a: string, b: string): boolean => { | |
| 54 | const x = Buffer.from(a); | |
| 55 | const y = Buffer.from(b); | |
| 56 | return x.length === y.length && timingSafeEqual(x, y); | |
| 57 | }; |
new above
offline