From 1a01c0ed641f4eda83f81c855c38651aa116933e Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Thu, 10 Sep 2026 07:50:01 -0400 Subject: [PATCH] feat(admin): expose read-only Discord suggestions API --- .env.example | 2 + README.md | 4 + .../api/suggestions/[id]/messages/route.ts | 14 ++ .../web/src/app/api/suggestions/[id]/route.ts | 14 ++ .../web/src/app/api/suggestions/route.test.ts | 71 ++++++++ apps/web/src/app/api/suggestions/route.ts | 14 ++ apps/web/src/lib/discord/suggestion-types.ts | 24 +++ apps/web/src/lib/discord/suggestions-api.ts | 50 ++++++ apps/web/src/lib/discord/suggestions.test.ts | 127 ++++++++++++++ apps/web/src/lib/discord/suggestions.ts | 157 ++++++++++++++++++ docs/admin-suggestions-api.md | 47 ++++++ 11 files changed, 524 insertions(+) create mode 100644 apps/web/src/app/api/suggestions/[id]/messages/route.ts create mode 100644 apps/web/src/app/api/suggestions/[id]/route.ts create mode 100644 apps/web/src/app/api/suggestions/route.test.ts create mode 100644 apps/web/src/app/api/suggestions/route.ts create mode 100644 apps/web/src/lib/discord/suggestion-types.ts create mode 100644 apps/web/src/lib/discord/suggestions-api.ts create mode 100644 apps/web/src/lib/discord/suggestions.test.ts create mode 100644 apps/web/src/lib/discord/suggestions.ts create mode 100644 docs/admin-suggestions-api.md diff --git a/.env.example b/.env.example index f3c3f20..1d01fe6 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ KEYCLOAK_REQUIRED_ROLE=minecraft-account-manager-admin DISCORD_BOT_TOKEN= DISCORD_APPLICATION_ID= DISCORD_GUILD_ID= +# Optional admin suggestions reader; set the real forum ID only through GitOps. +DISCORD_SUGGESTIONS_FORUM_ID= DISCORD_INVITE_URL=https://discord.gg/your-invite # Trust forwarding headers only when your reverse proxy overwrites them diff --git a/README.md b/README.md index 14d4e4a..9c552d3 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ Set `IP_INTELLIGENCE_PROVIDER=proxycheck`, add `PROXYCHECK_API_KEY`, and configu Open `http://localhost:3000`. +## Admin suggestions + +Administrators can read the configured Discord forum through the session-protected [suggestions API](docs/admin-suggestions-api.md). 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. + ## 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/src/app/api/suggestions/[id]/messages/route.ts b/apps/web/src/app/api/suggestions/[id]/messages/route.ts new file mode 100644 index 0000000..b7ab9c4 --- /dev/null +++ b/apps/web/src/app/api/suggestions/[id]/messages/route.ts @@ -0,0 +1,14 @@ +import { suggestionQuery, suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export function GET(request: Request, context: { params: Promise<{ id: string }> }) { + return suggestionsApi(request, async (client) => client.messages((await context.params).id, suggestionQuery(request))); +} + +export const POST = suggestionsReadOnly; +export const PUT = suggestionsReadOnly; +export const PATCH = suggestionsReadOnly; +export const DELETE = suggestionsReadOnly; +export const OPTIONS = suggestionsReadOnly; diff --git a/apps/web/src/app/api/suggestions/[id]/route.ts b/apps/web/src/app/api/suggestions/[id]/route.ts new file mode 100644 index 0000000..deaf147 --- /dev/null +++ b/apps/web/src/app/api/suggestions/[id]/route.ts @@ -0,0 +1,14 @@ +import { suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export function GET(request: Request, context: { params: Promise<{ id: string }> }) { + return suggestionsApi(request, async (client) => client.detail((await context.params).id)); +} + +export const POST = suggestionsReadOnly; +export const PUT = suggestionsReadOnly; +export const PATCH = suggestionsReadOnly; +export const DELETE = suggestionsReadOnly; +export const OPTIONS = suggestionsReadOnly; diff --git a/apps/web/src/app/api/suggestions/route.test.ts b/apps/web/src/app/api/suggestions/route.test.ts new file mode 100644 index 0000000..c91d9d4 --- /dev/null +++ b/apps/web/src/app/api/suggestions/route.test.ts @@ -0,0 +1,71 @@ +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"; +const context = { params: Promise.resolve({ id: "100000000000000009" }) }; + +const request = () => new Request("https://portal.example/api/suggestions"); +afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); +it("serves suggestions to the existing admin session using runtime env configuration", async () => { + auth.session.mockResolvedValue({ user: { roles: ["ops"] } }); + vi.stubEnv("DISCORD_BOT_TOKEN", "test-token"); + vi.stubEnv("DISCORD_GUILD_ID", "100000000000000001"); + vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", "100000000000000002"); + const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ id: "100000000000000002", guild_id: "100000000000000001", type: 15, available_tags: [] })).mockResolvedValueOnce(Response.json({ threads: [] })); + vi.stubGlobal("fetch", fetcher); + const response = await GET(request()); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ items: [], nextCursor: null }); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(fetcher).toHaveBeenCalledTimes(2); +}); +it.each([detail, messages])("independently protects detail and message routes", async (handler) => { + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + auth.session.mockResolvedValue(null); + expect((await handler(request(), context)).status).toBe(401); + auth.session.mockResolvedValue({ user: { roles: ["player"] } }); + expect((await handler(request(), context)).status).toBe(403); + expect(fetcher).not.toHaveBeenCalled(); +}); +it.each(["?limit=0", "?limit=101", "?limit=1.2", "?limit=", "?limit=1&limit=2", "?channel=100000000000000099", "?status=all", "?cursor=../secret"])('rejects invalid query %s without contacting Discord', async (query) => { + auth.session.mockResolvedValue({ user: { roles: ["ops"] } }); + const fetcher = vi.fn(); + vi.stubGlobal("fetch", fetcher); + const response = await GET(new Request(`https://portal.example/api/suggestions${query}`)); + expect(response.status).toBe(400); + expect(fetcher).not.toHaveBeenCalled(); +}); +it("returns a read-only problem for writes", async () => { + auth.session.mockResolvedValue({ user: { roles: ["ops"] } }); + const response = await POST(request()); + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("GET, HEAD"); +}); +it("reports missing configuration without exposing environment values", async () => { + auth.session.mockResolvedValue({ user: { roles: ["ops"] } }); + vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", ""); + const response = await GET(request()); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ type: "urn:error:suggestions-not-configured", status: 503, instance: "/api/suggestions" }); +}); +it("returns sanitized problems for unexpected failures", async () => { + auth.session.mockRejectedValue(new Error("private session details")); + const response = await GET(request()); + expect(response.status).toBe(503); + expect(await response.text()).not.toContain("private session details"); +}); +it("rejects a signed-in user without the required admin role", async () => { + auth.session.mockResolvedValue({ user: { roles: ["player"] } }); + expect((await GET(request())).status).toBe(403); +}); +it("rejects unauthenticated readers with a JSON problem instead of a redirect", async () => { + auth.session.mockResolvedValue(null); + const response = await GET(request()); + expect(response.status).toBe(401); + expect(response.headers.get("content-type")).toBe("application/problem+json"); + expect(response.headers.get("location")).toBeNull(); +}); diff --git a/apps/web/src/app/api/suggestions/route.ts b/apps/web/src/app/api/suggestions/route.ts new file mode 100644 index 0000000..c98717f --- /dev/null +++ b/apps/web/src/app/api/suggestions/route.ts @@ -0,0 +1,14 @@ +import { suggestionQuery, suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export function GET(request: Request) { + return suggestionsApi(request, (client) => client.list(suggestionQuery(request, true))); +} + +export const POST = suggestionsReadOnly; +export const PUT = suggestionsReadOnly; +export const PATCH = suggestionsReadOnly; +export const DELETE = suggestionsReadOnly; +export const OPTIONS = suggestionsReadOnly; diff --git a/apps/web/src/lib/discord/suggestion-types.ts b/apps/web/src/lib/discord/suggestion-types.ts new file mode 100644 index 0000000..937233d --- /dev/null +++ b/apps/web/src/lib/discord/suggestion-types.ts @@ -0,0 +1,24 @@ +export type SuggestionTag = { id: string; name: string }; +export type Suggestion = { + id: string; + title: string; + authorId: string; + createdAt: string; + archived: boolean; + locked: boolean; + tags: SuggestionTag[]; + messageCount: number; + discordUrl: string; +}; +export type SuggestionMessage = { + id: string; + author: { id: string; name: string }; + content: string; + createdAt: string; + editedAt: string | null; + reactions: { emoji: string; count: number }[]; + discordUrl: string; +}; +export type SuggestionPage = { items: Suggestion[]; nextCursor: string | null }; +export type MessagePage = { items: SuggestionMessage[]; nextCursor: string | null }; +export type SuggestionDetail = Suggestion & { originalPost: SuggestionMessage | null }; diff --git a/apps/web/src/lib/discord/suggestions-api.ts b/apps/web/src/lib/discord/suggestions-api.ts new file mode 100644 index 0000000..3b84d05 --- /dev/null +++ b/apps/web/src/lib/discord/suggestions-api.ts @@ -0,0 +1,50 @@ +import { getServerSession } from "next-auth"; +import { problemDetails } from "@minecraft-account-manager/contracts"; +import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth"; +import { problemInstance, problemResponse } from "@/lib/problem-response"; +import { createSuggestionsClient, SuggestionsError } from "./suggestions"; + +type Client = ReturnType; +let runtime: { token: string; guildId: string; forumId: string; client: Client } | undefined; +function getClient() { + const token = process.env.DISCORD_BOT_TOKEN?.trim() ?? ""; + const guildId = process.env.DISCORD_GUILD_ID?.trim() ?? ""; + const forumId = process.env.DISCORD_SUGGESTIONS_FORUM_ID?.trim() ?? ""; + if (!runtime || runtime.token !== token || runtime.guildId !== guildId || runtime.forumId !== forumId) { + runtime = { token, guildId, forumId, client: createSuggestionsClient({ token, guildId, forumId }) }; + } + return runtime.client; +} + +export async function suggestionsApi(request: Request, operation: (client: Client) => Promise) { + try { + const session = await getServerSession(adminAuthOptions); + if (!session) throw new SuggestionsError(401, "unauthorized", "Sign in as an administrator."); + const roles = (session.user as { roles?: unknown } | undefined)?.roles; + if (!Array.isArray(roles) || !roles.includes(requiredAdminRole)) throw new SuggestionsError(403, "forbidden", "This API is restricted to administrators."); + return Response.json(await operation(getClient()), { headers: { "cache-control": "no-store" } }); + } catch (error) { + const safe = error instanceof SuggestionsError ? error : new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable."); + const titles: Record = { 400: "Invalid request", 401: "Authentication required", 403: "Administrator role required", 404: "Suggestion not found", 405: "Method not allowed", 503: "Suggestions unavailable" }; + const response = problemResponse(problemDetails(`urn:error:${safe.code}`, titles[safe.status] ?? "Suggestions unavailable", safe.status, safe.message, problemInstance(request))); + if (safe.retryAfter) response.headers.set("retry-after", String(safe.retryAfter)); + return response; + } +} + +export function suggestionQuery(request: Request, list = false) { + const params = new URL(request.url).searchParams; + const allowed = list ? ["limit", "cursor", "status"] : ["limit", "cursor"]; + for (const key of params.keys()) { + if (!allowed.includes(key) || params.getAll(key).length !== 1 || !params.get(key)) throw new SuggestionsError(400, "invalid-request", "Unsupported or repeated query parameter."); + } + const rawLimit = params.get("limit"); + if (rawLimit !== null && !/^\d{1,3}$/.test(rawLimit)) throw new SuggestionsError(400, "invalid-request", "Limit must be between 1 and 100."); + return { limit: rawLimit === null ? undefined : Number(rawLimit), cursor: params.get("cursor") ?? undefined, ...(list ? { status: params.get("status") ?? undefined } : {}) }; +} + +export function suggestionsReadOnly(request: Request) { + return suggestionsApi(request, async () => { + throw new SuggestionsError(405, "method-not-allowed", "Suggestions are read-only. Use GET."); + }).then((response) => { if (response.status === 405) response.headers.set("allow", "GET, HEAD"); return response; }); +} diff --git a/apps/web/src/lib/discord/suggestions.test.ts b/apps/web/src/lib/discord/suggestions.test.ts new file mode 100644 index 0000000..c606d4f --- /dev/null +++ b/apps/web/src/lib/discord/suggestions.test.ts @@ -0,0 +1,127 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createSuggestionsClient } from "./suggestions"; +afterEach(() => vi.useRealTimers()); +const guildId = "100000000000000001"; +const forumId = "100000000000000002"; +const threadId = "100000000000000009"; +const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11, name: "More railway stations", owner_id: "100000000000000003", applied_tags: ["100000000000000004"], message_count: 3, thread_metadata: { archived: false, locked: false, archive_timestamp: "2026-01-01T00:00:00.000Z" } }; +function setup(responses: Record) { + const fetcher = vi.fn(async (input) => { + const path = String(input).replace("https://discord.com/api/v10", ""); + if (!(path in responses)) throw new Error(`Unexpected path: ${path}`); + const value = responses[path]; + return value instanceof Response ? value : Response.json(value); + }); + const client = createSuggestionsClient({ token: "test-token", guildId, forumId, fetch: fetcher }); + return { client, fetcher }; +} +it("reads archived forum pages using Discord's archive timestamp cursor", async () => { + const cursor = "2026-01-01T00:00:00Z"; + const { client } = setup({ + [`/channels/${forumId}`]: forum, + [`/channels/${forumId}/threads/archived/public?limit=1`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } }], has_more: true }, + [`/channels/${forumId}/threads/archived/public?limit=1&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false }, + }); + expect(await client.list({ status: "archived", limit: 1 })).toMatchObject({ items: [{ archived: true }], nextCursor: cursor }); + expect(await client.list({ status: "archived", limit: 1, cursor })).toEqual({ items: [], nextCursor: null }); +}); + +const message = { id: threadId, content: "Please add stations", timestamp: "2026-01-01T00:00:00.000Z", edited_timestamp: null, author: { id: "100000000000000003", username: "builder", global_name: "Builder" }, reactions: [{ emoji: { name: "👍" }, count: 4 }] }; +it("returns the starter post and paginates discussion with authors and reactions", async () => { + const { client } = setup({ + [`/channels/${forumId}`]: forum, + [`/channels/${threadId}`]: thread, + [`/channels/${threadId}/messages/${threadId}`]: message, + [`/channels/${threadId}/messages?limit=1`]: [{ ...message, id: "100000000000000020" }], + [`/channels/${threadId}/messages?limit=1&before=100000000000000020`]: [], + }); + expect(await client.detail(threadId)).toMatchObject({ title: thread.name, originalPost: { content: message.content, author: { name: "Builder" }, reactions: [{ emoji: "👍", count: 4 }], createdAt: "2026-01-01T00:00:00Z" } }); + expect(await client.messages(threadId, { limit: 1 })).toMatchObject({ items: [{ id: "100000000000000020" }], nextCursor: "100000000000000020" }); + expect(await client.messages(threadId, { limit: 1, cursor: "100000000000000020" })).toEqual({ items: [], nextCursor: null }); +}); + +it.each(["detail", "messages"] as const)("blocks %s of a thread outside the forum before reading messages", async (method) => { + const { client, fetcher } = setup({ [`/channels/${forumId}`]: forum, [`/channels/${threadId}`]: { ...thread, parent_id: "100000000000000099" } }); + await expect(client[method](threadId)).rejects.toMatchObject({ status: 404 }); + expect(fetcher).toHaveBeenCalledTimes(2); +}); +it("keeps a deleted starter post distinguishable from an empty message", async () => { + const { client } = setup({ [`/channels/${forumId}`]: forum, [`/channels/${threadId}`]: thread, [`/channels/${threadId}/messages/${threadId}`]: new Response(null, { status: 404 }) }); + expect(await client.detail(threadId)).toMatchObject({ originalPost: null }); +}); +it("backs off on Discord rate limits without exposing Discord error bodies", async () => { + const { client, fetcher } = setup({ [`/channels/${forumId}`]: Response.json({ retry_after: 2.5, message: "secret upstream details" }, { status: 429 }) }); + await expect(client.list()).rejects.toMatchObject({ status: 503, code: "discord-rate-limited", retryAfter: 3 }); + await expect(client.list()).rejects.toMatchObject({ status: 503, code: "discord-rate-limited" }); + expect(fetcher).toHaveBeenCalledTimes(1); +}); +it.each([401, 403, 404, 500])("translates Discord %s into a safe service error", async (status) => { + const { client } = setup({ [`/channels/${forumId}`]: new Response("sensitive error", { status }) }); + await expect(client.list()).rejects.toMatchObject({ status: 503 }); + await expect(client.list()).rejects.not.toThrow("sensitive error"); +}); +it("sanitizes network failures", async () => { + const client = createSuggestionsClient({ token: "test", guildId, forumId, fetch: vi.fn().mockRejectedValue(new Error("token leaked by upstream")) }); + await expect(client.list()).rejects.toMatchObject({ status: 503, message: "Discord suggestions are unavailable." }); +}); + +it("coalesces concurrent reads and refreshes expired cache entries", async () => { + vi.useFakeTimers(); + const { client, fetcher } = setup({ [`/channels/${forumId}`]: forum, [`/guilds/${guildId}/threads/active`]: { threads: [thread] } }); + await Promise.all([client.list(), client.list()]); + expect(fetcher).toHaveBeenCalledTimes(2); + vi.advanceTimersByTime(30_001); + await client.list(); + expect(fetcher).toHaveBeenCalledTimes(4); +}); +it.each([{ status: "all" }, { limit: 0 }, { limit: 101 }, { limit: 1.5 }, { cursor: "../secret" }, { status: "archived", cursor: "bad-date" }])("validates list query %j before network access", async (query) => { + const { client, fetcher } = setup({}); + await expect(client.list(query)).rejects.toMatchObject({ status: 400 }); + expect(fetcher).not.toHaveBeenCalled(); +}); +it.each(["detail", "messages"] as const)("validates %s IDs before network access", async (method) => { + const { client, fetcher } = setup({}); + await expect(client[method]("../secret")).rejects.toMatchObject({ status: 400 }); + expect(fetcher).not.toHaveBeenCalled(); +}); +it.each([{ ...forumPlaceholder(), type: 0 }, { ...forumPlaceholder(), guild_id: "100000000000000099" }])("refuses a non-forum or wrong-guild configured channel", async (value) => { + const { client, fetcher } = setup({ [`/channels/${forumId}`]: value }); + await expect(client.list()).rejects.toMatchObject({ status: 503 }); + expect(fetcher).toHaveBeenCalledTimes(1); +}); +it("bounds concurrent upstream requests instead of flooding Discord", async () => { + const releases: (() => void)[] = []; + const fetcher = vi.fn(async (input) => { + if (String(input).endsWith(`/channels/${forumId}`)) return Response.json(forum); + if (String(input).endsWith("/threads/active")) return Response.json({ threads: [] }); + return new Promise((resolve) => { releases.push(() => resolve(new Response(null, { status: 404 }))); }); + }); + const client = createSuggestionsClient({ token: "test", guildId, forumId, fetch: fetcher }); + await client.list(); + const results = Array.from({ length: 9 }, (_, index) => client.detail(`1000000000000001${index.toString().padStart(2, "0")}`).catch((error: unknown) => error)); + await vi.waitFor(() => expect(fetcher.mock.calls.length).toBeGreaterThanOrEqual(10)); + const count = releases.length; + releases.forEach((release) => release()); + const errors = await Promise.all(results); + expect(count).toBe(8); + expect(errors).toContainEqual(expect.objectContaining({ code: "discord-busy", status: 503 })); +}); +function forumPlaceholder() { return { id: forumId, guild_id: guildId, type: 15 }; } + +const forum = { id: forumId, guild_id: guildId, type: 15, available_tags: [{ id: "100000000000000004", name: "World" }] }; + +it("lists only configured-forum active suggestions, resolves tags and paginates newest first", async () => { + const { client, fetcher } = setup({ + [`/channels/${forumId}`]: forum, + [`/guilds/${guildId}/threads/active`]: { threads: [ + { ...thread, id: "100000000000000008" }, thread, + { ...thread, id: "100000000000000010", parent_id: "100000000000000099" }, + ] }, + }); + const first = await client.list({ limit: 1 }); + expect(first).toMatchObject({ items: [{ id: threadId, title: "More railway stations", tags: [{ name: "World" }], discordUrl: `https://discord.com/channels/${guildId}/${threadId}` }], nextCursor: threadId }); + const second = await client.list({ limit: 1, cursor: threadId }); + expect(second).toMatchObject({ items: [{ id: "100000000000000008" }], nextCursor: null }); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ headers: { Authorization: "Bot test-token" }, cache: "no-store", redirect: "error" }); +}); diff --git a/apps/web/src/lib/discord/suggestions.ts b/apps/web/src/lib/discord/suggestions.ts new file mode 100644 index 0000000..222a654 --- /dev/null +++ b/apps/web/src/lib/discord/suggestions.ts @@ -0,0 +1,157 @@ +import type { MessagePage, SuggestionDetail, SuggestionMessage, Suggestion, SuggestionPage, SuggestionTag } from "./suggestion-types"; + +type Forum = { id: string; guild_id: string; type: number; available_tags: SuggestionTag[] }; +type Thread = { + id: string; guild_id?: string; parent_id: string; type: number; name: string; owner_id: string; + applied_tags?: string[]; message_count?: number; + thread_metadata: { archived: boolean; locked: boolean; archive_timestamp: string }; +}; +type Message = { + id: string; content: string; timestamp: string; edited_timestamp?: string | null; + author: { id: string; username: string; global_name?: string | null }; + reactions?: { emoji: { id?: string | null; name: string | null }; count: number }[]; +}; +export type ListQuery = { status?: string; cursor?: string; limit?: number }; + +export class SuggestionsError extends Error { + constructor(public readonly status: number, public readonly code: string, message: string, public readonly retryAfter?: number) { + super(message); + } +} +const snowflake = /^[1-9]\d{16,19}$/; +function checkId(id: string) { + if (!snowflake.test(id)) throw new SuggestionsError(400, "invalid-request", "A valid Discord ID is required."); +} +function limitValue(limit = 25) { + if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new SuggestionsError(400, "invalid-request", "Limit must be between 1 and 100."); + return limit; +} +function timestamp(value: string | number) { + return new Date(value).toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +export function createSuggestionsClient(options: { token: string; guildId: string; forumId: string; fetch?: typeof fetch }) { + const { token, guildId, forumId } = options; + const fetcher = options.fetch ?? fetch; + const cache = new Map(); + let retryAt = 0; + let activeRequests = 0; + const pending = new Map>(); + async function get(path: string): Promise { + const existing = pending.get(path); + if (existing) return existing as Promise; + const request = load(path); + pending.set(path, request); + try { return await request; } finally { pending.delete(path); } + } + async function load(path: string): Promise { + const cached = cache.get(path); + if (cached && cached.expires > Date.now()) return cached.value as T; + if (Date.now() < retryAt) throw new SuggestionsError(503, "discord-rate-limited", "Discord is rate limited. Try again shortly.", Math.ceil((retryAt - Date.now()) / 1000)); + if (activeRequests >= 8) throw new SuggestionsError(503, "discord-busy", "Suggestions are busy. Try again shortly.", 1); + activeRequests += 1; + try { + const response = await fetcher(`https://discord.com/api/v10${path}`, { + headers: { Authorization: `Bot ${token}` }, cache: "no-store", redirect: "error", signal: AbortSignal.timeout(8000), + }); + if (response.status === 429) { + const body = await response.json().catch(() => null) as { retry_after?: number } | null; + const raw = Number(body?.retry_after ?? response.headers.get("retry-after") ?? 1); + const seconds = Number.isFinite(raw) && raw > 0 ? Math.ceil(raw) : 1; + retryAt = Date.now() + seconds * 1000; + throw new SuggestionsError(503, "discord-rate-limited", "Discord is rate limited. Try again shortly.", seconds); + } + if (response.status === 404) throw new SuggestionsError(404, "suggestion-not-found", "The suggestion or message was not found."); + if (!response.ok) throw new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable."); + const value: unknown = await response.json(); + if (cache.size >= 200) cache.delete(cache.keys().next().value!); + cache.set(path, { value, expires: Date.now() + 30_000 }); + return value as T; + } catch (error) { + if (error instanceof SuggestionsError) throw error; + throw new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable."); + } finally { + activeRequests -= 1; + } + } + async function getForum() { + if (!token || !snowflake.test(guildId) || !snowflake.test(forumId)) throw new SuggestionsError(503, "suggestions-not-configured", "Discord suggestions are not configured."); + const forum = await get(`/channels/${forumId}`).catch((error: unknown) => { + if (error instanceof SuggestionsError && error.status === 404) throw new SuggestionsError(503, "suggestions-not-configured", "The configured suggestions forum is unavailable."); + throw error; + }); + if (forum.id !== forumId || forum.guild_id !== guildId || forum.type !== 15) throw new SuggestionsError(503, "suggestions-not-configured", "The configured channel must be a forum in the configured guild."); + return forum; + } + function belongs(thread: Thread) { + return thread.parent_id === forumId && (!thread.guild_id || thread.guild_id === guildId) && thread.type === 11; + } + function summary(thread: Thread, forum: Forum): Suggestion { + return { + id: thread.id, title: thread.name, authorId: thread.owner_id, + createdAt: timestamp(Number((BigInt(thread.id) >> 22n) + 1420070400000n)), + archived: thread.thread_metadata.archived, locked: thread.thread_metadata.locked, + tags: (forum.available_tags ?? []).filter((tag) => thread.applied_tags?.includes(tag.id)).map(({ id, name }) => ({ id, name })), + messageCount: thread.message_count ?? 0, discordUrl: `https://discord.com/channels/${guildId}/${thread.id}`, + }; + } + async function getThread(id: string) { + checkId(id); + const forum = await getForum(); + const thread = await get(`/channels/${id}`); + if (thread.id !== id || !belongs(thread)) throw new SuggestionsError(404, "suggestion-not-found", "The suggestion was not found in the configured forum."); + return { thread, forum }; + } + function messageView(message: Message, threadId: string): SuggestionMessage { + return { + id: message.id, content: message.content, + author: { id: message.author.id, name: message.author.global_name || message.author.username }, + createdAt: timestamp(message.timestamp), editedAt: message.edited_timestamp ? timestamp(message.edited_timestamp) : null, + reactions: (message.reactions ?? []).map(({ emoji, count }) => ({ emoji: emoji.id ? `:${emoji.name ?? "emoji"}:` : emoji.name ?? "emoji", count })), + discordUrl: `https://discord.com/channels/${guildId}/${threadId}/${message.id}`, + }; + } + return { + async detail(id: string): Promise { + const { thread, forum } = await getThread(id); + const message = await get(`/channels/${id}/messages/${id}`).catch((error: unknown) => { + if (error instanceof SuggestionsError && error.status === 404) return null; + throw error; + }); + return { ...summary(thread, forum), originalPost: message ? messageView(message, id) : null }; + }, + async messages(id: string, query: { cursor?: string; limit?: number } = {}): Promise { + const limit = limitValue(query.limit); + if (query.cursor) checkId(query.cursor); + await getThread(id); + const before = query.cursor ? `&before=${query.cursor}` : ""; + const messages = await get(`/channels/${id}/messages?limit=${limit}${before}`); + return { items: messages.map((message) => messageView(message, id)), nextCursor: messages.length === limit ? messages.at(-1)!.id : null }; + }, + async list(query: ListQuery = {}): Promise { + const limit = limitValue(query.limit); + const status = query.status ?? "active"; + if (status !== "active" && status !== "archived") throw new SuggestionsError(400, "invalid-request", "Status must be active or archived."); + if (query.cursor) { + if (status === "active") checkId(query.cursor); + else if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/.test(query.cursor) || !Number.isFinite(Date.parse(query.cursor))) { + throw new SuggestionsError(400, "invalid-request", "The archive cursor must be a UTC timestamp."); + } + } + const forum = await getForum(); + if (status === "archived") { + const before = query.cursor ? `&before=${encodeURIComponent(query.cursor)}` : ""; + const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${limit}${before}`); + return { + items: data.threads.filter(belongs).map((thread) => summary(thread, forum)), + nextCursor: data.has_more && data.threads.length ? data.threads.at(-1)!.thread_metadata.archive_timestamp.replace(/\.000Z$/, "Z") : null, + }; + } + const data = await get<{ threads: Thread[] }>(`/guilds/${guildId}/threads/active`); + const threads = data.threads.filter(belongs).sort((a, b) => BigInt(a.id) > BigInt(b.id) ? -1 : 1) + .filter((thread) => !query.cursor || BigInt(thread.id) < BigInt(query.cursor)); + const page = threads.slice(0, limit); + return { items: page.map((thread) => summary(thread, forum)), nextCursor: threads.length > limit ? page.at(-1)!.id : null }; + }, + }; +} diff --git a/docs/admin-suggestions-api.md b/docs/admin-suggestions-api.md new file mode 100644 index 0000000..77379b2 --- /dev/null +++ b/docs/admin-suggestions-api.md @@ -0,0 +1,47 @@ +# Admin suggestions API + +The portal provides a read-only view of one Discord **forum channel**, using the existing NextAuth admin session and configured Keycloak role. Player sessions and Discord bot credentials are not accepted as API credentials. Sign in at `/admin/login` first; same-origin browser calls send the session cookie. Unattended machine authentication is not provided. + +## Runtime configuration + +- `DISCORD_SUGGESTIONS_FORUM_ID`: required forum channel snowflake, configured through GitOps. No source-code default. +- `DISCORD_GUILD_ID`: existing Discord guild configuration; the forum must belong to it. +- `DISCORD_BOT_TOKEN`: existing server-side credential, never returned to consumers. + +The web workload needs these variables, not just the bot workload. The actual forum ID is maintained only in GitOps. No schema or bot Gateway changes are needed. The application uses Discord REST API v10 with the existing bot identity. + +The bot must have **View Channel** and **Read Message History** for the forum and its posts. Message bodies are subject to Discord's **Message Content privileged intent**, including REST access: enable it for the application and obtain approval from Discord if required. Missing content can appear as an empty body rather than an HTTP error; check a known text post before production acceptance. No permissions or intents are changed by this feature. + +## Endpoints + +| GET endpoint | Result | +| --- | --- | +| `/api/suggestions?status=active&limit=25` | Active posts, newest-created first. `status` defaults to `active`. | +| `/api/suggestions?status=archived&limit=25` | Archived public forum posts, newest archive timestamp first. | +| `/api/suggestions/:id` | Suggestion metadata and `originalPost`; `null` when the starter message was deleted. | +| `/api/suggestions/:id/messages?limit=25` | Discussion messages, newest first, including the starter if reached. | + +Lists return `{ items, nextCursor }`. Pass `nextCursor` back as the URL-encoded `cursor` parameter with the same status. Limits are integers from 1 to 100. Active cursors are thread IDs; archived cursors are UTC archive timestamps; message cursors are message IDs. Treat cursors as opaque. Messages may yield a final empty page because Discord does not provide a `has_more` flag for messages. Active threads are fetched via the guild active-threads endpoint, filtered to the forum, sorted, and paginated locally. Archives are paginated by Discord. This is a live view, not a consistent snapshot: threads can move between active and archived lists. + +Suggestion fields: `id`, `title`, `authorId`, `createdAt`, `archived`, `locked`, `tags`, `messageCount`, `discordUrl`. Discord's message count is approximate, not a vote count. Detail messages include `id`, `author` (`id`, `name`), `content`, `createdAt`, `editedAt`, `reactions` (`emoji`, `count`), and `discordUrl`. Reactions remain reaction counts, not interpreted votes. Attachments, embeds, and rendered Discord Markdown are not mirrored; use Discord links for the original presentation. + +## Errors and safety + +Errors use RFC 9457 `application/problem+json`, HTTP-matching `status`, stable `urn:error:*` types, and safe details: + +- `401 unauthorized`: no admin session; no redirect. +- `403 forbidden`: session lacks the required role. +- `400 invalid-request`: invalid ID, cursor, limit, status, or list/message query parameter. +- `404 suggestion-not-found`: inaccessible/deleted thread, or thread outside the configured forum. +- `405 method-not-allowed`: writes are unsupported; `Allow: GET, HEAD`. +- `503 suggestions-not-configured`, `discord-unavailable`, `discord-rate-limited`, or `discord-busy`: configuration, permissions, upstream failure, or temporary backoff. Rate limits and capacity limits include `Retry-After`. + +Every endpoint rechecks admin authorization before any cached or fresh data is returned. Responses use `Cache-Control: no-store`. The in-process Discord cache lasts 30 seconds, contains at most 200 entries, coalesces identical concurrent reads, and permits at most eight concurrent upstream requests. Requests have an eight-second timeout and never follow redirects. Discord rate limits establish a per-client cooldown without retry loops. Caches and cooldowns are per process, not shared with bot Gateway activity or other replicas. + +The client checks the configured forum's guild/type and each requested thread's parent/type before reading messages. It only calls fixed Discord endpoints with validated snowflakes. Error bodies and credentials are not logged or forwarded. There is no database synchronization and no Discord mutation support. + +## Verification before rollout + +Run the source checks and focused tests in `apps/web/src/lib/discord/suggestions.test.ts` and `apps/web/src/app/api/suggestions/route.test.ts`. Under the separately approved GitOps/release plan, verify one known active post, one archived post, message content, and admin/non-admin access against the actual forum. Offline fixtures do not establish live Discord permissions or intent approval. + +References: [Discord threads](https://discord.com/developers/docs/topics/threads), [Channel resource](https://discord.com/developers/docs/resources/channel), [Message content intent](https://discord.com/developers/docs/events/gateway#message-content-intent).