Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb8ef18688 | ||
|
|
47782b3ccc | ||
|
|
c2ac2ad16b | ||
|
|
2402e9e42d | ||
|
|
0af4884f7e | ||
|
|
1a01c0ed64 | ||
|
|
df17c021c6 | ||
|
|
ed3f3cd843 | ||
|
|
168a7a2c36 | ||
|
|
56cbecc3f7 | ||
|
|
d45ea4db68 | ||
|
|
19486150c3 | ||
|
|
3564d24a45 | ||
|
|
7f6d69e0a7 | ||
|
|
f9ccfd821d | ||
|
|
e43db34402 |
@@ -14,6 +14,8 @@ KEYCLOAK_REQUIRED_ROLE=minecraft-account-manager-admin
|
||||
DISCORD_BOT_TOKEN=
|
||||
DISCORD_APPLICATION_ID=
|
||||
DISCORD_GUILD_ID=
|
||||
# Optional admin suggestions reader; set the real forum ID only through GitOps.
|
||||
DISCORD_SUGGESTIONS_FORUM_ID=
|
||||
DISCORD_INVITE_URL=https://discord.gg/your-invite
|
||||
|
||||
# Trust forwarding headers only when your reverse proxy overwrites them
|
||||
@@ -25,5 +27,9 @@ PROXYCHECK_API_KEY=
|
||||
IP_INTELLIGENCE_CACHE_HOURS=48
|
||||
BLOCK_HOSTING_IPS=false
|
||||
|
||||
# Optional independent 32-byte base64 RCON keys. When omitted, domain-separated keys are derived from AUTH_SECRET.
|
||||
RCON_CREDENTIAL_KEY=
|
||||
RCON_AUDIT_KEY=
|
||||
|
||||
# Structured Pino logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
@@ -44,9 +44,6 @@ jobs:
|
||||
commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.sha }}" \
|
||||
--extends @commitlint/config-conventional
|
||||
|
||||
- name: Validate OKF design
|
||||
run: npm run design:validate
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ jobs:
|
||||
|
||||
- name: Validate release source
|
||||
run: |
|
||||
npm run design:validate
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm test
|
||||
|
||||
@@ -1,43 +1,9 @@
|
||||
# Repository Agent Guidance
|
||||
# minecraft-account-manager agent entrypoint
|
||||
|
||||
## User-story-driven development
|
||||
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||
|
||||
The `design/` directory is the OKF v0.1 product record for this repository. Use user stories to plan, implement, verify, and track all behavior.
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/minecraft-account-manager/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/minecraft-account-manager/` stories. Also follow the parent workspace `AGENTS.md` when present.
|
||||
|
||||
Before changing behavior:
|
||||
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/minecraft-account-manager/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
|
||||
|
||||
1. Read `design/index.md` and every story related to the requested behavior.
|
||||
2. Draft updates to an existing story or create a new `design/us-NNN-short-name.md` story before implementation.
|
||||
3. Define observable acceptance criteria using user or operator language.
|
||||
4. Present the relevant new or updated stories and acceptance criteria to the user for review, and wait for explicit confirmation before changing implementation code.
|
||||
5. Incorporate requested story changes before proceeding.
|
||||
6. Set story status to `proposed` or `in-progress` while the work is incomplete.
|
||||
|
||||
While implementing:
|
||||
|
||||
1. Work in vertical slices against the documented acceptance criteria.
|
||||
2. Add tests for important behavior before implementation when practical.
|
||||
3. Keep implementation references and related-story links current.
|
||||
4. Do not mark an acceptance criterion complete until the behavior exists and has been validated.
|
||||
|
||||
Before completing or committing:
|
||||
|
||||
1. Set completed story status to `implemented` or `verified` as appropriate.
|
||||
2. Check completed acceptance criteria and record validation evidence.
|
||||
3. Update `design/index.md` whenever stories are added, renamed, moved, or materially reclassified.
|
||||
4. Add a high-level entry to `design/log.md` under the verified current date.
|
||||
5. Run `npm run design:validate` along with relevant tests, type checks, lint, and builds.
|
||||
|
||||
## OKF conventions
|
||||
|
||||
- Every non-reserved Markdown file in `design/` must have YAML frontmatter with a non-empty `type`.
|
||||
- User stories use `type: User Story` and include `story_id`, `status`, `title`, `description`, `tags`, and `timestamp`.
|
||||
- Allowed story statuses are `proposed`, `in-progress`, `implemented`, and `verified`.
|
||||
- `design/index.md` and `design/log.md` are reserved OKF files and follow the OKF index/log structures.
|
||||
- Prefer structured sections: `# User Story`, `# Acceptance Criteria`, `# Implementation`, `# Validation`, and `# Related Stories`.
|
||||
- Use repository-relative links and keep them valid when files move.
|
||||
- Preserve unknown frontmatter extensions.
|
||||
|
||||
## Timestamps
|
||||
|
||||
Always run `date -u +%Y-%m-%dT%H:%M:%SZ` before adding or updating story timestamps or dated log entries. Never guess dates.
|
||||
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
|
||||
|
||||
@@ -32,14 +32,23 @@ Set `IP_INTELLIGENCE_PROVIDER=proxycheck`, add `PROXYCHECK_API_KEY`, and configu
|
||||
|
||||
Open `http://localhost:3000`.
|
||||
|
||||
## Admin suggestions
|
||||
|
||||
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 as OKF user stories in [`design/index.md`](design/index.md). Validate the bundle with `npm run design:validate`.
|
||||
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.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
npm run design:validate
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
@@ -76,7 +85,7 @@ The token is displayed once and stored only as a SHA-256 hash.
|
||||
|
||||
- PostgreSQL and Drizzle ORM
|
||||
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
|
||||
- Admin user search, account management, event exploration, DAU and confirmed-connection metrics, toggleable Natural Earth/OpenStreetMap user-location views, and automatic Discord nickname synchronization
|
||||
- Admin user search, account management, event exploration, DAU and confirmed-connection metrics, toggleable Natural Earth/OpenStreetMap user-location views, RCON server-address management and command proxying, and automatic Discord nickname synchronization
|
||||
- Exclusive group admission: unassigned users fall back to protected `everyone`, and administrators manage effective membership, access, recurring UTC login windows, and VPN/proxy/Tor exceptions through confirmed group workflows
|
||||
- Deployment-managed Discord guild ID and invite URL
|
||||
- discord.js bot with `/register` and `/account`
|
||||
|
||||
@@ -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 [
|
||||
|
||||
+11
-3
@@ -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"
|
||||
},
|
||||
@@ -19,15 +21,18 @@
|
||||
"@minecraft-account-manager/network": "*",
|
||||
"d3-geo": "^3.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"jose": "^6.2.12",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^16.2.1",
|
||||
"next": "^16.3.4",
|
||||
"next-auth": "^4.24.13",
|
||||
"rcon-client": "^4.2.5",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"topojson-client": "^3.1.0",
|
||||
"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",
|
||||
@@ -36,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.2.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ export default async function AdminConsoleLayout({ children }: { children: React
|
||||
<Link className="hover:text-accent" href="/admin/settings">Settings</Link>
|
||||
<Link className="hover:text-accent" href="/admin/users">Users</Link>
|
||||
<Link className="hover:text-accent" href="/admin/groups">Groups</Link>
|
||||
<Link className="hover:text-accent" href="/admin/rcon">RCON</Link>
|
||||
<Link className="hover:text-accent" href="/admin/suggestions">Suggestions</Link>
|
||||
<Link className="hover:text-accent" href="/admin/events">Events</Link>
|
||||
</nav>
|
||||
<AdminSignOutButton />
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const actionState = vi.hoisted(() => ({
|
||||
authorized: 0,
|
||||
selected: [] as unknown[],
|
||||
transactionSelected: [] as unknown[],
|
||||
updates: [] as Record<string, unknown>[],
|
||||
inserts: [] as unknown[],
|
||||
audits: [] as Array<{ admin: unknown; subject: string; type: string; data: Record<string, unknown>; correlationId?: string }>,
|
||||
auditFailure: false,
|
||||
executions: [] as Array<{ connection: Record<string, unknown>; command: string }>,
|
||||
gatewayResult: { ok: true, response: "private response" } as
|
||||
| { ok: true; response: string }
|
||||
| { ok: false; reason: "busy" | "timeout" | "unavailable" },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/require-admin", () => ({
|
||||
requireAdminSession: async () => {
|
||||
actionState.authorized += 1;
|
||||
return { email: "admin@example.test", name: "Admin" };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (path: string) => {
|
||||
throw new Error(`REDIRECT:${path}`);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/database", () => {
|
||||
function selection(result: unknown[]) {
|
||||
const chain = {
|
||||
from: () => chain,
|
||||
where: () => chain,
|
||||
limit: async () => result,
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
const tx = {
|
||||
execute: async () => undefined,
|
||||
select: () => selection(actionState.transactionSelected),
|
||||
update: () => ({
|
||||
set: (value: Record<string, unknown>) => ({
|
||||
where: async () => { actionState.updates.push(value); },
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: async (value: unknown) => { actionState.inserts.push(value); },
|
||||
}),
|
||||
};
|
||||
return {
|
||||
db: {
|
||||
select: () => selection(actionState.selected),
|
||||
transaction: async (callback: (transaction: typeof tx) => Promise<unknown>) => callback(tx),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rcon-validation", () => ({
|
||||
validateRconCommand: (value: unknown) => typeof value === "string" && value.trim() ? value.trim() : null,
|
||||
validateRconConnection: (input: { name?: string; host?: string; port?: number; password?: string }) => {
|
||||
if (!input.name || !input.host || !input.port) return null;
|
||||
return input;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rcon-credentials", () => ({
|
||||
decryptRconPassword: () => "decrypted-password",
|
||||
encryptRconPassword: vi.fn(),
|
||||
rconCommandDigest: () => "hmac-sha256:v1:digest",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rcon-gateway", () => ({
|
||||
executeRcon: async (connection: Record<string, unknown>, command: string) => {
|
||||
actionState.executions.push({ connection, command });
|
||||
return actionState.gatewayResult;
|
||||
},
|
||||
testRconConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/audit", () => ({
|
||||
recordAdminSubjectEvent: async (
|
||||
admin: unknown,
|
||||
subject: string,
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
options?: { correlationId?: string },
|
||||
) => {
|
||||
if (actionState.auditFailure) throw new Error("audit unavailable");
|
||||
actionState.audits.push({ admin, subject, type, data, correlationId: options?.correlationId });
|
||||
return "22222222-2222-4222-8222-222222222222";
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
createRconServer,
|
||||
deleteRconServer,
|
||||
executeRconCommand,
|
||||
setRconServerEnabled,
|
||||
testSavedRconServer,
|
||||
updateRconServer,
|
||||
} from "./actions";
|
||||
|
||||
const serverId = "11111111-1111-4111-8111-111111111111";
|
||||
const savedServer = {
|
||||
id: serverId,
|
||||
name: "Season 4",
|
||||
host: "season4.somc.svc.cluster.local",
|
||||
port: 25575,
|
||||
encryptedPassword: "ciphertext",
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
describe("RCON server actions", () => {
|
||||
beforeEach(() => {
|
||||
actionState.authorized = 0;
|
||||
actionState.selected = [];
|
||||
actionState.transactionSelected = [];
|
||||
actionState.updates = [];
|
||||
actionState.inserts = [];
|
||||
actionState.audits = [];
|
||||
actionState.auditFailure = false;
|
||||
actionState.executions = [];
|
||||
actionState.gatewayResult = { ok: true, response: "private response" };
|
||||
});
|
||||
|
||||
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");
|
||||
await expect(setRconServerEnabled(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=unknown-connection");
|
||||
await expect(deleteRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=confirmation-required");
|
||||
await expect(testSavedRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=connection-unavailable");
|
||||
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, new FormData())).resolves.toEqual({
|
||||
status: "error",
|
||||
message: "Enter one command of at most 1,024 bytes without control characters.",
|
||||
serverId: "",
|
||||
});
|
||||
expect(actionState.authorized).toBe(6);
|
||||
});
|
||||
|
||||
it("preserves the encrypted password on an unrelated connection update", async () => {
|
||||
actionState.transactionSelected = [savedServer];
|
||||
const formData = new FormData();
|
||||
formData.set("serverId", serverId);
|
||||
formData.set("name", "Renamed server");
|
||||
formData.set("host", "season4.somc.svc.cluster.local");
|
||||
formData.set("port", "25575");
|
||||
formData.set("password", "");
|
||||
formData.set("enabled", "yes");
|
||||
|
||||
await expect(updateRconServer(formData)).rejects.toThrow("REDIRECT:/admin/rcon?saved=updated");
|
||||
expect(actionState.updates).toEqual([
|
||||
expect.objectContaining({ encryptedPassword: "ciphertext", enabled: true }),
|
||||
]);
|
||||
expect(JSON.stringify(actionState.inserts)).not.toContain("ciphertext");
|
||||
expect(JSON.stringify(actionState.inserts)).not.toContain("decrypted-password");
|
||||
});
|
||||
|
||||
it("rechecks enabled saved state and records complete command lifecycle audits", async () => {
|
||||
actionState.selected = [savedServer];
|
||||
const formData = new FormData();
|
||||
formData.set("serverId", serverId);
|
||||
formData.set("command", "say private value");
|
||||
|
||||
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
|
||||
status: "success",
|
||||
message: "private response",
|
||||
serverId,
|
||||
});
|
||||
|
||||
expect(actionState.authorized).toBe(1);
|
||||
expect(actionState.executions).toEqual([{
|
||||
connection: expect.objectContaining({ id: serverId, enabled: true, password: "decrypted-password" }),
|
||||
command: "say private value",
|
||||
}]);
|
||||
expect(actionState.audits).toEqual([
|
||||
expect.objectContaining({
|
||||
admin: { email: "admin@example.test", name: "Admin" },
|
||||
subject: `rcon-server/${serverId}`,
|
||||
type: "games.minecraft.account-manager.rcon.command.requested",
|
||||
data: expect.objectContaining({ command: "say private value", verb: "say", commandDigest: "hmac-sha256:v1:digest" }),
|
||||
correlationId: expect.stringMatching(/^[0-9a-f-]{36}$/),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
subject: `rcon-server/${serverId}`,
|
||||
type: "games.minecraft.account-manager.rcon.command.completed",
|
||||
data: expect.objectContaining({ success: true, durationMs: expect.any(Number) }),
|
||||
correlationId: expect.stringMatching(/^[0-9a-f-]{36}$/),
|
||||
}),
|
||||
]);
|
||||
const serializedAudits = JSON.stringify(actionState.audits);
|
||||
expect(actionState.audits[0]?.correlationId).toBe(actionState.audits[1]?.correlationId);
|
||||
expect(serializedAudits).toContain("private value");
|
||||
expect(serializedAudits).not.toContain("private response");
|
||||
expect(serializedAudits).not.toContain("decrypted-password");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["busy", "Another command is already running for this server."],
|
||||
["timeout", "The RCON request timed out."],
|
||||
["unavailable", "The RCON server was unavailable or rejected authentication."],
|
||||
] as const)("returns a safe %s failure without exposing transport details", async (reason, message) => {
|
||||
actionState.selected = [savedServer];
|
||||
actionState.gatewayResult = { ok: false, reason };
|
||||
const formData = new FormData();
|
||||
formData.set("serverId", serverId);
|
||||
formData.set("command", "list");
|
||||
|
||||
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
|
||||
status: "error",
|
||||
message,
|
||||
serverId,
|
||||
});
|
||||
expect(JSON.stringify(actionState.audits)).not.toContain("decrypted-password");
|
||||
});
|
||||
|
||||
it("does not send a command when its requested audit cannot be recorded", async () => {
|
||||
actionState.selected = [savedServer];
|
||||
actionState.auditFailure = true;
|
||||
const formData = new FormData();
|
||||
formData.set("serverId", serverId);
|
||||
formData.set("command", "list");
|
||||
|
||||
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
|
||||
status: "error",
|
||||
message: "Command not sent because its audit record could not be created.",
|
||||
serverId,
|
||||
});
|
||||
expect(actionState.executions).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not execute or audit when the enabled connection is unavailable", async () => {
|
||||
const formData = new FormData();
|
||||
formData.set("serverId", serverId);
|
||||
formData.set("command", "list");
|
||||
|
||||
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
|
||||
status: "error",
|
||||
message: "That RCON connection is disabled or unavailable.",
|
||||
serverId,
|
||||
});
|
||||
expect(actionState.executions).toEqual([]);
|
||||
expect(actionState.audits).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
"use server";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { events, rconServers } from "@minecraft-account-manager/database";
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||
import { db } from "@/lib/database";
|
||||
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
||||
import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "@/lib/rcon-credentials";
|
||||
import { executeRcon, testRconConnection } from "@/lib/rcon-gateway";
|
||||
import { recordAdminSubjectEvent } from "@/lib/audit";
|
||||
import { validateRconCommand, validateRconConnection } from "@/lib/rcon-validation";
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
type Admin = Awaited<ReturnType<typeof requireAdminSession>>;
|
||||
export type RconCommandState = {
|
||||
status: "idle" | "success" | "error";
|
||||
message: string;
|
||||
serverId: string;
|
||||
};
|
||||
|
||||
function formConnection(formData: FormData, passwordRequired: boolean) {
|
||||
return validateRconConnection({
|
||||
name: formData.get("name"),
|
||||
host: formData.get("host"),
|
||||
port: formData.get("port"),
|
||||
password: formData.get("password"),
|
||||
}, { passwordRequired });
|
||||
}
|
||||
|
||||
async function auditContext() {
|
||||
const requestHeaders = await headers();
|
||||
return getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
}
|
||||
|
||||
function auditData(admin: Admin, data: Record<string, unknown>) {
|
||||
return { ...data, adminEmail: admin.email, adminName: admin.name };
|
||||
}
|
||||
|
||||
function rconPath(query: string) {
|
||||
return `/admin/rcon?${query}`;
|
||||
}
|
||||
|
||||
export async function createRconServer(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const details = formConnection(formData, true);
|
||||
if (!details?.password) redirect(rconPath("error=invalid-connection"));
|
||||
const id = randomUUID();
|
||||
let encryptedPassword: string;
|
||||
try {
|
||||
encryptedPassword = encryptRconPassword(details.password, id);
|
||||
} catch {
|
||||
redirect(rconPath("error=configuration"));
|
||||
}
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(rconServers).values({
|
||||
id,
|
||||
name: details.name,
|
||||
host: details.host,
|
||||
port: details.port,
|
||||
encryptedPassword,
|
||||
enabled: formData.get("enabled") === "yes",
|
||||
});
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: "games.minecraft.account-manager.rcon.connection.created",
|
||||
subject: `rcon-server/${id}`,
|
||||
time: new Date(),
|
||||
data: auditData(admin, { name: details.name, host: details.host, port: details.port }),
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintViolation(error, "rcon_servers_name_uidx")) redirect(rconPath("error=duplicate-name"));
|
||||
redirect(rconPath("error=save-failed"));
|
||||
}
|
||||
redirect(rconPath("saved=created"));
|
||||
}
|
||||
|
||||
export async function updateRconServer(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const serverId = String(formData.get("serverId") ?? "");
|
||||
const details = formConnection(formData, false);
|
||||
if (!UUID_PATTERN.test(serverId) || !details) redirect(rconPath("error=invalid-connection"));
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
let result: string | null;
|
||||
try {
|
||||
result = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select ${rconServers.id} from ${rconServers} where ${rconServers.id} = ${serverId} for update`);
|
||||
const [current] = await tx.select().from(rconServers).where(eq(rconServers.id, serverId)).limit(1);
|
||||
if (!current) return null;
|
||||
let encryptedPassword = current.encryptedPassword;
|
||||
if (details.password) encryptedPassword = encryptRconPassword(details.password, current.id);
|
||||
const enabled = formData.get("enabled") === "yes";
|
||||
await tx.update(rconServers).set({
|
||||
name: details.name,
|
||||
host: details.host,
|
||||
port: details.port,
|
||||
encryptedPassword,
|
||||
enabled,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(rconServers.id, current.id));
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: "games.minecraft.account-manager.rcon.connection.updated",
|
||||
subject: `rcon-server/${current.id}`,
|
||||
time: new Date(),
|
||||
data: auditData(admin, {
|
||||
name: details.name,
|
||||
host: details.host,
|
||||
port: details.port,
|
||||
enabled,
|
||||
passwordReplaced: Boolean(details.password),
|
||||
}),
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
return current.id;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintViolation(error, "rcon_servers_name_uidx")) redirect(rconPath("error=duplicate-name"));
|
||||
redirect(rconPath("error=save-failed"));
|
||||
}
|
||||
if (!result) redirect(rconPath("error=unknown-connection"));
|
||||
redirect(rconPath("saved=updated"));
|
||||
}
|
||||
|
||||
export async function setRconServerEnabled(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const serverId = String(formData.get("serverId") ?? "");
|
||||
if (!UUID_PATTERN.test(serverId)) redirect(rconPath("error=unknown-connection"));
|
||||
const enabled = formData.get("enabled") === "yes";
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select ${rconServers.id} from ${rconServers} where ${rconServers.id} = ${serverId} for update`);
|
||||
const [current] = await tx.select().from(rconServers).where(eq(rconServers.id, serverId)).limit(1);
|
||||
if (!current) return "missing" as const;
|
||||
if (enabled && !validateRconConnection({ ...current, password: "placeholder" }, { passwordRequired: true })) return "invalid" as const;
|
||||
await tx.update(rconServers).set({ enabled, updatedAt: new Date() }).where(eq(rconServers.id, current.id));
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.enabled-updated",
|
||||
subject: `rcon-server/${current.id}`, time: new Date(), data: auditData(admin, { name: current.name, enabled }), ipAddress: ipAddress ?? null,
|
||||
});
|
||||
return "updated" as const;
|
||||
});
|
||||
if (result === "missing") redirect(rconPath("error=unknown-connection"));
|
||||
if (result === "invalid") redirect(rconPath("error=invalid-connection"));
|
||||
redirect(rconPath(`saved=${enabled ? "enabled" : "disabled"}`));
|
||||
}
|
||||
|
||||
export async function deleteRconServer(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const serverId = String(formData.get("serverId") ?? "");
|
||||
if (!UUID_PATTERN.test(serverId) || formData.get("confirmation") !== serverId) redirect(rconPath("error=confirmation-required"));
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
const deleted = await db.transaction(async (tx) => {
|
||||
const [server] = await tx.delete(rconServers).where(eq(rconServers.id, serverId)).returning({ id: rconServers.id, name: rconServers.name });
|
||||
if (!server) return null;
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.deleted",
|
||||
subject: `rcon-server/${server.id}`, time: new Date(), data: auditData(admin, { name: server.name }), ipAddress: ipAddress ?? null,
|
||||
});
|
||||
return server;
|
||||
});
|
||||
if (!deleted) redirect(rconPath("error=unknown-connection"));
|
||||
redirect(rconPath("saved=deleted"));
|
||||
}
|
||||
|
||||
async function savedConnection(serverId: string, requireEnabled: boolean) {
|
||||
if (!UUID_PATTERN.test(serverId)) return null;
|
||||
const [server] = await db.select().from(rconServers).where(requireEnabled
|
||||
? and(eq(rconServers.id, serverId), eq(rconServers.enabled, true))
|
||||
: eq(rconServers.id, serverId)).limit(1);
|
||||
if (!server) return null;
|
||||
const validated = validateRconConnection({ ...server, password: "placeholder" }, { passwordRequired: true });
|
||||
if (!validated) return null;
|
||||
try {
|
||||
return { ...server, password: decryptRconPassword(server.encryptedPassword, server.id) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSavedRconServer(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const serverId = String(formData.get("serverId") ?? "");
|
||||
const server = await savedConnection(serverId, false);
|
||||
if (!server) redirect(rconPath("error=connection-unavailable"));
|
||||
const started = Date.now();
|
||||
const result = await testRconConnection(server);
|
||||
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.connection.tested", {
|
||||
serverId: server.id,
|
||||
name: server.name,
|
||||
success: result.ok,
|
||||
reason: result.ok ? null : result.reason,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
redirect(rconPath(result.ok ? "saved=tested" : `error=test-${result.reason}`));
|
||||
}
|
||||
|
||||
export async function executeRconCommand(
|
||||
_previous: RconCommandState,
|
||||
formData: FormData,
|
||||
): Promise<RconCommandState> {
|
||||
const admin = await requireAdminSession();
|
||||
const serverId = String(formData.get("serverId") ?? "");
|
||||
const command = validateRconCommand(formData.get("command"));
|
||||
if (!command) return { status: "error", message: "Enter one command of at most 1,024 bytes without control characters.", serverId };
|
||||
const server = await savedConnection(serverId, true);
|
||||
if (!server) return { status: "error", message: "That RCON connection is disabled or unavailable.", serverId };
|
||||
const verb = command.split(/\s+/u, 1)[0]!.toLowerCase().slice(0, 64);
|
||||
let commandDigest: string;
|
||||
try {
|
||||
commandDigest = rconCommandDigest(command);
|
||||
} catch {
|
||||
return { status: "error", message: "RCON command auditing is not configured.", serverId };
|
||||
}
|
||||
const started = Date.now();
|
||||
const correlationId = randomUUID();
|
||||
|
||||
try {
|
||||
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.requested", {
|
||||
serverId: server.id,
|
||||
name: server.name,
|
||||
command,
|
||||
verb,
|
||||
commandDigest,
|
||||
}, { correlationId });
|
||||
} catch {
|
||||
return { status: "error", message: "Command not sent because its audit record could not be created.", serverId };
|
||||
}
|
||||
const result = await executeRcon(server, command);
|
||||
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.completed", {
|
||||
serverId: server.id,
|
||||
name: server.name,
|
||||
verb,
|
||||
commandDigest,
|
||||
success: result.ok,
|
||||
reason: result.ok ? null : result.reason,
|
||||
durationMs: Date.now() - started,
|
||||
}, { correlationId });
|
||||
if (!result.ok) {
|
||||
const message = result.reason === "busy"
|
||||
? "Another command is already running for this server."
|
||||
: result.reason === "timeout"
|
||||
? "The RCON request timed out."
|
||||
: "The RCON server was unavailable or rejected authentication.";
|
||||
return { status: "error", message, serverId };
|
||||
}
|
||||
return { status: "success", message: result.response || "Command completed with no response.", serverId };
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { events, rconServers } from "@minecraft-account-manager/database";
|
||||
import { and, desc, eq, ilike, inArray, or, sql, type SQL } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { db } from "@/lib/database";
|
||||
import {
|
||||
buildRconCommandHistory,
|
||||
normalizeRconHistoryFilters,
|
||||
RCON_COMMAND_COMPLETED,
|
||||
RCON_COMMAND_REQUESTED,
|
||||
} from "@/lib/rcon-command-history";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const requestedFields = {
|
||||
id: events.id,
|
||||
time: events.time,
|
||||
correlationId: events.correlationId,
|
||||
data: events.data,
|
||||
};
|
||||
|
||||
export default async function RconHistoryPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const [query, servers] = await Promise.all([
|
||||
searchParams,
|
||||
db.select({ id: rconServers.id, name: rconServers.name }).from(rconServers).orderBy(rconServers.name),
|
||||
]);
|
||||
const filters = normalizeRconHistoryFilters(query, servers.map((server) => server.id));
|
||||
const conditions: SQL[] = [eq(events.type, RCON_COMMAND_REQUESTED)];
|
||||
if (filters.serverId) conditions.push(eq(events.subject, `rcon-server/${filters.serverId}`));
|
||||
if (filters.command) conditions.push(ilike(sql<string>`${events.data} ->> 'command'`, `%${filters.command}%`));
|
||||
if (filters.admin) {
|
||||
conditions.push(or(
|
||||
ilike(sql<string>`${events.data} ->> 'adminEmail'`, `%${filters.admin}%`),
|
||||
ilike(sql<string>`${events.data} ->> 'adminName'`, `%${filters.admin}%`),
|
||||
)!);
|
||||
}
|
||||
|
||||
const requested = await db.select(requestedFields)
|
||||
.from(events)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(events.time))
|
||||
.limit(100);
|
||||
const correlationIds = requested.flatMap((event) => event.correlationId ? [event.correlationId] : []);
|
||||
const completed = correlationIds.length
|
||||
? await db.select(requestedFields).from(events).where(and(
|
||||
eq(events.type, RCON_COMMAND_COMPLETED),
|
||||
inArray(events.correlationId, correlationIds),
|
||||
))
|
||||
: [];
|
||||
const history = buildRconCommandHistory(requested, completed);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-6 py-14">
|
||||
<Link className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline underline-offset-4" href="/admin/rcon">← RCON console</Link>
|
||||
<header className="mt-7 grid gap-5 border-b-2 border-ink pb-8 lg:grid-cols-[1fr_auto] lg:items-end">
|
||||
<div>
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Persistent audit ledger</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">Command history</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-6 text-muted">Search commands sent through the portal. Responses and RCON credentials are never retained here.</p>
|
||||
</div>
|
||||
<div className="border border-line bg-panel px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-widest">
|
||||
<span className="text-accent">{history.length}</span> matching records
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form className="mt-8 border border-line bg-panel p-5 shadow-[6px_6px_0_var(--color-shadow)]" method="get">
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<Filter label="Command text" name="command" placeholder="say, whitelist add…" value={filters.command} />
|
||||
<Filter label="Administrator" name="admin" placeholder="name or email" value={filters.admin} />
|
||||
<label className="font-mono text-[10px] font-bold uppercase tracking-wider" htmlFor="history-server">
|
||||
Server
|
||||
<select className="mt-2 block w-full border border-line bg-canvas px-3 py-3 font-sans text-sm font-normal normal-case outline-none focus:border-accent" defaultValue={filters.serverId} id="history-server" name="server">
|
||||
<option value="">All servers</option>
|
||||
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-4">
|
||||
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Search history</button>
|
||||
<Link className="self-center font-mono text-[10px] font-bold uppercase underline underline-offset-4" href="/admin/rcon/history">Clear filters</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 overflow-x-auto border-2 border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<table className="w-full min-w-[980px] border-collapse text-left">
|
||||
<caption className="sr-only">RCON command audit history</caption>
|
||||
<thead className="border-b-2 border-ink bg-canvas font-mono text-[10px] uppercase tracking-widest text-muted">
|
||||
<tr><th className="p-4" scope="col">Time</th><th className="p-4" scope="col">Server</th><th className="p-4" scope="col">Administrator</th><th className="p-4" scope="col">Command</th><th className="p-4" scope="col">Outcome</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line text-xs">
|
||||
{history.map((entry) => (
|
||||
<tr className="align-top hover:bg-canvas/60" key={entry.eventId}>
|
||||
<td className="whitespace-nowrap p-4 font-mono text-muted"><Link className="underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/events/${entry.eventId}`}><time dateTime={entry.time.toISOString()}>{entry.time.toISOString()}</time></Link></td>
|
||||
<td className="p-4"><span className="font-mono font-bold">{entry.serverName}</span><span className="mt-1 block font-mono text-[9px] text-muted">{entry.serverId}</span></td>
|
||||
<td className="p-4"><span className="font-bold">{entry.adminName ?? "Unknown administrator"}</span><span className="mt-1 block font-mono text-[10px] text-muted">{entry.adminEmail ?? "Email unavailable"}</span></td>
|
||||
<td className="max-w-xl p-4"><code className="whitespace-pre-wrap break-words font-mono text-xs"><span className="mr-2 text-accent">$</span>{entry.command}</code></td>
|
||||
<td className="p-4"><Outcome status={entry.status} />{entry.reason && <span className="mt-2 block font-mono text-[9px] text-muted">{entry.reason}</span>}{entry.durationMs !== null && <span className="mt-1 block font-mono text-[9px] text-muted">{entry.durationMs} ms</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!history.length && <tr><td className="p-10 text-center text-muted" colSpan={5}>No RCON commands match these filters.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Filter({ label, name, placeholder, value }: { label: string; name: string; placeholder: string; value: string }) {
|
||||
return <label className="font-mono text-[10px] font-bold uppercase tracking-wider" htmlFor={`history-${name}`}>{label}<input className="mt-2 block w-full border border-line bg-canvas px-3 py-3 font-sans text-sm font-normal normal-case outline-none placeholder:text-muted focus:border-accent" defaultValue={value} id={`history-${name}`} maxLength={name === "command" ? 1024 : 320} name={name} placeholder={placeholder} /></label>;
|
||||
}
|
||||
|
||||
function Outcome({ status }: { status: "pending" | "succeeded" | "failed" }) {
|
||||
const className = status === "succeeded" ? "border-signal text-ink" : status === "failed" ? "border-accent text-accent" : "border-line text-muted";
|
||||
return <span className={`inline-block border-l-2 pl-2 font-mono text-[9px] font-bold uppercase tracking-wider ${className}`}>{status}</span>;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { rconServers } from "@minecraft-account-manager/database";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { RconConsole, type RconTerminalNotice } from "@/components/rcon-console";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const savedMessages: Record<string, string> = {
|
||||
created: "RCON connection created.",
|
||||
updated: "RCON connection updated.",
|
||||
enabled: "RCON connection enabled.",
|
||||
disabled: "RCON connection disabled.",
|
||||
deleted: "RCON connection deleted.",
|
||||
tested: "RCON authentication succeeded.",
|
||||
};
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
"invalid-connection": "Enter a valid DNS hostname, port, name, and password.",
|
||||
"duplicate-name": "Connection names must be unique.",
|
||||
configuration: "RCON credential encryption is not configured.",
|
||||
"save-failed": "The RCON connection could not be saved.",
|
||||
"unknown-connection": "That RCON connection no longer exists.",
|
||||
"confirmation-required": "Confirm the connection before deleting it.",
|
||||
"connection-unavailable": "The connection is invalid or its credential is unavailable.",
|
||||
"test-busy": "Another RCON operation is already using that server.",
|
||||
"test-timeout": "RCON authentication timed out.",
|
||||
"test-unavailable": "The RCON server was unavailable or rejected authentication.",
|
||||
};
|
||||
|
||||
function queryValue(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
export default async function RconPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const query = await searchParams;
|
||||
const saved = queryValue(query.saved);
|
||||
const error = queryValue(query.error);
|
||||
const notice: RconTerminalNotice | undefined = error
|
||||
? { status: "error", message: errorMessages[error] ?? "The RCON operation failed." }
|
||||
: saved
|
||||
? { status: "success", message: savedMessages[saved] ?? "RCON settings saved." }
|
||||
: undefined;
|
||||
const servers = await db.select({
|
||||
id: rconServers.id,
|
||||
name: rconServers.name,
|
||||
host: rconServers.host,
|
||||
port: rconServers.port,
|
||||
enabled: rconServers.enabled,
|
||||
}).from(rconServers).orderBy(asc(rconServers.name));
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||
<header className="border-b border-line pb-8">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Server operations</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">RCON</h1>
|
||||
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Select and manage a connection, then run commands through the portal backend. Credentials are never sent to the browser.</p>
|
||||
</header>
|
||||
|
||||
<section className="mt-10">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Command proxy</p>
|
||||
<h2 className="mt-2 font-display text-3xl font-black uppercase">Terminal</h2>
|
||||
</div>
|
||||
<p className="max-w-xl text-xs leading-5 text-muted">Only the latest bounded response is shown. Commands and responses are not saved as console history.</p>
|
||||
</div>
|
||||
<RconConsole notice={notice} servers={servers} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SuggestionReader } from "@/components/suggestion-reader";
|
||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SuggestionPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
await requireAdminSession();
|
||||
const { id } = await params;
|
||||
if (!/^[1-9]\d{16,19}$/.test(id)) notFound();
|
||||
return <main className="mx-auto max-w-6xl px-6 py-14">
|
||||
<Link className="mb-8 inline-block font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4 hover:text-accent" href="/admin/suggestions">← All suggestions</Link>
|
||||
<SuggestionReader id={id} key={id} />
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { expect, it, vi } from "vitest";
|
||||
vi.mock("next-auth", () => ({ getServerSession: async () => ({ user: { roles: ["ops"] } }) }));
|
||||
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
|
||||
vi.mock("@minecraft-account-manager/database", () => ({ recordEvent: vi.fn() }));
|
||||
vi.mock("@/lib/database", () => ({ db: {} }));
|
||||
vi.mock("@/components/admin-sign-out-button", () => ({ AdminSignOutButton: () => <button>Sign out</button> }));
|
||||
import AdminConsoleLayout from "../layout";
|
||||
it("adds suggestions to administrator navigation", async () => {
|
||||
const markup = renderToStaticMarkup(await AdminConsoleLayout({ children: <p>Console</p> }));
|
||||
expect(markup).toContain('href="/admin/suggestions"');
|
||||
expect(markup).toContain("Suggestions</a>");
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
const mocks = vi.hoisted(() => ({ requireAdmin: vi.fn() }));
|
||||
vi.mock("@/lib/auth/require-admin", () => ({ requireAdminSession: mocks.requireAdmin }));
|
||||
vi.mock("@/components/suggestions-browser", () => ({ SuggestionsBrowser: () => <div>Suggestions browser</div> }));
|
||||
vi.mock("@/components/suggestion-reader", () => ({ SuggestionReader: ({ id }: { id: string }) => <div>Reader {id}</div> }));
|
||||
import SuggestionsPage from "./page";
|
||||
import SuggestionPage from "./[id]/page";
|
||||
const params = Promise.resolve({ id: "100000000000000009" });
|
||||
beforeEach(() => { mocks.requireAdmin.mockReset(); });
|
||||
it("independently requires the admin guard before rendering either page", async () => {
|
||||
mocks.requireAdmin.mockRejectedValue(new Error("Admin sign-in required"));
|
||||
await expect(SuggestionsPage()).rejects.toThrow("Admin sign-in required");
|
||||
await expect(SuggestionPage({ params })).rejects.toThrow("Admin sign-in required");
|
||||
});
|
||||
it("renders the browser and detail in the existing portal layout", async () => {
|
||||
mocks.requireAdmin.mockResolvedValue({ name: "Admin" });
|
||||
expect(renderToStaticMarkup(await SuggestionsPage())).toContain("Suggestions browser");
|
||||
const detail = renderToStaticMarkup(await SuggestionPage({ params }));
|
||||
expect(detail).toContain("Reader");
|
||||
expect(detail).toContain('href="/admin/suggestions"');
|
||||
});
|
||||
it("rejects malformed IDs rather than constructing arbitrary API URLs", async () => {
|
||||
mocks.requireAdmin.mockResolvedValue({ name: "Admin" });
|
||||
await expect(SuggestionPage({ params: Promise.resolve({ id: "../secret" }) })).rejects.toThrow("NEXT_HTTP_ERROR_FALLBACK;404");
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { SuggestionsBrowser } from "@/components/suggestions-browser";
|
||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SuggestionsPage() {
|
||||
await requireAdminSession();
|
||||
return <main className="mx-auto max-w-6xl px-6 py-14">
|
||||
<header className="relative overflow-hidden border-b border-line pb-8">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Community / Idea desk</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">Suggestions</h1>
|
||||
<div className="mt-5 flex flex-wrap items-end justify-between gap-5">
|
||||
<p className="max-w-2xl text-sm leading-6 text-muted">Ideas from the Discord forum, brought into the operations desk. Read proposals and discussion here; keep the conversation in Discord.</p>
|
||||
<span className="border border-ink bg-signal px-3 py-2 font-mono text-[10px] font-bold uppercase tracking-widest">Admin-only / Read-only</span>
|
||||
</div>
|
||||
</header>
|
||||
<SuggestionsBrowser />
|
||||
</main>;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
|
||||
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||
import { events, groups, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { events, groups, ipIntelligence, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { groupAccessAddresses } from "@/lib/access-address-groups";
|
||||
import { accessAddressDetails, groupAccessAddresses } from "@/lib/access-address-groups";
|
||||
import { db } from "@/lib/database";
|
||||
import { discordIdentity } from "@/lib/discord-identity";
|
||||
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
|
||||
@@ -80,8 +80,16 @@ export default async function AdminUserPage({
|
||||
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
|
||||
recentEventsQuery,
|
||||
db
|
||||
.select()
|
||||
.select({
|
||||
id: ipObservations.id,
|
||||
ipAddress: ipObservations.ipAddress,
|
||||
source: ipObservations.source,
|
||||
classification: ipObservations.classification,
|
||||
observedAt: ipObservations.observedAt,
|
||||
intelligence: ipIntelligence.rawResponse,
|
||||
})
|
||||
.from(ipObservations)
|
||||
.leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||
.where(eq(ipObservations.userId, user.id))
|
||||
.orderBy(desc(ipObservations.observedAt))
|
||||
.limit(100),
|
||||
@@ -95,9 +103,7 @@ export default async function AdminUserPage({
|
||||
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
|
||||
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
|
||||
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
|
||||
const addressGroups = groupAccessAddresses(
|
||||
observations.map((observation) => ({ ...observation, intelligence: null })),
|
||||
);
|
||||
const addressGroups = groupAccessAddresses(observations);
|
||||
const primary = accounts.find((account) => account.isPrimary);
|
||||
const nickname = user.firstName
|
||||
? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
|
||||
@@ -215,16 +221,20 @@ export default async function AdminUserPage({
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Recent addresses</p>
|
||||
<p className="mt-3 text-[10px] leading-5 text-muted">Grouped by IPv4 /24 or IPv6 /64 network across the 100 most recent observations.</p>
|
||||
<div className="mt-4 divide-y divide-line">
|
||||
{addressGroups.map((group) => (
|
||||
<div className="py-3" key={group.network}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-mono text-xs font-bold">{group.network}</p>
|
||||
<span className="font-mono text-[9px] text-muted">×{group.count}</span>
|
||||
{addressGroups.map((group) => {
|
||||
const details = accessAddressDetails(group);
|
||||
return (
|
||||
<div className="py-3" key={group.network}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-mono text-xs font-bold">{group.network}</p>
|
||||
<span className="font-mono text-[9px] text-muted">×{group.count}</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[9px] text-muted">{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}</p>
|
||||
<p className="mt-1 break-all font-mono text-[9px] text-muted">Latest {group.latestAddress}</p>
|
||||
<p className="mt-1 text-xs text-muted">{details.location} · <span className="font-mono uppercase">{details.classification}</span></p>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[9px] text-muted">{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}</p>
|
||||
<p className="mt-1 break-all font-mono text-[9px] text-muted">Latest {group.latestAddress}</p>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
|
||||
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
||||
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
const issuer = "https://sso.example/realms/operators";
|
||||
let signed: string;
|
||||
const fetcher = vi.fn();
|
||||
beforeAll(async () => {
|
||||
const keys = await generateKeyPair("RS256");
|
||||
const jwks = { keys: [{ ...await exportJWK(keys.publicKey), kid: "whoami-key", alg: "RS256" }] };
|
||||
fetcher.mockImplementation(async () => Response.json(jwks));
|
||||
signed = await new SignJWT({ resource_access: { portal: { roles: ["ops"] } }, name: "Not exposed", email: "private@example.test" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "whoami-key" }).setIssuer(issuer).setAudience("portal")
|
||||
.setSubject("machine-subject").setExpirationTime("5m").sign(keys.privateKey);
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("KEYCLOAK_ISSUER_URL", issuer);
|
||||
vi.stubEnv("KEYCLOAK_CLIENT_ID", "portal");
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
});
|
||||
afterEach(() => { auth.session.mockReset(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
|
||||
|
||||
import { assertResponse } from "@/test/openapi-contract";
|
||||
import { GET } from "./route";
|
||||
async function get(request: Request) {
|
||||
const response = await GET(request);
|
||||
await assertResponse("/api/admin/whoami", "get", response);
|
||||
return response;
|
||||
}
|
||||
function request(authorization?: string) {
|
||||
return new Request("https://portal.example/api/admin/whoami", { headers: authorization === undefined ? {} : { authorization } });
|
||||
}
|
||||
it("returns only a safe machine identity through real bearer verification", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
const response = await get(request(`Bearer ${signed}`));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toEqual({ authenticationMethod: "bearer", subject: "machine-subject", name: null, email: null });
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
});
|
||||
it("returns the existing browser identity without roles or session internals", async () => {
|
||||
auth.session.mockResolvedValue({ user: { name: "Admin", email: "admin@example.test", roles: ["ops"], image: "private-image" }, expires: "private-expiry" });
|
||||
const response = await get(request());
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toEqual({ authenticationMethod: "session", subject: null, name: "Admin", email: "admin@example.test" });
|
||||
});
|
||||
it.each([401, 403, 503])("returns a safe %s problem for browser auth failures", async (status) => {
|
||||
if (status === 503) auth.session.mockRejectedValue(new Error("private-session-error"));
|
||||
else auth.session.mockResolvedValue(status === 401 ? null : { user: { roles: [] } });
|
||||
const response = await get(request());
|
||||
expect(response.status).toBe(status);
|
||||
expect(response.headers.get("content-type")).toBe("application/problem+json");
|
||||
expect(response.headers.get("www-authenticate")).toBe(status === 401 ? 'Bearer realm="admin-api"' : null);
|
||||
expect(await response.json()).toMatchObject({ status, instance: "/api/admin/whoami" });
|
||||
});
|
||||
it("never falls back to browser auth for a supplied invalid token", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
const response = await get(request("Bearer invalid"));
|
||||
expect(response.status).toBe(401);
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { authorizeAdminApi } from "@/lib/auth/admin-api-auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authorization = await authorizeAdminApi(request);
|
||||
if (authorization.response) return authorization.response;
|
||||
return Response.json(authorization.identity, { headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { suggestionQuery, suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export function GET(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
return suggestionsApi(request, async (client) => client.messages((await context.params).id, suggestionQuery(request)));
|
||||
}
|
||||
|
||||
export const POST = suggestionsReadOnly;
|
||||
export const PUT = suggestionsReadOnly;
|
||||
export const PATCH = suggestionsReadOnly;
|
||||
export const DELETE = suggestionsReadOnly;
|
||||
export const OPTIONS = suggestionsReadOnly;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export function GET(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
return suggestionsApi(request, async (client) => client.detail((await context.params).id));
|
||||
}
|
||||
|
||||
export const POST = suggestionsReadOnly;
|
||||
export const PUT = suggestionsReadOnly;
|
||||
export const PATCH = suggestionsReadOnly;
|
||||
export const DELETE = suggestionsReadOnly;
|
||||
export const OPTIONS = suggestionsReadOnly;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
|
||||
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
||||
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
import { GET as list, POST } from "./route";
|
||||
import { GET as detail } from "./[id]/route";
|
||||
import { GET as messages } from "./[id]/messages/route";
|
||||
const issuer = "https://sso.example/realms/operators";
|
||||
const guildId = "100000000000000001";
|
||||
const forumId = "100000000000000002";
|
||||
const threadId = "100000000000000009";
|
||||
const context = { params: Promise.resolve({ id: threadId }) };
|
||||
const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11, name: "Suggestion", owner_id: "100000000000000003", applied_tags: [], message_count: 0, thread_metadata: { archived: false, locked: false } };
|
||||
let signed: string;
|
||||
let roleless: string;
|
||||
let jwks: unknown;
|
||||
const fetcher = vi.fn();
|
||||
beforeAll(async () => {
|
||||
const keys = await generateKeyPair("RS256");
|
||||
jwks = { keys: [{ ...await exportJWK(keys.publicKey), kid: "suggestions-key", alg: "RS256" }] };
|
||||
const sign = (roles: string[]) => new SignJWT({ resource_access: { portal: { roles } }, realm_access: { roles: ["ops"] } })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "suggestions-key" }).setIssuer(issuer).setAudience("portal")
|
||||
.setSubject("machine-subject").setExpirationTime("5m").sign(keys.privateKey);
|
||||
signed = await sign(["ops"]);
|
||||
roleless = await sign([]);
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("KEYCLOAK_ISSUER_URL", issuer);
|
||||
vi.stubEnv("KEYCLOAK_CLIENT_ID", "portal");
|
||||
vi.stubEnv("DISCORD_BOT_TOKEN", "test-only-bot-token");
|
||||
vi.stubEnv("DISCORD_GUILD_ID", guildId);
|
||||
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", forumId);
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
fetcher.mockImplementation(async (input: string) => {
|
||||
if (input === `${issuer}/protocol/openid-connect/certs`) return Response.json(jwks);
|
||||
const path = String(input).replace("https://discord.com/api/v10", "");
|
||||
if (path === `/channels/${forumId}`) return Response.json({ id: forumId, guild_id: guildId, type: 15, available_tags: [] });
|
||||
if (path === `/guilds/${guildId}/threads/active`) return Response.json({ threads: [] });
|
||||
if (path === `/channels/${threadId}`) return Response.json(thread);
|
||||
if (path === `/channels/${threadId}/messages/${threadId}`) return new Response(null, { status: 404 });
|
||||
if (path === `/channels/${threadId}/messages?limit=25`) return Response.json([]);
|
||||
throw new Error("Unexpected transport request");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
});
|
||||
afterEach(() => { vi.resetAllMocks(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
|
||||
function request(token: string) {
|
||||
return new Request("https://portal.example/api/suggestions", { headers: { authorization: `Bearer ${token}` } });
|
||||
}
|
||||
it.each([list, detail, messages])("accepts signed machine credentials on each read route", async (handler) => {
|
||||
const response = await handler(request(signed), context);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([list, detail, messages])("denies invalid or role-less bearer credentials even with an admin session and cached data", async (handler) => {
|
||||
expect((await handler(request(signed), context)).status).toBe(200);
|
||||
fetcher.mockClear();
|
||||
expect((await handler(request("invalid"), context)).status).toBe(401);
|
||||
expect((await handler(request(roleless), context)).status).toBe(403);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not enable writes for machine identities", async () => {
|
||||
const response = await POST(request(signed));
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET, HEAD");
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { assertResponse } from "@/test/openapi-contract";
|
||||
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
import * as list from "./route";
|
||||
import * as detail from "./[id]/route";
|
||||
import * as messages from "./[id]/messages/route";
|
||||
const guild = "100000000000000001", forum = "100000000000000002", id = "100000000000000009";
|
||||
const thread = { id, parent_id: forum, guild_id: guild, type: 11, name: "A garden", owner_id: "100000000000000003", applied_tags: [], message_count: 2, thread_metadata: { archived: false, locked: false, archive_timestamp: "2026-09-10T00:00:00.123456+00:00" } };
|
||||
const message = { id, content: "", timestamp: "2026-09-10T00:00:00Z", edited_timestamp: null, author: { id: "100000000000000003", username: "Example" }, reactions: [{ emoji: { name: "👍" }, count: 1 }] };
|
||||
const context = { params: Promise.resolve({ id }) };
|
||||
const fetcher = vi.fn();
|
||||
let generation = 0;
|
||||
beforeEach(() => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
vi.stubEnv("DISCORD_BOT_TOKEN", `synthetic-fixture-${++generation}`);
|
||||
vi.stubEnv("DISCORD_GUILD_ID", guild);
|
||||
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", forum);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
fetcher.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith(`/channels/${forum}`)) return Response.json({ id: forum, guild_id: guild, type: 15, available_tags: [] });
|
||||
if (url.includes("/threads/archived/public")) {
|
||||
const params = new URL(url).searchParams;
|
||||
if (Number(params.get("limit")) < 2) return Response.json({ code: 50035, message: "Invalid Form Body", errors: { limit: { _errors: [{ code: "NUMBER_TYPE_MIN", message: "int value should be greater than or equal to 2." }] } } }, { status: 400 });
|
||||
const newest = { ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } };
|
||||
const older = { ...newest, id: "100000000000000008", thread_metadata: { ...newest.thread_metadata, archive_timestamp: "2026-09-10T00:00:00.123455+00:00" } };
|
||||
if (params.has("before")) {
|
||||
expect(params.get("before")).toBe("2026-09-10T00:00:00.123456Z");
|
||||
return Response.json({ threads: [older], has_more: false });
|
||||
}
|
||||
return Response.json({ threads: [newest, older], has_more: false });
|
||||
}
|
||||
if (url.includes("/threads/active")) return Response.json({ threads: [thread] });
|
||||
if (url.endsWith(`/channels/${id}`)) return Response.json(thread);
|
||||
if (url.endsWith(`/messages/${id}`)) return Response.json(message);
|
||||
if (url.includes("/messages?")) return Response.json([message]);
|
||||
throw new Error("Unexpected fixture request");
|
||||
});
|
||||
});
|
||||
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
|
||||
const routes = [
|
||||
{ path: "/api/suggestions", route: list },
|
||||
{ path: "/api/suggestions/{id}", route: detail },
|
||||
{ path: "/api/suggestions/{id}/messages", route: messages },
|
||||
];
|
||||
it.each(routes)("validates populated $path response against its canonical schema", async ({ path, route }) => {
|
||||
const response = await route.GET(new Request(`https://portal.example${path.replace("{id}", id)}?limit=1`), context);
|
||||
expect(response.status).toBe(200);
|
||||
await assertResponse(path, "get", response);
|
||||
});
|
||||
it("documents the nullable deleted starter and precise archived cursor", async () => {
|
||||
fetcher.mockImplementationOnce(async () => Response.json({ id: forum, guild_id: guild, type: 15, available_tags: [] }))
|
||||
.mockImplementationOnce(async () => Response.json(thread))
|
||||
.mockImplementationOnce(async () => new Response(null, { status: 404 }));
|
||||
const response = await detail.GET(new Request(`https://portal.example/api/suggestions/${id}`), context);
|
||||
await assertResponse("/api/suggestions/{id}", "get", response);
|
||||
expect((await response.json()).originalPost).toBeNull();
|
||||
const archived = await list.GET(new Request("https://portal.example/api/suggestions?status=archived&limit=1"));
|
||||
await assertResponse("/api/suggestions", "get", archived);
|
||||
expect(archived.status).toBe(200);
|
||||
const first = await archived.json();
|
||||
expect(first.items.map((item: { id: string }) => item.id)).toEqual([id]);
|
||||
expect(first.nextCursor).toBe("2026-09-10T00:00:00.123456Z");
|
||||
const terminal = await list.GET(new Request(`https://portal.example/api/suggestions?status=archived&limit=1&cursor=${encodeURIComponent(first.nextCursor)}`));
|
||||
expect(terminal.status).toBe(200);
|
||||
await assertResponse("/api/suggestions", "get", terminal);
|
||||
const last = await terminal.json();
|
||||
expect(last.items.map((item: { id: string }) => item.id)).toEqual(["100000000000000008"]);
|
||||
expect(last.nextCursor).toBeNull();
|
||||
});
|
||||
it.each(routes)("documents Retry-After on $path upstream rate limits", async ({ path, route }) => {
|
||||
fetcher.mockImplementation(async () => Response.json({ retry_after: 2.1 }, { status: 429 }));
|
||||
const response = await route.GET(new Request(`https://portal.example${path.replace("{id}", id)}`), context);
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers.get("retry-after")).toBe("3");
|
||||
await assertResponse(path, "get", response);
|
||||
});
|
||||
it.each(routes)("documents every explicitly rejected write method for $path", async ({ path, route }) => {
|
||||
for (const method of ["POST", "PUT", "PATCH", "DELETE", "OPTIONS"] as const) {
|
||||
const response = await route[method](new Request(`https://portal.example${path.replace("{id}", id)}`, { method }));
|
||||
expect(response.status).toBe(405);
|
||||
await assertResponse(path, method, response);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
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 { 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");
|
||||
it.each(["Bearer invalid", "Basic invalid", "", "Bearer", "Bearer a, Bearer b"])("rejects supplied authorization %j without session fallback", async (authorization) => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
const fetcher = vi.fn();
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
const response = await GET(new Request(request(), { headers: { authorization } }));
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get("www-authenticate")).toBe('Bearer realm="admin-api"');
|
||||
expect(response.headers.get("content-type")).toBe("application/problem+json");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toMatchObject({ type: "urn:error:unauthorized", status: 401, instance: "/api/suggestions" });
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
|
||||
it("serves suggestions to the existing admin session using runtime env configuration", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
vi.stubEnv("DISCORD_BOT_TOKEN", "test-token");
|
||||
vi.stubEnv("DISCORD_GUILD_ID", "100000000000000001");
|
||||
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", "100000000000000002");
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ id: "100000000000000002", guild_id: "100000000000000001", type: 15, available_tags: [] })).mockResolvedValueOnce(Response.json({ threads: [] }));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
const response = await GET(request());
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ items: [], nextCursor: null });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it.each([detail, messages])("independently protects detail and message routes", async (handler) => {
|
||||
const fetcher = vi.fn();
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
auth.session.mockResolvedValue(null);
|
||||
expect((await handler(request(), context)).status).toBe(401);
|
||||
auth.session.mockResolvedValue({ user: { roles: ["player"] } });
|
||||
expect((await handler(request(), context)).status).toBe(403);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["?limit=0", "?limit=101", "?limit=1.2", "?limit=", "?limit=1&limit=2", "?channel=100000000000000099", "?status=all", "?cursor=../secret"])('rejects invalid query %s without contacting Discord', async (query) => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
const fetcher = vi.fn();
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
const response = await GET(new Request(`https://portal.example/api/suggestions${query}`));
|
||||
expect(response.status).toBe(400);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it("returns a read-only problem for writes", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
const response = await POST(request());
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET, HEAD");
|
||||
});
|
||||
it("reports missing configuration without exposing environment values", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", "");
|
||||
const response = await GET(request());
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toMatchObject({ type: "urn:error:suggestions-not-configured", status: 503, instance: "/api/suggestions" });
|
||||
});
|
||||
it("returns sanitized problems for unexpected failures", async () => {
|
||||
auth.session.mockRejectedValue(new Error("private session details"));
|
||||
const response = await GET(request());
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.text()).not.toContain("private session details");
|
||||
});
|
||||
it("rejects a signed-in user without the required admin role", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["player"] } });
|
||||
expect((await GET(request())).status).toBe(403);
|
||||
});
|
||||
it("rejects unauthenticated readers with a JSON problem instead of a redirect", async () => {
|
||||
auth.session.mockResolvedValue(null);
|
||||
const response = await GET(request());
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get("content-type")).toBe("application/problem+json");
|
||||
expect(response.headers.get("location")).toBeNull();
|
||||
expect(response.headers.get("www-authenticate")).toBe('Bearer realm="admin-api"');
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { suggestionQuery, suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export function GET(request: Request) {
|
||||
return suggestionsApi(request, (client) => client.list(suggestionQuery(request, true)));
|
||||
}
|
||||
|
||||
export const POST = suggestionsReadOnly;
|
||||
export const PUT = suggestionsReadOnly;
|
||||
export const PATCH = suggestionsReadOnly;
|
||||
export const DELETE = suggestionsReadOnly;
|
||||
export const OPTIONS = suggestionsReadOnly;
|
||||
@@ -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");
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("health endpoint", () => {
|
||||
it("reports process readiness without requiring external services", async () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it("reports process readiness and the immutable build version without requiring external services", async () => {
|
||||
vi.stubEnv("APP_VERSION", "1.19.0");
|
||||
|
||||
const response = GET();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toEqual({ status: "ok" });
|
||||
expect(await response.json()).toEqual({ status: "ok", version: "1.19.0" });
|
||||
});
|
||||
|
||||
it("reports a development version when no build version is supplied", async () => {
|
||||
vi.stubEnv("APP_VERSION", "");
|
||||
|
||||
expect(await GET().json()).toEqual({ status: "ok", version: "development" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export function GET(): Response {
|
||||
return Response.json(
|
||||
{ status: "ok" },
|
||||
{ status: "ok", version: process.env.APP_VERSION?.trim() || "development" },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-static";
|
||||
|
||||
/** Build-time snapshot of the single source; no YAML parsing or rewriting. */
|
||||
export async function GET() {
|
||||
const specification = await readFile(resolve(process.cwd(), "../../openapi.yaml"));
|
||||
return new Response(specification, {
|
||||
headers: { "content-type": "application/yaml; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const actionMocks = vi.hoisted(() => ({
|
||||
execute: vi.fn(async (_previous: unknown, formData: FormData) => ({
|
||||
status: "success" as const,
|
||||
message: `Executed ${String(formData.get("command") ?? "")}`,
|
||||
serverId: String(formData.get("serverId") ?? ""),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/admin/(console)/rcon/actions", () => ({
|
||||
createRconServer: vi.fn(),
|
||||
deleteRconServer: vi.fn(),
|
||||
executeRconCommand: actionMocks.execute,
|
||||
setRconServerEnabled: vi.fn(),
|
||||
testSavedRconServer: vi.fn(),
|
||||
updateRconServer: vi.fn(),
|
||||
}));
|
||||
|
||||
import { RconConsole } from "./rcon-console";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const server = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
name: "Season 4",
|
||||
host: "season4.somc.svc.cluster.local",
|
||||
port: 25575,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const creative = {
|
||||
...server,
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
name: "Creative",
|
||||
host: "creative.example.com",
|
||||
};
|
||||
|
||||
describe("RconConsole", () => {
|
||||
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"');
|
||||
expect(markup).toContain('for="rcon-console-server"');
|
||||
expect(markup).toContain('for="rcon-command"');
|
||||
expect(markup).toContain("w-full");
|
||||
expect(markup).toContain("Season 4");
|
||||
expect(markup).toContain("server://");
|
||||
expect(markup).toContain("Awaiting command");
|
||||
expect(markup).toContain("Add");
|
||||
expect(markup).toContain("Edit");
|
||||
expect(markup).toContain("Test");
|
||||
expect(markup).toContain("Disable");
|
||||
expect(markup).toContain("Delete");
|
||||
expect(markup).toContain("Add RCON connection");
|
||||
expect(markup).toContain('href="/admin/rcon/history"');
|
||||
expect(markup).toContain("Command history");
|
||||
expect(markup).toContain("Edit Season 4");
|
||||
expect(markup).toContain("Delete Season 4?");
|
||||
expect(markup).toContain("Enter ↵");
|
||||
expect(markup).not.toContain("Latest response");
|
||||
});
|
||||
|
||||
it("renders connection operation notices inside the terminal viewport", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole notice={{ status: "error", message: "RCON authentication timed out." }} servers={[server]} />);
|
||||
expect(markup).toContain("RCON authentication timed out.");
|
||||
expect(markup).toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("keeps the terminal and add action available when no connection exists", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole servers={[]} />);
|
||||
expect(markup).toContain('aria-label="RCON terminal"');
|
||||
expect(markup).toContain("No connections configured");
|
||||
expect(markup).toContain("Add");
|
||||
expect(markup).not.toContain("Edit");
|
||||
expect(markup).not.toContain("Delete");
|
||||
});
|
||||
|
||||
it("navigates page-memory command history and restores the unsent draft", async () => {
|
||||
render(<RconConsole servers={[server]} />);
|
||||
const input = screen.getByLabelText("Command") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "list" } });
|
||||
fireEvent.submit(input.form!);
|
||||
await waitFor(() => expect(screen.getByText("Executed list")).toBeTruthy());
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(input.value).toBe("");
|
||||
|
||||
fireEvent.change(input, { target: { value: "say hello" } });
|
||||
fireEvent.submit(input.form!);
|
||||
await waitFor(() => expect(screen.getByText("Executed say hello")).toBeTruthy());
|
||||
|
||||
fireEvent.change(input, { target: { value: "draft command" } });
|
||||
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||
expect(input.value).toBe("say hello");
|
||||
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||
expect(input.value).toBe("list");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
expect(input.value).toBe("say hello");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
expect(input.value).toBe("draft command");
|
||||
});
|
||||
|
||||
it("retains chronological command and response exchanges in the terminal transcript", async () => {
|
||||
render(<RconConsole servers={[server]} />);
|
||||
const input = screen.getByLabelText("Command") as HTMLInputElement;
|
||||
|
||||
let listResponses = 0;
|
||||
for (const command of ["list", "say hello", "list"]) {
|
||||
fireEvent.change(input, { target: { value: command } });
|
||||
fireEvent.submit(input.form!);
|
||||
if (command === "list") listResponses += 1;
|
||||
await waitFor(() => expect(screen.getAllByText(`Executed ${command}`)).toHaveLength(command === "list" ? listResponses : 1));
|
||||
}
|
||||
|
||||
const transcript = screen.getByLabelText("Terminal transcript");
|
||||
const text = transcript.textContent ?? "";
|
||||
expect(text.indexOf("$ list")).toBeLessThan(text.indexOf("Executed list"));
|
||||
expect(text.indexOf("Executed list")).toBeLessThan(text.indexOf("$ say hello"));
|
||||
expect(text.indexOf("$ say hello")).toBeLessThan(text.indexOf("Executed say hello"));
|
||||
expect(screen.getAllByText("Executed list")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns focus to the command prompt after changing servers", async () => {
|
||||
render(<RconConsole servers={[server, creative]} />);
|
||||
const select = screen.getByLabelText("Server");
|
||||
select.focus();
|
||||
fireEvent.change(select, { target: { value: creative.id } });
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText("Command")));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState, 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";
|
||||
|
||||
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
|
||||
const MAX_COMMAND_HISTORY = 50;
|
||||
const MAX_TRANSCRIPT_EXCHANGES = 50;
|
||||
|
||||
type TranscriptExchange = {
|
||||
id: number;
|
||||
serverName: string;
|
||||
command: string;
|
||||
status: "pending" | "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type RconServerOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type RconTerminalNotice = {
|
||||
status: "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function RconConsole({
|
||||
notice,
|
||||
servers,
|
||||
}: {
|
||||
notice?: RconTerminalNotice;
|
||||
servers: RconServerOption[];
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState(servers[0]?.id ?? "");
|
||||
const [state, action, pending] = useActionState(executeRconCommand, initialState);
|
||||
const [command, setCommand] = useState("");
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const [transcript, setTranscript] = useState<TranscriptExchange[]>([]);
|
||||
const draftRef = useRef("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const nextExchangeIdRef = useRef(0);
|
||||
const pendingExchangeIdRef = useRef<number | null>(null);
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending && state.status !== "idle") inputRef.current?.focus();
|
||||
}, [pending, state.status]);
|
||||
|
||||
useEffect(() => {
|
||||
const exchangeId = pendingExchangeIdRef.current;
|
||||
if (exchangeId === null || state.status === "idle") return;
|
||||
const resultStatus: TranscriptExchange["status"] = state.status === "error" ? "error" : "success";
|
||||
setTranscript((current) => current.map((exchange) => exchange.id === exchangeId
|
||||
? { ...exchange, status: resultStatus, message: state.message }
|
||||
: exchange));
|
||||
pendingExchangeIdRef.current = null;
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (transcriptRef.current) transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
|
||||
}, [transcript]);
|
||||
|
||||
function navigateHistory(direction: "older" | "newer") {
|
||||
if (!history.length) return;
|
||||
if (direction === "older") {
|
||||
const nextIndex = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
|
||||
if (historyIndex === null) draftRef.current = command;
|
||||
setHistoryIndex(nextIndex);
|
||||
setCommand(history[nextIndex]!);
|
||||
return;
|
||||
}
|
||||
if (historyIndex === null) return;
|
||||
if (historyIndex < history.length - 1) {
|
||||
const nextIndex = historyIndex + 1;
|
||||
setHistoryIndex(nextIndex);
|
||||
setCommand(history[nextIndex]!);
|
||||
} else {
|
||||
setHistoryIndex(null);
|
||||
setCommand(draftRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberSubmittedCommand() {
|
||||
const submitted = command.trim();
|
||||
if (!submitted || !selected) return;
|
||||
const exchangeId = ++nextExchangeIdRef.current;
|
||||
pendingExchangeIdRef.current = exchangeId;
|
||||
const exchange: TranscriptExchange = {
|
||||
id: exchangeId,
|
||||
serverName: selected.name,
|
||||
command: submitted,
|
||||
status: "pending",
|
||||
message: "Command in progress…",
|
||||
};
|
||||
setTranscript((current) => [...current, exchange].slice(-MAX_TRANSCRIPT_EXCHANGES));
|
||||
setHistory((current) => [...current, submitted].slice(-MAX_COMMAND_HISTORY));
|
||||
setHistoryIndex(null);
|
||||
draftRef.current = "";
|
||||
setCommand("");
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="RCON terminal" className="mt-8 w-full overflow-hidden border-2 border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<div className="flex flex-col gap-4 border-b-2 border-ink bg-canvas px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2 font-mono text-[10px] font-bold uppercase tracking-wider text-muted">
|
||||
<span aria-hidden="true" className={`size-2 rounded-full shadow-[0_0_0_1px_var(--color-ink)] ${selected?.enabled ? "bg-signal" : "bg-line"}`} />
|
||||
<span>server://</span>
|
||||
</div>
|
||||
{servers.length ? (
|
||||
<label className="flex min-w-0 items-center gap-2 font-mono text-[9px] font-bold uppercase tracking-wider" htmlFor="rcon-console-server">
|
||||
<span className="sr-only">Server</span>
|
||||
<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)}
|
||||
value={selected?.id}
|
||||
>
|
||||
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}{server.enabled ? "" : " — disabled"}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<span className="font-mono text-xs font-bold text-muted">no-target</span>
|
||||
)}
|
||||
{selected && <span className="font-mono text-[9px] text-muted">{selected.host}:{selected.port}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" href="/admin/rcon/history">Command history</Link>
|
||||
<ConnectionModal mode="add" />
|
||||
{selected && (
|
||||
<>
|
||||
<form action={testSavedRconServer}>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<HeaderButton label="Test" />
|
||||
</form>
|
||||
<form action={setRconServerEnabled}>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<input name="enabled" type="hidden" value={selected.enabled ? "no" : "yes"} />
|
||||
<HeaderButton label={selected.enabled ? "Disable" : "Enable"} />
|
||||
</form>
|
||||
<ConnectionModal mode="edit" server={selected} />
|
||||
<AdminModalForm
|
||||
action={deleteRconServer}
|
||||
description={`Delete ${selected.name} and its encrypted credential. This cannot be undone.`}
|
||||
intent="danger"
|
||||
submitLabel="Delete connection"
|
||||
title={`Delete ${selected.name}?`}
|
||||
triggerClassName="border border-accent px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-accent"
|
||||
triggerLabel="Delete"
|
||||
>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<input name="confirmation" type="hidden" value={selected.id} />
|
||||
</AdminModalForm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div aria-label="Terminal transcript" aria-live="polite" aria-relevant="additions text" className="min-h-72 max-h-[32rem] overflow-auto p-5 font-mono text-xs leading-5" ref={transcriptRef} role="status">
|
||||
{notice && (
|
||||
<div className={`mb-5 border-l-2 pl-3 ${notice.status === "error" ? "border-accent" : "border-signal"}`} role={notice.status === "error" ? "alert" : "status"}>
|
||||
<p className={`text-[9px] font-bold uppercase tracking-wider ${notice.status === "error" ? "text-accent" : "text-muted"}`}>{notice.status === "error" ? "Connection error" : "Connection update"}</p>
|
||||
<p className="mt-2">{notice.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{!transcript.length && <TerminalIdle selected={selected} />}
|
||||
<div className="space-y-6">
|
||||
{transcript.map((exchange) => (
|
||||
<article className="border-l-2 border-line pl-3" key={exchange.id}>
|
||||
<p className="break-words">
|
||||
<span className="mr-2 text-[9px] font-bold uppercase tracking-wider text-muted">server://{exchange.serverName}</span>
|
||||
<span className="text-accent">$</span> {exchange.command}
|
||||
</p>
|
||||
<div className={`mt-2 ${exchange.status === "error" ? "text-accent" : "text-ink"}`} role={exchange.status === "error" ? "alert" : undefined}>
|
||||
{exchange.status === "pending" ? <p className="text-muted">Command in progress…</p> : <pre className="whitespace-pre-wrap break-words font-mono text-xs leading-5">{exchange.message}</pre>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</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
|
||||
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"
|
||||
disabled={!selected?.enabled || pending}
|
||||
id="rcon-command"
|
||||
key={selected?.id ?? "no-server"}
|
||||
maxLength={1024}
|
||||
name="command"
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
navigateHistory(event.key === "ArrowUp" ? "older" : "newer");
|
||||
}
|
||||
}}
|
||||
placeholder={selected ? (selected.enabled ? "list" : "Enable this connection to run commands") : "Add a connection to begin"}
|
||||
ref={inputRef}
|
||||
required
|
||||
spellCheck={false}
|
||||
value={command}
|
||||
/>
|
||||
<button className="border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas disabled:cursor-not-allowed disabled:opacity-50" disabled={!selected?.enabled || pending} type="submit">{pending ? "Running…" : "Enter ↵"}</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalIdle({ selected }: { selected?: RconServerOption }) {
|
||||
if (!selected) return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready</p><p className="mt-3">No connections configured. Use Add to create a server connection.</p></>;
|
||||
if (!selected.enabled) return <><p className="text-[9px] font-bold uppercase tracking-wider text-accent">Disabled — {selected.name}</p><p className="mt-3">Enable this connection before testing commands.</p></>;
|
||||
return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready — {selected.name}</p><p className="mt-3">Awaiting command</p></>;
|
||||
}
|
||||
|
||||
function HeaderButton({ label }: { label: string }) {
|
||||
return <button className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" type="submit">{label}</button>;
|
||||
}
|
||||
|
||||
function ConnectionModal({
|
||||
mode,
|
||||
server,
|
||||
}: {
|
||||
mode: "add" | "edit";
|
||||
server?: RconServerOption;
|
||||
}) {
|
||||
const editing = mode === "edit" ? server : undefined;
|
||||
return (
|
||||
<AdminModalForm
|
||||
action={editing ? updateRconServer : createRconServer}
|
||||
description={editing ? `Update ${editing.name}. Leave the password blank to preserve its encrypted credential.` : "Add an internal or external RCON server address. The password is encrypted before storage."}
|
||||
submitLabel={editing ? "Save connection" : "Add connection"}
|
||||
title={editing ? `Edit ${editing.name}` : "Add RCON connection"}
|
||||
triggerClassName={editing ? "border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider" : "border border-ink bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas"}
|
||||
triggerLabel={editing ? "Edit" : "Add"}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{editing && <input name="serverId" type="hidden" value={editing.id} />}
|
||||
<ConnectionFields defaults={editing} prefix={editing?.id ?? "new"} />
|
||||
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase">
|
||||
<input className="size-4" defaultChecked={editing?.enabled ?? false} name="enabled" type="checkbox" value="yes" />
|
||||
{editing ? "Enabled" : "Enable immediately"}
|
||||
</label>
|
||||
</div>
|
||||
</AdminModalForm>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionFields({
|
||||
defaults,
|
||||
prefix,
|
||||
}: {
|
||||
defaults?: { name: string; host: string; port: number };
|
||||
prefix: string;
|
||||
}) {
|
||||
const fieldClass = "mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent";
|
||||
return (
|
||||
<>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-name`}>Name<input className={fieldClass} defaultValue={defaults?.name} id={`${prefix}-rcon-name`} maxLength={100} name="name" required /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-host`}>Server address<input autoCapitalize="none" autoCorrect="off" className={fieldClass} defaultValue={defaults?.host} id={`${prefix}-rcon-host`} maxLength={253} name="host" placeholder="minecraft.example.com" required spellCheck={false} /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-port`}>Port<input className={fieldClass} defaultValue={defaults?.port ?? 25575} id={`${prefix}-rcon-port`} max={65535} min={1} name="port" required type="number" /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-password`}>{defaults ? "Replacement password" : "Password"}<input autoComplete="new-password" className={fieldClass} id={`${prefix}-rcon-password`} maxLength={512} name="password" required={!defaults} type="password" />{defaults && <span className="mt-2 block font-sans text-[10px] font-normal normal-case text-muted">Leave blank to preserve the current password.</span>}</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { SuggestionReader } from "./suggestion-reader";
|
||||
const id = "100000000000000009";
|
||||
const original = { id, author: { id: "100000000000000003", name: "Builder" }, content: "<script>unsafe()</script> Please build stations.", createdAt: "2026-01-01T00:00:00Z", editedAt: null, reactions: [{ emoji: "👍", count: 4 }], discordUrl: `https://discord.com/channels/100000000000000001/${id}/${id}` };
|
||||
const detail = { id, title: "More railway stations", authorId: original.author.id, createdAt: original.createdAt, archived: false, locked: false, tags: [{ id: "100000000000000004", name: "World" }], messageCount: 3, discordUrl: `https://discord.com/channels/100000000000000001/${id}`, originalPost: original };
|
||||
it("paginates discussion without duplicating the original post", async () => {
|
||||
const fetcher = vi.fn().mockImplementation(async (url: string) => {
|
||||
if (!url.includes("/messages")) return Response.json(detail);
|
||||
const older = url.includes("cursor=");
|
||||
return Response.json({ items: [original, { ...original, id: "100000000000000020", content: older ? "Earlier reply" : "Latest reply", editedAt: "2026-01-01T01:00:00Z" }], nextCursor: older ? null : "100000000000000020" });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionReader id={id} />);
|
||||
await screen.findByText("Latest reply");
|
||||
expect(screen.getAllByText(original.content)).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
|
||||
await screen.findByText("Earlier reply");
|
||||
expect(fetcher.mock.lastCall?.[0]).toBe(`/api/suggestions/${id}/messages?limit=25&cursor=100000000000000020`);
|
||||
expect(screen.getByText("2026-01-01T01:00:00Z")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Previous page" }));
|
||||
await screen.findByText("Latest reply");
|
||||
});
|
||||
it("distinguishes deleted starters and empty discussion", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => Response.json(url.includes("/messages") ? { items: [], nextCursor: null } : { ...detail, originalPost: null })));
|
||||
render(<SuggestionReader id={id} />);
|
||||
expect(await screen.findByText("The original post was deleted or is unavailable.")).toBeTruthy();
|
||||
expect(await screen.findByText("No replies on this page.")).toBeTruthy();
|
||||
});
|
||||
it("handles detail access denial without loading the discussion", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue(Response.json({}, { status: 403 }));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionReader id={id} />);
|
||||
await screen.findByRole("link", { name: "Admin sign-in" });
|
||||
expect(screen.queryByRole("heading", { name: "More railway stations" })).toBeNull();
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("makes empty message content explicit and retries discussion failures without writes", async () => {
|
||||
let messageReads = 0;
|
||||
const fetcher = vi.fn().mockImplementation(async (url: string) => {
|
||||
if (!url.includes("/messages")) return Response.json({ ...detail, originalPost: { ...original, content: "" } });
|
||||
messageReads += 1;
|
||||
return messageReads === 1 ? new Response(null, { status: 503 }) : Response.json({ items: [], nextCursor: null });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionReader id={id} />);
|
||||
await screen.findByText("No text was returned. View this message in Discord.");
|
||||
await screen.findByRole("alert");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
await screen.findByText("No replies on this page.");
|
||||
expect(fetcher.mock.calls.every(([, options]) => options.method === undefined || options.method === "GET")).toBe(true);
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
it("reads suggestion details and safely renders starter text, tags, reactions and Discord links", async () => {
|
||||
const fetcher = vi.fn().mockImplementation(async (url: string) => Response.json(url.includes("/messages") ? { items: [], nextCursor: null } : detail));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionReader id={id} />);
|
||||
expect(screen.getByRole("status").textContent).toContain("Loading suggestion");
|
||||
await screen.findByRole("heading", { name: "More railway stations", level: 1 });
|
||||
expect(screen.getByText(original.content)).toBeTruthy();
|
||||
expect(document.querySelector("script")).toBeNull();
|
||||
expect(screen.getByText("World")).toBeTruthy();
|
||||
expect(screen.getByText("👍 4")).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Open in Discord" }).getAttribute("href")).toBe(detail.discordUrl);
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe(`/api/suggestions/${id}`);
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import type { MessagePage, SuggestionDetail, SuggestionMessage } from "@/lib/discord/suggestion-types";
|
||||
import { SuggestionBadges, SuggestionFailure, SuggestionLoading, SuggestionPagination, suggestionControl, useSuggestionRead, useSuggestionPages, type SuggestionPaging } from "./suggestion-shared";
|
||||
|
||||
export function SuggestionReader({ id }: { id: string }) {
|
||||
const { data, error, retry } = useSuggestionRead<SuggestionDetail>(`/api/suggestions/${id}`);
|
||||
if (error) return <SuggestionFailure error={error} retry={retry} />;
|
||||
if (!data) return <SuggestionLoading>Loading suggestion…</SuggestionLoading>;
|
||||
return <>
|
||||
<header className="border-b border-line pb-8">
|
||||
<p className="mb-5 font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Community proposal / Read-only</p>
|
||||
<SuggestionBadges suggestion={data} />
|
||||
<h1 className="mt-5 break-words font-display text-4xl font-black sm:text-6xl">{data.title}</h1>
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-5">
|
||||
<p className="break-all font-mono text-[10px] leading-5 text-muted">Thread / {data.id}<br /><time dateTime={data.createdAt}>{data.createdAt}</time></p>
|
||||
<a className={`${suggestionControl} bg-ink text-panel`} href={data.discordUrl} rel="noopener noreferrer" target="_blank">Open in Discord</a>
|
||||
</div>
|
||||
</header>
|
||||
<section aria-labelledby="original-post-heading" className="mt-10 border border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<div className="border-b border-line px-6 py-4"><h2 className="font-mono text-[10px] font-bold uppercase tracking-[0.2em]" id="original-post-heading">Original suggestion</h2></div>
|
||||
{data.originalPost ? <MessageCard message={data.originalPost} /> : <p className="p-6 text-sm text-muted">The original post was deleted or is unavailable.</p>}
|
||||
</section>
|
||||
<Discussion id={id} />
|
||||
<p className="mt-6 text-xs leading-5 text-muted">Discord remains the source of truth. Open Discord for attachments, formatting, replies, and reactions. Message text may be unavailable if the bot lacks Message Content access.</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Discussion({ id }: { id: string }) {
|
||||
const paging = useSuggestionPages();
|
||||
const query = new URLSearchParams({ limit: "25" });
|
||||
if (paging.cursor) query.set("cursor", paging.cursor);
|
||||
const url = `/api/suggestions/${id}/messages?${query}`;
|
||||
return <section aria-labelledby="discussion-heading" className="mt-12">
|
||||
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<h2 className="font-display text-3xl font-black uppercase" id="discussion-heading">Discussion</h2>
|
||||
<p className="font-mono text-[10px] uppercase tracking-wider text-muted">Newest messages first / Read-only</p>
|
||||
</header>
|
||||
<DiscussionPage id={id} key={url} paging={paging} url={url} />
|
||||
</section>;
|
||||
}
|
||||
|
||||
function DiscussionPage({ id, url, paging }: { id: string; url: string; paging: SuggestionPaging }) {
|
||||
const { data, error, retry } = useSuggestionRead<MessagePage>(url);
|
||||
if (error) return <SuggestionFailure error={error} retry={retry} />;
|
||||
if (!data) return <SuggestionLoading>Loading discussion…</SuggestionLoading>;
|
||||
const replies = data.items.filter((message) => message.id !== id);
|
||||
return <>
|
||||
<div className="divide-y divide-line border border-line bg-panel">
|
||||
{replies.map((message) => <MessageCard key={message.id} message={message} />)}
|
||||
{!replies.length && <p className="p-8 text-sm text-muted">No replies on this page.</p>}
|
||||
</div>
|
||||
<SuggestionPagination nextCursor={data.nextCursor} {...paging} />
|
||||
</>;
|
||||
}
|
||||
|
||||
function MessageCard({ message }: { message: SuggestionMessage }) {
|
||||
return <article className="min-w-0 p-6 sm:p-8">
|
||||
<header className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h3 className="break-words font-display text-lg font-black">{message.author.name}</h3>
|
||||
<a aria-label={`Open message by ${message.author.name} in Discord`} className="font-mono text-[10px] text-muted underline underline-offset-4 hover:text-accent" href={message.discordUrl} rel="noopener noreferrer" target="_blank"><time dateTime={message.createdAt}>{message.createdAt}</time></a>
|
||||
</header>
|
||||
<p className="mt-5 whitespace-pre-wrap break-words text-sm leading-7 [overflow-wrap:anywhere]">{message.content || "No text was returned. View this message in Discord."}</p>
|
||||
{message.editedAt && <p className="mt-3 font-mono text-[10px] text-muted">Edited <time dateTime={message.editedAt}>{message.editedAt}</time></p>}
|
||||
{!!message.reactions.length && <ul aria-label="Reaction counts" className="mt-5 flex flex-wrap gap-2">{message.reactions.map((reaction, index) => <li className="border border-line bg-canvas px-3 py-1 font-mono text-xs" key={`${reaction.emoji}-${index}`}>{reaction.emoji} {reaction.count}</li>)}</ul>}
|
||||
</article>;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Suggestion } from "@/lib/discord/suggestion-types";
|
||||
|
||||
export const suggestionControl = "border border-ink px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-wider transition-colors hover:bg-ink hover:text-panel disabled:cursor-not-allowed disabled:opacity-40";
|
||||
|
||||
// Consumers remount on URL changes so previous-page data is never shown as new data.
|
||||
type ReadFailure = { status: number; message: string };
|
||||
class ReadError extends Error { constructor(public status: number) { super("Suggestions request failed"); } }
|
||||
function readFailure(error: unknown): ReadFailure {
|
||||
const status = error instanceof ReadError ? error.status : 0;
|
||||
const messages: Record<number, string> = {
|
||||
401: "Your admin session has expired. Sign in to continue.",
|
||||
403: "Your account does not have the required administrator role.",
|
||||
404: "This suggestion is no longer available in the configured forum.",
|
||||
503: "Discord suggestions are temporarily unavailable or not configured. Try again shortly.",
|
||||
};
|
||||
return { status, message: messages[status] ?? "Suggestions could not be loaded. Try again shortly." };
|
||||
}
|
||||
|
||||
export function useSuggestionRead<T>(url: string) {
|
||||
const [result, setResult] = useState<{ data: T | null; error: ReadFailure | null }>({ data: null, error: null });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch(url, { credentials: "same-origin", cache: "no-store", signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new ReadError(response.status);
|
||||
return await response.json() as T;
|
||||
})
|
||||
.then((data) => { if (!controller.signal.aborted) setResult({ data, error: null }); })
|
||||
.catch((error: unknown) => { if (!controller.signal.aborted) setResult({ data: null, error: readFailure(error) }); });
|
||||
return () => controller.abort();
|
||||
}, [url, attempt]);
|
||||
return { ...result, retry: () => { setResult({ data: null, error: null }); setAttempt((value) => value + 1); } };
|
||||
}
|
||||
|
||||
export function SuggestionBadges({ suggestion }: { suggestion: Suggestion }) {
|
||||
return <div className="flex flex-wrap gap-2 font-mono text-[10px] font-bold uppercase tracking-wider">
|
||||
<span className={`border border-ink px-2 py-1 ${suggestion.archived ? "bg-canvas text-muted" : "bg-signal text-ink"}`}>{suggestion.archived ? "Archived" : "Active"}</span>
|
||||
{suggestion.locked && <span className="border border-line px-2 py-1 text-muted">Locked</span>}
|
||||
{suggestion.tags.map((tag) => <span className="border border-line px-2 py-1 text-muted" key={tag.id}>{tag.name}</span>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function SuggestionFailure({ error, retry }: { error: ReadFailure; retry: () => void }) {
|
||||
const authError = error.status === 401 || error.status === 403;
|
||||
return <div className="border-l-4 border-accent bg-panel p-6" role="alert">
|
||||
<h2 className="font-display text-2xl font-black uppercase">{authError ? "Admin access required" : "Unable to load suggestions"}</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-muted">{error.message}</p>
|
||||
<div className="mt-5">{authError ? <Link className="font-mono text-xs font-bold underline underline-offset-4" href="/admin/login">Admin sign-in</Link> : <button className={suggestionControl} onClick={retry} type="button">Try again</button>}</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function useSuggestionPages() {
|
||||
const [state, setState] = useState<{ cursors: (string | undefined)[]; hasPaged: boolean }>({ cursors: [undefined], hasPaged: false });
|
||||
return {
|
||||
cursor: state.cursors.at(-1), page: state.cursors.length, focusOnMount: state.hasPaged,
|
||||
onNext: (cursor: string) => setState((current) => ({ cursors: [...current.cursors, cursor], hasPaged: true })),
|
||||
onPrevious: () => setState((current) => ({ cursors: current.cursors.length > 1 ? current.cursors.slice(0, -1) : current.cursors, hasPaged: true })),
|
||||
reset: () => setState({ cursors: [undefined], hasPaged: false }),
|
||||
};
|
||||
}
|
||||
|
||||
export type SuggestionPaging = { focusOnMount: boolean; onNext: (cursor: string) => void; onPrevious: () => void; page: number };
|
||||
export function SuggestionPagination({ nextCursor, onNext, onPrevious, page, focusOnMount }: SuggestionPaging & { nextCursor: string | null }) {
|
||||
const label = useRef<HTMLParagraphElement>(null);
|
||||
useEffect(() => { if (focusOnMount) label.current?.focus(); }, [focusOnMount]);
|
||||
return <nav aria-label="Pagination" className="mt-8 flex flex-wrap items-center justify-between gap-3 border-t border-line pt-6">
|
||||
<button className={suggestionControl} disabled={page <= 1} onClick={onPrevious} type="button">Previous page</button>
|
||||
<p aria-live="polite" className="font-mono text-[10px] uppercase tracking-wider text-muted" ref={label} tabIndex={-1}>Page {page}</p>
|
||||
<button className={suggestionControl} disabled={!nextCursor} onClick={() => { if (nextCursor) onNext(nextCursor); }} type="button">Next page</button>
|
||||
</nav>;
|
||||
}
|
||||
|
||||
export function SuggestionLoading({ children }: { children: string }) {
|
||||
return <div className="border border-line bg-panel p-8" role="status"><p className="font-mono text-xs uppercase tracking-widest">{children}</p><div aria-hidden="true" className="mt-6 h-1 w-20 bg-accent" /></div>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { SuggestionsBrowser } from "./suggestions-browser";
|
||||
|
||||
const suggestion = { id: "100000000000000009", title: "More railway stations", authorId: "100000000000000003", createdAt: "2026-01-01T00:00:00Z", archived: false, locked: false, tags: [{ id: "100000000000000004", name: "World" }], messageCount: 3, discordUrl: "https://discord.com/channels/100000000000000001/100000000000000009" };
|
||||
it("switches archives, pages forward and back, and resets pagination on status change", async () => {
|
||||
const fetcher = vi.fn().mockImplementation(async (url: string) => {
|
||||
const archived = url.includes("status=archived");
|
||||
const next = url.includes("cursor=");
|
||||
return Response.json({ items: [{ ...suggestion, title: archived ? (next ? "Earlier archived idea" : "Archived idea") : "Active idea", archived }], nextCursor: next ? null : "2026-01-01T00:00:00Z" });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionsBrowser />);
|
||||
await screen.findByRole("link", { name: "Active idea" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Archived" }));
|
||||
await screen.findByRole("link", { name: "Archived idea" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
|
||||
await screen.findByRole("link", { name: "Earlier archived idea" });
|
||||
expect(fetcher.mock.lastCall?.[0]).toContain("cursor=2026-01-01T00%3A00%3A00Z");
|
||||
expect(document.activeElement?.textContent).toBe("Page 2");
|
||||
expect((screen.getByRole("button", { name: "Next page" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Previous page" }));
|
||||
await screen.findByRole("link", { name: "Archived idea" });
|
||||
expect(document.activeElement?.textContent).toBe("Page 1");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Active" }));
|
||||
await screen.findByRole("link", { name: "Active idea" });
|
||||
expect(fetcher.mock.lastCall?.[0]).toBe("/api/suggestions?status=active&limit=25");
|
||||
expect((screen.getByRole("button", { name: "Previous page" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
it("shows an explicit empty state", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ items: [], nextCursor: null })));
|
||||
render(<SuggestionsBrowser />);
|
||||
expect(await screen.findByText("No suggestions on this page.")).toBeTruthy();
|
||||
});
|
||||
it("retries failed reads and distinguishes expired admin access", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ detail: "private upstream text" }, { status: 503 })).mockResolvedValueOnce(Response.json({ items: [], nextCursor: null }));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionsBrowser />);
|
||||
expect((await screen.findByRole("alert")).textContent).not.toContain("private upstream text");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
await screen.findByText("No suggestions on this page.");
|
||||
fetcher.mockResolvedValueOnce(Response.json({}, { status: 401 }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Archived" }));
|
||||
expect((await screen.findByRole("link", { name: "Admin sign-in" })).getAttribute("href")).toBe("/admin/login");
|
||||
expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
|
||||
});
|
||||
it("ignores a late response from the previously selected status", async () => {
|
||||
let resolveActive!: (response: Response) => void;
|
||||
const fetcher = vi.fn().mockImplementationOnce(() => new Promise<Response>((resolve) => { resolveActive = resolve; })).mockResolvedValueOnce(Response.json({ items: [{ ...suggestion, title: "Archived result", archived: true }], nextCursor: null }));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionsBrowser />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Archived" }));
|
||||
await screen.findByRole("link", { name: "Archived result" });
|
||||
resolveActive(Response.json({ items: [suggestion], nextCursor: null }));
|
||||
await waitFor(() => expect(fetcher.mock.calls[0]?.[1].signal.aborted).toBe(true));
|
||||
expect(screen.queryByRole("link", { name: "More railway stations" })).toBeNull();
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
it("loads and displays suggestions with tags and links through the admin API", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue(Response.json({ items: [suggestion], nextCursor: null }));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionsBrowser />);
|
||||
expect(screen.getByRole("status").textContent).toContain("Loading suggestions");
|
||||
const link = await screen.findByRole("link", { name: "More railway stations" });
|
||||
expect(link.getAttribute("href")).toBe(`/admin/suggestions/${suggestion.id}`);
|
||||
expect(screen.getByText("World")).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Open More railway stations in Discord" }).getAttribute("href")).toBe(suggestion.discordUrl);
|
||||
expect(fetcher).toHaveBeenCalledWith("/api/suggestions?status=active&limit=25", expect.objectContaining({ credentials: "same-origin", cache: "no-store" }));
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type { SuggestionPage } from "@/lib/discord/suggestion-types";
|
||||
import { SuggestionBadges, SuggestionFailure, SuggestionLoading, SuggestionPagination, suggestionControl, useSuggestionRead, useSuggestionPages, type SuggestionPaging } from "./suggestion-shared";
|
||||
|
||||
export function SuggestionsBrowser() {
|
||||
const [status, setStatus] = useState<"active" | "archived">("active");
|
||||
const paging = useSuggestionPages();
|
||||
const query = new URLSearchParams({ status, limit: "25" });
|
||||
if (paging.cursor) query.set("cursor", paging.cursor);
|
||||
const url = `/api/suggestions?${query}`;
|
||||
return <section aria-label="Suggestions" className="mt-10">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div aria-label="Suggestion status" className="flex gap-2">
|
||||
{(["active", "archived"] as const).map((value) => <button aria-pressed={status === value} className={`${suggestionControl} ${status === value ? "bg-ink text-panel" : "bg-panel"}`} key={value} onClick={() => { setStatus(value); paging.reset(); }} type="button">{value === "active" ? "Active" : "Archived"}</button>)}
|
||||
</div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-wider text-muted">{status === "active" ? "Newest ideas first" : "Most recently archived first"}</p>
|
||||
</div>
|
||||
<SuggestionList key={url} paging={paging} url={url} />
|
||||
</section>;
|
||||
}
|
||||
|
||||
function SuggestionList({ url, paging }: { url: string; paging: SuggestionPaging }) {
|
||||
const { data, error, retry } = useSuggestionRead<SuggestionPage>(url);
|
||||
if (error) return <SuggestionFailure error={error} retry={retry} />;
|
||||
if (!data) return <SuggestionLoading>Loading suggestions…</SuggestionLoading>;
|
||||
return <><ol className="divide-y divide-line border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
{!data.items.length && <li className="p-10"><h2 className="font-display text-2xl font-black uppercase">No suggestions on this page.</h2><p className="mt-3 text-sm text-muted">Try the other status, or return to the previous page.</p></li>}
|
||||
{data.items.map((suggestion, index) => <li className="grid gap-5 p-6 sm:grid-cols-[3rem_1fr_auto] sm:p-8" key={suggestion.id}>
|
||||
<span aria-hidden="true" className="font-mono text-sm text-muted">{String(index + 1).padStart(2, "0")}</span>
|
||||
<div className="min-w-0">
|
||||
<SuggestionBadges suggestion={suggestion} />
|
||||
<h2 className="mt-4 break-words font-display text-2xl font-black sm:text-3xl"><Link className="underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/suggestions/${suggestion.id}`}>{suggestion.title}</Link></h2>
|
||||
<p className="mt-3 break-words font-mono text-[10px] leading-5 text-muted"><time dateTime={suggestion.createdAt}>{suggestion.createdAt}</time><br />Author / {suggestion.authorId}</p>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-6 sm:flex-col sm:items-end">
|
||||
<p className="font-mono text-[10px] uppercase text-muted">~{suggestion.messageCount} messages</p>
|
||||
<a aria-label={`Open ${suggestion.title} in Discord`} className="font-mono text-[10px] font-bold uppercase underline underline-offset-4 hover:text-accent" href={suggestion.discordUrl} rel="noopener noreferrer" target="_blank">Discord ↗</a>
|
||||
</div>
|
||||
</li>)}
|
||||
</ol><SuggestionPagination nextCursor={data.nextCursor} {...paging} /></>;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groupAccessAddresses } from "./access-address-groups";
|
||||
import { accessAddressDetails, groupAccessAddresses } from "./access-address-groups";
|
||||
|
||||
describe("groupAccessAddresses", () => {
|
||||
it("collapses repeated observations from the same network into one recent summary", () => {
|
||||
@@ -20,4 +20,19 @@ describe("groupAccessAddresses", () => {
|
||||
});
|
||||
expect(groups[0]?.latestObservedAt.toISOString()).toBe("2026-08-01T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("presents the latest enriched location and classification with observation fallbacks", () => {
|
||||
expect(accessAddressDetails({
|
||||
classification: "vpn",
|
||||
intelligence: {
|
||||
classification: "vpn",
|
||||
location: { city: "Toronto", region: "Ontario", countryCode: "CA" },
|
||||
},
|
||||
})).toEqual({ location: "Toronto, Ontario, CA", classification: "vpn" });
|
||||
|
||||
expect(accessAddressDetails({ classification: "hosting", intelligence: null })).toEqual({
|
||||
location: "Location unavailable",
|
||||
classification: "hosting",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { addressGroup } from "@minecraft-account-manager/network";
|
||||
import { intelligenceSummary } from "./event-ip-summary";
|
||||
|
||||
type AccessObservation = {
|
||||
id: string;
|
||||
@@ -9,6 +10,14 @@ type AccessObservation = {
|
||||
intelligence: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function accessAddressDetails(observation: Pick<AccessObservation, "classification" | "intelligence">) {
|
||||
const summary = intelligenceSummary(observation.intelligence);
|
||||
return {
|
||||
location: summary.location ?? "Location unavailable",
|
||||
classification: summary.classification ?? observation.classification,
|
||||
};
|
||||
}
|
||||
|
||||
export type AccessAddressGroup = {
|
||||
network: string;
|
||||
latestAddress: string;
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function recordAdminSubjectEvent(
|
||||
subject: string,
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
options: { correlationId?: string } = {},
|
||||
) {
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
@@ -18,6 +19,7 @@ export async function recordAdminSubjectEvent(
|
||||
source: "/web/admin",
|
||||
subject,
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
correlationId: options.correlationId,
|
||||
data: { ...data, adminEmail: admin.email, adminName: admin.name },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
|
||||
import { exportJWK, generateKeyPair, SignJWT, type JWTPayload } from "jose";
|
||||
|
||||
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||
vi.mock("./admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
const issuer = "https://sso.example/realms/operators";
|
||||
const audience = "portal-admin";
|
||||
let keys: Awaited<ReturnType<typeof generateKeyPair>>;
|
||||
let jwks: { keys: unknown[] };
|
||||
let authorize: typeof import("./admin-api-auth").authorizeAdminApi;
|
||||
const fetcher = vi.fn();
|
||||
|
||||
beforeAll(async () => {
|
||||
keys = await generateKeyPair("RS256");
|
||||
jwks = { keys: [{ ...await exportJWK(keys.publicKey), kid: "test-key", alg: "RS256", use: "sig" }] };
|
||||
});
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv("KEYCLOAK_ISSUER_URL", issuer);
|
||||
vi.stubEnv("KEYCLOAK_CLIENT_ID", audience);
|
||||
vi.stubEnv("KEYCLOAK_CLIENT_SECRET", ""); // Machine verification needs no client secret.
|
||||
auth.session.mockResolvedValue({ user: { name: "Browser Admin", email: "admin@example.test", roles: ["ops"] } });
|
||||
fetcher.mockImplementation(async () => Response.json(jwks));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
authorize = (await import("./admin-api-auth")).authorizeAdminApi;
|
||||
});
|
||||
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
|
||||
|
||||
async function token(overrides: JWTPayload = {}, header: Record<string, unknown> = {}) {
|
||||
return new SignJWT({
|
||||
iss: issuer, aud: audience, sub: "machine-subject", exp: Math.floor(Date.now() / 1000) + 300,
|
||||
resource_access: { [audience]: { roles: ["ops"] } },
|
||||
...overrides,
|
||||
}).setProtectedHeader({ alg: "RS256", kid: "test-key", ...header }).sign(keys.privateKey);
|
||||
}
|
||||
function request(bearer: string) {
|
||||
return new Request("https://portal.example/api/admin/whoami", { headers: { authorization: `Bearer ${bearer}` } });
|
||||
}
|
||||
async function rejected(bearer: string, status = 401) {
|
||||
const result = await authorize(request(bearer));
|
||||
expect(result.identity).toBeUndefined();
|
||||
expect(result.response?.status).toBe(status);
|
||||
expect(result.response?.headers.get("content-type")).toBe("application/problem+json");
|
||||
expect(result.response?.headers.get("cache-control")).toBe("no-store");
|
||||
expect(result.response?.headers.get("www-authenticate")).toBe(status === 401 ? 'Bearer realm="admin-api"' : null);
|
||||
expect(await result.response?.json()).toMatchObject({ status, instance: "/api/admin/whoami" });
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
it("verifies real RS256 signatures and caches only the configured issuer JWKS", async () => {
|
||||
const signed = await token({ email: "private@example.test", name: "Private", arbitrary: "private" }, { jku: "https://attacker.example/keys" });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
expect(await authorize(request(signed))).toEqual({ identity: {
|
||||
authenticationMethod: "bearer", subject: "machine-subject", name: null, email: null,
|
||||
} });
|
||||
}
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetcher.mock.calls[0]?.[0])).toBe(`${issuer}/protocol/openid-connect/certs`);
|
||||
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual", method: "GET" });
|
||||
});
|
||||
it("accepts an audience list and case-insensitive bearer scheme", async () => {
|
||||
const signed = await token({ aud: ["other", audience] });
|
||||
const req = new Request(request(signed), { headers: { authorization: `bEaReR ${signed}` } });
|
||||
expect((await authorize(req)).identity?.subject).toBe("machine-subject");
|
||||
});
|
||||
it.each([
|
||||
["expired", { exp: 1 }], ["missing expiry", { exp: undefined }],
|
||||
["missing subject", { sub: undefined }], ["empty subject", { sub: "" }], ["blank subject", { sub: " " }],
|
||||
["future nbf", { nbf: 9999999999 }], ["wrong issuer", { iss: "https://attacker.example" }],
|
||||
["wrong audience", { aud: "another-client" }], ["missing audience", { aud: undefined }],
|
||||
] satisfies [string, JWTPayload][])("rejects %s despite an available privileged browser session", async (_name, claims) => {
|
||||
await rejected(await token(claims));
|
||||
});
|
||||
it("rejects tampering with a signed payload", async () => {
|
||||
const parts = (await token()).split(".");
|
||||
const payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString());
|
||||
parts[1] = Buffer.from(JSON.stringify({ ...payload, sub: "tampered" })).toString("base64url");
|
||||
await rejected(parts.join("."));
|
||||
});
|
||||
it.each([
|
||||
undefined, {}, { [audience]: { roles: [] } }, { another: { roles: ["ops"] } },
|
||||
{ [audience]: { roles: "ops" } }, { [audience]: { roles: ["player"] } },
|
||||
])("requires the configured client role, never a realm role (%j)", async (resource_access) => {
|
||||
await rejected(await token({ resource_access, realm_access: { roles: ["ops"] } }), 403);
|
||||
});
|
||||
it("rejects HS256 algorithm confusion", async () => {
|
||||
const signed = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: 9999999999 })
|
||||
.setProtectedHeader({ alg: "HS256", kid: "test-key" }).sign(new TextEncoder().encode("test-only-key-with-at-least-32-bytes"));
|
||||
await rejected(signed);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects an otherwise valid but non-allowlisted asymmetric algorithm", async () => {
|
||||
const ec = await generateKeyPair("ES256");
|
||||
fetcher.mockImplementation(async () => Response.json({ keys: [{ ...await exportJWK(ec.publicKey), kid: "ec-key" }] }));
|
||||
const signed = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: 9999999999 })
|
||||
.setProtectedHeader({ alg: "ES256", kid: "ec-key" }).sign(ec.privateKey);
|
||||
await rejected(signed);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects an unknown signing key", async () => { await rejected(await token({}, { kid: "unknown" })); });
|
||||
it("rejects a signature from an untrusted key even when its kid matches", async () => {
|
||||
const other = await generateKeyPair("RS256");
|
||||
const signed = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: 9999999999 })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "test-key" }).sign(other.privateKey);
|
||||
await rejected(signed);
|
||||
});
|
||||
it("coalesces concurrent JWKS reads and refreshes rotated keys after cooldown", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
const signed = await token();
|
||||
const results = await Promise.all(Array.from({ length: 8 }, () => authorize(request(signed))));
|
||||
expect(results.every((result) => result.identity?.subject === "machine-subject")).toBe(true);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const rotated = await generateKeyPair("RS256");
|
||||
fetcher.mockImplementation(async () => Response.json({ keys: [{ ...await exportJWK(rotated.publicKey), kid: "rotated-key", alg: "RS256" }] }));
|
||||
const next = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: Math.floor(Date.now() / 1000) + 300, resource_access: { [audience]: { roles: ["ops"] } } })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "rotated-key" }).sign(rotated.privateKey);
|
||||
await rejected(next);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
vi.setSystemTime(Date.now() + 31_000);
|
||||
expect((await authorize(request(next))).identity?.subject).toBe("machine");
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it.each(["redirect", "non-JSON", "invalid JWKS", "HTTP failure"])("fails closed on a %s JWKS response", async (kind) => {
|
||||
fetcher.mockImplementation(async () => {
|
||||
if (kind === "redirect") return new Response(null, { status: 302, headers: { location: "https://attacker.example/keys" } });
|
||||
if (kind === "non-JSON") return new Response("private malformed response");
|
||||
if (kind === "invalid JWKS") return Response.json({ keys: "private malformed keys" });
|
||||
return new Response("private upstream failure", { status: 500 });
|
||||
});
|
||||
await rejected(await token(), 503);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("fails closed and sanitizes a JWKS transport outage", async () => {
|
||||
fetcher.mockRejectedValue(new Error("private network details"));
|
||||
const result = await authorize(request(await token()));
|
||||
expect(result.response?.status).toBe(503);
|
||||
expect(await result.response?.text()).not.toContain("private");
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["", "http://sso.example/realms/operators", "not a URL", `${issuer}?query=1`, "https://user:password@sso.example/realm"])("fails closed on unsafe/missing issuer configuration %s", async (value) => {
|
||||
vi.stubEnv("KEYCLOAK_ISSUER_URL", value);
|
||||
await rejected(await token(), 503);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it("fails closed on missing client ID", async () => {
|
||||
vi.stubEnv("KEYCLOAK_CLIENT_ID", "");
|
||||
await rejected(await token(), 503);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([null, { user: {} }, { user: { roles: ["player"] } }])("preserves missing/unauthorized browser behavior (%j)", async (session) => {
|
||||
auth.session.mockResolvedValue(session);
|
||||
const result = await authorize(new Request("https://portal.example/api/admin/whoami"));
|
||||
expect(result.response?.status).toBe(session ? 403 : 401);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it("preserves browser identity without requiring machine configuration", async () => {
|
||||
vi.stubEnv("KEYCLOAK_ISSUER_URL", "");
|
||||
expect(await authorize(new Request("https://portal.example/api/admin/whoami"))).toEqual({ identity: {
|
||||
authenticationMethod: "session", subject: null, name: "Browser Admin", email: "admin@example.test",
|
||||
} });
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { createRemoteJWKSet, errors, jwtVerify } from "jose";
|
||||
import { problemDetails } from "@minecraft-account-manager/contracts";
|
||||
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||
import { adminAuthOptions, requiredAdminRole } from "./admin-auth";
|
||||
|
||||
export type AdminApiIdentity = {
|
||||
authenticationMethod: "session" | "bearer";
|
||||
subject: string | null;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
type Authorization = { identity: AdminApiIdentity; response?: never } | { response: Response; identity?: never };
|
||||
|
||||
function failure(request: Request, status: 401 | 403 | 503): Authorization {
|
||||
const problems = {
|
||||
401: ["unauthorized", "Authentication required", "Supply valid administrator credentials."],
|
||||
403: ["forbidden", "Administrator role required", "This API is restricted to administrators."],
|
||||
503: ["admin-auth-unavailable", "Authentication unavailable", "Administrator authentication is temporarily unavailable."],
|
||||
} as const;
|
||||
const [code, title, detail] = problems[status];
|
||||
const response = problemResponse(problemDetails(`urn:error:${code}`, title, status, detail, problemInstance(request)));
|
||||
if (status === 401) response.headers.set("www-authenticate", 'Bearer realm="admin-api"');
|
||||
return { response };
|
||||
}
|
||||
|
||||
// One bounded, process-local resolver. jose coalesces fetches and refreshes rotated keys.
|
||||
let remote: { issuer: string; keys: ReturnType<typeof createRemoteJWKSet> } | undefined;
|
||||
function bearerConfiguration() {
|
||||
const issuer = process.env.KEYCLOAK_ISSUER_URL?.trim() ?? "";
|
||||
const audience = process.env.KEYCLOAK_CLIENT_ID?.trim() ?? "";
|
||||
const url = new URL(issuer);
|
||||
if (!audience || url.protocol !== "https:" || url.username || url.password || url.search || url.hash) {
|
||||
throw new Error("Invalid administrator authentication configuration");
|
||||
}
|
||||
if (!remote || remote.issuer !== issuer) {
|
||||
// Never discover a key URL from untrusted token claims or headers (jku/x5u/iss).
|
||||
const jwksUrl = new URL(`${issuer.replace(/\/$/, "")}/protocol/openid-connect/certs`);
|
||||
remote = { issuer, keys: createRemoteJWKSet(jwksUrl, {
|
||||
timeoutDuration: 5_000, cooldownDuration: 30_000, cacheMaxAge: 600_000,
|
||||
}) };
|
||||
}
|
||||
return { issuer, audience, keys: remote.keys };
|
||||
}
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
async function authorizeBearer(request: Request): Promise<Authorization> {
|
||||
const match = /^Bearer +([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/i.exec(request.headers.get("authorization") ?? "");
|
||||
if (!match) return failure(request, 401);
|
||||
let configuration: ReturnType<typeof bearerConfiguration>;
|
||||
try {
|
||||
configuration = bearerConfiguration();
|
||||
} catch {
|
||||
return failure(request, 503);
|
||||
}
|
||||
try {
|
||||
const { issuer, audience, keys } = configuration;
|
||||
const { payload } = await jwtVerify(match[1]!, keys, {
|
||||
issuer, audience, algorithms: ["RS256"], requiredClaims: ["exp", "sub"],
|
||||
});
|
||||
if (typeof payload.sub !== "string" || !payload.sub.trim()) return failure(request, 401);
|
||||
const access = payload.resource_access;
|
||||
const client = record(access) && Object.hasOwn(access, audience) ? access[audience] : undefined;
|
||||
const roles = record(client) ? client.roles : undefined;
|
||||
if (!Array.isArray(roles) || !roles.includes(requiredAdminRole)) return failure(request, 403);
|
||||
return { identity: { authenticationMethod: "bearer", subject: payload.sub, name: null, email: null } };
|
||||
} catch (error) {
|
||||
// Verification failures are invalid credentials; transport/configuration failures are unavailable.
|
||||
const invalid = error instanceof errors.JWTClaimValidationFailed || error instanceof errors.JWTExpired
|
||||
|| error instanceof errors.JWSInvalid || error instanceof errors.JWTInvalid
|
||||
|| error instanceof errors.JWSSignatureVerificationFailed || error instanceof errors.JOSEAlgNotAllowed
|
||||
|| error instanceof errors.JWKSNoMatchingKey || error instanceof errors.JOSENotSupported;
|
||||
return failure(request, invalid ? 401 : 503);
|
||||
}
|
||||
}
|
||||
|
||||
/** API-only authorization; never use bearer tokens to authorize browser actions. */
|
||||
export async function authorizeAdminApi(request: Request): Promise<Authorization> {
|
||||
// Presence, including an empty/unsupported header, is authoritative. Never fall back.
|
||||
if (request.headers.has("authorization")) return authorizeBearer(request);
|
||||
try {
|
||||
const session = await getServerSession(adminAuthOptions);
|
||||
if (!session) return failure(request, 401);
|
||||
const roles = (session.user as { roles?: unknown } | undefined)?.roles;
|
||||
if (!Array.isArray(roles) || !roles.includes(requiredAdminRole)) return failure(request, 403);
|
||||
return { identity: {
|
||||
authenticationMethod: "session", subject: null,
|
||||
name: session.user?.name ?? null, email: session.user?.email ?? null,
|
||||
} };
|
||||
} catch {
|
||||
return failure(request, 503);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type SuggestionTag = { id: string; name: string };
|
||||
export type Suggestion = {
|
||||
id: string;
|
||||
title: string;
|
||||
authorId: string;
|
||||
createdAt: string;
|
||||
archived: boolean;
|
||||
locked: boolean;
|
||||
tags: SuggestionTag[];
|
||||
messageCount: number;
|
||||
discordUrl: string;
|
||||
};
|
||||
export type SuggestionMessage = {
|
||||
id: string;
|
||||
author: { id: string; name: string };
|
||||
content: string;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: { emoji: string; count: number }[];
|
||||
discordUrl: string;
|
||||
};
|
||||
export type SuggestionPage = { items: Suggestion[]; nextCursor: string | null };
|
||||
export type MessagePage = { items: SuggestionMessage[]; nextCursor: string | null };
|
||||
export type SuggestionDetail = Suggestion & { originalPost: SuggestionMessage | null };
|
||||
@@ -0,0 +1,47 @@
|
||||
import { problemDetails } from "@minecraft-account-manager/contracts";
|
||||
import { authorizeAdminApi } from "@/lib/auth/admin-api-auth";
|
||||
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||
import { createSuggestionsClient, SuggestionsError } from "./suggestions";
|
||||
|
||||
type Client = ReturnType<typeof createSuggestionsClient>;
|
||||
let runtime: { token: string; guildId: string; forumId: string; client: Client } | undefined;
|
||||
function getClient() {
|
||||
const token = process.env.DISCORD_BOT_TOKEN?.trim() ?? "";
|
||||
const guildId = process.env.DISCORD_GUILD_ID?.trim() ?? "";
|
||||
const forumId = process.env.DISCORD_SUGGESTIONS_FORUM_ID?.trim() ?? "";
|
||||
if (!runtime || runtime.token !== token || runtime.guildId !== guildId || runtime.forumId !== forumId) {
|
||||
runtime = { token, guildId, forumId, client: createSuggestionsClient({ token, guildId, forumId }) };
|
||||
}
|
||||
return runtime.client;
|
||||
}
|
||||
|
||||
export async function suggestionsApi(request: Request, operation: (client: Client) => Promise<unknown>) {
|
||||
const authorization = await authorizeAdminApi(request);
|
||||
if (authorization.response) return authorization.response;
|
||||
try {
|
||||
return Response.json(await operation(getClient()), { headers: { "cache-control": "no-store" } });
|
||||
} catch (error) {
|
||||
const safe = error instanceof SuggestionsError ? error : new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
|
||||
const titles: Record<number, string> = { 400: "Invalid request", 401: "Authentication required", 403: "Administrator role required", 404: "Suggestion not found", 405: "Method not allowed", 503: "Suggestions unavailable" };
|
||||
const response = problemResponse(problemDetails(`urn:error:${safe.code}`, titles[safe.status] ?? "Suggestions unavailable", safe.status, safe.message, problemInstance(request)));
|
||||
if (safe.retryAfter) response.headers.set("retry-after", String(safe.retryAfter));
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export function suggestionQuery(request: Request, list = false) {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const allowed = list ? ["limit", "cursor", "status"] : ["limit", "cursor"];
|
||||
for (const key of params.keys()) {
|
||||
if (!allowed.includes(key) || params.getAll(key).length !== 1 || !params.get(key)) throw new SuggestionsError(400, "invalid-request", "Unsupported or repeated query parameter.");
|
||||
}
|
||||
const rawLimit = params.get("limit");
|
||||
if (rawLimit !== null && !/^\d{1,3}$/.test(rawLimit)) throw new SuggestionsError(400, "invalid-request", "Limit must be between 1 and 100.");
|
||||
return { limit: rawLimit === null ? undefined : Number(rawLimit), cursor: params.get("cursor") ?? undefined, ...(list ? { status: params.get("status") ?? undefined } : {}) };
|
||||
}
|
||||
|
||||
export function suggestionsReadOnly(request: Request) {
|
||||
return suggestionsApi(request, async () => {
|
||||
throw new SuggestionsError(405, "method-not-allowed", "Suggestions are read-only. Use GET.");
|
||||
}).then((response) => { if (response.status === 405) response.headers.set("allow", "GET, HEAD"); return response; });
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { createSuggestionsClient } from "./suggestions";
|
||||
afterEach(() => vi.useRealTimers());
|
||||
const guildId = "100000000000000001";
|
||||
const forumId = "100000000000000002";
|
||||
const threadId = "100000000000000009";
|
||||
const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11, name: "More railway stations", owner_id: "100000000000000003", applied_tags: ["100000000000000004"], message_count: 3, thread_metadata: { archived: false, locked: false, archive_timestamp: "2026-01-01T00:00:00.000Z" } };
|
||||
function setup(responses: Record<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);
|
||||
});
|
||||
const client = createSuggestionsClient({ token: "test-token", guildId, forumId, fetch: fetcher });
|
||||
return { client, fetcher };
|
||||
}
|
||||
it("reads archived forum pages using Discord's archive timestamp cursor", async () => {
|
||||
const cursor = "2026-01-01T00:00:00Z";
|
||||
const { client } = setup({
|
||||
[`/channels/${forumId}`]: forum,
|
||||
[`/channels/${forumId}/threads/archived/public?limit=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=2`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: raw } }], has_more: true },
|
||||
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
|
||||
});
|
||||
const first = await client.list({ status: "archived", limit: 1 });
|
||||
expect(first.nextCursor).toBe(cursor);
|
||||
expect(await client.list({ status: "archived", limit: 1, cursor: first.nextCursor! })).toEqual({ items: [], nextCursor: null });
|
||||
});
|
||||
const message = { id: threadId, content: "Please add stations", timestamp: "2026-01-01T00:00:00.000Z", edited_timestamp: null, author: { id: "100000000000000003", username: "builder", global_name: "Builder" }, reactions: [{ emoji: { name: "👍" }, count: 4 }] };
|
||||
it("returns the starter post and paginates discussion with authors and reactions", async () => {
|
||||
const { client } = setup({
|
||||
[`/channels/${forumId}`]: forum,
|
||||
[`/channels/${threadId}`]: thread,
|
||||
[`/channels/${threadId}/messages/${threadId}`]: message,
|
||||
[`/channels/${threadId}/messages?limit=1`]: [{ ...message, id: "100000000000000020" }],
|
||||
[`/channels/${threadId}/messages?limit=1&before=100000000000000020`]: [],
|
||||
});
|
||||
expect(await client.detail(threadId)).toMatchObject({ title: thread.name, originalPost: { content: message.content, author: { name: "Builder" }, reactions: [{ emoji: "👍", count: 4 }], createdAt: "2026-01-01T00:00:00Z" } });
|
||||
expect(await client.messages(threadId, { limit: 1 })).toMatchObject({ items: [{ id: "100000000000000020" }], nextCursor: "100000000000000020" });
|
||||
expect(await client.messages(threadId, { limit: 1, cursor: "100000000000000020" })).toEqual({ items: [], nextCursor: null });
|
||||
});
|
||||
|
||||
it.each(["detail", "messages"] as const)("blocks %s of a thread outside the forum before reading messages", async (method) => {
|
||||
const { client, fetcher } = setup({ [`/channels/${forumId}`]: forum, [`/channels/${threadId}`]: { ...thread, parent_id: "100000000000000099" } });
|
||||
await expect(client[method](threadId)).rejects.toMatchObject({ status: 404 });
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("keeps a deleted starter post distinguishable from an empty message", async () => {
|
||||
const { client } = setup({ [`/channels/${forumId}`]: forum, [`/channels/${threadId}`]: thread, [`/channels/${threadId}/messages/${threadId}`]: new Response(null, { status: 404 }) });
|
||||
expect(await client.detail(threadId)).toMatchObject({ originalPost: null });
|
||||
});
|
||||
it("backs off on Discord rate limits without exposing Discord error bodies", async () => {
|
||||
const { client, fetcher } = setup({ [`/channels/${forumId}`]: Response.json({ retry_after: 2.5, message: "secret upstream details" }, { status: 429 }) });
|
||||
await expect(client.list()).rejects.toMatchObject({ status: 503, code: "discord-rate-limited", retryAfter: 3 });
|
||||
await expect(client.list()).rejects.toMatchObject({ status: 503, code: "discord-rate-limited" });
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each([401, 403, 404, 500])("translates Discord %s into a safe service error", async (status) => {
|
||||
const { client } = setup({ [`/channels/${forumId}`]: new Response("sensitive error", { status }) });
|
||||
await expect(client.list()).rejects.toMatchObject({ status: 503 });
|
||||
await expect(client.list()).rejects.not.toThrow("sensitive error");
|
||||
});
|
||||
it("sanitizes network failures", async () => {
|
||||
const client = createSuggestionsClient({ token: "test", guildId, forumId, fetch: vi.fn().mockRejectedValue(new Error("token leaked by upstream")) });
|
||||
await expect(client.list()).rejects.toMatchObject({ status: 503, message: "Discord suggestions are unavailable." });
|
||||
});
|
||||
|
||||
it("coalesces concurrent reads and refreshes expired cache entries", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { client, fetcher } = setup({ [`/channels/${forumId}`]: forum, [`/guilds/${guildId}/threads/active`]: { threads: [thread] } });
|
||||
await Promise.all([client.list(), client.list()]);
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
vi.advanceTimersByTime(30_001);
|
||||
await client.list();
|
||||
expect(fetcher).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
it.each([{ status: "all" }, { limit: 0 }, { limit: 101 }, { limit: 1.5 }, { cursor: "../secret" }, { status: "archived", cursor: "bad-date" }])("validates list query %j before network access", async (query) => {
|
||||
const { client, fetcher } = setup({});
|
||||
await expect(client.list(query)).rejects.toMatchObject({ status: 400 });
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["detail", "messages"] as const)("validates %s IDs before network access", async (method) => {
|
||||
const { client, fetcher } = setup({});
|
||||
await expect(client[method]("../secret")).rejects.toMatchObject({ status: 400 });
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([{ ...forumPlaceholder(), type: 0 }, { ...forumPlaceholder(), guild_id: "100000000000000099" }])("refuses a non-forum or wrong-guild configured channel", async (value) => {
|
||||
const { client, fetcher } = setup({ [`/channels/${forumId}`]: value });
|
||||
await expect(client.list()).rejects.toMatchObject({ status: 503 });
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("bounds concurrent upstream requests instead of flooding Discord", async () => {
|
||||
const releases: (() => void)[] = [];
|
||||
const fetcher = vi.fn<typeof fetch>(async (input) => {
|
||||
if (String(input).endsWith(`/channels/${forumId}`)) return Response.json(forum);
|
||||
if (String(input).endsWith("/threads/active")) return Response.json({ threads: [] });
|
||||
return new Promise<Response>((resolve) => { releases.push(() => resolve(new Response(null, { status: 404 }))); });
|
||||
});
|
||||
const client = createSuggestionsClient({ token: "test", guildId, forumId, fetch: fetcher });
|
||||
await client.list();
|
||||
const results = Array.from({ length: 9 }, (_, index) => client.detail(`1000000000000001${index.toString().padStart(2, "0")}`).catch((error: unknown) => error));
|
||||
await vi.waitFor(() => expect(fetcher.mock.calls.length).toBeGreaterThanOrEqual(10));
|
||||
const count = releases.length;
|
||||
releases.forEach((release) => release());
|
||||
const errors = await Promise.all(results);
|
||||
expect(count).toBe(8);
|
||||
expect(errors).toContainEqual(expect.objectContaining({ code: "discord-busy", status: 503 }));
|
||||
});
|
||||
function forumPlaceholder() { return { id: forumId, guild_id: guildId, type: 15 }; }
|
||||
|
||||
const forum = { id: forumId, guild_id: guildId, type: 15, available_tags: [{ id: "100000000000000004", name: "World" }] };
|
||||
|
||||
it("lists only configured-forum active suggestions, resolves tags and paginates newest first", async () => {
|
||||
const { client, fetcher } = setup({
|
||||
[`/channels/${forumId}`]: forum,
|
||||
[`/guilds/${guildId}/threads/active`]: { threads: [
|
||||
{ ...thread, id: "100000000000000008" }, thread,
|
||||
{ ...thread, id: "100000000000000010", parent_id: "100000000000000099" },
|
||||
] },
|
||||
});
|
||||
const first = await client.list({ limit: 1 });
|
||||
expect(first).toMatchObject({ items: [{ id: threadId, title: "More railway stations", tags: [{ name: "World" }], discordUrl: `https://discord.com/channels/${guildId}/${threadId}` }], nextCursor: threadId });
|
||||
const second = await client.list({ limit: 1, cursor: threadId });
|
||||
expect(second).toMatchObject({ items: [{ id: "100000000000000008" }], nextCursor: null });
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ headers: { Authorization: "Bot test-token" }, cache: "no-store", redirect: "error" });
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { MessagePage, SuggestionDetail, SuggestionMessage, Suggestion, SuggestionPage, SuggestionTag } from "./suggestion-types";
|
||||
|
||||
type Forum = { id: string; guild_id: string; type: number; available_tags: SuggestionTag[] };
|
||||
type Thread = {
|
||||
id: string; guild_id?: string; parent_id: string; type: number; name: string; owner_id: string;
|
||||
applied_tags?: string[]; message_count?: number;
|
||||
thread_metadata: { archived: boolean; locked: boolean; archive_timestamp: string };
|
||||
};
|
||||
type Message = {
|
||||
id: string; content: string; timestamp: string; edited_timestamp?: string | null;
|
||||
author: { id: string; username: string; global_name?: string | null };
|
||||
reactions?: { emoji: { id?: string | null; name: string | null }; count: number }[];
|
||||
};
|
||||
export type ListQuery = { status?: string; cursor?: string; limit?: number };
|
||||
|
||||
export class SuggestionsError extends Error {
|
||||
constructor(public readonly status: number, public readonly code: string, message: string, public readonly retryAfter?: number) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
const snowflake = /^[1-9]\d{16,19}$/;
|
||||
function checkId(id: string) {
|
||||
if (!snowflake.test(id)) throw new SuggestionsError(400, "invalid-request", "A valid Discord ID is required.");
|
||||
}
|
||||
function limitValue(limit = 25) {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new SuggestionsError(400, "invalid-request", "Limit must be between 1 and 100.");
|
||||
return limit;
|
||||
}
|
||||
function timestamp(value: string | number) {
|
||||
return new Date(value).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
function archiveCursor(value: string) {
|
||||
// Discord timestamps may use +00:00 and microseconds. Date alone truncates
|
||||
// that precision, potentially skipping posts at the archive page boundary.
|
||||
const fraction = value.match(/\.(\d{1,6})(?:Z|[+-]\d{2}:\d{2})$/)?.[1];
|
||||
return new Date(value).toISOString().replace(/\.\d{3}Z$/, fraction && /[1-9]/.test(fraction) ? `.${fraction}Z` : "Z");
|
||||
}
|
||||
|
||||
export function createSuggestionsClient(options: { token: string; guildId: string; forumId: string; fetch?: typeof fetch }) {
|
||||
const { token, guildId, forumId } = options;
|
||||
const fetcher = options.fetch ?? fetch;
|
||||
const cache = new Map<string, { expires: number; value: unknown }>();
|
||||
let retryAt = 0;
|
||||
let activeRequests = 0;
|
||||
const pending = new Map<string, Promise<unknown>>();
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const existing = pending.get(path);
|
||||
if (existing) return existing as Promise<T>;
|
||||
const request = load<T>(path);
|
||||
pending.set(path, request);
|
||||
try { return await request; } finally { pending.delete(path); }
|
||||
}
|
||||
async function load<T>(path: string): Promise<T> {
|
||||
const cached = cache.get(path);
|
||||
if (cached && cached.expires > Date.now()) return cached.value as T;
|
||||
if (Date.now() < retryAt) throw new SuggestionsError(503, "discord-rate-limited", "Discord is rate limited. Try again shortly.", Math.ceil((retryAt - Date.now()) / 1000));
|
||||
if (activeRequests >= 8) throw new SuggestionsError(503, "discord-busy", "Suggestions are busy. Try again shortly.", 1);
|
||||
activeRequests += 1;
|
||||
try {
|
||||
const response = await fetcher(`https://discord.com/api/v10${path}`, {
|
||||
headers: { Authorization: `Bot ${token}` }, cache: "no-store", redirect: "error", signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (response.status === 429) {
|
||||
const body = await response.json().catch(() => null) as { retry_after?: number } | null;
|
||||
const raw = Number(body?.retry_after ?? response.headers.get("retry-after") ?? 1);
|
||||
const seconds = Number.isFinite(raw) && raw > 0 ? Math.ceil(raw) : 1;
|
||||
retryAt = Date.now() + seconds * 1000;
|
||||
throw new SuggestionsError(503, "discord-rate-limited", "Discord is rate limited. Try again shortly.", seconds);
|
||||
}
|
||||
if (response.status === 404) throw new SuggestionsError(404, "suggestion-not-found", "The suggestion or message was not found.");
|
||||
if (!response.ok) throw new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
|
||||
const value: unknown = await response.json();
|
||||
if (cache.size >= 200) cache.delete(cache.keys().next().value!);
|
||||
cache.set(path, { value, expires: Date.now() + 30_000 });
|
||||
return value as T;
|
||||
} catch (error) {
|
||||
if (error instanceof SuggestionsError) throw error;
|
||||
throw new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
|
||||
} finally {
|
||||
activeRequests -= 1;
|
||||
}
|
||||
}
|
||||
async function getForum() {
|
||||
if (!token || !snowflake.test(guildId) || !snowflake.test(forumId)) throw new SuggestionsError(503, "suggestions-not-configured", "Discord suggestions are not configured.");
|
||||
const forum = await get<Forum>(`/channels/${forumId}`).catch((error: unknown) => {
|
||||
if (error instanceof SuggestionsError && error.status === 404) throw new SuggestionsError(503, "suggestions-not-configured", "The configured suggestions forum is unavailable.");
|
||||
throw error;
|
||||
});
|
||||
if (forum.id !== forumId || forum.guild_id !== guildId || forum.type !== 15) throw new SuggestionsError(503, "suggestions-not-configured", "The configured channel must be a forum in the configured guild.");
|
||||
return forum;
|
||||
}
|
||||
function belongs(thread: Thread) {
|
||||
return thread.parent_id === forumId && (!thread.guild_id || thread.guild_id === guildId) && thread.type === 11;
|
||||
}
|
||||
function summary(thread: Thread, forum: Forum): Suggestion {
|
||||
return {
|
||||
id: thread.id, title: thread.name, authorId: thread.owner_id,
|
||||
createdAt: timestamp(Number((BigInt(thread.id) >> 22n) + 1420070400000n)),
|
||||
archived: thread.thread_metadata.archived, locked: thread.thread_metadata.locked,
|
||||
tags: (forum.available_tags ?? []).filter((tag) => thread.applied_tags?.includes(tag.id)).map(({ id, name }) => ({ id, name })),
|
||||
messageCount: thread.message_count ?? 0, discordUrl: `https://discord.com/channels/${guildId}/${thread.id}`,
|
||||
};
|
||||
}
|
||||
async function getThread(id: string) {
|
||||
checkId(id);
|
||||
const forum = await getForum();
|
||||
const thread = await get<Thread>(`/channels/${id}`);
|
||||
if (thread.id !== id || !belongs(thread)) throw new SuggestionsError(404, "suggestion-not-found", "The suggestion was not found in the configured forum.");
|
||||
return { thread, forum };
|
||||
}
|
||||
function messageView(message: Message, threadId: string): SuggestionMessage {
|
||||
return {
|
||||
id: message.id, content: message.content,
|
||||
author: { id: message.author.id, name: message.author.global_name || message.author.username },
|
||||
createdAt: timestamp(message.timestamp), editedAt: message.edited_timestamp ? timestamp(message.edited_timestamp) : null,
|
||||
reactions: (message.reactions ?? []).map(({ emoji, count }) => ({ emoji: emoji.id ? `:${emoji.name ?? "emoji"}:` : emoji.name ?? "emoji", count })),
|
||||
discordUrl: `https://discord.com/channels/${guildId}/${threadId}/${message.id}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
async detail(id: string): Promise<SuggestionDetail> {
|
||||
const { thread, forum } = await getThread(id);
|
||||
const message = await get<Message>(`/channels/${id}/messages/${id}`).catch((error: unknown) => {
|
||||
if (error instanceof SuggestionsError && error.status === 404) return null;
|
||||
throw error;
|
||||
});
|
||||
return { ...summary(thread, forum), originalPost: message ? messageView(message, id) : null };
|
||||
},
|
||||
async messages(id: string, query: { cursor?: string; limit?: number } = {}): Promise<MessagePage> {
|
||||
const limit = limitValue(query.limit);
|
||||
if (query.cursor) checkId(query.cursor);
|
||||
await getThread(id);
|
||||
const before = query.cursor ? `&before=${query.cursor}` : "";
|
||||
const messages = await get<Message[]>(`/channels/${id}/messages?limit=${limit}${before}`);
|
||||
return { items: messages.map((message) => messageView(message, id)), nextCursor: messages.length === limit ? messages.at(-1)!.id : null };
|
||||
},
|
||||
async list(query: ListQuery = {}): Promise<SuggestionPage> {
|
||||
const limit = limitValue(query.limit);
|
||||
const status = query.status ?? "active";
|
||||
if (status !== "active" && status !== "archived") throw new SuggestionsError(400, "invalid-request", "Status must be active or archived.");
|
||||
if (query.cursor) {
|
||||
if (status === "active") checkId(query.cursor);
|
||||
else if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/.test(query.cursor) || !Number.isFinite(Date.parse(query.cursor))) {
|
||||
throw new SuggestionsError(400, "invalid-request", "The archive cursor must be a UTC timestamp.");
|
||||
}
|
||||
}
|
||||
const forum = await getForum();
|
||||
if (status === "archived") {
|
||||
const before = query.cursor ? `&before=${encodeURIComponent(query.cursor)}` : "";
|
||||
const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${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: 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`);
|
||||
const threads = data.threads.filter(belongs).sort((a, b) => BigInt(a.id) > BigInt(b.id) ? -1 : 1)
|
||||
.filter((thread) => !query.cursor || BigInt(thread.id) < BigInt(query.cursor));
|
||||
const page = threads.slice(0, limit);
|
||||
return { items: page.map((thread) => summary(thread, forum)), nextCursor: threads.length > limit ? page.at(-1)!.id : null };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
it("links the public contract and documents safe client-credentials usage", () => {
|
||||
const read = (path: string) => readFileSync(resolve(process.cwd(), "../..", path), "utf8");
|
||||
expect(read("README.md")).toContain("[OpenAPI 3.1](openapi.yaml)");
|
||||
expect(read("README.md")).toContain("/openapi.yaml");
|
||||
const docs = read("docs/admin-api-authentication.md");
|
||||
expect(docs).toContain("/protocol/openid-connect/token");
|
||||
expect(docs).toContain('"grant_type": "client_credentials"');
|
||||
expect(docs).toContain("getpass.getpass");
|
||||
expect(docs).toContain("No helper is installed");
|
||||
expect(docs).toContain("Do not enable shell tracing");
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { GET } from "../app/openapi.yaml/route";
|
||||
|
||||
it("serves the canonical YAML bytes publicly, without rewriting or authentication", async () => {
|
||||
const response = await GET();
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/yaml; charset=utf-8");
|
||||
expect(Buffer.from(await response.arrayBuffer())).toEqual(readFileSync(resolve(process.cwd(), "../../openapi.yaml")));
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { resolve, relative } from "node:path";
|
||||
import SwaggerParser from "@apidevtools/swagger-parser";
|
||||
import { parse } from "yaml";
|
||||
import { assert, expect, it } from "vitest";
|
||||
import { velocityAccessRequestSchema, velocityConnectionRequestSchema } from "@minecraft-account-manager/contracts";
|
||||
import { assertResponse, assertSchema, loadContract } from "@/test/openapi-contract";
|
||||
|
||||
const root = resolve(process.cwd(), "../..");
|
||||
const canonical = resolve(root, "openapi.yaml");
|
||||
it("publishes a valid OpenAPI 3.1 contract covering every application API", async () => {
|
||||
expect(existsSync(canonical), "root openapi.yaml must exist").toBe(true);
|
||||
const doc = parse(readFileSync(canonical, "utf8"));
|
||||
expect(doc.openapi).toBe("3.1.0");
|
||||
await SwaggerParser.validate(structuredClone(doc));
|
||||
const api = resolve(process.cwd(), "src/app/api");
|
||||
const routes = readdirSync(api, { recursive: true, withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name === "route.ts")
|
||||
.map((entry) => relative(api, resolve(entry.parentPath, entry.name)).replace(/\/route\.ts$/, "").replace(/\[([^\]]+)\]/g, "{$1}"))
|
||||
.filter((path) => !["route.ts", "{...path}", "auth/{...nextauth}"].includes(path))
|
||||
.map((path) => `/api/${path}`);
|
||||
expect(Object.keys(doc.paths).filter((path) => path.startsWith("/api/")).sort()).toEqual(routes.sort());
|
||||
for (const path of routes) {
|
||||
const source = readFileSync(resolve(api, path.slice(5).replace(/\{([^}]+)\}/g, "[$1]"), "route.ts"), "utf8");
|
||||
const methods = [...source.matchAll(/export (?:async )?(?:function|const) (GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g)].map((match) => match[1]!.toLowerCase());
|
||||
for (const method of methods) expect(doc.paths[path][method], `${method} ${path} must be documented`).toBeDefined();
|
||||
if (methods.includes("get")) expect(doc.paths[path].head).toBeDefined();
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const [path, item] of Object.entries(doc.paths) as [string, Record<string, Record<string, unknown>>][]) {
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
if (!["get", "head", "post", "put", "patch", "delete", "options"].includes(method)) continue;
|
||||
expect(operation.summary, `${method} ${path}`).toBeTruthy();
|
||||
expect(operation.description).toBeTruthy();
|
||||
expect(ids.has(operation.operationId as string)).toBe(false);
|
||||
ids.add(operation.operationId as string);
|
||||
expect(operation.operationId).toBeTruthy();
|
||||
if (path.startsWith("/api/suggestions") || path === "/api/admin/whoami") {
|
||||
expect(operation.security).toEqual([{ AdminSession: [] }, { AdminBearer: [] }]);
|
||||
} else if (path.startsWith("/api/velocity") && method === "post") {
|
||||
expect(operation.security).toEqual([{ VelocitySecret: [] }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
it("validates examples as JSON Schema 2020-12 and against real Velocity request parsers", async () => {
|
||||
const doc = await loadContract();
|
||||
for (const item of Object.values(doc.paths)) for (const operation of Object.values(item)) {
|
||||
for (const response of Object.values(operation.responses)) {
|
||||
for (const content of Object.values(response.content ?? {})) {
|
||||
for (const example of Object.values(content.examples ?? {})) assertSchema(content.schema, example.value);
|
||||
}
|
||||
}
|
||||
for (const content of Object.values(operation.requestBody?.content ?? {})) {
|
||||
for (const example of Object.values(content.examples ?? {})) assertSchema(content.schema, example.value);
|
||||
}
|
||||
}
|
||||
for (const [kind, schema] of [["access", velocityAccessRequestSchema], ["connection", velocityConnectionRequestSchema]] as const) {
|
||||
const content = doc.paths[`/api/velocity/${kind}`]?.post?.requestBody?.content["application/json"];
|
||||
assert(content);
|
||||
for (const example of Object.values(content.examples ?? {})) expect(schema.safeParse(example.value).success).toBe(true);
|
||||
}
|
||||
});
|
||||
it("contract checking rejects wrong status, media type and response data", async () => {
|
||||
await expect(assertResponse("/api/admin/whoami", "get", Response.json({ authenticationMethod: "bearer", subject: null, name: null, email: null }, { headers: { "cache-control": "no-store" } }))).rejects.toThrow();
|
||||
await expect(assertResponse("/api/admin/whoami", "get", new Response("{}", { headers: { "cache-control": "no-store" } }))).rejects.toThrow();
|
||||
await expect(assertResponse("/api/admin/whoami", "get", Response.json({}, { status: 418 }))).rejects.toThrow();
|
||||
const wrongStatus = Response.json({ type: "urn:error:unauthorized", title: "Unauthorized", status: 403 }, { status: 401, headers: { "content-type": "application/problem+json", "cache-control": "no-store", "www-authenticate": 'Bearer realm="admin-api"' } });
|
||||
await expect(assertResponse("/api/admin/whoami", "get", wrongStatus)).rejects.toThrow();
|
||||
});
|
||||
it("rejects an invalid OpenAPI document (not just parseable YAML)", async () => {
|
||||
await expect(SwaggerParser.validate({ openapi: "3.1.0", info: { title: "Broken", version: "1" }, paths: { "/broken": { get: { responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/Missing" } } } } } } } } } as never)).rejects.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildRconCommandHistory, normalizeRconHistoryFilters } from "./rcon-command-history";
|
||||
|
||||
const correlationId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function event(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
time: new Date("2026-08-14T01:00:00Z"),
|
||||
correlationId,
|
||||
data: {
|
||||
command: "say hello operators",
|
||||
serverId: "33333333-3333-4333-8333-333333333333",
|
||||
name: "Season 4",
|
||||
adminEmail: "admin@example.test",
|
||||
adminName: "Admin",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RCON command history", () => {
|
||||
it("normalizes bounded search filters and accepts only known servers", () => {
|
||||
expect(normalizeRconHistoryFilters({
|
||||
command: [" say hello ", "ignored"],
|
||||
admin: " admin@example.test ",
|
||||
server: "33333333-3333-4333-8333-333333333333",
|
||||
}, ["33333333-3333-4333-8333-333333333333"])).toEqual({
|
||||
command: "say hello",
|
||||
admin: "admin@example.test",
|
||||
serverId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(normalizeRconHistoryFilters({ server: "unknown" }, [])).toEqual({
|
||||
command: "",
|
||||
admin: "",
|
||||
serverId: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("pairs requested commands with their completion outcome without exposing responses", () => {
|
||||
const requested = event();
|
||||
const completed = event({
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
data: { success: false, reason: "timeout", durationMs: 5001 },
|
||||
});
|
||||
|
||||
expect(buildRconCommandHistory([requested], [completed])).toEqual([{
|
||||
eventId: requested.id,
|
||||
time: requested.time,
|
||||
command: "say hello operators",
|
||||
serverId: "33333333-3333-4333-8333-333333333333",
|
||||
serverName: "Season 4",
|
||||
adminEmail: "admin@example.test",
|
||||
adminName: "Admin",
|
||||
status: "failed",
|
||||
reason: "timeout",
|
||||
durationMs: 5001,
|
||||
}]);
|
||||
expect(JSON.stringify(buildRconCommandHistory([requested], [completed]))).not.toContain("response");
|
||||
});
|
||||
|
||||
it("marks a requested command pending when no completion event exists", () => {
|
||||
expect(buildRconCommandHistory([event()], [event({ correlationId: null })])[0]?.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
export const RCON_COMMAND_REQUESTED = "games.minecraft.account-manager.rcon.command.requested";
|
||||
export const RCON_COMMAND_COMPLETED = "games.minecraft.account-manager.rcon.command.completed";
|
||||
|
||||
export type RconHistoryEvent = {
|
||||
id: string;
|
||||
time: Date;
|
||||
correlationId: string | null;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RconCommandHistoryRow = {
|
||||
eventId: string;
|
||||
time: Date;
|
||||
command: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
adminEmail: string | null;
|
||||
adminName: string | null;
|
||||
status: "pending" | "succeeded" | "failed";
|
||||
reason: string | null;
|
||||
durationMs: number | null;
|
||||
};
|
||||
|
||||
type SearchParams = Record<string, string | string[] | undefined>;
|
||||
|
||||
function first(value: string | string[] | undefined) {
|
||||
return (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
|
||||
}
|
||||
|
||||
function text(data: Record<string, unknown>, key: string) {
|
||||
const value = data[key];
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
export function normalizeRconHistoryFilters(query: SearchParams, availableServerIds: string[]) {
|
||||
const requestedServerId = first(query.server);
|
||||
return {
|
||||
command: first(query.command).slice(0, 1024),
|
||||
admin: first(query.admin).slice(0, 320),
|
||||
serverId: availableServerIds.includes(requestedServerId) ? requestedServerId : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRconCommandHistory(
|
||||
requestedEvents: RconHistoryEvent[],
|
||||
completedEvents: RconHistoryEvent[],
|
||||
): RconCommandHistoryRow[] {
|
||||
const completions = new Map(completedEvents
|
||||
.filter((event) => event.correlationId)
|
||||
.map((event) => [event.correlationId, event]));
|
||||
|
||||
return requestedEvents.flatMap((event) => {
|
||||
const command = text(event.data, "command");
|
||||
const serverId = text(event.data, "serverId");
|
||||
const serverName = text(event.data, "name");
|
||||
if (!command || !serverId || !serverName) return [];
|
||||
|
||||
const completed = event.correlationId ? completions.get(event.correlationId) : undefined;
|
||||
const success = completed?.data.success;
|
||||
const duration = completed?.data.durationMs;
|
||||
return [{
|
||||
eventId: event.id,
|
||||
time: event.time,
|
||||
command,
|
||||
serverId,
|
||||
serverName,
|
||||
adminEmail: text(event.data, "adminEmail"),
|
||||
adminName: text(event.data, "adminName"),
|
||||
status: success === true ? "succeeded" as const : success === false ? "failed" as const : "pending" as const,
|
||||
reason: completed ? text(completed.data, "reason") : null,
|
||||
durationMs: typeof duration === "number" && Number.isFinite(duration) ? duration : null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "./rcon-credentials";
|
||||
|
||||
const key = randomBytes(32).toString("base64");
|
||||
const otherKey = randomBytes(32).toString("base64");
|
||||
const connectionId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
describe("RCON credential encryption", () => {
|
||||
it("round trips with randomized authenticated encryption", () => {
|
||||
const first = encryptRconPassword("super-secret", connectionId, key);
|
||||
const second = encryptRconPassword("super-secret", connectionId, key);
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first).not.toContain("super-secret");
|
||||
expect(decryptRconPassword(first, connectionId, key)).toBe("super-secret");
|
||||
expect(decryptRconPassword(second, connectionId, key)).toBe("super-secret");
|
||||
});
|
||||
|
||||
it("fails closed for tampering, another connection, or another key", () => {
|
||||
const encrypted = encryptRconPassword("super-secret", connectionId, key);
|
||||
expect(() => decryptRconPassword(`${encrypted}x`, connectionId, key)).toThrow("RCON credential unavailable");
|
||||
expect(() => decryptRconPassword(encrypted, "22222222-2222-4222-8222-222222222222", key)).toThrow("RCON credential unavailable");
|
||||
expect(() => decryptRconPassword(encrypted, connectionId, otherKey)).toThrow("RCON credential unavailable");
|
||||
});
|
||||
|
||||
it("requires an exact 32-byte deployment key", () => {
|
||||
expect(() => encryptRconPassword("secret", connectionId, "not-base64")).toThrow("RCON credential key is not configured");
|
||||
});
|
||||
|
||||
it("creates a keyed, versioned command digest", () => {
|
||||
const digest = rconCommandDigest("say secret message", key);
|
||||
expect(digest).toMatch(/^hmac-sha256:v1:[a-f0-9]{64}$/u);
|
||||
expect(digest).not.toContain("secret message");
|
||||
expect(rconCommandDigest("say secret message", key)).toBe(digest);
|
||||
expect(rconCommandDigest("say secret message", otherKey)).not.toBe(digest);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||||
|
||||
const VERSION = "v1";
|
||||
const KEY_BYTES = 32;
|
||||
const IV_BYTES = 12;
|
||||
|
||||
function explicitKey(encoded: string) {
|
||||
const key = Buffer.from(encoded, "base64");
|
||||
if (key.length !== KEY_BYTES || key.toString("base64").replace(/=+$/u, "") !== encoded.trim().replace(/=+$/u, "")) {
|
||||
throw new Error("invalid key");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function credentialKey(encoded: string | undefined, purpose: "credential" | "audit" = "credential") {
|
||||
if (encoded !== undefined) return explicitKey(encoded);
|
||||
const configured = purpose === "credential" ? process.env.RCON_CREDENTIAL_KEY : process.env.RCON_AUDIT_KEY;
|
||||
if (configured) return explicitKey(configured);
|
||||
const authSecret = process.env.AUTH_SECRET;
|
||||
if (!authSecret) throw new Error("missing key");
|
||||
return createHash("sha256").update(`minecraft-account-manager:rcon:${purpose}:v1\0${authSecret}`, "utf8").digest();
|
||||
}
|
||||
|
||||
function additionalData(connectionId: string) {
|
||||
return Buffer.from(`${VERSION}:${connectionId}`, "utf8");
|
||||
}
|
||||
|
||||
function decodeBase64url(value: string) {
|
||||
const decoded = Buffer.from(value, "base64url");
|
||||
if (decoded.toString("base64url") !== value) throw new Error("invalid envelope");
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export function encryptRconPassword(password: string, connectionId: string, encodedKey?: string) {
|
||||
let key: Buffer;
|
||||
try {
|
||||
key = credentialKey(encodedKey);
|
||||
} catch {
|
||||
throw new Error("RCON credential key is not configured");
|
||||
}
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
|
||||
cipher.setAAD(additionalData(connectionId));
|
||||
const ciphertext = Buffer.concat([cipher.update(password, "utf8"), cipher.final()]);
|
||||
return [VERSION, iv.toString("base64url"), cipher.getAuthTag().toString("base64url"), ciphertext.toString("base64url")].join(":");
|
||||
}
|
||||
|
||||
export function rconCommandDigest(command: string, encodedKey?: string) {
|
||||
let key: Buffer;
|
||||
try {
|
||||
key = credentialKey(encodedKey, "audit");
|
||||
} catch {
|
||||
throw new Error("RCON audit key is not configured");
|
||||
}
|
||||
return `hmac-sha256:v1:${createHmac("sha256", key).update(command, "utf8").digest("hex")}`;
|
||||
}
|
||||
|
||||
export function decryptRconPassword(envelope: string, connectionId: string, encodedKey?: string) {
|
||||
try {
|
||||
const key = credentialKey(encodedKey);
|
||||
const [version, ivValue, tagValue, ciphertextValue, extra] = envelope.split(":");
|
||||
if (version !== VERSION || !ivValue || !tagValue || !ciphertextValue || extra) throw new Error("invalid envelope");
|
||||
const iv = decodeBase64url(ivValue);
|
||||
const tag = decodeBase64url(tagValue);
|
||||
const ciphertext = decodeBase64url(ciphertextValue);
|
||||
if (iv.length !== IV_BYTES || tag.length !== 16) throw new Error("invalid envelope");
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
|
||||
decipher.setAAD(additionalData(connectionId));
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||
} catch {
|
||||
throw new Error("RCON credential unavailable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { executeRcon, testRconConnection, type RconTransport } from "./rcon-gateway";
|
||||
|
||||
function transport(overrides: Partial<RconTransport> = {}): RconTransport {
|
||||
return {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
send: vi.fn().mockResolvedValue("20 players online"),
|
||||
end: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RCON gateway", () => {
|
||||
it("authenticates a connection without sending a command", async () => {
|
||||
const client = transport();
|
||||
await expect(testRconConnection({ host: "season4", port: 25575, password: "secret" }, () => client)).resolves.toEqual({ ok: true });
|
||||
expect(client.connect).toHaveBeenCalledOnce();
|
||||
expect(client.send).not.toHaveBeenCalled();
|
||||
expect(client.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("executes one command and always closes the connection", async () => {
|
||||
const client = transport();
|
||||
await expect(executeRcon({ host: "season4", port: 25575, password: "secret" }, "list", () => client)).resolves.toEqual({
|
||||
ok: true,
|
||||
response: "20 players online",
|
||||
});
|
||||
expect(client.send).toHaveBeenCalledWith("list");
|
||||
expect(client.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns safe categorized failures and closes failed clients", async () => {
|
||||
const client = transport({ connect: vi.fn().mockRejectedValue(new Error("password secret rejected")) });
|
||||
await expect(testRconConnection({ host: "season4", port: 25575, password: "secret" }, () => client)).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "unavailable",
|
||||
});
|
||||
expect(client.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects concurrent work for the same connection", async () => {
|
||||
let release!: () => void;
|
||||
const pending = new Promise<string>((resolve) => { release = () => resolve("done"); });
|
||||
const firstClient = transport({ send: vi.fn().mockReturnValue(pending) });
|
||||
const first = executeRcon({ id: "server-one", host: "season4", port: 25575, password: "secret" }, "list", () => firstClient);
|
||||
await vi.waitFor(() => expect(firstClient.send).toHaveBeenCalled());
|
||||
|
||||
await expect(executeRcon({ id: "server-one", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "busy",
|
||||
});
|
||||
release();
|
||||
await first;
|
||||
});
|
||||
|
||||
it("bounds total concurrent work", async () => {
|
||||
let release!: () => void;
|
||||
const pendingResponse = new Promise<string>((resolve) => { release = () => resolve("done"); });
|
||||
const clients = Array.from({ length: 8 }, () => transport({ send: vi.fn().mockReturnValue(pendingResponse) }));
|
||||
const active = clients.map((client, index) => executeRcon({
|
||||
id: `server-${index}`,
|
||||
host: `season-${index}`,
|
||||
port: 25575,
|
||||
password: "secret",
|
||||
}, "list", () => client));
|
||||
await vi.waitFor(() => expect(clients.every((client) => vi.mocked(client.send).mock.calls.length === 1)).toBe(true));
|
||||
|
||||
await expect(executeRcon({ id: "server-ninth", host: "season-9", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "busy",
|
||||
});
|
||||
release();
|
||||
await Promise.all(active);
|
||||
});
|
||||
|
||||
it("times out the complete operation, aborts the socket, and releases the connection", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = transport({
|
||||
send: vi.fn().mockReturnValue(new Promise(() => undefined)),
|
||||
destroy: vi.fn(),
|
||||
});
|
||||
const pending = executeRcon({ id: "server-timeout", host: "season4", port: 25575, password: "secret" }, "list", () => client);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await expect(pending).resolves.toEqual({ ok: false, reason: "timeout" });
|
||||
expect(client.destroy).toHaveBeenCalledOnce();
|
||||
|
||||
await expect(executeRcon({ id: "server-timeout", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: true,
|
||||
response: "20 players online",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let stalled cleanup retain a connection lock", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = transport({ end: vi.fn().mockReturnValue(new Promise(() => undefined)) });
|
||||
const pending = executeRcon({ id: "server-cleanup", host: "season4", port: 25575, password: "secret" }, "list", () => client);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(pending).resolves.toEqual({ ok: true, response: "20 players online" });
|
||||
|
||||
await expect(executeRcon({ id: "server-cleanup", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: true,
|
||||
response: "20 players online",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Rcon } from "rcon-client";
|
||||
import { sanitizeRconOutput } from "./rcon-validation";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
const CLEANUP_TIMEOUT_MS = 1_000;
|
||||
const MAX_ACTIVE_CONNECTIONS = 8;
|
||||
const activeConnections = new Set<string>();
|
||||
|
||||
type Connection = { id?: string; host: string; port: number; password: string };
|
||||
type FailureReason = "busy" | "timeout" | "unavailable";
|
||||
|
||||
export interface RconTransport {
|
||||
connect(): Promise<unknown>;
|
||||
send(command: string): Promise<string>;
|
||||
end(): Promise<unknown>;
|
||||
destroy?(): void;
|
||||
}
|
||||
|
||||
type TransportFactory = (connection: Connection) => RconTransport;
|
||||
|
||||
class RconDeadlineError extends Error {}
|
||||
|
||||
async function deadline<T>(operation: Promise<T>, timeout: () => void, timeoutMs = TIMEOUT_MS) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
timeout();
|
||||
reject(new RconDeadlineError("RCON operation timed out"));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultTransport(connection: Connection): RconTransport {
|
||||
const client = new Rcon({
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
password: connection.password,
|
||||
timeout: TIMEOUT_MS,
|
||||
maxPending: 1,
|
||||
});
|
||||
return {
|
||||
connect: () => client.connect(),
|
||||
send: (command) => client.send(command),
|
||||
end: async () => {
|
||||
if (!client.socket) return;
|
||||
if (client.socket.connecting || !client.socket.writable) {
|
||||
client.socket.destroy();
|
||||
return;
|
||||
}
|
||||
await client.end();
|
||||
},
|
||||
destroy: () => client.socket?.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
function failure(error: unknown): { ok: false; reason: FailureReason } {
|
||||
return { ok: false, reason: error instanceof RconDeadlineError ? "timeout" : "unavailable" };
|
||||
}
|
||||
|
||||
async function withTransport<T>(
|
||||
connection: Connection,
|
||||
operation: (transport: RconTransport) => Promise<T>,
|
||||
factory: TransportFactory,
|
||||
): Promise<T | { ok: false; reason: FailureReason }> {
|
||||
const key = connection.id ?? `${connection.host}:${connection.port}`;
|
||||
if (activeConnections.has(key) || activeConnections.size >= MAX_ACTIVE_CONNECTIONS) {
|
||||
return { ok: false, reason: "busy" };
|
||||
}
|
||||
activeConnections.add(key);
|
||||
let transport: RconTransport | null = null;
|
||||
try {
|
||||
transport = factory(connection);
|
||||
return await deadline(operation(transport), () => transport?.destroy?.());
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
} finally {
|
||||
if (transport) {
|
||||
await deadline(transport.end(), () => transport?.destroy?.(), CLEANUP_TIMEOUT_MS).catch(() => undefined);
|
||||
}
|
||||
activeConnections.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function testRconConnection(connection: Connection, factory: TransportFactory = defaultTransport) {
|
||||
return withTransport(connection, async (transport) => {
|
||||
await transport.connect();
|
||||
return { ok: true as const };
|
||||
}, factory);
|
||||
}
|
||||
|
||||
export async function executeRcon(connection: Connection, command: string, factory: TransportFactory = defaultTransport) {
|
||||
return withTransport(connection, async (transport) => {
|
||||
await transport.connect();
|
||||
const response = await transport.send(command);
|
||||
return { ok: true as const, response: sanitizeRconOutput(response) };
|
||||
}, factory);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeRconOutput, validateRconCommand, validateRconConnection } from "./rcon-validation";
|
||||
|
||||
describe("RCON validation", () => {
|
||||
it("normalizes any valid DNS hostname and port without deployment configuration", () => {
|
||||
expect(validateRconConnection({
|
||||
name: " Season 4 ",
|
||||
host: "SEASON4.SOMC.SVC.CLUSTER.LOCAL",
|
||||
port: "25575",
|
||||
password: "correct horse battery staple",
|
||||
}, { passwordRequired: true })).toEqual({
|
||||
name: "Season 4",
|
||||
host: "season4.somc.svc.cluster.local",
|
||||
port: 25575,
|
||||
password: "correct horse battery staple",
|
||||
});
|
||||
|
||||
expect(validateRconConnection({
|
||||
name: "Creative",
|
||||
host: "creative.example.net",
|
||||
port: "43210",
|
||||
password: "secret",
|
||||
}, { passwordRequired: true })).toEqual({
|
||||
name: "Creative",
|
||||
host: "creative.example.net",
|
||||
port: 43210,
|
||||
password: "secret",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects IP literals and malformed DNS hostnames", () => {
|
||||
for (const host of ["10.0.0.1", "2001:db8::1", "season4.", "-season4.example", "season4..example"]) {
|
||||
expect(validateRconConnection({ name: "Server", host, port: "25575", password: "secret" }, {
|
||||
passwordRequired: true,
|
||||
})).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("allows a blank replacement password only while editing", () => {
|
||||
expect(validateRconConnection({ name: "Server", host: "season4.somc.svc.cluster.local", port: "25575", password: "" }, {
|
||||
passwordRequired: false,
|
||||
})?.password).toBeNull();
|
||||
expect(validateRconConnection({ name: "Server", host: "season4.somc.svc.cluster.local", port: "25575", password: "" }, {
|
||||
passwordRequired: true,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("bounds commands by UTF-8 bytes and rejects control characters", () => {
|
||||
expect(validateRconCommand(" list ")).toBe("list");
|
||||
expect(validateRconCommand("say first\nsay second")).toBeNull();
|
||||
expect(validateRconCommand("say \u001b[31mred")).toBeNull();
|
||||
expect(validateRconCommand(`say ${"😀".repeat(300)}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("strips output controls and bounds output by UTF-8 bytes", () => {
|
||||
expect(sanitizeRconOutput("ok\u001b[31mred\u0000done")).toBe("ok[31mreddone");
|
||||
expect(Buffer.byteLength(sanitizeRconOutput("😀".repeat(20_000)), "utf8")).toBeLessThanOrEqual(65_536);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
const HOST_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u;
|
||||
const MAX_COMMAND_BYTES = 1_024;
|
||||
const MAX_OUTPUT_BYTES = 65_536;
|
||||
|
||||
export type ValidRconConnection = {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
password: string | null;
|
||||
};
|
||||
|
||||
export function validateRconConnection(
|
||||
input: { name: unknown; host: unknown; port: unknown; password: unknown },
|
||||
options: { passwordRequired: boolean },
|
||||
): ValidRconConnection | null {
|
||||
const name = typeof input.name === "string" ? input.name.trim() : "";
|
||||
const host = typeof input.host === "string" ? input.host.trim().toLowerCase() : "";
|
||||
const portText = typeof input.port === "string" || typeof input.port === "number" ? String(input.port).trim() : "";
|
||||
const passwordText = typeof input.password === "string" ? input.password : "";
|
||||
const port = Number(portText);
|
||||
|
||||
if (!name || name.length > 100 || CONTROL_PATTERN.test(name)) return null;
|
||||
if (!host || host.endsWith(".") || isIP(host) !== 0 || !HOST_PATTERN.test(host)) return null;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) return null;
|
||||
if (passwordText.length > 512 || CONTROL_PATTERN.test(passwordText)) return null;
|
||||
if (options.passwordRequired && !passwordText) return null;
|
||||
|
||||
return { name, host, port, password: passwordText || null };
|
||||
}
|
||||
|
||||
export function validateRconCommand(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const command = value.trim();
|
||||
if (!command || CONTROL_PATTERN.test(command) || Buffer.byteLength(command, "utf8") > MAX_COMMAND_BYTES) return null;
|
||||
return command;
|
||||
}
|
||||
|
||||
export function sanitizeRconOutput(value: string) {
|
||||
const safe = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, "");
|
||||
if (Buffer.byteLength(safe, "utf8") <= MAX_OUTPUT_BYTES) return safe;
|
||||
let result = "";
|
||||
let bytes = 0;
|
||||
for (const character of safe) {
|
||||
const size = Buffer.byteLength(character, "utf8");
|
||||
if (bytes + size > MAX_OUTPUT_BYTES) break;
|
||||
result += character;
|
||||
bytes += size;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
okf_version: "0.1"
|
||||
---
|
||||
|
||||
# Minecraft Account Manager User Stories
|
||||
|
||||
This OKF bundle is the product record for implemented and proposed behavior. Story status and acceptance criteria are maintained alongside code changes.
|
||||
|
||||
## Player Experience
|
||||
|
||||
* [US-001 — Enter through Discord](us-001-discord-entry.md) - Direct portal visitors are guided to the configured Discord server.
|
||||
* [US-002 — Authenticate with a Discord magic link](us-002-discord-magic-link.md) - Discord users receive secure, private, single-use portal links.
|
||||
* [US-003 — Complete first-time onboarding](us-003-onboarding.md) - New users provide a name, connect a Java account, and confirm Discord identity.
|
||||
* [US-004 — Validate Minecraft accounts](us-004-minecraft-validation.md) - Java usernames resolve through Mojang with explicit unverified overrides.
|
||||
* [US-005 — Manage linked accounts](us-005-user-dashboard.md) - Users manage names, accounts, primaries, and security history.
|
||||
* [US-006 — Keep Discord nicknames synchronized](us-006-discord-nickname.md) - Names and primary accounts determine the guild nickname.
|
||||
|
||||
## Network and Game Access
|
||||
|
||||
* [US-007 — Enrich login IPs](us-007-ip-intelligence.md) - Portal and game login events include cached ProxyCheck location and network data.
|
||||
* [US-008 — Block anonymized account additions](us-008-vpn-blocking.md) - VPN, proxy, Tor, and unknown networks cannot add accounts.
|
||||
* [US-009 — Enforce registration at Velocity](us-009-velocity-admission.md) - The proxy admits positively identified registered Java accounts only.
|
||||
|
||||
## Administration and Governance
|
||||
|
||||
* [US-010 — Preserve an audit trail](us-010-audit-events.md) - Security and account activity is stored as CloudEvents-style events.
|
||||
* [US-011 — Authenticate administrators with SSO](us-011-admin-sso.md) - Keycloak and a required role protect the operator console.
|
||||
* [US-012 — Operate settings and audit views](us-012-admin-operations.md) - Administrators configure denial messaging and inspect events.
|
||||
* [US-013 — Manage users as an administrator](us-013-admin-user-management.md) - Administrators search users and manage names and Minecraft accounts.
|
||||
* [US-014 — Receive standardized API errors](us-014-problem-details.md) - Application APIs return RFC 9457 Problem Details.
|
||||
* [US-015 — Deploy and operate securely](us-015-platform-operations.md) - Operators have reproducible builds, migrations, credentials, and security controls.
|
||||
* [US-016 — Build and publish versioned releases](us-016-automated-releases.md) - Gitea Actions publish the Velocity JAR and web and migration images.
|
||||
* [US-017 — Control admission with groups](us-017-group-access.md) - Each user has one effective group that explicitly controls Minecraft access.
|
||||
* [US-018 — Monitor community account activity](us-018-admin-dashboard.md) - Administrators review daily users, confirmed connections, locations, denials, and risky networks.
|
||||
* [US-019 — Manage groups efficiently](us-019-admin-group-management.md) - Administrators manage group identity, policies, membership, and creation through focused confirmed workflows.
|
||||
* [US-020 — Schedule group access in UTC](us-020-scheduled-group-access.md) - Enabled groups may be restricted to recurring weekly UTC windows with static denial-message templates.
|
||||
|
||||
# Tracking
|
||||
|
||||
See the [design update log](log.md) for high-level changes. New work starts by creating or updating a story and its acceptance criteria.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Design Update Log
|
||||
|
||||
## 2026-08-07
|
||||
|
||||
* **Refine**: Select each admin map marker from the user's latest coordinate-bearing clear or hosting observation while keeping VPN, proxy, and Tor activity in the network-risk view.
|
||||
|
||||
## 2026-08-02
|
||||
|
||||
* **Extend**: Add recurring UTC group-access windows, browser-local schedule editing, and validated static denial-message variables.
|
||||
* **Refine**: Replace admin group cards with a policy table, confirmed modal workflows, editable group details, and reusable effective-member management.
|
||||
* **Add**: Provide Users-page group assignment, effective-group VPN/proxy/Tor exceptions for game admission, and independent configurable denial messages.
|
||||
* **Fix**: Treat malformed ProxyCheck proxy signals as unknown and classify every authenticated Velocity login before identity resolution.
|
||||
* **Fix**: Replace the dashboard's pre-enrichment network label with enriched company, ASN, connection type, Proxy/VPN status, and risk fields.
|
||||
* **Fix**: Group collocated map users into count-badged markers with complete nickname tooltips and per-user interactive-map links.
|
||||
* **Refine**: Replace registration counts with daily active users, collapse enriched VPN activity per user, add opt-in OpenStreetMap zoom, show managed nickname tooltips, and measure active Minecraft accounts from confirmed Velocity connections.
|
||||
* **Governance**: Require user review and explicit confirmation of relevant OKF story changes before future implementation work.
|
||||
|
||||
## 2026-08-01
|
||||
|
||||
* **Extend**: Plot each user's latest approximate location on an accessible, server-rendered Natural Earth world map in the operations dashboard.
|
||||
* **Refine**: Make group assignment exclusive with default fallback, add group deletion, automatically synchronize Discord nicknames with status notices, expose filterable event details, add an SSR operations dashboard, and improve accessibility.
|
||||
* **Extend**: Add SoMC Portal branding, live Discord identity details, admin guild configuration visibility, and fail-closed group-based Minecraft admission.
|
||||
* **Refine**: Group repeated access networks, confirm linked Discord nickname changes before mutation, and add DMG Games sponsorship attribution.
|
||||
* **Extend**: Add shared Pino logging with credential redaction and actionable web and Discord runtime diagnostics.
|
||||
* **Fix**: Build magic-link redirects from the configured public portal URL instead of the reverse proxy's internal request origin.
|
||||
* **Verify**: Confirmed `v1.1.1` left all pre-existing `latest` digests unchanged while publishing versioned artifacts.
|
||||
* **Refine**: Removed mutable `latest` publication so all deployable artifacts use explicit semantic versions.
|
||||
* **Verify**: Confirmed the `v1.1.0` Discord bot image and matching web, migration, and Velocity artifacts.
|
||||
* **Extend**: Added a releasable Discord bot image and a dependency-free web health endpoint for Kubernetes deployment.
|
||||
* **Verify**: Confirmed the initial `v1.0.0` release, public Velocity JAR, and versioned and `latest` web and migration image manifests.
|
||||
* **Create**: Added Gitea CI and semantic-release pipelines for downloadable Velocity JARs and versioned web and migration images.
|
||||
* **Document**: Added container deployment order, artifact names, and required repository secrets.
|
||||
* **Refine**: Corrected the Velocity Java and Gradle namespace to the repository owner's `games.dmg` reverse domain.
|
||||
* **Create**: Established the OKF v0.1 [user-story index](index.md).
|
||||
* **Document**: Captured the implemented player portal, Discord authentication, onboarding, account management, network intelligence, Velocity admission, auditing, administration, API error, and operational stories.
|
||||
* **Governance**: Added repository agent guidance and automated OKF validation for story-driven development.
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Enter the account portal through Discord
|
||||
description: Direct visitors are guided to the configured Discord community and its account commands.
|
||||
tags: [player, portal, discord, onboarding]
|
||||
timestamp: 2026-08-01T22:34:31Z
|
||||
story_id: US-001
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a prospective player, I want the portal to direct me to the community Discord, so that I can begin registration through the trusted entry point.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Given an unauthenticated visitor, when they open the portal, then they are told to run `/register` or `/account` in Discord.
|
||||
- [x] Given a configured invite URL, when the visitor selects the join action, then the Discord invite opens in a new browser context.
|
||||
- [x] Given a configured guild ID, when the visitor selects the app action, then a `discord://` guild link is opened.
|
||||
- [x] Given an unauthenticated protected-page request, when authorization fails, then the visitor returns to the portal with prominent Discord instructions.
|
||||
- [x] Every portal page credits Social Minecraft sponsorship by DMG Games and links to `https://dmg.games`.
|
||||
- [x] Portal branding uses the SoMC Portal name and a dedicated favicon.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/page.tsx`](../apps/web/src/app/page.tsx)
|
||||
- [`apps/web/src/lib/auth/user-session.ts`](../apps/web/src/lib/auth/user-session.ts)
|
||||
- [`apps/web/src/components/site-footer.tsx`](../apps/web/src/components/site-footer.tsx)
|
||||
- [`apps/web/src/app/icon.svg`](../apps/web/src/app/icon.svg)
|
||||
- Configuration: `DISCORD_GUILD_ID`, `DISCORD_INVITE_URL`
|
||||
|
||||
# Validation
|
||||
|
||||
Covered by the Next.js production build and protected-route session checks.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Discord magic-link authentication](us-002-discord-magic-link.md)
|
||||
- [First-time onboarding](us-003-onboarding.md)
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Authenticate with a Discord magic link
|
||||
description: Discord users receive private single-use links that establish secure portal sessions.
|
||||
tags: [player, discord, authentication, security]
|
||||
timestamp: 2026-08-01T20:43:46Z
|
||||
story_id: US-002
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a Discord community member, I want `/register` and `/account` to issue a private sign-in link, so that I can access the portal without creating another password.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Given the configured guild, when a user runs `/register` or `/account`, then the bot responds ephemerally with a private link.
|
||||
- [x] Given a generated link, then the raw login token is never stored in PostgreSQL.
|
||||
- [x] Given a login token, then it expires after ten minutes and can be consumed only once.
|
||||
- [x] Given repeated link requests, then requests are rate limited per Discord user and older active links are invalidated.
|
||||
- [x] Given a valid link, when it is consumed, then the Discord user is created or refreshed and a secure seven-day session is established.
|
||||
- [x] Given a magic-link result behind a reverse proxy, then the browser is redirected through the configured public application URL rather than an internal container address.
|
||||
- [x] Given an invalid, expired, or consumed link, then the user sees a safe recovery page instructing them to request another link.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/discord-bot/src/index.ts`](../apps/discord-bot/src/index.ts)
|
||||
- [`packages/auth/src/index.ts`](../packages/auth/src/index.ts)
|
||||
- [`packages/database/src/auth-repository.ts`](../packages/database/src/auth-repository.ts)
|
||||
- [`apps/web/src/app/auth/discord/route.ts`](../apps/web/src/app/auth/discord/route.ts)
|
||||
- [`apps/web/src/lib/application-url.ts`](../apps/web/src/lib/application-url.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`packages/auth/test/magic-link.test.ts`](../packages/auth/test/magic-link.test.ts)
|
||||
- [`apps/web/src/lib/application-url.test.ts`](../apps/web/src/lib/application-url.test.ts)
|
||||
- Discord command and authentication workspaces pass TypeScript validation.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Enter through Discord](us-001-discord-entry.md)
|
||||
- [Preserve an audit trail](us-010-audit-events.md)
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Complete first-time onboarding
|
||||
description: New users establish their preferred identity and first Minecraft account.
|
||||
tags: [player, onboarding, minecraft, discord]
|
||||
timestamp: 2026-08-01T18:43:58Z
|
||||
story_id: US-003
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a newly authenticated player, I want a guided setup flow, so that my preferred name, Minecraft identity, and Discord nickname are configured correctly.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Given a new Discord user, when they enter the portal, then they receive a personalized welcome.
|
||||
- [x] Given the first onboarding step, when the user enters a valid preferred name, then it is stored for their profile.
|
||||
- [x] Given the Minecraft step, when a valid Java username is submitted from an allowed network, then it is verified and added as primary.
|
||||
- [x] Given an unverifiable but syntactically valid username, then the user must explicitly confirm before continuing.
|
||||
- [x] Given a name and primary account, then the expected Discord nickname is previewed before any guild update.
|
||||
- [x] Given confirmation and a successful Discord update, then onboarding is marked complete and the dashboard opens.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/welcome/page.tsx`](../apps/web/src/app/welcome/page.tsx)
|
||||
- [`apps/web/src/app/welcome/minecraft/page.tsx`](../apps/web/src/app/welcome/minecraft/page.tsx)
|
||||
- [`apps/web/src/app/welcome/discord/page.tsx`](../apps/web/src/app/welcome/discord/page.tsx)
|
||||
- [`apps/web/src/app/welcome/actions.ts`](../apps/web/src/app/welcome/actions.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
Onboarding routes are protected by database-backed sessions and included in production route generation.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
|
||||
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
|
||||
- [Block anonymized account additions](us-008-vpn-blocking.md)
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Validate Minecraft accounts
|
||||
description: Java Edition usernames resolve to canonical Mojang identities with controlled override behavior.
|
||||
tags: [player, minecraft, mojang, identity]
|
||||
timestamp: 2026-08-01T18:43:58Z
|
||||
story_id: US-004
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a player, I want submitted Minecraft usernames checked against Mojang, so that the server can identify my online-mode Java account reliably.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Given a syntactically valid username, when it is submitted, then validation occurs server-side against the fixed Mojang endpoint.
|
||||
- [x] Given a Mojang match, then the canonical username and compact UUID are stored.
|
||||
- [x] Given no Mojang match, then the user or administrator must explicitly confirm an unverified override.
|
||||
- [x] Given malformed input, then it cannot be stored even through an override.
|
||||
- [x] Given an active UUID or case-insensitive username already registered, then another active registration is rejected.
|
||||
- [x] Given a later online-mode game login for an unverified account, then its UUID can be safely backfilled after username matching.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`packages/minecraft/src/index.ts`](../packages/minecraft/src/index.ts)
|
||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||
- User and administrator account actions under [`apps/web/src/app`](../apps/web/src/app)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts)
|
||||
- Database partial unique indexes preserve active identity invariants.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [First-time onboarding](us-003-onboarding.md)
|
||||
- [Velocity game admission](us-009-velocity-admission.md)
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Manage linked accounts from the dashboard
|
||||
description: Authenticated users maintain their profile and active Java Edition accounts.
|
||||
tags: [player, dashboard, minecraft, profile]
|
||||
timestamp: 2026-08-01T23:10:59Z
|
||||
story_id: US-005
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a registered player, I want to manage my profile and linked Minecraft accounts, so that my whitelist identity remains current.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Given an authenticated user, then only their own profile, accounts, and IP observations are visible and mutable.
|
||||
- [x] The user can update their preferred name.
|
||||
- [x] The user can add Mojang-verified or explicitly confirmed accounts from an allowed network.
|
||||
- [x] The user can soft-remove an active account.
|
||||
- [x] The user can choose exactly one active primary account.
|
||||
- [x] Removing a primary account promotes another active account when one exists.
|
||||
- [x] Name, primary, and account-removal changes automatically synchronize the expected Discord nickname and report the result.
|
||||
- [x] The dashboard shows recent portal and game IP observations with classification and available location.
|
||||
- [x] The dashboard shows the user's Discord display name, username, guild nickname, and immutable Discord ID.
|
||||
- [x] The dashboard shows the single effective access group and whether it grants Minecraft access.
|
||||
- [x] The user can revoke the current session by signing out.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/account/page.tsx`](../apps/web/src/app/account/page.tsx)
|
||||
- [`apps/web/src/app/account/actions.ts`](../apps/web/src/app/account/actions.ts)
|
||||
- [`apps/web/src/app/auth/actions.ts`](../apps/web/src/app/auth/actions.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
Server actions verify the current session and constrain every account lookup by the authenticated user ID. Nickname result announcements are covered by [`apps/web/src/components/nickname-notice.test.tsx`](../apps/web/src/components/nickname-notice.test.tsx).
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
|
||||
- [Enrich login IPs](us-007-ip-intelligence.md)
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Keep Discord nicknames synchronized
|
||||
description: Preferred names and primary Minecraft usernames determine community guild nicknames.
|
||||
tags: [player, admin, discord, identity]
|
||||
timestamp: 2026-08-01T23:10:59Z
|
||||
story_id: US-006
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a community member, I want my Discord nickname to reflect my preferred name and primary Minecraft account, so that other players can identify me consistently.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Given a preferred name and primary account, then the nickname format is `First name (MinecraftUsername)`.
|
||||
- [x] Given Discord's 32-character limit, then the preferred-name portion is shortened while preserving the Minecraft username.
|
||||
- [x] Given no remaining Minecraft account, then synchronization uses `First name (TBD)`.
|
||||
- [x] User name, first-account, primary, and account-removal changes synchronize the nickname automatically without a second confirmation step.
|
||||
- [x] Successful synchronization shows the exact new nickname in a dismissible status notice.
|
||||
- [x] Discord failures show an assertive error notice without falsely claiming synchronization completed.
|
||||
- [x] Administrator name, primary, and primary-removal operations synchronize the nickname automatically.
|
||||
- [x] A protected administrative retry action can synchronize the current desired nickname.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`packages/minecraft/src/index.ts`](../packages/minecraft/src/index.ts)
|
||||
- [`apps/web/src/app/account/actions.ts`](../apps/web/src/app/account/actions.ts)
|
||||
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
|
||||
- [`apps/web/src/components/nickname-notice.tsx`](../apps/web/src/components/nickname-notice.tsx)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`packages/minecraft/test/discord.test.ts`](../packages/minecraft/test/discord.test.ts)
|
||||
- Nickname length and fallback behavior are covered in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts).
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Manage linked accounts](us-005-user-dashboard.md)
|
||||
- [Administer users](us-013-admin-user-management.md)
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Enrich portal and game login IPs
|
||||
description: Login audit events include cached approximate location and network intelligence from ProxyCheck.io.
|
||||
tags: [security, network, audit, proxycheck]
|
||||
timestamp: 2026-08-02T14:12:43Z
|
||||
story_id: US-007
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an operator, I want portal and registered game logins enriched with network context, so that suspicious access can be investigated.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Given a public login IP, then ProxyCheck can provide city, region, country, coordinates, timezone, ASN, provider, risk, and anonymity classification.
|
||||
- [x] Results are cached in PostgreSQL for 48 hours by default.
|
||||
- [x] Provider failures are cached briefly and do not deny portal or registered game login.
|
||||
- [x] Private, loopback, reserved, documentation, and mapped-private addresses are never sent to ProxyCheck.
|
||||
- [x] Forwarded web IP headers are ignored unless trusted-proxy handling is explicitly enabled.
|
||||
- [x] Every authenticated Velocity login request uses the cached ProxyCheck path before identity resolution, preventing account-creation races from bypassing network policy.
|
||||
- [x] Login events and IP observations retain the available classification and approximate location.
|
||||
- [x] Users and administrators can see available location and classification in audit views.
|
||||
- [x] Repeated access observations are summarized by IPv4 /24 or IPv6 /64 network with counts, sources, and latest activity.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`packages/network/src/index.ts`](../packages/network/src/index.ts)
|
||||
- [`apps/web/src/lib/ip-intelligence.ts`](../apps/web/src/lib/ip-intelligence.ts)
|
||||
- [`apps/web/src/app/auth/discord/route.ts`](../apps/web/src/app/auth/discord/route.ts)
|
||||
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`packages/network/test/proxycheck.test.ts`](../packages/network/test/proxycheck.test.ts)
|
||||
- [`packages/network/test/client-ip.test.ts`](../packages/network/test/client-ip.test.ts)
|
||||
- [`packages/network/test/address-groups.test.ts`](../packages/network/test/address-groups.test.ts)
|
||||
- [`apps/web/src/lib/access-address-groups.test.ts`](../apps/web/src/lib/access-address-groups.test.ts)
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Block anonymized additions](us-008-vpn-blocking.md)
|
||||
- [Preserve an audit trail](us-010-audit-events.md)
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Block account additions from anonymized networks
|
||||
description: User Minecraft-account additions fail closed for VPN, proxy, Tor, or unknown IP classifications.
|
||||
tags: [security, vpn, proxy, minecraft]
|
||||
timestamp: 2026-08-02T14:12:43Z
|
||||
story_id: US-008
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an operator, I want account additions blocked from anonymized networks, so that whitelist identities are established from attributable connections.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] VPN, proxy, and Tor classifications block user account additions.
|
||||
- [x] Unknown or unavailable classification blocks additions rather than failing open.
|
||||
- [x] Hosting-provider ranges can be blocked through deployment configuration.
|
||||
- [x] Normal portal use and game login are not denied solely because intelligence is unavailable.
|
||||
- [x] Blocked users receive a clear recovery message without provider internals.
|
||||
- [x] Blocked and classification-unavailable attempts create distinct audit events with safe intelligence details.
|
||||
- [x] Administrative account additions remain available as an authorized recovery path.
|
||||
- [x] Administrators see enriched risky-network observations collapsed to one latest summary per user.
|
||||
- [x] Game admission enforces confirmed VPN, proxy, and Tor classifications according to the user's effective-group exception policy.
|
||||
- [x] Account-addition blocking remains unchanged and independent from the game-admission exception.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/lib/ip-intelligence.ts`](../apps/web/src/lib/ip-intelligence.ts)
|
||||
- [`apps/web/src/app/welcome/actions.ts`](../apps/web/src/app/welcome/actions.ts)
|
||||
- [`apps/web/src/app/account/actions.ts`](../apps/web/src/app/account/actions.ts)
|
||||
- Configuration: `PROXYCHECK_API_KEY`, `BLOCK_HOSTING_IPS`, `TRUST_PROXY`
|
||||
|
||||
# Validation
|
||||
|
||||
The fail-closed classification policy and provider mappings are covered by [`packages/network/test/proxycheck.test.ts`](../packages/network/test/proxycheck.test.ts).
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Enrich login IPs](us-007-ip-intelligence.md)
|
||||
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Enforce registration at the Velocity proxy
|
||||
description: Online-mode Java connections are admitted only after a fail-closed account-manager decision.
|
||||
tags: [minecraft, velocity, whitelist, security]
|
||||
timestamp: 2026-08-02T14:12:43Z
|
||||
story_id: US-009
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a registered player, I want the Velocity proxy to recognize my approved Java account, so that I can join while unknown identities are rejected.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] The plugin sends request ID, server ID, online-mode UUID, username, IP, and occurrence time.
|
||||
- [x] Every request uses a high-entropy per-server bearer credential stored only as a hash by the service.
|
||||
- [x] Requests outside the 45-second clock window are rejected.
|
||||
- [x] Database-unique request IDs reject cross-instance replay attempts.
|
||||
- [x] UUID matching is attempted before username fallback.
|
||||
- [x] Username fallback applies only when the stored account has no UUID.
|
||||
- [x] Successful fallback backfills UUID and canonical username.
|
||||
- [x] Changed usernames are persisted and audited.
|
||||
- [x] Registered players are allowed only when their single effective group has access enabled; explicit assignments override the default group.
|
||||
- [x] Disabled group access overrides every schedule; enabled groups with weekly windows admit logins only during an active UTC window.
|
||||
- [x] Schedule policy is checked before VPN/proxy/Tor policy and is enforced only at login.
|
||||
- [x] Unknown players, group-disabled players, API failures, malformed responses, and unauthorized requests fail closed with registration guidance.
|
||||
- [x] The plugin records the real Velocity connection IP and supports Java Edition online mode only.
|
||||
- [x] After admission, Velocity reports `PostLoginEvent` as best-effort authenticated telemetry without disconnecting an admitted player when reporting fails.
|
||||
- [x] Confirmed-connection reports use fresh timestamps and database replay protection.
|
||||
- [x] Group-disabled and VPN/proxy/Tor-policy denials return distinct operator-configured messages.
|
||||
- [x] Schedule denials return the configured static template with the effective group, player, and next UTC window.
|
||||
- [x] The default anonymized-network message directs the player to contact a host for an exception.
|
||||
- [x] API failures, malformed responses, and unauthorized requests retain fail-closed plugin fallback behavior.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`plugins/velocity`](../plugins/velocity)
|
||||
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
||||
- [`apps/web/src/app/api/velocity/connection/route.ts`](../apps/web/src/app/api/velocity/connection/route.ts)
|
||||
- [`packages/contracts/src/index.ts`](../packages/contracts/src/index.ts)
|
||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`plugins/velocity/src/test/java/games/dmg/accountmanager/AccountManagerClientTest.java`](../plugins/velocity/src/test/java/games/dmg/accountmanager/AccountManagerClientTest.java)
|
||||
- Shared request and response contracts are covered by [`packages/contracts/test/contracts.test.ts`](../packages/contracts/test/contracts.test.ts).
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
|
||||
- [Standardize API errors](us-014-problem-details.md)
|
||||
- [Control Minecraft admission with groups](us-017-group-access.md)
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Preserve a CloudEvents-style audit trail
|
||||
description: Authentication, UI, account, Discord, network, and game actions create searchable immutable-style events.
|
||||
tags: [audit, cloudevents, security, events]
|
||||
timestamp: 2026-08-02T00:12:32Z
|
||||
story_id: US-010
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an operator, I want security and identity activity recorded consistently, so that incidents and account changes can be reconstructed and later published to Kafka.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Events preserve CloudEvents-style ID, specification version, source, type, subject, time, content type, and JSON data.
|
||||
- [x] Events can include user actor, IP address, and correlation ID.
|
||||
- [x] Portal access, magic-link creation and consumption, account changes, nickname changes, VPN blocks, game decisions, and confirmed proxy connections are recorded.
|
||||
- [x] Username changes learned from Velocity create their own event.
|
||||
- [x] Administrative actions include the acting SSO identity in event data.
|
||||
- [x] Events can be filtered by operator-friendly view and selected event types globally and from an individual user view.
|
||||
- [x] Every listed event links to a detail page showing its complete CloudEvents envelope and formatted JSON data.
|
||||
- [x] `published_at` reserves an outbox path for future Kafka publishing.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`packages/database/src/events.ts`](../packages/database/src/events.ts)
|
||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||
- [`apps/web/src/lib/audit.ts`](../apps/web/src/lib/audit.ts)
|
||||
- [`apps/web/src/app/admin/(console)/events/page.tsx`](../apps/web/src/app/admin/%28console%29/events/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/events/[eventId]/page.tsx`](../apps/web/src/app/admin/%28console%29/events/%5BeventId%5D/page.tsx)
|
||||
|
||||
# Validation
|
||||
|
||||
The shared CloudEvent contract is covered by [`packages/contracts/test/contracts.test.ts`](../packages/contracts/test/contracts.test.ts), and event-producing routes pass full type and production-build validation.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Enrich login IPs](us-007-ip-intelligence.md)
|
||||
- [Administer users](us-013-admin-user-management.md)
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Authenticate administrators with Keycloak SSO
|
||||
description: The operator console requires a Keycloak identity with the configured administrator role.
|
||||
tags: [admin, keycloak, oidc, authentication]
|
||||
timestamp: 2026-08-01T18:43:58Z
|
||||
story_id: US-011
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an administrator, I want to authenticate through organizational SSO, so that privileged operations use centrally managed identities and roles.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Admin authentication uses Keycloak OpenID Connect authorization code flow.
|
||||
- [x] Sign-in is denied when the configured required role is absent.
|
||||
- [x] Realm and configured-client roles are extracted from fresh Keycloak tokens.
|
||||
- [x] Admin console layouts redirect unauthenticated or unauthorized users to the admin login page.
|
||||
- [x] Every privileged server action independently rechecks the admin session and role.
|
||||
- [x] Admin sessions use signed JWT behavior managed by NextAuth.
|
||||
- [x] Administrators can sign out and return to the restricted login page.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/lib/auth/admin-auth.ts`](../apps/web/src/lib/auth/admin-auth.ts)
|
||||
- [`apps/web/src/lib/auth/require-admin.ts`](../apps/web/src/lib/auth/require-admin.ts)
|
||||
- [`apps/web/src/app/admin`](../apps/web/src/app/admin)
|
||||
- [`docs/admin-oidc-keycloak-setup.md`](../docs/admin-oidc-keycloak-setup.md)
|
||||
|
||||
# Validation
|
||||
|
||||
OIDC role extraction is covered by [`packages/auth/test/oidc-roles.test.ts`](../packages/auth/test/oidc-roles.test.ts).
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Operate settings and audit views](us-012-admin-operations.md)
|
||||
- [Administer users](us-013-admin-user-management.md)
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Operate settings and audit views
|
||||
description: Authorized administrators control server messaging and investigate recent platform events.
|
||||
tags: [admin, settings, audit, operations]
|
||||
timestamp: 2026-08-02T14:12:43Z
|
||||
story_id: US-012
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an administrator, I want operational settings and audit visibility, so that I can manage player guidance and investigate activity.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] The admin console shows the deployment-managed Discord guild ID and linked invite URL.
|
||||
- [x] An authorized administrator can update the denied-player registration message.
|
||||
- [x] Settings actions validate message length server-side.
|
||||
- [x] Administrators can browse the latest 100 events.
|
||||
- [x] Event views show type, subject, IP, classification, and approximate location when available.
|
||||
- [x] Admin console access itself creates an audit event with the SSO identity.
|
||||
- [x] Settings, users, and events are linked from the shared admin navigation.
|
||||
- [x] Administrators can independently configure registration-required, group-access-disabled, schedule-denied, and VPN/proxy/Tor-denied game-message templates.
|
||||
- [x] Registration, group, and network templates accept only `{player}` and `{group}`; schedule templates also accept `{next_start}` and `{next_end}`.
|
||||
- [x] Every template is validated server-side and has a safe default.
|
||||
- [x] Admission-message changes are audited with the administrator identity without logging credentials.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/actions.ts`](../apps/web/src/app/admin/%28console%29/actions.ts)
|
||||
- [`apps/web/src/lib/admission-settings.ts`](../apps/web/src/lib/admission-settings.ts)
|
||||
- [`packages/database/drizzle/0004_zippy_silver_centurion.sql`](../packages/database/drizzle/0004_zippy_silver_centurion.sql)
|
||||
- [`packages/database/drizzle/0005_young_vertigo.sql`](../packages/database/drizzle/0005_young_vertigo.sql)
|
||||
- [`apps/web/src/app/admin/(console)/events/page.tsx`](../apps/web/src/app/admin/%28console%29/events/page.tsx)
|
||||
|
||||
# Validation
|
||||
|
||||
Admin routes are dynamic, role-protected, linted, and included in every production build.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Administrator SSO](us-011-admin-sso.md)
|
||||
- [Preserve an audit trail](us-010-audit-events.md)
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Manage users as an administrator
|
||||
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
|
||||
tags: [admin, users, minecraft, discord]
|
||||
timestamp: 2026-08-02T15:03:59Z
|
||||
story_id: US-013
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an administrator, I want to manage a user's identity and Minecraft accounts, so that support issues can be resolved without direct database access.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Administrators can search by preferred name, Discord username or ID, Minecraft username, or UUID.
|
||||
- [x] Search results show onboarding state, primary username, and active account count.
|
||||
- [x] A user detail view shows Discord display name, username, guild nickname, immutable ID, active accounts, groups, recent events, and recent IP observations.
|
||||
- [x] Administrators can update the preferred name and synchronize Discord.
|
||||
- [x] Administrators can add Mojang-verified accounts or explicitly override an unverified username.
|
||||
- [x] Administrators can remove an account only after a visible confirmation step.
|
||||
- [x] Removing a primary account selects a replacement or falls back to the preferred-name nickname.
|
||||
- [x] Administrators can set a new primary account and automatically update Discord.
|
||||
- [x] Every action rechecks role and account ownership and records the acting administrator.
|
||||
- [x] Discord failures do not falsely persist the requested name, primary, or removal change.
|
||||
- [x] Each row in the administrator user registry shows the user's effective group in an accessible dropdown.
|
||||
- [x] Selecting a group immediately applies the assignment; selecting `everyone` removes the explicit assignment.
|
||||
- [x] Group changes preserve the active user search and show accessible success or error feedback.
|
||||
- [x] Registry assignment changes revalidate administrator authorization, user existence, and group existence, and audit the previous and new effective groups.
|
||||
- [x] User rows and group-assignment controls are reusable between the Users registry and group-member details.
|
||||
- [x] Group details show only the group's effective members with identity, Discord, primary-account, account-count, status, and group columns.
|
||||
- [x] Changing a user's group requires modal confirmation and choosing `everyone` removes the explicit assignment.
|
||||
- [x] Moving a member to another group removes that user from the current effective-member list after confirmation.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/admin/(console)/users/page.tsx`](../apps/web/src/app/admin/%28console%29/users/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/users/[userId]/page.tsx`](../apps/web/src/app/admin/%28console%29/users/%5BuserId%5D/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
|
||||
- [`apps/web/src/components/user-group-select.tsx`](../apps/web/src/components/user-group-select.tsx)
|
||||
- [`apps/web/src/components/admin-user-table.tsx`](../apps/web/src/components/admin-user-table.tsx)
|
||||
|
||||
# Validation
|
||||
|
||||
Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts). Privileged routes pass TypeScript, lint, Semgrep, and production build checks.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Administrator SSO](us-011-admin-sso.md)
|
||||
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
|
||||
- [Control Minecraft admission with groups](us-017-group-access.md)
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Receive standardized API errors
|
||||
description: Application-owned HTTP APIs expose RFC 9457 Problem Details matching game-ingest-server conventions.
|
||||
tags: [api, errors, rfc9457, contracts]
|
||||
timestamp: 2026-08-01T18:43:58Z
|
||||
story_id: US-014
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an API consumer, I want errors returned as standardized Problem Details, so that failures can be handled consistently across game services.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Error responses use `application/problem+json`.
|
||||
- [x] Responses require `type`, `title`, and `status` and optionally include `detail`, `instance`, and `extensions`.
|
||||
- [x] Invalid Velocity payloads include machine-readable Zod issues under `extensions.issues`.
|
||||
- [x] Missing credentials, expired requests, replays, unsupported media, unsupported methods, unknown routes, and service failures have stable `urn:error:*` types.
|
||||
- [x] Unexpected application-owned Velocity errors are converted to safe `503` problems without internal details.
|
||||
- [x] A normal whitelist denial remains a successful `200` authorization decision.
|
||||
- [x] Browser form redirects remain accessible HTML flows and OAuth protocol responses remain owned by NextAuth.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`packages/contracts/src/index.ts`](../packages/contracts/src/index.ts)
|
||||
- [`apps/web/src/lib/problem-response.ts`](../apps/web/src/lib/problem-response.ts)
|
||||
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
||||
- [`docs/api-errors.md`](../docs/api-errors.md)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`packages/contracts/test/problem-details.test.ts`](../packages/contracts/test/problem-details.test.ts)
|
||||
- [`apps/web/src/app/api/velocity/access/route.test.ts`](../apps/web/src/app/api/velocity/access/route.test.ts)
|
||||
- Unknown-route and response-helper tests in the web workspace.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Velocity game admission](us-009-velocity-admission.md)
|
||||
- [Deploy and operate securely](us-015-platform-operations.md)
|
||||
|
||||
# Citations
|
||||
|
||||
[1] [RFC 9457 — Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457)
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Deploy and operate the platform securely
|
||||
description: Operators have repeatable builds, migrations, credential provisioning, configuration, and security checks.
|
||||
tags: [operations, security, database, deployment]
|
||||
timestamp: 2026-08-01T23:10:59Z
|
||||
story_id: US-015
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a platform operator, I want reproducible deployment and security controls, so that the portal, bot, database, and proxy can be operated safely.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] The repository is an npm TypeScript workspace with separate web, bot, contract, database, network, and Minecraft modules.
|
||||
- [x] PostgreSQL is available through Docker Compose for local use.
|
||||
- [x] Drizzle changes use generated, versioned migrations rather than schema push.
|
||||
- [x] Velocity credentials can be provisioned or rotated with a one-time-displayed token stored only as a hash.
|
||||
- [x] The Velocity Gradle wrapper produces a tested shaded JAR.
|
||||
- [x] Environment examples document database, Keycloak, Discord, trusted proxy, and ProxyCheck settings without secrets.
|
||||
- [x] The web application sets CSP, framing, MIME, referrer, and permissions headers.
|
||||
- [x] Database-backed user and administrator pages render as dynamic React Server Components with server-side data access.
|
||||
- [x] Core pages provide keyboard focus indication, a skip link, labelled controls, table semantics, live status messaging, sufficient text contrast, and reduced-motion support.
|
||||
- [x] The web runtime provides a dependency-free health endpoint for orchestration probes.
|
||||
- [x] Web and Discord bot runtimes emit structured Pino logs with credential-field redaction and safe operational context.
|
||||
- [x] npm dependency audit and Semgrep security review complete without findings at the last verified change.
|
||||
- [x] Architecture, Keycloak, API error, security, bot, and Velocity operating documentation is available.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`package.json`](../package.json)
|
||||
- [`compose.yml`](../compose.yml)
|
||||
- [`packages/database/drizzle`](../packages/database/drizzle)
|
||||
- [`packages/database/scripts/create-plugin-credential.ts`](../packages/database/scripts/create-plugin-credential.ts)
|
||||
- [`plugins/velocity/build.gradle.kts`](../plugins/velocity/build.gradle.kts)
|
||||
- [`apps/web/next.config.ts`](../apps/web/next.config.ts)
|
||||
- [`packages/logging/src/index.ts`](../packages/logging/src/index.ts)
|
||||
- [`docs/accessibility.md`](../docs/accessibility.md)
|
||||
|
||||
# Validation
|
||||
|
||||
Use `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, `npm run velocity:build`, `npm audit`, and `npm run design:validate`. Structured logging redaction is covered by [`packages/logging/test/logger.test.ts`](../packages/logging/test/logger.test.ts).
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Administrator SSO](us-011-admin-sso.md)
|
||||
- [Standardize API errors](us-014-problem-details.md)
|
||||
- [Build and publish versioned releases](us-016-automated-releases.md)
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Build and publish versioned releases
|
||||
description: Gitea Actions validate every change and publish semantically versioned Velocity and container artifacts.
|
||||
tags: [operations, ci, release, velocity, docker]
|
||||
timestamp: 2026-08-01T20:05:49Z
|
||||
story_id: US-016
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a platform operator, I want automated validation and semantic releases, so that deployable web, migration, and Velocity artifacts are reproducible and downloadable.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Pushes and pull requests run OKF validation, linting, type checks, tests, the web build, and the Velocity build.
|
||||
- [x] Pull requests validate conventional commit messages.
|
||||
- [x] CI uploads the development Velocity JAR as a workflow artifact.
|
||||
- [x] Main-branch conventional commits determine the next semantic version and create a `vMAJOR.MINOR.PATCH` tag.
|
||||
- [x] A release build embeds the semantic version in the Velocity plugin and JAR filename.
|
||||
- [x] A public Gitea release exposes the versioned Velocity JAR as a downloadable asset.
|
||||
- [x] Releases publish semantically versioned web runtime images to the Gitea registry.
|
||||
- [x] Releases publish semantically versioned Discord bot images to the Gitea registry.
|
||||
- [x] Releases publish semantically versioned migration images that run versioned Drizzle migrations.
|
||||
- [x] Releases do not publish mutable container tags such as `latest`.
|
||||
- [x] Runtime containers use unprivileged users and exclude development source and secrets where practical.
|
||||
- [x] Operators are told which repository secrets must be configured before the first push.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [CI workflow](../.gitea/workflows/ci.yml)
|
||||
- [Release workflow](../.gitea/workflows/release.yml)
|
||||
- [Semantic Release configuration](../.releaserc)
|
||||
- [Web and migration Docker targets](../Dockerfile)
|
||||
- [Velocity Gradle build](../plugins/velocity/build.gradle.kts)
|
||||
- [Release and deployment guide](../docs/releases.md)
|
||||
|
||||
# Validation
|
||||
|
||||
Local OKF, lint, typecheck, test, Next.js build, and versioned Velocity JAR checks pass. Initial Gitea CI and release runs succeeded. Release `v1.0.0` provides a publicly downloadable JAR whose Velocity metadata reports `1.0.0`. Registry manifests were resolved for the published semantic-version tags. Release `v1.1.0` also publishes resolvable versioned web, Discord bot, and migration manifests and a public Velocity JAR whose metadata reports `1.1.0`. Release `v1.1.1` published immutable semantic-version tags only; prior `latest` digests remained unchanged. Pull-request commitlint configuration is present; its conditional execution will be exercised by the first pull request.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Deploy and operate securely](us-015-platform-operations.md)
|
||||
- [Velocity game admission](us-009-velocity-admission.md)
|
||||
@@ -1,59 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Control Minecraft admission with groups
|
||||
description: Administrators assign users to groups and enable Minecraft access through explicit group policy.
|
||||
tags: [admin, groups, authorization, velocity, security]
|
||||
timestamp: 2026-08-02T15:03:59Z
|
||||
story_id: US-017
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an administrator, I want to organize registered users into access groups, so that server admission can be enabled for selected communities while remaining off by default.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] A registered user can have at most one explicit group assignment.
|
||||
- [x] Users without an explicit assignment fall back to the protected `everyone` group.
|
||||
- [x] The `everyone` group remains created with Minecraft access disabled.
|
||||
- [x] Administrators can create groups with access disabled by default and move users between groups.
|
||||
- [x] Administrators can enable or disable Minecraft admission for each group.
|
||||
- [x] Disabled Minecraft admission always denies group members; enabled admission may additionally be restricted by recurring UTC windows.
|
||||
- [x] Groups without windows retain unrestricted scheduling, and groups with windows admit logins only during an active window.
|
||||
- [x] Admission follows only the user's effective group; default and explicit-group access are never combined.
|
||||
- [x] Administrators can delete non-default groups, returning affected users to `everyone`.
|
||||
- [x] The protected default group cannot be deleted.
|
||||
- [x] Group creation, membership, and access-policy changes are audited.
|
||||
- [x] Users and administrators can inspect the user's single effective group assignment.
|
||||
- [x] Every group has an independently configurable VPN/proxy/Tor exception policy.
|
||||
- [x] The protected `everyone` group and newly created groups disallow VPN, proxy, and Tor connections by default.
|
||||
- [x] Confirmed VPN, proxy, or Tor game connections are denied unless the user's single effective group allows anonymized networks.
|
||||
- [x] Clear and hosting classifications are not denied by this group policy, and unavailable intelligence does not independently deny a registered player.
|
||||
- [x] VPN policy changes are authorized server-side and audited.
|
||||
- [x] Group creation can explicitly initialize Minecraft and VPN/proxy/Tor policies while retaining deny-by-default controls.
|
||||
- [x] List and detail policy changes use the same confirmation workflow.
|
||||
- [x] Effective member counts include unassigned users who fall back to `everyone`.
|
||||
- [x] Group names and descriptions are validated and editable server-side.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||
- [`packages/database/drizzle/0002_simple_queen_noir.sql`](../packages/database/drizzle/0002_simple_queen_noir.sql)
|
||||
- [`packages/database/drizzle/0003_smiling_silver_samurai.sql`](../packages/database/drizzle/0003_smiling_silver_samurai.sql)
|
||||
- [`packages/database/drizzle/0004_zippy_silver_centurion.sql`](../packages/database/drizzle/0004_zippy_silver_centurion.sql)
|
||||
- [`packages/database/drizzle/0005_young_vertigo.sql`](../packages/database/drizzle/0005_young_vertigo.sql)
|
||||
- [`apps/web/src/app/admin/(console)/groups/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/%5BgroupId%5D/page.tsx)
|
||||
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`packages/auth/test/group-access.test.ts`](../packages/auth/test/group-access.test.ts)
|
||||
- Drizzle migration generation, TypeScript validation, tests, lint, and the production build must pass.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Enforce registration at Velocity](us-009-velocity-admission.md)
|
||||
- [Manage users as an administrator](us-013-admin-user-management.md)
|
||||
- [Preserve an audit trail](us-010-audit-events.md)
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Monitor community account activity
|
||||
description: Administrators use a server-rendered dashboard to review daily activity, confirmed connections, locations, denials, and risky networks.
|
||||
tags: [admin, dashboard, metrics, security, maps, ssr]
|
||||
timestamp: 2026-08-07T22:31:05Z
|
||||
story_id: US-018
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an administrator, I want an operational dashboard of account and game activity, so that I can understand community growth and quickly investigate access risks.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] The administrator landing page is a dashboard rather than a settings form.
|
||||
- [x] A server-rendered Natural Earth overview plots each user's latest non-anonymized observation with valid approximate coordinates, allowing clear and hosting classifications while excluding VPN, proxy, and Tor observations.
|
||||
- [x] When a user's newest coordinate-bearing observation is VPN, proxy, or Tor, the map uses that user's older clear or hosting observation when one exists.
|
||||
- [x] A user without a coordinate-bearing clear or hosting observation is counted as unavailable on the map.
|
||||
- [x] Administrators can opt into a zoomable OpenStreetMap view without removing the default overview.
|
||||
- [x] OpenStreetMap tiles load only after the administrator selects the interactive view and retain required attribution.
|
||||
- [x] Map markers show the managed Discord nickname on hover or keyboard focus, link to user records, and have an accessible text-table equivalent.
|
||||
- [x] Users sharing approximate coordinates render as one grouped marker with a visible count in both map views.
|
||||
- [x] Grouped-marker hover and keyboard focus list every managed Discord nickname at that location.
|
||||
- [x] Interactive grouped markers open a popup with links to every corresponding user record.
|
||||
- [x] Single-user markers retain their direct nickname tooltip and user-record link.
|
||||
- [x] The location list identifies the enriched network company and ASN when available.
|
||||
- [x] The location list shows ProxyCheck's connection type separately from its risk classification.
|
||||
- [x] The location list shows the provider's proxy/VPN signal as an explicit Yes or No value.
|
||||
- [x] Unknown is shown only for individual enriched fields that are unavailable, including existing cached responses.
|
||||
- [x] The dashboard graphs distinct daily active users by UTC day for the previous 14 days with understandable date labels.
|
||||
- [x] Monthly active users count distinct users observed through portal or game activity in the previous 30 days.
|
||||
- [x] Monthly active Minecraft accounts count distinct accounts with a confirmed Velocity post-login connection in the previous 30 days.
|
||||
- [x] The dashboard shows login denials from the previous 24 hours.
|
||||
- [x] Recent VPN, proxy, and Tor observations remain available in the separate network-risk section when excluded from map-location selection.
|
||||
- [x] The graph includes an accessible title, description, point labels, and textual values.
|
||||
- [x] Dashboard queries and initial rendering execute server-side; only the opt-in pan-and-zoom map hydrates client-side.
|
||||
- [x] Deployment-managed guild settings and denial messaging remain available on a dedicated settings page.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/settings/page.tsx`](../apps/web/src/app/admin/%28console%29/settings/page.tsx)
|
||||
- [`apps/web/src/lib/admin-metrics.ts`](../apps/web/src/lib/admin-metrics.ts)
|
||||
- [`apps/web/src/components/user-world-map.tsx`](../apps/web/src/components/user-world-map.tsx)
|
||||
- [`apps/web/src/components/map-view-toggle.tsx`](../apps/web/src/components/map-view-toggle.tsx)
|
||||
- [`apps/web/src/lib/user-location-map.ts`](../apps/web/src/lib/user-location-map.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
- Missing-day chart behavior and per-user VPN collapsing are covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts).
|
||||
- Coordinate parsing, the clear/hosting map policy, backward-compatible ProxyCheck network parsing, normalized location grouping, projection, count badges, complete grouped tooltips, linked markers, semantic network columns, text fallback, and attribution are covered by the user-location and user-world-map tests.
|
||||
- The full test suite passes with 106 tests across 36 files; web type checking and lint pass.
|
||||
- The Next.js production build succeeds and reports the dashboard and database-backed console pages as dynamic server-rendered routes.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Preserve a CloudEvents-style audit trail](us-010-audit-events.md)
|
||||
- [Deploy and operate the platform securely](us-015-platform-operations.md)
|
||||
- [Block anonymized account additions](us-008-vpn-blocking.md)
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Manage groups efficiently
|
||||
description: Administrators use concise policy tables, focused group details, and confirmed modal workflows to manage access groups.
|
||||
tags: [admin, groups, usability, authorization]
|
||||
timestamp: 2026-08-02T15:03:59Z
|
||||
story_id: US-019
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an administrator, I want a concise group policy table and focused group details, so that I can manage access without navigating cumbersome controls.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] The main Groups page lists name, Minecraft access, schedule status, VPN/proxy/Tor access, and effective member count with the default group first and remaining names ordered alphabetically.
|
||||
- [x] Policy controls show their current state and require confirmation in an accessible modal before mutation.
|
||||
- [x] Selecting a group name opens a detail page with its description, policies, and effective members.
|
||||
- [x] Add group opens an accessible modal asking for name, description, Minecraft access, and VPN/proxy/Tor access.
|
||||
- [x] New-group policies default to denied and can be enabled before creation.
|
||||
- [x] Administrators manage only the display name; an internal collision-safe slug is generated automatically.
|
||||
- [x] Administrators can edit group name and description; the protected `everyone` name remains fixed while its description remains editable.
|
||||
- [x] Non-default groups can be deleted only after modal confirmation, returning all affected users to `everyone`.
|
||||
- [x] Group identity, policy, creation, and deletion mutations commit atomically with their audit events.
|
||||
- [x] Modal controls support keyboard operation, focus management, cancellation, and clear pending state.
|
||||
- [x] Group details summarize recurring UTC access windows in the browser's local timezone.
|
||||
- [x] Administrators use a confirmed modal to add, remove, and replace multiple non-overlapping windows.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/admin/(console)/groups/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/%5BgroupId%5D/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/groups/actions.ts`](../apps/web/src/app/admin/%28console%29/groups/actions.ts)
|
||||
- [`apps/web/src/components/admin-modal-form.tsx`](../apps/web/src/components/admin-modal-form.tsx)
|
||||
- [`apps/web/src/components/group-policy-control.tsx`](../apps/web/src/components/group-policy-control.tsx)
|
||||
- [`apps/web/src/components/group-schedule-editor.tsx`](../apps/web/src/components/group-schedule-editor.tsx)
|
||||
- [`apps/web/src/lib/group-management.ts`](../apps/web/src/lib/group-management.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
Native-dialog interaction and pending-state behavior are covered by [`apps/web/src/components/admin-modal-form.test.tsx`](../apps/web/src/components/admin-modal-form.test.tsx). Slug, return-path, protected-name, and effective-membership behavior are covered by [`apps/web/src/lib/group-management.test.ts`](../apps/web/src/lib/group-management.test.ts). TypeScript, lint, accessibility review, Semgrep, production build, and OKF validation pass.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Manage users as an administrator](us-013-admin-user-management.md)
|
||||
- [Control Minecraft admission with groups](us-017-group-access.md)
|
||||
- [Preserve an audit trail](us-010-audit-events.md)
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Schedule group access in UTC
|
||||
description: Administrators restrict enabled groups to recurring weekly UTC windows and provide static denial-message templates.
|
||||
tags: [admin, groups, scheduling, velocity, templates, security]
|
||||
timestamp: 2026-08-02T17:42:26Z
|
||||
story_id: US-020
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an administrator, I want an enabled group to have recurring access windows, so that its members can join only during approved weekly periods and receive useful denial guidance.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] A group can have zero or more recurring weekly access windows stored and evaluated in UTC.
|
||||
- [x] The browser shows each UTC window's current equivalent in the administrator's local timezone while clearly identifying UTC as authoritative.
|
||||
- [x] The Groups table identifies unrestricted groups and the configured window count, linking each status to schedule management.
|
||||
- [x] Administrators can add and remove multiple windows, including windows that cross the end of the UTC week.
|
||||
- [x] Window starts are inclusive and window ends are exclusive.
|
||||
- [x] No configured windows preserve unrestricted scheduling behavior while Minecraft access is enabled.
|
||||
- [x] Disabled Minecraft access always denies admission, regardless of schedule.
|
||||
- [x] Enabled Minecraft access with configured windows allows login only inside an active window.
|
||||
- [x] VPN/proxy/Tor policy is evaluated only after group access and schedule policy pass.
|
||||
- [x] Schedule enforcement occurs at login and does not disconnect an existing session when a window ends.
|
||||
- [x] Schedule changes require confirmation, reauthorize the administrator, and commit atomically with an audit event.
|
||||
- [x] Malformed or overlapping schedule data is rejected; malformed persisted policy fails closed.
|
||||
- [x] Registration, group-disabled, and VPN/proxy/Tor templates support `{player}` and `{group}`.
|
||||
- [x] Schedule-denied templates additionally support `{next_start}` and `{next_end}` for the earliest upcoming UTC window.
|
||||
- [x] Unknown template variables, control characters, and invalid lengths are rejected server-side.
|
||||
- [x] Registration denials use `everyone` when no effective group can be resolved.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||
- [`packages/database/drizzle/0005_young_vertigo.sql`](../packages/database/drizzle/0005_young_vertigo.sql)
|
||||
- [`apps/web/src/lib/group-schedule.ts`](../apps/web/src/lib/group-schedule.ts)
|
||||
- [`apps/web/src/lib/admission-settings.ts`](../apps/web/src/lib/admission-settings.ts)
|
||||
- [`apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/%5BgroupId%5D/page.tsx)
|
||||
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
UTC recurrence, multiple-window selection, local conversion, malformed schedules, template validation, policy precedence, and schedule-editor interactions are covered by automated tests. Drizzle generation, migration preflight, TypeScript, lint, build, security checks, and OKF validation must pass.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Enforce registration at Velocity](us-009-velocity-admission.md)
|
||||
- [Operate settings and audit views](us-012-admin-operations.md)
|
||||
- [Control Minecraft admission with groups](us-017-group-access.md)
|
||||
- [Manage groups efficiently](us-019-admin-group-management.md)
|
||||
@@ -0,0 +1,156 @@
|
||||
# Admin API authentication
|
||||
|
||||
Implemented for `GET /api/admin/whoami` and the read-only [suggestions API](admin-suggestions-api.md). The shared guard is `apps/web/src/lib/auth/admin-api-auth.ts`. This is not browser token login, a general admin mutation API, or the Velocity admission credential mechanism. Browser pages, privileged server actions, RCON, and Velocity authentication are unchanged. The canonical [OpenAPI 3.1 contract](../openapi.yaml) is public at `/openapi.yaml`; see [contract maintenance](openapi.md).
|
||||
|
||||
## Credential selection
|
||||
|
||||
- **No Authorization header:** use the existing NextAuth administrator session and its configured role check. Player sessions do not qualify. Existing browser realm/client role behavior remains intact.
|
||||
- **Any Authorization header supplied:** exclusively use bearer authentication. Empty, malformed, duplicated/combined, unsupported, expired, or otherwise invalid credentials never fall back to a browser session, including a privileged session cookie.
|
||||
- The bearer scheme is case-insensitive. Send one compact signed JWT, not a client secret, bot token, or refresh token.
|
||||
|
||||
## Machine verification
|
||||
|
||||
The web application directly depends on `jose` 6. Verification uses real cryptographic signature checking, not decoded-token role extraction:
|
||||
|
||||
- Only **RS256** is accepted.
|
||||
- `iss` must exactly equal the trimmed `KEYCLOAK_ISSUER_URL` configuration.
|
||||
- `aud` must contain `KEYCLOAK_CLIENT_ID` (a string or audience array is supported). `azp` does not substitute for `aud`.
|
||||
- `exp` and a nonblank string `sub` are required. Expired tokens and future `nbf` are rejected with no added clock tolerance.
|
||||
- The required role (configured `KEYCLOAK_REQUIRED_ROLE`, default `minecraft-account-manager-admin`) must appear in `resource_access[KEYCLOAK_CLIENT_ID].roles`. Realm roles or roles for other clients are not accepted.
|
||||
- The configured issuer must be HTTPS with no embedded credentials, query, or fragment. Keys come only from `<issuer-without-final-slash>/protocol/openid-connect/certs`; token `iss`, `jku`, and `x5u` never select a key URL. Redirects are not followed.
|
||||
|
||||
One bounded, process-local remote JWKS resolver caches keys for ten minutes, coalesces concurrent fetches, permits refresh for unknown keys after a 30-second cooldown, and bounds each network fetch to five seconds. A changed configured issuer replaces the resolver. Replicas do not share the cache. Key rotation can temporarily reject a new key during cooldown; removed keys may remain usable until the cache refreshes. Access-token validity is local JWT verification, not per-request revocation/introspection. Use suitably short token lifetimes and synchronized clocks.
|
||||
|
||||
`KEYCLOAK_CLIENT_SECRET` is not needed for machine verification. This implementation does not obtain tokens or alter identity-provider clients, role/audience mappers, credentials, or deployments. See [OIDC setup](admin-oidc-keycloak-setup.md) for the distinct browser configuration.
|
||||
|
||||
## Obtain and use a machine token safely
|
||||
|
||||
The Keycloak token endpoint is `<KEYCLOAK_ISSUER_URL-without-final-slash>/protocol/openid-connect/token`. Use `grant_type=client_credentials` with a separately provisioned confidential service-account client. Its issued access token must include the **portal audience** and the **portal client's administrator role** described above; the machine client's own ID or `azp` is not a substitute. Client provisioning, credential retrieval and role/audience changes require separate operational approval. Production uses issuer `https://auth.20faces.games/realms/infra`, token endpoint `https://auth.20faces.games/realms/infra/protocol/openid-connect/token`, and portal `https://portal.somc.club`. Confirm these against current approved configuration before use. The token endpoint is owned by Keycloak, not a portal route.
|
||||
|
||||
No helper is installed. This optional, one-shot Python 3 standard-library example prompts on the controlling terminal, keeps the client secret and access token in process memory, and prints only the HTTP status of whoami. It does not save or print the token or identity response. Use only on an approved trusted workstation. Do not enable shell tracing, HTTP debug logging, terminal recording, or request-body/header capture. Never paste a secret into a command, `curl -d`, an Authorization argument, environment export, chat, or a log. For automation, use an approved secret-manager/protected-file input and pass credentials directly to an HTTP library in memory rather than command arguments.
|
||||
|
||||
```sh
|
||||
python3 - <<'PY'
|
||||
import getpass
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
# HTTPS certificate verification remains enabled; never follow credential redirects.
|
||||
def https_url(value):
|
||||
value = value.strip().rstrip("/")
|
||||
url = urllib.parse.urlsplit(value)
|
||||
if (url.scheme != "https" or not url.hostname or url.username is not None
|
||||
or url.password is not None or url.query or url.fragment):
|
||||
raise ValueError("An approved HTTPS URL is required")
|
||||
return value
|
||||
|
||||
try:
|
||||
with open("/dev/tty", "r") as terminal:
|
||||
def prompt(label):
|
||||
print(label, end="", flush=True)
|
||||
return terminal.readline().strip()
|
||||
issuer = https_url(prompt("Approved Keycloak issuer URL: "))
|
||||
portal = https_url(prompt("Approved portal URL: "))
|
||||
client_id = prompt("Machine client ID: ")
|
||||
client_secret = getpass.getpass("Machine client secret: ")
|
||||
opener = urllib.request.build_opener(NoRedirect)
|
||||
form = urllib.parse.urlencode({
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
}).encode()
|
||||
token_request = urllib.request.Request(
|
||||
issuer + "/protocol/openid-connect/token", data=form,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
with opener.open(token_request, timeout=10) as response:
|
||||
access_token = json.load(response)["access_token"]
|
||||
identity_request = urllib.request.Request(
|
||||
portal + "/api/admin/whoami",
|
||||
headers={"Authorization": "Bearer " + access_token},
|
||||
)
|
||||
with opener.open(identity_request, timeout=10) as response:
|
||||
print("whoami HTTP", response.status)
|
||||
# To read suggestions, use the same in-memory header with /api/suggestions.
|
||||
# Process exit releases memory; this is not a secure-memory erasure guarantee.
|
||||
except urllib.error.HTTPError as error:
|
||||
print("Request failed; HTTP", error.code, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception:
|
||||
print("Request failed; verify configuration and connectivity securely.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
PY
|
||||
```
|
||||
|
||||
Token acquisition is not part of this application's implementation or offline tests. The example performs real network requests only when an operator explicitly runs it; it is not run by the source checks. A 401/403/503 from whoami has the semantics below. Do not print upstream error bodies while diagnosing issuance failures. Token requests can appear in identity-provider access logs: confirm that request bodies and Authorization headers are redacted before use.
|
||||
|
||||
## Safe identity endpoint
|
||||
|
||||
`GET /api/admin/whoami` authenticates and checks administrator permission before returning JSON with `Cache-Control: no-store`:
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticationMethod": "bearer",
|
||||
"subject": "machine-subject",
|
||||
"name": null,
|
||||
"email": null
|
||||
}
|
||||
```
|
||||
|
||||
For browser sessions, `authenticationMethod` is `session`, `subject` is `null` (the existing session does not expose it), and `name`/`email` are the existing session values or `null`. Machine profile claims are not returned. No raw token, role list, session expiry, key material, client secret, or arbitrary claims are exposed. This endpoint requires no Discord or database access.
|
||||
|
||||
## Failures
|
||||
|
||||
All guard failures use RFC 9457 `application/problem+json`, `Cache-Control: no-store`, a matching HTTP/body status, and a request-path instance. There are no sign-in redirects or token/error-detail logs.
|
||||
|
||||
| Status | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| 401 | `urn:error:unauthorized` | Missing session or invalid supplied credentials; includes `WWW-Authenticate: Bearer realm="admin-api"`. |
|
||||
| 403 | `urn:error:forbidden` | Authenticated identity lacks the required permission. |
|
||||
| 503 | `urn:error:admin-auth-unavailable` | Invalid/missing machine configuration, JWKS transport/format failure, or browser session service failure. |
|
||||
|
||||
Authentication is checked before suggestions cache access or Discord requests. Valid machine credentials do not enable writes: suggestions write methods remain 405. Errors do not reveal credentials, raw claims, upstream response bodies, or exception messages.
|
||||
|
||||
## Verification and rollout boundary
|
||||
|
||||
Focused coverage lives in:
|
||||
|
||||
- `apps/web/src/lib/auth/admin-api-auth.test.ts`: real signed JWTs, controlled JWKS HTTP transport (not mocked `jwtVerify`), validation failures, client-role isolation, configuration/network safety, caching, concurrent reads, and rotation.
|
||||
- `apps/web/src/app/api/admin/whoami/route.test.ts`: safe identity projection and guard failures through the route.
|
||||
- `apps/web/src/app/api/suggestions/{route,machine-auth}.test.ts`: browser regression, bearer precedence, all three read routes, cache authorization, and read-only behavior.
|
||||
|
||||
Run from the source repository:
|
||||
|
||||
```sh
|
||||
npm test --workspace @minecraft-account-manager/web -- src/lib/auth/admin-api-auth.test.ts src/app/api/admin/whoami/route.test.ts src/app/api/suggestions
|
||||
npm test
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run build
|
||||
npm run velocity:build
|
||||
```
|
||||
|
||||
### Test-first implementation evidence (US-025)
|
||||
|
||||
The following runs were observed against the local implementation; no commit or publication is part of this task:
|
||||
|
||||
| Slice / focused test arguments after `npm test --workspace @minecraft-account-manager/web --` | Red before implementation | Green after implementation |
|
||||
| --- | --- | --- |
|
||||
| `src/app/api/suggestions/route.test.ts` | 6 failures: supplied headers fell through to the browser path (503 instead of 401), and missing-session 401 lacked the challenge. | 21 passing after shared-guard integration. |
|
||||
| `src/lib/auth/admin-api-auth.test.ts` | 15 failures: valid signed tokens were rejected; client-role and unavailable-service responses were not implemented. | 53 passing with the suggestions regression suite after actual JWT/JWKS verification. |
|
||||
| `src/app/api/admin/whoami/route.test.ts` | 6 explicit route-absence assertion failures (suite ran without a broken module import). | 59 passing across all three suites after adding the route; route discovery then refactored to a direct import. |
|
||||
|
||||
Additional integration/resilience regression coverage brought the focused suite to **72 passing tests**: every suggestions read route, authorization before cached reads, unchanged write denial, untrusted signing keys, algorithm restrictions, concurrent JWKS fetching, key rotation, malformed upstream responses, and redirect refusal. Negative-token tests use an available privileged browser session to verify that invalid bearer credentials never fall back. The tests sign actual JWTs and exercise `jose` verification against controlled transport responses; `jwtVerify` is never mocked.
|
||||
|
||||
Full local verification passed: `npm test` (**252 tests**, including 214 web tests), `npm run lint` (zero errors; two existing navigation warnings in unchanged `map-view-toggle.tsx`), `npm run typecheck`, `npm run build` (whoami emitted as a dynamic route), and `npm run velocity:build` (`clean test shadowJar`, Java 17). The unrelated `next-env.d.ts` addition generated by Next.js during the build was removed to keep the source diff scoped.
|
||||
|
||||
Security scan: `semgrep scan --config p/typescript --config p/jwt --metrics=off` on the shared guard, suggestions wrapper, and whoami implementation completed with **74 rules, three files, zero findings**. The initial `--config auto --metrics=off` invocation was rejected by Semgrep; the explicit-rule run is the successful result. This is scoped static-analysis evidence, not a complete security audit.
|
||||
|
||||
Offline tests do not establish live Keycloak audience/role issuance, JWKS reachability, or production authorization. Production deployment remains separately gated. After explicit release approval, verify machine whoami and suggestions reads, rejection of an unauthorized identity, and browser session access using approved credential handling (never token values in chat, command arguments, or logs).
|
||||
@@ -1,6 +1,6 @@
|
||||
# Admin OIDC setup
|
||||
|
||||
The admin console will use Keycloak OIDC and JWT-backed Auth.js sessions, following the established pattern in the sibling Retro application.
|
||||
The admin console uses Keycloak OIDC and JWT-backed NextAuth sessions.
|
||||
|
||||
## Application environment
|
||||
|
||||
@@ -21,4 +21,10 @@ Allow exact callback and logout URLs for each environment. Avoid wildcard origin
|
||||
|
||||
Create the realm role `minecraft-account-manager-admin` and assign it directly or through an admin group. Ensure realm roles are emitted in `realm_access.roles`.
|
||||
|
||||
The admin console will reject sign-in when the required role is absent, even when Keycloak authentication itself succeeds.
|
||||
The admin console rejects sign-in when the required role is absent, even when Keycloak authentication itself succeeds. Existing browser sign-in accepts realm or configured-client roles; this behavior is unchanged.
|
||||
|
||||
## Read-only machine API access
|
||||
|
||||
The [admin API guard](admin-api-authentication.md) independently verifies signed Keycloak access tokens. Machine tokens must include `KEYCLOAK_CLIENT_ID` in `aud` and `KEYCLOAK_REQUIRED_ROLE` in `resource_access[KEYCLOAK_CLIENT_ID].roles`. A realm role alone is **not** sufficient for bearer access. The identity provider must emit both the portal audience and this client role; a token's `azp` is not an audience substitute.
|
||||
|
||||
Verification uses the HTTPS issuer's `/protocol/openid-connect/certs` JWKS endpoint and RS256 only. It does not use `KEYCLOAK_CLIENT_SECRET`, exchange tokens, or create a browser session. Browser client configuration above remains required for interactive SSO. Provisioning or changing machine clients, role/audience mappers, credentials, and production deployment requires separate operational approval; this source implementation performs none of those operations.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Admin suggestions API
|
||||
|
||||
The portal provides a read-only view of one Discord **forum channel**, using the existing NextAuth admin session or a verified Keycloak machine bearer token. Player sessions and Discord bot credentials are not accepted as API credentials. Browser users sign in at `/admin/login`; same-origin calls send the session cookie. Machine clients use `Authorization: Bearer <access-token>` with the configured portal audience and **client** role. See [Admin API authentication](admin-api-authentication.md) for verification rules, safe identity checks, and failure behavior.
|
||||
|
||||
## Portal interface
|
||||
|
||||
Open `/admin/suggestions` from the administrator navigation. The idea desk lists active or archived forum posts with tags, status, approximate message counts, author IDs, timestamps, and Discord links. Select a title to open `/admin/suggestions/:id`, read the starter post and its reaction counts, and page through discussion newest-first. The starter is not duplicated in the discussion view.
|
||||
|
||||
The UI uses these same admin-protected GET endpoints with its browser session, not a second integration. Both pages independently check admin access before rendering; the APIs recheck it on every read. Expired/unauthorized API access offers an admin sign-in link. Loading, empty/deleted content, missing-text, and retryable failure states are explicit. Changing status aborts obsolete requests, and pagination restores keyboard focus to the page indicator. Text is rendered literally with React escaping, never as HTML or interpreted Discord Markdown. No bot token, forum configuration value, reply input, vote button, or moderation control is added to the client bundle.
|
||||
|
||||
Pagination history is page-local and resets when switching status or leaving the page. Reload the browser to refresh a view; upstream reads may use the documented 30-second cache. The real forum ID is still configured only through GitOps, not the UI or source.
|
||||
|
||||
## Runtime configuration
|
||||
|
||||
- `DISCORD_SUGGESTIONS_FORUM_ID`: required forum channel snowflake, configured through GitOps. No source-code default.
|
||||
- `DISCORD_GUILD_ID`: existing Discord guild configuration; the forum must belong to it.
|
||||
- `DISCORD_BOT_TOKEN`: existing server-side credential, never returned to consumers.
|
||||
|
||||
The web workload needs these variables, not just the bot workload. The actual forum ID is maintained only in GitOps. No schema or bot Gateway changes are needed. The application uses Discord REST API v10 with the existing bot identity.
|
||||
|
||||
The bot must have **View Channel** and **Read Message History** for the forum and its posts. Message bodies are subject to Discord's **Message Content privileged intent**, including REST access: enable it for the application and obtain approval from Discord if required. Missing content can appear as an empty body rather than an HTTP error; check a known text post before production acceptance. No permissions or intents are changed by this feature.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| GET endpoint | Result |
|
||||
| --- | --- |
|
||||
| `/api/suggestions?status=active&limit=25` | Active posts, newest-created first. `status` defaults to `active`. |
|
||||
| `/api/suggestions?status=archived&limit=25` | Archived public forum posts, newest archive timestamp first. |
|
||||
| `/api/suggestions/:id` | Suggestion metadata and `originalPost`; `null` when the starter message was deleted. |
|
||||
| `/api/suggestions/:id/messages?limit=25` | Discussion messages, newest first, including the starter if reached. |
|
||||
|
||||
Lists return `{ items, nextCursor }`. Pass `nextCursor` back as the URL-encoded `cursor` parameter with the same status. Limits are integers from 1 to 100. Active cursors are thread IDs; archived cursors are UTC archive timestamps 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.
|
||||
|
||||
## Errors and safety
|
||||
|
||||
Errors use RFC 9457 `application/problem+json`, HTTP-matching `status`, stable `urn:error:*` types, and safe details:
|
||||
|
||||
- `401 unauthorized`: no admin session or invalid supplied credentials; no redirect; includes `WWW-Authenticate: Bearer realm="admin-api"`.
|
||||
- `403 forbidden`: verified identity lacks the required role (configured-client role for bearer tokens).
|
||||
- `503 admin-auth-unavailable`: authentication configuration, browser session service, or JWKS service unavailable; no session fallback for supplied credentials.
|
||||
- `400 invalid-request`: invalid ID, cursor, limit, status, or list/message query parameter.
|
||||
- `404 suggestion-not-found`: inaccessible/deleted thread, or thread outside the configured forum.
|
||||
- `405 method-not-allowed`: writes are unsupported; `Allow: GET, HEAD`.
|
||||
- `503 suggestions-not-configured`, `discord-unavailable`, `discord-rate-limited`, or `discord-busy`: configuration, permissions, upstream failure, or temporary backoff. Rate limits and capacity limits include `Retry-After`.
|
||||
|
||||
Every endpoint rechecks admin authorization before any cached or fresh data is returned. Responses use `Cache-Control: no-store`. The in-process Discord cache lasts 30 seconds, contains at most 200 entries, coalesces identical concurrent reads, and permits at most eight concurrent upstream requests. Requests have an eight-second timeout and never follow redirects. Discord rate limits establish a per-client cooldown without retry loops. Caches and cooldowns are per process, not shared with bot Gateway activity or other replicas.
|
||||
|
||||
The client checks the configured forum's guild/type and each requested thread's parent/type before reading messages. It only calls fixed Discord endpoints with validated snowflakes. Error bodies and credentials are not logged or forwarded. There is no database synchronization and no Discord mutation support.
|
||||
|
||||
## Verification before rollout
|
||||
|
||||
Run the source checks and focused API tests in `apps/web/src/lib/discord/suggestions.test.ts` and `apps/web/src/app/api/suggestions/route.test.ts`, plus UI tests in `apps/web/src/components/suggestions-browser.test.tsx`, `suggestion-reader.test.tsx`, and `apps/web/src/app/admin/(console)/suggestions/`. Under the separately approved GitOps/release plan, verify one known active post, one archived post, message content, and admin/non-admin access against the actual forum. Offline fixtures do not establish live Discord permissions or intent approval.
|
||||
|
||||
References: [Discord threads](https://discord.com/developers/docs/topics/threads), [Channel resource](https://discord.com/developers/docs/resources/channel), [Message content intent](https://discord.com/developers/docs/events/gateway#message-content-intent).
|
||||
@@ -8,6 +8,10 @@ The Next.js application owns user onboarding, account management, admin configur
|
||||
|
||||
User authentication begins with an opaque, short-lived, single-use token created for a Discord user. Only a cryptographic hash of the token is persisted. Admin authentication is a separate Keycloak OIDC flow and requires the `minecraft-account-manager-admin` role.
|
||||
|
||||
### RCON administration
|
||||
|
||||
The administrator console stores one or more RCON server addresses with write-only AES-GCM-encrypted passwords. Browser requests invoke authenticated server actions; only the Next.js runtime opens RCON TCP connections. Administrators may configure any syntactically valid internal or external DNS hostname and port without deployment-managed endpoint configuration; IP literals remain rejected. Commands and responses are bounded and ephemeral, while credential-safe audit events retain the operator, server, command verb, keyed digest, outcome, and duration. Operators remain responsible for endpoint exposure and transport security.
|
||||
|
||||
### Discord bot
|
||||
|
||||
The bot creates private login links in response to `/register` and `/account`. Discord user IDs are the canonical Discord identity; mutable usernames are snapshots only. Nickname updates target the deployment guild configured by `DISCORD_GUILD_ID`; the public join button uses `DISCORD_INVITE_URL`.
|
||||
@@ -26,6 +30,8 @@ The admission decision is fail closed. Unknown players, disabled effective group
|
||||
- Velocity requests use hashed per-server bearer credentials, timestamps, and database-unique request IDs for authentication and replay prevention.
|
||||
- Session and one-time-code values are random and stored only as hashes.
|
||||
- Exact IP addresses are sensitive data and require an explicit retention policy before production deployment.
|
||||
- RCON endpoints require syntactically valid DNS hostnames and ports; passwords never cross the browser trust boundary. Cluster egress policy and administrator authorization constrain the resulting outbound-connectivity trust boundary.
|
||||
- RCON commands and responses are untrusted, bounded, rendered only as text, and excluded from persistent history and logs.
|
||||
|
||||
## Database invariants
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,39 @@
|
||||
# RCON administration
|
||||
|
||||
The administrator RCON console proxies commands through the Next.js server runtime. Browsers never receive RCON credentials and never open RCON sockets.
|
||||
|
||||
## 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.
|
||||
|
||||
This flexibility means an authorized or compromised administrator can make RCON connection attempts to any DNS hostname and port reachable from the web runtime. Use cluster egress policy and administrator access controls to constrain that trust boundary where required.
|
||||
|
||||
Saved passwords are encrypted with AES-256-GCM and connection-bound authenticated data. By default, domain-separated credential and audit keys are derived from `AUTH_SECRET`. Deployments may instead provide independent 32-byte base64 values through `RCON_CREDENTIAL_KEY` and `RCON_AUDIT_KEY`. Rotating the credential key requires replacing saved RCON passwords.
|
||||
|
||||
## Minecraft server configuration
|
||||
|
||||
Enable RCON with a high-entropy password supplied through the deployment secret. Server addresses may resolve internally or externally. Prefer private networking, a VPN, or an encrypted tunnel; do not expose plaintext RCON directly to the public internet.
|
||||
|
||||
The password entered in the administrator connection form must match the server password. Existing passwords are write-only; leave the replacement field blank when editing unrelated connection settings.
|
||||
|
||||
## Security behavior
|
||||
|
||||
- Existing account-manager administrator authorization is rechecked for every connection mutation, test, and command.
|
||||
- Commands are limited to 1,024 UTF-8 bytes and reject control characters.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Migration
|
||||
|
||||
Apply the generated Drizzle migration before deploying the web image:
|
||||
|
||||
```bash
|
||||
npx drizzle-kit migrate
|
||||
```
|
||||
|
||||
Never use `drizzle push` for this schema change.
|
||||
@@ -65,6 +65,8 @@ docker run --rm -p 3000:3000 \
|
||||
|
||||
Provide all deployment settings described by [`.env.example`](../.env.example). Only set `TRUST_PROXY=true` behind a proxy that overwrites forwarding headers.
|
||||
|
||||
The release workflow passes the semantic version through Docker's `VERSION` build argument, and the final image embeds it as `APP_VERSION`. `GET /healthz` reports this immutable image version with readiness, for example `{"status":"ok","version":"1.19.0"}`. Manual builds that omit the build argument report `development`.
|
||||
|
||||
## Velocity JAR
|
||||
|
||||
Download the JAR from the matching public Gitea release, copy it to Velocity's `plugins/` directory, and retain the existing `plugins/minecraft-account-manager/config.properties` during upgrades.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Security review
|
||||
|
||||
Review date: 2026-08-02
|
||||
Review timestamp: 2026-09-10T12:20:32Z
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -8,10 +8,12 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
|
||||
|
||||
## Automated checks
|
||||
|
||||
- Semgrep `auto`: 0 findings
|
||||
- `npm audit`: 0 known vulnerabilities after dependency overrides
|
||||
- TypeScript, ESLint, unit tests, Next.js production build: passing
|
||||
- Velocity Java tests and shaded plugin build: passing
|
||||
- Full source Semgrep `auto`: 0 findings across 178 scanned files (331 rules; 13 files excluded by ignore patterns).
|
||||
- `npm audit` and `npm audit --omit=dev`: 0 known vulnerabilities after remediation. Baseline had six affected entries (two moderate, two high, two critical), covering Next.js/Sharp, the NextAuth dependency path, Vitest/mocker, and js-yaml.
|
||||
- Resolved patched versions: Next.js and eslint-config-next 16.3.4, Sharp 0.35.4, Vitest/mocker 4.1.11, js-yaml 4.3.2. Retained Next.js's PostCSS override at 8.5.25. No forced major dependency upgrades or database changes.
|
||||
- TypeScript, 196 workspace tests, Next.js production build, and Velocity Java tests/shaded plugin build pass with patched dependencies. ESLint exits successfully with two new framework-rule warnings about existing `window.location.assign()` calls in `map-view-toggle.tsx`; those unrelated navigation behaviors were not changed.
|
||||
- Verification is local source evidence, not a claim that patched artifacts have been deployed. API CI run 1979 passed for `1a01c0ed641f4eda83f81c855c38651aa116933e`; dependency-fix CI run 1985 passed for `0af4884f7e16161d0c0ea3a8f9c26b3fa9384cf9`.
|
||||
- Suggestions UI tests verify both page guards, malformed-ID rejection, literal text rendering (including script-like content), read-only API usage, explicit auth/error states, aborted obsolete reads, and pagination focus. Actual Discord permissions/Message Content access and production browser acceptance remain rollout checks.
|
||||
|
||||
## Implemented controls
|
||||
|
||||
@@ -19,6 +21,7 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
|
||||
- Login links expire after ten minutes, are single use, and are rate limited per Discord user with a PostgreSQL advisory lock.
|
||||
- Session cookies are `httpOnly`, `sameSite=lax`, path-scoped, and secure in production.
|
||||
- Admin access uses Keycloak OIDC and a required role.
|
||||
- Every suggestions API route independently requires that admin session/role before reading cached or live Discord data. The configured forum is guild/type-checked and requested threads are parent/type-checked before message access. Discord credentials remain server-side; fixed-host, validated-ID requests have bounded caching, concurrency, timeouts, and rate-limit backoff. Responses are uncached and errors use safe RFC 9457 problems. See [suggestions API](admin-suggestions-api.md) for live Discord permission/intent requirements.
|
||||
- User mutations verify ownership server-side.
|
||||
- Mojang lookup is server-side and targets a fixed host, avoiding client-forged validation and SSRF.
|
||||
- Velocity credentials are high-entropy bearer tokens stored only as hashes.
|
||||
|
||||
+1704
File diff suppressed because it is too large
Load Diff
Generated
+616
-352
File diff suppressed because it is too large
Load Diff
+3
-4
@@ -14,14 +14,13 @@
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"db:generate": "npm run db:generate --workspace @minecraft-account-manager/database",
|
||||
"db:migrate": "npm run db:migrate --workspace @minecraft-account-manager/database",
|
||||
"velocity:build": "cd plugins/velocity && ./gradlew clean test shadowJar",
|
||||
"design:validate": "node scripts/validate-okf.mjs"
|
||||
"velocity:build": "cd plugins/velocity && ./gradlew clean test shadowJar"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.25.12",
|
||||
"next@16.2.12": {
|
||||
"next@16.3.4": {
|
||||
"postcss": "8.5.25",
|
||||
"sharp": "0.35.3"
|
||||
"sharp": "0.35.4"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "rcon_servers" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"host" varchar(253) NOT NULL,
|
||||
"port" integer DEFAULT 25575 NOT NULL,
|
||||
"encrypted_password" text NOT NULL,
|
||||
"enabled" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "rcon_servers_port_check" CHECK ("rcon_servers"."port" between 1 and 65535)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "rcon_servers_name_uidx" ON "rcon_servers" USING btree (lower("name"));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1785692345708,
|
||||
"tag": "0005_young_vertigo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1786151282526,
|
||||
"tag": "0006_curious_lester",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -206,6 +206,23 @@ export const appSettings = pgTable("app_settings", {
|
||||
...timestamps(),
|
||||
});
|
||||
|
||||
export const rconServers = pgTable(
|
||||
"rcon_servers",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
host: varchar("host", { length: 253 }).notNull(),
|
||||
port: integer("port").notNull().default(25575),
|
||||
encryptedPassword: text("encrypted_password").notNull(),
|
||||
enabled: boolean("enabled").notNull().default(false),
|
||||
...timestamps(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("rcon_servers_name_uidx").on(sql`lower(${table.name})`),
|
||||
check("rcon_servers_port_check", sql`${table.port} between 1 and 65535`),
|
||||
],
|
||||
);
|
||||
|
||||
export const ipIntelligence = pgTable("ip_intelligence", {
|
||||
ipAddress: inet("ip_address").primaryKey(),
|
||||
classification: ipClassification("classification").notNull().default("unknown"),
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { access, readFile, readdir } from "node:fs/promises";
|
||||
|
||||
const designDirectory = new URL("../design/", import.meta.url);
|
||||
const reservedFiles = new Set(["index.md", "log.md"]);
|
||||
const allowedStatuses = new Set(["proposed", "in-progress", "implemented", "verified"]);
|
||||
const failures = [];
|
||||
const storyIds = new Map();
|
||||
|
||||
async function markdownFiles(directory, relativeDirectory = "") {
|
||||
const files = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await markdownFiles(new URL(`${entry.name}/`, directory), relativePath));
|
||||
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
files.push({ name: entry.name, relativePath, url: new URL(entry.name, directory) });
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
for (const file of await markdownFiles(designDirectory)) {
|
||||
const contents = await readFile(file.url, "utf8");
|
||||
for (const link of contents.matchAll(/\[[^\]]+\]\(([^)\s]+)\)/g)) {
|
||||
const target = link[1].split("#", 1)[0];
|
||||
if (!target || target.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(target)) continue;
|
||||
const targetUrl = target.startsWith("/")
|
||||
? new URL(target.slice(1), designDirectory)
|
||||
: new URL(target, file.url);
|
||||
try {
|
||||
await access(targetUrl);
|
||||
} catch {
|
||||
failures.push(`${file.relativePath}: broken link ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (reservedFiles.has(file.name)) continue;
|
||||
|
||||
const frontmatter = contents.match(/^---\n([\s\S]*?)\n---\n/);
|
||||
if (!frontmatter) {
|
||||
failures.push(`${file.relativePath}: missing YAML frontmatter`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = Object.fromEntries(
|
||||
frontmatter[1]
|
||||
.split("\n")
|
||||
.map((line) => line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/))
|
||||
.filter(Boolean)
|
||||
.map((match) => [match[1], match[2].replace(/^['"]|['"]$/g, "")]),
|
||||
);
|
||||
|
||||
if (!metadata.type) failures.push(`${file.relativePath}: missing required type`);
|
||||
if (metadata.type === "User Story") {
|
||||
for (const field of ["story_id", "title", "description", "status", "timestamp"]) {
|
||||
if (!metadata[field]) failures.push(`${file.relativePath}: missing ${field}`);
|
||||
}
|
||||
if (metadata.status && !allowedStatuses.has(metadata.status)) {
|
||||
failures.push(`${file.relativePath}: invalid status ${metadata.status}`);
|
||||
}
|
||||
if (metadata.timestamp && Number.isNaN(Date.parse(metadata.timestamp))) {
|
||||
failures.push(`${file.relativePath}: timestamp is not ISO 8601`);
|
||||
}
|
||||
if (metadata.story_id) {
|
||||
const duplicate = storyIds.get(metadata.story_id);
|
||||
if (duplicate) failures.push(`${file.relativePath}: duplicate ${metadata.story_id} also used by ${duplicate}`);
|
||||
storyIds.set(metadata.story_id, file.relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const index = await readFile(new URL("index.md", designDirectory), "utf8");
|
||||
for (const [storyId, filename] of storyIds) {
|
||||
if (!index.includes(`(${filename})`)) failures.push(`index.md: missing ${storyId} link to ${filename}`);
|
||||
}
|
||||
|
||||
const log = await readFile(new URL("log.md", designDirectory), "utf8");
|
||||
for (const heading of log.matchAll(/^##\s+(.+)$/gm)) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(heading[1])) {
|
||||
failures.push(`log.md: invalid date heading ${heading[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("OKF validation failed:\n" + failures.map((failure) => `- ${failure}`).join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`OKF validation passed: ${storyIds.size} user stories.`);
|
||||
Reference in New Issue
Block a user