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 { req: Request; url: URL; params: Record; body: B; query: Q; /** The authenticated user. Throws on `auth: "none"` routes, which have none. */ user: User; /** Who writes: `token:` 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 { method: Method; /** Relative to `/api/v1`, with `:param` segments. */ path: string; summary: string; auth: AuthMode; body?: z.ZodType; query?: z.ZodType; /** 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): Promise; } /** Declare a route; returns it unchanged with the handler's body and query types inferred from the schemas. */ export function route( def: Omit, z.output>, "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 = {}): 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 = {}): Response { return json({ error: { code, message } }, status, headers); } export const noContent = (): Response => new Response(null, { status: 204 }); const AUTH_STATUS: Record = { 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> { 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(); 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 ."); 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 | null { if (pattern.length !== parts.length) return null; const params: Record = {}; 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 { 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 } | 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 = { 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); } }; }