Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb8ef18688 | ||
|
|
47782b3ccc | ||
|
|
c2ac2ad16b | ||
|
|
2402e9e42d | ||
|
|
0af4884f7e |
@@ -34,7 +34,13 @@ Open `http://localhost:3000`.
|
|||||||
|
|
||||||
## Admin suggestions
|
## 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.
|
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: <http://localhost:3000/openapi.yaml>). 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: <https://portal.somc.club/openapi.yaml> (publication requires a release).
|
||||||
|
|
||||||
## Product design
|
## Product design
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const contentSecurityPolicy = [
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
// Keep the canonical source available in standalone/container output as well.
|
||||||
|
outputFileTracingIncludes: { "/openapi.yaml": ["../../openapi.yaml"] },
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
+10
-3
@@ -7,6 +7,8 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"test": "vitest run",
|
"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 .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -19,8 +21,9 @@
|
|||||||
"@minecraft-account-manager/network": "*",
|
"@minecraft-account-manager/network": "*",
|
||||||
"d3-geo": "^3.1.1",
|
"d3-geo": "^3.1.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"jose": "^6.2.12",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"next": "^16.2.1",
|
"next": "^16.3.4",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"rcon-client": "^4.2.5",
|
"rcon-client": "^4.2.5",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
@@ -29,6 +32,7 @@
|
|||||||
"world-atlas": "^2.0.2"
|
"world-atlas": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@apidevtools/swagger-parser": "^12.1.0",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/d3-geo": "^3.1.1",
|
"@types/d3-geo": "^3.1.1",
|
||||||
@@ -37,11 +41,14 @@
|
|||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/topojson-client": "^3.1.5",
|
"@types/topojson-client": "^3.1.5",
|
||||||
|
"ajv": "^8.20.0",
|
||||||
|
"ajv-formats": "^3.0.1",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.2.1",
|
"eslint-config-next": "^16.3.4",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.0",
|
||||||
|
"yaml": "^2.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export default async function AdminConsoleLayout({ children }: { children: React
|
|||||||
<Link className="hover:text-accent" href="/admin/users">Users</Link>
|
<Link className="hover:text-accent" href="/admin/users">Users</Link>
|
||||||
<Link className="hover:text-accent" href="/admin/groups">Groups</Link>
|
<Link className="hover:text-accent" href="/admin/groups">Groups</Link>
|
||||||
<Link className="hover:text-accent" href="/admin/rcon">RCON</Link>
|
<Link className="hover:text-accent" href="/admin/rcon">RCON</Link>
|
||||||
|
<Link className="hover:text-accent" href="/admin/suggestions">Suggestions</Link>
|
||||||
<Link className="hover:text-accent" href="/admin/events">Events</Link>
|
<Link className="hover:text-accent" href="/admin/events">Events</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<AdminSignOutButton />
|
<AdminSignOutButton />
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { SuggestionReader } from "@/components/suggestion-reader";
|
||||||
|
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function SuggestionPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
await requireAdminSession();
|
||||||
|
const { id } = await params;
|
||||||
|
if (!/^[1-9]\d{16,19}$/.test(id)) notFound();
|
||||||
|
return <main className="mx-auto max-w-6xl px-6 py-14">
|
||||||
|
<Link className="mb-8 inline-block font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4 hover:text-accent" href="/admin/suggestions">← All suggestions</Link>
|
||||||
|
<SuggestionReader id={id} key={id} />
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { expect, it, vi } from "vitest";
|
||||||
|
vi.mock("next-auth", () => ({ getServerSession: async () => ({ user: { roles: ["ops"] } }) }));
|
||||||
|
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||||
|
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
|
||||||
|
vi.mock("@minecraft-account-manager/database", () => ({ recordEvent: vi.fn() }));
|
||||||
|
vi.mock("@/lib/database", () => ({ db: {} }));
|
||||||
|
vi.mock("@/components/admin-sign-out-button", () => ({ AdminSignOutButton: () => <button>Sign out</button> }));
|
||||||
|
import AdminConsoleLayout from "../layout";
|
||||||
|
it("adds suggestions to administrator navigation", async () => {
|
||||||
|
const markup = renderToStaticMarkup(await AdminConsoleLayout({ children: <p>Console</p> }));
|
||||||
|
expect(markup).toContain('href="/admin/suggestions"');
|
||||||
|
expect(markup).toContain("Suggestions</a>");
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { beforeEach, expect, it, vi } from "vitest";
|
||||||
|
const mocks = vi.hoisted(() => ({ requireAdmin: vi.fn() }));
|
||||||
|
vi.mock("@/lib/auth/require-admin", () => ({ requireAdminSession: mocks.requireAdmin }));
|
||||||
|
vi.mock("@/components/suggestions-browser", () => ({ SuggestionsBrowser: () => <div>Suggestions browser</div> }));
|
||||||
|
vi.mock("@/components/suggestion-reader", () => ({ SuggestionReader: ({ id }: { id: string }) => <div>Reader {id}</div> }));
|
||||||
|
import SuggestionsPage from "./page";
|
||||||
|
import SuggestionPage from "./[id]/page";
|
||||||
|
const params = Promise.resolve({ id: "100000000000000009" });
|
||||||
|
beforeEach(() => { mocks.requireAdmin.mockReset(); });
|
||||||
|
it("independently requires the admin guard before rendering either page", async () => {
|
||||||
|
mocks.requireAdmin.mockRejectedValue(new Error("Admin sign-in required"));
|
||||||
|
await expect(SuggestionsPage()).rejects.toThrow("Admin sign-in required");
|
||||||
|
await expect(SuggestionPage({ params })).rejects.toThrow("Admin sign-in required");
|
||||||
|
});
|
||||||
|
it("renders the browser and detail in the existing portal layout", async () => {
|
||||||
|
mocks.requireAdmin.mockResolvedValue({ name: "Admin" });
|
||||||
|
expect(renderToStaticMarkup(await SuggestionsPage())).toContain("Suggestions browser");
|
||||||
|
const detail = renderToStaticMarkup(await SuggestionPage({ params }));
|
||||||
|
expect(detail).toContain("Reader");
|
||||||
|
expect(detail).toContain('href="/admin/suggestions"');
|
||||||
|
});
|
||||||
|
it("rejects malformed IDs rather than constructing arbitrary API URLs", async () => {
|
||||||
|
mocks.requireAdmin.mockResolvedValue({ name: "Admin" });
|
||||||
|
await expect(SuggestionPage({ params: Promise.resolve({ id: "../secret" }) })).rejects.toThrow("NEXT_HTTP_ERROR_FALLBACK;404");
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { SuggestionsBrowser } from "@/components/suggestions-browser";
|
||||||
|
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function SuggestionsPage() {
|
||||||
|
await requireAdminSession();
|
||||||
|
return <main className="mx-auto max-w-6xl px-6 py-14">
|
||||||
|
<header className="relative overflow-hidden border-b border-line pb-8">
|
||||||
|
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Community / Idea desk</p>
|
||||||
|
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">Suggestions</h1>
|
||||||
|
<div className="mt-5 flex flex-wrap items-end justify-between gap-5">
|
||||||
|
<p className="max-w-2xl text-sm leading-6 text-muted">Ideas from the Discord forum, brought into the operations desk. Read proposals and discussion here; keep the conversation in Discord.</p>
|
||||||
|
<span className="border border-ink bg-signal px-3 py-2 font-mono text-[10px] font-bold uppercase tracking-widest">Admin-only / Read-only</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<SuggestionsBrowser />
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
|
||||||
|
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
||||||
|
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||||
|
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||||
|
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||||
|
const issuer = "https://sso.example/realms/operators";
|
||||||
|
let signed: string;
|
||||||
|
const fetcher = vi.fn();
|
||||||
|
beforeAll(async () => {
|
||||||
|
const keys = await generateKeyPair("RS256");
|
||||||
|
const jwks = { keys: [{ ...await exportJWK(keys.publicKey), kid: "whoami-key", alg: "RS256" }] };
|
||||||
|
fetcher.mockImplementation(async () => Response.json(jwks));
|
||||||
|
signed = await new SignJWT({ resource_access: { portal: { roles: ["ops"] } }, name: "Not exposed", email: "private@example.test" })
|
||||||
|
.setProtectedHeader({ alg: "RS256", kid: "whoami-key" }).setIssuer(issuer).setAudience("portal")
|
||||||
|
.setSubject("machine-subject").setExpirationTime("5m").sign(keys.privateKey);
|
||||||
|
});
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv("KEYCLOAK_ISSUER_URL", issuer);
|
||||||
|
vi.stubEnv("KEYCLOAK_CLIENT_ID", "portal");
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
});
|
||||||
|
afterEach(() => { auth.session.mockReset(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
|
||||||
|
|
||||||
|
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 } });
|
||||||
|
}
|
||||||
|
it("returns only a safe machine identity through real bearer verification", async () => {
|
||||||
|
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||||
|
const response = await get(request(`Bearer ${signed}`));
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.json()).toEqual({ authenticationMethod: "bearer", subject: "machine-subject", name: null, email: null });
|
||||||
|
expect(auth.session).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("returns the existing browser identity without roles or session internals", async () => {
|
||||||
|
auth.session.mockResolvedValue({ user: { name: "Admin", email: "admin@example.test", roles: ["ops"], image: "private-image" }, expires: "private-expiry" });
|
||||||
|
const response = await get(request());
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.json()).toEqual({ authenticationMethod: "session", subject: null, name: "Admin", email: "admin@example.test" });
|
||||||
|
});
|
||||||
|
it.each([401, 403, 503])("returns a safe %s problem for browser auth failures", async (status) => {
|
||||||
|
if (status === 503) auth.session.mockRejectedValue(new Error("private-session-error"));
|
||||||
|
else auth.session.mockResolvedValue(status === 401 ? null : { user: { roles: [] } });
|
||||||
|
const response = await get(request());
|
||||||
|
expect(response.status).toBe(status);
|
||||||
|
expect(response.headers.get("content-type")).toBe("application/problem+json");
|
||||||
|
expect(response.headers.get("www-authenticate")).toBe(status === 401 ? 'Bearer realm="admin-api"' : null);
|
||||||
|
expect(await response.json()).toMatchObject({ status, instance: "/api/admin/whoami" });
|
||||||
|
});
|
||||||
|
it("never falls back to browser auth for a supplied invalid token", async () => {
|
||||||
|
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||||
|
const response = await get(request("Bearer invalid"));
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(auth.session).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { authorizeAdminApi } from "@/lib/auth/admin-api-auth";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const authorization = await authorizeAdminApi(request);
|
||||||
|
if (authorization.response) return authorization.response;
|
||||||
|
return Response.json(authorization.identity, { headers: { "cache-control": "no-store" } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
|
||||||
|
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
||||||
|
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 as list, POST } from "./route";
|
||||||
|
import { GET as detail } from "./[id]/route";
|
||||||
|
import { GET as messages } from "./[id]/messages/route";
|
||||||
|
const issuer = "https://sso.example/realms/operators";
|
||||||
|
const guildId = "100000000000000001";
|
||||||
|
const forumId = "100000000000000002";
|
||||||
|
const threadId = "100000000000000009";
|
||||||
|
const context = { params: Promise.resolve({ id: threadId }) };
|
||||||
|
const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11, name: "Suggestion", owner_id: "100000000000000003", applied_tags: [], message_count: 0, thread_metadata: { archived: false, locked: false } };
|
||||||
|
let signed: string;
|
||||||
|
let roleless: string;
|
||||||
|
let jwks: unknown;
|
||||||
|
const fetcher = vi.fn();
|
||||||
|
beforeAll(async () => {
|
||||||
|
const keys = await generateKeyPair("RS256");
|
||||||
|
jwks = { keys: [{ ...await exportJWK(keys.publicKey), kid: "suggestions-key", alg: "RS256" }] };
|
||||||
|
const sign = (roles: string[]) => new SignJWT({ resource_access: { portal: { roles } }, realm_access: { roles: ["ops"] } })
|
||||||
|
.setProtectedHeader({ alg: "RS256", kid: "suggestions-key" }).setIssuer(issuer).setAudience("portal")
|
||||||
|
.setSubject("machine-subject").setExpirationTime("5m").sign(keys.privateKey);
|
||||||
|
signed = await sign(["ops"]);
|
||||||
|
roleless = await sign([]);
|
||||||
|
});
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv("KEYCLOAK_ISSUER_URL", issuer);
|
||||||
|
vi.stubEnv("KEYCLOAK_CLIENT_ID", "portal");
|
||||||
|
vi.stubEnv("DISCORD_BOT_TOKEN", "test-only-bot-token");
|
||||||
|
vi.stubEnv("DISCORD_GUILD_ID", guildId);
|
||||||
|
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", forumId);
|
||||||
|
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||||
|
fetcher.mockImplementation(async (input: string) => {
|
||||||
|
if (input === `${issuer}/protocol/openid-connect/certs`) return Response.json(jwks);
|
||||||
|
const path = String(input).replace("https://discord.com/api/v10", "");
|
||||||
|
if (path === `/channels/${forumId}`) return Response.json({ id: forumId, guild_id: guildId, type: 15, available_tags: [] });
|
||||||
|
if (path === `/guilds/${guildId}/threads/active`) return Response.json({ threads: [] });
|
||||||
|
if (path === `/channels/${threadId}`) return Response.json(thread);
|
||||||
|
if (path === `/channels/${threadId}/messages/${threadId}`) return new Response(null, { status: 404 });
|
||||||
|
if (path === `/channels/${threadId}/messages?limit=25`) return Response.json([]);
|
||||||
|
throw new Error("Unexpected transport request");
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
});
|
||||||
|
afterEach(() => { vi.resetAllMocks(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
|
||||||
|
function request(token: string) {
|
||||||
|
return new Request("https://portal.example/api/suggestions", { headers: { authorization: `Bearer ${token}` } });
|
||||||
|
}
|
||||||
|
it.each([list, detail, messages])("accepts signed machine credentials on each read route", async (handler) => {
|
||||||
|
const response = await handler(request(signed), context);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(auth.session).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it.each([list, detail, messages])("denies invalid or role-less bearer credentials even with an admin session and cached data", async (handler) => {
|
||||||
|
expect((await handler(request(signed), context)).status).toBe(200);
|
||||||
|
fetcher.mockClear();
|
||||||
|
expect((await handler(request("invalid"), context)).status).toBe(401);
|
||||||
|
expect((await handler(request(roleless), context)).status).toBe(403);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
expect(auth.session).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("does not enable writes for machine identities", async () => {
|
||||||
|
const response = await POST(request(signed));
|
||||||
|
expect(response.status).toBe(405);
|
||||||
|
expect(response.headers.get("allow")).toBe("GET, HEAD");
|
||||||
|
expect(auth.session).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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")) {
|
||||||
|
const params = new URL(url).searchParams;
|
||||||
|
if (Number(params.get("limit")) < 2) return Response.json({ code: 50035, message: "Invalid Form Body", errors: { limit: { _errors: [{ code: "NUMBER_TYPE_MIN", message: "int value should be greater than or equal to 2." }] } } }, { status: 400 });
|
||||||
|
const newest = { ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } };
|
||||||
|
const older = { ...newest, id: "100000000000000008", thread_metadata: { ...newest.thread_metadata, archive_timestamp: "2026-09-10T00:00:00.123455+00:00" } };
|
||||||
|
if (params.has("before")) {
|
||||||
|
expect(params.get("before")).toBe("2026-09-10T00:00:00.123456Z");
|
||||||
|
return Response.json({ threads: [older], has_more: false });
|
||||||
|
}
|
||||||
|
return Response.json({ threads: [newest, older], has_more: false });
|
||||||
|
}
|
||||||
|
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(archived.status).toBe(200);
|
||||||
|
const first = await archived.json();
|
||||||
|
expect(first.items.map((item: { id: string }) => item.id)).toEqual([id]);
|
||||||
|
expect(first.nextCursor).toBe("2026-09-10T00:00:00.123456Z");
|
||||||
|
const terminal = await list.GET(new Request(`https://portal.example/api/suggestions?status=archived&limit=1&cursor=${encodeURIComponent(first.nextCursor)}`));
|
||||||
|
expect(terminal.status).toBe(200);
|
||||||
|
await assertResponse("/api/suggestions", "get", terminal);
|
||||||
|
const last = await terminal.json();
|
||||||
|
expect(last.items.map((item: { id: string }) => item.id)).toEqual(["100000000000000008"]);
|
||||||
|
expect(last.nextCursor).toBeNull();
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2,12 +2,46 @@ import { afterEach, expect, it, vi } from "vitest";
|
|||||||
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||||
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||||
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||||
import { GET, POST } from "./route";
|
import { assertResponse } from "@/test/openapi-contract";
|
||||||
import { GET as detail } from "./[id]/route";
|
import { GET as routeGet, POST as routePost } from "./route";
|
||||||
import { GET as messages } from "./[id]/messages/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<typeof routeDetail>[1]) {
|
||||||
|
const response = await routeDetail(request, context);
|
||||||
|
await assertResponse("/api/suggestions/{id}", "get", response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
async function messages(request: Request, context: Parameters<typeof routeMessages>[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 context = { params: Promise.resolve({ id: "100000000000000009" }) };
|
||||||
|
|
||||||
const request = () => new Request("https://portal.example/api/suggestions");
|
const request = () => new Request("https://portal.example/api/suggestions");
|
||||||
|
it.each(["Bearer invalid", "Basic invalid", "", "Bearer", "Bearer a, Bearer b"])("rejects supplied authorization %j without session fallback", async (authorization) => {
|
||||||
|
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||||
|
const fetcher = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
const response = await GET(new Request(request(), { headers: { authorization } }));
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.headers.get("www-authenticate")).toBe('Bearer realm="admin-api"');
|
||||||
|
expect(response.headers.get("content-type")).toBe("application/problem+json");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.json()).toMatchObject({ type: "urn:error:unauthorized", status: 401, instance: "/api/suggestions" });
|
||||||
|
expect(auth.session).not.toHaveBeenCalled();
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
|
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
|
||||||
it("serves suggestions to the existing admin session using runtime env configuration", async () => {
|
it("serves suggestions to the existing admin session using runtime env configuration", async () => {
|
||||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||||
@@ -68,4 +102,5 @@ it("rejects unauthenticated readers with a JSON problem instead of a redirect",
|
|||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
expect(response.headers.get("content-type")).toBe("application/problem+json");
|
expect(response.headers.get("content-type")).toBe("application/problem+json");
|
||||||
expect(response.headers.get("location")).toBeNull();
|
expect(response.headers.get("location")).toBeNull();
|
||||||
|
expect(response.headers.get("www-authenticate")).toBe('Bearer realm="admin-api"');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,7 +47,13 @@ vi.mock("@/lib/ip-intelligence", () => ({
|
|||||||
|
|
||||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
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 = {
|
const messages = {
|
||||||
registrationMessage: "Register {player} in {group}.",
|
registrationMessage: "Register {player} in {group}.",
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("Velocity access API problems", () => {
|
||||||
it("returns RFC 9457 for unsupported methods", async () => {
|
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.status).toBe(405);
|
||||||
expect(response.headers.get("allow")).toBe("POST");
|
expect(response.headers.get("allow")).toBe("POST");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { assertResponse } from "@/test/openapi-contract";
|
||||||
import { hashToken } from "@minecraft-account-manager/auth";
|
import { hashToken } from "@minecraft-account-manager/auth";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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<string, unknown> = {}) {
|
function validRequest(overrides: Record<string, unknown> = {}) {
|
||||||
return new Request("http://localhost/api/velocity/connection", {
|
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 () => {
|
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.status).toBe(405);
|
||||||
expect(response.headers.get("content-type")).toContain("application/problem+json");
|
expect(response.headers.get("content-type")).toContain("application/problem+json");
|
||||||
expect(response.headers.get("allow")).toBe("POST");
|
expect(response.headers.get("allow")).toBe("POST");
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { SuggestionReader } from "./suggestion-reader";
|
||||||
|
const id = "100000000000000009";
|
||||||
|
const original = { id, author: { id: "100000000000000003", name: "Builder" }, content: "<script>unsafe()</script> Please build stations.", createdAt: "2026-01-01T00:00:00Z", editedAt: null, reactions: [{ emoji: "👍", count: 4 }], discordUrl: `https://discord.com/channels/100000000000000001/${id}/${id}` };
|
||||||
|
const detail = { id, title: "More railway stations", authorId: original.author.id, createdAt: original.createdAt, archived: false, locked: false, tags: [{ id: "100000000000000004", name: "World" }], messageCount: 3, discordUrl: `https://discord.com/channels/100000000000000001/${id}`, originalPost: original };
|
||||||
|
it("paginates discussion without duplicating the original post", async () => {
|
||||||
|
const fetcher = vi.fn().mockImplementation(async (url: string) => {
|
||||||
|
if (!url.includes("/messages")) return Response.json(detail);
|
||||||
|
const older = url.includes("cursor=");
|
||||||
|
return Response.json({ items: [original, { ...original, id: "100000000000000020", content: older ? "Earlier reply" : "Latest reply", editedAt: "2026-01-01T01:00:00Z" }], nextCursor: older ? null : "100000000000000020" });
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
render(<SuggestionReader id={id} />);
|
||||||
|
await screen.findByText("Latest reply");
|
||||||
|
expect(screen.getAllByText(original.content)).toHaveLength(1);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
|
||||||
|
await screen.findByText("Earlier reply");
|
||||||
|
expect(fetcher.mock.lastCall?.[0]).toBe(`/api/suggestions/${id}/messages?limit=25&cursor=100000000000000020`);
|
||||||
|
expect(screen.getByText("2026-01-01T01:00:00Z")).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Previous page" }));
|
||||||
|
await screen.findByText("Latest reply");
|
||||||
|
});
|
||||||
|
it("distinguishes deleted starters and empty discussion", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => Response.json(url.includes("/messages") ? { items: [], nextCursor: null } : { ...detail, originalPost: null })));
|
||||||
|
render(<SuggestionReader id={id} />);
|
||||||
|
expect(await screen.findByText("The original post was deleted or is unavailable.")).toBeTruthy();
|
||||||
|
expect(await screen.findByText("No replies on this page.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
it("handles detail access denial without loading the discussion", async () => {
|
||||||
|
const fetcher = vi.fn().mockResolvedValue(Response.json({}, { status: 403 }));
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
render(<SuggestionReader id={id} />);
|
||||||
|
await screen.findByRole("link", { name: "Admin sign-in" });
|
||||||
|
expect(screen.queryByRole("heading", { name: "More railway stations" })).toBeNull();
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it("makes empty message content explicit and retries discussion failures without writes", async () => {
|
||||||
|
let messageReads = 0;
|
||||||
|
const fetcher = vi.fn().mockImplementation(async (url: string) => {
|
||||||
|
if (!url.includes("/messages")) return Response.json({ ...detail, originalPost: { ...original, content: "" } });
|
||||||
|
messageReads += 1;
|
||||||
|
return messageReads === 1 ? new Response(null, { status: 503 }) : Response.json({ items: [], nextCursor: null });
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
render(<SuggestionReader id={id} />);
|
||||||
|
await screen.findByText("No text was returned. View this message in Discord.");
|
||||||
|
await screen.findByRole("alert");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||||
|
await screen.findByText("No replies on this page.");
|
||||||
|
expect(fetcher.mock.calls.every(([, options]) => options.method === undefined || options.method === "GET")).toBe(true);
|
||||||
|
});
|
||||||
|
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||||
|
it("reads suggestion details and safely renders starter text, tags, reactions and Discord links", async () => {
|
||||||
|
const fetcher = vi.fn().mockImplementation(async (url: string) => Response.json(url.includes("/messages") ? { items: [], nextCursor: null } : detail));
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
render(<SuggestionReader id={id} />);
|
||||||
|
expect(screen.getByRole("status").textContent).toContain("Loading suggestion");
|
||||||
|
await screen.findByRole("heading", { name: "More railway stations", level: 1 });
|
||||||
|
expect(screen.getByText(original.content)).toBeTruthy();
|
||||||
|
expect(document.querySelector("script")).toBeNull();
|
||||||
|
expect(screen.getByText("World")).toBeTruthy();
|
||||||
|
expect(screen.getByText("👍 4")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("link", { name: "Open in Discord" }).getAttribute("href")).toBe(detail.discordUrl);
|
||||||
|
expect(fetcher.mock.calls[0]?.[0]).toBe(`/api/suggestions/${id}`);
|
||||||
|
expect(screen.queryByRole("textbox")).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { MessagePage, SuggestionDetail, SuggestionMessage } from "@/lib/discord/suggestion-types";
|
||||||
|
import { SuggestionBadges, SuggestionFailure, SuggestionLoading, SuggestionPagination, suggestionControl, useSuggestionRead, useSuggestionPages, type SuggestionPaging } from "./suggestion-shared";
|
||||||
|
|
||||||
|
export function SuggestionReader({ id }: { id: string }) {
|
||||||
|
const { data, error, retry } = useSuggestionRead<SuggestionDetail>(`/api/suggestions/${id}`);
|
||||||
|
if (error) return <SuggestionFailure error={error} retry={retry} />;
|
||||||
|
if (!data) return <SuggestionLoading>Loading suggestion…</SuggestionLoading>;
|
||||||
|
return <>
|
||||||
|
<header className="border-b border-line pb-8">
|
||||||
|
<p className="mb-5 font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Community proposal / Read-only</p>
|
||||||
|
<SuggestionBadges suggestion={data} />
|
||||||
|
<h1 className="mt-5 break-words font-display text-4xl font-black sm:text-6xl">{data.title}</h1>
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-between gap-5">
|
||||||
|
<p className="break-all font-mono text-[10px] leading-5 text-muted">Thread / {data.id}<br /><time dateTime={data.createdAt}>{data.createdAt}</time></p>
|
||||||
|
<a className={`${suggestionControl} bg-ink text-panel`} href={data.discordUrl} rel="noopener noreferrer" target="_blank">Open in Discord</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section aria-labelledby="original-post-heading" className="mt-10 border border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||||
|
<div className="border-b border-line px-6 py-4"><h2 className="font-mono text-[10px] font-bold uppercase tracking-[0.2em]" id="original-post-heading">Original suggestion</h2></div>
|
||||||
|
{data.originalPost ? <MessageCard message={data.originalPost} /> : <p className="p-6 text-sm text-muted">The original post was deleted or is unavailable.</p>}
|
||||||
|
</section>
|
||||||
|
<Discussion id={id} />
|
||||||
|
<p className="mt-6 text-xs leading-5 text-muted">Discord remains the source of truth. Open Discord for attachments, formatting, replies, and reactions. Message text may be unavailable if the bot lacks Message Content access.</p>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Discussion({ id }: { id: string }) {
|
||||||
|
const paging = useSuggestionPages();
|
||||||
|
const query = new URLSearchParams({ limit: "25" });
|
||||||
|
if (paging.cursor) query.set("cursor", paging.cursor);
|
||||||
|
const url = `/api/suggestions/${id}/messages?${query}`;
|
||||||
|
return <section aria-labelledby="discussion-heading" className="mt-12">
|
||||||
|
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<h2 className="font-display text-3xl font-black uppercase" id="discussion-heading">Discussion</h2>
|
||||||
|
<p className="font-mono text-[10px] uppercase tracking-wider text-muted">Newest messages first / Read-only</p>
|
||||||
|
</header>
|
||||||
|
<DiscussionPage id={id} key={url} paging={paging} url={url} />
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiscussionPage({ id, url, paging }: { id: string; url: string; paging: SuggestionPaging }) {
|
||||||
|
const { data, error, retry } = useSuggestionRead<MessagePage>(url);
|
||||||
|
if (error) return <SuggestionFailure error={error} retry={retry} />;
|
||||||
|
if (!data) return <SuggestionLoading>Loading discussion…</SuggestionLoading>;
|
||||||
|
const replies = data.items.filter((message) => message.id !== id);
|
||||||
|
return <>
|
||||||
|
<div className="divide-y divide-line border border-line bg-panel">
|
||||||
|
{replies.map((message) => <MessageCard key={message.id} message={message} />)}
|
||||||
|
{!replies.length && <p className="p-8 text-sm text-muted">No replies on this page.</p>}
|
||||||
|
</div>
|
||||||
|
<SuggestionPagination nextCursor={data.nextCursor} {...paging} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageCard({ message }: { message: SuggestionMessage }) {
|
||||||
|
return <article className="min-w-0 p-6 sm:p-8">
|
||||||
|
<header className="flex flex-wrap items-baseline justify-between gap-3">
|
||||||
|
<h3 className="break-words font-display text-lg font-black">{message.author.name}</h3>
|
||||||
|
<a aria-label={`Open message by ${message.author.name} in Discord`} className="font-mono text-[10px] text-muted underline underline-offset-4 hover:text-accent" href={message.discordUrl} rel="noopener noreferrer" target="_blank"><time dateTime={message.createdAt}>{message.createdAt}</time></a>
|
||||||
|
</header>
|
||||||
|
<p className="mt-5 whitespace-pre-wrap break-words text-sm leading-7 [overflow-wrap:anywhere]">{message.content || "No text was returned. View this message in Discord."}</p>
|
||||||
|
{message.editedAt && <p className="mt-3 font-mono text-[10px] text-muted">Edited <time dateTime={message.editedAt}>{message.editedAt}</time></p>}
|
||||||
|
{!!message.reactions.length && <ul aria-label="Reaction counts" className="mt-5 flex flex-wrap gap-2">{message.reactions.map((reaction, index) => <li className="border border-line bg-canvas px-3 py-1 font-mono text-xs" key={`${reaction.emoji}-${index}`}>{reaction.emoji} {reaction.count}</li>)}</ul>}
|
||||||
|
</article>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { Suggestion } from "@/lib/discord/suggestion-types";
|
||||||
|
|
||||||
|
export const suggestionControl = "border border-ink px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-wider transition-colors hover:bg-ink hover:text-panel disabled:cursor-not-allowed disabled:opacity-40";
|
||||||
|
|
||||||
|
// Consumers remount on URL changes so previous-page data is never shown as new data.
|
||||||
|
type ReadFailure = { status: number; message: string };
|
||||||
|
class ReadError extends Error { constructor(public status: number) { super("Suggestions request failed"); } }
|
||||||
|
function readFailure(error: unknown): ReadFailure {
|
||||||
|
const status = error instanceof ReadError ? error.status : 0;
|
||||||
|
const messages: Record<number, string> = {
|
||||||
|
401: "Your admin session has expired. Sign in to continue.",
|
||||||
|
403: "Your account does not have the required administrator role.",
|
||||||
|
404: "This suggestion is no longer available in the configured forum.",
|
||||||
|
503: "Discord suggestions are temporarily unavailable or not configured. Try again shortly.",
|
||||||
|
};
|
||||||
|
return { status, message: messages[status] ?? "Suggestions could not be loaded. Try again shortly." };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSuggestionRead<T>(url: string) {
|
||||||
|
const [result, setResult] = useState<{ data: T | null; error: ReadFailure | null }>({ data: null, error: null });
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetch(url, { credentials: "same-origin", cache: "no-store", signal: controller.signal })
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) throw new ReadError(response.status);
|
||||||
|
return await response.json() as T;
|
||||||
|
})
|
||||||
|
.then((data) => { if (!controller.signal.aborted) setResult({ data, error: null }); })
|
||||||
|
.catch((error: unknown) => { if (!controller.signal.aborted) setResult({ data: null, error: readFailure(error) }); });
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [url, attempt]);
|
||||||
|
return { ...result, retry: () => { setResult({ data: null, error: null }); setAttempt((value) => value + 1); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuggestionBadges({ suggestion }: { suggestion: Suggestion }) {
|
||||||
|
return <div className="flex flex-wrap gap-2 font-mono text-[10px] font-bold uppercase tracking-wider">
|
||||||
|
<span className={`border border-ink px-2 py-1 ${suggestion.archived ? "bg-canvas text-muted" : "bg-signal text-ink"}`}>{suggestion.archived ? "Archived" : "Active"}</span>
|
||||||
|
{suggestion.locked && <span className="border border-line px-2 py-1 text-muted">Locked</span>}
|
||||||
|
{suggestion.tags.map((tag) => <span className="border border-line px-2 py-1 text-muted" key={tag.id}>{tag.name}</span>)}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuggestionFailure({ error, retry }: { error: ReadFailure; retry: () => void }) {
|
||||||
|
const authError = error.status === 401 || error.status === 403;
|
||||||
|
return <div className="border-l-4 border-accent bg-panel p-6" role="alert">
|
||||||
|
<h2 className="font-display text-2xl font-black uppercase">{authError ? "Admin access required" : "Unable to load suggestions"}</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-muted">{error.message}</p>
|
||||||
|
<div className="mt-5">{authError ? <Link className="font-mono text-xs font-bold underline underline-offset-4" href="/admin/login">Admin sign-in</Link> : <button className={suggestionControl} onClick={retry} type="button">Try again</button>}</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSuggestionPages() {
|
||||||
|
const [state, setState] = useState<{ cursors: (string | undefined)[]; hasPaged: boolean }>({ cursors: [undefined], hasPaged: false });
|
||||||
|
return {
|
||||||
|
cursor: state.cursors.at(-1), page: state.cursors.length, focusOnMount: state.hasPaged,
|
||||||
|
onNext: (cursor: string) => setState((current) => ({ cursors: [...current.cursors, cursor], hasPaged: true })),
|
||||||
|
onPrevious: () => setState((current) => ({ cursors: current.cursors.length > 1 ? current.cursors.slice(0, -1) : current.cursors, hasPaged: true })),
|
||||||
|
reset: () => setState({ cursors: [undefined], hasPaged: false }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SuggestionPaging = { focusOnMount: boolean; onNext: (cursor: string) => void; onPrevious: () => void; page: number };
|
||||||
|
export function SuggestionPagination({ nextCursor, onNext, onPrevious, page, focusOnMount }: SuggestionPaging & { nextCursor: string | null }) {
|
||||||
|
const label = useRef<HTMLParagraphElement>(null);
|
||||||
|
useEffect(() => { if (focusOnMount) label.current?.focus(); }, [focusOnMount]);
|
||||||
|
return <nav aria-label="Pagination" className="mt-8 flex flex-wrap items-center justify-between gap-3 border-t border-line pt-6">
|
||||||
|
<button className={suggestionControl} disabled={page <= 1} onClick={onPrevious} type="button">Previous page</button>
|
||||||
|
<p aria-live="polite" className="font-mono text-[10px] uppercase tracking-wider text-muted" ref={label} tabIndex={-1}>Page {page}</p>
|
||||||
|
<button className={suggestionControl} disabled={!nextCursor} onClick={() => { if (nextCursor) onNext(nextCursor); }} type="button">Next page</button>
|
||||||
|
</nav>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuggestionLoading({ children }: { children: string }) {
|
||||||
|
return <div className="border border-line bg-panel p-8" role="status"><p className="font-mono text-xs uppercase tracking-widest">{children}</p><div aria-hidden="true" className="mt-6 h-1 w-20 bg-accent" /></div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { SuggestionsBrowser } from "./suggestions-browser";
|
||||||
|
|
||||||
|
const suggestion = { id: "100000000000000009", title: "More railway stations", authorId: "100000000000000003", createdAt: "2026-01-01T00:00:00Z", archived: false, locked: false, tags: [{ id: "100000000000000004", name: "World" }], messageCount: 3, discordUrl: "https://discord.com/channels/100000000000000001/100000000000000009" };
|
||||||
|
it("switches archives, pages forward and back, and resets pagination on status change", async () => {
|
||||||
|
const fetcher = vi.fn().mockImplementation(async (url: string) => {
|
||||||
|
const archived = url.includes("status=archived");
|
||||||
|
const next = url.includes("cursor=");
|
||||||
|
return Response.json({ items: [{ ...suggestion, title: archived ? (next ? "Earlier archived idea" : "Archived idea") : "Active idea", archived }], nextCursor: next ? null : "2026-01-01T00:00:00Z" });
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
render(<SuggestionsBrowser />);
|
||||||
|
await screen.findByRole("link", { name: "Active idea" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Archived" }));
|
||||||
|
await screen.findByRole("link", { name: "Archived idea" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
|
||||||
|
await screen.findByRole("link", { name: "Earlier archived idea" });
|
||||||
|
expect(fetcher.mock.lastCall?.[0]).toContain("cursor=2026-01-01T00%3A00%3A00Z");
|
||||||
|
expect(document.activeElement?.textContent).toBe("Page 2");
|
||||||
|
expect((screen.getByRole("button", { name: "Next page" }) as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Previous page" }));
|
||||||
|
await screen.findByRole("link", { name: "Archived idea" });
|
||||||
|
expect(document.activeElement?.textContent).toBe("Page 1");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Active" }));
|
||||||
|
await screen.findByRole("link", { name: "Active idea" });
|
||||||
|
expect(fetcher.mock.lastCall?.[0]).toBe("/api/suggestions?status=active&limit=25");
|
||||||
|
expect((screen.getByRole("button", { name: "Previous page" }) as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
});
|
||||||
|
it("shows an explicit empty state", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ items: [], nextCursor: null })));
|
||||||
|
render(<SuggestionsBrowser />);
|
||||||
|
expect(await screen.findByText("No suggestions on this page.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
it("retries failed reads and distinguishes expired admin access", async () => {
|
||||||
|
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ detail: "private upstream text" }, { status: 503 })).mockResolvedValueOnce(Response.json({ items: [], nextCursor: null }));
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
render(<SuggestionsBrowser />);
|
||||||
|
expect((await screen.findByRole("alert")).textContent).not.toContain("private upstream text");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||||
|
await screen.findByText("No suggestions on this page.");
|
||||||
|
fetcher.mockResolvedValueOnce(Response.json({}, { status: 401 }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Archived" }));
|
||||||
|
expect((await screen.findByRole("link", { name: "Admin sign-in" })).getAttribute("href")).toBe("/admin/login");
|
||||||
|
expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
|
||||||
|
});
|
||||||
|
it("ignores a late response from the previously selected status", async () => {
|
||||||
|
let resolveActive!: (response: Response) => void;
|
||||||
|
const fetcher = vi.fn().mockImplementationOnce(() => new Promise<Response>((resolve) => { resolveActive = resolve; })).mockResolvedValueOnce(Response.json({ items: [{ ...suggestion, title: "Archived result", archived: true }], nextCursor: null }));
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
render(<SuggestionsBrowser />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Archived" }));
|
||||||
|
await screen.findByRole("link", { name: "Archived result" });
|
||||||
|
resolveActive(Response.json({ items: [suggestion], nextCursor: null }));
|
||||||
|
await waitFor(() => expect(fetcher.mock.calls[0]?.[1].signal.aborted).toBe(true));
|
||||||
|
expect(screen.queryByRole("link", { name: "More railway stations" })).toBeNull();
|
||||||
|
});
|
||||||
|
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||||
|
it("loads and displays suggestions with tags and links through the admin API", async () => {
|
||||||
|
const fetcher = vi.fn().mockResolvedValue(Response.json({ items: [suggestion], nextCursor: null }));
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
render(<SuggestionsBrowser />);
|
||||||
|
expect(screen.getByRole("status").textContent).toContain("Loading suggestions");
|
||||||
|
const link = await screen.findByRole("link", { name: "More railway stations" });
|
||||||
|
expect(link.getAttribute("href")).toBe(`/admin/suggestions/${suggestion.id}`);
|
||||||
|
expect(screen.getByText("World")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("link", { name: "Open More railway stations in Discord" }).getAttribute("href")).toBe(suggestion.discordUrl);
|
||||||
|
expect(fetcher).toHaveBeenCalledWith("/api/suggestions?status=active&limit=25", expect.objectContaining({ credentials: "same-origin", cache: "no-store" }));
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { SuggestionPage } from "@/lib/discord/suggestion-types";
|
||||||
|
import { SuggestionBadges, SuggestionFailure, SuggestionLoading, SuggestionPagination, suggestionControl, useSuggestionRead, useSuggestionPages, type SuggestionPaging } from "./suggestion-shared";
|
||||||
|
|
||||||
|
export function SuggestionsBrowser() {
|
||||||
|
const [status, setStatus] = useState<"active" | "archived">("active");
|
||||||
|
const paging = useSuggestionPages();
|
||||||
|
const query = new URLSearchParams({ status, limit: "25" });
|
||||||
|
if (paging.cursor) query.set("cursor", paging.cursor);
|
||||||
|
const url = `/api/suggestions?${query}`;
|
||||||
|
return <section aria-label="Suggestions" className="mt-10">
|
||||||
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div aria-label="Suggestion status" className="flex gap-2">
|
||||||
|
{(["active", "archived"] as const).map((value) => <button aria-pressed={status === value} className={`${suggestionControl} ${status === value ? "bg-ink text-panel" : "bg-panel"}`} key={value} onClick={() => { setStatus(value); paging.reset(); }} type="button">{value === "active" ? "Active" : "Archived"}</button>)}
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-[10px] uppercase tracking-wider text-muted">{status === "active" ? "Newest ideas first" : "Most recently archived first"}</p>
|
||||||
|
</div>
|
||||||
|
<SuggestionList key={url} paging={paging} url={url} />
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuggestionList({ url, paging }: { url: string; paging: SuggestionPaging }) {
|
||||||
|
const { data, error, retry } = useSuggestionRead<SuggestionPage>(url);
|
||||||
|
if (error) return <SuggestionFailure error={error} retry={retry} />;
|
||||||
|
if (!data) return <SuggestionLoading>Loading suggestions…</SuggestionLoading>;
|
||||||
|
return <><ol className="divide-y divide-line border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||||
|
{!data.items.length && <li className="p-10"><h2 className="font-display text-2xl font-black uppercase">No suggestions on this page.</h2><p className="mt-3 text-sm text-muted">Try the other status, or return to the previous page.</p></li>}
|
||||||
|
{data.items.map((suggestion, index) => <li className="grid gap-5 p-6 sm:grid-cols-[3rem_1fr_auto] sm:p-8" key={suggestion.id}>
|
||||||
|
<span aria-hidden="true" className="font-mono text-sm text-muted">{String(index + 1).padStart(2, "0")}</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<SuggestionBadges suggestion={suggestion} />
|
||||||
|
<h2 className="mt-4 break-words font-display text-2xl font-black sm:text-3xl"><Link className="underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/suggestions/${suggestion.id}`}>{suggestion.title}</Link></h2>
|
||||||
|
<p className="mt-3 break-words font-mono text-[10px] leading-5 text-muted"><time dateTime={suggestion.createdAt}>{suggestion.createdAt}</time><br />Author / {suggestion.authorId}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end justify-between gap-6 sm:flex-col sm:items-end">
|
||||||
|
<p className="font-mono text-[10px] uppercase text-muted">~{suggestion.messageCount} messages</p>
|
||||||
|
<a aria-label={`Open ${suggestion.title} in Discord`} className="font-mono text-[10px] font-bold uppercase underline underline-offset-4 hover:text-accent" href={suggestion.discordUrl} rel="noopener noreferrer" target="_blank">Discord ↗</a>
|
||||||
|
</div>
|
||||||
|
</li>)}
|
||||||
|
</ol><SuggestionPagination nextCursor={data.nextCursor} {...paging} /></>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
|
||||||
|
import { exportJWK, generateKeyPair, SignJWT, type JWTPayload } from "jose";
|
||||||
|
|
||||||
|
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||||
|
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||||
|
vi.mock("./admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||||
|
const issuer = "https://sso.example/realms/operators";
|
||||||
|
const audience = "portal-admin";
|
||||||
|
let keys: Awaited<ReturnType<typeof generateKeyPair>>;
|
||||||
|
let jwks: { keys: unknown[] };
|
||||||
|
let authorize: typeof import("./admin-api-auth").authorizeAdminApi;
|
||||||
|
const fetcher = vi.fn();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
keys = await generateKeyPair("RS256");
|
||||||
|
jwks = { keys: [{ ...await exportJWK(keys.publicKey), kid: "test-key", alg: "RS256", use: "sig" }] };
|
||||||
|
});
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.stubEnv("KEYCLOAK_ISSUER_URL", issuer);
|
||||||
|
vi.stubEnv("KEYCLOAK_CLIENT_ID", audience);
|
||||||
|
vi.stubEnv("KEYCLOAK_CLIENT_SECRET", ""); // Machine verification needs no client secret.
|
||||||
|
auth.session.mockResolvedValue({ user: { name: "Browser Admin", email: "admin@example.test", roles: ["ops"] } });
|
||||||
|
fetcher.mockImplementation(async () => Response.json(jwks));
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
authorize = (await import("./admin-api-auth")).authorizeAdminApi;
|
||||||
|
});
|
||||||
|
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
|
||||||
|
|
||||||
|
async function token(overrides: JWTPayload = {}, header: Record<string, unknown> = {}) {
|
||||||
|
return new SignJWT({
|
||||||
|
iss: issuer, aud: audience, sub: "machine-subject", exp: Math.floor(Date.now() / 1000) + 300,
|
||||||
|
resource_access: { [audience]: { roles: ["ops"] } },
|
||||||
|
...overrides,
|
||||||
|
}).setProtectedHeader({ alg: "RS256", kid: "test-key", ...header }).sign(keys.privateKey);
|
||||||
|
}
|
||||||
|
function request(bearer: string) {
|
||||||
|
return new Request("https://portal.example/api/admin/whoami", { headers: { authorization: `Bearer ${bearer}` } });
|
||||||
|
}
|
||||||
|
async function rejected(bearer: string, status = 401) {
|
||||||
|
const result = await authorize(request(bearer));
|
||||||
|
expect(result.identity).toBeUndefined();
|
||||||
|
expect(result.response?.status).toBe(status);
|
||||||
|
expect(result.response?.headers.get("content-type")).toBe("application/problem+json");
|
||||||
|
expect(result.response?.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(result.response?.headers.get("www-authenticate")).toBe(status === 401 ? 'Bearer realm="admin-api"' : null);
|
||||||
|
expect(await result.response?.json()).toMatchObject({ status, instance: "/api/admin/whoami" });
|
||||||
|
expect(auth.session).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("verifies real RS256 signatures and caches only the configured issuer JWKS", async () => {
|
||||||
|
const signed = await token({ email: "private@example.test", name: "Private", arbitrary: "private" }, { jku: "https://attacker.example/keys" });
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
expect(await authorize(request(signed))).toEqual({ identity: {
|
||||||
|
authenticationMethod: "bearer", subject: "machine-subject", name: null, email: null,
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
expect(auth.session).not.toHaveBeenCalled();
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(fetcher.mock.calls[0]?.[0])).toBe(`${issuer}/protocol/openid-connect/certs`);
|
||||||
|
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual", method: "GET" });
|
||||||
|
});
|
||||||
|
it("accepts an audience list and case-insensitive bearer scheme", async () => {
|
||||||
|
const signed = await token({ aud: ["other", audience] });
|
||||||
|
const req = new Request(request(signed), { headers: { authorization: `bEaReR ${signed}` } });
|
||||||
|
expect((await authorize(req)).identity?.subject).toBe("machine-subject");
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
["expired", { exp: 1 }], ["missing expiry", { exp: undefined }],
|
||||||
|
["missing subject", { sub: undefined }], ["empty subject", { sub: "" }], ["blank subject", { sub: " " }],
|
||||||
|
["future nbf", { nbf: 9999999999 }], ["wrong issuer", { iss: "https://attacker.example" }],
|
||||||
|
["wrong audience", { aud: "another-client" }], ["missing audience", { aud: undefined }],
|
||||||
|
] satisfies [string, JWTPayload][])("rejects %s despite an available privileged browser session", async (_name, claims) => {
|
||||||
|
await rejected(await token(claims));
|
||||||
|
});
|
||||||
|
it("rejects tampering with a signed payload", async () => {
|
||||||
|
const parts = (await token()).split(".");
|
||||||
|
const payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString());
|
||||||
|
parts[1] = Buffer.from(JSON.stringify({ ...payload, sub: "tampered" })).toString("base64url");
|
||||||
|
await rejected(parts.join("."));
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
undefined, {}, { [audience]: { roles: [] } }, { another: { roles: ["ops"] } },
|
||||||
|
{ [audience]: { roles: "ops" } }, { [audience]: { roles: ["player"] } },
|
||||||
|
])("requires the configured client role, never a realm role (%j)", async (resource_access) => {
|
||||||
|
await rejected(await token({ resource_access, realm_access: { roles: ["ops"] } }), 403);
|
||||||
|
});
|
||||||
|
it("rejects HS256 algorithm confusion", async () => {
|
||||||
|
const signed = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: 9999999999 })
|
||||||
|
.setProtectedHeader({ alg: "HS256", kid: "test-key" }).sign(new TextEncoder().encode("test-only-key-with-at-least-32-bytes"));
|
||||||
|
await rejected(signed);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("rejects an otherwise valid but non-allowlisted asymmetric algorithm", async () => {
|
||||||
|
const ec = await generateKeyPair("ES256");
|
||||||
|
fetcher.mockImplementation(async () => Response.json({ keys: [{ ...await exportJWK(ec.publicKey), kid: "ec-key" }] }));
|
||||||
|
const signed = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: 9999999999 })
|
||||||
|
.setProtectedHeader({ alg: "ES256", kid: "ec-key" }).sign(ec.privateKey);
|
||||||
|
await rejected(signed);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("rejects an unknown signing key", async () => { await rejected(await token({}, { kid: "unknown" })); });
|
||||||
|
it("rejects a signature from an untrusted key even when its kid matches", async () => {
|
||||||
|
const other = await generateKeyPair("RS256");
|
||||||
|
const signed = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: 9999999999 })
|
||||||
|
.setProtectedHeader({ alg: "RS256", kid: "test-key" }).sign(other.privateKey);
|
||||||
|
await rejected(signed);
|
||||||
|
});
|
||||||
|
it("coalesces concurrent JWKS reads and refreshes rotated keys after cooldown", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
const signed = await token();
|
||||||
|
const results = await Promise.all(Array.from({ length: 8 }, () => authorize(request(signed))));
|
||||||
|
expect(results.every((result) => result.identity?.subject === "machine-subject")).toBe(true);
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
const rotated = await generateKeyPair("RS256");
|
||||||
|
fetcher.mockImplementation(async () => Response.json({ keys: [{ ...await exportJWK(rotated.publicKey), kid: "rotated-key", alg: "RS256" }] }));
|
||||||
|
const next = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: Math.floor(Date.now() / 1000) + 300, resource_access: { [audience]: { roles: ["ops"] } } })
|
||||||
|
.setProtectedHeader({ alg: "RS256", kid: "rotated-key" }).sign(rotated.privateKey);
|
||||||
|
await rejected(next);
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
vi.setSystemTime(Date.now() + 31_000);
|
||||||
|
expect((await authorize(request(next))).identity?.subject).toBe("machine");
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
it.each(["redirect", "non-JSON", "invalid JWKS", "HTTP failure"])("fails closed on a %s JWKS response", async (kind) => {
|
||||||
|
fetcher.mockImplementation(async () => {
|
||||||
|
if (kind === "redirect") return new Response(null, { status: 302, headers: { location: "https://attacker.example/keys" } });
|
||||||
|
if (kind === "non-JSON") return new Response("private malformed response");
|
||||||
|
if (kind === "invalid JWKS") return Response.json({ keys: "private malformed keys" });
|
||||||
|
return new Response("private upstream failure", { status: 500 });
|
||||||
|
});
|
||||||
|
await rejected(await token(), 503);
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it("fails closed and sanitizes a JWKS transport outage", async () => {
|
||||||
|
fetcher.mockRejectedValue(new Error("private network details"));
|
||||||
|
const result = await authorize(request(await token()));
|
||||||
|
expect(result.response?.status).toBe(503);
|
||||||
|
expect(await result.response?.text()).not.toContain("private");
|
||||||
|
expect(auth.session).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it.each(["", "http://sso.example/realms/operators", "not a URL", `${issuer}?query=1`, "https://user:password@sso.example/realm"])("fails closed on unsafe/missing issuer configuration %s", async (value) => {
|
||||||
|
vi.stubEnv("KEYCLOAK_ISSUER_URL", value);
|
||||||
|
await rejected(await token(), 503);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("fails closed on missing client ID", async () => {
|
||||||
|
vi.stubEnv("KEYCLOAK_CLIENT_ID", "");
|
||||||
|
await rejected(await token(), 503);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it.each([null, { user: {} }, { user: { roles: ["player"] } }])("preserves missing/unauthorized browser behavior (%j)", async (session) => {
|
||||||
|
auth.session.mockResolvedValue(session);
|
||||||
|
const result = await authorize(new Request("https://portal.example/api/admin/whoami"));
|
||||||
|
expect(result.response?.status).toBe(session ? 403 : 401);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("preserves browser identity without requiring machine configuration", async () => {
|
||||||
|
vi.stubEnv("KEYCLOAK_ISSUER_URL", "");
|
||||||
|
expect(await authorize(new Request("https://portal.example/api/admin/whoami"))).toEqual({ identity: {
|
||||||
|
authenticationMethod: "session", subject: null, name: "Browser Admin", email: "admin@example.test",
|
||||||
|
} });
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { createRemoteJWKSet, errors, jwtVerify } from "jose";
|
||||||
|
import { problemDetails } from "@minecraft-account-manager/contracts";
|
||||||
|
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||||
|
import { adminAuthOptions, requiredAdminRole } from "./admin-auth";
|
||||||
|
|
||||||
|
export type AdminApiIdentity = {
|
||||||
|
authenticationMethod: "session" | "bearer";
|
||||||
|
subject: string | null;
|
||||||
|
name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
};
|
||||||
|
type Authorization = { identity: AdminApiIdentity; response?: never } | { response: Response; identity?: never };
|
||||||
|
|
||||||
|
function failure(request: Request, status: 401 | 403 | 503): Authorization {
|
||||||
|
const problems = {
|
||||||
|
401: ["unauthorized", "Authentication required", "Supply valid administrator credentials."],
|
||||||
|
403: ["forbidden", "Administrator role required", "This API is restricted to administrators."],
|
||||||
|
503: ["admin-auth-unavailable", "Authentication unavailable", "Administrator authentication is temporarily unavailable."],
|
||||||
|
} as const;
|
||||||
|
const [code, title, detail] = problems[status];
|
||||||
|
const response = problemResponse(problemDetails(`urn:error:${code}`, title, status, detail, problemInstance(request)));
|
||||||
|
if (status === 401) response.headers.set("www-authenticate", 'Bearer realm="admin-api"');
|
||||||
|
return { response };
|
||||||
|
}
|
||||||
|
|
||||||
|
// One bounded, process-local resolver. jose coalesces fetches and refreshes rotated keys.
|
||||||
|
let remote: { issuer: string; keys: ReturnType<typeof createRemoteJWKSet> } | undefined;
|
||||||
|
function bearerConfiguration() {
|
||||||
|
const issuer = process.env.KEYCLOAK_ISSUER_URL?.trim() ?? "";
|
||||||
|
const audience = process.env.KEYCLOAK_CLIENT_ID?.trim() ?? "";
|
||||||
|
const url = new URL(issuer);
|
||||||
|
if (!audience || url.protocol !== "https:" || url.username || url.password || url.search || url.hash) {
|
||||||
|
throw new Error("Invalid administrator authentication configuration");
|
||||||
|
}
|
||||||
|
if (!remote || remote.issuer !== issuer) {
|
||||||
|
// Never discover a key URL from untrusted token claims or headers (jku/x5u/iss).
|
||||||
|
const jwksUrl = new URL(`${issuer.replace(/\/$/, "")}/protocol/openid-connect/certs`);
|
||||||
|
remote = { issuer, keys: createRemoteJWKSet(jwksUrl, {
|
||||||
|
timeoutDuration: 5_000, cooldownDuration: 30_000, cacheMaxAge: 600_000,
|
||||||
|
}) };
|
||||||
|
}
|
||||||
|
return { issuer, audience, keys: remote.keys };
|
||||||
|
}
|
||||||
|
function record(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
async function authorizeBearer(request: Request): Promise<Authorization> {
|
||||||
|
const match = /^Bearer +([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/i.exec(request.headers.get("authorization") ?? "");
|
||||||
|
if (!match) return failure(request, 401);
|
||||||
|
let configuration: ReturnType<typeof bearerConfiguration>;
|
||||||
|
try {
|
||||||
|
configuration = bearerConfiguration();
|
||||||
|
} catch {
|
||||||
|
return failure(request, 503);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { issuer, audience, keys } = configuration;
|
||||||
|
const { payload } = await jwtVerify(match[1]!, keys, {
|
||||||
|
issuer, audience, algorithms: ["RS256"], requiredClaims: ["exp", "sub"],
|
||||||
|
});
|
||||||
|
if (typeof payload.sub !== "string" || !payload.sub.trim()) return failure(request, 401);
|
||||||
|
const access = payload.resource_access;
|
||||||
|
const client = record(access) && Object.hasOwn(access, audience) ? access[audience] : undefined;
|
||||||
|
const roles = record(client) ? client.roles : undefined;
|
||||||
|
if (!Array.isArray(roles) || !roles.includes(requiredAdminRole)) return failure(request, 403);
|
||||||
|
return { identity: { authenticationMethod: "bearer", subject: payload.sub, name: null, email: null } };
|
||||||
|
} catch (error) {
|
||||||
|
// Verification failures are invalid credentials; transport/configuration failures are unavailable.
|
||||||
|
const invalid = error instanceof errors.JWTClaimValidationFailed || error instanceof errors.JWTExpired
|
||||||
|
|| error instanceof errors.JWSInvalid || error instanceof errors.JWTInvalid
|
||||||
|
|| error instanceof errors.JWSSignatureVerificationFailed || error instanceof errors.JOSEAlgNotAllowed
|
||||||
|
|| error instanceof errors.JWKSNoMatchingKey || error instanceof errors.JOSENotSupported;
|
||||||
|
return failure(request, invalid ? 401 : 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API-only authorization; never use bearer tokens to authorize browser actions. */
|
||||||
|
export async function authorizeAdminApi(request: Request): Promise<Authorization> {
|
||||||
|
// Presence, including an empty/unsupported header, is authoritative. Never fall back.
|
||||||
|
if (request.headers.has("authorization")) return authorizeBearer(request);
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(adminAuthOptions);
|
||||||
|
if (!session) return failure(request, 401);
|
||||||
|
const roles = (session.user as { roles?: unknown } | undefined)?.roles;
|
||||||
|
if (!Array.isArray(roles) || !roles.includes(requiredAdminRole)) return failure(request, 403);
|
||||||
|
return { identity: {
|
||||||
|
authenticationMethod: "session", subject: null,
|
||||||
|
name: session.user?.name ?? null, email: session.user?.email ?? null,
|
||||||
|
} };
|
||||||
|
} catch {
|
||||||
|
return failure(request, 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { problemDetails } from "@minecraft-account-manager/contracts";
|
import { problemDetails } from "@minecraft-account-manager/contracts";
|
||||||
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
|
import { authorizeAdminApi } from "@/lib/auth/admin-api-auth";
|
||||||
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||||
import { createSuggestionsClient, SuggestionsError } from "./suggestions";
|
import { createSuggestionsClient, SuggestionsError } from "./suggestions";
|
||||||
|
|
||||||
@@ -17,11 +16,9 @@ function getClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function suggestionsApi(request: Request, operation: (client: Client) => Promise<unknown>) {
|
export async function suggestionsApi(request: Request, operation: (client: Client) => Promise<unknown>) {
|
||||||
|
const authorization = await authorizeAdminApi(request);
|
||||||
|
if (authorization.response) return authorization.response;
|
||||||
try {
|
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" } });
|
return Response.json(await operation(getClient()), { headers: { "cache-control": "no-store" } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const safe = error instanceof SuggestionsError ? error : new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
|
const safe = error instanceof SuggestionsError ? error : new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11,
|
|||||||
function setup(responses: Record<string, unknown>) {
|
function setup(responses: Record<string, unknown>) {
|
||||||
const fetcher = vi.fn<typeof fetch>(async (input) => {
|
const fetcher = vi.fn<typeof fetch>(async (input) => {
|
||||||
const path = String(input).replace("https://discord.com/api/v10", "");
|
const path = String(input).replace("https://discord.com/api/v10", "");
|
||||||
|
if (path.includes("/threads/archived/public") && Number(new URL(String(input)).searchParams.get("limit")) < 2) {
|
||||||
|
return Response.json({ code: 50035, message: "Invalid Form Body", errors: { limit: { _errors: [{ code: "NUMBER_TYPE_MIN", message: "int value should be greater than or equal to 2." }] } } }, { status: 400 });
|
||||||
|
}
|
||||||
if (!(path in responses)) throw new Error(`Unexpected path: ${path}`);
|
if (!(path in responses)) throw new Error(`Unexpected path: ${path}`);
|
||||||
const value = responses[path];
|
const value = responses[path];
|
||||||
return value instanceof Response ? value : Response.json(value);
|
return value instanceof Response ? value : Response.json(value);
|
||||||
@@ -19,13 +22,99 @@ it("reads archived forum pages using Discord's archive timestamp cursor", async
|
|||||||
const cursor = "2026-01-01T00:00:00Z";
|
const cursor = "2026-01-01T00:00:00Z";
|
||||||
const { client } = setup({
|
const { client } = setup({
|
||||||
[`/channels/${forumId}`]: forum,
|
[`/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=2`]: { 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 },
|
[`/channels/${forumId}/threads/archived/public?limit=2&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 })).toMatchObject({ items: [{ archived: true }], nextCursor: cursor });
|
||||||
expect(await client.list({ status: "archived", limit: 1, cursor })).toEqual({ items: [], nextCursor: null });
|
expect(await client.list({ status: "archived", limit: 1, cursor })).toEqual({ items: [], nextCursor: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([true, false])("returns one archived post without skipping buffered posts when has_more=%s", async (hasMore) => {
|
||||||
|
const cursor = "2026-01-01T00:00:00.123456Z";
|
||||||
|
const newest = { ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: "2026-01-01T00:00:00.123456+00:00" } };
|
||||||
|
const older = { ...newest, id: "100000000000000008", thread_metadata: { ...newest.thread_metadata, archive_timestamp: "2026-01-01T00:00:00.123455+00:00" } };
|
||||||
|
const { client } = setup({
|
||||||
|
[`/channels/${forumId}`]: forum,
|
||||||
|
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [newest, older], has_more: hasMore },
|
||||||
|
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [older], has_more: false },
|
||||||
|
});
|
||||||
|
const first = await client.list({ status: "archived", limit: 1 });
|
||||||
|
expect(first.items.map(({ id }) => id)).toEqual([newest.id]);
|
||||||
|
expect(first.nextCursor).toBe(cursor);
|
||||||
|
const last = await client.list({ status: "archived", limit: 1, cursor: first.nextCursor! });
|
||||||
|
expect(last.items.map(({ id }) => id)).toEqual([older.id]);
|
||||||
|
expect(last.nextCursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([1, 2, 25, 100])("preserves archive request bounds and terminal pages for limit=%s", async (limit) => {
|
||||||
|
const { client } = setup({
|
||||||
|
[`/channels/${forumId}`]: forum,
|
||||||
|
[`/channels/${forumId}/threads/archived/public?limit=${Math.max(2, limit)}`]: { threads: [thread], has_more: false },
|
||||||
|
});
|
||||||
|
const result = await client.list({ status: "archived", limit });
|
||||||
|
expect(result.items.map(({ id }) => id)).toEqual([threadId]);
|
||||||
|
expect(result.nextCursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])("does not invent a cursor for an empty archive response with has_more=%s", async (hasMore) => {
|
||||||
|
const { client } = setup({
|
||||||
|
[`/channels/${forumId}`]: forum,
|
||||||
|
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [], has_more: hasMore },
|
||||||
|
});
|
||||||
|
expect(await client.list({ status: "archived", limit: 1 })).toEqual({ items: [], nextCursor: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ parent_id: "100000000000000099" },
|
||||||
|
{ guild_id: "100000000000000099" },
|
||||||
|
{ type: 12 },
|
||||||
|
])("filters unrelated archives before slicing and choosing a continuation: %j", async (outside) => {
|
||||||
|
const cursor = "2026-01-01T00:00:00Z";
|
||||||
|
const unrelated = { ...thread, ...outside, id: "100000000000000007", thread_metadata: { ...thread.thread_metadata, archive_timestamp: "2025-12-31T00:00:00Z" } };
|
||||||
|
const newerUnrelated = { ...unrelated, thread_metadata: { ...unrelated.thread_metadata, archive_timestamp: "2026-01-02T00:00:00Z" } };
|
||||||
|
for (const threads of [[newerUnrelated, thread], [thread, unrelated]]) {
|
||||||
|
for (const hasMore of [false, true]) {
|
||||||
|
const { client } = setup({
|
||||||
|
[`/channels/${forumId}`]: forum,
|
||||||
|
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads, has_more: hasMore },
|
||||||
|
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
|
||||||
|
});
|
||||||
|
const result = await client.list({ status: "archived", limit: 1 });
|
||||||
|
expect(result.items.map(({ id }) => id)).toEqual([threadId]);
|
||||||
|
expect(result.nextCursor).toBe(hasMore ? cursor : null);
|
||||||
|
if (result.nextCursor) expect(await client.list({ status: "archived", limit: 1, cursor: result.nextCursor })).toEqual({ items: [], nextCursor: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])("preserves progress for a fully filtered archive page with has_more=%s", async (hasMore) => {
|
||||||
|
const cursor = "2026-01-01T00:00:00Z";
|
||||||
|
const { client } = setup({
|
||||||
|
[`/channels/${forumId}`]: forum,
|
||||||
|
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [{ ...thread, parent_id: "100000000000000099" }], has_more: hasMore },
|
||||||
|
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archive_timestamp: "2025-12-31T00:00:00Z" } }], has_more: false },
|
||||||
|
});
|
||||||
|
const result = await client.list({ status: "archived", limit: 1 });
|
||||||
|
expect(result).toEqual({ items: [], nextCursor: hasMore ? cursor : null });
|
||||||
|
if (result.nextCursor) {
|
||||||
|
const next = await client.list({ status: "archived", limit: 1, cursor: result.nextCursor });
|
||||||
|
expect(next.items.map(({ id }) => id)).toEqual([threadId]);
|
||||||
|
expect(next.nextCursor).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes offset archive timestamps without losing cursor precision", async () => {
|
||||||
|
const raw = "2026-01-01T00:00:00.123456+00:00";
|
||||||
|
const cursor = "2026-01-01T00:00:00.123456Z";
|
||||||
|
const { client } = setup({
|
||||||
|
[`/channels/${forumId}`]: forum,
|
||||||
|
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: raw } }], has_more: true },
|
||||||
|
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
|
||||||
|
});
|
||||||
|
const first = await client.list({ status: "archived", limit: 1 });
|
||||||
|
expect(first.nextCursor).toBe(cursor);
|
||||||
|
expect(await client.list({ status: "archived", limit: 1, cursor: first.nextCursor! })).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 }] };
|
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 () => {
|
it("returns the starter post and paginates discussion with authors and reactions", async () => {
|
||||||
const { client } = setup({
|
const { client } = setup({
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ function timestamp(value: string | number) {
|
|||||||
return new Date(value).toISOString().replace(/\.\d{3}Z$/, "Z");
|
return new Date(value).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function archiveCursor(value: string) {
|
||||||
|
// Discord timestamps may use +00:00 and microseconds. Date alone truncates
|
||||||
|
// that precision, potentially skipping posts at the archive page boundary.
|
||||||
|
const fraction = value.match(/\.(\d{1,6})(?:Z|[+-]\d{2}:\d{2})$/)?.[1];
|
||||||
|
return new Date(value).toISOString().replace(/\.\d{3}Z$/, fraction && /[1-9]/.test(fraction) ? `.${fraction}Z` : "Z");
|
||||||
|
}
|
||||||
|
|
||||||
export function createSuggestionsClient(options: { token: string; guildId: string; forumId: string; fetch?: typeof fetch }) {
|
export function createSuggestionsClient(options: { token: string; guildId: string; forumId: string; fetch?: typeof fetch }) {
|
||||||
const { token, guildId, forumId } = options;
|
const { token, guildId, forumId } = options;
|
||||||
const fetcher = options.fetch ?? fetch;
|
const fetcher = options.fetch ?? fetch;
|
||||||
@@ -141,10 +148,15 @@ export function createSuggestionsClient(options: { token: string; guildId: strin
|
|||||||
const forum = await getForum();
|
const forum = await getForum();
|
||||||
if (status === "archived") {
|
if (status === "archived") {
|
||||||
const before = query.cursor ? `&before=${encodeURIComponent(query.cursor)}` : "";
|
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}`);
|
const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${Math.max(2, limit)}${before}`);
|
||||||
|
const threads = data.threads.filter(belongs);
|
||||||
|
const page = threads.slice(0, limit);
|
||||||
|
// Resume after the last returned post, not the extra post fetched for
|
||||||
|
// Discord's minimum limit. Fully filtered pages must still advance.
|
||||||
|
const last = page.at(-1) ?? data.threads.at(-1);
|
||||||
return {
|
return {
|
||||||
items: data.threads.filter(belongs).map((thread) => summary(thread, forum)),
|
items: page.map((thread) => summary(thread, forum)),
|
||||||
nextCursor: data.has_more && data.threads.length ? data.threads.at(-1)!.thread_metadata.archive_timestamp.replace(/\.000Z$/, "Z") : null,
|
nextCursor: (data.has_more || threads.length > limit) && last ? archiveCursor(last.thread_metadata.archive_timestamp) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const data = await get<{ threads: Thread[] }>(`/guilds/${guildId}/threads/active`);
|
const data = await get<{ threads: Thread[] }>(`/guilds/${guildId}/threads/active`);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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")));
|
||||||
|
});
|
||||||
@@ -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<string>();
|
||||||
|
for (const [path, item] of Object.entries(doc.paths) as [string, Record<string, Record<string, unknown>>][]) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
type ResponseContract = {
|
||||||
|
headers?: Record<string, { schema: Schema; description?: string }>;
|
||||||
|
content?: Record<string, { schema: Schema; examples?: Record<string, { value: unknown }> }>;
|
||||||
|
};
|
||||||
|
type Operation = { security?: Record<string, string[]>[]; responses: Record<string, ResponseContract>; requestBody?: { content: Record<string, { schema: Schema; examples?: Record<string, { value: unknown }> }> } };
|
||||||
|
export type Contract = { paths: Record<string, Record<string, Operation>> };
|
||||||
|
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
||||||
|
addFormats(ajv);
|
||||||
|
let contract: Promise<Contract> | 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);
|
||||||
|
}
|
||||||
@@ -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<void>((done) => socket.close(() => done()));
|
||||||
|
let child: ReturnType<typeof spawn> | 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<void>((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);
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# 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. The canonical [OpenAPI 3.1 contract](../openapi.yaml) is public at `/openapi.yaml`; see [contract maintenance](openapi.md).
|
||||||
|
|
||||||
|
## Credential selection
|
||||||
|
|
||||||
|
- **No Authorization header:** use the existing NextAuth administrator session and its configured role check. Player sessions do not qualify. Existing browser realm/client role behavior remains intact.
|
||||||
|
- **Any Authorization header supplied:** exclusively use bearer authentication. Empty, malformed, duplicated/combined, unsupported, expired, or otherwise invalid credentials never fall back to a browser session, including a privileged session cookie.
|
||||||
|
- The bearer scheme is case-insensitive. Send one compact signed JWT, not a client secret, bot token, or refresh token.
|
||||||
|
|
||||||
|
## Machine verification
|
||||||
|
|
||||||
|
The web application directly depends on `jose` 6. Verification uses real cryptographic signature checking, not decoded-token role extraction:
|
||||||
|
|
||||||
|
- Only **RS256** is accepted.
|
||||||
|
- `iss` must exactly equal the trimmed `KEYCLOAK_ISSUER_URL` configuration.
|
||||||
|
- `aud` must contain `KEYCLOAK_CLIENT_ID` (a string or audience array is supported). `azp` does not substitute for `aud`.
|
||||||
|
- `exp` and a nonblank string `sub` are required. Expired tokens and future `nbf` are rejected with no added clock tolerance.
|
||||||
|
- The required role (configured `KEYCLOAK_REQUIRED_ROLE`, default `minecraft-account-manager-admin`) must appear in `resource_access[KEYCLOAK_CLIENT_ID].roles`. Realm roles or roles for other clients are not accepted.
|
||||||
|
- The configured issuer must be HTTPS with no embedded credentials, query, or fragment. Keys come only from `<issuer-without-final-slash>/protocol/openid-connect/certs`; token `iss`, `jku`, and `x5u` never select a key URL. Redirects are not followed.
|
||||||
|
|
||||||
|
One bounded, process-local remote JWKS resolver caches keys for ten minutes, coalesces concurrent fetches, permits refresh for unknown keys after a 30-second cooldown, and bounds each network fetch to five seconds. A changed configured issuer replaces the resolver. Replicas do not share the cache. Key rotation can temporarily reject a new key during cooldown; removed keys may remain usable until the cache refreshes. Access-token validity is local JWT verification, not per-request revocation/introspection. Use suitably short token lifetimes and synchronized clocks.
|
||||||
|
|
||||||
|
`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 `<KEYCLOAK_ISSUER_URL-without-final-slash>/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`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authenticationMethod": "bearer",
|
||||||
|
"subject": "machine-subject",
|
||||||
|
"name": null,
|
||||||
|
"email": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For browser sessions, `authenticationMethod` is `session`, `subject` is `null` (the existing session does not expose it), and `name`/`email` are the existing session values or `null`. Machine profile claims are not returned. No raw token, role list, session expiry, key material, client secret, or arbitrary claims are exposed. This endpoint requires no Discord or database access.
|
||||||
|
|
||||||
|
## Failures
|
||||||
|
|
||||||
|
All guard failures use RFC 9457 `application/problem+json`, `Cache-Control: no-store`, a matching HTTP/body status, and a request-path instance. There are no sign-in redirects or token/error-detail logs.
|
||||||
|
|
||||||
|
| Status | Type | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 401 | `urn:error:unauthorized` | Missing session or invalid supplied credentials; includes `WWW-Authenticate: Bearer realm="admin-api"`. |
|
||||||
|
| 403 | `urn:error:forbidden` | Authenticated identity lacks the required permission. |
|
||||||
|
| 503 | `urn:error:admin-auth-unavailable` | Invalid/missing machine configuration, JWKS transport/format failure, or browser session service failure. |
|
||||||
|
|
||||||
|
Authentication is checked before suggestions cache access or Discord requests. Valid machine credentials do not enable writes: suggestions write methods remain 405. Errors do not reveal credentials, raw claims, upstream response bodies, or exception messages.
|
||||||
|
|
||||||
|
## Verification and rollout boundary
|
||||||
|
|
||||||
|
Focused coverage lives in:
|
||||||
|
|
||||||
|
- `apps/web/src/lib/auth/admin-api-auth.test.ts`: real signed JWTs, controlled JWKS HTTP transport (not mocked `jwtVerify`), validation failures, client-role isolation, configuration/network safety, caching, concurrent reads, and rotation.
|
||||||
|
- `apps/web/src/app/api/admin/whoami/route.test.ts`: safe identity projection and guard failures through the route.
|
||||||
|
- `apps/web/src/app/api/suggestions/{route,machine-auth}.test.ts`: browser regression, bearer precedence, all three read routes, cache authorization, and read-only behavior.
|
||||||
|
|
||||||
|
Run from the source repository:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test --workspace @minecraft-account-manager/web -- src/lib/auth/admin-api-auth.test.ts src/app/api/admin/whoami/route.test.ts src/app/api/suggestions
|
||||||
|
npm test
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
npm run velocity:build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test-first implementation evidence (US-025)
|
||||||
|
|
||||||
|
The following runs were observed against the local implementation; no commit or publication is part of this task:
|
||||||
|
|
||||||
|
| Slice / focused test arguments after `npm test --workspace @minecraft-account-manager/web --` | Red before implementation | Green after implementation |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src/app/api/suggestions/route.test.ts` | 6 failures: supplied headers fell through to the browser path (503 instead of 401), and missing-session 401 lacked the challenge. | 21 passing after shared-guard integration. |
|
||||||
|
| `src/lib/auth/admin-api-auth.test.ts` | 15 failures: valid signed tokens were rejected; client-role and unavailable-service responses were not implemented. | 53 passing with the suggestions regression suite after actual JWT/JWKS verification. |
|
||||||
|
| `src/app/api/admin/whoami/route.test.ts` | 6 explicit route-absence assertion failures (suite ran without a broken module import). | 59 passing across all three suites after adding the route; route discovery then refactored to a direct import. |
|
||||||
|
|
||||||
|
Additional integration/resilience regression coverage brought the focused suite to **72 passing tests**: every suggestions read route, authorization before cached reads, unchanged write denial, untrusted signing keys, algorithm restrictions, concurrent JWKS fetching, key rotation, malformed upstream responses, and redirect refusal. Negative-token tests use an available privileged browser session to verify that invalid bearer credentials never fall back. The tests sign actual JWTs and exercise `jose` verification against controlled transport responses; `jwtVerify` is never mocked.
|
||||||
|
|
||||||
|
Full local verification passed: `npm test` (**252 tests**, including 214 web tests), `npm run lint` (zero errors; two existing navigation warnings in unchanged `map-view-toggle.tsx`), `npm run typecheck`, `npm run build` (whoami emitted as a dynamic route), and `npm run velocity:build` (`clean test shadowJar`, Java 17). The unrelated `next-env.d.ts` addition generated by Next.js during the build was removed to keep the source diff scoped.
|
||||||
|
|
||||||
|
Security scan: `semgrep scan --config p/typescript --config p/jwt --metrics=off` on the shared guard, suggestions wrapper, and whoami implementation completed with **74 rules, three files, zero findings**. The initial `--config auto --metrics=off` invocation was rejected by Semgrep; the explicit-rule run is the successful result. This is scoped static-analysis evidence, not a complete security audit.
|
||||||
|
|
||||||
|
Offline tests do not establish live Keycloak audience/role issuance, JWKS reachability, or production authorization. Production deployment remains separately gated. After explicit release approval, verify machine whoami and suggestions reads, rejection of an unauthorized identity, and browser session access using approved credential handling (never token values in chat, command arguments, or logs).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Admin OIDC setup
|
# Admin OIDC setup
|
||||||
|
|
||||||
The admin console will use Keycloak OIDC and JWT-backed Auth.js sessions, following the established pattern in the sibling Retro application.
|
The admin console uses Keycloak OIDC and JWT-backed NextAuth sessions.
|
||||||
|
|
||||||
## Application environment
|
## Application environment
|
||||||
|
|
||||||
@@ -21,4 +21,10 @@ Allow exact callback and logout URLs for each environment. Avoid wildcard origin
|
|||||||
|
|
||||||
Create the realm role `minecraft-account-manager-admin` and assign it directly or through an admin group. Ensure realm roles are emitted in `realm_access.roles`.
|
Create the realm role `minecraft-account-manager-admin` and assign it directly or through an admin group. Ensure realm roles are emitted in `realm_access.roles`.
|
||||||
|
|
||||||
The admin console will reject sign-in when the required role is absent, even when Keycloak authentication itself succeeds.
|
The admin console rejects sign-in when the required role is absent, even when Keycloak authentication itself succeeds. Existing browser sign-in accepts realm or configured-client roles; this behavior is unchanged.
|
||||||
|
|
||||||
|
## Read-only machine API access
|
||||||
|
|
||||||
|
The [admin API guard](admin-api-authentication.md) independently verifies signed Keycloak access tokens. Machine tokens must include `KEYCLOAK_CLIENT_ID` in `aud` and `KEYCLOAK_REQUIRED_ROLE` in `resource_access[KEYCLOAK_CLIENT_ID].roles`. A realm role alone is **not** sufficient for bearer access. The identity provider must emit both the portal audience and this client role; a token's `azp` is not an audience substitute.
|
||||||
|
|
||||||
|
Verification uses the HTTPS issuer's `/protocol/openid-connect/certs` JWKS endpoint and RS256 only. It does not use `KEYCLOAK_CLIENT_SECRET`, exchange tokens, or create a browser session. Browser client configuration above remains required for interactive SSO. Provisioning or changing machine clients, role/audience mappers, credentials, and production deployment requires separate operational approval; this source implementation performs none of those operations.
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
# Admin suggestions API
|
# 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.
|
The portal provides a read-only view of one Discord **forum channel**, using the existing NextAuth admin session or a verified Keycloak machine bearer token. Player sessions and Discord bot credentials are not accepted as API credentials. Browser users sign in at `/admin/login`; same-origin calls send the session cookie. Machine clients use `Authorization: Bearer <access-token>` with the configured portal audience and **client** role. See [Admin API authentication](admin-api-authentication.md) for verification rules, safe identity checks, and failure behavior.
|
||||||
|
|
||||||
|
## Portal interface
|
||||||
|
|
||||||
|
Open `/admin/suggestions` from the administrator navigation. The idea desk lists active or archived forum posts with tags, status, approximate message counts, author IDs, timestamps, and Discord links. Select a title to open `/admin/suggestions/:id`, read the starter post and its reaction counts, and page through discussion newest-first. The starter is not duplicated in the discussion view.
|
||||||
|
|
||||||
|
The UI uses these same admin-protected GET endpoints with its browser session, not a second integration. Both pages independently check admin access before rendering; the APIs recheck it on every read. Expired/unauthorized API access offers an admin sign-in link. Loading, empty/deleted content, missing-text, and retryable failure states are explicit. Changing status aborts obsolete requests, and pagination restores keyboard focus to the page indicator. Text is rendered literally with React escaping, never as HTML or interpreted Discord Markdown. No bot token, forum configuration value, reply input, vote button, or moderation control is added to the client bundle.
|
||||||
|
|
||||||
|
Pagination history is page-local and resets when switching status or leaving the page. Reload the browser to refresh a view; upstream reads may use the documented 30-second cache. The real forum ID is still configured only through GitOps, not the UI or source.
|
||||||
|
|
||||||
## Runtime configuration
|
## Runtime configuration
|
||||||
|
|
||||||
@@ -21,7 +29,7 @@ The bot must have **View Channel** and **Read Message History** for the forum an
|
|||||||
| `/api/suggestions/:id` | Suggestion metadata and `originalPost`; `null` when the starter message was deleted. |
|
| `/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. |
|
| `/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.
|
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 normalized to `Z` while preserving fractional precision; 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. For `limit=1`, the portal requests Discord's minimum of two posts but returns at most one; the cursor follows the last returned post so the extra post remains available on the next page, even when Discord reports no further upstream pages. 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.
|
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.
|
||||||
|
|
||||||
@@ -29,8 +37,9 @@ Suggestion fields: `id`, `title`, `authorId`, `createdAt`, `archived`, `locked`,
|
|||||||
|
|
||||||
Errors use RFC 9457 `application/problem+json`, HTTP-matching `status`, stable `urn:error:*` types, and safe details:
|
Errors use RFC 9457 `application/problem+json`, HTTP-matching `status`, stable `urn:error:*` types, and safe details:
|
||||||
|
|
||||||
- `401 unauthorized`: no admin session; no redirect.
|
- `401 unauthorized`: no admin session or invalid supplied credentials; no redirect; includes `WWW-Authenticate: Bearer realm="admin-api"`.
|
||||||
- `403 forbidden`: session lacks the required role.
|
- `403 forbidden`: verified identity lacks the required role (configured-client role for bearer tokens).
|
||||||
|
- `503 admin-auth-unavailable`: authentication configuration, browser session service, or JWKS service unavailable; no session fallback for supplied credentials.
|
||||||
- `400 invalid-request`: invalid ID, cursor, limit, status, or list/message query parameter.
|
- `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.
|
- `404 suggestion-not-found`: inaccessible/deleted thread, or thread outside the configured forum.
|
||||||
- `405 method-not-allowed`: writes are unsupported; `Allow: GET, HEAD`.
|
- `405 method-not-allowed`: writes are unsupported; `Allow: GET, HEAD`.
|
||||||
@@ -42,6 +51,6 @@ The client checks the configured forum's guild/type and each requested thread's
|
|||||||
|
|
||||||
## Verification before rollout
|
## 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.
|
Run the source checks and focused API tests in `apps/web/src/lib/discord/suggestions.test.ts` and `apps/web/src/app/api/suggestions/route.test.ts`, plus UI tests in `apps/web/src/components/suggestions-browser.test.tsx`, `suggestion-reader.test.tsx`, and `apps/web/src/app/admin/(console)/suggestions/`. 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).
|
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).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Security review
|
# Security review
|
||||||
|
|
||||||
Review date: 2026-08-02
|
Review timestamp: 2026-09-10T12:20:32Z
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -8,10 +8,12 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
|
|||||||
|
|
||||||
## Automated checks
|
## Automated checks
|
||||||
|
|
||||||
- Semgrep `auto`: 0 findings
|
- Full source Semgrep `auto`: 0 findings across 178 scanned files (331 rules; 13 files excluded by ignore patterns).
|
||||||
- `npm audit`: 0 known vulnerabilities after dependency overrides
|
- `npm audit` and `npm audit --omit=dev`: 0 known vulnerabilities after remediation. Baseline had six affected entries (two moderate, two high, two critical), covering Next.js/Sharp, the NextAuth dependency path, Vitest/mocker, and js-yaml.
|
||||||
- TypeScript, ESLint, unit tests, Next.js production build: passing
|
- Resolved patched versions: Next.js and eslint-config-next 16.3.4, Sharp 0.35.4, Vitest/mocker 4.1.11, js-yaml 4.3.2. Retained Next.js's PostCSS override at 8.5.25. No forced major dependency upgrades or database changes.
|
||||||
- Velocity Java tests and shaded plugin build: passing
|
- TypeScript, 196 workspace tests, Next.js production build, and Velocity Java tests/shaded plugin build pass with patched dependencies. ESLint exits successfully with two new framework-rule warnings about existing `window.location.assign()` calls in `map-view-toggle.tsx`; those unrelated navigation behaviors were not changed.
|
||||||
|
- Verification is local source evidence, not a claim that patched artifacts have been deployed. API CI run 1979 passed for `1a01c0ed641f4eda83f81c855c38651aa116933e`; dependency-fix CI run 1985 passed for `0af4884f7e16161d0c0ea3a8f9c26b3fa9384cf9`.
|
||||||
|
- Suggestions UI tests verify both page guards, malformed-ID rejection, literal text rendering (including script-like content), read-only API usage, explicit auth/error states, aborted obsolete reads, and pagination focus. Actual Discord permissions/Message Content access and production browser acceptance remain rollout checks.
|
||||||
|
|
||||||
## Implemented controls
|
## Implemented controls
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
|
|||||||
- Login links expire after ten minutes, are single use, and are rate limited per Discord user with a PostgreSQL advisory lock.
|
- Login links expire after ten minutes, are single use, and are rate limited per Discord user with a PostgreSQL advisory lock.
|
||||||
- Session cookies are `httpOnly`, `sameSite=lax`, path-scoped, and secure in production.
|
- Session cookies are `httpOnly`, `sameSite=lax`, path-scoped, and secure in production.
|
||||||
- Admin access uses Keycloak OIDC and a required role.
|
- Admin access uses Keycloak OIDC and a required role.
|
||||||
|
- Every suggestions API route independently requires that admin session/role before reading cached or live Discord data. The configured forum is guild/type-checked and requested threads are parent/type-checked before message access. Discord credentials remain server-side; fixed-host, validated-ID requests have bounded caching, concurrency, timeouts, and rate-limit backoff. Responses are uncached and errors use safe RFC 9457 problems. See [suggestions API](admin-suggestions-api.md) for live Discord permission/intent requirements.
|
||||||
- User mutations verify ownership server-side.
|
- User mutations verify ownership server-side.
|
||||||
- Mojang lookup is server-side and targets a fixed host, avoiding client-forged validation and SSRF.
|
- Mojang lookup is server-side and targets a fixed host, avoiding client-forged validation and SSRF.
|
||||||
- Velocity credentials are high-entropy bearer tokens stored only as hashes.
|
- Velocity credentials are high-entropy bearer tokens stored only as hashes.
|
||||||
|
|||||||
+1704
File diff suppressed because it is too large
Load Diff
Generated
+597
-349
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -18,9 +18,9 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": "0.25.12",
|
"esbuild": "0.25.12",
|
||||||
"next@16.2.12": {
|
"next@16.3.4": {
|
||||||
"postcss": "8.5.25",
|
"postcss": "8.5.25",
|
||||||
"sharp": "0.35.3"
|
"sharp": "0.35.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
Reference in New Issue
Block a user