feat(api): anchor board identity on durable hwId + persist the registry
The board registry keyed on kitId, so a board that changed IP, was re-provisioned under a new label, or came up on a wiped laptop looked like a brand-new board — breaking claim resume and auto-heal. Key the registry on hwId (WiFi MAC + eMMC serial, survives OS reflash; machine-id does not). kitId is now a display label only. announce() takes hwId and upserts by it, preserving claimedBy across IP/label changes. /nodes/self-register accepts hwId (falls back to kitId for older nodes). Persist boards in a new sqlite `boards` table keyed by hwId and seed the in-memory registry from it on boot, so recognition survives an API restart. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d71899520e
commit
60416cb8c7
+4
-1
@@ -164,7 +164,10 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
if (![b.kitId, b.claimCode].every((v) => typeof v === 'string' && v) || !url) {
|
if (![b.kitId, b.claimCode].every((v) => typeof v === 'string' && v) || !url) {
|
||||||
return res.status(400).json({ error: 'kitId and claimCode are required (url derived from source IP if omitted)' })
|
return res.status(400).json({ error: 'kitId and claimCode are required (url derived from source IP if omitted)' })
|
||||||
}
|
}
|
||||||
const r = boards.announce({ kitId: b.kitId, url, token, claimCode: b.claimCode })
|
// `hwId` is the durable board key (WiFi MAC + eMMC serial). Older nodes that
|
||||||
|
// don't send it fall back to keying by kitId, so they still self-register.
|
||||||
|
const hwId = typeof b.hwId === 'string' && b.hwId ? b.hwId : b.kitId
|
||||||
|
const r = boards.announce({ hwId, kitId: b.kitId, url, token, claimCode: b.claimCode })
|
||||||
if (r.claimed && r.teamId && nodes) {
|
if (r.claimed && r.teamId && nodes) {
|
||||||
await nodes.register({ teamId: r.teamId, url, token })
|
await nodes.register({ teamId: r.teamId, url, token })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ const ADMIN = 'admin-code'
|
|||||||
const JUDGE = 'judge-code'
|
const JUDGE = 'judge-code'
|
||||||
const FLEET = 'fleet-secret'
|
const FLEET = 'fleet-secret'
|
||||||
|
|
||||||
const board = { kitId: 'KIT-07', url: 'http://192.168.1.7:8080', token: 'zc_secret_token', claimCode: '418302' }
|
const board: { kitId: string; url: string; token: string; claimCode: string; hwId?: string } = {
|
||||||
|
kitId: 'KIT-07',
|
||||||
|
url: 'http://192.168.1.7:8080',
|
||||||
|
token: 'zc_secret_token',
|
||||||
|
claimCode: '418302',
|
||||||
|
}
|
||||||
|
|
||||||
describe('board self-register + claim', () => {
|
describe('board self-register + claim', () => {
|
||||||
let store: Store
|
let store: Store
|
||||||
@@ -185,6 +190,18 @@ describe('board self-register + claim', () => {
|
|||||||
expect(pool.body).toEqual({ kits: [] })
|
expect(pool.body).toEqual({ kits: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('recognizes the same board by hwId even after its kit label is re-provisioned', async () => {
|
||||||
|
// a distinct board announces with a durable hwId, then gets claimed
|
||||||
|
await selfRegister({ hwId: 'hw-abc', kitId: 'KIT-XY', claimCode: '999999' }).expect(201)
|
||||||
|
await request(app).post('/claim').send({ kit: 'KIT-XY', teamId: 'team-xy', code: '999999' }).expect(201)
|
||||||
|
// re-provisioned under a new kit label + new IP, SAME hardware → still claimed, no orphan
|
||||||
|
const res = await selfRegister({ hwId: 'hw-abc', kitId: 'KIT-RENAMED', claimCode: '999999', url: 'http://192.168.1.9:8080' }).expect(201)
|
||||||
|
expect(res.body).toMatchObject({ kitId: 'KIT-RENAMED', claimed: true })
|
||||||
|
// the renamed board is not offered as a fresh unclaimed board (only the default KIT-07 remains)
|
||||||
|
const pool = await request(app).get('/nodes/unclaimed').set('x-access-code', ADMIN).expect(200)
|
||||||
|
expect(pool.body).toEqual({ kits: ['KIT-07'] })
|
||||||
|
})
|
||||||
|
|
||||||
it('lets an instructor release a claimed kit back to the pool for reassignment', async () => {
|
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').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
|
await request(app).post('/claim/release').send({ kit: 'KIT-07' }).expect(401) // admin only
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { createBoardRegistry, MAX_FAILS, WINDOW_MS } from './claim'
|
import { createBoardRegistry, MAX_FAILS, WINDOW_MS } from './claim'
|
||||||
|
|
||||||
const board = (over: Partial<Parameters<ReturnType<typeof createBoardRegistry>['announce']>[0]> = {}) => ({
|
const board = (over: Partial<Parameters<ReturnType<typeof createBoardRegistry>['announce']>[0]> = {}) => ({
|
||||||
|
hwId: 'hw-07',
|
||||||
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',
|
||||||
@@ -111,4 +112,27 @@ describe('board registry', () => {
|
|||||||
r.announce(board()) // board rebooted and re-registered
|
r.announce(board()) // board rebooted and re-registered
|
||||||
expect(r.claim('KIT-07', '418302', 'team-07', 0)).toMatchObject({ ok: true })
|
expect(r.claim('KIT-07', '418302', 'team-07', 0)).toMatchObject({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('recognizes the same board (durable hwId) even when its kit label changes', () => {
|
||||||
|
const r = createBoardRegistry()
|
||||||
|
r.announce(board())
|
||||||
|
r.claim('KIT-07', '418302', 'team-07', 0)
|
||||||
|
// re-provisioned under a new kit label + new IP, same hardware → still claimed
|
||||||
|
const res = r.announce(board({ kitId: 'KIT-NEW', url: 'http://192.168.1.9:8080' }))
|
||||||
|
expect(res).toEqual({ claimed: true, teamId: 'team-07' })
|
||||||
|
expect(r.isClaimed('KIT-NEW')).toBe(true)
|
||||||
|
expect(r.get('KIT-NEW')?.claimedBy).toBe('team-07')
|
||||||
|
expect(r.unclaimedKits()).toEqual([]) // not a second, separate board
|
||||||
|
})
|
||||||
|
|
||||||
|
it('seeds from persisted boards and reports each mutation via onChange', () => {
|
||||||
|
const saved: string[] = []
|
||||||
|
const r = createBoardRegistry(
|
||||||
|
[{ hwId: 'hw-07', kitId: 'KIT-07', url: 'u', token: 't', claimCode: '418302', claimedBy: 'team-07' }],
|
||||||
|
(b) => saved.push(`${b.hwId}:${b.claimedBy}`),
|
||||||
|
)
|
||||||
|
expect(r.isClaimed('KIT-07')).toBe(true) // restored claim survives restart
|
||||||
|
r.release('KIT-07')
|
||||||
|
expect(saved).toContain('hw-07:null') // release persisted
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+63
-23
@@ -5,8 +5,13 @@ import { matches } from './auth'
|
|||||||
* bearer token (server-side, never sent to a browser), the per-board claim code
|
* bearer token (server-side, never sent to a browser), the per-board claim code
|
||||||
* an attendee proves possession with (printed / QR-encoded on the kit), and the
|
* an attendee proves possession with (printed / QR-encoded on the kit), and the
|
||||||
* team it is bound to once claimed (`null` while unclaimed).
|
* team it is bound to once claimed (`null` while unclaimed).
|
||||||
|
*
|
||||||
|
* The durable key is `hwId` — a stable hardware fingerprint (WiFi MAC + eMMC
|
||||||
|
* serial) that survives IP change, reboot, laptop-wipe, and OS reflash. `kitId`
|
||||||
|
* is a human-facing label only (it may change if the board is re-provisioned).
|
||||||
*/
|
*/
|
||||||
export interface Board {
|
export interface Board {
|
||||||
|
hwId: string
|
||||||
kitId: string
|
kitId: string
|
||||||
url: string
|
url: string
|
||||||
token: string
|
token: string
|
||||||
@@ -27,11 +32,17 @@ export type ClaimOutcome =
|
|||||||
| { ok: false; reason: 'unknown' | 'bad_code' | 'rate_limited' }
|
| { ok: false; reason: 'unknown' | 'bad_code' | 'rate_limited' }
|
||||||
|
|
||||||
export interface BoardRegistry {
|
export interface BoardRegistry {
|
||||||
/** A board announces itself on boot / on its timer. Upserts url/token/claimCode,
|
/** A board announces itself on boot / on its timer, keyed by its stable `hwId`.
|
||||||
* preserves the existing claim, and reports whether it is already claimed. */
|
* Upserts url/token/claimCode/kitId, preserves the existing claim (so a board
|
||||||
announce(n: { kitId: string; url: string; token: string; claimCode: string }): AnnounceResult
|
* re-announcing under a new IP — or even a new kitId label — is recognized as
|
||||||
|
* the same board), and reports whether it is already claimed. */
|
||||||
|
announce(n: { hwId: string; kitId: string; url: string; token: string; claimCode: string }): AnnounceResult
|
||||||
/** Kit ids of boards that are not yet claimed — never url/token/claimCode. */
|
/** Kit ids of boards that are not yet claimed — never url/token/claimCode. */
|
||||||
unclaimedKits(): { kitId: string }[]
|
unclaimedKits(): { kitId: string }[]
|
||||||
|
/** Full board snapshot by durable hwId (for persistence / recognition). */
|
||||||
|
getByHwId(hwId: string): Board | undefined
|
||||||
|
/** All boards (for persistence). */
|
||||||
|
all(): Board[]
|
||||||
isClaimed(kitId: string): boolean
|
isClaimed(kitId: string): boolean
|
||||||
/**
|
/**
|
||||||
* Claim (or resume) a board by proving its code. First claim binds it to
|
* Claim (or resume) a board by proving its code. First claim binds it to
|
||||||
@@ -55,43 +66,70 @@ export interface BoardRegistry {
|
|||||||
export const MAX_FAILS = 5
|
export const MAX_FAILS = 5
|
||||||
export const WINDOW_MS = 60_000
|
export const WINDOW_MS = 60_000
|
||||||
|
|
||||||
export function createBoardRegistry(seed: Board[] = []): BoardRegistry {
|
/**
|
||||||
const boards = new Map<string, Board>()
|
* @param seed boards to restore (from the persistent store) on boot.
|
||||||
for (const b of seed) boards.set(b.kitId, { ...b })
|
* @param onChange called with a board whenever it is created or mutated
|
||||||
const fails = new Map<string, number[]>() // kitId -> recent failed-attempt timestamps (ms)
|
* (announce/claim/release), so the caller can persist it.
|
||||||
|
*/
|
||||||
|
export function createBoardRegistry(
|
||||||
|
seed: Board[] = [],
|
||||||
|
onChange: (b: Board) => void = () => {},
|
||||||
|
): BoardRegistry {
|
||||||
|
const boards = new Map<string, Board>() // keyed by durable hwId
|
||||||
|
for (const b of seed) boards.set(b.hwId, { ...b })
|
||||||
|
const fails = new Map<string, number[]>() // hwId -> recent failed-attempt timestamps (ms)
|
||||||
|
|
||||||
const recentFails = (kitId: string, nowMs: number): number => {
|
const recentFails = (hwId: string, nowMs: number): number => {
|
||||||
const arr = (fails.get(kitId) ?? []).filter((t) => nowMs - t < WINDOW_MS)
|
const arr = (fails.get(hwId) ?? []).filter((t) => nowMs - t < WINDOW_MS)
|
||||||
if (arr.length) fails.set(kitId, arr)
|
if (arr.length) fails.set(hwId, arr)
|
||||||
else fails.delete(kitId)
|
else fails.delete(hwId)
|
||||||
return arr.length
|
return arr.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kitId is a display label, not the key — resolve it by scan (fleet is small).
|
||||||
|
const byKit = (kitId: string): Board | undefined => [...boards.values()].find((b) => b.kitId === kitId)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
announce(n) {
|
announce(n) {
|
||||||
const claimedBy = boards.get(n.kitId)?.claimedBy ?? null
|
const claimedBy = boards.get(n.hwId)?.claimedBy ?? null
|
||||||
boards.set(n.kitId, { kitId: n.kitId, url: n.url, token: n.token, claimCode: n.claimCode, claimedBy })
|
const board: Board = {
|
||||||
fails.delete(n.kitId)
|
hwId: n.hwId,
|
||||||
|
kitId: n.kitId,
|
||||||
|
url: n.url,
|
||||||
|
token: n.token,
|
||||||
|
claimCode: n.claimCode,
|
||||||
|
claimedBy,
|
||||||
|
}
|
||||||
|
boards.set(n.hwId, board)
|
||||||
|
fails.delete(n.hwId)
|
||||||
|
onChange(board)
|
||||||
return { claimed: claimedBy !== null, teamId: claimedBy }
|
return { claimed: claimedBy !== null, teamId: claimedBy }
|
||||||
},
|
},
|
||||||
unclaimedKits() {
|
unclaimedKits() {
|
||||||
return [...boards.values()].filter((b) => b.claimedBy === null).map((b) => ({ kitId: b.kitId }))
|
return [...boards.values()].filter((b) => b.claimedBy === null).map((b) => ({ kitId: b.kitId }))
|
||||||
},
|
},
|
||||||
|
getByHwId(hwId) {
|
||||||
|
return boards.get(hwId)
|
||||||
|
},
|
||||||
|
all() {
|
||||||
|
return [...boards.values()]
|
||||||
|
},
|
||||||
isClaimed(kitId) {
|
isClaimed(kitId) {
|
||||||
const b = boards.get(kitId)
|
const b = byKit(kitId)
|
||||||
return !!b && b.claimedBy !== null
|
return !!b && b.claimedBy !== null
|
||||||
},
|
},
|
||||||
claim(kitId, code, teamId, nowMs) {
|
claim(kitId, code, teamId, nowMs) {
|
||||||
const board = boards.get(kitId)
|
const board = byKit(kitId)
|
||||||
if (!board) 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(board.hwId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
|
||||||
if (!matches(code, board.claimCode)) {
|
if (!matches(code, board.claimCode)) {
|
||||||
fails.set(kitId, [...(fails.get(kitId) ?? []), nowMs])
|
fails.set(board.hwId, [...(fails.get(board.hwId) ?? []), nowMs])
|
||||||
return { ok: false, reason: 'bad_code' }
|
return { ok: false, reason: 'bad_code' }
|
||||||
}
|
}
|
||||||
fails.delete(kitId)
|
fails.delete(board.hwId)
|
||||||
if (board.claimedBy === null) {
|
if (board.claimedBy === null) {
|
||||||
board.claimedBy = teamId
|
board.claimedBy = teamId
|
||||||
|
onChange(board)
|
||||||
return { ok: true, board, resumed: false }
|
return { ok: true, board, resumed: false }
|
||||||
}
|
}
|
||||||
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
|
||||||
@@ -100,23 +138,25 @@ export function createBoardRegistry(seed: Board[] = []): BoardRegistry {
|
|||||||
// Unique per-board codes → the code identifies the board. No match = bad code.
|
// Unique per-board codes → the code identifies the board. No match = bad code.
|
||||||
const board = [...boards.values()].find((b) => matches(code, b.claimCode))
|
const board = [...boards.values()].find((b) => matches(code, b.claimCode))
|
||||||
if (!board) return { ok: false, reason: 'bad_code' }
|
if (!board) return { ok: false, reason: 'bad_code' }
|
||||||
if (recentFails(board.kitId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
|
if (recentFails(board.hwId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
|
||||||
fails.delete(board.kitId)
|
fails.delete(board.hwId)
|
||||||
if (board.claimedBy === null) {
|
if (board.claimedBy === null) {
|
||||||
board.claimedBy = teamId
|
board.claimedBy = teamId
|
||||||
|
onChange(board)
|
||||||
return { ok: true, board, resumed: false }
|
return { ok: true, board, resumed: false }
|
||||||
}
|
}
|
||||||
return { ok: true, board, resumed: true }
|
return { ok: true, board, resumed: true }
|
||||||
},
|
},
|
||||||
release(kitId) {
|
release(kitId) {
|
||||||
const board = boards.get(kitId)
|
const board = byKit(kitId)
|
||||||
if (!board || board.claimedBy === null) return null
|
if (!board || board.claimedBy === null) return null
|
||||||
const freed = board.claimedBy
|
const freed = board.claimedBy
|
||||||
board.claimedBy = null
|
board.claimedBy = null
|
||||||
|
onChange(board)
|
||||||
return freed
|
return freed
|
||||||
},
|
},
|
||||||
get(kitId) {
|
get(kitId) {
|
||||||
return boards.get(kitId)
|
return byKit(kitId)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
ScoreDTO,
|
ScoreDTO,
|
||||||
LeaderboardRow,
|
LeaderboardRow,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
import type { Board } from './claim'
|
||||||
|
|
||||||
export interface Store {
|
export interface Store {
|
||||||
upsertTeam(t: TeamSnapshot): void
|
upsertTeam(t: TeamSnapshot): void
|
||||||
@@ -17,9 +18,22 @@ export interface Store {
|
|||||||
getSubmission(teamId: string): SubmissionDTO | null
|
getSubmission(teamId: string): SubmissionDTO | null
|
||||||
insertScore(input: ScoreInput, createdAt: string): ScoreDTO
|
insertScore(input: ScoreInput, createdAt: string): ScoreDTO
|
||||||
leaderboard(): LeaderboardRow[]
|
leaderboard(): LeaderboardRow[]
|
||||||
|
/** Persist a board (keyed by durable hwId) so the registry survives restart. */
|
||||||
|
saveBoard(b: Board): void
|
||||||
|
/** All persisted boards, to seed the in-memory registry on boot. */
|
||||||
|
listBoards(): Board[]
|
||||||
close(): void
|
close(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BoardRow {
|
||||||
|
hw_id: string
|
||||||
|
kit_id: string
|
||||||
|
url: string
|
||||||
|
token: string
|
||||||
|
claim_code: string
|
||||||
|
claimed_by: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface TeamRow {
|
interface TeamRow {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -78,6 +92,14 @@ export function openStore(path = ':memory:'): Store {
|
|||||||
notes TEXT NOT NULL DEFAULT '',
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS boards (
|
||||||
|
hw_id TEXT PRIMARY KEY,
|
||||||
|
kit_id TEXT NOT NULL DEFAULT '',
|
||||||
|
url TEXT NOT NULL DEFAULT '',
|
||||||
|
token TEXT NOT NULL DEFAULT '',
|
||||||
|
claim_code TEXT NOT NULL DEFAULT '',
|
||||||
|
claimed_by TEXT
|
||||||
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Migration for DBs created before the `domain` column existed. CREATE TABLE
|
// Migration for DBs created before the `domain` column existed. CREATE TABLE
|
||||||
@@ -128,6 +150,14 @@ export function openStore(path = ':memory:'): Store {
|
|||||||
const setPhaseAddStmt = db.prepare(
|
const setPhaseAddStmt = db.prepare(
|
||||||
`UPDATE teams SET phases = json_set(phases, '$.add', json('true')) WHERE id = ?`,
|
`UPDATE teams SET phases = json_set(phases, '$.add', json('true')) WHERE id = ?`,
|
||||||
)
|
)
|
||||||
|
const saveBoardStmt = db.prepare(`
|
||||||
|
INSERT INTO boards (hw_id, kit_id, url, token, claim_code, claimed_by)
|
||||||
|
VALUES (@hw_id, @kit_id, @url, @token, @claim_code, @claimed_by)
|
||||||
|
ON CONFLICT(hw_id) DO UPDATE SET
|
||||||
|
kit_id=excluded.kit_id, url=excluded.url, token=excluded.token,
|
||||||
|
claim_code=excluded.claim_code, claimed_by=excluded.claimed_by
|
||||||
|
`)
|
||||||
|
const listBoardsStmt = db.prepare('SELECT * FROM boards')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
upsertTeam(t) {
|
upsertTeam(t) {
|
||||||
@@ -202,6 +232,26 @@ export function openStore(path = ':memory:'): Store {
|
|||||||
scoreCount: r.scoreCount,
|
scoreCount: r.scoreCount,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
saveBoard(b) {
|
||||||
|
saveBoardStmt.run({
|
||||||
|
hw_id: b.hwId,
|
||||||
|
kit_id: b.kitId,
|
||||||
|
url: b.url,
|
||||||
|
token: b.token,
|
||||||
|
claim_code: b.claimCode,
|
||||||
|
claimed_by: b.claimedBy,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
listBoards() {
|
||||||
|
return (listBoardsStmt.all() as BoardRow[]).map((r) => ({
|
||||||
|
hwId: r.hw_id,
|
||||||
|
kitId: r.kit_id,
|
||||||
|
url: r.url,
|
||||||
|
token: r.token,
|
||||||
|
claimCode: r.claim_code,
|
||||||
|
claimedBy: r.claimed_by,
|
||||||
|
}))
|
||||||
|
},
|
||||||
close() {
|
close() {
|
||||||
db.close()
|
db.close()
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -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 boards = createBoardRegistry()
|
const boards = createBoardRegistry(store.listBoards(), (b) => store.saveBoard(b))
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
store,
|
store,
|
||||||
broadcast: hub.broadcast,
|
broadcast: hub.broadcast,
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ describe('collective WS feed', () => {
|
|||||||
store = openStore(':memory:')
|
store = openStore(':memory:')
|
||||||
const hub = createHub()
|
const hub = createHub()
|
||||||
const boards = createBoardRegistry([
|
const boards = createBoardRegistry([
|
||||||
{ kitId: 'KIT-05', url: 'http://b', token: 't', claimCode: '111111', claimedBy: null },
|
{ hwId: 'hw-05', 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user