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:
Lucas Winther
2026-10-30 21:15:01 +01:00
co-authored by Claude Opus 5.5
parent 7b0a6738dd
commit 6963e297b0
19 changed files with 1599 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
import { z } from "zod";
import type { RouteDef } from "./http.ts";
type JsonSchema = Record<string, unknown>;
const toSchema = (s: z.ZodType): JsonSchema => {
const { $schema: _, ...rest } = z.toJSONSchema(s, { io: "input", unrepresentable: "any" }) as JsonSchema;
return rest;
};
const SECURITY: Record<RouteDef["auth"], { [k: string]: string[] }[]> = {
none: [],
session: [{ session: [] }],
"token:read": [{ appToken: ["read"] }],
"token:write": [{ appToken: ["write"] }],
"any:read": [{ session: [] }, { appToken: ["read"] }],
"any:write": [{ session: [] }, { appToken: ["write"] }],
};
/** OpenAPI 3.1 document generated from the route table (paths, summaries, request schemas). */
export function buildOpenApi(routes: RouteDef[], prefix = "/api/v1"): JsonSchema {
const paths: Record<string, Record<string, JsonSchema>> = {};
for (const r of routes) {
const path = `${prefix}/${r.path.replace(/:(\w+)/g, "{$1}")}`;
const parameters: JsonSchema[] = [...r.path.matchAll(/:(\w+)/g)].map((m) => ({
name: m[1],
in: "path",
required: true,
schema: { type: "string" },
}));
if (r.query) {
const q = toSchema(r.query) as { properties?: Record<string, JsonSchema>; required?: string[] };
for (const [name, schema] of Object.entries(q.properties ?? {})) {
parameters.push({ name, in: "query", required: q.required?.includes(name) ?? false, schema });
}
}
const op: JsonSchema = {
operationId: `${r.method.toLowerCase()}_${r.path.replace(/[^\w]+/g, "_")}`,
summary: r.summary,
security: SECURITY[r.auth],
responses: {
default: {
description: "Success, or an error: `{ error: { code, message } }`.",
},
},
};
if (parameters.length > 0) op.parameters = parameters;
if (r.body) op.requestBody = { required: !r.body.safeParse(undefined).success, content: { "application/json": { schema: toSchema(r.body) } } };
if (r.multipart) op.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object" } } } };
(paths[path] ??= {})[r.method.toLowerCase()] = op;
}
return {
openapi: "3.1.0",
info: { title: "STTracker API", version: "1" },
servers: [{ url: "/" }],
components: {
securitySchemes: {
session: { type: "apiKey", in: "cookie", name: "stt_session", description: "Non-GET requests also need X-Requested-With: sttracker." },
appToken: { type: "http", scheme: "bearer", description: "App token with scopes read, write or ingest." },
},
},
paths,
};
}