Files
STTracker/apps/server/src/http.ts
T
Lucas WintherandClaude Opus 5.5 6963e297b0 feat(server): REST API, auth, sources, changes, export
createApp() serves /api/v1 from a typed route table (zod bodies and queries), with
session-cookie and Bearer app-token auth, a CSRF header for cookie writes, login/register
rate limiting, body caps, JSON error mapping, OpenAPI generation and a 404 /ingest mount.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:15:01 +01:00

317 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth";
import { AuthError, ImportError, NotFoundError, ValidationError } from "@sttracker/core/errors";
import { isUniqueViolation } from "@sttracker/core/matching";
import type { PluginRegistry } from "@sttracker/core/registry";
import type { Db } from "@sttracker/db/db";
import type { TokenScope } from "@sttracker/db/schema";
import { z } from "zod";
export type AuthMode = "none" | "session" | "token:read" | "token:write" | "any:read" | "any:write";
export type Method = "GET" | "POST" | "PATCH" | "DELETE";
export interface AppDeps {
db: Db;
registry: PluginRegistry;
secret: string;
dataDir: string;
/** When it starts with `https:`, the session cookie is `Secure`. */
publicUrl?: string;
}
export const SESSION_COOKIE = "stt_session";
export const CSRF_HEADER = "x-requested-with";
export const CSRF_VALUE = "sttracker";
export const MAX_JSON_BYTES = 1024 * 1024;
export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
const SESSION_MAX_AGE_S = 30 * 24 * 60 * 60;
export interface Ctx<B, Q> {
req: Request;
url: URL;
params: Record<string, string>;
body: B;
query: Q;
/** The authenticated user. Throws on `auth: "none"` routes, which have none. */
user: User;
/** The session token, when the request was authenticated by cookie. */
sessionToken: string | null;
/** Parsed multipart body, for `multipart: true` routes. */
form: FormData;
deps: AppDeps;
ip: string;
}
export interface RouteDef<B = any, Q = any> {
method: Method;
/** Relative to `/api/v1`, with `:param` segments. */
path: string;
summary: string;
auth: AuthMode;
body?: z.ZodType<B>;
query?: z.ZodType<Q>;
/** Parse the body as multipart form data (upload limit) instead of JSON. */
multipart?: boolean;
/** Rate limit per client IP (login, register). */
rateLimited?: boolean;
/** Only admins may call it (403 otherwise). */
admin?: boolean;
handler(ctx: Ctx<B, Q>): Promise<unknown>;
}
/** Declare a route; returns it unchanged with the handler's body and query types inferred from the schemas. */
export function route<BS extends z.ZodType = z.ZodUnknown, QS extends z.ZodType = z.ZodUnknown>(
def: Omit<RouteDef<z.output<BS>, z.output<QS>>, "body" | "query"> & { body?: BS; query?: QS },
): RouteDef {
return def as RouteDef;
}
/** Throw from a handler to answer with a specific status and error code. */
export class HttpError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
export function json(data: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8", ...headers } });
}
export function errorResponse(status: number, code: string, message: string, headers: Record<string, string> = {}): Response {
return json({ error: { code, message } }, status, headers);
}
export const noContent = (): Response => new Response(null, { status: 204 });
const AUTH_STATUS: Record<string, [number, string, string]> = {
invalid_credentials: [401, "unauthenticated", "Wrong username or password."],
unauthenticated: [401, "unauthenticated", "Sign in first."],
forbidden: [403, "forbidden", "You are not allowed to do that."],
invite_required: [400, "invite_required", "Registration needs a valid invite code."],
username_taken: [400, "username_taken", "That username is taken."],
invalid_username: [400, "invalid_username", "Usernames are 2–32 characters: lowercase letters, digits and underscores."],
};
/** Map a thrown error to the JSON error response (ruling 5). Unknown errors are logged and hidden. */
export function toErrorResponse(e: unknown): Response {
if (e instanceof HttpError) return errorResponse(e.status, e.code, e.message);
if (e instanceof z.ZodError) {
const msg = e.issues.map((i) => `${i.path.length ? i.path.join(".") : "body"}: ${i.message}`).join("; ");
return errorResponse(400, "validation", msg);
}
if (e instanceof ValidationError) return errorResponse(400, "validation", `${e.field}: ${e.reason}`);
if (e instanceof NotFoundError) return errorResponse(404, "not_found", e.message);
if (e instanceof AuthError) {
const [status, code, message] = AUTH_STATUS[e.code] ?? [403, "forbidden", "You are not allowed to do that."];
return errorResponse(status, code, message);
}
if (e instanceof ImportError) {
const status = e.code === "account_not_empty" || e.code === "conflict" ? 409 : 400;
return errorResponse(status, e.code, `Import failed: ${e.code}.`);
}
if (isUniqueViolation(e)) return errorResponse(409, "conflict", "That already exists.");
console.error("[api]", e);
return errorResponse(500, "internal", "Something went wrong on the server.");
}
export function sessionCookie(token: string, publicUrl: string | undefined): string {
const secure = publicUrl?.startsWith("https:") ? "; Secure" : "";
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE_S}${secure}`;
}
export function clearSessionCookie(publicUrl: string | undefined): string {
const secure = publicUrl?.startsWith("https:") ? "; Secure" : "";
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`;
}
function readCookie(req: Request, name: string): string | null {
const header = req.headers.get("cookie");
if (!header) return null;
for (const part of header.split(";")) {
const i = part.indexOf("=");
if (i > 0 && part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim()) || null;
}
return null;
}
/** Read a request body, failing with 413 once it exceeds `max` bytes (whatever Content-Length claims). */
async function readLimited(req: Request, max: number): Promise<Uint8Array<ArrayBuffer>> {
const tooLarge = () => new HttpError(413, "validation", `Request body is too large (max ${max / (1024 * 1024)} MB).`);
const declared = Number(req.headers.get("content-length") ?? NaN);
if (declared > max) throw tooLarge();
if (!req.body) return new Uint8Array();
const reader = req.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > max) {
await reader.cancel();
throw tooLarge();
}
chunks.push(value);
}
const out = new Uint8Array(total);
let at = 0;
for (const c of chunks) {
out.set(c, at);
at += c.byteLength;
}
return out;
}
/** Fixed-window-ish limiter: at most `limit` hits per `windowMs` per key. */
export function rateLimiter(limit: number, windowMs: number): (key: string) => boolean {
const hits = new Map<string, number[]>();
return (key) => {
const now = Date.now();
if (hits.size > 10_000) for (const [k, v] of hits) if (v.every((t) => now - t >= windowMs)) hits.delete(k);
const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
if (recent.length >= limit) {
hits.set(key, recent);
return false;
}
recent.push(now);
hits.set(key, recent);
return true;
};
}
const SCOPES: TokenScope[] = ["read", "write", "ingest"];
/** Resolve the caller for `mode`. Returns the user and the session token used, if any. */
async function authenticate(req: Request, mode: AuthMode, db: Db): Promise<{ user: User | null; sessionToken: string | null }> {
if (mode === "none") return { user: null, sessionToken: null };
const authz = req.headers.get("authorization");
const bearer = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : null;
const [kind, scopeName] = mode.split(":") as ["session" | "token" | "any", TokenScope | undefined];
if (kind !== "session" && bearer) {
const scope = scopeName!;
const user = await verifyAppToken(db, bearer, scope);
if (user) return { user, sessionToken: null };
for (const other of SCOPES) {
if (other !== scope && (await verifyAppToken(db, bearer, other))) {
throw new HttpError(403, "forbidden", `This app token lacks the '${scope}' scope.`);
}
}
throw new HttpError(401, "unauthenticated", "Invalid or revoked app token.");
}
if (kind === "token") throw new HttpError(401, "unauthenticated", "Send an app token: Authorization: Bearer <token>.");
const token = readCookie(req, SESSION_COOKIE);
const user = token ? await getSessionUser(db, token) : null;
if (!user) throw new HttpError(401, "unauthenticated", "Sign in first.");
// CSRF: browsers cannot set this header cross-site without a CORS preflight, which we never grant.
if (req.method !== "GET" && req.method !== "HEAD" && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
throw new HttpError(403, "forbidden", `Cookie-authenticated requests must send the header X-Requested-With: ${CSRF_VALUE}.`);
}
return { user, sessionToken: token };
}
interface Compiled {
def: RouteDef;
segments: string[];
}
function matchSegments(pattern: string[], parts: string[]): Record<string, string> | null {
if (pattern.length !== parts.length) return null;
const params: Record<string, string> = {};
for (let i = 0; i < pattern.length; i++) {
const p = pattern[i]!;
const v = parts[i]!;
if (p.startsWith(":")) {
if (v === "") return null;
try {
params[p.slice(1)] = decodeURIComponent(v);
} catch {
return null;
}
} else if (p !== v) return null;
}
return params;
}
/** Build the `/api/v1/*` request handler over a route table. */
export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/"): (req: Request, ip: string) => Promise<Response> {
const compiled: Compiled[] = routes.map((def) => ({ def, segments: def.path.split("/") }));
const limit = rateLimiter(10, 60_000);
return async (req, ip) => {
try {
const url = new URL(req.url);
const parts = url.pathname.slice(prefix.length).replace(/\/$/, "").split("/");
let found: { def: RouteDef; params: Record<string, string> } | null = null;
for (const c of compiled) {
if (c.def.method !== req.method) continue;
const params = matchSegments(c.segments, parts);
if (params) {
found = { def: c.def, params };
break;
}
}
if (!found) return errorResponse(404, "not_found", "No such endpoint.");
const { def, params } = found;
if (def.rateLimited && !limit(`${def.path}|${ip}`)) {
return errorResponse(429, "rate_limited", "Too many attempts. Wait a minute and try again.", { "retry-after": "60" });
}
const { user, sessionToken } = await authenticate(req, def.auth, deps.db);
if (def.admin && user?.role !== "admin") throw new HttpError(403, "forbidden", "Only an admin can do that.");
let body: unknown;
let form: FormData | undefined;
if (def.multipart) {
const type = req.headers.get("content-type") ?? "";
if (!type.startsWith("multipart/form-data")) throw new HttpError(400, "validation", "body: expected multipart/form-data");
const bytes = await readLimited(req, MAX_UPLOAD_BYTES);
try {
form = await new Response(bytes, { headers: { "content-type": type } }).formData();
} catch {
throw new HttpError(400, "validation", "body: malformed multipart form data");
}
} else if (def.body) {
const bytes = await readLimited(req, MAX_JSON_BYTES);
let raw: unknown;
if (bytes.byteLength > 0) {
try {
raw = JSON.parse(new TextDecoder().decode(bytes));
} catch {
throw new HttpError(400, "validation", "body: invalid JSON");
}
}
body = def.body.parse(raw);
}
const query = def.query ? def.query.parse(Object.fromEntries(url.searchParams)) : undefined;
const ctx: Ctx<unknown, unknown> = {
req,
url,
params,
body,
query,
get user(): User {
if (!user) throw new Error(`route ${def.method} ${def.path} has no authenticated user`);
return user;
},
sessionToken,
get form(): FormData {
if (!form) throw new Error(`route ${def.method} ${def.path} is not multipart`);
return form;
},
deps,
ip,
};
const out = await def.handler(ctx);
return out instanceof Response ? out : json(out);
} catch (e) {
return toErrorResponse(e);
}
};
}