- 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]>
73 lines
2.8 KiB
TypeScript
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,
|
|
};
|
|
}
|