feat(onboarding): board re-attach — auto-heal reboots, resume lost browsers, instructor release

Fixes a latent bug and hardens claim recovery. Refactors the unclaimed pool
into a BoardRegistry that tracks claimedBy, so a board's binding survives its
own reboot and a team can get back onto its board after an error.

- Auto-heal: a claimed board that reboots re-announces with a fresh IP/token;
  self-register now REFRESHES its node binding instead of dumping it back into
  the unclaimed pool (previously the team's board went stale/offline + the kit
  wrongly reappeared as unclaimed).
- Resume: re-claiming an already-claimed kit with the right code returns the
  board's CANONICAL teamId + team snapshot (not a fresh identity). The client
  adopts it via a new resumeTeam() action, restoring name/members/phases/stats
  so a lost-browser re-claim doesn't clobber synced progress.
- Release: POST /claim/release (admin) frees a kit back to the pool + unbinds
  its node, for mis-claims / reassignment. Wired to a small control in /admin.

API 56 tests green; web 220 green; tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-08 17:16:14 -07:00
co-authored by Claude Opus 4.8
parent 4d9d984b04
commit 2c18e67c1b
13 changed files with 374 additions and 115 deletions
+52 -24
View File
@@ -4,7 +4,7 @@ import type { Store } from './db'
import { requireCode, requireAnyCode, matches } 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' import type { BoardRegistry } from './claim'
export interface AppOptions { export interface AppOptions {
store: Store store: Store
@@ -15,8 +15,8 @@ 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. */ /** Optional registry of self-registered boards (claimed + unclaimed). */
unclaimed?: UnclaimedPool boards?: BoardRegistry
/** Shared fleet secret boards present when self-registering. */ /** Shared fleet secret boards present when self-registering. */
fleetSecret?: string fleetSecret?: string
} }
@@ -26,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, unclaimed, fleetSecret } = opts const { store, broadcast, adminCode, judgeCode, nodes, boards, 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 }))
@@ -120,12 +120,12 @@ export function createApp(opts: AppOptions): Express {
// Instructor view: kit ids of boards that have powered on + self-registered // Instructor view: kit ids of boards that have powered on + self-registered
// but no team has claimed yet. Never exposes url/token/claimCode. // but no team has claimed yet. Never exposes url/token/claimCode.
app.get('/nodes/unclaimed', requireCode(adminCode), (_req, res) => { app.get('/nodes/unclaimed', requireCode(adminCode), (_req, res) => {
if (!unclaimed) return res.status(503).json({ error: 'claim unavailable' }) if (!boards) return res.status(503).json({ error: 'claim unavailable' })
res.json({ kits: unclaimed.list().map((u) => u.kitId) }) res.json({ kits: boards.unclaimedKits().map((u) => u.kitId) })
}) })
const broadcastUnclaimed = () => { const broadcastUnclaimed = () => {
if (unclaimed) broadcast({ type: 'unclaimed:update', kits: unclaimed.list().map((u) => u.kitId) }) if (boards) broadcast({ type: 'unclaimed:update', kits: boards.unclaimedKits().map((u) => u.kitId) })
} }
app.post('/nodes', requireCode(adminCode), async (req, res) => { app.post('/nodes', requireCode(adminCode), async (req, res) => {
@@ -138,11 +138,13 @@ 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 // A board announces itself on boot. Gated by the shared fleet secret (baked
// shared fleet secret (baked into the board image) — not the admin code — // into the board image) — not the admin code — so a booting board needs no
// so a booting board needs no operator, but randoms can't seed the pool. // operator, but randoms can't seed the pool. If the kit is already claimed
app.post('/nodes/self-register', (req, res) => { // (a rebooted board re-announcing with a fresh IP/token), auto-heal: refresh
if (!unclaimed) return res.status(503).json({ error: 'claim unavailable' }) // its node binding so the team's board comes back online without re-claiming.
app.post('/nodes/self-register', async (req, res) => {
if (!boards) return res.status(503).json({ error: 'claim unavailable' })
if (!fleetSecret || !matches(req.header('x-fleet-secret') ?? '', fleetSecret)) { if (!fleetSecret || !matches(req.header('x-fleet-secret') ?? '', fleetSecret)) {
return res.status(401).json({ error: 'unauthorized' }) return res.status(401).json({ error: 'unauthorized' })
} }
@@ -150,22 +152,30 @@ export function createApp(opts: AppOptions): Express {
if (![b.kitId, b.url, b.token, b.claimCode].every((v) => typeof v === 'string' && v)) { 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' }) 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 }) const r = boards.announce({ kitId: b.kitId, url: b.url, token: b.token, claimCode: b.claimCode })
if (r.claimed && r.teamId && nodes) {
await nodes.register({ teamId: r.teamId, url: b.url, token: b.token })
}
broadcastUnclaimed() broadcastUnclaimed()
res.status(201).json({ kitId: b.kitId }) res.status(201).json({ kitId: b.kitId, claimed: r.claimed })
}) })
// A participant claims their powered-on board to their team by proving // 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 // 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 // the loop); the bearer token moves straight from the pool into the bridge
// and never touches the browser. // and never touches the browser.
//
// Re-claiming an already-claimed kit succeeds as a **resume**: the board is
// re-bound with its current url/token and the response carries the board's
// *canonical* teamId + team snapshot, so a team that lost its browser gets
// back onto its own board (and its progress) instead of a fresh identity.
app.post('/claim', async (req, res) => { app.post('/claim', async (req, res) => {
if (!unclaimed || !nodes) return res.status(503).json({ error: 'claim unavailable' }) if (!boards || !nodes) return res.status(503).json({ error: 'claim unavailable' })
const b = req.body ?? {} const b = req.body ?? {}
if (typeof b.kit !== 'string' || typeof b.teamId !== 'string' || typeof b.code !== 'string') { 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' }) return res.status(400).json({ error: 'kit, teamId and code are required' })
} }
const result = unclaimed.claim(b.kit, b.code, Date.parse(now())) const result = boards.claim(b.kit, b.code, b.teamId, Date.parse(now()))
if (!result.ok) { if (!result.ok) {
if (result.reason === 'unknown') { if (result.reason === 'unknown') {
return res.status(404).json({ error: 'no board found for that kit — is it powered on?' }) return res.status(404).json({ error: 'no board found for that kit — is it powered on?' })
@@ -175,14 +185,18 @@ export function createApp(opts: AppOptions): Express {
} }
return res.status(401).json({ error: 'wrong claim code' }) return res.status(401).json({ error: 'wrong claim code' })
} }
await nodes.register({ teamId: b.teamId, url: result.node.url, token: result.node.token }) const teamId = result.board.claimedBy as string // canonical (== b.teamId on first claim)
// Bind the board to the team without clobbering an existing registration. await nodes.register({ teamId, url: result.board.url, token: result.board.token })
const prev = store.getTeam(b.teamId) // Bind/refresh the board without clobbering an existing team snapshot. On a
// resume the canonical team's stored name/members win over the new browser's.
const prev = store.getTeam(teamId)
const pickName = result.resumed ? prev?.name : typeof b.teamName === 'string' && b.teamName ? b.teamName : prev?.name
const pickMembers = result.resumed ? prev?.members : Array.isArray(b.members) ? b.members : prev?.members
const team: TeamSnapshot = { const team: TeamSnapshot = {
id: b.teamId, id: teamId,
name: typeof b.teamName === 'string' && b.teamName ? b.teamName : prev?.name ?? '', name: pickName ?? '',
kit: b.kit, kit: b.kit,
members: Array.isArray(b.members) ? b.members : prev?.members ?? [], members: pickMembers ?? [],
phases: { ...emptyPhases, ...(prev?.phases ?? {}) }, phases: { ...emptyPhases, ...(prev?.phases ?? {}) },
stats: { ...emptyStats, ...(prev?.stats ?? {}) }, stats: { ...emptyStats, ...(prev?.stats ?? {}) },
deviceConnected: true, deviceConnected: true,
@@ -191,8 +205,22 @@ export function createApp(opts: AppOptions): Express {
store.upsertTeam(team) store.upsertTeam(team)
broadcast({ type: 'team:update', team }) broadcast({ type: 'team:update', team })
broadcastUnclaimed() // the claimed kit left the pool broadcastUnclaimed() // the claimed kit left the pool
const online = nodes.list().find((n) => n.teamId === b.teamId)?.online ?? false const online = nodes.list().find((n) => n.teamId === teamId)?.online ?? false
res.status(201).json({ teamId: b.teamId, kit: b.kit, online }) res.status(201).json({ teamId, kit: b.kit, online, resumed: result.resumed, team })
})
// Instructor action: release a kit back to the unclaimed pool and unbind its
// board, so a mis-claimed / reassigned kit can be claimed by another team.
app.post('/claim/release', requireCode(adminCode), (req, res) => {
if (!boards) return res.status(503).json({ error: 'claim unavailable' })
const b = req.body ?? {}
if (typeof b.kit !== 'string' || !b.kit) {
return res.status(400).json({ error: 'kit is required' })
}
const freed = boards.release(b.kit)
if (freed && nodes) nodes.remove(freed)
broadcastUnclaimed()
res.json({ kit: b.kit, released: !!freed, teamId: freed })
}) })
app.delete('/nodes/:teamId', requireCode(adminCode), (req, res) => { app.delete('/nodes/:teamId', requireCode(adminCode), (req, res) => {
+59 -9
View File
@@ -3,7 +3,7 @@ import request from 'supertest'
import { openStore, type Store } from './db' import { openStore, type Store } from './db'
import { createApp } from './app' import { createApp } from './app'
import { createNodeBridge } from './nodes' import { createNodeBridge } from './nodes'
import { createUnclaimedPool } from './claim' import { createBoardRegistry } from './claim'
import { MAX_FAILS } from './claim' import { MAX_FAILS } from './claim'
import type { WsEvent } from './types' import type { WsEvent } from './types'
@@ -36,7 +36,7 @@ describe('board self-register + claim', () => {
adminCode: ADMIN, adminCode: ADMIN,
judgeCode: JUDGE, judgeCode: JUDGE,
nodes, nodes,
unclaimed: createUnclaimedPool(), boards: createBoardRegistry(),
fleetSecret: FLEET, fleetSecret: FLEET,
now: () => now, now: () => now,
}) })
@@ -57,7 +57,7 @@ describe('board self-register + claim', () => {
it('accepts a board presenting the fleet secret', async () => { it('accepts a board presenting the fleet secret', async () => {
const res = await selfRegister().expect(201) const res = await selfRegister().expect(201)
expect(res.body).toEqual({ kitId: 'KIT-07' }) expect(res.body).toEqual({ kitId: 'KIT-07', claimed: false })
}) })
it('broadcasts the unclaimed pool and lists it for the instructor', async () => { it('broadcasts the unclaimed pool and lists it for the instructor', async () => {
@@ -88,7 +88,8 @@ describe('board self-register + claim', () => {
.post('/claim') .post('/claim')
.send({ kit: 'KIT-07', teamId: 'team-07', teamName: 'team_resonance', code: '418302' }) .send({ kit: 'KIT-07', teamId: 'team-07', teamName: 'team_resonance', code: '418302' })
.expect(201) .expect(201)
expect(res.body).toEqual({ teamId: 'team-07', kit: 'KIT-07', online: true }) expect(res.body).toMatchObject({ teamId: 'team-07', kit: 'KIT-07', online: true, resumed: false })
expect(res.body.team).toMatchObject({ id: 'team-07', name: 'team_resonance', kit: 'KIT-07', deviceConnected: true })
// the node is now registered + online for the team // the node is now registered + online for the team
const list = await request(app).get('/nodes').set('x-access-code', ADMIN).expect(200) const list = await request(app).get('/nodes').set('x-access-code', ADMIN).expect(200)
@@ -117,17 +118,65 @@ describe('board self-register + claim', () => {
expect(res.body).toEqual({ teamId: 'team-07', online: true }) expect(res.body).toEqual({ teamId: 'team-07', online: true })
}) })
it('is single-use — a second claim of the same kit 404s', async () => { it('resumes (not rejects) a re-claim of the same kit to its canonical team', 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').send({ kit: 'KIT-07', teamId: 'team-08', code: '418302' }).expect(404) // a different browser (fresh teamId) re-claims — resumes team-07, not a new identity
const res = await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-08', code: '418302' }).expect(201)
expect(res.body).toMatchObject({ teamId: 'team-07', kit: 'KIT-07', resumed: true })
}) })
it('does not clobber an existing team name/members on re-claim', async () => { it('resume returns the canonical team + its synced snapshot (a lost browser)', async () => {
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', teamName: 'team_resonance', code: '418302' }).expect(201)
// some progress synced to the collective under team-07
await request(app)
.put('/teams/team-07')
.send({ name: 'team_resonance', kit: 'KIT-07', members: ['ada'], phases: { reg: true, setup: true } })
.expect(204)
// new browser (fresh teamId) re-claims the same kit + code
const res = await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-FRESH', code: '418302' }).expect(201)
expect(res.body).toMatchObject({ teamId: 'team-07', resumed: true })
expect(res.body.team).toMatchObject({
id: 'team-07',
name: 'team_resonance',
members: ['ada'],
phases: expect.objectContaining({ reg: true, setup: true }),
})
// no orphan team-FRESH is created
const teams = await request(app).get('/teams').set('x-access-code', ADMIN).expect(200)
expect(teams.body.some((t: { id: string }) => t.id === 'team-FRESH')).toBe(false)
})
it('auto-heals a rebooted claimed board — refreshes its binding without a re-claim', async () => {
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
// board reboots with a new IP + a fresh paired token and re-announces
const res = await selfRegister({ url: 'http://192.168.1.9:8080', token: 'zc_new_token' }).expect(201)
expect(res.body).toEqual({ kitId: 'KIT-07', claimed: true })
// the team's binding is refreshed (new url), still online, still not unclaimed
const list = await request(app).get('/nodes').set('x-access-code', ADMIN).expect(200)
expect(list.body).toEqual([{ teamId: 'team-07', url: 'http://192.168.1.9:8080', online: true }])
const pool = await request(app).get('/nodes/unclaimed').set('x-access-code', ADMIN).expect(200)
expect(pool.body).toEqual({ kits: [] })
})
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/release').send({ kit: 'KIT-07' }).expect(401) // admin only
const res = await request(app).post('/claim/release').set('x-access-code', ADMIN).send({ kit: 'KIT-07' }).expect(200)
expect(res.body).toEqual({ kit: 'KIT-07', released: true, teamId: 'team-07' })
// back in the pool, node unbound
const pool = await request(app).get('/nodes/unclaimed').set('x-access-code', ADMIN).expect(200)
expect(pool.body).toEqual({ kits: ['KIT-07'] })
await request(app).get('/nodes/team-07/status').expect(404)
// another team can now claim it
const claim = await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-99', code: '418302' }).expect(201)
expect(claim.body).toMatchObject({ teamId: 'team-99', resumed: false })
})
it('does not clobber an existing team name/members on claim', async () => {
await request(app) await request(app)
.put('/teams/team-07') .put('/teams/team-07')
.send({ name: 'team_resonance', kit: 'KIT-07', members: ['ada', 'linus'] }) .send({ name: 'team_resonance', kit: 'KIT-07', members: ['ada', 'linus'] })
.expect(204) .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) 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) const teams = await request(app).get('/teams').set('x-access-code', ADMIN).expect(200)
expect(teams.body).toContainEqual( expect(teams.body).toContainEqual(
@@ -147,9 +196,10 @@ describe('board self-register + claim', () => {
}) })
}) })
it('503s the claim routes when the pool is not configured', async () => { it('503s the claim routes when the registry is not configured', async () => {
const bare = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE }) 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('/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) await request(bare).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(503)
await request(bare).post('/claim/release').set('x-access-code', ADMIN).send({ kit: 'KIT-07' }).expect(503)
}) })
}) })
+67 -33
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { createUnclaimedPool, MAX_FAILS, WINDOW_MS } from './claim' import { createBoardRegistry, MAX_FAILS, WINDOW_MS } from './claim'
const node = (over: Partial<Parameters<ReturnType<typeof createUnclaimedPool>['announce']>[0]> = {}) => ({ const board = (over: Partial<Parameters<ReturnType<typeof createBoardRegistry>['announce']>[0]> = {}) => ({
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',
@@ -9,53 +9,87 @@ const node = (over: Partial<Parameters<ReturnType<typeof createUnclaimedPool>['a
...over, ...over,
}) })
describe('unclaimed pool', () => { describe('board registry', () => {
it('announces a board and lists only its kit id (no secrets)', () => { it('announces a board and offers only its kit id (no secrets)', () => {
const pool = createUnclaimedPool() const r = createBoardRegistry()
pool.announce(node()) expect(r.announce(board())).toEqual({ claimed: false, teamId: null })
expect(pool.has('KIT-07')).toBe(true) expect(r.isClaimed('KIT-07')).toBe(false)
expect(pool.list()).toEqual([{ kitId: 'KIT-07' }]) expect(r.unclaimedKits()).toEqual([{ kitId: 'KIT-07' }])
expect(JSON.stringify(pool.list())).not.toContain('zc_secret_token') expect(JSON.stringify(r.unclaimedKits())).not.toContain('zc_secret_token')
expect(JSON.stringify(pool.list())).not.toContain('418302') expect(JSON.stringify(r.unclaimedKits())).not.toContain('418302')
}) })
it('claims with the right code, returns the node, and removes it from the pool', () => { it('claims with the right code, binds the team, and leaves the unclaimed pool', () => {
const pool = createUnclaimedPool() const r = createBoardRegistry()
pool.announce(node()) r.announce(board())
const r = pool.claim('KIT-07', '418302', 0) const out = r.claim('KIT-07', '418302', 'team-07', 0)
expect(r).toEqual({ ok: true, node: node() }) expect(out).toMatchObject({ ok: true, resumed: false })
expect(pool.has('KIT-07')).toBe(false) // single-use: gone after a successful claim expect(out.ok && out.board.claimedBy).toBe('team-07')
expect(r.isClaimed('KIT-07')).toBe(true)
expect(r.unclaimedKits()).toEqual([]) // claimed → no longer offered
}) })
it('rejects an unknown kit', () => { it('rejects an unknown kit', () => {
const pool = createUnclaimedPool() const r = createBoardRegistry()
expect(pool.claim('KIT-99', 'x', 0)).toEqual({ ok: false, reason: 'unknown' }) expect(r.claim('KIT-99', 'x', 'team-07', 0)).toEqual({ ok: false, reason: 'unknown' })
}) })
it('rejects a wrong code without consuming the board', () => { it('rejects a wrong code without binding the board', () => {
const pool = createUnclaimedPool() const r = createBoardRegistry()
pool.announce(node()) r.announce(board())
expect(pool.claim('KIT-07', '000000', 0)).toEqual({ ok: false, reason: 'bad_code' }) expect(r.claim('KIT-07', '000000', 'team-07', 0)).toEqual({ ok: false, reason: 'bad_code' })
expect(pool.has('KIT-07')).toBe(true) expect(r.isClaimed('KIT-07')).toBe(false)
})
it('resumes an already-claimed board to its canonical team (a lost browser re-claims)', () => {
const r = createBoardRegistry()
r.announce(board())
r.claim('KIT-07', '418302', 'team-07', 0)
// a different browser (fresh teamId) re-claims with the right code → resume
const again = r.claim('KIT-07', '418302', 'team-99', 1)
expect(again).toMatchObject({ ok: true, resumed: true })
expect(again.ok && again.board.claimedBy).toBe('team-07') // canonical, not team-99
})
it('a rebooted claimed board stays claimed, out of the pool, with refreshed url/token (auto-heal)', () => {
const r = createBoardRegistry()
r.announce(board())
r.claim('KIT-07', '418302', 'team-07', 0)
const res = r.announce(board({ url: 'http://192.168.1.9:8080', token: 'zc_new_token' }))
expect(res).toEqual({ claimed: true, teamId: 'team-07' })
expect(r.isClaimed('KIT-07')).toBe(true)
expect(r.unclaimedKits()).toEqual([])
expect(r.get('KIT-07')?.url).toBe('http://192.168.1.9:8080')
expect(r.get('KIT-07')?.token).toBe('zc_new_token')
})
it('release returns a claimed board to the unclaimed pool', () => {
const r = createBoardRegistry()
r.announce(board())
r.claim('KIT-07', '418302', 'team-07', 0)
expect(r.release('KIT-07')).toBe('team-07')
expect(r.isClaimed('KIT-07')).toBe(false)
expect(r.unclaimedKits()).toEqual([{ kitId: 'KIT-07' }])
expect(r.release('KIT-07')).toBeNull() // already released
}) })
it('locks a kit after too many wrong codes, then recovers after the window', () => { it('locks a kit after too many wrong codes, then recovers after the window', () => {
const pool = createUnclaimedPool() const r = createBoardRegistry()
pool.announce(node()) r.announce(board())
for (let i = 0; i < MAX_FAILS; i++) { for (let i = 0; i < MAX_FAILS; i++) {
expect(pool.claim('KIT-07', '000000', 0).ok).toBe(false) expect(r.claim('KIT-07', '000000', 'team-07', 0).ok).toBe(false)
} }
// further attempts are rate-limited — even the correct code is refused while locked // 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' }) expect(r.claim('KIT-07', '418302', 'team-07', 100)).toEqual({ ok: false, reason: 'rate_limited' })
// once the window passes, the correct code works again // once the window passes, the correct code works again
expect(pool.claim('KIT-07', '418302', WINDOW_MS + 1)).toEqual({ ok: true, node: node() }) expect(r.claim('KIT-07', '418302', 'team-07', WINDOW_MS + 1)).toMatchObject({ ok: true, resumed: false })
}) })
it('re-announcing (a reboot) clears the fail counter', () => { it('re-announcing (a reboot) clears the fail counter', () => {
const pool = createUnclaimedPool() const r = createBoardRegistry()
pool.announce(node()) r.announce(board())
for (let i = 0; i < MAX_FAILS; i++) pool.claim('KIT-07', '000000', 0) for (let i = 0; i < MAX_FAILS; i++) r.claim('KIT-07', '000000', 'team-07', 0)
pool.announce(node()) // board rebooted and re-registered r.announce(board()) // board rebooted and re-registered
expect(pool.claim('KIT-07', '418302', 0)).toEqual({ ok: true, node: node() }) expect(r.claim('KIT-07', '418302', 'team-07', 0)).toMatchObject({ ok: true })
}) })
}) })
+61 -31
View File
@@ -1,45 +1,59 @@
import { matches } from './auth' import { matches } from './auth'
/** /**
* A board that has powered on and announced itself, but is not yet bound to a * A board that has powered on and announced itself. Holds the gateway url +
* team. Holds the gateway url + bearer token (server-side, never sent to a * bearer token (server-side, never sent to a browser), the per-board claim code
* browser) plus the per-board claim code an attendee proves possession with * an attendee proves possession with (printed / QR-encoded on the kit), and the
* (it is printed / QR-encoded on the physical kit). * team it is bound to once claimed (`null` while unclaimed).
*/ */
export interface UnclaimedNode { export interface Board {
kitId: string kitId: string
url: string url: string
token: string token: string
claimCode: string claimCode: string
claimedBy: string | null
} }
export type ClaimResult = export interface AnnounceResult {
| { ok: true; node: UnclaimedNode } /** True if this kit is already claimed — the caller should refresh its node
* binding with the freshly-announced url/token (a rebooted board's IP/token
* change, otherwise the team's board goes stale/offline). */
claimed: boolean
teamId: string | null
}
export type ClaimOutcome =
| { ok: true; board: Board; resumed: boolean }
| { ok: false; reason: 'unknown' | 'bad_code' | 'rate_limited' } | { ok: false; reason: 'unknown' | 'bad_code' | 'rate_limited' }
export interface UnclaimedPool { export interface BoardRegistry {
/** A board announces itself on boot (idempotent per kit; resets its fails). */ /** A board announces itself on boot / on its timer. Upserts url/token/claimCode,
announce(n: UnclaimedNode): void * preserves the existing claim, and reports whether it is already claimed. */
/** Kit ids only — never the url/token/claimCode. */ announce(n: { kitId: string; url: string; token: string; claimCode: string }): AnnounceResult
list(): { kitId: string }[] /** Kit ids of boards that are not yet claimed — never url/token/claimCode. */
has(kitId: string): boolean unclaimedKits(): { kitId: string }[]
isClaimed(kitId: string): boolean
/** /**
* Attempt a claim. On success removes the entry and returns the node so the * Claim (or resume) a board by proving its code. First claim binds it to
* caller can bind it to a team. Rate-limited per kit to blunt code guessing. * `teamId`; a later claim of an already-claimed kit succeeds as a **resume**
* (returns the board with its canonical `claimedBy`, unchanged) so a team that
* lost its browser can get back onto its own board. Rate-limited per kit.
*/ */
claim(kitId: string, code: string, nowMs: number): ClaimResult claim(kitId: string, code: string, teamId: string, nowMs: number): ClaimOutcome
/** Release a kit back to unclaimed; returns the freed teamId (or null). */
release(kitId: string): string | null
get(kitId: string): Board | undefined
} }
/** Wrong-code attempts allowed per kit inside {@link WINDOW_MS} before lockout. */ /** Wrong-code attempts allowed per kit inside {@link WINDOW_MS} before lockout. */
export const MAX_FAILS = 5 export const MAX_FAILS = 5
export const WINDOW_MS = 60_000 export const WINDOW_MS = 60_000
export function createUnclaimedPool(seed: UnclaimedNode[] = []): UnclaimedPool { export function createBoardRegistry(seed: Board[] = []): BoardRegistry {
const pool = new Map<string, UnclaimedNode>() const boards = new Map<string, Board>()
for (const n of seed) pool.set(n.kitId, n) for (const b of seed) boards.set(b.kitId, { ...b })
const fails = new Map<string, number[]>() // kitId -> recent failed-attempt timestamps (ms) 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 recentFails = (kitId: string, nowMs: number): number => {
const arr = (fails.get(kitId) ?? []).filter((t) => nowMs - t < WINDOW_MS) const arr = (fails.get(kitId) ?? []).filter((t) => nowMs - t < WINDOW_MS)
if (arr.length) fails.set(kitId, arr) if (arr.length) fails.set(kitId, arr)
@@ -49,26 +63,42 @@ export function createUnclaimedPool(seed: UnclaimedNode[] = []): UnclaimedPool {
return { return {
announce(n) { announce(n) {
pool.set(n.kitId, n) const claimedBy = boards.get(n.kitId)?.claimedBy ?? null
boards.set(n.kitId, { kitId: n.kitId, url: n.url, token: n.token, claimCode: n.claimCode, claimedBy })
fails.delete(n.kitId) fails.delete(n.kitId)
return { claimed: claimedBy !== null, teamId: claimedBy }
}, },
list() { unclaimedKits() {
return [...pool.keys()].map((kitId) => ({ kitId })) return [...boards.values()].filter((b) => b.claimedBy === null).map((b) => ({ kitId: b.kitId }))
}, },
has(kitId) { isClaimed(kitId) {
return pool.has(kitId) const b = boards.get(kitId)
return !!b && b.claimedBy !== null
}, },
claim(kitId, code, nowMs) { claim(kitId, code, teamId, nowMs) {
const node = pool.get(kitId) const board = boards.get(kitId)
if (!node) 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(kitId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
if (!matches(code, node.claimCode)) { if (!matches(code, board.claimCode)) {
fails.set(kitId, [...(fails.get(kitId) ?? []), nowMs]) fails.set(kitId, [...(fails.get(kitId) ?? []), nowMs])
return { ok: false, reason: 'bad_code' } return { ok: false, reason: 'bad_code' }
} }
pool.delete(kitId)
fails.delete(kitId) fails.delete(kitId)
return { ok: true, node } if (board.claimedBy === null) {
board.claimedBy = teamId
return { ok: true, board, resumed: false }
}
return { ok: true, board, resumed: true } // already claimed → resume to the canonical team
},
release(kitId) {
const board = boards.get(kitId)
if (!board || board.claimedBy === null) return null
const freed = board.claimedBy
board.claimedBy = null
return freed
},
get(kitId) {
return boards.get(kitId)
}, },
} }
} }
+4 -4
View File
@@ -4,7 +4,7 @@ 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' import { createBoardRegistry } 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'
@@ -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 unclaimed = createUnclaimedPool() const boards = createBoardRegistry()
const app = createApp({ const app = createApp({
store, store,
broadcast: hub.broadcast, broadcast: hub.broadcast,
@@ -31,12 +31,12 @@ const app = createApp({
judgeCode: JUDGE_CODE, judgeCode: JUDGE_CODE,
corsOrigin: CORS_ORIGIN, corsOrigin: CORS_ORIGIN,
nodes, nodes,
unclaimed, boards,
fleetSecret: FLEET_SECRET, fleetSecret: FLEET_SECRET,
}) })
const server = http.createServer(app) const server = http.createServer(app)
attachWs(server, { store, hub, adminCode: ADMIN_CODE, judgeCode: JUDGE_CODE, unclaimed }) attachWs(server, { store, hub, adminCode: ADMIN_CODE, judgeCode: JUDGE_CODE, boards })
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`[apess-api] listening on :${PORT} (db: ${DB_PATH})`) console.log(`[apess-api] listening on :${PORT} (db: ${DB_PATH})`)
+4 -4
View File
@@ -7,7 +7,7 @@ import { openStore, type Store } from './db'
import { createHub } from './hub' import { createHub } from './hub'
import { createApp } from './app' import { createApp } from './app'
import { attachWs } from './ws' import { attachWs } from './ws'
import { createUnclaimedPool } from './claim' import { createBoardRegistry } from './claim'
import type { WsEvent } from './types' import type { WsEvent } from './types'
const ADMIN = 'admin-code' const ADMIN = 'admin-code'
@@ -25,12 +25,12 @@ describe('collective WS feed', () => {
beforeEach(async () => { beforeEach(async () => {
store = openStore(':memory:') store = openStore(':memory:')
const hub = createHub() const hub = createHub()
const unclaimed = createUnclaimedPool([ const boards = createBoardRegistry([
{ kitId: 'KIT-05', url: 'http://b', token: 't', claimCode: '111111' }, { 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)
attachWs(server, { store, hub, adminCode: ADMIN, judgeCode: JUDGE, unclaimed }) attachWs(server, { store, hub, adminCode: ADMIN, judgeCode: JUDGE, boards })
await new Promise<void>((r) => server.listen(0, r)) await new Promise<void>((r) => server.listen(0, r))
port = (server.address() as AddressInfo).port port = (server.address() as AddressInfo).port
}) })
+5 -5
View File
@@ -2,7 +2,7 @@ import { WebSocketServer } from 'ws'
import type { Server } from 'node:http' import type { Server } from 'node:http'
import type { Store } from './db' import type { Store } from './db'
import type { Hub } from './hub' import type { Hub } from './hub'
import type { UnclaimedPool } from './claim' import type { BoardRegistry } from './claim'
import { matches } from './auth' import { matches } from './auth'
const HEARTBEAT_MS = 30000 const HEARTBEAT_MS = 30000
@@ -12,14 +12,14 @@ export interface WsOptions {
hub: Hub hub: Hub
adminCode: string adminCode: string
judgeCode: string judgeCode: string
/** Optional unclaimed-board pool; its kit ids ride along in the snapshot. */ /** Optional board registry; its unclaimed kit ids ride along in the snapshot. */
unclaimed?: UnclaimedPool boards?: BoardRegistry
} }
/** Attach the collective WS feed at /ws: auth via ?code=, snapshot on connect. */ /** Attach the collective WS feed at /ws: auth via ?code=, snapshot on connect. */
export function attachWs( export function attachWs(
server: Server, server: Server,
{ store, hub, adminCode, judgeCode, unclaimed }: WsOptions, { store, hub, adminCode, judgeCode, boards }: WsOptions,
): WebSocketServer { ): WebSocketServer {
const wss = new WebSocketServer({ server, path: '/ws' }) const wss = new WebSocketServer({ server, path: '/ws' })
@@ -35,7 +35,7 @@ export function attachWs(
type: 'snapshot', type: 'snapshot',
teams: store.listTeams(), teams: store.listTeams(),
submissions: store.listSubmissions(), submissions: store.listSubmissions(),
unclaimed: unclaimed?.list().map((u) => u.kitId) ?? [], unclaimed: boards?.unclaimedKits().map((u) => u.kitId) ?? [],
}), }),
) )
hub.add(ws) hub.add(ws)
+18
View File
@@ -80,6 +80,12 @@ export interface ClaimResult {
teamId: string teamId: string
kit: string kit: string
online: boolean online: boolean
/** True when the kit was already claimed — the server resumed its canonical
* team rather than binding a new one (a lost-browser re-claim). */
resumed?: boolean
/** The canonical team snapshot, so a resuming browser can restore its identity
* + progress instead of clobbering the collective with a fresh empty state. */
team?: TeamSnapshot
} }
/** A failed claim, carrying the HTTP status + the server's human message. */ /** A failed claim, carrying the HTTP status + the server's human message. */
@@ -123,6 +129,18 @@ export async function getUnclaimed(code: string): Promise<string[]> {
return ((await res.json()) as { kits: string[] }).kits return ((await res.json()) as { kits: string[] }).kits
} }
/** Instructor-only: release a kit back to the unclaimed pool (unbinds its board)
* so a mis-claimed / reassigned kit can be claimed by another team. */
export async function releaseBoard(kit: string, code: string): Promise<{ released: boolean; teamId: string | null }> {
const res = await fetch(`${API_BASE}/claim/release`, {
method: 'POST',
headers: { ...authHeaders(code), 'content-type': 'application/json' },
body: JSON.stringify({ kit }),
})
if (!res.ok) throw new Error(`releaseBoard ${res.status}`)
return (await res.json()) as { released: boolean; teamId: string | null }
}
// --- ZeroClaw node (participant) ------------------------------------------ // --- ZeroClaw node (participant) ------------------------------------------
/** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */ /** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */
export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> { export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> {
+1
View File
@@ -13,6 +13,7 @@ vi.mock('@/lib/api', () => ({
getTeams: vi.fn().mockResolvedValue([]), getTeams: vi.fn().mockResolvedValue([]),
getSubmissions: vi.fn().mockResolvedValue([]), getSubmissions: vi.fn().mockResolvedValue([]),
getUnclaimed: vi.fn().mockResolvedValue([]), getUnclaimed: vi.fn().mockResolvedValue([]),
releaseBoard: vi.fn().mockResolvedValue({ released: true, teamId: null }),
})) }))
import { Admin } from './Admin' import { Admin } from './Admin'
+49 -4
View File
@@ -1,11 +1,58 @@
import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { AccessGate } from '@/components/AccessGate' import { AccessGate } from '@/components/AccessGate'
import { TeamCard } from '@/components/TeamCard' import { TeamCard } from '@/components/TeamCard'
import { BoardActivity } from '@/components/BoardActivity' import { BoardActivity } from '@/components/BoardActivity'
import { useCollective } from '@/lib/useCollective' import { useCollective } from '@/lib/useCollective'
import { useNow } from '@/lib/useNow' import { useNow } from '@/lib/useNow'
import { releaseBoard } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
/** Release a mis-claimed / reassigned kit back to the pool so another team can
* claim it. The collective WS pushes the refreshed unclaimed list automatically. */
function ReleaseKit({ code }: { code: string }) {
const [kit, setKit] = useState('')
const [msg, setMsg] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const submit = async (e: React.FormEvent) => {
e.preventDefault()
const k = kit.trim().toUpperCase()
if (!k || busy) return
setBusy(true)
setMsg(null)
try {
const r = await releaseBoard(k, code)
setMsg(r.released ? `Released ${k}${r.teamId ? ` (was ${r.teamId})` : ''}` : `${k} was not claimed`)
if (r.released) setKit('')
} catch {
setMsg(`Could not release ${k}`)
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit} className="flex items-center gap-2">
<input
aria-label="Kit to release"
placeholder="KIT-07"
value={kit}
onChange={(e) => setKit(e.target.value)}
className="w-24 font-mono text-xs px-2 py-1 rounded-md border border-border bg-background"
/>
<button
type="submit"
disabled={busy || !kit.trim()}
className="font-mono text-[10px] uppercase tracking-widest px-2 py-1 rounded-md border border-border hover:bg-muted disabled:opacity-40"
>
{busy ? '…' : 'Release'}
</button>
{msg && <span className="font-mono text-[10px] text-muted-foreground">{msg}</span>}
</form>
)
}
function Stat({ label, value }: { label: string; value: number | string }) { function Stat({ label, value }: { label: string; value: number | string }) {
return ( return (
<div className="bg-background p-3 text-center space-y-1"> <div className="bg-background p-3 text-center space-y-1">
@@ -55,13 +102,11 @@ function AdminBoard({ code }: { code: string }) {
</div> </div>
<div className="border border-border rounded-md p-4 space-y-2" data-testid="unclaimed-boards"> <div className="border border-border rounded-md p-4 space-y-2" data-testid="unclaimed-boards">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground"> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Unclaimed boards Unclaimed boards
</div> </div>
<span className="font-mono text-[10px] text-muted-foreground"> <ReleaseKit code={code} />
powered on · waiting to be claimed
</span>
</div> </div>
{unclaimed.length === 0 ? ( {unclaimed.length === 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
+16 -1
View File
@@ -18,6 +18,7 @@ export function TeamRegistration() {
const device = useSession((s) => s.device) const device = useSession((s) => s.device)
const setTeam = useSession((s) => s.setTeam) const setTeam = useSession((s) => s.setTeam)
const setDevice = useSession((s) => s.setDevice) const setDevice = useSession((s) => s.setDevice)
const resumeTeam = useSession((s) => s.resumeTeam)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
useEffect(() => { useEffect(() => {
@@ -114,7 +115,21 @@ export function TeamRegistration() {
connected={device.connected} connected={device.connected}
port={device.port} port={device.port}
initialCode={/^\d{6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined} initialCode={/^\d{6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
onClaimed={(r) => setDevice({ connected: true, port: `board · ${r.kit}`, uptimeS: 0 })} onClaimed={(r) => {
// Resume (a lost-browser re-claim): adopt the board's canonical
// team + restore its progress instead of keeping this fresh id.
if (r.resumed && r.team) {
resumeTeam({
id: r.team.id,
name: r.team.name,
kit: r.team.kit,
members: r.team.members,
phases: r.team.phases,
stats: r.team.stats,
})
}
setDevice({ connected: true, port: `board · ${r.kit}`, uptimeS: 0 })
}}
/> />
{!device.connected && ( {!device.connected && (
<button <button
+20
View File
@@ -19,4 +19,24 @@ describe('session store · teamId', () => {
expect(useSession.getState().teamId).toBe(before) expect(useSession.getState().teamId).toBe(before)
expect(useSession.getState().team.name).toBe('') expect(useSession.getState().team.name).toBe('')
}) })
it('resumeTeam adopts the canonical identity + restores progress (lost-browser re-claim)', () => {
// a fresh browser: random id, empty team
expect(useSession.getState().team.name).toBe('')
useSession.getState().resumeTeam({
id: 'team-07',
name: 'team_resonance',
kit: 'KIT-07',
members: ['ada', 'linus'],
phases: { reg: true, setup: true, m1: false, m2: false, add: false },
stats: { calls: 9, nominal: 5, anomalous: 3, critical: 1 },
})
const s = useSession.getState()
expect(s.teamId).toBe('team-07') // adopted the board's canonical team, not the fresh id
expect(s.team).toEqual({ name: 'team_resonance', kit: 'KIT-07', members: ['ada', 'linus'] })
expect(s.phases.reg).toBe(true)
expect(s.phases.setup).toBe(true)
expect(s.stats.calls).toBe(9)
expect(s.device.connected).toBe(true) // back on the board
})
}) })
+18
View File
@@ -76,6 +76,16 @@ export interface SessionState {
recordEvent: (kind: 'nominal' | 'anomalous' | 'critical') => void recordEvent: (kind: 'nominal' | 'anomalous' | 'critical') => void
setAddLayer: <K extends keyof AddLayers>(key: K, value: AddLayers[K]) => void setAddLayer: <K extends keyof AddLayers>(key: K, value: AddLayers[K]) => void
setSubmission: (s: Submission) => void setSubmission: (s: Submission) => void
/** Adopt a canonical team on a resume (lost-browser re-claim): switch identity
* and restore name/members/phases/stats so we don't clobber synced progress. */
resumeTeam: (snap: {
id: string
name: string
kit: string
members: string[]
phases: Record<PhaseKey, boolean>
stats: SessionStats
}) => void
reset: () => void reset: () => void
} }
@@ -114,6 +124,14 @@ export const useSession = create<SessionState>()(
})), })),
setAddLayer: (key, value) => set((s) => ({ add: { ...s.add, [key]: value } })), setAddLayer: (key, value) => set((s) => ({ add: { ...s.add, [key]: value } })),
setSubmission: (submission) => set({ submission }), setSubmission: (submission) => set({ submission }),
resumeTeam: (snap) =>
set((s) => ({
teamId: snap.id,
team: { name: snap.name, kit: snap.kit, members: snap.members },
phases: { ...s.phases, ...snap.phases },
stats: snap.stats,
device: { ...s.device, connected: true },
})),
reset: () => set(initial), reset: () => set(initial),
}), }),
{ {