Files
STTracker/apps/server/src/http.ts
T
Lucas WintherandClaude Opus 5.5 5f7d54b805 fix(server): token actor, login CSRF, 415, cookie decoding, admin invites, production default
- API writes pass token:<name> for Bearer requests and "user" for sessions.
- X-Requested-With: sttracker on every non-Bearer write, login and register included.
- JSON-body routes reject non-JSON content types with 415.
- A malformed stt_session cookie counts as no cookie (401), not 500.
- DELETE me/tokens/:id is 404 for unknown or foreign ids; admin/invites is admin-only.
- Bun.serve development mode only with NODE_ENV=development or --hot.
- OpenAPI documents multipart fields.

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

337 lines
13 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 { authenticateAppToken, getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth";
import { AuthError, ImportError, NotFoundError, ValidationError } from "@sttracker/core/errors";
import type { Actor } from "@sttracker/core/history";
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;
/** Who writes: `token:<name>` for Bearer requests, `"user"` for sessions. Pass it to every core write. */
actor: Actor;
/** 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;
/** Non-file multipart fields, for the OpenAPI document (handlers validate them). Every multipart route also takes `file`. */
formFields?: z.ZodObject;
/** 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) {
try {
return decodeURIComponent(part.slice(i + 1).trim()) || null;
} catch {
return null; // malformed percent-encoding: treat as no cookie
}
}
}
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; actor: Actor }> {
if (mode === "none") return { user: null, sessionToken: null, actor: "user" };
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 auth = await authenticateAppToken(db, bearer, scope);
if (auth) return { user: auth.user, sessionToken: null, actor: `token:${auth.tokenName}` };
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.");
return { user, sessionToken: token, actor: "user" };
}
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" });
}
// CSRF: every non-Bearer write (login and register included) must carry this header. Browsers cannot add it
// cross-site without a CORS preflight, which the server never grants. Bearer requests are not ambient, so exempt.
const bearer = req.headers.get("authorization")?.startsWith("Bearer ") ?? false;
if (req.method !== "GET" && req.method !== "HEAD" && !bearer && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
throw new HttpError(403, "forbidden", `Browser requests that change data must send the header X-Requested-With: ${CSRF_VALUE}.`);
}
const { user, sessionToken, actor } = 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) {
const type = (req.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase();
if (type !== "application/json") throw new HttpError(415, "unsupported_media_type", "Send the body as Content-Type: application/json.");
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;
},
actor,
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);
}
};
}