feat(api): anchor board identity on durable hwId + persist the registry

The board registry keyed on kitId, so a board that changed IP, was
re-provisioned under a new label, or came up on a wiped laptop looked
like a brand-new board — breaking claim resume and auto-heal.

Key the registry on hwId (WiFi MAC + eMMC serial, survives OS reflash;
machine-id does not). kitId is now a display label only. announce()
takes hwId and upserts by it, preserving claimedBy across IP/label
changes. /nodes/self-register accepts hwId (falls back to kitId for
older nodes). Persist boards in a new sqlite `boards` table keyed by
hwId and seed the in-memory registry from it on boot, so recognition
survives an API restart.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-21 23:08:50 -07:00
co-authored by Claude Opus 4.8
parent d71899520e
commit 60416cb8c7
7 changed files with 161 additions and 27 deletions
+4 -1
View File
@@ -164,7 +164,10 @@ export function createApp(opts: AppOptions): Express {
if (![b.kitId, b.claimCode].every((v) => typeof v === 'string' && v) || !url) { if (![b.kitId, b.claimCode].every((v) => typeof v === 'string' && v) || !url) {
return res.status(400).json({ error: 'kitId and claimCode are required (url derived from source IP if omitted)' }) return res.status(400).json({ error: 'kitId and claimCode are required (url derived from source IP if omitted)' })
} }
const r = boards.announce({ kitId: b.kitId, url, token, claimCode: b.claimCode }) // `hwId` is the durable board key (WiFi MAC + eMMC serial). Older nodes that
// don't send it fall back to keying by kitId, so they still self-register.
const hwId = typeof b.hwId === 'string' && b.hwId ? b.hwId : b.kitId
const r = boards.announce({ hwId, kitId: b.kitId, url, token, claimCode: b.claimCode })
if (r.claimed && r.teamId && nodes) { if (r.claimed && r.teamId && nodes) {
await nodes.register({ teamId: r.teamId, url, token }) await nodes.register({ teamId: r.teamId, url, token })
} }
+18 -1
View File
@@ -11,7 +11,12 @@ const ADMIN = 'admin-code'
const JUDGE = 'judge-code' const JUDGE = 'judge-code'
const FLEET = 'fleet-secret' const FLEET = 'fleet-secret'
const board = { kitId: 'KIT-07', url: 'http://192.168.1.7:8080', token: 'zc_secret_token', claimCode: '418302' } const board: { kitId: string; url: string; token: string; claimCode: string; hwId?: string } = {
kitId: 'KIT-07',
url: 'http://192.168.1.7:8080',
token: 'zc_secret_token',
claimCode: '418302',
}
describe('board self-register + claim', () => { describe('board self-register + claim', () => {
let store: Store let store: Store
@@ -185,6 +190,18 @@ describe('board self-register + claim', () => {
expect(pool.body).toEqual({ kits: [] }) expect(pool.body).toEqual({ kits: [] })
}) })
it('recognizes the same board by hwId even after its kit label is re-provisioned', async () => {
// a distinct board announces with a durable hwId, then gets claimed
await selfRegister({ hwId: 'hw-abc', kitId: 'KIT-XY', claimCode: '999999' }).expect(201)
await request(app).post('/claim').send({ kit: 'KIT-XY', teamId: 'team-xy', code: '999999' }).expect(201)
// re-provisioned under a new kit label + new IP, SAME hardware → still claimed, no orphan
const res = await selfRegister({ hwId: 'hw-abc', kitId: 'KIT-RENAMED', claimCode: '999999', url: 'http://192.168.1.9:8080' }).expect(201)
expect(res.body).toMatchObject({ kitId: 'KIT-RENAMED', claimed: true })
// the renamed board is not offered as a fresh unclaimed board (only the default KIT-07 remains)
const pool = await request(app).get('/nodes/unclaimed').set('x-access-code', ADMIN).expect(200)
expect(pool.body).toEqual({ kits: ['KIT-07'] })
})
it('lets an instructor release a claimed kit back to the pool for reassignment', async () => { it('lets an instructor release a claimed kit back to the pool for reassignment', async () => {
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201) await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
await request(app).post('/claim/release').send({ kit: 'KIT-07' }).expect(401) // admin only await request(app).post('/claim/release').send({ kit: 'KIT-07' }).expect(401) // admin only
+24
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import { createBoardRegistry, MAX_FAILS, WINDOW_MS } from './claim' import { createBoardRegistry, MAX_FAILS, WINDOW_MS } from './claim'
const board = (over: Partial<Parameters<ReturnType<typeof createBoardRegistry>['announce']>[0]> = {}) => ({ const board = (over: Partial<Parameters<ReturnType<typeof createBoardRegistry>['announce']>[0]> = {}) => ({
hwId: 'hw-07',
kitId: 'KIT-07', kitId: 'KIT-07',
url: 'http://192.168.1.7:8080', url: 'http://192.168.1.7:8080',
token: 'zc_secret_token', token: 'zc_secret_token',
@@ -111,4 +112,27 @@ describe('board registry', () => {
r.announce(board()) // board rebooted and re-registered r.announce(board()) // board rebooted and re-registered
expect(r.claim('KIT-07', '418302', 'team-07', 0)).toMatchObject({ ok: true }) expect(r.claim('KIT-07', '418302', 'team-07', 0)).toMatchObject({ ok: true })
}) })
it('recognizes the same board (durable hwId) even when its kit label changes', () => {
const r = createBoardRegistry()
r.announce(board())
r.claim('KIT-07', '418302', 'team-07', 0)
// re-provisioned under a new kit label + new IP, same hardware → still claimed
const res = r.announce(board({ kitId: 'KIT-NEW', url: 'http://192.168.1.9:8080' }))
expect(res).toEqual({ claimed: true, teamId: 'team-07' })
expect(r.isClaimed('KIT-NEW')).toBe(true)
expect(r.get('KIT-NEW')?.claimedBy).toBe('team-07')
expect(r.unclaimedKits()).toEqual([]) // not a second, separate board
})
it('seeds from persisted boards and reports each mutation via onChange', () => {
const saved: string[] = []
const r = createBoardRegistry(
[{ hwId: 'hw-07', kitId: 'KIT-07', url: 'u', token: 't', claimCode: '418302', claimedBy: 'team-07' }],
(b) => saved.push(`${b.hwId}:${b.claimedBy}`),
)
expect(r.isClaimed('KIT-07')).toBe(true) // restored claim survives restart
r.release('KIT-07')
expect(saved).toContain('hw-07:null') // release persisted
})
}) })
+63 -23
View File
@@ -5,8 +5,13 @@ import { matches } from './auth'
* bearer token (server-side, never sent to a browser), the per-board claim code * bearer token (server-side, never sent to a browser), the per-board claim code
* an attendee proves possession with (printed / QR-encoded on the kit), and the * an attendee proves possession with (printed / QR-encoded on the kit), and the
* team it is bound to once claimed (`null` while unclaimed). * team it is bound to once claimed (`null` while unclaimed).
*
* The durable key is `hwId` — a stable hardware fingerprint (WiFi MAC + eMMC
* serial) that survives IP change, reboot, laptop-wipe, and OS reflash. `kitId`
* is a human-facing label only (it may change if the board is re-provisioned).
*/ */
export interface Board { export interface Board {
hwId: string
kitId: string kitId: string
url: string url: string
token: string token: string
@@ -27,11 +32,17 @@ export type ClaimOutcome =
| { ok: false; reason: 'unknown' | 'bad_code' | 'rate_limited' } | { ok: false; reason: 'unknown' | 'bad_code' | 'rate_limited' }
export interface BoardRegistry { export interface BoardRegistry {
/** A board announces itself on boot / on its timer. Upserts url/token/claimCode, /** A board announces itself on boot / on its timer, keyed by its stable `hwId`.
* preserves the existing claim, and reports whether it is already claimed. */ * Upserts url/token/claimCode/kitId, preserves the existing claim (so a board
announce(n: { kitId: string; url: string; token: string; claimCode: string }): AnnounceResult * re-announcing under a new IP — or even a new kitId label — is recognized as
* the same board), and reports whether it is already claimed. */
announce(n: { hwId: string; kitId: string; url: string; token: string; claimCode: string }): AnnounceResult
/** Kit ids of boards that are not yet claimed — never url/token/claimCode. */ /** Kit ids of boards that are not yet claimed — never url/token/claimCode. */
unclaimedKits(): { kitId: string }[] unclaimedKits(): { kitId: string }[]
/** Full board snapshot by durable hwId (for persistence / recognition). */
getByHwId(hwId: string): Board | undefined
/** All boards (for persistence). */
all(): Board[]
isClaimed(kitId: string): boolean isClaimed(kitId: string): boolean
/** /**
* Claim (or resume) a board by proving its code. First claim binds it to * Claim (or resume) a board by proving its code. First claim binds it to
@@ -55,43 +66,70 @@ export interface BoardRegistry {
export const MAX_FAILS = 5 export const MAX_FAILS = 5
export const WINDOW_MS = 60_000 export const WINDOW_MS = 60_000
export function createBoardRegistry(seed: Board[] = []): BoardRegistry { /**
const boards = new Map<string, Board>() * @param seed boards to restore (from the persistent store) on boot.
for (const b of seed) boards.set(b.kitId, { ...b }) * @param onChange called with a board whenever it is created or mutated
const fails = new Map<string, number[]>() // kitId -> recent failed-attempt timestamps (ms) * (announce/claim/release), so the caller can persist it.
*/
export function createBoardRegistry(
seed: Board[] = [],
onChange: (b: Board) => void = () => {},
): BoardRegistry {
const boards = new Map<string, Board>() // keyed by durable hwId
for (const b of seed) boards.set(b.hwId, { ...b })
const fails = new Map<string, number[]>() // hwId -> recent failed-attempt timestamps (ms)
const recentFails = (kitId: string, nowMs: number): number => { const recentFails = (hwId: string, nowMs: number): number => {
const arr = (fails.get(kitId) ?? []).filter((t) => nowMs - t < WINDOW_MS) const arr = (fails.get(hwId) ?? []).filter((t) => nowMs - t < WINDOW_MS)
if (arr.length) fails.set(kitId, arr) if (arr.length) fails.set(hwId, arr)
else fails.delete(kitId) else fails.delete(hwId)
return arr.length return arr.length
} }
// kitId is a display label, not the key — resolve it by scan (fleet is small).
const byKit = (kitId: string): Board | undefined => [...boards.values()].find((b) => b.kitId === kitId)
return { return {
announce(n) { announce(n) {
const claimedBy = boards.get(n.kitId)?.claimedBy ?? null const claimedBy = boards.get(n.hwId)?.claimedBy ?? null
boards.set(n.kitId, { kitId: n.kitId, url: n.url, token: n.token, claimCode: n.claimCode, claimedBy }) const board: Board = {
fails.delete(n.kitId) hwId: n.hwId,
kitId: n.kitId,
url: n.url,
token: n.token,
claimCode: n.claimCode,
claimedBy,
}
boards.set(n.hwId, board)
fails.delete(n.hwId)
onChange(board)
return { claimed: claimedBy !== null, teamId: claimedBy } return { claimed: claimedBy !== null, teamId: claimedBy }
}, },
unclaimedKits() { unclaimedKits() {
return [...boards.values()].filter((b) => b.claimedBy === null).map((b) => ({ kitId: b.kitId })) return [...boards.values()].filter((b) => b.claimedBy === null).map((b) => ({ kitId: b.kitId }))
}, },
getByHwId(hwId) {
return boards.get(hwId)
},
all() {
return [...boards.values()]
},
isClaimed(kitId) { isClaimed(kitId) {
const b = boards.get(kitId) const b = byKit(kitId)
return !!b && b.claimedBy !== null return !!b && b.claimedBy !== null
}, },
claim(kitId, code, teamId, nowMs) { claim(kitId, code, teamId, nowMs) {
const board = boards.get(kitId) const board = byKit(kitId)
if (!board) return { ok: false, reason: 'unknown' } if (!board) return { ok: false, reason: 'unknown' }
if (recentFails(kitId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' } if (recentFails(board.hwId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
if (!matches(code, board.claimCode)) { if (!matches(code, board.claimCode)) {
fails.set(kitId, [...(fails.get(kitId) ?? []), nowMs]) fails.set(board.hwId, [...(fails.get(board.hwId) ?? []), nowMs])
return { ok: false, reason: 'bad_code' } return { ok: false, reason: 'bad_code' }
} }
fails.delete(kitId) fails.delete(board.hwId)
if (board.claimedBy === null) { if (board.claimedBy === null) {
board.claimedBy = teamId board.claimedBy = teamId
onChange(board)
return { ok: true, board, resumed: false } return { ok: true, board, resumed: false }
} }
return { ok: true, board, resumed: true } // already claimed → resume to the canonical team return { ok: true, board, resumed: true } // already claimed → resume to the canonical team
@@ -100,23 +138,25 @@ export function createBoardRegistry(seed: Board[] = []): BoardRegistry {
// Unique per-board codes → the code identifies the board. No match = bad code. // Unique per-board codes → the code identifies the board. No match = bad code.
const board = [...boards.values()].find((b) => matches(code, b.claimCode)) const board = [...boards.values()].find((b) => matches(code, b.claimCode))
if (!board) return { ok: false, reason: 'bad_code' } if (!board) return { ok: false, reason: 'bad_code' }
if (recentFails(board.kitId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' } if (recentFails(board.hwId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
fails.delete(board.kitId) fails.delete(board.hwId)
if (board.claimedBy === null) { if (board.claimedBy === null) {
board.claimedBy = teamId board.claimedBy = teamId
onChange(board)
return { ok: true, board, resumed: false } return { ok: true, board, resumed: false }
} }
return { ok: true, board, resumed: true } return { ok: true, board, resumed: true }
}, },
release(kitId) { release(kitId) {
const board = boards.get(kitId) const board = byKit(kitId)
if (!board || board.claimedBy === null) return null if (!board || board.claimedBy === null) return null
const freed = board.claimedBy const freed = board.claimedBy
board.claimedBy = null board.claimedBy = null
onChange(board)
return freed return freed
}, },
get(kitId) { get(kitId) {
return boards.get(kitId) return byKit(kitId)
}, },
} }
} }
+50
View File
@@ -7,6 +7,7 @@ import type {
ScoreDTO, ScoreDTO,
LeaderboardRow, LeaderboardRow,
} from './types' } from './types'
import type { Board } from './claim'
export interface Store { export interface Store {
upsertTeam(t: TeamSnapshot): void upsertTeam(t: TeamSnapshot): void
@@ -17,9 +18,22 @@ export interface Store {
getSubmission(teamId: string): SubmissionDTO | null getSubmission(teamId: string): SubmissionDTO | null
insertScore(input: ScoreInput, createdAt: string): ScoreDTO insertScore(input: ScoreInput, createdAt: string): ScoreDTO
leaderboard(): LeaderboardRow[] leaderboard(): LeaderboardRow[]
/** Persist a board (keyed by durable hwId) so the registry survives restart. */
saveBoard(b: Board): void
/** All persisted boards, to seed the in-memory registry on boot. */
listBoards(): Board[]
close(): void close(): void
} }
interface BoardRow {
hw_id: string
kit_id: string
url: string
token: string
claim_code: string
claimed_by: string | null
}
interface TeamRow { interface TeamRow {
id: string id: string
name: string name: string
@@ -78,6 +92,14 @@ export function openStore(path = ':memory:'): Store {
notes TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS boards (
hw_id TEXT PRIMARY KEY,
kit_id TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
token TEXT NOT NULL DEFAULT '',
claim_code TEXT NOT NULL DEFAULT '',
claimed_by TEXT
);
`) `)
// Migration for DBs created before the `domain` column existed. CREATE TABLE // Migration for DBs created before the `domain` column existed. CREATE TABLE
@@ -128,6 +150,14 @@ export function openStore(path = ':memory:'): Store {
const setPhaseAddStmt = db.prepare( const setPhaseAddStmt = db.prepare(
`UPDATE teams SET phases = json_set(phases, '$.add', json('true')) WHERE id = ?`, `UPDATE teams SET phases = json_set(phases, '$.add', json('true')) WHERE id = ?`,
) )
const saveBoardStmt = db.prepare(`
INSERT INTO boards (hw_id, kit_id, url, token, claim_code, claimed_by)
VALUES (@hw_id, @kit_id, @url, @token, @claim_code, @claimed_by)
ON CONFLICT(hw_id) DO UPDATE SET
kit_id=excluded.kit_id, url=excluded.url, token=excluded.token,
claim_code=excluded.claim_code, claimed_by=excluded.claimed_by
`)
const listBoardsStmt = db.prepare('SELECT * FROM boards')
return { return {
upsertTeam(t) { upsertTeam(t) {
@@ -202,6 +232,26 @@ export function openStore(path = ':memory:'): Store {
scoreCount: r.scoreCount, scoreCount: r.scoreCount,
})) }))
}, },
saveBoard(b) {
saveBoardStmt.run({
hw_id: b.hwId,
kit_id: b.kitId,
url: b.url,
token: b.token,
claim_code: b.claimCode,
claimed_by: b.claimedBy,
})
},
listBoards() {
return (listBoardsStmt.all() as BoardRow[]).map((r) => ({
hwId: r.hw_id,
kitId: r.kit_id,
url: r.url,
token: r.token,
claimCode: r.claim_code,
claimedBy: r.claimed_by,
}))
},
close() { close() {
db.close() db.close()
}, },
+1 -1
View File
@@ -23,7 +23,7 @@ if (!FLEET_SECRET) {
const store = openStore(DB_PATH) const store = openStore(DB_PATH)
const hub = createHub() const hub = createHub()
const nodes = createNodeBridge({ broadcast: hub.broadcast }) const nodes = createNodeBridge({ broadcast: hub.broadcast })
const boards = createBoardRegistry() const boards = createBoardRegistry(store.listBoards(), (b) => store.saveBoard(b))
const app = createApp({ const app = createApp({
store, store,
broadcast: hub.broadcast, broadcast: hub.broadcast,
+1 -1
View File
@@ -26,7 +26,7 @@ describe('collective WS feed', () => {
store = openStore(':memory:') store = openStore(':memory:')
const hub = createHub() const hub = createHub()
const boards = createBoardRegistry([ const boards = createBoardRegistry([
{ kitId: 'KIT-05', url: 'http://b', token: 't', claimCode: '111111', claimedBy: null }, { hwId: 'hw-05', kitId: 'KIT-05', url: 'http://b', token: 't', claimCode: '111111', claimedBy: null },
]) ])
const app = createApp({ store, broadcast: hub.broadcast, adminCode: ADMIN, judgeCode: JUDGE }) const app = createApp({ store, broadcast: hub.broadcast, adminCode: ADMIN, judgeCode: JUDGE })
server = http.createServer(app) server = http.createServer(app)