fix: make the app boot on an insecure origin, and re-aim the capture harness
Three things, all found by trying to re-shoot the product imagery and failing.
**`crypto.randomUUID` is secure-context only.** `main.ts` called it at module top
level for its three wire identities, so on any origin that is not HTTPS and not
`localhost` the call threw before the scene was built and the app stopped at
"Starting up" with one TypeError and no other symptom. Every way a developer
normally opens this app is a secure context — `vite dev` and `vite preview` serve
localhost, the deployed site is HTTPS — which is why this survived since 3326d2e.
It breaks the brand-capture harness, which serves `dist/` over
`http://tera.lumbridgecorp.com:5210` so the app can read its own hostname and
decide which door it is; it breaks the plain `http://` static host STATIC.md
explicitly invites; and it breaks opening the dev server by LAN IP to try it on a
phone. `src/ids.ts` prefers the platform's `randomUUID` and falls back to
`getRandomValues`, which carries no such restriction. The tests exercise the
fallback specifically, because the happy path was never the broken one.
**`waitForFunction` was ignoring its own timeout.** Playwright's signature is
`(pageFunction, arg, options)` and all four call sites in `shots.mjs` and
`films.mjs` passed `{ timeout: 180_000 }` second, binding it as the predicate's
argument. The wait silently used the 30 s default, which was invisible for as
long as the app booted inside thirty seconds and started failing the moment the
California board grew its relief — with "Timeout 30000ms exceeded" reported
against a line that plainly reads 180_000.
**The office shot list photographed a building that no longer exists.** The
`expect` guard caught it and refused to shoot, which is exactly what it is for:
chapter 0 is "Front Door" now, not "The Floor". But the captions were staler than
the labels — they described forty-eight metres by eighteen, thirty-six seats in
four benches and a fourteen-metre interstitial commons, and Lumbridge HQ is a
live/work studio now. Re-aimed at the buildings that exist: `office-floor` and
`office-desks` at the SF studio (the second specifically at the bench, because the
desk mic and the machine speaker are the new thing there), `office-commons` at the
LA courtyard, `office-hangar` still at Frontier Valley and now honest about being
in development. The four ids are deliberately unchanged: the manifest emits a
`ShotId` union that v4 imports, so renaming one fails v4's typecheck at push.
Also `#onboarding-host` joins the harness's clutter list. The first-run card is
correct behaviour for a real visitor and wrong in a product photograph, and it had
quietly placed itself in the middle of every frame — which is the general hazard
that list exists for, because a shot with a stray card still renders and still
looks deliberate.
`bay-relief`'s daylight frame moves from 07:40 to 09:10. At 07:40 the marine layer
buried the heightfield the shot exists to demonstrate; the version currently on the
site is almost entirely white. Its `note` moves with it, since the note names the
hour and a stale one describes light that is not in the picture.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* `randomId` exists because the app must boot on an insecure origin.
|
||||
*
|
||||
* `crypto.randomUUID` is defined only in a secure context. Every way a
|
||||
* developer normally opens this app is one — `vite dev` and `vite preview` serve
|
||||
* `localhost`, the deployed site is HTTPS — so a top-level call to it in
|
||||
* `main.ts` ran green for months and threw on the two origins nobody tries:
|
||||
* the brand-capture harness, which serves `dist/` over
|
||||
* `http://tera.lumbridgecorp.com:5210` so the app can read its own hostname, and
|
||||
* the plain `http://` static host `deploy/STATIC.md` invites.
|
||||
*
|
||||
* The symptom was a boot card stuck on "Starting up" and one TypeError, which is
|
||||
* why these tests check the *fallback path specifically* rather than only the
|
||||
* happy one — the happy one was never broken.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { randomId } from "../../ids.ts";
|
||||
|
||||
const V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
/** Run `fn` with `globalThis.crypto` replaced, then put it back. */
|
||||
function withCrypto<T>(replacement: unknown, fn: () => T): T {
|
||||
const real = Object.getOwnPropertyDescriptor(globalThis, "crypto");
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
value: replacement,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (real === undefined) delete (globalThis as { crypto?: unknown }).crypto;
|
||||
else Object.defineProperty(globalThis, "crypto", real);
|
||||
}
|
||||
}
|
||||
|
||||
describe("randomId", () => {
|
||||
it("is a well-formed v4 with the platform's own implementation", () => {
|
||||
assert.match(randomId(), V4);
|
||||
});
|
||||
|
||||
it("still returns a well-formed v4 on an insecure origin, where randomUUID is absent", () => {
|
||||
// Exactly the shape of an insecure context: getRandomValues, no randomUUID.
|
||||
const insecure = {
|
||||
getRandomValues<T extends ArrayBufferView>(view: T): T {
|
||||
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 37 + 11) & 0xff;
|
||||
return view;
|
||||
},
|
||||
};
|
||||
const id = withCrypto(insecure, () => randomId());
|
||||
assert.match(id, V4, "the fallback must set the version and variant nibbles, not just fill bytes");
|
||||
});
|
||||
|
||||
it("does not throw when there is no crypto at all", () => {
|
||||
const id = withCrypto(undefined, () => randomId());
|
||||
assert.match(id, V4);
|
||||
});
|
||||
|
||||
it("does not repeat itself", () => {
|
||||
const seen = new Set(Array.from({ length: 500 }, () => randomId()));
|
||||
assert.equal(seen.size, 500);
|
||||
});
|
||||
|
||||
it("main.ts mints its wire identities through it, not through crypto directly", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const source = readFileSync(new URL("../../main.ts", import.meta.url), "utf8");
|
||||
assert.ok(
|
||||
!/crypto\.randomUUID/.test(source),
|
||||
"a bare crypto.randomUUID in main.ts runs at module load and breaks boot on http://",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user