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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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`.
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user