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:
co-authored by
Claude Opus 4.8
parent
4d9d984b04
commit
2c18e67c1b
+52
-24
@@ -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) => {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user