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',