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>
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* `/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,
|
||||
issueSessionToken,
|
||||
sessionCookie,
|
||||
} 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;
|
||||
/** 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;
|
||||
|
||||
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.authenticated, viewer.subject, 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));
|
||||
return body(true, 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(false, null, auth.passwordLogin !== null);
|
||||
});
|
||||
}
|
||||
|
||||
function body(authenticated: boolean, subject: string | null, passwordLogin: boolean): SessionBody {
|
||||
return { authenticated, subject, 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user