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:
+76
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* A random opaque identifier, in every context the app is actually served from.
|
||||
*
|
||||
* `crypto.randomUUID()` is only defined in a **secure context** — HTTPS, or a
|
||||
* `localhost` origin. `crypto.getRandomValues()` has no such restriction and is
|
||||
* available everywhere `crypto` is. That difference is easy to never notice,
|
||||
* because every way a developer normally opens this app is a secure context:
|
||||
* `vite dev` and `vite preview` both serve `localhost`, and the deployed site is
|
||||
* HTTPS.
|
||||
*
|
||||
* It is not a hypothetical gap. `main.ts` called `crypto.randomUUID()` at module
|
||||
* top level for its three wire identities, so on any other origin the call threw
|
||||
* before the scene was built and the app stopped at "Starting up" with one
|
||||
* `TypeError` in the console and no other symptom. That is:
|
||||
*
|
||||
* - 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 — this is how the bug was found, as a screenshot run
|
||||
* that timed out waiting for a boot that was never coming;
|
||||
* - a plain `http://` static host, which `deploy/STATIC.md` explicitly invites
|
||||
* ("Serve `dist/` from anything. A Content-Security-Policy of `default-src
|
||||
* 'self'` is sufficient") — the zero-config path this project cares about;
|
||||
* - anyone opening the dev server by LAN IP to try it on a phone.
|
||||
*
|
||||
* So the fallback is not defensive clutter. It is the difference between "runs
|
||||
* anywhere" being true and being a sentence in a document.
|
||||
*
|
||||
* The output is UUID-shaped rather than merely random because these strings go
|
||||
* on the realtime wire next to ids minted by other clients, and one shape is
|
||||
* easier to read in a log than two. Version and variant nibbles are set so it is
|
||||
* a well-formed v4 and not something that merely looks like one.
|
||||
*/
|
||||
|
||||
/** 16 random bytes, from the best source this context actually has. */
|
||||
function randomBytes(): Uint8Array {
|
||||
const bytes = new Uint8Array(16);
|
||||
const source = globalThis.crypto;
|
||||
if (source !== undefined && typeof source.getRandomValues === "function") {
|
||||
source.getRandomValues(bytes);
|
||||
return bytes;
|
||||
}
|
||||
// No `crypto` at all. Not reachable in a browser this app supports, and
|
||||
// reachable under a bare test runner, so it returns something valid rather
|
||||
// than throwing — these ids are opaque handles, never a security boundary.
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const HEX: string[] = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
|
||||
|
||||
/**
|
||||
* A v4-shaped identifier: `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`.
|
||||
*
|
||||
* Prefers the platform's own `randomUUID` when it exists, so that in the common
|
||||
* case this is exactly what the code it replaced produced.
|
||||
*/
|
||||
export function randomId(): string {
|
||||
const source = globalThis.crypto;
|
||||
if (source !== undefined && typeof source.randomUUID === "function") {
|
||||
return source.randomUUID();
|
||||
}
|
||||
|
||||
const b = randomBytes();
|
||||
// Version 4 in the high nibble of byte 6, RFC 4122 variant in byte 8.
|
||||
b[6] = (b[6]! & 0x0f) | 0x40;
|
||||
b[8] = (b[8]! & 0x3f) | 0x80;
|
||||
|
||||
const h = (i: number): string => HEX[b[i]!]!;
|
||||
return (
|
||||
h(0) + h(1) + h(2) + h(3) + "-" +
|
||||
h(4) + h(5) + "-" +
|
||||
h(6) + h(7) + "-" +
|
||||
h(8) + h(9) + "-" +
|
||||
h(10) + h(11) + h(12) + h(13) + h(14) + h(15)
|
||||
);
|
||||
}
|
||||
+4
-3
@@ -81,6 +81,7 @@ import {
|
||||
import { OFFICE_SITES, type ShippedOfficeId } from "./offices/sites.ts";
|
||||
import { authFetch } from "./session.ts";
|
||||
import { capabilitiesFor, resolveAccess, type Access } from "./access.ts";
|
||||
import { randomId } from "./ids.ts";
|
||||
import { createMinimap, type Minimap } from "./engine/minimap.ts";
|
||||
import {
|
||||
createJourney,
|
||||
@@ -354,9 +355,9 @@ let realtimeOperation = 0;
|
||||
let lastRealtimePublishAt = 0;
|
||||
let realtimePageActive = true;
|
||||
/** Page-scoped wire identities; auth subject never enters the spatial protocol. */
|
||||
const realtimeActorId = crypto.randomUUID();
|
||||
const realtimeVehicleId = crypto.randomUUID();
|
||||
const realtimeAircraftId = crypto.randomUUID();
|
||||
const realtimeActorId = randomId();
|
||||
const realtimeVehicleId = randomId();
|
||||
const realtimeAircraftId = randomId();
|
||||
|
||||
/**
|
||||
* The buildings you can walk into, as procedural glyphs on the city.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ICE_CONFIG_PROTOCOL_VERSION } from "./iceTypes.ts";
|
||||
import { randomId } from "../ids.ts";
|
||||
import { isIceConfigGrantActive, parseIceConfigResponse } from "./iceValidation.ts";
|
||||
import type { ScreenShareBinding, ScreenShareCredential } from "./signalingTypes.ts";
|
||||
|
||||
@@ -42,7 +43,7 @@ export async function fetchEphemeralIceConfiguration(
|
||||
throw new TypeError("ICE configuration: authenticated fetch is required");
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const id = (options.requestId ?? (() => `tera-ice-${crypto.randomUUID()}`))();
|
||||
const id = (options.requestId ?? (() => `tera-ice-${randomId()}`))();
|
||||
const response = await options.authenticatedFetch(options.endpoint ?? "/api/v1/media/ice", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
|
||||
@@ -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