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
+206
View File
@@ -0,0 +1,206 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Sign in — Lumbridge Simulate</title>
<style>
/* The same stack index.html uses. No webfont, no CDN, nothing to fetch:
this page has to work on a box with no network but its own. */
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #0d1219;
font-family: ui-monospace, "SF Mono", Menlo, monospace; color: rgba(255,255,255,0.72); }
body { display: flex; align-items: center; justify-content: center; padding: 1rem; }
main { width: 20rem; }
h1 { margin: 0; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase;
color: #f2b134; }
h1 + p { margin: 0.15rem 0 1.1rem; font-size: 11px; line-height: 1.5;
color: rgba(255,255,255,0.42); }
form { display: flex; flex-direction: column; gap: 0.5rem;
background: rgba(255,255,255,0.04); border-radius: 6px; padding: 0.9rem; }
label { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase;
color: rgba(255,255,255,0.42); }
input { font: inherit; font-size: 13px; padding: 0.5rem 0.6rem; border-radius: 4px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(8,12,16,0.6);
color: rgba(255,255,255,0.9); }
input:focus { outline: none; border-color: #f2b134; }
button { font: inherit; font-size: 12px; font-weight: 600; margin-top: 0.35rem;
padding: 0.55rem 0.7rem; cursor: pointer; border: 0; border-radius: 6px;
color: #10161d; background: #f2b134; }
button:hover:enabled { background: #ffc555; }
button:disabled { opacity: 0.55; cursor: default; }
#note { min-height: 1.4rem; margin: 0.6rem 0 0; font-size: 11px; line-height: 1.4;
color: rgba(255,255,255,0.5); }
#note.bad { color: #ffb4a2; }
footer { margin-top: 0.9rem; font-size: 10px; color: rgba(255,255,255,0.25); }
</style>
</head>
<body>
<main>
<h1>Lumbridge Simulate</h1>
<p>Tera · sign in to reach a private office.</p>
<form id="form" autocomplete="on">
<label id="userLabel" for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" required
autocapitalize="none" autocorrect="off" spellcheck="false" />
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password"
required />
<button id="submit" type="submit">Sign in</button>
</form>
<p id="note" role="status" aria-live="polite"></p>
<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);
}
/**
* Where to go once the cookie is set. `?next=` is honoured only when it is
* a path on this origin — a redirect target taken from a query string is
* how a sign-in page becomes somebody else's phishing hop. A leading `//`
* is a protocol-relative URL to another host, which is exactly the case a
* `startsWith("/")` check on its own would wave through.
*/
function destination() {
const next = new URLSearchParams(location.search).get("next");
if (typeof next !== "string") return "/";
if (!next.startsWith("/") || next.startsWith("//")) return "/";
return next;
}
// 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 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 && !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
// will produce a better message than a guess made before anyone typed.
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
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",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({
username: form.username.value,
password: form.password.value,
}),
});
if (res.ok) {
// The token is in an HttpOnly cookie and this page never sees it,
// which is the point: a script that can read the session is a
// script that can walk off with it.
say("Signed in. Taking you back…");
location.replace(destination());
return;
}
form.password.value = "";
if (res.status === 429) {
const wait = Number(res.headers.get("retry-after"));
say(
Number.isFinite(wait) && wait > 0
? `Too many attempts. Try again in ${wait} seconds.`
: "Too many attempts. Try again shortly.",
true,
);
} else {
// Deliberately the server's single message: it does not distinguish
// a wrong password from a username that does not exist, and neither
// does this page.
say("Those credentials were not accepted.", true);
}
} catch {
say("Could not reach the server.", true);
} finally {
submit.disabled = false;
}
});
</script>
</body>
</html>