1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/access.ts
T
karti acf4d1a510 feat: the state becomes California, and the port fills with ships
**Stage 1 of one California.** The owner's complaint had two halves and this is
the first: the state board was a CROPPED SLAB. `california.ts` stopped at 38.05 N,
so the board disagreed with its own minimap about the shape of California in a
single frame, and Bug Fire's 93,733 acres burned off-frame while the panel said
all clear. Bounds now run 32.50-42.05 N / -124.50 to -114.0 W — Cape Mendocino,
the ruled Oregon parallel, the 120th-meridian corner into the Nevada diagonal.

**And it got cheaper.** 391,169 triangles to 375,351, while gaining the North
Coast, the Sacramento Valley, the Klamath knot, the Cascade arc, Shasta at 4,320 m
and Lassen at 3,190 m. Extending the bounds alone would have doubled the lattice
to 168,813 points and blown the mobile cap; coarsening cellLat 0.022 -> 0.0312 and
cellLng 0.027 -> 0.0383 holds it at ~83,800. The cell as a FRACTION of the board
moves 0.0030 -> 0.0033 — unchanged in frame — because the camera retreats to frame
whatever it is given. That argument was already written in the pack's own comment.

The second half — three boards becoming one world you zoom through — is NOT here.
Merging at Bay density would be 34.04M triangles, 13x the highest budget, and
merging at SoCal density would downgrade San Francisco from 40 m lots to 164 m.
Both delete the board every marketing still is shot from. `sf.ts` and `socal.ts`
are untouched by design.

**Aerial perspective, which the state board could not have had before.** The old
fog started at 1.15 board spans = 944 km, on a board whose longest diagonal is
820 km — so no pixel could ever be fogged. Fog now responds to camera altitude,
clamped to the authored pair as a ceiling.

`Atmosphere.aerial(env, view)` is a second pure method returning `{ near, far }`
and **deliberately no colour**. That is structural, not stylistic: it is why a
future camera-dependent term cannot reach `environmentKey()`'s colour fingerprint
and start rebuilding the PMREM cubemap on every camera step. Coarsening the
fingerprint instead would have hidden one instance and armed the mechanism. A
mutation-tested seam guard fails if anyone merges the two paths back together.

**The port.** Terminal Island rendered as a bare tan polygon with generic white
blocks while the chapter text called it the busiest port complex in the
hemisphere. Now six container yards drawn as canvas atlases, 56 gantry cranes at
varied boom angles, the 13 km San Pedro breakwater, the dredged channel. Five
buckets merging ACROSS ports the way airports.ts merges across fields, so a
second complex costs no extra draws: +11 draws and +4,377 triangles for all of it.

At vertical exaggeration 3.4 a 130 m gantry is 1.132 units tall against a 400 m
ship's 1.024 long — the crane is the taller object, and it is what makes a port
read as a port from altitude.

**Ships, and the wake carries the information.** Moored hulls have no foam,
verified at three terminals; a tug under way in the Main Channel trails a clean
Kelvin V. One hull geometry, one InstancedMesh, orientation from the BERTH rather
than the wire. The AIS gate strips sog 102.3, heading 511 and cog 360 — all mean
"not available" — with an explicit test that cog 358.7 SURVIVES, because a naive
range check on cog eats real headings near north.

"Empty or full" is not in AIS position reports and is not invented per ship. The
honest answer is at port level and is a better story: 348,691 of 460,467 boxes
left Los Angeles empty in July 2026, corroborated by FBX01 $7,491 inbound against
FBX02 $347 outbound.

**Radar and birds ship dark, and say why.** California is 0.47% wet and migration
is nocturnal and seasonal, so both layers have nothing to say on most days. The
panel reads "No radar feed is configured, so this board draws no weather. That is
a fact about this box, not about the sky."

Also recorded, and it matters beyond this commit: **the GPU on amd-server never
leaves 500 MHz of a possible 2725**, traced across 80 seconds of sustained load.
`bay-area/desktop` is fragment-bound at that clock and sits on the vsync deadline,
so a trivial change in fragment work flips it between 16.8 and 33.3 with geometry
identical to the digit. Every frame-time number measured on this box is a floor.
Two investigations reached two different wrong conclusions from single-run
comparisons before this was traced. Geometry is the gate; frame time is advisory.
No cap was raised.

Tests 1,340 -> 1,540, server 280 -> 295.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 23:35:09 -07:00

541 lines
26 KiB
TypeScript

/**
* What this visitor may do — resolved once at boot, read everywhere after.
*
* There are three kinds of person in front of this map. Someone who has not
* signed in (**anon**) gets the public city and a public office: the shell, the
* furniture, the named viewpoints, nobody home. Someone signed in (**member**)
* gets the live feeds and the people in the room. An administrator (**god**)
* gets those plus the instruments — the time scrubber and the debug readouts —
* which are development tools that happen to be shipped.
*
* This module exists so `main.ts` never has to think about auth again. Before
* it, the app carried two loose booleans (`canEnterOffice`, `signInUrl`) and the
* rule that produced them was inline in the boot path; every new capability
* meant another boolean and another chance to get the rule subtly wrong. One
* function, one value, one place to read the reasoning.
*
* ## These capabilities are UI, not security
*
* Say it plainly, because the shape of this file invites the opposite reading:
* **nothing here is a boundary.** It is a set of decisions about what to draw.
* Anyone can open the console and set `can.liveData` to true.
*
* The two halves of that are genuinely different and it matters which is which:
*
* - `timeControl` and `debug` are *purely* client-side. Scrubbing the clock
* changes a `Date` that is fed to `observe()` in this browser and moves a sun
* this browser is drawing. There is no server to enforce anything against, so
* hiding the control here **is** the whole enforcement, and that is fine and
* honest: the worst a determined visitor achieves is a sunset at 2 p.m. on
* their own screen. Nothing leaks.
*
* - `liveData` and `officeDepth` are **not** enforced here even slightly. The
* API returns nothing — no markers, and a 404 rather than a 403 for a private
* office pack (CONTRACT.md §6) — to a caller it does not recognise. That
* refusal is the security. What this module does is stop the app from asking
* for something it will not get and from rendering an empty room as though it
* were an empty office. It is a convenience laid on top of a server-side rule,
* never a substitute for one. If you are ever tempted to move an access check
* *out* of the API and into here because it is easier, that is the moment this
* file has been misread.
*
* ## Why an unreachable API means `member` and not `god`
*
* A clean clone with no server is the repo's flagship case (CONTRACT.md §0) and
* it has to be a good experience, so it gets `member`: live-shaped UI over the
* bundled sample data, the whole office, no sign-in prompt for a door that does
* not exist. It deliberately does **not** get `god`. The self-host promise is
* "clone it and it works", not "clone it and you are an administrator of a
* deployment you did not configure" — and the difference stops mattering only
* until someone puts a static build in front of an API they do not control, at
* which point a client that awards itself godmode whenever it cannot reach the
* server has turned a network failure into a privilege escalation.
*
* Godmode comes from an explicit server-side grant. Always. Absence of an answer
* is not an answer.
*/
import { authFetch } from "./session.ts";
/** Where the API lives, per CONTRACT.md §5. Same-origin, behind the site's own proxy. */
const BASE = "/api/v1";
/**
* How long either probe may take before it counts as no answer.
*
* Boot awaits this, so an unbounded wait is not "eventually correct", it is a
* map that never appears. A black-holed port — a firewall dropping packets
* rather than refusing the connection — hangs `fetch` indefinitely, and that is
* exactly the deployment mistake most likely to be made by the person this
* timeout protects.
*/
const TIMEOUT_MS = 4000;
export type Tier = "anon" | "member" | "god";
export interface Capabilities {
/** Step into the office at all. True for everyone; `officeDepth` is what differs. */
enterOffice: boolean;
/** "public" = shell, furniture and named views, nobody home. "full" = presence and occupants. */
officeDepth: "public" | "full";
/** Scrub the clock and the date. God only — see the note about why this is honest. */
timeControl: boolean;
/**
* The real sky — observed weather and observed aircraft — rather than the
* synthetic one.
*
* **Public, including to a visitor who has not signed in.** It was briefly
* `tier !== "anon"`, on the reasoning that live feeds are what an account
* buys you. That reasoning does not survive contact with what the data
* actually is: the cloud cover over San Francisco is a public observation
* from a government sensor, and the aircraft are broadcasting their positions
* unencrypted to anyone with a forty-dollar receiver. Neither is a thing an
* account can grant you access to, because neither is withheld from anyone.
*
* What it cost was the only moment that makes this project land — fog rolling
* off the Pacific onto a city you recognise, at the real time of day, on a
* first visit. Gating that behind a sign-in traded the whole first impression
* for a rule with nothing behind it.
*/
liveEnvironment: boolean;
/**
* Markers: whatever this deployment has decided its map is *about*.
*
* Separate from `liveEnvironment` because it is the one feed that can carry
* something private. The sky is the same for everybody; a marker set is a
* company's pipeline, or a person's job search, and whether it is public is a
* property of the deployment rather than of this file. The **server** decides
* — `TERA_MARKERS_ACCESS`, which defaults to `members` so that a self-hoster
* who wires real data up gets the safe answer without having chosen it — and
* this flag only reports what the server already said. Setting it true here
* against a server set to `members` earns a 401 and nothing else.
*/
liveMarkers: boolean;
/**
* Read this deployment's **real** device route, rather than the simulator
* bundled in the tab.
*
* `false` for an anonymous visitor, and that is not a restriction on what
* they see — it is what makes the studio work for them at all. The route is
* `members`-only and answers 401 to an anonymous GET, which is correct: the
* mics and the camera in it are hardware in somebody's room. What was wrong
* was the *client*, which asked anyway. `serverHasDevices` was the only gate
* on the API strategy, so on the production box — where `/health` reports
* `devices: "sim"` — an anonymous visitor took the API path, was refused,
* rendered every instrument permanently at rest, and backed off exponentially
* against a request it could never pass. The panel beside it promised a
* locally simulated studio.
*
* So this is the second half of a decision that already had a first half.
* `Feeds.devices` asks "has this deployment got a device source at all"; this
* asks "may *this viewer* read it". Both have to be true before a request is
* worth making, and when either is false the answer is the same and it is a
* good one: run the fixed-step simulator in this tab, which is what
* `adapter.ts`, `routes/devices.ts` and `devicePanel.ts` have all documented
* as the anonymous experience since they were written.
*/
liveDevices: boolean;
/** Debug overlays: frame time, draw calls, chapter poses, the solar readout. */
debug: boolean;
}
/**
* Which of the feeds this deployment has actually wired.
*
* A capability says what a *visitor* may have; this says what the *server* has,
* and the app needs both before it opens a socket. `can.liveData` is true for
* every member of every deployment, including the overwhelming majority that
* have `weather: "none"` — so gating on the capability alone starts a
* ten-minute weather poll against a box that will answer 404 to all of it,
* forever, on every tab that is open.
*
* Each field is `true` when `/health` named a source other than `"none"`, which
* is deliberately coarser than the string. The app does not care whether the
* weather comes from NWS or met.no; it cares whether asking is pointless.
* `flights: "sim"` counts as wired, because the server's synchronised plan is
* worth fetching even though it is not observed — `TrafficSource.live()` is the
* thing that knows the difference, and it says `false` for it.
*/
export interface Feeds {
weather: boolean;
flights: boolean;
/**
* Device state for the studios.
*
* `false` on a zero-config box and on every clone, which is the default and
* is not a gap: `src/devices/adapter.ts` reads this and runs the bundled
* simulator in the tab instead, so the studio is alive either way. What the
* flag actually prevents is a poll against a box that will answer 404 to all
* of it, forever, on every open tab.
*/
devices: boolean;
/**
* A satellite catalogue. Off on almost every box, including this repo's own
* default — see `loadSatellites` in the server's `config.ts` for why a clone
* does not start pulling CelesTrak the moment it boots.
*/
satellites: boolean;
markers: boolean;
/**
* A wildfire projection, from `TERA_FIRES_SOURCE`.
*
* Off on a clone and off on this repo's own default, which is the important
* half: a board with no fire feed behind it must draw nothing and say nothing
* rather than poll `/fires` every ten minutes forever to be told the same
* empty body. There is deliberately no `can.` twin — a wildfire is a public
* agency record and an account cannot grant you one — and there is
* deliberately no synthetic fallback anywhere beneath it. An invented
* aeroplane is a plausible aeroplane; an invented fire is a claim that a named
* place is burning, made to somebody who may live there.
*/
fires: boolean;
/**
* The two sky projections, from `TERA_RADAR_SOURCE` and `TERA_BIRDS_SOURCE`.
*
* **Optional, unlike every field above**, and that is the one thing to
* understand about them: `sources.radar` and `sources.birds` are newer than
* some servers this client will meet, and `undefined` has to mean the same as
* `false` — do not ask. A required boolean here would have read a body from an
* older box as a definite "no", which is the same answer by luck rather than
* by construction, and would have made every existing `Feeds` literal in the
* tests a compile error for no gain.
*
* Both are off on this repo's default and on every clone, and both stay
* honest when they are: `promoteRadar(null)` and `promoteBirds(null)` write
* "no feed is configured — that is a fact about this box, not about the sky",
* which is the sentence a blank panel could not say.
*/
radar?: boolean;
birds?: boolean;
}
export interface Access {
tier: Tier;
subject: string | null;
/** Where to send someone who is not signed in. `null` means this deployment has no door. */
signInUrl: string | null;
can: Capabilities;
/**
* What `/health` said is wired, or `null` when nothing answered — which is
* the zero-config case, and means every feed is the bundled sample.
*
* It rides along here rather than being fetched again by whoever wants it
* because this module has already paid for the round trip: `/health` is the
* first thing boot asks for, and a second identical GET a moment later to
* read a different field of the same body is a request nobody needs to make.
*/
feeds: Feeds | null;
/**
* Every demotion this deployment made, in the server's own words.
*
* `/api/v1/health` has carried this since the config learned to demote rather
* than to die (CONTRACT.md §5.1), and until now **nothing in `src/` read
* it**: it was built, served, logged and then dropped on the floor by the one
* consumer that could put it in front of a person. So an operator whose
* `TERA_WEATHER_CONTACT` was missing saw a permanently clear sky, with the
* sentence explaining exactly that sitting in a JSON body one fetch away.
*
* It rides along here because this module has already paid for the round
* trip — `/health` is the first thing boot asks for — and a second identical
* GET to read a different field of the same body is a request nobody needs to
* make. Empty on a fully-configured box, and empty when nothing answered:
* a deployment that does not exist has not demoted anything.
*
* The interface shows it to admin-tier viewers. It names environment
* variables and internal source ids, which is diagnostic detail rather than a
* secret — but it is also noise to everybody who cannot act on it.
*
* **Optional in the type and always present in practice**: every `Access`
* `resolveAccess` returns carries one, empty when there is nothing to report.
* The `?` is there only so that a hand-written pre-boot literal — the closed
* default `main.ts` holds before `resolveAccess()` settles — does not have to
* restate an empty array to keep compiling. Read it as `access.degraded ?? []`
* and the two cases are the same case.
*/
degraded?: string[];
}
/**
* The table. One place, so "what does a member actually get?" is answered by
* reading five lines rather than by grepping for `tier ===` across the app.
*
* `enterOffice` is true for all three on purpose. An earlier cut of this made
* the office a members-only destination and the anonymous view of the site was
* a map with a greyed-out button on it — the single most interesting thing this
* project does, visible only as something you cannot have. The public office is
* the same room with the occupancy layer off, and it costs nothing to show,
* because the floorplan is a data file in this bundle and not a secret.
*/
export function capabilitiesFor(tier: Tier): Capabilities {
return {
enterOffice: true,
officeDepth: tier === "anon" ? "public" : "full",
timeControl: tier === "god",
liveEnvironment: true,
// Asked for by everyone; granted by the server or not. See the field's own
// note — the client requesting a marker set it may not have is a 401, which
// is the correct place for that decision to be enforced and the only place
// it can be enforced at all.
liveMarkers: true,
// The one feed where an anonymous "no" is better than an anonymous "ask".
// See the field. The refusal is real, it is correct, and the simulator on
// the other side of it is a working studio rather than a consolation.
liveDevices: tier !== "anon",
debug: tier === "god",
};
}
/**
* Ask the deployment what it is, then ask it who you are.
*
* **The rule is the auth mode, not the presence of a login form.** This is a
* bug that has already been fixed once in this repo and the way it was written
* is worth keeping in front of anyone editing this function. The old line was:
*
* canEnterOffice = s.authenticated || !s.passwordLogin;
*
* which reads as "if this box cannot sign anyone in, it must be open". True for
* `auth: none`. Dangerously false for `sso` and `jwt`, where `POST
* /api/v1/session` is 404 precisely *because* credentials are issued somewhere
* else — so on an SSO deployment that line handed every anonymous visitor the
* private view while the config still said the deployment was private.
*
* So the mode comes from `/api/v1/health`, which already reports it, and only
* `none` means open. Everything else is a private deployment and has to be told
* affirmatively who you are.
*
* The two failure paths land in deliberately different places, and the asymmetry
* is the entire point:
*
* - **No answer from `/health`** — no API, no deployment-level auth to honour,
* the self-host default. `member`, no sign-in link.
* - **`/health` answered and named a mode, then `/session` failed** — this is a
* configured private deployment having a bad minute. Fail *closed*: `anon`.
* An API that has already told you it has auth is not an API you may assume is
* open.
*/
export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<Access> {
const health = await getJson<{
auth?: { mode?: unknown; entryUrl?: unknown };
sources?: unknown;
degraded?: unknown;
}>(fetcher, "/health");
// Something is mounted at `/api/v1` and it is unwell. That is not the same
// fact as "there is no API", and collapsing the two is how a deployment that
// says it is private comes up open: `tera-api` restarts, Caddy answers 502 for
// the eight seconds it takes, and every anonymous visitor in that window would
// otherwise be told they are a member — badge, full-depth office, and a
// markers request the server is about to refuse anyway. A 5xx is an answer,
// so it is treated like a failed `/session`: closed, and no sign-in link,
// because we do not yet know which door this deployment uses.
if (health.kind === "broken") return access("anon", null, null);
// Nothing answered. Clone-and-run: full experience, no door, no godmode.
if (health.kind === "gone") return access("member", null, null);
const body = health.body;
const mode = typeof body.auth?.mode === "string" ? body.auth.mode : "none";
const entryUrl = entryHref(body.auth?.entryUrl);
const feeds = feedsFrom(body.sources);
const degraded = degradedFrom(body.degraded);
// A box with auth switched off is a self-host that chose to stay open. Same
// deal as no API at all, and for the same reason it is `member` and not `god`.
if (mode === "none") return access("member", null, null, feeds, degraded);
const fetched = await getJson<{
authenticated?: unknown;
subject?: unknown;
passwordLogin?: unknown;
admin?: unknown;
}>(fetcher, "/session");
// Every way `/session` can fail is the same way here — this deployment has
// already said it has auth, so anything short of an affirmative answer is
// `anon`. The three-way split above exists for `/health`, where the question
// is whether there is an API at all; by this line that question is settled.
const session = fetched.kind === "ok" ? fetched.body : null;
const authenticated = session !== null && session.authenticated === true;
const passwordLogin = session !== null && session.passwordLogin === true;
/**
* Read defensively, because `admin` is newer than some servers this client
* will meet. A deployment that has not been updated omits the field, `typeof`
* says `undefined`, and its signed-in users are members — which is the only
* safe direction for a missing field to fall. Never infer godmode from
* silence; see the module header.
*/
const admin = session !== null && typeof session.admin === "boolean" ? session.admin : false;
const subject = session !== null && typeof session.subject === "string" ? session.subject : null;
/**
* The door, in order of how likely it is to actually work.
*
* `entryUrl` is the identity provider naming itself, so it wins. Otherwise the
* local form, but *only* on a server that said it can process one: `login.html`
* ships in this bundle and so is never a 404, which makes the failure mode
* worse rather than better — a page that renders, takes an email and a
* password, and posts them to an endpoint that answers 404 because this
* deployment issues credentials elsewhere. An inert state that says "sign in
* required" is more honest than a form that cannot succeed.
*
* A `/session` that did not answer counts as no local form for the same
* reason. `GET /session` is public and always answers on a healthy box; if it
* did not, the login POST is not going to fare better.
*/
const signInUrl = entryUrl ?? (passwordLogin ? "/login.html" : null);
if (!authenticated) return access("anon", null, signInUrl, feeds, degraded);
return access(admin ? "god" : "member", subject, signInUrl, feeds, degraded);
}
function access(
tier: Tier,
subject: string | null,
signInUrl: string | null,
feeds: Feeds | null = null,
degraded: string[] = [],
): Access {
return { tier, subject, signInUrl, can: capabilitiesFor(tier), feeds, degraded };
}
/**
* `/health`'s `sources` block, read as one yes/no answer per feed.
*
* Defensively, like `admin` above and for the same reason: this field is newer
* than some servers this client will meet, and a missing one has to fall the
* safe way. Here "safe" is `false` — no feed, no request — because the bundled
* sample set is a working map and a poll against a server that never heard of
* the route is not.
*/
function feedsFrom(raw: unknown): Feeds {
const sources = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
const wired = (key: string) => typeof sources[key] === "string" && sources[key] !== "none";
return {
weather: wired("weather"),
flights: wired("flights"),
satellites: wired("satellites"),
markers: wired("markers"),
devices: wired("devices"),
fires: wired("fires"),
radar: wired("radar"),
birds: wired("birds"),
};
}
/**
* `/health`'s `degraded` block, read as sentences.
*
* Filtered rather than cast, and for a reason beyond tidiness: these strings go
* into the interface, so a body carrying numbers, objects or `null` in that
* array would put `[object Object]` in front of an operator who is already
* looking at this list because something is wrong. Anything that is not a
* string is not a sentence and is dropped.
*
* Bounded as well. The list is one line per demotion and a fully-configured box
* has none, so a body with thousands in it is a server this client should not
* be rendering unboundedly — the same disposition `presence/store.ts` takes to
* a roster with fifty thousand rows in it.
*/
function degradedFrom(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw.filter((line): line is string => typeof line === "string").slice(0, MAX_DEGRADED);
}
/** More demotions than any real configuration can produce. See `degradedFrom`. */
const MAX_DEGRADED = 32;
/**
* The three answers a request to `/api/v1` can carry, which is one more than
* this used to have.
*
* `null` for everything was the right shape while the only question was "is
* there an API". It stopped being the right shape once the answer decided
* whether an anonymous visitor is a member: a 502 while `tera-api` restarts and
* a bare static host with no API behind it are the same `null` and the opposite
* conclusion. So there are three, and no more than three — a 404 and a DNS
* failure still land together, because no caller branches on the difference.
*/
type Fetched<T> =
/** 2xx, JSON, parsed. */
| { kind: "ok"; body: T }
/** Nothing is mounted here: transport failure, 404, or a static host's HTML shell. */
| { kind: "gone" }
/** Something is mounted here and it is failing: 5xx. */
| { kind: "broken" };
/**
* One GET, sorted into one of the three.
*
* Still deliberately coarse, in the spirit of `adapters/http.ts`: a timeout, a
* CORS refusal and a DNS failure are all `gone`, and a taxonomy of failures
* nobody reads is a taxonomy nobody maintains. The one distinction that earns
* its keep is 5xx, because it is the only status that means "the thing exists".
*
* The content-type check is not pedantry. A static host serving this bundle
* answers an unknown path with `index.html` and a 200, so without it `/health`
* "succeeds", `res.json()` throws on a `<!doctype html>`, and the throw happens
* to land in the right place — which is a correct outcome arrived at by
* accident. Checking makes it a decision.
*/
async function getJson<T>(fetcher: typeof fetch, path: string): Promise<Fetched<T>> {
try {
const res = await fetcher(`${BASE}${path}`, {
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { accept: "application/json" },
});
if (res.status >= 500) return { kind: "broken" };
if (!res.ok) return { kind: "gone" };
if (!(res.headers.get("content-type") ?? "").includes("json")) return { kind: "gone" };
return { kind: "ok", body: (await res.json()) as T };
} catch {
// Includes a body that claimed JSON and was not. A malformed answer from a
// live server is closer to a broken server than to an absent one, but it is
// indistinguishable here from a socket that died mid-read, and `gone` is
// what the zero-config case needs. The status check above is the line that
// actually catches a sick API.
return { kind: "gone" };
}
}
/**
* `entryUrl` as something safe to put in an `href`.
*
* It arrives from `/api/v1/health`, which is to say from whatever this browser
* is pointed at, and it lands in `a.href` in two places in `main.ts`. A CSP of
* `script-src 'self' 'unsafe-inline'` — which is what `deploy/STATIC.md`
* recommends and what the Lumbridge vhost serves — does **not** block a
* `javascript:` URL from navigating, so an operator who pastes an untrusted
* `TERA_AUTH_ENTRY_URL`, or an API that has been taken over, gets script
* execution in the origin where the sso bearer token lives.
*
* Rejecting it once here beats validating at each sink, and the accepted set is
* deliberately narrow: an absolute `http`/`https` URL, or a path on this origin.
* Anything else — `javascript:`, `data:`, `blob:`, a protocol-relative `//host`
* that silently leaves the origin — is not a sign-in page, and the honest
* outcome for a deployment whose door is unusable is no door at all.
*/
function entryHref(raw: unknown): string | null {
if (typeof raw !== "string" || raw === "") return null;
/**
* Before `new URL`, because `new URL` is what hides this one.
*
* A protocol-relative `//evil.example/login` inherits the page's scheme, so
* `url.protocol` comes back `https:` and the check below waves it through —
* the comment above listed it among the rejected set and it was not among the
* rejected set. It is not the `javascript:` case and it is not script
* execution; it is a value an operator pasted, or an API answered with, being
* turned into a link off this origin that says "Sign in" on it. A host that
* wants to be honoured can write its scheme.
*/
if (/^\s*\/\//.test(raw)) return null;
try {
const url = new URL(raw, window.location.origin);
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
return url.href;
} catch {
return null;
}
}