1
0

A plan view in the corner, a night you can actually see, and three kinds of visitor

The right half of the screen was empty sky. It holds the board now, drawn flat,
with the footprint of the camera's own frustum on it — the one part of a minimap
that earns its place, because it answers "where am I looking from" without
leaving the shot. Click it, drag it, scroll it. It is a 2D canvas rather than a
second WebGL context, cached per city and redrawn only when something moved.

Night was black. Not dark — black: at 3 a.m. the coastline, the hills and the
bay were one shape, and the frame read as a failed render rather than as
darkness. The sky already had a floor for exactly this reason and nothing did
the equivalent for the ground, so the ground has one now. The moon still has to
be worth computing, so the gap between a moonlit night and a moonless one is
preserved rather than filled in.

Three tiers, resolved once in the new src/access.ts: anonymous, signed in,
admin. Anonymous gets the map and a public office — the shell, the furniture,
the named viewpoints, nobody home — built without the private objects rather
than with them hidden, because scene.traverse makes hiding a leak with a bow on
it. The time scrubber and the debug readouts are admin only, and admin is
granted by TERA_ADMIN_SUBJECTS on the server and inferred nowhere else. An
unreachable API means member, never god: the promise is "clone it and it works",
not "clone it and you are an administrator of a deployment you did not
configure".

Three things this run found and fixed rather than shipped:

  - entryUrl came off the wire and went straight into an href with no scheme
    check, and a CSP of script-src 'self' 'unsafe-inline' does not stop a
    javascript: URL from navigating. One rejection point in access.ts now.
  - A 5xx from /health was the same null as "no API at all" and therefore the
    opposite conclusion. Eight seconds of tera-api restarting would have told
    every anonymous visitor they were a member. A 5xx is an answer; it fails
    closed.
  - decodeURIComponent in cookieToken was the one path in auth/index.ts that
    threw rather than returning ANONYMOUS, so one malformed cookie header from
    an unauthenticated caller turned /api/v1/session into a 500.

Also: keyboard shortcuts, focus rings, a boot state instead of a blank 2.3
seconds, a collapsible panel under 900px, and no horizontal overflow at 375,
768, 1440 or 2560.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-05 22:53:30 -07:00
parent 47faec9f9d
commit 5bc7258753
24 changed files with 3982 additions and 211 deletions
+65 -4
View File
@@ -35,23 +35,69 @@ export interface Viewer {
authenticated: boolean;
/** Stable subject id where one is known. Never a token, never an email. */
subject: string | null;
/**
* The god tier. Decided here, from `TERA_ADMIN_SUBJECTS` and nothing else:
* the client is *told* whether it is an admin, it never asserts it, and no
* request header, query parameter or claim in the token can turn this on.
* Anonymous is never an admin, and neither is an authenticated subject the
* operator did not list — including the local `password` account, which gets
* no automatic grant precisely so that there is one path to godmode and it
* is legible in the env file.
*/
admin: boolean;
}
export interface AuthService {
resolve(req: FastifyRequest): Promise<Viewer>;
}
const ANONYMOUS: Viewer = { authenticated: false, subject: null };
const ANONYMOUS: Viewer = { authenticated: false, subject: null, admin: false };
/** Positive revalidations are held briefly; negative ones are not held at all. */
const SESSION_TTL_MS = 60_000;
/**
* Whether a subject holds the god tier, by the one rule there is.
*
* Exported because `routes/session.ts` has to answer the POST that signs
* somebody in *before* any token has been round-tripped through `resolve()`,
* and its answer must be the value the next `GET /api/v1/session` produces for
* the same account. Two expressions of the same rule is exactly how a UI ends
* up drawing controls the server will refuse to honour, so there is one
* function and both callers go through it.
*
* The grant comes in on `AuthConfig` rather than as a second argument to
* `createAuth` so that the list arrives by the same route as every other thing
* an operator configured, read once in `config.ts` and never from `process.env`
* down here.
*/
export function grantsAdmin(config: AuthConfig, subject: string | null): boolean {
// `*` means "everyone who is authenticated", so it grants even where the
// issuer handed us no `sub` to match against. It is a development switch and
// it announces itself in `degraded`; see `loadAdmins` in config.ts.
if (config.admins.everyone) return true;
if (subject === null) return false;
// Exact match. The trimming happened once, at load.
return config.admins.subjects.includes(subject);
}
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.
//
// What it holds is the whole viewer, `admin` included, for up to
// SESSION_TTL_MS. That is safe to hold because the grant is a pure function
// of the subject and of `TERA_ADMIN_SUBJECTS`, and the environment is read
// exactly once, at boot: the only way to change who is an admin is to edit
// the env file and restart, and a restart is a new process with an empty
// map. There is no sequence of operator actions that leaves a stale
// `admin: true` being served. What the sixty seconds does cost is the other
// direction — a session revoked upstream keeps working for up to a minute,
// admin sessions along with everything else — and a minute of staleness on
// a positive revalidation is the trade this cache exists to make.
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;
@@ -65,7 +111,7 @@ export function createAuth(config: AuthConfig): AuthService {
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 };
viewer = { authenticated: true, subject: sub, admin: grantsAdmin(config, sub) };
}
} catch {
// An unreachable identity service means nobody is authenticated. That is
@@ -89,7 +135,8 @@ export function createAuth(config: AuthConfig): AuthService {
const claims = await verifyJwt(token, config);
if (claims === null) return ANONYMOUS;
return { authenticated: true, subject: typeof claims.sub === "string" ? claims.sub : null };
const subject = typeof claims.sub === "string" ? claims.sub : null;
return { authenticated: true, subject, admin: grantsAdmin(config, subject) };
},
};
}
@@ -165,6 +212,15 @@ function bearerToken(req: FastifyRequest): string | 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.
*
* The decode is guarded because `decodeURIComponent` throws on malformed
* percent-encoding, and that throw was the one error path in this file that
* escaped: everything else here is written to hand back `ANONYMOUS` rather than
* raise, on the reasoning at the top of `resolve()`. A single request carrying
* `Cookie: tera_session=%zz` turned `GET /api/v1/session` — and the private
* office check that shares this code path — into a 500, from an unauthenticated
* caller, with one header. A cookie that is not valid percent-encoding is not a
* token this box issued, so the honest answer is "no token".
*/
function cookieToken(req: FastifyRequest, name: string): string | null {
const header = req.headers.cookie;
@@ -174,7 +230,12 @@ function cookieToken(req: FastifyRequest, name: string): string | null {
if (eq === -1) continue;
if (pair.slice(0, eq).trim() !== name) continue;
const value = pair.slice(eq + 1).trim();
return value === "" ? null : decodeURIComponent(value);
if (value === "") return null;
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
return null;
}