1
0
Commit Graph

11 Commits

Author SHA1 Message Date
karti d841575315 feat: add private office media signaling 2026-08-11 20:48:19 -07:00
karti 92ebf8abb5 feat: add authenticated realtime presence 2026-08-11 20:22:12 -07:00
karti b20fe41a68 The sky the real catalogue served, rather than the one it was assumed to serve
Three fixes, all of them found by pointing the thing at celestrak.org and
looking at what came back.

**The catalogue was 100% Starlink.** The supplemental feed is 10,766
objects against a 6,000-object ceiling, so a first-wins merge spent the
whole budget on it and served a sky with no ISS, no GPS, no weather
satellites — every other group fetched, parsed, and thrown away. `merge`
now reserves the ceiling for the small groups and lets the fill group
take the remainder: 1,044 of everything else and 4,956 Starlinks. The
truncation warning is what caught it, which is the argument for the
no-silent-caps rule; it now reports the split rather than only the fact
that a cap was hit.

**You could get outside the sky.** The dome sat at 1.2 board spans and
the camera orbits to 1.5, so pulling all the way back put the viewer
outside it looking in, with half the constellation behind the camera.
2.0 is inside the far plane at 3.0 and outside the orbit.

**Size attenuation was wrong in principle.** A satellite does not get
bigger because you zoomed the map in. Fixed pixel size, at roughly what a
naked-eye Starlink actually looks like — the attenuated version shrank to
sub-pixel specks at the far end of the zoom and read as noise.

Measured over San Francisco at 21:19 local: 324 above the horizon, 267
fully lit, 96 at local midnight. The terminator does what it should.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 21:20:56 -07:00
karti a229fb2721 The sky gets the things above the aeroplanes
Satellites, end to end: CelesTrak element sets behind the same TTL cache
the weather and the flights use, served as TLEs rather than as positions,
and propagated in the browser with SGP4.

Sending elements is the same trick `flights/plan.ts` plays and it has a
better excuse here — a TLE *is* the closed form, valid for days either
side of its epoch, so one cacheable fetch every six hours replaces a poll
and every viewer agrees about where everything is.

Two things are worth knowing about the shape of it:

  - There is no region parameter. An aeroplane at 10,000 m is local and
    a satellite at 550 km is above the horizon for a circle two thousand
    kilometres across, so one catalogue serves both boards and the client
    decides what is above its own horizon. Only the observer is per-city,
    which is why `main.ts` shares the elements and rebuilds the catalogue.
  - The layer draws on a dome, because it cannot draw anywhere else.
    `world.metres(550_000)` is 21,000 scene units against a far plane at
    3,000. Azimuth and elevation are real; the radius carries nothing.

Off by default: a clone that started pulling CelesTrak on `npm run dev`
would have volunteered somebody else's bandwidth for its onboarding.

Godmode gets the two dials that point at the sky rather than at the
light — fabricated traffic, which composes with a live ADS-B feed instead
of replacing it, and a switch for the satellite layer with a count beside
it. Both are god-only lies about the inputs, in the manner of the weather
override.

`satellite.js` is the second runtime dependency this package has taken.
Its entry point star-exports an Emscripten build that cannot be shaken
out, so `noWasmPropagator` in the Vite config cuts it: 308 kB of WASM
loader for a bulk propagator nothing calls, against 26 kB for the SGP4
that does the work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:57:14 -07:00
karti 3e9b97ed8b Signing in puts people in the building
`member` and `anon` were told apart inside the office by the client and by
nothing else. `access.ts` picked an `officeDepth`, `createOfficeScene` built a
presence layer at full depth — and then nobody ever called `setPresence`, so
both tiers rendered the identical empty room. A tier that changes nothing you
can see is not a tier, and `routes/markers.ts` had already written down why one
drawn only in the browser is worse than none: it is a UI hiding a control over a
body the API hands to whoever asks.

So the refusal happens on the server now.

`GET /api/v1/offices/:id/presence` is the one route that always takes a session,
whatever else the deployment is configured for. `markers.ts` serves its feed to
anonymous callers when no feed is configured, on the grounds that there is
nothing there to protect; that reasoning does not transfer, and the difference is
the whole point — a marker is a company at an address and a presence is a person
at a desk.

The ordering inside the handler is the security property, not a detail. It
resolves the viewer *before* it looks at the id, so an anonymous caller gets an
identical 401 for a real office, a private one and one that was never created.
Check the office first and 404-for-unknown against 401-for-known tells them apart
perfectly, which is the enumeration oracle CONTRACT.md §6 forbids, wearing a
different status code. Three requests and one `deepEqual` hold that down.

`TERA_PRESENCE_DIR` is a second directory rather than a `people` field on the
pack, and that is the design. `types.ts` says a `Presence` binds to a `seatId`
and never to a coordinate so the geometry can be published while the people
cannot — which buys nothing if both live in one file, because an operator who
wants a public floorplan then has to strip the roster out by hand, and the first
time they forget the leak is permanent. Two directories makes the safe thing the
default thing. An office with no roster is 200 and empty, never 404: "no such
office" and "nobody has told me who is in this one" are different problems with
different fixes, and one 404 sends an operator after the wrong one.

On the client, occupancy arrives after the room is on screen rather than before —
the building is worth looking at while a second request is in flight. An API that
answers is believed, including when it answers with nobody; an office where
everyone has gone home is a real fact and overwriting it with invented people to
liven up the demo is the one thing this must never do. An API that does not
answer falls back to a fabricated roster, exactly as the markers do, because a
clone with no server is the flagship case and a member shown the same empty room
as a stranger has been told the tier means something when it does not.

Those twenty-five people are invented and the page says so. `sample.ts` says it
to a reader of the source; `#office-badge` now says "Sample occupancy — these
people are invented" to the person looking at the room, and it is not suppressed
when a real deployment's API merely happened to be down — that is exactly the
case where a member would otherwise read invented names as their colleagues.
Fabricated names at real desks look like a staff list, and a screenshot of one
must not be possible to take without the caption.

The floor plan marks the occupied desks, one colour for everybody where the
scene has four: at three device pixels a hue is a guess. The plan answers "is
anyone there" and the room answers "who, and what are they doing". Hovering a
desk names them, and the readout reads as an address getting more specific —
metres, then room, then person.

server: 127 tests pass, 11 of them new. Client typechecks and builds; the office
chunk absorbed the plan renderer and the entry chunk moved 2.3 kB for the sample
roster. Checked in the browser at office.lumbridgecorp.com: FULL VIEW, the badge,
figures at the benches, dots on the plan, and "3.7, 16.7 m · Alcatraz ·
Clementine Roux" under the pointer.
2026-08-06 03:46:01 -07:00
karti 1db868ad70 The sky is not something an account grants you
`liveData` was one flag meaning two unrelated things, and it was set to
`tier !== "anon"`. That reasoning does not survive asking what the data actually
is. Cloud cover over San Francisco is a reading from a government sensor. The
aircraft are broadcasting their positions, unencrypted, to anyone within range
who owns a forty-dollar receiver. Neither is withheld from anybody by anybody,
so neither is a thing an account can grant access to — and putting them behind a
sign-in cost the only moment that makes this project land: real fog rolling off
the Pacific onto a city you recognise, at the real time of day, on a first
visit. On an SSO-gated deployment it cost that moment for every visitor there
currently is.

So `liveData` splits. `liveEnvironment` is public and unconditional.
`liveMarkers` is asked for by everyone and granted by the server, because the
marker set is the one feed here that can carry something private — a company's
pipeline, a person's job search — and whether it is public is a property of the
deployment, not of a file in this repo.

`TERA_MARKERS_ACCESS` is therefore a server switch and its default is `members`,
which is the safe answer rather than the common one. The failure mode of getting
this wrong is silent: nothing throws, nothing looks broken, the data is simply
readable by the internet. An operator who wires real markers up gets the shut
door without having chosen it and has to say `public` out loud — and saying it
appends a line to `degraded[]`, so `/api/v1/health` reports that this box is
publishing its map without anyone having to go and read the env file. Same
reasoning as `TERA_ADMIN_SUBJECTS=*`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 03:36:12 -07:00
karti e41c90fe8d Real weather, real aircraft, a heightfield off the main thread, and instruments
Three things that were built and never connected, connected.

**The weather was already there.** `observe()` has always taken a
`WeatherObservation` and `main.ts` has always passed null, so the cloud,
precipitation, visibility and marine-layer paths in atmosphere.ts had never run
outside a test. The server already shipped NWS, met.no and Open-Meteo, all
configured off. What was actually missing was that a single TERA_ORIGIN_LAT/LNG
served one metro and lied to the other — so weather and traffic are per-region
now, derived from the city's own bounds, and the Bay Area gets its fog while
Long Beach gets its own sky. The route takes ?city= or a validated ?lat=&lng=
and refuses to become an open geocoding proxy for the planet.

**The heightfield moved to a Worker.** 2.3 s of blocked main thread at boot, and
another ~950 ms of point-in-polygon on top of it: the park mask is filled in the
worker now, and block placement samples four corners and only runs the exact
test on a cell that straddles an edge — 8 buildings differ out of 185,036.
createScene is async and takes a Stage as a consequence, and there is a
main-thread fallback because "clone it and it works" has no exception clause.

**Spaces is a chunk you fetch when you reach for the door**, not one everybody
downloads. Same for the godmode tools. The entry chunk is 722 kB rather than
772; three.js is most of what is left and splitting it is a different job.

**Godmode is an instrument panel now** rather than one slider: the date and the
season, not just the hour, so the Meeus moon and the sun's seasonal arc become
visible instead of merely correct; a weather override that says on screen when
it is lying; a frame-time and draw-call readout; and a pose editor that emits a
paste-ready Chapter block, which is the thing that makes adding New York cheap.

Two blockers the review caught:

  - Every city switch leaked 8 GPU textures — one of them a 2048x2048 shadow map
    — and ~10.5 shader programs, and deleteTexture had never been called once in
    the app's lifetime. The renderer was being built per scene; it belongs to the
    canvas, for the life of the page.
  - An upstream fetch that threw rather than returning null skipped the cache
    stamp, so the TTL — the only rate limit on outbound calls — collapsed to one
    upstream request per inbound request, and the caller got a 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 03:25:31 -07:00
karti 5bc7258753 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>
2026-08-05 22:53:30 -07:00
Karti Tripathi fe43077ff8 The same bug the camera had, still live in the default light rig
Three loose ends turned into four, because one of them was not a stale comment.

`cityDaylight()` closed its fog at 210/460 — numbers tuned when San Francisco's
230-unit board was the only one. Every caller who supplies an `Atmosphere`
overrides them, which is why this survived: the demo does, so nothing looked
wrong. A self-hoster who renders a city with no clock and no weather gets the
default path, and on the Bay Area's 1003 units that fog closes well inside the
city. It now scales to the board exactly as the camera limits do, with the old
constants preserved as the default for a caller holding a palette but no world.

The three that really were comments: socal.ts justified its `latScale` by
citing the 340-unit orbit cap and the 460-unit fog as things the engine
insisted on, and the engine stopped insisting when both became board-relative —
285 is now a choice the pack makes because its lot sizes and hill radii were
authored against it. CONTRACT.md and ARCHITECTURE.md still measured the
retention argument against a 336,864-point San Francisco that has been the
whole Bay Area for some time. And server/package.json listed five routes where
there are six.

None of these changed behaviour except the first. All of them would have gone
on quietly disagreeing with the pages that now cite them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 04:34:00 -07:00
Karti Tripathi 44c5a79424 SoCal, the whole bay, a moon, and gates that actually run
Six agents in parallel, and the two city packs independently reported the same
blocker: `focusRegions` and `coarseFactor` existed on the `City` type and
nothing implemented them. Uniform lattices would have been 2.9M points for
Southern California and 3.7M for the expanded bay. Both packs were unloadable
as written.

`buildAxis` is the answer, and it is honest about its limits: refinement is per
axis, not per rectangle, so a focus region sharpens its whole row *and* its
whole column. Two regions at opposite corners refine nearly everything between
them. Measured, not guessed — the bay went 0.53M points with one region and
1.64M with three, for detail nobody is looking at from a board this wide. One
region each, coarse factor ten, and the builds land at 3.8 s and 2.3 s.

Then three things that were only ever right because San Francisco was the only
city. `maxDistance: 340` and a 170-unit shadow box were constants tuned for a
230-unit board; the bay is 1003 units across and the camera physically could
not retreat far enough to frame it. Fog distances were scene units pinned to
the same assumption. And `minVisibilityM` defaulted to 4.5 km of honest
weather, which over ninety-four kilometres of bay correctly hides three
quarters of it — the night view was a black rectangle for a completely
reasonable reason. All three now derive from the board.

The moon is a real ephemeris and its light is a deliberate lie: 1.15, against a
physical ratio of one to four hundred thousand. What is being reproduced is
what a moonlit night looks like on a screen in a lit room.

The CI gate caught itself, which is the part worth keeping. Port 8431 was
already held by a server from an earlier session, so the boot check polled a
healthy stranger while the process it started died on EADDRINUSE. It now
refuses to run rather than pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 03:13:32 -07:00
Karti Tripathi d464459838 Spaces: the inside of the world, and a sun that is actually where it should be
Ten agents wrote this in parallel against CONTRACT.md, which exists because the
five design agents before them collided on fifteen blocking points — four files
specified twice with incompatible contents, three separate backends for one box,
and `Environment` exported twice meaning different things.

What landed: a Stage owning only the renderer and the loop, with the city and an
office as two scenes over it. They cannot share one — San Francisco is ~94 m per
scene unit with 3.6x vertical exaggeration and an office is 1 unit = 1 m — and
the city is paused rather than disposed on the way in, because rebuilding its
336,864-point heightfield costs about a second on the way back out.

Offices are data. `src/offices/lumbridge-hq.ts` is fifteen rooms and seventy-six
seats, and it is the file a self-hoster copies. Walls are a segment list with
1-D openings, so doors and windows are holes punched in a wall rather than
placed objects, and the pass that splits a wall around its openings hands the
walk-mode collider its segments for free.

The sun is real. `solar.ts` is a NOAA/Meeus implementation with no imports at
all — not even three.js — so time of day keeps working on a laptop in a field.
Verified against known values: 75.45 degrees at the June solstice in SF, 28.79
at December, sunset at 03:15Z. The first screenshot after wiring it was a black
rectangle, which turned out to be correct: it was midnight in San Francisco.

Presence binds to a seat id and never to a coordinate. The pack knows where
`eng-04` is; who is sitting in it is private data behind an API. Same shape as
the marker rule, one level in.

Two corrections to ARCHITECTURE.md are in here. Containment does not discharge
ODbL — publishing OSM-derived coordinates is Public Use of a Derivative Database
wherever the rows live, so the rule is about the geocoder (US Census, public
domain) and not the storage. And a person at a desk is not a Marker; markers are
geographic.

One contract gap surfaced only in a screenshot: two agents read `height` on a
viewpoint differently, so the establishing shot aimed at empty air fourteen
metres above the roof. It now means what the same field means for a city.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 00:11:01 -07:00