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:
co-authored by
Claude Opus 4.8
parent
95c947be5b
commit
af9cae0abc
+2
-2
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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. */
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user