From 7c1021748bb05346196ebf7a98fb53a4f6606862 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 12:38:57 +0000 Subject: [PATCH 1/3] fix(console): insecure origins get a guarded crypto.randomUUID shim (#4563) crypto.randomUUID is exposed only in secure contexts (HTTPS or http://localhost). Reaching a dev box over plain HTTP from another machine - http://192.168.x.x:4001/_console/ - leaves the method undefined, and every unguarded caller throws TypeError: crypto.randomUUID is not a function which takes the console's list views into the ErrorBoundary. The fix is a GLOBAL shim rather than a shared newId() helper, because a helper only reaches call sites that agree to import it and the crashing ones do not: the console's own graph carries unguarded calls in five packages, and the reporter's stack attributes the throwing frame to a vendored chunk this repository does not author at all. The call sites therefore stay untouched - they call a standard platform API correctly; what was missing is the platform. apps/console/index.html loads the shim from a script type=module placed ahead of the /src/main.tsx entry. Both are deferred and run in document order, so the shim precedes every consumer in the app's module graph; a placement test pins that ordering against a silent regression. The fallback builds RFC 4122 v4 from crypto.getRandomValues, which is not secure-context-gated. Guarded on absence, so a native implementation is never replaced. With no entropy source it installs nothing rather than degrading to Math.random (surfacing that state is objectui#4570). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .changeset/clever-hounds-brake.md | 18 ++ apps/console/index.html | 17 ++ .../insecure-origin-crypto.placement.test.ts | 65 +++++++ .../__tests__/insecure-origin-crypto.test.ts | 184 ++++++++++++++++++ apps/console/src/insecure-origin-crypto.ts | 163 ++++++++++++++++ 5 files changed, 447 insertions(+) create mode 100644 .changeset/clever-hounds-brake.md create mode 100644 apps/console/src/__tests__/insecure-origin-crypto.placement.test.ts create mode 100644 apps/console/src/__tests__/insecure-origin-crypto.test.ts create mode 100644 apps/console/src/insecure-origin-crypto.ts diff --git a/.changeset/clever-hounds-brake.md b/.changeset/clever-hounds-brake.md new file mode 100644 index 0000000000..882a2b18fb --- /dev/null +++ b/.changeset/clever-hounds-brake.md @@ -0,0 +1,18 @@ +--- +'@object-ui/console': patch +--- + +Console: restore `crypto.randomUUID` on insecure origins so list views stop crashing on LAN IPs + +`crypto.randomUUID` is exposed only in secure contexts (HTTPS or +`http://localhost`). Reaching a dev box over plain HTTP from another machine — +`http://192.168.x.x:4001/_console/`, the ordinary second-device flow — left the +method undefined, and every unguarded caller threw +`TypeError: crypto.randomUUID is not a function`, taking the console's list +views into the ErrorBoundary. + +The console entry now installs an RFC 4122 v4 fallback built on +`crypto.getRandomValues` (which is not secure-context-gated, so the entropy +stays cryptographic). It is guarded on absence and never replaces a native +implementation, so secure origins are unaffected; with no entropy source +available it installs nothing rather than degrading to `Math.random`. diff --git a/apps/console/index.html b/apps/console/index.html index 9d5037c90e..98b0115f7b 100644 --- a/apps/console/index.html +++ b/apps/console/index.html @@ -46,6 +46,23 @@ + +
diff --git a/apps/console/src/__tests__/insecure-origin-crypto.placement.test.ts b/apps/console/src/__tests__/insecure-origin-crypto.placement.test.ts new file mode 100644 index 0000000000..f10f8bd228 --- /dev/null +++ b/apps/console/src/__tests__/insecure-origin-crypto.placement.test.ts @@ -0,0 +1,65 @@ +/** + * objectui#4563 — the shim's PLACEMENT is the fix, not just its code. + * + * A `crypto.randomUUID` fallback that runs after a consumer has already called + * the missing method fixes nothing. The guarantee the console relies on is + * purely ordering: `index.html` loads the shim from a `script type="module"` + * placed ahead of the `/src/main.tsx` entry, and module scripts are deferred + * and executed in DOCUMENT ORDER — so the shim evaluates before the + * application's first import, hence before every consumer in its module graph. + * + * That ordering is one line in an HTML file with nothing else defending it: + * moving the tag below the entry, renaming the module, or dropping the tag + * during an unrelated `index.html` edit all leave a green build that crashes on + * a LAN IP exactly as before. This test is what makes that regression loud. + */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, it, expect } from 'vitest'; + +const INDEX_HTML = path.resolve(import.meta.dirname, '../../index.html'); +const SHIM_SRC = '/src/insecure-origin-crypto.ts'; +const APP_ENTRY = '/src/main.tsx'; + +describe('apps/console/index.html — insecure-origin crypto shim placement', () => { + const html = readFileSync(INDEX_HTML, 'utf8'); + + it('loads the shim as a module script', () => { + expect(html).toContain(``); + }); + + it('still loads the application entry as a module script', () => { + // Both must be `type="module"`: document-order execution is guaranteed + // between deferred module scripts, and a classic script would break it. + expect(html).toContain(``); + }); + + it('runs the shim BEFORE the application entry', () => { + const shimAt = html.indexOf(SHIM_SRC); + const entryAt = html.indexOf(APP_ENTRY); + + expect(shimAt).toBeGreaterThan(-1); + expect(entryAt).toBeGreaterThan(-1); + expect(shimAt).toBeLessThan(entryAt); + }); + + it('keeps the shim ahead of every script tag that is not the shim itself', () => { + // Stronger than the pairwise check above: nothing at all may be scheduled + // to run before the shim except the inline head scripts that precede it, + // which are pinned here by name so a new one cannot be added silently. + const tags = [...html.matchAll(/]*>/g)].map((match) => match[0]); + const shimIndex = tags.findIndex((tag) => tag.includes(SHIM_SRC)); + const entryIndex = tags.findIndex((tag) => tag.includes(APP_ENTRY)); + + expect(shimIndex).toBeGreaterThan(-1); + expect(entryIndex).toBe(tags.length - 1); + expect(shimIndex).toBeLessThan(entryIndex); + + // Everything before the shim is an inline classic script (early branding, + // the `window.process` polyfill) — no module, and nothing with a `src`. + for (const tag of tags.slice(0, shimIndex)) { + expect(tag).not.toContain('src='); + expect(tag).not.toContain('type="module"'); + } + }); +}); diff --git a/apps/console/src/__tests__/insecure-origin-crypto.test.ts b/apps/console/src/__tests__/insecure-origin-crypto.test.ts new file mode 100644 index 0000000000..56c01be39c --- /dev/null +++ b/apps/console/src/__tests__/insecure-origin-crypto.test.ts @@ -0,0 +1,184 @@ +/** + * objectui#4563 — `crypto.randomUUID` on insecure origins. + * + * The card's defect is a MISSING PLATFORM METHOD, so the tests are written + * against that: the red-first case drives a REAL in-repo consumer + * (`@object-ui/plugin-view`'s `parseSpecFilter`, whose `parseTriplet` mints + * `crypto.randomUUID()` unguarded on the list-view filter path) on a crypto + * object shaped exactly like an insecure origin's, and pins the resulting + * message verbatim against the string the report quotes. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { parseSpecFilter } from '@object-ui/plugin-view'; +import { + randomUuidV4, + installRandomUuidShim, + type GetRandomValues, +} from '../insecure-origin-crypto'; + +/** + * The real entropy source, captured BEFORE any test stubs the global — the + * uniqueness sample must not be graded against a fake. + */ +const realGetRandomValues: GetRandomValues = (buffer) => globalThis.crypto.getRandomValues(buffer); + +/** Canonical RFC 4122 v4, lowercase: version nibble `4`, variant `8|9|a|b`. */ +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +/** What a browser hands you on `http://LAN-IP`: getRandomValues, no randomUUID. */ +function insecureOriginCrypto(): { getRandomValues: GetRandomValues; randomUUID?: unknown } { + return { getRandomValues: realGetRandomValues }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('randomUuidV4 — the generator', () => { + it('renders the canonical 8-4-4-4-12 shape', () => { + expect(randomUuidV4(realGetRandomValues)).toMatch(UUID_V4); + }); + + it('forces the version nibble and variant bits regardless of the entropy', () => { + // All-ones and all-zeros entropy pin the SIX fixed bits exactly: only the + // version nibble (octet 6 high half) and the variant (octet 8 top two bits) + // may differ from the raw bytes. + const ones: GetRandomValues = (buffer) => buffer.fill(0xff); + const zeros: GetRandomValues = (buffer) => buffer.fill(0x00); + + expect(randomUuidV4(ones)).toBe('ffffffff-ffff-4fff-bfff-ffffffffffff'); + expect(randomUuidV4(zeros)).toBe('00000000-0000-4000-8000-000000000000'); + }); + + it('always reports version 4 and the RFC 4122 variant', () => { + for (let i = 0; i < 200; i++) { + const uuid = randomUuidV4(realGetRandomValues); + expect(uuid).toMatch(UUID_V4); + expect(uuid[14]).toBe('4'); + expect(['8', '9', 'a', 'b']).toContain(uuid[19]); + } + }); + + it('draws 16 bytes and does not repeat itself across a sample', () => { + const widths: number[] = []; + randomUuidV4((buffer) => { + widths.push(buffer.length); + return realGetRandomValues(buffer); + }); + expect(widths).toEqual([16]); + + const sample = new Set(Array.from({ length: 1000 }, () => randomUuidV4(realGetRandomValues))); + expect(sample.size).toBe(1000); + }); +}); + +describe('installRandomUuidShim — the installer', () => { + it('installs onto an insecure-origin crypto that lacks randomUUID', () => { + const cryptoLike = insecureOriginCrypto(); + expect('randomUUID' in cryptoLike).toBe(false); + + expect(installRandomUuidShim(cryptoLike)).toBe('installed'); + + expect(typeof cryptoLike.randomUUID).toBe('function'); + expect((cryptoLike.randomUUID as () => string)()).toMatch(UUID_V4); + }); + + it('MUST NOT touch a native implementation — identity is preserved', () => { + const native = (): string => '11111111-2222-4333-8444-555555555555'; + const cryptoLike = { randomUUID: native, getRandomValues: realGetRandomValues }; + + expect(installRandomUuidShim(cryptoLike)).toBe('native'); + + // Identity, not behaviour: the native function object itself is still there. + expect(cryptoLike.randomUUID).toBe(native); + }); + + it('is idempotent — a second install keeps the first fallback', () => { + const cryptoLike = insecureOriginCrypto(); + expect(installRandomUuidShim(cryptoLike)).toBe('installed'); + const first = cryptoLike.randomUUID; + + expect(installRandomUuidShim(cryptoLike)).toBe('native'); + expect(cryptoLike.randomUUID).toBe(first); + }); + + it('refuses to install without an entropy source instead of degrading', () => { + // No getRandomValues => no cryptographic randomness available. We do NOT + // fall back to Math.random: an id generator that only LOOKS like crypto is + // worse than the honest absence. Surfacing this state to the user is + // objectui#4570, deliberately not this shim's job. + const cryptoLike: { randomUUID?: unknown } = {}; + expect(installRandomUuidShim(cryptoLike)).toBe('unavailable'); + expect('randomUUID' in cryptoLike).toBe(false); + }); + + it('reports unavailable rather than throwing when there is no crypto at all', () => { + expect(installRandomUuidShim(undefined)).toBe('unavailable'); + expect(installRandomUuidShim(null)).toBe('unavailable'); + }); + + it('defaults to globalThis.crypto', () => { + const cryptoLike = insecureOriginCrypto(); + vi.stubGlobal('crypto', cryptoLike); + + expect(installRandomUuidShim()).toBe('installed'); + expect((globalThis.crypto.randomUUID as () => string)()).toMatch(UUID_V4); + }); +}); + +describe('the real consumer path (objectui#4563 repro)', () => { + /** + * RED-FIRST. `parseSpecFilter` -> `parseTriplet` calls `crypto.randomUUID()` + * with no guard (packages/plugin-view/src/config/view-config-utils.ts). On an + * insecure origin that is exactly the card's crash, and the message below is + * the one the report quotes verbatim. + */ + it('throws the card verbatim TypeError with no shim installed', () => { + vi.stubGlobal('crypto', insecureOriginCrypto()); + + let caught: unknown; + try { + parseSpecFilter([['name', '=', 'x']]); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(TypeError); + expect((caught as TypeError).message).toBe('crypto.randomUUID is not a function'); + }); + + it('renders the same filter normally once the shim is installed', () => { + const cryptoLike = insecureOriginCrypto(); + vi.stubGlobal('crypto', cryptoLike); + expect(installRandomUuidShim(cryptoLike)).toBe('installed'); + + const parsed = parseSpecFilter([['name', '=', 'x']]); + + expect(parsed.conditions).toHaveLength(1); + expect(parsed.conditions[0]?.field).toBe('name'); + expect(parsed.conditions[0]?.id).toMatch(UUID_V4); + }); +}); + +describe('the module-evaluation side effect', () => { + it('installs on import when the origin is insecure', async () => { + vi.resetModules(); + vi.stubGlobal('crypto', insecureOriginCrypto()); + + const module = await import('../insecure-origin-crypto'); + + expect(module.consoleRandomUuidShimOutcome).toBe('installed'); + expect((globalThis.crypto.randomUUID as () => string)()).toMatch(UUID_V4); + }); + + it('leaves a secure origin untouched on import', async () => { + const native = (): string => '11111111-2222-4333-8444-555555555555'; + vi.resetModules(); + vi.stubGlobal('crypto', { randomUUID: native, getRandomValues: realGetRandomValues }); + + const module = await import('../insecure-origin-crypto'); + + expect(module.consoleRandomUuidShimOutcome).toBe('native'); + expect(globalThis.crypto.randomUUID).toBe(native); + }); +}); diff --git a/apps/console/src/insecure-origin-crypto.ts b/apps/console/src/insecure-origin-crypto.ts new file mode 100644 index 0000000000..07a4bcecb1 --- /dev/null +++ b/apps/console/src/insecure-origin-crypto.ts @@ -0,0 +1,163 @@ +/** + * `crypto.randomUUID` fallback for INSECURE ORIGINS (objectui#4563). + * + * ## Why this exists at all + * + * `crypto.randomUUID` is exposed only in [secure contexts][mdn] — HTTPS, or + * `http://localhost`. Serve the console over plain HTTP from anything else — + * `http://192.168.1.20:4001/_console/`, the ordinary way a second device on + * the LAN reaches a dev box — and the browser simply does not provide the + * method. `window.isSecureContext` is `false` on that origin, and every + * unguarded `crypto.randomUUID()` call throws + * + * TypeError: crypto.randomUUID is not a function + * + * which the console's list views take straight into the ErrorBoundary. + * + * [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID + * + * ## Why a GLOBAL shim and not a shared `newId()` helper + * + * A helper only fixes call sites that agree to import it. The call sites that + * crash do not: at the time of writing the console's own dependency graph + * carries UNGUARDED `crypto.randomUUID()` calls in five packages — the census + * is in the PR for #4563, and `packages/plugin-view`'s `parseTriplet` + * (`config/view-config-utils.ts`) is on the list-view filter path that the + * report reproduces. The reporter's stack additionally attributes the throwing + * frame to a VENDORED chunk, i.e. code this repository does not author at all. + * Guaranteeing the platform method is the only fix that reaches every caller, + * in-repo and vendored alike, without editing any of them. + * + * So the call sites deliberately stay as they are. They are correct code: they + * call a standard platform API. What was missing is the platform. + * + * ## Guarded on ABSENCE + * + * `installRandomUuidShim` never replaces a working implementation — a real + * browser's native `randomUUID` (a CSPRNG) must always win over anything this + * module can build. The shim is installed only when the method is missing, + * which on a secure origin is never. + * + * ## Where the randomness comes from + * + * `crypto.getRandomValues` is NOT secure-context-gated, so it is present on the + * exact origins where `randomUUID` is not. The fallback therefore keeps + * cryptographic-quality randomness and only rebuilds the RFC 4122 formatting + * around it. When `getRandomValues` is missing too we install NOTHING and + * report `'unavailable'` rather than degrade to `Math.random()`: a + * predictable-id generator that silently claims to be `crypto` is worse than + * the honest absence. Surfacing that state to the user is a separate concern + * (objectui#4570). + */ + +/** `0x00`–`0xff` as zero-padded hex pairs — built once, indexed per byte. */ +const HEX_OCTETS: readonly string[] = Array.from({ length: 256 }, (_unused, index) => + (index + 0x100).toString(16).slice(1) +); + +/** What `installRandomUuidShim` did, for tests and for callers that log. */ +export type RandomUuidShimOutcome = + /** A working `randomUUID` was already there; it was left untouched. */ + | 'native' + /** `randomUUID` was missing and the fallback is now in place. */ + | 'installed' + /** No usable entropy source (or no target); nothing was changed. */ + | 'unavailable'; + +/** Fills the passed buffer with cryptographically strong random bytes. */ +export type GetRandomValues = (buffer: Uint8Array) => unknown; + +/** + * Build one RFC 4122 §4.4 version-4 UUID from `getRandomValues`. + * + * 122 random bits, with the 6 fixed bits the format requires: the version + * nibble (`4`) in the high half of byte 6, and the variant bits (`10`) in the + * top two bits of byte 8. Rendered canonically as 8-4-4-4-12 lowercase hex. + */ +export function randomUuidV4(getRandomValues: GetRandomValues): string { + const bytes = new Uint8Array(16); + getRandomValues(bytes); + + // Version 4: high nibble of octet 6 := 0100. + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; + // Variant RFC 4122: top two bits of octet 8 := 10. + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; + + const hex = (index: number): string => HEX_OCTETS[bytes[index] ?? 0] ?? '00'; + + return ( + hex(0) + hex(1) + hex(2) + hex(3) + + '-' + + hex(4) + hex(5) + + '-' + + hex(6) + hex(7) + + '-' + + hex(8) + hex(9) + + '-' + + hex(10) + hex(11) + hex(12) + hex(13) + hex(14) + hex(15) + ); +} + +/** + * Guarantee `randomUUID` on the given `Crypto`-shaped object. + * + * Absence-guarded: a callable `randomUUID` already present is reported as + * `'native'` and left strictly alone (identity-preserving — the test pins it). + * + * The property is defined rather than assigned so the shim also works if a host + * ever exposes `crypto` as a getter-only accessor whose instance still accepts + * own properties; plain assignment is kept as the fallback path, and a target + * that refuses both is reported honestly instead of being assumed to have + * worked. + * + * Idempotent: a second call sees its own installation and returns `'native'`. + * + * @param target Defaults to `globalThis.crypto`. + */ +export function installRandomUuidShim( + target: unknown = typeof globalThis === 'undefined' ? undefined : globalThis.crypto +): RandomUuidShimOutcome { + if (target === null || (typeof target !== 'object' && typeof target !== 'function')) { + return 'unavailable'; + } + + const cryptoLike = target as { randomUUID?: unknown; getRandomValues?: unknown }; + + // Never override a working implementation. + if (typeof cryptoLike.randomUUID === 'function') return 'native'; + + const getRandomValues = cryptoLike.getRandomValues; + if (typeof getRandomValues !== 'function') return 'unavailable'; + + const bound: GetRandomValues = (buffer) => + (getRandomValues as GetRandomValues).call(cryptoLike, buffer); + const fallback = (): string => randomUuidV4(bound); + + try { + Object.defineProperty(cryptoLike, 'randomUUID', { + value: fallback, + writable: true, + configurable: true, + enumerable: false, + }); + } catch { + try { + cryptoLike.randomUUID = fallback; + } catch { + return 'unavailable'; + } + } + + return typeof cryptoLike.randomUUID === 'function' ? 'installed' : 'unavailable'; +} + +/** + * Module-evaluation side effect — this is what actually fixes the console. + * + * `apps/console/index.html` loads this module from a `script type="module"` + * placed BEFORE the `/src/main.tsx` entry. Module scripts are deferred and run + * in document order, so this executes before the application's first import and + * therefore before every consumer in the console's graph. + * `insecure-origin-crypto.placement.test.ts` pins that ordering. + */ +export const consoleRandomUuidShimOutcome: RandomUuidShimOutcome = installRandomUuidShim(); From 28724eb8b56cb77d7080b501cdfbb333d668932c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 12:43:23 +0000 Subject: [PATCH 2/3] test(console): drive the #4563 red-first case through plugin-view's public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first run of these tests was red for four reasons, all of them in the tests rather than the shim, and each one worth keeping written down: - `parseSpecFilter` is not exported from `@object-ui/plugin-view`, so the "real consumer" case threw `parseSpecFilter is not a function` — a red that looks like the card's red and proves nothing. The public entry points that DO reach the unguarded calls are `toFilterGroup` (via parseSpecFilter -> parseTriplet, view-config-utils.ts:146) and `toSortItems` (:294 directly); both are now exercised. - The entropy helper re-read `globalThis.crypto` at call time, so the moment a test stubbed the global with an object whose `getRandomValues` WAS that helper it recursed until the stack blew. It now binds the native function once at module load. - `installRandomUuidShim(undefined)` hits the DEFAULT parameter and so means "use globalThis.crypto" — it cannot pose the no-crypto case. That case is now posed by removing the global. - The placement test compared raw string offsets, and both paths also appear in the explanatory HTML comment above the shim tag, so `indexOf(APP_ENTRY)` found the comment and the ordering assertion inverted while the markup was correct. It now compares script-tag positions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .../insecure-origin-crypto.placement.test.ts | 11 ++- .../__tests__/insecure-origin-crypto.test.ts | 68 +++++++++++++++---- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/apps/console/src/__tests__/insecure-origin-crypto.placement.test.ts b/apps/console/src/__tests__/insecure-origin-crypto.placement.test.ts index f10f8bd228..7f8a64454d 100644 --- a/apps/console/src/__tests__/insecure-origin-crypto.placement.test.ts +++ b/apps/console/src/__tests__/insecure-origin-crypto.placement.test.ts @@ -35,8 +35,13 @@ describe('apps/console/index.html — insecure-origin crypto shim placement', () }); it('runs the shim BEFORE the application entry', () => { - const shimAt = html.indexOf(SHIM_SRC); - const entryAt = html.indexOf(APP_ENTRY); + // Compare SCRIPT TAG positions, never raw string positions: both paths are + // also named in the explanatory HTML comment above the shim tag, so + // `html.indexOf(APP_ENTRY)` finds the COMMENT first and the ordering + // assertion inverts while the real markup is correct. (Measured — this + // test failed exactly that way before the tag-based lookup.) + const shimAt = html.indexOf(` +
diff --git a/apps/console/src/__tests__/insecure-origin-crypto.placement.test.ts b/apps/console/src/__tests__/insecure-origin-crypto.placement.test.ts index 7f8a64454d..25d5d62a40 100644 --- a/apps/console/src/__tests__/insecure-origin-crypto.placement.test.ts +++ b/apps/console/src/__tests__/insecure-origin-crypto.placement.test.ts @@ -2,71 +2,91 @@ * objectui#4563 — the shim's PLACEMENT is the fix, not just its code. * * A `crypto.randomUUID` fallback that runs after a consumer has already called - * the missing method fixes nothing. The guarantee the console relies on is - * purely ordering: `index.html` loads the shim from a `script type="module"` - * placed ahead of the `/src/main.tsx` entry, and module scripts are deferred - * and executed in DOCUMENT ORDER — so the shim evaluates before the - * application's first import, hence before every consumer in its module graph. + * the missing method fixes nothing, so what this file defends is ORDER. * - * That ordering is one line in an HTML file with nothing else defending it: - * moving the tag below the entry, renaming the module, or dropping the tag - * during an unrelated `index.html` edit all leave a green build that crashes on - * a LAN IP exactly as before. This test is what makes that regression loud. + * The guarantee relied upon is that a CLASSIC inline script executes + * synchronously during parse, before any module script and therefore before any + * bundled chunk. The weaker guarantee — document order between two + * `type="module"` scripts — was tried first and MEASURED to fail: Vite merges + * multiple HTML module entries into one chunk, whose static imports are hoisted + * above the merged body, so 16 chunks (`vendor-react`, `ui-components` and + * `RecordDetailView` among them) evaluated before the shim installed. Document + * order is real in the browser but it does not survive bundling. + * + * Hence the assertions below: the shim must stay a classic inline script, and + * it must stay ahead of every script that carries a `src`. */ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; +// Read through Vite's `?raw` rather than `node:fs`: this app's tsconfig is +// browser-only (`lib: ES2020, DOM`, and `types` without `node`), so a +// `node:fs`/`node:path` import fails `tsc` in the console's build even while +// the test itself passes under Vitest. +import html from '../../index.html?raw'; import { describe, it, expect } from 'vitest'; -const INDEX_HTML = path.resolve(import.meta.dirname, '../../index.html'); -const SHIM_SRC = '/src/insecure-origin-crypto.ts'; +const SHIM_MARKER = 'installInsecureOriginRandomUuid'; const APP_ENTRY = '/src/main.tsx'; -describe('apps/console/index.html — insecure-origin crypto shim placement', () => { - const html = readFileSync(INDEX_HTML, 'utf8'); +/** + * Every ``); - }); +const shimIndex = scripts.findIndex((script) => script.body.includes(SHIM_MARKER)); +const entryIndex = scripts.findIndex((script) => script.attrs.includes(APP_ENTRY)); - it('still loads the application entry as a module script', () => { - // Both must be `type="module"`: document-order execution is guaranteed - // between deferred module scripts, and a classic script would break it. - expect(html).toContain(``); +describe('apps/console/index.html — insecure-origin crypto shim placement', () => { + it('is present at all', () => { + expect(shimIndex).toBeGreaterThan(-1); }); - it('runs the shim BEFORE the application entry', () => { - // Compare SCRIPT TAG positions, never raw string positions: both paths are - // also named in the explanatory HTML comment above the shim tag, so - // `html.indexOf(APP_ENTRY)` finds the COMMENT first and the ordering - // assertion inverts while the real markup is correct. (Measured — this - // test failed exactly that way before the tag-based lookup.) - const shimAt = html.indexOf(`