Compare commits

..
8 Commits
Author SHA1 Message Date
dmg 7f6d69e0a7 feat(rcon): allow administrator-defined endpoints
CI / validate (push) Successful in 6m16s
Release / release (push) Successful in 8m27s
2026-08-08 07:45:24 -04:00
dmg f9ccfd821d feat(rcon): add admin server console
CI / validate (push) Successful in 6m5s
Release / release (push) Successful in 9m56s
2026-08-07 21:44:00 -04:00
dmg e43db34402 feat(admin): show recent address locations
CI / validate (push) Successful in 5m59s
Release / release (push) Successful in 7m38s
2026-08-07 19:09:33 -04:00
dmg 20dfc58d63 fix(admin): prefer non-anonymized map locations
CI / validate (push) Successful in 6m9s
Release / release (push) Successful in 8m18s
2026-08-07 18:36:59 -04:00
dmg 6425a5056a fix(release): publish versioned images to Docker Hub
CI / validate (push) Successful in 9m32s
Release / release (push) Successful in 13m11s
2026-08-03 20:36:06 -04:00
dmg 6fa33c9f7b feat(admin): show group schedule status
CI / validate (push) Successful in 6m9s
Release / release (push) Successful in 7m51s
2026-08-02 14:26:24 -04:00
dmg c131465ff5 feat(admission): add scheduled group access
CI / validate (push) Successful in 6m15s
Release / release (push) Successful in 7m49s
2026-08-02 14:04:48 -04:00
dmg eb1c5b6de4 fix(admin): collapse location list by default
CI / validate (push) Successful in 5m35s
Release / release (push) Successful in 7m40s
2026-08-02 12:11:07 -04:00
64 changed files with 5360 additions and 104 deletions
+4
View File
@@ -25,5 +25,9 @@ PROXYCHECK_API_KEY=
IP_INTELLIGENCE_CACHE_HOURS=48
BLOCK_HOSTING_IPS=false
# Optional independent 32-byte base64 RCON keys. When omitted, domain-separated keys are derived from AUTH_SECRET.
RCON_CREDENTIAL_KEY=
RCON_AUDIT_KEY=
# Structured Pino logging
LOG_LEVEL=info
+8 -9
View File
@@ -7,7 +7,6 @@ on:
permissions:
contents: write
packages: write
jobs:
release:
@@ -116,9 +115,9 @@ jobs:
apt-get install -y docker-ce-cli
fi
- name: Log in to Gitea container registry
- name: Log in to Docker Hub
if: steps.release.outputs.created == 'true'
run: echo "${{ secrets.CONTAINER_REGISTRY_TOKEN }}" | docker login git.garvis.dev -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Build and push web image
if: steps.release.outputs.created == 'true'
@@ -129,9 +128,9 @@ jobs:
--platform linux/amd64 \
--target runner \
--build-arg VERSION="$VERSION" \
-t "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}" \
-t "dmgarvis/minecraft-account-manager:${VERSION}" \
.
docker push "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}"
docker push "dmgarvis/minecraft-account-manager:${VERSION}"
- name: Build and push Discord bot image
if: steps.release.outputs.created == 'true'
@@ -142,9 +141,9 @@ jobs:
--platform linux/amd64 \
--target bot \
--build-arg VERSION="$VERSION" \
-t "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}" \
-t "dmgarvis/minecraft-account-manager-bot:${VERSION}" \
.
docker push "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}"
docker push "dmgarvis/minecraft-account-manager-bot:${VERSION}"
- name: Build and push migration image
if: steps.release.outputs.created == 'true'
@@ -155,9 +154,9 @@ jobs:
--platform linux/amd64 \
--target migrate \
--build-arg VERSION="$VERSION" \
-t "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}" \
-t "dmgarvis/minecraft-account-manager-migrate:${VERSION}" \
.
docker push "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}"
docker push "dmgarvis/minecraft-account-manager-migrate:${VERSION}"
- name: Create Gitea release and upload Velocity JAR
if: steps.release.outputs.created == 'true'
+4 -3
View File
@@ -76,12 +76,13 @@ The token is displayed once and stored only as a SHA-256 hash.
- PostgreSQL and Drizzle ORM
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
- Admin user search, account management, event exploration, DAU and confirmed-connection metrics, toggleable Natural Earth/OpenStreetMap user-location views, and automatic Discord nickname synchronization
- Exclusive group admission: unassigned users fall back to protected `everyone`, and administrators manage effective membership, access, and VPN/proxy/Tor exceptions through confirmed group workflows
- Admin user search, account management, event exploration, DAU and confirmed-connection metrics, toggleable Natural Earth/OpenStreetMap user-location views, internal RCON connection management and command proxying, and automatic Discord nickname synchronization
- Exclusive group admission: unassigned users fall back to protected `everyone`, and administrators manage effective membership, access, recurring UTC login windows, and VPN/proxy/Tor exceptions through confirmed group workflows
- Deployment-managed Discord guild ID and invite URL
- discord.js bot with `/register` and `/account`
- Java Edition online-mode accounts only
- Velocity admission checks are fail closed
- Velocity admission checks are fail closed; disabled group access overrides recurring schedules, which are evaluated only at login
- Static denial-message templates support validated player/group variables and next scheduled UTC window guidance
- ProxyCheck.io geolocation and VPN/proxy/Tor detection with a 48-hour PostgreSQL cache and group-scoped game-connection exceptions
See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities, [`docs/api-errors.md`](docs/api-errors.md) for the RFC 9457 API error contract, and [`docs/security-review.md`](docs/security-review.md) for implemented controls and production requirements, and [`docs/accessibility.md`](docs/accessibility.md) for the WCAG-oriented interface review.
+1
View File
@@ -22,6 +22,7 @@
"leaflet": "^1.9.4",
"next": "^16.2.1",
"next-auth": "^4.24.13",
"rcon-client": "^4.2.5",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"topojson-client": "^3.1.0",
@@ -1,4 +1,4 @@
import { groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { groupAccessWindows, groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
import type { ReactNode } from "react";
import Link from "next/link";
@@ -6,16 +6,18 @@ import { notFound } from "next/navigation";
import { AdminModalForm } from "@/components/admin-modal-form";
import { AdminUserTable } from "@/components/admin-user-table";
import { GroupPolicyControl } from "@/components/group-policy-control";
import { GroupScheduleEditor, GroupScheduleSummary } from "@/components/group-schedule-editor";
import { db } from "@/lib/database";
import { isEffectiveGroupMember } from "@/lib/group-management";
import { assignUserGroupFromRegistry } from "../../users/actions";
import { deleteGroup, setGroupAccess, setGroupAnonymizedNetworkAccess, updateGroupDetails } from "../actions";
import { deleteGroup, replaceGroupSchedule, setGroupAccess, setGroupAnonymizedNetworkAccess, updateGroupDetails } from "../actions";
const savedMessages: Record<string, string> = {
created: "Group created.",
details: "Group details updated.",
access: "Minecraft access policy updated.",
"network-access": "VPN, proxy, and Tor policy updated.",
schedule: "Weekly access schedule updated.",
group: "Member group updated.",
};
@@ -23,6 +25,7 @@ const errorMessages: Record<string, string> = {
"invalid-group": "Enter a valid name and a description of no more than 500 characters.",
"duplicate-group": "A group with that name already exists.",
"invalid-group-assignment": "The user or destination group no longer exists. No membership change was applied.",
"invalid-schedule": "Use valid, non-overlapping weekly access windows. Start and end cannot be identical.",
};
export const dynamic = "force-dynamic";
@@ -39,7 +42,7 @@ export default async function GroupPage({
const [group] = await db.select().from(groups).where(eq(groups.id, groupId)).limit(1);
if (!group) notFound();
const [allUsers, allGroups, memberships] = await Promise.all([
const [allUsers, allGroups, memberships, accessWindows] = await Promise.all([
db.select({
id: users.id,
firstName: users.firstName,
@@ -65,6 +68,11 @@ export default async function GroupPage({
.from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
db.select({ userId: userGroupMemberships.userId, groupId: userGroupMemberships.groupId })
.from(userGroupMemberships),
db.select({
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id))
.orderBy(groupAccessWindows.startMinuteOfWeek),
]);
const assignmentByUser = Object.fromEntries(memberships.map((membership) => [membership.userId, membership.groupId]));
const memberUsers = allUsers.filter((user) => isEffectiveGroupMember(user.id, assignmentByUser, group));
@@ -101,8 +109,17 @@ export default async function GroupPage({
<section aria-labelledby="group-policy-heading" className="mt-10 border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
<div className="border-b border-line pb-4"><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Admission controls</p><h2 className="mt-2 font-display text-3xl font-black uppercase" id="group-policy-heading">Group policies</h2></div>
<div className="mt-6 grid gap-6 sm:grid-cols-2">
<PolicyDetail description="Controls whether members can connect to Minecraft." label="Minecraft access"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="Minecraft access" returnLocation="detail" /></PolicyDetail>
<PolicyDetail description="Allows confirmed VPN, proxy, and Tor connections." label="VPN / proxy / Tor"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="VPN / proxy / Tor" returnLocation="detail" /></PolicyDetail>
<PolicyDetail description="Controls whether members can connect to Minecraft. Disabled access always overrides the schedule." label="Minecraft access"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="Minecraft access" returnLocation="detail" /></PolicyDetail>
<PolicyDetail description="Allows confirmed VPN, proxy, and Tor connections after access and schedule checks pass." label="VPN / proxy / Tor"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="VPN / proxy / Tor" returnLocation="detail" /></PolicyDetail>
</div>
<div className="mt-7 scroll-mt-6 border-t border-line pt-6" id="group-schedule">
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
<div className="max-w-2xl"><h3 className="font-mono text-xs font-bold uppercase">Weekly access schedule</h3><p className="mt-2 text-xs leading-5 text-muted">When Minecraft access is enabled, members may log in only during these recurring UTC windows. Existing sessions are not disconnected when a window ends.</p><div className="mt-4"><GroupScheduleSummary windows={accessWindows} /></div></div>
<AdminModalForm action={replaceGroupSchedule} description={`Replace the complete weekly access schedule for ${group.name}. Minecraft access must still be enabled.`} submitLabel="Save schedule" title={`Schedule ${group.name}`} triggerClassName="shrink-0 border border-ink px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-wider" triggerLabel="Edit schedule">
<input name="groupId" type="hidden" value={group.id} />
<GroupScheduleEditor windows={accessWindows} />
</AdminModalForm>
</div>
</div>
</section>
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const actionState = vi.hoisted(() => ({
selected: [] as unknown[][],
inserted: [] as unknown[],
deleted: 0,
authorized: 0,
failAudit: false,
}));
vi.mock("@/lib/auth/require-admin", () => ({
requireAdminSession: async () => {
actionState.authorized += 1;
return { email: "admin@example.test", name: "Admin" };
},
}));
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
vi.mock("next/navigation", () => ({
redirect: (path: string) => {
throw new Error(`REDIRECT:${path}`);
},
}));
vi.mock("@/lib/database", () => {
function selection(response: unknown[]) {
const chain: Record<string, unknown> = {};
for (const method of ["from", "where", "orderBy"]) chain[method] = () => chain;
chain.limit = () => Promise.resolve(response);
chain.then = (resolve: (value: unknown[]) => unknown, reject: (reason: unknown) => unknown) =>
Promise.resolve(response).then(resolve, reject);
return chain;
}
const tx = {
execute: async () => undefined,
select: () => selection(actionState.selected.shift() ?? []),
delete: () => ({ where: async () => { actionState.deleted += 1; } }),
insert: () => ({
values: async (value: unknown) => {
if (actionState.failAudit && !Array.isArray(value)) throw new Error("audit unavailable");
actionState.inserted.push(value);
},
}),
};
return { db: { transaction: async (callback: (transaction: typeof tx) => Promise<unknown>) => callback(tx) } };
});
import { replaceGroupSchedule } from "./actions";
function scheduleForm() {
const formData = new FormData();
formData.set("groupId", "11111111-1111-4111-8111-111111111111");
formData.append("startMinuteOfWeek", "6960");
formData.append("endMinuteOfWeek", "7200");
return formData;
}
describe("replaceGroupSchedule", () => {
beforeEach(() => {
actionState.selected = [
[{ id: "11111111-1111-4111-8111-111111111111", name: "Friday friends" }],
[{ startMinuteOfWeek: 480, endMinuteOfWeek: 540 }],
];
actionState.inserted = [];
actionState.deleted = 0;
actionState.authorized = 0;
actionState.failAudit = false;
});
it("reauthorizes and replaces all windows with an audit in one transaction", async () => {
await expect(replaceGroupSchedule(scheduleForm())).rejects.toThrow("REDIRECT:/admin/groups/11111111-1111-4111-8111-111111111111?saved=schedule");
expect(actionState.authorized).toBe(1);
expect(actionState.deleted).toBe(1);
expect(actionState.inserted[0]).toEqual([{
groupId: "11111111-1111-4111-8111-111111111111",
startMinuteOfWeek: 6960,
endMinuteOfWeek: 7200,
}]);
expect(actionState.inserted[1]).toEqual(expect.objectContaining({
type: "games.minecraft.account-manager.group.schedule-updated",
data: expect.objectContaining({
previousWindows: [{ startMinuteOfWeek: 480, endMinuteOfWeek: 540 }],
windows: [{ startMinuteOfWeek: 6960, endMinuteOfWeek: 7200 }],
adminEmail: "admin@example.test",
}),
}));
});
it("does not report success when the atomic audit write fails", async () => {
actionState.failAudit = true;
await expect(replaceGroupSchedule(scheduleForm())).rejects.toThrow("audit unavailable");
expect(actionState.inserted).toEqual([[{
groupId: "11111111-1111-4111-8111-111111111111",
startMinuteOfWeek: 6960,
endMinuteOfWeek: 7200,
}]]);
});
});
@@ -1,7 +1,7 @@
"use server";
import { randomUUID } from "node:crypto";
import { events, groups, userGroupMemberships } from "@minecraft-account-manager/database";
import { events, groupAccessWindows, groups, userGroupMemberships } from "@minecraft-account-manager/database";
import { getClientIp } from "@minecraft-account-manager/network";
import { and, eq, ne, sql } from "drizzle-orm";
import { headers } from "next/headers";
@@ -9,6 +9,7 @@ import { redirect } from "next/navigation";
import { requireAdminSession } from "@/lib/auth/require-admin";
import { db } from "@/lib/database";
import { editableGroupName, groupSlug, validateGroupDetails } from "@/lib/group-management";
import { parseScheduleWindows } from "@/lib/group-schedule";
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -175,6 +176,41 @@ export async function setGroupAnonymizedNetworkAccess(formData: FormData) {
return updateGroupPolicy(formData, "anonymized-networks");
}
export async function replaceGroupSchedule(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const windows = parseScheduleWindows(formData);
if (!UUID_PATTERN.test(groupId) || !windows) redirect(groupPath(groupId, "error=invalid-schedule"));
const ipAddress = await auditContext();
const updated = await db.transaction(async (tx) => {
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
const [group] = await tx.select({ id: groups.id, name: groups.name }).from(groups)
.where(eq(groups.id, groupId)).limit(1);
if (!group) return null;
const previous = await tx.select({
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id));
await tx.delete(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id));
if (windows.length) {
await tx.insert(groupAccessWindows).values(windows.map((window) => ({ ...window, groupId: group.id })));
}
await tx.insert(events).values({
id: randomUUID(),
source: "/web/admin",
type: "games.minecraft.account-manager.group.schedule-updated",
subject: `group/${group.id}`,
time: new Date(),
data: auditData(admin, { name: group.name, previousWindows: previous, windows }),
ipAddress: ipAddress ?? null,
});
return group;
});
if (!updated) redirect("/admin/groups?error=unknown-group");
redirect(groupPath(updated.id, "saved=schedule"));
}
export async function deleteGroup(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
@@ -1,10 +1,11 @@
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { groupAccessWindows, groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { asc, count, desc } from "drizzle-orm";
import Link from "next/link";
import { AdminModalForm } from "@/components/admin-modal-form";
import { GroupPolicyControl } from "@/components/group-policy-control";
import { db } from "@/lib/database";
import { effectiveGroupMemberCount } from "@/lib/group-management";
import { groupScheduleStatus } from "@/lib/group-schedule";
import { createGroup, setGroupAccess, setGroupAnonymizedNetworkAccess } from "./actions";
const errors: Record<string, string> = {
@@ -26,11 +27,15 @@ export const dynamic = "force-dynamic";
export default async function GroupsPage({ searchParams }: { searchParams: Promise<{ error?: string; saved?: string }> }) {
const query = await searchParams;
const [allGroups, memberships, [registeredUsers]] = await Promise.all([
const [allGroups, memberships, [registeredUsers], scheduleCounts] = await Promise.all([
db.select().from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
db.select({ groupId: userGroupMemberships.groupId }).from(userGroupMemberships),
db.select({ count: count() }).from(users),
db.select({ groupId: groupAccessWindows.groupId, count: count() })
.from(groupAccessWindows)
.groupBy(groupAccessWindows.groupId),
]);
const scheduleCountByGroup = new Map(scheduleCounts.map((schedule) => [schedule.groupId, Number(schedule.count)]));
return (
<main className="mx-auto max-w-6xl px-6 py-14">
<header className="flex flex-col gap-6 border-b border-line pb-8 sm:flex-row sm:items-end sm:justify-between">
@@ -60,10 +65,10 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">{savedMessages[query.saved] ?? "Group updated."}</p>}
<div className="mt-9 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
<table className="w-full min-w-[760px] border-collapse text-left">
<table className="w-full min-w-[880px] border-collapse text-left">
<caption className="sr-only">Access groups and their effective policies</caption>
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
<tr><th className="p-4" scope="col">Name</th><th className="p-4" scope="col">Minecraft access</th><th className="p-4" scope="col">VPN access</th><th className="p-4 text-right" scope="col">Users</th></tr>
<tr><th className="p-4" scope="col">Name</th><th className="p-4" scope="col">Minecraft access</th><th className="p-4" scope="col">Schedule</th><th className="p-4" scope="col">VPN access</th><th className="p-4 text-right" scope="col">Users</th></tr>
</thead>
<tbody className="divide-y divide-line">
{allGroups.map((group) => {
@@ -72,6 +77,7 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi
memberships.map((membership) => membership.groupId),
group,
);
const scheduleStatus = groupScheduleStatus(scheduleCountByGroup.get(group.id) ?? 0);
return (
<tr className="transition-colors hover:bg-canvas/60" key={group.id}>
<th className="p-4 text-left" scope="row">
@@ -79,6 +85,7 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi
{group.isDefault && <span className="ml-3 bg-ink px-2 py-1 font-mono text-[8px] font-bold uppercase text-canvas">Default</span>}
</th>
<td className="p-4"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberCount} policy="Minecraft access" returnLocation="list" /></td>
<td className="p-4"><Link aria-label={`${scheduleStatus}. Edit schedule for ${group.name}`} className={`font-mono text-[10px] font-bold uppercase underline underline-offset-4 ${scheduleStatus === "Unrestricted" ? "text-muted" : "text-accent"}`} href={`/admin/groups/${group.id}#group-schedule`}>{scheduleStatus}</Link></td>
<td className="p-4"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberCount} policy="VPN / proxy / Tor" returnLocation="list" /></td>
<td className="p-4 text-right font-mono text-sm font-bold">{memberCount}</td>
</tr>
@@ -38,6 +38,7 @@ export default async function AdminConsoleLayout({ children }: { children: React
<Link className="hover:text-accent" href="/admin/settings">Settings</Link>
<Link className="hover:text-accent" href="/admin/users">Users</Link>
<Link className="hover:text-accent" href="/admin/groups">Groups</Link>
<Link className="hover:text-accent" href="/admin/rcon">RCON</Link>
<Link className="hover:text-accent" href="/admin/events">Events</Link>
</nav>
<AdminSignOutButton />
+2 -1
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map";
import { db } from "@/lib/database";
import { fillDailySeries, mergeRiskActivity, type DailyCount } from "@/lib/admin-metrics";
import { parseUserLocation, parseUserNetwork } from "@/lib/user-location-map";
import { MAP_LOCATION_CLASSIFICATIONS, parseUserLocation, parseUserNetwork } from "@/lib/user-location-map";
export const dynamic = "force-dynamic";
@@ -58,6 +58,7 @@ export default async function AdminDashboardPage() {
))
.where(and(
isNotNull(ipObservations.userId),
inArray(ipIntelligence.classification, MAP_LOCATION_CLASSIFICATIONS),
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'latitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'latitude')::double precision between -90 and 90 else false end`,
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'longitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'longitude')::double precision between -180 and 180 else false end`,
))
@@ -0,0 +1,225 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const actionState = vi.hoisted(() => ({
authorized: 0,
selected: [] as unknown[],
transactionSelected: [] as unknown[],
updates: [] as Record<string, unknown>[],
inserts: [] as unknown[],
audits: [] as Array<{ admin: unknown; subject: string; type: string; data: Record<string, unknown> }>,
executions: [] as Array<{ connection: Record<string, unknown>; command: string }>,
gatewayResult: { ok: true, response: "private response" } as
| { ok: true; response: string }
| { ok: false; reason: "busy" | "timeout" | "unavailable" },
}));
vi.mock("@/lib/auth/require-admin", () => ({
requireAdminSession: async () => {
actionState.authorized += 1;
return { email: "admin@example.test", name: "Admin" };
},
}));
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
vi.mock("next/navigation", () => ({
redirect: (path: string) => {
throw new Error(`REDIRECT:${path}`);
},
}));
vi.mock("@/lib/database", () => {
function selection(result: unknown[]) {
const chain = {
from: () => chain,
where: () => chain,
limit: async () => result,
};
return chain;
}
const tx = {
execute: async () => undefined,
select: () => selection(actionState.transactionSelected),
update: () => ({
set: (value: Record<string, unknown>) => ({
where: async () => { actionState.updates.push(value); },
}),
}),
insert: () => ({
values: async (value: unknown) => { actionState.inserts.push(value); },
}),
};
return {
db: {
select: () => selection(actionState.selected),
transaction: async (callback: (transaction: typeof tx) => Promise<unknown>) => callback(tx),
},
};
});
vi.mock("@/lib/rcon-validation", () => ({
validateRconCommand: (value: unknown) => typeof value === "string" && value.trim() ? value.trim() : null,
validateRconConnection: (input: { name?: string; host?: string; port?: number; password?: string }) => {
if (!input.name || !input.host || !input.port) return null;
return input;
},
}));
vi.mock("@/lib/rcon-credentials", () => ({
decryptRconPassword: () => "decrypted-password",
encryptRconPassword: vi.fn(),
rconCommandDigest: () => "hmac-sha256:v1:digest",
}));
vi.mock("@/lib/rcon-gateway", () => ({
executeRcon: async (connection: Record<string, unknown>, command: string) => {
actionState.executions.push({ connection, command });
return actionState.gatewayResult;
},
testRconConnection: vi.fn(),
}));
vi.mock("@/lib/audit", () => ({
recordAdminSubjectEvent: async (
admin: unknown,
subject: string,
type: string,
data: Record<string, unknown>,
) => {
actionState.audits.push({ admin, subject, type, data });
},
}));
import {
createRconServer,
deleteRconServer,
executeRconCommand,
setRconServerEnabled,
testSavedRconServer,
updateRconServer,
} from "./actions";
const serverId = "11111111-1111-4111-8111-111111111111";
const savedServer = {
id: serverId,
name: "Season 4",
host: "season4.somc.svc.cluster.local",
port: 25575,
encryptedPassword: "ciphertext",
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
describe("RCON server actions", () => {
beforeEach(() => {
actionState.authorized = 0;
actionState.selected = [];
actionState.transactionSelected = [];
actionState.updates = [];
actionState.inserts = [];
actionState.audits = [];
actionState.executions = [];
actionState.gatewayResult = { ok: true, response: "private response" };
});
it("independently authorizes every exported operation before accepting input", async () => {
await expect(createRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
await expect(updateRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
await expect(setRconServerEnabled(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=unknown-connection");
await expect(deleteRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=confirmation-required");
await expect(testSavedRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=connection-unavailable");
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, new FormData())).resolves.toEqual({
status: "error",
message: "Enter one command of at most 1,024 bytes without control characters.",
serverId: "",
});
expect(actionState.authorized).toBe(6);
});
it("preserves the encrypted password on an unrelated connection update", async () => {
actionState.transactionSelected = [savedServer];
const formData = new FormData();
formData.set("serverId", serverId);
formData.set("name", "Renamed server");
formData.set("host", "season4.somc.svc.cluster.local");
formData.set("port", "25575");
formData.set("password", "");
formData.set("enabled", "yes");
await expect(updateRconServer(formData)).rejects.toThrow("REDIRECT:/admin/rcon?saved=updated");
expect(actionState.updates).toEqual([
expect.objectContaining({ encryptedPassword: "ciphertext", enabled: true }),
]);
expect(JSON.stringify(actionState.inserts)).not.toContain("ciphertext");
expect(JSON.stringify(actionState.inserts)).not.toContain("decrypted-password");
});
it("rechecks enabled saved state and records credential-safe command lifecycle audits", async () => {
actionState.selected = [savedServer];
const formData = new FormData();
formData.set("serverId", serverId);
formData.set("command", "say private value");
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
status: "success",
message: "private response",
serverId,
});
expect(actionState.authorized).toBe(1);
expect(actionState.executions).toEqual([{
connection: expect.objectContaining({ id: serverId, enabled: true, password: "decrypted-password" }),
command: "say private value",
}]);
expect(actionState.audits).toEqual([
expect.objectContaining({
admin: { email: "admin@example.test", name: "Admin" },
subject: `rcon-server/${serverId}`,
type: "games.minecraft.account-manager.rcon.command.requested",
data: expect.objectContaining({ verb: "say", commandDigest: "hmac-sha256:v1:digest" }),
}),
expect.objectContaining({
subject: `rcon-server/${serverId}`,
type: "games.minecraft.account-manager.rcon.command.completed",
data: expect.objectContaining({ success: true, durationMs: expect.any(Number) }),
}),
]);
const serializedAudits = JSON.stringify(actionState.audits);
expect(serializedAudits).not.toContain("private value");
expect(serializedAudits).not.toContain("private response");
expect(serializedAudits).not.toContain("decrypted-password");
});
it.each([
["busy", "Another command is already running for this server."],
["timeout", "The RCON request timed out."],
["unavailable", "The RCON server was unavailable or rejected authentication."],
] as const)("returns a safe %s failure without exposing transport details", async (reason, message) => {
actionState.selected = [savedServer];
actionState.gatewayResult = { ok: false, reason };
const formData = new FormData();
formData.set("serverId", serverId);
formData.set("command", "list");
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
status: "error",
message,
serverId,
});
expect(JSON.stringify(actionState.audits)).not.toContain("decrypted-password");
});
it("does not execute or audit when the enabled connection is unavailable", async () => {
const formData = new FormData();
formData.set("serverId", serverId);
formData.set("command", "list");
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
status: "error",
message: "That RCON connection is disabled or unavailable.",
serverId,
});
expect(actionState.executions).toEqual([]);
expect(actionState.audits).toEqual([]);
});
});
@@ -0,0 +1,256 @@
"use server";
import { randomUUID } from "node:crypto";
import { events, rconServers } from "@minecraft-account-manager/database";
import { getClientIp } from "@minecraft-account-manager/network";
import { and, eq, sql } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { requireAdminSession } from "@/lib/auth/require-admin";
import { db } from "@/lib/database";
import { isUniqueConstraintViolation } from "@/lib/database-errors";
import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "@/lib/rcon-credentials";
import { executeRcon, testRconConnection } from "@/lib/rcon-gateway";
import { recordAdminSubjectEvent } from "@/lib/audit";
import { validateRconCommand, validateRconConnection } from "@/lib/rcon-validation";
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
type Admin = Awaited<ReturnType<typeof requireAdminSession>>;
export type RconCommandState = {
status: "idle" | "success" | "error";
message: string;
serverId: string;
};
function formConnection(formData: FormData, passwordRequired: boolean) {
return validateRconConnection({
name: formData.get("name"),
host: formData.get("host"),
port: formData.get("port"),
password: formData.get("password"),
}, { passwordRequired });
}
async function auditContext() {
const requestHeaders = await headers();
return getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
}
function auditData(admin: Admin, data: Record<string, unknown>) {
return { ...data, adminEmail: admin.email, adminName: admin.name };
}
function rconPath(query: string) {
return `/admin/rcon?${query}`;
}
export async function createRconServer(formData: FormData) {
const admin = await requireAdminSession();
const details = formConnection(formData, true);
if (!details?.password) redirect(rconPath("error=invalid-connection"));
const id = randomUUID();
let encryptedPassword: string;
try {
encryptedPassword = encryptRconPassword(details.password, id);
} catch {
redirect(rconPath("error=configuration"));
}
const ipAddress = await auditContext();
try {
await db.transaction(async (tx) => {
await tx.insert(rconServers).values({
id,
name: details.name,
host: details.host,
port: details.port,
encryptedPassword,
enabled: formData.get("enabled") === "yes",
});
await tx.insert(events).values({
id: randomUUID(),
source: "/web/admin",
type: "games.minecraft.account-manager.rcon.connection.created",
subject: `rcon-server/${id}`,
time: new Date(),
data: auditData(admin, { name: details.name, host: details.host, port: details.port }),
ipAddress: ipAddress ?? null,
});
});
} catch (error) {
if (isUniqueConstraintViolation(error, "rcon_servers_name_uidx")) redirect(rconPath("error=duplicate-name"));
redirect(rconPath("error=save-failed"));
}
redirect(rconPath("saved=created"));
}
export async function updateRconServer(formData: FormData) {
const admin = await requireAdminSession();
const serverId = String(formData.get("serverId") ?? "");
const details = formConnection(formData, false);
if (!UUID_PATTERN.test(serverId) || !details) redirect(rconPath("error=invalid-connection"));
const ipAddress = await auditContext();
let result: string | null;
try {
result = await db.transaction(async (tx) => {
await tx.execute(sql`select ${rconServers.id} from ${rconServers} where ${rconServers.id} = ${serverId} for update`);
const [current] = await tx.select().from(rconServers).where(eq(rconServers.id, serverId)).limit(1);
if (!current) return null;
let encryptedPassword = current.encryptedPassword;
if (details.password) encryptedPassword = encryptRconPassword(details.password, current.id);
const enabled = formData.get("enabled") === "yes";
await tx.update(rconServers).set({
name: details.name,
host: details.host,
port: details.port,
encryptedPassword,
enabled,
updatedAt: new Date(),
}).where(eq(rconServers.id, current.id));
await tx.insert(events).values({
id: randomUUID(),
source: "/web/admin",
type: "games.minecraft.account-manager.rcon.connection.updated",
subject: `rcon-server/${current.id}`,
time: new Date(),
data: auditData(admin, {
name: details.name,
host: details.host,
port: details.port,
enabled,
passwordReplaced: Boolean(details.password),
}),
ipAddress: ipAddress ?? null,
});
return current.id;
});
} catch (error) {
if (isUniqueConstraintViolation(error, "rcon_servers_name_uidx")) redirect(rconPath("error=duplicate-name"));
redirect(rconPath("error=save-failed"));
}
if (!result) redirect(rconPath("error=unknown-connection"));
redirect(rconPath("saved=updated"));
}
export async function setRconServerEnabled(formData: FormData) {
const admin = await requireAdminSession();
const serverId = String(formData.get("serverId") ?? "");
if (!UUID_PATTERN.test(serverId)) redirect(rconPath("error=unknown-connection"));
const enabled = formData.get("enabled") === "yes";
const ipAddress = await auditContext();
const result = await db.transaction(async (tx) => {
await tx.execute(sql`select ${rconServers.id} from ${rconServers} where ${rconServers.id} = ${serverId} for update`);
const [current] = await tx.select().from(rconServers).where(eq(rconServers.id, serverId)).limit(1);
if (!current) return "missing" as const;
if (enabled && !validateRconConnection({ ...current, password: "placeholder" }, { passwordRequired: true })) return "invalid" as const;
await tx.update(rconServers).set({ enabled, updatedAt: new Date() }).where(eq(rconServers.id, current.id));
await tx.insert(events).values({
id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.enabled-updated",
subject: `rcon-server/${current.id}`, time: new Date(), data: auditData(admin, { name: current.name, enabled }), ipAddress: ipAddress ?? null,
});
return "updated" as const;
});
if (result === "missing") redirect(rconPath("error=unknown-connection"));
if (result === "invalid") redirect(rconPath("error=invalid-connection"));
redirect(rconPath(`saved=${enabled ? "enabled" : "disabled"}`));
}
export async function deleteRconServer(formData: FormData) {
const admin = await requireAdminSession();
const serverId = String(formData.get("serverId") ?? "");
if (!UUID_PATTERN.test(serverId) || formData.get("confirmation") !== serverId) redirect(rconPath("error=confirmation-required"));
const ipAddress = await auditContext();
const deleted = await db.transaction(async (tx) => {
const [server] = await tx.delete(rconServers).where(eq(rconServers.id, serverId)).returning({ id: rconServers.id, name: rconServers.name });
if (!server) return null;
await tx.insert(events).values({
id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.deleted",
subject: `rcon-server/${server.id}`, time: new Date(), data: auditData(admin, { name: server.name }), ipAddress: ipAddress ?? null,
});
return server;
});
if (!deleted) redirect(rconPath("error=unknown-connection"));
redirect(rconPath("saved=deleted"));
}
async function savedConnection(serverId: string, requireEnabled: boolean) {
if (!UUID_PATTERN.test(serverId)) return null;
const [server] = await db.select().from(rconServers).where(requireEnabled
? and(eq(rconServers.id, serverId), eq(rconServers.enabled, true))
: eq(rconServers.id, serverId)).limit(1);
if (!server) return null;
const validated = validateRconConnection({ ...server, password: "placeholder" }, { passwordRequired: true });
if (!validated) return null;
try {
return { ...server, password: decryptRconPassword(server.encryptedPassword, server.id) };
} catch {
return null;
}
}
export async function testSavedRconServer(formData: FormData) {
const admin = await requireAdminSession();
const serverId = String(formData.get("serverId") ?? "");
const server = await savedConnection(serverId, false);
if (!server) redirect(rconPath("error=connection-unavailable"));
const started = Date.now();
const result = await testRconConnection(server);
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.connection.tested", {
serverId: server.id,
name: server.name,
success: result.ok,
reason: result.ok ? null : result.reason,
durationMs: Date.now() - started,
});
redirect(rconPath(result.ok ? "saved=tested" : `error=test-${result.reason}`));
}
export async function executeRconCommand(
_previous: RconCommandState,
formData: FormData,
): Promise<RconCommandState> {
const admin = await requireAdminSession();
const serverId = String(formData.get("serverId") ?? "");
const command = validateRconCommand(formData.get("command"));
if (!command) return { status: "error", message: "Enter one command of at most 1,024 bytes without control characters.", serverId };
const server = await savedConnection(serverId, true);
if (!server) return { status: "error", message: "That RCON connection is disabled or unavailable.", serverId };
const verb = command.split(/\s+/u, 1)[0]!.toLowerCase().slice(0, 64);
let commandDigest: string;
try {
commandDigest = rconCommandDigest(command);
} catch {
return { status: "error", message: "RCON command auditing is not configured.", serverId };
}
const started = Date.now();
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.requested", {
serverId: server.id,
name: server.name,
verb,
commandDigest,
});
const result = await executeRcon(server, command);
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.completed", {
serverId: server.id,
name: server.name,
verb,
commandDigest,
success: result.ok,
reason: result.ok ? null : result.reason,
durationMs: Date.now() - started,
});
if (!result.ok) {
const message = result.reason === "busy"
? "Another command is already running for this server."
: result.reason === "timeout"
? "The RCON request timed out."
: "The RCON server was unavailable or rejected authentication.";
return { status: "error", message, serverId };
}
return { status: "success", message: result.response || "Command completed with no response.", serverId };
}
@@ -0,0 +1,147 @@
import { rconServers } from "@minecraft-account-manager/database";
import { asc } from "drizzle-orm";
import { RconConsole } from "@/components/rcon-console";
import { db } from "@/lib/database";
import {
createRconServer,
deleteRconServer,
setRconServerEnabled,
testSavedRconServer,
updateRconServer,
} from "./actions";
export const dynamic = "force-dynamic";
const savedMessages: Record<string, string> = {
created: "RCON connection created.",
updated: "RCON connection updated.",
enabled: "RCON connection enabled.",
disabled: "RCON connection disabled.",
deleted: "RCON connection deleted.",
tested: "RCON authentication succeeded.",
};
const errorMessages: Record<string, string> = {
"invalid-connection": "Enter a valid DNS hostname, port, name, and password.",
"duplicate-name": "Connection names must be unique.",
configuration: "RCON credential encryption is not configured.",
"save-failed": "The RCON connection could not be saved.",
"unknown-connection": "That RCON connection no longer exists.",
"confirmation-required": "Confirm the connection before deleting it.",
"connection-unavailable": "The connection is invalid or its credential is unavailable.",
"test-busy": "Another RCON operation is already using that server.",
"test-timeout": "RCON authentication timed out.",
"test-unavailable": "The RCON server was unavailable or rejected authentication.",
};
function queryValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
export default async function RconPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const query = await searchParams;
const saved = queryValue(query.saved);
const error = queryValue(query.error);
const servers = await db.select({
id: rconServers.id,
name: rconServers.name,
host: rconServers.host,
port: rconServers.port,
enabled: rconServers.enabled,
updatedAt: rconServers.updatedAt,
}).from(rconServers).orderBy(asc(rconServers.name));
return (
<main className="mx-auto max-w-6xl px-6 py-14">
<header className="border-b border-line pb-8">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Server operations</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">RCON</h1>
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Run commands through the portal backend. RCON endpoints remain internal and credentials are never sent to the browser.</p>
</header>
{saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">{savedMessages[saved] ?? "RCON settings saved."}</p>}
{error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errorMessages[error] ?? "The RCON operation failed."}</p>}
<div className="mt-10 grid gap-10 lg:grid-cols-[1.1fr_0.9fr]">
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Command proxy</p>
<h2 className="mt-2 font-display text-3xl font-black uppercase">Console</h2>
<p className="mt-3 text-xs leading-5 text-muted">Only the latest bounded response is shown. Commands and responses are not saved as console history.</p>
<RconConsole servers={servers.filter((server) => server.enabled).map(({ id, name }) => ({ id, name }))} />
</section>
<section>
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Configuration</p>
<h2 className="mt-2 font-display text-3xl font-black uppercase">Add connection</h2>
<form action={createRconServer} className="mt-5 space-y-4 border border-line bg-panel p-6">
<ConnectionFields prefix="new" />
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase"><input className="size-4" name="enabled" type="checkbox" value="yes" />Enable immediately</label>
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Add connection</button>
</form>
</section>
</div>
<section className="mt-12">
<div className="flex items-end justify-between border-b border-line pb-4">
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Saved endpoints</p><h2 className="mt-2 font-display text-3xl font-black uppercase">Connections</h2></div>
<span className="font-mono text-xs text-muted">{servers.length} configured</span>
</div>
<div className="divide-y divide-line">
{servers.map((server) => (
<article className="grid gap-5 py-6 lg:grid-cols-[1fr_auto] lg:items-start" key={server.id}>
<div>
<div className="flex flex-wrap items-center gap-3"><h3 className="font-mono text-lg font-bold">{server.name}</h3><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${server.enabled ? "bg-signal text-ink" : "border border-line text-muted"}`}>{server.enabled ? "Enabled" : "Disabled"}</span></div>
<p className="mt-2 font-mono text-[10px] text-muted">{server.host}:{server.port}</p>
<p className="mt-1 font-mono text-[9px] text-muted">Updated {server.updatedAt.toISOString()}</p>
</div>
<div className="flex flex-wrap items-start gap-4">
<form action={testSavedRconServer}><input name="serverId" type="hidden" value={server.id} /><button className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" type="submit">Test</button></form>
<form action={setRconServerEnabled}><input name="serverId" type="hidden" value={server.id} /><input name="enabled" type="hidden" value={server.enabled ? "no" : "yes"} /><button className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" type="submit">{server.enabled ? "Disable" : "Enable"}</button></form>
<details className="relative">
<summary className="cursor-pointer list-none font-mono text-[9px] font-bold uppercase underline underline-offset-4">Edit</summary>
<form action={updateRconServer} className="relative z-10 mt-3 w-[min(28rem,80vw)] space-y-4 border border-line bg-panel p-5 shadow-[5px_5px_0_var(--color-shadow)] lg:absolute lg:right-0">
<input name="serverId" type="hidden" value={server.id} />
<ConnectionFields defaults={server} prefix={server.id} />
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase"><input className="size-4" defaultChecked={server.enabled} name="enabled" type="checkbox" value="yes" />Enabled</label>
<button className="border border-ink px-4 py-2 font-mono text-[9px] font-bold uppercase" type="submit">Save connection</button>
</form>
</details>
<details className="relative">
<summary className="cursor-pointer list-none font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4">Delete</summary>
<form action={deleteRconServer} className="relative z-10 mt-3 w-64 border border-accent bg-panel p-5 shadow-[5px_5px_0_var(--color-accent)] lg:absolute lg:right-0">
<input name="serverId" type="hidden" value={server.id} /><input name="confirmation" type="hidden" value={server.id} />
<p className="text-xs leading-5">Delete {server.name}? Its encrypted credential will be removed.</p>
<button className="mt-4 bg-accent px-4 py-2 font-mono text-[9px] font-bold uppercase text-canvas" type="submit">Confirm deletion</button>
</form>
</details>
</div>
</article>
))}
{!servers.length && <p className="py-8 text-sm text-muted">No RCON connections configured.</p>}
</div>
</section>
</main>
);
}
function ConnectionFields({
prefix,
defaults,
}: {
prefix: string;
defaults?: { name: string; host: string; port: number };
}) {
const fieldClass = "mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent";
return (
<>
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-name`}>Name<input className={fieldClass} defaultValue={defaults?.name} id={`${prefix}-rcon-name`} maxLength={100} name="name" required /></label>
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-host`}>Internal hostname<input autoCapitalize="none" autoCorrect="off" className={fieldClass} defaultValue={defaults?.host} id={`${prefix}-rcon-host`} maxLength={253} name="host" placeholder="season4.somc.svc.cluster.local" required spellCheck={false} /></label>
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-port`}>Port<input className={fieldClass} defaultValue={defaults?.port ?? 25575} id={`${prefix}-rcon-port`} max={65535} min={1} name="port" required type="number" /></label>
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-password`}>{defaults ? "Replacement password" : "Password"}<input autoComplete="new-password" className={fieldClass} id={`${prefix}-rcon-password`} maxLength={512} name="password" required={!defaults} type="password" />{defaults && <span className="mt-2 block font-sans text-[10px] font-normal normal-case text-muted">Leave blank to preserve the current password.</span>}</label>
</>
);
}
@@ -17,6 +17,7 @@ export default async function SettingsPage({
registrationMessage: settings?.registrationMessage ?? DEFAULT_ADMISSION_MESSAGES.registrationMessage,
groupAccessDeniedMessage: settings?.groupAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage,
vpnDeniedMessage: settings?.vpnDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage,
scheduledAccessDeniedMessage: settings?.scheduledAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.scheduledAccessDeniedMessage,
};
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
@@ -40,9 +41,10 @@ export default async function SettingsPage({
<fieldset className="space-y-7">
<legend className="font-display text-2xl font-black uppercase">Minecraft denial messages</legend>
<p className="text-sm leading-6 text-muted">Each plain-text message is returned for one admission outcome. Messages must be between 10 and 500 characters.</p>
<AdmissionMessageField description="Shown when the Minecraft identity is not registered." label="Registration required" name="registrationMessage" value={messages.registrationMessage} />
<p className="text-sm leading-6 text-muted">Each plain-text template is returned for one admission outcome. Messages must be between 10 and 500 characters. Registration, group, and network templates support <code>{"{player}"}</code> and <code>{"{group}"}</code>.</p>
<AdmissionMessageField description="Shown when the Minecraft identity is not registered. The unresolved group is everyone." label="Registration required" name="registrationMessage" value={messages.registrationMessage} />
<AdmissionMessageField description="Shown when the effective group has Minecraft access disabled." label="Group access disabled" name="groupAccessDeniedMessage" value={messages.groupAccessDeniedMessage} />
<AdmissionMessageField description="Shown outside a scheduled access window. Also supports {next_start} and {next_end}; generated times explicitly use UTC." label="Scheduled access denied" name="scheduledAccessDeniedMessage" value={messages.scheduledAccessDeniedMessage} />
<AdmissionMessageField description="Shown for VPN, proxy, or Tor connections when the effective group has no exception." label="VPN, proxy, or Tor denied" name="vpnDeniedMessage" value={messages.vpnDeniedMessage} />
</fieldset>
<button className="mt-8 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-[0.16em] text-canvas hover:bg-accent" type="submit">Save configuration</button>
@@ -60,7 +62,7 @@ function AdmissionMessageField({
}: {
description: string;
label: string;
name: "registrationMessage" | "groupAccessDeniedMessage" | "vpnDeniedMessage";
name: "registrationMessage" | "groupAccessDeniedMessage" | "scheduledAccessDeniedMessage" | "vpnDeniedMessage";
value: string;
}) {
const descriptionId = `${name}-description`;
@@ -1,10 +1,10 @@
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
import { events, groups, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { events, groups, ipIntelligence, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
import Link from "next/link";
import { notFound } from "next/navigation";
import { groupAccessAddresses } from "@/lib/access-address-groups";
import { accessAddressDetails, groupAccessAddresses } from "@/lib/access-address-groups";
import { db } from "@/lib/database";
import { discordIdentity } from "@/lib/discord-identity";
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
@@ -80,8 +80,16 @@ export default async function AdminUserPage({
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
recentEventsQuery,
db
.select()
.select({
id: ipObservations.id,
ipAddress: ipObservations.ipAddress,
source: ipObservations.source,
classification: ipObservations.classification,
observedAt: ipObservations.observedAt,
intelligence: ipIntelligence.rawResponse,
})
.from(ipObservations)
.leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
.where(eq(ipObservations.userId, user.id))
.orderBy(desc(ipObservations.observedAt))
.limit(100),
@@ -95,9 +103,7 @@ export default async function AdminUserPage({
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
const addressGroups = groupAccessAddresses(
observations.map((observation) => ({ ...observation, intelligence: null })),
);
const addressGroups = groupAccessAddresses(observations);
const primary = accounts.find((account) => account.isPrimary);
const nickname = user.firstName
? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
@@ -215,16 +221,20 @@ export default async function AdminUserPage({
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Recent addresses</p>
<p className="mt-3 text-[10px] leading-5 text-muted">Grouped by IPv4 /24 or IPv6 /64 network across the 100 most recent observations.</p>
<div className="mt-4 divide-y divide-line">
{addressGroups.map((group) => (
<div className="py-3" key={group.network}>
<div className="flex items-center justify-between gap-3">
<p className="font-mono text-xs font-bold">{group.network}</p>
<span className="font-mono text-[9px] text-muted">×{group.count}</span>
{addressGroups.map((group) => {
const details = accessAddressDetails(group);
return (
<div className="py-3" key={group.network}>
<div className="flex items-center justify-between gap-3">
<p className="font-mono text-xs font-bold">{group.network}</p>
<span className="font-mono text-[9px] text-muted">×{group.count}</span>
</div>
<p className="mt-1 font-mono text-[9px] text-muted">{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}</p>
<p className="mt-1 break-all font-mono text-[9px] text-muted">Latest {group.latestAddress}</p>
<p className="mt-1 text-xs text-muted">{details.location} · <span className="font-mono uppercase">{details.classification}</span></p>
</div>
<p className="mt-1 font-mono text-[9px] text-muted">{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}</p>
<p className="mt-1 break-all font-mono text-[9px] text-muted">Latest {group.latestAddress}</p>
</div>
))}
);
})}
{!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
</div>
</section>
@@ -0,0 +1,136 @@
import { hashToken } from "@minecraft-account-manager/auth";
import { beforeEach, describe, expect, it, vi } from "vitest";
const databaseState = vi.hoisted(() => ({
responses: [] as unknown[][],
inserted: [] as Array<Record<string, unknown>>,
isolationLevel: "",
}));
vi.mock("@/lib/database", () => {
function selection(response: unknown[]) {
const chain: Record<string, unknown> = {};
for (const method of ["from", "where", "innerJoin", "leftJoin", "orderBy"]) {
chain[method] = () => chain;
}
chain.limit = () => Promise.resolve(response);
chain.then = (resolve: (value: unknown[]) => unknown, reject: (reason: unknown) => unknown) =>
Promise.resolve(response).then(resolve, reject);
return chain;
}
const tx = {
select: () => selection(databaseState.responses.shift() ?? []),
insert: () => ({
values: (value: Record<string, unknown>) => {
databaseState.inserted.push(value);
return Promise.resolve();
},
}),
delete: () => ({ where: () => Promise.resolve() }),
update: () => ({ set: () => ({ where: () => Promise.resolve() }) }),
};
return {
db: {
select: () => selection(databaseState.responses.shift() ?? []),
transaction: async (callback: (transaction: typeof tx) => Promise<unknown>, options: { isolationLevel?: string }) => {
databaseState.isolationLevel = options?.isolationLevel ?? "";
return callback(tx);
},
},
};
});
vi.mock("@/lib/ip-intelligence", () => ({
getIpIntelligence: async () => ({ classification: "clear" }),
toAuditIpData: () => ({ classification: "clear" }),
}));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
import { POST } from "./route";
const messages = {
registrationMessage: "Register {player} in {group}.",
groupAccessDeniedMessage: "Disabled {player} in {group}.",
vpnDeniedMessage: "Network denied for {player} in {group}.",
scheduledAccessDeniedMessage: "Scheduled {player} in {group}: {next_start} / {next_end}.",
};
function utcMinuteOfWeek(value: Date) {
return ((value.getUTCDay() + 6) % 7) * 1440 + value.getUTCHours() * 60 + value.getUTCMinutes();
}
function normalized(value: number) {
return (value + 10080) % 10080;
}
function request() {
return new Request("http://localhost/api/velocity/access", {
method: "POST",
headers: { authorization: "Bearer route-secret", "content-type": "application/json" },
body: JSON.stringify({
requestId: "11111111-1111-4111-8111-111111111111",
serverId: "velocity-main",
minecraftUuid: "0123456789abcdef0123456789abcdef",
username: "AlexMC",
ipAddress: "203.0.113.10",
occurredAt: new Date().toISOString(),
}),
});
}
function arrange(group: { accessEnabled: boolean; anonymizedNetworksAllowed: boolean }, windows: Array<{ startMinuteOfWeek: number; endMinuteOfWeek: number }>) {
databaseState.responses = [
[{ secretHash: hashToken("route-secret") }],
[messages],
[{ id: "account-id", userId: "user-id", minecraftUuid: "0123456789abcdef0123456789abcdef", username: "AlexMC" }],
[{ id: "group-id", name: "Friday friends", ...group }],
[{ id: "everyone-id", name: "everyone", accessEnabled: false, anonymizedNetworksAllowed: false }],
windows,
];
}
describe("Velocity scheduled admission integration", () => {
beforeEach(() => {
databaseState.responses = [];
databaseState.inserted = [];
databaseState.isolationLevel = "";
});
it("loads effective-group windows and returns a rendered schedule denial", async () => {
const minute = utcMinuteOfWeek(new Date());
arrange(
{ accessEnabled: true, anonymizedNetworksAllowed: false },
[{ startMinuteOfWeek: normalized(minute + 60), endMinuteOfWeek: normalized(minute + 120) }],
);
const response = await POST(request());
const body = await response.json();
expect(body.allowed).toBe(false);
expect(body.message).toMatch(/^Scheduled AlexMC in Friday friends: .* UTC \/ .* UTC\.$/);
expect(databaseState.isolationLevel).toBe("repeatable read");
expect(databaseState.inserted).toContainEqual(expect.objectContaining({
type: "games.minecraft.account-manager.game.login.denied",
data: expect.objectContaining({ reason: "schedule_disallowed", accessGroup: "Friday friends" }),
}));
});
it("fails closed for malformed persisted windows while disabled access retains precedence", async () => {
const malformed = [
{ startMinuteOfWeek: 100, endMinuteOfWeek: 200 },
{ startMinuteOfWeek: 150, endMinuteOfWeek: 250 },
];
arrange({ accessEnabled: true, anonymizedNetworksAllowed: true }, malformed);
expect(await (await POST(request())).json()).toMatchObject({ allowed: false, message: expect.stringContaining("unavailable") });
expect(databaseState.inserted).toContainEqual(expect.objectContaining({
data: expect.objectContaining({ reason: "schedule_disallowed" }),
}));
databaseState.inserted = [];
arrange({ accessEnabled: false, anonymizedNetworksAllowed: true }, malformed);
expect(await (await POST(request())).json()).toEqual({ allowed: false, message: "Disabled AlexMC in Friday friends." });
expect(databaseState.inserted).toContainEqual(expect.objectContaining({
data: expect.objectContaining({ reason: "group_access_disabled" }),
}));
});
});
+35 -8
View File
@@ -1,9 +1,10 @@
import { randomUUID } from "node:crypto";
import { gameAdmissionDenialReason, isRequestTimestampFresh, resolveEffectiveGroup, verifyHashedToken } from "@minecraft-account-manager/auth";
import { isRequestTimestampFresh, resolveEffectiveGroup, verifyHashedToken } from "@minecraft-account-manager/auth";
import { problemDetails, velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
import {
appSettings,
events,
groupAccessWindows,
groups,
ipObservations,
minecraftAccounts,
@@ -13,9 +14,10 @@ import {
} from "@minecraft-account-manager/database";
import { and, eq, isNull, lt, sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES } from "@/lib/admission-settings";
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES, renderAdmissionMessage } from "@/lib/admission-settings";
import { db } from "@/lib/database";
import { isUniqueConstraintViolation } from "@/lib/database-errors";
import { evaluateRegisteredPlayerAdmission } from "@/lib/game-admission-policy";
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
import { logger } from "@/lib/logger";
import { problemInstance, problemResponse } from "@/lib/problem-response";
@@ -115,10 +117,12 @@ async function handleVelocityAccess(request: Request) {
registrationMessage: settings?.registrationMessage ?? DEFAULT_ADMISSION_MESSAGES.registrationMessage,
groupAccessDeniedMessage: settings?.groupAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage,
vpnDeniedMessage: settings?.vpnDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage,
scheduledAccessDeniedMessage: settings?.scheduledAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.scheduledAccessDeniedMessage,
};
const intelligence = await getIpIntelligence(input.ipAddress);
const auditIpData = toAuditIpData(intelligence);
const decisionAt = new Date();
const decision = await db.transaction(async (tx) => {
await tx.delete(pluginRequests).where(lt(pluginRequests.expiresAt, new Date()));
@@ -187,7 +191,13 @@ async function handleVelocityAccess(request: Request) {
classification: intelligence.classification,
observedAt: occurredAt,
});
return { allowed: false as const, message: admissionDenialMessage("not_registered", admissionMessages) };
return {
allowed: false as const,
message: renderAdmissionMessage(admissionDenialMessage("not_registered", admissionMessages), {
player: input.username,
group: "everyone",
}),
};
}
const [explicitGroup] = await tx
@@ -202,9 +212,22 @@ async function handleVelocityAccess(request: Request) {
.where(eq(groups.isDefault, true))
.limit(1);
const effectiveGroup = resolveEffectiveGroup(explicitGroup ?? null, defaultGroup ?? null);
const denialReason = gameAdmissionDenialReason(effectiveGroup ?? null, intelligence.classification);
if (denialReason) {
const accessWindows = effectiveGroup
? await tx.select({
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, effectiveGroup.id))
: [];
const policyDecision = evaluateRegisteredPlayerAdmission({
group: effectiveGroup ?? null,
windows: accessWindows,
classification: intelligence.classification,
now: decisionAt,
player: input.username,
messages: admissionMessages,
});
if (!policyDecision.allowed) {
const denialReason = policyDecision.reason;
await tx.insert(events).values({
id: randomUUID(),
source: `/velocity/${input.serverId}`,
@@ -217,6 +240,10 @@ async function handleVelocityAccess(request: Request) {
reason: denialReason,
accessGroup: effectiveGroup?.name ?? null,
accessGroupId: effectiveGroup?.id ?? null,
nextScheduleWindow: policyDecision.nextWindow ? {
start: policyDecision.nextWindow.start.toISOString(),
end: policyDecision.nextWindow.end.toISOString(),
} : null,
ipIntelligence: auditIpData,
},
ipAddress: input.ipAddress,
@@ -234,7 +261,7 @@ async function handleVelocityAccess(request: Request) {
});
return {
allowed: false as const,
message: admissionDenialMessage(denialReason, admissionMessages),
message: policyDecision.message,
};
}
@@ -297,7 +324,7 @@ async function handleVelocityAccess(request: Request) {
});
return { allowed: true as const, message: "Account approved." };
});
}, { isolationLevel: "repeatable read" });
return NextResponse.json(decision);
}
+7 -3
View File
@@ -29,6 +29,7 @@ export function AdminModalForm({
const titleId = useId();
const descriptionId = useId();
const [submitting, setSubmitting] = useState(false);
const [dialogGeneration, setDialogGeneration] = useState(0);
return (
<>
<button
@@ -36,7 +37,10 @@ export function AdminModalForm({
aria-pressed={triggerPressed}
className={triggerClassName ?? "font-mono text-[10px] font-bold uppercase underline underline-offset-4"}
disabled={submitting}
onClick={() => dialogRef.current?.showModal()}
onClick={() => {
setDialogGeneration((generation) => generation + 1);
dialogRef.current?.showModal();
}}
type="button"
>
{triggerLabel}
@@ -44,7 +48,7 @@ export function AdminModalForm({
<dialog
aria-describedby={descriptionId}
aria-labelledby={titleId}
className="admin-modal m-auto w-[min(92vw,36rem)] border border-ink bg-panel p-0 text-ink shadow-[10px_10px_0_var(--color-shadow)] backdrop:bg-ink/70"
className="admin-modal m-auto max-h-[90vh] w-[min(92vw,36rem)] overflow-y-auto border border-ink bg-panel p-0 text-ink shadow-[10px_10px_0_var(--color-shadow)] backdrop:bg-ink/70"
onCancel={(event) => { if (submitting) event.preventDefault(); }}
ref={dialogRef}
>
@@ -52,7 +56,7 @@ export function AdminModalForm({
<p className="font-mono text-[9px] font-bold uppercase tracking-[0.2em] text-accent">Confirm operation</p>
<h2 className="mt-3 font-display text-3xl font-black uppercase" id={titleId}>{title}</h2>
<p className="mt-3 text-sm leading-6 text-muted" id={descriptionId}>{description}</p>
{children && <div className="mt-6">{children}</div>}
{children && <div className="mt-6" key={dialogGeneration}>{children}</div>}
<ModalActions dialogRef={dialogRef} intent={intent} onPendingChange={setSubmitting} submitLabel={submitLabel} />
</form>
</dialog>
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { AdminModalForm } from "./admin-modal-form";
import { GroupScheduleEditor, GroupScheduleSummary } from "./group-schedule-editor";
beforeEach(() => {
HTMLDialogElement.prototype.showModal = function showModal() { this.open = true; };
HTMLDialogElement.prototype.close = function close() {
this.open = false;
this.dispatchEvent(new Event("close"));
};
});
describe("GroupScheduleEditor", () => {
it("shows UTC authority, browser-local equivalents, and repeatable windows", () => {
const { container } = render(<GroupScheduleEditor windows={[{
startMinuteOfWeek: 6960,
endMinuteOfWeek: 7199,
}]} />);
expect(screen.getByText(/stored and enforced in UTC/i)).toBeTruthy();
expect(screen.getAllByRole("group", { name: /access window/i })).toHaveLength(1);
expect(container.querySelectorAll('input[name="startMinuteOfWeek"]')).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: /add window/i }));
expect(screen.getAllByRole("group", { name: /access window/i })).toHaveLength(2);
expect(container.querySelectorAll('input[name="startMinuteOfWeek"]')).toHaveLength(2);
fireEvent.click(screen.getAllByRole("button", { name: /remove window/i })[0]!);
expect(screen.getAllByRole("group", { name: /access window/i })).toHaveLength(1);
});
it("discards an abandoned draft when its confirmation dialog is reopened", () => {
render(<AdminModalForm action={async () => undefined} description="Confirm schedule." submitLabel="Save schedule" title="Schedule group" triggerLabel="Edit schedule"><GroupScheduleEditor windows={[{ startMinuteOfWeek: 6960, endMinuteOfWeek: 7200 }]} /></AdminModalForm>);
fireEvent.click(screen.getByRole("button", { name: "Edit schedule" }));
const dialog = screen.getByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Add window" }));
expect(within(dialog).getAllByRole("group", { name: /access window/i })).toHaveLength(2);
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
fireEvent.click(screen.getByRole("button", { name: "Edit schedule" }));
expect(within(dialog).getAllByRole("group", { name: /access window/i })).toHaveLength(1);
});
it("summarizes an unrestricted group and configured local equivalents", () => {
const { container, rerender } = render(<GroupScheduleSummary windows={[]} />);
expect(container.textContent).toMatch(/no schedule restrictions/i);
rerender(<GroupScheduleSummary windows={[{ startMinuteOfWeek: 6960, endMinuteOfWeek: 7199 }]} />);
expect(container.textContent).toMatch(/current browser-local equivalent/i);
expect(container.textContent).toContain("Friday 20:00 UTC");
});
});
@@ -0,0 +1,143 @@
"use client";
import { useRef, useState, useSyncExternalStore } from "react";
import {
formatWeeklyMinute,
localWindowToUtc,
utcWindowToLocal,
type WeeklyAccessWindow,
} from "@/lib/group-schedule";
const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const;
interface EditableWindow extends WeeklyAccessWindow {
key: number;
}
function minuteParts(minuteOfWeek: number) {
const day = Math.floor(minuteOfWeek / 1440);
const minute = minuteOfWeek % 1440;
return {
day,
time: `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`,
};
}
function withDay(minuteOfWeek: number, day: number) {
return day * 1440 + (minuteOfWeek % 1440);
}
function withTime(minuteOfWeek: number, time: string) {
const [hour, minute] = time.split(":").map(Number);
return Math.floor(minuteOfWeek / 1440) * 1440 + (hour ?? 0) * 60 + (minute ?? 0);
}
const subscribeToBrowserClock = () => () => undefined;
function useBrowserClock() {
const offset = useSyncExternalStore(
subscribeToBrowserClock,
() => new Date().getTimezoneOffset(),
() => 0,
);
const zone = useSyncExternalStore(
subscribeToBrowserClock,
() => Intl.DateTimeFormat().resolvedOptions().timeZone || "browser local time",
() => "UTC",
);
return { offset, zone };
}
export function GroupScheduleEditor({ windows }: { windows: WeeklyAccessWindow[] }) {
const { offset, zone } = useBrowserClock();
const [editable, setEditable] = useState<EditableWindow[]>(
windows.map((window, key) => ({ ...window, key })),
);
const nextKey = useRef(windows.length);
function update(key: number, field: "startMinuteOfWeek" | "endMinuteOfWeek", value: number) {
setEditable((current) => current.map((window) => {
if (window.key !== key) return window;
const local = { ...utcWindowToLocal(window, offset), [field]: value };
return { ...localWindowToUtc(local, offset), key };
}));
}
return (
<div className="space-y-5">
<p className="text-sm leading-6 text-muted">
Schedules are stored and enforced in UTC. The editor shows the current browser-local equivalent in <strong className="text-ink">{zone}</strong>; it may shift when your local daylight-saving offset changes.
</p>
{!editable.length && <p className="border-l-2 border-signal pl-4 text-sm">No windows means no schedule restrictions while Minecraft access is enabled.</p>}
{editable.map((window, index) => {
const local = utcWindowToLocal(window, offset);
const start = minuteParts(local.startMinuteOfWeek);
const end = minuteParts(local.endMinuteOfWeek);
return (
<fieldset aria-label={`Access window ${index + 1}`} className="border border-line p-4" key={window.key}>
<legend className="px-2 font-mono text-[10px] font-bold uppercase tracking-wider">Access window {index + 1}</legend>
<div className="grid gap-4 sm:grid-cols-2">
<ScheduleBoundary day={start.day} label="Starts" onDay={(day) => update(window.key, "startMinuteOfWeek", withDay(local.startMinuteOfWeek, day))} onTime={(time) => update(window.key, "startMinuteOfWeek", withTime(local.startMinuteOfWeek, time))} time={start.time} />
<ScheduleBoundary day={end.day} label="Ends (exclusive)" onDay={(day) => update(window.key, "endMinuteOfWeek", withDay(local.endMinuteOfWeek, day))} onTime={(time) => update(window.key, "endMinuteOfWeek", withTime(local.endMinuteOfWeek, time))} time={end.time} />
</div>
<input name="startMinuteOfWeek" type="hidden" value={window.startMinuteOfWeek} />
<input name="endMinuteOfWeek" type="hidden" value={window.endMinuteOfWeek} />
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<p className="font-mono text-[9px] uppercase text-muted">UTC: {formatWeeklyMinute(window.startMinuteOfWeek)}{formatWeeklyMinute(window.endMinuteOfWeek)}</p>
<button className="font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4" onClick={() => setEditable((current) => current.filter((item) => item.key !== window.key))} type="button">Remove window {index + 1}</button>
</div>
</fieldset>
);
})}
<button
className="border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider disabled:cursor-not-allowed disabled:opacity-50"
disabled={editable.length >= 50}
onClick={() => {
const key = nextKey.current++;
setEditable((current) => [...current, {
key,
...localWindowToUtc({
startMinuteOfWeek: 4 * 1440 + 20 * 60,
endMinuteOfWeek: 5 * 1440,
}, offset),
}]);
}}
type="button"
>Add window</button>
</div>
);
}
function ScheduleBoundary({ day, label, onDay, onTime, time }: {
day: number;
label: string;
onDay: (day: number) => void;
onTime: (time: string) => void;
time: string;
}) {
return (
<div>
<span className="block text-xs font-bold">{label}</span>
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2">
<label><span className="sr-only">{label} weekday</span><select className="w-full border border-line bg-canvas px-3 py-2 text-sm" onChange={(event) => onDay(Number(event.target.value))} value={day}>{DAYS.map((name, value) => <option key={name} value={value}>{name}</option>)}</select></label>
<label><span className="sr-only">{label} time</span><input className="border border-line bg-canvas px-3 py-2 text-sm" onChange={(event) => onTime(event.target.value)} required type="time" value={time} /></label>
</div>
</div>
);
}
export function GroupScheduleSummary({ windows }: { windows: WeeklyAccessWindow[] }) {
const { offset, zone } = useBrowserClock();
if (!windows.length) return <p className="text-sm text-muted">No schedule restrictions. Enabled members may attempt to join at any time.</p>;
return (
<div>
<p className="text-xs text-muted">Current browser-local equivalent: {zone}. UTC remains authoritative.</p>
<ol className="mt-3 space-y-2">
{windows.map((window, index) => {
const local = utcWindowToLocal(window, offset);
return <li className="border-l-2 border-accent pl-3 text-sm" key={`${window.startMinuteOfWeek}-${window.endMinuteOfWeek}-${index}`}><span className="font-bold">{formatWeeklyMinute(local.startMinuteOfWeek)}{formatWeeklyMinute(local.endMinuteOfWeek)}</span><span className="mt-1 block font-mono text-[9px] uppercase text-muted">{formatWeeklyMinute(window.startMinuteOfWeek)} UTC{formatWeeklyMinute(window.endMinuteOfWeek)} UTC</span></li>;
})}
</ol>
</div>
);
}
@@ -0,0 +1,23 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/app/admin/(console)/rcon/actions", () => ({
executeRconCommand: vi.fn(),
}));
import { RconConsole } from "./rcon-console";
describe("RconConsole", () => {
it("renders labelled keyboard-operable controls without history", () => {
const markup = renderToStaticMarkup(<RconConsole servers={[{ id: "one", name: "Season 4" }]} />);
expect(markup).toContain('for="rcon-console-server"');
expect(markup).toContain('for="rcon-command"');
expect(markup).toContain("Season 4");
expect(markup).toContain("Run command");
expect(markup).not.toContain("Latest response");
});
it("explains when no enabled connection is available", () => {
expect(renderToStaticMarkup(<RconConsole servers={[]} />)).toContain("Enable an RCON connection");
});
});
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useActionState } from "react";
import { executeRconCommand, type RconCommandState } from "@/app/admin/(console)/rcon/actions";
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
type ServerOption = { id: string; name: string };
export function RconConsole({ servers }: { servers: ServerOption[] }) {
const [state, action, pending] = useActionState(executeRconCommand, initialState);
const responseServer = servers.find((server) => server.id === state.serverId);
if (!servers.length) {
return <p className="mt-5 text-sm text-muted">Enable an RCON connection before opening the console.</p>;
}
return (
<form action={action} className="mt-6 space-y-4">
<label className="block font-mono text-[10px] font-bold uppercase tracking-wider" htmlFor="rcon-console-server">
Server
<select className="mt-2 block w-full border border-line bg-canvas px-4 py-3 font-sans text-sm font-normal normal-case" defaultValue={state.serverId || servers[0]?.id} id="rcon-console-server" name="serverId" required>
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}</option>)}
</select>
</label>
<label className="block font-mono text-[10px] font-bold uppercase tracking-wider" htmlFor="rcon-command">
Command
<input autoComplete="off" className="mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent" id="rcon-command" maxLength={1024} name="command" placeholder="list" required spellCheck={false} />
</label>
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas disabled:cursor-wait disabled:opacity-60" disabled={pending} type="submit">{pending ? "Running…" : "Run command"}</button>
{state.status !== "idle" && (
<div aria-live="polite" className={`border-l-2 bg-canvas p-4 ${state.status === "error" ? "border-accent" : "border-signal"}`} role={state.status === "error" ? "alert" : "status"}>
<p className="font-mono text-[9px] font-bold uppercase tracking-wider text-muted">Latest response{responseServer ? `${responseServer.name}` : ""}</p>
<pre className="mt-2 max-h-80 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-5">{state.message}</pre>
</div>
)}
</form>
);
}
@@ -44,7 +44,8 @@ describe("UserWorldMap", () => {
expect(markup).toContain("Alex (AlexMC)");
expect(markup).toContain("2 users near Mountain View, California, US");
expect(markup).toMatch(/<text[^>]*>2<\/text>/);
expect(markup).toContain('<details class="mt-5 border-t border-line pt-4" id="map-location-list" open="">');
expect(markup).toContain('<details class="mt-5 border-t border-line pt-4" id="map-location-list">');
expect(markup).not.toContain('id="map-location-list" open');
expect(markup).toContain("Mountain View, California, US");
expect(markup).toContain("Comcast Cable Communications, LLC");
expect(markup).toContain("AS7922");
+1 -1
View File
@@ -99,7 +99,7 @@ export function UserWorldMap({ locations, unavailableCount }: { locations: UserM
</MapViewToggle>
<p className="mt-2 text-right font-mono text-[9px] text-muted">Map boundaries: Natural Earth, public domain</p>
<details className="mt-5 border-t border-line pt-4" id="map-location-list" open={locationGroups.some((group) => group.count > 1)}>
<details className="mt-5 border-t border-line pt-4" id="map-location-list">
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase underline underline-offset-4">View accessible location list</summary>
<div className="mt-4 overflow-x-auto">
<table className="w-full min-w-[980px] border-collapse text-left text-xs">
+16 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { groupAccessAddresses } from "./access-address-groups";
import { accessAddressDetails, groupAccessAddresses } from "./access-address-groups";
describe("groupAccessAddresses", () => {
it("collapses repeated observations from the same network into one recent summary", () => {
@@ -20,4 +20,19 @@ describe("groupAccessAddresses", () => {
});
expect(groups[0]?.latestObservedAt.toISOString()).toBe("2026-08-01T12:00:00.000Z");
});
it("presents the latest enriched location and classification with observation fallbacks", () => {
expect(accessAddressDetails({
classification: "vpn",
intelligence: {
classification: "vpn",
location: { city: "Toronto", region: "Ontario", countryCode: "CA" },
},
})).toEqual({ location: "Toronto, Ontario, CA", classification: "vpn" });
expect(accessAddressDetails({ classification: "hosting", intelligence: null })).toEqual({
location: "Location unavailable",
classification: "hosting",
});
});
});
@@ -1,4 +1,5 @@
import { addressGroup } from "@minecraft-account-manager/network";
import { intelligenceSummary } from "./event-ip-summary";
type AccessObservation = {
id: string;
@@ -9,6 +10,14 @@ type AccessObservation = {
intelligence: Record<string, unknown> | null;
};
export function accessAddressDetails(observation: Pick<AccessObservation, "classification" | "intelligence">) {
const summary = intelligenceSummary(observation.intelligence);
return {
location: summary.location ?? "Location unavailable",
classification: summary.classification ?? observation.classification,
};
}
export type AccessAddressGroup = {
network: string;
latestAddress: string;
+31 -14
View File
@@ -1,33 +1,50 @@
import { describe, expect, it } from "vitest";
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES, parseAdmissionMessages } from "./admission-settings";
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES, parseAdmissionMessages, renderAdmissionMessage } from "./admission-settings";
describe("admission message settings", () => {
it("normalizes three independently configured denial messages", () => {
const formData = new FormData();
formData.set("registrationMessage", " Register your account before joining. ");
formData.set("groupAccessDeniedMessage", "This group cannot access the server right now.");
formData.set("vpnDeniedMessage", "VPN access requires a host-approved exception.");
it("normalizes independently configured denial templates with allowed variables", () => {
const formData = validMessages();
formData.set("registrationMessage", " Register {player} before joining {group}. ");
formData.set("scheduledAccessDeniedMessage", "{player}, {group} may join from {next_start} to {next_end}.");
expect(parseAdmissionMessages(formData)).toEqual({
registrationMessage: "Register your account before joining.",
groupAccessDeniedMessage: "This group cannot access the server right now.",
vpnDeniedMessage: "VPN access requires a host-approved exception.",
registrationMessage: "Register {player} before joining {group}.",
groupAccessDeniedMessage: "{player} cannot access the server with {group}.",
vpnDeniedMessage: "VPN access for {player} in {group} requires an exception.",
scheduledAccessDeniedMessage: "{player}, {group} may join from {next_start} to {next_end}.",
});
});
it("selects the configured message for each admission denial reason", () => {
expect(admissionDenialMessage("not_registered", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.registrationMessage);
expect(admissionDenialMessage("group_access_disabled", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage);
expect(admissionDenialMessage("schedule_disallowed", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.scheduledAccessDeniedMessage);
expect(admissionDenialMessage("anonymized_network_disallowed", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage);
});
it("rejects missing, short, overlong, or control-character messages", () => {
for (const invalid of ["short", "a".repeat(501), "Denied\nInjected"] as const) {
const formData = new FormData();
formData.set("registrationMessage", DEFAULT_ADMISSION_MESSAGES.registrationMessage);
formData.set("groupAccessDeniedMessage", DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage);
it("renders static variables without evaluating expressions", () => {
expect(renderAdmissionMessage("{player} uses {group}; next: {next_start}{next_end}.", {
player: "AlexMC",
group: "Friday friends",
next_start: "2026-08-07 20:00 UTC",
next_end: "2026-08-07 23:59 UTC",
})).toBe("AlexMC uses Friday friends; next: 2026-08-07 20:00 UTC2026-08-07 23:59 UTC.");
});
it("rejects missing, short, overlong, control-character, or unsupported-variable messages", () => {
for (const invalid of ["short", "a".repeat(501), "Denied\nInjected", "Denied for {next_start}.", "Denied for {unknown}."] as const) {
const formData = validMessages();
formData.set("vpnDeniedMessage", invalid);
expect(parseAdmissionMessages(formData)).toBeNull();
}
});
});
function validMessages() {
const formData = new FormData();
formData.set("registrationMessage", "Register {player} before joining {group}.");
formData.set("groupAccessDeniedMessage", "{player} cannot access the server with {group}.");
formData.set("vpnDeniedMessage", "VPN access for {player} in {group} requires an exception.");
formData.set("scheduledAccessDeniedMessage", "{group} may join from {next_start} to {next_end}.");
return formData;
}
+30 -13
View File
@@ -2,36 +2,53 @@ export interface AdmissionMessages {
registrationMessage: string;
groupAccessDeniedMessage: string;
vpnDeniedMessage: string;
scheduledAccessDeniedMessage: string;
}
export type AdmissionDenialReason =
| "not_registered"
| "group_access_disabled"
| "schedule_disallowed"
| "anonymized_network_disallowed";
export const DEFAULT_ADMISSION_MESSAGES: AdmissionMessages = {
registrationMessage: "Please register your Minecraft account before joining.",
groupAccessDeniedMessage: "Your account group does not currently have server access. Contact a host if you believe this is a mistake.",
vpnDeniedMessage: "VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception.",
scheduledAccessDeniedMessage: "Your group is only allowed access from {next_start} to {next_end}.",
} as const;
export function admissionDenialMessage(
reason: "not_registered" | "group_access_disabled" | "anonymized_network_disallowed",
messages: AdmissionMessages,
) {
export function admissionDenialMessage(reason: AdmissionDenialReason, messages: AdmissionMessages) {
if (reason === "not_registered") return messages.registrationMessage;
if (reason === "group_access_disabled") return messages.groupAccessDeniedMessage;
if (reason === "schedule_disallowed") return messages.scheduledAccessDeniedMessage;
return messages.vpnDeniedMessage;
}
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
const TEMPLATE_VARIABLE = /\{([a-z_]+)\}/g;
const COMMON_VARIABLES = new Set(["player", "group"]);
const SCHEDULE_VARIABLES = new Set(["player", "group", "next_start", "next_end"]);
function messageValue(formData: FormData, name: string) {
type MessageName = keyof AdmissionMessages;
function messageValue(formData: FormData, name: MessageName, allowedVariables: Set<string>) {
const value = String(formData.get(name) ?? "").trim();
return value.length >= 10 && value.length <= 500 && !CONTROL_CHARACTERS.test(value)
? value
: null;
if (value.length < 10 || value.length > 500 || CONTROL_CHARACTERS.test(value)) return null;
const withoutVariables = value.replace(TEMPLATE_VARIABLE, (match, variable: string) =>
allowedVariables.has(variable) ? "" : match);
return /[{}]/.test(withoutVariables) ? null : value;
}
export function parseAdmissionMessages(formData: FormData) {
const registrationMessage = messageValue(formData, "registrationMessage");
const groupAccessDeniedMessage = messageValue(formData, "groupAccessDeniedMessage");
const vpnDeniedMessage = messageValue(formData, "vpnDeniedMessage");
if (!registrationMessage || !groupAccessDeniedMessage || !vpnDeniedMessage) return null;
return { registrationMessage, groupAccessDeniedMessage, vpnDeniedMessage };
const registrationMessage = messageValue(formData, "registrationMessage", COMMON_VARIABLES);
const groupAccessDeniedMessage = messageValue(formData, "groupAccessDeniedMessage", COMMON_VARIABLES);
const vpnDeniedMessage = messageValue(formData, "vpnDeniedMessage", COMMON_VARIABLES);
const scheduledAccessDeniedMessage = messageValue(formData, "scheduledAccessDeniedMessage", SCHEDULE_VARIABLES);
if (!registrationMessage || !groupAccessDeniedMessage || !vpnDeniedMessage || !scheduledAccessDeniedMessage) return null;
return { registrationMessage, groupAccessDeniedMessage, vpnDeniedMessage, scheduledAccessDeniedMessage };
}
export function renderAdmissionMessage(template: string, variables: Record<string, string>) {
return template.replace(TEMPLATE_VARIABLE, (match, variable: string) => variables[variable] ?? match);
}
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_ADMISSION_MESSAGES } from "./admission-settings";
import { evaluateRegisteredPlayerAdmission } from "./game-admission-policy";
const group = {
name: "Friday friends",
accessEnabled: true,
anonymizedNetworksAllowed: false,
};
const fridayWindow = { startMinuteOfWeek: 6960, endMinuteOfWeek: 7200 };
describe("registered player admission policy", () => {
it("lets disabled Minecraft access override an active schedule", () => {
const decision = evaluateRegisteredPlayerAdmission({
group: { ...group, accessEnabled: false },
windows: [fridayWindow],
classification: "clear",
now: new Date("2026-08-07T21:00:00Z"),
player: "AlexMC",
messages: DEFAULT_ADMISSION_MESSAGES,
});
expect(decision.reason).toBe("group_access_disabled");
});
it("denies an enabled group outside its schedule with the next UTC window", () => {
const decision = evaluateRegisteredPlayerAdmission({
group,
windows: [fridayWindow],
classification: "vpn",
now: new Date("2026-08-08T01:00:00Z"),
player: "AlexMC",
messages: {
...DEFAULT_ADMISSION_MESSAGES,
scheduledAccessDeniedMessage: "{player} in {group}: {next_start}{next_end}.",
},
});
expect(decision.reason).toBe("schedule_disallowed");
expect(decision.message).toBe("AlexMC in Friday friends: 2026-08-14 20:00 UTC2026-08-15 00:00 UTC.");
});
it("applies network policy only after group access and schedule pass", () => {
const denied = evaluateRegisteredPlayerAdmission({
group,
windows: [fridayWindow],
classification: "vpn",
now: new Date("2026-08-07T21:00:00Z"),
player: "AlexMC",
messages: DEFAULT_ADMISSION_MESSAGES,
});
expect(denied.reason).toBe("anonymized_network_disallowed");
expect(evaluateRegisteredPlayerAdmission({
group: { ...group, anonymizedNetworksAllowed: true },
windows: [fridayWindow],
classification: "vpn",
now: new Date("2026-08-07T21:00:00Z"),
player: "AlexMC",
messages: DEFAULT_ADMISSION_MESSAGES,
}).allowed).toBe(true);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { gameAdmissionDenialReason } from "@minecraft-account-manager/auth";
import {
admissionDenialMessage,
renderAdmissionMessage,
type AdmissionMessages,
} from "./admission-settings";
import {
evaluateGroupSchedule,
formatScheduleInstant,
type WeeklyAccessWindow,
} from "./group-schedule";
interface EffectiveGroupPolicy {
name: string;
accessEnabled: boolean;
anonymizedNetworksAllowed: boolean;
}
interface RegisteredPlayerAdmissionInput {
group: EffectiveGroupPolicy | null;
windows: WeeklyAccessWindow[];
classification: "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor";
now: Date;
player: string;
messages: AdmissionMessages;
}
export function evaluateRegisteredPlayerAdmission(input: RegisteredPlayerAdmissionInput) {
const schedule = evaluateGroupSchedule(input.windows, input.now);
const reason = gameAdmissionDenialReason(input.group, input.classification, schedule.allowed);
if (!reason) return { allowed: true as const, reason: null, message: null, nextWindow: null };
return {
allowed: false as const,
reason,
message: renderAdmissionMessage(admissionDenialMessage(reason, input.messages), {
player: input.player,
group: input.group?.name ?? "everyone",
next_start: schedule.nextWindow ? formatScheduleInstant(schedule.nextWindow.start) : "unavailable",
next_end: schedule.nextWindow ? formatScheduleInstant(schedule.nextWindow.end) : "unavailable",
}),
nextWindow: schedule.nextWindow,
};
}
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
evaluateGroupSchedule,
groupScheduleStatus,
localWindowToUtc,
parseScheduleWindows,
utcWindowToLocal,
type WeeklyAccessWindow,
} from "./group-schedule";
const fridayEvening: WeeklyAccessWindow = {
startMinuteOfWeek: 4 * 24 * 60 + 20 * 60,
endMinuteOfWeek: 4 * 24 * 60 + 23 * 60 + 59,
};
describe("weekly group access schedules", () => {
it("summarizes whether a group has configured windows", () => {
expect(groupScheduleStatus(0)).toBe("Unrestricted");
expect(groupScheduleStatus(1)).toBe("1 window");
expect(groupScheduleStatus(3)).toBe("3 windows");
});
it("allows an enabled group at any time when no schedule is configured", () => {
expect(evaluateGroupSchedule([], new Date("2026-08-07T19:00:00Z"))).toEqual({
allowed: true,
nextWindow: null,
});
});
it("allows only inside a UTC window and identifies the next window when denied", () => {
expect(evaluateGroupSchedule([fridayEvening], new Date("2026-08-07T21:30:00Z")).allowed).toBe(true);
const denied = evaluateGroupSchedule([fridayEvening], new Date("2026-08-08T01:00:00Z"));
expect(denied.allowed).toBe(false);
expect(denied.nextWindow).toEqual({
start: new Date("2026-08-14T20:00:00.000Z"),
end: new Date("2026-08-14T23:59:00.000Z"),
});
});
it("uses inclusive starts and exclusive ends across the UTC week boundary", () => {
const sundayNight = { startMinuteOfWeek: 6 * 1440 + 23 * 60, endMinuteOfWeek: 2 * 60 };
expect(evaluateGroupSchedule([sundayNight], new Date("2026-08-09T23:00:00Z")).allowed).toBe(true);
expect(evaluateGroupSchedule([sundayNight], new Date("2026-08-10T01:59:59Z")).allowed).toBe(true);
expect(evaluateGroupSchedule([sundayNight], new Date("2026-08-10T02:00:00Z")).allowed).toBe(false);
});
it("selects the earliest upcoming window when several are configured", () => {
const mondayMorning = { startMinuteOfWeek: 8 * 60, endMinuteOfWeek: 9 * 60 };
const decision = evaluateGroupSchedule(
[fridayEvening, mondayMorning],
new Date("2026-08-08T01:00:00Z"),
);
expect(decision.nextWindow?.start).toEqual(new Date("2026-08-10T08:00:00.000Z"));
expect(decision.nextWindow?.end).toEqual(new Date("2026-08-10T09:00:00.000Z"));
});
it("fails closed for malformed persisted policy", () => {
expect(evaluateGroupSchedule([
{ startMinuteOfWeek: 100, endMinuteOfWeek: 200 },
{ startMinuteOfWeek: 150, endMinuteOfWeek: 250 },
], new Date("2026-08-03T02:30:00Z"))).toEqual({ allowed: false, nextWindow: null });
});
it("rejects malformed and overlapping submitted windows", () => {
const valid = new FormData();
valid.append("startMinuteOfWeek", "6960");
valid.append("endMinuteOfWeek", "7199");
valid.append("startMinuteOfWeek", "480");
valid.append("endMinuteOfWeek", "540");
expect(parseScheduleWindows(valid)).toEqual([
{ startMinuteOfWeek: 480, endMinuteOfWeek: 540 },
fridayEvening,
]);
const overlapping = new FormData();
overlapping.append("startMinuteOfWeek", "100");
overlapping.append("endMinuteOfWeek", "200");
overlapping.append("startMinuteOfWeek", "150");
overlapping.append("endMinuteOfWeek", "250");
expect(parseScheduleWindows(overlapping)).toBeNull();
const wrappingOverlap = new FormData();
wrappingOverlap.append("startMinuteOfWeek", String(6 * 1440 + 23 * 60));
wrappingOverlap.append("endMinuteOfWeek", String(2 * 60));
wrappingOverlap.append("startMinuteOfWeek", String(60));
wrappingOverlap.append("endMinuteOfWeek", String(3 * 60));
expect(parseScheduleWindows(wrappingOverlap)).toBeNull();
const mismatched = new FormData();
mismatched.append("startMinuteOfWeek", "100");
expect(parseScheduleWindows(mismatched)).toBeNull();
const invalid = new FormData();
invalid.append("startMinuteOfWeek", "10080");
invalid.append("endMinuteOfWeek", "0");
expect(parseScheduleWindows(invalid)).toBeNull();
for (const malformedValue of ["", " ", "+1", "0x10", "1e2", "1.5"]) {
const malformed = new FormData();
malformed.append("startMinuteOfWeek", malformedValue);
malformed.append("endMinuteOfWeek", "2");
expect(parseScheduleWindows(malformed)).toBeNull();
}
const tooMany = new FormData();
for (let index = 0; index < 51; index += 1) {
tooMany.append("startMinuteOfWeek", String(index * 2));
tooMany.append("endMinuteOfWeek", String(index * 2 + 1));
}
expect(parseScheduleWindows(tooMany)).toBeNull();
});
it("converts browser-local weekly values to authoritative UTC and back", () => {
const local = { startMinuteOfWeek: 4 * 1440 + 20 * 60, endMinuteOfWeek: 4 * 1440 + 23 * 60 };
const utc = localWindowToUtc(local, 420);
expect(utc).toEqual({
startMinuteOfWeek: 5 * 1440 + 3 * 60,
endMinuteOfWeek: 5 * 1440 + 6 * 60,
});
expect(utcWindowToLocal(utc, 420)).toEqual(local);
});
});
+124
View File
@@ -0,0 +1,124 @@
export const MINUTES_PER_WEEK = 7 * 24 * 60;
const MAX_WINDOWS = 50;
export function groupScheduleStatus(windowCount: number) {
if (windowCount <= 0) return "Unrestricted";
return `${windowCount} ${windowCount === 1 ? "window" : "windows"}`;
}
export interface WeeklyAccessWindow {
startMinuteOfWeek: number;
endMinuteOfWeek: number;
}
interface ScheduleDecision {
allowed: boolean;
nextWindow: { start: Date; end: Date } | null;
}
function normalizedMinute(value: number) {
return ((value % MINUTES_PER_WEEK) + MINUTES_PER_WEEK) % MINUTES_PER_WEEK;
}
function validWindow(window: WeeklyAccessWindow) {
return Number.isInteger(window.startMinuteOfWeek)
&& Number.isInteger(window.endMinuteOfWeek)
&& window.startMinuteOfWeek >= 0
&& window.startMinuteOfWeek < MINUTES_PER_WEEK
&& window.endMinuteOfWeek >= 0
&& window.endMinuteOfWeek < MINUTES_PER_WEEK
&& window.startMinuteOfWeek !== window.endMinuteOfWeek;
}
function segments(window: WeeklyAccessWindow) {
return window.endMinuteOfWeek > window.startMinuteOfWeek
? [[window.startMinuteOfWeek, window.endMinuteOfWeek] as const]
: [
[window.startMinuteOfWeek, MINUTES_PER_WEEK] as const,
[0, window.endMinuteOfWeek] as const,
];
}
export function validateScheduleWindows(windows: WeeklyAccessWindow[]) {
if (windows.length > MAX_WINDOWS || windows.some((window) => !validWindow(window))) return null;
for (let left = 0; left < windows.length; left += 1) {
for (let right = left + 1; right < windows.length; right += 1) {
const overlaps = segments(windows[left]!).some(([leftStart, leftEnd]) =>
segments(windows[right]!).some(([rightStart, rightEnd]) =>
leftStart < rightEnd && rightStart < leftEnd));
if (overlaps) return null;
}
}
return [...windows].sort((left, right) => left.startMinuteOfWeek - right.startMinuteOfWeek);
}
export function parseScheduleWindows(formData: FormData) {
const starts = formData.getAll("startMinuteOfWeek").map(String);
const ends = formData.getAll("endMinuteOfWeek").map(String);
if (starts.length !== ends.length) return null;
const decimalInteger = /^(0|[1-9]\d*)$/;
if (starts.some((value) => !decimalInteger.test(value)) || ends.some((value) => !decimalInteger.test(value))) {
return null;
}
return validateScheduleWindows(starts.map((start, index) => ({
startMinuteOfWeek: Number(start),
endMinuteOfWeek: Number(ends[index]),
})));
}
function utcWeekStart(now: Date) {
const dayFromMonday = (now.getUTCDay() + 6) % 7;
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - dayFromMonday);
}
function windowDuration(window: WeeklyAccessWindow) {
return normalizedMinute(window.endMinuteOfWeek - window.startMinuteOfWeek);
}
export function evaluateGroupSchedule(windows: WeeklyAccessWindow[], now: Date): ScheduleDecision {
if (!windows.length) return { allowed: true, nextWindow: null };
const valid = validateScheduleWindows(windows);
if (!valid || !Number.isFinite(now.getTime())) return { allowed: false, nextWindow: null };
const weekStart = utcWeekStart(now);
const occurrences = valid.flatMap((window) => [-1, 0, 1].map((weekOffset) => {
const start = new Date(weekStart + (weekOffset * MINUTES_PER_WEEK + window.startMinuteOfWeek) * 60_000);
const end = new Date(start.getTime() + windowDuration(window) * 60_000);
return { start, end };
}));
if (occurrences.some(({ start, end }) => now >= start && now < end)) {
return { allowed: true, nextWindow: null };
}
const nextWindow = occurrences
.filter(({ start }) => start > now)
.sort((left, right) => left.start.getTime() - right.start.getTime())[0] ?? null;
return { allowed: false, nextWindow };
}
export function localWindowToUtc(window: WeeklyAccessWindow, browserOffsetMinutes: number) {
return {
startMinuteOfWeek: normalizedMinute(window.startMinuteOfWeek + browserOffsetMinutes),
endMinuteOfWeek: normalizedMinute(window.endMinuteOfWeek + browserOffsetMinutes),
};
}
export function utcWindowToLocal(window: WeeklyAccessWindow, browserOffsetMinutes: number) {
return {
startMinuteOfWeek: normalizedMinute(window.startMinuteOfWeek - browserOffsetMinutes),
endMinuteOfWeek: normalizedMinute(window.endMinuteOfWeek - browserOffsetMinutes),
};
}
const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const;
export function formatWeeklyMinute(minuteOfWeek: number) {
const minute = normalizedMinute(minuteOfWeek);
const day = WEEKDAYS[Math.floor(minute / (24 * 60))];
const hour = Math.floor((minute % (24 * 60)) / 60);
const minuteOfHour = minute % 60;
return `${day} ${String(hour).padStart(2, "0")}:${String(minuteOfHour).padStart(2, "0")}`;
}
export function formatScheduleInstant(value: Date) {
return `${value.toISOString().slice(0, 16).replace("T", " ")} UTC`;
}
+38
View File
@@ -0,0 +1,38 @@
import { randomBytes } from "node:crypto";
import { describe, expect, it } from "vitest";
import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "./rcon-credentials";
const key = randomBytes(32).toString("base64");
const otherKey = randomBytes(32).toString("base64");
const connectionId = "11111111-1111-4111-8111-111111111111";
describe("RCON credential encryption", () => {
it("round trips with randomized authenticated encryption", () => {
const first = encryptRconPassword("super-secret", connectionId, key);
const second = encryptRconPassword("super-secret", connectionId, key);
expect(first).not.toBe(second);
expect(first).not.toContain("super-secret");
expect(decryptRconPassword(first, connectionId, key)).toBe("super-secret");
expect(decryptRconPassword(second, connectionId, key)).toBe("super-secret");
});
it("fails closed for tampering, another connection, or another key", () => {
const encrypted = encryptRconPassword("super-secret", connectionId, key);
expect(() => decryptRconPassword(`${encrypted}x`, connectionId, key)).toThrow("RCON credential unavailable");
expect(() => decryptRconPassword(encrypted, "22222222-2222-4222-8222-222222222222", key)).toThrow("RCON credential unavailable");
expect(() => decryptRconPassword(encrypted, connectionId, otherKey)).toThrow("RCON credential unavailable");
});
it("requires an exact 32-byte deployment key", () => {
expect(() => encryptRconPassword("secret", connectionId, "not-base64")).toThrow("RCON credential key is not configured");
});
it("creates a keyed, versioned command digest", () => {
const digest = rconCommandDigest("say secret message", key);
expect(digest).toMatch(/^hmac-sha256:v1:[a-f0-9]{64}$/u);
expect(digest).not.toContain("secret message");
expect(rconCommandDigest("say secret message", key)).toBe(digest);
expect(rconCommandDigest("say secret message", otherKey)).not.toBe(digest);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
const VERSION = "v1";
const KEY_BYTES = 32;
const IV_BYTES = 12;
function explicitKey(encoded: string) {
const key = Buffer.from(encoded, "base64");
if (key.length !== KEY_BYTES || key.toString("base64").replace(/=+$/u, "") !== encoded.trim().replace(/=+$/u, "")) {
throw new Error("invalid key");
}
return key;
}
function credentialKey(encoded: string | undefined, purpose: "credential" | "audit" = "credential") {
if (encoded !== undefined) return explicitKey(encoded);
const configured = purpose === "credential" ? process.env.RCON_CREDENTIAL_KEY : process.env.RCON_AUDIT_KEY;
if (configured) return explicitKey(configured);
const authSecret = process.env.AUTH_SECRET;
if (!authSecret) throw new Error("missing key");
return createHash("sha256").update(`minecraft-account-manager:rcon:${purpose}:v1\0${authSecret}`, "utf8").digest();
}
function additionalData(connectionId: string) {
return Buffer.from(`${VERSION}:${connectionId}`, "utf8");
}
function decodeBase64url(value: string) {
const decoded = Buffer.from(value, "base64url");
if (decoded.toString("base64url") !== value) throw new Error("invalid envelope");
return decoded;
}
export function encryptRconPassword(password: string, connectionId: string, encodedKey?: string) {
let key: Buffer;
try {
key = credentialKey(encodedKey);
} catch {
throw new Error("RCON credential key is not configured");
}
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
cipher.setAAD(additionalData(connectionId));
const ciphertext = Buffer.concat([cipher.update(password, "utf8"), cipher.final()]);
return [VERSION, iv.toString("base64url"), cipher.getAuthTag().toString("base64url"), ciphertext.toString("base64url")].join(":");
}
export function rconCommandDigest(command: string, encodedKey?: string) {
let key: Buffer;
try {
key = credentialKey(encodedKey, "audit");
} catch {
throw new Error("RCON audit key is not configured");
}
return `hmac-sha256:v1:${createHmac("sha256", key).update(command, "utf8").digest("hex")}`;
}
export function decryptRconPassword(envelope: string, connectionId: string, encodedKey?: string) {
try {
const key = credentialKey(encodedKey);
const [version, ivValue, tagValue, ciphertextValue, extra] = envelope.split(":");
if (version !== VERSION || !ivValue || !tagValue || !ciphertextValue || extra) throw new Error("invalid envelope");
const iv = decodeBase64url(ivValue);
const tag = decodeBase64url(tagValue);
const ciphertext = decodeBase64url(ciphertextValue);
if (iv.length !== IV_BYTES || tag.length !== 16) throw new Error("invalid envelope");
const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
decipher.setAAD(additionalData(connectionId));
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
} catch {
throw new Error("RCON credential unavailable");
}
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from "vitest";
import { executeRcon, testRconConnection, type RconTransport } from "./rcon-gateway";
function transport(overrides: Partial<RconTransport> = {}): RconTransport {
return {
connect: vi.fn().mockResolvedValue(undefined),
send: vi.fn().mockResolvedValue("20 players online"),
end: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
describe("RCON gateway", () => {
it("authenticates a connection without sending a command", async () => {
const client = transport();
await expect(testRconConnection({ host: "season4", port: 25575, password: "secret" }, () => client)).resolves.toEqual({ ok: true });
expect(client.connect).toHaveBeenCalledOnce();
expect(client.send).not.toHaveBeenCalled();
expect(client.end).toHaveBeenCalledOnce();
});
it("executes one command and always closes the connection", async () => {
const client = transport();
await expect(executeRcon({ host: "season4", port: 25575, password: "secret" }, "list", () => client)).resolves.toEqual({
ok: true,
response: "20 players online",
});
expect(client.send).toHaveBeenCalledWith("list");
expect(client.end).toHaveBeenCalledOnce();
});
it("returns safe categorized failures and closes failed clients", async () => {
const client = transport({ connect: vi.fn().mockRejectedValue(new Error("password secret rejected")) });
await expect(testRconConnection({ host: "season4", port: 25575, password: "secret" }, () => client)).resolves.toEqual({
ok: false,
reason: "unavailable",
});
expect(client.end).toHaveBeenCalledOnce();
});
it("rejects concurrent work for the same connection", async () => {
let release!: () => void;
const pending = new Promise<string>((resolve) => { release = () => resolve("done"); });
const firstClient = transport({ send: vi.fn().mockReturnValue(pending) });
const first = executeRcon({ id: "server-one", host: "season4", port: 25575, password: "secret" }, "list", () => firstClient);
await vi.waitFor(() => expect(firstClient.send).toHaveBeenCalled());
await expect(executeRcon({ id: "server-one", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
ok: false,
reason: "busy",
});
release();
await first;
});
it("bounds total concurrent work", async () => {
let release!: () => void;
const pendingResponse = new Promise<string>((resolve) => { release = () => resolve("done"); });
const clients = Array.from({ length: 8 }, () => transport({ send: vi.fn().mockReturnValue(pendingResponse) }));
const active = clients.map((client, index) => executeRcon({
id: `server-${index}`,
host: `season-${index}`,
port: 25575,
password: "secret",
}, "list", () => client));
await vi.waitFor(() => expect(clients.every((client) => vi.mocked(client.send).mock.calls.length === 1)).toBe(true));
await expect(executeRcon({ id: "server-ninth", host: "season-9", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
ok: false,
reason: "busy",
});
release();
await Promise.all(active);
});
it("times out the complete operation, aborts the socket, and releases the connection", async () => {
vi.useFakeTimers();
try {
const client = transport({
send: vi.fn().mockReturnValue(new Promise(() => undefined)),
destroy: vi.fn(),
});
const pending = executeRcon({ id: "server-timeout", host: "season4", port: 25575, password: "secret" }, "list", () => client);
await vi.advanceTimersByTimeAsync(5_000);
await expect(pending).resolves.toEqual({ ok: false, reason: "timeout" });
expect(client.destroy).toHaveBeenCalledOnce();
await expect(executeRcon({ id: "server-timeout", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
ok: true,
response: "20 players online",
});
} finally {
vi.useRealTimers();
}
});
it("does not let stalled cleanup retain a connection lock", async () => {
vi.useFakeTimers();
try {
const client = transport({ end: vi.fn().mockReturnValue(new Promise(() => undefined)) });
const pending = executeRcon({ id: "server-cleanup", host: "season4", port: 25575, password: "secret" }, "list", () => client);
await vi.advanceTimersByTimeAsync(1_000);
await expect(pending).resolves.toEqual({ ok: true, response: "20 players online" });
await expect(executeRcon({ id: "server-cleanup", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
ok: true,
response: "20 players online",
});
} finally {
vi.useRealTimers();
}
});
});
+104
View File
@@ -0,0 +1,104 @@
import { Rcon } from "rcon-client";
import { sanitizeRconOutput } from "./rcon-validation";
const TIMEOUT_MS = 5_000;
const CLEANUP_TIMEOUT_MS = 1_000;
const MAX_ACTIVE_CONNECTIONS = 8;
const activeConnections = new Set<string>();
type Connection = { id?: string; host: string; port: number; password: string };
type FailureReason = "busy" | "timeout" | "unavailable";
export interface RconTransport {
connect(): Promise<unknown>;
send(command: string): Promise<string>;
end(): Promise<unknown>;
destroy?(): void;
}
type TransportFactory = (connection: Connection) => RconTransport;
class RconDeadlineError extends Error {}
async function deadline<T>(operation: Promise<T>, timeout: () => void, timeoutMs = TIMEOUT_MS) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
timeout();
reject(new RconDeadlineError("RCON operation timed out"));
}, timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function defaultTransport(connection: Connection): RconTransport {
const client = new Rcon({
host: connection.host,
port: connection.port,
password: connection.password,
timeout: TIMEOUT_MS,
maxPending: 1,
});
return {
connect: () => client.connect(),
send: (command) => client.send(command),
end: async () => {
if (!client.socket) return;
if (client.socket.connecting || !client.socket.writable) {
client.socket.destroy();
return;
}
await client.end();
},
destroy: () => client.socket?.destroy(),
};
}
function failure(error: unknown): { ok: false; reason: FailureReason } {
return { ok: false, reason: error instanceof RconDeadlineError ? "timeout" : "unavailable" };
}
async function withTransport<T>(
connection: Connection,
operation: (transport: RconTransport) => Promise<T>,
factory: TransportFactory,
): Promise<T | { ok: false; reason: FailureReason }> {
const key = connection.id ?? `${connection.host}:${connection.port}`;
if (activeConnections.has(key) || activeConnections.size >= MAX_ACTIVE_CONNECTIONS) {
return { ok: false, reason: "busy" };
}
activeConnections.add(key);
let transport: RconTransport | null = null;
try {
transport = factory(connection);
return await deadline(operation(transport), () => transport?.destroy?.());
} catch (error) {
return failure(error);
} finally {
if (transport) {
await deadline(transport.end(), () => transport?.destroy?.(), CLEANUP_TIMEOUT_MS).catch(() => undefined);
}
activeConnections.delete(key);
}
}
export async function testRconConnection(connection: Connection, factory: TransportFactory = defaultTransport) {
return withTransport(connection, async (transport) => {
await transport.connect();
return { ok: true as const };
}, factory);
}
export async function executeRcon(connection: Connection, command: string, factory: TransportFactory = defaultTransport) {
return withTransport(connection, async (transport) => {
await transport.connect();
const response = await transport.send(command);
return { ok: true as const, response: sanitizeRconOutput(response) };
}, factory);
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { sanitizeRconOutput, validateRconCommand, validateRconConnection } from "./rcon-validation";
describe("RCON validation", () => {
it("normalizes any valid DNS hostname and port without deployment configuration", () => {
expect(validateRconConnection({
name: " Season 4 ",
host: "SEASON4.SOMC.SVC.CLUSTER.LOCAL",
port: "25575",
password: "correct horse battery staple",
}, { passwordRequired: true })).toEqual({
name: "Season 4",
host: "season4.somc.svc.cluster.local",
port: 25575,
password: "correct horse battery staple",
});
expect(validateRconConnection({
name: "Creative",
host: "creative.example.net",
port: "43210",
password: "secret",
}, { passwordRequired: true })).toEqual({
name: "Creative",
host: "creative.example.net",
port: 43210,
password: "secret",
});
});
it("rejects IP literals and malformed DNS hostnames", () => {
for (const host of ["10.0.0.1", "2001:db8::1", "season4.", "-season4.example", "season4..example"]) {
expect(validateRconConnection({ name: "Server", host, port: "25575", password: "secret" }, {
passwordRequired: true,
})).toBeNull();
}
});
it("allows a blank replacement password only while editing", () => {
expect(validateRconConnection({ name: "Server", host: "season4.somc.svc.cluster.local", port: "25575", password: "" }, {
passwordRequired: false,
})?.password).toBeNull();
expect(validateRconConnection({ name: "Server", host: "season4.somc.svc.cluster.local", port: "25575", password: "" }, {
passwordRequired: true,
})).toBeNull();
});
it("bounds commands by UTF-8 bytes and rejects control characters", () => {
expect(validateRconCommand(" list ")).toBe("list");
expect(validateRconCommand("say first\nsay second")).toBeNull();
expect(validateRconCommand("say \u001b[31mred")).toBeNull();
expect(validateRconCommand(`say ${"😀".repeat(300)}`)).toBeNull();
});
it("strips output controls and bounds output by UTF-8 bytes", () => {
expect(sanitizeRconOutput("ok\u001b[31mred\u0000done")).toBe("ok[31mreddone");
expect(Buffer.byteLength(sanitizeRconOutput("😀".repeat(20_000)), "utf8")).toBeLessThanOrEqual(65_536);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { isIP } from "node:net";
const HOST_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u;
const MAX_COMMAND_BYTES = 1_024;
const MAX_OUTPUT_BYTES = 65_536;
export type ValidRconConnection = {
name: string;
host: string;
port: number;
password: string | null;
};
export function validateRconConnection(
input: { name: unknown; host: unknown; port: unknown; password: unknown },
options: { passwordRequired: boolean },
): ValidRconConnection | null {
const name = typeof input.name === "string" ? input.name.trim() : "";
const host = typeof input.host === "string" ? input.host.trim().toLowerCase() : "";
const portText = typeof input.port === "string" || typeof input.port === "number" ? String(input.port).trim() : "";
const passwordText = typeof input.password === "string" ? input.password : "";
const port = Number(portText);
if (!name || name.length > 100 || CONTROL_PATTERN.test(name)) return null;
if (!host || host.endsWith(".") || isIP(host) !== 0 || !HOST_PATTERN.test(host)) return null;
if (!Number.isInteger(port) || port < 1 || port > 65_535) return null;
if (passwordText.length > 512 || CONTROL_PATTERN.test(passwordText)) return null;
if (options.passwordRequired && !passwordText) return null;
return { name, host, port, password: passwordText || null };
}
export function validateRconCommand(value: unknown) {
if (typeof value !== "string") return null;
const command = value.trim();
if (!command || CONTROL_PATTERN.test(command) || Buffer.byteLength(command, "utf8") > MAX_COMMAND_BYTES) return null;
return command;
}
export function sanitizeRconOutput(value: string) {
const safe = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, "");
if (Buffer.byteLength(safe, "utf8") <= MAX_OUTPUT_BYTES) return safe;
let result = "";
let bytes = 0;
for (const character of safe) {
const size = Buffer.byteLength(character, "utf8");
if (bytes + size > MAX_OUTPUT_BYTES) break;
result += character;
bytes += size;
}
return result;
}
+14 -1
View File
@@ -1,7 +1,20 @@
import { describe, expect, it } from "vitest";
import { groupMapLocations, parseUserLocation, parseUserNetwork, projectWorldPoint } from "./user-location-map";
import {
MAP_LOCATION_CLASSIFICATIONS,
groupMapLocations,
parseUserLocation,
parseUserNetwork,
projectWorldPoint,
} from "./user-location-map";
describe("user location map", () => {
it("allows only clear and hosting observations as map locations", () => {
expect(MAP_LOCATION_CLASSIFICATIONS).toEqual(["clear", "hosting"]);
expect(MAP_LOCATION_CLASSIFICATIONS).not.toContain("vpn");
expect(MAP_LOCATION_CLASSIFICATIONS).not.toContain("proxy");
expect(MAP_LOCATION_CLASSIFICATIONS).not.toContain("tor");
});
it("extracts a valid approximate location from cached IP intelligence", () => {
expect(parseUserLocation({
classification: "clear",
+2
View File
@@ -1,5 +1,7 @@
type UnknownMap = Record<string, unknown>;
export const MAP_LOCATION_CLASSIFICATIONS = ["clear", "hosting"] as const;
function objectValue(value: unknown): UnknownMap | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as UnknownMap
+3
View File
@@ -33,6 +33,9 @@ This OKF bundle is the product record for implemented and proposed behavior. Sto
* [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 internal Minecraft RCON endpoints.
* [US-022 — Operate servers through an RCON console](us-022-rcon-console.md) - Administrators execute bounded commands through the server-side portal proxy.
# Tracking
+13
View File
@@ -1,7 +1,20 @@
# 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.
## 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.
+3
View File
@@ -23,11 +23,14 @@ As a registered player, I want the Velocity proxy to recognize my approved Java
- [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.
+4 -2
View File
@@ -21,8 +21,9 @@ As an administrator, I want operational settings and audit visibility, so that I
- [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, and VPN/proxy/Tor-denied game messages.
- [x] Every message is validated server-side and has a safe default.
- [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
@@ -31,6 +32,7 @@ As an administrator, I want operational settings and audit visibility, so that I
- [`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
+5 -2
View File
@@ -3,7 +3,7 @@ type: User Story
title: Manage users as an administrator
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
tags: [admin, users, minecraft, discord]
timestamp: 2026-08-02T15:03:59Z
timestamp: 2026-08-07T23:02:04Z
story_id: US-013
status: verified
---
@@ -17,6 +17,9 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
- [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.
@@ -43,7 +46,7 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
# Validation
Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts). Privileged routes pass TypeScript, lint, Semgrep, and production build checks.
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
+3
View File
@@ -19,6 +19,8 @@ As an administrator, I want to organize registered users into access groups, so
- [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.
@@ -40,6 +42,7 @@ As an administrator, I want to organize registered users into access groups, so
- [`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)
+8 -5
View File
@@ -3,7 +3,7 @@ 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-02T12:05:27Z
timestamp: 2026-08-07T22:31:05Z
story_id: US-018
status: verified
---
@@ -15,7 +15,9 @@ As an administrator, I want an operational dashboard of account and game activit
# 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 observation with valid approximate coordinates.
- [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.
@@ -31,7 +33,7 @@ As an administrator, I want an operational dashboard of account and game activit
- [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 use enriched ProxyCheck classifications, collapse repeated rows per user, and show counts, sources, and latest activity.
- [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.
@@ -48,8 +50,9 @@ As an administrator, I want an operational dashboard of account and game activit
# 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, 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-world-map tests.
- The Next.js production build reports the dashboard and database-backed console pages as dynamic server-rendered routes.
- 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
+4 -1
View File
@@ -14,7 +14,7 @@ As an administrator, I want a concise group policy table and focused group detai
# Acceptance Criteria
- [x] The main Groups page lists name, Minecraft access, VPN/proxy/Tor access, and effective member count with the default group first and remaining names ordered alphabetically.
- [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.
@@ -24,6 +24,8 @@ As an administrator, I want a concise group policy table and focused group detai
- [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
@@ -32,6 +34,7 @@ As an administrator, I want a concise group policy table and focused group detai
- [`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
+52
View File
@@ -0,0 +1,52 @@
---
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
@@ -0,0 +1,39 @@
---
type: User Story
title: Manage RCON server connections
description: Administrators manage encrypted connection settings for internal Minecraft RCON endpoints.
tags: [admin, rcon, minecraft, security, operations]
timestamp: 2026-08-08T11:44:59Z
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 can list, add, edit, test, enable or disable, and delete RCON server connections.
- [x] Each connection has a unique display name, internal hostname, 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] Deleting a connection requires explicit confirmation.
- [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 administrator RCON page and 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 DNS hostname and port pairs 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)
+39
View File
@@ -0,0 +1,39 @@
---
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-08T11:44:59Z
story_id: US-022
status: verified
---
# User Story
As an administrator, I want an RCON console in the portal, so that I can operate internal Minecraft servers without exposing RCON publicly.
# 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 an internal 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 are not persisted in console history, 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 is keyboard accessible and clearly identifies the selected server.
- [x] RCON remains internal to the cluster and is not exposed through public ingress or a load balancer.
# Implementation
The client console 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. The SoMC GitOps deployment verifies Season 4 RCON through an authenticated internal ClusterIP Service backed by a Kubernetes Secret, with no public ingress or load balancer exposure.
# 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)
+2 -1
View File
@@ -13,7 +13,8 @@ Player account management, administrator navigation, dashboard metrics and chart
- Preserved reduced-motion behavior and disabled decorative cursor animation when requested.
- Added labels or accessible names to search, Minecraft username, settings, group, and event-filter controls.
- Added reusable per-user group dropdowns that open labelled confirmation dialogs, restore the prior selection on cancellation, prevent dismissal while pending, and provide live progress and result feedback.
- Group creation, policy, editing, and deletion use native modal dialogs with keyboard cancellation, focus management, descriptive confirmation text, and disabled pending controls.
- Group creation, policy, schedule editing, metadata editing, and deletion use scrollable native modal dialogs with keyboard cancellation, focus management, descriptive confirmation text, and disabled pending controls.
- Weekly schedule rows use labelled fieldsets, weekday and time controls, explicit exclusive-end wording, authoritative UTC values, and browser-local equivalents with the detected timezone.
- Added `fieldset` and `legend` semantics to multi-select event-type filters.
- Added table captions, column scopes, and row scopes to administrator data tables.
- Added `role=status` with polite announcements for successful nickname changes and `role=alert` with assertive announcements for errors.
+8 -1
View File
@@ -8,6 +8,10 @@ The Next.js application owns user onboarding, account management, admin configur
User authentication begins with an opaque, short-lived, single-use token created for a Discord user. Only a cryptographic hash of the token is persisted. Admin authentication is a separate Keycloak OIDC flow and requires the `minecraft-account-manager-admin` role.
### RCON administration
The administrator console stores one or more RCON endpoints with write-only AES-GCM-encrypted passwords. Browser requests invoke authenticated server actions; only the Next.js runtime opens RCON TCP connections. Administrators may configure any syntactically valid DNS hostname and port without deployment-managed endpoint configuration; IP literals remain rejected. Commands and responses are bounded and ephemeral, while credential-safe audit events retain the operator, server, command verb, keyed digest, outcome, and duration. Production Minecraft RCON is exposed only through internal cluster services and never through public ingress.
### Discord bot
The bot creates private login links in response to `/register` and `/account`. Discord user IDs are the canonical Discord identity; mutable usernames are snapshots only. Nickname updates target the deployment guild configured by `DISCORD_GUILD_ID`; the public join button uses `DISCORD_INVITE_URL`.
@@ -16,7 +20,7 @@ The bot creates private login links in response to `/register` and `/account`. D
Velocity sends the authenticated Java UUID, current username, source IP, server ID, request ID, and occurrence time. The API matches UUID first. Username fallback is allowed only when the stored account has no UUID, after which UUID and canonical username are updated.
The admission decision is fail closed. Unknown players, disabled effective groups, disallowed confirmed VPN/proxy/Tor connections, invalid responses, expired requests, authentication failures, and unavailable API responses are denied. Registration, group-access, and anonymized-network denials use independent operator-configured messages; transport and service failures retain the plugin's local fallback. After admission succeeds, `PostLoginEvent` reports a confirmed proxy connection through a fresh, authenticated, replay-protected request. Connection telemetry is best effort and never disconnects an already admitted player.
The admission decision is fail closed. Unknown players, disabled effective groups, out-of-window scheduled groups, disallowed confirmed VPN/proxy/Tor connections, invalid responses, expired requests, authentication failures, and unavailable API responses are denied. Policy order is fixed: registration, enabled effective group, recurring UTC schedule, then anonymized-network exception. Zero schedule windows mean no time restriction; configured starts are inclusive and ends are exclusive. Scheduling is checked only at login and never disconnects an admitted player. Registration, group-access, schedule, and anonymized-network denials use independent operator-configured static templates; validated variables provide player, group, and next-window UTC guidance without executable expressions. Transport and service failures retain the plugin's local fallback. After admission succeeds, `PostLoginEvent` reports a confirmed proxy connection through a fresh, authenticated, replay-protected request. Connection telemetry is best effort and never disconnects an already admitted player.
## Trust boundaries
@@ -26,12 +30,15 @@ The admission decision is fail closed. Unknown players, disabled effective group
- Velocity requests use hashed per-server bearer credentials, timestamps, and database-unique request IDs for authentication and replay prevention.
- Session and one-time-code values are random and stored only as hashes.
- Exact IP addresses are sensitive data and require an explicit retention policy before production deployment.
- RCON endpoints require syntactically valid DNS hostnames and ports; passwords never cross the browser trust boundary. Cluster egress policy and administrator authorization constrain the resulting outbound-connectivity trust boundary.
- RCON commands and responses are untrusted, bounded, rendered only as text, and excluded from persistent history and logs.
## Database invariants
- A Discord user ID maps to one user.
- An active Minecraft UUID or case-insensitive username maps to one account.
- A user has at most one active primary Minecraft account.
- Group schedule boundaries are integer UTC minutes of the recurring Monday-based week; malformed or overlapping persisted windows fail closed during admission.
- Removed accounts are soft deleted to retain audit history.
- Audit events are CloudEvents-shaped, append-only application records.
- `published_at` reserves an outbox-style path for later Kafka publishing.
+39
View File
@@ -0,0 +1,39 @@
# RCON administration
The administrator RCON console proxies commands through the Next.js server runtime. Browsers never receive RCON credentials and never open RCON sockets.
## Application configuration
Administrators may configure any syntactically valid DNS hostname and TCP port without deployment-managed endpoint configuration. IP literals, trailing-dot hostnames, and malformed DNS names are rejected whenever a connection is saved, tested, or used.
This flexibility means an authorized or compromised administrator can make RCON connection attempts to any DNS hostname and port reachable from the web runtime. Use cluster egress policy and administrator access controls to constrain that trust boundary where required.
Saved passwords are encrypted with AES-256-GCM and connection-bound authenticated data. By default, domain-separated credential and audit keys are derived from `AUTH_SECRET`. Deployments may instead provide independent 32-byte base64 values through `RCON_CREDENTIAL_KEY` and `RCON_AUDIT_KEY`. Rotating the credential key requires replacing saved RCON passwords.
## Minecraft server configuration
Enable RCON with a high-entropy password supplied through the deployment secret. Expose its port only on an internal `ClusterIP` service. Do not add RCON to an Ingress, NodePort, or public LoadBalancer.
The password entered in the administrator connection form must match the server password. Existing passwords are write-only; leave the replacement field blank when editing unrelated connection settings.
## Security behavior
- Existing account-manager administrator authorization is rechecked for every connection mutation, test, and command.
- Commands are limited to 1,024 UTF-8 bytes and reject control characters.
- Each web process allows one operation per connection and at most eight RCON operations total. Size replica counts with that aggregate ceiling in mind.
- Each complete connect-and-response operation times out after five seconds and tears down the socket; cleanup is independently capped at one second.
- Responses are sanitized and limited to 64 KiB.
- Full commands and responses are not persisted or logged. Audit events contain the command verb and a domain-separated HMAC digest.
- Connection passwords are never selected by page queries or returned to the browser.
RCON is plaintext TCP. Keep it on the cluster network and use network policy or an encrypted tunnel when the network trust model requires stronger isolation.
## Migration
Apply the generated Drizzle migration before deploying the web image:
```bash
npx drizzle-kit migrate
```
Never use `drizzle push` for this schema change.
+4 -2
View File
@@ -24,9 +24,11 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
- Velocity credentials are high-entropy bearer tokens stored only as hashes.
- Velocity admission and confirmed-connection requests have a 45-second clock window and database-unique request IDs for cross-instance replay prevention.
- Velocity and its API fail closed.
- Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback.
- Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback. Disabled access always overrides scheduling.
- Enabled groups with recurring UTC windows admit only during an active login window. Window boundaries and overlap are validated server-side, malformed persisted policy fails closed, and connected sessions are not re-evaluated.
- Confirmed VPN, proxy, and Tor game connections are denied unless that same effective group has an explicit exception; `everyone` and new groups default to no exception.
- Group, VPN-policy, identity, and membership mutations re-check the Keycloak administrator role server-side; registry assignments, policy changes, group edits, creation, deletion, and message settings commit atomically with their audit events.
- Static admission templates use a fixed allowlist of non-executable variables. Unknown placeholders and control characters are rejected, and rendered values remain plain JSON/text.
- Group, schedule, VPN-policy, identity, and membership mutations re-check the Keycloak administrator role server-side; registry assignments, policy changes, group edits, creation, deletion, and message settings commit atomically with their audit events.
- Group identity creation/rename and membership assignment/deletion use compatible PostgreSQL advisory and row locks to prevent duplicate names, stale audit records, or membership/deletion races. The protected default name and deletion restriction are enforced server-side.
- Every bearer-authenticated Velocity login uses cached IP intelligence before identity resolution, preventing account-creation races from bypassing network policy; malformed provider proxy signals classify as unknown.
- Event filters accept only event types already present in the ledger, and event detail routes remain role-protected.
+19 -3
View File
@@ -47,6 +47,7 @@
"leaflet": "^1.9.4",
"next": "^16.2.1",
"next-auth": "^4.24.13",
"rcon-client": "^4.2.5",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"topojson-client": "^3.1.0",
@@ -7358,9 +7359,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@@ -8062,6 +8063,15 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/rcon-client": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/rcon-client/-/rcon-client-4.2.5.tgz",
"integrity": "sha512-AnX1GU/ZTlwtYup3H6h0J1hwfP3OYltXVe+8ReBzmNEepX3xGH8nDg7gYqT5Y9rpAS/LmQ48h0BKINt1YGd8bA==",
"license": "MIT",
"dependencies": {
"typed-emitter": "^0.1.0"
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -9197,6 +9207,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typed-emitter": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-0.1.0.tgz",
"integrity": "sha512-Tfay0l6gJMP5rkil8CzGbLthukn+9BN/VXWcABVFPjOoelJ+koW8BuPZYk+h/L+lEeIp1fSzVRiWRPIjKVjPdg==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+2
View File
@@ -125,8 +125,10 @@ export function isGameNetworkAllowed(
export function gameAdmissionDenialReason(
group: { accessEnabled: boolean; anonymizedNetworksAllowed: boolean } | null,
classification: "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor",
scheduleAllowed = true,
) {
if (!group?.accessEnabled) return "group_access_disabled" as const;
if (!scheduleAllowed) return "schedule_disallowed" as const;
if (!isGameNetworkAllowed(classification, group.anonymizedNetworksAllowed)) {
return "anonymized_network_disallowed" as const;
}
+6 -4
View File
@@ -31,12 +31,14 @@ describe("group-based admission", () => {
}
});
it("prioritizes disabled group access before the network exception policy", () => {
expect(gameAdmissionDenialReason({ accessEnabled: false, anonymizedNetworksAllowed: false }, "vpn"))
it("prioritizes disabled group access, then schedule, then network policy", () => {
expect(gameAdmissionDenialReason({ accessEnabled: false, anonymizedNetworksAllowed: false }, "vpn", false))
.toBe("group_access_disabled");
expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: false }, "vpn"))
expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: false }, "vpn", false))
.toBe("schedule_disallowed");
expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: false }, "vpn", true))
.toBe("anonymized_network_disallowed");
expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: true }, "vpn"))
expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: true }, "vpn", true))
.toBeNull();
});
});
@@ -0,0 +1,12 @@
CREATE TABLE "group_access_windows" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"group_id" uuid NOT NULL,
"start_minute_of_week" integer NOT NULL,
"end_minute_of_week" integer NOT NULL,
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
CONSTRAINT "group_access_windows_minute_range_check" CHECK ("group_access_windows"."start_minute_of_week" >= 0 and "group_access_windows"."start_minute_of_week" < 10080 and "group_access_windows"."end_minute_of_week" >= 0 and "group_access_windows"."end_minute_of_week" < 10080 and "group_access_windows"."start_minute_of_week" <> "group_access_windows"."end_minute_of_week")
);
--> statement-breakpoint
ALTER TABLE "app_settings" ADD COLUMN "scheduled_access_denied_message" text DEFAULT 'Your group is only allowed access from {next_start} to {next_end}.' NOT NULL;--> statement-breakpoint
ALTER TABLE "group_access_windows" ADD CONSTRAINT "group_access_windows_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "group_access_windows_group_idx" ON "group_access_windows" USING btree ("group_id");
@@ -0,0 +1,13 @@
CREATE TABLE "rcon_servers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(100) NOT NULL,
"host" varchar(253) NOT NULL,
"port" integer DEFAULT 25575 NOT NULL,
"encrypted_password" text NOT NULL,
"enabled" boolean DEFAULT false NOT NULL,
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
CONSTRAINT "rcon_servers_port_check" CHECK ("rcon_servers"."port" between 1 and 65535)
);
--> statement-breakpoint
CREATE UNIQUE INDEX "rcon_servers_name_uidx" ON "rcon_servers" USING btree (lower("name"));
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -36,6 +36,20 @@
"when": 1785678753029,
"tag": "0004_zippy_silver_centurion",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1785692345708,
"tag": "0005_young_vertigo",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1786151282526,
"tag": "0006_curious_lester",
"breakpoints": true
}
]
}
+42
View File
@@ -1,7 +1,9 @@
import { sql } from "drizzle-orm";
import {
boolean,
check,
index,
integer,
inet,
jsonb,
pgEnum,
@@ -80,6 +82,26 @@ export const groups = pgTable(
],
);
export const groupAccessWindows = pgTable(
"group_access_windows",
{
id: uuid("id").primaryKey().defaultRandom(),
groupId: uuid("group_id")
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
startMinuteOfWeek: integer("start_minute_of_week").notNull(),
endMinuteOfWeek: integer("end_minute_of_week").notNull(),
createdAt: createdAt(),
},
(table) => [
index("group_access_windows_group_idx").on(table.groupId),
check(
"group_access_windows_minute_range_check",
sql`${table.startMinuteOfWeek} >= 0 and ${table.startMinuteOfWeek} < 10080 and ${table.endMinuteOfWeek} >= 0 and ${table.endMinuteOfWeek} < 10080 and ${table.startMinuteOfWeek} <> ${table.endMinuteOfWeek}`,
),
],
);
export const userGroupMemberships = pgTable(
"user_group_memberships",
{
@@ -178,9 +200,29 @@ export const appSettings = pgTable("app_settings", {
vpnDeniedMessage: text("vpn_denied_message")
.notNull()
.default("VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception."),
scheduledAccessDeniedMessage: text("scheduled_access_denied_message")
.notNull()
.default("Your group is only allowed access from {next_start} to {next_end}."),
...timestamps(),
});
export const rconServers = pgTable(
"rcon_servers",
{
id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 100 }).notNull(),
host: varchar("host", { length: 253 }).notNull(),
port: integer("port").notNull().default(25575),
encryptedPassword: text("encrypted_password").notNull(),
enabled: boolean("enabled").notNull().default(false),
...timestamps(),
},
(table) => [
uniqueIndex("rcon_servers_name_uidx").on(sql`lower(${table.name})`),
check("rcon_servers_port_check", sql`${table.port} between 1 and 65535`),
],
);
export const ipIntelligence = pgTable("ip_intelligence", {
ipAddress: inet("ip_address").primaryKey(),
classification: ipClassification("classification").notNull().default("unknown"),