Merge pull request #1 from Coffey-Labs/board-moves-out

Move the board out to the service every game shares
This commit is contained in:
Coffey Labs
2026-09-08 22:34:08 -07:00
committed by GitHub
9 changed files with 33 additions and 399 deletions
+15 -6
View File
@@ -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 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 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 The board is no longer in this repository and is no longer ours alone: every
game that needs a line out. With the service unreachable the game plays exactly game on the site shares one service,
as normal and the board says so plainly rather than breaking; posting happens [games-scores](https://github.com/Coffey-Labs/games-scores), which is one
behind the closing standings, so a slow network never holds up the end of a container and one volume however many games there are. It started here, because
season. See [server/README.md](server/README.md) for the API, the validation when it was written there was one game; a second game would have meant a second
and what a board with no accounts can and cannot promise. 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 ## How it is put together
+1 -3
View File
@@ -7,9 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint",
"preview": "vite preview", "preview": "vite preview"
"scores": "node --watch server/src/index.ts",
"dev:all": "node scripts/dev-all.mjs"
}, },
"dependencies": { "dependencies": {
"react": "^19.2.8", "react": "^19.2.8",
-17
View File
@@ -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)
-22
View File
@@ -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"]
-72
View File
@@ -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 14 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`.
-188
View File
@@ -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<string, Bucket>()
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<string> {
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<string, unknown>[]).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)
})
})
}
-86
View File
@@ -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<string, unknown>
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 }
}
+13 -2
View File
@@ -3,8 +3,16 @@
* service rather than the browser. Nothing about it is trusted on the way in: * 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 * the server decides what is plausible, and the client re-checks the shape of
* whatever comes back before putting it on screen. * 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 BASE = (import.meta.env.VITE_SCORES_API ?? '/api').replace(/\/+$/, '')
const TIMEOUT_MS = 8000 const TIMEOUT_MS = 8000
@@ -62,14 +70,17 @@ async function call(path: string, init?: RequestInit): Promise<unknown> {
} }
export async function fetchScores(): Promise<Score[]> { export async function fetchScores(): Promise<Score[]> {
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. */ /** Posts a whole table's worth of players at once and returns the new board. */
export async function submitScores( export async function submitScores(
entries: NewScore[], entries: NewScore[],
): Promise<{ ids: string[]; scores: Score[] }> { ): 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 const ids = (body as { ids?: unknown })?.ids
return { return {
ids: Array.isArray(ids) ? ids.filter((i): i is string => typeof i === 'string') : [], ids: Array.isArray(ids) ? ids.filter((i): i is string => typeof i === 'string') : [],
+4 -3
View File
@@ -8,9 +8,10 @@ export default defineConfig({
base: process.env.BASE_PATH ?? '/', base: process.env.BASE_PATH ?? '/',
plugins: [react()], plugins: [react()],
server: { server: {
// The game talks to /api in every environment; in development that is the // The game talks to /api in every environment; in production that is
// scores service on 5184, in production it is nginx in front of the // nginx in front of the shared scores container. In development, run
// container. Nothing in the client needs to know the difference. // https://github.com/Coffey-Labs/games-scores alongside this -- or do not,
// and the board will say so rather than breaking.
proxy: { proxy: {
'/api': { '/api': {
target: process.env.SCORES_TARGET ?? 'http://localhost:5184', target: process.env.SCORES_TARGET ?? 'http://localhost:5184',