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

Merged opened this in answer to

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

Merged

vesper/cs-engine:vesper/38-security-the-leaderboard-is-forgeable-in → main 3 commits fork

  • Merged. vesper/38-security-the-leaderboard-is-forgeable-in was merged into main as 3c0c7cd.

Review

Sign in to review this change.
17 files changed +738 −54
README.md +54 −13
@@ -1,11 +1,12 @@
1 # cs
1 # cs-engine
22
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.
910
1011 ```sh
1112 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
4041 - **Simulated.** The opponents are bots, and the match runs entirely in your
4142 browser: there is no multiplayer and no server-side authority. Scores are
4243 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.
4579
4680 ## Playing
4781
@@ -168,7 +202,13 @@ page shows is the loop and the controls themselves, as numbers that move.
168202 moved; a failure names the first second at which the two runs part and prints
169203 the constants to paste.
170204 - `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.
172212 - `src/pages/shell.ts` — the document every game is served inside, and nothing
173213 about any one game: `page({ title, description, heading, env, pull, commit,
174214 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,
239279 so their tests press keys and read lines with no terminal and no database —
240280 with the escapes taken off by `plain()`, so an expectation is a sentence.
241281
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.
245286
246287 ## The bots
247288
package.json +1 −1
@@ -1,5 +1,5 @@
11 {
2 "name": "cs",
2 "name": "cs-engine",
33 "private": true,
44 "type": "module",
55 "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 @@
22 * The match records, in SQLite under FORGE_APP_DATA. One row per match, keyed
33 * by the id the browser generated for it, so a match that is still being
44 * 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.
511 */
612
713 import { Database } from "bun:sqlite";
@@ -12,6 +18,8 @@ import type { Result } from "./result.ts";
1218 export type MatchRow = {
1319 id: string;
1420 session: string;
21 /** The forge identity that posted it, or "" for a guest. */
22 user_id: string;
1523 name: string;
1624 kills: number;
1725 deaths: number;
@@ -27,6 +35,8 @@ export type MatchRow = {
2735
2836 export type Standing = {
2937 name: string;
38 /** The forge identity these matches were played under, or "" for a guest. */
39 user_id: string;
3040 matches: number;
3141 kills: number;
3242 deaths: number;
@@ -43,6 +53,7 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data
4353 db.exec(`CREATE TABLE IF NOT EXISTS match (
4454 id TEXT PRIMARY KEY,
4555 session TEXT NOT NULL,
56 user_id TEXT NOT NULL DEFAULT '',
4657 name TEXT NOT NULL,
4758 kills INTEGER NOT NULL,
4859 deaths INTEGER NOT NULL,
@@ -56,20 +67,51 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data
5667 created_at TEXT NOT NULL DEFAULT (datetime('now')),
5768 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
5869 )`);
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 ''");
5975 db.exec("CREATE INDEX IF NOT EXISTS match_by_time ON match (updated_at DESC)");
6076 return db;
6177 }
6278
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
6389 /**
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.
6693 */
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 }
70112 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)
73115 ON CONFLICT(id) DO UPDATE SET
74116 name = excluded.name, kills = excluded.kills, deaths = excluded.deaths,
75117 headshots = excluded.headshots, damage = excluded.damage, rounds = excluded.rounds,
@@ -77,23 +119,29 @@ export function saveMatch(db: Database, session: string, result: Result): boolea
77119 finished = excluded.finished, seconds = excluded.seconds,
78120 updated_at = datetime('now')`,
79121 ).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,
81123 result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0, result.seconds,
124 player.userId,
82125 );
83126 return true;
84127 }
85128
86129 export const recentMatches = (db: Database, limit = 10): MatchRow[] =>
87130 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,
89132 score_mine, score_theirs, finished, seconds, updated_at
90133 FROM match WHERE rounds > 0 ORDER BY updated_at DESC LIMIT ?1`,
91134 ).all(limit);
92135
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 */
94142 export const standings = (db: Database, limit = 10): Standing[] =>
95143 db.query<Standing, [number]>(
96 `SELECT name,
144 `SELECT name, user_id,
97145 COUNT(*) AS matches,
98146 SUM(kills) AS kills,
99147 SUM(deaths) AS deaths,
@@ -102,7 +150,7 @@ export const standings = (db: Database, limit = 10): Standing[] =>
102150 SUM(CASE WHEN finished = 1 AND score_mine > score_theirs THEN 1 ELSE 0 END) AS wins,
103151 SUM(rounds) AS rounds
104152 FROM match WHERE rounds > 0
105 GROUP BY name
153 GROUP BY user_id, name
106154 ORDER BY kills DESC, damage DESC
107155 LIMIT ?1`,
108156 ).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 @@
1010 */
1111
1212 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";
1414 import { CARD } from "../client/weights.ts";
15 import type { Who } from "../who.ts";
1516 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
1617
1718 export { escape };
@@ -22,13 +23,13 @@ export function statsHtml(db: Database): string {
2223 const recent = recentMatches(db, 8);
2324 const rows = table
2425 .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>` +
2627 `<td>${ratio(s.kills, s.deaths)}</td><td>${headshotShare(s.headshots, s.kills)}</td><td>${s.wins}</td></tr>`,
2728 )
2829 .join("");
2930 const feed = recent
3031 .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} ` +
3233 `<span>${m.kills}k ${m.deaths}d · ${m.damage} damage${m.finished === 1 ? "" : " · in progress"}</span> ` +
3334 `<time>${escape(m.updated_at)}</time></li>`,
3435 )
@@ -42,13 +43,34 @@ ${table.length === 0
4243 }
4344
4445 /** 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 };
4650
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 => `
4864 <h2>Defuse or detonate</h2>
4965 <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>
5070 <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>
5274 <label>Match<select id="length"><option value="short">Short — first to 5</option><option value="long">Competitive — first to 13</option></select></label>
5375 <label>Side<select id="side"><option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select></label>
5476 <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 = `
6486 <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div>
6587 `;
6688
67 export function gamePage(db: Database, options: PageOptions): string {
89 export function gamePage(db: Database, { who: visitor, ...options }: PageOptions): string {
6890 return page({
6991 ...options,
7092 title: "cs — a browser defusal shooter",
@@ -87,7 +109,7 @@ export function gamePage(db: Database, options: PageOptions): string {
87109 ],
88110 ],
89111 overlays: [
90 { id: "menu", html: MENU },
112 { id: "menu", html: menu(visitor) },
91113 { id: "pause", html: PAUSE, hidden: true },
92114 { id: "buy", html: "", hidden: true },
93115 ],
@@ -111,7 +133,9 @@ export function gamePage(db: Database, options: PageOptions): string {
111133 bot's own skill still decides how fast it can turn, how steady its hands are and how long it
112134 takes to react, so there is still a ladder from fodder to uncomfortable.</p>
113135 <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>
115139 <p>The engine underneath — the loop, the controls, the grid, the meshes, the textures and
116140 the sounds — is a package, and <a href="/range">the range</a> is a second game being built
117141 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 = {
2222 const MAX_ROUNDS = 30;
2323 const KILLS_PER_ROUND = 5; // five enemies is all there are
2424
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
2533 const integer = (value: unknown, max: number): number | null => {
2634 if (typeof value !== "number" || !Number.isFinite(value)) return null;
2735 const n = Math.floor(value);
@@ -48,7 +56,7 @@ export function parseResult(value: unknown): Result | null {
4856 const deaths = integer(body.deaths, MAX_ROUNDS);
4957 const headshots = integer(body.headshots, MAX_ROUNDS * KILLS_PER_ROUND);
5058 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);
5260 if (
5361 rounds === null || scoreMine === null || scoreTheirs === null || kills === null ||
5462 deaths === null || headshots === null || damage === null || seconds === null
@@ -59,6 +67,10 @@ export function parseResult(value: unknown): Result | null {
5967 if (headshots > kills) return null;
6068 if (deaths > rounds) return null;
6169 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;
6274 return {
6375 matchId,
6476 name: cleanName(body.name),
src/server.test.ts +112 −3
@@ -4,13 +4,23 @@ import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { bundle, STATIC_DIR } from "../scripts/build.ts";
66 import { parseResult } from "./result.ts";
7 import { sign } from "./who.ts";
78 import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts";
89
910 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
1011 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");
1216 const base = `http://${server.hostname}:${server.port}`;
1317
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
1424 const result = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
1525 matchId: crypto.randomUUID(),
1626 name: "tester",
@@ -19,10 +29,10 @@ const result = (over: Record<string, unknown> = {}): Record<string, unknown> =>
1929 ...over,
2030 });
2131
22 const post = (body: unknown, cookie = ""): Promise<Response> =>
32 const post = (body: unknown, cookie = "", headers: Record<string, string> = {}): Promise<Response> =>
2333 fetch(base + "/api/match", {
2434 method: "POST",
25 headers: { "content-type": "application/json", ...(cookie === "" ? {} : { cookie }) },
35 headers: { "content-type": "application/json", ...(cookie === "" ? {} : { cookie }), ...headers },
2636 body: JSON.stringify(body),
2737 });
2838
@@ -163,6 +173,89 @@ test("another browser cannot overwrite a match it did not play", async () => {
163173 expect(await (await fetch(base + "/")).text()).not.toContain("thief");
164174 });
165175
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("&lt;script&gt;alert(1)");
257 });
258
166259 test("a body is capped by the bytes read, whatever it claims", async () => {
167260 const response = await fetch(base + "/api/match", {
168261 method: "POST",
@@ -228,3 +321,19 @@ test("a result parses to exactly what was sent, and nothing else", () => {
228321 expect(parseResult(null)).toBe(null);
229322 expect(parseResult("no")).toBe(null);
230323 });
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 @@
44 * The game itself runs in the page; this server renders the first paint,
55 * serves each game's bundled script, and keeps match results in SQLite under
66 * 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.
713 */
814
915 import { existsSync, readdirSync } from "node:fs";
1016 import { join } from "node:path";
1117 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";
1321 import { escape, gamePage, statsHtml } from "./pages/game.ts";
1422 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";
1625
1726 const db = openDatabase();
1827
@@ -21,6 +30,10 @@ const pull = process.env.FORGE_APP_PULL;
2130 const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7);
2231 const ssh = process.env.FORGE_APP_SSH;
2332
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
2437 // The security floor every response stands on. Scripts and styles come from
2538 // this origin (inline styles allowed, inline scripts not), nothing frames us,
2639 // browsers never guess a content type, and referrers stay on this site.
@@ -111,6 +124,45 @@ const newSession = (): string =>
111124 const sessionCookie = (session: string): string =>
112125 `${SESSION_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${secure ? "; Secure" : ""}`;
113126
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
114166 // The built bundles, one per entry, each named by its content so it can be
115167 // cached for a year. Resolved once at start-up and by exact name: with a second
116168 // 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 =>
130182
131183 const isRead = (request: Request): boolean => request.method === "GET" || request.method === "HEAD";
132184
185 const listen = bind(process.env);
186
133187 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) {
137191 const url = new URL(request.url);
138192 if (request.method === "POST" && !sameOrigin(request))
139193 return new Response("forbidden", { status: 403, headers: SECURITY });
140194
141195 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 });
142203 const body = await readBody(request);
143204 if (body === undefined) return new Response("too large", { status: 413, headers: SECURITY });
144205 let parsed: unknown;
@@ -149,8 +210,8 @@ export const server = Bun.serve({
149210 }
150211 const result = parseResult(parsed);
151212 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);
154215 if (!stored) return new Response("not yours", { status: 409, headers: SECURITY });
155216 return html(statsHtml(db), 200, { "set-cookie": sessionCookie(session) });
156217 }
@@ -158,7 +219,7 @@ export const server = Bun.serve({
158219 // The game, and a health check on HEAD sees the app up.
159220 if (url.pathname === "/" && isRead(request)) {
160221 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) });
162223 return withoutBodyIfHead(
163224 request,
164225 html(page, 200, session === null ? { "set-cookie": sessionCookie(newSession()) } : {}),
@@ -193,6 +254,10 @@ export const server = Bun.serve({
193254 },
194255 });
195256
257 if (listen.unusable !== undefined)
258 console.log(`HOST=${listen.unusable} is not an address to bind; listening on ${listen.hostname} instead`);
259
196260 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"}`,
198263 );
src/terminal/keys.test.ts +3 −2
@@ -16,6 +16,7 @@ const CARD = { trainedAt: "2026-09-14", decisions: 1, envSteps: 1, updates: 1, s
1616 const match = (id: string, name: string): MatchRow => ({
1717 id,
1818 session: "s",
19 user_id: "u-1",
1920 name,
2021 kills: 7,
2122 deaths: 4,
@@ -31,8 +32,8 @@ const match = (id: string, name: string): MatchRow => ({
3132
3233 const data: Data = {
3334 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 },
3637 ],
3738 matches: [match("m1", "ace"), match("m2", "bo"), match("m3", "cy")],
3839 total: 4,
src/terminal/screen.test.ts +14 −1
@@ -22,12 +22,15 @@ const CARD: Card = {
2222 baselineWinRate: 38,
2323 };
2424
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.
2527 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 });
2729
2830 const match = (id: string, name: string, over: Partial<MatchRow> = {}): MatchRow => ({
2931 id,
3032 session: "s",
33 user_id: "u-1",
3134 name,
3235 kills: 7,
3336 deaths: 4,
@@ -98,6 +101,16 @@ test("the match list keeps its columns and marks the ones still being played", (
98101 expect(lines[6]).toBe(" bo 3-2 7/4 950 2026-09-16 10:00:00 (live)");
99102 });
100103
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
101114 test("an empty database says so on both lists", () => {
102115 const empty = data({ standings: [], matches: [] });
103116 expect(text(view(), empty)[5]).toBe(" nothing played yet");
src/terminal/screen.ts +3 −3
@@ -10,7 +10,7 @@
1010
1111 import type { Card } from "../client/weights.ts";
1212 import type { MatchRow, Standing } from "../db.ts";
13 import { headshotShare, ratio } from "../db.ts";
13 import { displayName, headshotShare, ratio } from "../db.ts";
1414
1515 export type Tab = "standings" | "matches" | "policy";
1616
@@ -82,7 +82,7 @@ function standingsLines(table: Standing[], cursor: number): string[] {
8282 ];
8383 if (table.length === 0) lines.push(`${esc}2m nothing played yet${esc}0m`);
8484 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)}` +
8686 `${pad(ratio(s.kills, s.deaths), 7)}${pad(headshotShare(s.headshots, s.kills), 6)}${s.wins}`;
8787 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
8888 });
@@ -95,7 +95,7 @@ function matchLines(matches: MatchRow[], cursor: number): string[] {
9595 ];
9696 if (matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`);
9797 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)}` +
9999 `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${m.updated_at}` +
100100 `${m.finished === 1 ? "" : " (live)"}`;
101101 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 };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs