From 79fcb75ffbde6298922e87810987db540a0b1acd Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Sat, 26 Sep 2026 12:01:43 +0200 Subject: [PATCH] Create client ids without crypto.randomUUID crypto.randomUUID() is only defined in secure contexts (HTTPS or localhost). Opened over plain HTTP from another machine in the LAN, the circuit list editor failed with "crypto.randomUUID is not a function" whenever it created a circuit, device row, circuit group or distribution board component, including typing into the -frei- row. createClientId() uses crypto.randomUUID() where available and otherwise builds an RFC 4122 version 4 UUID from crypto.getRandomValues(), which works in every context. AGENTS.md notes the rule. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 7 +++ .../components/circuit-tree-editor.tsx | 9 ++-- src/frontend/utils/client-id.ts | 27 +++++++++++ tests/client-id.test.ts | 45 +++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 src/frontend/utils/client-id.ts create mode 100644 tests/client-id.test.ts diff --git a/AGENTS.md b/AGENTS.md index f1a509d..0e252dd 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"); + }); +});