Files
STTracker/apps/server/src/openapi.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

73 lines
2.8 KiB
TypeScript

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) {
const fields = r.formFields ? (toSchema(r.formFields) as { properties?: JsonSchema; required?: string[] }) : {};
const schema = {
type: "object",
properties: { file: { type: "string", format: "binary" }, ...fields.properties },
required: ["file", ...(fields.required ?? [])],
};
op.requestBody = { required: true, content: { "multipart/form-data": { schema } } };
}
(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,
};
}