diff --git a/AGENTS.md b/AGENTS.md index bcd2ca6..77b0170 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,6 +177,13 @@ The circuit list editor must remain a custom spreadsheet-like component with con Avoid permanent Bootstrap form controls inside table cells. Table cells should show static text by default and switch to inputs only while editing. +## Browser Runtime + +The web UI is also opened over plain HTTP from other machines in the LAN, which +is not a secure context. Do not call secure-context-only browser APIs such as +`crypto.randomUUID()` directly; create client-side ids with `createClientId()` +from `src/frontend/utils/client-id.ts`. + ## Drag-and-Drop Rules Dragging from the circuit identifier / circuit handle moves the whole circuit. diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 03aa38d..8dc993e 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -76,6 +76,7 @@ import { type MutableDistributionBoardComponentRole, } from "../utils/distribution-board-component-editing"; import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history"; +import { createClientId } from "../utils/client-id"; import type { CellKey, ColumnDef, @@ -977,7 +978,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str defaultSortOrder: number ) { return buildCircuitDeviceRowInsertSnapshot({ - id: crypto.randomUUID(), + id: createClientId(), circuitId, values, defaultSortOrder, @@ -1001,7 +1002,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ), data ); - const circuitId = crypto.randomUUID(); + const circuitId = createClientId(); const deviceRows = deviceRowValues.map((row, index) => createDeviceRowSnapshot(circuitId, row, (index + 1) * 10) ); @@ -1169,7 +1170,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str (section) => section.id === intent.sectionId )?.components ?? []; const snapshot = buildDistributionBoardComponentSnapshot({ - id: crypto.randomUUID(), + id: createClientId(), circuitListId, role: intent.role, sectionId: intent.sectionId, @@ -1234,7 +1235,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str projectId, getExpectedProjectRevision(), buildNewCircuitGroupSnapshot({ - id: crypto.randomUUID(), + id: createClientId(), circuitListId, category: values.category, displayName: values.displayName, diff --git a/src/frontend/utils/client-id.ts b/src/frontend/utils/client-id.ts new file mode 100644 index 0000000..f3804d5 --- /dev/null +++ b/src/frontend/utils/client-id.ts @@ -0,0 +1,27 @@ +/** + * Creates a random RFC 4122 version 4 UUID for new entities built in the + * browser. + * + * crypto.randomUUID() exists only in secure contexts (HTTPS or localhost). + * The app is also opened over plain HTTP from other machines in the LAN, where + * it is undefined, so fall back to crypto.getRandomValues(), which is + * available in every context. + */ +export function createClientId( + source: Pick & Partial> = crypto +): string { + if (typeof source.randomUUID === "function") { + return source.randomUUID(); + } + const bytes = source.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20), + ].join("-"); +} diff --git a/tests/client-id.test.ts b/tests/client-id.test.ts new file mode 100644 index 0000000..95baa60 --- /dev/null +++ b/tests/client-id.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { webcrypto } from "node:crypto"; +import { describe, it } from "node:test"; +import { z } from "zod"; +import { createClientId } from "../src/frontend/utils/client-id.js"; + +const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe("client id", () => { + it("uses crypto.randomUUID when the context provides it", () => { + const id = createClientId({ + getRandomValues: () => { + throw new Error("fallback must not be used"); + }, + randomUUID: () => "11111111-2222-4333-8444-555555555555", + }); + assert.equal(id, "11111111-2222-4333-8444-555555555555"); + }); + + it("falls back to getRandomValues outside a secure context", () => { + const insecure = { + getRandomValues: webcrypto.getRandomValues.bind(webcrypto), + } as Pick; + const ids = new Set(); + for (let index = 0; index < 200; index += 1) { + const id = createClientId(insecure); + assert.match(id, uuidV4); + assert.equal(z.string().uuid().safeParse(id).success, true); + ids.add(id); + } + assert.equal(ids.size, 200); + }); + + it("sets version and variant bits regardless of the random bytes", () => { + const allOnes = { + getRandomValues: (array: T) => { + if (array instanceof Uint8Array) { + array.fill(0xff); + } + return array; + }, + } as Pick; + assert.equal(createClientId(allOnes), "ffffffff-ffff-4fff-bfff-ffffffffffff"); + }); +});