feat(onboarding): surface board LAN URL so teams open their node's ZeroClaw web UI

The board self-registers its gateway URL (http://<lan-ip>:8080) but the API
withheld it from the browser — teams saw only an online boolean. The bearer
token is the only real secret; the URL is safe to expose. Return it in the
/claim response and the node status poll, thread it into device.nodeUrl, and
render an 'Open your node →' deep-link in EnvSetup's connected-device card so a
team can reach its own node's embedded web chat directly on the workshop LAN.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-15 16:26:15 -07:00
co-authored by Claude Opus 4.8
parent 95c947be5b
commit af9cae0abc
8 changed files with 51 additions and 8 deletions
+2 -2
View File
@@ -206,7 +206,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, online, resumed: result.resumed, team }) res.status(201).json({ teamId, kit: b.kit, 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
@@ -248,7 +248,7 @@ export function createApp(opts: AppOptions): Express {
const teamId = String(req.params.teamId) const teamId = String(req.params.teamId)
const view = nodes.list().find((n) => n.teamId === teamId) const view = nodes.list().find((n) => n.teamId === teamId)
if (!view) return res.status(404).json({ error: 'no node registered for team' }) if (!view) return res.status(404).json({ error: 'no node registered for team' })
res.json({ teamId, online: view.online }) res.json({ teamId, url: view.url, online: view.online })
}) })
// Participant-scoped SSE: a team watches only its own board's activity // Participant-scoped SSE: a team watches only its own board's activity
+4 -2
View File
@@ -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).toMatchObject({ teamId: 'team-07', kit: 'KIT-07', online: true, resumed: false }) expect(res.body).toMatchObject({ teamId: 'team-07', kit: 'KIT-07', url: board.url, online: true, resumed: false })
expect(JSON.stringify(res.body)).not.toContain('zc_secret_token')
expect(res.body.team).toMatchObject({ id: 'team-07', name: 'team_resonance', kit: 'KIT-07', deviceConnected: true }) 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
@@ -115,7 +116,8 @@ describe('board self-register + claim', () => {
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)
const res = await request(app).get('/nodes/team-07/status').expect(200) const res = await request(app).get('/nodes/team-07/status').expect(200)
expect(res.body).toEqual({ teamId: 'team-07', online: true }) expect(res.body).toEqual({ teamId: 'team-07', url: board.url, online: true })
expect(JSON.stringify(res.body)).not.toContain('zc_secret_token')
}) })
it('resumes (not rejects) a re-claim of the same kit to its canonical team', async () => { it('resumes (not rejects) a re-claim of the same kit to its canonical team', async () => {
+5 -2
View File
@@ -93,6 +93,9 @@ export interface ClaimResult {
teamId: string teamId: string
kit: string kit: string
online: boolean online: boolean
/** The board's LAN URL (e.g. http://<ip>:8080) for the team's embedded ZeroClaw
* web UI. Safe to expose — only the bearer token stays server-side. */
url?: string
/** True when the kit was already claimed — the server resumed its canonical /** True when the kit was already claimed — the server resumed its canonical
* team rather than binding a new one (a lost-browser re-claim). */ * team rather than binding a new one (a lost-browser re-claim). */
resumed?: boolean resumed?: boolean
@@ -129,10 +132,10 @@ export async function claimBoard(input: ClaimInput): Promise<ClaimResult> {
} }
/** Poll a team's board liveness (used by the setup self-test). */ /** Poll a team's board liveness (used by the setup self-test). */
export async function getNodeStatus(teamId: string): Promise<{ teamId: string; online: boolean }> { export async function getNodeStatus(teamId: string): Promise<{ teamId: string; url?: string; online: boolean }> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/status`) const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/status`)
if (!res.ok) throw new Error(`getNodeStatus ${res.status}`) if (!res.ok) throw new Error(`getNodeStatus ${res.status}`)
return (await res.json()) as { teamId: string; online: boolean } return (await res.json()) as { teamId: string; url?: string; online: boolean }
} }
/** Instructor-only: kit ids of boards that have self-registered but are unclaimed. */ /** Instructor-only: kit ids of boards that have self-registered but are unclaimed. */
+17
View File
@@ -72,6 +72,23 @@ describe('EnvSetup', () => {
}) })
}) })
it('shows an "Open your node" link to the board url when connected with a nodeUrl', () => {
useSession.getState().setMode('live')
useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0, nodeUrl: 'http://192.168.1.7:8080' })
renderPage()
const link = screen.getByRole('link', { name: /open your node/i })
expect(link).toHaveAttribute('href', 'http://192.168.1.7:8080')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('hides the "Open your node" link when there is no nodeUrl', () => {
useSession.getState().setMode('live')
useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0, nodeUrl: null })
renderPage()
expect(screen.queryByRole('link', { name: /open your node/i })).not.toBeInTheDocument()
})
it('keeps Proceed gated when connected but self-test has not run', () => { it('keeps Proceed gated when connected but self-test has not run', () => {
useSession.getState().setMode('live') useSession.getState().setMode('live')
useSession.getState().setDevice({ connected: true, port: 'x', uptimeS: 0 }) useSession.getState().setDevice({ connected: true, port: 'x', uptimeS: 0 })
+10
View File
@@ -141,6 +141,16 @@ export function EnvSetup() {
<span className="text-sm font-medium">Connected</span> <span className="text-sm font-medium">Connected</span>
</div> </div>
<div className="font-mono text-[10px] text-muted-foreground mt-1">{device.port}</div> <div className="font-mono text-[10px] text-muted-foreground mt-1">{device.port}</div>
{device.nodeUrl && (
<a
href={device.nodeUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-mono text-[10px] text-primary hover:underline mt-2"
>
Open your node →
</a>
)}
</div> </div>
) : ( ) : (
<div className="border border-amber/40 bg-amber/5 rounded-md px-4 py-3 text-sm"> <div className="border border-amber/40 bg-amber/5 rounded-md px-4 py-3 text-sm">
+1 -1
View File
@@ -128,7 +128,7 @@ export function TeamRegistration() {
stats: r.team.stats, stats: r.team.stats,
}) })
} }
setDevice({ connected: true, port: `board · ${r.kit}`, uptimeS: 0 }) setDevice({ connected: true, port: `board · ${r.kit}`, uptimeS: 0, nodeUrl: r.url ?? null })
}} }}
/> />
{!device.connected && ( {!device.connected && (
+8
View File
@@ -12,6 +12,14 @@ describe('session store · teamId', () => {
expect(typeof id).toBe('string') expect(typeof id).toBe('string')
}) })
it('defaults device.nodeUrl to null and lets a claim set it', () => {
expect(useSession.getState().device.nodeUrl).toBeNull()
useSession.getState().setDevice({ connected: true, nodeUrl: 'http://192.168.1.7:8080' })
expect(useSession.getState().device.nodeUrl).toBe('http://192.168.1.7:8080')
useSession.getState().reset()
expect(useSession.getState().device.nodeUrl).toBeNull()
})
it('preserves the team id across reset()', () => { it('preserves the team id across reset()', () => {
const before = useSession.getState().teamId const before = useSession.getState().teamId
useSession.getState().setTeam({ name: 'x' }) useSession.getState().setTeam({ name: 'x' })
+4 -1
View File
@@ -20,6 +20,9 @@ export interface Device {
connected: boolean connected: boolean
port: string | null port: string | null
uptimeS: number uptimeS: number
/** The board's LAN URL for its embedded ZeroClaw web UI, so the team can open
* their own node. Null until a live claim provides it. */
nodeUrl: string | null
} }
export interface Harness { export interface Harness {
@@ -92,7 +95,7 @@ export interface SessionState {
const initial = { const initial = {
teamId: genTeamId(), teamId: genTeamId(),
team: { name: '', members: [] as string[], kit: 'KIT-01' }, team: { name: '', members: [] as string[], kit: 'KIT-01' },
device: { connected: false, port: null, uptimeS: 0 }, device: { connected: false, port: null, uptimeS: 0, nodeUrl: null },
mode: 'sim' as RunMode, mode: 'sim' as RunMode,
phases: { reg: false, setup: false, m1: false, m2: false, add: false } as Record<PhaseKey, boolean>, phases: { reg: false, setup: false, m1: false, m2: false, add: false } as Record<PhaseKey, boolean>,
harness: { harness: {