1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/server/src/routes/session.ts
T
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

253 lines
9.9 KiB
TypeScript

/**
* `/api/v1/session` — the only place this box issues a credential rather than
* checking one.
*
* It exists because neither committed mode lets a human sign in: `none` is open
* and `jwt` verifies a token somebody else already issued. `TERA_AUTH_MODE=password`
* fills that gap for a self-hoster with no identity provider, and it fills it by
* signing the same HS256 token `jwt` mode verifies and putting it in a cookie.
* `offices.ts` is unchanged and unaware; there is still exactly one code path
* that decides whether a private office exists for you.
*
* Three deliberate refusals to be helpful, all of them about not answering
* questions an anonymous caller should not get answers to:
*
* - A wrong username and a wrong password produce the same status, the same body
* and the same amount of work. `credentialsMatch` in `auth/password.ts` is
* where that is enforced.
* - When this box cannot sign anyone in, `POST` answers **404 with the body an
* unrouted path gets**, not "password login is disabled". Whether an operator
* configured a local account is not public information.
* - A private office still answers 404 rather than 403, before and after signing
* in. That rule lives in `offices.ts` and nothing here weakens it.
*/
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";
import type { Services } from "../services.ts";
/**
* What the client gets to know. `passwordLogin` is the field the UI gates on:
* it says a login form would do something here, which is not a secret — the
* login page is served to anyone — while `authenticated` says whether this
* particular caller needs one.
*/
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;
}
/** Byte-identical to what the not-found handler serves for a path with no route. */
const NOT_FOUND: ErrorBody = { error: "not_found", message: "No such route." };
/**
* One message for every rejected credential. It says nothing about which half
* was wrong, and the same object is sent for a username that does not exist.
*/
const REJECTED: ErrorBody = {
error: "unauthorized",
message: "Those credentials were not accepted.",
};
/**
* `unauthorized` rather than a token of its own because the error union in
* `wire.ts` is closed and deliberately short. The 429 and `Retry-After` carry
* the distinction, and a client that only reads `error` treats a rate-limited
* attempt as a failed one, which is the right thing for it to do.
*/
const RATE_LIMITED: ErrorBody = {
error: "unauthorized",
message: "Too many sign-in attempts. Try again shortly.",
};
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 —
// which is what the tests are — cannot share a limiter.
const limiter = createLimiter(
auth.passwordLogin?.rateAttempts ?? 8,
auth.passwordLogin?.rateWindowSeconds ?? 300,
);
app.get("/api/v1/session", async (req) => {
const viewer = await services.auth.resolve(req);
return body(viewer, auth.passwordLogin !== null);
});
app.post("/api/v1/session", async (req, reply) => {
const login = auth.passwordLogin;
if (login === null) return reply.code(404).send(NOT_FOUND);
const retryAfter = limiter.check(req.ip);
if (retryAfter !== null) {
// Before reading the body and before hashing anything: the point of the
// limit is that a flood of attempts costs the box nothing.
return reply.code(429).header("retry-after", String(retryAfter)).send(RATE_LIMITED);
}
const supplied = credentials(req.body);
if (supplied === null) {
// A malformed body is a failed attempt, not a 400. Answering differently
// would let an attacker probe the endpoint without spending attempts.
limiter.fail(req.ip);
return reply.code(401).send(REJECTED);
}
const ok = await credentialsMatch(
login.username,
login.hash,
supplied.username,
supplied.password,
);
if (!ok) {
limiter.fail(req.ip);
return reply.code(401).send(REJECTED);
}
limiter.succeed(req.ip);
const token = issueSessionToken(auth, login.username, login.sessionTtlSeconds);
reply.header("set-cookie", sessionCookie(auth, token, login.sessionTtlSeconds));
// 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
// never issued the cookie. Refusing to clear a cookie tells the caller
// 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(SIGNED_OUT, auth.passwordLogin !== null);
});
}
/**
* 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,
};
}
/**
* Read the two fields, or decide there was nothing to read.
*
* Length caps are here rather than at the hash: a request body is up to a
* megabyte and there is no reason to hand any of it to a key-derivation
* function on an unauthenticated route.
*/
function credentials(raw: unknown): { username: string; password: string } | null {
if (raw === null || typeof raw !== "object") return null;
const { username, password } = raw as { username?: unknown; password?: unknown };
if (typeof username !== "string" || typeof password !== "string") return null;
if (username === "" || password === "") return null;
if (username.length > MAX_USERNAME_LENGTH || password.length > MAX_PASSWORD_LENGTH) return null;
return { username, password };
}
// ---- Rate limiting --------------------------------------------------------
interface Bucket {
failures: number;
windowEndsAt: number;
}
/**
* A fixed window of failures per client address, held in memory.
*
* In memory because there is one process and one box (CONTRACT.md §5) and a
* Redis to lose is worse than a limiter that resets on deploy. Keyed on
* `req.ip`, which is the real client because Caddy is the only thing that can
* reach the socket and `trustProxy` is on — nothing else can forge the header.
*
* Only failures count. A working session should not be able to lock its own
* owner out by reloading, and an attacker who is already succeeding is not
* someone a rate limit is going to help with.
*/
function createLimiter(attempts: number, windowSeconds: number) {
const buckets = new Map<string, Bucket>();
const limit = Math.max(1, Math.floor(attempts));
const windowMs = Math.max(1, Math.floor(windowSeconds)) * 1000;
/** Bounded so a flood from many addresses cannot grow the map without limit. */
const MAX_TRACKED = 4096;
function sweep(now: number): void {
for (const [key, bucket] of buckets) {
if (bucket.windowEndsAt <= now) buckets.delete(key);
}
// Still full after dropping the expired ones: that is a flood rather than
// accumulation, and clearing is the right trade. The worst case is that some
// attackers get their attempts back; the alternative is unbounded memory.
if (buckets.size >= MAX_TRACKED) buckets.clear();
}
return {
/** Seconds the caller should wait, or `null` when it may try now. */
check(ip: string): number | null {
const now = Date.now();
const bucket = buckets.get(ip);
if (bucket === undefined || bucket.windowEndsAt <= now) return null;
if (bucket.failures < limit) return null;
return Math.max(1, Math.ceil((bucket.windowEndsAt - now) / 1000));
},
fail(ip: string): void {
const now = Date.now();
if (buckets.size >= MAX_TRACKED) sweep(now);
const bucket = buckets.get(ip);
if (bucket === undefined || bucket.windowEndsAt <= now) {
buckets.set(ip, { failures: 1, windowEndsAt: now + windowMs });
return;
}
bucket.failures += 1;
},
succeed(ip: string): void {
buckets.delete(ip);
},
};
}