Compare commits

...
1 Commits
Author SHA1 Message Date
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
12 changed files with 338 additions and 21 deletions
@@ -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>;
}
@@ -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,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,
}];
});
}
+4
View File
@@ -1,5 +1,9 @@
# Design Update Log
## 2026-08-14
* **Verify**: Persist complete administrator-attributed RCON commands in correlated requested/completed audit events and add protected history search by command text, server, and administrator without retaining responses or credentials.
## 2026-08-08
* **Verify**: Added encrypted, allowlisted administrator RCON connection management with credential-safe audits and a generated Drizzle migration.
+9 -2
View File
@@ -3,7 +3,7 @@ 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
timestamp: 2026-08-14T01:23:35Z
story_id: US-010
status: verified
---
@@ -19,6 +19,9 @@ As an operator, I want security and identity activity recorded consistently, so
- [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] Every sent RCON command is represented in the audit ledger with its complete command text and acting SSO identity.
- [x] RCON responses and credentials are never persisted in audit events.
- [x] RCON command events are searchable by command text, server, and administrator.
- [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.
@@ -28,14 +31,18 @@ As an operator, I want security and identity activity recorded consistently, so
- [`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/lib/rcon-command-history.ts`](../apps/web/src/lib/rcon-command-history.ts)
- [`apps/web/src/app/admin/(console)/rcon/actions.ts`](../apps/web/src/app/admin/%28console%29/rcon/actions.ts)
- [`apps/web/src/app/admin/(console)/rcon/history/page.tsx`](../apps/web/src/app/admin/%28console%29/rcon/history/page.tsx)
- [`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.
The shared CloudEvent contract is covered by [`packages/contracts/test/contracts.test.ts`](../packages/contracts/test/contracts.test.ts). RCON action and history tests verify complete command attribution, correlated outcomes, audit-before-send behavior, and response and credential exclusion. All workspace tests, type checks, web lint, OKF validation, Semgrep, dependency audit, and the production build passed on 2026-08-14.
# Related Stories
- [Enrich login IPs](us-007-ip-intelligence.md)
- [Administer users](us-013-admin-user-management.md)
- [Operate servers through an RCON console](us-022-rcon-console.md)
+5 -2
View File
@@ -3,7 +3,7 @@ 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
timestamp: 2026-08-14T01:23:35Z
story_id: US-012
status: verified
---
@@ -18,6 +18,7 @@ As an administrator, I want operational settings and audit visibility, so that I
- [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] Administrators can locate RCON command events through command-text, server, and administrator filters.
- [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.
@@ -34,12 +35,14 @@ As an administrator, I want operational settings and audit visibility, so that I
- [`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)
- [`apps/web/src/app/admin/(console)/rcon/history/page.tsx`](../apps/web/src/app/admin/%28console%29/rcon/history/page.tsx)
# Validation
Admin routes are dynamic, role-protected, linted, and included in every production build.
Admin routes are dynamic, role-protected, linted, and included in every production build. RCON history filter and outcome-pairing tests pass, and the searchable history route was verified in the production route manifest on 2026-08-14.
# Related Stories
- [Administrator SSO](us-011-admin-sso.md)
- [Preserve an audit trail](us-010-audit-events.md)
- [Operate servers through an RCON console](us-022-rcon-console.md)
+15 -5
View File
@@ -3,7 +3,7 @@ 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
timestamp: 2026-08-14T01:23:35Z
story_id: US-022
status: verified
---
@@ -18,8 +18,14 @@ As an administrator, I want an RCON console in the portal, so that I can operate
- [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] Command responses are displayed safely, and commands and responses are not persisted in browser storage or application logs.
- [x] Every valid command is written to a `games.minecraft.account-manager.rcon.command.requested` audit event before it is sent.
- [x] The requested event records the complete command, administrator identity, server ID and name, timestamp, command verb, and command digest.
- [x] The corresponding `games.minecraft.account-manager.rcon.command.completed` event records success or failure, a safe failure reason, and duration without storing the RCON response.
- [x] A command is not sent if its required requested audit event cannot be recorded.
- [x] Administrators can search persistent RCON command history by command text and filter it by server and administrator.
- [x] History results show when the command was sent, who sent it, its target server, and its outcome.
- [x] Browser page-memory recall remains separate from persistent audit-backed history.
- [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.
@@ -31,11 +37,15 @@ As an administrator, I want an RCON console in the portal, so that I can operate
# 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.
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. Before gateway execution, the action records the complete command and administrator in a requested event; if that write fails, the command is not sent. A shared correlation ID associates the requested event with its credential- and response-safe completion event. 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, and sanitizes and truncates output.
The protected command-history route reads these audit events, pairs lifecycle outcomes by correlation ID, and searches the latest 100 matching commands by command text, server, and administrator. It remains separate from the console's page-memory recall and links each result to its complete CloudEvent envelope.
# 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.
Application behavior is verified with gateway, validation, component, credential, server-action, and command-history tests; full workspace tests and type checks; web lint; OKF validation; Semgrep; dependency audit; and a production Next.js build on 2026-08-14. Action tests confirm audit-before-send behavior, complete command and administrator attribution, lifecycle correlation, safe outcomes, and response and credential exclusion. History tests confirm bounded filters, lifecycle pairing, and pending outcomes; component tests confirm the persistent-history link remains distinct from page-memory command recall and transcript behavior. The SoMC GitOps deployment previously verified Season 4 RCON through an authenticated internal ClusterIP Service backed by a Kubernetes Secret while product guidance also covers external server addresses.
# Related Stories