feat(api): board self-register + attendee claim (onboarding slice 1)

Adds the server side of the "preloaded + self-register + claim" board
onboarding so attendees bring a board online with no operator and no
admin code.

- claim.ts: an unclaimed pool keyed by kitId, holding {url, token,
  claimCode}; single-use claim with a constant-time code compare and a
  per-kit sliding-window rate limit (MAX_FAILS/WINDOW_MS) to blunt code
  guessing.
- POST /nodes/self-register — a booting board announces itself; gated
  by a shared FLEET_SECRET (baked into the image), not the admin code.
- POST /claim {kit, teamId, code} — public + rate-limited; validates
  the claim code, moves the bearer token straight from the pool into
  the node bridge (never touches the browser), binds the board to the
  team without clobbering an existing name/members, and emits the
  node:status + team:update the live feeds already consume.
- index.ts: wire the pool + FLEET_SECRET env.

22 new tests (claim unit + endpoint integration).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-06 15:28:14 -07:00
co-authored by Claude Opus 4.8
parent 88789b3f5c
commit 14e08b3623
5 changed files with 340 additions and 2 deletions
+62 -2
View File
@@ -1,9 +1,10 @@
import express, { type Express } from 'express' import express, { type Express } from 'express'
import cors from 'cors' import cors from 'cors'
import type { Store } from './db' import type { Store } from './db'
import { requireCode, requireAnyCode } from './auth' import { requireCode, requireAnyCode, matches } from './auth'
import type { TeamSnapshot, SubmissionDTO, WsEvent } from './types' import type { TeamSnapshot, SubmissionDTO, WsEvent } from './types'
import type { NodeBridge } from './nodes' import type { NodeBridge } from './nodes'
import type { UnclaimedPool } from './claim'
export interface AppOptions { export interface AppOptions {
store: Store store: Store
@@ -14,6 +15,10 @@ export interface AppOptions {
now?: () => string now?: () => string
/** Optional ZeroClaw node bridge; when absent, /nodes routes 503. */ /** Optional ZeroClaw node bridge; when absent, /nodes routes 503. */
nodes?: NodeBridge nodes?: NodeBridge
/** Optional pool of self-registered, not-yet-claimed boards. */
unclaimed?: UnclaimedPool
/** Shared fleet secret boards present when self-registering. */
fleetSecret?: string
} }
const emptyPhases = { reg: false, setup: false, m1: false, m2: false, add: false } const emptyPhases = { reg: false, setup: false, m1: false, m2: false, add: false }
@@ -21,7 +26,7 @@ const emptyStats = { calls: 0, nominal: 0, anomalous: 0, critical: 0 }
/** Build the collective REST app. Pure of I/O wiring (db + broadcast injected). */ /** Build the collective REST app. Pure of I/O wiring (db + broadcast injected). */
export function createApp(opts: AppOptions): Express { export function createApp(opts: AppOptions): Express {
const { store, broadcast, adminCode, judgeCode, nodes } = opts const { store, broadcast, adminCode, judgeCode, nodes, unclaimed, fleetSecret } = opts
const now = opts.now ?? (() => new Date().toISOString()) const now = opts.now ?? (() => new Date().toISOString())
const app = express() const app = express()
app.use(cors({ origin: opts.corsOrigin ?? true })) app.use(cors({ origin: opts.corsOrigin ?? true }))
@@ -122,6 +127,61 @@ export function createApp(opts: AppOptions): Express {
res.status(201).json({ teamId: b.teamId, url: b.url }) res.status(201).json({ teamId: b.teamId, url: b.url })
}) })
// A board announces itself on boot into the unclaimed pool. Gated by the
// shared fleet secret (baked into the board image) — not the admin code —
// so a booting board needs no operator, but randoms can't seed the pool.
app.post('/nodes/self-register', (req, res) => {
if (!unclaimed) return res.status(503).json({ error: 'claim unavailable' })
if (!fleetSecret || !matches(req.header('x-fleet-secret') ?? '', fleetSecret)) {
return res.status(401).json({ error: 'unauthorized' })
}
const b = req.body ?? {}
if (![b.kitId, b.url, b.token, b.claimCode].every((v) => typeof v === 'string' && v)) {
return res.status(400).json({ error: 'kitId, url, token and claimCode are required' })
}
unclaimed.announce({ kitId: b.kitId, url: b.url, token: b.token, claimCode: b.claimCode })
res.status(201).json({ kitId: b.kitId })
})
// A participant claims their powered-on board to their team by proving
// possession of the kit's claim code. Public + rate-limited (no operator in
// the loop); the bearer token moves straight from the pool into the bridge
// and never touches the browser.
app.post('/claim', async (req, res) => {
if (!unclaimed || !nodes) return res.status(503).json({ error: 'claim unavailable' })
const b = req.body ?? {}
if (typeof b.kit !== 'string' || typeof b.teamId !== 'string' || typeof b.code !== 'string') {
return res.status(400).json({ error: 'kit, teamId and code are required' })
}
const result = unclaimed.claim(b.kit, b.code, Date.parse(now()))
if (!result.ok) {
if (result.reason === 'unknown') {
return res.status(404).json({ error: 'no board found for that kit — is it powered on?' })
}
if (result.reason === 'rate_limited') {
return res.status(429).json({ error: 'too many attempts — wait a minute and try again' })
}
return res.status(401).json({ error: 'wrong claim code' })
}
await nodes.register({ teamId: b.teamId, url: result.node.url, token: result.node.token })
// Bind the board to the team without clobbering an existing registration.
const prev = store.getTeam(b.teamId)
const team: TeamSnapshot = {
id: b.teamId,
name: typeof b.teamName === 'string' && b.teamName ? b.teamName : prev?.name ?? '',
kit: b.kit,
members: Array.isArray(b.members) ? b.members : prev?.members ?? [],
phases: { ...emptyPhases, ...(prev?.phases ?? {}) },
stats: { ...emptyStats, ...(prev?.stats ?? {}) },
deviceConnected: true,
updatedAt: now(),
}
store.upsertTeam(team)
broadcast({ type: 'team:update', team })
const online = nodes.list().find((n) => n.teamId === b.teamId)?.online ?? false
res.status(201).json({ teamId: b.teamId, kit: b.kit, online })
})
app.delete('/nodes/:teamId', requireCode(adminCode), (req, res) => { app.delete('/nodes/:teamId', requireCode(adminCode), (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' }) if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
nodes.remove(String(req.params.teamId)) nodes.remove(String(req.params.teamId))
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach } from 'vitest'
import request from 'supertest'
import { openStore, type Store } from './db'
import { createApp } from './app'
import { createNodeBridge } from './nodes'
import { createUnclaimedPool } from './claim'
import { MAX_FAILS } from './claim'
import type { WsEvent } from './types'
const ADMIN = 'admin-code'
const JUDGE = 'judge-code'
const FLEET = 'fleet-secret'
const board = { kitId: 'KIT-07', url: 'http://192.168.1.7:8080', token: 'zc_secret_token', claimCode: '418302' }
describe('board self-register + claim', () => {
let store: Store
let events: WsEvent[]
let now: string
let app: ReturnType<typeof createApp>
beforeEach(() => {
store = openStore(':memory:')
events = []
now = '2026-07-27T14:00:00.000Z'
const broadcast = (e: WsEvent) => events.push(e)
const nodes = createNodeBridge({
broadcast,
ping: async () => true,
send: async () => {},
subscribe: () => () => {},
})
app = createApp({
store,
broadcast,
adminCode: ADMIN,
judgeCode: JUDGE,
nodes,
unclaimed: createUnclaimedPool(),
fleetSecret: FLEET,
now: () => now,
})
})
const selfRegister = (over: Partial<typeof board> = {}, secret = FLEET) =>
request(app).post('/nodes/self-register').set('x-fleet-secret', secret).send({ ...board, ...over })
describe('self-register', () => {
it('rejects a board without the fleet secret', async () => {
await request(app).post('/nodes/self-register').send(board).expect(401)
await selfRegister({}, 'wrong-secret').expect(401)
})
it('validates the body', async () => {
await selfRegister({ token: '' }).expect(400)
})
it('accepts a board presenting the fleet secret', async () => {
const res = await selfRegister().expect(201)
expect(res.body).toEqual({ kitId: 'KIT-07' })
})
})
describe('claim', () => {
beforeEach(async () => {
await selfRegister().expect(201)
})
it('rejects an unknown kit', async () => {
await request(app).post('/claim').send({ kit: 'KIT-99', teamId: 'team-07', code: '418302' }).expect(404)
})
it('rejects a wrong claim code', async () => {
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '000000' }).expect(401)
})
it('binds the board to the team, brings it online, and never leaks the token', async () => {
const res = await request(app)
.post('/claim')
.send({ kit: 'KIT-07', teamId: 'team-07', teamName: 'team_resonance', code: '418302' })
.expect(201)
expect(res.body).toEqual({ teamId: 'team-07', kit: 'KIT-07', online: true })
// the node is now registered + online for the team
const list = await request(app).get('/nodes').set('x-access-code', ADMIN).expect(200)
expect(list.body).toEqual([{ teamId: 'team-07', url: board.url, online: true }])
expect(JSON.stringify(list.body)).not.toContain('zc_secret_token')
// the team now exists, device-connected, with its kit + name
const teams = await request(app).get('/teams').set('x-access-code', ADMIN).expect(200)
expect(teams.body).toContainEqual(
expect.objectContaining({ id: 'team-07', name: 'team_resonance', kit: 'KIT-07', deviceConnected: true }),
)
// participants + judges get the live updates
expect(events).toContainEqual({ type: 'node:status', teamId: 'team-07', online: true })
expect(events.some((e) => e.type === 'team:update' && e.team.id === 'team-07')).toBe(true)
})
it('is single-use — a second claim of the same kit 404s', 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-08', code: '418302' }).expect(404)
})
it('does not clobber an existing team name/members on re-claim', async () => {
await request(app)
.put('/teams/team-07')
.send({ name: 'team_resonance', kit: 'KIT-07', members: ['ada', 'linus'] })
.expect(204)
await selfRegister().expect(201) // board rebooted, back in the pool
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
const teams = await request(app).get('/teams').set('x-access-code', ADMIN).expect(200)
expect(teams.body).toContainEqual(
expect.objectContaining({ id: 'team-07', name: 'team_resonance', members: ['ada', 'linus'] }),
)
})
it('rate-limits repeated wrong codes, then recovers after the window', async () => {
for (let i = 0; i < MAX_FAILS; i++) {
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '000000' }).expect(401)
}
// locked — even the right code is refused
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(429)
// a minute later the window has passed
now = '2026-07-27T14:02:00.000Z'
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
})
})
it('503s the claim routes when the pool is not configured', async () => {
const bare = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE })
await request(bare).post('/nodes/self-register').set('x-fleet-secret', FLEET).send(board).expect(503)
await request(bare).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(503)
})
})
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import { createUnclaimedPool, MAX_FAILS, WINDOW_MS } from './claim'
const node = (over: Partial<Parameters<ReturnType<typeof createUnclaimedPool>['announce']>[0]> = {}) => ({
kitId: 'KIT-07',
url: 'http://192.168.1.7:8080',
token: 'zc_secret_token',
claimCode: '418302',
...over,
})
describe('unclaimed pool', () => {
it('announces a board and lists only its kit id (no secrets)', () => {
const pool = createUnclaimedPool()
pool.announce(node())
expect(pool.has('KIT-07')).toBe(true)
expect(pool.list()).toEqual([{ kitId: 'KIT-07' }])
expect(JSON.stringify(pool.list())).not.toContain('zc_secret_token')
expect(JSON.stringify(pool.list())).not.toContain('418302')
})
it('claims with the right code, returns the node, and removes it from the pool', () => {
const pool = createUnclaimedPool()
pool.announce(node())
const r = pool.claim('KIT-07', '418302', 0)
expect(r).toEqual({ ok: true, node: node() })
expect(pool.has('KIT-07')).toBe(false) // single-use: gone after a successful claim
})
it('rejects an unknown kit', () => {
const pool = createUnclaimedPool()
expect(pool.claim('KIT-99', 'x', 0)).toEqual({ ok: false, reason: 'unknown' })
})
it('rejects a wrong code without consuming the board', () => {
const pool = createUnclaimedPool()
pool.announce(node())
expect(pool.claim('KIT-07', '000000', 0)).toEqual({ ok: false, reason: 'bad_code' })
expect(pool.has('KIT-07')).toBe(true)
})
it('locks a kit after too many wrong codes, then recovers after the window', () => {
const pool = createUnclaimedPool()
pool.announce(node())
for (let i = 0; i < MAX_FAILS; i++) {
expect(pool.claim('KIT-07', '000000', 0).ok).toBe(false)
}
// further attempts are rate-limited — even the correct code is refused while locked
expect(pool.claim('KIT-07', '418302', 100)).toEqual({ ok: false, reason: 'rate_limited' })
// once the window passes, the correct code works again
expect(pool.claim('KIT-07', '418302', WINDOW_MS + 1)).toEqual({ ok: true, node: node() })
})
it('re-announcing (a reboot) clears the fail counter', () => {
const pool = createUnclaimedPool()
pool.announce(node())
for (let i = 0; i < MAX_FAILS; i++) pool.claim('KIT-07', '000000', 0)
pool.announce(node()) // board rebooted and re-registered
expect(pool.claim('KIT-07', '418302', 0)).toEqual({ ok: true, node: node() })
})
})
+74
View File
@@ -0,0 +1,74 @@
import { matches } from './auth'
/**
* A board that has powered on and announced itself, but is not yet bound to a
* team. Holds the gateway url + bearer token (server-side, never sent to a
* browser) plus the per-board claim code an attendee proves possession with
* (it is printed / QR-encoded on the physical kit).
*/
export interface UnclaimedNode {
kitId: string
url: string
token: string
claimCode: string
}
export type ClaimResult =
| { ok: true; node: UnclaimedNode }
| { ok: false; reason: 'unknown' | 'bad_code' | 'rate_limited' }
export interface UnclaimedPool {
/** A board announces itself on boot (idempotent per kit; resets its fails). */
announce(n: UnclaimedNode): void
/** Kit ids only — never the url/token/claimCode. */
list(): { kitId: string }[]
has(kitId: string): boolean
/**
* Attempt a claim. On success removes the entry and returns the node so the
* caller can bind it to a team. Rate-limited per kit to blunt code guessing.
*/
claim(kitId: string, code: string, nowMs: number): ClaimResult
}
/** Wrong-code attempts allowed per kit inside {@link WINDOW_MS} before lockout. */
export const MAX_FAILS = 5
export const WINDOW_MS = 60_000
export function createUnclaimedPool(seed: UnclaimedNode[] = []): UnclaimedPool {
const pool = new Map<string, UnclaimedNode>()
for (const n of seed) pool.set(n.kitId, n)
const fails = new Map<string, number[]>() // kitId -> recent failed-attempt timestamps (ms)
// Count fails still inside the sliding window, pruning older ones in place.
const recentFails = (kitId: string, nowMs: number): number => {
const arr = (fails.get(kitId) ?? []).filter((t) => nowMs - t < WINDOW_MS)
if (arr.length) fails.set(kitId, arr)
else fails.delete(kitId)
return arr.length
}
return {
announce(n) {
pool.set(n.kitId, n)
fails.delete(n.kitId)
},
list() {
return [...pool.keys()].map((kitId) => ({ kitId }))
},
has(kitId) {
return pool.has(kitId)
},
claim(kitId, code, nowMs) {
const node = pool.get(kitId)
if (!node) return { ok: false, reason: 'unknown' }
if (recentFails(kitId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
if (!matches(code, node.claimCode)) {
fails.set(kitId, [...(fails.get(kitId) ?? []), nowMs])
return { ok: false, reason: 'bad_code' }
}
pool.delete(kitId)
fails.delete(kitId)
return { ok: true, node }
},
}
}
+8
View File
@@ -4,20 +4,26 @@ import { createHub } from './hub'
import { createApp } from './app' import { createApp } from './app'
import { attachWs } from './ws' import { attachWs } from './ws'
import { createNodeBridge } from './nodes' import { createNodeBridge } from './nodes'
import { createUnclaimedPool } from './claim'
const PORT = Number(process.env.PORT ?? 3000) const PORT = Number(process.env.PORT ?? 3000)
const DB_PATH = process.env.DB_PATH ?? '/data/apess.db' const DB_PATH = process.env.DB_PATH ?? '/data/apess.db'
const ADMIN_CODE = process.env.ADMIN_CODE ?? '' const ADMIN_CODE = process.env.ADMIN_CODE ?? ''
const JUDGE_CODE = process.env.JUDGE_CODE ?? '' const JUDGE_CODE = process.env.JUDGE_CODE ?? ''
const FLEET_SECRET = process.env.FLEET_SECRET ?? ''
const CORS_ORIGIN = process.env.CORS_ORIGIN const CORS_ORIGIN = process.env.CORS_ORIGIN
if (!ADMIN_CODE || !JUDGE_CODE) { if (!ADMIN_CODE || !JUDGE_CODE) {
console.warn('[apess-api] ADMIN_CODE / JUDGE_CODE not set — protected routes will reject all requests') console.warn('[apess-api] ADMIN_CODE / JUDGE_CODE not set — protected routes will reject all requests')
} }
if (!FLEET_SECRET) {
console.warn('[apess-api] FLEET_SECRET not set — board self-registration (/nodes/self-register) will reject all boards')
}
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 unclaimed = createUnclaimedPool()
const app = createApp({ const app = createApp({
store, store,
broadcast: hub.broadcast, broadcast: hub.broadcast,
@@ -25,6 +31,8 @@ const app = createApp({
judgeCode: JUDGE_CODE, judgeCode: JUDGE_CODE,
corsOrigin: CORS_ORIGIN, corsOrigin: CORS_ORIGIN,
nodes, nodes,
unclaimed,
fleetSecret: FLEET_SECRET,
}) })
const server = http.createServer(app) const server = http.createServer(app)