Split The code is public; the app answers only to the crew.
Your own row on the board, wherever you rank #240
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/95-your-own-row-on-the-board-wherever-you-r → main 2 commits fork 2 threads · 2 unresolved
7 files
changed +203
−26
README.md +9 −1
| @@ -175,7 +175,15 @@ wants one, but the running server never reads it. | ||
| 175 | 175 | `FORGE_APP_DATA`, also served as plain JSON from `GET /api/leaderboard` for |
| 176 | 176 | the terminal or any other client — with a `guest` flag per row instead of the |
| 177 | 177 | identity it comes from, because a guest's identity is the session cookie that |
| 178 | owns their matches and that does not travel. A row is **live**, **finished** or | |
| 178 | owns their matches and that does not travel. The board itself is the top ten, | |
| 179 | but a visitor sitting eleventh or below still gets their own row: highlighted | |
| 180 | in place when they are inside it, pinned below the table (and in the `you` | |
| 181 | field of the JSON, `null` for someone who has not played) with their true | |
| 182 | rank when they are not — a rank shared with anyone else tied on the same | |
| 183 | kills and damage, so a pinned row can read the same number as the table | |
| 184 | row above it. The terminal has no visitor to highlight — an | |
| 185 | operator's pty, not a browser's session — so it shows the same top ten and | |
| 186 | nothing pinned beneath it. A row is **live**, **finished** or | |
| 179 | 187 | **abandoned** — one word, computed once in `src/db.ts` and read by the page, |
| 180 | 188 | `GET /api/leaderboard` and the terminal alike, never worked out twice. Live is |
| 181 | 189 | a match still posting; finished is one that played to a result; abandoned is |
src/db.test.ts +28 −1
| @@ -8,7 +8,7 @@ import { Database } from "bun:sqlite"; | ||
| 8 | 8 | import { mkdtempSync } from "node:fs"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | import { openDatabase, recentMatches, saveMatch, standings } from "./db.ts"; | |
| 11 | import { openDatabase, recentMatches, saveMatch, standingFor, standings } from "./db.ts"; | |
| 12 | 12 | import type { Result } from "./result.ts"; |
| 13 | 13 | |
| 14 | 14 | const dir = (): string => mkdtempSync(join(tmpdir(), "db-")); |
| @@ -243,6 +243,33 @@ test("standings answers in a budget that does not grow with the table: 20,000 ma | ||
| 243 | 243 | expect(performance.now() - start).toBeLessThan(10); |
| 244 | 244 | }); |
| 245 | 245 | |
| 246 | test("standingFor answers one identity's own rank and totals, off the bottom of the table or on it", () => { | |
| 247 | const db = openDatabase(dir()); | |
| 248 | // Twelve identities, best first: the eleventh (index 10) sits at rank 11. | |
| 249 | for (let i = 0; i < 12; i++) { | |
| 250 | expect(saveMatch(db, writer(`user:u_${i}`, `player ${i}`), result({ kills: 100 - i, damage: 900 - i }))).toBe(true); | |
| 251 | } | |
| 252 | const eleventh = standingFor(db, "user:u_10"); | |
| 253 | expect(eleventh?.rank).toBe(11); | |
| 254 | expect(eleventh?.name).toBe("player 10"); | |
| 255 | expect(eleventh?.kills).toBe(90); | |
| 256 | expect(eleventh?.matches).toBe(1); | |
| 257 | expect(standingFor(db, "user:u_0")?.rank).toBe(1); | |
| 258 | expect(standingFor(db, "nobody:home")).toBe(null); | |
| 259 | }); | |
| 260 | ||
| 261 | test("standingFor ties resolve the same way the table's order does: equal kills and damage share a rank", () => { | |
| 262 | const db = openDatabase(dir()); | |
| 263 | expect(saveMatch(db, writer("user:a", "a"), result({ kills: 10, damage: 500 }))).toBe(true); | |
| 264 | expect(saveMatch(db, writer("user:b", "b"), result({ kills: 10, damage: 500 }))).toBe(true); | |
| 265 | // Neither outranks the other — nobody with strictly more kills or damage — so both read rank 1. | |
| 266 | expect(standingFor(db, "user:a")?.rank).toBe(1); | |
| 267 | expect(standingFor(db, "user:b")?.rank).toBe(1); | |
| 268 | // Behind them, damage breaks the tie in kills. | |
| 269 | expect(saveMatch(db, writer("user:c", "c"), result({ kills: 10, damage: 400 }))).toBe(true); | |
| 270 | expect(standingFor(db, "user:c")?.rank).toBe(3); | |
| 271 | }); | |
| 272 | ||
| 246 | 273 | test("a row nobody ever closed reads as abandoned once it goes stale", () => { |
| 247 | 274 | const db = openDatabase(dir()); |
| 248 | 275 | const stale = result({ finished: false, left: false }); |
src/db.ts +19
| @@ -237,6 +237,25 @@ export const standings = (db: Database, limit = 10): Standing[] => | ||
| 237 | 237 | LIMIT ?1`, |
| 238 | 238 | ).all(limit); |
| 239 | 239 | |
| 240 | export type Ranked = Standing & { rank: number }; | |
| 241 | ||
| 242 | /** | |
| 243 | * One identity's own row, wherever it sits — for a visitor off the bottom of | |
| 244 | * `standings`' top `limit`, still their record and their place. The rank is | |
| 245 | * the count of identities `standing` orders ahead of this one under the same | |
| 246 | * `kills DESC, damage DESC` the table itself sorts by, plus one, so a tie | |
| 247 | * with the row above reads the same rank there as it would in the full list. | |
| 248 | * One query, costed by the board's size — never the match table behind it. | |
| 249 | */ | |
| 250 | export function standingFor(db: Database, identity: string): Ranked | null { | |
| 251 | return db.query<Ranked, [string]>( | |
| 252 | `SELECT s.identity, s.name, s.last, s.matches, s.kills, s.deaths, s.headshots, s.damage, s.wins, s.rounds, | |
| 253 | (SELECT COUNT(*) FROM standing o | |
| 254 | WHERE o.kills > s.kills OR (o.kills = s.kills AND o.damage > s.damage)) + 1 AS rank | |
| 255 | FROM standing s WHERE s.identity = ?1`, | |
| 256 | ).get(identity); | |
| 257 | } | |
| 258 | ||
| 240 | 259 | export const matchCount = (db: Database): number => |
| 241 | 260 | db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM match").get()?.n ?? 0; |
| 242 | 261 |
src/pages/game.test.ts +38
| @@ -161,6 +161,44 @@ test("a recent match's row names the map it was played on", () => { | ||
| 161 | 161 | expect(html).toContain("de_forge"); |
| 162 | 162 | }); |
| 163 | 163 | |
| 164 | test("a visitor outside the top ten gets their own row pinned below it, at their true rank", () => { | |
| 165 | const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-"))); | |
| 166 | // Twelve identities, best first: the eleventh (index 10) sits at rank 11. | |
| 167 | for (let i = 0; i < 12; i++) { | |
| 168 | const writer = { identity: `user:u_${i}`, name: `player ${i}`, guest: false, session: `${i}`.repeat(32) }; | |
| 169 | saveMatch(stats, writer, matchResult({ kills: 100 - i, damage: 900 - i })); | |
| 170 | } | |
| 171 | const eleventh = { identity: "user:u_10", name: "player 10", guest: false }; | |
| 172 | const html = statsHtml(stats, eleventh); | |
| 173 | expect(html).toContain("<tfoot>"); | |
| 174 | const tfoot = html.slice(html.indexOf("<tfoot>"), html.indexOf("</tfoot>") + "</tfoot>".length); | |
| 175 | expect(tfoot).toContain("<td>11</td>"); | |
| 176 | expect(tfoot).toContain(">player 10<"); | |
| 177 | expect(tfoot).toContain(`class="you"`); | |
| 178 | // The table itself is still only the top ten, unchanged by the pin below it. | |
| 179 | expect(html.slice(html.indexOf("<tbody>"), html.indexOf("</tbody>"))).not.toContain("player 10"); | |
| 180 | }); | |
| 181 | ||
| 182 | test("a visitor inside the top ten has their own row highlighted in place, not pinned again below", () => { | |
| 183 | const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-"))); | |
| 184 | const writer = { identity: "user:top", name: "topper", guest: false, session: "f".repeat(32) }; | |
| 185 | saveMatch(stats, writer, matchResult({ kills: 50 })); | |
| 186 | const html = statsHtml(stats, { identity: "user:top", name: "topper", guest: false }); | |
| 187 | expect(html).toContain(`<tr class="you">`); | |
| 188 | expect(html).not.toContain("<tfoot>"); | |
| 189 | }); | |
| 190 | ||
| 191 | test("a visitor who has played nothing gets no invented row, only the line saying who they are", () => { | |
| 192 | const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-"))); | |
| 193 | const writer = { identity: "user:someone", name: "someone", guest: false, session: "9".repeat(32) }; | |
| 194 | saveMatch(stats, writer, matchResult()); | |
| 195 | const nobody = { identity: "user:nobody", name: "nobody", guest: true }; | |
| 196 | const html = statsHtml(stats, nobody); | |
| 197 | expect(html).not.toContain(`<tr class="you">`); | |
| 198 | expect(html).not.toContain("<tfoot>"); | |
| 199 | expect(html).toContain("You are playing as <b>nobody</b>"); | |
| 200 | }); | |
| 201 | ||
| 164 | 202 | test("the leaderboard table carries the hook and labels a phone-width card layout needs", () => { |
| 165 | 203 | const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-"))); |
| 166 | 204 | const writer = { identity: "user:u_4", name: "sable", guest: false, session: "e".repeat(32) }; |
src/pages/game.ts +36 −20
| @@ -9,7 +9,7 @@ import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts | ||
| 9 | 9 | import { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "../client/zoom.ts"; |
| 10 | 10 | import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts"; |
| 11 | 11 | import { DEFAULT_MOTION, MOTION_LABELS, MOTIONS } from "../client/motion.ts"; |
| 12 | import { headshotShare, ratio, recentMatches, standings, type MatchRow, type Standing } from "../db.ts"; | |
| 12 | import { headshotShare, ratio, recentMatches, standingFor, standings, type MatchRow, type Standing } from "../db.ts"; | |
| 13 | 13 | import { isGuest, type Player } from "../player.ts"; |
| 14 | 14 | import { FORGE_LINK_ATTRS, signInHref } from "../who.ts"; |
| 15 | 15 | import { CARD } from "../client/weights.ts"; |
| @@ -28,14 +28,19 @@ export function statsHtml(db: Database, player: Player): string { | ||
| 28 | 28 | const table = standings(db, 10); |
| 29 | 29 | const recent = recentMatches(db, 8); |
| 30 | 30 | const tag = (identity: string): string => (isGuest(identity) ? ` <span class="tag">guest</span>` : ""); |
| 31 | const rows = table | |
| 32 | .map( | |
| 33 | (s, i) => `<tr><td>${i + 1}</td><td>${escape(s.name)}${tag(s.identity)}</td>` + | |
| 34 | `<td data-label="matches">${s.matches}</td><td data-label="kills">${s.kills}</td>` + | |
| 35 | `<td data-label="K/D">${ratio(s.kills, s.deaths)}</td><td data-label="HS">${headshotShare(s.headshots, s.kills)}</td>` + | |
| 36 | `<td data-label="wins">${s.wins}</td></tr>`, | |
| 37 | ) | |
| 38 | .join(""); | |
| 31 | const row = (rank: number, s: Standing, mine: boolean): string => | |
| 32 | `<tr${mine ? ` class="you"` : ""}><td>${rank}</td><td>${escape(s.name)}${tag(s.identity)}</td>` + | |
| 33 | `<td data-label="matches">${s.matches}</td><td data-label="kills">${s.kills}</td>` + | |
| 34 | `<td data-label="K/D">${ratio(s.kills, s.deaths)}</td><td data-label="HS">${headshotShare(s.headshots, s.kills)}</td>` + | |
| 35 | `<td data-label="wins">${s.wins}</td></tr>`; | |
| 36 | const inTable = table.some((s) => s.identity === player.identity); | |
| 37 | // Nowhere in the top ten: their own row, wherever it really sits — pinned | |
| 38 | // below the table instead of invented or left off it. A visitor who has | |
| 39 | // never finished a match has no row in `standing` either, so this stays | |
| 40 | // null and the table renders exactly as it did before this row existed. | |
| 41 | const you = inTable ? null : standingFor(db, player.identity); | |
| 42 | const rows = table.map((s, i) => row(i + 1, s, s.identity === player.identity)).join(""); | |
| 43 | const pinned = you === null ? "" : `<tfoot>${row(you.rank, you, true)}</tfoot>`; | |
| 39 | 44 | const status = (m: MatchRow): string => |
| 40 | 45 | m.state === "finished" ? "" : m.state === "abandoned" ? " · abandoned" : " · in progress"; |
| 41 | 46 | const feed = recent |
| @@ -52,7 +57,7 @@ export function statsHtml(db: Database, player: Player): string { | ||
| 52 | 57 | : `Signed in as <b>${escape(player.name)}</b> — your matches land under that name.`}</p> |
| 53 | 58 | ${table.length === 0 |
| 54 | 59 | ? `<p class="empty">No matches yet. Play one and it lands here.</p>` |
| 55 | : `<table class="board"><thead><tr><th>#</th><th>player</th><th>matches</th><th>kills</th><th>K/D</th><th>HS</th><th>wins</th></tr></thead><tbody>${rows}</tbody></table>`} | |
| 60 | : `<table class="board"><thead><tr><th>#</th><th>player</th><th>matches</th><th>kills</th><th>K/D</th><th>HS</th><th>wins</th></tr></thead><tbody>${rows}</tbody>${pinned}</table>`} | |
| 56 | 61 | <h3>Recent matches</h3> |
| 57 | 62 | <ul class="recent">${feed || `<li class="empty">nothing yet</li>`}</ul>`; |
| 58 | 63 | } |
| @@ -60,22 +65,28 @@ ${table.length === 0 | ||
| 60 | 65 | export type LeaderboardJson = { |
| 61 | 66 | standings: (Omit<Standing, "identity"> & { guest: boolean })[]; |
| 62 | 67 | recent: (Omit<MatchRow, "session" | "identity" | "walked_out"> & { guest: boolean })[]; |
| 68 | you: (Omit<Standing, "identity"> & { guest: boolean; rank: number }) | null; | |
| 63 | 69 | }; |
| 64 | 70 | |
| 71 | function withoutIdentity<T extends { identity: string }>(row: T): Omit<T, "identity"> & { guest: boolean } { | |
| 72 | const { identity, ...rest } = row; | |
| 73 | return { ...rest, guest: isGuest(identity) }; | |
| 74 | } | |
| 75 | ||
| 65 | 76 | /** |
| 66 | 77 | * The same leaderboard as statsHtml, as data: what GET /api/leaderboard |
| 67 | 78 | * answers. The session never travels, and neither does the identity that |
| 68 | 79 | * holds it — a guest's is `guest:<session>`, which is the cookie that owns |
| 69 | 80 | * their matches. What a reader needs from it is the one bit the page shows as |
| 70 | * a tag, so that is what goes out. | |
| 81 | * a tag, so that is what goes out — `you` included, null for a visitor with | |
| 82 | * no row in `standing` yet. | |
| 71 | 83 | */ |
| 72 | export function leaderboardJson(db: Database): LeaderboardJson { | |
| 84 | export function leaderboardJson(db: Database, player: Player): LeaderboardJson { | |
| 85 | const mine = standingFor(db, player.identity); | |
| 73 | 86 | return { |
| 74 | standings: standings(db, 10).map(({ identity, ...standing }) => ({ ...standing, guest: isGuest(identity) })), | |
| 75 | recent: recentMatches(db, 8).map(({ session: _session, identity, walked_out: _walkedOut, ...match }) => ({ | |
| 76 | ...match, | |
| 77 | guest: isGuest(identity), | |
| 78 | })), | |
| 87 | standings: standings(db, 10).map(withoutIdentity), | |
| 88 | recent: recentMatches(db, 8).map(({ session: _session, walked_out: _walkedOut, ...match }) => withoutIdentity(match)), | |
| 89 | you: mine === null ? null : withoutIdentity(mine), | |
| 79 | 90 | }; |
| 80 | 91 | } |
| 81 | 92 | |
| @@ -248,11 +259,16 @@ main section { margin:1.5rem 0; } | ||
| 248 | 259 | table { width:100%; border-collapse:collapse; font-size:.9rem; } |
| 249 | 260 | th, td { text-align:left; padding:.35rem .5rem; border-bottom:1px solid var(--line); } |
| 250 | 261 | th { color:var(--dim); font-weight:500; } |
| 262 | /* The visitor's own row: highlighted where it already sits in the top ten, | |
| 263 | or — table.board tfoot — pinned below with a double border standing in | |
| 264 | for the rows between it and the table above, wherever its rank really is. */ | |
| 265 | table.board tr.you td { background:#1b2432; } | |
| 266 | table.board tfoot td { border-top:3px double var(--line); border-bottom:none; } | |
| 251 | 267 | ul.recent { list-style:none; padding:0; margin:0; font-size:.86rem; } |
| 252 | 268 | ul.recent li { padding:.35rem 0; border-bottom:1px solid var(--line); display:flex; gap:.5rem; flex-wrap:wrap; } |
| 253 | ul.recent span, ul.recent time, .empty, .you { color:var(--dim); } | |
| 254 | .you { font-size:.86rem; margin:.2rem 0 .6rem; } | |
| 255 | .you b { color:var(--text); } | |
| 269 | ul.recent span, ul.recent time, .empty, p.you { color:var(--dim); } | |
| 270 | p.you { font-size:.86rem; margin:.2rem 0 .6rem; } | |
| 271 | p.you b { color:var(--text); } | |
| 256 | 272 | .tag { font-size:.7rem; color:var(--dim); border:1px solid var(--line); border-radius:999px; padding:0 .35rem; font-weight:400; } |
| 257 | 273 | kbd { background:#1b2230; border:1px solid var(--line); border-radius:.25rem; padding:0 .3rem; font-size:.82rem; } |
| 258 | 274 | footer { color:var(--dim); font-size:.8rem; padding:1rem; text-align:center; } |
src/server.test.ts +60
| @@ -141,6 +141,11 @@ test("the first paint is kept out of shared caches; the bundle is kept in them", | ||
| 141 | 141 | expect(script.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); |
| 142 | 142 | }); |
| 143 | 143 | |
| 144 | test("the leaderboard JSON is per-visitor too, so it stays out of shared caches", async () => { | |
| 145 | const response = await fetch(base + "/api/leaderboard"); | |
| 146 | expect(response.headers.get("cache-control")).toContain("private"); | |
| 147 | }); | |
| 148 | ||
| 144 | 149 | test("a result is stored and comes back as the leaderboard", async () => { |
| 145 | 150 | const response = await post(result(), "", forge("u_1", "Vesper")); |
| 146 | 151 | expect(response.status).toBe(200); |
| @@ -371,6 +376,61 @@ test("the leaderboard JSON hands out no session, in a cookie's clothing or other | ||
| 371 | 376 | } |
| 372 | 377 | }); |
| 373 | 378 | |
| 379 | test("your own row: highlighted in place inside the top ten, or pinned below it at your true rank — in the HTML and the JSON alike", async () => { | |
| 380 | // Every match a browser can report is capped at 150 kills (30 rounds of | |
| 381 | // 5), so one identity's true total is the sum across several matches — the | |
| 382 | // same sum `standing` keeps. Seeded well above what any other test in this | |
| 383 | // file can accumulate under the 30-posts-a-minute gate (9 kills a post, so | |
| 384 | // at most 270), twelve such identities — best first — keep their order | |
| 385 | // safe from whatever else this shared server and database have already | |
| 386 | // recorded: the eleventh (index 10) sits at rank 11, outranked by exactly | |
| 387 | // the ten ahead of it and nothing else in the table. | |
| 388 | let firstHtml = ""; | |
| 389 | let eleventhHtml = ""; | |
| 390 | for (let i = 0; i < 12; i += 1) { | |
| 391 | const who = forge(`u_rank${i}`, `ranker${i}`); | |
| 392 | let last: Response | undefined; | |
| 393 | for (const k of [150, 150, 150 - i]) { | |
| 394 | last = await post( | |
| 395 | result({ | |
| 396 | matchId: crypto.randomUUID(), rounds: 30, scoreMine: 30, scoreTheirs: 0, | |
| 397 | kills: k, deaths: 5, headshots: Math.min(20, k), damage: 9000, seconds: 3600, | |
| 398 | }), | |
| 399 | "", | |
| 400 | who, | |
| 401 | ); | |
| 402 | expect(last.status).toBe(200); | |
| 403 | } | |
| 404 | const text = await last?.text(); | |
| 405 | if (i === 0) firstHtml = text ?? ""; | |
| 406 | if (i === 10) eleventhHtml = text ?? ""; | |
| 407 | } | |
| 408 | expect(firstHtml).toContain(`<tr class="you">`); | |
| 409 | expect(firstHtml).not.toContain("<tfoot>"); | |
| 410 | expect(eleventhHtml).toContain("<tfoot>"); | |
| 411 | expect(eleventhHtml).toContain("<td>11</td>"); | |
| 412 | expect(eleventhHtml).toContain(">ranker10<"); | |
| 413 | ||
| 414 | // The same story from the JSON endpoint, for the same eleventh identity: | |
| 415 | // its rank, its own numbers, and only the guest flag — never the identity. | |
| 416 | const response = await fetch(base + "/api/leaderboard", { headers: forge("u_rank10", "ranker10") }); | |
| 417 | const body = (await response.json()) as { you: { name: string; rank: number; kills: number; guest: boolean } | null }; | |
| 418 | expect(body.you).not.toBeNull(); | |
| 419 | expect(body.you?.rank).toBe(11); | |
| 420 | expect(body.you?.name).toBe("ranker10"); | |
| 421 | expect(body.you?.kills).toBe(440); | |
| 422 | expect(body.you?.guest).toBe(false); | |
| 423 | const text = JSON.stringify(body); | |
| 424 | expect(text).not.toContain("guest:"); | |
| 425 | expect(text).not.toContain("u_rank10"); | |
| 426 | }); | |
| 427 | ||
| 428 | test("the leaderboard JSON answers you: null for a visitor who has played nothing", async () => { | |
| 429 | const response = await fetch(base + "/api/leaderboard", { headers: forge("u_never_played", "ghost") }); | |
| 430 | const body = (await response.json()) as { you: unknown }; | |
| 431 | expect(body.you).toBeNull(); | |
| 432 | }); | |
| 433 | ||
| 374 | 434 | test("a result parses to exactly what was sent, and nothing else", () => { |
| 375 | 435 | const parsed = parseResult({ ...result(), extra: "ignored", name: "nobody" }); |
| 376 | 436 | expect(parsed?.kills).toBe(9); |
src/server.ts +13 −4
| @@ -224,10 +224,19 @@ export async function serve(): Promise<ReturnType<typeof Bun.serve>> { | ||
| 224 | 224 | // The same leaderboard as the page's first paint, as data: what a |
| 225 | 225 | // terminal or another client reads instead of scraping the HTML. |
| 226 | 226 | if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/api/leaderboard") { |
| 227 | const headers = { "content-type": "application/json; charset=utf-8", ...SECURITY }; | |
| 228 | return request.method === "HEAD" | |
| 229 | ? new Response(null, { headers }) | |
| 230 | : new Response(JSON.stringify(leaderboardJson(db)), { headers }); | |
| 227 | const headers = { | |
| 228 | "content-type": "application/json; charset=utf-8", | |
| 229 | // Per-visitor (a `you` row worked out from sign-in and session): | |
outdated
x |
||
| 230 | // kept out of shared caches, same reason as the `/` page below. | |
| 231 | "cache-control": "private, no-cache", | |
| 232 | ...SECURITY, | |
| 233 | }; | |
| 234 | if (request.method === "HEAD") return new Response(null, { headers }); | |
| 235 | // No cookie is set here: a read never mints a session. A visitor | |
| 236 | // with none yet has played nothing under any identity this could | |
| 237 | // find, so `you` comes back null exactly as it should. | |
| 238 | const player = credit(whoIs(request.headers), readSession(request) ?? ""); | |
| 239 | return new Response(JSON.stringify(leaderboardJson(db, player)), { headers }); | |
| 231 | 240 | } |
| 232 | 241 | |
| 233 | 242 | // HEAD is GET without the body: a health check sees the app up. |
Changes requested — one thing to fix first, plus one to check before the next round. (Ignore the stray "x" comment on line 229: it was a slip while I was testing the review API, and the forge has no way to delete it.)
Fix first (high: per-user data leaking through shared caches) — this line. Until now
/api/leaderboardreturned the same response to every visitor. With this change it is personal:youis worked out from the signed forge identity and thecs_sessioncookie. But its headers are still onlycontent-type+SECURITY, with nocache-control. The/route a few lines below sends"cache-control": "private, no-cache"for exactly this reason ("Per-visitor … kept out of shared caches"). The preview sits behind Cloudflare, so any shared cache between the browser and the app could store one visitor'syou(their name, rank and totals) and serve it to the next visitor. Add"cache-control": "private, no-cache"to these headers, which covers both GET and HEAD. Then add a server test that checksGET /api/leaderboardreturns acache-controlcontainingprivate, like the existing check for the/page. Without the header, that test fails.Check: the preview does not answer. The preview's
/and/api/leaderboardboth return404 not foundat this head, so I could not see the pinned row working. Push the fix and confirm that/loads and that/api/leaderboardincludes ayoufield.Low, no fix needed now: the table numbers its rows by position (
i + 1), whilestandingForgives tied players the same rank. A visitor tied on kills and damage with row 10 but left out of the top ten gets a pinned row reading10under a table row that also reads10. It's defensible, but one sentence in the README saying ties share a rank would make it read as intended rather than as a bug.Everything else looks right. Identity comes only from
whoIsandcredit, and the JSON dropsidentityandsessionin favour of theguestflag. A read without a cookie mints no session and resolves toguest:, which matches no row. The SQL uses bound parameters and the names are escaped. The query is one indexed count. The README explains why the terminal has no visitor row. The tests cover the in-top-ten, off-the-board and never-played cases on the page and in the JSON.