Compare commits

...
4 Commits
Author SHA1 Message Date
dmg 1a01c0ed64 feat(admin): expose read-only Discord suggestions API
CI / validate (push) Successful in 6m28s
Release / release (push) Successful in 10m33s
2026-09-10 07:50:01 -04:00
dmg df17c021c6 chore(knowledge): move canonical docs to shared SoMC wiki
CI / validate (push) Successful in 8m7s
Release / release (push) Successful in 8m54s
2026-09-09 23:17:47 -04:00
dmg ed3f3cd843 feat(rcon): add audited command history
CI / validate (push) Successful in 6m38s
Release / release (push) Successful in 8m29s
2026-08-13 21:26:24 -04:00
dmg 168a7a2c36 feat(health): report build version
CI / validate (push) Successful in 6m24s
Release / release (push) Successful in 8m19s
2026-08-08 11:06:06 -04:00
51 changed files with 852 additions and 1247 deletions
+2
View File
@@ -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
-3
View File
@@ -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
-1
View File
@@ -37,7 +37,6 @@ jobs:
- name: Validate release source
run: |
npm run design:validate
npm run lint
npm run typecheck
npm test
+5 -39
View File
@@ -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.
+5 -2
View File
@@ -32,14 +32,17 @@ Set `IP_INTELLIGENCE_PROVIDER=proxycheck`, add `PROXYCHECK_API_KEY`, and configu
Open `http://localhost:3000`.
## Admin suggestions
Administrators can read the configured Discord forum through the session-protected [suggestions API](docs/admin-suggestions-api.md). Set `DISCORD_SUGGESTIONS_FORUM_ID` through GitOps; the existing bot token stays server-side. This integration is read-only and does not synchronize data into the database.
## Product design
Implemented and proposed behavior is tracked 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
@@ -6,7 +6,8 @@ const actionState = vi.hoisted(() => ({
transactionSelected: [] as unknown[],
updates: [] as Record<string, unknown>[],
inserts: [] as unknown[],
audits: [] as Array<{ admin: unknown; subject: string; type: string; data: Record<string, 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 }
@@ -84,8 +85,11 @@ vi.mock("@/lib/audit", () => ({
subject: string,
type: string,
data: Record<string, unknown>,
options?: { correlationId?: string },
) => {
actionState.audits.push({ admin, subject, type, data });
if (actionState.auditFailure) throw new Error("audit unavailable");
actionState.audits.push({ admin, subject, type, data, correlationId: options?.correlationId });
return "22222222-2222-4222-8222-222222222222";
},
}));
@@ -118,6 +122,7 @@ describe("RCON server actions", () => {
actionState.updates = [];
actionState.inserts = [];
actionState.audits = [];
actionState.auditFailure = false;
actionState.executions = [];
actionState.gatewayResult = { ok: true, response: "private response" };
});
@@ -154,7 +159,7 @@ describe("RCON server actions", () => {
expect(JSON.stringify(actionState.inserts)).not.toContain("decrypted-password");
});
it("rechecks enabled saved state and records credential-safe command lifecycle audits", async () => {
it("rechecks enabled saved state and records complete command lifecycle audits", async () => {
actionState.selected = [savedServer];
const formData = new FormData();
formData.set("serverId", serverId);
@@ -176,16 +181,19 @@ describe("RCON server actions", () => {
admin: { email: "admin@example.test", name: "Admin" },
subject: `rcon-server/${serverId}`,
type: "games.minecraft.account-manager.rcon.command.requested",
data: expect.objectContaining({ verb: "say", commandDigest: "hmac-sha256:v1:digest" }),
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(serializedAudits).not.toContain("private value");
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");
});
@@ -209,6 +217,21 @@ describe("RCON server actions", () => {
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);
@@ -227,13 +227,19 @@ export async function executeRconCommand(
return { status: "error", message: "RCON command auditing is not configured.", serverId };
}
const started = Date.now();
const correlationId = randomUUID();
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.requested", {
serverId: server.id,
name: server.name,
verb,
commandDigest,
});
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,
@@ -243,7 +249,7 @@ export async function executeRconCommand(
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."
@@ -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,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,71 @@
import { afterEach, expect, it, vi } from "vitest";
const auth = vi.hoisted(() => ({ session: vi.fn() }));
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
import { GET, POST } from "./route";
import { GET as detail } from "./[id]/route";
import { GET as messages } from "./[id]/messages/route";
const context = { params: Promise.resolve({ id: "100000000000000009" }) };
const request = () => new Request("https://portal.example/api/suggestions");
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
it("serves suggestions to the existing admin session using runtime env configuration", async () => {
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
vi.stubEnv("DISCORD_BOT_TOKEN", "test-token");
vi.stubEnv("DISCORD_GUILD_ID", "100000000000000001");
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", "100000000000000002");
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ id: "100000000000000002", guild_id: "100000000000000001", type: 15, available_tags: [] })).mockResolvedValueOnce(Response.json({ threads: [] }));
vi.stubGlobal("fetch", fetcher);
const response = await GET(request());
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ items: [], nextCursor: null });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(fetcher).toHaveBeenCalledTimes(2);
});
it.each([detail, messages])("independently protects detail and message routes", async (handler) => {
const fetcher = vi.fn();
vi.stubGlobal("fetch", fetcher);
auth.session.mockResolvedValue(null);
expect((await handler(request(), context)).status).toBe(401);
auth.session.mockResolvedValue({ user: { roles: ["player"] } });
expect((await handler(request(), context)).status).toBe(403);
expect(fetcher).not.toHaveBeenCalled();
});
it.each(["?limit=0", "?limit=101", "?limit=1.2", "?limit=", "?limit=1&limit=2", "?channel=100000000000000099", "?status=all", "?cursor=../secret"])('rejects invalid query %s without contacting Discord', async (query) => {
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
const fetcher = vi.fn();
vi.stubGlobal("fetch", fetcher);
const response = await GET(new Request(`https://portal.example/api/suggestions${query}`));
expect(response.status).toBe(400);
expect(fetcher).not.toHaveBeenCalled();
});
it("returns a read-only problem for writes", async () => {
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
const response = await POST(request());
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("GET, HEAD");
});
it("reports missing configuration without exposing environment values", async () => {
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", "");
const response = await GET(request());
expect(response.status).toBe(503);
expect(await response.json()).toMatchObject({ type: "urn:error:suggestions-not-configured", status: 503, instance: "/api/suggestions" });
});
it("returns sanitized problems for unexpected failures", async () => {
auth.session.mockRejectedValue(new Error("private session details"));
const response = await GET(request());
expect(response.status).toBe(503);
expect(await response.text()).not.toContain("private session details");
});
it("rejects a signed-in user without the required admin role", async () => {
auth.session.mockResolvedValue({ user: { roles: ["player"] } });
expect((await GET(request())).status).toBe(403);
});
it("rejects unauthenticated readers with a JSON problem instead of a redirect", async () => {
auth.session.mockResolvedValue(null);
const response = await GET(request());
expect(response.status).toBe(401);
expect(response.headers.get("content-type")).toBe("application/problem+json");
expect(response.headers.get("location")).toBeNull();
});
+14
View File
@@ -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;
+13 -3
View File
@@ -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 -1
View File
@@ -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",
@@ -56,6 +56,8 @@ describe("RconConsole", () => {
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 ↵");
+2
View File
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useRef, useState } from "react";
import {
createRconServer,
@@ -145,6 +146,7 @@ export function RconConsole({
</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 && (
<>
+2
View File
@@ -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,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,50 @@
import { getServerSession } from "next-auth";
import { problemDetails } from "@minecraft-account-manager/contracts";
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
import { problemInstance, problemResponse } from "@/lib/problem-response";
import { createSuggestionsClient, SuggestionsError } from "./suggestions";
type Client = ReturnType<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>) {
try {
const session = await getServerSession(adminAuthOptions);
if (!session) throw new SuggestionsError(401, "unauthorized", "Sign in as an administrator.");
const roles = (session.user as { roles?: unknown } | undefined)?.roles;
if (!Array.isArray(roles) || !roles.includes(requiredAdminRole)) throw new SuggestionsError(403, "forbidden", "This API is restricted to administrators.");
return Response.json(await operation(getClient()), { headers: { "cache-control": "no-store" } });
} catch (error) {
const safe = error instanceof SuggestionsError ? error : new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
const titles: Record<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,127 @@
import { afterEach, expect, it, vi } from "vitest";
import { createSuggestionsClient } from "./suggestions";
afterEach(() => vi.useRealTimers());
const guildId = "100000000000000001";
const forumId = "100000000000000002";
const threadId = "100000000000000009";
const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11, name: "More railway stations", owner_id: "100000000000000003", applied_tags: ["100000000000000004"], message_count: 3, thread_metadata: { archived: false, locked: false, archive_timestamp: "2026-01-01T00:00:00.000Z" } };
function setup(responses: Record<string, unknown>) {
const fetcher = vi.fn<typeof fetch>(async (input) => {
const path = String(input).replace("https://discord.com/api/v10", "");
if (!(path in responses)) throw new Error(`Unexpected path: ${path}`);
const value = responses[path];
return value instanceof Response ? value : Response.json(value);
});
const client = createSuggestionsClient({ token: "test-token", guildId, forumId, fetch: fetcher });
return { client, fetcher };
}
it("reads archived forum pages using Discord's archive timestamp cursor", async () => {
const cursor = "2026-01-01T00:00:00Z";
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=1`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } }], has_more: true },
[`/channels/${forumId}/threads/archived/public?limit=1&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
});
expect(await client.list({ status: "archived", limit: 1 })).toMatchObject({ items: [{ archived: true }], nextCursor: cursor });
expect(await client.list({ status: "archived", limit: 1, cursor })).toEqual({ items: [], nextCursor: null });
});
const message = { id: threadId, content: "Please add stations", timestamp: "2026-01-01T00:00:00.000Z", edited_timestamp: null, author: { id: "100000000000000003", username: "builder", global_name: "Builder" }, reactions: [{ emoji: { name: "👍" }, count: 4 }] };
it("returns the starter post and paginates discussion with authors and reactions", async () => {
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${threadId}`]: thread,
[`/channels/${threadId}/messages/${threadId}`]: message,
[`/channels/${threadId}/messages?limit=1`]: [{ ...message, id: "100000000000000020" }],
[`/channels/${threadId}/messages?limit=1&before=100000000000000020`]: [],
});
expect(await client.detail(threadId)).toMatchObject({ title: thread.name, originalPost: { content: message.content, author: { name: "Builder" }, reactions: [{ emoji: "👍", count: 4 }], createdAt: "2026-01-01T00:00:00Z" } });
expect(await client.messages(threadId, { limit: 1 })).toMatchObject({ items: [{ id: "100000000000000020" }], nextCursor: "100000000000000020" });
expect(await client.messages(threadId, { limit: 1, cursor: "100000000000000020" })).toEqual({ items: [], nextCursor: null });
});
it.each(["detail", "messages"] as const)("blocks %s of a thread outside the forum before reading messages", async (method) => {
const { client, fetcher } = setup({ [`/channels/${forumId}`]: forum, [`/channels/${threadId}`]: { ...thread, parent_id: "100000000000000099" } });
await expect(client[method](threadId)).rejects.toMatchObject({ status: 404 });
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("keeps a deleted starter post distinguishable from an empty message", async () => {
const { client } = setup({ [`/channels/${forumId}`]: forum, [`/channels/${threadId}`]: thread, [`/channels/${threadId}/messages/${threadId}`]: new Response(null, { status: 404 }) });
expect(await client.detail(threadId)).toMatchObject({ originalPost: null });
});
it("backs off on Discord rate limits without exposing Discord error bodies", async () => {
const { client, fetcher } = setup({ [`/channels/${forumId}`]: Response.json({ retry_after: 2.5, message: "secret upstream details" }, { status: 429 }) });
await expect(client.list()).rejects.toMatchObject({ status: 503, code: "discord-rate-limited", retryAfter: 3 });
await expect(client.list()).rejects.toMatchObject({ status: 503, code: "discord-rate-limited" });
expect(fetcher).toHaveBeenCalledTimes(1);
});
it.each([401, 403, 404, 500])("translates Discord %s into a safe service error", async (status) => {
const { client } = setup({ [`/channels/${forumId}`]: new Response("sensitive error", { status }) });
await expect(client.list()).rejects.toMatchObject({ status: 503 });
await expect(client.list()).rejects.not.toThrow("sensitive error");
});
it("sanitizes network failures", async () => {
const client = createSuggestionsClient({ token: "test", guildId, forumId, fetch: vi.fn().mockRejectedValue(new Error("token leaked by upstream")) });
await expect(client.list()).rejects.toMatchObject({ status: 503, message: "Discord suggestions are unavailable." });
});
it("coalesces concurrent reads and refreshes expired cache entries", async () => {
vi.useFakeTimers();
const { client, fetcher } = setup({ [`/channels/${forumId}`]: forum, [`/guilds/${guildId}/threads/active`]: { threads: [thread] } });
await Promise.all([client.list(), client.list()]);
expect(fetcher).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(30_001);
await client.list();
expect(fetcher).toHaveBeenCalledTimes(4);
});
it.each([{ status: "all" }, { limit: 0 }, { limit: 101 }, { limit: 1.5 }, { cursor: "../secret" }, { status: "archived", cursor: "bad-date" }])("validates list query %j before network access", async (query) => {
const { client, fetcher } = setup({});
await expect(client.list(query)).rejects.toMatchObject({ status: 400 });
expect(fetcher).not.toHaveBeenCalled();
});
it.each(["detail", "messages"] as const)("validates %s IDs before network access", async (method) => {
const { client, fetcher } = setup({});
await expect(client[method]("../secret")).rejects.toMatchObject({ status: 400 });
expect(fetcher).not.toHaveBeenCalled();
});
it.each([{ ...forumPlaceholder(), type: 0 }, { ...forumPlaceholder(), guild_id: "100000000000000099" }])("refuses a non-forum or wrong-guild configured channel", async (value) => {
const { client, fetcher } = setup({ [`/channels/${forumId}`]: value });
await expect(client.list()).rejects.toMatchObject({ status: 503 });
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("bounds concurrent upstream requests instead of flooding Discord", async () => {
const releases: (() => void)[] = [];
const fetcher = vi.fn<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" });
});
+157
View File
@@ -0,0 +1,157 @@
import type { MessagePage, SuggestionDetail, SuggestionMessage, Suggestion, SuggestionPage, SuggestionTag } from "./suggestion-types";
type Forum = { id: string; guild_id: string; type: number; available_tags: SuggestionTag[] };
type Thread = {
id: string; guild_id?: string; parent_id: string; type: number; name: string; owner_id: string;
applied_tags?: string[]; message_count?: number;
thread_metadata: { archived: boolean; locked: boolean; archive_timestamp: string };
};
type Message = {
id: string; content: string; timestamp: string; edited_timestamp?: string | null;
author: { id: string; username: string; global_name?: string | null };
reactions?: { emoji: { id?: string | null; name: string | null }; count: number }[];
};
export type ListQuery = { status?: string; cursor?: string; limit?: number };
export class SuggestionsError extends Error {
constructor(public readonly status: number, public readonly code: string, message: string, public readonly retryAfter?: number) {
super(message);
}
}
const snowflake = /^[1-9]\d{16,19}$/;
function checkId(id: string) {
if (!snowflake.test(id)) throw new SuggestionsError(400, "invalid-request", "A valid Discord ID is required.");
}
function limitValue(limit = 25) {
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new SuggestionsError(400, "invalid-request", "Limit must be between 1 and 100.");
return limit;
}
function timestamp(value: string | number) {
return new Date(value).toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function createSuggestionsClient(options: { token: string; guildId: string; forumId: string; fetch?: typeof fetch }) {
const { token, guildId, forumId } = options;
const fetcher = options.fetch ?? fetch;
const cache = new Map<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=${limit}${before}`);
return {
items: data.threads.filter(belongs).map((thread) => summary(thread, forum)),
nextCursor: data.has_more && data.threads.length ? data.threads.at(-1)!.thread_metadata.archive_timestamp.replace(/\.000Z$/, "Z") : null,
};
}
const data = await get<{ threads: Thread[] }>(`/guilds/${guildId}/threads/active`);
const threads = data.threads.filter(belongs).sort((a, b) => BigInt(a.id) > BigInt(b.id) ? -1 : 1)
.filter((thread) => !query.cursor || BigInt(thread.id) < BigInt(query.cursor));
const page = threads.slice(0, limit);
return { items: page.map((thread) => summary(thread, forum)), nextCursor: threads.length > limit ? page.at(-1)!.id : null };
},
};
}
@@ -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");
});
});
+74
View File
@@ -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,
}];
});
}
-42
View File
@@ -1,42 +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.
* [US-021 — Manage RCON server connections](us-021-rcon-connections.md) - Administrators manage encrypted Minecraft RCON server addresses.
* [US-022 — Operate servers through an RCON console](us-022-rcon-console.md) - Administrators execute bounded commands through the server-side portal proxy.
# 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.
-48
View File
@@ -1,48 +0,0 @@
# Design Update Log
## 2026-08-08
* **Verify**: Added encrypted, allowlisted administrator RCON connection management with credential-safe audits and a generated Drizzle migration.
* **Implement**: Added a bounded server-side RCON command console with safe output and error handling; internal-only deployment verification remains pending.
* **Refine**: Removed deployment-managed RCON endpoint allowlisting so administrators may configure any valid DNS hostname and port, while retaining IP-literal rejection and documenting the outbound-connectivity trust boundary.
* **Verify**: Confirmed the RCON console uses an authenticated internal ClusterIP deployment with secret-backed credentials and no public RCON exposure.
* **Refine**: Renamed RCON host configuration to server addresses, documented internal and external targets, and redesigned the console as a portal-colored terminal with a target bar, command prompt, and latest-response viewport.
* **Refine**: Consolidated RCON connection management into a full-width terminal workspace with header controls, modal add/edit/delete flows, terminal-contained notices, and no duplicate configuration panels.
* **Extend**: Added bounded page-memory RCON command recall with Arrow Up/Arrow Down navigation, unsent-draft restoration, and prompt focus retention after results and server changes.
* **Extend**: Retained up to 50 chronological page-memory RCON command/response exchanges in the auto-scrolling terminal transcript without persisting them.
## 2026-08-07
* **Extend**: Show each grouped recent address's latest approximate location and network classification on administrator user records.
* **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.
-39
View File
@@ -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)
-42
View File
@@ -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)
-39
View File
@@ -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)
-38
View File
@@ -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)
-42
View File
@@ -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)
-41
View File
@@ -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)
-44
View File
@@ -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)
-42
View File
@@ -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)
-54
View File
@@ -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)
-41
View File
@@ -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)
-39
View File
@@ -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)
-45
View File
@@ -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)
-55
View File
@@ -1,55 +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-07T23:02:04Z
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] Each grouped recent address shows the latest observation's approximate location and classification, including clear, VPN, proxy, Tor, hosting, and unknown classifications.
- [x] Missing IP enrichment is labelled as location unavailable and falls back to the stored observation classification.
- [x] Address groups use the enrichment associated with their latest observation.
- [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). Latest-observation enrichment and classification fallback are covered by [`apps/web/src/lib/access-address-groups.test.ts`](../apps/web/src/lib/access-address-groups.test.ts). The full 107-test suite, TypeScript, lint, OKF validation, and the production build pass.
# 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)
-45
View File
@@ -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)
-50
View File
@@ -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)
-46
View File
@@ -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)
-59
View File
@@ -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)
-61
View File
@@ -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)
-48
View File
@@ -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)
-52
View File
@@ -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)
-39
View File
@@ -1,39 +0,0 @@
---
type: User Story
title: Manage RCON server connections
description: Administrators manage encrypted connection settings for Minecraft RCON server addresses.
tags: [admin, rcon, minecraft, security, operations]
timestamp: 2026-08-08T13:40:43Z
story_id: US-021
status: verified
---
# User Story
As an administrator, I want to manage one or more Minecraft RCON connections, so that server operations can be reached from the existing protected console.
# Acceptance Criteria
- [x] Existing account-manager administrators use the terminal header to select, add, edit, test, enable or disable, and delete RCON server connections.
- [x] Each connection has a unique display name, server address, port, enabled state, and write-only password.
- [x] RCON passwords are encrypted with an authenticated cipher using a deployment-managed master key and are never returned to the browser, audit events, or application logs.
- [x] Updating a connection preserves its password unless an administrator explicitly supplies a replacement.
- [x] Administrators can save any syntactically valid DNS hostname and port without deployment-managed endpoint configuration; IP literals remain rejected.
- [x] Testing a connection authenticates through the server-side RCON proxy and reports a safe success or failure result.
- [x] Add and edit use accessible modal forms, and deleting a connection requires an explicit danger-confirmation modal.
- [x] Connection mutations independently recheck administrator authorization and create credential-safe audit events.
- [x] Database changes use a generated versioned Drizzle migration rather than schema push.
# Implementation
The unified terminal header selects connections and exposes add, test, enable or disable, edit, and delete controls. Add and edit use reusable accessible modal forms, while delete uses a danger-confirmation modal. Server actions manage endpoints without deployment-managed endpoint configuration, preserve write-only passwords, encrypt credentials with connection-bound AES-256-GCM, and emit credential-safe audit events. The `rcon_servers` table is delivered through generated migration `0006_curious_lester.sql`.
# Validation
Verified with RCON validation, encryption, gateway, component, and server-action tests; full workspace tests and type checks; web lint; OKF validation; Semgrep; dependency audit; and a production Next.js build on 2026-08-08. Validation confirms arbitrary valid internal or external DNS server addresses and ports no longer require deployment configuration while IP literals and malformed hostnames remain rejected. Action tests confirm independent authorization, password preservation, enabled-state rechecks, safe failures, and command audit redaction.
# Related Stories
- [Operate servers through an RCON console](us-022-rcon-console.md)
- [Authenticate administrators with SSO](us-011-admin-sso.md)
- [Deploy and operate securely](us-015-platform-operations.md)
-44
View File
@@ -1,44 +0,0 @@
---
type: User Story
title: Operate servers through an RCON console
description: Administrators execute bounded RCON commands through the server-side portal proxy.
tags: [admin, rcon, minecraft, console, security]
timestamp: 2026-08-08T14:46:10Z
story_id: US-022
status: verified
---
# User Story
As an administrator, I want an RCON console in the portal, so that I can operate configured Minecraft servers without exposing credentials to the browser.
# Acceptance Criteria
- [x] Existing account-manager administrators can select an enabled connection and execute an RCON command from the admin UI.
- [x] Browsers never connect to RCON directly; commands pass through the authenticated Next.js server runtime to the configured endpoint.
- [x] Every command independently rechecks administrator authorization and the selected connection's enabled state.
- [x] Commands are length-limited, reject control characters, execute with bounded concurrency and a timeout, and return bounded output.
- [x] Command responses are displayed safely, and commands and responses are not persisted in browser storage, audit data, or application logs.
- [x] Audit events record the administrator, connection, command verb and digest, success, and duration without recording complete commands or responses.
- [x] Authentication, timeout, and connection failures return safe operator-facing messages without credentials or stack traces.
- [x] The console spans the available content width and uses the portal color palette to present a terminal-style server header with connection controls, a single keyboard-accessible prompt, pending state, and scrollable transcript viewport.
- [x] Configured server addresses may be internal or external, and operators receive guidance that RCON network exposure and transport security remain their responsibility.
- [x] Connection errors and connection-operation results appear in the terminal viewport, including an actionable empty state when no connection exists.
- [x] The page has no duplicate connection form or connection-list panel outside the terminal workspace.
- [x] A bounded page-memory-only history lets administrators use Arrow Up and Arrow Down to recall and edit commands, then restore the unsent draft after the newest history entry.
- [x] After each command result and server selection change, focus returns to the command input for immediate editing or resubmission.
- [x] A bounded chronological transcript retains up to 50 page-memory command/response exchanges, labels each selected server and submitted command, places its safe response or error directly below it, and scrolls to the newest exchange.
# Implementation
The full-width portal-colored terminal workspace identifies and manages the selected server in its header, accepts one command through a keyboard-focused prompt, and displays connection-operation notices plus up to 50 chronological command/response exchanges in one auto-scrolling viewport. Each exchange labels its server and complete submitted command, then places the bounded safe response or error directly below it. It retains an actionable terminal and Add control when no connections exist, with no duplicate configuration panels. Up to 50 submitted commands remain only in page memory for editable Arrow Up/Arrow Down recall, including restoration of the current unsent draft; focus returns to the prompt after command results and server changes. The client invokes an authenticated server action that revalidates the enabled connection, decrypts its credential only in the server runtime, and executes one bounded command. The gateway limits each process to one operation per server and eight total operations, applies a five-second end-to-end deadline plus bounded cleanup, sanitizes and truncates output, and records keyed command lifecycle audits without command or response content.
# Validation
Application behavior is verified with gateway, validation, component, credential, and server-action tests; full workspace tests and type checks; web lint; OKF validation; Semgrep; dependency audit; and a production Next.js build on 2026-08-08. Component validation confirms the full-width workspace, labelled server and command controls, header actions, accessible modal forms, terminal-contained notices, the actionable no-server state, editable command-history navigation with draft restoration, prompt focus after command results and server changes, and ordered retention of repeated command/response exchanges. The SoMC GitOps deployment verifies Season 4 RCON through an authenticated internal ClusterIP Service backed by a Kubernetes Secret while product guidance also covers external server addresses.
# Related Stories
- [Manage RCON server connections](us-021-rcon-connections.md)
- [Operate settings and audit views](us-012-admin-operations.md)
- [Preserve an audit trail](us-010-audit-events.md)
+47
View File
@@ -0,0 +1,47 @@
# Admin suggestions API
The portal provides a read-only view of one Discord **forum channel**, using the existing NextAuth admin session and configured Keycloak role. Player sessions and Discord bot credentials are not accepted as API credentials. Sign in at `/admin/login` first; same-origin browser calls send the session cookie. Unattended machine authentication is not provided.
## Runtime configuration
- `DISCORD_SUGGESTIONS_FORUM_ID`: required forum channel snowflake, configured through GitOps. No source-code default.
- `DISCORD_GUILD_ID`: existing Discord guild configuration; the forum must belong to it.
- `DISCORD_BOT_TOKEN`: existing server-side credential, never returned to consumers.
The web workload needs these variables, not just the bot workload. The actual forum ID is maintained only in GitOps. No schema or bot Gateway changes are needed. The application uses Discord REST API v10 with the existing bot identity.
The bot must have **View Channel** and **Read Message History** for the forum and its posts. Message bodies are subject to Discord's **Message Content privileged intent**, including REST access: enable it for the application and obtain approval from Discord if required. Missing content can appear as an empty body rather than an HTTP error; check a known text post before production acceptance. No permissions or intents are changed by this feature.
## Endpoints
| GET endpoint | Result |
| --- | --- |
| `/api/suggestions?status=active&limit=25` | Active posts, newest-created first. `status` defaults to `active`. |
| `/api/suggestions?status=archived&limit=25` | Archived public forum posts, newest archive timestamp first. |
| `/api/suggestions/:id` | Suggestion metadata and `originalPost`; `null` when the starter message was deleted. |
| `/api/suggestions/:id/messages?limit=25` | Discussion messages, newest first, including the starter if reached. |
Lists return `{ items, nextCursor }`. Pass `nextCursor` back as the URL-encoded `cursor` parameter with the same status. Limits are integers from 1 to 100. Active cursors are thread IDs; archived cursors are UTC archive timestamps; message cursors are message IDs. Treat cursors as opaque. Messages may yield a final empty page because Discord does not provide a `has_more` flag for messages. Active threads are fetched via the guild active-threads endpoint, filtered to the forum, sorted, and paginated locally. Archives are paginated by Discord. This is a live view, not a consistent snapshot: threads can move between active and archived lists.
Suggestion fields: `id`, `title`, `authorId`, `createdAt`, `archived`, `locked`, `tags`, `messageCount`, `discordUrl`. Discord's message count is approximate, not a vote count. Detail messages include `id`, `author` (`id`, `name`), `content`, `createdAt`, `editedAt`, `reactions` (`emoji`, `count`), and `discordUrl`. Reactions remain reaction counts, not interpreted votes. Attachments, embeds, and rendered Discord Markdown are not mirrored; use Discord links for the original presentation.
## Errors and safety
Errors use RFC 9457 `application/problem+json`, HTTP-matching `status`, stable `urn:error:*` types, and safe details:
- `401 unauthorized`: no admin session; no redirect.
- `403 forbidden`: session lacks the required role.
- `400 invalid-request`: invalid ID, cursor, limit, status, or list/message query parameter.
- `404 suggestion-not-found`: inaccessible/deleted thread, or thread outside the configured forum.
- `405 method-not-allowed`: writes are unsupported; `Allow: GET, HEAD`.
- `503 suggestions-not-configured`, `discord-unavailable`, `discord-rate-limited`, or `discord-busy`: configuration, permissions, upstream failure, or temporary backoff. Rate limits and capacity limits include `Retry-After`.
Every endpoint rechecks admin authorization before any cached or fresh data is returned. Responses use `Cache-Control: no-store`. The in-process Discord cache lasts 30 seconds, contains at most 200 entries, coalesces identical concurrent reads, and permits at most eight concurrent upstream requests. Requests have an eight-second timeout and never follow redirects. Discord rate limits establish a per-client cooldown without retry loops. Caches and cooldowns are per process, not shared with bot Gateway activity or other replicas.
The client checks the configured forum's guild/type and each requested thread's parent/type before reading messages. It only calls fixed Discord endpoints with validated snowflakes. Error bodies and credentials are not logged or forwarded. There is no database synchronization and no Discord mutation support.
## Verification before rollout
Run the source checks and focused tests in `apps/web/src/lib/discord/suggestions.test.ts` and `apps/web/src/app/api/suggestions/route.test.ts`. Under the separately approved GitOps/release plan, verify one known active post, one archived post, message content, and admin/non-admin access against the actual forum. Offline fixtures do not establish live Discord permissions or intent approval.
References: [Discord threads](https://discord.com/developers/docs/topics/threads), [Channel resource](https://discord.com/developers/docs/resources/channel), [Message content intent](https://discord.com/developers/docs/events/gateway#message-content-intent).
+2
View File
@@ -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 -2
View File
@@ -14,8 +14,7 @@
"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",
-89
View File
@@ -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.`);