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

Cap playing seats by address, not by cookie

benjivers pointed out the previous fix only closed the anonymous
version of the both-teams hole: a cookie is free to mint, so three
plain GETs of "/" gave one machine three sessions, three seats, and
the union of both teams' positions — no socket needed to carry no
cookie at all.

A session still gets you back to your own seat on a reload; it no
longer gets you a second one. takeSeat() now also rations by the
connecting address (self.requestIP in server.ts), synchronously in the
same claim that already closed the earlier race, so a second session
from an address that already holds a seat gets null instead of a bot.
/ws treats that the same as a sessionless connection: no seat, a
spectator view. The spectator snapshot already carried no positions
(score, clock, phase, round, events only) — confirmed that end-to-end
against a real running server with two real cookies from one address,
not just in the test's own process.

This still cannot stop two machines on two networks — that is true of
any anonymous game with no accounts, and I said so in the README
rather than adding more cookies to chase it, per the feedback.

Test note: getting this right against the shared test server exposed
that several existing tests claim a real seat over real HTTP from the
test process's own loopback address and never release it — a `/join`
that's never followed by a socket has nothing to close. That's
harmless on its own, but it meant later tests needing a *fresh*
address could find it already spent by an earlier one. Added
`releaseAddress()` (test-only, documented as such) so a test that
needs a clean address can start from one, used it defensively in the
new end-to-end test, and cleaned up the two existing tests whose real
claims were the actual culprits (client.test.ts's own socket, and
"a name is taken..."'s /join) rather than leaving them to keep
tripping up whatever runs after them.

New tests: a direct arena-level test that a second session from an
address already holding a seat gets refused; and an end-to-end test
over two real websockets from one address (the test process's own)
that the first plays and the second is a spectator with no `x` or `y`
anywhere in its snapshot. Both fail against the pre-fix code.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Pike committed 8b7d34c Browse files
5 files changed +140 −46
README.md +6 −1
@@ -52,7 +52,12 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
5252 spike, ability zones — is a flat billboard scaled by distance and clipped
5353 against the walls.
5454 - One map, one match at a time, ten seats. There is no matchmaking, no
55 ranking and no account: your seat belongs to a session cookie.
55 ranking and no account: your seat belongs to a session cookie, and a
56 playing seat is capped one per client address so a cookie — free to mint
57 again — cannot buy a second one on the other team. A second machine on a
58 second network is still a second person as far as the server can tell,
59 the same as any anonymous game without accounts; a socket that does not
60 get a seat is sent a spectator view with no position in it, never a side.
5661 - No overtime — first to 13 ends it at 12–12 too.
5762 - Sight is a full circle rather than a view cone — you are drawn what is in
5863 front of you, but the server reveals anything you have line of sight to,
src/arena.ts +42 −11
@@ -31,10 +31,16 @@ import { isWeaponId, WEAPONS } from "./weapons.ts";
3131 export const TICK = 1 / 30;
3232 const BROADCAST_EVERY = 2;
3333
34 export type Seat = { playerId: string; token: string; send: (data: string) => void };
34 export type Seat = { playerId: string; token: string; address: string; send: (data: string) => void };
3535
3636 let state: MatchState = newMatch();
3737 const seats = new Map<string, Seat>();
38 // A session is a cookie, and a cookie costs one HTTP request — free for
39 // anyone to mint as many of as they like. The address is the one thing here
40 // a single machine cannot hand itself two of, so it is what a playing seat
41 // is actually rationed by; the session on top of it is only what lets a
42 // reload find its way back to the same seat rather than a fresh one.
43 const addressSeats = new Map<string, string>();
3844 let ticks = 0;
3945 let lastResult: string | null = null;
4046 // The kill feed is read off the match a tick at a time; the rules keep no
@@ -65,23 +71,34 @@ function freeName(want: string, exceptId: string): string {
6571 return want;
6672 }
6773
74 /**
75 * Whether this address already plays under a different session. Checked
76 * before a socket is even offered a seat, so the caller can hand it a
77 * spectator view instead of upgrading it into a second side's vision.
78 */
79 export function addressCapped(address: string, session: string): boolean {
80 const holder = addressSeats.get(address);
81 return holder !== undefined && holder !== session;
82 }
83
6884 /**
6985 * Sit somebody down in the emptiest team, replacing a bot where it stands.
7086 *
71 * The claim goes into `seats` right here, synchronously, before the caller
72 * ever opens a socket. A websocket upgrade completes on a later tick, so two
73 * requests for the same session arriving close together both used to read
74 * `seats` before either had written to it, and both walked away with their
75 * own seat on opposite teams — one client, two sides, the vision of both.
76 * Claiming inline closes that window: the second call sees the first's claim.
87 * Both claims go in right here, synchronously, before the caller ever opens
88 * a socket. A websocket upgrade completes on a later tick, so two requests
89 * arriving close together both used to read the maps before either had
90 * written to them — one client, two seats, opposite sides, the vision of
91 * both. Claiming inline closes that window regardless of how the two
92 * requests were related.
7793 */
78 export function takeSeat(session: string, name: string): Player | null {
94 export function takeSeat(address: string, session: string, name: string): Player | null {
7995 const existing = seats.get(session);
8096 const held = existing ? findPlayer(existing.playerId) : undefined;
8197 if (held) {
8298 held.name = freeName(name, held.id);
8399 return held;
84100 }
101 if (addressCapped(address, session)) return null;
85102 const humans = { red: 0, blue: 0 };
86103 for (const player of state.players) if (!player.bot) humans[player.team] += 1;
87104 const wanted = humans.red <= humans.blue ? "red" : "blue";
@@ -92,13 +109,14 @@ export function takeSeat(session: string, name: string): Player | null {
92109 seat.name = freeName(name, seat.id);
93110 seat.think = 0;
94111 seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null };
95 seats.set(session, { playerId: seat.id, token: "", send: () => {} });
112 addressSeats.set(address, session);
113 seats.set(session, { playerId: seat.id, token: "", address, send: () => {} });
96114 return seat;
97115 }
98116
99117 /** The socket that actually opened fills in how to reach it and its token. */
100 export function attach(session: string, playerId: string, token: string, send: (data: string) => void): void {
101 seats.set(session, { playerId, token, send });
118 export function attach(session: string, playerId: string, address: string, token: string, send: (data: string) => void): void {
119 seats.set(session, { playerId, token, address, send });
102120 }
103121
104122 /**
@@ -112,6 +130,7 @@ export function release(session: string, token: string): void {
112130 const seat = seats.get(session);
113131 if (!seat || seat.token !== token) return;
114132 seats.delete(session);
133 if (addressSeats.get(seat.address) === session) addressSeats.delete(seat.address);
115134 const player = findPlayer(seat.playerId);
116135 if (player && !stillHeld(seat.playerId)) {
117136 player.bot = true;
@@ -121,6 +140,18 @@ export function release(session: string, token: string): void {
121140 }
122141 }
123142
143 /**
144 * Test-only hygiene: give back whatever this address currently holds. The
145 * arena is one match shared by the whole test run, over real HTTP, from one
146 * loopback address — this is how a test that needs that address free gets it
147 * free, rather than depending on every other test in the file to clean up
148 * after itself.
149 */
150 export function releaseAddress(address: string): void {
151 const session = addressSeats.get(address);
152 if (session !== undefined) release(session, seats.get(session)?.token ?? "");
153 }
154
124155 const stillHeld = (playerId: string): boolean =>
125156 [...seats.values()].some((s) => s.playerId === playerId);
126157
src/client.test.ts +9 −1
@@ -153,9 +153,11 @@ globalThis.fetch = ((input: string, init?: RequestInit) =>
153153 // its own; this stub is not a browser, so it carries the cookie the page's
154154 // own fetch was handed, the way one actually would.
155155 let sessionCookie = "";
156 let openedSocket: WebSocket | null = null;
156157 const RealWebSocket = globalThis.WebSocket;
157158 globalThis.WebSocket = function (url: string) {
158 return new RealWebSocket(url, { headers: { cookie: sessionCookie } } as unknown as string[]);
159 openedSocket = new RealWebSocket(url, { headers: { cookie: sessionCookie } } as unknown as string[]);
160 return openedSocket;
159161 } as unknown as typeof WebSocket;
160162
161163 const runFrame = () => {
@@ -271,4 +273,10 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
271273
272274 // And the socket really took a seat in the running match.
273275 expect(seat.bot).toBe(false);
276
277 // This test's loopback address is shared with every other test in the
278 // run; close the socket so the address is free for the next real
279 // connection rather than left holding a seat for the rest of the suite.
280 openedSocket?.close();
281 await Bun.sleep(50);
274282 }, 15000);
src/server.test.ts +64 −20
@@ -6,7 +6,7 @@ import { join } from "node:path";
66 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
77 process.env.PORT = "0";
88 const { server } = await import("./server.ts");
9 const { match, snapshot, advance, takeSeat } = await import("./arena.ts");
9 const { match, snapshot, advance, takeSeat, release, releaseAddress } = await import("./arena.ts");
1010 const base = `http://${server.hostname}:${server.port}`;
1111
1212 test("serves a live match on the first paint, with no script needed", async () => {
@@ -47,6 +47,13 @@ test("a name is taken from a same-origin form and sanitised", async () => {
4747 expect(match().players.every((p) => !p.name.includes("<"))).toBe(true);
4848 // The seat that name took is a real player in the running match.
4949 expect(match().players.some((p) => !p.bot && p.name.includes("Wren"))).toBe(true);
50
51 // A join never opens a socket to close, so nothing ever frees the address
52 // it claimed on its own; every other test in this run shares this same
53 // loopback address, so free it explicitly rather than starve them of it.
54 const sessionCookie = posted.headers.getSetCookie().find((c) => c.startsWith("val_session="));
55 const session = sessionCookie?.split(";")[0]?.split("=")[1];
56 if (session) release(session, "");
5057 });
5158
5259 test("refuses a cross-site post", async () => {
@@ -211,9 +218,9 @@ test("a stale socket closing does not evict a live reconnect's seat", async () =
211218 // A reload opens the new socket before the old one's close event lands.
212219 // The old close must not turn a player who is still connected into a bot.
213220 const { attach, release } = await import("./arena.ts");
214 const player = takeSeat("reload-session", "Reload")!;
215 attach("reload-session", player.id, "first", () => {});
216 attach("reload-session", player.id, "second", () => {});
221 const player = takeSeat("203.0.113.1", "reload-session", "Reload")!;
222 attach("reload-session", player.id, "203.0.113.1", "first", () => {});
223 attach("reload-session", player.id, "203.0.113.1", "second", () => {});
217224 release("reload-session", "first");
218225 expect(player.bot).toBe(false);
219226 });
@@ -224,32 +231,69 @@ test("one session cannot claim two seats on opposite teams", () => {
224231 // map before either had written to it — one client, two seats, opposite
225232 // sides, the vision of both. Nothing here waits for a socket to open:
226233 // the claim has to land the moment a seat is handed out.
227 const first = takeSeat("cheat-session", "Cheat")!;
228 const second = takeSeat("cheat-session", "Cheat")!;
234 const first = takeSeat("203.0.113.2", "cheat-session", "Cheat")!;
235 const second = takeSeat("203.0.113.2", "cheat-session", "Cheat")!;
229236 expect(second.id).toBe(first.id);
230237 expect(match().players.filter((p) => !p.bot && p.name === "Cheat").length).toBe(1);
231238 });
232239
233 test("a socket with no session the page minted watches instead of playing", async () => {
234 const before = match().players.filter((p) => !p.bot).length;
235 const ws = new WebSocket(base.replace(/^http/, "ws") + "/ws");
236 const first = await new Promise<unknown>((resolve, reject) => {
237 ws.onmessage = (event) => resolve(JSON.parse(String(event.data)));
238 ws.onerror = reject;
240 test("one address cannot hold seats on both sides through a second session", async () => {
241 // Three GETs of "/" mint three different, equally legitimate-looking
242 // cookies. A session names a browser tab, not a person, so capping by
243 // session alone still lets one machine sit on both teams and read both
244 // snapshots — every position on the map, no forged traffic required.
245 const { addressCapped } = await import("./arena.ts");
246 const first = takeSeat("198.51.100.9", "session-a", "OneMachine")!;
247 expect(addressCapped("198.51.100.9", "session-b")).toBe(true);
248 const second = takeSeat("198.51.100.9", "session-b", "OneMachine");
249 expect(second).toBe(null);
250 // The same session reconnecting is not a second claim, and is let through.
251 expect(addressCapped("198.51.100.9", "session-a")).toBe(false);
252 expect(takeSeat("198.51.100.9", "session-a", "OneMachine")!.id).toBe(first.id);
253 });
254
255 test("a real second connection from one address gets a spectator view, positions and all excluded", async () => {
256 // The end-to-end path: two sockets from this test's own loopback address,
257 // one cookie each. The second must come back with no seat and nothing in
258 // its snapshot that carries a coordinate — score and clock, not a map.
259 // This loopback address is shared with every other test that runs a real
260 // request; start from a clean slate rather than trust none of them left it
261 // holding a seat.
262 releaseAddress("127.0.0.1");
263 const first = await fetch(base + "/");
264 const cookieOne = (first.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
265 const second = await fetch(base + "/");
266 const cookieTwo = (second.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
267 expect(cookieOne).not.toBe(cookieTwo);
268
269 const wsUrl = base.replace(/^http/, "ws") + "/ws";
270 const wsOne = new WebSocket(wsUrl, { headers: { cookie: cookieOne } } as unknown as string[]);
271 const snapOne = await new Promise<{ spectator?: boolean; me?: unknown }>((resolve, reject) => {
272 wsOne.onmessage = (event) => resolve(JSON.parse(String(event.data)));
273 wsOne.onerror = reject;
239274 });
240 expect(first).toMatchObject({ t: "s", spectator: true });
241 expect(first).not.toHaveProperty("me");
242 expect(first).not.toHaveProperty("players");
243 // No bot was converted to serve a connection that carried no identity.
244 expect(match().players.filter((p) => !p.bot).length).toBe(before);
245 ws.close();
275 // The first connection from this address is the one that actually plays.
276 expect(snapOne.spectator).toBeUndefined();
277 expect(snapOne.me).toBeDefined();
278
279 const wsTwo = new WebSocket(wsUrl, { headers: { cookie: cookieTwo } } as unknown as string[]);
280 const snapTwo = await new Promise<unknown>((resolve, reject) => {
281 wsTwo.onmessage = (event) => resolve(JSON.parse(String(event.data)));
282 wsTwo.onerror = reject;
283 });
284 expect(snapTwo).toMatchObject({ t: "s", spectator: true });
285 // No coordinate anywhere, not under "me", not under a different key either.
286 expect(JSON.stringify(snapTwo)).not.toMatch(/"x"|"y"/);
287 wsOne.close();
288 wsTwo.close();
289 await Bun.sleep(50);
246290 });
247291
248292 test("two people who pick the same name do not end up with the same name", () => {
249293 // Everything that addresses a player — the kill feed, the round log, the
250294 // scoreboard — does it by name, so the match may never hold two of one.
251 const first = takeSeat("clash-a", "Twin")!;
252 const second = takeSeat("clash-b", "Twin")!;
295 const first = takeSeat("198.51.100.10", "clash-a", "Twin")!;
296 const second = takeSeat("198.51.100.11", "clash-b", "Twin")!;
253297 expect(first.name).toBe("Twin");
254298 expect(second.name).not.toBe("Twin");
255299 expect(second.name.startsWith("Twin")).toBe(true);
src/server.ts +19 −13
@@ -11,6 +11,7 @@ import { createHash, randomUUID } from "node:crypto";
1111 import { join } from "node:path";
1212 import { AGENTS, SLOTS } from "./abilities.ts";
1313 import {
14 addressCapped,
1415 attach,
1516 command,
1617 findPlayer,
@@ -157,7 +158,7 @@ const cleanName = (raw: unknown): string => {
157158 const setCookie = (name: string, value: string, httpOnly: boolean): string =>
158159 `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=604800${httpOnly ? "; HttpOnly" : ""}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`;
159160
160 type SocketData = { session: string; playerId: string; token: string; spectator: boolean };
161 type SocketData = { session: string; playerId: string; address: string; token: string; spectator: boolean };
161162
162163 // ---------------------------------------------------------------- routing
163164
@@ -169,6 +170,10 @@ export const server = Bun.serve<SocketData, Record<string, never>>({
169170 const jar = cookies(request);
170171 const session = jar[SESSION] ?? randomUUID();
171172 const fresh = jar[SESSION] === undefined;
173 // A cookie costs one HTTP request, so it names a browser, not a person —
174 // the address is the one thing here a single machine cannot mint a
175 // second one of, which is what a playing seat is actually rationed by.
176 const address = self?.requestIP?.(request)?.address ?? "";
172177
173178 if (request.method === "POST" && !sameOrigin(request))
174179 return new Response("forbidden", { status: 403, headers: SECURITY });
@@ -181,27 +186,28 @@ export const server = Bun.serve<SocketData, Record<string, never>>({
181186 headers.append("set-cookie", setCookie(SESSION, session, true));
182187 if (name) {
183188 headers.append("set-cookie", setCookie(NAME, name, false));
184 takeSeat(session, name);
189 takeSeat(address, session, name);
185190 }
186191 return new Response(null, { status: 303, headers });
187192 }
188193
189 // The socket: one seat per session, taken from a bot where it stands.
190 // A connection with no session the page minted gets no seat at all — the
191 // page always carries the cookie by the time its own script opens this,
192 // so this is a raw connection that skipped it, and it watches instead of
193 // playing, on both sides of the vision a seat would otherwise hand it.
194 // The socket: one playing seat per address, taken from a bot where it
195 // stands. A connection with no session the page minted, or one whose
196 // address already plays under a different session, gets no seat at all —
197 // it watches instead, with a view built for no side, rather than being
198 // handed the vision a seat would otherwise give it on top of the one it
199 // already has.
194200 if (url.pathname === "/ws") {
195201 if (!sameOrigin(request)) return new Response("forbidden", { status: 403, headers: SECURITY });
196202 const token = randomUUID();
197 if (fresh) {
198 const upgraded = self.upgrade(request, { data: { session, playerId: "", token, spectator: true } });
203 if (fresh || addressCapped(address, session)) {
204 const upgraded = self.upgrade(request, { data: { session, playerId: "", address, token, spectator: true } });
199205 return upgraded ? undefined : new Response("expected a websocket", { status: 426, headers: SECURITY });
200206 }
201207 const name = cleanName(jar[NAME]) || `Guest-${session.slice(0, 4)}`;
202 const player = takeSeat(session, name);
208 const player = takeSeat(address, session, name);
203209 if (!player) return new Response("no seats", { status: 503, headers: SECURITY });
204 const upgraded = self.upgrade(request, { data: { session, playerId: player.id, token, spectator: false } });
210 const upgraded = self.upgrade(request, { data: { session, playerId: player.id, address, token, spectator: false } });
205211 return upgraded ? undefined : new Response("expected a websocket", { status: 426, headers: SECURITY });
206212 }
207213
@@ -285,9 +291,9 @@ export const server = Bun.serve<SocketData, Record<string, never>>({
285291 websocket: {
286292 maxPayloadLength: 4 * 1024,
287293 open(socket) {
288 const { session, playerId, token, spectator } = socket.data;
294 const { session, playerId, address, token, spectator } = socket.data;
289295 if (spectator) return void watch(token, (data) => socket.send(data));
290 attach(session, playerId, token, (data) => socket.send(data));
296 attach(session, playerId, address, token, (data) => socket.send(data));
291297 socket.send(snapshot(playerId));
292298 },
293299 message(socket, raw) {
↑↓ navigate ↵ open esc close

Keyboard shortcuts