From 47782b3ccc86f406bc727eaba0e2cfba6be48727 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Thu, 10 Sep 2026 15:11:57 -0400 Subject: [PATCH] feat(api): publish validated OpenAPI contract --- README.md | 6 + apps/web/next.config.ts | 2 + apps/web/package.json | 8 +- .../src/app/api/admin/whoami/route.test.ts | 8 +- .../src/app/api/suggestions/openapi.test.ts | 66 + .../web/src/app/api/suggestions/route.test.ts | 27 +- .../velocity/access/route.schedule.test.ts | 8 +- .../src/app/api/velocity/access/route.test.ts | 15 +- .../app/api/velocity/connection/route.test.ts | 16 +- apps/web/src/app/openapi.yaml/route.ts | 13 + apps/web/src/lib/openapi-docs.test.ts | 15 + apps/web/src/lib/openapi-serving.test.ts | 11 + apps/web/src/lib/openapi.test.ts | 73 + apps/web/src/test/openapi-contract.ts | 51 + apps/web/src/test/openapi-standalone.test.ts | 73 + docs/admin-api-authentication.md | 70 +- docs/openapi.md | 55 + openapi.yaml | 1704 +++++++++++++++++ package-lock.json | 211 +- 19 files changed, 2420 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/app/api/suggestions/openapi.test.ts create mode 100644 apps/web/src/app/openapi.yaml/route.ts create mode 100644 apps/web/src/lib/openapi-docs.test.ts create mode 100644 apps/web/src/lib/openapi-serving.test.ts create mode 100644 apps/web/src/lib/openapi.test.ts create mode 100644 apps/web/src/test/openapi-contract.ts create mode 100644 apps/web/src/test/openapi-standalone.test.ts create mode 100644 docs/openapi.md create mode 100644 openapi.yaml diff --git a/README.md b/README.md index c54d72a..4b7cd8a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,12 @@ Open `http://localhost:3000`. Administrators can browse the configured Discord forum at `/admin/suggestions` or use the same session-protected [suggestions API](docs/admin-suggestions-api.md). The portal includes active/archive browsing, original posts, reactions, and paginated discussion. Set `DISCORD_SUGGESTIONS_FORUM_ID` through GitOps; the existing bot token stays server-side. This integration is read-only and does not synchronize data into the database. +## Application API contract + +The canonical [OpenAPI 3.1](openapi.yaml) contract is publicly served as plain YAML at `/openapi.yaml` (locally: ). It covers administrator identity, all three suggestions reads and the two Velocity integrations, including method rejection and implicit HEAD behavior. NextAuth internals and browser server actions are explicitly excluded; no interactive UI is installed. + +Admin reads accept an administrator session **or** an authorized Keycloak machine JWT. Velocity requires its **separate shared server secret**, not a machine JWT. See [authentication and safe client-credentials usage](docs/admin-api-authentication.md) and [contract maintenance/packaging](docs/openapi.md). Production: (publication requires a release). + ## Product design Implemented and proposed behavior is tracked in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/minecraft-account-manager/index.md). Validate canonical knowledge in that repository with `okflint validate --manifest okf-base.yaml`; source builds do not require wiki access. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 17188b2..1f1a8e5 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -15,6 +15,8 @@ const contentSecurityPolicy = [ const nextConfig: NextConfig = { output: "standalone", + // Keep the canonical source available in standalone/container output as well. + outputFileTracingIncludes: { "/openapi.yaml": ["../../openapi.yaml"] }, poweredByHeader: false, async headers() { return [ diff --git a/apps/web/package.json b/apps/web/package.json index 23ae517..b08ea1b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,8 @@ "build": "next build", "start": "next start", "test": "vitest run", + "openapi:validate": "vitest run src/lib/openapi.test.ts", + "openapi:standalone": "OPENAPI_STANDALONE_TEST=1 vitest run src/test/openapi-standalone.test.ts", "lint": "eslint .", "typecheck": "tsc --noEmit" }, @@ -30,6 +32,7 @@ "world-atlas": "^2.0.2" }, "devDependencies": { + "@apidevtools/swagger-parser": "^12.1.0", "@tailwindcss/postcss": "^4.2.1", "@testing-library/react": "^16.3.2", "@types/d3-geo": "^3.1.1", @@ -38,11 +41,14 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/topojson-client": "^3.1.5", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "eslint": "^9.39.4", "eslint-config-next": "^16.3.4", "jsdom": "^30.0.1", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", - "vitest": "^4.1.0" + "vitest": "^4.1.0", + "yaml": "^2.9.0" } } diff --git a/apps/web/src/app/api/admin/whoami/route.test.ts b/apps/web/src/app/api/admin/whoami/route.test.ts index cdcadb5..d062880 100644 --- a/apps/web/src/app/api/admin/whoami/route.test.ts +++ b/apps/web/src/app/api/admin/whoami/route.test.ts @@ -21,7 +21,13 @@ beforeEach(() => { }); afterEach(() => { auth.session.mockReset(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); }); -import { GET as get } from "./route"; +import { assertResponse } from "@/test/openapi-contract"; +import { GET } from "./route"; +async function get(request: Request) { + const response = await GET(request); + await assertResponse("/api/admin/whoami", "get", response); + return response; +} function request(authorization?: string) { return new Request("https://portal.example/api/admin/whoami", { headers: authorization === undefined ? {} : { authorization } }); } diff --git a/apps/web/src/app/api/suggestions/openapi.test.ts b/apps/web/src/app/api/suggestions/openapi.test.ts new file mode 100644 index 0000000..44d139c --- /dev/null +++ b/apps/web/src/app/api/suggestions/openapi.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { assertResponse } from "@/test/openapi-contract"; +const auth = vi.hoisted(() => ({ session: vi.fn() })); +vi.mock("next-auth", () => ({ getServerSession: auth.session })); +vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" })); +import * as list from "./route"; +import * as detail from "./[id]/route"; +import * as messages from "./[id]/messages/route"; +const guild = "100000000000000001", forum = "100000000000000002", id = "100000000000000009"; +const thread = { id, parent_id: forum, guild_id: guild, type: 11, name: "A garden", owner_id: "100000000000000003", applied_tags: [], message_count: 2, thread_metadata: { archived: false, locked: false, archive_timestamp: "2026-09-10T00:00:00.123456+00:00" } }; +const message = { id, content: "", timestamp: "2026-09-10T00:00:00Z", edited_timestamp: null, author: { id: "100000000000000003", username: "Example" }, reactions: [{ emoji: { name: "👍" }, count: 1 }] }; +const context = { params: Promise.resolve({ id }) }; +const fetcher = vi.fn(); +let generation = 0; +beforeEach(() => { + auth.session.mockResolvedValue({ user: { roles: ["ops"] } }); + vi.stubEnv("DISCORD_BOT_TOKEN", `synthetic-fixture-${++generation}`); + vi.stubEnv("DISCORD_GUILD_ID", guild); + vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", forum); + vi.stubGlobal("fetch", fetcher); + fetcher.mockImplementation(async (url: string) => { + if (url.endsWith(`/channels/${forum}`)) return Response.json({ id: forum, guild_id: guild, type: 15, available_tags: [] }); + if (url.includes("/threads/archived/public")) return Response.json({ threads: [thread], has_more: true }); + if (url.includes("/threads/active")) return Response.json({ threads: [thread] }); + if (url.endsWith(`/channels/${id}`)) return Response.json(thread); + if (url.endsWith(`/messages/${id}`)) return Response.json(message); + if (url.includes("/messages?")) return Response.json([message]); + throw new Error("Unexpected fixture request"); + }); +}); +afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); +const routes = [ + { path: "/api/suggestions", route: list }, + { path: "/api/suggestions/{id}", route: detail }, + { path: "/api/suggestions/{id}/messages", route: messages }, +]; +it.each(routes)("validates populated $path response against its canonical schema", async ({ path, route }) => { + const response = await route.GET(new Request(`https://portal.example${path.replace("{id}", id)}?limit=1`), context); + expect(response.status).toBe(200); + await assertResponse(path, "get", response); +}); +it("documents the nullable deleted starter and precise archived cursor", async () => { + fetcher.mockImplementationOnce(async () => Response.json({ id: forum, guild_id: guild, type: 15, available_tags: [] })) + .mockImplementationOnce(async () => Response.json(thread)) + .mockImplementationOnce(async () => new Response(null, { status: 404 })); + const response = await detail.GET(new Request(`https://portal.example/api/suggestions/${id}`), context); + await assertResponse("/api/suggestions/{id}", "get", response); + expect((await response.json()).originalPost).toBeNull(); + const archived = await list.GET(new Request("https://portal.example/api/suggestions?status=archived&limit=1")); + await assertResponse("/api/suggestions", "get", archived); + expect((await archived.json()).nextCursor).toBe("2026-09-10T00:00:00.123456Z"); +}); +it.each(routes)("documents Retry-After on $path upstream rate limits", async ({ path, route }) => { + fetcher.mockImplementation(async () => Response.json({ retry_after: 2.1 }, { status: 429 })); + const response = await route.GET(new Request(`https://portal.example${path.replace("{id}", id)}`), context); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).toBe("3"); + await assertResponse(path, "get", response); +}); +it.each(routes)("documents every explicitly rejected write method for $path", async ({ path, route }) => { + for (const method of ["POST", "PUT", "PATCH", "DELETE", "OPTIONS"] as const) { + const response = await route[method](new Request(`https://portal.example${path.replace("{id}", id)}`, { method })); + expect(response.status).toBe(405); + await assertResponse(path, method, response); + } +}); diff --git a/apps/web/src/app/api/suggestions/route.test.ts b/apps/web/src/app/api/suggestions/route.test.ts index 5a7f8db..7117e02 100644 --- a/apps/web/src/app/api/suggestions/route.test.ts +++ b/apps/web/src/app/api/suggestions/route.test.ts @@ -2,9 +2,30 @@ import { afterEach, expect, it, vi } from "vitest"; const auth = vi.hoisted(() => ({ session: vi.fn() })); vi.mock("next-auth", () => ({ getServerSession: auth.session })); vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" })); -import { GET, POST } from "./route"; -import { GET as detail } from "./[id]/route"; -import { GET as messages } from "./[id]/messages/route"; +import { assertResponse } from "@/test/openapi-contract"; +import { GET as routeGet, POST as routePost } from "./route"; +import { GET as routeDetail } from "./[id]/route"; +import { GET as routeMessages } from "./[id]/messages/route"; +async function GET(request: Request) { + const response = await routeGet(request); + await assertResponse("/api/suggestions", "get", response); + return response; +} +async function POST(request: Request) { + const response = await routePost(request); + await assertResponse("/api/suggestions", "post", response); + return response; +} +async function detail(request: Request, context: Parameters[1]) { + const response = await routeDetail(request, context); + await assertResponse("/api/suggestions/{id}", "get", response); + return response; +} +async function messages(request: Request, context: Parameters[1]) { + const response = await routeMessages(request, context); + await assertResponse("/api/suggestions/{id}/messages", "get", response); + return response; +} const context = { params: Promise.resolve({ id: "100000000000000009" }) }; const request = () => new Request("https://portal.example/api/suggestions"); diff --git a/apps/web/src/app/api/velocity/access/route.schedule.test.ts b/apps/web/src/app/api/velocity/access/route.schedule.test.ts index a0b0f85..e295c28 100644 --- a/apps/web/src/app/api/velocity/access/route.schedule.test.ts +++ b/apps/web/src/app/api/velocity/access/route.schedule.test.ts @@ -47,7 +47,13 @@ vi.mock("@/lib/ip-intelligence", () => ({ vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } })); -import { POST } from "./route"; +import { assertResponse } from "@/test/openapi-contract"; +import { POST as routePost } from "./route"; +async function POST(request: Request) { + const response = await routePost(request); + await assertResponse("/api/velocity/access", "post", response); + return response; +} const messages = { registrationMessage: "Register {player} in {group}.", diff --git a/apps/web/src/app/api/velocity/access/route.test.ts b/apps/web/src/app/api/velocity/access/route.test.ts index b395e9d..283af25 100644 --- a/apps/web/src/app/api/velocity/access/route.test.ts +++ b/apps/web/src/app/api/velocity/access/route.test.ts @@ -1,9 +1,20 @@ import { describe, expect, it } from "vitest"; -import { GET, POST } from "./route"; +import { assertResponse } from "@/test/openapi-contract"; +import { GET as routeGet, POST as routePost } from "./route"; +async function GET(request: Request) { + const response = routeGet(request); + await assertResponse("/api/velocity/access", "get", response); + return response; +} +async function POST(request: Request) { + const response = await routePost(request); + await assertResponse("/api/velocity/access", "post", response); + return response; +} describe("Velocity access API problems", () => { it("returns RFC 9457 for unsupported methods", async () => { - const response = GET(new Request("http://localhost/api/velocity/access")); + const response = await GET(new Request("http://localhost/api/velocity/access")); expect(response.status).toBe(405); expect(response.headers.get("allow")).toBe("POST"); diff --git a/apps/web/src/app/api/velocity/connection/route.test.ts b/apps/web/src/app/api/velocity/connection/route.test.ts index 9740d15..0d9c673 100644 --- a/apps/web/src/app/api/velocity/connection/route.test.ts +++ b/apps/web/src/app/api/velocity/connection/route.test.ts @@ -1,3 +1,4 @@ +import { assertResponse } from "@/test/openapi-contract"; import { hashToken } from "@minecraft-account-manager/auth"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -38,7 +39,18 @@ vi.mock("@/lib/database", () => ({ }, })); -import { GET, POST } from "./route"; +import { GET as routeGet, POST as routePost } from "./route"; + +async function GET(request: Request) { + const response = routeGet(request); + await assertResponse("/api/velocity/connection", "get", response); + return response; +} +async function POST(request: Request) { + const response = await routePost(request); + await assertResponse("/api/velocity/connection", "post", response); + return response; +} function validRequest(overrides: Record = {}) { return new Request("http://localhost/api/velocity/connection", { @@ -64,7 +76,7 @@ describe("Velocity connection reporting endpoint", () => { }); it("rejects methods other than POST with Problem Details", async () => { - const response = GET(new Request("http://localhost/api/velocity/connection")); + const response = await GET(new Request("http://localhost/api/velocity/connection")); expect(response.status).toBe(405); expect(response.headers.get("content-type")).toContain("application/problem+json"); expect(response.headers.get("allow")).toBe("POST"); diff --git a/apps/web/src/app/openapi.yaml/route.ts b/apps/web/src/app/openapi.yaml/route.ts new file mode 100644 index 0000000..adc32bc --- /dev/null +++ b/apps/web/src/app/openapi.yaml/route.ts @@ -0,0 +1,13 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +export const runtime = "nodejs"; +export const dynamic = "force-static"; + +/** Build-time snapshot of the single source; no YAML parsing or rewriting. */ +export async function GET() { + const specification = await readFile(resolve(process.cwd(), "../../openapi.yaml")); + return new Response(specification, { + headers: { "content-type": "application/yaml; charset=utf-8" }, + }); +} diff --git a/apps/web/src/lib/openapi-docs.test.ts b/apps/web/src/lib/openapi-docs.test.ts new file mode 100644 index 0000000..900262a --- /dev/null +++ b/apps/web/src/lib/openapi-docs.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { expect, it } from "vitest"; + +it("links the public contract and documents safe client-credentials usage", () => { + const read = (path: string) => readFileSync(resolve(process.cwd(), "../..", path), "utf8"); + expect(read("README.md")).toContain("[OpenAPI 3.1](openapi.yaml)"); + expect(read("README.md")).toContain("/openapi.yaml"); + const docs = read("docs/admin-api-authentication.md"); + expect(docs).toContain("/protocol/openid-connect/token"); + expect(docs).toContain('"grant_type": "client_credentials"'); + expect(docs).toContain("getpass.getpass"); + expect(docs).toContain("No helper is installed"); + expect(docs).toContain("Do not enable shell tracing"); +}); diff --git a/apps/web/src/lib/openapi-serving.test.ts b/apps/web/src/lib/openapi-serving.test.ts new file mode 100644 index 0000000..fea3ad1 --- /dev/null +++ b/apps/web/src/lib/openapi-serving.test.ts @@ -0,0 +1,11 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { expect, it } from "vitest"; +import { GET } from "../app/openapi.yaml/route"; + +it("serves the canonical YAML bytes publicly, without rewriting or authentication", async () => { + const response = await GET(); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/yaml; charset=utf-8"); + expect(Buffer.from(await response.arrayBuffer())).toEqual(readFileSync(resolve(process.cwd(), "../../openapi.yaml"))); +}); diff --git a/apps/web/src/lib/openapi.test.ts b/apps/web/src/lib/openapi.test.ts new file mode 100644 index 0000000..5bca6e5 --- /dev/null +++ b/apps/web/src/lib/openapi.test.ts @@ -0,0 +1,73 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { resolve, relative } from "node:path"; +import SwaggerParser from "@apidevtools/swagger-parser"; +import { parse } from "yaml"; +import { assert, expect, it } from "vitest"; +import { velocityAccessRequestSchema, velocityConnectionRequestSchema } from "@minecraft-account-manager/contracts"; +import { assertResponse, assertSchema, loadContract } from "@/test/openapi-contract"; + +const root = resolve(process.cwd(), "../.."); +const canonical = resolve(root, "openapi.yaml"); +it("publishes a valid OpenAPI 3.1 contract covering every application API", async () => { + expect(existsSync(canonical), "root openapi.yaml must exist").toBe(true); + const doc = parse(readFileSync(canonical, "utf8")); + expect(doc.openapi).toBe("3.1.0"); + await SwaggerParser.validate(structuredClone(doc)); + const api = resolve(process.cwd(), "src/app/api"); + const routes = readdirSync(api, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name === "route.ts") + .map((entry) => relative(api, resolve(entry.parentPath, entry.name)).replace(/\/route\.ts$/, "").replace(/\[([^\]]+)\]/g, "{$1}")) + .filter((path) => !["route.ts", "{...path}", "auth/{...nextauth}"].includes(path)) + .map((path) => `/api/${path}`); + expect(Object.keys(doc.paths).filter((path) => path.startsWith("/api/")).sort()).toEqual(routes.sort()); + for (const path of routes) { + const source = readFileSync(resolve(api, path.slice(5).replace(/\{([^}]+)\}/g, "[$1]"), "route.ts"), "utf8"); + const methods = [...source.matchAll(/export (?:async )?(?:function|const) (GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g)].map((match) => match[1]!.toLowerCase()); + for (const method of methods) expect(doc.paths[path][method], `${method} ${path} must be documented`).toBeDefined(); + if (methods.includes("get")) expect(doc.paths[path].head).toBeDefined(); + } + const ids = new Set(); + for (const [path, item] of Object.entries(doc.paths) as [string, Record>][]) { + for (const [method, operation] of Object.entries(item)) { + if (!["get", "head", "post", "put", "patch", "delete", "options"].includes(method)) continue; + expect(operation.summary, `${method} ${path}`).toBeTruthy(); + expect(operation.description).toBeTruthy(); + expect(ids.has(operation.operationId as string)).toBe(false); + ids.add(operation.operationId as string); + expect(operation.operationId).toBeTruthy(); + if (path.startsWith("/api/suggestions") || path === "/api/admin/whoami") { + expect(operation.security).toEqual([{ AdminSession: [] }, { AdminBearer: [] }]); + } else if (path.startsWith("/api/velocity") && method === "post") { + expect(operation.security).toEqual([{ VelocitySecret: [] }]); + } + } + } +}); +it("validates examples as JSON Schema 2020-12 and against real Velocity request parsers", async () => { + const doc = await loadContract(); + for (const item of Object.values(doc.paths)) for (const operation of Object.values(item)) { + for (const response of Object.values(operation.responses)) { + for (const content of Object.values(response.content ?? {})) { + for (const example of Object.values(content.examples ?? {})) assertSchema(content.schema, example.value); + } + } + for (const content of Object.values(operation.requestBody?.content ?? {})) { + for (const example of Object.values(content.examples ?? {})) assertSchema(content.schema, example.value); + } + } + for (const [kind, schema] of [["access", velocityAccessRequestSchema], ["connection", velocityConnectionRequestSchema]] as const) { + const content = doc.paths[`/api/velocity/${kind}`]?.post?.requestBody?.content["application/json"]; + assert(content); + for (const example of Object.values(content.examples ?? {})) expect(schema.safeParse(example.value).success).toBe(true); + } +}); +it("contract checking rejects wrong status, media type and response data", async () => { + await expect(assertResponse("/api/admin/whoami", "get", Response.json({ authenticationMethod: "bearer", subject: null, name: null, email: null }, { headers: { "cache-control": "no-store" } }))).rejects.toThrow(); + await expect(assertResponse("/api/admin/whoami", "get", new Response("{}", { headers: { "cache-control": "no-store" } }))).rejects.toThrow(); + await expect(assertResponse("/api/admin/whoami", "get", Response.json({}, { status: 418 }))).rejects.toThrow(); + const wrongStatus = Response.json({ type: "urn:error:unauthorized", title: "Unauthorized", status: 403 }, { status: 401, headers: { "content-type": "application/problem+json", "cache-control": "no-store", "www-authenticate": 'Bearer realm="admin-api"' } }); + await expect(assertResponse("/api/admin/whoami", "get", wrongStatus)).rejects.toThrow(); +}); +it("rejects an invalid OpenAPI document (not just parseable YAML)", async () => { + await expect(SwaggerParser.validate({ openapi: "3.1.0", info: { title: "Broken", version: "1" }, paths: { "/broken": { get: { responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/Missing" } } } } } } } } } as never)).rejects.toThrow(); +}); diff --git a/apps/web/src/test/openapi-contract.ts b/apps/web/src/test/openapi-contract.ts new file mode 100644 index 0000000..8e38dfe --- /dev/null +++ b/apps/web/src/test/openapi-contract.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import SwaggerParser from "@apidevtools/swagger-parser"; +import Ajv2020 from "ajv/dist/2020"; +import addFormats from "ajv-formats"; +import { parse } from "yaml"; +import { assert, expect } from "vitest"; + +type Schema = Record; +type ResponseContract = { + headers?: Record; + content?: Record }>; +}; +type Operation = { security?: Record[]; responses: Record; requestBody?: { content: Record }> } }; +export type Contract = { paths: Record> }; +const ajv = new Ajv2020({ strict: false, allErrors: true }); +addFormats(ajv); +let contract: Promise | undefined; +export function loadContract() { + return contract ??= SwaggerParser.dereference(parse(readFileSync(resolve(process.cwd(), "../../openapi.yaml"), "utf8"))).then((doc) => doc as unknown as Contract); +} +export function assertSchema(schema: Schema, value: unknown) { + const validate = ajv.compile(schema); + expect(validate(value), JSON.stringify(validate.errors)).toBe(true); +} +/** Clone preserves the body for existing behavior assertions. Never mock handlers. */ +export async function assertResponse(path: string, method: string, response: Response) { + const doc = await loadContract(); + const operation = doc.paths[path]?.[method.toLowerCase()]; + assert(operation, `undocumented operation: ${method} ${path}`); + const expected = operation.responses[String(response.status)]; + assert(expected, `undocumented HTTP ${response.status}: ${method} ${path}`); + for (const [name, header] of Object.entries(expected.headers ?? {})) { + const value = response.headers.get(name); + if (name.toLowerCase() === "retry-after" && value === null) continue; + expect(value, `missing ${name}`).not.toBeNull(); + assertSchema(header.schema, header.schema.type === "integer" ? Number(value) : value); + } + const body = response.clone(); + if (!expected.content) { + expect(await body.text()).toBe(""); + return; + } + const mediaType = response.headers.get("content-type")?.split(";", 1)[0]; + expect(mediaType).toBeTruthy(); + const content = expected.content[mediaType!]; + assert(content, `undocumented Content-Type: ${mediaType}`); + const value = await body.json(); + assertSchema(content.schema, value); + if (mediaType === "application/problem+json") expect(value.status).toBe(response.status); +} diff --git a/apps/web/src/test/openapi-standalone.test.ts b/apps/web/src/test/openapi-standalone.test.ts new file mode 100644 index 0000000..04d790c --- /dev/null +++ b/apps/web/src/test/openapi-standalone.test.ts @@ -0,0 +1,73 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { cp, mkdtemp, readFile, rm } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { expect, it } from "vitest"; +import { assertResponse } from "./openapi-contract"; + +it.runIf(process.env.OPENAPI_STANDALONE_TEST === "1")("serves canonical bytes and implicit HEAD from isolated standalone/container layout", async () => { + const source = resolve(process.cwd(), ".next/standalone"); + const directory = await mkdtemp(resolve(tmpdir(), "portal-openapi-")); + const socket = createServer(); + socket.listen(0, "127.0.0.1"); + await once(socket, "listening"); + const port = (socket.address() as { port: number }).port; + await new Promise((done) => socket.close(() => done())); + let child: ReturnType | undefined; + try { + await cp(source, directory, { recursive: true }); + // This mirrors the existing Dockerfile: standalone plus the separate static tree. + await cp(resolve(process.cwd(), ".next/static"), resolve(directory, "apps/web/.next/static"), { recursive: true }); + const canonical = await readFile(resolve(process.cwd(), "../../openapi.yaml")); + expect(await readFile(resolve(directory, "openapi.yaml"))).toEqual(canonical); + child = spawn(process.execPath, ["apps/web/server.js"], { + cwd: directory, + env: { PATH: process.env.PATH, NODE_ENV: "production", HOSTNAME: "127.0.0.1", PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + }); + const server = child; + await new Promise((done, reject) => { + const timer = setTimeout(() => reject(new Error("Standalone startup timed out")), 20_000); + server.once("error", (error) => { clearTimeout(timer); reject(error); }); + server.once("exit", (code) => { clearTimeout(timer); reject(new Error(`Standalone exited: ${code}`)); }); + server.stdout!.on("data", (chunk: Buffer) => { + if (chunk.toString().includes("Ready")) { clearTimeout(timer); done(); } + }); + // Drain stderr, but do not forward arbitrary server output into test logs. + server.stderr!.resume(); + }); + const origin = `http://127.0.0.1:${port}`; + const response = await fetch(`${origin}/openapi.yaml`); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/yaml; charset=utf-8"); + expect(Buffer.from(await response.arrayBuffer())).toEqual(canonical); + const head = await fetch(`${origin}/openapi.yaml`, { method: "HEAD" }); + expect(head.status).toBe(200); + expect(await head.text()).toBe(""); + for (const path of ["/api/admin/whoami", "/api/suggestions", "/api/suggestions/{id}", "/api/suggestions/{id}/messages"]) { + const url = `${origin}${path.replace("{id}", "100000000000000009")}`; + for (const method of ["GET", "HEAD"]) { + const result = await fetch(url, { method, headers: { authorization: "Bearer invalid" } }); + expect(result.status).toBe(401); + await assertResponse(path, method, result); + } + } + for (const kind of ["access", "connection"]) { + const path = `/api/velocity/${kind}`; + const result = await fetch(origin + path, { method: "HEAD" }); + expect(result.status).toBe(405); + await assertResponse(path, "head", result); + } + } finally { + if (child && child.exitCode === null) { + const stopped = once(child, "exit"); + child.kill("SIGTERM"); + const timer = setTimeout(() => child?.kill("SIGKILL"), 3000); + await stopped; + clearTimeout(timer); + } + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); diff --git a/docs/admin-api-authentication.md b/docs/admin-api-authentication.md index 59bc33e..737291d 100644 --- a/docs/admin-api-authentication.md +++ b/docs/admin-api-authentication.md @@ -1,6 +1,6 @@ # Admin API authentication -Implemented for `GET /api/admin/whoami` and the read-only [suggestions API](admin-suggestions-api.md). The shared guard is `apps/web/src/lib/auth/admin-api-auth.ts`. This is not browser token login, a general admin mutation API, or the Velocity admission credential mechanism. Browser pages, privileged server actions, RCON, and Velocity authentication are unchanged. OpenAPI publication is separate work (US-026). +Implemented for `GET /api/admin/whoami` and the read-only [suggestions API](admin-suggestions-api.md). The shared guard is `apps/web/src/lib/auth/admin-api-auth.ts`. This is not browser token login, a general admin mutation API, or the Velocity admission credential mechanism. Browser pages, privileged server actions, RCON, and Velocity authentication are unchanged. The canonical [OpenAPI 3.1 contract](../openapi.yaml) is public at `/openapi.yaml`; see [contract maintenance](openapi.md). ## Credential selection @@ -23,6 +23,74 @@ One bounded, process-local remote JWKS resolver caches keys for ten minutes, coa `KEYCLOAK_CLIENT_SECRET` is not needed for machine verification. This implementation does not obtain tokens or alter identity-provider clients, role/audience mappers, credentials, or deployments. See [OIDC setup](admin-oidc-keycloak-setup.md) for the distinct browser configuration. +## Obtain and use a machine token safely + +The Keycloak token endpoint is `/protocol/openid-connect/token`. Use `grant_type=client_credentials` with a separately provisioned confidential service-account client. Its issued access token must include the **portal audience** and the **portal client's administrator role** described above; the machine client's own ID or `azp` is not a substitute. Client provisioning, credential retrieval and role/audience changes require separate operational approval. Production uses issuer `https://auth.20faces.games/realms/infra`, token endpoint `https://auth.20faces.games/realms/infra/protocol/openid-connect/token`, and portal `https://portal.somc.club`. Confirm these against current approved configuration before use. The token endpoint is owned by Keycloak, not a portal route. + +No helper is installed. This optional, one-shot Python 3 standard-library example prompts on the controlling terminal, keeps the client secret and access token in process memory, and prints only the HTTP status of whoami. It does not save or print the token or identity response. Use only on an approved trusted workstation. Do not enable shell tracing, HTTP debug logging, terminal recording, or request-body/header capture. Never paste a secret into a command, `curl -d`, an Authorization argument, environment export, chat, or a log. For automation, use an approved secret-manager/protected-file input and pass credentials directly to an HTTP library in memory rather than command arguments. + +```sh +python3 - <<'PY' +import getpass +import json +import sys +import urllib.error +import urllib.parse +import urllib.request + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + +# HTTPS certificate verification remains enabled; never follow credential redirects. +def https_url(value): + value = value.strip().rstrip("/") + url = urllib.parse.urlsplit(value) + if (url.scheme != "https" or not url.hostname or url.username is not None + or url.password is not None or url.query or url.fragment): + raise ValueError("An approved HTTPS URL is required") + return value + +try: + with open("/dev/tty", "r") as terminal: + def prompt(label): + print(label, end="", flush=True) + return terminal.readline().strip() + issuer = https_url(prompt("Approved Keycloak issuer URL: ")) + portal = https_url(prompt("Approved portal URL: ")) + client_id = prompt("Machine client ID: ") + client_secret = getpass.getpass("Machine client secret: ") + opener = urllib.request.build_opener(NoRedirect) + form = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }).encode() + token_request = urllib.request.Request( + issuer + "/protocol/openid-connect/token", data=form, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with opener.open(token_request, timeout=10) as response: + access_token = json.load(response)["access_token"] + identity_request = urllib.request.Request( + portal + "/api/admin/whoami", + headers={"Authorization": "Bearer " + access_token}, + ) + with opener.open(identity_request, timeout=10) as response: + print("whoami HTTP", response.status) + # To read suggestions, use the same in-memory header with /api/suggestions. + # Process exit releases memory; this is not a secure-memory erasure guarantee. +except urllib.error.HTTPError as error: + print("Request failed; HTTP", error.code, file=sys.stderr) + sys.exit(1) +except Exception: + print("Request failed; verify configuration and connectivity securely.", file=sys.stderr) + sys.exit(1) +PY +``` + +Token acquisition is not part of this application's implementation or offline tests. The example performs real network requests only when an operator explicitly runs it; it is not run by the source checks. A 401/403/503 from whoami has the semantics below. Do not print upstream error bodies while diagnosing issuance failures. Token requests can appear in identity-provider access logs: confirm that request bodies and Authorization headers are redacted before use. + ## Safe identity endpoint `GET /api/admin/whoami` authenticates and checks administrator permission before returning JSON with `Cache-Control: no-store`: diff --git a/docs/openapi.md b/docs/openapi.md new file mode 100644 index 0000000..0aab6b4 --- /dev/null +++ b/docs/openapi.md @@ -0,0 +1,55 @@ +# Application API contract + +[`../openapi.yaml`](../openapi.yaml) is the only maintained specification. It is OpenAPI **3.1.0**, with JSON Schema 2020-12 null types, named security schemes, reusable schemas/responses/examples and no interactive documentation UI. Download it anonymously from `/openapi.yaml` on the portal. Production is `https://portal.somc.club`, as recorded in the shared account-manager cutover guide. Local development is `http://localhost:3000`. Publication of this endpoint requires a release; these changes do not deploy it. + +## Boundaries and compatibility + +- Administrator identity and all three suggestions endpoints accept an existing administrator session **OR** a verified machine bearer token. Any Authorization header selects only bearer verification; failure never falls back to the cookie. See [client-credentials usage](admin-api-authentication.md) for the Keycloak token endpoint, required audience/client role, and safe secret handling. +- Both Velocity POST endpoints use their separately provisioned shared server secret, **not** a machine JWT or browser session. Admission denial is a normal 200 decision; a recorded connection is 204 without a body. +- NextAuth framework routes, Discord browser magic-link flows, server actions, infrastructure `/healthz` and unknown-route fallbacks are not supported integration operations in this contract. Keycloak's token endpoint is external to the portal. +- Suggestions' explicit unsupported methods authenticate first, then return RFC 9457 405 with `Allow: GET, HEAD`. Next.js generates HEAD from GET, running the same checks and suppressing the body. Velocity's explicit method rejection is unauthenticated; implicit HEAD returns bodyless 405. Framework-generated OPTIONS (Velocity/whoami) and unsupported whoami methods have no application JSON contract. +- Errors document actual status-specific `urn:error:*` types, RFC 9457 content, no-store and applicable challenge/retry/Allow headers. Nullable starter posts, profile values, cursors and edit times reflect source behavior. `Retry-After` is conditional, in whole seconds. Lists reject unknown/repeated/empty query parameters; detail ignores query parameters. Read-only upstream caching is not permission caching. +- Known existing limitation: Velocity connection credential lookup occurs before its transaction error handler. A lookup exception can yield a framework 500 without stable JSON. The specification does not pretend this is a sanitized 503; fixing that behavior is outside US-026. + +## Single-source serving and container packaging + +`apps/web/src/app/openapi.yaml/route.ts` reads the root file without YAML parsing, reserialization, authentication, or interpolation. Next.js statically snapshots those exact bytes during `next build`. Edit the root and rebuild to publish an updated contract; do not edit `.next` output or maintain a second spec under `public/`. + +`next.config.ts` explicitly traces `../../openapi.yaml` for this route so standalone output also contains the canonical source. The existing Dockerfile copies the standalone tree and static assets, which already includes the snapshot and traced source; it needs no extra copy or deployment changes. Development and `next start` work through the same route. Direct web commands must run from `apps/web` (npm workspace commands do this automatically), as with the standalone `apps/web/server.js` launcher. + +## Validation + +Run from the repository root: + +```sh +npm run openapi:validate --workspace @minecraft-account-manager/web +npm test +npm run lint +npm run typecheck +npm run build +npm run openapi:standalone --workspace @minecraft-account-manager/web +npm run velocity:build +``` + +- `@apidevtools/swagger-parser` 12 validates OpenAPI 3.1 structure and resolves references. Invalid-reference regression proves parseable but invalid YAML is rejected. Ajv 8's 2020-12 entry point plus `ajv-formats` validates actual JSON responses, status-specific errors, headers, and examples. These are development dependencies only. +- Contract coverage discovers application API route files and their explicit exported methods, requires implicit HEAD descriptions, and excludes only the stated framework/fallback files. New application routes/methods therefore require documentation. +- Existing whoami, suggestions and Velocity route suites also validate returned responses against the canonical document, without changing handler behavior. They cover real signed JWT verification, session identities, allowed/denied admission, connection success/replay/missing accounts, and safe errors. Suggestions contract tests use real handlers/Discord normalization with controlled upstream transport, populated pages, deleted starters, precise archive cursors, rate limits and all explicit rejected methods. +- Mutation regressions prove wrong response data, media type, and HTTP/body status fail validation. Request examples also run through the actual shared Velocity Zod parsers. +- The standalone smoke test is opt-in so ordinary tests do not require a pre-existing build. It copies the built standalone tree into a disposable directory outside the checkout, mirroring Docker's file layout, starts it on loopback with no production configuration, checks canonical source and served bytes, then exercises actual HTTP HEAD/GET authentication and Velocity HEAD rejection. It stops the child and removes the directory. It does not contact Keycloak, Discord or a database. Run it after every production build; a stale build is intentionally rejected. + +Offline checks do not establish live audience/role issuance, Discord permissions, production hostname correctness or an actual container image build. Deployment and publication remain separately approved operations. + +## US-026 local verification evidence + +Verified at `2026-09-10T19:10:39Z` on the uncommitted US-026 working tree based on `c2ac2ad`. No wiki edits, commits, pushes, database operations or deployments were performed. US-025 implementation behavior is unchanged. + +| Slice / command (web workspace unless noted) | Observed red | Observed green | +| --- | --- | --- | +| `npm test -- src/lib/openapi.test.ts` | Missing canonical-file assertion failed; invalid-document regression already passed. | Initial schema/coverage slice: 2 passing; expanded examples and mutation regressions: 4 passing. | +| `npm test -- src/lib/openapi-serving.test.ts` | Explicit route discovery assertion failed before adding the public handler. | Exact-byte/media-type test passed; discovery then refactored to direct import. | +| `npm test -- src/lib/openapi-docs.test.ts` | README lacked the canonical OpenAPI link. | Contract link and safe client-credentials documentation assertions passed. | +| `npm run openapi:standalone` | Against the old build, isolated packaging lacked `openapi.yaml` (ENOENT). This was a stale-artifact regression check, not a claimed pre-implementation code red. | After rebuilding: canonical traced source and HTTP response byte equality, public GET/HEAD, four admin GET/HEAD rejection paths, and two Velocity HEAD paths passed. | + +Final root `npm test`: **268 passing**, plus one intentionally skipped opt-in packaging test. The explicit standalone command passed its **one** smoke test. `npm run lint` passed with zero errors and two pre-existing warnings in unchanged `map-view-toggle.tsx`. `npm run typecheck`, `npm run build`, canonical-vs-standalone `cmp`, and `npm run velocity:build` (`clean test shadowJar`) passed. The first full run exposed strict TypeScript errors in the new test helpers; those were corrected before the successful full reruns. Next.js emitted `/openapi.yaml` as static content. Build-generated `next-env.d.ts` drift was removed. + +`npm audit`: **zero vulnerabilities**. Scoped `semgrep scan --config p/typescript --metrics=off` on the new serving route, Next config and two contract/packaging helpers: **74 rules, four files, zero findings**. This is scoped static-analysis evidence, not a complete application security audit. The documented Python snippet compiled successfully without executing it or contacting the identity provider. Verification used local Node.js `v26.7.0`; CI's declared Node.js 22 was not independently rerun. No Docker image was built; standalone isolation tests exercised the existing Dockerfile's copied runtime layout. diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..3ae926a --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,1704 @@ +openapi: 3.1.0 +info: + title: Minecraft Account Manager API + version: 0.1.0 + description: Supported application API contract. Root openapi.yaml is canonical and publicly + available at /openapi.yaml; no interactive UI. Includes identity, read-only suggestions and + Velocity integrations. Framework NextAuth routes, browser magic-link flows and server actions + are excluded, as are infrastructure health probes and unknown-route fallbacks. Admin APIs accept + session OR Keycloak bearer; any Authorization header disables session fallback. Machine tokens + require RS256, exact configured issuer, portal audience, expiry, subject and configured-client + administrator role. Obtain client_credentials tokens from + /protocol/openid-connect/token using a separately authorized + service-account client; see docs/admin-api-authentication.md for safe in-memory usage. Machine + credentials do not grant Velocity access. +servers: + - url: https://portal.somc.club + description: Production portal. + - url: http://localhost:3000 + description: Local development +tags: + - name: Identity + description: Administrator identity checks. + - name: Suggestions + description: Read-only configured Discord forum. + - name: Velocity + description: Proxy integrations using a distinct shared secret. +paths: + /api/admin/whoami: + get: + tags: + - Identity + operationId: getAdminIdentity + summary: Verify administrator identity + description: Returns only safe identity fields. No database or Discord access. Supplied + Authorization exclusively selects bearer verification. HEAD is provided by Next.js with the + same checks/status/headers and no body; other unsupported methods receive framework empty + 405, OPTIONS is framework-generated. + security: + - AdminSession: [] + - AdminBearer: [] + responses: + "200": + $ref: "#/components/responses/AdminIdentity" + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + head: + tags: + - Identity + operationId: headAdminIdentity + summary: Check administrator identity headers + description: Next.js delegates to GET, including authentication; HTTP HEAD has no response body. + security: + - AdminSession: [] + - AdminBearer: [] + responses: + "200": + $ref: "#/components/responses/ReadHead" + "401": + $ref: "#/components/responses/AdminUnauthorizedHead" + "403": + $ref: "#/components/responses/AdminForbiddenHead" + "503": + $ref: "#/components/responses/AdminAuthUnavailableHead" + /api/suggestions: + get: + tags: + - Suggestions + operationId: listSuggestions + summary: List suggestions + description: "Active posts are newest-created first; archives newest-archived first. Unknown, empty + or repeated query parameters are rejected. Threads may move between lists; this is not a + snapshot. Authorization is checked before every cached/fresh read. Process-local Discord + cache: 30 seconds, 200 entries, eight concurrent requests; responses remain no-store." + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/limit_1" + - $ref: "#/components/parameters/status_2" + - $ref: "#/components/parameters/cursor_3" + responses: + "200": + $ref: "#/components/responses/Suggestions" + "400": + $ref: "#/components/responses/InvalidSuggestionRequest" + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "404": + $ref: "#/components/responses/SuggestionNotFound" + "503": + $ref: "#/components/responses/SuggestionsUnavailable" + head: + tags: + - Suggestions + operationId: listSuggestionsHead + summary: Read headers without a body + description: Implicit Next.js HEAD runs GET with the same authentication, validation and + upstream/cache behavior, but suppresses the response body. + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/limit_1" + - $ref: "#/components/parameters/status_2" + - $ref: "#/components/parameters/cursor_3" + responses: + "200": + $ref: "#/components/responses/ReadHead" + "400": + $ref: "#/components/responses/InvalidSuggestionRequestHead" + "401": + $ref: "#/components/responses/AdminUnauthorizedHead" + "403": + $ref: "#/components/responses/AdminForbiddenHead" + "404": + $ref: "#/components/responses/SuggestionNotFoundHead" + "503": + $ref: "#/components/responses/SuggestionsUnavailableHead" + post: + tags: + - Suggestions + operationId: listSuggestionsPostRejected + summary: Reject unsupported method + description: Read-only endpoint. Authentication is checked first; authorized requests return 405, + without evaluating query/body. No mutation is supported. + security: + - AdminSession: [] + - AdminBearer: [] + responses: + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "405": + $ref: "#/components/responses/SuggestionsReadOnly" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + put: + tags: + - Suggestions + operationId: listSuggestionsPutRejected + summary: Reject unsupported method + description: Read-only endpoint. Authentication is checked first; authorized requests return 405, + without evaluating query/body. No mutation is supported. + security: + - AdminSession: [] + - AdminBearer: [] + responses: + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "405": + $ref: "#/components/responses/SuggestionsReadOnly" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + patch: + tags: + - Suggestions + operationId: listSuggestionsPatchRejected + summary: Reject unsupported method + description: Read-only endpoint. Authentication is checked first; authorized requests return 405, + without evaluating query/body. No mutation is supported. + security: + - AdminSession: [] + - AdminBearer: [] + responses: + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "405": + $ref: "#/components/responses/SuggestionsReadOnly" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + delete: + tags: + - Suggestions + operationId: listSuggestionsDeleteRejected + summary: Reject unsupported method + description: Read-only endpoint. Authentication is checked first; authorized requests return 405, + without evaluating query/body. No mutation is supported. + security: + - AdminSession: [] + - AdminBearer: [] + responses: + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "405": + $ref: "#/components/responses/SuggestionsReadOnly" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + options: + tags: + - Suggestions + operationId: listSuggestionsOptionsRejected + summary: Reject unsupported method + description: Read-only endpoint. Authentication is checked first; authorized requests return 405, + without evaluating query/body. No mutation is supported. + security: + - AdminSession: [] + - AdminBearer: [] + responses: + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "405": + $ref: "#/components/responses/SuggestionsReadOnly" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + /api/suggestions/{id}: + get: + tags: + - Suggestions + operationId: getSuggestion + summary: Get suggestion + description: "Metadata and original starter, or null when deleted. Query parameters are ignored by + this detail route. Unrelated forum threads are inaccessible. Authorization is checked before + every cached/fresh read. Process-local Discord cache: 30 seconds, 200 entries, eight + concurrent requests; responses remain no-store." + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/id_4" + responses: + "200": + $ref: "#/components/responses/SuggestionDetail" + "400": + $ref: "#/components/responses/InvalidSuggestionRequest" + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "404": + $ref: "#/components/responses/SuggestionNotFound" + "503": + $ref: "#/components/responses/SuggestionsUnavailable" + head: + tags: + - Suggestions + operationId: getSuggestionHead + summary: Read headers without a body + description: Implicit Next.js HEAD runs GET with the same authentication, validation and + upstream/cache behavior, but suppresses the response body. + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/id_4" + responses: + "200": + $ref: "#/components/responses/ReadHead" + "400": + $ref: "#/components/responses/InvalidSuggestionRequestHead" + "401": + $ref: "#/components/responses/AdminUnauthorizedHead" + "403": + $ref: "#/components/responses/AdminForbiddenHead" + "404": + $ref: "#/components/responses/SuggestionNotFoundHead" + "503": + $ref: "#/components/responses/SuggestionsUnavailableHead" + post: + tags: + - Suggestions + operationId: getSuggestionPostRejected + summary: Reject unsupported method + description: Read-only endpoint. Authentication is checked first; authorized requests return 405, + without evaluating query/body. No mutation is supported. + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/id_4" + responses: + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "405": + $ref: "#/components/responses/SuggestionsReadOnly" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + put: + tags: + - Suggestions + operationId: getSuggestionPutRejected + summary: Reject unsupported method + description: Read-only endpoint. Authentication is checked first; authorized requests return 405, + without evaluating query/body. No mutation is supported. + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/id_4" + responses: + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "405": + $ref: "#/components/responses/SuggestionsReadOnly" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + patch: + tags: + - Suggestions + operationId: getSuggestionPatchRejected + summary: Reject unsupported method + description: Read-only endpoint. Authentication is checked first; authorized requests return 405, + without evaluating query/body. No mutation is supported. + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/id_4" + responses: + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "405": + $ref: "#/components/responses/SuggestionsReadOnly" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + delete: + tags: + - Suggestions + operationId: getSuggestionDeleteRejected + summary: Reject unsupported method + description: Read-only endpoint. Authentication is checked first; authorized requests return 405, + without evaluating query/body. No mutation is supported. + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/id_4" + responses: + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "405": + $ref: "#/components/responses/SuggestionsReadOnly" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + options: + tags: + - Suggestions + operationId: getSuggestionOptionsRejected + summary: Reject unsupported method + description: Read-only endpoint. Authentication is checked first; authorized requests return 405, + without evaluating query/body. No mutation is supported. + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/id_4" + responses: + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "405": + $ref: "#/components/responses/SuggestionsReadOnly" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + /api/suggestions/{id}/messages: + get: + tags: + - Suggestions + operationId: listSuggestionMessages + summary: List suggestion messages + description: "Newest-first discussion, including the starter if reached. A final empty page is + possible. Unknown, empty or repeated query parameters are rejected. Authorization is checked + before every cached/fresh read. Process-local Discord cache: 30 seconds, 200 entries, eight + concurrent requests; responses remain no-store." + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/id_4" + - $ref: "#/components/parameters/limit_1" + - $ref: "#/components/parameters/cursor_5" + responses: + "200": + $ref: "#/components/responses/SuggestionMessages" + "400": + $ref: "#/components/responses/InvalidSuggestionRequest" + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "404": + $ref: "#/components/responses/SuggestionNotFound" + "503": + $ref: "#/components/responses/SuggestionsUnavailable" + head: + tags: + - Suggestions + operationId: listSuggestionMessagesHead + summary: Read headers without a body + description: Implicit Next.js HEAD runs GET with the same authentication, validation and + upstream/cache behavior, but suppresses the response body. + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/id_4" + - $ref: "#/components/parameters/limit_1" + - $ref: "#/components/parameters/cursor_5" + responses: + "200": + $ref: "#/components/responses/ReadHead" + "400": + $ref: "#/components/responses/InvalidSuggestionRequestHead" + "401": + $ref: "#/components/responses/AdminUnauthorizedHead" + "403": + $ref: "#/components/responses/AdminForbiddenHead" + "404": + $ref: "#/components/responses/SuggestionNotFoundHead" + "503": + $ref: "#/components/responses/SuggestionsUnavailableHead" + post: + tags: + - Suggestions + operationId: listSuggestionMessagesPostRejected + summary: Reject unsupported method + description: Read-only endpoint. Authentication is checked first; authorized requests return 405, + without evaluating query/body. No mutation is supported. + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/id_4" + responses: + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "405": + $ref: "#/components/responses/SuggestionsReadOnly" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + put: + tags: + - Suggestions + operationId: listSuggestionMessagesPutRejected + summary: Reject unsupported method + description: Read-only endpoint. Authentication is checked first; authorized requests return 405, + without evaluating query/body. No mutation is supported. + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/id_4" + responses: + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "405": + $ref: "#/components/responses/SuggestionsReadOnly" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + patch: + tags: + - Suggestions + operationId: listSuggestionMessagesPatchRejected + summary: Reject unsupported method + description: Read-only endpoint. Authentication is checked first; authorized requests return 405, + without evaluating query/body. No mutation is supported. + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/id_4" + responses: + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "405": + $ref: "#/components/responses/SuggestionsReadOnly" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + delete: + tags: + - Suggestions + operationId: listSuggestionMessagesDeleteRejected + summary: Reject unsupported method + description: Read-only endpoint. Authentication is checked first; authorized requests return 405, + without evaluating query/body. No mutation is supported. + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/id_4" + responses: + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "405": + $ref: "#/components/responses/SuggestionsReadOnly" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + options: + tags: + - Suggestions + operationId: listSuggestionMessagesOptionsRejected + summary: Reject unsupported method + description: Read-only endpoint. Authentication is checked first; authorized requests return 405, + without evaluating query/body. No mutation is supported. + security: + - AdminSession: [] + - AdminBearer: [] + parameters: + - $ref: "#/components/parameters/id_4" + responses: + "401": + $ref: "#/components/responses/AdminUnauthorized" + "403": + $ref: "#/components/responses/AdminForbidden" + "405": + $ref: "#/components/responses/SuggestionsReadOnly" + "503": + $ref: "#/components/responses/AdminAuthUnavailable" + /api/velocity/access: + post: + tags: + - Velocity + operationId: decideVelocityAccess + summary: Decide Minecraft admission + description: "Uses a separately provisioned per-server shared bearer secret (hashed in plugin + credentials), NOT a Keycloak machine JWT or browser session. The Bearer prefix is + case-sensitive. Requires fresh timestamp and unique request UUID; replay retention is five + minutes. Unknown body properties are stripped. GET/PUT/PATCH/DELETE return RFC 9457 405 with + Allow: POST; implicit HEAD uses GET without a body. OPTIONS is framework-generated." + security: + - VelocitySecret: [] + requestBody: + required: true + description: JSON payload; all documented fields required. Example timestamp is illustrative and + must be replaced with current time. + content: + application/json: + schema: + $ref: "#/components/schemas/VelocityAccessRequest" + examples: + representative: + $ref: "#/components/examples/AccessRequest" + responses: + "200": + $ref: "#/components/responses/VelocityDecision" + "400": + $ref: "#/components/responses/InvalidAccessRequest" + "401": + $ref: "#/components/responses/VelocityAccessUnauthorized" + "409": + $ref: "#/components/responses/AccessReplay" + "415": + $ref: "#/components/responses/VelocityUnsupportedMedia" + "503": + $ref: "#/components/responses/VelocityAccessUnavailable" + get: + tags: + - Velocity + operationId: rejectVelocityaccessget + summary: Reject unsupported method + description: Always returns 405 without accessing credentials or the database. + security: [] + responses: + "405": + $ref: "#/components/responses/VelocityMethodNotAllowed" + put: + tags: + - Velocity + operationId: rejectVelocityaccessput + summary: Reject unsupported method + description: Always returns 405 without accessing credentials or the database. + security: [] + responses: + "405": + $ref: "#/components/responses/VelocityMethodNotAllowed" + patch: + tags: + - Velocity + operationId: rejectVelocityaccesspatch + summary: Reject unsupported method + description: Always returns 405 without accessing credentials or the database. + security: [] + responses: + "405": + $ref: "#/components/responses/VelocityMethodNotAllowed" + delete: + tags: + - Velocity + operationId: rejectVelocityaccessdelete + summary: Reject unsupported method + description: Always returns 405 without accessing credentials or the database. + security: [] + responses: + "405": + $ref: "#/components/responses/VelocityMethodNotAllowed" + head: + tags: + - Velocity + operationId: rejectVelocityaccesshead + summary: Reject unsupported method + description: Next.js delegates HEAD to the GET rejection and suppresses its body. + security: [] + responses: + "405": + $ref: "#/components/responses/VelocityMethodNotAllowedHead" + /api/velocity/connection: + post: + tags: + - Velocity + operationId: recordVelocityConnection + summary: Record a confirmed connection + description: "Uses a separately provisioned per-server shared bearer secret (hashed in plugin + credentials), NOT a Keycloak machine JWT or browser session. The Bearer prefix is + case-sensitive. Requires fresh timestamp and unique request UUID; replay retention is five + minutes. Unknown body properties are stripped. GET/PUT/PATCH/DELETE return RFC 9457 405 with + Allow: POST; implicit HEAD uses GET without a body. OPTIONS is framework-generated." + security: + - VelocitySecret: [] + requestBody: + required: true + description: JSON payload; all documented fields required. Example timestamp is illustrative and + must be replaced with current time. + content: + application/json: + schema: + $ref: "#/components/schemas/VelocityConnectionRequest" + examples: + representative: + $ref: "#/components/examples/ConnectionRequest" + responses: + "204": + $ref: "#/components/responses/ConnectionRecorded" + "400": + $ref: "#/components/responses/InvalidConnectionRequest" + "401": + $ref: "#/components/responses/VelocityConnectionUnauthorized" + "404": + $ref: "#/components/responses/UnknownMinecraftAccount" + "409": + $ref: "#/components/responses/ConnectionReplay" + "415": + $ref: "#/components/responses/VelocityUnsupportedMedia" + "500": + $ref: "#/components/responses/FrameworkConnectionFailure" + "503": + $ref: "#/components/responses/VelocityConnectionUnavailable" + get: + tags: + - Velocity + operationId: rejectVelocityconnectionget + summary: Reject unsupported method + description: Always returns 405 without accessing credentials or the database. + security: [] + responses: + "405": + $ref: "#/components/responses/VelocityMethodNotAllowed" + put: + tags: + - Velocity + operationId: rejectVelocityconnectionput + summary: Reject unsupported method + description: Always returns 405 without accessing credentials or the database. + security: [] + responses: + "405": + $ref: "#/components/responses/VelocityMethodNotAllowed" + patch: + tags: + - Velocity + operationId: rejectVelocityconnectionpatch + summary: Reject unsupported method + description: Always returns 405 without accessing credentials or the database. + security: [] + responses: + "405": + $ref: "#/components/responses/VelocityMethodNotAllowed" + delete: + tags: + - Velocity + operationId: rejectVelocityconnectiondelete + summary: Reject unsupported method + description: Always returns 405 without accessing credentials or the database. + security: [] + responses: + "405": + $ref: "#/components/responses/VelocityMethodNotAllowed" + head: + tags: + - Velocity + operationId: rejectVelocityconnectionhead + summary: Reject unsupported method + description: Next.js delegates HEAD to the GET rejection and suppresses its body. + security: [] + responses: + "405": + $ref: "#/components/responses/VelocityMethodNotAllowedHead" +components: + securitySchemes: + AdminSession: + type: apiKey + in: cookie + name: __Secure-next-auth.session-token + description: Existing NextAuth administrator session. Local HTTP uses next-auth.session-token + instead. Player sessions do not qualify. Used only when Authorization is absent. + AdminBearer: + type: http + scheme: bearer + bearerFormat: JWT + description: Keycloak RS256 access token with configured portal aud and + resource_access[portal-client].roles permission. Never send a client secret here. + VelocitySecret: + type: http + scheme: bearer + description: Separate per-server shared secret, NOT a JWT. Send the exact case-sensitive Bearer + prefix. No session or machine-token alternative. + schemas: + Snowflake: + type: string + pattern: ^[1-9]\d{16,19}$ + description: Discord snowflake as a string, never a JSON number. Examples are synthetic. + Problem: + type: object + required: + - type + - title + - status + properties: + type: + type: string + format: uri + title: + type: string + status: + type: integer + minimum: 100 + maximum: 599 + detail: + type: string + instance: + type: string + extensions: + type: object + additionalProperties: true + properties: + issues: + type: array + items: + type: object + required: + - path + - message + - code + properties: + path: + type: string + message: + type: string + code: + type: string + description: RFC 9457. Validation issues are nested in extensions.issues, not at the top level. + Identity: + oneOf: + - type: object + required: + - authenticationMethod + - subject + - name + - email + properties: + authenticationMethod: + const: bearer + subject: + type: string + minLength: 1 + name: + type: "null" + email: + type: "null" + - type: object + required: + - authenticationMethod + - subject + - name + - email + properties: + authenticationMethod: + const: session + subject: + type: "null" + name: + type: + - string + - "null" + email: + type: + - string + - "null" + Tag: + type: object + required: + - id + - name + properties: + id: + $ref: "#/components/schemas/Snowflake" + name: + type: string + Suggestion: + type: object + required: + - id + - title + - authorId + - createdAt + - archived + - locked + - tags + - messageCount + - discordUrl + properties: + id: + $ref: "#/components/schemas/Snowflake" + title: + type: string + authorId: + $ref: "#/components/schemas/Snowflake" + createdAt: + type: string + format: date-time + archived: + type: boolean + locked: + type: boolean + tags: + type: array + items: + $ref: "#/components/schemas/Tag" + messageCount: + type: integer + description: Approximate Discord message count, not votes. + discordUrl: + type: string + format: uri + Message: + type: object + required: + - id + - author + - content + - createdAt + - editedAt + - reactions + - discordUrl + properties: + id: + $ref: "#/components/schemas/Snowflake" + author: + type: object + required: + - id + - name + properties: + id: + $ref: "#/components/schemas/Snowflake" + name: + type: string + content: + type: string + description: Literal text; may be empty due to Message Content intent. No rendered Markdown, + attachments, or embeds. + createdAt: + type: string + format: date-time + editedAt: + type: + - string + - "null" + format: date-time + reactions: + type: array + items: + type: object + required: + - emoji + - count + properties: + emoji: + type: string + count: + type: integer + discordUrl: + type: string + format: uri + SuggestionDetail: + allOf: + - $ref: "#/components/schemas/Suggestion" + - type: object + required: + - originalPost + properties: + originalPost: + anyOf: + - $ref: "#/components/schemas/Message" + - type: "null" + description: Null if the starter was deleted. + SuggestionPage: + type: object + required: + - items + - nextCursor + properties: + items: + type: array + items: + $ref: "#/components/schemas/Suggestion" + nextCursor: + type: + - string + - "null" + description: Null at end; active thread ID or archived UTC timestamp with up to six fractional + digits. Treat as opaque; URL-encode and keep the same status. + MessagePage: + type: object + required: + - items + - nextCursor + properties: + items: + type: array + items: + $ref: "#/components/schemas/Message" + nextCursor: + type: + - string + - "null" + description: Message ID or null. A full last page can yield a cursor followed by an empty page. + VelocityConnectionRequest: + type: object + required: + - requestId + - serverId + - minecraftUuid + - username + - occurredAt + properties: + requestId: + type: string + format: uuid + serverId: + type: string + minLength: 1 + maxLength: 100 + minecraftUuid: + type: string + pattern: ^[0-9a-fA-F]{32}$ + username: + type: string + pattern: ^[A-Za-z0-9_]{3,16}$ + occurredAt: + type: string + format: date-time + description: RFC 3339 timestamp; UTC Z recommended, numeric offsets accepted. Must be within 45 + seconds of server time. + VelocityAccessRequest: + allOf: + - $ref: "#/components/schemas/VelocityConnectionRequest" + - type: object + required: + - ipAddress + properties: + ipAddress: + anyOf: + - type: string + format: ipv4 + - type: string + format: ipv6 + VelocityDecision: + type: object + required: + - allowed + - message + properties: + allowed: + type: boolean + message: + type: string + examples: + MachineIdentity: + summary: Synthetic, non-live example + value: + authenticationMethod: bearer + subject: example-machine-subject + name: null + email: null + BrowserIdentity: + summary: Synthetic, non-live example + value: + authenticationMethod: session + subject: null + name: Example operator + email: null + Suggestions: + summary: Synthetic, non-live example + value: + items: + - id: "100000000000000009" + title: Add a community garden + authorId: "100000000000000003" + createdAt: 2026-09-10T00:00:00Z + archived: false + locked: false + tags: + - id: "100000000000000004" + name: Idea + messageCount: 2 + discordUrl: https://discord.com/channels/100000000000000001/100000000000000009 + nextCursor: null + DeletedStarter: + summary: Synthetic, non-live example + value: + id: "100000000000000009" + title: Add a community garden + authorId: "100000000000000003" + createdAt: 2026-09-10T00:00:00Z + archived: false + locked: false + tags: + - id: "100000000000000004" + name: Idea + messageCount: 2 + discordUrl: https://discord.com/channels/100000000000000001/100000000000000009 + originalPost: null + Messages: + summary: Synthetic, non-live example + value: + items: + - id: "100000000000000010" + author: + id: "100000000000000003" + name: Example player + content: A shared garden would be fun. + createdAt: 2026-09-10T00:00:00Z + editedAt: null + reactions: + - emoji: 👍 + count: 2 + discordUrl: https://discord.com/channels/100000000000000001/100000000000000009/100000000000000010 + nextCursor: "100000000000000010" + ConnectionRequest: + summary: Synthetic, non-live example + value: + requestId: 00000000-0000-4000-8000-000000000001 + serverId: example-proxy + minecraftUuid: "00000000000040008000000000000001" + username: ExamplePlayer + occurredAt: 2026-09-10T00:00:00Z + AccessRequest: + summary: Synthetic, non-live example + value: + requestId: 00000000-0000-4000-8000-000000000001 + serverId: example-proxy + minecraftUuid: "00000000000040008000000000000001" + username: ExamplePlayer + occurredAt: 2026-09-10T00:00:00Z + ipAddress: 192.0.2.10 + Allowed: + summary: Synthetic, non-live example + value: + allowed: true + message: Account approved. + Denied: + summary: Synthetic, non-live example + value: + allowed: false + message: Please register before joining. + Unauthorized: + summary: Synthetic, non-live example + value: + type: urn:error:unauthorized + title: Unauthorized + status: 401 + detail: Administrator authentication is required. + instance: /api/admin/whoami + responses: + AdminIdentity: + description: Successful read. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/json: + schema: + $ref: "#/components/schemas/Identity" + examples: + representative: + $ref: "#/components/examples/MachineIdentity" + browser: + $ref: "#/components/examples/BrowserIdentity" + AdminUnauthorized: + description: Missing session or invalid supplied credentials; no redirect and no session fallback. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + WWW-Authenticate: + description: Bearer challenge. + schema: + type: string + const: Bearer realm="admin-api" + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 401 + type: + enum: + - urn:error:unauthorized + examples: + unauthorized: + $ref: "#/components/examples/Unauthorized" + AdminForbidden: + description: Authenticated identity lacks the required administrator permission. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 403 + type: + enum: + - urn:error:forbidden + AdminAuthUnavailable: + description: Authentication configuration, session service, or JWKS is unavailable. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 503 + type: + enum: + - urn:error:admin-auth-unavailable + ReadHead: + description: Successful read. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + AdminUnauthorizedHead: + description: Missing session or invalid supplied credentials; no redirect and no session fallback. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + WWW-Authenticate: + description: Bearer challenge. + schema: + type: string + const: Bearer realm="admin-api" + AdminForbiddenHead: + description: Authenticated identity lacks the required administrator permission. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + AdminAuthUnavailableHead: + description: Authentication configuration, session service, or JWKS is unavailable. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + Suggestions: + description: Successful read. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/json: + schema: + $ref: "#/components/schemas/SuggestionPage" + examples: + representative: + $ref: "#/components/examples/Suggestions" + InvalidSuggestionRequest: + description: Invalid ID, status, cursor, limit or query parameters. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 400 + type: + enum: + - urn:error:invalid-request + SuggestionNotFound: + description: Suggestion/message missing, deleted or outside the configured forum. Forum + configuration failures instead use 503. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 404 + type: + enum: + - urn:error:suggestion-not-found + SuggestionsUnavailable: + description: Authentication unavailable, forum unconfigured, Discord unavailable, rate limited or + busy. Retry-After is present only for rate limiting/capacity; seconds, no automatic retries. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + Retry-After: + description: Optional; positive whole seconds for discord-rate-limited or discord-busy. + schema: + type: integer + minimum: 1 + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 503 + type: + enum: + - urn:error:admin-auth-unavailable + - urn:error:suggestions-not-configured + - urn:error:discord-unavailable + - urn:error:discord-rate-limited + - urn:error:discord-busy + InvalidSuggestionRequestHead: + description: Invalid ID, status, cursor, limit or query parameters. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + SuggestionNotFoundHead: + description: Suggestion/message missing, deleted or outside the configured forum. Forum + configuration failures instead use 503. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + SuggestionsUnavailableHead: + description: Authentication unavailable, forum unconfigured, Discord unavailable, rate limited or + busy. Retry-After is present only for rate limiting/capacity; seconds, no automatic retries. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + Retry-After: + description: Optional; positive whole seconds for discord-rate-limited or discord-busy. + schema: + type: integer + minimum: 1 + SuggestionsReadOnly: + description: Suggestions are read-only. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + Allow: + description: Supported reads. + schema: + type: string + const: GET, HEAD + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 405 + type: + enum: + - urn:error:method-not-allowed + SuggestionDetail: + description: Successful read. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/json: + schema: + $ref: "#/components/schemas/SuggestionDetail" + examples: + representative: + $ref: "#/components/examples/DeletedStarter" + SuggestionMessages: + description: Successful read. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/json: + schema: + $ref: "#/components/schemas/MessagePage" + examples: + representative: + $ref: "#/components/examples/Messages" + VelocityDecision: + description: Admission decision. Policy denial is still HTTP 200, with allowed=false and a + user-facing message. + content: + application/json: + schema: + $ref: "#/components/schemas/VelocityDecision" + examples: + allowed: + $ref: "#/components/examples/Allowed" + denied: + $ref: "#/components/examples/Denied" + InvalidAccessRequest: + description: Malformed JSON or invalid body. Includes extensions.issues. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 400 + type: + enum: + - urn:error:invalid-velocity-access-request + VelocityAccessUnauthorized: + description: Missing, invalid or revoked shared server secret, or timestamp outside ±45 seconds. No + admin bearer challenge. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 401 + type: + enum: + - urn:error:unauthorized + - urn:error:expired-velocity-access-request + AccessReplay: + description: Request ID already processed. Replay IDs are shared by both Velocity endpoints; use a + fresh UUID for each request. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 409 + type: + enum: + - urn:error:replayed-velocity-access-request + VelocityUnsupportedMedia: + description: Content-Type must be application/json (parameters permitted). Credential presence is + checked first. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 415 + type: + enum: + - urn:error:unsupported-media-type + VelocityAccessUnavailable: + description: Access decision could not be completed; unexpected failures are sanitized. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 503 + type: + enum: + - urn:error:service-unavailable + VelocityMethodNotAllowed: + description: Only POST is supported; method rejection happens without credential verification. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + Allow: + description: Supported method. + schema: + type: string + const: POST + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 405 + type: + enum: + - urn:error:method-not-allowed + VelocityMethodNotAllowedHead: + description: Only POST is supported; method rejection happens without credential verification. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + Allow: + description: Supported method. + schema: + type: string + const: POST + ConnectionRecorded: + description: Connection recorded; no body. + InvalidConnectionRequest: + description: Malformed JSON or invalid body. Includes extensions.issues. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 400 + type: + enum: + - urn:error:invalid-velocity-connection-request + VelocityConnectionUnauthorized: + description: Missing, invalid or revoked shared server secret, or timestamp outside ±45 seconds. No + admin bearer challenge. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 401 + type: + enum: + - urn:error:unauthorized + - urn:error:expired-velocity-connection-request + UnknownMinecraftAccount: + description: Account no longer registered. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 404 + type: + enum: + - urn:error:unknown-minecraft-account + ConnectionReplay: + description: Request ID already processed. Replay IDs are shared by both Velocity endpoints; use a + fresh UUID for each request. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 409 + type: + enum: + - urn:error:replayed-velocity-connection-request + FrameworkConnectionFailure: + description: Unhandled credential lookup failure before the transaction catch; framework-owned + response, no stable JSON contract. + VelocityConnectionUnavailable: + description: Transaction failure. Credential database lookup errors occur before the handler catch + and may produce a framework 500, not a Problem Details response. + headers: + Cache-Control: + description: Do not cache this response. + schema: + type: string + const: no-store + content: + application/problem+json: + schema: + allOf: + - $ref: "#/components/schemas/Problem" + - type: object + properties: + status: + const: 503 + type: + enum: + - urn:error:service-unavailable + parameters: + limit_1: + name: limit + in: query + description: One decimal integer (1–3 digits), 1–100; defaults to 25. Empty and repeated parameters + are rejected. + schema: + type: integer + minimum: 1 + maximum: 100 + default: 25 + status_2: + name: status + in: query + description: Select active or public archived posts. Keep unchanged when following a cursor. + schema: + type: string + enum: + - active + - archived + default: active + cursor_3: + name: cursor + in: query + description: "Opaque nextCursor: active snowflake or archived UTC Z timestamp (0–6 fractional + digits). URL-encode it." + schema: + type: string + minLength: 1 + id_4: + name: id + in: path + required: true + description: Thread in the configured forum only. + schema: + $ref: "#/components/schemas/Snowflake" + cursor_5: + name: cursor + in: query + description: Opaque message ID from nextCursor; fetch messages before it. + schema: + $ref: "#/components/schemas/Snowflake" diff --git a/package-lock.json b/package-lock.json index d99d1b3..d225dfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "world-atlas": "^2.0.2" }, "devDependencies": { + "@apidevtools/swagger-parser": "^12.1.0", "@tailwindcss/postcss": "^4.2.1", "@testing-library/react": "^16.3.2", "@types/d3-geo": "^3.1.1", @@ -63,12 +64,32 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/topojson-client": "^3.1.5", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "eslint": "^9.39.4", "eslint-config-next": "^16.3.4", "jsdom": "^30.0.1", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", - "vitest": "^4.1.0" + "vitest": "^4.1.0", + "yaml": "^2.9.0" + } + }, + "apps/web/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, "apps/web/node_modules/jose": { @@ -80,6 +101,13 @@ "url": "https://github.com/sponsors/panva" } }, + "apps/web/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -93,6 +121,97 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", + "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz", + "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "14.0.1", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", @@ -3971,6 +4090,48 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -4390,6 +4551,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5752,6 +5920,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", @@ -7693,6 +7878,14 @@ "node": ">=14.0.0" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/openid-client": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", @@ -10114,6 +10307,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",