Compare commits

..
3 Commits
Author SHA1 Message Date
dmg 999c27c7f9 feat(rcon): discover commands and autocomplete server help
CI / validate (push) Successful in 8m20s
Release / release (push) Successful in 11m23s
2026-09-14 15:52:56 -04:00
dmg eb8ef18688 fix(suggestions): preserve one-item archived pagination
CI / validate (push) Successful in 8m20s
Release / release (push) Successful in 10m52s
2026-09-10 16:00:02 -04:00
dmg 47782b3ccc feat(api): publish validated OpenAPI contract
CI / validate (push) Successful in 7m15s
Release / release (push) Successful in 11m32s
2026-09-10 15:11:57 -04:00
29 changed files with 3219 additions and 32 deletions
+6
View File
@@ -36,6 +36,12 @@ Open `http://localhost:3000`.
Administrators can browse the configured Discord forum at `/admin/suggestions` or use the same session-protected [suggestions API](docs/admin-suggestions-api.md). The portal includes active/archive browsing, original posts, reactions, and paginated discussion. Set `DISCORD_SUGGESTIONS_FORUM_ID` through GitOps; the existing bot token stays server-side. This integration is read-only and does not synchronize data into the database.
## Application API contract
The canonical [OpenAPI 3.1](openapi.yaml) contract is publicly served as plain YAML at `/openapi.yaml` (locally: <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
Implemented and proposed behavior is tracked in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/minecraft-account-manager/index.md). Validate canonical knowledge in that repository with `okflint validate --manifest okf-base.yaml`; source builds do not require wiki access.
+2
View File
@@ -15,6 +15,8 @@ const contentSecurityPolicy = [
const nextConfig: NextConfig = {
output: "standalone",
// Keep the canonical source available in standalone/container output as well.
outputFileTracingIncludes: { "/openapi.yaml": ["../../openapi.yaml"] },
poweredByHeader: false,
async headers() {
return [
+7 -1
View File
@@ -7,6 +7,8 @@
"build": "next build",
"start": "next start",
"test": "vitest run",
"openapi:validate": "vitest run src/lib/openapi.test.ts",
"openapi:standalone": "OPENAPI_STANDALONE_TEST=1 vitest run src/test/openapi-standalone.test.ts",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
@@ -30,6 +32,7 @@
"world-atlas": "^2.0.2"
},
"devDependencies": {
"@apidevtools/swagger-parser": "^12.1.0",
"@tailwindcss/postcss": "^4.2.1",
"@testing-library/react": "^16.3.2",
"@types/d3-geo": "^3.1.1",
@@ -38,11 +41,14 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"eslint": "^9.39.4",
"eslint-config-next": "^16.3.4",
"jsdom": "^30.0.1",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vitest": "^4.1.0"
"vitest": "^4.1.0",
"yaml": "^2.9.0"
}
}
@@ -1,7 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// @vitest-environment jsdom
import { createElement } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RconConsole } from "@/components/rcon-console";
const actionState = vi.hoisted(() => ({
authorized: 0,
authFailure: false,
helpResponses: {} as Record<string, string>,
requestedBeforeSend: [] as boolean[],
selected: [] as unknown[],
transactionSelected: [] as unknown[],
updates: [] as Record<string, unknown>[],
@@ -17,6 +25,7 @@ const actionState = vi.hoisted(() => ({
vi.mock("@/lib/auth/require-admin", () => ({
requireAdminSession: async () => {
actionState.authorized += 1;
if (actionState.authFailure) throw new Error("REDIRECT:/admin/login");
return { email: "admin@example.test", name: "Admin" };
},
}));
@@ -73,8 +82,9 @@ vi.mock("@/lib/rcon-credentials", () => ({
vi.mock("@/lib/rcon-gateway", () => ({
executeRcon: async (connection: Record<string, unknown>, command: string) => {
actionState.requestedBeforeSend.push(actionState.audits.at(-1)?.type.endsWith(".requested") === true);
actionState.executions.push({ connection, command });
return actionState.gatewayResult;
return command in actionState.helpResponses ? { ok: true, response: actionState.helpResponses[command] } : actionState.gatewayResult;
},
testRconConnection: vi.fn(),
}));
@@ -115,8 +125,12 @@ const savedServer = {
};
describe("RCON server actions", () => {
afterEach(() => cleanup());
beforeEach(() => {
actionState.authorized = 0;
actionState.authFailure = false;
actionState.helpResponses = {};
actionState.requestedBeforeSend = [];
actionState.selected = [];
actionState.transactionSelected = [];
actionState.updates = [];
@@ -127,6 +141,55 @@ describe("RCON server actions", () => {
actionState.gatewayResult = { ok: true, response: "private response" };
});
it("routes UI discovery and usage through independently authorized, audit-before-send RCON actions", async () => {
actionState.selected = [savedServer];
actionState.helpResponses = {
help: "Help: Index (1/2)\n/leaf: Private protection description",
"help 2": "Help: Index (2/2)\n/tyrant: Tyrant features",
"help tyrant": "Usage: /tyrant <menu|status>",
};
render(createElement(RconConsole, { servers: [savedServer] }));
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyrant " } });
await screen.findByLabelText("Command usage");
expect(actionState.executions.map((entry) => entry.command)).toEqual(["help", "help 2", "help tyrant"]);
expect(actionState.authorized).toBe(3);
expect(actionState.requestedBeforeSend).toEqual([true, true, true]);
expect(actionState.audits).toHaveLength(6);
for (let index = 0; index < 6; index += 2) {
expect(actionState.audits[index]?.admin).toEqual({ email: "admin@example.test", name: "Admin" });
expect(actionState.audits[index]?.correlationId).toBe(actionState.audits[index + 1]?.correlationId);
}
const audit = JSON.stringify(actionState.audits);
expect(audit).not.toContain("Private protection description");
expect(audit).not.toContain("/tyrant <menu|status>");
expect(audit).not.toContain("decrypted-password");
});
it.each(["authorization", "enabled-connection", "audit"])("does not send discovery requests when %s fails", async (failure) => {
actionState.selected = failure === "enabled-connection" ? [] : [savedServer];
actionState.authFailure = failure === "authorization";
actionState.auditFailure = failure === "audit";
render(createElement(RconConsole, { servers: [savedServer] }));
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
expect(actionState.authorized).toBe(1);
expect(actionState.executions).toEqual([]);
expect(document.body.textContent).not.toContain("REDIRECT:");
});
it.each(["busy", "timeout", "unavailable"] as const)("preserves safe %s gateway outcomes for UI discovery", async (reason) => {
actionState.selected = [savedServer];
actionState.gatewayResult = { ok: false, reason };
render(createElement(RconConsole, { servers: [savedServer] }));
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
expect(actionState.executions).toHaveLength(1);
expect(actionState.audits[1]?.data).toEqual(expect.objectContaining({ success: false, reason }));
expect((screen.getByLabelText("Command") as HTMLInputElement).disabled).toBe(false);
});
it("independently authorizes every exported operation before accepting input", async () => {
await expect(createRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
await expect(updateRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
@@ -21,7 +21,13 @@ beforeEach(() => {
});
afterEach(() => { auth.session.mockReset(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
import { GET as get } from "./route";
import { assertResponse } from "@/test/openapi-contract";
import { GET } from "./route";
async function get(request: Request) {
const response = await GET(request);
await assertResponse("/api/admin/whoami", "get", response);
return response;
}
function request(authorization?: string) {
return new Request("https://portal.example/api/admin/whoami", { headers: authorization === undefined ? {} : { authorization } });
}
@@ -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);
}
});
+24 -3
View File
@@ -2,9 +2,30 @@ import { afterEach, expect, it, vi } from "vitest";
const auth = vi.hoisted(() => ({ session: vi.fn() }));
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
import { GET, POST } from "./route";
import { GET as detail } from "./[id]/route";
import { GET as messages } from "./[id]/messages/route";
import { assertResponse } from "@/test/openapi-contract";
import { GET as routeGet, POST as routePost } from "./route";
import { GET as routeDetail } from "./[id]/route";
import { GET as routeMessages } from "./[id]/messages/route";
async function GET(request: Request) {
const response = await routeGet(request);
await assertResponse("/api/suggestions", "get", response);
return response;
}
async function POST(request: Request) {
const response = await routePost(request);
await assertResponse("/api/suggestions", "post", response);
return response;
}
async function detail(request: Request, context: Parameters<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 request = () => new Request("https://portal.example/api/suggestions");
@@ -47,7 +47,13 @@ vi.mock("@/lib/ip-intelligence", () => ({
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
import { POST } from "./route";
import { assertResponse } from "@/test/openapi-contract";
import { POST as routePost } from "./route";
async function POST(request: Request) {
const response = await routePost(request);
await assertResponse("/api/velocity/access", "post", response);
return response;
}
const messages = {
registrationMessage: "Register {player} in {group}.",
@@ -1,9 +1,20 @@
import { describe, expect, it } from "vitest";
import { GET, POST } from "./route";
import { assertResponse } from "@/test/openapi-contract";
import { GET as routeGet, POST as routePost } from "./route";
async function GET(request: Request) {
const response = routeGet(request);
await assertResponse("/api/velocity/access", "get", response);
return response;
}
async function POST(request: Request) {
const response = await routePost(request);
await assertResponse("/api/velocity/access", "post", response);
return response;
}
describe("Velocity access API problems", () => {
it("returns RFC 9457 for unsupported methods", async () => {
const response = GET(new Request("http://localhost/api/velocity/access"));
const response = await GET(new Request("http://localhost/api/velocity/access"));
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("POST");
@@ -1,3 +1,4 @@
import { assertResponse } from "@/test/openapi-contract";
import { hashToken } from "@minecraft-account-manager/auth";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -38,7 +39,18 @@ vi.mock("@/lib/database", () => ({
},
}));
import { GET, POST } from "./route";
import { GET as routeGet, POST as routePost } from "./route";
async function GET(request: Request) {
const response = routeGet(request);
await assertResponse("/api/velocity/connection", "get", response);
return response;
}
async function POST(request: Request) {
const response = await routePost(request);
await assertResponse("/api/velocity/connection", "post", response);
return response;
}
function validRequest(overrides: Record<string, unknown> = {}) {
return new Request("http://localhost/api/velocity/connection", {
@@ -64,7 +76,7 @@ describe("Velocity connection reporting endpoint", () => {
});
it("rejects methods other than POST with Problem Details", async () => {
const response = GET(new Request("http://localhost/api/velocity/connection"));
const response = await GET(new Request("http://localhost/api/velocity/connection"));
expect(response.status).toBe(405);
expect(response.headers.get("content-type")).toContain("application/problem+json");
expect(response.headers.get("allow")).toBe("POST");
+13
View File
@@ -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" },
});
}
+186 -2
View File
@@ -1,8 +1,8 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const actionMocks = vi.hoisted(() => ({
execute: vi.fn(async (_previous: unknown, formData: FormData) => ({
@@ -41,6 +41,190 @@ const creative = {
};
describe("RconConsole", () => {
beforeEach(() => {
actionMocks.execute.mockClear();
actionMocks.execute.mockImplementation(async (_previous, formData) => {
const command = String(formData.get("command") ?? "");
const serverId = String(formData.get("serverId") ?? "");
const message = command === "help"
? "§eHelp: Index (1/2)\n§6Leaf: §fAll commands for Leaf\n§6/leaf: §fProtection"
: command === "help 2" ? "Help: Index (2/2)\n/tyrant: Tyrant features"
: command === "help tyrant" ? "Usage: /tyrant <menu|status|armor <helmet|boots>>"
: `Executed ${command}`;
return { status: "success", message, serverId };
});
});
it("refreshes server help and accepts suggestions without submitting or polluting recall", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyr" } });
expect(screen.getByRole("option", { name: /tyrant Tyrant features/ })).toBeTruthy();
expect(fireEvent.keyDown(input, { key: "Enter" })).toBe(false);
expect(input.value).toBe("tyrant ");
expect(screen.queryByRole("listbox")).toBeNull();
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "help 2"]);
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("tyrant ");
expect(screen.getByLabelText("Terminal transcript").textContent).not.toContain("$ help");
});
it("fetches and caches usage on demand and offers contextual literal arguments", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyrant " } });
await screen.findByLabelText("Command usage");
expect(screen.getByRole("option", { name: "menu From server help" })).toBeTruthy();
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(false);
expect(input.value).toBe("tyrant status ");
fireEvent.change(input, { target: { value: "tyrant armor " } });
fireEvent.click(screen.getByRole("option", { name: "helmet From server help" }));
expect(input.value).toBe("tyrant armor helmet ");
expect(document.activeElement).toBe(input);
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "help 2", "help tyrant"]);
});
it("reports incomplete help safely and leaves unknown manual commands usable", async () => {
actionMocks.execute.mockRejectedValueOnce(new Error("private transport detail"));
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
expect(document.body.textContent).not.toContain("private transport detail");
const input = screen.getByLabelText("Command") as HTMLInputElement;
expect(input.disabled).toBe(false);
fireEvent.change(input, { target: { value: "custom-plugin arbitrary value" } });
fireEvent.submit(input.form!);
await screen.findByText("Executed custom-plugin arbitrary value");
});
it("isolates caches by server, invalidates edited endpoints, and discards help on remount", async () => {
const storage = vi.spyOn(Storage.prototype, "setItem");
const view = render(<RconConsole servers={[server, creative]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyrant " } });
await screen.findByLabelText("Command usage");
fireEvent.change(screen.getByLabelText("Server"), { target: { value: creative.id } });
let input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyr" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(screen.queryByLabelText("Command usage")).toBeNull();
fireEvent.change(screen.getByLabelText("Server"), { target: { value: server.id } });
input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyrant " } });
expect(screen.getByLabelText("Command usage")).toBeTruthy();
expect(actionMocks.execute).toHaveBeenCalledTimes(3);
view.rerender(<RconConsole servers={[{ ...server, host: "replacement.example.com" }, creative]} />);
expect(screen.queryByLabelText("Command usage")).toBeNull();
expect(screen.queryByRole("listbox")).toBeNull();
view.unmount();
render(<RconConsole servers={[server]} />);
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyr" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(storage).not.toHaveBeenCalled();
storage.mockRestore();
});
it("dismisses suggestions for history recall, restores drafts, and submits only intentionally", async () => {
render(<RconConsole servers={[server]} />);
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "list" } });
fireEvent.submit(input.form!);
await screen.findByText("Executed list");
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
fireEvent.change(input, { target: { value: "tyr" } });
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("tyr");
fireEvent.keyDown(input, { key: "Escape" });
expect(input.getAttribute("aria-expanded")).toBe("false");
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("list");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(input.value).toBe("tyr");
fireEvent.submit(input.form!);
await screen.findByText("Executed tyr");
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["list", "help", "help 2", "tyr"]);
});
it("does not offer end-of-command replacement while editing in the middle", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyr" } });
input.setSelectionRange(1, 1);
fireEvent.select(input);
expect(screen.queryByRole("listbox")).toBeNull();
expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(true);
expect(input.value).toBe("tyr");
});
it("stops pagination on server change and never publishes a late result to the new selection", async () => {
let resolve!: (value: { status: "success"; message: string; serverId: string }) => void;
actionMocks.execute.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
render(<RconConsole servers={[server, creative]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
fireEvent.change(screen.getByLabelText("Server"), { target: { value: creative.id } });
await act(async () => resolve({ status: "success", message: "Help: Index (1/9)\n/secretcmd: Private", serverId: server.id }));
expect(actionMocks.execute).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "secret" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(screen.getByText("Refresh commands to discover server help")).toBeTruthy();
});
it("lets manual execution interrupt discovery and waits for the in-flight help request", async () => {
let resolve!: (value: { status: "success"; message: string; serverId: string }) => void;
actionMocks.execute.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "list" } });
fireEvent.submit(input.form!);
expect(actionMocks.execute).toHaveBeenCalledTimes(1);
await act(async () => resolve({ status: "success", message: "Help: Index (1/9)\n/leaf: Protection", serverId: server.id }));
await screen.findByText("Executed list");
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "list"]);
expect(input.disabled).toBe(false);
});
it("caches safe usage failure without retries until refresh and keeps manual entry available", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
actionMocks.execute.mockRejectedValueOnce(new Error("private usage detail"));
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyrant " } });
await screen.findByText("Usage unavailable. Refresh commands to retry.");
fireEvent.change(input, { target: { value: "tyrant armor " } });
expect(actionMocks.execute).toHaveBeenCalledTimes(3);
expect(document.body.textContent).not.toContain("private usage detail");
expect(input.disabled).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("/tyrant <menu|status|armor <helmet|boots>>");
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "help 2", "help tyrant", "help", "help 2", "help tyrant"]);
});
it("renders server help as inert text and never refreshes missing or disabled connections", async () => {
const view = render(<RconConsole servers={[]} />);
expect((screen.getByRole("button", { name: "Refresh commands" }) as HTMLButtonElement).disabled).toBe(true);
view.rerender(<RconConsole servers={[{ ...server, enabled: false }]} />);
expect((screen.getByRole("button", { name: "Refresh commands" }) as HTMLButtonElement).disabled).toBe(true);
expect(actionMocks.execute).not.toHaveBeenCalled();
view.rerender(<RconConsole servers={[server]} />);
actionMocks.execute.mockResolvedValueOnce({ status: "success", message: 'Help: Index (1/1)\n/leaf: <img src=x onerror="alert(1)">', serverId: server.id });
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("1 commands discovered · best-effort server help");
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "lea" } });
expect(screen.getByText('<img src=x onerror="alert(1)">')).toBeTruthy();
expect(document.querySelector("img")).toBeNull();
});
it("renders one wide terminal workspace with connection controls and modal forms", () => {
const markup = renderToStaticMarkup(<RconConsole servers={[server]} />);
expect(markup).toContain('aria-label="RCON terminal"');
+80 -7
View File
@@ -1,19 +1,18 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
createRconServer,
deleteRconServer,
executeRconCommand,
setRconServerEnabled,
testSavedRconServer,
type RconCommandState,
updateRconServer,
} from "@/app/admin/(console)/rcon/actions";
import { AdminModalForm } from "@/components/admin-modal-form";
import { rconSuggestions, type RconSuggestion } from "@/lib/rcon-help";
import { useRconSession } from "./use-rcon-session";
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
const MAX_COMMAND_HISTORY = 50;
const MAX_TRANSCRIPT_EXCHANGES = 50;
@@ -46,8 +45,9 @@ export function RconConsole({
servers: RconServerOption[];
}) {
const [selectedId, setSelectedId] = useState(servers[0]?.id ?? "");
const [state, action, pending] = useActionState(executeRconCommand, initialState);
const [command, setCommand] = useState("");
const [suggestionsOpen, setSuggestionsOpen] = useState(false);
const [activeSuggestion, setActiveSuggestion] = useState(0);
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [transcript, setTranscript] = useState<TranscriptExchange[]>([]);
@@ -57,11 +57,27 @@ export function RconConsole({
const pendingExchangeIdRef = useRef<number | null>(null);
const transcriptRef = useRef<HTMLDivElement>(null);
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
const help = useRconSession(selected, command);
const { state, action, pending } = help;
const suggestions = selected?.enabled && !pending && suggestionsOpen
? rconSuggestions(command, help.catalog?.commands ?? [], help.usage?.text) : [];
const suggestionIndex = Math.min(activeSuggestion, Math.max(0, suggestions.length - 1));
function acceptSuggestion(suggestion: RconSuggestion) {
setCommand(suggestion.value);
setSuggestionsOpen(false);
setHistoryIndex(null);
inputRef.current?.focus();
}
useEffect(() => {
inputRef.current?.focus();
}, [selectedId]);
useEffect(() => {
if (suggestions.length) document.getElementById(`rcon-suggestion-${suggestionIndex}`)?.scrollIntoView?.({ block: "nearest" });
}, [suggestionIndex, suggestions.length]);
useEffect(() => {
if (!pending && state.status !== "idle") inputRef.current?.focus();
}, [pending, state.status]);
@@ -101,6 +117,8 @@ export function RconConsole({
}
function rememberSubmittedCommand() {
help.stopHelp();
setSuggestionsOpen(false);
const submitted = command.trim();
if (!submitted || !selected) return;
const exchangeId = ++nextExchangeIdRef.current;
@@ -133,7 +151,11 @@ export function RconConsole({
<select
className="max-w-full border border-line bg-panel px-3 py-2 font-mono text-xs font-bold normal-case outline-none focus:border-accent"
id="rcon-console-server"
onChange={(event) => setSelectedId(event.target.value)}
onChange={(event) => {
help.stopHelp();
setSuggestionsOpen(false);
setSelectedId(event.target.value);
}}
value={selected?.id}
>
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}{server.enabled ? "" : " — disabled"}</option>)}
@@ -200,11 +222,39 @@ export function RconConsole({
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-line bg-canvas px-4 py-2 font-mono text-[10px] text-muted">
<p aria-live="polite" role="status">{help.loading ? "Reading server help…" : help.message}</p>
<button className="border border-line px-3 py-2 font-bold uppercase tracking-wider hover:border-ink disabled:opacity-50" disabled={!selected?.enabled || pending || help.loading} onClick={() => void help.refresh()} type="button">Refresh commands</button>
</div>
{help.usage && (
<div aria-label="Command usage" className="border-t border-line bg-panel px-4 py-3 font-mono text-xs" id="rcon-usage">
<p className="mb-1 text-[9px] uppercase tracking-wider text-muted">Server usage hint</p>
<pre className="whitespace-pre-wrap break-words font-mono text-xs">{help.usage.text ?? (help.usage.failed ? "Usage unavailable. Refresh commands to retry." : "No usage supplied by server help.")}</pre>
</div>
)}
{suggestions.length > 0 && (
<div className="border-t border-line bg-panel px-4 py-3 font-mono text-xs">
<p className="mb-2 text-[9px] uppercase tracking-wider text-muted" id="rcon-suggestion-hint"> Select · Tab / Enter Insert · Esc Dismiss</p>
<ul aria-label="Command suggestions" className="max-h-48 overflow-y-auto" id="rcon-suggestions" role="listbox">
{suggestions.map((suggestion, index) => (
<li aria-selected={index === suggestionIndex} className={`flex cursor-pointer flex-wrap gap-x-4 gap-y-1 border-l-2 px-3 py-2 ${index === suggestionIndex ? "border-accent bg-canvas text-ink" : "border-transparent text-muted"}`} id={`rcon-suggestion-${index}`} key={suggestion.value} onClick={() => acceptSuggestion(suggestion)} onMouseDown={(event) => event.preventDefault()} role="option">
<span className="font-bold">{suggestion.label}</span>{" "}<span className="break-words">{suggestion.description}</span>
</li>
))}
</ul>
</div>
)}
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3" onSubmit={rememberSubmittedCommand}>
<input name="serverId" type="hidden" value={selected?.id ?? ""} />
<span aria-hidden="true" className="font-mono text-lg font-black text-accent">$</span>
<label className="sr-only" htmlFor="rcon-command">Command</label>
<input
aria-activedescendant={suggestions.length ? `rcon-suggestion-${suggestionIndex}` : undefined}
aria-autocomplete="list"
aria-controls={suggestions.length ? "rcon-suggestions" : undefined}
aria-describedby={[suggestions.length ? "rcon-suggestion-hint" : "", help.usage ? "rcon-usage" : ""].filter(Boolean).join(" ") || undefined}
aria-expanded={suggestions.length > 0}
role="combobox"
autoComplete="off"
autoFocus
className="min-w-0 flex-1 bg-transparent px-1 py-2 font-mono text-sm outline-none placeholder:text-muted focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
@@ -213,8 +263,31 @@ export function RconConsole({
key={selected?.id ?? "no-server"}
maxLength={1024}
name="command"
onChange={(event) => setCommand(event.target.value)}
onSelect={(event) => {
const input = event.currentTarget;
if (input.selectionStart !== input.value.length || input.selectionEnd !== input.value.length) setSuggestionsOpen(false);
}}
onBlur={() => setSuggestionsOpen(false)}
onChange={(event) => {
setCommand(event.target.value);
setSuggestionsOpen(true);
setActiveSuggestion(0);
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Escape") { setSuggestionsOpen(false); return; }
if (suggestions.length) {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
setActiveSuggestion((suggestionIndex + (event.key === "ArrowUp" ? -1 : 1) + suggestions.length) % suggestions.length);
return;
}
if ((event.key === "Tab" && !event.shiftKey) || event.key === "Enter") {
event.preventDefault();
acceptSuggestion(suggestions[suggestionIndex]!);
return;
}
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
navigateHistory(event.key === "ArrowUp" ? "older" : "newer");
@@ -0,0 +1,97 @@
"use client";
import { useActionState, useCallback, useEffect, useRef, useState } from "react";
import { executeRconCommand, type RconCommandState } from "@/app/admin/(console)/rcon/actions";
import { discoverRconCommands, parseRconHelp, type RconHelpCatalog } from "@/lib/rcon-help";
import type { RconServerOption } from "./rcon-console";
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
type UsageResult = { text: string | null; failed: boolean };
type HelpCache = RconHelpCatalog & { usage: Map<string, UsageResult> };
export function useRconSession(server: RconServerOption | undefined, command: string) {
// Endpoint edits/enable changes invalidate the old cache, not just selection changes.
const key = server ? JSON.stringify([server.id, server.host, server.port, server.enabled]) : "";
const [cache, setCache] = useState(new Map<string, HelpCache>());
const [loading, setLoading] = useState(false);
const operationRef = useRef<AbortController | null>(null);
const flightRef = useRef<Promise<string> | null>(null);
function stopHelp() { operationRef.current?.abort(); }
useEffect(() => () => { operationRef.current?.abort(); }, [key]);
const [state, action, pending] = useActionState(async (previous: RconCommandState, formData: FormData) => {
// Do not race the page's help lookup against a manual command for the gateway lock.
stopHelp();
await flightRef.current?.catch(() => undefined);
return executeRconCommand(previous, formData);
}, initialState);
const serverId = server?.id ?? "";
const requestHelp = useCallback((helpCommand: string): Promise<string> => {
const data = new FormData();
data.set("serverId", serverId);
data.set("command", helpCommand);
const flight = executeRconCommand(initialState, data).then((result) => {
if (result.status !== "success") throw new Error("Help request unavailable");
return result.message;
});
flightRef.current = flight;
return flight;
}, [serverId]);
async function refresh() {
if (!server?.enabled || pending || operationRef.current) return;
const controller = new AbortController();
operationRef.current = controller;
setLoading(true);
try {
const result = await discoverRconCommands(requestHelp, controller.signal);
if (!controller.signal.aborted) {
setCache((current) => new Map(current).set(key, { ...result, usage: new Map() }));
}
} finally {
operationRef.current = null;
setLoading(false);
}
}
const catalog = cache.get(key);
const typedVerb = command.match(/^\/?([a-z0-9_.:-]+)\s/i)?.[1];
const verb = catalog?.commands.find((entry) => entry.name.toLowerCase() === typedVerb?.toLowerCase())?.name;
const usage = verb ? catalog?.usage.get(verb) : undefined;
const enabled = server?.enabled ?? false;
useEffect(() => {
if (!enabled || !verb || usage || pending || loading) return;
const timer = setTimeout(async () => {
if (operationRef.current) return;
const controller = new AbortController();
operationRef.current = controller;
setLoading(true);
let result: UsageResult;
try {
result = { text: parseRconHelp(await requestHelp(`help ${verb}`)).usage, failed: false };
} catch {
result = { text: null, failed: true };
}
if (!controller.signal.aborted) {
setCache((current) => {
const entry = current.get(key);
if (!entry) return current;
return new Map(current).set(key, { ...entry, usage: new Map(entry.usage).set(verb, result) });
});
}
operationRef.current = null;
setLoading(false);
}, 350);
return () => clearTimeout(timer);
}, [enabled, key, verb, usage, pending, loading, requestHelp]);
const message = catalog
? catalog.incomplete
? `${catalog.commands.length} commands discovered · incomplete; refresh to retry`
: `${catalog.commands.length} commands discovered · best-effort server help`
: "Refresh commands to discover server help";
return { state, action, pending, catalog, usage, loading, refresh, stopHelp, message };
}
+81 -4
View File
@@ -8,6 +8,9 @@ const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11,
function setup(responses: Record<string, unknown>) {
const fetcher = vi.fn<typeof fetch>(async (input) => {
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}`);
const value = responses[path];
return value instanceof Response ? value : Response.json(value);
@@ -19,20 +22,94 @@ it("reads archived forum pages using Discord's archive timestamp cursor", async
const cursor = "2026-01-01T00:00:00Z";
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=1`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } }], has_more: true },
[`/channels/${forumId}/threads/archived/public?limit=1&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
[`/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=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, 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=1`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: raw } }], has_more: true },
[`/channels/${forumId}/threads/archived/public?limit=1&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
[`/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);
+8 -3
View File
@@ -148,10 +148,15 @@ export function createSuggestionsClient(options: { token: string; guildId: strin
const forum = await getForum();
if (status === "archived") {
const before = query.cursor ? `&before=${encodeURIComponent(query.cursor)}` : "";
const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${limit}${before}`);
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 {
items: data.threads.filter(belongs).map((thread) => summary(thread, forum)),
nextCursor: data.has_more && data.threads.length ? archiveCursor(data.threads.at(-1)!.thread_metadata.archive_timestamp) : null,
items: page.map((thread) => summary(thread, forum)),
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`);
+15
View File
@@ -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");
});
+11
View File
@@ -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")));
});
+73
View File
@@ -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();
});
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { discoverRconCommands, MAX_HELP_COMMANDS, MAX_HELP_PAGES, parseRconHelp, rconSuggestions } from "./rcon-help";
describe("RCON help discovery", () => {
it("walks help pages sequentially and deduplicates commands", async () => {
const requests: string[] = [];
const result = await discoverRconCommands(async (command) => {
requests.push(command);
return command === "help"
? "Help: Index (1/2)\nLeaf: All commands for Leaf\n/leaf: Protection"
: "Help: Index (2/2)\n/leaf: Protection\n/tyrant: Tyrant features";
});
expect(requests).toEqual(["help", "help 2"]);
expect(result).toEqual({ commands: [
{ name: "leaf", description: "Protection" },
{ name: "tyrant", description: "Tyrant features" },
], incomplete: false });
});
it.each([
"Unrecognized help format",
"Help: Index (0/2)",
"Help: Index (1/0)",
"Help: Index (1/9007199254740992)",
])("stops on malformed pagination: %s", async (output) => {
let calls = 0;
const result = await discoverRconCommands(async () => { calls++; return output; });
expect(calls).toBe(1);
expect(result.incomplete).toBe(true);
});
it("stops on repeated pages, transport failure, or cancellation without losing earlier commands", async () => {
const first = "Help: Index (1/3)\n/leaf: Protection";
let calls = 0;
const repeated = await discoverRconCommands(async () => { calls++; return first; });
expect(calls).toBe(2);
expect(repeated).toEqual({ commands: [{ name: "leaf", description: "Protection" }], incomplete: true });
calls = 0;
const failed = await discoverRconCommands(async () => {
if (calls++) throw new Error("private transport detail");
return first;
});
expect(failed).toEqual(repeated);
const controller = new AbortController();
const cancelled = await discoverRconCommands(async () => { controller.abort(); return first; }, controller.signal);
expect(cancelled).toEqual({ commands: [], incomplete: true });
});
it("bounds cached command count independently of pagination", async () => {
let calls = 0;
const result = await discoverRconCommands(async () => {
calls++;
return `Help: Index (${calls}/64)\n` + Array.from({ length: 100 }, (_, index) => `/c${calls}_${index}: A command`).join("\n");
});
expect(result.commands).toHaveLength(MAX_HELP_COMMANDS);
expect(result.incomplete).toBe(true);
expect(calls).toBeLessThan(MAX_HELP_PAGES);
});
it("caps excessive pagination and reports partial results", async () => {
let calls = 0;
const result = await discoverRconCommands(async () => `Help: Index (${++calls}/99999)\n/c${calls}: A command`);
expect(calls).toBe(MAX_HELP_PAGES);
expect(result.commands).toHaveLength(MAX_HELP_PAGES);
expect(result.incomplete).toBe(true);
});
});
describe("RCON suggestions and usage", () => {
it("filters discovered names without requiring a slash and preserves a typed slash", () => {
const commands = [{ name: "tyrant", description: "Tyrant features" }, { name: "leaf", description: "Protection" }];
expect(rconSuggestions("tyr", commands)).toEqual([{ value: "tyrant ", label: "tyrant", description: "Tyrant features" }]);
expect(rconSuggestions("/tyr", commands)[0]?.value).toBe("/tyrant ");
expect(rconSuggestions("unknown", commands)).toEqual([]);
expect(rconSuggestions("", commands)).toEqual([]);
});
it("reads wrapped Tyrant usage and offers only context-correct literal branches", () => {
const { usage } = parseRconHelp(`§e--------- §fHelp: §r/tyrant §e-------------------------------
§6Description: §fView and use Spigot Tyrant game features.
§f§6Usage: §f/tyrant <menu|status|choices|buy|armor
§f<helmet|chestplate|leggings|boots>|gear
§f<axe|pickaxe|sword|hoe|shovel>|assign|item|intelligence
§f|optout|optin|relinquish confirm>`);
expect(usage).toContain("/tyrant <menu|status");
expect(usage).not.toContain("§");
const commands = [{ name: "tyrant", description: "Tyrant features" }];
expect(rconSuggestions("tyrant ", commands, usage).map((item) => item.label)).toEqual([
"menu", "status", "choices", "buy", "armor", "gear", "assign", "item", "intelligence", "optout", "optin", "relinquish",
]);
expect(rconSuggestions("tyrant armor he", commands, usage).map((item) => item.value)).toEqual(["tyrant armor helmet "]);
expect(rconSuggestions("tyrant relinquish ", commands, usage).map((item) => item.label)).toEqual(["confirm"]);
});
it.each([
"/leaf <player>", "/leaf [player]", "/leaf <a|b", "/other <a|b>",
"/leaf <a||b>", "/leaf <a|b>>", "/leaf <a|b> ".repeat(300),
"/leaf " + "<a|b> ".repeat(10),
])("does not invent literals for opaque or malformed syntax: %s", (usage) => {
expect(rconSuggestions("leaf ", [{ name: "leaf", description: "Protection" }], usage)).toEqual([]);
});
});
describe("RCON help parsing", () => {
it("discovers slash commands, not help categories, from formatted paginated output", () => {
expect(parseRconHelp(`§e--------- §fHelp: §rIndex (1/31) §e--------------------------
§7Use /help [n] to get page n of help.
§7§6Aliases: §fLists command aliases
§f§6Leaf: §fAll commands for Leaf
§f§6/leaf: §fControl or administer Leaf protection.
§f§6/minecraft:help: §fProvides help
§f§6/not a command: §fIgnore this
`)).toEqual({
commands: [
{ name: "leaf", description: "Control or administer Leaf protection." },
{ name: "minecraft:help", description: "Provides help" },
],
page: 1, pages: 31, usage: null,
});
});
});
+127
View File
@@ -0,0 +1,127 @@
export type RconHelpCommand = { name: string; description: string };
export type RconHelpPage = {
commands: RconHelpCommand[];
page: number | null;
pages: number | null;
usage: string | null;
};
export type RconHelpCatalog = { commands: RconHelpCommand[]; incomplete: boolean };
export const MAX_HELP_PAGES = 64;
export const MAX_HELP_COMMANDS = 2_048;
export async function discoverRconCommands(
request: (command: string) => Promise<string>,
signal?: AbortSignal,
): Promise<RconHelpCatalog> {
const commands = new Map<string, RconHelpCommand>();
let total: number | null = null;
for (let page = 1; page <= MAX_HELP_PAGES; page += 1) {
if (signal?.aborted) break;
let parsed: RconHelpPage;
try {
parsed = parseRconHelp(await request(page === 1 ? "help" : `help ${page}`));
} catch {
break;
}
if (signal?.aborted) break;
for (const command of parsed.commands) {
if (commands.size >= MAX_HELP_COMMANDS) return { commands: [...commands.values()], incomplete: true };
commands.set(command.name.toLowerCase(), command);
}
if (total === null) total = parsed.pages;
if (parsed.page !== page || parsed.pages !== total || !Number.isSafeInteger(total) || total! < page) break;
if (page === total) return { commands: [...commands.values()], incomplete: false };
}
return { commands: [...commands.values()], incomplete: true };
}
export type RconSuggestion = { value: string; label: string; description: string };
// Only expand a small, balanced literal/alternative grammar. Single angle-bracket
// values are placeholders, not literals; unsupported syntax remains a usage hint.
function usagePaths(pattern: string): Array<Array<string | null>> {
if (pattern.length > 4_096 || /[^a-z0-9_.:<>|\s-]/i.test(pattern)) return [];
const tokens = pattern.match(/[a-z0-9_.:-]+|[<>|]/gi) ?? [];
if (tokens.length > 256) return [];
let cursor = 0;
function expression(depth: number): Array<Array<string | null>> {
if (depth > 8) throw new Error("Complex usage");
const alternatives: Array<Array<string | null>> = [];
let sequence: Array<Array<string | null>> = [[]];
let hasAlternatives = false;
while (cursor < tokens.length && tokens[cursor] !== ">") {
const token = tokens[cursor++]!;
if (token === "|") {
if (sequence.some((path) => !path.length)) throw new Error("Empty alternative");
alternatives.push(...sequence);
sequence = [[]];
hasAlternatives = true;
continue;
}
const values = token === "<" ? expression(depth + 1) : [[token]];
if (token === "<" && tokens[cursor++] !== ">") throw new Error("Unbalanced usage");
if (sequence.length * values.length + alternatives.length > 256) throw new Error("Complex usage");
sequence = sequence.flatMap((prefix) => values.map((suffix) => [...prefix, ...suffix]));
}
if (sequence.some((path) => !path.length)) throw new Error("Empty alternative");
alternatives.push(...sequence);
return depth > 0 && !hasAlternatives ? [[null]] : alternatives;
}
try {
const paths = expression(0);
return cursor === tokens.length ? paths : [];
} catch {
return [];
}
}
export function rconSuggestions(input: string, commands: RconHelpCommand[], usage?: string | null): RconSuggestion[] {
if (!input || input.length > 1_024) return [];
const slash = input.startsWith("/") ? "/" : "";
const parts = input.replace(/^\//, "").split(/\s+/);
const verb = parts[0]!;
if (parts.length === 1) {
return commands.filter((command) => command.name.toLowerCase().startsWith(verb.toLowerCase()))
.slice(0, 20).map((command) => ({ value: `${slash}${command.name} `, label: command.name, description: command.description }));
}
if (!commands.some((command) => command.name.toLowerCase() === verb.toLowerCase()) || !usage) return [];
const match = usage.match(/^\/?([a-z0-9_.:-]+)\s+([\s\S]+)$/i);
if (!match || match[1]!.toLowerCase() !== verb.toLowerCase()) return [];
const entered = parts.slice(1, -1);
const prefix = parts.at(-1)!;
const literals = new Set<string>();
for (const path of usagePaths(match[2]!)) {
if (!entered.every((value, index) => path[index] === value)) continue;
const next = path[entered.length];
if (next && next.startsWith(prefix)) literals.add(next);
}
const base = input.slice(0, input.length - prefix.length);
return [...literals].slice(0, 20).map((literal) => ({ value: `${base}${literal} `, label: literal, description: "From server help" }));
}
export function parseRconHelp(output: string): RconHelpPage {
const text = output.slice(0, 65_536).replace(/§[0-9a-fk-orx]/gi, "");
const pagination = text.match(/^.*Help:.*\((\d+)\/(\d+)\).*$/m);
const commands: RconHelpCommand[] = [];
for (const line of text.split(/\r?\n/)) {
const entry = line.trim().match(/^\/([a-z0-9_.:-]{1,128}):\s+(.+)$/i);
if (entry) commands.push({ name: entry[1]!, description: entry[2]!.slice(0, 512) });
}
const lines = text.split(/\r?\n/).map((line) => line.trim());
const usageStart = lines.findIndex((line) => /^Usage:\s*/i.test(line));
const usageLines: string[] = [];
if (usageStart !== -1) {
usageLines.push(lines[usageStart]!.replace(/^Usage:\s*/i, ""));
for (const line of lines.slice(usageStart + 1)) {
if (!line || /^[a-z][a-z ]+:\s|^-{3}/i.test(line)) break;
usageLines.push(line);
}
}
return {
commands,
page: pagination ? Number(pagination[1]) : null,
pages: pagination ? Number(pagination[2]) : null,
usage: usageLines.length ? usageLines.join("\n").slice(0, 4_096) : null,
};
}
+51
View File
@@ -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);
+69 -1
View File
@@ -1,6 +1,6 @@
# Admin API authentication
Implemented for `GET /api/admin/whoami` and the read-only [suggestions API](admin-suggestions-api.md). The shared guard is `apps/web/src/lib/auth/admin-api-auth.ts`. This is not browser token login, a general admin mutation API, or the Velocity admission credential mechanism. Browser pages, privileged server actions, RCON, and Velocity authentication are unchanged. OpenAPI publication is separate work (US-026).
Implemented for `GET /api/admin/whoami` and the read-only [suggestions API](admin-suggestions-api.md). The shared guard is `apps/web/src/lib/auth/admin-api-auth.ts`. This is not browser token login, a general admin mutation API, or the Velocity admission credential mechanism. Browser pages, privileged server actions, RCON, and Velocity authentication are unchanged. The canonical [OpenAPI 3.1 contract](../openapi.yaml) is public at `/openapi.yaml`; see [contract maintenance](openapi.md).
## Credential selection
@@ -23,6 +23,74 @@ One bounded, process-local remote JWKS resolver caches keys for ten minutes, coa
`KEYCLOAK_CLIENT_SECRET` is not needed for machine verification. This implementation does not obtain tokens or alter identity-provider clients, role/audience mappers, credentials, or deployments. See [OIDC setup](admin-oidc-keycloak-setup.md) for the distinct browser configuration.
## Obtain and use a machine token safely
The Keycloak token endpoint is `<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`:
+1 -1
View File
@@ -29,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/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 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. 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.
+55
View File
@@ -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.
+14 -1
View File
@@ -2,6 +2,19 @@
The administrator RCON console proxies commands through the Next.js server runtime. Browsers never receive RCON credentials and never open RCON sockets.
## Command discovery and autocomplete
Select an enabled server and choose **Refresh commands**. The portal reads `help`, then its numbered pages, stripping Minecraft formatting codes and collecting slash-prefixed command entries and descriptions. Plugin/category headings are not executable suggestions. Discovery is best-effort: plugins can omit commands, customize help, or use unsupported formatting. Unknown commands remain freely enterable.
- Refresh is sequential and capped at **64 pages** and **2,048 commands**. Inconsistent pagination, limits, cancellation, or failed requests produce incomplete results rather than a complete-coverage claim.
- Type a command prefix to open suggestions. **Arrow Up/Down** selects; **Tab/Enter** inserts without executing; **Escape** dismisses. Mouse selection also inserts. With suggestions closed, Arrow Up/Down recalls submitted commands and restores the unsent draft. Enter then submits normally; the explicit submit button remains available.
- After a discovered command followed by whitespace is entered, a **350 ms debounce** fetches `help <command>` once for usage hints. Wrapped usage remains readable. Balanced, bounded literal alternatives can suggest arguments, including nested alternatives; placeholders, optional/unsupported syntax, and overly complex usage are hints only, never invented values. This is not native Minecraft tab completion or live player-name completion.
- Commands, descriptions, and usage lookup results (including failures) stay in **page memory**, separately keyed by server ID, endpoint, and enabled state. Reload clears them; endpoint edits invalidate the matching cache. Refresh clears cached usage so failed lookups can be retried. Browsing already cached suggestions sends no RCON requests.
- Switching servers or submitting a manual command stops further discovery pages. An already-sent help request cannot be recalled; manual execution waits for that request to finish rather than racing it for the connection lock. Discovery and usage requests do not populate the console transcript or Arrow-key recall.
- Help requests use the same authorized, audited, bounded server-side command path as manual commands. Each request records a command audit event; responses and suggestion caches are not persisted. Help text is rendered as inert text, never HTML. Errors leave manual command entry available.
No additional Minecraft plugin, database migration, secret, or deployment setting is required for autocomplete.
## Application configuration
Administrators may configure any syntactically valid DNS hostname and TCP port without deployment-managed endpoint configuration. IP literals, trailing-dot hostnames, and malformed DNS names are rejected whenever a connection is saved, tested, or used.
@@ -23,7 +36,7 @@ The password entered in the administrator connection form must match the server
- Each web process allows one operation per connection and at most eight RCON operations total. Size replica counts with that aggregate ceiling in mind.
- Each complete connect-and-response operation times out after five seconds and tears down the socket; cleanup is independently capped at one second.
- Responses are sanitized and limited to 64 KiB.
- Up to 50 command/response exchanges remain in a chronological page-memory transcript, and up to 50 submitted commands support Arrow Up/Arrow Down recall. Both are discarded on reload; commands and responses are never written to browser storage, application persistence, or logs. Audit events contain the command verb and a domain-separated HMAC digest.
- Up to 50 command/response exchanges remain in a chronological page-memory transcript, and up to 50 submitted commands support Arrow Up/Arrow Down recall. Both are discarded on reload; commands and responses are never written to browser storage or application logs, and responses are not persisted. Audit events persist the complete submitted command, acting administrator and server identity, command verb, a domain-separated HMAC digest, and a correlated safe outcome. Persistent audit-backed command history is separate from page-memory recall.
- Connection passwords are never selected by page queries or returned to the browser.
RCON is plaintext TCP. Internal deployments should use network policy; external connections should use private routing, a VPN, or an encrypted tunnel rather than direct public exposure.
+1704
View File
File diff suppressed because it is too large Load Diff
+210 -1
View File
@@ -55,6 +55,7 @@
"world-atlas": "^2.0.2"
},
"devDependencies": {
"@apidevtools/swagger-parser": "^12.1.0",
"@tailwindcss/postcss": "^4.2.1",
"@testing-library/react": "^16.3.2",
"@types/d3-geo": "^3.1.1",
@@ -63,12 +64,32 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"eslint": "^9.39.4",
"eslint-config-next": "^16.3.4",
"jsdom": "^30.0.1",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vitest": "^4.1.0"
"vitest": "^4.1.0",
"yaml": "^2.9.0"
}
},
"apps/web/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"apps/web/node_modules/jose": {
@@ -80,6 +101,13 @@
"url": "https://github.com/sponsors/panva"
}
},
"apps/web/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -93,6 +121,97 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz",
"integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.15",
"js-yaml": "^4.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/philsturgeon"
}
},
"node_modules/@apidevtools/openapi-schemas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@apidevtools/swagger-methods": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
"dev": true,
"license": "MIT"
},
"node_modules/@apidevtools/swagger-parser": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz",
"integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==",
"dev": true,
"license": "MIT",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "14.0.1",
"@apidevtools/openapi-schemas": "^2.1.0",
"@apidevtools/swagger-methods": "^3.0.2",
"ajv": "^8.17.1",
"ajv-draft-04": "^1.0.0",
"call-me-maybe": "^1.0.2"
},
"peerDependencies": {
"openapi-types": ">=7"
}
},
"node_modules/@apidevtools/swagger-parser/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@apidevtools/swagger-parser/node_modules/ajv-draft-04": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"ajv": "^8.5.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/@apidevtools/swagger-parser/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/@asamuzakjp/css-color": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
@@ -3971,6 +4090,48 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -4390,6 +4551,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -5752,6 +5920,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fastq": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
@@ -7693,6 +7878,14 @@
"node": ">=14.0.0"
}
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
@@ -10114,6 +10307,22 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",