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>
2.3 KiB
Hosting the static build
Tera's map view is a static bundle that needs a file server and nothing else.
No API, no keys, no account: the sun and moon are computed locally by
src/engine/solar.ts, the traffic is simulated, the minimap is drawn from the
city pack, and the office is a data file.
It does make two requests at boot, and both are meant to fail on a plain
static host: GET /api/v1/health and GET /api/v1/session, which is how
src/access.ts works out whether this deployment has accounts at all. Nothing
answering means nothing to sign in to, so the visitor gets the full public
experience and no sign-in link — see that file's header for why an unreachable
API and an API that answered 5xx are deliberately not the same case. Your
server log will show two 404s per load; that is the zero-config path working,
not a misconfiguration.
npm ci
npm run build # -> dist/
Serve dist/ from anything. A Content-Security-Policy of default-src 'self'
is sufficient; the build references no external fonts, CDNs or images.
Caddy
tera.example.com {
root * /var/www/tera
encode zstd gzip
try_files {path} {path}/index.html /index.html
file_server
header {
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'"
}
}
try_files … /index.html matters if you add client-side routes later: without
it a route that exists only in JavaScript 404s for anyone who types it or
refreshes on it.
img-src needs data: and blob: because every texture in the asset library
is drawn on a canvas at runtime rather than shipped as a file — see
ARCHITECTURE.md §3.1 for why that is a licensing decision and not a technical
one.
Serving the API too
Only needed for live weather, real ADS-B, or markers from an external source.
The map runs fully without it. See deploy/tera-api.service,
deploy/Caddyfile.snippet and deploy/docker-compose.yml.
Base path
Deployed at a subdomain root, no configuration is needed. Under a subpath, set
TERA_BASE at build time (TERA_BASE=/tera/ npm run build) — the trailing
slash matters, since every asset URL resolves against it.