Compare commits

...
20 Commits
Author SHA1 Message Date
dmg 0af4884f7e fix(deps): patch framework and tooling vulnerabilities
CI / validate (push) Successful in 7m32s
Release / release (push) Successful in 10m52s
2026-09-10 08:00:50 -04:00
dmg 1a01c0ed64 feat(admin): expose read-only Discord suggestions API
CI / validate (push) Successful in 6m28s
Release / release (push) Successful in 10m33s
2026-09-10 07:50:01 -04:00
dmg df17c021c6 chore(knowledge): move canonical docs to shared SoMC wiki
CI / validate (push) Successful in 8m7s
Release / release (push) Successful in 8m54s
2026-09-09 23:17:47 -04:00
dmg ed3f3cd843 feat(rcon): add audited command history
CI / validate (push) Successful in 6m38s
Release / release (push) Successful in 8m29s
2026-08-13 21:26:24 -04:00
dmg 168a7a2c36 feat(health): report build version
CI / validate (push) Successful in 6m24s
Release / release (push) Successful in 8m19s
2026-08-08 11:06:06 -04:00
dmg 56cbecc3f7 feat(rcon): retain terminal transcript
CI / validate (push) Successful in 6m28s
Release / release (push) Successful in 8m16s
2026-08-08 10:46:50 -04:00
dmg d45ea4db68 feat(rcon): add command history navigation
CI / validate (push) Successful in 6m26s
Release / release (push) Successful in 8m14s
2026-08-08 10:07:05 -04:00
dmg 19486150c3 feat(rcon): unify terminal workspace
CI / validate (push) Successful in 6m45s
Release / release (push) Successful in 8m38s
2026-08-08 09:41:37 -04:00
dmg 3564d24a45 feat(rcon): refine server console
CI / validate (push) Successful in 6m11s
Release / release (push) Successful in 7m59s
2026-08-08 08:06:11 -04:00
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
dmg 9f0832a5b5 fix(release): scope runtime workspace installs
CI / validate (push) Successful in 5m21s
Release / release (push) Successful in 8m35s
2026-08-02 11:39:18 -04:00
dmg ae623f5316 fix(release): reduce runtime dependency layers
CI / validate (push) Successful in 5m40s
Release / release (push) Failing after 10m13s
2026-08-02 11:26:24 -04:00
dmg d4afb71798 feat(admin): streamline group management
CI / validate (push) Successful in 4m16s
Release / release (push) Failing after 9m14s
2026-08-02 11:06:55 -04:00
106 changed files with 8229 additions and 1863 deletions
+6
View File
@@ -14,6 +14,8 @@ KEYCLOAK_REQUIRED_ROLE=minecraft-account-manager-admin
DISCORD_BOT_TOKEN=
DISCORD_APPLICATION_ID=
DISCORD_GUILD_ID=
# Optional admin suggestions reader; set the real forum ID only through GitOps.
DISCORD_SUGGESTIONS_FORUM_ID=
DISCORD_INVITE_URL=https://discord.gg/your-invite
# Trust forwarding headers only when your reverse proxy overwrites them
@@ -25,5 +27,9 @@ PROXYCHECK_API_KEY=
IP_INTELLIGENCE_CACHE_HOURS=48
BLOCK_HOSTING_IPS=false
# Optional independent 32-byte base64 RCON keys. When omitted, domain-separated keys are derived from AUTH_SECRET.
RCON_CREDENTIAL_KEY=
RCON_AUDIT_KEY=
# Structured Pino logging
LOG_LEVEL=info
-3
View File
@@ -44,9 +44,6 @@ jobs:
commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.sha }}" \
--extends @commitlint/config-conventional
- name: Validate OKF design
run: npm run design:validate
- name: Lint
run: npm run lint
+8 -10
View File
@@ -7,7 +7,6 @@ on:
permissions:
contents: write
packages: write
jobs:
release:
@@ -38,7 +37,6 @@ jobs:
- name: Validate release source
run: |
npm run design:validate
npm run lint
npm run typecheck
npm test
@@ -116,9 +114,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 +127,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 +140,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 +153,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'
+5 -39
View File
@@ -1,43 +1,9 @@
# Repository Agent Guidance
# minecraft-account-manager agent entrypoint
## User-story-driven development
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
The `design/` directory is the OKF v0.1 product record for this repository. Use user stories to plan, implement, verify, and track all behavior.
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/minecraft-account-manager/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/minecraft-account-manager/` stories. Also follow the parent workspace `AGENTS.md` when present.
Before changing behavior:
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/minecraft-account-manager/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
1. Read `design/index.md` and every story related to the requested behavior.
2. Draft updates to an existing story or create a new `design/us-NNN-short-name.md` story before implementation.
3. Define observable acceptance criteria using user or operator language.
4. Present the relevant new or updated stories and acceptance criteria to the user for review, and wait for explicit confirmation before changing implementation code.
5. Incorporate requested story changes before proceeding.
6. Set story status to `proposed` or `in-progress` while the work is incomplete.
While implementing:
1. Work in vertical slices against the documented acceptance criteria.
2. Add tests for important behavior before implementation when practical.
3. Keep implementation references and related-story links current.
4. Do not mark an acceptance criterion complete until the behavior exists and has been validated.
Before completing or committing:
1. Set completed story status to `implemented` or `verified` as appropriate.
2. Check completed acceptance criteria and record validation evidence.
3. Update `design/index.md` whenever stories are added, renamed, moved, or materially reclassified.
4. Add a high-level entry to `design/log.md` under the verified current date.
5. Run `npm run design:validate` along with relevant tests, type checks, lint, and builds.
## OKF conventions
- Every non-reserved Markdown file in `design/` must have YAML frontmatter with a non-empty `type`.
- User stories use `type: User Story` and include `story_id`, `status`, `title`, `description`, `tags`, and `timestamp`.
- Allowed story statuses are `proposed`, `in-progress`, `implemented`, and `verified`.
- `design/index.md` and `design/log.md` are reserved OKF files and follow the OKF index/log structures.
- Prefer structured sections: `# User Story`, `# Acceptance Criteria`, `# Implementation`, `# Validation`, and `# Related Stories`.
- Use repository-relative links and keep them valid when files move.
- Preserve unknown frontmatter extensions.
## Timestamps
Always run `date -u +%Y-%m-%dT%H:%M:%SZ` before adding or updating story timestamps or dated log entries. Never guess dates.
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
+11 -3
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS dependencies
FROM node:22-alpine AS manifests
WORKDIR /app
RUN apk add --no-cache libc6-compat
@@ -13,8 +13,16 @@ COPY packages/database/package.json ./packages/database/package.json
COPY packages/logging/package.json ./packages/logging/package.json
COPY packages/minecraft/package.json ./packages/minecraft/package.json
COPY packages/network/package.json ./packages/network/package.json
FROM manifests AS dependencies
RUN npm ci
FROM manifests AS bot-dependencies
RUN npm ci --omit=dev --workspace @minecraft-account-manager/discord-bot
FROM manifests AS migration-dependencies
RUN npm ci --omit=dev --workspace @minecraft-account-manager/database
FROM dependencies AS builder
COPY . .
RUN npm run build --workspace @minecraft-account-manager/web
@@ -36,7 +44,7 @@ USER app
EXPOSE 3000
CMD ["node", "apps/web/server.js"]
FROM dependencies AS bot
FROM bot-dependencies AS bot
ARG VERSION=development
LABEL org.opencontainers.image.title="Minecraft Account Manager Discord Bot" \
org.opencontainers.image.version="${VERSION}" \
@@ -51,7 +59,7 @@ COPY --chown=app:app packages ./packages
USER app
CMD ["npm", "run", "start", "--workspace", "@minecraft-account-manager/discord-bot"]
FROM dependencies AS migrate
FROM migration-dependencies AS migrate
ARG VERSION=development
LABEL org.opencontainers.image.title="Minecraft Account Manager Migrations" \
org.opencontainers.image.version="${VERSION}" \
+9 -5
View File
@@ -32,14 +32,17 @@ Set `IP_INTELLIGENCE_PROVIDER=proxycheck`, add `PROXYCHECK_API_KEY`, and configu
Open `http://localhost:3000`.
## Admin suggestions
Administrators can read the configured Discord forum through the session-protected [suggestions API](docs/admin-suggestions-api.md). Set `DISCORD_SUGGESTIONS_FORUM_ID` through GitOps; the existing bot token stays server-side. This integration is read-only and does not synchronize data into the database.
## Product design
Implemented and proposed behavior is tracked as OKF user stories in [`design/index.md`](design/index.md). Validate the bundle with `npm run design:validate`.
Implemented and proposed behavior is tracked in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/minecraft-account-manager/index.md). Validate canonical knowledge in that repository with `okflint validate --manifest okf-base.yaml`; source builds do not require wiki access.
## Validation
```bash
npm run design:validate
npm test
npm run typecheck
npm run lint
@@ -76,12 +79,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 only the effective group's access and VPN/proxy/Tor exception settings apply
- Admin user search, account management, event exploration, DAU and confirmed-connection metrics, toggleable Natural Earth/OpenStreetMap user-location views, RCON server-address management and command proxying, and automatic Discord nickname synchronization
- Exclusive group admission: unassigned users fall back to protected `everyone`, and administrators manage effective membership, access, recurring UTC login windows, and VPN/proxy/Tor exceptions through confirmed group workflows
- Deployment-managed Discord guild ID and invite URL
- discord.js bot with `/register` and `/account`
- 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.
+2 -2
View File
@@ -16,10 +16,10 @@
"@minecraft-account-manager/logging": "*",
"discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1"
"drizzle-orm": "^0.45.1",
"tsx": "^4.21.0"
},
"devDependencies": {
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
+5 -2
View File
@@ -20,8 +20,9 @@
"d3-geo": "^3.1.1",
"drizzle-orm": "^0.45.1",
"leaflet": "^1.9.4",
"next": "^16.2.1",
"next": "^16.3.4",
"next-auth": "^4.24.13",
"rcon-client": "^4.2.5",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"topojson-client": "^3.1.0",
@@ -29,6 +30,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@testing-library/react": "^16.3.2",
"@types/d3-geo": "^3.1.1",
"@types/leaflet": "^1.9.22",
"@types/node": "^25.0.3",
@@ -36,7 +38,8 @@
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.1",
"eslint-config-next": "^16.3.4",
"jsdom": "^30.0.1",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vitest": "^4.1.0"
@@ -1,16 +1,31 @@
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { asc, eq } from "drizzle-orm";
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";
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 { addGroupMember, assignDefaultGroup, deleteGroup, removeGroupMember, setGroupAccess, setGroupAnonymizedNetworkAccess } from "../actions";
import { isEffectiveGroupMember } from "@/lib/group-management";
import { assignUserGroupFromRegistry } from "../../users/actions";
import { deleteGroup, replaceGroupSchedule, setGroupAccess, setGroupAnonymizedNetworkAccess, updateGroupDetails } from "../actions";
const savedMessages: Record<string, string> = {
created: "Group created with access disabled.",
access: "Group access policy updated.",
"network-access": "Group VPN, proxy, and Tor policy updated.",
"member-added": "User assigned to the group.",
"member-removed": "User returned to the default group.",
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.",
};
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";
@@ -20,31 +35,48 @@ export default async function GroupPage({
searchParams,
}: {
params: Promise<{ groupId: string }>;
searchParams: Promise<{ saved?: string }>;
searchParams: Promise<{ error?: string; saved?: string }>;
}) {
const { groupId } = await params;
const query = await searchParams;
const [group] = await db.select().from(groups).where(eq(groups.id, groupId)).limit(1);
if (!group) notFound();
const [allUsers, memberships] = await Promise.all([
const [allUsers, allGroups, memberships, accessWindows] = await Promise.all([
db.select({
id: users.id,
firstName: users.firstName,
discordUsername: users.discordUsername,
discordGlobalName: users.discordGlobalName,
discordUserId: users.discordUserId,
}).from(users).orderBy(asc(users.discordUsername)),
onboardingCompletedAt: users.onboardingCompletedAt,
primaryUsername: minecraftAccounts.username,
accountCount: sql<number>`(
select count(*)::int from ${minecraftAccounts} account_count
where account_count.user_id = ${users.id}
and account_count.deleted_at is null
)`,
})
.from(users)
.leftJoin(minecraftAccounts, and(
eq(minecraftAccounts.userId, users.id),
eq(minecraftAccounts.isPrimary, true),
isNull(minecraftAccounts.deletedAt),
))
.orderBy(users.firstName, users.discordUsername),
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault })
.from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
db.select({ userId: userGroupMemberships.userId, groupId: userGroupMemberships.groupId })
.from(userGroupMemberships),
db.select({
userId: userGroupMemberships.userId,
groupId: userGroupMemberships.groupId,
groupName: groups.name,
}).from(userGroupMemberships).innerJoin(groups, eq(groups.id, userGroupMemberships.groupId)),
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id))
.orderBy(groupAccessWindows.startMinuteOfWeek),
]);
const assignmentByUser = new Map(memberships.map((membership) => [membership.userId, membership]));
const memberCount = group.isDefault
? allUsers.length - assignmentByUser.size
: memberships.filter((membership) => membership.groupId === group.id).length;
const assignmentByUser = Object.fromEntries(memberships.map((membership) => [membership.userId, membership.groupId]));
const memberUsers = allUsers.filter((user) => isEffectiveGroupMember(user.id, assignmentByUser, group));
const returnTo = `/admin/groups/${group.id}`;
return (
<main className="mx-auto max-w-6xl px-6 py-12">
@@ -53,83 +85,66 @@ export default async function GroupPage({
<div>
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Access group</p>
<div className="mt-4 flex flex-wrap items-center gap-3"><h1 className="font-display text-5xl font-black uppercase sm:text-7xl">{group.name}</h1>{group.isDefault && <span className="bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase text-canvas">Default</span>}</div>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
</div>
<div className="grid gap-6 sm:grid-cols-2">
<form action={setGroupAccess} className="border-l-2 border-accent pl-5">
<input name="groupId" type="hidden" value={group.id} />
<input name="accessEnabled" type="hidden" value={group.accessEnabled ? "no" : "yes"} />
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Minecraft admission</p>
<p className="mt-2 font-display text-2xl font-black uppercase">{group.accessEnabled ? "Allowed" : "Denied"}</p>
<button className="mt-3 font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn access {group.accessEnabled ? "off" : "on"}</button>
</form>
<form action={setGroupAnonymizedNetworkAccess} className="border-l-2 border-accent pl-5">
<input name="groupId" type="hidden" value={group.id} />
<input name="anonymizedNetworksAllowed" type="hidden" value={group.anonymizedNetworksAllowed ? "no" : "yes"} />
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">VPN / proxy / Tor</p>
<p className="mt-2 font-display text-2xl font-black uppercase">{group.anonymizedNetworksAllowed ? "Allowed" : "Denied"}</p>
<button className="mt-3 font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn exceptions {group.anonymizedNetworksAllowed ? "off" : "on"}</button>
</form>
<p className="mt-3 max-w-2xl whitespace-pre-line text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
</div>
<AdminModalForm
action={updateGroupDetails}
description={group.isDefault ? "Update the protected default group's description. Its name remains everyone." : "Update the administrator-facing name and description. The internal slug remains stable."}
submitLabel="Save details"
title={`Edit ${group.name}`}
triggerClassName="border border-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider"
triggerLabel="Edit group"
>
<input name="groupId" type="hidden" value={group.id} />
<div className="space-y-5">
<label className="block text-sm font-bold">Name<input className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent read-only:cursor-not-allowed read-only:text-muted" defaultValue={group.name} maxLength={50} name="name" readOnly={group.isDefault} required /></label>
<label className="block text-sm font-bold">Description<textarea className="mt-2 min-h-32 w-full resize-y border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" defaultValue={group.description ?? ""} maxLength={500} name="description" /></label>
</div>
</AdminModalForm>
</header>
{query.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[query.saved] ?? "Group updated."}</p>}
{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>}
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errorMessages[query.error] ?? "The group operation failed."}</p>}
<section className="mt-10">
<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">Membership</p><h2 className="mt-2 font-display text-3xl font-black uppercase">Registered users</h2></div>
<span className="font-mono text-xs text-muted">{memberCount} members</span>
<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. 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>
{group.isDefault && <p className="border-b border-line bg-panel px-5 py-4 text-sm text-muted">Users belong to <strong className="text-ink">everyone</strong> only while they have no explicit group assignment.</p>}
<div className="divide-y divide-line">
{allUsers.map((user) => {
const assignment = assignmentByUser.get(user.id);
const isMember = group.isDefault ? !assignment : assignment?.groupId === group.id;
return (
<article className="grid gap-4 py-5 sm:grid-cols-[1fr_auto] sm:items-center" key={user.id}>
<div>
<Link className="font-mono text-sm font-bold underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? user.discordGlobalName ?? user.discordUsername}</Link>
<p className="mt-1 font-mono text-[10px] text-muted">@{user.discordUsername} · {user.discordUserId}</p>
{!isMember && assignment && <p className="mt-1 text-xs text-muted">Currently assigned to {assignment.groupName}</p>}
</div>
{isMember ? (
group.isDefault ? <span className="font-mono text-[9px] font-bold uppercase text-muted">Default assignment</span> : (
<form action={removeGroupMember}>
<input name="groupId" type="hidden" value={group.id} />
<input name="userId" type="hidden" value={user.id} />
<button className="font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Return to everyone</button>
</form>
)
) : (
<form action={group.isDefault ? assignDefaultGroup : addGroupMember}>
<input name="groupId" type="hidden" value={group.id} />
<input name="userId" type="hidden" value={user.id} />
<button className="font-mono text-[9px] font-bold uppercase text-ink underline underline-offset-4" type="submit">Move to {group.name}</button>
</form>
)}
</article>
);
})}
{!allUsers.length && <p className="py-8 text-sm text-muted">No registered users yet.</p>}
<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>
<section className="mt-10" aria-labelledby="group-members-heading">
<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">Effective membership</p><h2 className="mt-2 font-display text-3xl font-black uppercase" id="group-members-heading">Members</h2></div>
<span className="font-mono text-xs text-muted">{memberUsers.length} {memberUsers.length === 1 ? "member" : "members"}</span>
</div>
<p className="border-x border-line bg-panel px-5 py-4 text-sm text-muted">{group.isDefault ? <>These users have no explicit assignment and therefore use <strong className="text-ink">everyone</strong>.</> : <>Choose another group to move a member, or choose <strong className="text-ink">everyone</strong> to remove the member from {group.name}. Every change requires confirmation.</>}</p>
<div className="mt-5"><AdminUserTable action={assignUserGroupFromRegistry} assignmentByUser={assignmentByUser} emptyMessage="This group has no effective members." groups={allGroups} returnTo={returnTo} users={memberUsers} /></div>
</section>
{!group.isDefault && (
<section className="mt-12 border border-accent bg-panel p-6">
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Danger zone</p>
<h2 className="mt-3 font-display text-2xl font-black uppercase">Delete {group.name}</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted">Deleting this group returns its {memberCount} {memberCount === 1 ? "member" : "members"} to the protected default group. This cannot be undone.</p>
<details className="mt-5">
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4">Review deletion</summary>
<form action={deleteGroup} className="mt-4 flex flex-wrap items-center gap-4">
<input name="groupId" type="hidden" value={group.id} />
<input name="confirmDelete" type="hidden" value="yes" />
<button className="bg-accent px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Delete group permanently</button>
<span className="text-xs text-muted">Members will use everyone immediately.</span>
</form>
</details>
<section className="mt-12 flex flex-col gap-5 border border-accent bg-panel p-6 sm:flex-row sm:items-center sm:justify-between">
<div><p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Danger zone</p><h2 className="mt-2 font-display text-2xl font-black uppercase">Delete {group.name}</h2><p className="mt-2 max-w-2xl text-sm leading-6 text-muted">All {memberUsers.length} effective {memberUsers.length === 1 ? "member" : "members"} will return to everyone.</p></div>
<AdminModalForm action={deleteGroup} description={`Permanently delete ${group.name} and return ${memberUsers.length} ${memberUsers.length === 1 ? "member" : "members"} to everyone. This cannot be undone.`} intent="danger" submitLabel="Delete group" title={`Delete ${group.name}?`} triggerClassName="bg-accent px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" triggerLabel="Delete group">
<input name="groupId" type="hidden" value={group.id} />
<input name="confirmDelete" type="hidden" value="yes" />
</AdminModalForm>
</section>
)}
</main>
);
}
function PolicyDetail({ children, description, label }: { children: ReactNode; description: string; label: string }) {
return <div className="flex items-center justify-between gap-5 border-l-2 border-accent pl-5"><div><h3 className="font-mono text-xs font-bold uppercase">{label}</h3><p className="mt-2 text-xs leading-5 text-muted">{description}</p></div>{children}</div>;
}
@@ -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,
}]]);
});
});
+174 -147
View File
@@ -1,179 +1,214 @@
"use server";
import { randomUUID } from "node:crypto";
import { events, groups, userGroupMemberships, users } 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 } from "drizzle-orm";
import { and, eq, ne, sql } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { recordAdminSubjectEvent } from "@/lib/audit";
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;
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
type Admin = Awaited<ReturnType<typeof requireAdminSession>>;
type ReturnLocation = "list" | "detail";
function groupPath(groupId: string, query?: string) {
return `/admin/groups/${encodeURIComponent(groupId)}${query ? `?${query}` : ""}`;
}
function returnLocation(formData: FormData): ReturnLocation {
return formData.get("returnLocation") === "list" ? "list" : "detail";
}
function operationPath(groupId: string, location: ReturnLocation, query: string) {
return location === "list" ? `/admin/groups?${query}` : groupPath(groupId, query);
}
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 };
}
export async function createGroup(formData: FormData) {
const admin = await requireAdminSession();
const name = String(formData.get("name") ?? "").trim();
const slug = String(formData.get("slug") ?? "").trim().toLowerCase();
const description = String(formData.get("description") ?? "").trim();
if (name.length < 1 || name.length > 50 || !SLUG_PATTERN.test(slug) || slug.length > 50 || description.length > 500) {
redirect("/admin/groups?error=invalid-group");
}
let group: { id: string } | undefined;
try {
[group] = await db.insert(groups).values({
name,
slug,
description: description || null,
accessEnabled: false,
anonymizedNetworksAllowed: false,
isDefault: false,
}).returning({ id: groups.id });
} catch {
redirect("/admin/groups?error=duplicate-group");
}
if (!group) redirect("/admin/groups?error=create-failed");
await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.created", {
name,
slug,
accessEnabled: false,
anonymizedNetworksAllowed: false,
});
redirect(groupPath(group.id, "saved=created"));
}
export async function setGroupAccess(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const details = validateGroupDetails(formData.get("name"), formData.get("description"));
if (!details) redirect("/admin/groups?error=invalid-group");
const accessEnabled = formData.get("accessEnabled") === "yes";
if (!UUID_PATTERN.test(groupId)) redirect("/admin/groups?error=unknown-group");
const anonymizedNetworksAllowed = formData.get("anonymizedNetworksAllowed") === "yes";
const ipAddress = await auditContext();
const [group] = await db.update(groups).set({ accessEnabled, updatedAt: new Date() })
.where(eq(groups.id, groupId)).returning({ id: groups.id, name: groups.name });
if (!group) redirect("/admin/groups?error=unknown-group");
await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.access-updated", {
name: group.name,
accessEnabled,
});
redirect(groupPath(group.id, "saved=access"));
let created: { id: string } | null = null;
try {
created = await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-identity'))`);
const [duplicate] = await tx.select({ id: groups.id }).from(groups)
.where(sql`lower(${groups.name}) = lower(${details.name})`).limit(1);
if (duplicate) return null;
const existing = await tx.select({ slug: groups.slug }).from(groups);
const slug = groupSlug(details.name, new Set(existing.map((group) => group.slug.toLowerCase())));
const [group] = await tx.insert(groups).values({
name: details.name,
slug,
description: details.description || null,
accessEnabled,
anonymizedNetworksAllowed,
isDefault: false,
}).returning({ id: groups.id });
if (!group) throw new Error("Group insert returned no row");
await tx.insert(events).values({
id: randomUUID(),
source: "/web/admin",
type: "games.minecraft.account-manager.group.created",
subject: `group/${group.id}`,
time: new Date(),
data: auditData(admin, {
name: details.name,
slug,
accessEnabled,
anonymizedNetworksAllowed,
}),
ipAddress: ipAddress ?? null,
});
return group;
});
} catch {
redirect("/admin/groups?error=create-failed");
}
if (!created) redirect("/admin/groups?error=duplicate-group");
redirect(groupPath(created.id, "saved=created"));
}
export async function setGroupAnonymizedNetworkAccess(formData: FormData) {
export async function updateGroupDetails(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const anonymizedNetworksAllowed = formData.get("anonymizedNetworksAllowed") === "yes";
const details = validateGroupDetails(formData.get("name"), formData.get("description"));
if (!UUID_PATTERN.test(groupId) || !details) redirect(operationPath(groupId, "detail", "error=invalid-group"));
const ipAddress = await auditContext();
const result = await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-identity'))`);
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
const [current] = await tx.select().from(groups).where(eq(groups.id, groupId)).limit(1);
if (!current) return "missing" as const;
const name = editableGroupName(current.name, current.isDefault, details.name);
if (!current.isDefault) {
const [duplicate] = await tx.select({ id: groups.id }).from(groups)
.where(and(sql`lower(${groups.name}) = lower(${name})`, ne(groups.id, current.id))).limit(1);
if (duplicate) return "duplicate" as const;
}
const [updated] = await tx.update(groups).set({ name, description: details.description || null, updatedAt: new Date() })
.where(eq(groups.id, current.id)).returning({ id: groups.id });
if (!updated) return "missing" as const;
await tx.insert(events).values({
id: randomUUID(),
source: "/web/admin",
type: "games.minecraft.account-manager.group.details-updated",
subject: `group/${current.id}`,
time: new Date(),
data: auditData(admin, {
previousName: current.name,
name,
previousDescription: current.description,
description: details.description || null,
}),
ipAddress: ipAddress ?? null,
});
return "updated" as const;
});
if (result === "missing") redirect("/admin/groups?error=unknown-group");
if (result === "duplicate") redirect(groupPath(groupId, "error=duplicate-group"));
redirect(groupPath(groupId, "saved=details"));
}
async function updateGroupPolicy(
formData: FormData,
policy: "access" | "anonymized-networks",
) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const location = returnLocation(formData);
if (!UUID_PATTERN.test(groupId)) redirect("/admin/groups?error=unknown-group");
const requestHeaders = await headers();
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
const enabled = formData.get("enabled") === "yes";
const ipAddress = await auditContext();
const group = await db.transaction(async (tx) => {
const [updated] = await tx.update(groups).set({ anonymizedNetworksAllowed, updatedAt: new Date() })
.where(eq(groups.id, groupId)).returning({ id: groups.id, name: groups.name });
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
const [current] = await tx.select().from(groups).where(eq(groups.id, groupId)).limit(1);
if (!current) return null;
const update = policy === "access" ? { accessEnabled: enabled } : { anonymizedNetworksAllowed: enabled };
const [updated] = await tx.update(groups).set({ ...update, updatedAt: new Date() })
.where(eq(groups.id, current.id)).returning({ id: groups.id });
if (!updated) return null;
await tx.insert(events).values({
id: randomUUID(),
source: "/web/admin",
type: "games.minecraft.account-manager.group.anonymized-network-access-updated",
subject: `group/${updated.id}`,
type: policy === "access"
? "games.minecraft.account-manager.group.access-updated"
: "games.minecraft.account-manager.group.anonymized-network-access-updated",
subject: `group/${current.id}`,
time: new Date(),
data: { name: updated.name, anonymizedNetworksAllowed, adminEmail: admin.email, adminName: admin.name },
data: auditData(admin, {
name: current.name,
previousEnabled: policy === "access" ? current.accessEnabled : current.anonymizedNetworksAllowed,
enabled,
}),
ipAddress: ipAddress ?? null,
});
return updated;
return current;
});
if (!group) redirect("/admin/groups?error=unknown-group");
redirect(groupPath(group.id, "saved=network-access"));
redirect(operationPath(group.id, location, `saved=${policy === "access" ? "access" : "network-access"}`));
}
export async function addGroupMember(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const userId = String(formData.get("userId") ?? "");
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
const [[group], [user]] = await Promise.all([
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
]);
if (!group || !user || group.isDefault) redirect("/admin/groups?error=invalid-membership");
const previousGroup = await db.transaction(async (tx) => {
const [previous] = await tx
.select({ id: groups.id, name: groups.name })
.from(userGroupMemberships)
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
.where(eq(userGroupMemberships.userId, user.id))
.limit(1);
await tx.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
await tx.insert(userGroupMemberships).values({ groupId: group.id, userId: user.id });
return previous ?? null;
});
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.assignment-updated", {
groupId: group.id,
groupName: group.name,
previousGroupId: previousGroup?.id ?? null,
previousGroupName: previousGroup?.name ?? "everyone",
});
redirect(groupPath(group.id, "saved=member-added"));
export async function setGroupAccess(formData: FormData) {
return updateGroupPolicy(formData, "access");
}
export async function removeGroupMember(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const userId = String(formData.get("userId") ?? "");
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
const [group] = await db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault })
.from(groups).where(eq(groups.id, groupId)).limit(1);
if (!group || group.isDefault) redirect("/admin/groups?error=invalid-membership");
await db.delete(userGroupMemberships).where(and(
eq(userGroupMemberships.groupId, group.id),
eq(userGroupMemberships.userId, userId),
));
await recordAdminSubjectEvent(admin, `user/${userId}`, "games.minecraft.account-manager.group.assignment-removed", {
groupId: group.id,
groupName: group.name,
fallbackGroup: "everyone",
});
redirect(groupPath(group.id, "saved=member-removed"));
export async function setGroupAnonymizedNetworkAccess(formData: FormData) {
return updateGroupPolicy(formData, "anonymized-networks");
}
export async function assignDefaultGroup(formData: FormData) {
export async function replaceGroupSchedule(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const userId = String(formData.get("userId") ?? "");
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
const windows = parseScheduleWindows(formData);
if (!UUID_PATTERN.test(groupId) || !windows) redirect(groupPath(groupId, "error=invalid-schedule"));
const ipAddress = await auditContext();
const [[defaultGroup], [user]] = await Promise.all([
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
]);
if (!defaultGroup?.isDefault || !user) redirect("/admin/groups?error=invalid-membership");
const [previous] = await db
.select({ id: groups.id, name: groups.name })
.from(userGroupMemberships)
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
.where(eq(userGroupMemberships.userId, user.id))
.limit(1);
await db.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.assignment-updated", {
groupId: defaultGroup.id,
groupName: defaultGroup.name,
previousGroupId: previous?.id ?? null,
previousGroupName: previous?.name ?? null,
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;
});
redirect(groupPath(defaultGroup.id, "saved=member-added"));
if (!updated) redirect("/admin/groups?error=unknown-group");
redirect(groupPath(updated.id, "saved=schedule"));
}
export async function deleteGroup(formData: FormData) {
@@ -181,40 +216,32 @@ export async function deleteGroup(formData: FormData) {
const groupId = String(formData.get("groupId") ?? "");
const confirmed = formData.get("confirmDelete") === "yes";
if (!UUID_PATTERN.test(groupId) || !confirmed) redirect("/admin/groups?error=invalid-delete");
const requestHeaders = await headers();
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
const ipAddress = await auditContext();
const deleted = await db.transaction(async (tx) => {
const [group] = await tx
.select({ id: groups.id, name: groups.name, slug: groups.slug, isDefault: groups.isDefault })
.from(groups)
.where(eq(groups.id, groupId))
.limit(1);
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-membership'))`);
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
const [group] = await tx.select().from(groups).where(eq(groups.id, groupId)).limit(1);
if (!group || group.isDefault) return null;
const members = await tx
.select({ userId: userGroupMemberships.userId })
.from(userGroupMemberships)
.where(eq(userGroupMemberships.groupId, group.id));
const members = await tx.select({ userId: userGroupMemberships.userId })
.from(userGroupMemberships).where(eq(userGroupMemberships.groupId, group.id));
await tx.insert(events).values({
id: randomUUID(),
source: "/web/admin",
type: "games.minecraft.account-manager.group.deleted",
subject: `group/${group.id}`,
time: new Date(),
data: {
data: auditData(admin, {
name: group.name,
slug: group.slug,
affectedUsers: members.length,
fallbackGroup: "everyone",
adminEmail: admin.email,
adminName: admin.name,
},
}),
ipAddress: ipAddress ?? null,
});
await tx.delete(groups).where(eq(groups.id, group.id));
return group;
});
if (!deleted) redirect("/admin/groups?error=protected-group");
redirect("/admin/groups?saved=deleted");
}
@@ -1,84 +1,109 @@
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { asc, desc } from "drizzle-orm";
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 { createGroup, setGroupAccess } from "./actions";
import { effectiveGroupMemberCount } from "@/lib/group-management";
import { groupScheduleStatus } from "@/lib/group-schedule";
import { createGroup, setGroupAccess, setGroupAnonymizedNetworkAccess } from "./actions";
const errors: Record<string, string> = {
"invalid-group": "Enter a name and a lowercase slug containing letters, numbers, or hyphens.",
"duplicate-group": "That group slug already exists.",
"invalid-group": "Enter a group name and an optional description of no more than 500 characters.",
"duplicate-group": "A group with that name already exists.",
"create-failed": "The group could not be created.",
"unknown-group": "That group no longer exists.",
"invalid-membership": "That membership change was invalid.",
"invalid-delete": "Confirm the group deletion before continuing.",
"protected-group": "The protected default group cannot be deleted.",
};
const savedMessages: Record<string, string> = {
deleted: "Group deleted. Its former members now use the default group.",
access: "Minecraft access policy updated.",
"network-access": "VPN, proxy, and Tor policy updated.",
};
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({ id: users.id }).from(users),
db.select({ count: count() }).from(users),
db.select({ groupId: groupAccessWindows.groupId, count: count() })
.from(groupAccessWindows)
.groupBy(groupAccessWindows.groupId),
]);
const membershipCounts = new Map<string, number>();
for (const membership of memberships) {
membershipCounts.set(membership.groupId, (membershipCounts.get(membership.groupId) ?? 0) + 1);
}
const explicitlyAssignedUsers = memberships.length;
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="border-b border-line pb-8">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Admission policy</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase">Access groups</h1>
<p className="mt-5 max-w-2xl leading-7 text-muted">Each user has one effective group. Users without an explicit assignment fall back to <strong className="text-ink">everyone</strong>; Minecraft admission follows only that groups access setting.</p>
<header className="flex flex-col gap-6 border-b border-line pb-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Admission policy</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase">Access groups</h1>
<p className="mt-5 max-w-2xl leading-7 text-muted">One effective group controls Minecraft and VPN access. Every policy change asks for confirmation before it applies.</p>
</div>
<AdminModalForm
action={createGroup}
description="Create a named access group. Both policies start denied unless you explicitly enable them below."
submitLabel="Create group"
title="Add access group"
triggerClassName="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas"
triggerLabel="Add group"
>
<div className="space-y-5">
<label className="block text-sm font-bold">Name<input autoComplete="off" className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={50} name="name" required /></label>
<label className="block text-sm font-bold">Description<textarea className="mt-2 min-h-28 w-full resize-y border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={500} name="description" /></label>
<PolicyCheckbox description="Allow members to connect to Minecraft." label="Minecraft access" name="accessEnabled" />
<PolicyCheckbox description="Allow confirmed VPN, proxy, and Tor connections." label="VPN / proxy / Tor exception" name="anonymizedNetworksAllowed" />
</div>
</AdminModalForm>
</header>
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errors[query.error] ?? "The group operation failed."}</p>}
{query.saved === "deleted" && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">Group deleted. Its former members now use the default group.</p>}
{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>}
<section className="mt-10 grid gap-5 md:grid-cols-2">
{allGroups.map((group) => {
const memberCount = group.isDefault ? registeredUsers.length - explicitlyAssignedUsers : membershipCounts.get(group.id) ?? 0;
return (
<article className="border border-line bg-panel p-6 shadow-[5px_5px_0_var(--color-shadow)]" key={group.id}>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-display text-2xl font-black uppercase">{group.name}</h2>
{group.isDefault && <span className="bg-ink px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">Default</span>}
</div>
<p className="mt-1 font-mono text-[10px] text-muted">{group.slug} · {memberCount} members</p>
</div>
<div className="flex flex-col items-end gap-2"><span className={`px-3 py-2 font-mono text-[9px] font-bold uppercase ${group.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{group.accessEnabled ? "Access on" : "Access off"}</span><span className="font-mono text-[9px] font-bold uppercase text-muted">VPN {group.anonymizedNetworksAllowed ? "allowed" : "denied"}</span></div>
</div>
<p className="mt-4 min-h-12 text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
<div className="mt-5 flex items-center justify-between gap-4 border-t border-line pt-4">
<Link className="font-mono text-[10px] font-bold uppercase underline underline-offset-4" href={`/admin/groups/${group.id}`}>Manage members</Link>
<form action={setGroupAccess}>
<input name="groupId" type="hidden" value={group.id} />
<input name="accessEnabled" type="hidden" value={group.accessEnabled ? "no" : "yes"} />
<button className="font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn access {group.accessEnabled ? "off" : "on"}</button>
</form>
</div>
</article>
);
})}
</section>
<form action={createGroup} className="mt-12 border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)]">
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Create a group</p>
<div className="mt-5 grid gap-5 sm:grid-cols-2">
<label className="text-sm font-bold">Name<input className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={50} name="name" required /></label>
<label className="text-sm font-bold">Slug<input className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-mono font-normal outline-none focus:border-accent" maxLength={50} name="slug" pattern="[a-z0-9]+(?:-[a-z0-9]+)*" placeholder="ops" required /></label>
</div>
<label className="mt-5 block text-sm font-bold">Description<textarea className="mt-2 min-h-24 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={500} name="description" /></label>
<p className="mt-4 text-xs text-muted">New groups start with Minecraft access and VPN/proxy/Tor exceptions disabled.</p>
<button className="mt-6 border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Create group</button>
</form>
<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-[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">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) => {
const memberCount = effectiveGroupMemberCount(
Number(registeredUsers?.count ?? 0),
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">
<Link className="font-display text-xl font-black uppercase underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/groups/${group.id}`}>{group.name}</Link>
{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>
);
})}
</tbody>
</table>
</div>
<p className="mt-4 text-xs leading-5 text-muted">Users without an explicit assignment count toward <strong className="text-ink">everyone</strong>.</p>
</main>
);
}
function PolicyCheckbox({ description, label, name }: { description: string; label: string; name: string }) {
return (
<label className="flex cursor-pointer items-start justify-between gap-4 border border-line bg-canvas p-4">
<span><span className="block font-mono text-xs font-bold uppercase">{label}</span><span className="mt-1 block text-xs leading-5 text-muted">{description}</span></span>
<input className="mt-1 size-5 accent-[var(--color-accent)]" name={name} type="checkbox" value="yes" />
</label>
);
}
@@ -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,248 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const actionState = vi.hoisted(() => ({
authorized: 0,
selected: [] as unknown[],
transactionSelected: [] as unknown[],
updates: [] as Record<string, unknown>[],
inserts: [] as unknown[],
audits: [] as Array<{ admin: unknown; subject: string; type: string; data: Record<string, unknown>; correlationId?: string }>,
auditFailure: false,
executions: [] as Array<{ connection: Record<string, unknown>; command: string }>,
gatewayResult: { ok: true, response: "private response" } as
| { ok: true; response: string }
| { ok: false; reason: "busy" | "timeout" | "unavailable" },
}));
vi.mock("@/lib/auth/require-admin", () => ({
requireAdminSession: async () => {
actionState.authorized += 1;
return { email: "admin@example.test", name: "Admin" };
},
}));
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
vi.mock("next/navigation", () => ({
redirect: (path: string) => {
throw new Error(`REDIRECT:${path}`);
},
}));
vi.mock("@/lib/database", () => {
function selection(result: unknown[]) {
const chain = {
from: () => chain,
where: () => chain,
limit: async () => result,
};
return chain;
}
const tx = {
execute: async () => undefined,
select: () => selection(actionState.transactionSelected),
update: () => ({
set: (value: Record<string, unknown>) => ({
where: async () => { actionState.updates.push(value); },
}),
}),
insert: () => ({
values: async (value: unknown) => { actionState.inserts.push(value); },
}),
};
return {
db: {
select: () => selection(actionState.selected),
transaction: async (callback: (transaction: typeof tx) => Promise<unknown>) => callback(tx),
},
};
});
vi.mock("@/lib/rcon-validation", () => ({
validateRconCommand: (value: unknown) => typeof value === "string" && value.trim() ? value.trim() : null,
validateRconConnection: (input: { name?: string; host?: string; port?: number; password?: string }) => {
if (!input.name || !input.host || !input.port) return null;
return input;
},
}));
vi.mock("@/lib/rcon-credentials", () => ({
decryptRconPassword: () => "decrypted-password",
encryptRconPassword: vi.fn(),
rconCommandDigest: () => "hmac-sha256:v1:digest",
}));
vi.mock("@/lib/rcon-gateway", () => ({
executeRcon: async (connection: Record<string, unknown>, command: string) => {
actionState.executions.push({ connection, command });
return actionState.gatewayResult;
},
testRconConnection: vi.fn(),
}));
vi.mock("@/lib/audit", () => ({
recordAdminSubjectEvent: async (
admin: unknown,
subject: string,
type: string,
data: Record<string, unknown>,
options?: { correlationId?: string },
) => {
if (actionState.auditFailure) throw new Error("audit unavailable");
actionState.audits.push({ admin, subject, type, data, correlationId: options?.correlationId });
return "22222222-2222-4222-8222-222222222222";
},
}));
import {
createRconServer,
deleteRconServer,
executeRconCommand,
setRconServerEnabled,
testSavedRconServer,
updateRconServer,
} from "./actions";
const serverId = "11111111-1111-4111-8111-111111111111";
const savedServer = {
id: serverId,
name: "Season 4",
host: "season4.somc.svc.cluster.local",
port: 25575,
encryptedPassword: "ciphertext",
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
describe("RCON server actions", () => {
beforeEach(() => {
actionState.authorized = 0;
actionState.selected = [];
actionState.transactionSelected = [];
actionState.updates = [];
actionState.inserts = [];
actionState.audits = [];
actionState.auditFailure = false;
actionState.executions = [];
actionState.gatewayResult = { ok: true, response: "private response" };
});
it("independently authorizes every exported operation before accepting input", async () => {
await expect(createRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
await expect(updateRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
await expect(setRconServerEnabled(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=unknown-connection");
await expect(deleteRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=confirmation-required");
await expect(testSavedRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=connection-unavailable");
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, new FormData())).resolves.toEqual({
status: "error",
message: "Enter one command of at most 1,024 bytes without control characters.",
serverId: "",
});
expect(actionState.authorized).toBe(6);
});
it("preserves the encrypted password on an unrelated connection update", async () => {
actionState.transactionSelected = [savedServer];
const formData = new FormData();
formData.set("serverId", serverId);
formData.set("name", "Renamed server");
formData.set("host", "season4.somc.svc.cluster.local");
formData.set("port", "25575");
formData.set("password", "");
formData.set("enabled", "yes");
await expect(updateRconServer(formData)).rejects.toThrow("REDIRECT:/admin/rcon?saved=updated");
expect(actionState.updates).toEqual([
expect.objectContaining({ encryptedPassword: "ciphertext", enabled: true }),
]);
expect(JSON.stringify(actionState.inserts)).not.toContain("ciphertext");
expect(JSON.stringify(actionState.inserts)).not.toContain("decrypted-password");
});
it("rechecks enabled saved state and records complete command lifecycle audits", async () => {
actionState.selected = [savedServer];
const formData = new FormData();
formData.set("serverId", serverId);
formData.set("command", "say private value");
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
status: "success",
message: "private response",
serverId,
});
expect(actionState.authorized).toBe(1);
expect(actionState.executions).toEqual([{
connection: expect.objectContaining({ id: serverId, enabled: true, password: "decrypted-password" }),
command: "say private value",
}]);
expect(actionState.audits).toEqual([
expect.objectContaining({
admin: { email: "admin@example.test", name: "Admin" },
subject: `rcon-server/${serverId}`,
type: "games.minecraft.account-manager.rcon.command.requested",
data: expect.objectContaining({ command: "say private value", verb: "say", commandDigest: "hmac-sha256:v1:digest" }),
correlationId: expect.stringMatching(/^[0-9a-f-]{36}$/),
}),
expect.objectContaining({
subject: `rcon-server/${serverId}`,
type: "games.minecraft.account-manager.rcon.command.completed",
data: expect.objectContaining({ success: true, durationMs: expect.any(Number) }),
correlationId: expect.stringMatching(/^[0-9a-f-]{36}$/),
}),
]);
const serializedAudits = JSON.stringify(actionState.audits);
expect(actionState.audits[0]?.correlationId).toBe(actionState.audits[1]?.correlationId);
expect(serializedAudits).toContain("private value");
expect(serializedAudits).not.toContain("private response");
expect(serializedAudits).not.toContain("decrypted-password");
});
it.each([
["busy", "Another command is already running for this server."],
["timeout", "The RCON request timed out."],
["unavailable", "The RCON server was unavailable or rejected authentication."],
] as const)("returns a safe %s failure without exposing transport details", async (reason, message) => {
actionState.selected = [savedServer];
actionState.gatewayResult = { ok: false, reason };
const formData = new FormData();
formData.set("serverId", serverId);
formData.set("command", "list");
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
status: "error",
message,
serverId,
});
expect(JSON.stringify(actionState.audits)).not.toContain("decrypted-password");
});
it("does not send a command when its requested audit cannot be recorded", async () => {
actionState.selected = [savedServer];
actionState.auditFailure = true;
const formData = new FormData();
formData.set("serverId", serverId);
formData.set("command", "list");
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
status: "error",
message: "Command not sent because its audit record could not be created.",
serverId,
});
expect(actionState.executions).toEqual([]);
});
it("does not execute or audit when the enabled connection is unavailable", async () => {
const formData = new FormData();
formData.set("serverId", serverId);
formData.set("command", "list");
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
status: "error",
message: "That RCON connection is disabled or unavailable.",
serverId,
});
expect(actionState.executions).toEqual([]);
expect(actionState.audits).toEqual([]);
});
});
@@ -0,0 +1,262 @@
"use server";
import { randomUUID } from "node:crypto";
import { events, rconServers } from "@minecraft-account-manager/database";
import { getClientIp } from "@minecraft-account-manager/network";
import { and, eq, sql } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { requireAdminSession } from "@/lib/auth/require-admin";
import { db } from "@/lib/database";
import { isUniqueConstraintViolation } from "@/lib/database-errors";
import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "@/lib/rcon-credentials";
import { executeRcon, testRconConnection } from "@/lib/rcon-gateway";
import { recordAdminSubjectEvent } from "@/lib/audit";
import { validateRconCommand, validateRconConnection } from "@/lib/rcon-validation";
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
type Admin = Awaited<ReturnType<typeof requireAdminSession>>;
export type RconCommandState = {
status: "idle" | "success" | "error";
message: string;
serverId: string;
};
function formConnection(formData: FormData, passwordRequired: boolean) {
return validateRconConnection({
name: formData.get("name"),
host: formData.get("host"),
port: formData.get("port"),
password: formData.get("password"),
}, { passwordRequired });
}
async function auditContext() {
const requestHeaders = await headers();
return getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
}
function auditData(admin: Admin, data: Record<string, unknown>) {
return { ...data, adminEmail: admin.email, adminName: admin.name };
}
function rconPath(query: string) {
return `/admin/rcon?${query}`;
}
export async function createRconServer(formData: FormData) {
const admin = await requireAdminSession();
const details = formConnection(formData, true);
if (!details?.password) redirect(rconPath("error=invalid-connection"));
const id = randomUUID();
let encryptedPassword: string;
try {
encryptedPassword = encryptRconPassword(details.password, id);
} catch {
redirect(rconPath("error=configuration"));
}
const ipAddress = await auditContext();
try {
await db.transaction(async (tx) => {
await tx.insert(rconServers).values({
id,
name: details.name,
host: details.host,
port: details.port,
encryptedPassword,
enabled: formData.get("enabled") === "yes",
});
await tx.insert(events).values({
id: randomUUID(),
source: "/web/admin",
type: "games.minecraft.account-manager.rcon.connection.created",
subject: `rcon-server/${id}`,
time: new Date(),
data: auditData(admin, { name: details.name, host: details.host, port: details.port }),
ipAddress: ipAddress ?? null,
});
});
} catch (error) {
if (isUniqueConstraintViolation(error, "rcon_servers_name_uidx")) redirect(rconPath("error=duplicate-name"));
redirect(rconPath("error=save-failed"));
}
redirect(rconPath("saved=created"));
}
export async function updateRconServer(formData: FormData) {
const admin = await requireAdminSession();
const serverId = String(formData.get("serverId") ?? "");
const details = formConnection(formData, false);
if (!UUID_PATTERN.test(serverId) || !details) redirect(rconPath("error=invalid-connection"));
const ipAddress = await auditContext();
let result: string | null;
try {
result = await db.transaction(async (tx) => {
await tx.execute(sql`select ${rconServers.id} from ${rconServers} where ${rconServers.id} = ${serverId} for update`);
const [current] = await tx.select().from(rconServers).where(eq(rconServers.id, serverId)).limit(1);
if (!current) return null;
let encryptedPassword = current.encryptedPassword;
if (details.password) encryptedPassword = encryptRconPassword(details.password, current.id);
const enabled = formData.get("enabled") === "yes";
await tx.update(rconServers).set({
name: details.name,
host: details.host,
port: details.port,
encryptedPassword,
enabled,
updatedAt: new Date(),
}).where(eq(rconServers.id, current.id));
await tx.insert(events).values({
id: randomUUID(),
source: "/web/admin",
type: "games.minecraft.account-manager.rcon.connection.updated",
subject: `rcon-server/${current.id}`,
time: new Date(),
data: auditData(admin, {
name: details.name,
host: details.host,
port: details.port,
enabled,
passwordReplaced: Boolean(details.password),
}),
ipAddress: ipAddress ?? null,
});
return current.id;
});
} catch (error) {
if (isUniqueConstraintViolation(error, "rcon_servers_name_uidx")) redirect(rconPath("error=duplicate-name"));
redirect(rconPath("error=save-failed"));
}
if (!result) redirect(rconPath("error=unknown-connection"));
redirect(rconPath("saved=updated"));
}
export async function setRconServerEnabled(formData: FormData) {
const admin = await requireAdminSession();
const serverId = String(formData.get("serverId") ?? "");
if (!UUID_PATTERN.test(serverId)) redirect(rconPath("error=unknown-connection"));
const enabled = formData.get("enabled") === "yes";
const ipAddress = await auditContext();
const result = await db.transaction(async (tx) => {
await tx.execute(sql`select ${rconServers.id} from ${rconServers} where ${rconServers.id} = ${serverId} for update`);
const [current] = await tx.select().from(rconServers).where(eq(rconServers.id, serverId)).limit(1);
if (!current) return "missing" as const;
if (enabled && !validateRconConnection({ ...current, password: "placeholder" }, { passwordRequired: true })) return "invalid" as const;
await tx.update(rconServers).set({ enabled, updatedAt: new Date() }).where(eq(rconServers.id, current.id));
await tx.insert(events).values({
id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.enabled-updated",
subject: `rcon-server/${current.id}`, time: new Date(), data: auditData(admin, { name: current.name, enabled }), ipAddress: ipAddress ?? null,
});
return "updated" as const;
});
if (result === "missing") redirect(rconPath("error=unknown-connection"));
if (result === "invalid") redirect(rconPath("error=invalid-connection"));
redirect(rconPath(`saved=${enabled ? "enabled" : "disabled"}`));
}
export async function deleteRconServer(formData: FormData) {
const admin = await requireAdminSession();
const serverId = String(formData.get("serverId") ?? "");
if (!UUID_PATTERN.test(serverId) || formData.get("confirmation") !== serverId) redirect(rconPath("error=confirmation-required"));
const ipAddress = await auditContext();
const deleted = await db.transaction(async (tx) => {
const [server] = await tx.delete(rconServers).where(eq(rconServers.id, serverId)).returning({ id: rconServers.id, name: rconServers.name });
if (!server) return null;
await tx.insert(events).values({
id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.deleted",
subject: `rcon-server/${server.id}`, time: new Date(), data: auditData(admin, { name: server.name }), ipAddress: ipAddress ?? null,
});
return server;
});
if (!deleted) redirect(rconPath("error=unknown-connection"));
redirect(rconPath("saved=deleted"));
}
async function savedConnection(serverId: string, requireEnabled: boolean) {
if (!UUID_PATTERN.test(serverId)) return null;
const [server] = await db.select().from(rconServers).where(requireEnabled
? and(eq(rconServers.id, serverId), eq(rconServers.enabled, true))
: eq(rconServers.id, serverId)).limit(1);
if (!server) return null;
const validated = validateRconConnection({ ...server, password: "placeholder" }, { passwordRequired: true });
if (!validated) return null;
try {
return { ...server, password: decryptRconPassword(server.encryptedPassword, server.id) };
} catch {
return null;
}
}
export async function testSavedRconServer(formData: FormData) {
const admin = await requireAdminSession();
const serverId = String(formData.get("serverId") ?? "");
const server = await savedConnection(serverId, false);
if (!server) redirect(rconPath("error=connection-unavailable"));
const started = Date.now();
const result = await testRconConnection(server);
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.connection.tested", {
serverId: server.id,
name: server.name,
success: result.ok,
reason: result.ok ? null : result.reason,
durationMs: Date.now() - started,
});
redirect(rconPath(result.ok ? "saved=tested" : `error=test-${result.reason}`));
}
export async function executeRconCommand(
_previous: RconCommandState,
formData: FormData,
): Promise<RconCommandState> {
const admin = await requireAdminSession();
const serverId = String(formData.get("serverId") ?? "");
const command = validateRconCommand(formData.get("command"));
if (!command) return { status: "error", message: "Enter one command of at most 1,024 bytes without control characters.", serverId };
const server = await savedConnection(serverId, true);
if (!server) return { status: "error", message: "That RCON connection is disabled or unavailable.", serverId };
const verb = command.split(/\s+/u, 1)[0]!.toLowerCase().slice(0, 64);
let commandDigest: string;
try {
commandDigest = rconCommandDigest(command);
} catch {
return { status: "error", message: "RCON command auditing is not configured.", serverId };
}
const started = Date.now();
const correlationId = randomUUID();
try {
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.requested", {
serverId: server.id,
name: server.name,
command,
verb,
commandDigest,
}, { correlationId });
} catch {
return { status: "error", message: "Command not sent because its audit record could not be created.", serverId };
}
const result = await executeRcon(server, command);
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.completed", {
serverId: server.id,
name: server.name,
verb,
commandDigest,
success: result.ok,
reason: result.ok ? null : result.reason,
durationMs: Date.now() - started,
}, { correlationId });
if (!result.ok) {
const message = result.reason === "busy"
? "Another command is already running for this server."
: result.reason === "timeout"
? "The RCON request timed out."
: "The RCON server was unavailable or rejected authentication.";
return { status: "error", message, serverId };
}
return { status: "success", message: result.response || "Command completed with no response.", serverId };
}
@@ -0,0 +1,118 @@
import { events, rconServers } from "@minecraft-account-manager/database";
import { and, desc, eq, ilike, inArray, or, sql, type SQL } from "drizzle-orm";
import Link from "next/link";
import { db } from "@/lib/database";
import {
buildRconCommandHistory,
normalizeRconHistoryFilters,
RCON_COMMAND_COMPLETED,
RCON_COMMAND_REQUESTED,
} from "@/lib/rcon-command-history";
export const dynamic = "force-dynamic";
const requestedFields = {
id: events.id,
time: events.time,
correlationId: events.correlationId,
data: events.data,
};
export default async function RconHistoryPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const [query, servers] = await Promise.all([
searchParams,
db.select({ id: rconServers.id, name: rconServers.name }).from(rconServers).orderBy(rconServers.name),
]);
const filters = normalizeRconHistoryFilters(query, servers.map((server) => server.id));
const conditions: SQL[] = [eq(events.type, RCON_COMMAND_REQUESTED)];
if (filters.serverId) conditions.push(eq(events.subject, `rcon-server/${filters.serverId}`));
if (filters.command) conditions.push(ilike(sql<string>`${events.data} ->> 'command'`, `%${filters.command}%`));
if (filters.admin) {
conditions.push(or(
ilike(sql<string>`${events.data} ->> 'adminEmail'`, `%${filters.admin}%`),
ilike(sql<string>`${events.data} ->> 'adminName'`, `%${filters.admin}%`),
)!);
}
const requested = await db.select(requestedFields)
.from(events)
.where(and(...conditions))
.orderBy(desc(events.time))
.limit(100);
const correlationIds = requested.flatMap((event) => event.correlationId ? [event.correlationId] : []);
const completed = correlationIds.length
? await db.select(requestedFields).from(events).where(and(
eq(events.type, RCON_COMMAND_COMPLETED),
inArray(events.correlationId, correlationIds),
))
: [];
const history = buildRconCommandHistory(requested, completed);
return (
<main className="mx-auto max-w-7xl px-6 py-14">
<Link className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline underline-offset-4" href="/admin/rcon"> RCON console</Link>
<header className="mt-7 grid gap-5 border-b-2 border-ink pb-8 lg:grid-cols-[1fr_auto] lg:items-end">
<div>
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Persistent audit ledger</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">Command history</h1>
<p className="mt-4 max-w-2xl text-sm leading-6 text-muted">Search commands sent through the portal. Responses and RCON credentials are never retained here.</p>
</div>
<div className="border border-line bg-panel px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-widest">
<span className="text-accent">{history.length}</span> matching records
</div>
</header>
<form className="mt-8 border border-line bg-panel p-5 shadow-[6px_6px_0_var(--color-shadow)]" method="get">
<div className="grid gap-5 md:grid-cols-3">
<Filter label="Command text" name="command" placeholder="say, whitelist add…" value={filters.command} />
<Filter label="Administrator" name="admin" placeholder="name or email" value={filters.admin} />
<label className="font-mono text-[10px] font-bold uppercase tracking-wider" htmlFor="history-server">
Server
<select className="mt-2 block w-full border border-line bg-canvas px-3 py-3 font-sans text-sm font-normal normal-case outline-none focus:border-accent" defaultValue={filters.serverId} id="history-server" name="server">
<option value="">All servers</option>
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}</option>)}
</select>
</label>
</div>
<div className="mt-5 flex flex-wrap gap-4">
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Search history</button>
<Link className="self-center font-mono text-[10px] font-bold uppercase underline underline-offset-4" href="/admin/rcon/history">Clear filters</Link>
</div>
</form>
<div className="mt-8 overflow-x-auto border-2 border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
<table className="w-full min-w-[980px] border-collapse text-left">
<caption className="sr-only">RCON command audit history</caption>
<thead className="border-b-2 border-ink bg-canvas font-mono text-[10px] uppercase tracking-widest text-muted">
<tr><th className="p-4" scope="col">Time</th><th className="p-4" scope="col">Server</th><th className="p-4" scope="col">Administrator</th><th className="p-4" scope="col">Command</th><th className="p-4" scope="col">Outcome</th></tr>
</thead>
<tbody className="divide-y divide-line text-xs">
{history.map((entry) => (
<tr className="align-top hover:bg-canvas/60" key={entry.eventId}>
<td className="whitespace-nowrap p-4 font-mono text-muted"><Link className="underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/events/${entry.eventId}`}><time dateTime={entry.time.toISOString()}>{entry.time.toISOString()}</time></Link></td>
<td className="p-4"><span className="font-mono font-bold">{entry.serverName}</span><span className="mt-1 block font-mono text-[9px] text-muted">{entry.serverId}</span></td>
<td className="p-4"><span className="font-bold">{entry.adminName ?? "Unknown administrator"}</span><span className="mt-1 block font-mono text-[10px] text-muted">{entry.adminEmail ?? "Email unavailable"}</span></td>
<td className="max-w-xl p-4"><code className="whitespace-pre-wrap break-words font-mono text-xs"><span className="mr-2 text-accent">$</span>{entry.command}</code></td>
<td className="p-4"><Outcome status={entry.status} />{entry.reason && <span className="mt-2 block font-mono text-[9px] text-muted">{entry.reason}</span>}{entry.durationMs !== null && <span className="mt-1 block font-mono text-[9px] text-muted">{entry.durationMs} ms</span>}</td>
</tr>
))}
{!history.length && <tr><td className="p-10 text-center text-muted" colSpan={5}>No RCON commands match these filters.</td></tr>}
</tbody>
</table>
</div>
</main>
);
}
function Filter({ label, name, placeholder, value }: { label: string; name: string; placeholder: string; value: string }) {
return <label className="font-mono text-[10px] font-bold uppercase tracking-wider" htmlFor={`history-${name}`}>{label}<input className="mt-2 block w-full border border-line bg-canvas px-3 py-3 font-sans text-sm font-normal normal-case outline-none placeholder:text-muted focus:border-accent" defaultValue={value} id={`history-${name}`} maxLength={name === "command" ? 1024 : 320} name={name} placeholder={placeholder} /></label>;
}
function Outcome({ status }: { status: "pending" | "succeeded" | "failed" }) {
const className = status === "succeeded" ? "border-signal text-ink" : status === "failed" ? "border-accent text-accent" : "border-line text-muted";
return <span className={`inline-block border-l-2 pl-2 font-mono text-[9px] font-bold uppercase tracking-wider ${className}`}>{status}</span>;
}
@@ -0,0 +1,75 @@
import { rconServers } from "@minecraft-account-manager/database";
import { asc } from "drizzle-orm";
import { RconConsole, type RconTerminalNotice } from "@/components/rcon-console";
import { db } from "@/lib/database";
export const dynamic = "force-dynamic";
const savedMessages: Record<string, string> = {
created: "RCON connection created.",
updated: "RCON connection updated.",
enabled: "RCON connection enabled.",
disabled: "RCON connection disabled.",
deleted: "RCON connection deleted.",
tested: "RCON authentication succeeded.",
};
const errorMessages: Record<string, string> = {
"invalid-connection": "Enter a valid DNS hostname, port, name, and password.",
"duplicate-name": "Connection names must be unique.",
configuration: "RCON credential encryption is not configured.",
"save-failed": "The RCON connection could not be saved.",
"unknown-connection": "That RCON connection no longer exists.",
"confirmation-required": "Confirm the connection before deleting it.",
"connection-unavailable": "The connection is invalid or its credential is unavailable.",
"test-busy": "Another RCON operation is already using that server.",
"test-timeout": "RCON authentication timed out.",
"test-unavailable": "The RCON server was unavailable or rejected authentication.",
};
function queryValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
export default async function RconPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const query = await searchParams;
const saved = queryValue(query.saved);
const error = queryValue(query.error);
const notice: RconTerminalNotice | undefined = error
? { status: "error", message: errorMessages[error] ?? "The RCON operation failed." }
: saved
? { status: "success", message: savedMessages[saved] ?? "RCON settings saved." }
: undefined;
const servers = await db.select({
id: rconServers.id,
name: rconServers.name,
host: rconServers.host,
port: rconServers.port,
enabled: rconServers.enabled,
}).from(rconServers).orderBy(asc(rconServers.name));
return (
<main className="mx-auto max-w-6xl px-6 py-14">
<header className="border-b border-line pb-8">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Server operations</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">RCON</h1>
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Select and manage a connection, then run commands through the portal backend. Credentials are never sent to the browser.</p>
</header>
<section className="mt-10">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Command proxy</p>
<h2 className="mt-2 font-display text-3xl font-black uppercase">Terminal</h2>
</div>
<p className="max-w-xl text-xs leading-5 text-muted">Only the latest bounded response is shown. Commands and responses are not saved as console history.</p>
</div>
<RconConsole notice={notice} servers={servers} />
</section>
</main>
);
}
@@ -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>
@@ -14,6 +14,7 @@ import { redirect } from "next/navigation";
import { recordAdminEvent } from "@/lib/audit";
import { requireAdminSession } from "@/lib/auth/require-admin";
import { db } from "@/lib/database";
import { adminGroupReturnPath } from "@/lib/group-management";
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
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;
@@ -22,12 +23,6 @@ function userPath(userId: string, query?: string) {
return `/admin/users/${encodeURIComponent(userId)}${query ? `?${query}` : ""}`;
}
function userRegistryPath(search: string, result: "saved=group" | "error=invalid-group-assignment") {
const query = new URLSearchParams(result);
if (search) query.set("q", search);
return `/admin/users?${query.toString()}`;
}
async function targetUser(userId: string) {
if (!UUID_PATTERN.test(userId)) return null;
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
@@ -85,20 +80,26 @@ export async function assignUserGroupFromRegistry(formData: FormData) {
const admin = await requireAdminSession();
const userId = String(formData.get("userId") ?? "");
const groupId = String(formData.get("groupId") ?? "");
const search = String(formData.get("search") ?? "").trim().slice(0, 100);
if (!UUID_PATTERN.test(userId) || !UUID_PATTERN.test(groupId)) redirect(userRegistryPath(search, "error=invalid-group-assignment"));
const [[user], [targetGroup]] = await Promise.all([
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
]);
if (!user || !targetGroup) redirect(userRegistryPath(search, "error=invalid-group-assignment"));
const returnTo = formData.get("returnTo");
if (!UUID_PATTERN.test(userId) || !UUID_PATTERN.test(groupId)) redirect(adminGroupReturnPath(returnTo, "error=invalid-group-assignment"));
const requestHeaders = await headers();
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
try {
await db.transaction(async (tx) => {
await tx.execute(sql`select ${users.id} from ${users} where ${users.id} = ${user.id} for update`);
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-membership'))`);
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
await tx.execute(sql`select ${users.id} from ${users} where ${users.id} = ${userId} for update`);
const [[user], [targetGroup]] = await Promise.all([
tx.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
tx.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
]);
if (!user || !targetGroup) throw new Error("User or destination group no longer exists");
const [membership] = await tx.select({ groupId: userGroupMemberships.groupId })
.from(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id)).limit(1);
if (membership && membership.groupId !== targetGroup.id) {
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${membership.groupId} for update`);
}
const [previous] = await tx.select({ id: groups.id, name: groups.name })
.from(userGroupMemberships)
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
@@ -131,9 +132,9 @@ export async function assignUserGroupFromRegistry(formData: FormData) {
});
});
} catch {
redirect(userRegistryPath(search, "error=invalid-group-assignment"));
redirect(adminGroupReturnPath(returnTo, "error=invalid-group-assignment"));
}
redirect(userRegistryPath(search, "saved=group"));
redirect(adminGroupReturnPath(returnTo, "saved=group"));
}
export async function updateUserName(formData: FormData) {
@@ -1,7 +1,6 @@
import { groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { and, asc, desc, eq, ilike, isNull, or, sql } from "drizzle-orm";
import Link from "next/link";
import { UserGroupSelect } from "@/components/user-group-select";
import { AdminUserTable } from "@/components/admin-user-table";
import { db } from "@/lib/database";
import { assignUserGroupFromRegistry } from "./actions";
@@ -66,8 +65,8 @@ export default async function AdminUsersPage({
db.select({ userId: userGroupMemberships.userId, groupId: userGroupMemberships.groupId })
.from(userGroupMemberships),
]);
const defaultGroup = allGroups.find((group) => group.isDefault);
const groupByUser = new Map(memberships.map((membership) => [membership.userId, membership.groupId]));
const assignmentByUser = Object.fromEntries(memberships.map((membership) => [membership.userId, membership.groupId]));
const returnTo = `/admin/users${search ? `?${new URLSearchParams({ q: search }).toString()}` : ""}`;
return (
<main className="mx-auto max-w-6xl px-6 py-14">
@@ -92,26 +91,8 @@ export default async function AdminUsersPage({
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{query.error === "invalid-group-assignment" ? "The user or group no longer exists. No group change was applied." : "The requested user could not be found."}</p>}
{query.saved === "group" && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">User group updated.</p>}
<div className="mt-8 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
<table className="w-full min-w-[900px] border-collapse text-left">
<caption className="sr-only">Registered portal users</caption>
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
<tr><th className="p-4" scope="col">User</th><th className="p-4" scope="col">Discord</th><th className="p-4" scope="col">Primary</th><th className="p-4" scope="col">Accounts</th><th className="p-4" scope="col">Group</th><th className="p-4" scope="col">Status</th></tr>
</thead>
<tbody className="divide-y divide-line">
{results.map((user) => (
<tr className="transition-colors hover:bg-canvas/60" key={user.id}>
<th className="p-4 text-left" scope="row"><Link className="font-display text-lg font-black underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? "Name needed"}</Link></th>
<td className="p-4"><div className="font-mono text-xs font-bold">{user.discordGlobalName ?? user.discordUsername}</div><div className="mt-1 font-mono text-[10px] text-muted">@{user.discordUsername}</div><div className="mt-1 font-mono text-[9px] text-muted">{user.discordUserId}</div></td>
<td className="p-4 font-mono text-xs">{user.primaryUsername ?? "—"}</td>
<td className="p-4 font-mono text-xs">{user.accountCount}</td>
<td className="p-4">{defaultGroup ? <UserGroupSelect action={assignUserGroupFromRegistry} effectiveGroupId={groupByUser.get(user.id) ?? defaultGroup.id} groups={allGroups} search={search} userId={user.id} userLabel={user.firstName ?? user.discordUsername} /> : <span className="text-xs text-accent">Default group missing</span>}</td>
<td className="p-4"><span className={`border px-2 py-1 font-mono text-[9px] uppercase tracking-wider ${user.onboardingCompletedAt ? "border-line text-muted" : "border-accent text-accent"}`}>{user.onboardingCompletedAt ? "Ready" : "Onboarding"}</span></td>
</tr>
))}
{!results.length && <tr><td className="p-8 text-muted" colSpan={6}>No users match that search.</td></tr>}
</tbody>
</table>
<div className="mt-8">
<AdminUserTable action={assignUserGroupFromRegistry} assignmentByUser={assignmentByUser} emptyMessage="No users match that search." groups={allGroups} returnTo={returnTo} users={results} />
</div>
<p className="mt-4 font-mono text-[9px] uppercase tracking-widest text-muted">Showing up to 100 users</p>
</main>
@@ -0,0 +1,14 @@
import { suggestionQuery, suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export function GET(request: Request, context: { params: Promise<{ id: string }> }) {
return suggestionsApi(request, async (client) => client.messages((await context.params).id, suggestionQuery(request)));
}
export const POST = suggestionsReadOnly;
export const PUT = suggestionsReadOnly;
export const PATCH = suggestionsReadOnly;
export const DELETE = suggestionsReadOnly;
export const OPTIONS = suggestionsReadOnly;
@@ -0,0 +1,14 @@
import { suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export function GET(request: Request, context: { params: Promise<{ id: string }> }) {
return suggestionsApi(request, async (client) => client.detail((await context.params).id));
}
export const POST = suggestionsReadOnly;
export const PUT = suggestionsReadOnly;
export const PATCH = suggestionsReadOnly;
export const DELETE = suggestionsReadOnly;
export const OPTIONS = suggestionsReadOnly;
@@ -0,0 +1,71 @@
import { afterEach, expect, it, vi } from "vitest";
const auth = vi.hoisted(() => ({ session: vi.fn() }));
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
import { GET, POST } from "./route";
import { GET as detail } from "./[id]/route";
import { GET as messages } from "./[id]/messages/route";
const context = { params: Promise.resolve({ id: "100000000000000009" }) };
const request = () => new Request("https://portal.example/api/suggestions");
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
it("serves suggestions to the existing admin session using runtime env configuration", async () => {
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
vi.stubEnv("DISCORD_BOT_TOKEN", "test-token");
vi.stubEnv("DISCORD_GUILD_ID", "100000000000000001");
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", "100000000000000002");
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ id: "100000000000000002", guild_id: "100000000000000001", type: 15, available_tags: [] })).mockResolvedValueOnce(Response.json({ threads: [] }));
vi.stubGlobal("fetch", fetcher);
const response = await GET(request());
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ items: [], nextCursor: null });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(fetcher).toHaveBeenCalledTimes(2);
});
it.each([detail, messages])("independently protects detail and message routes", async (handler) => {
const fetcher = vi.fn();
vi.stubGlobal("fetch", fetcher);
auth.session.mockResolvedValue(null);
expect((await handler(request(), context)).status).toBe(401);
auth.session.mockResolvedValue({ user: { roles: ["player"] } });
expect((await handler(request(), context)).status).toBe(403);
expect(fetcher).not.toHaveBeenCalled();
});
it.each(["?limit=0", "?limit=101", "?limit=1.2", "?limit=", "?limit=1&limit=2", "?channel=100000000000000099", "?status=all", "?cursor=../secret"])('rejects invalid query %s without contacting Discord', async (query) => {
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
const fetcher = vi.fn();
vi.stubGlobal("fetch", fetcher);
const response = await GET(new Request(`https://portal.example/api/suggestions${query}`));
expect(response.status).toBe(400);
expect(fetcher).not.toHaveBeenCalled();
});
it("returns a read-only problem for writes", async () => {
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
const response = await POST(request());
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("GET, HEAD");
});
it("reports missing configuration without exposing environment values", async () => {
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", "");
const response = await GET(request());
expect(response.status).toBe(503);
expect(await response.json()).toMatchObject({ type: "urn:error:suggestions-not-configured", status: 503, instance: "/api/suggestions" });
});
it("returns sanitized problems for unexpected failures", async () => {
auth.session.mockRejectedValue(new Error("private session details"));
const response = await GET(request());
expect(response.status).toBe(503);
expect(await response.text()).not.toContain("private session details");
});
it("rejects a signed-in user without the required admin role", async () => {
auth.session.mockResolvedValue({ user: { roles: ["player"] } });
expect((await GET(request())).status).toBe(403);
});
it("rejects unauthenticated readers with a JSON problem instead of a redirect", async () => {
auth.session.mockResolvedValue(null);
const response = await GET(request());
expect(response.status).toBe(401);
expect(response.headers.get("content-type")).toBe("application/problem+json");
expect(response.headers.get("location")).toBeNull();
});
+14
View File
@@ -0,0 +1,14 @@
import { suggestionQuery, suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export function GET(request: Request) {
return suggestionsApi(request, (client) => client.list(suggestionQuery(request, true)));
}
export const POST = suggestionsReadOnly;
export const PUT = suggestionsReadOnly;
export const PATCH = suggestionsReadOnly;
export const DELETE = suggestionsReadOnly;
export const OPTIONS = suggestionsReadOnly;
@@ -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);
}
+13 -3
View File
@@ -1,12 +1,22 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { GET } from "./route";
describe("health endpoint", () => {
it("reports process readiness without requiring external services", async () => {
afterEach(() => vi.unstubAllEnvs());
it("reports process readiness and the immutable build version without requiring external services", async () => {
vi.stubEnv("APP_VERSION", "1.19.0");
const response = GET();
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ status: "ok" });
expect(await response.json()).toEqual({ status: "ok", version: "1.19.0" });
});
it("reports a development version when no build version is supplied", async () => {
vi.stubEnv("APP_VERSION", "");
expect(await GET().json()).toEqual({ status: "ok", version: "development" });
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
export function GET(): Response {
return Response.json(
{ status: "ok" },
{ status: "ok", version: process.env.APP_VERSION?.trim() || "development" },
{
headers: {
"Cache-Control": "no-store",
@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AdminModalForm } from "./admin-modal-form";
beforeEach(() => {
HTMLDialogElement.prototype.showModal = function showModal() { this.open = true; };
HTMLDialogElement.prototype.close = function close() {
this.open = false;
this.dispatchEvent(new Event("close"));
};
});
describe("AdminModalForm", () => {
it("renders an accessible trigger, labelled dialog, cancellation, and pending-capable submit control", () => {
const markup = renderToStaticMarkup(
<AdminModalForm
action={async () => undefined}
description="Review this policy change before applying it."
submitLabel="Apply policy"
title="Change access policy"
triggerLabel="Change"
>
<input name="groupId" type="hidden" value="group-one" />
</AdminModalForm>,
);
expect(markup).toContain("Change access policy");
expect(markup).toContain("Review this policy change before applying it.");
expect(markup).toContain("<dialog");
expect(markup).toContain("aria-haspopup=\"dialog\"");
expect(markup).toContain("Cancel");
expect(markup).toContain("Apply policy");
});
it("opens, cancels, and prevents dismissal while the action is pending", async () => {
let finishAction!: () => void;
const action = vi.fn(() => new Promise<void>((resolve) => { finishAction = resolve; }));
render(<AdminModalForm action={action} description="Confirm it." submitLabel="Apply policy" title="Change access policy" triggerLabel="Change" />);
fireEvent.click(screen.getByRole("button", { name: "Change" }));
const dialog = screen.getByRole("dialog") as HTMLDialogElement;
expect(dialog.open).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Apply policy" }));
await waitFor(() => expect(action).toHaveBeenCalledOnce());
expect((screen.getByRole("button", { name: "Change" }) as HTMLButtonElement).disabled).toBe(true);
const cancelEvent = new Event("cancel", { bubbles: false, cancelable: true });
dialog.dispatchEvent(cancelEvent);
expect(cancelEvent.defaultPrevented).toBe(true);
expect(dialog.open).toBe(true);
finishAction();
await waitFor(() => expect((screen.getByRole("button", { name: "Change" }) as HTMLButtonElement).disabled).toBe(false));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(dialog.open).toBe(false);
});
});
@@ -0,0 +1,109 @@
"use client";
import type { ReactNode, RefObject } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { useFormStatus } from "react-dom";
export function AdminModalForm({
action,
children,
description,
intent = "default",
submitLabel,
title,
triggerClassName,
triggerLabel,
triggerPressed,
}: {
action: (formData: FormData) => Promise<void>;
children?: ReactNode;
description: string;
intent?: "default" | "danger";
submitLabel: string;
title: string;
triggerClassName?: string;
triggerLabel: string;
triggerPressed?: boolean;
}) {
const dialogRef = useRef<HTMLDialogElement>(null);
const titleId = useId();
const descriptionId = useId();
const [submitting, setSubmitting] = useState(false);
const [dialogGeneration, setDialogGeneration] = useState(0);
return (
<>
<button
aria-haspopup="dialog"
aria-pressed={triggerPressed}
className={triggerClassName ?? "font-mono text-[10px] font-bold uppercase underline underline-offset-4"}
disabled={submitting}
onClick={() => {
setDialogGeneration((generation) => generation + 1);
dialogRef.current?.showModal();
}}
type="button"
>
{triggerLabel}
</button>
<dialog
aria-describedby={descriptionId}
aria-labelledby={titleId}
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}
>
<form action={action} className="p-6 sm:p-8" onSubmit={() => setSubmitting(true)}>
<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" key={dialogGeneration}>{children}</div>}
<ModalActions dialogRef={dialogRef} intent={intent} onPendingChange={setSubmitting} submitLabel={submitLabel} />
</form>
</dialog>
</>
);
}
function ModalActions({
dialogRef,
intent,
onPendingChange,
submitLabel,
}: {
dialogRef: RefObject<HTMLDialogElement | null>;
intent: "default" | "danger";
onPendingChange: (pending: boolean) => void;
submitLabel: string;
}) {
const { pending } = useFormStatus();
const observedPending = useRef(false);
useEffect(() => {
if (pending) {
observedPending.current = true;
onPendingChange(true);
} else if (observedPending.current) {
observedPending.current = false;
onPendingChange(false);
}
}, [onPendingChange, pending]);
return (
<div className="mt-8 flex flex-wrap justify-end gap-3 border-t border-line pt-5">
<button
className="border border-line px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider disabled:opacity-50"
disabled={pending}
onClick={() => dialogRef.current?.close()}
type="button"
>
Cancel
</button>
<button
className={`px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas disabled:cursor-wait disabled:opacity-60 ${intent === "danger" ? "bg-accent" : "bg-ink"}`}
disabled={pending}
type="submit"
>
{pending ? "Applying…" : submitLabel}
</button>
<span aria-live="polite" className="sr-only">{pending ? "Operation in progress." : ""}</span>
</div>
);
}
@@ -0,0 +1,30 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { AdminUserTable } from "./admin-user-table";
describe("AdminUserTable", () => {
it("renders reusable identity and confirmed group controls", () => {
const markup = renderToStaticMarkup(<AdminUserTable
action={async () => undefined}
assignmentByUser={{ user1: "ops" }}
emptyMessage="No members."
groups={[{ id: "everyone", name: "everyone", isDefault: true }, { id: "ops", name: "Ops", isDefault: false }]}
returnTo="/admin/groups/11111111-1111-4111-8111-111111111111"
users={[{
id: "user1",
firstName: "Alex",
discordUsername: "alex",
discordGlobalName: "Alex Global",
discordUserId: "123",
onboardingCompletedAt: new Date("2026-08-01T00:00:00Z"),
primaryUsername: "AlexMC",
accountCount: 2,
}]}
/>);
expect(markup).toContain("Alex Global");
expect(markup).toContain("AlexMC");
expect(markup).toContain("Accounts");
expect(markup).toContain("Group for Alex");
expect(markup).toContain("Confirm move");
});
});
@@ -0,0 +1,54 @@
import Link from "next/link";
import { UserGroupSelect } from "./user-group-select";
export interface AdminUserRow {
id: string;
firstName: string | null;
discordUsername: string;
discordGlobalName: string | null;
discordUserId: string;
onboardingCompletedAt: Date | null;
primaryUsername: string | null;
accountCount: number;
}
export function AdminUserTable({
action,
assignmentByUser,
emptyMessage,
groups,
returnTo,
users,
}: {
action: (formData: FormData) => Promise<void>;
assignmentByUser: Record<string, string>;
emptyMessage: string;
groups: Array<{ id: string; name: string; isDefault: boolean }>;
returnTo: string;
users: AdminUserRow[];
}) {
const defaultGroup = groups.find((group) => group.isDefault);
return (
<div className="overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
<table className="w-full min-w-[900px] border-collapse text-left">
<caption className="sr-only">Registered portal users and effective groups</caption>
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
<tr><th className="p-4" scope="col">User</th><th className="p-4" scope="col">Discord</th><th className="p-4" scope="col">Primary</th><th className="p-4" scope="col">Accounts</th><th className="p-4" scope="col">Group</th><th className="p-4" scope="col">Status</th></tr>
</thead>
<tbody className="divide-y divide-line">
{users.map((user) => (
<tr className="transition-colors hover:bg-canvas/60" key={user.id}>
<th className="p-4 text-left" scope="row"><Link className="font-display text-lg font-black underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? "Name needed"}</Link></th>
<td className="p-4"><div className="font-mono text-xs font-bold">{user.discordGlobalName ?? user.discordUsername}</div><div className="mt-1 font-mono text-[10px] text-muted">@{user.discordUsername}</div><div className="mt-1 font-mono text-[9px] text-muted">{user.discordUserId}</div></td>
<td className="p-4 font-mono text-xs">{user.primaryUsername ?? "—"}</td>
<td className="p-4 font-mono text-xs">{user.accountCount}</td>
<td className="p-4">{defaultGroup ? <UserGroupSelect action={action} effectiveGroupId={assignmentByUser[user.id] ?? defaultGroup.id} groups={groups} returnTo={returnTo} userId={user.id} userLabel={user.firstName ?? user.discordUsername} /> : <span className="text-xs text-accent">Default group missing</span>}</td>
<td className="p-4"><span className={`border px-2 py-1 font-mono text-[9px] uppercase tracking-wider ${user.onboardingCompletedAt ? "border-line text-muted" : "border-accent text-accent"}`}>{user.onboardingCompletedAt ? "Ready" : "Onboarding"}</span></td>
</tr>
))}
{!users.length && <tr><td className="p-8 text-muted" colSpan={6}>{emptyMessage}</td></tr>}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,36 @@
import { AdminModalForm } from "./admin-modal-form";
export function GroupPolicyControl({
action,
enabled,
groupId,
groupName,
memberCount,
policy,
returnLocation,
}: {
action: (formData: FormData) => Promise<void>;
enabled: boolean;
groupId: string;
groupName: string;
memberCount: number;
policy: string;
returnLocation: "list" | "detail";
}) {
const nextState = enabled ? "deny" : "allow";
return (
<AdminModalForm
action={action}
description={`${nextState === "allow" ? "Allow" : "Deny"} ${policy.toLowerCase()} for ${memberCount} effective ${memberCount === 1 ? "member" : "members"} of ${groupName}.`}
submitLabel={`${nextState === "allow" ? "Allow" : "Deny"} access`}
title={`${nextState === "allow" ? "Allow" : "Deny"} ${policy}?`}
triggerClassName={`min-w-24 border px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider ${enabled ? "border-signal bg-signal text-ink" : "border-accent bg-transparent text-accent"}`}
triggerLabel={enabled ? "Allowed" : "Denied"}
triggerPressed={enabled}
>
<input name="groupId" type="hidden" value={groupId} />
<input name="enabled" type="hidden" value={enabled ? "no" : "yes"} />
<input name="returnLocation" type="hidden" value={returnLocation} />
</AdminModalForm>
);
}
@@ -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,134 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
const actionMocks = vi.hoisted(() => ({
execute: vi.fn(async (_previous: unknown, formData: FormData) => ({
status: "success" as const,
message: `Executed ${String(formData.get("command") ?? "")}`,
serverId: String(formData.get("serverId") ?? ""),
})),
}));
vi.mock("@/app/admin/(console)/rcon/actions", () => ({
createRconServer: vi.fn(),
deleteRconServer: vi.fn(),
executeRconCommand: actionMocks.execute,
setRconServerEnabled: vi.fn(),
testSavedRconServer: vi.fn(),
updateRconServer: vi.fn(),
}));
import { RconConsole } from "./rcon-console";
afterEach(() => cleanup());
const server = {
id: "11111111-1111-4111-8111-111111111111",
name: "Season 4",
host: "season4.somc.svc.cluster.local",
port: 25575,
enabled: true,
};
const creative = {
...server,
id: "22222222-2222-4222-8222-222222222222",
name: "Creative",
host: "creative.example.com",
};
describe("RconConsole", () => {
it("renders one wide terminal workspace with connection controls and modal forms", () => {
const markup = renderToStaticMarkup(<RconConsole servers={[server]} />);
expect(markup).toContain('aria-label="RCON terminal"');
expect(markup).toContain('for="rcon-console-server"');
expect(markup).toContain('for="rcon-command"');
expect(markup).toContain("w-full");
expect(markup).toContain("Season 4");
expect(markup).toContain("server://");
expect(markup).toContain("Awaiting command");
expect(markup).toContain("Add");
expect(markup).toContain("Edit");
expect(markup).toContain("Test");
expect(markup).toContain("Disable");
expect(markup).toContain("Delete");
expect(markup).toContain("Add RCON connection");
expect(markup).toContain('href="/admin/rcon/history"');
expect(markup).toContain("Command history");
expect(markup).toContain("Edit Season 4");
expect(markup).toContain("Delete Season 4?");
expect(markup).toContain("Enter ↵");
expect(markup).not.toContain("Latest response");
});
it("renders connection operation notices inside the terminal viewport", () => {
const markup = renderToStaticMarkup(<RconConsole notice={{ status: "error", message: "RCON authentication timed out." }} servers={[server]} />);
expect(markup).toContain("RCON authentication timed out.");
expect(markup).toContain('role="alert"');
});
it("keeps the terminal and add action available when no connection exists", () => {
const markup = renderToStaticMarkup(<RconConsole servers={[]} />);
expect(markup).toContain('aria-label="RCON terminal"');
expect(markup).toContain("No connections configured");
expect(markup).toContain("Add");
expect(markup).not.toContain("Edit");
expect(markup).not.toContain("Delete");
});
it("navigates page-memory command history and restores the unsent draft", async () => {
render(<RconConsole servers={[server]} />);
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "list" } });
fireEvent.submit(input.form!);
await waitFor(() => expect(screen.getByText("Executed list")).toBeTruthy());
expect(document.activeElement).toBe(input);
expect(input.value).toBe("");
fireEvent.change(input, { target: { value: "say hello" } });
fireEvent.submit(input.form!);
await waitFor(() => expect(screen.getByText("Executed say hello")).toBeTruthy());
fireEvent.change(input, { target: { value: "draft command" } });
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("say hello");
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("list");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(input.value).toBe("say hello");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(input.value).toBe("draft command");
});
it("retains chronological command and response exchanges in the terminal transcript", async () => {
render(<RconConsole servers={[server]} />);
const input = screen.getByLabelText("Command") as HTMLInputElement;
let listResponses = 0;
for (const command of ["list", "say hello", "list"]) {
fireEvent.change(input, { target: { value: command } });
fireEvent.submit(input.form!);
if (command === "list") listResponses += 1;
await waitFor(() => expect(screen.getAllByText(`Executed ${command}`)).toHaveLength(command === "list" ? listResponses : 1));
}
const transcript = screen.getByLabelText("Terminal transcript");
const text = transcript.textContent ?? "";
expect(text.indexOf("$ list")).toBeLessThan(text.indexOf("Executed list"));
expect(text.indexOf("Executed list")).toBeLessThan(text.indexOf("$ say hello"));
expect(text.indexOf("$ say hello")).toBeLessThan(text.indexOf("Executed say hello"));
expect(screen.getAllByText("Executed list")).toHaveLength(2);
});
it("returns focus to the command prompt after changing servers", async () => {
render(<RconConsole servers={[server, creative]} />);
const select = screen.getByLabelText("Server");
select.focus();
fireEvent.change(select, { target: { value: creative.id } });
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText("Command")));
});
});
+290
View File
@@ -0,0 +1,290 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useRef, useState } from "react";
import {
createRconServer,
deleteRconServer,
executeRconCommand,
setRconServerEnabled,
testSavedRconServer,
type RconCommandState,
updateRconServer,
} from "@/app/admin/(console)/rcon/actions";
import { AdminModalForm } from "@/components/admin-modal-form";
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
const MAX_COMMAND_HISTORY = 50;
const MAX_TRANSCRIPT_EXCHANGES = 50;
type TranscriptExchange = {
id: number;
serverName: string;
command: string;
status: "pending" | "success" | "error";
message: string;
};
export type RconServerOption = {
id: string;
name: string;
host: string;
port: number;
enabled: boolean;
};
export type RconTerminalNotice = {
status: "success" | "error";
message: string;
};
export function RconConsole({
notice,
servers,
}: {
notice?: RconTerminalNotice;
servers: RconServerOption[];
}) {
const [selectedId, setSelectedId] = useState(servers[0]?.id ?? "");
const [state, action, pending] = useActionState(executeRconCommand, initialState);
const [command, setCommand] = useState("");
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [transcript, setTranscript] = useState<TranscriptExchange[]>([]);
const draftRef = useRef("");
const inputRef = useRef<HTMLInputElement>(null);
const nextExchangeIdRef = useRef(0);
const pendingExchangeIdRef = useRef<number | null>(null);
const transcriptRef = useRef<HTMLDivElement>(null);
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
useEffect(() => {
inputRef.current?.focus();
}, [selectedId]);
useEffect(() => {
if (!pending && state.status !== "idle") inputRef.current?.focus();
}, [pending, state.status]);
useEffect(() => {
const exchangeId = pendingExchangeIdRef.current;
if (exchangeId === null || state.status === "idle") return;
const resultStatus: TranscriptExchange["status"] = state.status === "error" ? "error" : "success";
setTranscript((current) => current.map((exchange) => exchange.id === exchangeId
? { ...exchange, status: resultStatus, message: state.message }
: exchange));
pendingExchangeIdRef.current = null;
}, [state]);
useEffect(() => {
if (transcriptRef.current) transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
}, [transcript]);
function navigateHistory(direction: "older" | "newer") {
if (!history.length) return;
if (direction === "older") {
const nextIndex = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
if (historyIndex === null) draftRef.current = command;
setHistoryIndex(nextIndex);
setCommand(history[nextIndex]!);
return;
}
if (historyIndex === null) return;
if (historyIndex < history.length - 1) {
const nextIndex = historyIndex + 1;
setHistoryIndex(nextIndex);
setCommand(history[nextIndex]!);
} else {
setHistoryIndex(null);
setCommand(draftRef.current);
}
}
function rememberSubmittedCommand() {
const submitted = command.trim();
if (!submitted || !selected) return;
const exchangeId = ++nextExchangeIdRef.current;
pendingExchangeIdRef.current = exchangeId;
const exchange: TranscriptExchange = {
id: exchangeId,
serverName: selected.name,
command: submitted,
status: "pending",
message: "Command in progress…",
};
setTranscript((current) => [...current, exchange].slice(-MAX_TRANSCRIPT_EXCHANGES));
setHistory((current) => [...current, submitted].slice(-MAX_COMMAND_HISTORY));
setHistoryIndex(null);
draftRef.current = "";
setCommand("");
}
return (
<section aria-label="RCON terminal" className="mt-8 w-full overflow-hidden border-2 border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
<div className="flex flex-col gap-4 border-b-2 border-ink bg-canvas px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<div className="flex items-center gap-2 font-mono text-[10px] font-bold uppercase tracking-wider text-muted">
<span aria-hidden="true" className={`size-2 rounded-full shadow-[0_0_0_1px_var(--color-ink)] ${selected?.enabled ? "bg-signal" : "bg-line"}`} />
<span>server://</span>
</div>
{servers.length ? (
<label className="flex min-w-0 items-center gap-2 font-mono text-[9px] font-bold uppercase tracking-wider" htmlFor="rcon-console-server">
<span className="sr-only">Server</span>
<select
className="max-w-full border border-line bg-panel px-3 py-2 font-mono text-xs font-bold normal-case outline-none focus:border-accent"
id="rcon-console-server"
onChange={(event) => setSelectedId(event.target.value)}
value={selected?.id}
>
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}{server.enabled ? "" : " — disabled"}</option>)}
</select>
</label>
) : (
<span className="font-mono text-xs font-bold text-muted">no-target</span>
)}
{selected && <span className="font-mono text-[9px] text-muted">{selected.host}:{selected.port}</span>}
</div>
<div className="flex flex-wrap items-center gap-2">
<Link className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" href="/admin/rcon/history">Command history</Link>
<ConnectionModal mode="add" />
{selected && (
<>
<form action={testSavedRconServer}>
<input name="serverId" type="hidden" value={selected.id} />
<HeaderButton label="Test" />
</form>
<form action={setRconServerEnabled}>
<input name="serverId" type="hidden" value={selected.id} />
<input name="enabled" type="hidden" value={selected.enabled ? "no" : "yes"} />
<HeaderButton label={selected.enabled ? "Disable" : "Enable"} />
</form>
<ConnectionModal mode="edit" server={selected} />
<AdminModalForm
action={deleteRconServer}
description={`Delete ${selected.name} and its encrypted credential. This cannot be undone.`}
intent="danger"
submitLabel="Delete connection"
title={`Delete ${selected.name}?`}
triggerClassName="border border-accent px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-accent"
triggerLabel="Delete"
>
<input name="serverId" type="hidden" value={selected.id} />
<input name="confirmation" type="hidden" value={selected.id} />
</AdminModalForm>
</>
)}
</div>
</div>
<div aria-label="Terminal transcript" aria-live="polite" aria-relevant="additions text" className="min-h-72 max-h-[32rem] overflow-auto p-5 font-mono text-xs leading-5" ref={transcriptRef} role="status">
{notice && (
<div className={`mb-5 border-l-2 pl-3 ${notice.status === "error" ? "border-accent" : "border-signal"}`} role={notice.status === "error" ? "alert" : "status"}>
<p className={`text-[9px] font-bold uppercase tracking-wider ${notice.status === "error" ? "text-accent" : "text-muted"}`}>{notice.status === "error" ? "Connection error" : "Connection update"}</p>
<p className="mt-2">{notice.message}</p>
</div>
)}
{!transcript.length && <TerminalIdle selected={selected} />}
<div className="space-y-6">
{transcript.map((exchange) => (
<article className="border-l-2 border-line pl-3" key={exchange.id}>
<p className="break-words">
<span className="mr-2 text-[9px] font-bold uppercase tracking-wider text-muted">server://{exchange.serverName}</span>
<span className="text-accent">$</span> {exchange.command}
</p>
<div className={`mt-2 ${exchange.status === "error" ? "text-accent" : "text-ink"}`} role={exchange.status === "error" ? "alert" : undefined}>
{exchange.status === "pending" ? <p className="text-muted">Command in progress</p> : <pre className="whitespace-pre-wrap break-words font-mono text-xs leading-5">{exchange.message}</pre>}
</div>
</article>
))}
</div>
</div>
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3" onSubmit={rememberSubmittedCommand}>
<input name="serverId" type="hidden" value={selected?.id ?? ""} />
<span aria-hidden="true" className="font-mono text-lg font-black text-accent">$</span>
<label className="sr-only" htmlFor="rcon-command">Command</label>
<input
autoComplete="off"
autoFocus
className="min-w-0 flex-1 bg-transparent px-1 py-2 font-mono text-sm outline-none placeholder:text-muted focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={!selected?.enabled || pending}
id="rcon-command"
key={selected?.id ?? "no-server"}
maxLength={1024}
name="command"
onChange={(event) => setCommand(event.target.value)}
onKeyDown={(event) => {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
navigateHistory(event.key === "ArrowUp" ? "older" : "newer");
}
}}
placeholder={selected ? (selected.enabled ? "list" : "Enable this connection to run commands") : "Add a connection to begin"}
ref={inputRef}
required
spellCheck={false}
value={command}
/>
<button className="border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas disabled:cursor-not-allowed disabled:opacity-50" disabled={!selected?.enabled || pending} type="submit">{pending ? "Running…" : "Enter ↵"}</button>
</form>
</section>
);
}
function TerminalIdle({ selected }: { selected?: RconServerOption }) {
if (!selected) return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready</p><p className="mt-3">No connections configured. Use Add to create a server connection.</p></>;
if (!selected.enabled) return <><p className="text-[9px] font-bold uppercase tracking-wider text-accent">Disabled {selected.name}</p><p className="mt-3">Enable this connection before testing commands.</p></>;
return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready {selected.name}</p><p className="mt-3">Awaiting command</p></>;
}
function HeaderButton({ label }: { label: string }) {
return <button className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" type="submit">{label}</button>;
}
function ConnectionModal({
mode,
server,
}: {
mode: "add" | "edit";
server?: RconServerOption;
}) {
const editing = mode === "edit" ? server : undefined;
return (
<AdminModalForm
action={editing ? updateRconServer : createRconServer}
description={editing ? `Update ${editing.name}. Leave the password blank to preserve its encrypted credential.` : "Add an internal or external RCON server address. The password is encrypted before storage."}
submitLabel={editing ? "Save connection" : "Add connection"}
title={editing ? `Edit ${editing.name}` : "Add RCON connection"}
triggerClassName={editing ? "border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider" : "border border-ink bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas"}
triggerLabel={editing ? "Edit" : "Add"}
>
<div className="space-y-4">
{editing && <input name="serverId" type="hidden" value={editing.id} />}
<ConnectionFields defaults={editing} prefix={editing?.id ?? "new"} />
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase">
<input className="size-4" defaultChecked={editing?.enabled ?? false} name="enabled" type="checkbox" value="yes" />
{editing ? "Enabled" : "Enable immediately"}
</label>
</div>
</AdminModalForm>
);
}
function ConnectionFields({
defaults,
prefix,
}: {
defaults?: { name: string; host: string; port: number };
prefix: string;
}) {
const fieldClass = "mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent";
return (
<>
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-name`}>Name<input className={fieldClass} defaultValue={defaults?.name} id={`${prefix}-rcon-name`} maxLength={100} name="name" required /></label>
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-host`}>Server address<input autoCapitalize="none" autoCorrect="off" className={fieldClass} defaultValue={defaults?.host} id={`${prefix}-rcon-host`} maxLength={253} name="host" placeholder="minecraft.example.com" required spellCheck={false} /></label>
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-port`}>Port<input className={fieldClass} defaultValue={defaults?.port ?? 25575} id={`${prefix}-rcon-port`} max={65535} min={1} name="port" required type="number" /></label>
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-password`}>{defaults ? "Replacement password" : "Password"}<input autoComplete="new-password" className={fieldClass} id={`${prefix}-rcon-password`} maxLength={512} name="password" required={!defaults} type="password" />{defaults && <span className="mt-2 block font-sans text-[10px] font-normal normal-case text-muted">Leave blank to preserve the current password.</span>}</label>
</>
);
}
@@ -1,21 +1,53 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { UserGroupSelect } from "./user-group-select";
const groups = [{ id: "group-everyone", name: "everyone" }, { id: "group-ops", name: "Ops" }];
beforeEach(() => {
HTMLDialogElement.prototype.showModal = function showModal() { this.open = true; };
HTMLDialogElement.prototype.close = function close() {
this.open = false;
this.dispatchEvent(new Event("close"));
};
});
describe("UserGroupSelect", () => {
it("renders the effective group and preserves the active search", () => {
it("renders the effective group and preserves the return path", () => {
const markup = renderToStaticMarkup(<UserGroupSelect
action={async () => undefined}
effectiveGroupId="group-ops"
groups={[{ id: "group-everyone", name: "everyone" }, { id: "group-ops", name: "Ops" }]}
search="alex smith"
groups={groups}
returnTo="/admin/users?q=alex%20smith"
userId="user-one"
userLabel="Alex"
/>);
expect(markup).toContain('aria-label="Group for Alex"');
expect(markup).toContain('<option value="group-ops" selected="">Ops</option>');
expect(markup).toContain('<input type="hidden" name="search" value="alex smith"/>');
expect(markup).toContain("Apply group");
expect(markup).toContain('<input type="hidden" name="returnTo" value="/admin/users?q=alex%20smith"/>');
expect(markup).toContain("Changing this selection opens a confirmation dialog.");
expect(markup).toContain("Confirm move");
});
it("requires confirmation and restores the effective group when cancelled", () => {
render(<UserGroupSelect action={async () => undefined} effectiveGroupId="group-ops" groups={groups} returnTo="/admin/users" userId="user-one" userLabel="Alex" />);
const select = screen.getByRole("combobox", { name: "Group for Alex" }) as HTMLSelectElement;
fireEvent.change(select, { target: { value: "group-everyone" } });
const dialog = screen.getByRole("dialog") as HTMLDialogElement;
expect(dialog.open).toBe(true);
expect(select.value).toBe("group-everyone");
expect(select.disabled).toBe(true);
expect(screen.getByText(/from/).textContent).toContain("Ops");
expect(screen.getByText(/from/).textContent).toContain("everyone");
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(dialog.open).toBe(false);
expect(select.value).toBe("group-ops");
expect(select.disabled).toBe(false);
});
});
+69 -30
View File
@@ -1,60 +1,99 @@
"use client";
import type { RefObject } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { useFormStatus } from "react-dom";
export function UserGroupSelect({
action,
effectiveGroupId,
groups,
search,
returnTo,
userId,
userLabel,
}: {
action: (formData: FormData) => Promise<void>;
effectiveGroupId: string;
groups: Array<{ id: string; name: string }>;
search: string;
returnTo: string;
userId: string;
userLabel: string;
}) {
const helpId = `group-help-${userId}`;
return (
<form action={action} className="flex items-center gap-2">
<input name="userId" type="hidden" value={userId} />
<input name="search" type="hidden" value={search} />
<span className="sr-only" id={helpId}>Changing this selection applies the group immediately.</span>
<GroupSelectControl effectiveGroupId={effectiveGroupId} groups={groups} helpId={helpId} userLabel={userLabel} />
</form>
);
}
const dialogRef = useRef<HTMLDialogElement>(null);
const titleId = useId();
const descriptionId = useId();
const helpId = useId();
const [selectedGroupId, setSelectedGroupId] = useState(effectiveGroupId);
const [proposedGroupId, setProposedGroupId] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const currentGroup = groups.find((group) => group.id === effectiveGroupId);
const proposedGroup = groups.find((group) => group.id === proposedGroupId);
function resetSelection() {
setSelectedGroupId(effectiveGroupId);
setProposedGroupId(null);
}
function GroupSelectControl({
effectiveGroupId,
groups,
helpId,
userLabel,
}: {
effectiveGroupId: string;
groups: Array<{ id: string; name: string }>;
helpId: string;
userLabel: string;
}) {
const { pending } = useFormStatus();
return (
<>
<span className="sr-only" id={helpId}>Changing this selection opens a confirmation dialog.</span>
<select
aria-describedby={helpId}
aria-label={`Group for ${userLabel}`}
className="max-w-44 border border-line bg-canvas px-3 py-2 font-mono text-xs outline-none focus:border-accent disabled:cursor-wait disabled:opacity-60"
defaultValue={effectiveGroupId}
disabled={pending}
name="groupId"
onChange={(event) => event.currentTarget.form?.requestSubmit()}
disabled={proposedGroupId !== null || submitting}
onChange={(event) => {
const nextGroupId = event.currentTarget.value;
if (nextGroupId === effectiveGroupId) return;
setSelectedGroupId(nextGroupId);
setProposedGroupId(nextGroupId);
dialogRef.current?.showModal();
}}
value={selectedGroupId}
>
{groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
</select>
<span aria-live="polite" className="sr-only">{pending ? "Updating group." : ""}</span>
<button className="sr-only focus:not-sr-only" disabled={pending} type="submit">Apply group</button>
<dialog
aria-describedby={descriptionId}
aria-labelledby={titleId}
className="admin-modal m-auto w-[min(92vw,34rem)] 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(); }}
onClose={() => { if (!submitting) resetSelection(); }}
ref={dialogRef}
>
<form action={action} className="p-6 sm:p-8" onSubmit={() => setSubmitting(true)}>
<input name="userId" type="hidden" value={userId} />
<input name="groupId" type="hidden" value={proposedGroupId ?? effectiveGroupId} />
<input name="returnTo" type="hidden" value={returnTo} />
<p className="font-mono text-[9px] font-bold uppercase tracking-[0.2em] text-accent">Confirm membership</p>
<h2 className="mt-3 font-display text-3xl font-black uppercase" id={titleId}>Move {userLabel}?</h2>
<p className="mt-3 text-sm leading-6 text-muted" id={descriptionId}>
Change the effective group from <strong className="text-ink">{currentGroup?.name ?? "unknown"}</strong> to <strong className="text-ink">{proposedGroup?.name ?? "unknown"}</strong>. Their access policy changes immediately.
</p>
<AssignmentActions dialogRef={dialogRef} onPendingChange={setSubmitting} />
</form>
</dialog>
</>
);
}
function AssignmentActions({ dialogRef, onPendingChange }: { dialogRef: RefObject<HTMLDialogElement | null>; onPendingChange: (pending: boolean) => void }) {
const { pending } = useFormStatus();
const observedPending = useRef(false);
useEffect(() => {
if (pending) {
observedPending.current = true;
onPendingChange(true);
} else if (observedPending.current) {
observedPending.current = false;
onPendingChange(false);
}
}, [onPendingChange, pending]);
return (
<div className="mt-8 flex justify-end gap-3 border-t border-line pt-5">
<button className="border border-line px-5 py-3 font-mono text-[10px] font-bold uppercase" disabled={pending} onClick={() => dialogRef.current?.close()} type="button">Cancel</button>
<button className="bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase text-canvas disabled:cursor-wait disabled:opacity-60" disabled={pending} type="submit">{pending ? "Moving…" : "Confirm move"}</button>
<span aria-live="polite" className="sr-only">{pending ? "Group change in progress." : ""}</span>
</div>
);
}
@@ -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);
}
+2
View File
@@ -10,6 +10,7 @@ export async function recordAdminSubjectEvent(
subject: string,
type: string,
data: Record<string, unknown>,
options: { correlationId?: string } = {},
) {
const requestHeaders = await headers();
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
@@ -18,6 +19,7 @@ export async function recordAdminSubjectEvent(
source: "/web/admin",
subject,
ipAddress: ipAddress ?? undefined,
correlationId: options.correlationId,
data: { ...data, adminEmail: admin.email, adminName: admin.name },
});
}
@@ -0,0 +1,24 @@
export type SuggestionTag = { id: string; name: string };
export type Suggestion = {
id: string;
title: string;
authorId: string;
createdAt: string;
archived: boolean;
locked: boolean;
tags: SuggestionTag[];
messageCount: number;
discordUrl: string;
};
export type SuggestionMessage = {
id: string;
author: { id: string; name: string };
content: string;
createdAt: string;
editedAt: string | null;
reactions: { emoji: string; count: number }[];
discordUrl: string;
};
export type SuggestionPage = { items: Suggestion[]; nextCursor: string | null };
export type MessagePage = { items: SuggestionMessage[]; nextCursor: string | null };
export type SuggestionDetail = Suggestion & { originalPost: SuggestionMessage | null };
@@ -0,0 +1,50 @@
import { getServerSession } from "next-auth";
import { problemDetails } from "@minecraft-account-manager/contracts";
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
import { problemInstance, problemResponse } from "@/lib/problem-response";
import { createSuggestionsClient, SuggestionsError } from "./suggestions";
type Client = ReturnType<typeof createSuggestionsClient>;
let runtime: { token: string; guildId: string; forumId: string; client: Client } | undefined;
function getClient() {
const token = process.env.DISCORD_BOT_TOKEN?.trim() ?? "";
const guildId = process.env.DISCORD_GUILD_ID?.trim() ?? "";
const forumId = process.env.DISCORD_SUGGESTIONS_FORUM_ID?.trim() ?? "";
if (!runtime || runtime.token !== token || runtime.guildId !== guildId || runtime.forumId !== forumId) {
runtime = { token, guildId, forumId, client: createSuggestionsClient({ token, guildId, forumId }) };
}
return runtime.client;
}
export async function suggestionsApi(request: Request, operation: (client: Client) => Promise<unknown>) {
try {
const session = await getServerSession(adminAuthOptions);
if (!session) throw new SuggestionsError(401, "unauthorized", "Sign in as an administrator.");
const roles = (session.user as { roles?: unknown } | undefined)?.roles;
if (!Array.isArray(roles) || !roles.includes(requiredAdminRole)) throw new SuggestionsError(403, "forbidden", "This API is restricted to administrators.");
return Response.json(await operation(getClient()), { headers: { "cache-control": "no-store" } });
} catch (error) {
const safe = error instanceof SuggestionsError ? error : new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
const titles: Record<number, string> = { 400: "Invalid request", 401: "Authentication required", 403: "Administrator role required", 404: "Suggestion not found", 405: "Method not allowed", 503: "Suggestions unavailable" };
const response = problemResponse(problemDetails(`urn:error:${safe.code}`, titles[safe.status] ?? "Suggestions unavailable", safe.status, safe.message, problemInstance(request)));
if (safe.retryAfter) response.headers.set("retry-after", String(safe.retryAfter));
return response;
}
}
export function suggestionQuery(request: Request, list = false) {
const params = new URL(request.url).searchParams;
const allowed = list ? ["limit", "cursor", "status"] : ["limit", "cursor"];
for (const key of params.keys()) {
if (!allowed.includes(key) || params.getAll(key).length !== 1 || !params.get(key)) throw new SuggestionsError(400, "invalid-request", "Unsupported or repeated query parameter.");
}
const rawLimit = params.get("limit");
if (rawLimit !== null && !/^\d{1,3}$/.test(rawLimit)) throw new SuggestionsError(400, "invalid-request", "Limit must be between 1 and 100.");
return { limit: rawLimit === null ? undefined : Number(rawLimit), cursor: params.get("cursor") ?? undefined, ...(list ? { status: params.get("status") ?? undefined } : {}) };
}
export function suggestionsReadOnly(request: Request) {
return suggestionsApi(request, async () => {
throw new SuggestionsError(405, "method-not-allowed", "Suggestions are read-only. Use GET.");
}).then((response) => { if (response.status === 405) response.headers.set("allow", "GET, HEAD"); return response; });
}
@@ -0,0 +1,127 @@
import { afterEach, expect, it, vi } from "vitest";
import { createSuggestionsClient } from "./suggestions";
afterEach(() => vi.useRealTimers());
const guildId = "100000000000000001";
const forumId = "100000000000000002";
const threadId = "100000000000000009";
const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11, name: "More railway stations", owner_id: "100000000000000003", applied_tags: ["100000000000000004"], message_count: 3, thread_metadata: { archived: false, locked: false, archive_timestamp: "2026-01-01T00:00:00.000Z" } };
function setup(responses: Record<string, unknown>) {
const fetcher = vi.fn<typeof fetch>(async (input) => {
const path = String(input).replace("https://discord.com/api/v10", "");
if (!(path in responses)) throw new Error(`Unexpected path: ${path}`);
const value = responses[path];
return value instanceof Response ? value : Response.json(value);
});
const client = createSuggestionsClient({ token: "test-token", guildId, forumId, fetch: fetcher });
return { client, fetcher };
}
it("reads archived forum pages using Discord's archive timestamp cursor", async () => {
const cursor = "2026-01-01T00:00:00Z";
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=1`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } }], has_more: true },
[`/channels/${forumId}/threads/archived/public?limit=1&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
});
expect(await client.list({ status: "archived", limit: 1 })).toMatchObject({ items: [{ archived: true }], nextCursor: cursor });
expect(await client.list({ status: "archived", limit: 1, cursor })).toEqual({ items: [], nextCursor: null });
});
const message = { id: threadId, content: "Please add stations", timestamp: "2026-01-01T00:00:00.000Z", edited_timestamp: null, author: { id: "100000000000000003", username: "builder", global_name: "Builder" }, reactions: [{ emoji: { name: "👍" }, count: 4 }] };
it("returns the starter post and paginates discussion with authors and reactions", async () => {
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${threadId}`]: thread,
[`/channels/${threadId}/messages/${threadId}`]: message,
[`/channels/${threadId}/messages?limit=1`]: [{ ...message, id: "100000000000000020" }],
[`/channels/${threadId}/messages?limit=1&before=100000000000000020`]: [],
});
expect(await client.detail(threadId)).toMatchObject({ title: thread.name, originalPost: { content: message.content, author: { name: "Builder" }, reactions: [{ emoji: "👍", count: 4 }], createdAt: "2026-01-01T00:00:00Z" } });
expect(await client.messages(threadId, { limit: 1 })).toMatchObject({ items: [{ id: "100000000000000020" }], nextCursor: "100000000000000020" });
expect(await client.messages(threadId, { limit: 1, cursor: "100000000000000020" })).toEqual({ items: [], nextCursor: null });
});
it.each(["detail", "messages"] as const)("blocks %s of a thread outside the forum before reading messages", async (method) => {
const { client, fetcher } = setup({ [`/channels/${forumId}`]: forum, [`/channels/${threadId}`]: { ...thread, parent_id: "100000000000000099" } });
await expect(client[method](threadId)).rejects.toMatchObject({ status: 404 });
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("keeps a deleted starter post distinguishable from an empty message", async () => {
const { client } = setup({ [`/channels/${forumId}`]: forum, [`/channels/${threadId}`]: thread, [`/channels/${threadId}/messages/${threadId}`]: new Response(null, { status: 404 }) });
expect(await client.detail(threadId)).toMatchObject({ originalPost: null });
});
it("backs off on Discord rate limits without exposing Discord error bodies", async () => {
const { client, fetcher } = setup({ [`/channels/${forumId}`]: Response.json({ retry_after: 2.5, message: "secret upstream details" }, { status: 429 }) });
await expect(client.list()).rejects.toMatchObject({ status: 503, code: "discord-rate-limited", retryAfter: 3 });
await expect(client.list()).rejects.toMatchObject({ status: 503, code: "discord-rate-limited" });
expect(fetcher).toHaveBeenCalledTimes(1);
});
it.each([401, 403, 404, 500])("translates Discord %s into a safe service error", async (status) => {
const { client } = setup({ [`/channels/${forumId}`]: new Response("sensitive error", { status }) });
await expect(client.list()).rejects.toMatchObject({ status: 503 });
await expect(client.list()).rejects.not.toThrow("sensitive error");
});
it("sanitizes network failures", async () => {
const client = createSuggestionsClient({ token: "test", guildId, forumId, fetch: vi.fn().mockRejectedValue(new Error("token leaked by upstream")) });
await expect(client.list()).rejects.toMatchObject({ status: 503, message: "Discord suggestions are unavailable." });
});
it("coalesces concurrent reads and refreshes expired cache entries", async () => {
vi.useFakeTimers();
const { client, fetcher } = setup({ [`/channels/${forumId}`]: forum, [`/guilds/${guildId}/threads/active`]: { threads: [thread] } });
await Promise.all([client.list(), client.list()]);
expect(fetcher).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(30_001);
await client.list();
expect(fetcher).toHaveBeenCalledTimes(4);
});
it.each([{ status: "all" }, { limit: 0 }, { limit: 101 }, { limit: 1.5 }, { cursor: "../secret" }, { status: "archived", cursor: "bad-date" }])("validates list query %j before network access", async (query) => {
const { client, fetcher } = setup({});
await expect(client.list(query)).rejects.toMatchObject({ status: 400 });
expect(fetcher).not.toHaveBeenCalled();
});
it.each(["detail", "messages"] as const)("validates %s IDs before network access", async (method) => {
const { client, fetcher } = setup({});
await expect(client[method]("../secret")).rejects.toMatchObject({ status: 400 });
expect(fetcher).not.toHaveBeenCalled();
});
it.each([{ ...forumPlaceholder(), type: 0 }, { ...forumPlaceholder(), guild_id: "100000000000000099" }])("refuses a non-forum or wrong-guild configured channel", async (value) => {
const { client, fetcher } = setup({ [`/channels/${forumId}`]: value });
await expect(client.list()).rejects.toMatchObject({ status: 503 });
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("bounds concurrent upstream requests instead of flooding Discord", async () => {
const releases: (() => void)[] = [];
const fetcher = vi.fn<typeof fetch>(async (input) => {
if (String(input).endsWith(`/channels/${forumId}`)) return Response.json(forum);
if (String(input).endsWith("/threads/active")) return Response.json({ threads: [] });
return new Promise<Response>((resolve) => { releases.push(() => resolve(new Response(null, { status: 404 }))); });
});
const client = createSuggestionsClient({ token: "test", guildId, forumId, fetch: fetcher });
await client.list();
const results = Array.from({ length: 9 }, (_, index) => client.detail(`1000000000000001${index.toString().padStart(2, "0")}`).catch((error: unknown) => error));
await vi.waitFor(() => expect(fetcher.mock.calls.length).toBeGreaterThanOrEqual(10));
const count = releases.length;
releases.forEach((release) => release());
const errors = await Promise.all(results);
expect(count).toBe(8);
expect(errors).toContainEqual(expect.objectContaining({ code: "discord-busy", status: 503 }));
});
function forumPlaceholder() { return { id: forumId, guild_id: guildId, type: 15 }; }
const forum = { id: forumId, guild_id: guildId, type: 15, available_tags: [{ id: "100000000000000004", name: "World" }] };
it("lists only configured-forum active suggestions, resolves tags and paginates newest first", async () => {
const { client, fetcher } = setup({
[`/channels/${forumId}`]: forum,
[`/guilds/${guildId}/threads/active`]: { threads: [
{ ...thread, id: "100000000000000008" }, thread,
{ ...thread, id: "100000000000000010", parent_id: "100000000000000099" },
] },
});
const first = await client.list({ limit: 1 });
expect(first).toMatchObject({ items: [{ id: threadId, title: "More railway stations", tags: [{ name: "World" }], discordUrl: `https://discord.com/channels/${guildId}/${threadId}` }], nextCursor: threadId });
const second = await client.list({ limit: 1, cursor: threadId });
expect(second).toMatchObject({ items: [{ id: "100000000000000008" }], nextCursor: null });
expect(fetcher).toHaveBeenCalledTimes(2);
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ headers: { Authorization: "Bot test-token" }, cache: "no-store", redirect: "error" });
});
+157
View File
@@ -0,0 +1,157 @@
import type { MessagePage, SuggestionDetail, SuggestionMessage, Suggestion, SuggestionPage, SuggestionTag } from "./suggestion-types";
type Forum = { id: string; guild_id: string; type: number; available_tags: SuggestionTag[] };
type Thread = {
id: string; guild_id?: string; parent_id: string; type: number; name: string; owner_id: string;
applied_tags?: string[]; message_count?: number;
thread_metadata: { archived: boolean; locked: boolean; archive_timestamp: string };
};
type Message = {
id: string; content: string; timestamp: string; edited_timestamp?: string | null;
author: { id: string; username: string; global_name?: string | null };
reactions?: { emoji: { id?: string | null; name: string | null }; count: number }[];
};
export type ListQuery = { status?: string; cursor?: string; limit?: number };
export class SuggestionsError extends Error {
constructor(public readonly status: number, public readonly code: string, message: string, public readonly retryAfter?: number) {
super(message);
}
}
const snowflake = /^[1-9]\d{16,19}$/;
function checkId(id: string) {
if (!snowflake.test(id)) throw new SuggestionsError(400, "invalid-request", "A valid Discord ID is required.");
}
function limitValue(limit = 25) {
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new SuggestionsError(400, "invalid-request", "Limit must be between 1 and 100.");
return limit;
}
function timestamp(value: string | number) {
return new Date(value).toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function createSuggestionsClient(options: { token: string; guildId: string; forumId: string; fetch?: typeof fetch }) {
const { token, guildId, forumId } = options;
const fetcher = options.fetch ?? fetch;
const cache = new Map<string, { expires: number; value: unknown }>();
let retryAt = 0;
let activeRequests = 0;
const pending = new Map<string, Promise<unknown>>();
async function get<T>(path: string): Promise<T> {
const existing = pending.get(path);
if (existing) return existing as Promise<T>;
const request = load<T>(path);
pending.set(path, request);
try { return await request; } finally { pending.delete(path); }
}
async function load<T>(path: string): Promise<T> {
const cached = cache.get(path);
if (cached && cached.expires > Date.now()) return cached.value as T;
if (Date.now() < retryAt) throw new SuggestionsError(503, "discord-rate-limited", "Discord is rate limited. Try again shortly.", Math.ceil((retryAt - Date.now()) / 1000));
if (activeRequests >= 8) throw new SuggestionsError(503, "discord-busy", "Suggestions are busy. Try again shortly.", 1);
activeRequests += 1;
try {
const response = await fetcher(`https://discord.com/api/v10${path}`, {
headers: { Authorization: `Bot ${token}` }, cache: "no-store", redirect: "error", signal: AbortSignal.timeout(8000),
});
if (response.status === 429) {
const body = await response.json().catch(() => null) as { retry_after?: number } | null;
const raw = Number(body?.retry_after ?? response.headers.get("retry-after") ?? 1);
const seconds = Number.isFinite(raw) && raw > 0 ? Math.ceil(raw) : 1;
retryAt = Date.now() + seconds * 1000;
throw new SuggestionsError(503, "discord-rate-limited", "Discord is rate limited. Try again shortly.", seconds);
}
if (response.status === 404) throw new SuggestionsError(404, "suggestion-not-found", "The suggestion or message was not found.");
if (!response.ok) throw new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
const value: unknown = await response.json();
if (cache.size >= 200) cache.delete(cache.keys().next().value!);
cache.set(path, { value, expires: Date.now() + 30_000 });
return value as T;
} catch (error) {
if (error instanceof SuggestionsError) throw error;
throw new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
} finally {
activeRequests -= 1;
}
}
async function getForum() {
if (!token || !snowflake.test(guildId) || !snowflake.test(forumId)) throw new SuggestionsError(503, "suggestions-not-configured", "Discord suggestions are not configured.");
const forum = await get<Forum>(`/channels/${forumId}`).catch((error: unknown) => {
if (error instanceof SuggestionsError && error.status === 404) throw new SuggestionsError(503, "suggestions-not-configured", "The configured suggestions forum is unavailable.");
throw error;
});
if (forum.id !== forumId || forum.guild_id !== guildId || forum.type !== 15) throw new SuggestionsError(503, "suggestions-not-configured", "The configured channel must be a forum in the configured guild.");
return forum;
}
function belongs(thread: Thread) {
return thread.parent_id === forumId && (!thread.guild_id || thread.guild_id === guildId) && thread.type === 11;
}
function summary(thread: Thread, forum: Forum): Suggestion {
return {
id: thread.id, title: thread.name, authorId: thread.owner_id,
createdAt: timestamp(Number((BigInt(thread.id) >> 22n) + 1420070400000n)),
archived: thread.thread_metadata.archived, locked: thread.thread_metadata.locked,
tags: (forum.available_tags ?? []).filter((tag) => thread.applied_tags?.includes(tag.id)).map(({ id, name }) => ({ id, name })),
messageCount: thread.message_count ?? 0, discordUrl: `https://discord.com/channels/${guildId}/${thread.id}`,
};
}
async function getThread(id: string) {
checkId(id);
const forum = await getForum();
const thread = await get<Thread>(`/channels/${id}`);
if (thread.id !== id || !belongs(thread)) throw new SuggestionsError(404, "suggestion-not-found", "The suggestion was not found in the configured forum.");
return { thread, forum };
}
function messageView(message: Message, threadId: string): SuggestionMessage {
return {
id: message.id, content: message.content,
author: { id: message.author.id, name: message.author.global_name || message.author.username },
createdAt: timestamp(message.timestamp), editedAt: message.edited_timestamp ? timestamp(message.edited_timestamp) : null,
reactions: (message.reactions ?? []).map(({ emoji, count }) => ({ emoji: emoji.id ? `:${emoji.name ?? "emoji"}:` : emoji.name ?? "emoji", count })),
discordUrl: `https://discord.com/channels/${guildId}/${threadId}/${message.id}`,
};
}
return {
async detail(id: string): Promise<SuggestionDetail> {
const { thread, forum } = await getThread(id);
const message = await get<Message>(`/channels/${id}/messages/${id}`).catch((error: unknown) => {
if (error instanceof SuggestionsError && error.status === 404) return null;
throw error;
});
return { ...summary(thread, forum), originalPost: message ? messageView(message, id) : null };
},
async messages(id: string, query: { cursor?: string; limit?: number } = {}): Promise<MessagePage> {
const limit = limitValue(query.limit);
if (query.cursor) checkId(query.cursor);
await getThread(id);
const before = query.cursor ? `&before=${query.cursor}` : "";
const messages = await get<Message[]>(`/channels/${id}/messages?limit=${limit}${before}`);
return { items: messages.map((message) => messageView(message, id)), nextCursor: messages.length === limit ? messages.at(-1)!.id : null };
},
async list(query: ListQuery = {}): Promise<SuggestionPage> {
const limit = limitValue(query.limit);
const status = query.status ?? "active";
if (status !== "active" && status !== "archived") throw new SuggestionsError(400, "invalid-request", "Status must be active or archived.");
if (query.cursor) {
if (status === "active") checkId(query.cursor);
else if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/.test(query.cursor) || !Number.isFinite(Date.parse(query.cursor))) {
throw new SuggestionsError(400, "invalid-request", "The archive cursor must be a UTC timestamp.");
}
}
const forum = await getForum();
if (status === "archived") {
const before = query.cursor ? `&before=${encodeURIComponent(query.cursor)}` : "";
const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${limit}${before}`);
return {
items: data.threads.filter(belongs).map((thread) => summary(thread, forum)),
nextCursor: data.has_more && data.threads.length ? data.threads.at(-1)!.thread_metadata.archive_timestamp.replace(/\.000Z$/, "Z") : null,
};
}
const data = await get<{ threads: Thread[] }>(`/guilds/${guildId}/threads/active`);
const threads = data.threads.filter(belongs).sort((a, b) => BigInt(a.id) > BigInt(b.id) ? -1 : 1)
.filter((thread) => !query.cursor || BigInt(thread.id) < BigInt(query.cursor));
const page = threads.slice(0, limit);
return { items: page.map((thread) => summary(thread, forum)), nextCursor: threads.length > limit ? page.at(-1)!.id : null };
},
};
}
@@ -0,0 +1,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,
};
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { adminGroupReturnPath, editableGroupName, effectiveGroupMemberCount, isEffectiveGroupMember, groupSlug, validateGroupDetails } from "./group-management";
describe("group management", () => {
it("generates a collision-safe internal slug from the display name", () => {
expect(groupSlug(" Trusted Öps Team! ", new Set(["trusted-ops-team", "trusted-ops-team-2"])))
.toBe("trusted-ops-team-3");
expect(groupSlug("🔥", new Set())).toBe("group");
});
it("allows only local Users and group-detail return paths", () => {
expect(adminGroupReturnPath("/admin/users?q=alex", "saved=group")).toBe("/admin/users?q=alex&saved=group");
expect(adminGroupReturnPath("/admin/groups/11111111-1111-4111-8111-111111111111", "saved=group"))
.toBe("/admin/groups/11111111-1111-4111-8111-111111111111?saved=group");
expect(adminGroupReturnPath("https://evil.example/admin/users", "saved=group")).toBe("/admin/users?saved=group");
expect(adminGroupReturnPath("/admin/settings", "error=invalid-group-assignment")).toBe("/admin/users?error=invalid-group-assignment");
});
it("counts and filters explicit and default effective memberships", () => {
const assignments = { one: "ops", two: "builders" };
expect(effectiveGroupMemberCount(4, Object.values(assignments), { id: "everyone", isDefault: true })).toBe(2);
expect(effectiveGroupMemberCount(4, Object.values(assignments), { id: "ops", isDefault: false })).toBe(1);
expect(isEffectiveGroupMember("three", assignments, { id: "everyone", isDefault: true })).toBe(true);
expect(isEffectiveGroupMember("one", assignments, { id: "ops", isDefault: false })).toBe(true);
expect(isEffectiveGroupMember("two", assignments, { id: "ops", isDefault: false })).toBe(false);
});
it("keeps the protected default group name fixed", () => {
expect(editableGroupName("everyone", true, "Renamed")).toBe("everyone");
expect(editableGroupName("Ops", false, "Trusted hosts")).toBe("Trusted hosts");
});
it("validates and normalizes editable group details", () => {
expect(validateGroupDetails(" Trusted hosts ", " Can use managed VPNs. ")).toEqual({
name: "Trusted hosts",
description: "Can use managed VPNs.",
});
expect(validateGroupDetails("", "description")).toBeNull();
expect(validateGroupDetails("bad\nname", "description")).toBeNull();
expect(validateGroupDetails("Valid", "x".repeat(501))).toBeNull();
});
});
+75
View File
@@ -0,0 +1,75 @@
const NAME_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
const TEXT_CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
export function validateGroupDetails(nameValue: unknown, descriptionValue: unknown) {
const name = String(nameValue ?? "").trim();
const description = String(descriptionValue ?? "").trim();
if (
name.length < 1 ||
name.length > 50 ||
NAME_CONTROL_CHARACTERS.test(name) ||
description.length > 500 ||
TEXT_CONTROL_CHARACTERS.test(description)
) return null;
return { name, description };
}
export function adminGroupReturnPath(
value: unknown,
result: "saved=group" | "error=invalid-group-assignment",
) {
const requested = String(value ?? "");
let pathname = "/admin/users";
const parameters = new URLSearchParams();
if (requested.startsWith("/")) {
const url = new URL(requested, "http://internal");
if (url.pathname === "/admin/users") {
const search = url.searchParams.get("q")?.trim().slice(0, 100);
if (search) parameters.set("q", search);
} else if (/^\/admin\/groups\/[0-9a-f-]{36}$/i.test(url.pathname)) {
pathname = url.pathname;
}
}
const [key, resultValue] = result.split("=", 2) as ["saved" | "error", string];
parameters.set(key, resultValue);
return `${pathname}?${parameters.toString()}`;
}
export function editableGroupName(currentName: string, isDefault: boolean, requestedName: string) {
return isDefault ? currentName : requestedName;
}
export function effectiveGroupMemberCount(
totalUsers: number,
assignedGroupIds: string[],
group: { id: string; isDefault: boolean },
) {
return group.isDefault
? Math.max(0, totalUsers - assignedGroupIds.length)
: assignedGroupIds.filter((groupId) => groupId === group.id).length;
}
export function isEffectiveGroupMember(
userId: string,
assignmentByUser: Record<string, string>,
group: { id: string; isDefault: boolean },
) {
return group.isDefault ? !assignmentByUser[userId] : assignmentByUser[userId] === group.id;
}
export function groupSlug(name: string, existingSlugs: Set<string>) {
const normalized = name
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "group";
const base = normalized.slice(0, 50).replace(/-+$/g, "") || "group";
if (!existingSlugs.has(base)) return base;
for (let suffix = 2; suffix < 10_000; suffix += 1) {
const suffixText = `-${suffix}`;
const candidate = `${base.slice(0, 50 - suffixText.length).replace(/-+$/g, "")}${suffixText}`;
if (!existingSlugs.has(candidate)) return candidate;
}
throw new Error("Could not generate a unique group slug");
}
+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`;
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { buildRconCommandHistory, normalizeRconHistoryFilters } from "./rcon-command-history";
const correlationId = "11111111-1111-4111-8111-111111111111";
function event(overrides: Record<string, unknown> = {}) {
return {
id: "22222222-2222-4222-8222-222222222222",
time: new Date("2026-08-14T01:00:00Z"),
correlationId,
data: {
command: "say hello operators",
serverId: "33333333-3333-4333-8333-333333333333",
name: "Season 4",
adminEmail: "admin@example.test",
adminName: "Admin",
},
...overrides,
};
}
describe("RCON command history", () => {
it("normalizes bounded search filters and accepts only known servers", () => {
expect(normalizeRconHistoryFilters({
command: [" say hello ", "ignored"],
admin: " admin@example.test ",
server: "33333333-3333-4333-8333-333333333333",
}, ["33333333-3333-4333-8333-333333333333"])).toEqual({
command: "say hello",
admin: "admin@example.test",
serverId: "33333333-3333-4333-8333-333333333333",
});
expect(normalizeRconHistoryFilters({ server: "unknown" }, [])).toEqual({
command: "",
admin: "",
serverId: "",
});
});
it("pairs requested commands with their completion outcome without exposing responses", () => {
const requested = event();
const completed = event({
id: "44444444-4444-4444-8444-444444444444",
data: { success: false, reason: "timeout", durationMs: 5001 },
});
expect(buildRconCommandHistory([requested], [completed])).toEqual([{
eventId: requested.id,
time: requested.time,
command: "say hello operators",
serverId: "33333333-3333-4333-8333-333333333333",
serverName: "Season 4",
adminEmail: "admin@example.test",
adminName: "Admin",
status: "failed",
reason: "timeout",
durationMs: 5001,
}]);
expect(JSON.stringify(buildRconCommandHistory([requested], [completed]))).not.toContain("response");
});
it("marks a requested command pending when no completion event exists", () => {
expect(buildRconCommandHistory([event()], [event({ correlationId: null })])[0]?.status).toBe("pending");
});
});
+74
View File
@@ -0,0 +1,74 @@
export const RCON_COMMAND_REQUESTED = "games.minecraft.account-manager.rcon.command.requested";
export const RCON_COMMAND_COMPLETED = "games.minecraft.account-manager.rcon.command.completed";
export type RconHistoryEvent = {
id: string;
time: Date;
correlationId: string | null;
data: Record<string, unknown>;
};
export type RconCommandHistoryRow = {
eventId: string;
time: Date;
command: string;
serverId: string;
serverName: string;
adminEmail: string | null;
adminName: string | null;
status: "pending" | "succeeded" | "failed";
reason: string | null;
durationMs: number | null;
};
type SearchParams = Record<string, string | string[] | undefined>;
function first(value: string | string[] | undefined) {
return (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
}
function text(data: Record<string, unknown>, key: string) {
const value = data[key];
return typeof value === "string" && value ? value : null;
}
export function normalizeRconHistoryFilters(query: SearchParams, availableServerIds: string[]) {
const requestedServerId = first(query.server);
return {
command: first(query.command).slice(0, 1024),
admin: first(query.admin).slice(0, 320),
serverId: availableServerIds.includes(requestedServerId) ? requestedServerId : "",
};
}
export function buildRconCommandHistory(
requestedEvents: RconHistoryEvent[],
completedEvents: RconHistoryEvent[],
): RconCommandHistoryRow[] {
const completions = new Map(completedEvents
.filter((event) => event.correlationId)
.map((event) => [event.correlationId, event]));
return requestedEvents.flatMap((event) => {
const command = text(event.data, "command");
const serverId = text(event.data, "serverId");
const serverName = text(event.data, "name");
if (!command || !serverId || !serverName) return [];
const completed = event.correlationId ? completions.get(event.correlationId) : undefined;
const success = completed?.data.success;
const duration = completed?.data.durationMs;
return [{
eventId: event.id,
time: event.time,
command,
serverId,
serverName,
adminEmail: text(event.data, "adminEmail"),
adminName: text(event.data, "adminName"),
status: success === true ? "succeeded" as const : success === false ? "failed" as const : "pending" as const,
reason: completed ? text(completed.data, "reason") : null,
durationMs: typeof duration === "number" && Number.isFinite(duration) ? duration : null,
}];
});
}
+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
-38
View File
@@ -1,38 +0,0 @@
---
okf_version: "0.1"
---
# Minecraft Account Manager User Stories
This OKF bundle is the product record for implemented and proposed behavior. Story status and acceptance criteria are maintained alongside code changes.
## Player Experience
* [US-001 — Enter through Discord](us-001-discord-entry.md) - Direct portal visitors are guided to the configured Discord server.
* [US-002 — Authenticate with a Discord magic link](us-002-discord-magic-link.md) - Discord users receive secure, private, single-use portal links.
* [US-003 — Complete first-time onboarding](us-003-onboarding.md) - New users provide a name, connect a Java account, and confirm Discord identity.
* [US-004 — Validate Minecraft accounts](us-004-minecraft-validation.md) - Java usernames resolve through Mojang with explicit unverified overrides.
* [US-005 — Manage linked accounts](us-005-user-dashboard.md) - Users manage names, accounts, primaries, and security history.
* [US-006 — Keep Discord nicknames synchronized](us-006-discord-nickname.md) - Names and primary accounts determine the guild nickname.
## Network and Game Access
* [US-007 — Enrich login IPs](us-007-ip-intelligence.md) - Portal and game login events include cached ProxyCheck location and network data.
* [US-008 — Block anonymized account additions](us-008-vpn-blocking.md) - VPN, proxy, Tor, and unknown networks cannot add accounts.
* [US-009 — Enforce registration at Velocity](us-009-velocity-admission.md) - The proxy admits positively identified registered Java accounts only.
## Administration and Governance
* [US-010 — Preserve an audit trail](us-010-audit-events.md) - Security and account activity is stored as CloudEvents-style events.
* [US-011 — Authenticate administrators with SSO](us-011-admin-sso.md) - Keycloak and a required role protect the operator console.
* [US-012 — Operate settings and audit views](us-012-admin-operations.md) - Administrators configure denial messaging and inspect events.
* [US-013 — Manage users as an administrator](us-013-admin-user-management.md) - Administrators search users and manage names and Minecraft accounts.
* [US-014 — Receive standardized API errors](us-014-problem-details.md) - Application APIs return RFC 9457 Problem Details.
* [US-015 — Deploy and operate securely](us-015-platform-operations.md) - Operators have reproducible builds, migrations, credentials, and security controls.
* [US-016 — Build and publish versioned releases](us-016-automated-releases.md) - Gitea Actions publish the Velocity JAR and web and migration images.
* [US-017 — Control admission with groups](us-017-group-access.md) - Each user has one effective group that explicitly controls Minecraft access.
* [US-018 — Monitor community account activity](us-018-admin-dashboard.md) - Administrators review daily users, confirmed connections, locations, denials, and risky networks.
# Tracking
See the [design update log](log.md) for high-level changes. New work starts by creating or updating a story and its acceptance criteria.
-30
View File
@@ -1,30 +0,0 @@
# Design Update Log
## 2026-08-02
* **Add**: Provide Users-page group assignment, effective-group VPN/proxy/Tor exceptions for game admission, and independent configurable denial messages.
* **Fix**: Treat malformed ProxyCheck proxy signals as unknown and classify every authenticated Velocity login before identity resolution.
* **Fix**: Replace the dashboard's pre-enrichment network label with enriched company, ASN, connection type, Proxy/VPN status, and risk fields.
* **Fix**: Group collocated map users into count-badged markers with complete nickname tooltips and per-user interactive-map links.
* **Refine**: Replace registration counts with daily active users, collapse enriched VPN activity per user, add opt-in OpenStreetMap zoom, show managed nickname tooltips, and measure active Minecraft accounts from confirmed Velocity connections.
* **Governance**: Require user review and explicit confirmation of relevant OKF story changes before future implementation work.
## 2026-08-01
* **Extend**: Plot each user's latest approximate location on an accessible, server-rendered Natural Earth world map in the operations dashboard.
* **Refine**: Make group assignment exclusive with default fallback, add group deletion, automatically synchronize Discord nicknames with status notices, expose filterable event details, add an SSR operations dashboard, and improve accessibility.
* **Extend**: Add SoMC Portal branding, live Discord identity details, admin guild configuration visibility, and fail-closed group-based Minecraft admission.
* **Refine**: Group repeated access networks, confirm linked Discord nickname changes before mutation, and add DMG Games sponsorship attribution.
* **Extend**: Add shared Pino logging with credential redaction and actionable web and Discord runtime diagnostics.
* **Fix**: Build magic-link redirects from the configured public portal URL instead of the reverse proxy's internal request origin.
* **Verify**: Confirmed `v1.1.1` left all pre-existing `latest` digests unchanged while publishing versioned artifacts.
* **Refine**: Removed mutable `latest` publication so all deployable artifacts use explicit semantic versions.
* **Verify**: Confirmed the `v1.1.0` Discord bot image and matching web, migration, and Velocity artifacts.
* **Extend**: Added a releasable Discord bot image and a dependency-free web health endpoint for Kubernetes deployment.
* **Verify**: Confirmed the initial `v1.0.0` release, public Velocity JAR, and versioned and `latest` web and migration image manifests.
* **Create**: Added Gitea CI and semantic-release pipelines for downloadable Velocity JARs and versioned web and migration images.
* **Document**: Added container deployment order, artifact names, and required repository secrets.
* **Refine**: Corrected the Velocity Java and Gradle namespace to the repository owner's `games.dmg` reverse domain.
* **Create**: Established the OKF v0.1 [user-story index](index.md).
* **Document**: Captured the implemented player portal, Discord authentication, onboarding, account management, network intelligence, Velocity admission, auditing, administration, API error, and operational stories.
* **Governance**: Added repository agent guidance and automated OKF validation for story-driven development.
-39
View File
@@ -1,39 +0,0 @@
---
type: User Story
title: Enter the account portal through Discord
description: Direct visitors are guided to the configured Discord community and its account commands.
tags: [player, portal, discord, onboarding]
timestamp: 2026-08-01T22:34:31Z
story_id: US-001
status: verified
---
# User Story
As a prospective player, I want the portal to direct me to the community Discord, so that I can begin registration through the trusted entry point.
# Acceptance Criteria
- [x] Given an unauthenticated visitor, when they open the portal, then they are told to run `/register` or `/account` in Discord.
- [x] Given a configured invite URL, when the visitor selects the join action, then the Discord invite opens in a new browser context.
- [x] Given a configured guild ID, when the visitor selects the app action, then a `discord://` guild link is opened.
- [x] Given an unauthenticated protected-page request, when authorization fails, then the visitor returns to the portal with prominent Discord instructions.
- [x] Every portal page credits Social Minecraft sponsorship by DMG Games and links to `https://dmg.games`.
- [x] Portal branding uses the SoMC Portal name and a dedicated favicon.
# Implementation
- [`apps/web/src/app/page.tsx`](../apps/web/src/app/page.tsx)
- [`apps/web/src/lib/auth/user-session.ts`](../apps/web/src/lib/auth/user-session.ts)
- [`apps/web/src/components/site-footer.tsx`](../apps/web/src/components/site-footer.tsx)
- [`apps/web/src/app/icon.svg`](../apps/web/src/app/icon.svg)
- Configuration: `DISCORD_GUILD_ID`, `DISCORD_INVITE_URL`
# Validation
Covered by the Next.js production build and protected-route session checks.
# Related Stories
- [Discord magic-link authentication](us-002-discord-magic-link.md)
- [First-time onboarding](us-003-onboarding.md)
-42
View File
@@ -1,42 +0,0 @@
---
type: User Story
title: Authenticate with a Discord magic link
description: Discord users receive private single-use links that establish secure portal sessions.
tags: [player, discord, authentication, security]
timestamp: 2026-08-01T20:43:46Z
story_id: US-002
status: verified
---
# User Story
As a Discord community member, I want `/register` and `/account` to issue a private sign-in link, so that I can access the portal without creating another password.
# Acceptance Criteria
- [x] Given the configured guild, when a user runs `/register` or `/account`, then the bot responds ephemerally with a private link.
- [x] Given a generated link, then the raw login token is never stored in PostgreSQL.
- [x] Given a login token, then it expires after ten minutes and can be consumed only once.
- [x] Given repeated link requests, then requests are rate limited per Discord user and older active links are invalidated.
- [x] Given a valid link, when it is consumed, then the Discord user is created or refreshed and a secure seven-day session is established.
- [x] Given a magic-link result behind a reverse proxy, then the browser is redirected through the configured public application URL rather than an internal container address.
- [x] Given an invalid, expired, or consumed link, then the user sees a safe recovery page instructing them to request another link.
# Implementation
- [`apps/discord-bot/src/index.ts`](../apps/discord-bot/src/index.ts)
- [`packages/auth/src/index.ts`](../packages/auth/src/index.ts)
- [`packages/database/src/auth-repository.ts`](../packages/database/src/auth-repository.ts)
- [`apps/web/src/app/auth/discord/route.ts`](../apps/web/src/app/auth/discord/route.ts)
- [`apps/web/src/lib/application-url.ts`](../apps/web/src/lib/application-url.ts)
# Validation
- [`packages/auth/test/magic-link.test.ts`](../packages/auth/test/magic-link.test.ts)
- [`apps/web/src/lib/application-url.test.ts`](../apps/web/src/lib/application-url.test.ts)
- Discord command and authentication workspaces pass TypeScript validation.
# Related Stories
- [Enter through Discord](us-001-discord-entry.md)
- [Preserve an audit trail](us-010-audit-events.md)
-39
View File
@@ -1,39 +0,0 @@
---
type: User Story
title: Complete first-time onboarding
description: New users establish their preferred identity and first Minecraft account.
tags: [player, onboarding, minecraft, discord]
timestamp: 2026-08-01T18:43:58Z
story_id: US-003
status: verified
---
# User Story
As a newly authenticated player, I want a guided setup flow, so that my preferred name, Minecraft identity, and Discord nickname are configured correctly.
# Acceptance Criteria
- [x] Given a new Discord user, when they enter the portal, then they receive a personalized welcome.
- [x] Given the first onboarding step, when the user enters a valid preferred name, then it is stored for their profile.
- [x] Given the Minecraft step, when a valid Java username is submitted from an allowed network, then it is verified and added as primary.
- [x] Given an unverifiable but syntactically valid username, then the user must explicitly confirm before continuing.
- [x] Given a name and primary account, then the expected Discord nickname is previewed before any guild update.
- [x] Given confirmation and a successful Discord update, then onboarding is marked complete and the dashboard opens.
# Implementation
- [`apps/web/src/app/welcome/page.tsx`](../apps/web/src/app/welcome/page.tsx)
- [`apps/web/src/app/welcome/minecraft/page.tsx`](../apps/web/src/app/welcome/minecraft/page.tsx)
- [`apps/web/src/app/welcome/discord/page.tsx`](../apps/web/src/app/welcome/discord/page.tsx)
- [`apps/web/src/app/welcome/actions.ts`](../apps/web/src/app/welcome/actions.ts)
# Validation
Onboarding routes are protected by database-backed sessions and included in production route generation.
# Related Stories
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
- [Block anonymized account additions](us-008-vpn-blocking.md)
-38
View File
@@ -1,38 +0,0 @@
---
type: User Story
title: Validate Minecraft accounts
description: Java Edition usernames resolve to canonical Mojang identities with controlled override behavior.
tags: [player, minecraft, mojang, identity]
timestamp: 2026-08-01T18:43:58Z
story_id: US-004
status: verified
---
# User Story
As a player, I want submitted Minecraft usernames checked against Mojang, so that the server can identify my online-mode Java account reliably.
# Acceptance Criteria
- [x] Given a syntactically valid username, when it is submitted, then validation occurs server-side against the fixed Mojang endpoint.
- [x] Given a Mojang match, then the canonical username and compact UUID are stored.
- [x] Given no Mojang match, then the user or administrator must explicitly confirm an unverified override.
- [x] Given malformed input, then it cannot be stored even through an override.
- [x] Given an active UUID or case-insensitive username already registered, then another active registration is rejected.
- [x] Given a later online-mode game login for an unverified account, then its UUID can be safely backfilled after username matching.
# Implementation
- [`packages/minecraft/src/index.ts`](../packages/minecraft/src/index.ts)
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
- User and administrator account actions under [`apps/web/src/app`](../apps/web/src/app)
# Validation
- [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts)
- Database partial unique indexes preserve active identity invariants.
# Related Stories
- [First-time onboarding](us-003-onboarding.md)
- [Velocity game admission](us-009-velocity-admission.md)
-42
View File
@@ -1,42 +0,0 @@
---
type: User Story
title: Manage linked accounts from the dashboard
description: Authenticated users maintain their profile and active Java Edition accounts.
tags: [player, dashboard, minecraft, profile]
timestamp: 2026-08-01T23:10:59Z
story_id: US-005
status: verified
---
# User Story
As a registered player, I want to manage my profile and linked Minecraft accounts, so that my whitelist identity remains current.
# Acceptance Criteria
- [x] Given an authenticated user, then only their own profile, accounts, and IP observations are visible and mutable.
- [x] The user can update their preferred name.
- [x] The user can add Mojang-verified or explicitly confirmed accounts from an allowed network.
- [x] The user can soft-remove an active account.
- [x] The user can choose exactly one active primary account.
- [x] Removing a primary account promotes another active account when one exists.
- [x] Name, primary, and account-removal changes automatically synchronize the expected Discord nickname and report the result.
- [x] The dashboard shows recent portal and game IP observations with classification and available location.
- [x] The dashboard shows the user's Discord display name, username, guild nickname, and immutable Discord ID.
- [x] The dashboard shows the single effective access group and whether it grants Minecraft access.
- [x] The user can revoke the current session by signing out.
# Implementation
- [`apps/web/src/app/account/page.tsx`](../apps/web/src/app/account/page.tsx)
- [`apps/web/src/app/account/actions.ts`](../apps/web/src/app/account/actions.ts)
- [`apps/web/src/app/auth/actions.ts`](../apps/web/src/app/auth/actions.ts)
# Validation
Server actions verify the current session and constrain every account lookup by the authenticated user ID. Nickname result announcements are covered by [`apps/web/src/components/nickname-notice.test.tsx`](../apps/web/src/components/nickname-notice.test.tsx).
# Related Stories
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
- [Enrich login IPs](us-007-ip-intelligence.md)
-41
View File
@@ -1,41 +0,0 @@
---
type: User Story
title: Keep Discord nicknames synchronized
description: Preferred names and primary Minecraft usernames determine community guild nicknames.
tags: [player, admin, discord, identity]
timestamp: 2026-08-01T23:10:59Z
story_id: US-006
status: verified
---
# User Story
As a community member, I want my Discord nickname to reflect my preferred name and primary Minecraft account, so that other players can identify me consistently.
# Acceptance Criteria
- [x] Given a preferred name and primary account, then the nickname format is `First name (MinecraftUsername)`.
- [x] Given Discord's 32-character limit, then the preferred-name portion is shortened while preserving the Minecraft username.
- [x] Given no remaining Minecraft account, then synchronization uses `First name (TBD)`.
- [x] User name, first-account, primary, and account-removal changes synchronize the nickname automatically without a second confirmation step.
- [x] Successful synchronization shows the exact new nickname in a dismissible status notice.
- [x] Discord failures show an assertive error notice without falsely claiming synchronization completed.
- [x] Administrator name, primary, and primary-removal operations synchronize the nickname automatically.
- [x] A protected administrative retry action can synchronize the current desired nickname.
# Implementation
- [`packages/minecraft/src/index.ts`](../packages/minecraft/src/index.ts)
- [`apps/web/src/app/account/actions.ts`](../apps/web/src/app/account/actions.ts)
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
- [`apps/web/src/components/nickname-notice.tsx`](../apps/web/src/components/nickname-notice.tsx)
# Validation
- [`packages/minecraft/test/discord.test.ts`](../packages/minecraft/test/discord.test.ts)
- Nickname length and fallback behavior are covered in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts).
# Related Stories
- [Manage linked accounts](us-005-user-dashboard.md)
- [Administer users](us-013-admin-user-management.md)
-44
View File
@@ -1,44 +0,0 @@
---
type: User Story
title: Enrich portal and game login IPs
description: Login audit events include cached approximate location and network intelligence from ProxyCheck.io.
tags: [security, network, audit, proxycheck]
timestamp: 2026-08-02T14:12:43Z
story_id: US-007
status: verified
---
# User Story
As an operator, I want portal and registered game logins enriched with network context, so that suspicious access can be investigated.
# Acceptance Criteria
- [x] Given a public login IP, then ProxyCheck can provide city, region, country, coordinates, timezone, ASN, provider, risk, and anonymity classification.
- [x] Results are cached in PostgreSQL for 48 hours by default.
- [x] Provider failures are cached briefly and do not deny portal or registered game login.
- [x] Private, loopback, reserved, documentation, and mapped-private addresses are never sent to ProxyCheck.
- [x] Forwarded web IP headers are ignored unless trusted-proxy handling is explicitly enabled.
- [x] Every authenticated Velocity login request uses the cached ProxyCheck path before identity resolution, preventing account-creation races from bypassing network policy.
- [x] Login events and IP observations retain the available classification and approximate location.
- [x] Users and administrators can see available location and classification in audit views.
- [x] Repeated access observations are summarized by IPv4 /24 or IPv6 /64 network with counts, sources, and latest activity.
# Implementation
- [`packages/network/src/index.ts`](../packages/network/src/index.ts)
- [`apps/web/src/lib/ip-intelligence.ts`](../apps/web/src/lib/ip-intelligence.ts)
- [`apps/web/src/app/auth/discord/route.ts`](../apps/web/src/app/auth/discord/route.ts)
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
# Validation
- [`packages/network/test/proxycheck.test.ts`](../packages/network/test/proxycheck.test.ts)
- [`packages/network/test/client-ip.test.ts`](../packages/network/test/client-ip.test.ts)
- [`packages/network/test/address-groups.test.ts`](../packages/network/test/address-groups.test.ts)
- [`apps/web/src/lib/access-address-groups.test.ts`](../apps/web/src/lib/access-address-groups.test.ts)
# Related Stories
- [Block anonymized additions](us-008-vpn-blocking.md)
- [Preserve an audit trail](us-010-audit-events.md)
-42
View File
@@ -1,42 +0,0 @@
---
type: User Story
title: Block account additions from anonymized networks
description: User Minecraft-account additions fail closed for VPN, proxy, Tor, or unknown IP classifications.
tags: [security, vpn, proxy, minecraft]
timestamp: 2026-08-02T14:12:43Z
story_id: US-008
status: verified
---
# User Story
As an operator, I want account additions blocked from anonymized networks, so that whitelist identities are established from attributable connections.
# Acceptance Criteria
- [x] VPN, proxy, and Tor classifications block user account additions.
- [x] Unknown or unavailable classification blocks additions rather than failing open.
- [x] Hosting-provider ranges can be blocked through deployment configuration.
- [x] Normal portal use and game login are not denied solely because intelligence is unavailable.
- [x] Blocked users receive a clear recovery message without provider internals.
- [x] Blocked and classification-unavailable attempts create distinct audit events with safe intelligence details.
- [x] Administrative account additions remain available as an authorized recovery path.
- [x] Administrators see enriched risky-network observations collapsed to one latest summary per user.
- [x] Game admission enforces confirmed VPN, proxy, and Tor classifications according to the user's effective-group exception policy.
- [x] Account-addition blocking remains unchanged and independent from the game-admission exception.
# Implementation
- [`apps/web/src/lib/ip-intelligence.ts`](../apps/web/src/lib/ip-intelligence.ts)
- [`apps/web/src/app/welcome/actions.ts`](../apps/web/src/app/welcome/actions.ts)
- [`apps/web/src/app/account/actions.ts`](../apps/web/src/app/account/actions.ts)
- Configuration: `PROXYCHECK_API_KEY`, `BLOCK_HOSTING_IPS`, `TRUST_PROXY`
# Validation
The fail-closed classification policy and provider mappings are covered by [`packages/network/test/proxycheck.test.ts`](../packages/network/test/proxycheck.test.ts).
# Related Stories
- [Enrich login IPs](us-007-ip-intelligence.md)
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
-51
View File
@@ -1,51 +0,0 @@
---
type: User Story
title: Enforce registration at the Velocity proxy
description: Online-mode Java connections are admitted only after a fail-closed account-manager decision.
tags: [minecraft, velocity, whitelist, security]
timestamp: 2026-08-02T14:12:43Z
story_id: US-009
status: verified
---
# User Story
As a registered player, I want the Velocity proxy to recognize my approved Java account, so that I can join while unknown identities are rejected.
# Acceptance Criteria
- [x] The plugin sends request ID, server ID, online-mode UUID, username, IP, and occurrence time.
- [x] Every request uses a high-entropy per-server bearer credential stored only as a hash by the service.
- [x] Requests outside the 45-second clock window are rejected.
- [x] Database-unique request IDs reject cross-instance replay attempts.
- [x] UUID matching is attempted before username fallback.
- [x] Username fallback applies only when the stored account has no UUID.
- [x] Successful fallback backfills UUID and canonical username.
- [x] Changed usernames are persisted and audited.
- [x] Registered players are allowed only when their single effective group has access enabled; explicit assignments override the default group.
- [x] 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] The default anonymized-network message directs the player to contact a host for an exception.
- [x] API failures, malformed responses, and unauthorized requests retain fail-closed plugin fallback behavior.
# Implementation
- [`plugins/velocity`](../plugins/velocity)
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
- [`apps/web/src/app/api/velocity/connection/route.ts`](../apps/web/src/app/api/velocity/connection/route.ts)
- [`packages/contracts/src/index.ts`](../packages/contracts/src/index.ts)
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
# Validation
- [`plugins/velocity/src/test/java/games/dmg/accountmanager/AccountManagerClientTest.java`](../plugins/velocity/src/test/java/games/dmg/accountmanager/AccountManagerClientTest.java)
- Shared request and response contracts are covered by [`packages/contracts/test/contracts.test.ts`](../packages/contracts/test/contracts.test.ts).
# Related Stories
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
- [Standardize API errors](us-014-problem-details.md)
- [Control Minecraft admission with groups](us-017-group-access.md)
-41
View File
@@ -1,41 +0,0 @@
---
type: User Story
title: Preserve a CloudEvents-style audit trail
description: Authentication, UI, account, Discord, network, and game actions create searchable immutable-style events.
tags: [audit, cloudevents, security, events]
timestamp: 2026-08-02T00:12:32Z
story_id: US-010
status: verified
---
# User Story
As an operator, I want security and identity activity recorded consistently, so that incidents and account changes can be reconstructed and later published to Kafka.
# Acceptance Criteria
- [x] Events preserve CloudEvents-style ID, specification version, source, type, subject, time, content type, and JSON data.
- [x] Events can include user actor, IP address, and correlation ID.
- [x] Portal access, magic-link creation and consumption, account changes, nickname changes, VPN blocks, game decisions, and confirmed proxy connections are recorded.
- [x] Username changes learned from Velocity create their own event.
- [x] Administrative actions include the acting SSO identity in event data.
- [x] Events can be filtered by operator-friendly view and selected event types globally and from an individual user view.
- [x] Every listed event links to a detail page showing its complete CloudEvents envelope and formatted JSON data.
- [x] `published_at` reserves an outbox path for future Kafka publishing.
# Implementation
- [`packages/database/src/events.ts`](../packages/database/src/events.ts)
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
- [`apps/web/src/lib/audit.ts`](../apps/web/src/lib/audit.ts)
- [`apps/web/src/app/admin/(console)/events/page.tsx`](../apps/web/src/app/admin/%28console%29/events/page.tsx)
- [`apps/web/src/app/admin/(console)/events/[eventId]/page.tsx`](../apps/web/src/app/admin/%28console%29/events/%5BeventId%5D/page.tsx)
# Validation
The shared CloudEvent contract is covered by [`packages/contracts/test/contracts.test.ts`](../packages/contracts/test/contracts.test.ts), and event-producing routes pass full type and production-build validation.
# Related Stories
- [Enrich login IPs](us-007-ip-intelligence.md)
- [Administer users](us-013-admin-user-management.md)
-39
View File
@@ -1,39 +0,0 @@
---
type: User Story
title: Authenticate administrators with Keycloak SSO
description: The operator console requires a Keycloak identity with the configured administrator role.
tags: [admin, keycloak, oidc, authentication]
timestamp: 2026-08-01T18:43:58Z
story_id: US-011
status: verified
---
# User Story
As an administrator, I want to authenticate through organizational SSO, so that privileged operations use centrally managed identities and roles.
# Acceptance Criteria
- [x] Admin authentication uses Keycloak OpenID Connect authorization code flow.
- [x] Sign-in is denied when the configured required role is absent.
- [x] Realm and configured-client roles are extracted from fresh Keycloak tokens.
- [x] Admin console layouts redirect unauthenticated or unauthorized users to the admin login page.
- [x] Every privileged server action independently rechecks the admin session and role.
- [x] Admin sessions use signed JWT behavior managed by NextAuth.
- [x] Administrators can sign out and return to the restricted login page.
# Implementation
- [`apps/web/src/lib/auth/admin-auth.ts`](../apps/web/src/lib/auth/admin-auth.ts)
- [`apps/web/src/lib/auth/require-admin.ts`](../apps/web/src/lib/auth/require-admin.ts)
- [`apps/web/src/app/admin`](../apps/web/src/app/admin)
- [`docs/admin-oidc-keycloak-setup.md`](../docs/admin-oidc-keycloak-setup.md)
# Validation
OIDC role extraction is covered by [`packages/auth/test/oidc-roles.test.ts`](../packages/auth/test/oidc-roles.test.ts).
# Related Stories
- [Operate settings and audit views](us-012-admin-operations.md)
- [Administer users](us-013-admin-user-management.md)
-43
View File
@@ -1,43 +0,0 @@
---
type: User Story
title: Operate settings and audit views
description: Authorized administrators control server messaging and investigate recent platform events.
tags: [admin, settings, audit, operations]
timestamp: 2026-08-02T14:12:43Z
story_id: US-012
status: verified
---
# User Story
As an administrator, I want operational settings and audit visibility, so that I can manage player guidance and investigate activity.
# Acceptance Criteria
- [x] The admin console shows the deployment-managed Discord guild ID and linked invite URL.
- [x] An authorized administrator can update the denied-player registration message.
- [x] Settings actions validate message length server-side.
- [x] Administrators can browse the latest 100 events.
- [x] Event views show type, subject, IP, classification, and approximate location when available.
- [x] Admin console access itself creates an audit event with the SSO identity.
- [x] Settings, users, and events are linked from the shared admin navigation.
- [x] Administrators can independently configure registration-required, group-access-disabled, and VPN/proxy/Tor-denied game messages.
- [x] Every message is validated server-side and has a safe default.
- [x] Admission-message changes are audited with the administrator identity without logging credentials.
# Implementation
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
- [`apps/web/src/app/admin/(console)/actions.ts`](../apps/web/src/app/admin/%28console%29/actions.ts)
- [`apps/web/src/lib/admission-settings.ts`](../apps/web/src/lib/admission-settings.ts)
- [`packages/database/drizzle/0004_zippy_silver_centurion.sql`](../packages/database/drizzle/0004_zippy_silver_centurion.sql)
- [`apps/web/src/app/admin/(console)/events/page.tsx`](../apps/web/src/app/admin/%28console%29/events/page.tsx)
# Validation
Admin routes are dynamic, role-protected, linted, and included in every production build.
# Related Stories
- [Administrator SSO](us-011-admin-sso.md)
- [Preserve an audit trail](us-010-audit-events.md)
-47
View File
@@ -1,47 +0,0 @@
---
type: User Story
title: Manage users as an administrator
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
tags: [admin, users, minecraft, discord]
timestamp: 2026-08-02T14:12:43Z
story_id: US-013
status: verified
---
# User Story
As an administrator, I want to manage a user's identity and Minecraft accounts, so that support issues can be resolved without direct database access.
# Acceptance Criteria
- [x] Administrators can search by preferred name, Discord username or ID, Minecraft username, or UUID.
- [x] Search results show onboarding state, primary username, and active account count.
- [x] A user detail view shows Discord display name, username, guild nickname, immutable ID, active accounts, groups, recent events, and recent IP observations.
- [x] Administrators can update the preferred name and synchronize Discord.
- [x] Administrators can add Mojang-verified accounts or explicitly override an unverified username.
- [x] Administrators can remove an account only after a visible confirmation step.
- [x] Removing a primary account selects a replacement or falls back to the preferred-name nickname.
- [x] Administrators can set a new primary account and automatically update Discord.
- [x] Every action rechecks role and account ownership and records the acting administrator.
- [x] Discord failures do not falsely persist the requested name, primary, or removal change.
- [x] Each row in the administrator user registry shows the user's effective group in an accessible dropdown.
- [x] Selecting a group immediately applies the assignment; selecting `everyone` removes the explicit assignment.
- [x] Group changes preserve the active user search and show accessible success or error feedback.
- [x] Registry assignment changes revalidate administrator authorization, user existence, and group existence, and audit the previous and new effective groups.
# Implementation
- [`apps/web/src/app/admin/(console)/users/page.tsx`](../apps/web/src/app/admin/%28console%29/users/page.tsx)
- [`apps/web/src/app/admin/(console)/users/[userId]/page.tsx`](../apps/web/src/app/admin/%28console%29/users/%5BuserId%5D/page.tsx)
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
- [`apps/web/src/components/user-group-select.tsx`](../apps/web/src/components/user-group-select.tsx)
# Validation
Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts). Privileged routes pass TypeScript, lint, Semgrep, and production build checks.
# Related Stories
- [Administrator SSO](us-011-admin-sso.md)
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
- [Control Minecraft admission with groups](us-017-group-access.md)
-45
View File
@@ -1,45 +0,0 @@
---
type: User Story
title: Receive standardized API errors
description: Application-owned HTTP APIs expose RFC 9457 Problem Details matching game-ingest-server conventions.
tags: [api, errors, rfc9457, contracts]
timestamp: 2026-08-01T18:43:58Z
story_id: US-014
status: verified
---
# User Story
As an API consumer, I want errors returned as standardized Problem Details, so that failures can be handled consistently across game services.
# Acceptance Criteria
- [x] Error responses use `application/problem+json`.
- [x] Responses require `type`, `title`, and `status` and optionally include `detail`, `instance`, and `extensions`.
- [x] Invalid Velocity payloads include machine-readable Zod issues under `extensions.issues`.
- [x] Missing credentials, expired requests, replays, unsupported media, unsupported methods, unknown routes, and service failures have stable `urn:error:*` types.
- [x] Unexpected application-owned Velocity errors are converted to safe `503` problems without internal details.
- [x] A normal whitelist denial remains a successful `200` authorization decision.
- [x] Browser form redirects remain accessible HTML flows and OAuth protocol responses remain owned by NextAuth.
# Implementation
- [`packages/contracts/src/index.ts`](../packages/contracts/src/index.ts)
- [`apps/web/src/lib/problem-response.ts`](../apps/web/src/lib/problem-response.ts)
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
- [`docs/api-errors.md`](../docs/api-errors.md)
# Validation
- [`packages/contracts/test/problem-details.test.ts`](../packages/contracts/test/problem-details.test.ts)
- [`apps/web/src/app/api/velocity/access/route.test.ts`](../apps/web/src/app/api/velocity/access/route.test.ts)
- Unknown-route and response-helper tests in the web workspace.
# Related Stories
- [Velocity game admission](us-009-velocity-admission.md)
- [Deploy and operate securely](us-015-platform-operations.md)
# Citations
[1] [RFC 9457 — Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457)
-50
View File
@@ -1,50 +0,0 @@
---
type: User Story
title: Deploy and operate the platform securely
description: Operators have repeatable builds, migrations, credential provisioning, configuration, and security checks.
tags: [operations, security, database, deployment]
timestamp: 2026-08-01T23:10:59Z
story_id: US-015
status: verified
---
# User Story
As a platform operator, I want reproducible deployment and security controls, so that the portal, bot, database, and proxy can be operated safely.
# Acceptance Criteria
- [x] The repository is an npm TypeScript workspace with separate web, bot, contract, database, network, and Minecraft modules.
- [x] PostgreSQL is available through Docker Compose for local use.
- [x] Drizzle changes use generated, versioned migrations rather than schema push.
- [x] Velocity credentials can be provisioned or rotated with a one-time-displayed token stored only as a hash.
- [x] The Velocity Gradle wrapper produces a tested shaded JAR.
- [x] Environment examples document database, Keycloak, Discord, trusted proxy, and ProxyCheck settings without secrets.
- [x] The web application sets CSP, framing, MIME, referrer, and permissions headers.
- [x] Database-backed user and administrator pages render as dynamic React Server Components with server-side data access.
- [x] Core pages provide keyboard focus indication, a skip link, labelled controls, table semantics, live status messaging, sufficient text contrast, and reduced-motion support.
- [x] The web runtime provides a dependency-free health endpoint for orchestration probes.
- [x] Web and Discord bot runtimes emit structured Pino logs with credential-field redaction and safe operational context.
- [x] npm dependency audit and Semgrep security review complete without findings at the last verified change.
- [x] Architecture, Keycloak, API error, security, bot, and Velocity operating documentation is available.
# Implementation
- [`package.json`](../package.json)
- [`compose.yml`](../compose.yml)
- [`packages/database/drizzle`](../packages/database/drizzle)
- [`packages/database/scripts/create-plugin-credential.ts`](../packages/database/scripts/create-plugin-credential.ts)
- [`plugins/velocity/build.gradle.kts`](../plugins/velocity/build.gradle.kts)
- [`apps/web/next.config.ts`](../apps/web/next.config.ts)
- [`packages/logging/src/index.ts`](../packages/logging/src/index.ts)
- [`docs/accessibility.md`](../docs/accessibility.md)
# Validation
Use `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, `npm run velocity:build`, `npm audit`, and `npm run design:validate`. Structured logging redaction is covered by [`packages/logging/test/logger.test.ts`](../packages/logging/test/logger.test.ts).
# Related Stories
- [Administrator SSO](us-011-admin-sso.md)
- [Standardize API errors](us-014-problem-details.md)
- [Build and publish versioned releases](us-016-automated-releases.md)
-46
View File
@@ -1,46 +0,0 @@
---
type: User Story
title: Build and publish versioned releases
description: Gitea Actions validate every change and publish semantically versioned Velocity and container artifacts.
tags: [operations, ci, release, velocity, docker]
timestamp: 2026-08-01T20:05:49Z
story_id: US-016
status: verified
---
# User Story
As a platform operator, I want automated validation and semantic releases, so that deployable web, migration, and Velocity artifacts are reproducible and downloadable.
# Acceptance Criteria
- [x] Pushes and pull requests run OKF validation, linting, type checks, tests, the web build, and the Velocity build.
- [x] Pull requests validate conventional commit messages.
- [x] CI uploads the development Velocity JAR as a workflow artifact.
- [x] Main-branch conventional commits determine the next semantic version and create a `vMAJOR.MINOR.PATCH` tag.
- [x] A release build embeds the semantic version in the Velocity plugin and JAR filename.
- [x] A public Gitea release exposes the versioned Velocity JAR as a downloadable asset.
- [x] Releases publish semantically versioned web runtime images to the Gitea registry.
- [x] Releases publish semantically versioned Discord bot images to the Gitea registry.
- [x] Releases publish semantically versioned migration images that run versioned Drizzle migrations.
- [x] Releases do not publish mutable container tags such as `latest`.
- [x] Runtime containers use unprivileged users and exclude development source and secrets where practical.
- [x] Operators are told which repository secrets must be configured before the first push.
# Implementation
- [CI workflow](../.gitea/workflows/ci.yml)
- [Release workflow](../.gitea/workflows/release.yml)
- [Semantic Release configuration](../.releaserc)
- [Web and migration Docker targets](../Dockerfile)
- [Velocity Gradle build](../plugins/velocity/build.gradle.kts)
- [Release and deployment guide](../docs/releases.md)
# Validation
Local OKF, lint, typecheck, test, Next.js build, and versioned Velocity JAR checks pass. Initial Gitea CI and release runs succeeded. Release `v1.0.0` provides a publicly downloadable JAR whose Velocity metadata reports `1.0.0`. Registry manifests were resolved for the published semantic-version tags. Release `v1.1.0` also publishes resolvable versioned web, Discord bot, and migration manifests and a public Velocity JAR whose metadata reports `1.1.0`. Release `v1.1.1` published immutable semantic-version tags only; prior `latest` digests remained unchanged. Pull-request commitlint configuration is present; its conditional execution will be exercised by the first pull request.
# Related Stories
- [Deploy and operate securely](us-015-platform-operations.md)
- [Velocity game admission](us-009-velocity-admission.md)
-52
View File
@@ -1,52 +0,0 @@
---
type: User Story
title: Control Minecraft admission with groups
description: Administrators assign users to groups and enable Minecraft access through explicit group policy.
tags: [admin, groups, authorization, velocity, security]
timestamp: 2026-08-02T14:12:43Z
story_id: US-017
status: verified
---
# User Story
As an administrator, I want to organize registered users into access groups, so that server admission can be enabled for selected communities while remaining off by default.
# Acceptance Criteria
- [x] A registered user can have at most one explicit group assignment.
- [x] Users without an explicit assignment fall back to the protected `everyone` group.
- [x] The `everyone` group remains created with Minecraft access disabled.
- [x] Administrators can create groups with access disabled by default and move users between groups.
- [x] Administrators can enable or disable Minecraft admission for each group.
- [x] Admission follows only the user's effective group; default and explicit-group access are never combined.
- [x] Administrators can delete non-default groups, returning affected users to `everyone`.
- [x] The protected default group cannot be deleted.
- [x] Group creation, membership, and access-policy changes are audited.
- [x] Users and administrators can inspect the user's single effective group assignment.
- [x] Every group has an independently configurable VPN/proxy/Tor exception policy.
- [x] The protected `everyone` group and newly created groups disallow VPN, proxy, and Tor connections by default.
- [x] Confirmed VPN, proxy, or Tor game connections are denied unless the user's single effective group allows anonymized networks.
- [x] Clear and hosting classifications are not denied by this group policy, and unavailable intelligence does not independently deny a registered player.
- [x] VPN policy changes are authorized server-side and audited.
# Implementation
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
- [`packages/database/drizzle/0002_simple_queen_noir.sql`](../packages/database/drizzle/0002_simple_queen_noir.sql)
- [`packages/database/drizzle/0003_smiling_silver_samurai.sql`](../packages/database/drizzle/0003_smiling_silver_samurai.sql)
- [`packages/database/drizzle/0004_zippy_silver_centurion.sql`](../packages/database/drizzle/0004_zippy_silver_centurion.sql)
- [`apps/web/src/app/admin/(console)/groups/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/page.tsx)
- [`apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/%5BgroupId%5D/page.tsx)
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
# Validation
- [`packages/auth/test/group-access.test.ts`](../packages/auth/test/group-access.test.ts)
- Drizzle migration generation, TypeScript validation, tests, lint, and the production build must pass.
# Related Stories
- [Enforce registration at Velocity](us-009-velocity-admission.md)
- [Manage users as an administrator](us-013-admin-user-management.md)
- [Preserve an audit trail](us-010-audit-events.md)
-58
View File
@@ -1,58 +0,0 @@
---
type: User Story
title: Monitor community account activity
description: Administrators use a server-rendered dashboard to review daily activity, confirmed connections, locations, denials, and risky networks.
tags: [admin, dashboard, metrics, security, maps, ssr]
timestamp: 2026-08-02T12:05:27Z
story_id: US-018
status: verified
---
# User Story
As an administrator, I want an operational dashboard of account and game activity, so that I can understand community growth and quickly investigate access risks.
# Acceptance Criteria
- [x] The administrator landing page is a dashboard rather than a settings form.
- [x] A server-rendered Natural Earth overview plots each user's latest observation with valid approximate coordinates.
- [x] Administrators can opt into a zoomable OpenStreetMap view without removing the default overview.
- [x] OpenStreetMap tiles load only after the administrator selects the interactive view and retain required attribution.
- [x] Map markers show the managed Discord nickname on hover or keyboard focus, link to user records, and have an accessible text-table equivalent.
- [x] Users sharing approximate coordinates render as one grouped marker with a visible count in both map views.
- [x] Grouped-marker hover and keyboard focus list every managed Discord nickname at that location.
- [x] Interactive grouped markers open a popup with links to every corresponding user record.
- [x] Single-user markers retain their direct nickname tooltip and user-record link.
- [x] The location list identifies the enriched network company and ASN when available.
- [x] The location list shows ProxyCheck's connection type separately from its risk classification.
- [x] The location list shows the provider's proxy/VPN signal as an explicit Yes or No value.
- [x] Unknown is shown only for individual enriched fields that are unavailable, including existing cached responses.
- [x] The dashboard graphs distinct daily active users by UTC day for the previous 14 days with understandable date labels.
- [x] Monthly active users count distinct users observed through portal or game activity in the previous 30 days.
- [x] Monthly active Minecraft accounts count distinct accounts with a confirmed Velocity post-login connection in the previous 30 days.
- [x] The dashboard shows login denials from the previous 24 hours.
- [x] Recent VPN, proxy, and Tor observations use enriched ProxyCheck classifications, collapse repeated rows per user, and show counts, sources, and latest activity.
- [x] The graph includes an accessible title, description, point labels, and textual values.
- [x] Dashboard queries and initial rendering execute server-side; only the opt-in pan-and-zoom map hydrates client-side.
- [x] Deployment-managed guild settings and denial messaging remain available on a dedicated settings page.
# Implementation
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
- [`apps/web/src/app/admin/(console)/settings/page.tsx`](../apps/web/src/app/admin/%28console%29/settings/page.tsx)
- [`apps/web/src/lib/admin-metrics.ts`](../apps/web/src/lib/admin-metrics.ts)
- [`apps/web/src/components/user-world-map.tsx`](../apps/web/src/components/user-world-map.tsx)
- [`apps/web/src/components/map-view-toggle.tsx`](../apps/web/src/components/map-view-toggle.tsx)
- [`apps/web/src/lib/user-location-map.ts`](../apps/web/src/lib/user-location-map.ts)
# Validation
- Missing-day chart behavior and per-user VPN collapsing are covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts).
- Coordinate parsing, 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.
# Related Stories
- [Preserve a CloudEvents-style audit trail](us-010-audit-events.md)
- [Deploy and operate the platform securely](us-015-platform-operations.md)
- [Block anonymized account additions](us-008-vpn-blocking.md)
+3 -1
View File
@@ -12,7 +12,9 @@ Player account management, administrator navigation, dashboard metrics and chart
- Darkened the accent color so accent text reaches at least 4.5:1 contrast on both canvas and panel backgrounds.
- 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 per-user group dropdowns with immediate-change instructions, keyboard submission fallback, and live success or error feedback.
- 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, 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.
+47
View File
@@ -0,0 +1,47 @@
# Admin suggestions API
The portal provides a read-only view of one Discord **forum channel**, using the existing NextAuth admin session and configured Keycloak role. Player sessions and Discord bot credentials are not accepted as API credentials. Sign in at `/admin/login` first; same-origin browser calls send the session cookie. Unattended machine authentication is not provided.
## Runtime configuration
- `DISCORD_SUGGESTIONS_FORUM_ID`: required forum channel snowflake, configured through GitOps. No source-code default.
- `DISCORD_GUILD_ID`: existing Discord guild configuration; the forum must belong to it.
- `DISCORD_BOT_TOKEN`: existing server-side credential, never returned to consumers.
The web workload needs these variables, not just the bot workload. The actual forum ID is maintained only in GitOps. No schema or bot Gateway changes are needed. The application uses Discord REST API v10 with the existing bot identity.
The bot must have **View Channel** and **Read Message History** for the forum and its posts. Message bodies are subject to Discord's **Message Content privileged intent**, including REST access: enable it for the application and obtain approval from Discord if required. Missing content can appear as an empty body rather than an HTTP error; check a known text post before production acceptance. No permissions or intents are changed by this feature.
## Endpoints
| GET endpoint | Result |
| --- | --- |
| `/api/suggestions?status=active&limit=25` | Active posts, newest-created first. `status` defaults to `active`. |
| `/api/suggestions?status=archived&limit=25` | Archived public forum posts, newest archive timestamp first. |
| `/api/suggestions/:id` | Suggestion metadata and `originalPost`; `null` when the starter message was deleted. |
| `/api/suggestions/:id/messages?limit=25` | Discussion messages, newest first, including the starter if reached. |
Lists return `{ items, nextCursor }`. Pass `nextCursor` back as the URL-encoded `cursor` parameter with the same status. Limits are integers from 1 to 100. Active cursors are thread IDs; archived cursors are UTC archive timestamps; message cursors are message IDs. Treat cursors as opaque. Messages may yield a final empty page because Discord does not provide a `has_more` flag for messages. Active threads are fetched via the guild active-threads endpoint, filtered to the forum, sorted, and paginated locally. Archives are paginated by Discord. This is a live view, not a consistent snapshot: threads can move between active and archived lists.
Suggestion fields: `id`, `title`, `authorId`, `createdAt`, `archived`, `locked`, `tags`, `messageCount`, `discordUrl`. Discord's message count is approximate, not a vote count. Detail messages include `id`, `author` (`id`, `name`), `content`, `createdAt`, `editedAt`, `reactions` (`emoji`, `count`), and `discordUrl`. Reactions remain reaction counts, not interpreted votes. Attachments, embeds, and rendered Discord Markdown are not mirrored; use Discord links for the original presentation.
## Errors and safety
Errors use RFC 9457 `application/problem+json`, HTTP-matching `status`, stable `urn:error:*` types, and safe details:
- `401 unauthorized`: no admin session; no redirect.
- `403 forbidden`: session lacks the required role.
- `400 invalid-request`: invalid ID, cursor, limit, status, or list/message query parameter.
- `404 suggestion-not-found`: inaccessible/deleted thread, or thread outside the configured forum.
- `405 method-not-allowed`: writes are unsupported; `Allow: GET, HEAD`.
- `503 suggestions-not-configured`, `discord-unavailable`, `discord-rate-limited`, or `discord-busy`: configuration, permissions, upstream failure, or temporary backoff. Rate limits and capacity limits include `Retry-After`.
Every endpoint rechecks admin authorization before any cached or fresh data is returned. Responses use `Cache-Control: no-store`. The in-process Discord cache lasts 30 seconds, contains at most 200 entries, coalesces identical concurrent reads, and permits at most eight concurrent upstream requests. Requests have an eight-second timeout and never follow redirects. Discord rate limits establish a per-client cooldown without retry loops. Caches and cooldowns are per process, not shared with bot Gateway activity or other replicas.
The client checks the configured forum's guild/type and each requested thread's parent/type before reading messages. It only calls fixed Discord endpoints with validated snowflakes. Error bodies and credentials are not logged or forwarded. There is no database synchronization and no Discord mutation support.
## Verification before rollout
Run the source checks and focused tests in `apps/web/src/lib/discord/suggestions.test.ts` and `apps/web/src/app/api/suggestions/route.test.ts`. Under the separately approved GitOps/release plan, verify one known active post, one archived post, message content, and admin/non-admin access against the actual forum. Offline fixtures do not establish live Discord permissions or intent approval.
References: [Discord threads](https://discord.com/developers/docs/topics/threads), [Channel resource](https://discord.com/developers/docs/resources/channel), [Message content intent](https://discord.com/developers/docs/events/gateway#message-content-intent).
+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 server addresses with write-only AES-GCM-encrypted passwords. Browser requests invoke authenticated server actions; only the Next.js runtime opens RCON TCP connections. Administrators may configure any syntactically valid internal or external DNS hostname and port without deployment-managed endpoint configuration; IP literals remain rejected. Commands and responses are bounded and ephemeral, while credential-safe audit events retain the operator, server, command verb, keyed digest, outcome, and duration. Operators remain responsible for endpoint exposure and transport security.
### Discord bot
The bot creates private login links in response to `/register` and `/account`. Discord user IDs are the canonical Discord identity; mutable usernames are snapshots only. Nickname updates target the deployment guild configured by `DISCORD_GUILD_ID`; the public join button uses `DISCORD_INVITE_URL`.
@@ -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. Server addresses may resolve internally or externally. Prefer private networking, a VPN, or an encrypted tunnel; do not expose plaintext RCON directly to the public internet.
The password entered in the administrator connection form must match the server password. Existing passwords are write-only; leave the replacement field blank when editing unrelated connection settings.
## Security behavior
- Existing account-manager administrator authorization is rechecked for every connection mutation, test, and command.
- Commands are limited to 1,024 UTF-8 bytes and reject control characters.
- Each web process allows one operation per connection and at most eight RCON operations total. Size replica counts with that aggregate ceiling in mind.
- Each complete connect-and-response operation times out after five seconds and tears down the socket; cleanup is independently capped at one second.
- Responses are sanitized and limited to 64 KiB.
- Up to 50 command/response exchanges remain in a chronological page-memory transcript, and up to 50 submitted commands support Arrow Up/Arrow Down recall. Both are discarded on reload; commands and responses are never written to browser storage, application persistence, or logs. Audit events contain the command verb and a domain-separated HMAC digest.
- Connection passwords are never selected by page queries or returned to the browser.
RCON is plaintext TCP. Internal deployments should use network policy; external connections should use private routing, a VPN, or an encrypted tunnel rather than direct public exposure.
## Migration
Apply the generated Drizzle migration before deploying the web image:
```bash
npx drizzle-kit migrate
```
Never use `drizzle push` for this schema change.
+2
View File
@@ -65,6 +65,8 @@ docker run --rm -p 3000:3000 \
Provide all deployment settings described by [`.env.example`](../.env.example). Only set `TRUST_PROXY=true` behind a proxy that overwrites forwarding headers.
The release workflow passes the semantic version through Docker's `VERSION` build argument, and the final image embeds it as `APP_VERSION`. `GET /healthz` reports this immutable image version with readiness, for example `{"status":"ok","version":"1.19.0"}`. Manual builds that omit the build argument report `development`.
## Velocity JAR
Download the JAR from the matching public Gitea release, copy it to Velocity's `plugins/` directory, and retain the existing `plugins/minecraft-account-manager/config.properties` during upgrades.
+12 -7
View File
@@ -1,6 +1,6 @@
# Security review
Review date: 2026-08-02
Review timestamp: 2026-09-10T12:00:10Z
## Scope
@@ -8,10 +8,11 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
## Automated checks
- Semgrep `auto`: 0 findings
- `npm audit`: 0 known vulnerabilities after dependency overrides
- TypeScript, ESLint, unit tests, Next.js production build: passing
- Velocity Java tests and shaded plugin build: passing
- Full source Semgrep `auto`: 0 findings across 169 scanned files (331 rules; 13 files excluded by ignore patterns).
- `npm audit` and `npm audit --omit=dev`: 0 known vulnerabilities after remediation. Baseline had six affected entries (two moderate, two high, two critical), covering Next.js/Sharp, the NextAuth dependency path, Vitest/mocker, and js-yaml.
- Resolved patched versions: Next.js and eslint-config-next 16.3.4, Sharp 0.35.4, Vitest/mocker 4.1.11, js-yaml 4.3.2. Retained Next.js's PostCSS override at 8.5.25. No forced major dependency upgrades or database changes.
- TypeScript, 181 workspace tests, Next.js production build, and Velocity Java tests/shaded plugin build pass with patched dependencies. ESLint exits successfully with two new framework-rule warnings about existing `window.location.assign()` calls in `map-view-toggle.tsx`; those unrelated navigation behaviors were not changed.
- Verification is local source evidence, not a claim that patched artifacts have been deployed. API CI run 1979 passed for the preceding API source `1a01c0ed641f4eda83f81c855c38651aa116933e`.
## Implemented controls
@@ -19,14 +20,18 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
- Login links expire after ten minutes, are single use, and are rate limited per Discord user with a PostgreSQL advisory lock.
- Session cookies are `httpOnly`, `sameSite=lax`, path-scoped, and secure in production.
- Admin access uses Keycloak OIDC and a required role.
- Every suggestions API route independently requires that admin session/role before reading cached or live Discord data. The configured forum is guild/type-checked and requested threads are parent/type-checked before message access. Discord credentials remain server-side; fixed-host, validated-ID requests have bounded caching, concurrency, timeouts, and rate-limit backoff. Responses are uncached and errors use safe RFC 9457 problems. See [suggestions API](admin-suggestions-api.md) for live Discord permission/intent requirements.
- User mutations verify ownership server-side.
- Mojang lookup is server-side and targets a fixed host, avoiding client-forged validation and SSRF.
- Velocity credentials are high-entropy bearer tokens stored only as hashes.
- 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, and membership mutations re-check the Keycloak administrator role server-side; registry assignments, VPN-policy changes, message settings, and destructive group deletion 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.
- The administrator-only map defaults to bundled Natural Earth boundaries. OpenStreetMap tile requests begin only after an explicit operator opt-in; marker coordinates are not transmitted as data, but the requested tiles disclose the viewed geographic extent along with the administrator's IP and portal origin.
+1089 -394
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -14,14 +14,13 @@
"typecheck": "npm run typecheck --workspaces --if-present",
"db:generate": "npm run db:generate --workspace @minecraft-account-manager/database",
"db:migrate": "npm run db:migrate --workspace @minecraft-account-manager/database",
"velocity:build": "cd plugins/velocity && ./gradlew clean test shadowJar",
"design:validate": "node scripts/validate-okf.mjs"
"velocity:build": "cd plugins/velocity && ./gradlew clean test shadowJar"
},
"overrides": {
"esbuild": "0.25.12",
"next@16.2.12": {
"next@16.3.4": {
"postcss": "8.5.25",
"sharp": "0.35.3"
"sharp": "0.35.4"
}
},
"engines": {
+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"));

Some files were not shown because too many files have changed in this diff Show More