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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7b0a6738dd
commit
6963e297b0
@@ -0,0 +1,316 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user