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
+26 -1
View File
@@ -20,6 +20,31 @@ a city is a data contribution anyone can review, not a fork.
San Francisco ships today. Los Angeles / Orange County / Riverside is next; New
York after that.
A **plan view** sits top right: the board drawn flat, with the footprint of the
camera's own frustum on it, so you can see where you are looking from outside
the shot. Click or drag it to move the camera; scroll it to dolly. It is a 2D
canvas rather than a second WebGL context, drawn from the same city pack, and it
follows the sun into the night along with everything else.
## Who sees what
Three tiers, resolved once at boot by `src/access.ts`:
| | anonymous | signed in | admin |
|---|---|---|---|
| the map, the plan view, the named chapters | ✅ | ✅ | ✅ |
| the office | public depth — shell, furniture, viewpoints, nobody home | full depth, with presence | full depth |
| live markers and live traffic | — | ✅ | ✅ |
| the time scrubber and debug readouts | — | — | ✅ |
**These are drawing decisions, not a security boundary**, and `src/access.ts`
says so at length. Live data and office presence are withheld by the *API*, from
a caller it does not recognise; the client tier stops the app asking for
something it will not get. Admin is granted only by `TERA_ADMIN_SUBJECTS` on the
server — never inferred in the browser, and never from an API that failed to
answer. A deployment with no API at all is open, because "clone it and it works"
is the promise; it is not "clone it and you are an administrator".
## Quick start
```bash
@@ -76,7 +101,7 @@ answer is an RTL-SDR receiver: first-party data with nothing to comply with.
## Layout
```
src/engine/ renderer — terrain, blocks, structures, markers, flights, scene
src/engine/ renderer — terrain, blocks, structures, markers, flights, scene, minimap
src/cities/ data packs — pure geography, no code
src/adapters/ where outside data plugs in
```
+13 -4
View File
@@ -1,9 +1,18 @@
# Hosting the static build
Tera's map view is a static bundle. It makes **no network calls at all** — the
sun is computed locally by `src/engine/solar.ts`, the traffic is simulated, and
the office is a data file — so it needs a file server and nothing else. No API,
no keys, no account.
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.
```bash
npm ci
+607 -53
View File
@@ -3,75 +3,629 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<!--
An empty data URI, not an icon file. `src/` carries no binary assets by
policy (ARCHITECTURE.md §3.1) and a favicon is not worth being the
exception; without this line every load spends a request on `/favicon.ico`
and logs the 404 to the console, which buries the errors that matter.
-->
<link rel="icon" href="data:," />
<title>Lumbridge Simulate — San Francisco</title>
<style>
/*
* The whole interface, inline, on purpose.
*
* There is no framework and no stylesheet because there is no build step
* that would earn one: this file is served as written, so what you read
* here is what the browser gets. It also means the first paint needs
* nothing but this document — which is the entire reason the boot card at
* the bottom of the body can be on screen before a single module has been
* fetched, let alone before the Bay Area heightfield has been built.
*
* Two constraints shape every rule below.
*
* The first is that this chrome floats over a *photographic* background
* that changes from a bright noon sky to a nearly black one and back. A
* panel tuned only for the dark case — which is what this was — reads as
* a grey smudge at noon, because dark-on-dark needs no edge and
* dark-on-bright very much does. Every card therefore carries three
* separations at once: a fill dark enough to hold white text against a
* white sky, a hairline that is light on the inside so the card has a
* rim, and one soft drop shadow so it sits above rather than in.
*
* The second is that there is exactly one accent. `#f2b134` is the
* identity and it is spent only on things that are *active* — the current
* chapter, the current city, the office door, a focus ring. The moment it
* also means "heading" and "border" and "hover" it stops meaning
* anything, which is what a second pass at this looked like before it was
* pulled back.
*/
:root {
--amber: #f2b134;
--amber-lit: #ffc555;
--amber-ink: #ffd68a;
/* One glass recipe, two weights. The strong one is for things that must
be read over the brightest part of the sky: the boot card and the
shortcuts sheet. */
--glass: rgba(9, 13, 18, 0.62);
--glass-strong: rgba(9, 13, 18, 0.86);
--glass-inset: rgba(255, 255, 255, 0.05);
--hairline: rgba(255, 255, 255, 0.11);
--blur: blur(14px) saturate(1.2);
--shadow: 0 6px 22px rgba(3, 6, 10, 0.45);
/* Four steps of ink and no more. Anything that wanted a fifth was
saying something the type scale should have said instead. */
--ink: rgba(255, 255, 255, 0.78);
--ink-2: rgba(255, 255, 255, 0.56);
--ink-3: rgba(255, 255, 255, 0.4);
--ink-4: rgba(255, 255, 255, 0.26);
/* 4px rhythm. Every gap, pad and offset below is a multiple of it, so
the columns line up without anyone having to nudge a value. */
--s1: 4px;
--s2: 8px;
--s3: 12px;
--s4: 16px;
--s5: 24px;
--r: 8px;
--r-sm: 5px;
--t: 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #d9e6ee; overflow: hidden;
font-family: ui-monospace, "SF Mono", Menlo, monospace; }
#scene { display: block; width: 100vw; height: 100vh; }
#panel { position: fixed; top: 0; left: 0; padding: 1rem; width: 19rem;
display: flex; flex-direction: column; gap: 0.25rem; pointer-events: none; }
[hidden] { display: none !important; }
html, body {
margin: 0;
height: 100%;
/* Dark, not the old `#d9e6ee`. The canvas covers this within a frame,
but the frame before it used to be a full-screen flash of pale blue
that then dropped to a night scene. A dark page is wrong for half a
second at noon and right for half a second at midnight; the flash is
what people noticed. */
background: #0d1218;
overflow: hidden;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
#scene { display: block; width: 100vw; height: 100dvh; touch-action: none; }
/* ---- Type scale ------------------------------------------------------
Five sizes. 9px is reserved for uppercase micro-labels, where the caps
and the tracking carry the legibility that the size does not. */
.t-title { font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; }
.t-body { font-size: 11px; letter-spacing: 0.01em; line-height: 1.55; }
.t-ctrl { font-size: 12px; letter-spacing: 0.01em; }
.t-micro { font-size: 10px; letter-spacing: 0.06em; }
.t-caps { font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; }
/* ---- Glass ----------------------------------------------------------- */
.card {
background: var(--glass);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
border: 1px solid var(--hairline);
box-shadow: var(--shadow), inset 0 1px 0 var(--glass-inset);
border-radius: var(--r);
padding: var(--s3);
color: var(--ink);
font-size: 11px;
line-height: 1.55;
}
/* One focus treatment for everything that can take focus. The offset is
what makes it legible on a control that is already amber. */
:is(button, a, input, [tabindex]):focus-visible {
outline: 2px solid var(--amber);
outline-offset: 2px;
}
/* ---- Left column ------------------------------------------------------ */
#panel {
position: fixed;
top: 0;
left: 0;
padding: var(--s4);
width: min(19.5rem, calc(100vw - var(--s4) * 2));
max-height: 100dvh;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
display: flex;
flex-direction: column;
gap: var(--s1);
pointer-events: none;
transition: transform var(--t), opacity var(--t);
}
#panel > * { pointer-events: auto; }
.card { background: rgba(8,12,16,0.55); backdrop-filter: blur(6px);
border-radius: 6px; padding: 0.75rem; color: rgba(255,255,255,0.72); font-size: 11px;
line-height: 1.5; }
h1 { margin: 0; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase;
color: #f2b134; }
h1 + p { margin: 0.15rem 0 0; color: rgba(255,255,255,0.55); }
#chapters { display: flex; flex-direction: column; gap: 1px; background: rgba(8,12,16,0.45);
backdrop-filter: blur(6px); border-radius: 6px; padding: 4px; }
.chapter { display: flex; align-items: baseline; gap: 0.5rem; padding: 0.4rem 0.5rem;
background: none; border: 0; border-radius: 4px; cursor: pointer; text-align: left;
font: inherit; font-size: 12px; color: rgba(255,255,255,0.7); }
.chapter:hover { background: rgba(255,255,255,0.09); }
.chapter.active { background: rgba(242,177,52,0.2); color: #ffd68a; }
.num { font-size: 10px; opacity: 0.6; font-variant-numeric: tabular-nums; }
#detail { position: fixed; right: 1rem; bottom: 2.5rem; }
#hint { position: fixed; right: 1rem; bottom: 1rem; font-size: 10px;
color: rgba(20,30,40,0.5); }
.clock { margin: 0.35rem 0 0; font-size: 10px; letter-spacing: 0.08em;
color: rgba(255,255,255,0.42); }
.enter { font: inherit; font-size: 12px; padding: 0.5rem 0.7rem; cursor: pointer;
text-align: left; border: 0; border-radius: 6px; color: #10161d;
background: #f2b134; font-weight: 600; }
.enter:hover { background: #ffc555; }
.scrub { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.45rem; }
.scrub input { flex: 1; accent-color: #f2b134; height: 14px; }
.cities { display: flex; gap: 3px; }
.city { flex: 1; font: inherit; font-size: 11px; padding: 0.35rem; cursor: pointer;
border: 0; border-radius: 5px; background: rgba(8,12,16,0.55); color: rgba(255,255,255,0.6);
backdrop-filter: blur(6px); }
.city:hover { background: rgba(255,255,255,0.14); }
.city.active { background: rgba(242,177,52,0.22); color: #ffd68a; }
.source { position: fixed; left: 1rem; bottom: 1rem; font-size: 10px;
color: rgba(255,255,255,0.4); background: rgba(8,12,16,0.5); padding: 0.25rem 0.5rem;
border-radius: 4px; backdrop-filter: blur(6px); }
.source.live { color: #7ee08a; }
.scrub button { font: inherit; font-size: 9px; text-transform: uppercase;
letter-spacing: 0.08em; padding: 0.15rem 0.35rem; cursor: pointer; border: 0;
border-radius: 3px; background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.7); }
h1 {
margin: 0;
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--amber);
}
#subtitle { margin: var(--s1) 0 0; font-size: 10px; letter-spacing: 0.06em; color: var(--ink-3); }
.clock {
margin: var(--s2) 0 0;
font-size: 10px;
letter-spacing: 0.06em;
color: var(--ink-2);
font-variant-numeric: tabular-nums;
}
.scrub { display: flex; align-items: center; gap: var(--s2); margin-top: var(--s2); }
.scrub input { flex: 1; accent-color: var(--amber); height: 14px; min-width: 0; }
.scrub button {
font: inherit;
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 3px 6px;
cursor: pointer;
border: 1px solid var(--hairline);
border-radius: var(--r-sm);
background: rgba(255, 255, 255, 0.08);
color: var(--ink-2);
transition: background var(--t), color var(--t);
}
.scrub button:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }
.cities { display: flex; gap: var(--s1); }
.city {
flex: 1;
font: inherit;
font-size: 11px;
padding: var(--s2) var(--s1);
cursor: pointer;
border: 1px solid var(--hairline);
border-radius: var(--r-sm);
background: var(--glass);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
box-shadow: var(--shadow);
color: var(--ink-2);
transition: background var(--t), color var(--t);
}
.city:hover { background: rgba(40, 48, 58, 0.7); color: var(--ink); }
.city[aria-pressed="true"] { background: rgba(242, 177, 52, 0.22); color: var(--amber-ink); }
.enter {
font: inherit;
font-size: 12px;
padding: var(--s2) var(--s3);
cursor: pointer;
text-align: left;
border: 1px solid transparent;
border-radius: var(--r-sm);
color: #10161d;
background: var(--amber);
font-weight: 600;
box-shadow: var(--shadow);
transition: background var(--t);
}
.enter:hover { background: var(--amber-lit); }
/* The public-office note. Amber-edged rather than amber-filled: it is an
explanation, not an action, and the one filled amber thing on screen
should stay the door. */
.badge {
margin: 0;
border-left: 2px solid rgba(242, 177, 52, 0.55);
border-radius: var(--r-sm);
padding: var(--s2) var(--s3);
font-size: 10px;
line-height: 1.6;
color: var(--ink-2);
}
.badge a { color: var(--amber-ink); }
#chapters {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--glass);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
border: 1px solid var(--hairline);
box-shadow: var(--shadow), inset 0 1px 0 var(--glass-inset);
border-radius: var(--r);
padding: var(--s1);
}
.chapter {
display: flex;
align-items: baseline;
gap: var(--s2);
padding: var(--s2) var(--s2);
background: none;
border: 0;
border-radius: var(--r-sm);
cursor: pointer;
text-align: left;
font: inherit;
font-size: 12px;
color: var(--ink-2);
transition: background var(--t), color var(--t);
}
.chapter:hover { background: rgba(255, 255, 255, 0.09); color: var(--ink); }
.chapter[aria-pressed="true"] { background: rgba(242, 177, 52, 0.2); color: var(--amber-ink); }
.num { font-size: 9px; letter-spacing: 0.1em; opacity: 0.55; font-variant-numeric: tabular-nums; }
#blurb { margin: 0; color: var(--ink-2); }
/* The panel toggle only exists on a narrow screen, where it doubles as the
header — hence the city name in it. */
.panel-toggle {
display: none;
position: fixed;
top: var(--s3);
left: var(--s3);
z-index: 4;
align-items: center;
gap: var(--s2);
font: inherit;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: var(--s2) var(--s3);
cursor: pointer;
color: var(--amber);
background: var(--glass);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
border: 1px solid var(--hairline);
border-radius: var(--r-sm);
box-shadow: var(--shadow);
}
.panel-toggle .glyph { font-size: 13px; line-height: 1; }
/* ---- Top right: the plan --------------------------------------------- */
.corner {
position: fixed;
top: var(--s4);
right: var(--s4);
width: 15rem;
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--s1);
}
#minimap { padding: var(--s1); display: flex; flex-direction: column; gap: var(--s1); }
/* A real height, always. The canvas is pinned to 100%/100% inline by
`minimap.ts`, and an `auto` height here puts back the feedback loop that
rule exists to break: the backing store feeds layout, layout feeds the
backing store, and the map doubles in size every frame. */
.minimap-frame {
width: 100%;
height: 14rem;
border-radius: var(--r-sm);
overflow: hidden;
}
.minimap-canvas { outline: none; cursor: crosshair; }
.minimap-canvas:focus-visible { outline: 2px solid var(--amber); outline-offset: -2px; }
.minimap-readout {
margin: 0;
padding: 0 var(--s1) 2px;
font-size: 10px;
letter-spacing: 0.05em;
color: var(--ink-3);
min-height: 1.3em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tier {
margin: 0;
padding: var(--s2) var(--s3);
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--s2);
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-3);
}
.tier .who { color: var(--ink-2); text-transform: none; letter-spacing: 0.04em; }
.tier.god .who { color: var(--amber-ink); }
.tier a { color: var(--amber-ink); text-transform: none; letter-spacing: 0.04em; }
/* ---- Bottom right: what you picked, and how to drive ------------------ */
.rail {
position: fixed;
right: var(--s4);
bottom: var(--s4);
z-index: 3;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--s1);
/* Wide enough that the key hints stay on one line at desktop widths —
a shortcut list that wraps reads as five separate notes rather than
one row — and narrow enough that a long detail card never becomes a
second panel. */
max-width: min(30rem, calc(100vw - var(--s4) * 2));
}
#detail { max-width: 100%; }
.hint {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: var(--s1) var(--s3);
padding: var(--s2) var(--s3);
font-size: 10px;
letter-spacing: 0.04em;
color: var(--ink-3);
}
kbd {
font: inherit;
font-size: 9px;
padding: 1px 4px;
border: 1px solid var(--hairline);
border-radius: 3px;
background: rgba(255, 255, 255, 0.08);
color: var(--ink-2);
}
.help {
font: inherit;
font-size: 10px;
letter-spacing: 0.04em;
padding: 2px 6px;
cursor: pointer;
border: 1px solid var(--hairline);
border-radius: var(--r-sm);
background: rgba(255, 255, 255, 0.06);
color: var(--ink-2);
transition: background var(--t), color var(--t);
}
.help:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }
/* ---- Bottom left: where the numbers came from -------------------------
This line is load-bearing. The markers on this map are invented, and a
map that looks this much like a real one has to say so on the same
screen as the map — not in a README nobody opens. */
.source {
position: fixed;
left: var(--s4);
bottom: var(--s4);
margin: 0;
z-index: 3;
max-width: calc(100vw - var(--s4) * 2);
font-size: 10px;
letter-spacing: 0.04em;
color: var(--ink-3);
background: var(--glass);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
border: 1px solid var(--hairline);
box-shadow: var(--shadow);
padding: var(--s1) var(--s2);
border-radius: var(--r-sm);
}
.source.live { color: #8fe89a; }
/* ---- Overlays --------------------------------------------------------- */
.overlay {
position: fixed;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: var(--s4);
background: rgba(4, 7, 11, 0.55);
backdrop-filter: blur(3px);
}
.sheet {
background: var(--glass-strong);
width: min(26rem, 100%);
max-height: calc(100dvh - var(--s5) * 2);
overflow-y: auto;
padding: var(--s4);
}
.sheet h2 { margin: 0 0 var(--s3); font-size: 11px; letter-spacing: 0.2em;
text-transform: uppercase; color: var(--amber); }
.keys { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: var(--s2) var(--s3);
align-items: baseline; font-size: 11px; }
.keys dt { text-align: right; }
.keys dd { margin: 0; color: var(--ink-2); }
.sheet-close {
margin-top: var(--s4);
font: inherit;
font-size: 11px;
padding: var(--s2) var(--s3);
cursor: pointer;
width: 100%;
border: 1px solid var(--hairline);
border-radius: var(--r-sm);
background: rgba(255, 255, 255, 0.08);
color: var(--ink);
}
.sheet-close:hover { background: rgba(255, 255, 255, 0.16); }
/* ---- Boot -------------------------------------------------------------
The page used to be blank for the ~2.3 s it takes to build the Bay Area
heightfield, which reads as a broken site rather than a busy one. This
card is in the document, so it is on screen at first paint — before the
module graph has even been fetched — and `main.ts` only ever writes the
step line and then fades the whole thing out.
It is deliberately not a spinner. A spinner says "wait"; naming the
thing being built says "wait, for this", and the sentence about running
with no server is the one fact about this project worth reading while
you have nothing else to do. */
.boot {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
padding: var(--s4);
background: radial-gradient(120% 90% at 50% 30%, #172029 0%, #0b0f14 70%);
transition: opacity 260ms ease;
}
.boot.done { opacity: 0; pointer-events: none; }
.boot-inner { width: min(22rem, 100%); text-align: left; }
.boot-mark { margin: 0; font-size: 12px; letter-spacing: 0.42em; text-transform: uppercase;
color: var(--amber); }
.boot-sub { margin: var(--s2) 0 0; font-size: 10px; letter-spacing: 0.08em; color: var(--ink-3); }
.boot-bar {
margin: var(--s4) 0 var(--s2);
height: 2px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.09);
overflow: hidden;
}
.boot-bar i {
display: block;
height: 100%;
width: 34%;
border-radius: 2px;
background: linear-gradient(90deg, transparent, var(--amber), transparent);
animation: sweep 1400ms cubic-bezier(0.45, 0, 0.55, 1) infinite;
}
@keyframes sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(340%); }
}
.boot-step { margin: 0; font-size: 10px; letter-spacing: 0.06em; color: var(--ink-2);
min-height: 1.4em; }
.boot-note { margin: var(--s4) 0 0; font-size: 10px; line-height: 1.6; color: var(--ink-4); }
/* ---- Responsive -------------------------------------------------------
Two breakpoints and no more. At 900 the left column stops being furniture
and becomes a sheet you open; at 600 the plan stops fitting beside the
map at all and lives behind `M`. `main.ts` owns the *state* of both, so
these rules only describe what each state looks like. */
@media (max-width: 900px) {
.panel-toggle { display: flex; }
#panel {
padding-top: 56px;
width: min(18rem, calc(100vw - var(--s3) * 2));
z-index: 3;
}
body.panel-closed #panel {
transform: translateX(calc(-100% - var(--s3)));
opacity: 0;
pointer-events: none;
}
.corner { width: 11.5rem; top: var(--s3); right: var(--s3); }
.minimap-frame { height: 10rem; }
.rail { right: var(--s3); bottom: var(--s3); }
.source { left: var(--s3); bottom: var(--s3); }
}
@media (max-width: 600px) {
.rail { max-width: calc(100vw - var(--s3) * 2); }
/* The provenance line and the key hints would collide in the last 40px
of a phone, and the honesty line is the one that has to survive. */
.hint span { display: none; }
.source { max-width: 60vw; }
}
/* The minimap's own visibility is a user decision (`M`), seeded from the
viewport width by `main.ts` rather than by a media query, so that
toggling it on at 375px actually shows it. */
body.minimap-off .corner { display: none; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 1ms !important;
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
}
.boot-bar i { width: 100%; transform: none; }
}
</style>
</head>
<body>
<canvas id="scene"></canvas>
<canvas id="scene" aria-label="Map of San Francisco, seen from above. Drag to orbit, scroll to zoom."></canvas>
<button id="panel-toggle" class="panel-toggle" aria-expanded="true" aria-controls="panel">
<span class="glyph" aria-hidden="true"></span><span id="panel-toggle-label">Bay Area</span>
</button>
<div id="panel">
<div class="card">
<section class="card">
<h1 id="title">San Francisco</h1>
<p id="subtitle">Tera · Lumbridge Simulate</p>
<p id="clock" class="clock"></p>
<div class="scrub">
<input id="hour" type="range" min="0" max="23.9" step="0.1" value="12" />
<p id="clock" class="clock" aria-live="polite"></p>
<div id="scrub" class="scrub" hidden>
<input id="hour" type="range" min="0" max="23.9" step="0.1" value="12"
aria-label="Hour of day" />
<button id="now" title="follow the wall clock">now</button>
</div>
</div>
<nav id="cities" class="cities"></nav>
</section>
<nav id="cities" class="cities" aria-label="City"></nav>
<button id="enter" class="enter">Enter the office →</button>
<nav id="chapters"></nav>
<p class="card badge" id="office-badge" hidden></p>
<nav id="chapters" aria-label="Chapters"></nav>
<p class="card" id="blurb"></p>
</div>
<div class="card" id="detail" hidden></div>
<aside id="corner" class="corner" aria-label="Plan view">
<div class="card" id="minimap">
<div class="minimap-frame"></div>
<p id="minimap-readout" class="minimap-readout"></p>
</div>
<p class="card tier" id="tier" hidden></p>
</aside>
<div class="rail" id="rail">
<div class="card" id="detail" aria-live="polite" hidden></div>
<div class="card hint" id="hint">
<span><kbd>1</kbd><kbd>9</kbd> chapters</span>
<span><kbd>[</kbd> <kbd>]</kbd> city</span>
<span><kbd>O</kbd> office</span>
<span><kbd>M</kbd> plan</span>
<button id="help" class="help" aria-haspopup="dialog">? shortcuts</button>
</div>
</div>
<p id="source" class="source"></p>
<p id="hint">drag to orbit · scroll to zoom</p>
<div class="overlay" id="shortcuts" role="dialog" aria-modal="true"
aria-labelledby="shortcuts-title" hidden>
<div class="card sheet">
<h2 id="shortcuts-title">Keyboard</h2>
<dl class="keys">
<dt><kbd>1</kbd><kbd>9</kbd></dt><dd>Fly to a chapter, or an office viewpoint</dd>
<dt><kbd>[</kbd> <kbd>]</kbd></dt><dd>Previous / next city</dd>
<dt><kbd>O</kbd></dt><dd>Enter or leave the office</dd>
<dt><kbd>M</kbd></dt><dd>Show or hide the plan view</dd>
<dt><kbd>?</kbd></dt><dd>This card</dd>
<dt><kbd>Esc</kbd></dt><dd>Close an overlay, or leave the office</dd>
<dt><kbd>Tab</kbd></dt><dd>Every control, in reading order</dd>
</dl>
<p class="boot-note">On the plan: click or drag to move the view, scroll to zoom,
arrow keys to aim and <kbd>Enter</kbd> to go.</p>
<button class="sheet-close" id="shortcuts-close">Close</button>
</div>
</div>
<div class="boot" id="boot">
<div class="boot-inner">
<p class="boot-mark">Tera</p>
<p class="boot-sub">Lumbridge Simulate · cities from above</p>
<div class="boot-bar"><i></i></div>
<p class="boot-step" id="boot-step">Starting up</p>
<p class="boot-note">Runs with no server, no key and no account. The markers are
fabricated — no real company data ships in this build.</p>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+62 -4
View File
@@ -42,7 +42,7 @@
<p>Tera · sign in to reach a private office.</p>
<form id="form" autocomplete="on">
<label for="username">Username</label>
<label id="userLabel" for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" required
autocapitalize="none" autocorrect="off" spellcheck="false" />
@@ -54,14 +54,42 @@
</form>
<p id="note" role="status" aria-live="polite"></p>
<footer>The session is a cookie this server signs. Nothing leaves the box.</footer>
<footer id="footer">The session is a cookie this server signs. Nothing leaves the box.</footer>
</main>
<script type="module">
import {
authFetch,
clearToken,
identityConfigured,
signIn,
writeToken,
} from "./src/session.ts";
const form = document.getElementById("form");
const submit = document.getElementById("submit");
const note = document.getElementById("note");
/**
* Two deployments share this page.
*
* With an identity provider configured the credentials go to **it**, not
* here, and what comes back is a bearer token this origin stores — the
* `sso` arrangement, where this box holds no credentials and only ever
* revalidates. Without one, nothing below changes: the form posts to
* `/api/v1/session` and the server signs a cookie, which is the
* self-hoster's default and the better of the two.
*/
if (identityConfigured) {
document.getElementById("userLabel").textContent = "Email";
const username = document.getElementById("username");
username.type = "email";
username.autocomplete = "email";
username.placeholder = "you@example.com";
document.getElementById("footer").textContent =
"Signed in with your Lumbridge account. The office checks it with the control plane.";
}
function say(text, bad) {
note.textContent = text;
note.classList.toggle("bad", bad === true);
@@ -84,13 +112,21 @@
// If the cookie is already good, there is nothing to ask for. This also
// tells us whether this deployment can sign anyone in at all.
try {
const res = await fetch("/api/v1/session", { credentials: "same-origin" });
const res = await authFetch("/api/v1/session");
const state = res.ok ? await res.json() : null;
if (state?.authenticated === true) {
location.replace(destination());
} else if (state !== null && state.passwordLogin !== true) {
} else if (state !== null && state.passwordLogin !== true && !identityConfigured) {
// No local form AND no identity provider means there is genuinely
// nothing to offer. With a provider configured this branch must not
// fire: `passwordLogin` is false in `sso` mode precisely because the
// credentials belong somewhere else, which is the normal case here.
form.hidden = true;
say("This deployment does not sign people in here.");
} else if (state?.authenticated === false) {
// A stored token the server no longer accepts. Drop it, or the next
// request re-sends a token that is only going to be refused again.
clearToken();
}
} catch {
// An unreachable API is not a reason to hide the form; the submit below
@@ -102,6 +138,28 @@
submit.disabled = true;
say("Checking…");
// Identity-provider path: the password never reaches this origin.
if (identityConfigured) {
try {
const token = await signIn(form.username.value, form.password.value);
if (token !== null) {
writeToken(token);
say("Signed in. Taking you back…");
location.replace(destination());
return;
}
form.password.value = "";
// One message, as below: which half was wrong is not this page's to
// disclose, and the provider does not tell us either.
say("Those credentials were not accepted.", true);
} catch {
say("Could not reach the sign-in service.", true);
} finally {
submit.disabled = false;
}
return;
}
try {
const res = await fetch("/api/v1/session", {
method: "POST",
+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:
+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;
}
+84
View File
@@ -76,6 +76,18 @@ export interface PasswordLogin {
rateWindowSeconds: number;
}
/**
* Who holds the god tier, resolved from `TERA_ADMIN_SUBJECTS`. See `loadAdmins`
* for the rules; the shape exists so that "everyone" is a state the type system
* knows about rather than a magic string left sitting in `subjects`.
*/
export interface AdminGrant {
/** `TERA_ADMIN_SUBJECTS=*`. Development only; see `loadAdmins`. */
everyone: boolean;
/** Exact subject ids, trimmed. Empty with `everyone: false` means no admins. */
subjects: string[];
}
export interface AuthConfig {
mode: AuthMode;
/** Where a browser sends someone to sign in. `sso` mode only. */
@@ -93,6 +105,8 @@ export interface AuthConfig {
audience: string;
/** Set only by `TERA_AUTH_MODE=password`; see `PasswordLogin` and `loadPasswordLogin`. */
passwordLogin: PasswordLogin | null;
/** Subjects the server will call admins. Unset means nobody; see `loadAdmins`. */
admins: AdminGrant;
}
export interface Config {
@@ -352,9 +366,79 @@ function loadAuth(env: Env, degraded: string[]): AuthConfig {
issuer: str(env, "TERA_AUTH_JWT_ISSUER", ""),
audience: str(env, "TERA_AUTH_JWT_AUDIENCE", ""),
passwordLogin,
// Last, because it wants the mode *after* every demotion above has run: a
// list of admins on a box that just demoted to mode=none is worth a
// sentence, and the sentence is only true once `mode` has settled.
admins: loadAdmins(env, mode, degraded),
};
}
/** `TERA_ADMIN_SUBJECTS=*`; see `loadAdmins` for why this is a dev switch. */
const ADMIN_WILDCARD = "*";
/**
* Who gets the god tier — the time controls, the debug panel, and whatever else
* ends up behind `Viewer.admin`.
*
* `TERA_ADMIN_SUBJECTS` is a comma-separated list of **subject ids**: 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 — and only by
* naming it here. Password mode is deliberately not auto-admin: one grant path,
* written down in the environment, is the property worth having.
*
* **Unset means no admins, and that is the safe default.** A box handed nothing
* serves the public map to everyone and the god tier to nobody. Matching is
* exact after trimming, and case-sensitive: subject ids are opaque strings an
* issuer minted, `user_01H…` and `USER_01H…` can be two different accounts, and
* folding case here would silently widen a grant to an id nobody configured.
*
* The one wildcard is `*`, which makes **every authenticated subject an admin**.
* It is here so a self-hoster poking at this on a laptop does not have to go
* find their own subject id first. It is a development switch, it must never
* reach a deployment env file, and it pushes a line into `degraded` so that
* `/api/v1/health` says out loud that the box is handing out godmode.
*
* That loudness is the whole point, and it is paid for. lumbridge-v4 shipped
* `ADMIN_EMAILS` with `admin@lumbridgecorp.com` as a committed default while
* nobody had ever registered that address — a standing offer of admin to
* whoever signed up for it first, invisible because nothing anywhere announced
* it. A grant nobody can see is a grant nobody revokes. Hence: no committed
* defaults, no implicit grants, and the one blanket switch reports itself.
*
* Note what is *not* here: no count and no list ever reaches the wire.
* `routes/health.ts` serves `degraded`, and these sentences name the variable,
* never its contents.
*/
function loadAdmins(env: Env, mode: AuthMode, degraded: string[]): AdminGrant {
const configured = list(env, "TERA_ADMIN_SUBJECTS");
const everyone = configured.includes(ADMIN_WILDCARD);
const subjects = configured.filter((subject) => subject !== ADMIN_WILDCARD);
if (everyone) {
degraded.push(
"TERA_ADMIN_SUBJECTS=* grants the admin tier to every authenticated " +
"subject on this box, time controls and debug included. That is a " +
"development switch; anywhere reachable from outside, list the subject " +
"ids instead.",
);
}
// Not a demotion of this setting — nothing falls back — but a line worth
// printing, because admin is a property of an *authenticated* viewer and
// mode=none never produces one. An operator who listed admins here and reads
// `degraded` learns in one sentence why nobody is getting them.
if ((everyone || subjects.length > 0) && mode === "none") {
degraded.push(
"TERA_ADMIN_SUBJECTS is set, but authentication resolved to mode=none: " +
"nobody can sign in, so nobody is an admin. Set TERA_AUTH_MODE, and " +
"check the lines above for an auth demotion that got you here.",
);
}
return { everyone, subjects };
}
/**
* The credential for `TERA_AUTH_MODE=password`, or `null` with a loud line if
* the environment did not supply a usable one.
+42 -5
View File
@@ -25,8 +25,10 @@
import type { FastifyInstance } from "fastify";
import {
clearedSessionCookie,
grantsAdmin,
issueSessionToken,
sessionCookie,
type Viewer,
} from "../auth/index.ts";
import { MAX_PASSWORD_LENGTH, credentialsMatch } from "../auth/password.ts";
import type { ErrorBody } from "../../../src/server/wire.ts";
@@ -42,6 +44,14 @@ export interface SessionBody {
authenticated: boolean;
/** The signed-in subject, or `null`. Never a token. */
subject: string | null;
/**
* The god tier, decided by `TERA_ADMIN_SUBJECTS` on the server. The client
* reads it to decide what to draw — the time scrubber, the debug panel — and
* that is all it is for. It is not a capability: everything gated on it is
* gated again where it is enforced, because a boolean that arrived over the
* wire is a rendering hint and nothing more.
*/
admin: boolean;
/** Whether `POST` to this endpoint can sign somebody in on this deployment. */
passwordLogin: boolean;
}
@@ -71,6 +81,14 @@ const RATE_LIMITED: ErrorBody = {
const MAX_USERNAME_LENGTH = 256;
/**
* What `DELETE` reports. Signing out drops the tier with the session, and it
* has to be said explicitly rather than left to the client: a page that cached
* `admin: true` and only ever hears "authenticated: false" would keep drawing
* the god-only controls until the next reload.
*/
const SIGNED_OUT: Viewer = { authenticated: false, subject: null, admin: false };
export function registerSession(app: FastifyInstance, services: Services): void {
const { auth } = services.config;
// Per app instance rather than per module, so two servers in one process —
@@ -82,7 +100,7 @@ export function registerSession(app: FastifyInstance, services: Services): void
app.get("/api/v1/session", async (req) => {
const viewer = await services.auth.resolve(req);
return body(viewer.authenticated, viewer.subject, auth.passwordLogin !== null);
return body(viewer, auth.passwordLogin !== null);
});
app.post("/api/v1/session", async (req, reply) => {
@@ -118,7 +136,16 @@ export function registerSession(app: FastifyInstance, services: Services): void
limiter.succeed(req.ip);
const token = issueSessionToken(auth, login.username, login.sessionTtlSeconds);
reply.header("set-cookie", sessionCookie(auth, token, login.sessionTtlSeconds));
return body(true, login.username, true);
// The real grant for the account that just signed in, not `false` and not a
// guess. `issueSessionToken` put `login.username` in the `sub` claim, so
// `grantsAdmin` is being asked the same question about the same string that
// `resolve()` will ask on the very next request with this cookie — a login
// that answered differently from the GET a moment later would be a flicker
// nobody could reproduce.
return body(
{ authenticated: true, subject: login.username, admin: grantsAdmin(auth, login.username) },
true,
);
});
// Signing out is available in every mode, including the ones where this box
@@ -126,12 +153,22 @@ export function registerSession(app: FastifyInstance, services: Services): void
// something about how the deployment is configured and helps nobody.
app.delete("/api/v1/session", async (_req, reply) => {
reply.header("set-cookie", clearedSessionCookie(auth));
return body(false, null, auth.passwordLogin !== null);
return body(SIGNED_OUT, auth.passwordLogin !== null);
});
}
function body(authenticated: boolean, subject: string | null, passwordLogin: boolean): SessionBody {
return { authenticated, subject, passwordLogin };
/**
* The one place the session shape is written. All three handlers go through it,
* so `admin` cannot be present on one response and missing from another — which
* is the bug a client's `s.admin === true` would read as "demoted" and act on.
*/
function body(viewer: Viewer, passwordLogin: boolean): SessionBody {
return {
authenticated: viewer.authenticated,
subject: viewer.subject,
admin: viewer.admin,
passwordLogin,
};
}
/**
+3
View File
@@ -35,6 +35,9 @@ describe("a box handed nothing", () => {
assert.equal(config.flights.source, "sim");
assert.equal(config.markers.source, "none");
assert.equal(config.auth.mode, "none");
// Nobody configured an admin, so there is no admin. The god tier has to be
// the thing an empty environment does *not* hand out.
assert.deepEqual(config.auth.admins, { everyone: false, subjects: [] });
assert.equal(config.host, "127.0.0.1");
assert.equal(config.port, 8431);
assert.deepEqual(config.degraded, []);
+20
View File
@@ -91,4 +91,24 @@ describe("other misconfigurations", () => {
const config = loadConfig({ TERA_MARKERS_SOURCE: "file" });
assert.equal(config.markers.source, "none");
});
it("says so when admins are listed on a box where nobody can sign in", () => {
// Nothing falls back here — there is nothing to fall back to — but the
// operator who wrote a name into TERA_ADMIN_SUBJECTS is owed the sentence
// explaining why that name is not getting the time controls.
const config = loadConfig({ TERA_ADMIN_SUBJECTS: "karti" });
assert.equal(config.auth.mode, "none");
assert.deepEqual(config.auth.admins, { everyone: false, subjects: ["karti"] });
assert.match(config.degraded[0] ?? "", /TERA_ADMIN_SUBJECTS/);
assert.match(config.degraded[0] ?? "", /mode=none/);
});
it("records the wildcard as its own line, on top of any other demotion", () => {
const config = loadConfig({ TERA_AUTH_MODE: "jwt", TERA_ADMIN_SUBJECTS: "*" });
// The jwt demotion, the wildcard, and the fact that the wildcard cannot
// reach anybody on a box that just closed itself: three separate facts, and
// health prints all three rather than the first one that happened.
assert.equal(config.degraded.length, 3);
assert.ok(config.degraded.some((line) => line.includes("TERA_ADMIN_SUBJECTS=*")));
});
});
+130
View File
@@ -21,6 +21,7 @@ import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import { hashPassword, parseScryptHash, verifyPassword } from "../auth/password.ts";
import type { SessionBody } from "../routes/session.ts";
import type { HealthBody } from "../../../src/server/wire.ts";
const SECRET = "not-a-real-secret-and-never-was";
const USER = "karti";
@@ -89,6 +90,7 @@ describe("the login endpoint", () => {
assert.deepEqual(res.json<SessionBody>(), {
authenticated: true,
subject: USER,
admin: false,
passwordLogin: true,
});
@@ -188,6 +190,7 @@ describe("a session cookie and a private office", () => {
assert.deepEqual(anonymous.json<SessionBody>(), {
authenticated: false,
subject: null,
admin: false,
passwordLogin: true,
});
@@ -196,6 +199,7 @@ describe("a session cookie and a private office", () => {
assert.deepEqual(signedIn.json<SessionBody>(), {
authenticated: true,
subject: USER,
admin: false,
passwordLogin: true,
});
});
@@ -212,6 +216,131 @@ describe("a session cookie and a private office", () => {
});
});
/**
* The god tier is a fact the server states and the client repeats. Everything
* below is one shape of the same question *can the environment, and only the
* environment, decide this?* so the negatives outnumber the positive again:
* an unlisted account, an anonymous caller under the blanket switch, a name
* that differs only in case. The `*` case is tested for its `degraded` line as
* much as for the grant, because a silent grant-everyone switch is the failure
* this whole setting is shaped around.
*/
describe("the admin tier", () => {
/** Sign in as USER on a box configured this way, and report what it says. */
async function signedIn(env: Record<string, string>): Promise<SessionBody> {
const app = appWith({ ...passwordEnv, ...env });
after(() => app.close());
const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
const state = await app.inject({ method: "GET", url: "/api/v1/session", headers: { cookie } });
return state.json<SessionBody>();
}
it("grants nobody when TERA_ADMIN_SUBJECTS is unset", async () => {
assert.equal((await signedIn({})).admin, false);
// And the safe default is silent: an unset variable is not a demotion.
assert.deepEqual(loadConfig(passwordEnv).degraded, []);
});
it("grants a listed subject, on the login itself and on the session after it", async () => {
const app = appWith({ ...passwordEnv, TERA_ADMIN_SUBJECTS: USER });
after(() => app.close());
const res = await login(app, USER, PASSWORD);
// The POST answers with the real grant for the account that just signed in,
// not a placeholder the following GET would contradict.
assert.deepEqual(res.json<SessionBody>(), {
authenticated: true,
subject: USER,
admin: true,
passwordLogin: true,
});
const cookie = cookiePair(res.headers["set-cookie"]);
const state = await app.inject({ method: "GET", url: "/api/v1/session", headers: { cookie } });
assert.deepEqual(state.json<SessionBody>(), res.json<SessionBody>());
});
it("does not grant an authenticated subject nobody listed", async () => {
const state = await signedIn({ TERA_ADMIN_SUBJECTS: "someone-else,third-party" });
assert.equal(state.authenticated, true);
assert.equal(state.admin, false);
});
it("matches exactly after trimming, and is case-sensitive", async () => {
// Surrounding whitespace is an artefact of writing a list in an env file and
// is dropped; the id itself must be the id.
assert.equal((await signedIn({ TERA_ADMIN_SUBJECTS: " karti , other " })).admin, true);
// Case is not. A subject id is an opaque string an issuer minted, and two
// ids that differ only in case can be two accounts.
assert.equal((await signedIn({ TERA_ADMIN_SUBJECTS: "KARTI" })).admin, false);
});
it("grants everyone under the wildcard, and announces it on health", async () => {
const config = loadConfig({ TERA_OFFICES_DIR: dir, ...passwordEnv, TERA_ADMIN_SUBJECTS: "*" });
config.logLevel = "silent";
const app = buildApp(config);
after(() => app.close());
const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
const state = await app.inject({ method: "GET", url: "/api/v1/session", headers: { cookie } });
assert.equal(state.json<SessionBody>().admin, true);
// The whole point of the wildcard being allowed at all: it cannot be on
// without `/api/v1/health` saying so.
const health = (await app.inject({ method: "GET", url: "/api/v1/health" })).json<HealthBody>();
assert.ok(health.degraded.some((line) => line.includes("TERA_ADMIN_SUBJECTS=*")));
});
it("still refuses the anonymous caller under the wildcard", async () => {
const app = appWith({ ...passwordEnv, TERA_ADMIN_SUBJECTS: "*" });
after(() => app.close());
// "Everyone" means everyone *authenticated*. A browser with no cookie is
// not a member, let alone a god.
const state = await app.inject({ method: "GET", url: "/api/v1/session" });
assert.deepEqual(state.json<SessionBody>(), {
authenticated: false,
subject: null,
admin: false,
passwordLogin: true,
});
});
it("takes the tier away with the session", async () => {
const app = appWith({ ...passwordEnv, TERA_ADMIN_SUBJECTS: USER });
after(() => app.close());
const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
const out = await app.inject({ method: "DELETE", url: "/api/v1/session", headers: { cookie } });
assert.equal(out.statusCode, 200);
assert.deepEqual(out.json<SessionBody>(), {
authenticated: false,
subject: null,
// Said out loud rather than implied by `authenticated: false`, so a page
// holding the old value has something to overwrite it with.
admin: false,
passwordLogin: true,
});
});
it("keeps the list and its size off the health body", async () => {
const app = appWith({
...passwordEnv,
TERA_ADMIN_SUBJECTS: `${USER},someone-else,third-party`,
});
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/health" });
const health = res.json<HealthBody>();
// Who the admins are is not a public question, and neither is how many
// there are — a count is an invitation to go looking for the one account.
assert.equal(res.body.includes(USER), false);
assert.equal(res.body.includes("someone-else"), false);
assert.equal(res.body.includes("admin"), false);
assert.deepEqual(health.degraded, []);
});
});
describe("deployments that cannot sign anyone in", () => {
it("leaves mode=none open and offers no login", async () => {
const app = appWith({});
@@ -223,6 +352,7 @@ describe("deployments that cannot sign anyone in", () => {
assert.deepEqual(state.json<SessionBody>(), {
authenticated: false,
subject: null,
admin: false,
passwordLogin: false,
});
+301
View File
@@ -0,0 +1,301 @@
/**
* 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;
/** Live markers and live flights rather than the fabricated sample set. */
liveData: boolean;
/** Debug overlays: frame time, draw calls, chapter poses, the solar readout. */
debug: 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;
}
/**
* 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",
liveData: 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 };
}>(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);
// 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);
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);
return access(admin ? "god" : "member", subject, signInUrl);
}
function access(tier: Tier, subject: string | null, signInUrl: string | null): Access {
return { tier, subject, signInUrl, can: capabilitiesFor(tier) };
}
/**
* 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;
try {
const url = new URL(raw, window.location.origin);
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
return url.href;
} catch {
return null;
}
}
+209 -60
View File
@@ -400,6 +400,16 @@ export const DEFAULT_MOONLIGHT: MoonlightOptions = {
// night on a screen someone is looking at in a lit room, not the photon
// count. At the honest value the map is a black rectangle, which is the bug
// this exists to fix.
//
// Left where it was when the moonless floor came up under it, and that is a
// decision rather than an oversight. The key is already three quarters of the
// light in a full-moon frame, so raising it to keep the gap would have been
// raising the one term that is nearest to overshooting into a blue-graded day;
// the gap is defended in `applyNight` instead, on the fill, where there was
// room. What did have to be checked is that moonrise is still an *event* —
// see the sanity checks at the foot of this file, where a full moon 43° up is
// seven times the key of a moonless night and better than twice its ground
// luminance on screen.
intensity: 1.15,
color: 0x9db4e8,
skyLift: 0.85,
@@ -484,11 +494,71 @@ const DEFAULT_SHADOW_FLOOR_DEG = 7;
const NIGHT_FLOOR_TOP = 0x090e1c;
const NIGHT_FLOOR_HORIZON = 0x16203a;
/** The same sky with a full moon in it. */
/**
* The same floor, for the light that lands on the ground.
*
* `NIGHT_FLOOR_TOP` above makes the argument for the sky and then only fixes
* the sky, which is exactly half the job and the half that hides the other
* half, because a lifted sky behind a black landmass reads as a working render
* of an empty ocean. What it was actually producing at -18° was terrain at
* #000004 under a sky at #16203a: the coastline gone, the hills gone, water and
* land the same colour, and nothing left in the frame but the lit windows and
* the freeway threads floating in it.
*
* A photometrically correct answer here really is close to zero. A moonless
* night is a few thousandths of a lux of airglow and starlight, against a hundred
* thousand at noon, and any honest ratio lands under one code value. But three
* things make zero the wrong number to render:
*
* - **The display has no room underneath.** Everything from 1/1000 of white
* down to nothing shares the bottom two or three code values of an 8-bit
* sRGB ramp. A correctly exposed night does not come out dim, it comes out
* quantised to black, and no amount of squinting recovers a coastline that
* was rounded to #000.
* - **Nobody is dark-adapted.** The eye that can read a moonless landscape has
* spent forty minutes getting there. The eye looking at this has a lit room
* behind it and a white browser chrome around it, and its black point is
* several stops above the screen's.
* - **This is a map.** It is looked at from eighty kilometres up, from outside
* the atmosphere it is depicting, by someone who wants to know where the bay
* is. A view that goes correctly blank at 3 a.m. is not a night mode, it is
* an outage and it is reported as one.
*
* So these are the same kind of lie as `DEFAULT_MOONLIGHT.intensity`: not the
* light there is, but the light a moonless night *looks like* it has once you
* are standing in it. Held deliberately low enough that the city's own lit
* windows stay the brightest thing in the frame by a factor of four or five,
* which is the one relationship that makes it read as night rather than as a
* blue-graded day.
*
* Split five ways rather than folded into one brightness because the *ratio*
* between them is what stops the result looking like fog. Ambient is
* unshaped every surface gets the same number whichever way it faces so a
* night lit by ambient alone is flat, and flat and dark is fog, not darkness.
* The hemisphere carries most of it instead, sky term well above ground term, so
* a roof is lighter than a wall; and the keyframe table's token sidelight
* survives at full strength on a moonless night (see `applyNight`) so the hills
* still have a lit side and a dark one.
*/
const NIGHT_FLOOR_HEMI_SKY = 0x354c88;
const NIGHT_FLOOR_HEMI_GROUND = 0x1f2740;
const NIGHT_FLOOR_HEMI_INTENSITY = 0.78;
const NIGHT_FLOOR_AMBIENT = 0x47557f;
const NIGHT_FLOOR_AMBIENT_INTENSITY = 0.22;
/**
* The same sky, and the same fill, with a full moon in it.
*
* These moved up when the floor did, and they had to: a floor raised to meet the
* moon has deleted the moon, and `moonPosition` is four hundred lines of Meeus
* that would then be decorative. The gap is the point a moonlit night has to
* arrive as an event, four to five times the moonless floor in linear light, and
* with a *direction* in it that the floor by construction does not have.
*/
const MOONLIT_SKY_TOP = 0x111d3e;
const MOONLIT_SKY_HORIZON = 0x2d3c62;
const MOONLIT_HEMI_SKY = 0x2b3b60;
const MOONLIT_AMBIENT = 0x3f4c76;
const MOONLIT_HEMI_SKY = 0x40597f;
const MOONLIT_AMBIENT = 0x546490;
/**
* Where fog starts, as a fraction of where it ends. `cityDaylight`'s 210/460 is
@@ -557,40 +627,52 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
// either; it is a token sidelight standing in for moonlight and city
// glow, because a scene lit by hemisphere alone has no silhouettes in it
// and reads as a bug rather than as darkness.
//
// The three night stops used to be an order of magnitude below this, and
// the reason they were wrong is instructive: they were read off a
// photograph of a night sky, which is a picture of the *sky* and says
// nothing about the ground under it. Multiplied out, `hemiSky` at 0x121a30
// times 0.25 came to about four thousandths of the fill at noon, which is
// roughly honest and rendered San Francisco as #000004. What is here now
// is the ground reading the eye reports — the coastline findable, the
// hills with a lit side, the bay darker than the land around it — with the
// sky stops left where they were, because those were never the problem.
// See `NIGHT_FLOOR_HEMI_SKY`, which is what actually holds this up: these
// rows sit just under the floor and the floor is what binds.
elevation: -18,
skyTop: 0x05070f,
skyHorizon: 0x0b1120,
sunColor: 0x2e3c66,
sunIntensity: 0.05,
hemiSky: 0x121a30,
hemiGround: 0x080a10,
hemiIntensity: 0.25,
ambientColor: 0x28304a,
ambientIntensity: 0.1,
sunColor: 0x44558a,
sunIntensity: 0.16,
hemiSky: 0x2f447e,
hemiGround: 0x1b2234,
hemiIntensity: 0.55,
ambientColor: 0x414e78,
ambientIntensity: 0.16,
},
{
elevation: -12,
skyTop: 0x080d1e,
skyHorizon: 0x141d38,
sunColor: 0x3d4a76,
sunIntensity: 0.07,
hemiSky: 0x18223c,
hemiGround: 0x0a0d16,
hemiIntensity: 0.28,
ambientColor: 0x2c3552,
ambientIntensity: 0.11,
sunColor: 0x51629b,
sunIntensity: 0.19,
hemiSky: 0x32477d,
hemiGround: 0x1d2437,
hemiIntensity: 0.57,
ambientColor: 0x424f7a,
ambientIntensity: 0.17,
},
{
elevation: -6,
skyTop: 0x101a3a,
skyHorizon: 0x2b3560,
sunColor: 0x5b5d8e,
sunIntensity: 0.12,
hemiSky: 0x22304f,
hemiGround: 0x121520,
hemiIntensity: 0.35,
ambientColor: 0x38406a,
ambientIntensity: 0.14,
sunColor: 0x66699a,
sunIntensity: 0.26,
hemiSky: 0x3c558c,
hemiGround: 0x23293c,
hemiIntensity: 0.6,
ambientColor: 0x485389,
ambientIntensity: 0.19,
},
{
// The sun on the horizon. Warm at the bottom, cold at the top, and the
@@ -766,7 +848,7 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
}
fogFar = Math.max(fogFar, floorFar);
const fogColor = applyObscuration(rig, obscuration, day, condition);
const fogColor = applyObscuration(rig, obscuration, day, night, condition);
// A clear day keeps the near plane it was given; anything shorter holds the
// ratio instead, because fog that starts where the clear day's did and ends
@@ -943,15 +1025,25 @@ function applyNight(
// silhouettes in it and reads as a bug. There is a real moon now, so the
// stand-in gets out of its way — not entirely, because something still has
// to hold the shape of the city up on an overcast night at new moon.
rig.sunIntensity *= 1 - 0.85 * night;
//
// Weighted by how much moon there actually is, and not, as it was, by how
// much night there is. Those are the same number only on the nights the
// moon happens to be up, and the difference is the whole bug: `moonRig`
// correctly returns nothing for a moon below the horizon, so on the common
// case — which is most of every month, and *every* night before moonrise —
// this line was removing 85% of the only directional light in the scene in
// favour of a moon that was not there. The hills lost their lit side and
// the frame went flat, on exactly the nights that needed the stand-in most.
const present = clamp(moon.glow, 0, 1);
rig.sunIntensity *= 1 - 0.85 * night * present;
const glow = clamp(moon.glow * options.skyLift, 0, 1);
rig.skyTop = mixHex(rig.skyTop, MOONLIT_SKY_TOP, glow);
rig.skyHorizon = mixHex(rig.skyHorizon, MOONLIT_SKY_HORIZON, glow);
rig.hemiSky = mixHex(rig.hemiSky, MOONLIT_HEMI_SKY, 0.7 * glow);
rig.ambientColor = mixHex(rig.ambientColor, MOONLIT_AMBIENT, 0.7 * glow);
rig.hemiIntensity *= 1 + 0.5 * glow;
rig.ambientIntensity *= 1 + 0.45 * glow;
rig.hemiIntensity *= 1 + 0.7 * glow;
rig.ambientIntensity *= 1 + 0.6 * glow;
}
// Starlight, airglow, and the sodium of everywhere else bouncing off whatever
@@ -965,6 +1057,32 @@ function applyNight(
// the frame without having to know this ran.
rig.skyTop = mixHex(rig.skyTop, atLeast(rig.skyTop, NIGHT_FLOOR_TOP), night);
rig.skyHorizon = mixHex(rig.skyHorizon, atLeast(rig.skyHorizon, NIGHT_FLOOR_HORIZON), night);
// And the same again for the light that lands on the ground, which is the
// half the sky floor above was always missing. Colours per channel through
// `atLeast` for the reason that function documents — the floor is a blue, not
// a brightness — and the two intensities by plain maximum, because both terms
// are colour *times* intensity and flooring only one of them can be undone by
// the other. Both ramp in on `night`, so this is a floor that arrives through
// civil twilight rather than a step that switches on at some elevation.
//
// A moonlit night is already well above all five of these and passes through
// untouched, which is the whole reason the moon's own lift went up when this
// went in. See `MOONLIT_HEMI_SKY`.
rig.hemiSky = mixHex(rig.hemiSky, atLeast(rig.hemiSky, NIGHT_FLOOR_HEMI_SKY), night);
rig.hemiGround = mixHex(rig.hemiGround, atLeast(rig.hemiGround, NIGHT_FLOOR_HEMI_GROUND), night);
const ambientFloor = atLeast(rig.ambientColor, NIGHT_FLOOR_AMBIENT);
rig.ambientColor = mixHex(rig.ambientColor, ambientFloor, night);
rig.hemiIntensity = lerp(
rig.hemiIntensity,
Math.max(rig.hemiIntensity, NIGHT_FLOOR_HEMI_INTENSITY),
night,
);
rig.ambientIntensity = lerp(
rig.ambientIntensity,
Math.max(rig.ambientIntensity, NIGHT_FLOOR_AMBIENT_INTENSITY),
night,
);
}
/**
@@ -1215,11 +1333,27 @@ function windGate(layer: MarineLayerOptions, weather: WeatherObservation | null)
* `cityDaylight()` commits to and the physically honest answer distant haze
* is lit by the sky it sits in front of, so it goes warm at sunset along with
* everything else rather than staying a neutral grey.
*
* **The convergence is a daytime effect and only a daytime effect**, which is
* what `night` is for. Fog is bright because the sun is in it, so pulling the
* fill toward the fog colour is a *brightening* and a flattening the two
* things that make an overcast noon look like an overcast noon. Run the same
* mixes at 3 a.m., where the fog colour is a near-black derived from a night
* sky, and they are pure subtraction: San Francisco's August marine layer is at
* its thickest at three in the morning, and it was quietly taking 63% of the
* ground fill and all of its colour straight back out again after
* `applyNight` had finished, so the night floor could not see it happen and had
* no chance to defend the frame. Which is a fair description of what a fog does
* to a photograph and a terrible description of what it does to a city, where
* the deck is lit from *underneath* by everything that is still switched on.
* The sky still converges at night; it should, since a foggy night has no stars
* in it. The ground no longer does.
*/
function applyObscuration(
rig: Rig,
obscuration: number,
day: number,
night: number,
condition: SkyCondition,
): number {
let thick = mixHex(desaturate(rig.skyHorizon, 0.9), 0xbfc8cc, 0.5 * day);
@@ -1233,10 +1367,11 @@ function applyObscuration(
rig.sunIntensity *= 1 - 0.88 * obscuration;
rig.sunColor = mixHex(rig.sunColor, 0xdfe6ea, 0.7 * obscuration);
rig.hemiSky = mixHex(rig.hemiSky, fogColor, 0.7 * obscuration);
rig.hemiGround = desaturate(rig.hemiGround, 0.6 * obscuration);
const lit = 1 - night;
rig.hemiSky = mixHex(rig.hemiSky, fogColor, 0.7 * obscuration * lit);
rig.hemiGround = desaturate(rig.hemiGround, 0.6 * obscuration * lit);
rig.hemiIntensity *= 1 + 0.12 * obscuration * day;
rig.ambientColor = mixHex(rig.ambientColor, fogColor, 0.6 * obscuration);
rig.ambientColor = mixHex(rig.ambientColor, fogColor, 0.6 * obscuration * lit);
rig.ambientIntensity *= 1 + 0.45 * obscuration * day;
return fogColor;
@@ -1387,12 +1522,14 @@ function wrapSigned(x: number, period: number): number {
* back a clear golden morning or it is not a model of anything October is
* the month San Francisco is warm and cloudless and every visitor is
* surprised by it.
* - **03:00 PDT** (-23.7°, with the moon down): key 0.002, hemisphere 0.32,
* ambient 0.12, and the light direction's `y` pinned at 0.122, which is
* sin 7° the shadow floor, keeping what is left of the token night
* sidelight from shining up through the ground. The key is a thousandth
* - **03:00 PDT** (-23.7°, with the moon down): key 0.045, hemisphere 0.78,
* ambient 0.22, and the light direction's `y` pinned at 0.122, which is
* sin 7° the shadow floor, keeping the token night sidelight from shining
* up through the ground. The hemisphere and the ambient are the night
* floor's own two numbers to the digit, which is the floor doing exactly the
* job it is there for; the key is down to 0.045 from the table's 0.16
* because the marine layer is at its thickest at 3 a.m. in June and takes
* 88% of it; the sky is a fog grey rather than a night blue for the same
* 88% of it, and the sky is a fog grey rather than a night blue for the same
* reason, which is right a foggy night has no stars in it either.
* - **Solar noon, 21 December** (28.8°): sun 2.07, fog 204/453, sky exactly
* the palette's own. Out of season, the layer is not there.
@@ -1416,42 +1553,54 @@ function wrapSigned(x: number, period: number): number {
* *comes from* when nobody was asked; it is not what makes obscuration look
* like anything.
*
* Tromsø on 5 January, at -2.98°, returns sun 0.30 against a twilight-blue sky
* Tromsø on 5 January, at -2.98°, returns sun 0.37 against a twilight-blue sky
* and nothing non-finite anywhere, which is the polar-night path through
* `solar.ts` arriving here intact.
*
* **At night, San Francisco with no weather and no marine layer**, so that the
* moon can be read on its own:
*
* - **Full moon 43° up** (28 August 2026, 08:00 UTC): key 0.307, colour
* #9bb2e6, hemisphere 0.46, ambient 0.17, sky #101b39 over #2a385b. A
* seventh of the sun's noon intensity against nearly half of its fill,
* which is a soft directional key with shadows you can find and not trip
* over on a sky that is unmistakably night and unmistakably blue.
* - **New moon, below the horizon** (12 August 2026, 08:00 UTC): key 0.008
* of the table's own sidelight colour, and the sky lands on #090e1c over
* #16203a the floor, exactly. That is the darkest frame this file can
* produce, and it is the point: a genuinely correct night is `#000` and
* `#000` is a bug report.
* - **Half moon 4° up** (20 August 2026, 06:00 UTC): key 0.078. A quarter of
* the full moon's key from half its disc and a tenth of its altitude,
* which is the phase curve and the rise ramp both doing visible work.
* - **The same full moon with `PACIFIC_MARINE_LAYER` on**: key 0.16 and the
* - **Full moon 43° up** (28 August 2026, 08:00 UTC): key 1.174, colour
* #9cb3e6, hemisphere 1.12 of #3a5188, ambient 0.29 of #4d5c87, sky #101b39
* over #2a385b. Half the sun's noon intensity, which is a preposterous
* number and the one that puts a soft directional key on the city with
* shadows you can find and not trip over on a sky that is unmistakably
* night and unmistakably blue.
* - **New moon, below the horizon** (12 August 2026, 08:00 UTC): key 0.160 of
* the table's own sidelight colour #44558a, hemisphere 0.78 of #354c88 over
* #1f2740, ambient 0.22 of #47557f, and the sky at #090e1c over #16203a.
* Every one of those five is the floor to the digit. That is the darkest
* frame this file can produce, and it is the point: a genuinely correct
* night is `#000` and `#000` is a bug report. Rendered, the Bay Area board
* comes out at about y23 on the land against y15 on the bay and y29 on the
* sky, with downtown's windows peaking past y130 dark, but a dark you can
* find a coastline in.
* - **Half moon 4° up** (20 August 2026, 06:00 UTC): key 0.397. A third of the
* full moon's key from half its disc and a tenth of its altitude, which is
* the phase curve and the rise ramp both doing visible work.
* - **The same full moon with `PACIFIC_MARINE_LAYER` on**: key 0.610 and the
* sky greyed to #232b43 over #2d3855. The fog takes half the moonlight and
* all of the colour, and August is when it would.
* - **The same night reported overcast**: key 0.032 no shadows at all
* all of the colour, and August is when it would. The *fill* it no longer
* takes; see `applyObscuration`.
* - **The same night reported overcast**: key 0.131 no shadows at all
* with the fill barely down, because a cloud deck over a full moon is a
* softbox rather than a lid.
* - **`moonlight: null`** returns the pre-moon rig unchanged: key 0.050,
* colour #2e3c66, the table's token sidelight left in charge. The night
* floor under the sky still applies, because that one is not about the
* moon.
* - **`moonlight: null`** returns the pre-moon rig unchanged: key 0.160,
* colour #44558a, the table's token sidelight left in charge which is now
* the same frame the moon-below-the-horizon case produces, and should be.
* Both floors still apply, because neither of them is about the moon.
*
* The gap between the second of those and the first is the one relationship
* this file is tuned around: seven times the key, and on screen a Bay Area board
* that goes from about y23 on the land to about y53. Moonrise is an event you
* can watch happen, which is the whole justification for `moonPosition` being
* four hundred lines of Meeus rather than a constant.
*
* The 28 August 2026 dusk is worth watching as a sequence, because it is the
* configuration `combineKey` exists for a full moon rising as the sun sets,
* the two of them opposite each other in the sky. At sun +0.8° the key is 0.663
* and #d4835a from the west; at -3.4° it is 0.226 and #9482a0 from between
* them; by -7.3° it is 0.303 and #9ab0e3 from the east. The shadows swing
* the two of them opposite each other in the sky. At sun +0.5° the key is 0.592
* and #ce805b from the west; at -3.7° it is 0.328 and #8e6f87 from between
* them; by -7.6° it is 0.572 and #8ea0d3 from the east. The shadows swing
* across the city over about half an hour, which is not an artefact it is
* what actually happens, and on the one night a month it happens on.
*/
File diff suppressed because it is too large Load Diff
+37 -9
View File
@@ -113,8 +113,23 @@ const OFFICE_LIT = 0.24;
const WINDOW_WARM = 0xffc178;
const WINDOW_COOL = 0xd8e4ff;
/** Peak emissive radiance of a lit pane. Below 1 so a window is bright, not blown. */
const WINDOW_GAIN = 0.8;
/**
* Peak emissive radiance of a lit pane. Below 1 so a window is bright, not blown.
*
* 0.8 when the ground under the city was effectively black, 0.95 now that
* `atmosphere.ts` holds a real floor under a moonless night. That floor moved
* the terrain from about #000004 to something you can find a coastline in, and
* a window has to stay the brightest thing in the frame by a comfortable factor
* or the whole picture stops being a city at night and becomes a city at dusk.
* It is the *ratio* that is being defended here, not the absolute value.
*
* Still under 1, and that is not an accident: at 1.0 the emissive term alone
* saturates the channel and a lit pane clips to white, taking `WINDOW_WARM` with
* it. A skyline whose windows have lost the difference between tungsten and a
* ceiling fluorescent is a skyline with the character taken out of it, and there
* is no HDR buffer here to get it back from.
*/
const WINDOW_GAIN = 0.95;
/** Sodium, because a street lamp is the one light in a city that still is. */
const LAMP_COLOR = 0xffb264;
@@ -372,14 +387,22 @@ if (uNight > 0.002) {
float footprint = max(fwidth(grid.x), fwidth(grid.y));
float detail = 1.0 - smoothstep(0.5, 1.4, footprint);
// 0.56 x 0.38 is the pane inside its cell, so 0.2128 x chance is the grid's
// own mean — and 1.8 times that is what is actually used, which is a lie
// own mean — and 2.6 times that is what is actually used, which is a lie
// worth being explicit about. The mean is the right answer for a display
// whose response is linear, and no display's is: a pixel that in reality
// contains one small blazing window and three dark ones does not read to
// the eye as the average of the four, it reads as lit. With no HDR buffer
// and no bloom to arrive at that honestly, the multiplier is the cheap way
// to keep the far city as bright as the near city says it ought to be.
float glow = mix(1.8 * 0.2128 * chance, coverage * on, detail);
//
// 1.8 for as long as the ground was black, because against black anything
// reads. This is the branch the whole-board framing takes — every pixel of
// the city is past the fwidth cutoff from up there — so it is also the
// branch that had to answer when the atmosphere's night floor brought the
// terrain up to meet it. At 1.8 against the new floor the lit grid and the
// bare ground came out at the same luminance and downtown stopped being
// findable, which is a worse bug than the one being fixed.
float glow = mix(2.6 * 0.2128 * chance, coverage * on, detail);
// Roughly seven windows in ten warm. A skyline is mostly people's lamps and
// only partly the floors the cleaners are still on.
@@ -553,17 +576,22 @@ function smoothstep(edge0: number, edge1: number, x: number): number {
* - **-8° and below**: 1.0. The lights stopped changing some minutes ago;
* what changed after that was the sky behind them.
*
* Downtown's mean emission at distance is 1.8 x 0.2128 x 0.24 x 0.8 = 0.074,
* against the avenues' 0.025 a ratio of just under 3:1, which is the whole
* Downtown's mean emission at distance is 2.6 x 0.2128 x 0.24 x 0.95 = 0.126,
* against the avenues' 0.043 a ratio of just under 3:1, which is the whole
* picture, since the thing that makes a night skyline is not that the towers
* are taller but that they are the part of the city with all its lights still
* on. Around each of those figures the per-building variation spans 0.15x to
* 2.7x, so a run of towers has dark ones in it and the odd one blazing, and the
* financial district does not smear into a single rectangle when you pull back.
*
* On a moonless night the buildings come out at about #4a403d against water at
* #191b21 and a sky at #2d3855: the city is the brightest thing in the frame,
* as it should be, and the sky is still visibly a sky.
* On a moonless night, whole-board framing, measured off the render: the Bay
* Area board puts downtown at about y34 mean and its brightest windows past
* y130, against land at y23, bay water at y15 and sky at y29; the SoCal board,
* which has no marine layer over it, comes out at y36 / y137 against land y34,
* ocean y13 and sky y15. The city is comfortably the brightest thing in the
* frame in both, which is the relationship that has to hold and it stopped
* holding, briefly, when `atmosphere.ts` first raised the ground under it.
* That is what the 2.6 and the 0.95 are for.
*
* SF's twenty-nine roads at 55 m spacing come to 12,038 lamps in one draw call,
* comfortably under the 24,000 ceiling. The ceiling exists for the city pack
+198 -18
View File
@@ -36,12 +36,40 @@
* ceilings come off, the walls between you and what you are looking at go
* translucent, and the existing camera, flight and picking machinery is reused
* verbatim.
*
* ### Two depths, and the public one is the architecture without the people
*
* `depth: "public"` is the office an anonymous visitor gets, and the office is
* becoming a front door in its own right, so this is the majority case rather
* than a degraded one. It keeps the shell, the floor plan, the furniture, the
* lighting and every named `View`. It builds **no presence layer at all** no
* occupants, no avatars, no seat states, nothing to hover that could name a
* person and `Plan` has already dropped whatever the pack marked
* `audience: "private"` before this file sees it.
*
* The rule the two depths are written to is *build-time exclusion, never
* visibility toggling*. There is no `presence.group.visible = false` path here
* and there must not be one: a scene that constructs the private objects and
* then hides them still hands every one of them to `scene.traverse`, to the
* devtools scene graph and to anyone who types `scene.children` into a console.
* That is a data leak dressed as a privacy feature, and it is worse than not
* having the feature, because it looks like it works.
*
* **None of that is a security boundary.** The office pack is bundled into the
* static build, so its contents are public by construction whatever they are
* marked, and `lumbridge-hq.ts` is fabricated sample data besides. The only
* thing genuinely being withheld from an anonymous visitor is occupancy, and it
* is withheld because live `Presence` comes from the API and **the API is what
* refuses an anonymous caller** not because this file declined to draw it. If
* a future deployment ever ships real occupant data, that server-side refusal is
* the fix; a `depth` argument in the browser is not, and never will be. See the
* note on `Audience` in `types.ts`.
*/
import * as THREE from "three";
import { createSceneKit, type Pose } from "../engine/scenekit.ts";
import type { StageScene } from "../engine/stage.ts";
import type { LightingState, View } from "../engine/types.ts";
import type { LightingState, Pin, View } from "../engine/types.ts";
import type { AssetRegistry } from "../assets/kit.ts";
import { MaterialRegistry, type MaterialQuality } from "../assets/materials.ts";
import type { InteriorPalette } from "../assets/palette.ts";
@@ -51,11 +79,15 @@ import type { InteriorPalette } from "../assets/palette.ts";
// replacement of a built-in id.
import "../assets/office/index.ts";
import { createFurnishings, type Furnishings } from "./furnish.ts";
import { Plan, type PlanOptions } from "./plan.ts";
import { Plan, type Depth, type PlanOptions } from "./plan.ts";
import { createPresenceLayer, type PresenceLayer, type PresencePalette } from "./presence.ts";
import { createShell, type Shell, type WallInfo } from "./shell.ts";
import type { Office, Point2, Presence, Viewpoint } from "./types.ts";
// Re-exported so a caller can name the tier it is asking for without importing
// the resolver. `Plan` is where depth is *applied*; this is where it is chosen.
export type { Depth } from "./plan.ts";
export interface OfficeSceneOptions {
/**
* The renderer's canvas. Orbit input and pointer coordinates are read against
@@ -63,9 +95,25 @@ export interface OfficeSceneOptions {
* renderer and has its own everything else.
*/
dom: HTMLElement;
/**
* How much of the office to build. Defaults to `"full"`, which is every
* caller that existed before this option did.
*
* `"public"` is the not-signed-in building: same shell, same plan, same
* furniture, same lighting, same views, and no people. See the header for what
* that means and, more importantly, for what it does not mean.
*
* There is no way to change this after construction, on purpose. Signing in
* while standing in the public office is a `dispose()` and a second
* `createOfficeScene` at `"full"`, which is cheap if you hand both of them the
* same `materials` the textures are the expensive part and they are drawn
* once per registry, not once per office.
*/
depth?: Depth;
/**
* Bring your own, to share one set of materials and textures across two
* offices. Made here otherwise, and disposed here only if it was made here.
* offices or across the same office reopened at another depth. Made here
* otherwise, and disposed here only if it was made here.
*/
materials?: MaterialRegistry;
quality?: MaterialQuality;
@@ -76,7 +124,19 @@ export interface OfficeSceneOptions {
colorFor?: (key: string) => number | undefined;
/** Resolves a `Presence.colorKey` to a colour. Also opaque. */
presencePalette?: PresencePalette;
/** Full depth only. At `"public"` there is no presence to pick. */
onPresencePick?: (presence: Presence | null) => void;
/**
* Public depth only: the pointer is over a desk, and here is what a stranger
* is allowed to be told about it.
*
* The public office is not a diorama you can still hover the furniture but
* what comes back is a `Pin` and never a `Presence`, and its label is
* `"Desk 14"`. It is a separate callback rather than a widened
* `onPresencePick` because the two carry different things: one says who is
* there, and this one says only that there is a there.
*/
onPlacePick?: (place: Pin | null) => void;
/** Overrides the fixed interior rig. Must carry `sky: null` and `fog: null`. */
lighting?: LightingState;
/** Defaults to false — the lid comes off, because that is the whole view. */
@@ -93,23 +153,45 @@ export interface OfficeSceneOptions {
export interface OfficeScene extends StageScene {
plan: Plan;
/**
* What this office actually is, so the caller can tell what it got rather than
* assuming it got what it asked for. The UI reads this to decide whether to
* print the "no presence" badge and whether to offer a sign-in.
*/
depth: Depth;
/** The pack's viewpoints, as the thing a legend prints and `flyTo` is keyed on. */
views: View[];
flyTo(viewId: string): void;
current(): string | null;
onViewChange(fn: (id: string) => void): void;
/** Occupancy, bound by seat id. Safe to call before the scene is shown. */
/**
* Occupancy, bound by seat id. Safe to call before the scene is shown.
*
* A no-op at public depth there is no layer to put anybody in and it warns
* once rather than silently accepting people it will not draw. A caller that
* finds itself needing that warning is asking an anonymous session for
* occupancy, which is a question the API should already have refused.
*/
setPresence(people: Presence[]): void;
/** Scene-space label anchors per presence id, for an HTML overlay. */
/** Scene-space label anchors per presence id, for an HTML overlay. Empty at public depth. */
anchors: Map<string, THREE.Vector3>;
setCeilingsVisible(visible: boolean): void;
setLighting(state: LightingState): void;
}
export function createOfficeScene(office: Office, options: OfficeSceneOptions): OfficeScene {
const plan = new Plan(office, options.plan ?? {});
const depth: Depth = options.depth ?? "full";
// The scene's `depth` wins over anything `plan` carried. There is one tier per
// office and it is chosen here; a `PlanOptions.depth` that disagreed with the
// handle's would produce a scene whose `depth` field was a lie, which is the
// one field a caller has to be able to trust.
const plan = new Plan(office, { ...(options.plan ?? {}), depth });
const scene = new THREE.Scene();
scene.name = `office:${office.id}`;
// The public build says so in the scene graph, and the full one keeps the name
// it has always had. Whoever is reading `scene.name` in the devtools is the
// exact person who needs to know which of the two buildings they are looking
// at before they conclude anything from what is missing.
scene.name = depth === "full" ? `office:${office.id}` : `office:${office.id}:public`;
const ownsMaterials = options.materials === undefined;
const materials =
@@ -169,8 +251,18 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
...(options.registry ? { registry: options.registry } : {}),
...(options.colorFor ? { colorFor: options.colorFor } : {}),
});
const presence: PresenceLayer = createPresenceLayer(plan, options.presencePalette ?? {});
scene.add(shell.group, furnishings.group, presence.group);
// A public office has no presence layer, rather than an empty one. The
// difference is not cosmetic: an empty `PresenceLayer` is a `THREE.Group`
// named "presence" hanging in the scene graph, a `setPresence` that works, and
// a pair of figure geometries one call away from being populated by any code
// that gets a handle on it. None of that should exist in the building a
// stranger is looking at. The layer is `null`, the group is never added, and
// every path that would have used it is written to cope with its absence
// rather than to hide it. See the header.
const presence: PresenceLayer | null =
depth === "full" ? createPresenceLayer(plan, options.presencePalette ?? {}) : null;
scene.add(shell.group, furnishings.group);
if (presence) scene.add(presence.group);
shell.ceilings.visible = options.showCeilings ?? false;
// ---- Viewpoints ---------------------------------------------------------
@@ -241,13 +333,56 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
// ---- Picking ------------------------------------------------------------
// `pickables` is rebuilt in place whenever occupancy changes, so the getter
// rather than the array: the office outlives any one set of people in it.
kit.setPicking<Presence>({
targets: () => presence.pickables,
resolve: (hit) => (hit.object.userData.presence as Presence | undefined) ?? null,
onChange: (person) => options.onPresencePick?.(person),
});
/**
* At public depth, the desks are the pick surface and a desk is a number.
*
* Built once, up front, and handed out by reference `SceneKit` decides
* whether the hover changed by comparing what `resolve` returned against what
* it returned last frame, so a fresh object literal per hit would fire
* `onChange` every frame the pointer sat still.
*
* The numbering is the point of the map. A desk's real address is its seat id,
* `eng-14`, and that string says which team sits there it is the id a
* private occupancy API is keyed on precisely because it means something. A
* stranger gets `Desk 14`, numbered from one in plan order across the whole
* building, which says only that this office has at least fourteen desks. The
* bank ids, the seat ids and the station numbers stay on this side of the
* callback.
*/
const places: Map<string, Pin> | null = depth === "public" ? new Map() : null;
if (places) {
let n = 0;
for (const level of plan.levels) {
for (const prop of level.props) {
if (prop.source?.part !== "desk") continue;
n += 1;
places.set(prop.id, { id: `desk-${n}`, label: `Desk ${n}`, colorKey: "desk" });
}
}
}
if (presence) {
// `pickables` is rebuilt in place whenever occupancy changes, so the getter
// rather than the array: the office outlives any one set of people in it.
kit.setPicking<Presence>({
targets: () => presence.pickables,
resolve: (hit) => (hit.object.userData.presence as Presence | undefined) ?? null,
onChange: (person) => options.onPresencePick?.(person),
});
} else if (places) {
// The furnishings are instanced, so the hit resolves in two steps: the
// instanced mesh plus the instance index gives a prop id, and only the prop
// ids that are in the map — the desks — resolve to anything at all. A chair,
// a plant or a light is not a place and comes back `null`.
kit.setPicking<Pin>({
targets: () => furnishings.pickables,
resolve: (hit) => {
const id = furnishings.propAt(hit.object, hit.instanceId);
return id === null ? null : (places.get(id) ?? null);
},
onChange: (place) => options.onPlacePick?.(place),
});
}
// ---- Occlusion fade -----------------------------------------------------
@@ -296,19 +431,51 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
// ---- The scene, as the stage sees it ------------------------------------
/**
* Disposal is one-way and it is checked, because the reason this handle gets
* thrown away is usually that another one is being built to replace it.
*
* Signing in while standing in the public office disposes this scene and
* constructs a `"full"` one; the stage is mid-frame when that happens, and a
* `tick` arriving after `dispose` would drive an `OrbitControls` that has
* already released its listeners. Guarding here rather than asking every
* caller to sequence it correctly is the difference between a dispose you can
* rely on and one that mostly works.
*/
let disposed = false;
let warnedNoPresence = false;
return {
scene,
camera: kit.camera,
controls: kit.controls,
plan,
depth,
views,
anchors: presence.anchors,
// A public office anchors nothing, because it has nobody to anchor. The
// empty map is this scene's own rather than a shared module-level one: an
// HTML overlay that writes into what it was handed should not be able to
// reach across into another office.
anchors: presence?.anchors ?? new Map<string, THREE.Vector3>(),
flyTo,
current: () => currentView,
onViewChange(fn) {
viewListeners.push(fn);
},
setPresence(people) {
if (!presence) {
// Once, not once per poll: an occupancy feed pointed at the public
// office will call this every few seconds, and the console is where the
// author of the caller finds out that nothing is happening.
if (!warnedNoPresence) {
warnedNoPresence = true;
console.warn(
`[tera/interiors] office "${office.id}" was built at depth "public"; ` +
`${people.length} presence record(s) ignored. Rebuild at "full" to show people.`,
);
}
return;
}
presence.setPresence(people);
},
setCeilingsVisible(visible) {
@@ -321,11 +488,14 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
// detail card for whoever the pointer was over survives the journey.
onExit: () => kit.resetPick(),
tick(dt) {
if (disposed) return;
kit.tick(dt);
updateOcclusion();
},
dispose() {
presence.dispose();
if (disposed) return;
disposed = true;
presence?.dispose();
furnishings.dispose();
shell.dispose();
kit.dispose();
@@ -333,6 +503,16 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
// other asset in the page is still using it.
if (ownsMaterials) materials.dispose();
scene.clear();
// Three things the old version left behind, and all three matter when the
// reason for disposing is that a second office is about to be built: the
// background `Color`, the view listeners — whose closures reach back into
// whatever UI created this scene — and the desk table. None of them is
// large; all of them are held for as long as anything holds this handle,
// and a handle is exactly the sort of thing a `let office` keeps a stale
// copy of.
scene.background = null;
viewListeners.length = 0;
places?.clear();
},
};
}
+68
View File
@@ -42,10 +42,24 @@
* `console.warn` and then the offending item is dropped or repaired. An
* exception with no context in the middle of a 180-prop pack tells the author
* nothing and loses the other 179.
*
* ### Depth: a public build does not build the private half
*
* `PlanOptions.depth` is the other reason something can be absent from the build
* product, and it is the one that is not an error. At `"public"` every item the
* pack marked `audience: "private"` is skipped here, in the resolution pass,
* before it is a placement and long before it is a mesh. That ordering is the
* whole point: a private prop that is built and then hidden is still in
* `scene.traverse`, in the devtools graph and in a `JSON.stringify` of this
* object, which is a data leak with a checkbox in front of it. Read the note on
* `Audience` in `types.ts` including the paragraph saying this is a UI tier
* and not a security boundary, because a pack is bundled into the static build
* and is public whatever it is marked.
*/
import type {
AssetId,
Audience,
DeskBank,
Level,
Office,
@@ -279,7 +293,38 @@ export interface PlanProblem {
* polygon and opening helpers can stay free functions. */
type Report = (where: string, message: string, action: PlanProblem["action"]) => void;
/**
* How much of a pack to resolve.
*
* The counterpart to `Audience` and deliberately not the same union: an item
* says who it is *for* (`"public"` or `"private"`), a build says how far in it
* *goes* (`"public"` or `"full"`). Spelling both with a shared two-member union
* would make `depth === audience` compile and mean nothing.
*
* It lives here rather than in `types.ts` because it is not something a pack can
* say. `types.ts` is the contract for authored data; this is an argument to the
* thing that reads it.
*/
export type Depth = "public" | "full";
/** Whether a build at `depth` includes an item the pack marked `audience`. */
function included(depth: Depth, audience: Audience | undefined): boolean {
return depth === "full" || audience !== "private";
}
export interface PlanOptions {
/**
* `"full"` the default resolves the whole pack. `"public"` skips every
* item marked `audience: "private"`, which is how the office gets a
* not-signed-in version without a second pack to keep in step. See the header.
*
* One consequence worth knowing about: id collisions are detected against what
* was actually resolved, so a pack whose private half collides with its public
* half reports that problem at `"full"` and not at `"public"`. Validate a pack
* at full depth that is the build the author is responsible for, and the
* public one is a subset of it.
*/
depth?: Depth;
/**
* How high a hole must clear for a walker to pass through it, in metres.
* Defaults to 1.1.
@@ -311,6 +356,12 @@ function devBuild(): boolean {
export class Plan {
readonly office: Office;
/**
* How much of the pack this is. Read it rather than inferring it from what is
* missing an office with nothing marked private resolves identically at both
* depths, and that is the normal case rather than a suspicious one.
*/
readonly depth: Depth;
readonly levels: readonly LevelPlan[];
/** Only those whose `levelId` resolves. `viewpoints[0]` is still the arrival pose. */
readonly viewpoints: readonly Viewpoint[];
@@ -327,6 +378,7 @@ export class Plan {
constructor(office: Office, options: PlanOptions = {}) {
this.office = office;
this.depth = options.depth ?? "full";
this.walkHeight = options.walkHeight ?? DEFAULT_WALK_HEIGHT;
const problems: PlanProblem[] = [];
@@ -395,6 +447,11 @@ export class Plan {
report(where, `on unknown level "${viewpoint.levelId}"`, "dropped");
return;
}
// Not a problem, so not reported: a viewpoint the pack reserved for the
// signed-in building is absent from `viewpoints` at public depth, which
// means it is absent from the legend too rather than leaving a button that
// flies nowhere.
if (!included(this.depth, viewpoint.audience)) return;
seen.viewpoint.add(viewpoint.id);
viewpoints.push(viewpoint);
this.viewpointsById.set(viewpoint.id, viewpoint);
@@ -493,9 +550,16 @@ export class Plan {
const floorplan = level.floorplan;
const extent = new Extent();
// Every one of the five passes below opens the same way: a private item at
// public depth is skipped before anything is resolved about it, so it never
// becomes a `ResolvedRoom`, a `PropPlacement` or a `ResolvedSeat` and there
// is nothing downstream for a mesh layer to build or a traversal to find.
// It is not reported — the pack is not wrong, it is being read at a depth
// that does not include it.
const rooms: ResolvedRoom[] = [];
(floorplan.rooms ?? []).forEach((room, ri) => {
const at = `${where}.rooms[${ri}]`;
if (!included(this.depth, room.audience)) return;
if (seen.room.has(room.id)) {
report(at, `duplicate room id "${room.id}"`, "dropped");
return;
@@ -545,11 +609,13 @@ export class Plan {
const seats: ResolvedSeat[] = [];
(floorplan.deskBanks ?? []).forEach((bank, bi) => {
const at = `${where}.deskBanks[${bi}]`;
if (!included(this.depth, bank.audience)) return;
this.expandBank(bank, level, floorY, at, seen, report, props, seats);
});
(floorplan.props ?? []).forEach((prop, pi) => {
const at = `${where}.props[${pi}]`;
if (!included(this.depth, prop.audience)) return;
if (seen.prop.has(prop.id)) {
report(at, `duplicate prop id "${prop.id}"`, "dropped");
return;
@@ -560,6 +626,7 @@ export class Plan {
(floorplan.seats ?? []).forEach((seat, si) => {
const at = `${where}.seats[${si}]`;
if (!included(this.depth, seat.audience)) return;
if (seen.seat.has(seat.id)) {
report(at, `duplicate seat id "${seat.id}"`, "dropped");
return;
@@ -571,6 +638,7 @@ export class Plan {
const zones: ResolvedZone[] = [];
(floorplan.zones ?? []).forEach((zone, zi) => {
const at = `${where}.zones[${zi}]`;
if (!included(this.depth, zone.audience)) return;
if (seen.zone.has(zone.id)) {
report(at, `duplicate zone id "${zone.id}"`, "dropped");
return;
+15
View File
@@ -18,6 +18,21 @@
* turn a private id into a public coordinate, which is the exact thing the split
* exists to prevent.
*
* ### The public office does not call this file
*
* `createOfficeScene(office, { depth: "public" })` never constructs a
* `PresenceLayer`. Not an empty one, not a hidden one none. That is worth
* stating here rather than only at the call site, because the tempting change to
* this file, the first time somebody wants an anonymous view, is a `visible`
* flag or an `if (anonymous) return` inside `setPresence`. Both of those leave a
* layer in the scene graph that is one call away from being populated, and
* `officeScene.ts` is where the decision belongs precisely so that the layer
* that must not exist is not built at all.
*
* The split above is what makes that cheap: an office pack has no people in it,
* so a building with no presence layer is not a building with something taken
* out of it. It is the same building, before anyone arrived.
*
* ### Figures
*
* Two poses, one merged geometry each, one material per colour, one mesh per
+91
View File
@@ -106,6 +106,58 @@ export type AssetId = string;
*/
export type SurfaceId = string;
// ---- Audience -------------------------------------------------------------
/**
* Who a piece of a pack is built for.
*
* An office has two audiences now. `office.lumbridgecorp.com` is a front door
* anyone can walk up to, and the same building signed in is the one with the
* people in it. Marking a room, a prop, a bank, a seat, a zone or a viewpoint
* `"private"` says: this exists for the second audience and not the first, and
* a public build must never construct it.
*
* Absent means `"public"`. Every pack written before this field existed keeps
* working, and a pack that never thinks about it never has to.
*
* ### It is not built, rather than built and hidden
*
* `Plan` drops private items during resolution, so a public build has no
* `PropPlacement`, no `ResolvedSeat` and no mesh for them at all. Building them
* and setting `visible = false` would leave every one of them in
* `scene.traverse`, in the devtools scene graph and in a `JSON.stringify` of the
* plan a data leak dressed as a privacy feature. See `PlanOptions.depth` in
* `plan.ts`, which is where the drop happens.
*
* ### Walls have no audience, and cannot get one
*
* A wall is the difference between a floor plan and a floor, and it is what the
* collision pass is made of. A building whose partitions come and go with who is
* looking at it is two different buildings, and the walk-mode collider would be
* describing whichever one you were not in. Mark what stands in the room. A
* `Room` *can* be marked, but a private room takes its floor slab and its
* ceiling with it and leaves a hole in the plan, so that is nearly always the
* wrong field to reach for mark the contents.
*
* ### This is a UI tier and it is not a security boundary
*
* **A pack is bundled into the static build, so everything in it is public by
* construction**, whatever this field says. The file is in the JavaScript;
* anyone who wants the private half can read it out of the bundle in ten
* seconds. What the field buys is that an anonymous visitor is not *shown* the
* parts of a building that are nobody's business. That is a product decision
* worth making, and it is not the same act as withholding them.
*
* The thing that is genuinely private is `Presence` who is in today and where
* they sit and it is private because it never appears in a pack at all. It
* arrives from an API over authentication, and **the API is what refuses an
* anonymous caller**. Nothing on this side of the wire can enforce that. A pack
* that puts something actually secret behind `audience: "private"` has published
* it, and the reason this paragraph is here is so that nobody discovers that
* later.
*/
export type Audience = "public" | "private";
// ---- The office -----------------------------------------------------------
/**
@@ -220,6 +272,12 @@ export interface Room {
* an atrium, a double-height void, or a cutaway you want to look down into.
*/
ceiling?: RoomCeiling | null;
/**
* See `Audience`. Absent means public. A private room takes its floor slab and
* its ceiling with it and leaves a hole in the plan, which is almost never
* what is wanted mark the props in the room instead.
*/
audience?: Audience;
}
/** A ceiling override for one room. Both fields fall back to the level. */
@@ -331,6 +389,8 @@ export interface Prop {
* without knowing which mesh is which.
*/
seat?: string;
/** See `Audience`. Absent means public. */
audience?: Audience;
}
/**
@@ -393,6 +453,12 @@ export interface DeskBank {
pose?: SeatPose;
/** Overrides the bank `id` as the seat-id prefix. */
seatPrefix?: string;
/**
* See `Audience`. Absent means public, and it covers the whole expansion: a
* private bank generates no desks, no chairs and no seats, so there is nothing
* left for a presence to bind to.
*/
audience?: Audience;
}
// ---- Seats and zones ------------------------------------------------------
@@ -413,6 +479,13 @@ export interface Seat {
/** Which way an occupant looks. See `Yaw`. */
facing: Yaw;
pose: SeatPose;
/**
* See `Audience`. Absent means public. A private seat is not resolved at
* public depth, so a `Presence` naming it is dropped the same way one naming a
* seat that does not exist is which is the answer you want, since at public
* depth there is no presence layer to drop it into either.
*/
audience?: Audience;
}
/**
@@ -430,6 +503,13 @@ export interface Zone {
outline: Outline;
/** Opaque palette key, resolved by the caller. */
colorKey?: string;
/**
* See `Audience`. Absent means public. A zone is a label on an area and a
* label is exactly the sort of thing that turns out to be organisational
* "Engineering" says who sits there so this is the field a pack reaches for
* most.
*/
audience?: Audience;
}
// ---- Viewpoints -----------------------------------------------------------
@@ -454,6 +534,17 @@ export interface Viewpoint extends View {
/** Camera azimuth about the target. See `Yaw`. */
rotation: Yaw;
};
/**
* See `Audience`. Absent means public.
*
* Use it sparingly and think first. A viewpoint is a promise printed in a
* legend, and a visitor told there are five and shown three has been lied to;
* a private viewpoint disappears from `views` entirely rather than leaving a
* dead button, but the honest fix is usually to reframe the shot rather than
* to withhold it. Mark one private only when the *pose itself* is the
* disclosure a camera two metres from the whiteboard in the board room.
*/
audience?: Audience;
}
// ---- Presence -------------------------------------------------------------
+504 -46
View File
@@ -22,7 +22,11 @@ import SOCAL from "./cities/socal.ts";
import { createTeraClient } from "./adapters/http.ts";
import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_ROUTES } from "./adapters/sample.ts";
import { createOfficeScene, type OfficeScene } from "./interiors/officeScene.ts";
import { authFetch } from "./session.ts";
import LUMBRIDGE_HQ from "./offices/lumbridge-hq.ts";
import { capabilitiesFor, resolveAccess, type Access } from "./access.ts";
import { createMinimap, type Minimap } from "./engine/minimap.ts";
import { MaterialRegistry } from "./assets/materials.ts";
const CITIES: { id: string; label: string; city: City }[] = [
{ id: "sf", label: "Bay Area", city: SAN_FRANCISCO },
@@ -32,7 +36,10 @@ const CITIES: { id: string; label: string; city: City }[] = [
const canvas = document.querySelector<HTMLCanvasElement>("#scene");
if (!canvas) throw new Error("#scene canvas missing");
const tera = createTeraClient();
// `authFetch` so the private-office pack (`/api/v1/offices/:id`, which answers
// 404 rather than 403 to anyone who may not see it) is requested as the signed-in
// viewer. On a `password`-mode or open deployment it is an ordinary fetch.
const tera = createTeraClient({ fetch: authFetch });
let city: SceneHandle | null = null;
let cityId = "sf";
@@ -41,8 +48,47 @@ let inside = false;
let markers: Marker[] = SAMPLE_MARKERS;
let palette: MarkerPalette = SAMPLE_PALETTE;
let liveData = false;
let canEnterOffice = false;
/**
* What this visitor may do. Resolved once in `boot()`; every gate below reads
* `access.can.*` and nothing else.
*
* The pre-boot value is the **closed** one, deliberately. A handler that
* somehow fires before `resolveAccess()` has settled a keystroke on a slow
* connection, a click on a control that is in the document from first paint
* should offer a visitor less than they are entitled to and never more. The
* rule that produces this value, and the SSO bug that once produced it wrongly,
* are written out in `src/access.ts`.
*/
let access: Access = {
tier: "anon",
subject: null,
signInUrl: null,
can: capabilitiesFor("anon"),
};
let atmosphere: ReturnType<typeof createAtmosphere> | null = null;
let minimap: Minimap | null = null;
/**
* One texture set for every office this page ever builds.
*
* Signing in while standing in the public office is a `dispose()` and a second
* `createOfficeScene` at `"full"` cheap only if both are handed the same
* registry, because drawing the textures is the expensive part and a registry
* draws them once. Owned here and disposed nowhere: it outlives every scene
* that borrows it, and the page teardown takes the process with it.
*/
const officeMaterials = new MaterialRegistry({ quality: "high" });
/**
* `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind
* two names, and which name you arrived at is the whole difference: one is a
* map with an office in it, the other is an office with a map behind it. The
* city is still built underneath either way that is what makes " Back to the
* city" work from the office front door so this is a policy about where boot
* *stops*, not about what boot builds.
*/
const OPENS_IN_OFFICE =
location.hostname.split(".")[0] === "office" ||
new URLSearchParams(location.search).get("view") === "office";
// ---- Time -----------------------------------------------------------------
@@ -67,6 +113,10 @@ function updateSun() {
const env = observe(active.center.lat, active.center.lng, currentInstant());
city.setLighting(atmosphere.apply(env));
city.setSolarElevation(env.sun.elevation);
// The plan view follows the same day the map does. It computes its own
// palette from this one number rather than reading the rig, because a rig is
// a set of three.js lights and the minimap has none.
minimap?.setSolarElevation(env.sun.elevation);
const clock = document.querySelector<HTMLElement>("#clock");
if (!clock) return;
@@ -92,21 +142,38 @@ function mountCity(id: string) {
office?.dispose();
office = null;
inside = false;
minimap?.dispose();
minimap = null;
city?.dispose();
cityId = id;
city = createScene(canvas, {
city: entry.city,
markerPalette: palette,
flights: liveData ? tera.flights() : new SimulatedFlights(SAMPLE_ROUTES),
// `liveData` alone is not enough: it only records that a feed answered
// once, at boot, before the tier was known. An anonymous visitor asking for
// live traffic gets an empty sky rather than the simulation, which looks
// like a broken layer instead of an honest one.
flights:
access.can.liveData && liveData ? tera.flights() : new SimulatedFlights(SAMPLE_ROUTES),
onMarkerPick: (m) => showDetail(m ? `${m.label}${m.blurb ? `${m.blurb}` : ""}` : null),
});
// Fog distances are scene units, so they have to follow the board — 210/460
// was tuned for a 230-unit San Francisco and fogs out most of a 1000-unit
// Bay Area. They also have to clear the CAMERA, which sits about 0.6 spans
// out on the whole-board view: a fog starting nearer than that is behind the
// viewer's own shoulder, and at night, when the fog colour is nearly black
// rather than bright haze, it turns the entire map off.
// viewer's own shoulder, and every pixel in frame is then at full fog.
//
// That last failure used to be catastrophic and is now only bad, and the
// difference is worth recording because the comment used to claim the worse
// version. The night fog colour is derived from the sky, the night sky was
// nearly black, and so a fog plane behind the camera turned the entire map
// off. `atmosphere.ts` now floors the night ground rig and stops the
// obscuration convergence subtracting it again, and the night fog here lands
// around #16203a — aerial perspective that lifts distance rather than a
// blackout. The clearance is still required: a board flattened to one uniform
// value is unreadable at any brightness. It is no longer the difference
// between a map and a black rectangle.
const [wx, nz] = city.world.project(entry.city.bounds.maxLat, entry.city.bounds.minLng);
const [ex, sz] = city.world.project(entry.city.bounds.minLat, entry.city.bounds.maxLng);
const span = Math.max(Math.abs(ex - wx), Math.abs(sz - nz));
@@ -128,18 +195,100 @@ function mountCity(id: string) {
});
city.setMarkers(id === "sf" ? markers : []);
city.onChapterChange(() => renderLegend());
/**
* The plan view, built last, because it reads the finished `World` the
* heightfield the terrain has already paid for and the live camera and
* controls the scene has just made. It is torn down and rebuilt with the
* city for the same reason the city is: nothing in it survives a change of
* board, and it holds a `World` that would otherwise leak.
*/
minimap = createMinimap({
world: city.world,
city: entry.city,
camera: city.stageScene.camera,
controls: city.stageScene.controls,
markerPalette: palette,
onSeek(lat, lng) {
if (!city) return;
/**
* Slide the orbit target and carry the camera with it, keeping the offset
* between them. A seek is "look over there", not "go to chapter three":
* snapping to a chapter pose throws away the angle and the distance the
* user spent the last minute choosing, and doing it from a click on a map
* is the kind of surprise that stops people clicking on the map.
*
* No easing, deliberately. `flyTo` would need a pose, which is the thing
* being avoided, and an instant move is also the correct answer under
* `prefers-reduced-motion`.
*/
const { camera, controls } = city.stageScene;
const [x, z] = city.world.project(lat, lng);
const y = city.world.groundAt(lat, lng);
const dx = camera.position.x - controls.target.x;
const dy = camera.position.y - controls.target.y;
const dz = camera.position.z - controls.target.z;
controls.target.set(x, y, z);
camera.position.set(x + dx, y + dy, z + dz);
},
onHover(info) {
if (!minimapReadout) return;
minimapReadout.textContent = info
? `${info.lat.toFixed(4)}, ${info.lng.toFixed(4)}${info.district ? ` · ${info.district}` : ""}`
: "";
},
});
minimapFrame?.replaceChildren(minimap.canvas);
minimap.setMarkers(id === "sf" ? markers : []);
updateSun();
renderLegend();
}
/**
* The minimap's own frame pump.
*
* `Stage` owns the render loop and `SceneHandle` exposes no per-frame hook, so
* the alternative is adding an `onTick` to the scene handle for exactly one
* call site. This is the smaller change and it costs nothing measurable: an
* idle `tick()` is a timestamp comparison and a dirty flag, 0.0002 ms, and the
* loop keeps running unchanged across a city swap, across the office swap, and
* during the window where there is no minimap at all.
*/
requestAnimationFrame(function pumpMinimap() {
requestAnimationFrame(pumpMinimap);
minimap?.tick();
});
// ---- Office ---------------------------------------------------------------
/**
* Everyone gets in. The tier picks which building they get, not whether the
* door opens.
*
* The office used to be members-only, and the anonymous view of this site was a
* map with a greyed-out button on it the single most interesting thing the
* project does, visible only as something you cannot have. At `"public"` depth
* the same shell, the same furniture and the same named viewpoints are built,
* and the only thing missing is the people. That is withheld because the API
* refuses occupancy to an anonymous caller, not because this function declined
* to draw it.
*/
function enterOffice() {
if (!city || !canEnterOffice) return;
if (!city) return;
if (!office) {
const depth = access.can.officeDepth;
office = createOfficeScene(LUMBRIDGE_HQ, {
dom: city.stage.renderer.domElement,
background: 0x11161c,
depth,
materials: officeMaterials,
// Two different questions, so two different callbacks. `onPresencePick`
// answers "who is at this desk"; `onPlacePick` answers only "this is a
// desk, and it is the fourteenth one" — which is all a stranger is told.
...(depth === "full"
? { onPresencePick: (p) => showDetail(p ? p.label : null) }
: { onPlacePick: (place) => showDetail(place ? place.label : null) }),
});
office.onViewChange(() => renderLegend());
}
@@ -166,6 +315,14 @@ const subtitle = document.querySelector<HTMLElement>("#subtitle");
const enterButton = document.querySelector<HTMLButtonElement>("#enter");
const cityNav = document.querySelector<HTMLElement>("#cities");
const source = document.querySelector<HTMLElement>("#source");
const minimapFrame = document.querySelector<HTMLElement>("#minimap .minimap-frame");
const minimapReadout = document.querySelector<HTMLElement>("#minimap-readout");
const tierBadge = document.querySelector<HTMLElement>("#tier");
const officeBadge = document.querySelector<HTMLElement>("#office-badge");
const panelToggle = document.querySelector<HTMLButtonElement>("#panel-toggle");
const panelToggleLabel = document.querySelector<HTMLElement>("#panel-toggle-label");
const shortcutsCard = document.querySelector<HTMLElement>("#shortcuts");
const helpButton = document.querySelector<HTMLButtonElement>("#help");
function showDetail(text: string | null) {
const card = document.querySelector<HTMLElement>("#detail");
@@ -179,12 +336,14 @@ function renderCityPicker() {
cityNav.replaceChildren();
for (const c of CITIES) {
const b = document.createElement("button");
b.className = c.id === cityId && !inside ? "city active" : "city";
// `aria-pressed` rather than a class, because that is what these are: two
// buttons of which exactly one is on. The stylesheet keys off the attribute
// so the visual state and the announced state cannot drift apart.
b.className = "city";
b.type = "button";
b.setAttribute("aria-pressed", String(c.id === cityId && !inside));
b.textContent = c.label;
b.addEventListener("click", () => {
if (inside) leaveOffice();
if (c.id !== cityId) mountCity(c.id);
});
b.addEventListener("click", () => switchCity(c.id));
cityNav.append(b);
}
}
@@ -199,13 +358,12 @@ function renderLegend() {
nav.replaceChildren();
views.forEach((view, i) => {
const button = document.createElement("button");
button.className = view.id === activeId ? "chapter active" : "chapter";
button.className = "chapter";
button.type = "button";
button.setAttribute("aria-pressed", String(view.id === activeId));
const number = view.number ?? String(i + 1).padStart(2, "0");
button.innerHTML = `<span class="num">${number}</span><span>${view.shortLabel}</span>`;
button.addEventListener("click", () => {
if (inside && office) office.flyTo(view.id);
else city?.flyTo(view.id);
});
button.addEventListener("click", () => flyToIndex(i));
nav.append(button);
});
@@ -220,64 +378,364 @@ function renderLegend() {
subtitle.textContent = inside ? "Spaces · a Lumbridge office" : "Tera · Lumbridge Simulate";
}
if (enterButton) {
if (inside) enterButton.textContent = "← Back to the city";
else if (canEnterOffice) enterButton.textContent = "Enter the office →";
else enterButton.textContent = "Sign in to enter the office →";
// One label for everyone. The door is open at both tiers; what differs is
// what is behind it, and that is the badge's job to say, not the button's.
enterButton.textContent = inside ? "← Back to the city" : "Enter the office →";
}
if (source) {
source.textContent = liveData ? "live data" : "sample data · fabricated, not real companies";
source.className = liveData ? "source live" : "source";
}
if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel;
if (canvas) {
canvas.setAttribute(
"aria-label",
inside
? `${LUMBRIDGE_HQ.name}, seen from above. Drag to orbit, scroll to zoom.`
: `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`,
);
}
minimap?.setChapters(city.chapters, city.current());
renderOfficeBadge();
}
enterButton?.addEventListener("click", () => {
/**
* The one thing a public visitor is actually missing, said in the place where
* they would notice it missing.
*
* An empty office with no explanation reads as a bug a floor that failed to
* load and the fix for that is a sentence, not a disabled button. The
* sign-in link is offered *beside* the office rather than in front of it, so it
* is an upgrade and never a toll gate.
*/
function renderOfficeBadge() {
if (!officeBadge) return;
const publicOffice = inside && office !== null && office.depth === "public";
officeBadge.hidden = !publicOffice;
if (!publicOffice) return;
officeBadge.replaceChildren(
document.createTextNode("Public view — the building, not the people. "),
);
if (access.signInUrl !== null) {
const link = document.createElement("a");
link.href = access.signInUrl;
link.textContent = "Sign in for the live floor";
officeBadge.append(link, document.createTextNode("."));
} else {
officeBadge.append(document.createTextNode("Sign in to see who's in."));
}
}
/**
* Who the site thinks you are, in the corner, always. Three words and a name.
*
* It is here rather than buried in a menu because every other difference on
* this page an empty office, sample markers, a missing scrubber is a
* *silence*, and a silence you cannot attribute is indistinguishable from a
* fault. This is the line that tells you which of the two you are looking at.
*/
function renderTierBadge() {
if (!tierBadge) return;
tierBadge.className = `card tier ${access.tier}`;
const label = document.createElement("span");
/**
* The label names what you *get*, not who you are, and that is deliberate.
* "Signed in" was the first draft and it is a lie in the commonest case:
* a clean clone with no API at all resolves to `member`, and telling someone
* they are signed in to a server that does not exist is the sort of small
* dishonesty that makes the rest of the interface untrustworthy. "Full view"
* is true whether the tier came from a session or from there being nothing to
* have a session with; the subject, when there is one, says the rest.
*/
label.textContent =
access.tier === "god" ? "Godmode" : access.tier === "member" ? "Full view" : "Public view";
tierBadge.replaceChildren(label);
if (access.subject !== null) {
const who = document.createElement("span");
who.className = "who";
who.textContent = access.subject;
tierBadge.append(who);
} else if (access.signInUrl !== null) {
const link = document.createElement("a");
link.href = access.signInUrl;
link.textContent = "Sign in";
tierBadge.append(link);
}
tierBadge.hidden = false;
}
// ---- Navigation -------------------------------------------------------------
/** The views on offer right now — city chapters, or office viewpoints inside. */
function currentViews(): View[] {
if (inside && office) return office.views;
return city?.chapters ?? [];
}
function flyToIndex(index: number) {
const view = currentViews()[index];
if (!view) return;
if (inside && office) office.flyTo(view.id);
else city?.flyTo(view.id);
}
function switchCity(id: string) {
if (inside) leaveOffice();
else if (canEnterOffice) enterOffice();
else window.location.href = "/login.html";
if (id === cityId) return;
const label = CITIES.find((c) => c.id === id)?.label ?? id;
void building(`Building ${label}`, () => mountCity(id));
}
function stepCity(delta: number) {
const at = CITIES.findIndex((c) => c.id === cityId);
const next = CITIES[(at + delta + CITIES.length) % CITIES.length];
if (next) switchCity(next.id);
}
function toggleOffice() {
if (inside) {
leaveOffice();
return;
}
// Only the first entry builds anything; after that the office is parked in
// memory next to the paused city and the swap is a pointer.
if (office) enterOffice();
else void building("Building the office…", () => enterOffice());
}
enterButton?.addEventListener("click", () => toggleOffice());
// ---- Panels, plan and overlays ----------------------------------------------
/**
* Two pieces of chrome are a *user* decision rather than a media query, and the
* distinction matters: a media query that hides the plan below 600px also makes
* `M` do nothing there, which is the width where a plan view is most useful and
* least affordable. So the width only seeds the initial state, and the moment
* someone presses the key the viewport stops having an opinion.
*/
let panelOpen = window.innerWidth > 900;
let planOpen = window.innerWidth > 600;
let planChosen = false;
function applyPanel() {
document.body.classList.toggle("panel-closed", !panelOpen);
panelToggle?.setAttribute("aria-expanded", String(panelOpen));
}
function applyPlan() {
document.body.classList.toggle("minimap-off", !planOpen);
}
panelToggle?.addEventListener("click", () => {
panelOpen = !panelOpen;
applyPanel();
});
window.addEventListener("resize", () => {
if (!planChosen) {
planOpen = window.innerWidth > 600;
applyPlan();
}
});
function openShortcuts() {
if (!shortcutsCard || !shortcutsCard.hidden) return;
shortcutsCard.hidden = false;
document.querySelector<HTMLButtonElement>("#shortcuts-close")?.focus();
}
function closeShortcuts() {
if (!shortcutsCard || shortcutsCard.hidden) return;
shortcutsCard.hidden = true;
helpButton?.focus();
}
helpButton?.addEventListener("click", () => openShortcuts());
document.querySelector<HTMLElement>("#shortcuts-close")?.addEventListener("click", closeShortcuts);
shortcutsCard?.addEventListener("click", (event) => {
// The backdrop, not the sheet. Clicking the card itself must not close it.
if (event.target === shortcutsCard) closeShortcuts();
});
/**
* Keyboard access to everything the mouse can reach.
*
* Bound to `window` rather than to the canvas, because the canvas is only
* focusable by accident and a shortcut that stops working when you tab to the
* legend is worse than no shortcut. The guard is the usual one: a keystroke
* that lands in a text field or on the plan view's own arrow-key handler
* belongs to that control, not to this.
*/
window.addEventListener("keydown", (event) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
return;
}
if (event.key === "Escape") {
if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts();
else if (inside) leaveOffice();
else showDetail(null);
return;
}
if (event.key === "?") {
if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts();
else openShortcuts();
event.preventDefault();
return;
}
if (event.key >= "1" && event.key <= "9") {
flyToIndex(Number(event.key) - 1);
return;
}
if (event.key === "[") {
stepCity(-1);
return;
}
if (event.key === "]") {
stepCity(1);
return;
}
const lower = event.key.toLowerCase();
if (lower === "m") {
planOpen = !planOpen;
planChosen = true;
applyPlan();
return;
}
if (lower === "o") toggleOffice();
});
// ---- Time -------------------------------------------------------------------
const scrubber = document.querySelector<HTMLInputElement>("#hour");
scrubber?.addEventListener("input", () => {
if (!access.can.timeControl) return;
hourOverride = Number(scrubber.value);
updateSun();
});
document.querySelector<HTMLElement>("#now")?.addEventListener("click", () => {
if (!access.can.timeControl) return;
hourOverride = null;
if (scrubber) scrubber.value = String(new Date().getHours());
updateSun();
});
/**
* The scrubber is an instrument, and instruments are god-only. The *clock* is
* not: a map that will not tell you what time it is showing is worse than one
* you cannot scrub, so `#clock` stays outside `#scrub` and stays visible to
* everyone.
*
* `hidden` rather than `disabled`, because a disabled slider is still a tab
* stop and still announces itself an affordance offered and withdrawn in the
* same breath. Without the control there is no override, so the clock follows
* the wall clock, which is the honest default anyway.
*/
function applyTimeControl() {
const scrub = document.querySelector<HTMLElement>("#scrub");
if (scrub) scrub.hidden = !access.can.timeControl;
if (!access.can.timeControl) hourOverride = null;
if (scrubber) scrubber.value = String(new Date().getHours());
}
// ---- The boot card ----------------------------------------------------------
const bootCard = document.querySelector<HTMLElement>("#boot");
const bootStep = document.querySelector<HTMLElement>("#boot-step");
/**
* Wait until the browser has actually put pixels on the glass.
*
* Writing to `textContent` and then immediately building a heightfield paints
* nothing: the style change and the two seconds of synchronous work are in the
* same task, so the frame the user sees is the one *after* the work. Two
* `requestAnimationFrame`s straddle a paint, which is the whole trick a
* single one still runs before it.
*/
function painted(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
}
/**
* Run something slow and synchronous with the boot card up and a sentence
* saying what it is.
*
* The Bay Area heightfield takes about 2.3 s and the office about half that.
* Neither can be made asynchronous without splitting the builders across
* frames, which is a large change to earn a progress bar. Naming the work is
* most of the value: a blank page for two seconds reads as broken, and
* "Building the Bay Area…" for two seconds reads as busy.
*/
async function building<T>(label: string, work: () => T): Promise<T> {
if (bootStep) bootStep.textContent = label;
if (bootCard) {
bootCard.hidden = false;
bootCard.classList.remove("done");
}
await painted();
const result = work();
// A second paint before the fade, so the first frame of the finished scene is
// behind the card rather than appearing with it.
await painted();
bootCard?.classList.add("done");
window.setTimeout(() => {
if (bootCard?.classList.contains("done")) bootCard.hidden = true;
}, 300);
return result;
}
// ---- Boot -----------------------------------------------------------------
/**
* Markers are awaited before the scene is built, because `markerPalette` is
* fixed at construction and the sample palette's keys are not the API's.
* Everything else about the API is optional: no server means sample data and a
* label saying so.
* Access first, then data, then the board.
*
* The order is load-bearing in both directions and it used to be wrong. Markers
* were fetched before the tier was known, which is a request an anonymous
* visitor should not be making; and both decisions have to be settled before
* the *first* `mountCity`, because `markerPalette` is fixed at scene
* construction the sample palette's keys are not the API's and the flight
* source is chosen in the same call.
*
* Everything about the API remains optional. No server means the bundled sample
* set, the simulated traffic, and a label at the bottom of the screen saying
* which of the two you are looking at.
*/
async function boot() {
try {
const feed = await tera.markers();
markers = feed.value;
palette = feed.palette;
liveData = feed.live;
} catch {
// A missing API is the self-host default, not an error.
}
try {
const res = await fetch("/api/v1/session", { credentials: "same-origin" });
if (res.ok) {
const s = (await res.json()) as { authenticated: boolean; passwordLogin: boolean };
canEnterOffice = s.authenticated || !s.passwordLogin;
} else {
canEnterOffice = true;
applyPanel();
applyPlan();
if (bootStep) bootStep.textContent = "Asking the deployment who you are…";
access = await resolveAccess();
applyTimeControl();
renderTierBadge();
if (access.can.liveData) {
try {
const feed = await tera.markers();
markers = feed.value;
palette = feed.palette;
liveData = feed.live;
} catch {
// A missing API is the self-host default, not an error.
}
} catch {
// No server means nothing to sign in to, so the office is open. That is the
// self-host posture: auth is something a deployment adds, not removes.
canEnterOffice = true;
}
mountCity("sf");
const first = CITIES[0];
await building(`Building ${first?.label ?? "the city"}`, () => mountCity(first?.id ?? "sf"));
// The `office.` front door. The city is already standing behind this, so the
// back button is a scene swap and not a rebuild.
if (OPENS_IN_OFFICE) await building("Building the office…", () => enterOffice());
window.setInterval(() => hourOverride === null && updateSun(), 60_000);
}
+88 -6
View File
@@ -21,6 +21,26 @@
* this building see ARCHITECTURE.md §3.3, and the note on `Presence` in
* `interiors/types.ts`, which is the same rule one level in.
*
* ### Two audiences, and why this pack barely uses the second one
*
* A handful of items below carry `audience: "private"`, which means a public
* build `createOfficeScene(office, { depth: "public" })`, the office an
* anonymous visitor gets does not construct them. There are seven of them: two
* zones and five objects in the server room. Everything else in this file is
* built at both depths, and that is the honest answer for this pack rather than
* a gap in it. The difference between the public building and the signed-in one
* is **the people**, and the people are not in this file at all.
*
* That is the whole reason a pack can be published. `Presence` binds by seat id
* and arrives from somewhere else, so the geometry and the occupancy are two
* separate acts and only one of them is happening here. Read the note on
* `Audience` in `interiors/types.ts` before reaching for the field, especially
* the part that says it is a UI tier and not a security boundary: **this file is
* bundled into the static build**, so a self-hoster who marks their real floor
* plan private has published their real floor plan with an extra step. The
* building here is fabricated. If yours is not, the thing that keeps a stranger
* out is your API, not this field.
*
* ### The coordinate frame
*
* Metres, `1 unit = 1 m`, the floor on the XZ plane with +Y up. The origin is
@@ -51,6 +71,7 @@
import type {
AssetId,
Audience,
DeskBank,
Level,
Office,
@@ -281,7 +302,7 @@ function scatter(
prefix: string,
kind: AssetId,
points: Point2[],
opts: { rotation?: Yaw; elevation?: number; colorKey?: string } = {},
opts: { rotation?: Yaw; elevation?: number; colorKey?: string; audience?: Audience } = {},
): Prop[] {
return points.map((position, i) => ({
id: `${prefix}-${String(i + 1).padStart(2, "0")}`,
@@ -290,6 +311,10 @@ function scatter(
rotation: opts.rotation ?? NORTH,
elevation: opts.elevation,
colorKey: opts.colorKey,
// A run of identical props is a run of identical props at both depths.
// Nothing in the format stops a pack marking one rack of four private, but
// half a row is a stranger thing to look at than no row.
audience: opts.audience,
}));
}
@@ -1143,9 +1168,35 @@ const PROPS: Prop[] = [
// hot-aisle arrangement and also the only way two rows of anything read as
// deliberate. A storage locker is not a rack, but it is a 1.2 x 0.5 x 1.8 m
// box with a front and a back, and at this scale that is a rack.
...scatter("mdf-rack-n", LOCKER, grid(27.4, 13.4, 2, 1, 1.3, 0), { rotation: NORTH }),
...scatter("mdf-rack-s", LOCKER, grid(27.4, 15.8, 2, 1, 1.3, 0), { rotation: SOUTH }),
{ id: "mdf-shelf", kind: SHELF, position: { x: 29.4, z: DEPTH - EXT_FACE - 0.18 }, rotation: SOUTH },
//
// The five objects in this room are the pack's one worked example of
// `audience: "private"`. The room, its raised floor, its walls and its lid are
// all still built at public depth — the *architecture* is not the secret — but
// what is standing in it is not shown to a stranger. How many cabinets an
// organisation runs and which way the aisle faces is the kind of detail that
// is worth nothing to a visitor and something to somebody else, and the room
// already keeps its ceiling for the same reason, which is the pack saying the
// same thing twice in two vocabularies.
//
// At public depth this leaves a lit, empty, raised-floor room, which is an
// honest picture of a room you are not being shown the inside of. The lights
// stay: a dark hole in a floor plan reads as a rendering fault, not as
// discretion.
...scatter("mdf-rack-n", LOCKER, grid(27.4, 13.4, 2, 1, 1.3, 0), {
rotation: NORTH,
audience: "private",
}),
...scatter("mdf-rack-s", LOCKER, grid(27.4, 15.8, 2, 1, 1.3, 0), {
rotation: SOUTH,
audience: "private",
}),
{
id: "mdf-shelf",
kind: SHELF,
position: { x: 29.4, z: DEPTH - EXT_FACE - 0.18 },
rotation: SOUTH,
audience: "private",
},
...scatter("mdf-light", TROFFER, grid(27.6, 14.0, 2, 2, 2.0, 2.4), { elevation: 2.6 }),
// -- Facilities -----------------------------------------------------------
@@ -1169,10 +1220,41 @@ const PROPS: Prop[] = [
* "eng" is a team, a cost centre or a colour scheme is not the engine's
* business the same rule as `Marker.colorKey`, which is why there is no
* `kind` field to be tempted by.
*
* ### Two of them are private, and it is the names that make them so
*
* The engine cannot tell these four apart, but a reader can. "Social" and
* "Focus" describe what the floor is *for*: anyone standing in the lounge can
* see that it is the lounge, and a public visitor learning that the three
* glass-lidded boxes are the focus booths has learned nothing they could not
* have guessed from the plan.
*
* "Engineering" and "Studio" describe who *sits* there, and that is a different
* kind of fact. It is org chart drawn on a floor: how many desks each function
* has, where they are relative to each other, which corner grew last quarter.
* Nobody signed in is surprised by it and nobody anonymous is owed it, which is
* exactly the line `audience` exists to draw so the two team zones are not
* built at public depth and the two spatial ones are.
*
* This is the granularity the field is for. The alternative anybody reaches for
* first is a single `private: true` on the whole floorplan, and it is useless:
* a building is not private or public, the labels on it are.
*/
const ZONES: Zone[] = [
{ id: "zone-eng", name: "Engineering", outline: rect(8.0, 0.6, 18.9, 7.6), colorKey: "team-a" },
{ id: "zone-studio", name: "Studio", outline: rect(19.4, 0.6, 25.2, 7.6), colorKey: "team-b" },
{
id: "zone-eng",
name: "Engineering",
outline: rect(8.0, 0.6, 18.9, 7.6),
colorKey: "team-a",
audience: "private",
},
{
id: "zone-studio",
name: "Studio",
outline: rect(19.4, 0.6, 25.2, 7.6),
colorKey: "team-b",
audience: "private",
},
{ id: "zone-social", name: "Social", outline: rect(SOCIAL_W, 0, WIDTH, SPINE_N), colorKey: "social" },
{ id: "zone-focus", name: "Focus", outline: rect(X_BOOTH_1, BOOTH_N, X_BOOTH_E, SPINE_N), colorKey: "focus" },
];
+101
View File
@@ -0,0 +1,101 @@
/**
* The browser's half of `TERA_AUTH_MODE=sso`.
*
* In `password` mode the server issues an HttpOnly cookie and this module has
* nothing to do `credentials: "same-origin"` carries the session and no script
* ever sees it, which is the better arrangement and the reason it is still the
* default for a self-hoster.
*
* `sso` mode cannot work that way. The identity provider is a **different
* origin** from this one, so the session it hands out is not a cookie this site
* can read or be sent. What the browser gets instead is a bearer token, which
* means it has to be stored somewhere a script can reach and attached by hand.
* That is a real downgrade an XSS bug on this page can now walk off with a
* session and it is accepted here for the same reason the rest of the fleet
* accepts it: the alternative is a cross-origin cookie with `SameSite=None`,
* which is worse, and the token is short-lived and revocable at the issuer.
*
* `sessionStorage`, not `localStorage`: the token dies with the tab. A shared
* office kiosk is a plausible way to use this, and "signed in forever on a
* machine somebody walked away from" is the failure this avoids.
*/
/** Namespaced so a self-hoster running something else on this origin is unaffected. */
const KEY = "tera.session.token";
/** Whether this build was given an identity provider to sign in against. */
export const IDENTITY_URL: string = import.meta.env.VITE_IDENTITY_URL ?? "";
/** The provider's PUBLIC key. Publishable by design — it gates nothing on its own. */
export const IDENTITY_KEY: string = import.meta.env.VITE_IDENTITY_ANON_KEY ?? "";
export const identityConfigured = IDENTITY_URL !== "" && IDENTITY_KEY !== "";
export function readToken(): string | null {
try {
const raw = sessionStorage.getItem(KEY);
return raw === null || raw === "" ? null : raw;
} catch {
// Storage can throw outright in a partitioned or cookie-blocked context.
// No token is a correct answer there; it just means signing in again.
return null;
}
}
export function writeToken(token: string): void {
try {
sessionStorage.setItem(KEY, token);
} catch {
// Nothing to do: the sign-in still succeeded at the issuer, and the caller
// finds out on the next request that it did not stick.
}
}
export function clearToken(): void {
try {
sessionStorage.removeItem(KEY);
} catch {
/* see writeToken */
}
}
/**
* `fetch` with the bearer token attached when there is one.
*
* `credentials: "same-origin"` is kept alongside it so a `password`-mode
* deployment where the cookie is the session and this module holds nothing
* goes on working through exactly the same call.
*/
export function authFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
const token = readToken();
const headers = new Headers(init.headers);
if (token !== null) headers.set("authorization", `Bearer ${token}`);
return fetch(input, { ...init, credentials: "same-origin", headers });
}
/**
* Exchange an email and password for an access token at the identity provider.
*
* A direct `fetch` rather than `@supabase/supabase-js`, which is a large
* dependency for one documented REST call and would be this repo's only reason
* to carry it. The same call is what the rest of the fleet's sites make.
*
* Returns the token, or `null` for every way it can fail the caller shows one
* message either way, because distinguishing "no such account" from "wrong
* password" is an enumeration oracle and not a kindness.
*/
export async function signIn(email: string, password: string): Promise<string | null> {
if (!identityConfigured) return null;
try {
const res = await fetch(`${IDENTITY_URL.replace(/\/+$/, "")}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: IDENTITY_KEY, "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) return null;
const body = (await res.json()) as { access_token?: unknown };
return typeof body.access_token === "string" && body.access_token !== ""
? body.access_token
: null;
} catch {
return null;
}
}
+18
View File
@@ -0,0 +1,18 @@
/// <reference types="vite/client" />
/**
* The two build-time values that turn on `sso` sign-in in the browser.
*
* Both are PUBLIC an issuer URL and a publishable key and neither grants
* anything on its own; the server still revalidates every token against
* `TERA_AUTH_REVALIDATE_URL`. They are optional, and a build without them keeps
* the local password form, which is the self-host default.
*/
interface ImportMetaEnv {
readonly VITE_IDENTITY_URL?: string;
readonly VITE_IDENTITY_ANON_KEY?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+9 -1
View File
@@ -4,5 +4,13 @@ export default defineConfig({
// Mounted under tera.lumbridgecorp.com in production; the trailing
// slash matters, since every asset URL is resolved against it.
base: process.env.TERA_BASE ?? "/",
build: { outDir: "dist", target: "es2022" },
build: {
outDir: "dist",
target: "es2022",
// Two entries. `login.html` used to sit in `public/`, which Vite copies
// verbatim — so `import.meta.env` was never substituted there and the page
// could not be told which identity provider to sign in against. It is a real
// entry now; the built URL (`/login.html`) is unchanged.
rollupOptions: { input: { index: "index.html", login: "login.html" } },
},
});