From 931c2dc2990450243e3d4aa5ed15103f62441bb9 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 8 Sep 2026 22:33:51 -0700 Subject: [PATCH] Move the board out to the service every game shares The leaderboard started in this repository because when it was written there was one game. It was already being treated as shared -- games.jcoffey.dev calls it "the shared leaderboard" in its compose file and nginx proxies it at a site-wide /api/ rather than under /lemonade/ -- but it was still this game's code, and the second game would have meant a second container, a second volume and a second database to back up for every game after that. It lives in https://github.com/Coffey-Labs/games-scores now, which is its own repository rather than either game's. A shared board belongs to no game, and leaving it here would have made every future game depend on the lemonade stand for its leaderboard. What changes here is smaller than the diff suggests. server/ goes, and with it scripts/dev-all.mjs and two package scripts. The client names the game on every call and is otherwise untouched: rows still come back as assets, days, glasses and broke, because the shared service hands each game its own field names back. Nothing about the deployed board moves. The volume keeps its name, the rows are migrated in place on first boot, and the original table is kept alongside as scores_lemonade_v1 rather than dropped -- it is the only copy of a board real people are on. Bundles already in browsers keep working too: a post with no game at all is treated as this one, deliberately, until this build has been out long enough that nobody is running the old one. Build clean. --- README.md | 21 +++-- package.json | 4 +- scripts/dev-all.mjs | 17 ---- server/Dockerfile | 22 ----- server/README.md | 72 ---------------- server/src/index.ts | 188 ----------------------------------------- server/src/validate.ts | 86 ------------------- src/game/highscores.ts | 15 +++- vite.config.ts | 7 +- 9 files changed, 33 insertions(+), 399 deletions(-) delete mode 100644 scripts/dev-all.mjs delete mode 100644 server/Dockerfile delete mode 100644 server/README.md delete mode 100644 server/src/index.ts delete mode 100644 server/src/validate.ts diff --git a/README.md b/README.md index 04a1367..44abaf1 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,21 @@ finished are picked out in yellow. There is no way to clear it. A stand leaves the list only by being pushed off the bottom by a better one, so a good summer stands until somebody beats it. -The board lives in `server/`, not in the browser, so it is the one part of the -game that needs a line out. With the service unreachable the game plays exactly -as normal and the board says so plainly rather than breaking; posting happens -behind the closing standings, so a slow network never holds up the end of a -season. See [server/README.md](server/README.md) for the API, the validation -and what a board with no accounts can and cannot promise. +The board is no longer in this repository and is no longer ours alone: every +game on the site shares one service, +[games-scores](https://github.com/Coffey-Labs/games-scores), which is one +container and one volume however many games there are. It started here, because +when it was written there was one game; a second game would have meant a second +container and a second database to back up for every game after that. All this +repository holds now is the client in `src/game/highscores.ts`, which names the +game on every call and is otherwise what it always was — rows come back in this +game's own field names. + +It is still the one part of the game that needs a line out. With the service +unreachable the game plays exactly as normal and the board says so plainly +rather than breaking; posting happens behind the closing standings, so a slow +network never holds up the end of a season. That repository's README has the +API, the validation and what a board with no accounts can and cannot promise. ## How it is put together diff --git a/package.json b/package.json index 6c7da53..19101f8 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint", - "preview": "vite preview", - "scores": "node --watch server/src/index.ts", - "dev:all": "node scripts/dev-all.mjs" + "preview": "vite preview" }, "dependencies": { "react": "^19.2.8", diff --git a/scripts/dev-all.mjs b/scripts/dev-all.mjs deleted file mode 100644 index 1cf211c..0000000 --- a/scripts/dev-all.mjs +++ /dev/null @@ -1,17 +0,0 @@ -// Runs the game and the scores service together, so `npm run dev:all` is all -// you need for a working leaderboard on localhost. -import { spawn } from 'node:child_process' - -const children = [ - spawn('npm', ['run', 'scores'], { stdio: 'inherit', shell: false }), - spawn('npm', ['run', 'dev'], { stdio: 'inherit', shell: false }), -] - -const stop = () => { - for (const c of children) c.kill('SIGTERM') - process.exit(0) -} - -process.on('SIGINT', stop) -process.on('SIGTERM', stop) -for (const c of children) c.on('exit', stop) diff --git a/server/Dockerfile b/server/Dockerfile deleted file mode 100644 index d37e20a..0000000 --- a/server/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -# node:sqlite is built into the runtime and the server runs straight from -# TypeScript, so there is no build stage, no compiler and no native module. -FROM node:26-alpine - -ENV NODE_ENV=production \ - PORT=5184 \ - DB_PATH=/data/scores.db \ - TRUST_PROXY=1 - -WORKDIR /app -COPY src ./src - -# The database is the only writable path; everything else can stay read-only. -RUN mkdir -p /data && chown -R node:node /data -USER node -VOLUME ["/data"] -EXPOSE 5184 - -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD node -e "fetch('http://127.0.0.1:5184/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" - -CMD ["node", "src/index.ts"] diff --git a/server/README.md b/server/README.md deleted file mode 100644 index bc6e6c2..0000000 --- a/server/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# Lemonade scores service - -The leaderboard everybody shares. A single Node process with SQLite, no build -step and no native modules: `node:sqlite` ships with the runtime and Node runs -the TypeScript directly. - -## API - -| Method | Path | Purpose | -| ------ | -------------- | ------------------------------------------- | -| GET | `/api/health` | liveness, used by the container healthcheck | -| GET | `/api/scores` | the top 50, best first | -| POST | `/api/scores` | submit 1–4 players from one finished season | - -```jsonc -// POST /api/scores -{ "entries": [ { "name": "ADA", "assets": 4635, "days": 12, - "glasses": 800, "seed": 1234, "broke": false } ] } -// 201 -> { "ids": ["17"], "scores": [ ...the new board... ] } -``` - -## What it does and does not guarantee - -Everything in a request is treated as hostile. Names are forced to a printable -uppercase subset and cut to 12 characters. Every number must be an integer in -range, and a score is refused if it could not have happened: assets above -`$2.00 + $25 a day`, or glasses above 400 a day, are rejected as impossible for -the days claimed. Bodies are capped at 4 KB and submissions at 120 an hour per -address, which is why `TRUST_PROXY=1` matters behind nginx — otherwise every -request looks like it came from the proxy and one busy classroom would lock -everyone else out. - -**It cannot prove a score is real.** There are no accounts and no signing, so -anyone willing to craft a request can post a plausible score under any name. -The checks stop nonsense and casual spam, not a determined forger. That is a -deliberate trade for a game with no sign-up; if the board ever needs to be -trustworthy it needs identities, which is a different piece of work. - -Only the best 500 rows are kept; the rest are pruned on write. - -## Running it - -```bash -npm run scores # from the repo root, on :5184 -npm run dev:all # game and scores service together -``` - -Environment: `PORT` (5184), `DB_PATH` (`./data/scores.db`), `TRUST_PROXY` -(`1` behind a proxy). - -## Deploying - -```bash -docker compose -f server/compose.yml up -d --build -``` - -The container is read-only apart from the named volume holding the database — -that volume is the one piece of state that must survive a redeploy. In front -of it, nginx terminates TLS and proxies `/api/` through: - -```nginx -location /api/ { - proxy_pass http://127.0.0.1:5184; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; -} -``` - -The game itself is a static bundle and needs no server; point it at another -origin with `VITE_SCORES_API=https://example.org/api` at build time, or serve -both from one origin and leave it on the default `/api`. diff --git a/server/src/index.ts b/server/src/index.ts deleted file mode 100644 index 1673f0a..0000000 --- a/server/src/index.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' -import { DatabaseSync } from 'node:sqlite' -import { mkdirSync } from 'node:fs' -import { dirname } from 'node:path' -import { MAX_ENTRIES_PER_POST, validateBatch } from './validate.ts' - -const PORT = Number(process.env.PORT ?? 5184) -const DB_PATH = process.env.DB_PATH ?? './data/scores.db' -/** Behind nginx the socket address is the proxy, so take the forwarded hop. */ -const TRUST_PROXY = process.env.TRUST_PROXY === '1' -const BOARD_LIMIT = 50 -/** Rows kept on disk. The board only ever shows the top of this. */ -const KEEP_ROWS = 500 -const MAX_BODY_BYTES = 4096 - -mkdirSync(dirname(DB_PATH), { recursive: true }) -const db = new DatabaseSync(DB_PATH) -db.exec(` - PRAGMA journal_mode = WAL; - CREATE TABLE IF NOT EXISTS scores ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - assets INTEGER NOT NULL, - days INTEGER NOT NULL, - glasses INTEGER NOT NULL, - seed INTEGER NOT NULL, - broke INTEGER NOT NULL, - created_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS scores_rank - ON scores (assets DESC, days ASC, created_at ASC); -`) - -const selectTop = db.prepare( - `SELECT id, name, assets, days, glasses, broke, created_at - FROM scores ORDER BY assets DESC, days ASC, created_at ASC LIMIT ?`, -) -const insertScore = db.prepare( - `INSERT INTO scores (name, assets, days, glasses, seed, broke, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, -) -const prune = db.prepare( - `DELETE FROM scores WHERE id NOT IN ( - SELECT id FROM scores ORDER BY assets DESC, days ASC, created_at ASC LIMIT ? - )`, -) - -// ---------------------------------------------------------------- throttle - -interface Bucket { - count: number - resetAt: number -} -const posts = new Map() -const POST_WINDOW_MS = 60 * 60 * 1000 -/* - * Per address, per hour. Generous on purpose: a classroom, an office or a - * household all arrive from one address, and thirty was low enough that a - * class finishing a season together would have started losing scores to a - * 429. What actually keeps rubbish off the board is the plausibility check - * in validate.ts, not this - this only stops the database being hammered. - */ -const POST_LIMIT = 120 - -function overPostLimit(ip: string): boolean { - const now = Date.now() - const b = posts.get(ip) - if (!b || now > b.resetAt) { - posts.set(ip, { count: 1, resetAt: now + POST_WINDOW_MS }) - return false - } - b.count += 1 - return b.count > POST_LIMIT -} - -// Buckets are tiny, but a long-lived process should still not hoard them. -setInterval(() => { - const now = Date.now() - for (const [ip, b] of posts) if (now > b.resetAt) posts.delete(ip) -}, POST_WINDOW_MS).unref() - -function clientIp(req: IncomingMessage): string { - if (TRUST_PROXY) { - const fwd = req.headers['x-forwarded-for'] - const first = (Array.isArray(fwd) ? fwd[0] : fwd)?.split(',')[0]?.trim() - if (first) return first - } - return req.socket.remoteAddress ?? 'unknown' -} - -// ------------------------------------------------------------------ replies - -function send(res: ServerResponse, status: number, body: unknown) { - const payload = JSON.stringify(body) - res.writeHead(status, { - 'content-type': 'application/json; charset=utf-8', - 'cache-control': 'no-store', - // A public board with no cookies and no credentials. - 'access-control-allow-origin': '*', - 'access-control-allow-methods': 'GET, POST, OPTIONS', - 'access-control-allow-headers': 'content-type', - 'access-control-max-age': '86400', - }) - res.end(payload) -} - -function readBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let size = 0 - const chunks: Buffer[] = [] - req.on('data', (c: Buffer) => { - size += c.length - if (size > MAX_BODY_BYTES) { - reject(new Error('body too large')) - req.destroy() - return - } - chunks.push(c) - }) - req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) - req.on('error', reject) - }) -} - -// ------------------------------------------------------------------- routes - -const board = () => - (selectTop.all(BOARD_LIMIT) as Record[]).map((r) => ({ - id: String(r.id), - name: r.name as string, - assets: r.assets as number, - days: r.days as number, - glasses: r.glasses as number, - broke: r.broke === 1, - at: r.created_at as number, - })) - -const server = createServer(async (req, res) => { - const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`) - const path = url.pathname.replace(/\/+$/, '') || '/' - - if (req.method === 'OPTIONS') return send(res, 204, {}) - if (path === '/api/health') return send(res, 200, { ok: true }) - - if (path === '/api/scores' && req.method === 'GET') { - return send(res, 200, { scores: board() }) - } - - if (path === '/api/scores' && req.method === 'POST') { - const ip = clientIp(req) - if (overPostLimit(ip)) return send(res, 429, { error: 'too many submissions' }) - - let parsed: unknown - try { - parsed = JSON.parse(await readBody(req)) - } catch { - return send(res, 400, { error: 'invalid JSON' }) - } - - const check = validateBatch(parsed) - if (!check.ok) return send(res, 400, { error: check.why }) - - const now = Date.now() - const ids: string[] = [] - for (const e of check.entries) { - const info = insertScore.run(e.name, e.assets, e.days, e.glasses, e.seed, e.broke ? 1 : 0, now) - ids.push(String(info.lastInsertRowid)) - } - prune.run(KEEP_ROWS) - - return send(res, 201, { ids, scores: board() }) - } - - send(res, 404, { error: 'not found' }) -}) - -server.listen(PORT, () => { - console.log(`[scores] listening on :${PORT}, db ${DB_PATH}, max ${MAX_ENTRIES_PER_POST}/post`) -}) - -for (const sig of ['SIGINT', 'SIGTERM'] as const) { - process.on(sig, () => { - server.close(() => { - db.close() - process.exit(0) - }) - }) -} diff --git a/server/src/validate.ts b/server/src/validate.ts deleted file mode 100644 index 0b4c4c7..0000000 --- a/server/src/validate.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Everything here treats the request body as hostile. A public leaderboard - * with no accounts cannot prove a score is real - what it can do is refuse - * anything impossible, keep names printable, and stop one client filling the - * table on its own. - */ - -export interface Entry { - name: string - assets: number - days: number - glasses: number - seed: number - broke: boolean -} - -export const MAX_ENTRIES_PER_POST = 4 -export const NAME_MAX = 12 - -/** The simulation's best possible day is about $12.50; $25 leaves headroom. */ -const MAX_CENTS_PER_DAY = 2500 -const STARTING_ASSETS = 200 -const MAX_GLASSES_PER_DAY = 400 -const MAX_DAYS = 365 - -const isInt = (v: unknown, lo: number, hi: number): v is number => - typeof v === 'number' && Number.isInteger(v) && v >= lo && v <= hi - -/** Printable, uppercase, and short enough to fit the board. */ -export function cleanName(raw: unknown): string { - if (typeof raw !== 'string') return 'ANON' - const cleaned = raw - .toUpperCase() - .replace(/[^A-Z0-9 .'-]/g, '') - .replace(/\s+/g, ' ') - .trim() - .slice(0, NAME_MAX) - return cleaned || 'ANON' -} - -export type Validated = { ok: true; entry: Entry } | { ok: false; why: string } - -export function validateEntry(raw: unknown): Validated { - if (typeof raw !== 'object' || raw === null) return { ok: false, why: 'not an object' } - const e = raw as Record - - if (!isInt(e.days, 1, MAX_DAYS)) return { ok: false, why: 'days out of range' } - if (!isInt(e.assets, 0, 10_000_00)) return { ok: false, why: 'assets out of range' } - if (!isInt(e.glasses, 0, MAX_DAYS * MAX_GLASSES_PER_DAY)) - return { ok: false, why: 'glasses out of range' } - if (!isInt(e.seed, 0, 9_999_999)) return { ok: false, why: 'seed out of range' } - if (typeof e.broke !== 'boolean') return { ok: false, why: 'broke must be a boolean' } - - // No stand can earn more than the simulation allows in the days it traded. - if (e.assets > STARTING_ASSETS + e.days * MAX_CENTS_PER_DAY) - return { ok: false, why: 'assets impossible for that many days' } - if (e.glasses > e.days * MAX_GLASSES_PER_DAY) - return { ok: false, why: 'glasses impossible for that many days' } - - return { - ok: true, - entry: { - name: cleanName(e.name), - assets: e.assets, - days: e.days, - glasses: e.glasses, - seed: e.seed, - broke: e.broke, - }, - } -} - -export function validateBatch(raw: unknown): { ok: true; entries: Entry[] } | { ok: false; why: string } { - const list = Array.isArray(raw) ? raw : (raw as { entries?: unknown })?.entries - if (!Array.isArray(list)) return { ok: false, why: 'expected an array of entries' } - if (list.length === 0) return { ok: false, why: 'no entries' } - if (list.length > MAX_ENTRIES_PER_POST) return { ok: false, why: 'too many entries' } - - const entries: Entry[] = [] - for (const item of list) { - const v = validateEntry(item) - if (!v.ok) return v - entries.push(v.entry) - } - return { ok: true, entries } -} diff --git a/src/game/highscores.ts b/src/game/highscores.ts index ee245ef..2770717 100644 --- a/src/game/highscores.ts +++ b/src/game/highscores.ts @@ -3,8 +3,16 @@ * service rather than the browser. Nothing about it is trusted on the way in: * the server decides what is plausible, and the client re-checks the shape of * whatever comes back before putting it on screen. + * + * The service is no longer ours. It is shared with the other games on the site + * -- see https://github.com/Coffey-Labs/games-scores -- which is why every + * call names the game. Nothing else about it leaks in here: rows come back in + * this game's own field names, so this file is otherwise what it always was. */ +/** Which board on the shared service is ours. */ +export const GAME = 'lemonade' + const BASE = (import.meta.env.VITE_SCORES_API ?? '/api').replace(/\/+$/, '') const TIMEOUT_MS = 8000 @@ -62,14 +70,17 @@ async function call(path: string, init?: RequestInit): Promise { } export async function fetchScores(): Promise { - return parseBoard(await call('/scores')) + return parseBoard(await call(`/scores?game=${GAME}`)) } /** Posts a whole table's worth of players at once and returns the new board. */ export async function submitScores( entries: NewScore[], ): Promise<{ ids: string[]; scores: Score[] }> { - const body = await call('/scores', { method: 'POST', body: JSON.stringify({ entries }) }) + const body = await call('/scores', { + method: 'POST', + body: JSON.stringify({ game: GAME, entries }), + }) const ids = (body as { ids?: unknown })?.ids return { ids: Array.isArray(ids) ? ids.filter((i): i is string => typeof i === 'string') : [], diff --git a/vite.config.ts b/vite.config.ts index 9f3cfc6..e93d6a5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,9 +8,10 @@ export default defineConfig({ base: process.env.BASE_PATH ?? '/', plugins: [react()], server: { - // The game talks to /api in every environment; in development that is the - // scores service on 5184, in production it is nginx in front of the - // container. Nothing in the client needs to know the difference. + // The game talks to /api in every environment; in production that is + // nginx in front of the shared scores container. In development, run + // https://github.com/Coffey-Labs/games-scores alongside this -- or do not, + // and the board will say so rather than breaking. proxy: { '/api': { target: process.env.SCORES_TARGET ?? 'http://localhost:5184',