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
+45
View File
@@ -171,6 +171,51 @@ a JWKS-only implementation would reject every real token. (CONTRACT.md §6.)
never created — so the endpoint cannot be used to enumerate what exists. A pack
that does not declare its visibility is treated as private.
### The god tier
| variable | default | what it does |
| --- | --- | --- |
| `TERA_ADMIN_SUBJECTS` | *(empty)* | Comma-separated subject ids that get the admin tier. Empty means **no admins**. |
There are three tiers on the wire and the server decides all three.
`GET /api/v1/session` answers `{ authenticated, subject, admin, passwordLogin }`:
anonymous is `authenticated: false`, a member is `authenticated: true`, and a god
is `admin: true`. The client reads `admin` to decide what to *draw* — the
time/date scrubber, the debug panel — and nothing is authorised by it. A boolean
that arrived over the wire is a rendering hint; anything that actually matters is
checked again where it is enforced.
The list holds **subject ids**, meaning the `sub` claim this box verifies — not
an email and not a display name. Under `TERA_AUTH_MODE=password` the subject is
`TERA_AUTH_PASSWORD_USER`, so the single self-hosted account becomes an admin by
naming it here:
```ini
TERA_AUTH_MODE=password
TERA_AUTH_PASSWORD_USER=karti
TERA_ADMIN_SUBJECTS=karti
```
Password mode is deliberately **not** auto-admin. One grant path, written down
in the environment, is worth more than a convenience that makes "who is a god on
this box" a question you answer by reading code.
Matching is exact after trimming and **case-sensitive**: `karti` and `KARTI` are
two ids as far as an issuer is concerned, and folding case here would widen a
grant to something nobody configured.
`TERA_ADMIN_SUBJECTS=*` grants the tier to **every authenticated subject**. It is
a development escape hatch for a self-hoster who does not want to go find their
own subject id first, it must never reach a deployment env file, and it pushes a
line into `degraded` so `/api/v1/health` announces it. lumbridge-v4 is why:
`ADMIN_EMAILS` shipped with `admin@lumbridgecorp.com` as a committed default
while nobody had registered that address — a standing offer of admin to whoever
claimed it first, invisible because nothing said it was on. A grant nobody can
see is a grant nobody revokes.
Health never serves the list or its length. The `degraded` lines name the
variable; they never name a subject.
## Deploying
Three files in `../deploy`, and exactly one of each: