feat(reg): correct Phase 1 for pre-deployed devices + matrix-code binding

Teams already have their boards, so drop the kit pickup + sticker claim. New flow:
run the setup script on your board -> it self-registers and scrolls a code on its
LED matrix -> enter that code to bind the node to your team.

- api: BoardRegistry.claimByCode() binds the unique board whose claimCode matches,
  no kit needed; /claim accepts code-first ({teamId, code[, members]}) and keeps the
  legacy kit path. Snapshot/response use the resolved board's kitId.
- client: ClaimInput.kit optional, carries members.
- BoardClaim: setup-script command + copy, code from the matrix, 'Bind board'.
- TeamRegistration: remove KitSelector + QR-kit preselect; card is now 'Your board'.
- Tests updated + code-first coverage (registry + HTTP).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-21 06:18:09 -07:00
co-authored by Claude Opus 4.8
parent 1b31f6db1b
commit fc5f66021f
8 changed files with 113 additions and 69 deletions
+11 -6
View File
@@ -173,10 +173,15 @@ export function createApp(opts: AppOptions): Express {
app.post('/claim', async (req, res) => { app.post('/claim', async (req, res) => {
if (!boards || !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.teamId !== 'string' || typeof b.code !== 'string') {
return res.status(400).json({ error: 'kit, teamId and code are required' }) return res.status(400).json({ error: 'teamId and code are required' })
} }
const result = boards.claim(b.kit, b.code, b.teamId, Date.parse(now())) // Code-first (board scrolls its code on the matrix, no kit picked) is the
// default; a supplied `kit` keeps the legacy sticker-claim path working.
const result =
typeof b.kit === 'string' && b.kit
? boards.claim(b.kit, b.code, b.teamId, Date.parse(now()))
: boards.claimByCode(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?' })
@@ -184,7 +189,7 @@ export function createApp(opts: AppOptions): Express {
if (result.reason === 'rate_limited') { if (result.reason === 'rate_limited') {
return res.status(429).json({ error: 'too many attempts — wait a minute and try again' }) return res.status(429).json({ error: 'too many attempts — wait a minute and try again' })
} }
return res.status(401).json({ error: 'wrong claim code' }) return res.status(401).json({ error: "wrong code — check what your board is showing on its matrix" })
} }
const teamId = result.board.claimedBy as string // canonical (== b.teamId on first claim) const teamId = result.board.claimedBy as string // canonical (== b.teamId on first claim)
await nodes.register({ teamId, url: result.board.url, token: result.board.token }) await nodes.register({ teamId, url: result.board.url, token: result.board.token })
@@ -196,7 +201,7 @@ export function createApp(opts: AppOptions): Express {
const team: TeamSnapshot = { const team: TeamSnapshot = {
id: teamId, id: teamId,
name: pickName ?? '', name: pickName ?? '',
kit: b.kit, kit: result.board.kitId,
members: pickMembers ?? [], members: pickMembers ?? [],
domain: typeof prev?.domain === 'string' ? prev.domain : '', domain: typeof prev?.domain === 'string' ? prev.domain : '',
phases: { ...emptyPhases, ...(prev?.phases ?? {}) }, phases: { ...emptyPhases, ...(prev?.phases ?? {}) },
@@ -208,7 +213,7 @@ export function createApp(opts: AppOptions): Express {
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 === teamId)?.online ?? false const online = nodes.list().find((n) => n.teamId === teamId)?.online ?? false
res.status(201).json({ teamId, kit: b.kit, url: result.board.url, online, resumed: result.resumed, team }) res.status(201).json({ teamId, kit: result.board.kitId, url: result.board.url, online, resumed: result.resumed, team })
}) })
// Instructor action: release a kit back to the unclaimed pool and unbind its // Instructor action: release a kit back to the unclaimed pool and unbind its
+14
View File
@@ -112,6 +112,20 @@ describe('board self-register + claim', () => {
expect(pool.body).toEqual({ kits: [] }) expect(pool.body).toEqual({ kits: [] })
}) })
it('code-first: binds by the matrix code with no kit supplied', async () => {
const res = await request(app)
.post('/claim')
.send({ teamId: 'team-07', teamName: 'team_resonance', members: ['A. Rossi'], code: '418302' })
.expect(201)
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', members: ['A. Rossi'], deviceConnected: true })
expect(JSON.stringify(res.body)).not.toContain('zc_secret_token')
})
it('code-first: a wrong code is rejected', async () => {
await request(app).post('/claim').send({ teamId: 'team-07', code: '000000' }).expect(401)
})
it('exposes public per-team liveness after a claim', async () => { it('exposes public per-team liveness after a claim', async () => {
await request(app).get('/nodes/team-07/status').expect(404) // not yet claimed await request(app).get('/nodes/team-07/status').expect(404) // not yet claimed
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)
+19
View File
@@ -51,6 +51,25 @@ describe('board registry', () => {
expect(again.ok && again.board.claimedBy).toBe('team-07') // canonical, not team-99 expect(again.ok && again.board.claimedBy).toBe('team-07') // canonical, not team-99
}) })
it('claimByCode binds the matching board with no kit, and rejects a wrong code', () => {
const r = createBoardRegistry()
r.announce(board())
expect(r.claimByCode('000000', 'team-07', 0)).toEqual({ ok: false, reason: 'bad_code' })
const out = r.claimByCode('418302', 'team-07', 0)
expect(out).toMatchObject({ ok: true, resumed: false })
expect(out.ok && out.board.kitId).toBe('KIT-07')
expect(r.isClaimed('KIT-07')).toBe(true)
})
it('claimByCode resumes an already-claimed board to its canonical team', () => {
const r = createBoardRegistry()
r.announce(board())
r.claimByCode('418302', 'team-07', 0)
const again = r.claimByCode('418302', 'team-99', 1)
expect(again).toMatchObject({ ok: true, resumed: true })
expect(again.ok && again.board.claimedBy).toBe('team-07')
})
it('a rebooted claimed board stays claimed, out of the pool, with refreshed url/token (auto-heal)', () => { it('a rebooted claimed board stays claimed, out of the pool, with refreshed url/token (auto-heal)', () => {
const r = createBoardRegistry() const r = createBoardRegistry()
r.announce(board()) r.announce(board())
+18
View File
@@ -40,6 +40,12 @@ export interface BoardRegistry {
* lost its browser can get back onto its own board. Rate-limited per kit. * lost its browser can get back onto its own board. Rate-limited per kit.
*/ */
claim(kitId: string, code: string, teamId: string, nowMs: number): ClaimOutcome claim(kitId: string, code: string, teamId: string, nowMs: number): ClaimOutcome
/**
* Claim (or resume) a board by its code ALONE — the attendee proves possession
* by reading the code the board scrolls on its own matrix, with no kit to pick.
* Finds the unique board whose `claimCode` matches; otherwise `bad_code`.
*/
claimByCode(code: string, teamId: string, nowMs: number): ClaimOutcome
/** Release a kit back to unclaimed; returns the freed teamId (or null). */ /** Release a kit back to unclaimed; returns the freed teamId (or null). */
release(kitId: string): string | null release(kitId: string): string | null
get(kitId: string): Board | undefined get(kitId: string): Board | undefined
@@ -90,6 +96,18 @@ export function createBoardRegistry(seed: Board[] = []): BoardRegistry {
} }
return { ok: true, board, resumed: true } // already claimed → resume to the canonical team return { ok: true, board, resumed: true } // already claimed → resume to the canonical team
}, },
claimByCode(code, teamId, nowMs) {
// Unique per-board codes → the code identifies the board. No match = bad code.
const board = [...boards.values()].find((b) => matches(code, b.claimCode))
if (!board) return { ok: false, reason: 'bad_code' }
if (recentFails(board.kitId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
fails.delete(board.kitId)
if (board.claimedBy === null) {
board.claimedBy = teamId
return { ok: true, board, resumed: false }
}
return { ok: true, board, resumed: true }
},
release(kitId) { release(kitId) {
const board = boards.get(kitId) const board = boards.get(kitId)
if (!board || board.claimedBy === null) return null if (!board || board.claimedBy === null) return null
+27 -13
View File
@@ -5,28 +5,31 @@ import { claimBoard, ClaimError, type ClaimResult } from '@/lib/api'
export interface BoardClaimProps { export interface BoardClaimProps {
teamId: string teamId: string
kit: string
teamName: string teamName: string
members: string[]
connected: boolean connected: boolean
port: string | null port: string | null
onClaimed: (result: ClaimResult) => void onClaimed: (result: ClaimResult) => void
/** Pre-fill the code (from the kit QR's ?code= param). */ /** Pre-fill the code (e.g. from a ?code= param). */
initialCode?: string initialCode?: string
} }
/** The three physical bring-up steps an attendee performs before claiming. */ /** The self-service bring-up steps — the board is already set up from the week. */
const STEPS = [ const STEPS = [
'Plug your Uno Q into power over USB-C — the 13×8 matrix lights up.', 'On your board, open a terminal and run the workshop setup script (below).',
'Wait ~30 s for it to boot and join the workshop network.', 'It checks your node is ready, registers it, and scrolls a code across the LED matrix.',
'Enter the 6-digit claim code printed on your kit sticker.', 'Type the code your board is showing to bind it to your team.',
] ]
const SETUP_CMD = 'curl -fsSL https://apess.redclaw.dev/setup.sh | bash'
/** /**
* The board bring-up wizard: walks the attendee through powering on their Uno Q * The board bring-up wizard for pre-deployed devices: the attendee runs the
* and claims it to their team by proving the kit's claim code. On success the * setup script on their own Uno Q, which self-registers the node and shows a
* board is bound server-side (its bearer token never touches the browser). * code on its matrix; entering that code binds the board to the team. The
* bearer token never touches the browser.
*/ */
export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed, initialCode }: BoardClaimProps) { export function BoardClaim({ teamId, teamName, members, connected, port, onClaimed, initialCode }: BoardClaimProps) {
const [code, setCode] = useState(initialCode ?? '') const [code, setCode] = useState(initialCode ?? '')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -49,7 +52,7 @@ export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed,
setBusy(true) setBusy(true)
setError(null) setError(null)
try { try {
const result = await claimBoard({ teamId, kit, teamName, code: trimmed }) const result = await claimBoard({ teamId, teamName, members, code: trimmed })
onClaimed(result) onClaimed(result)
} catch (e) { } catch (e) {
setError(e instanceof ClaimError ? e.message : 'Could not reach the workshop — check your connection.') setError(e instanceof ClaimError ? e.message : 'Could not reach the workshop — check your connection.')
@@ -68,18 +71,29 @@ export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed,
</li> </li>
))} ))}
</ol> </ol>
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
<code className="font-mono text-[11px] text-foreground/90 select-all flex-1 truncate">{SETUP_CMD}</code>
<button
type="button"
aria-label="Copy setup command"
onClick={() => navigator.clipboard?.writeText(SETUP_CMD)}
className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground hover:text-foreground shrink-0"
>
Copy
</button>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
aria-label="Claim code" aria-label="Claim code"
inputMode="numeric" inputMode="numeric"
placeholder="418302" placeholder="code on your matrix"
value={code} value={code}
onChange={(e) => setCode(e.target.value)} onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && claim()} onKeyDown={(e) => e.key === 'Enter' && claim()}
className="font-mono" className="font-mono"
/> />
<Button onClick={claim} disabled={busy || !code.trim()}> <Button onClick={claim} disabled={busy || !code.trim()}>
{busy ? 'Claiming…' : 'Claim board'} {busy ? 'Binding…' : 'Bind board'}
</Button> </Button>
</div> </div>
{error && ( {error && (
+3 -1
View File
@@ -85,8 +85,10 @@ export async function getLeaderboard(code: string): Promise<LeaderboardRow[]> {
// --- board onboarding (participant) --------------------------------------- // --- board onboarding (participant) ---------------------------------------
export interface ClaimInput { export interface ClaimInput {
teamId: string teamId: string
kit: string /** Optional legacy sticker path; omit for code-first (board shows its code). */
kit?: string
teamName?: string teamName?: string
members?: string[]
code: string code: string
} }
export interface ClaimResult { export interface ClaimResult {
+15 -30
View File
@@ -36,13 +36,6 @@ describe('TeamRegistration', () => {
expect(useSession.getState().team.name).toBe('team_resonance') expect(useSession.getState().team.name).toBe('team_resonance')
}) })
it('persists the selected kit to the session store on click', async () => {
const user = userEvent.setup()
renderPage()
await user.click(screen.getByRole('button', { name: 'KIT-04' }))
expect(useSession.getState().team.kit).toBe('KIT-04')
})
it('gates the Proceed button until name + member + board are ready', async () => { it('gates the Proceed button until name + member + board are ready', async () => {
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
@@ -61,35 +54,37 @@ describe('TeamRegistration', () => {
expect(proceed).toBeEnabled() expect(proceed).toBeEnabled()
}) })
it('claims a board with the kit code and marks it connected', async () => { it('binds a board by the matrix code and marks it connected', async () => {
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: async () => ({ teamId: 't', kit: 'KIT-01', online: true }), json: async () => ({ teamId: 't', kit: 'crimson-otter', online: true }),
}) })
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
await user.type(screen.getByLabelText(/claim code/i), '418302') await user.type(screen.getByLabelText(/claim code/i), '4821')
await user.click(screen.getByRole('button', { name: /claim board/i })) await user.click(screen.getByRole('button', { name: /bind board/i }))
expect(await screen.findByTestId('board-connected')).toBeInTheDocument() expect(await screen.findByTestId('board-connected')).toBeInTheDocument()
expect(useSession.getState().device.connected).toBe(true) expect(useSession.getState().device.connected).toBe(true)
// the claim went out with the typed code + selected kit // code-first: the claim carries the code, no kit
const [, init] = fetchMock.mock.calls[0] const [, init] = fetchMock.mock.calls[0]
expect(JSON.parse(init.body)).toMatchObject({ kit: 'KIT-01', code: '418302' }) const body = JSON.parse(init.body)
expect(body).toMatchObject({ code: '4821' })
expect(body.kit).toBeUndefined()
}) })
it('surfaces a wrong-code error from the server', async () => { it('surfaces a wrong-code error from the server', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: 'wrong claim code' }) }), vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: 'wrong code — check your matrix' }) }),
) )
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
await user.type(screen.getByLabelText(/claim code/i), '000000') await user.type(screen.getByLabelText(/claim code/i), '0000')
await user.click(screen.getByRole('button', { name: /claim board/i })) await user.click(screen.getByRole('button', { name: /bind board/i }))
expect(await screen.findByRole('alert')).toHaveTextContent(/wrong claim code/i) expect(await screen.findByRole('alert')).toHaveTextContent(/wrong code/i)
expect(useSession.getState().device.connected).toBe(false) expect(useSession.getState().device.connected).toBe(false)
}) })
@@ -103,22 +98,12 @@ describe('TeamRegistration', () => {
expect(useSession.getState().phases.reg).toBe(true) expect(useSession.getState().phases.reg).toBe(true)
}) })
it('pre-selects kit from the ?kit= URL param (QR sticker flow)', () => { it('pre-fills the claim code from the ?code= URL param', () => {
render( render(
<MemoryRouter initialEntries={['/workshop?kit=KIT-12']}> <MemoryRouter initialEntries={['/workshop?code=4821']}>
<TeamRegistration /> <TeamRegistration />
</MemoryRouter>, </MemoryRouter>,
) )
expect(useSession.getState().team.kit).toBe('KIT-12') expect(screen.getByLabelText(/claim code/i)).toHaveValue('4821')
expect(screen.getByRole('button', { name: 'KIT-12' })).toHaveAttribute('aria-pressed', 'true')
})
it('pre-fills the claim code from the ?code= URL param (full QR flow)', () => {
render(
<MemoryRouter initialEntries={['/workshop?kit=KIT-07&code=418302']}>
<TeamRegistration />
</MemoryRouter>,
)
expect(screen.getByLabelText(/claim code/i)).toHaveValue('418302')
}) })
}) })
+6 -19
View File
@@ -1,11 +1,9 @@
import { useEffect } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { MemberFields } from '@/components/MemberFields' import { MemberFields } from '@/components/MemberFields'
import { KitSelector } from '@/components/KitSelector'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { BoardClaim } from '@/components/BoardClaim' import { BoardClaim } from '@/components/BoardClaim'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
@@ -21,11 +19,6 @@ export function TeamRegistration() {
const resumeTeam = useSession((s) => s.resumeTeam) const resumeTeam = useSession((s) => s.resumeTeam)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
useEffect(() => {
const k = params.get('kit')
if (k && /^KIT-\d{2}$/.test(k)) setTeam({ kit: k })
}, [params, setTeam])
const ready = team.name.trim().length > 0 && team.members.length > 0 && device.connected const ready = team.name.trim().length > 0 && team.members.length > 0 && device.connected
const onProceed = () => { const onProceed = () => {
@@ -55,8 +48,8 @@ export function TeamRegistration() {
</Badge> </Badge>
<h1 className="text-3xl font-bold tracking-tight">Team registration</h1> <h1 className="text-3xl font-bold tracking-tight">Team registration</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl"> <p className="text-sm text-muted-foreground mt-2 max-w-xl">
Name your team, add 3–5 members, pick up your kit, and claim your board. Name your team, add 3–5 members, then bind the board you already set up this
The QR sticker on your kit pre-selects the kit number for you. week — run the setup script and enter the code it scrolls on its LED matrix.
</p> </p>
</div> </div>
</div> </div>
@@ -93,26 +86,20 @@ export function TeamRegistration() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Kit & device</CardTitle> <CardTitle className="text-base">Your board</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground"> <div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Select kit Bind your node
</div>
<KitSelector value={team.kit} onChange={(kit) => setTeam({ kit })} />
</div>
<div className="space-y-2">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Board
</div> </div>
<BoardClaim <BoardClaim
teamId={teamId} teamId={teamId}
kit={team.kit}
teamName={team.name} teamName={team.name}
members={team.members}
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{4,6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
onClaimed={(r) => { onClaimed={(r) => {
// Resume (a lost-browser re-claim): adopt the board's canonical // Resume (a lost-browser re-claim): adopt the board's canonical
// team + restore its progress instead of keeping this fresh id. // team + restore its progress instead of keeping this fresh id.