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:
@@ -393,6 +393,7 @@ async function film(browser, spec, dir) {
|
||||
() =>
|
||||
document.getElementById("boot")?.hidden === true &&
|
||||
document.querySelectorAll("#chapters .chapter").length > 0,
|
||||
undefined,
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
@@ -178,12 +178,20 @@ export function clockShim(iso) {
|
||||
* would be a picture of a permission nobody browsing the site actually has.
|
||||
* - `#rail` is the keyboard hint strip, which is advice for a person holding a
|
||||
* keyboard, not information about the city.
|
||||
* - `#onboarding-host` is the first-run card. It is *correct* — a real visitor
|
||||
* on a first visit does see it — but it is teaching the reader how to fly a
|
||||
* board they are looking at a photograph of, and it covers the bottom-left
|
||||
* quarter of the frame. It is also the newest thing here, which is the
|
||||
* general hazard this list exists for: every card added to the interface is
|
||||
* in the product imagery by default, and silently, because the shot still
|
||||
* renders and still looks deliberate. Anything mounted over the canvas that
|
||||
* is not the world or an instrument reading it belongs in this list.
|
||||
*/
|
||||
export const FURNITURE = {
|
||||
/** Never wanted in a photograph: the boot curtain and the phone's panel scrim. */
|
||||
transient: ["#boot", "#scrim"],
|
||||
BARE: ["#panel", "#panel-toggle", "#corner", "#rail", "#source", "#tier"],
|
||||
CLUTTER: ["#rail", "#tier", "#source", "#panel-toggle"],
|
||||
transient: ["#boot", "#scrim", "#onboarding-host"],
|
||||
BARE: ["#panel", "#panel-toggle", "#corner", "#rail", "#source", "#tier", "#onboarding-host"],
|
||||
CLUTTER: ["#rail", "#tier", "#source", "#panel-toggle", "#onboarding-host"],
|
||||
};
|
||||
|
||||
/** Hide selectors, and wait a beat for the layout to settle before shooting. */
|
||||
|
||||
@@ -85,9 +85,9 @@ const SHOTS = [
|
||||
caption:
|
||||
"Everything else in the engine stands on this heightfield: 83 named hills, four octaves of value noise, and overlapping peaks combined as tallest-plus-35%-of-the-rest — the one rule that makes a run of summits read as a ridge instead of stacking into a fictional mountain.",
|
||||
day: {
|
||||
at: "2026-08-06T07:40:00-07:00",
|
||||
note: "Twenty to eight, sun fifteen degrees up: low enough that the hills rake shadow across the flats and the relief reads as relief.",
|
||||
alt: "The San Francisco Bay Area seen from above in low morning light, the peninsula and the East Bay hills raking shadows across the water.",
|
||||
at: "2026-08-06T09:10:00-07:00",
|
||||
note: "Ten past nine, sun thirty degrees up: high enough that the marine layer has burned back off the bay, low enough that the East Bay hills still rake shadow west across the flats.",
|
||||
alt: "The San Francisco Bay Area seen from above in mid-morning light, the peninsula and the East Bay hills casting shadows west, scattered cloud over the water.",
|
||||
},
|
||||
night: {
|
||||
at: "2026-08-06T21:35:00-07:00",
|
||||
@@ -178,58 +178,59 @@ const SHOTS = [
|
||||
{
|
||||
id: "office-floor",
|
||||
door: "office",
|
||||
chapter: 0,
|
||||
expect: "The Floor",
|
||||
place: "Lumbridge HQ",
|
||||
chapter: 1,
|
||||
expect: "Studio",
|
||||
place: "SF HQ · Studio",
|
||||
caption:
|
||||
"The same renderer from inside: forty-eight metres by eighteen, two storeys held a long way apart, glazed along the north edge and open to the roof at the east end. An Office is one building's interior and a Space is a room within it — and this cannot share a THREE.Scene with the city, because San Francisco puts one scene unit at about 94 metres and this is one unit to the metre.",
|
||||
"The same renderer from inside, at one unit to the metre — which is why this cannot share a THREE.Scene with the city, where San Francisco puts one unit at about 94. A live/work studio rather than a floorplate: the desk, the bench and the domestic half are one room, and an Office is one building's interior while a Space is a room within it.",
|
||||
day: {
|
||||
at: "2026-08-06T11:20:00-07:00",
|
||||
note: "Twenty past eleven in the morning.",
|
||||
alt: "A whole office interior rendered in 3D as an open-topped model, walls, desks and meeting rooms laid out across one floor, with its plan drawn beside it.",
|
||||
note: "Twenty past eleven, daylight through the glazing — the sun is the real one over this coordinate, and the pack says where on the earth it stands and which way it is turned.",
|
||||
alt: "A studio interior rendered in 3D as an open-topped model: desks with monitors, a sofa and rug, shelving and planting, with its floor plan drawn beside it.",
|
||||
},
|
||||
night: {
|
||||
at: "2026-08-06T21:30:00-07:00",
|
||||
note: "Half past nine. The building has switched its own lights on, because the sun it is following has gone down over the real San Francisco — and a fitting brightens as somebody walks under it.",
|
||||
alt: "The same office model at night, lit from within by its ceiling fittings against a dark ground.",
|
||||
alt: "The same studio at night, lit from within by its ceiling fittings against a dark ground.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "office-desks",
|
||||
door: "office",
|
||||
chapter: 2,
|
||||
expect: "Desks",
|
||||
place: "Lumbridge HQ",
|
||||
expect: "Agent Bench",
|
||||
place: "SF HQ · Studio",
|
||||
caption:
|
||||
"Thirty-six seats in four benches, and how many of them are taken depends on the hour you are reading this. Stepping in here does not throw the city away — the city scene is retained and paused rather than disposed, because rebuilding the Bay's heightfield costs about 2.3 seconds and nobody should pay that to walk back out of a door.",
|
||||
"The bench, and the hardware on it. A microphone on the desk and a speaker on the machine are declared by the pack and simulated deterministically behind an adapter a real device API can occupy later — power, gain, level, volume, all readable by anyone, all honestly labelled as demonstration data. Stepping in here does not throw the city away: the city scene is retained and paused rather than disposed, because rebuilding the Bay's heightfield costs about 2.3 seconds.",
|
||||
day: {
|
||||
at: "2026-08-06T11:20:00-07:00",
|
||||
note: "Twenty past eleven, daylight through the glazing on the north edge.",
|
||||
alt: "A closer view inside the office: rows of desks with chairs and monitors, people seated at them, bookshelves and planting behind a partition.",
|
||||
note: "Twenty past eleven, daylight across the bench.",
|
||||
alt: "A close view of a two-place desk bench in a studio: monitors, chairs, a desk microphone and a speaker, with planting behind.",
|
||||
},
|
||||
night: {
|
||||
at: "2026-08-06T21:30:00-07:00",
|
||||
note: "Half past nine, and the windows have stopped being the light source. The fittings have taken over, and two humanoids are walking the floor.",
|
||||
alt: "The same desks at night, lit by the office's own lighting with darkness beyond the windows.",
|
||||
note: "Half past nine, and the windows have stopped being the light source. The fittings have taken over.",
|
||||
alt: "The same bench at night, lit by the studio's own lighting with darkness beyond the windows.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "office-commons",
|
||||
door: "office",
|
||||
chapter: 5,
|
||||
expect: "Commons",
|
||||
place: "Lumbridge HQ",
|
||||
office: "LA HQ",
|
||||
chapter: 6,
|
||||
expect: "Courtyard",
|
||||
place: "LA HQ · Office, Arts District",
|
||||
caption:
|
||||
"Fourteen metres of floor open all the way to the roof, with a gallery cutting across one side of it. The two storeys are held apart by a fourteen-metre interstitial — not architecture, a deliberate exaggeration of the service void, so that the space between the slabs is somewhere things can hang. Everything you can see is composed at runtime from cached unit primitives; no mesh in this repo was ever a file.",
|
||||
"A second studio, eight hundred kilometres south, and the argument for the format being a format. A courtyard block square to the 1781 pueblo grid rather than to the compass — which is the whole reason a pack carries a heading, because the heading decides which walls the sun comes through. Same engine, same clock, same sun; nothing here was ever a mesh file.",
|
||||
day: {
|
||||
at: "2026-08-06T11:20:00-07:00",
|
||||
note: "Twenty past eleven, and the sky through the glazing is the real sky over this coordinate.",
|
||||
alt: "A tall interior atrium seen from floor level: pale terrazzo, trees, lounge seating, with a balcony crossing high above and daylight beyond the glass.",
|
||||
note: "Twenty past eleven, and the courtyard is taking the sun the way a courtyard at 36 degrees off north does.",
|
||||
alt: "An open courtyard inside a two-storey block rendered in 3D: planters, a parasol, tables and chairs, glazed offices around the edges.",
|
||||
},
|
||||
night: {
|
||||
at: "2026-08-06T21:30:00-07:00",
|
||||
note: "Half past nine, with the pendants hanging nineteen metres into the void.",
|
||||
alt: "The same atrium at night, lit by pendants high in the volume.",
|
||||
note: "Half past nine, and the courtyard is lit from the rooms around it rather than from above.",
|
||||
alt: "The same courtyard at night, lit from the glazed rooms around its edges.",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -240,7 +241,7 @@ const SHOTS = [
|
||||
expect: "Hangar",
|
||||
place: "Frontier Valley, Alameda Point",
|
||||
caption:
|
||||
"A second building, and the argument for the format being a format: one room, fifty-four metres by thirty, nine to the trusses, in a hangar on the old naval air station across the estuary. Same engine, same clock, same sun — and it feels nothing like the tower, because an office pack now says where on the earth it stands and how it is turned, and four metres above an airfield is not a hundred and eighty-eight above Transbay.",
|
||||
"A third pack, still in development, and the clearest statement of what an office pack is: one room, fifty-four metres by thirty, nine to the trusses, in a hangar on the old naval air station across the estuary. It feels nothing like either studio because four metres above an airfield is not a hundred and eighty-eight above Transbay, and the pack is what says so.",
|
||||
day: {
|
||||
at: "2026-08-06T11:20:00-07:00",
|
||||
note: "Twenty past eleven. North light through the clerestory, which is the light a shed actually wants.",
|
||||
@@ -413,6 +414,7 @@ async function shoot(browser, shot, frame) {
|
||||
() =>
|
||||
document.getElementById("boot")?.hidden === true &&
|
||||
document.querySelectorAll("#chapters .chapter").length > 0,
|
||||
undefined,
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
// Boot hiding means the scene exists, not that it has drawn a full frame.
|
||||
@@ -447,7 +449,8 @@ async function shoot(browser, shot, frame) {
|
||||
() =>
|
||||
document.getElementById("boot")?.hidden === true &&
|
||||
document.querySelectorAll("#chapters .chapter").length > 0,
|
||||
{ timeout: 180_000 },
|
||||
undefined,
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
await page.waitForTimeout(preview ? 3000 : 6000);
|
||||
}
|
||||
|
||||
+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