Chat took the whole page down at the end of every turn with `TypeError: crypto.randomUUID is not a function`. `crypto.randomUUID()` is SECURE-CONTEXT ONLY — over plain http on anything that is not localhost it is not defined at all. Officer is reached at http://officer-dev:9000, which is neither, so all eighteen call sites in the frontend were throwing. The stack shows why it was fatal rather than merely broken: it was called inside a `useState` initialiser, so the throw happened during render and unmounted the tree. The assistant message that has no id yet is created at the end of a turn, which is exactly when it fired. No TLS needed. `crypto.getRandomValues()` carries no such restriction — it is on `Crypto`, not `SubtleCrypto`, and works in an insecure context. helpers/random-id uses randomUUID when it exists and otherwise assembles a v4 from the same CSPRNG: same 122 bits, same version and variant bits. Verified both paths produce a UUID matching the v4 pattern, including with randomUUID deleted. `crypto.subtle` is not used anywhere in the frontend, so randomUUID was the whole of the problem. Audio recording is a different matter — getUserMedia genuinely requires a secure context and cannot be polyfilled. Nine files, eighteen call sites. The vendored hls.mjs is left alone. Two of my own mistakes on the way, both caught by parsing rather than by reading: the rewrite added an import of the helper TO the helper, and inserted another one inside a multi-line import block — the same trap as the officerdb move earlier tonight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
49 lines
2.2 KiB
TypeScript
49 lines
2.2 KiB
TypeScript
/**
|
|
* A UUID v4, in a browser that may not be in a secure context.
|
|
*
|
|
* ── Why this exists ──
|
|
*
|
|
* `crypto.randomUUID()` is SECURE-CONTEXT ONLY. Over plain http on anything that
|
|
* is not localhost it is not defined at all, and calling it throws
|
|
* `TypeError: crypto.randomUUID is not a function`.
|
|
*
|
|
* Officer is reached over the tailnet — `http://officer-dev:9000` — which is
|
|
* neither localhost nor https, so every one of these threw. Worst inside a
|
|
* `useState` initialiser, where the throw happens during render and takes the
|
|
* whole tree down: chat crashed at the end of every turn, on the assistant
|
|
* message that did not have an id yet.
|
|
*
|
|
* `crypto.getRandomValues()` carries NO such restriction — it is on `Crypto`,
|
|
* not on `SubtleCrypto`, and works in an insecure context. So the randomness
|
|
* below is exactly what `randomUUID` would have given; only the convenience
|
|
* wrapper was missing.
|
|
*
|
|
* ── This is not a weakening ──
|
|
*
|
|
* Same CSPRNG, same 122 bits of entropy, same version and variant bits. When
|
|
* `randomUUID` exists it is used unchanged; otherwise the value is assembled by
|
|
* hand from the source `randomUUID` itself draws on.
|
|
*
|
|
* The final fallback is `Math.random`, and it is there for completeness rather
|
|
* than for use: a browser with no `crypto` object at all cannot run this app.
|
|
* Never reached in practice, and marked so nobody mistakes it for a supported
|
|
* path or copies it somewhere it would matter.
|
|
*/
|
|
export function randomId(): string {
|
|
const c = globalThis.crypto;
|
|
|
|
if (typeof c?.randomUUID === 'function') return c.randomUUID();
|
|
|
|
if (typeof c?.getRandomValues === 'function') {
|
|
const bytes = c.getRandomValues(new Uint8Array(16));
|
|
// Version 4, and the RFC 4122 variant. Exactly what randomUUID sets.
|
|
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
|
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
|
const hex = Array.from(bytes, (b) => b.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)}`;
|
|
}
|
|
|
|
// Unreachable in any browser that can run this app. Not a supported path.
|
|
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
|
|
}
|