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.
This commit is contained in:
2026-09-08 22:33:51 -07:00
parent 87474ad5ec
commit 931c2dc299
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
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
+1 -3
View File
@@ -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",
-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:
* 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<unknown> {
}
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. */
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') : [],
+4 -3
View File
@@ -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',