From a62ced2f112c0032417cbcadda2b5bb40202c47d Mon Sep 17 00:00:00 2001 From: lovepixel-git Date: Sat, 1 Aug 2026 22:48:04 -0400 Subject: [PATCH] fix(data): stop the field dialog crashing outside a secure context `crypto.randomUUID()` is only defined when `window.isSecureContext` is true. An admin reached over plain HTTP on a LAN address or bare hostname has no such function, so opening the New Field dialog threw `TypeError: crypto.randomUUID is not a function` and crashed the page. `localhost` is treated as a secure context regardless of scheme, which is why this never reproduced in local development. `handleAdd()` in the framework scale panel had the same latent bug. Both call sites move to `nanoid`, already the id primitive everywhere else in the codebase, including `fieldDefaults.ts` and `scaleGroups.ts` in these same two feature areas. `SelectOptionSchema.id` is `Type.String()` with no format constraint, so ids already persisted in UUID shape stay valid and no migration is needed. Fixes #319 --- .../data/newFieldDialogModel.test.ts | 78 +++++++++++++++++++ .../NewFieldDialog/newFieldDialogModel.ts | 3 +- .../ClassGeneratorList.tsx | 3 +- 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/data/newFieldDialogModel.test.ts diff --git a/src/__tests__/data/newFieldDialogModel.test.ts b/src/__tests__/data/newFieldDialogModel.test.ts new file mode 100644 index 000000000..6fa9039a6 --- /dev/null +++ b/src/__tests__/data/newFieldDialogModel.test.ts @@ -0,0 +1,78 @@ +/** + * Unit tests for makeOption() in the New Field dialog model. + * + * Regression cover for the crash reported in #319: `crypto.randomUUID()` is + * only defined in a secure context, so any admin reached over plain HTTP on a + * LAN address or bare hostname (the common Docker Compose setup) has no such + * function and the dialog throws on open. Option ids must be generated with a + * primitive that does not depend on secure-context availability. + */ +import { afterEach, describe, expect, it } from 'bun:test' +import { Value } from '@sinclair/typebox/value' +import { DataFieldSchema } from '@core/data/schemas' +import { makeOption, slugifyOptionValue } from '@admin/pages/data/components/NewFieldDialog/newFieldDialogModel' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Run `fn` with `crypto.randomUUID` absent, mirroring an insecure browsing + * context. Restores the original descriptor afterwards. + */ +function withoutRandomUUID(fn: () => T): T { + const original = Object.getOwnPropertyDescriptor(globalThis.crypto, 'randomUUID') + Object.defineProperty(globalThis.crypto, 'randomUUID', { + value: undefined, + configurable: true, + writable: true, + }) + try { + return fn() + } finally { + if (original) Object.defineProperty(globalThis.crypto, 'randomUUID', original) + else delete (globalThis.crypto as { randomUUID?: unknown }).randomUUID + } +} + +afterEach(() => { + expect(typeof globalThis.crypto.randomUUID).toBe('function') +}) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('makeOption', () => { + it('produces an option in a secure context', () => { + const option = makeOption('In stock') + expect(option.label).toBe('In stock') + expect(option.value).toBe('in_stock') + expect(option.id).toBeTruthy() + }) + + it('produces an option in an insecure context, where crypto.randomUUID is undefined', () => { + const option = withoutRandomUUID(() => makeOption('In stock')) + expect(option.label).toBe('In stock') + expect(option.value).toBe('in_stock') + expect(option.id).toBeTruthy() + }) + + it('generates distinct ids in an insecure context', () => { + const ids = withoutRandomUUID(() => + Array.from({ length: 100 }, () => makeOption('Option').id), + ) + expect(new Set(ids).size).toBe(100) + }) + + it('keeps select options schema-valid when generated in an insecure context', () => { + const options = withoutRandomUUID(() => + ['Draft', 'Published'].map((label) => { + const option = makeOption(label) + return { id: option.id, label, value: option.value || slugifyOptionValue(label) } + }), + ) + const field = { type: 'select', id: 'status', label: 'Status', options } + expect(Value.Check(DataFieldSchema, field)).toBe(true) + }) +}) diff --git a/src/admin/pages/data/components/NewFieldDialog/newFieldDialogModel.ts b/src/admin/pages/data/components/NewFieldDialog/newFieldDialogModel.ts index 84947ee6c..e4e74d03e 100644 --- a/src/admin/pages/data/components/NewFieldDialog/newFieldDialogModel.ts +++ b/src/admin/pages/data/components/NewFieldDialog/newFieldDialogModel.ts @@ -1,3 +1,4 @@ +import { nanoid } from 'nanoid' import type { DataFieldType } from '@core/data/schemas' export interface DraftOption { @@ -59,7 +60,7 @@ export function fieldIdFromLabel(label: string): string { } export function makeOption(label: string): DraftOption { - return { id: crypto.randomUUID(), label, value: slugifyOptionValue(label) } + return { id: nanoid(), label, value: slugifyOptionValue(label) } } export function fieldIdError(id: string, existingIds: string[]): string | null { diff --git a/src/admin/pages/site/panels/FrameworkScalePanel/ClassGeneratorList.tsx b/src/admin/pages/site/panels/FrameworkScalePanel/ClassGeneratorList.tsx index 6fd5ac44f..944041734 100644 --- a/src/admin/pages/site/panels/FrameworkScalePanel/ClassGeneratorList.tsx +++ b/src/admin/pages/site/panels/FrameworkScalePanel/ClassGeneratorList.tsx @@ -1,3 +1,4 @@ +import { nanoid } from 'nanoid' import { Button } from '@ui/components/Button' import { ClassGeneratorRow } from './ClassGeneratorRow' import type { GeneratorShape, GroupShape, ScaleAdapter } from './adapter' @@ -29,7 +30,7 @@ export function ClassGeneratorList({ function handleAdd() { const fresh = { - id: crypto.randomUUID(), + id: nanoid(), name: `${groupNamingConvention}-*`, property: [adapter.classGeneratorProperties[0]?.value ?? ''], tabId: groupId,