1
0

Spaces: the inside of the world, and a sun that is actually where it should be

Ten agents wrote this in parallel against CONTRACT.md, which exists because the
five design agents before them collided on fifteen blocking points — four files
specified twice with incompatible contents, three separate backends for one box,
and `Environment` exported twice meaning different things.

What landed: a Stage owning only the renderer and the loop, with the city and an
office as two scenes over it. They cannot share one — San Francisco is ~94 m per
scene unit with 3.6x vertical exaggeration and an office is 1 unit = 1 m — and
the city is paused rather than disposed on the way in, because rebuilding its
336,864-point heightfield costs about a second on the way back out.

Offices are data. `src/offices/lumbridge-hq.ts` is fifteen rooms and seventy-six
seats, and it is the file a self-hoster copies. Walls are a segment list with
1-D openings, so doors and windows are holes punched in a wall rather than
placed objects, and the pass that splits a wall around its openings hands the
walk-mode collider its segments for free.

The sun is real. `solar.ts` is a NOAA/Meeus implementation with no imports at
all — not even three.js — so time of day keeps working on a laptop in a field.
Verified against known values: 75.45 degrees at the June solstice in SF, 28.79
at December, sunset at 03:15Z. The first screenshot after wiring it was a black
rectangle, which turned out to be correct: it was midnight in San Francisco.

Presence binds to a seat id and never to a coordinate. The pack knows where
`eng-04` is; who is sitting in it is private data behind an API. Same shape as
the marker rule, one level in.

Two corrections to ARCHITECTURE.md are in here. Containment does not discharge
ODbL — publishing OSM-derived coordinates is Public Use of a Derivative Database
wherever the rows live, so the rule is about the geocoder (US Census, public
domain) and not the storage. And a person at a desk is not a Marker; markers are
geographic.

One contract gap surfaced only in a screenshot: two agents read `height` on a
viewpoint differently, so the establishing shot aimed at empty air fourteen
metres above the roof. It now means what the same field means for a city.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Karti Tripathi
2026-08-05 00:11:01 -07:00
parent 36471bbad7
commit d464459838
77 changed files with 14266 additions and 216 deletions
+112
View File
@@ -0,0 +1,112 @@
/**
* Who is asking — in three modes, two of which most deployments never turn on.
*
* - **`none`** is the default and the one the acceptance test runs. Everything
* this box serves is public, nobody has an account anywhere, and a private
* office simply does not exist as far as the API is concerned.
* - **`sso`** is what Lumbridge's own deployment uses. This world holds **no
* credentials**: it is handed an entry URL to send people to and a
* revalidate URL to ask about a token, and the answer comes back from the
* thing that issued the session. Reusing the pattern already running on the
* fleet means there is no second identity system to keep secure, and both ends
* are env vars, which is what a dev kit needs. CONTRACT.md §6.
* - **`jwt`** verifies a token here, for a deployment that would rather not make
* an outbound call per request. See `jwt.ts` for why HS256 is the primary path.
*
* Enforcement is on the server in all three. A viewer object that says
* `authenticated: false` is the only thing a route ever sees, and the route
* answers 404 — never 403 — so the endpoint cannot be used to enumerate what
* exists.
*/
import { createHash } from "node:crypto";
import type { FastifyRequest } from "fastify";
import type { AuthConfig } from "../config.ts";
import { verifyJwt } from "./jwt.ts";
export interface Viewer {
authenticated: boolean;
/** Stable subject id where one is known. Never a token, never an email. */
subject: string | null;
}
export interface AuthService {
resolve(req: FastifyRequest): Promise<Viewer>;
}
const ANONYMOUS: Viewer = { authenticated: false, subject: null };
/** Positive revalidations are held briefly; negative ones are not held at all. */
const SESSION_TTL_MS = 60_000;
export function createAuth(config: AuthConfig): AuthService {
const sessions = new Map<string, { viewer: Viewer; checkedAt: number }>();
async function revalidate(token: string): Promise<Viewer> {
// The cache is keyed on a hash so that a heap dump, a debugger or a stray
// log line never contains a usable session token.
const key = createHash("sha256").update(token).digest("hex");
const hit = sessions.get(key);
if (hit !== undefined && Date.now() - hit.checkedAt < SESSION_TTL_MS) return hit.viewer;
let viewer = ANONYMOUS;
try {
const res = await fetch(config.revalidateUrl, {
headers: { authorization: `Bearer ${token}`, accept: "application/json" },
signal: AbortSignal.timeout(4000),
});
if (res.ok) {
const body = (await res.json().catch(() => null)) as { sub?: unknown } | null;
const sub = typeof body?.sub === "string" ? body.sub : null;
viewer = { authenticated: true, subject: sub };
}
} catch {
// An unreachable identity service means nobody is authenticated. That is
// the safe direction, and it is why this returns a viewer rather than
// throwing: the route still answers, it just answers 404.
return ANONYMOUS;
}
if (viewer.authenticated) sessions.set(key, { viewer, checkedAt: Date.now() });
return viewer;
}
return {
async resolve(req: FastifyRequest): Promise<Viewer> {
if (config.mode === "none") return ANONYMOUS;
const token = bearerToken(req) ?? cookieToken(req, config.cookieName);
if (token === null) return ANONYMOUS;
if (config.mode === "sso") return revalidate(token);
const claims = await verifyJwt(token, config);
if (claims === null) return ANONYMOUS;
return { authenticated: true, subject: typeof claims.sub === "string" ? claims.sub : null };
},
};
}
function bearerToken(req: FastifyRequest): string | null {
const header = req.headers.authorization;
if (typeof header !== "string") return null;
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
return match?.[1] ?? null;
}
/**
* Cookies are parsed by hand rather than with a plugin. One header, one split,
* and the alternative is a dependency whose entire job is this function.
*/
function cookieToken(req: FastifyRequest, name: string): string | null {
const header = req.headers.cookie;
if (typeof header !== "string") return null;
for (const pair of header.split(";")) {
const eq = pair.indexOf("=");
if (eq === -1) continue;
if (pair.slice(0, eq).trim() !== name) continue;
const value = pair.slice(eq + 1).trim();
return value === "" ? null : decodeURIComponent(value);
}
return null;
}
+165
View File
@@ -0,0 +1,165 @@
/**
* JWT verification, with HS256 as the primary path.
*
* That ordering is a correction from verified fact rather than a preference. The
* issuer this runs against — the fleet's Supabase — signs `{"alg":"HS256"}`, so
* a JWKS-only implementation would reject every real token that ever arrived.
* Asymmetric verification is here and works, but it sits behind
* `TERA_AUTH_JWT_VERIFY=jwks`. CONTRACT.md §6.
*
* Written against `node:crypto` rather than a JWT library on purpose: HS256 is
* an HMAC and a handful of claim checks, and this service's dependency list is
* short enough to read in one breath. The parts that are easy to get wrong —
* verifying before parsing, comparing signatures in constant time, rejecting
* `alg: none` and rejecting an algorithm the operator did not ask for — are all
* below and all deliberate.
*/
import { createHmac, createPublicKey, timingSafeEqual, verify as cryptoVerify } from "node:crypto";
import type { KeyObject } from "node:crypto";
import { getJson } from "../http.ts";
import type { AuthConfig } from "../config.ts";
export interface Claims {
sub?: string;
exp?: number;
nbf?: number;
iss?: string;
aud?: string | string[];
[claim: string]: unknown;
}
/** Sixty seconds of tolerance for clocks that disagree, which they do. */
const CLOCK_SKEW_SECONDS = 60;
export async function verifyJwt(token: string, config: AuthConfig): Promise<Claims | null> {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerPart, payloadPart, signaturePart] = parts;
if (headerPart === undefined || payloadPart === undefined || signaturePart === undefined) {
return null;
}
const header = decodeJson<{ alg?: string; kid?: string }>(headerPart);
if (header === null) return null;
const signed = `${headerPart}.${payloadPart}`;
const signature = Buffer.from(signaturePart, "base64url");
const signatureOk =
config.jwtVerify === "jwks"
? await verifyAsymmetric(header, signed, signature, config)
: verifyHs256(header.alg, signed, signature, config.jwtSecret);
if (!signatureOk) return null;
// Only now is the payload worth reading. Parsing claims out of an unverified
// token and checking the signature afterwards is how `alg: none` bugs happen.
const claims = decodeJson<Claims>(payloadPart);
if (claims === null) return null;
return claimsValid(claims, config) ? claims : null;
}
function verifyHs256(
alg: string | undefined,
signed: string,
signature: Buffer,
secret: string,
): boolean {
// Pinned, not merely checked against a list: an operator who configured a
// shared secret has said what algorithm they expect, and accepting anything
// else here is the classic confusion attack.
if (alg !== "HS256" || secret === "") return false;
const expected = createHmac("sha256", secret).update(signed).digest();
if (expected.length !== signature.length) return false;
return timingSafeEqual(expected, signature);
}
// ---- JWKS -----------------------------------------------------------------
interface Jwk {
kid?: string;
kty?: string;
alg?: string;
[field: string]: unknown;
}
const ASYMMETRIC: Record<string, { algorithm: string; ieeeP1363: boolean }> = {
RS256: { algorithm: "RSA-SHA256", ieeeP1363: false },
RS384: { algorithm: "RSA-SHA384", ieeeP1363: false },
RS512: { algorithm: "RSA-SHA512", ieeeP1363: false },
ES256: { algorithm: "SHA256", ieeeP1363: true },
ES384: { algorithm: "SHA384", ieeeP1363: true },
};
const keyCache = new Map<string, KeyObject>();
let keysFetchedAt = 0;
async function verifyAsymmetric(
header: { alg?: string; kid?: string },
signed: string,
signature: Buffer,
config: AuthConfig,
): Promise<boolean> {
const alg = header.alg ?? "";
const spec = ASYMMETRIC[alg];
if (spec === undefined) return false;
const key = await resolveKey(header.kid ?? "", config.jwksUrl);
if (key === null) return false;
try {
return cryptoVerify(spec.algorithm, Buffer.from(signed), {
key,
// ECDSA signatures in a JWT are the raw r‖s pair, not the DER sequence
// OpenSSL expects. Without this, every ES256 token fails to verify.
...(spec.ieeeP1363 ? { dsaEncoding: "ieee-p1363" as const } : {}),
}, signature);
} catch {
return false;
}
}
async function resolveKey(kid: string, jwksUrl: string): Promise<KeyObject | null> {
const cached = keyCache.get(kid);
if (cached !== undefined) return cached;
// Refetch on an unseen kid, but not more than once a minute — a rotated key
// should be picked up quickly, and a token with a junk kid should not be able
// to turn one request into one upstream fetch.
if (Date.now() - keysFetchedAt < 60_000) return null;
keysFetchedAt = Date.now();
const jwks = await getJson<{ keys?: Jwk[] }>(jwksUrl);
for (const jwk of jwks?.keys ?? []) {
if (typeof jwk.kid !== "string") continue;
try {
keyCache.set(jwk.kid, createPublicKey({ key: jwk as never, format: "jwk" }));
} catch {
// A key this build of Node cannot represent is not a reason to drop the rest.
}
}
return keyCache.get(kid) ?? null;
}
// ---- Claims ---------------------------------------------------------------
function claimsValid(claims: Claims, config: AuthConfig): boolean {
const now = Math.floor(Date.now() / 1000);
if (typeof claims.exp === "number" && claims.exp + CLOCK_SKEW_SECONDS < now) return false;
if (typeof claims.nbf === "number" && claims.nbf - CLOCK_SKEW_SECONDS > now) return false;
if (config.issuer !== "" && claims.iss !== config.issuer) return false;
if (config.audience !== "") {
const aud = claims.aud;
const matches = Array.isArray(aud) ? aud.includes(config.audience) : aud === config.audience;
if (!matches) return false;
}
return true;
}
function decodeJson<T>(part: string): T | null {
try {
return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as T;
} catch {
return null;
}
}