Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb8ef18688 | ||
|
|
47782b3ccc | ||
|
|
c2ac2ad16b | ||
|
|
2402e9e42d | ||
|
|
0af4884f7e | ||
|
|
1a01c0ed64 | ||
|
|
df17c021c6 | ||
|
|
ed3f3cd843 | ||
|
|
168a7a2c36 | ||
|
|
56cbecc3f7 | ||
|
|
d45ea4db68 | ||
|
|
19486150c3 | ||
|
|
3564d24a45 | ||
|
|
7f6d69e0a7 | ||
|
|
f9ccfd821d | ||
|
|
e43db34402 | ||
|
|
20dfc58d63 | ||
|
|
6425a5056a | ||
|
|
6fa33c9f7b | ||
|
|
c131465ff5 | ||
|
|
eb1c5b6de4 | ||
|
|
9f0832a5b5 |
@@ -14,6 +14,8 @@ KEYCLOAK_REQUIRED_ROLE=minecraft-account-manager-admin
|
||||
DISCORD_BOT_TOKEN=
|
||||
DISCORD_APPLICATION_ID=
|
||||
DISCORD_GUILD_ID=
|
||||
# Optional admin suggestions reader; set the real forum ID only through GitOps.
|
||||
DISCORD_SUGGESTIONS_FORUM_ID=
|
||||
DISCORD_INVITE_URL=https://discord.gg/your-invite
|
||||
|
||||
# Trust forwarding headers only when your reverse proxy overwrites them
|
||||
@@ -25,5 +27,9 @@ PROXYCHECK_API_KEY=
|
||||
IP_INTELLIGENCE_CACHE_HOURS=48
|
||||
BLOCK_HOSTING_IPS=false
|
||||
|
||||
# Optional independent 32-byte base64 RCON keys. When omitted, domain-separated keys are derived from AUTH_SECRET.
|
||||
RCON_CREDENTIAL_KEY=
|
||||
RCON_AUDIT_KEY=
|
||||
|
||||
# Structured Pino logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
@@ -44,9 +44,6 @@ jobs:
|
||||
commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.sha }}" \
|
||||
--extends @commitlint/config-conventional
|
||||
|
||||
- name: Validate OKF design
|
||||
run: npm run design:validate
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
+7
-4
@@ -17,8 +17,11 @@ COPY packages/network/package.json ./packages/network/package.json
|
||||
FROM manifests AS dependencies
|
||||
RUN npm ci
|
||||
|
||||
FROM manifests AS production-dependencies
|
||||
RUN npm ci --omit=dev
|
||||
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 . .
|
||||
@@ -41,7 +44,7 @@ USER app
|
||||
EXPOSE 3000
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
|
||||
FROM production-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}" \
|
||||
@@ -56,7 +59,7 @@ COPY --chown=app:app packages ./packages
|
||||
USER app
|
||||
CMD ["npm", "run", "start", "--workspace", "@minecraft-account-manager/discord-bot"]
|
||||
|
||||
FROM production-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}" \
|
||||
|
||||
@@ -32,14 +32,23 @@ Set `IP_INTELLIGENCE_PROVIDER=proxycheck`, add `PROXYCHECK_API_KEY`, and configu
|
||||
|
||||
Open `http://localhost:3000`.
|
||||
|
||||
## Admin suggestions
|
||||
|
||||
Administrators can browse the configured Discord forum at `/admin/suggestions` or use the same session-protected [suggestions API](docs/admin-suggestions-api.md). The portal includes active/archive browsing, original posts, reactions, and paginated discussion. Set `DISCORD_SUGGESTIONS_FORUM_ID` through GitOps; the existing bot token stays server-side. This integration is read-only and does not synchronize data into the database.
|
||||
|
||||
## Application API contract
|
||||
|
||||
The canonical [OpenAPI 3.1](openapi.yaml) contract is publicly served as plain YAML at `/openapi.yaml` (locally: <http://localhost:3000/openapi.yaml>). It covers administrator identity, all three suggestions reads and the two Velocity integrations, including method rejection and implicit HEAD behavior. NextAuth internals and browser server actions are explicitly excluded; no interactive UI is installed.
|
||||
|
||||
Admin reads accept an administrator session **or** an authorized Keycloak machine JWT. Velocity requires its **separate shared server secret**, not a machine JWT. See [authentication and safe client-credentials usage](docs/admin-api-authentication.md) and [contract maintenance/packaging](docs/openapi.md). Production: <https://portal.somc.club/openapi.yaml> (publication requires a release).
|
||||
|
||||
## Product design
|
||||
|
||||
Implemented and proposed behavior is tracked as OKF user stories in [`design/index.md`](design/index.md). Validate the bundle with `npm run design:validate`.
|
||||
Implemented and proposed behavior is tracked in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/minecraft-account-manager/index.md). Validate canonical knowledge in that repository with `okflint validate --manifest okf-base.yaml`; source builds do not require wiki access.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
npm run design:validate
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
@@ -76,12 +85,13 @@ The token is displayed once and stored only as a SHA-256 hash.
|
||||
|
||||
- PostgreSQL and Drizzle ORM
|
||||
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
|
||||
- Admin user search, account management, event exploration, DAU and confirmed-connection metrics, toggleable Natural Earth/OpenStreetMap user-location views, and automatic Discord nickname synchronization
|
||||
- Exclusive group admission: unassigned users fall back to protected `everyone`, and administrators manage effective membership, access, and VPN/proxy/Tor exceptions through confirmed group workflows
|
||||
- Admin user search, account management, event exploration, DAU and confirmed-connection metrics, toggleable Natural Earth/OpenStreetMap user-location views, 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.
|
||||
|
||||
@@ -15,6 +15,8 @@ const contentSecurityPolicy = [
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
// Keep the canonical source available in standalone/container output as well.
|
||||
outputFileTracingIncludes: { "/openapi.yaml": ["../../openapi.yaml"] },
|
||||
poweredByHeader: false,
|
||||
async headers() {
|
||||
return [
|
||||
|
||||
+11
-3
@@ -7,6 +7,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "vitest run",
|
||||
"openapi:validate": "vitest run src/lib/openapi.test.ts",
|
||||
"openapi:standalone": "OPENAPI_STANDALONE_TEST=1 vitest run src/test/openapi-standalone.test.ts",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
@@ -19,15 +21,18 @@
|
||||
"@minecraft-account-manager/network": "*",
|
||||
"d3-geo": "^3.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"jose": "^6.2.12",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^16.2.1",
|
||||
"next": "^16.3.4",
|
||||
"next-auth": "^4.24.13",
|
||||
"rcon-client": "^4.2.5",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apidevtools/swagger-parser": "^12.1.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-geo": "^3.1.1",
|
||||
@@ -36,11 +41,14 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.2.1",
|
||||
"eslint-config-next": "^16.3.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.0"
|
||||
"vitest": "^4.1.0",
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { groupAccessWindows, groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
@@ -6,16 +6,18 @@ import { notFound } from "next/navigation";
|
||||
import { AdminModalForm } from "@/components/admin-modal-form";
|
||||
import { AdminUserTable } from "@/components/admin-user-table";
|
||||
import { GroupPolicyControl } from "@/components/group-policy-control";
|
||||
import { GroupScheduleEditor, GroupScheduleSummary } from "@/components/group-schedule-editor";
|
||||
import { db } from "@/lib/database";
|
||||
import { isEffectiveGroupMember } from "@/lib/group-management";
|
||||
import { assignUserGroupFromRegistry } from "../../users/actions";
|
||||
import { deleteGroup, setGroupAccess, setGroupAnonymizedNetworkAccess, updateGroupDetails } from "../actions";
|
||||
import { deleteGroup, replaceGroupSchedule, setGroupAccess, setGroupAnonymizedNetworkAccess, updateGroupDetails } from "../actions";
|
||||
|
||||
const savedMessages: Record<string, string> = {
|
||||
created: "Group created.",
|
||||
details: "Group details updated.",
|
||||
access: "Minecraft access policy updated.",
|
||||
"network-access": "VPN, proxy, and Tor policy updated.",
|
||||
schedule: "Weekly access schedule updated.",
|
||||
group: "Member group updated.",
|
||||
};
|
||||
|
||||
@@ -23,6 +25,7 @@ const errorMessages: Record<string, string> = {
|
||||
"invalid-group": "Enter a valid name and a description of no more than 500 characters.",
|
||||
"duplicate-group": "A group with that name already exists.",
|
||||
"invalid-group-assignment": "The user or destination group no longer exists. No membership change was applied.",
|
||||
"invalid-schedule": "Use valid, non-overlapping weekly access windows. Start and end cannot be identical.",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -39,7 +42,7 @@ export default async function GroupPage({
|
||||
const [group] = await db.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||
if (!group) notFound();
|
||||
|
||||
const [allUsers, allGroups, memberships] = await Promise.all([
|
||||
const [allUsers, allGroups, memberships, accessWindows] = await Promise.all([
|
||||
db.select({
|
||||
id: users.id,
|
||||
firstName: users.firstName,
|
||||
@@ -65,6 +68,11 @@ export default async function GroupPage({
|
||||
.from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
|
||||
db.select({ userId: userGroupMemberships.userId, groupId: userGroupMemberships.groupId })
|
||||
.from(userGroupMemberships),
|
||||
db.select({
|
||||
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
|
||||
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
|
||||
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id))
|
||||
.orderBy(groupAccessWindows.startMinuteOfWeek),
|
||||
]);
|
||||
const assignmentByUser = Object.fromEntries(memberships.map((membership) => [membership.userId, membership.groupId]));
|
||||
const memberUsers = allUsers.filter((user) => isEffectiveGroupMember(user.id, assignmentByUser, group));
|
||||
@@ -101,8 +109,17 @@ export default async function GroupPage({
|
||||
<section aria-labelledby="group-policy-heading" className="mt-10 border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<div className="border-b border-line pb-4"><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Admission controls</p><h2 className="mt-2 font-display text-3xl font-black uppercase" id="group-policy-heading">Group policies</h2></div>
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||
<PolicyDetail description="Controls whether members can connect to Minecraft." label="Minecraft access"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="Minecraft access" returnLocation="detail" /></PolicyDetail>
|
||||
<PolicyDetail description="Allows confirmed VPN, proxy, and Tor connections." label="VPN / proxy / Tor"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="VPN / proxy / Tor" returnLocation="detail" /></PolicyDetail>
|
||||
<PolicyDetail description="Controls whether members can connect to Minecraft. Disabled access always overrides the schedule." label="Minecraft access"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="Minecraft access" returnLocation="detail" /></PolicyDetail>
|
||||
<PolicyDetail description="Allows confirmed VPN, proxy, and Tor connections after access and schedule checks pass." label="VPN / proxy / Tor"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="VPN / proxy / Tor" returnLocation="detail" /></PolicyDetail>
|
||||
</div>
|
||||
<div className="mt-7 scroll-mt-6 border-t border-line pt-6" id="group-schedule">
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="max-w-2xl"><h3 className="font-mono text-xs font-bold uppercase">Weekly access schedule</h3><p className="mt-2 text-xs leading-5 text-muted">When Minecraft access is enabled, members may log in only during these recurring UTC windows. Existing sessions are not disconnected when a window ends.</p><div className="mt-4"><GroupScheduleSummary windows={accessWindows} /></div></div>
|
||||
<AdminModalForm action={replaceGroupSchedule} description={`Replace the complete weekly access schedule for ${group.name}. Minecraft access must still be enabled.`} submitLabel="Save schedule" title={`Schedule ${group.name}`} triggerClassName="shrink-0 border border-ink px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-wider" triggerLabel="Edit schedule">
|
||||
<input name="groupId" type="hidden" value={group.id} />
|
||||
<GroupScheduleEditor windows={accessWindows} />
|
||||
</AdminModalForm>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const actionState = vi.hoisted(() => ({
|
||||
selected: [] as unknown[][],
|
||||
inserted: [] as unknown[],
|
||||
deleted: 0,
|
||||
authorized: 0,
|
||||
failAudit: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/require-admin", () => ({
|
||||
requireAdminSession: async () => {
|
||||
actionState.authorized += 1;
|
||||
return { email: "admin@example.test", name: "Admin" };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (path: string) => {
|
||||
throw new Error(`REDIRECT:${path}`);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/database", () => {
|
||||
function selection(response: unknown[]) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
for (const method of ["from", "where", "orderBy"]) chain[method] = () => chain;
|
||||
chain.limit = () => Promise.resolve(response);
|
||||
chain.then = (resolve: (value: unknown[]) => unknown, reject: (reason: unknown) => unknown) =>
|
||||
Promise.resolve(response).then(resolve, reject);
|
||||
return chain;
|
||||
}
|
||||
const tx = {
|
||||
execute: async () => undefined,
|
||||
select: () => selection(actionState.selected.shift() ?? []),
|
||||
delete: () => ({ where: async () => { actionState.deleted += 1; } }),
|
||||
insert: () => ({
|
||||
values: async (value: unknown) => {
|
||||
if (actionState.failAudit && !Array.isArray(value)) throw new Error("audit unavailable");
|
||||
actionState.inserted.push(value);
|
||||
},
|
||||
}),
|
||||
};
|
||||
return { db: { transaction: async (callback: (transaction: typeof tx) => Promise<unknown>) => callback(tx) } };
|
||||
});
|
||||
|
||||
import { replaceGroupSchedule } from "./actions";
|
||||
|
||||
function scheduleForm() {
|
||||
const formData = new FormData();
|
||||
formData.set("groupId", "11111111-1111-4111-8111-111111111111");
|
||||
formData.append("startMinuteOfWeek", "6960");
|
||||
formData.append("endMinuteOfWeek", "7200");
|
||||
return formData;
|
||||
}
|
||||
|
||||
describe("replaceGroupSchedule", () => {
|
||||
beforeEach(() => {
|
||||
actionState.selected = [
|
||||
[{ id: "11111111-1111-4111-8111-111111111111", name: "Friday friends" }],
|
||||
[{ startMinuteOfWeek: 480, endMinuteOfWeek: 540 }],
|
||||
];
|
||||
actionState.inserted = [];
|
||||
actionState.deleted = 0;
|
||||
actionState.authorized = 0;
|
||||
actionState.failAudit = false;
|
||||
});
|
||||
|
||||
it("reauthorizes and replaces all windows with an audit in one transaction", async () => {
|
||||
await expect(replaceGroupSchedule(scheduleForm())).rejects.toThrow("REDIRECT:/admin/groups/11111111-1111-4111-8111-111111111111?saved=schedule");
|
||||
expect(actionState.authorized).toBe(1);
|
||||
expect(actionState.deleted).toBe(1);
|
||||
expect(actionState.inserted[0]).toEqual([{
|
||||
groupId: "11111111-1111-4111-8111-111111111111",
|
||||
startMinuteOfWeek: 6960,
|
||||
endMinuteOfWeek: 7200,
|
||||
}]);
|
||||
expect(actionState.inserted[1]).toEqual(expect.objectContaining({
|
||||
type: "games.minecraft.account-manager.group.schedule-updated",
|
||||
data: expect.objectContaining({
|
||||
previousWindows: [{ startMinuteOfWeek: 480, endMinuteOfWeek: 540 }],
|
||||
windows: [{ startMinuteOfWeek: 6960, endMinuteOfWeek: 7200 }],
|
||||
adminEmail: "admin@example.test",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not report success when the atomic audit write fails", async () => {
|
||||
actionState.failAudit = true;
|
||||
await expect(replaceGroupSchedule(scheduleForm())).rejects.toThrow("audit unavailable");
|
||||
expect(actionState.inserted).toEqual([[{
|
||||
groupId: "11111111-1111-4111-8111-111111111111",
|
||||
startMinuteOfWeek: 6960,
|
||||
endMinuteOfWeek: 7200,
|
||||
}]]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { events, groups, userGroupMemberships } from "@minecraft-account-manager/database";
|
||||
import { events, groupAccessWindows, groups, userGroupMemberships } from "@minecraft-account-manager/database";
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import { and, eq, ne, sql } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
@@ -9,6 +9,7 @@ import { redirect } from "next/navigation";
|
||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||
import { db } from "@/lib/database";
|
||||
import { editableGroupName, groupSlug, validateGroupDetails } from "@/lib/group-management";
|
||||
import { parseScheduleWindows } from "@/lib/group-schedule";
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
@@ -175,6 +176,41 @@ export async function setGroupAnonymizedNetworkAccess(formData: FormData) {
|
||||
return updateGroupPolicy(formData, "anonymized-networks");
|
||||
}
|
||||
|
||||
export async function replaceGroupSchedule(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const groupId = String(formData.get("groupId") ?? "");
|
||||
const windows = parseScheduleWindows(formData);
|
||||
if (!UUID_PATTERN.test(groupId) || !windows) redirect(groupPath(groupId, "error=invalid-schedule"));
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
|
||||
const [group] = await tx.select({ id: groups.id, name: groups.name }).from(groups)
|
||||
.where(eq(groups.id, groupId)).limit(1);
|
||||
if (!group) return null;
|
||||
const previous = await tx.select({
|
||||
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
|
||||
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
|
||||
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id));
|
||||
await tx.delete(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id));
|
||||
if (windows.length) {
|
||||
await tx.insert(groupAccessWindows).values(windows.map((window) => ({ ...window, groupId: group.id })));
|
||||
}
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: "games.minecraft.account-manager.group.schedule-updated",
|
||||
subject: `group/${group.id}`,
|
||||
time: new Date(),
|
||||
data: auditData(admin, { name: group.name, previousWindows: previous, windows }),
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
return group;
|
||||
});
|
||||
if (!updated) redirect("/admin/groups?error=unknown-group");
|
||||
redirect(groupPath(updated.id, "saved=schedule"));
|
||||
}
|
||||
|
||||
export async function deleteGroup(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const groupId = String(formData.get("groupId") ?? "");
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { groupAccessWindows, groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { asc, count, desc } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { AdminModalForm } from "@/components/admin-modal-form";
|
||||
import { GroupPolicyControl } from "@/components/group-policy-control";
|
||||
import { db } from "@/lib/database";
|
||||
import { effectiveGroupMemberCount } from "@/lib/group-management";
|
||||
import { groupScheduleStatus } from "@/lib/group-schedule";
|
||||
import { createGroup, setGroupAccess, setGroupAnonymizedNetworkAccess } from "./actions";
|
||||
|
||||
const errors: Record<string, string> = {
|
||||
@@ -26,11 +27,15 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function GroupsPage({ searchParams }: { searchParams: Promise<{ error?: string; saved?: string }> }) {
|
||||
const query = await searchParams;
|
||||
const [allGroups, memberships, [registeredUsers]] = await Promise.all([
|
||||
const [allGroups, memberships, [registeredUsers], scheduleCounts] = await Promise.all([
|
||||
db.select().from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
|
||||
db.select({ groupId: userGroupMemberships.groupId }).from(userGroupMemberships),
|
||||
db.select({ count: count() }).from(users),
|
||||
db.select({ groupId: groupAccessWindows.groupId, count: count() })
|
||||
.from(groupAccessWindows)
|
||||
.groupBy(groupAccessWindows.groupId),
|
||||
]);
|
||||
const scheduleCountByGroup = new Map(scheduleCounts.map((schedule) => [schedule.groupId, Number(schedule.count)]));
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||
<header className="flex flex-col gap-6 border-b border-line pb-8 sm:flex-row sm:items-end sm:justify-between">
|
||||
@@ -60,10 +65,10 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi
|
||||
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">{savedMessages[query.saved] ?? "Group updated."}</p>}
|
||||
|
||||
<div className="mt-9 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<table className="w-full min-w-[760px] border-collapse text-left">
|
||||
<table className="w-full min-w-[880px] border-collapse text-left">
|
||||
<caption className="sr-only">Access groups and their effective policies</caption>
|
||||
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
|
||||
<tr><th className="p-4" scope="col">Name</th><th className="p-4" scope="col">Minecraft access</th><th className="p-4" scope="col">VPN access</th><th className="p-4 text-right" scope="col">Users</th></tr>
|
||||
<tr><th className="p-4" scope="col">Name</th><th className="p-4" scope="col">Minecraft access</th><th className="p-4" scope="col">Schedule</th><th className="p-4" scope="col">VPN access</th><th className="p-4 text-right" scope="col">Users</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{allGroups.map((group) => {
|
||||
@@ -72,6 +77,7 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi
|
||||
memberships.map((membership) => membership.groupId),
|
||||
group,
|
||||
);
|
||||
const scheduleStatus = groupScheduleStatus(scheduleCountByGroup.get(group.id) ?? 0);
|
||||
return (
|
||||
<tr className="transition-colors hover:bg-canvas/60" key={group.id}>
|
||||
<th className="p-4 text-left" scope="row">
|
||||
@@ -79,6 +85,7 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi
|
||||
{group.isDefault && <span className="ml-3 bg-ink px-2 py-1 font-mono text-[8px] font-bold uppercase text-canvas">Default</span>}
|
||||
</th>
|
||||
<td className="p-4"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberCount} policy="Minecraft access" returnLocation="list" /></td>
|
||||
<td className="p-4"><Link aria-label={`${scheduleStatus}. Edit schedule for ${group.name}`} className={`font-mono text-[10px] font-bold uppercase underline underline-offset-4 ${scheduleStatus === "Unrestricted" ? "text-muted" : "text-accent"}`} href={`/admin/groups/${group.id}#group-schedule`}>{scheduleStatus}</Link></td>
|
||||
<td className="p-4"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberCount} policy="VPN / proxy / Tor" returnLocation="list" /></td>
|
||||
<td className="p-4 text-right font-mono text-sm font-bold">{memberCount}</td>
|
||||
</tr>
|
||||
|
||||
@@ -38,6 +38,8 @@ export default async function AdminConsoleLayout({ children }: { children: React
|
||||
<Link className="hover:text-accent" href="/admin/settings">Settings</Link>
|
||||
<Link className="hover:text-accent" href="/admin/users">Users</Link>
|
||||
<Link className="hover:text-accent" href="/admin/groups">Groups</Link>
|
||||
<Link className="hover:text-accent" href="/admin/rcon">RCON</Link>
|
||||
<Link className="hover:text-accent" href="/admin/suggestions">Suggestions</Link>
|
||||
<Link className="hover:text-accent" href="/admin/events">Events</Link>
|
||||
</nav>
|
||||
<AdminSignOutButton />
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SuggestionReader } from "@/components/suggestion-reader";
|
||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SuggestionPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
await requireAdminSession();
|
||||
const { id } = await params;
|
||||
if (!/^[1-9]\d{16,19}$/.test(id)) notFound();
|
||||
return <main className="mx-auto max-w-6xl px-6 py-14">
|
||||
<Link className="mb-8 inline-block font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4 hover:text-accent" href="/admin/suggestions">← All suggestions</Link>
|
||||
<SuggestionReader id={id} key={id} />
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { expect, it, vi } from "vitest";
|
||||
vi.mock("next-auth", () => ({ getServerSession: async () => ({ user: { roles: ["ops"] } }) }));
|
||||
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
|
||||
vi.mock("@minecraft-account-manager/database", () => ({ recordEvent: vi.fn() }));
|
||||
vi.mock("@/lib/database", () => ({ db: {} }));
|
||||
vi.mock("@/components/admin-sign-out-button", () => ({ AdminSignOutButton: () => <button>Sign out</button> }));
|
||||
import AdminConsoleLayout from "../layout";
|
||||
it("adds suggestions to administrator navigation", async () => {
|
||||
const markup = renderToStaticMarkup(await AdminConsoleLayout({ children: <p>Console</p> }));
|
||||
expect(markup).toContain('href="/admin/suggestions"');
|
||||
expect(markup).toContain("Suggestions</a>");
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
const mocks = vi.hoisted(() => ({ requireAdmin: vi.fn() }));
|
||||
vi.mock("@/lib/auth/require-admin", () => ({ requireAdminSession: mocks.requireAdmin }));
|
||||
vi.mock("@/components/suggestions-browser", () => ({ SuggestionsBrowser: () => <div>Suggestions browser</div> }));
|
||||
vi.mock("@/components/suggestion-reader", () => ({ SuggestionReader: ({ id }: { id: string }) => <div>Reader {id}</div> }));
|
||||
import SuggestionsPage from "./page";
|
||||
import SuggestionPage from "./[id]/page";
|
||||
const params = Promise.resolve({ id: "100000000000000009" });
|
||||
beforeEach(() => { mocks.requireAdmin.mockReset(); });
|
||||
it("independently requires the admin guard before rendering either page", async () => {
|
||||
mocks.requireAdmin.mockRejectedValue(new Error("Admin sign-in required"));
|
||||
await expect(SuggestionsPage()).rejects.toThrow("Admin sign-in required");
|
||||
await expect(SuggestionPage({ params })).rejects.toThrow("Admin sign-in required");
|
||||
});
|
||||
it("renders the browser and detail in the existing portal layout", async () => {
|
||||
mocks.requireAdmin.mockResolvedValue({ name: "Admin" });
|
||||
expect(renderToStaticMarkup(await SuggestionsPage())).toContain("Suggestions browser");
|
||||
const detail = renderToStaticMarkup(await SuggestionPage({ params }));
|
||||
expect(detail).toContain("Reader");
|
||||
expect(detail).toContain('href="/admin/suggestions"');
|
||||
});
|
||||
it("rejects malformed IDs rather than constructing arbitrary API URLs", async () => {
|
||||
mocks.requireAdmin.mockResolvedValue({ name: "Admin" });
|
||||
await expect(SuggestionPage({ params: Promise.resolve({ id: "../secret" }) })).rejects.toThrow("NEXT_HTTP_ERROR_FALLBACK;404");
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { SuggestionsBrowser } from "@/components/suggestions-browser";
|
||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SuggestionsPage() {
|
||||
await requireAdminSession();
|
||||
return <main className="mx-auto max-w-6xl px-6 py-14">
|
||||
<header className="relative overflow-hidden border-b border-line pb-8">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Community / Idea desk</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">Suggestions</h1>
|
||||
<div className="mt-5 flex flex-wrap items-end justify-between gap-5">
|
||||
<p className="max-w-2xl text-sm leading-6 text-muted">Ideas from the Discord forum, brought into the operations desk. Read proposals and discussion here; keep the conversation in Discord.</p>
|
||||
<span className="border border-ink bg-signal px-3 py-2 font-mono text-[10px] font-bold uppercase tracking-widest">Admin-only / Read-only</span>
|
||||
</div>
|
||||
</header>
|
||||
<SuggestionsBrowser />
|
||||
</main>;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
|
||||
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||
import { events, groups, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { events, groups, ipIntelligence, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { groupAccessAddresses } from "@/lib/access-address-groups";
|
||||
import { accessAddressDetails, groupAccessAddresses } from "@/lib/access-address-groups";
|
||||
import { db } from "@/lib/database";
|
||||
import { discordIdentity } from "@/lib/discord-identity";
|
||||
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
|
||||
@@ -80,8 +80,16 @@ export default async function AdminUserPage({
|
||||
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
|
||||
recentEventsQuery,
|
||||
db
|
||||
.select()
|
||||
.select({
|
||||
id: ipObservations.id,
|
||||
ipAddress: ipObservations.ipAddress,
|
||||
source: ipObservations.source,
|
||||
classification: ipObservations.classification,
|
||||
observedAt: ipObservations.observedAt,
|
||||
intelligence: ipIntelligence.rawResponse,
|
||||
})
|
||||
.from(ipObservations)
|
||||
.leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||
.where(eq(ipObservations.userId, user.id))
|
||||
.orderBy(desc(ipObservations.observedAt))
|
||||
.limit(100),
|
||||
@@ -95,9 +103,7 @@ export default async function AdminUserPage({
|
||||
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
|
||||
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
|
||||
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
|
||||
const addressGroups = groupAccessAddresses(
|
||||
observations.map((observation) => ({ ...observation, intelligence: null })),
|
||||
);
|
||||
const addressGroups = groupAccessAddresses(observations);
|
||||
const primary = accounts.find((account) => account.isPrimary);
|
||||
const nickname = user.firstName
|
||||
? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
|
||||
@@ -215,16 +221,20 @@ export default async function AdminUserPage({
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Recent addresses</p>
|
||||
<p className="mt-3 text-[10px] leading-5 text-muted">Grouped by IPv4 /24 or IPv6 /64 network across the 100 most recent observations.</p>
|
||||
<div className="mt-4 divide-y divide-line">
|
||||
{addressGroups.map((group) => (
|
||||
<div className="py-3" key={group.network}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-mono text-xs font-bold">{group.network}</p>
|
||||
<span className="font-mono text-[9px] text-muted">×{group.count}</span>
|
||||
{addressGroups.map((group) => {
|
||||
const details = accessAddressDetails(group);
|
||||
return (
|
||||
<div className="py-3" key={group.network}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-mono text-xs font-bold">{group.network}</p>
|
||||
<span className="font-mono text-[9px] text-muted">×{group.count}</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[9px] text-muted">{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}</p>
|
||||
<p className="mt-1 break-all font-mono text-[9px] text-muted">Latest {group.latestAddress}</p>
|
||||
<p className="mt-1 text-xs text-muted">{details.location} · <span className="font-mono uppercase">{details.classification}</span></p>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[9px] text-muted">{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}</p>
|
||||
<p className="mt-1 break-all font-mono text-[9px] text-muted">Latest {group.latestAddress}</p>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
|
||||
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
||||
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
const issuer = "https://sso.example/realms/operators";
|
||||
let signed: string;
|
||||
const fetcher = vi.fn();
|
||||
beforeAll(async () => {
|
||||
const keys = await generateKeyPair("RS256");
|
||||
const jwks = { keys: [{ ...await exportJWK(keys.publicKey), kid: "whoami-key", alg: "RS256" }] };
|
||||
fetcher.mockImplementation(async () => Response.json(jwks));
|
||||
signed = await new SignJWT({ resource_access: { portal: { roles: ["ops"] } }, name: "Not exposed", email: "private@example.test" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "whoami-key" }).setIssuer(issuer).setAudience("portal")
|
||||
.setSubject("machine-subject").setExpirationTime("5m").sign(keys.privateKey);
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("KEYCLOAK_ISSUER_URL", issuer);
|
||||
vi.stubEnv("KEYCLOAK_CLIENT_ID", "portal");
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
});
|
||||
afterEach(() => { auth.session.mockReset(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
|
||||
|
||||
import { assertResponse } from "@/test/openapi-contract";
|
||||
import { GET } from "./route";
|
||||
async function get(request: Request) {
|
||||
const response = await GET(request);
|
||||
await assertResponse("/api/admin/whoami", "get", response);
|
||||
return response;
|
||||
}
|
||||
function request(authorization?: string) {
|
||||
return new Request("https://portal.example/api/admin/whoami", { headers: authorization === undefined ? {} : { authorization } });
|
||||
}
|
||||
it("returns only a safe machine identity through real bearer verification", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
const response = await get(request(`Bearer ${signed}`));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toEqual({ authenticationMethod: "bearer", subject: "machine-subject", name: null, email: null });
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
});
|
||||
it("returns the existing browser identity without roles or session internals", async () => {
|
||||
auth.session.mockResolvedValue({ user: { name: "Admin", email: "admin@example.test", roles: ["ops"], image: "private-image" }, expires: "private-expiry" });
|
||||
const response = await get(request());
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toEqual({ authenticationMethod: "session", subject: null, name: "Admin", email: "admin@example.test" });
|
||||
});
|
||||
it.each([401, 403, 503])("returns a safe %s problem for browser auth failures", async (status) => {
|
||||
if (status === 503) auth.session.mockRejectedValue(new Error("private-session-error"));
|
||||
else auth.session.mockResolvedValue(status === 401 ? null : { user: { roles: [] } });
|
||||
const response = await get(request());
|
||||
expect(response.status).toBe(status);
|
||||
expect(response.headers.get("content-type")).toBe("application/problem+json");
|
||||
expect(response.headers.get("www-authenticate")).toBe(status === 401 ? 'Bearer realm="admin-api"' : null);
|
||||
expect(await response.json()).toMatchObject({ status, instance: "/api/admin/whoami" });
|
||||
});
|
||||
it("never falls back to browser auth for a supplied invalid token", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
const response = await get(request("Bearer invalid"));
|
||||
expect(response.status).toBe(401);
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { authorizeAdminApi } from "@/lib/auth/admin-api-auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authorization = await authorizeAdminApi(request);
|
||||
if (authorization.response) return authorization.response;
|
||||
return Response.json(authorization.identity, { headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { suggestionQuery, suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export function GET(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
return suggestionsApi(request, async (client) => client.messages((await context.params).id, suggestionQuery(request)));
|
||||
}
|
||||
|
||||
export const POST = suggestionsReadOnly;
|
||||
export const PUT = suggestionsReadOnly;
|
||||
export const PATCH = suggestionsReadOnly;
|
||||
export const DELETE = suggestionsReadOnly;
|
||||
export const OPTIONS = suggestionsReadOnly;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export function GET(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
return suggestionsApi(request, async (client) => client.detail((await context.params).id));
|
||||
}
|
||||
|
||||
export const POST = suggestionsReadOnly;
|
||||
export const PUT = suggestionsReadOnly;
|
||||
export const PATCH = suggestionsReadOnly;
|
||||
export const DELETE = suggestionsReadOnly;
|
||||
export const OPTIONS = suggestionsReadOnly;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
|
||||
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
||||
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
import { GET as list, POST } from "./route";
|
||||
import { GET as detail } from "./[id]/route";
|
||||
import { GET as messages } from "./[id]/messages/route";
|
||||
const issuer = "https://sso.example/realms/operators";
|
||||
const guildId = "100000000000000001";
|
||||
const forumId = "100000000000000002";
|
||||
const threadId = "100000000000000009";
|
||||
const context = { params: Promise.resolve({ id: threadId }) };
|
||||
const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11, name: "Suggestion", owner_id: "100000000000000003", applied_tags: [], message_count: 0, thread_metadata: { archived: false, locked: false } };
|
||||
let signed: string;
|
||||
let roleless: string;
|
||||
let jwks: unknown;
|
||||
const fetcher = vi.fn();
|
||||
beforeAll(async () => {
|
||||
const keys = await generateKeyPair("RS256");
|
||||
jwks = { keys: [{ ...await exportJWK(keys.publicKey), kid: "suggestions-key", alg: "RS256" }] };
|
||||
const sign = (roles: string[]) => new SignJWT({ resource_access: { portal: { roles } }, realm_access: { roles: ["ops"] } })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "suggestions-key" }).setIssuer(issuer).setAudience("portal")
|
||||
.setSubject("machine-subject").setExpirationTime("5m").sign(keys.privateKey);
|
||||
signed = await sign(["ops"]);
|
||||
roleless = await sign([]);
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("KEYCLOAK_ISSUER_URL", issuer);
|
||||
vi.stubEnv("KEYCLOAK_CLIENT_ID", "portal");
|
||||
vi.stubEnv("DISCORD_BOT_TOKEN", "test-only-bot-token");
|
||||
vi.stubEnv("DISCORD_GUILD_ID", guildId);
|
||||
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", forumId);
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
fetcher.mockImplementation(async (input: string) => {
|
||||
if (input === `${issuer}/protocol/openid-connect/certs`) return Response.json(jwks);
|
||||
const path = String(input).replace("https://discord.com/api/v10", "");
|
||||
if (path === `/channels/${forumId}`) return Response.json({ id: forumId, guild_id: guildId, type: 15, available_tags: [] });
|
||||
if (path === `/guilds/${guildId}/threads/active`) return Response.json({ threads: [] });
|
||||
if (path === `/channels/${threadId}`) return Response.json(thread);
|
||||
if (path === `/channels/${threadId}/messages/${threadId}`) return new Response(null, { status: 404 });
|
||||
if (path === `/channels/${threadId}/messages?limit=25`) return Response.json([]);
|
||||
throw new Error("Unexpected transport request");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
});
|
||||
afterEach(() => { vi.resetAllMocks(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
|
||||
function request(token: string) {
|
||||
return new Request("https://portal.example/api/suggestions", { headers: { authorization: `Bearer ${token}` } });
|
||||
}
|
||||
it.each([list, detail, messages])("accepts signed machine credentials on each read route", async (handler) => {
|
||||
const response = await handler(request(signed), context);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([list, detail, messages])("denies invalid or role-less bearer credentials even with an admin session and cached data", async (handler) => {
|
||||
expect((await handler(request(signed), context)).status).toBe(200);
|
||||
fetcher.mockClear();
|
||||
expect((await handler(request("invalid"), context)).status).toBe(401);
|
||||
expect((await handler(request(roleless), context)).status).toBe(403);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not enable writes for machine identities", async () => {
|
||||
const response = await POST(request(signed));
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET, HEAD");
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { assertResponse } from "@/test/openapi-contract";
|
||||
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
import * as list from "./route";
|
||||
import * as detail from "./[id]/route";
|
||||
import * as messages from "./[id]/messages/route";
|
||||
const guild = "100000000000000001", forum = "100000000000000002", id = "100000000000000009";
|
||||
const thread = { id, parent_id: forum, guild_id: guild, type: 11, name: "A garden", owner_id: "100000000000000003", applied_tags: [], message_count: 2, thread_metadata: { archived: false, locked: false, archive_timestamp: "2026-09-10T00:00:00.123456+00:00" } };
|
||||
const message = { id, content: "", timestamp: "2026-09-10T00:00:00Z", edited_timestamp: null, author: { id: "100000000000000003", username: "Example" }, reactions: [{ emoji: { name: "👍" }, count: 1 }] };
|
||||
const context = { params: Promise.resolve({ id }) };
|
||||
const fetcher = vi.fn();
|
||||
let generation = 0;
|
||||
beforeEach(() => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
vi.stubEnv("DISCORD_BOT_TOKEN", `synthetic-fixture-${++generation}`);
|
||||
vi.stubEnv("DISCORD_GUILD_ID", guild);
|
||||
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", forum);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
fetcher.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith(`/channels/${forum}`)) return Response.json({ id: forum, guild_id: guild, type: 15, available_tags: [] });
|
||||
if (url.includes("/threads/archived/public")) {
|
||||
const params = new URL(url).searchParams;
|
||||
if (Number(params.get("limit")) < 2) return Response.json({ code: 50035, message: "Invalid Form Body", errors: { limit: { _errors: [{ code: "NUMBER_TYPE_MIN", message: "int value should be greater than or equal to 2." }] } } }, { status: 400 });
|
||||
const newest = { ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } };
|
||||
const older = { ...newest, id: "100000000000000008", thread_metadata: { ...newest.thread_metadata, archive_timestamp: "2026-09-10T00:00:00.123455+00:00" } };
|
||||
if (params.has("before")) {
|
||||
expect(params.get("before")).toBe("2026-09-10T00:00:00.123456Z");
|
||||
return Response.json({ threads: [older], has_more: false });
|
||||
}
|
||||
return Response.json({ threads: [newest, older], has_more: false });
|
||||
}
|
||||
if (url.includes("/threads/active")) return Response.json({ threads: [thread] });
|
||||
if (url.endsWith(`/channels/${id}`)) return Response.json(thread);
|
||||
if (url.endsWith(`/messages/${id}`)) return Response.json(message);
|
||||
if (url.includes("/messages?")) return Response.json([message]);
|
||||
throw new Error("Unexpected fixture request");
|
||||
});
|
||||
});
|
||||
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
|
||||
const routes = [
|
||||
{ path: "/api/suggestions", route: list },
|
||||
{ path: "/api/suggestions/{id}", route: detail },
|
||||
{ path: "/api/suggestions/{id}/messages", route: messages },
|
||||
];
|
||||
it.each(routes)("validates populated $path response against its canonical schema", async ({ path, route }) => {
|
||||
const response = await route.GET(new Request(`https://portal.example${path.replace("{id}", id)}?limit=1`), context);
|
||||
expect(response.status).toBe(200);
|
||||
await assertResponse(path, "get", response);
|
||||
});
|
||||
it("documents the nullable deleted starter and precise archived cursor", async () => {
|
||||
fetcher.mockImplementationOnce(async () => Response.json({ id: forum, guild_id: guild, type: 15, available_tags: [] }))
|
||||
.mockImplementationOnce(async () => Response.json(thread))
|
||||
.mockImplementationOnce(async () => new Response(null, { status: 404 }));
|
||||
const response = await detail.GET(new Request(`https://portal.example/api/suggestions/${id}`), context);
|
||||
await assertResponse("/api/suggestions/{id}", "get", response);
|
||||
expect((await response.json()).originalPost).toBeNull();
|
||||
const archived = await list.GET(new Request("https://portal.example/api/suggestions?status=archived&limit=1"));
|
||||
await assertResponse("/api/suggestions", "get", archived);
|
||||
expect(archived.status).toBe(200);
|
||||
const first = await archived.json();
|
||||
expect(first.items.map((item: { id: string }) => item.id)).toEqual([id]);
|
||||
expect(first.nextCursor).toBe("2026-09-10T00:00:00.123456Z");
|
||||
const terminal = await list.GET(new Request(`https://portal.example/api/suggestions?status=archived&limit=1&cursor=${encodeURIComponent(first.nextCursor)}`));
|
||||
expect(terminal.status).toBe(200);
|
||||
await assertResponse("/api/suggestions", "get", terminal);
|
||||
const last = await terminal.json();
|
||||
expect(last.items.map((item: { id: string }) => item.id)).toEqual(["100000000000000008"]);
|
||||
expect(last.nextCursor).toBeNull();
|
||||
});
|
||||
it.each(routes)("documents Retry-After on $path upstream rate limits", async ({ path, route }) => {
|
||||
fetcher.mockImplementation(async () => Response.json({ retry_after: 2.1 }, { status: 429 }));
|
||||
const response = await route.GET(new Request(`https://portal.example${path.replace("{id}", id)}`), context);
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers.get("retry-after")).toBe("3");
|
||||
await assertResponse(path, "get", response);
|
||||
});
|
||||
it.each(routes)("documents every explicitly rejected write method for $path", async ({ path, route }) => {
|
||||
for (const method of ["POST", "PUT", "PATCH", "DELETE", "OPTIONS"] as const) {
|
||||
const response = await route[method](new Request(`https://portal.example${path.replace("{id}", id)}`, { method }));
|
||||
expect(response.status).toBe(405);
|
||||
await assertResponse(path, method, response);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
import { assertResponse } from "@/test/openapi-contract";
|
||||
import { GET as routeGet, POST as routePost } from "./route";
|
||||
import { GET as routeDetail } from "./[id]/route";
|
||||
import { GET as routeMessages } from "./[id]/messages/route";
|
||||
async function GET(request: Request) {
|
||||
const response = await routeGet(request);
|
||||
await assertResponse("/api/suggestions", "get", response);
|
||||
return response;
|
||||
}
|
||||
async function POST(request: Request) {
|
||||
const response = await routePost(request);
|
||||
await assertResponse("/api/suggestions", "post", response);
|
||||
return response;
|
||||
}
|
||||
async function detail(request: Request, context: Parameters<typeof routeDetail>[1]) {
|
||||
const response = await routeDetail(request, context);
|
||||
await assertResponse("/api/suggestions/{id}", "get", response);
|
||||
return response;
|
||||
}
|
||||
async function messages(request: Request, context: Parameters<typeof routeMessages>[1]) {
|
||||
const response = await routeMessages(request, context);
|
||||
await assertResponse("/api/suggestions/{id}/messages", "get", response);
|
||||
return response;
|
||||
}
|
||||
const context = { params: Promise.resolve({ id: "100000000000000009" }) };
|
||||
|
||||
const request = () => new Request("https://portal.example/api/suggestions");
|
||||
it.each(["Bearer invalid", "Basic invalid", "", "Bearer", "Bearer a, Bearer b"])("rejects supplied authorization %j without session fallback", async (authorization) => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
const fetcher = vi.fn();
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
const response = await GET(new Request(request(), { headers: { authorization } }));
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get("www-authenticate")).toBe('Bearer realm="admin-api"');
|
||||
expect(response.headers.get("content-type")).toBe("application/problem+json");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toMatchObject({ type: "urn:error:unauthorized", status: 401, instance: "/api/suggestions" });
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
|
||||
it("serves suggestions to the existing admin session using runtime env configuration", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
vi.stubEnv("DISCORD_BOT_TOKEN", "test-token");
|
||||
vi.stubEnv("DISCORD_GUILD_ID", "100000000000000001");
|
||||
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", "100000000000000002");
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ id: "100000000000000002", guild_id: "100000000000000001", type: 15, available_tags: [] })).mockResolvedValueOnce(Response.json({ threads: [] }));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
const response = await GET(request());
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ items: [], nextCursor: null });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it.each([detail, messages])("independently protects detail and message routes", async (handler) => {
|
||||
const fetcher = vi.fn();
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
auth.session.mockResolvedValue(null);
|
||||
expect((await handler(request(), context)).status).toBe(401);
|
||||
auth.session.mockResolvedValue({ user: { roles: ["player"] } });
|
||||
expect((await handler(request(), context)).status).toBe(403);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["?limit=0", "?limit=101", "?limit=1.2", "?limit=", "?limit=1&limit=2", "?channel=100000000000000099", "?status=all", "?cursor=../secret"])('rejects invalid query %s without contacting Discord', async (query) => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
const fetcher = vi.fn();
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
const response = await GET(new Request(`https://portal.example/api/suggestions${query}`));
|
||||
expect(response.status).toBe(400);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it("returns a read-only problem for writes", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
const response = await POST(request());
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET, HEAD");
|
||||
});
|
||||
it("reports missing configuration without exposing environment values", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", "");
|
||||
const response = await GET(request());
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toMatchObject({ type: "urn:error:suggestions-not-configured", status: 503, instance: "/api/suggestions" });
|
||||
});
|
||||
it("returns sanitized problems for unexpected failures", async () => {
|
||||
auth.session.mockRejectedValue(new Error("private session details"));
|
||||
const response = await GET(request());
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.text()).not.toContain("private session details");
|
||||
});
|
||||
it("rejects a signed-in user without the required admin role", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["player"] } });
|
||||
expect((await GET(request())).status).toBe(403);
|
||||
});
|
||||
it("rejects unauthenticated readers with a JSON problem instead of a redirect", async () => {
|
||||
auth.session.mockResolvedValue(null);
|
||||
const response = await GET(request());
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get("content-type")).toBe("application/problem+json");
|
||||
expect(response.headers.get("location")).toBeNull();
|
||||
expect(response.headers.get("www-authenticate")).toBe('Bearer realm="admin-api"');
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { suggestionQuery, suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export function GET(request: Request) {
|
||||
return suggestionsApi(request, (client) => client.list(suggestionQuery(request, true)));
|
||||
}
|
||||
|
||||
export const POST = suggestionsReadOnly;
|
||||
export const PUT = suggestionsReadOnly;
|
||||
export const PATCH = suggestionsReadOnly;
|
||||
export const DELETE = suggestionsReadOnly;
|
||||
export const OPTIONS = suggestionsReadOnly;
|
||||
@@ -0,0 +1,142 @@
|
||||
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 { assertResponse } from "@/test/openapi-contract";
|
||||
import { POST as routePost } from "./route";
|
||||
async function POST(request: Request) {
|
||||
const response = await routePost(request);
|
||||
await assertResponse("/api/velocity/access", "post", response);
|
||||
return response;
|
||||
}
|
||||
|
||||
const messages = {
|
||||
registrationMessage: "Register {player} in {group}.",
|
||||
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" }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GET, POST } from "./route";
|
||||
import { assertResponse } from "@/test/openapi-contract";
|
||||
import { GET as routeGet, POST as routePost } from "./route";
|
||||
async function GET(request: Request) {
|
||||
const response = routeGet(request);
|
||||
await assertResponse("/api/velocity/access", "get", response);
|
||||
return response;
|
||||
}
|
||||
async function POST(request: Request) {
|
||||
const response = await routePost(request);
|
||||
await assertResponse("/api/velocity/access", "post", response);
|
||||
return response;
|
||||
}
|
||||
|
||||
describe("Velocity access API problems", () => {
|
||||
it("returns RFC 9457 for unsupported methods", async () => {
|
||||
const response = GET(new Request("http://localhost/api/velocity/access"));
|
||||
const response = await GET(new Request("http://localhost/api/velocity/access"));
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("POST");
|
||||
|
||||
@@ -1,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);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertResponse } from "@/test/openapi-contract";
|
||||
import { hashToken } from "@minecraft-account-manager/auth";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -38,7 +39,18 @@ vi.mock("@/lib/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET, POST } from "./route";
|
||||
import { GET as routeGet, POST as routePost } from "./route";
|
||||
|
||||
async function GET(request: Request) {
|
||||
const response = routeGet(request);
|
||||
await assertResponse("/api/velocity/connection", "get", response);
|
||||
return response;
|
||||
}
|
||||
async function POST(request: Request) {
|
||||
const response = await routePost(request);
|
||||
await assertResponse("/api/velocity/connection", "post", response);
|
||||
return response;
|
||||
}
|
||||
|
||||
function validRequest(overrides: Record<string, unknown> = {}) {
|
||||
return new Request("http://localhost/api/velocity/connection", {
|
||||
@@ -64,7 +76,7 @@ describe("Velocity connection reporting endpoint", () => {
|
||||
});
|
||||
|
||||
it("rejects methods other than POST with Problem Details", async () => {
|
||||
const response = GET(new Request("http://localhost/api/velocity/connection"));
|
||||
const response = await GET(new Request("http://localhost/api/velocity/connection"));
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("content-type")).toContain("application/problem+json");
|
||||
expect(response.headers.get("allow")).toBe("POST");
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("health endpoint", () => {
|
||||
it("reports process readiness without requiring external services", async () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it("reports process readiness and the immutable build version without requiring external services", async () => {
|
||||
vi.stubEnv("APP_VERSION", "1.19.0");
|
||||
|
||||
const response = GET();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toEqual({ status: "ok" });
|
||||
expect(await response.json()).toEqual({ status: "ok", version: "1.19.0" });
|
||||
});
|
||||
|
||||
it("reports a development version when no build version is supplied", async () => {
|
||||
vi.stubEnv("APP_VERSION", "");
|
||||
|
||||
expect(await GET().json()).toEqual({ status: "ok", version: "development" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export function GET(): Response {
|
||||
return Response.json(
|
||||
{ status: "ok" },
|
||||
{ status: "ok", version: process.env.APP_VERSION?.trim() || "development" },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-static";
|
||||
|
||||
/** Build-time snapshot of the single source; no YAML parsing or rewriting. */
|
||||
export async function GET() {
|
||||
const specification = await readFile(resolve(process.cwd(), "../../openapi.yaml"));
|
||||
return new Response(specification, {
|
||||
headers: { "content-type": "application/yaml; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export function AdminModalForm({
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [dialogGeneration, setDialogGeneration] = useState(0);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -36,7 +37,10 @@ export function AdminModalForm({
|
||||
aria-pressed={triggerPressed}
|
||||
className={triggerClassName ?? "font-mono text-[10px] font-bold uppercase underline underline-offset-4"}
|
||||
disabled={submitting}
|
||||
onClick={() => dialogRef.current?.showModal()}
|
||||
onClick={() => {
|
||||
setDialogGeneration((generation) => generation + 1);
|
||||
dialogRef.current?.showModal();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{triggerLabel}
|
||||
@@ -44,7 +48,7 @@ export function AdminModalForm({
|
||||
<dialog
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
className="admin-modal m-auto w-[min(92vw,36rem)] border border-ink bg-panel p-0 text-ink shadow-[10px_10px_0_var(--color-shadow)] backdrop:bg-ink/70"
|
||||
className="admin-modal m-auto max-h-[90vh] w-[min(92vw,36rem)] overflow-y-auto border border-ink bg-panel p-0 text-ink shadow-[10px_10px_0_var(--color-shadow)] backdrop:bg-ink/70"
|
||||
onCancel={(event) => { if (submitting) event.preventDefault(); }}
|
||||
ref={dialogRef}
|
||||
>
|
||||
@@ -52,7 +56,7 @@ export function AdminModalForm({
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-[0.2em] text-accent">Confirm operation</p>
|
||||
<h2 className="mt-3 font-display text-3xl font-black uppercase" id={titleId}>{title}</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-muted" id={descriptionId}>{description}</p>
|
||||
{children && <div className="mt-6">{children}</div>}
|
||||
{children && <div className="mt-6" key={dialogGeneration}>{children}</div>}
|
||||
<ModalActions dialogRef={dialogRef} intent={intent} onPendingChange={setSubmitting} submitLabel={submitLabel} />
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { AdminModalForm } from "./admin-modal-form";
|
||||
import { GroupScheduleEditor, GroupScheduleSummary } from "./group-schedule-editor";
|
||||
|
||||
beforeEach(() => {
|
||||
HTMLDialogElement.prototype.showModal = function showModal() { this.open = true; };
|
||||
HTMLDialogElement.prototype.close = function close() {
|
||||
this.open = false;
|
||||
this.dispatchEvent(new Event("close"));
|
||||
};
|
||||
});
|
||||
|
||||
describe("GroupScheduleEditor", () => {
|
||||
it("shows UTC authority, browser-local equivalents, and repeatable windows", () => {
|
||||
const { container } = render(<GroupScheduleEditor windows={[{
|
||||
startMinuteOfWeek: 6960,
|
||||
endMinuteOfWeek: 7199,
|
||||
}]} />);
|
||||
|
||||
expect(screen.getByText(/stored and enforced in UTC/i)).toBeTruthy();
|
||||
expect(screen.getAllByRole("group", { name: /access window/i })).toHaveLength(1);
|
||||
expect(container.querySelectorAll('input[name="startMinuteOfWeek"]')).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add window/i }));
|
||||
expect(screen.getAllByRole("group", { name: /access window/i })).toHaveLength(2);
|
||||
expect(container.querySelectorAll('input[name="startMinuteOfWeek"]')).toHaveLength(2);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /remove window/i })[0]!);
|
||||
expect(screen.getAllByRole("group", { name: /access window/i })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("discards an abandoned draft when its confirmation dialog is reopened", () => {
|
||||
render(<AdminModalForm action={async () => undefined} description="Confirm schedule." submitLabel="Save schedule" title="Schedule group" triggerLabel="Edit schedule"><GroupScheduleEditor windows={[{ startMinuteOfWeek: 6960, endMinuteOfWeek: 7200 }]} /></AdminModalForm>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit schedule" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Add window" }));
|
||||
expect(within(dialog).getAllByRole("group", { name: /access window/i })).toHaveLength(2);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit schedule" }));
|
||||
expect(within(dialog).getAllByRole("group", { name: /access window/i })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("summarizes an unrestricted group and configured local equivalents", () => {
|
||||
const { container, rerender } = render(<GroupScheduleSummary windows={[]} />);
|
||||
expect(container.textContent).toMatch(/no schedule restrictions/i);
|
||||
|
||||
rerender(<GroupScheduleSummary windows={[{ startMinuteOfWeek: 6960, endMinuteOfWeek: 7199 }]} />);
|
||||
expect(container.textContent).toMatch(/current browser-local equivalent/i);
|
||||
expect(container.textContent).toContain("Friday 20:00 UTC");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
formatWeeklyMinute,
|
||||
localWindowToUtc,
|
||||
utcWindowToLocal,
|
||||
type WeeklyAccessWindow,
|
||||
} from "@/lib/group-schedule";
|
||||
|
||||
const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const;
|
||||
|
||||
interface EditableWindow extends WeeklyAccessWindow {
|
||||
key: number;
|
||||
}
|
||||
|
||||
function minuteParts(minuteOfWeek: number) {
|
||||
const day = Math.floor(minuteOfWeek / 1440);
|
||||
const minute = minuteOfWeek % 1440;
|
||||
return {
|
||||
day,
|
||||
time: `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
function withDay(minuteOfWeek: number, day: number) {
|
||||
return day * 1440 + (minuteOfWeek % 1440);
|
||||
}
|
||||
|
||||
function withTime(minuteOfWeek: number, time: string) {
|
||||
const [hour, minute] = time.split(":").map(Number);
|
||||
return Math.floor(minuteOfWeek / 1440) * 1440 + (hour ?? 0) * 60 + (minute ?? 0);
|
||||
}
|
||||
|
||||
const subscribeToBrowserClock = () => () => undefined;
|
||||
|
||||
function useBrowserClock() {
|
||||
const offset = useSyncExternalStore(
|
||||
subscribeToBrowserClock,
|
||||
() => new Date().getTimezoneOffset(),
|
||||
() => 0,
|
||||
);
|
||||
const zone = useSyncExternalStore(
|
||||
subscribeToBrowserClock,
|
||||
() => Intl.DateTimeFormat().resolvedOptions().timeZone || "browser local time",
|
||||
() => "UTC",
|
||||
);
|
||||
return { offset, zone };
|
||||
}
|
||||
|
||||
export function GroupScheduleEditor({ windows }: { windows: WeeklyAccessWindow[] }) {
|
||||
const { offset, zone } = useBrowserClock();
|
||||
const [editable, setEditable] = useState<EditableWindow[]>(
|
||||
windows.map((window, key) => ({ ...window, key })),
|
||||
);
|
||||
const nextKey = useRef(windows.length);
|
||||
|
||||
function update(key: number, field: "startMinuteOfWeek" | "endMinuteOfWeek", value: number) {
|
||||
setEditable((current) => current.map((window) => {
|
||||
if (window.key !== key) return window;
|
||||
const local = { ...utcWindowToLocal(window, offset), [field]: value };
|
||||
return { ...localWindowToUtc(local, offset), key };
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm leading-6 text-muted">
|
||||
Schedules are stored and enforced in UTC. The editor shows the current browser-local equivalent in <strong className="text-ink">{zone}</strong>; it may shift when your local daylight-saving offset changes.
|
||||
</p>
|
||||
{!editable.length && <p className="border-l-2 border-signal pl-4 text-sm">No windows means no schedule restrictions while Minecraft access is enabled.</p>}
|
||||
{editable.map((window, index) => {
|
||||
const local = utcWindowToLocal(window, offset);
|
||||
const start = minuteParts(local.startMinuteOfWeek);
|
||||
const end = minuteParts(local.endMinuteOfWeek);
|
||||
return (
|
||||
<fieldset aria-label={`Access window ${index + 1}`} className="border border-line p-4" key={window.key}>
|
||||
<legend className="px-2 font-mono text-[10px] font-bold uppercase tracking-wider">Access window {index + 1}</legend>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<ScheduleBoundary day={start.day} label="Starts" onDay={(day) => update(window.key, "startMinuteOfWeek", withDay(local.startMinuteOfWeek, day))} onTime={(time) => update(window.key, "startMinuteOfWeek", withTime(local.startMinuteOfWeek, time))} time={start.time} />
|
||||
<ScheduleBoundary day={end.day} label="Ends (exclusive)" onDay={(day) => update(window.key, "endMinuteOfWeek", withDay(local.endMinuteOfWeek, day))} onTime={(time) => update(window.key, "endMinuteOfWeek", withTime(local.endMinuteOfWeek, time))} time={end.time} />
|
||||
</div>
|
||||
<input name="startMinuteOfWeek" type="hidden" value={window.startMinuteOfWeek} />
|
||||
<input name="endMinuteOfWeek" type="hidden" value={window.endMinuteOfWeek} />
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="font-mono text-[9px] uppercase text-muted">UTC: {formatWeeklyMinute(window.startMinuteOfWeek)}–{formatWeeklyMinute(window.endMinuteOfWeek)}</p>
|
||||
<button className="font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4" onClick={() => setEditable((current) => current.filter((item) => item.key !== window.key))} type="button">Remove window {index + 1}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
className="border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={editable.length >= 50}
|
||||
onClick={() => {
|
||||
const key = nextKey.current++;
|
||||
setEditable((current) => [...current, {
|
||||
key,
|
||||
...localWindowToUtc({
|
||||
startMinuteOfWeek: 4 * 1440 + 20 * 60,
|
||||
endMinuteOfWeek: 5 * 1440,
|
||||
}, offset),
|
||||
}]);
|
||||
}}
|
||||
type="button"
|
||||
>Add window</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleBoundary({ day, label, onDay, onTime, time }: {
|
||||
day: number;
|
||||
label: string;
|
||||
onDay: (day: number) => void;
|
||||
onTime: (time: string) => void;
|
||||
time: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<span className="block text-xs font-bold">{label}</span>
|
||||
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2">
|
||||
<label><span className="sr-only">{label} weekday</span><select className="w-full border border-line bg-canvas px-3 py-2 text-sm" onChange={(event) => onDay(Number(event.target.value))} value={day}>{DAYS.map((name, value) => <option key={name} value={value}>{name}</option>)}</select></label>
|
||||
<label><span className="sr-only">{label} time</span><input className="border border-line bg-canvas px-3 py-2 text-sm" onChange={(event) => onTime(event.target.value)} required type="time" value={time} /></label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupScheduleSummary({ windows }: { windows: WeeklyAccessWindow[] }) {
|
||||
const { offset, zone } = useBrowserClock();
|
||||
if (!windows.length) return <p className="text-sm text-muted">No schedule restrictions. Enabled members may attempt to join at any time.</p>;
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted">Current browser-local equivalent: {zone}. UTC remains authoritative.</p>
|
||||
<ol className="mt-3 space-y-2">
|
||||
{windows.map((window, index) => {
|
||||
const local = utcWindowToLocal(window, offset);
|
||||
return <li className="border-l-2 border-accent pl-3 text-sm" key={`${window.startMinuteOfWeek}-${window.endMinuteOfWeek}-${index}`}><span className="font-bold">{formatWeeklyMinute(local.startMinuteOfWeek)}–{formatWeeklyMinute(local.endMinuteOfWeek)}</span><span className="mt-1 block font-mono text-[9px] uppercase text-muted">{formatWeeklyMinute(window.startMinuteOfWeek)} UTC–{formatWeeklyMinute(window.endMinuteOfWeek)} UTC</span></li>;
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const actionMocks = vi.hoisted(() => ({
|
||||
execute: vi.fn(async (_previous: unknown, formData: FormData) => ({
|
||||
status: "success" as const,
|
||||
message: `Executed ${String(formData.get("command") ?? "")}`,
|
||||
serverId: String(formData.get("serverId") ?? ""),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/admin/(console)/rcon/actions", () => ({
|
||||
createRconServer: vi.fn(),
|
||||
deleteRconServer: vi.fn(),
|
||||
executeRconCommand: actionMocks.execute,
|
||||
setRconServerEnabled: vi.fn(),
|
||||
testSavedRconServer: vi.fn(),
|
||||
updateRconServer: vi.fn(),
|
||||
}));
|
||||
|
||||
import { RconConsole } from "./rcon-console";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const server = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
name: "Season 4",
|
||||
host: "season4.somc.svc.cluster.local",
|
||||
port: 25575,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const creative = {
|
||||
...server,
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
name: "Creative",
|
||||
host: "creative.example.com",
|
||||
};
|
||||
|
||||
describe("RconConsole", () => {
|
||||
it("renders one wide terminal workspace with connection controls and modal forms", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole servers={[server]} />);
|
||||
expect(markup).toContain('aria-label="RCON terminal"');
|
||||
expect(markup).toContain('for="rcon-console-server"');
|
||||
expect(markup).toContain('for="rcon-command"');
|
||||
expect(markup).toContain("w-full");
|
||||
expect(markup).toContain("Season 4");
|
||||
expect(markup).toContain("server://");
|
||||
expect(markup).toContain("Awaiting command");
|
||||
expect(markup).toContain("Add");
|
||||
expect(markup).toContain("Edit");
|
||||
expect(markup).toContain("Test");
|
||||
expect(markup).toContain("Disable");
|
||||
expect(markup).toContain("Delete");
|
||||
expect(markup).toContain("Add RCON connection");
|
||||
expect(markup).toContain('href="/admin/rcon/history"');
|
||||
expect(markup).toContain("Command history");
|
||||
expect(markup).toContain("Edit Season 4");
|
||||
expect(markup).toContain("Delete Season 4?");
|
||||
expect(markup).toContain("Enter ↵");
|
||||
expect(markup).not.toContain("Latest response");
|
||||
});
|
||||
|
||||
it("renders connection operation notices inside the terminal viewport", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole notice={{ status: "error", message: "RCON authentication timed out." }} servers={[server]} />);
|
||||
expect(markup).toContain("RCON authentication timed out.");
|
||||
expect(markup).toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("keeps the terminal and add action available when no connection exists", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole servers={[]} />);
|
||||
expect(markup).toContain('aria-label="RCON terminal"');
|
||||
expect(markup).toContain("No connections configured");
|
||||
expect(markup).toContain("Add");
|
||||
expect(markup).not.toContain("Edit");
|
||||
expect(markup).not.toContain("Delete");
|
||||
});
|
||||
|
||||
it("navigates page-memory command history and restores the unsent draft", async () => {
|
||||
render(<RconConsole servers={[server]} />);
|
||||
const input = screen.getByLabelText("Command") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "list" } });
|
||||
fireEvent.submit(input.form!);
|
||||
await waitFor(() => expect(screen.getByText("Executed list")).toBeTruthy());
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(input.value).toBe("");
|
||||
|
||||
fireEvent.change(input, { target: { value: "say hello" } });
|
||||
fireEvent.submit(input.form!);
|
||||
await waitFor(() => expect(screen.getByText("Executed say hello")).toBeTruthy());
|
||||
|
||||
fireEvent.change(input, { target: { value: "draft command" } });
|
||||
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||
expect(input.value).toBe("say hello");
|
||||
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||
expect(input.value).toBe("list");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
expect(input.value).toBe("say hello");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
expect(input.value).toBe("draft command");
|
||||
});
|
||||
|
||||
it("retains chronological command and response exchanges in the terminal transcript", async () => {
|
||||
render(<RconConsole servers={[server]} />);
|
||||
const input = screen.getByLabelText("Command") as HTMLInputElement;
|
||||
|
||||
let listResponses = 0;
|
||||
for (const command of ["list", "say hello", "list"]) {
|
||||
fireEvent.change(input, { target: { value: command } });
|
||||
fireEvent.submit(input.form!);
|
||||
if (command === "list") listResponses += 1;
|
||||
await waitFor(() => expect(screen.getAllByText(`Executed ${command}`)).toHaveLength(command === "list" ? listResponses : 1));
|
||||
}
|
||||
|
||||
const transcript = screen.getByLabelText("Terminal transcript");
|
||||
const text = transcript.textContent ?? "";
|
||||
expect(text.indexOf("$ list")).toBeLessThan(text.indexOf("Executed list"));
|
||||
expect(text.indexOf("Executed list")).toBeLessThan(text.indexOf("$ say hello"));
|
||||
expect(text.indexOf("$ say hello")).toBeLessThan(text.indexOf("Executed say hello"));
|
||||
expect(screen.getAllByText("Executed list")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns focus to the command prompt after changing servers", async () => {
|
||||
render(<RconConsole servers={[server, creative]} />);
|
||||
const select = screen.getByLabelText("Server");
|
||||
select.focus();
|
||||
fireEvent.change(select, { target: { value: creative.id } });
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText("Command")));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
createRconServer,
|
||||
deleteRconServer,
|
||||
executeRconCommand,
|
||||
setRconServerEnabled,
|
||||
testSavedRconServer,
|
||||
type RconCommandState,
|
||||
updateRconServer,
|
||||
} from "@/app/admin/(console)/rcon/actions";
|
||||
import { AdminModalForm } from "@/components/admin-modal-form";
|
||||
|
||||
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
|
||||
const MAX_COMMAND_HISTORY = 50;
|
||||
const MAX_TRANSCRIPT_EXCHANGES = 50;
|
||||
|
||||
type TranscriptExchange = {
|
||||
id: number;
|
||||
serverName: string;
|
||||
command: string;
|
||||
status: "pending" | "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type RconServerOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type RconTerminalNotice = {
|
||||
status: "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function RconConsole({
|
||||
notice,
|
||||
servers,
|
||||
}: {
|
||||
notice?: RconTerminalNotice;
|
||||
servers: RconServerOption[];
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState(servers[0]?.id ?? "");
|
||||
const [state, action, pending] = useActionState(executeRconCommand, initialState);
|
||||
const [command, setCommand] = useState("");
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const [transcript, setTranscript] = useState<TranscriptExchange[]>([]);
|
||||
const draftRef = useRef("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const nextExchangeIdRef = useRef(0);
|
||||
const pendingExchangeIdRef = useRef<number | null>(null);
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending && state.status !== "idle") inputRef.current?.focus();
|
||||
}, [pending, state.status]);
|
||||
|
||||
useEffect(() => {
|
||||
const exchangeId = pendingExchangeIdRef.current;
|
||||
if (exchangeId === null || state.status === "idle") return;
|
||||
const resultStatus: TranscriptExchange["status"] = state.status === "error" ? "error" : "success";
|
||||
setTranscript((current) => current.map((exchange) => exchange.id === exchangeId
|
||||
? { ...exchange, status: resultStatus, message: state.message }
|
||||
: exchange));
|
||||
pendingExchangeIdRef.current = null;
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (transcriptRef.current) transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
|
||||
}, [transcript]);
|
||||
|
||||
function navigateHistory(direction: "older" | "newer") {
|
||||
if (!history.length) return;
|
||||
if (direction === "older") {
|
||||
const nextIndex = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
|
||||
if (historyIndex === null) draftRef.current = command;
|
||||
setHistoryIndex(nextIndex);
|
||||
setCommand(history[nextIndex]!);
|
||||
return;
|
||||
}
|
||||
if (historyIndex === null) return;
|
||||
if (historyIndex < history.length - 1) {
|
||||
const nextIndex = historyIndex + 1;
|
||||
setHistoryIndex(nextIndex);
|
||||
setCommand(history[nextIndex]!);
|
||||
} else {
|
||||
setHistoryIndex(null);
|
||||
setCommand(draftRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberSubmittedCommand() {
|
||||
const submitted = command.trim();
|
||||
if (!submitted || !selected) return;
|
||||
const exchangeId = ++nextExchangeIdRef.current;
|
||||
pendingExchangeIdRef.current = exchangeId;
|
||||
const exchange: TranscriptExchange = {
|
||||
id: exchangeId,
|
||||
serverName: selected.name,
|
||||
command: submitted,
|
||||
status: "pending",
|
||||
message: "Command in progress…",
|
||||
};
|
||||
setTranscript((current) => [...current, exchange].slice(-MAX_TRANSCRIPT_EXCHANGES));
|
||||
setHistory((current) => [...current, submitted].slice(-MAX_COMMAND_HISTORY));
|
||||
setHistoryIndex(null);
|
||||
draftRef.current = "";
|
||||
setCommand("");
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="RCON terminal" className="mt-8 w-full overflow-hidden border-2 border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<div className="flex flex-col gap-4 border-b-2 border-ink bg-canvas px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2 font-mono text-[10px] font-bold uppercase tracking-wider text-muted">
|
||||
<span aria-hidden="true" className={`size-2 rounded-full shadow-[0_0_0_1px_var(--color-ink)] ${selected?.enabled ? "bg-signal" : "bg-line"}`} />
|
||||
<span>server://</span>
|
||||
</div>
|
||||
{servers.length ? (
|
||||
<label className="flex min-w-0 items-center gap-2 font-mono text-[9px] font-bold uppercase tracking-wider" htmlFor="rcon-console-server">
|
||||
<span className="sr-only">Server</span>
|
||||
<select
|
||||
className="max-w-full border border-line bg-panel px-3 py-2 font-mono text-xs font-bold normal-case outline-none focus:border-accent"
|
||||
id="rcon-console-server"
|
||||
onChange={(event) => setSelectedId(event.target.value)}
|
||||
value={selected?.id}
|
||||
>
|
||||
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}{server.enabled ? "" : " — disabled"}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<span className="font-mono text-xs font-bold text-muted">no-target</span>
|
||||
)}
|
||||
{selected && <span className="font-mono text-[9px] text-muted">{selected.host}:{selected.port}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" href="/admin/rcon/history">Command history</Link>
|
||||
<ConnectionModal mode="add" />
|
||||
{selected && (
|
||||
<>
|
||||
<form action={testSavedRconServer}>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<HeaderButton label="Test" />
|
||||
</form>
|
||||
<form action={setRconServerEnabled}>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<input name="enabled" type="hidden" value={selected.enabled ? "no" : "yes"} />
|
||||
<HeaderButton label={selected.enabled ? "Disable" : "Enable"} />
|
||||
</form>
|
||||
<ConnectionModal mode="edit" server={selected} />
|
||||
<AdminModalForm
|
||||
action={deleteRconServer}
|
||||
description={`Delete ${selected.name} and its encrypted credential. This cannot be undone.`}
|
||||
intent="danger"
|
||||
submitLabel="Delete connection"
|
||||
title={`Delete ${selected.name}?`}
|
||||
triggerClassName="border border-accent px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-accent"
|
||||
triggerLabel="Delete"
|
||||
>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<input name="confirmation" type="hidden" value={selected.id} />
|
||||
</AdminModalForm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div aria-label="Terminal transcript" aria-live="polite" aria-relevant="additions text" className="min-h-72 max-h-[32rem] overflow-auto p-5 font-mono text-xs leading-5" ref={transcriptRef} role="status">
|
||||
{notice && (
|
||||
<div className={`mb-5 border-l-2 pl-3 ${notice.status === "error" ? "border-accent" : "border-signal"}`} role={notice.status === "error" ? "alert" : "status"}>
|
||||
<p className={`text-[9px] font-bold uppercase tracking-wider ${notice.status === "error" ? "text-accent" : "text-muted"}`}>{notice.status === "error" ? "Connection error" : "Connection update"}</p>
|
||||
<p className="mt-2">{notice.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{!transcript.length && <TerminalIdle selected={selected} />}
|
||||
<div className="space-y-6">
|
||||
{transcript.map((exchange) => (
|
||||
<article className="border-l-2 border-line pl-3" key={exchange.id}>
|
||||
<p className="break-words">
|
||||
<span className="mr-2 text-[9px] font-bold uppercase tracking-wider text-muted">server://{exchange.serverName}</span>
|
||||
<span className="text-accent">$</span> {exchange.command}
|
||||
</p>
|
||||
<div className={`mt-2 ${exchange.status === "error" ? "text-accent" : "text-ink"}`} role={exchange.status === "error" ? "alert" : undefined}>
|
||||
{exchange.status === "pending" ? <p className="text-muted">Command in progress…</p> : <pre className="whitespace-pre-wrap break-words font-mono text-xs leading-5">{exchange.message}</pre>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3" onSubmit={rememberSubmittedCommand}>
|
||||
<input name="serverId" type="hidden" value={selected?.id ?? ""} />
|
||||
<span aria-hidden="true" className="font-mono text-lg font-black text-accent">$</span>
|
||||
<label className="sr-only" htmlFor="rcon-command">Command</label>
|
||||
<input
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
className="min-w-0 flex-1 bg-transparent px-1 py-2 font-mono text-sm outline-none placeholder:text-muted focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!selected?.enabled || pending}
|
||||
id="rcon-command"
|
||||
key={selected?.id ?? "no-server"}
|
||||
maxLength={1024}
|
||||
name="command"
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
navigateHistory(event.key === "ArrowUp" ? "older" : "newer");
|
||||
}
|
||||
}}
|
||||
placeholder={selected ? (selected.enabled ? "list" : "Enable this connection to run commands") : "Add a connection to begin"}
|
||||
ref={inputRef}
|
||||
required
|
||||
spellCheck={false}
|
||||
value={command}
|
||||
/>
|
||||
<button className="border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas disabled:cursor-not-allowed disabled:opacity-50" disabled={!selected?.enabled || pending} type="submit">{pending ? "Running…" : "Enter ↵"}</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalIdle({ selected }: { selected?: RconServerOption }) {
|
||||
if (!selected) return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready</p><p className="mt-3">No connections configured. Use Add to create a server connection.</p></>;
|
||||
if (!selected.enabled) return <><p className="text-[9px] font-bold uppercase tracking-wider text-accent">Disabled — {selected.name}</p><p className="mt-3">Enable this connection before testing commands.</p></>;
|
||||
return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready — {selected.name}</p><p className="mt-3">Awaiting command</p></>;
|
||||
}
|
||||
|
||||
function HeaderButton({ label }: { label: string }) {
|
||||
return <button className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" type="submit">{label}</button>;
|
||||
}
|
||||
|
||||
function ConnectionModal({
|
||||
mode,
|
||||
server,
|
||||
}: {
|
||||
mode: "add" | "edit";
|
||||
server?: RconServerOption;
|
||||
}) {
|
||||
const editing = mode === "edit" ? server : undefined;
|
||||
return (
|
||||
<AdminModalForm
|
||||
action={editing ? updateRconServer : createRconServer}
|
||||
description={editing ? `Update ${editing.name}. Leave the password blank to preserve its encrypted credential.` : "Add an internal or external RCON server address. The password is encrypted before storage."}
|
||||
submitLabel={editing ? "Save connection" : "Add connection"}
|
||||
title={editing ? `Edit ${editing.name}` : "Add RCON connection"}
|
||||
triggerClassName={editing ? "border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider" : "border border-ink bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas"}
|
||||
triggerLabel={editing ? "Edit" : "Add"}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{editing && <input name="serverId" type="hidden" value={editing.id} />}
|
||||
<ConnectionFields defaults={editing} prefix={editing?.id ?? "new"} />
|
||||
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase">
|
||||
<input className="size-4" defaultChecked={editing?.enabled ?? false} name="enabled" type="checkbox" value="yes" />
|
||||
{editing ? "Enabled" : "Enable immediately"}
|
||||
</label>
|
||||
</div>
|
||||
</AdminModalForm>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionFields({
|
||||
defaults,
|
||||
prefix,
|
||||
}: {
|
||||
defaults?: { name: string; host: string; port: number };
|
||||
prefix: string;
|
||||
}) {
|
||||
const fieldClass = "mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent";
|
||||
return (
|
||||
<>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-name`}>Name<input className={fieldClass} defaultValue={defaults?.name} id={`${prefix}-rcon-name`} maxLength={100} name="name" required /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-host`}>Server address<input autoCapitalize="none" autoCorrect="off" className={fieldClass} defaultValue={defaults?.host} id={`${prefix}-rcon-host`} maxLength={253} name="host" placeholder="minecraft.example.com" required spellCheck={false} /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-port`}>Port<input className={fieldClass} defaultValue={defaults?.port ?? 25575} id={`${prefix}-rcon-port`} max={65535} min={1} name="port" required type="number" /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-password`}>{defaults ? "Replacement password" : "Password"}<input autoComplete="new-password" className={fieldClass} id={`${prefix}-rcon-password`} maxLength={512} name="password" required={!defaults} type="password" />{defaults && <span className="mt-2 block font-sans text-[10px] font-normal normal-case text-muted">Leave blank to preserve the current password.</span>}</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { SuggestionReader } from "./suggestion-reader";
|
||||
const id = "100000000000000009";
|
||||
const original = { id, author: { id: "100000000000000003", name: "Builder" }, content: "<script>unsafe()</script> Please build stations.", createdAt: "2026-01-01T00:00:00Z", editedAt: null, reactions: [{ emoji: "👍", count: 4 }], discordUrl: `https://discord.com/channels/100000000000000001/${id}/${id}` };
|
||||
const detail = { id, title: "More railway stations", authorId: original.author.id, createdAt: original.createdAt, archived: false, locked: false, tags: [{ id: "100000000000000004", name: "World" }], messageCount: 3, discordUrl: `https://discord.com/channels/100000000000000001/${id}`, originalPost: original };
|
||||
it("paginates discussion without duplicating the original post", async () => {
|
||||
const fetcher = vi.fn().mockImplementation(async (url: string) => {
|
||||
if (!url.includes("/messages")) return Response.json(detail);
|
||||
const older = url.includes("cursor=");
|
||||
return Response.json({ items: [original, { ...original, id: "100000000000000020", content: older ? "Earlier reply" : "Latest reply", editedAt: "2026-01-01T01:00:00Z" }], nextCursor: older ? null : "100000000000000020" });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionReader id={id} />);
|
||||
await screen.findByText("Latest reply");
|
||||
expect(screen.getAllByText(original.content)).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
|
||||
await screen.findByText("Earlier reply");
|
||||
expect(fetcher.mock.lastCall?.[0]).toBe(`/api/suggestions/${id}/messages?limit=25&cursor=100000000000000020`);
|
||||
expect(screen.getByText("2026-01-01T01:00:00Z")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Previous page" }));
|
||||
await screen.findByText("Latest reply");
|
||||
});
|
||||
it("distinguishes deleted starters and empty discussion", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => Response.json(url.includes("/messages") ? { items: [], nextCursor: null } : { ...detail, originalPost: null })));
|
||||
render(<SuggestionReader id={id} />);
|
||||
expect(await screen.findByText("The original post was deleted or is unavailable.")).toBeTruthy();
|
||||
expect(await screen.findByText("No replies on this page.")).toBeTruthy();
|
||||
});
|
||||
it("handles detail access denial without loading the discussion", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue(Response.json({}, { status: 403 }));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionReader id={id} />);
|
||||
await screen.findByRole("link", { name: "Admin sign-in" });
|
||||
expect(screen.queryByRole("heading", { name: "More railway stations" })).toBeNull();
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("makes empty message content explicit and retries discussion failures without writes", async () => {
|
||||
let messageReads = 0;
|
||||
const fetcher = vi.fn().mockImplementation(async (url: string) => {
|
||||
if (!url.includes("/messages")) return Response.json({ ...detail, originalPost: { ...original, content: "" } });
|
||||
messageReads += 1;
|
||||
return messageReads === 1 ? new Response(null, { status: 503 }) : Response.json({ items: [], nextCursor: null });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionReader id={id} />);
|
||||
await screen.findByText("No text was returned. View this message in Discord.");
|
||||
await screen.findByRole("alert");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
await screen.findByText("No replies on this page.");
|
||||
expect(fetcher.mock.calls.every(([, options]) => options.method === undefined || options.method === "GET")).toBe(true);
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
it("reads suggestion details and safely renders starter text, tags, reactions and Discord links", async () => {
|
||||
const fetcher = vi.fn().mockImplementation(async (url: string) => Response.json(url.includes("/messages") ? { items: [], nextCursor: null } : detail));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionReader id={id} />);
|
||||
expect(screen.getByRole("status").textContent).toContain("Loading suggestion");
|
||||
await screen.findByRole("heading", { name: "More railway stations", level: 1 });
|
||||
expect(screen.getByText(original.content)).toBeTruthy();
|
||||
expect(document.querySelector("script")).toBeNull();
|
||||
expect(screen.getByText("World")).toBeTruthy();
|
||||
expect(screen.getByText("👍 4")).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Open in Discord" }).getAttribute("href")).toBe(detail.discordUrl);
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe(`/api/suggestions/${id}`);
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import type { MessagePage, SuggestionDetail, SuggestionMessage } from "@/lib/discord/suggestion-types";
|
||||
import { SuggestionBadges, SuggestionFailure, SuggestionLoading, SuggestionPagination, suggestionControl, useSuggestionRead, useSuggestionPages, type SuggestionPaging } from "./suggestion-shared";
|
||||
|
||||
export function SuggestionReader({ id }: { id: string }) {
|
||||
const { data, error, retry } = useSuggestionRead<SuggestionDetail>(`/api/suggestions/${id}`);
|
||||
if (error) return <SuggestionFailure error={error} retry={retry} />;
|
||||
if (!data) return <SuggestionLoading>Loading suggestion…</SuggestionLoading>;
|
||||
return <>
|
||||
<header className="border-b border-line pb-8">
|
||||
<p className="mb-5 font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Community proposal / Read-only</p>
|
||||
<SuggestionBadges suggestion={data} />
|
||||
<h1 className="mt-5 break-words font-display text-4xl font-black sm:text-6xl">{data.title}</h1>
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-5">
|
||||
<p className="break-all font-mono text-[10px] leading-5 text-muted">Thread / {data.id}<br /><time dateTime={data.createdAt}>{data.createdAt}</time></p>
|
||||
<a className={`${suggestionControl} bg-ink text-panel`} href={data.discordUrl} rel="noopener noreferrer" target="_blank">Open in Discord</a>
|
||||
</div>
|
||||
</header>
|
||||
<section aria-labelledby="original-post-heading" className="mt-10 border border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<div className="border-b border-line px-6 py-4"><h2 className="font-mono text-[10px] font-bold uppercase tracking-[0.2em]" id="original-post-heading">Original suggestion</h2></div>
|
||||
{data.originalPost ? <MessageCard message={data.originalPost} /> : <p className="p-6 text-sm text-muted">The original post was deleted or is unavailable.</p>}
|
||||
</section>
|
||||
<Discussion id={id} />
|
||||
<p className="mt-6 text-xs leading-5 text-muted">Discord remains the source of truth. Open Discord for attachments, formatting, replies, and reactions. Message text may be unavailable if the bot lacks Message Content access.</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Discussion({ id }: { id: string }) {
|
||||
const paging = useSuggestionPages();
|
||||
const query = new URLSearchParams({ limit: "25" });
|
||||
if (paging.cursor) query.set("cursor", paging.cursor);
|
||||
const url = `/api/suggestions/${id}/messages?${query}`;
|
||||
return <section aria-labelledby="discussion-heading" className="mt-12">
|
||||
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<h2 className="font-display text-3xl font-black uppercase" id="discussion-heading">Discussion</h2>
|
||||
<p className="font-mono text-[10px] uppercase tracking-wider text-muted">Newest messages first / Read-only</p>
|
||||
</header>
|
||||
<DiscussionPage id={id} key={url} paging={paging} url={url} />
|
||||
</section>;
|
||||
}
|
||||
|
||||
function DiscussionPage({ id, url, paging }: { id: string; url: string; paging: SuggestionPaging }) {
|
||||
const { data, error, retry } = useSuggestionRead<MessagePage>(url);
|
||||
if (error) return <SuggestionFailure error={error} retry={retry} />;
|
||||
if (!data) return <SuggestionLoading>Loading discussion…</SuggestionLoading>;
|
||||
const replies = data.items.filter((message) => message.id !== id);
|
||||
return <>
|
||||
<div className="divide-y divide-line border border-line bg-panel">
|
||||
{replies.map((message) => <MessageCard key={message.id} message={message} />)}
|
||||
{!replies.length && <p className="p-8 text-sm text-muted">No replies on this page.</p>}
|
||||
</div>
|
||||
<SuggestionPagination nextCursor={data.nextCursor} {...paging} />
|
||||
</>;
|
||||
}
|
||||
|
||||
function MessageCard({ message }: { message: SuggestionMessage }) {
|
||||
return <article className="min-w-0 p-6 sm:p-8">
|
||||
<header className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h3 className="break-words font-display text-lg font-black">{message.author.name}</h3>
|
||||
<a aria-label={`Open message by ${message.author.name} in Discord`} className="font-mono text-[10px] text-muted underline underline-offset-4 hover:text-accent" href={message.discordUrl} rel="noopener noreferrer" target="_blank"><time dateTime={message.createdAt}>{message.createdAt}</time></a>
|
||||
</header>
|
||||
<p className="mt-5 whitespace-pre-wrap break-words text-sm leading-7 [overflow-wrap:anywhere]">{message.content || "No text was returned. View this message in Discord."}</p>
|
||||
{message.editedAt && <p className="mt-3 font-mono text-[10px] text-muted">Edited <time dateTime={message.editedAt}>{message.editedAt}</time></p>}
|
||||
{!!message.reactions.length && <ul aria-label="Reaction counts" className="mt-5 flex flex-wrap gap-2">{message.reactions.map((reaction, index) => <li className="border border-line bg-canvas px-3 py-1 font-mono text-xs" key={`${reaction.emoji}-${index}`}>{reaction.emoji} {reaction.count}</li>)}</ul>}
|
||||
</article>;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Suggestion } from "@/lib/discord/suggestion-types";
|
||||
|
||||
export const suggestionControl = "border border-ink px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-wider transition-colors hover:bg-ink hover:text-panel disabled:cursor-not-allowed disabled:opacity-40";
|
||||
|
||||
// Consumers remount on URL changes so previous-page data is never shown as new data.
|
||||
type ReadFailure = { status: number; message: string };
|
||||
class ReadError extends Error { constructor(public status: number) { super("Suggestions request failed"); } }
|
||||
function readFailure(error: unknown): ReadFailure {
|
||||
const status = error instanceof ReadError ? error.status : 0;
|
||||
const messages: Record<number, string> = {
|
||||
401: "Your admin session has expired. Sign in to continue.",
|
||||
403: "Your account does not have the required administrator role.",
|
||||
404: "This suggestion is no longer available in the configured forum.",
|
||||
503: "Discord suggestions are temporarily unavailable or not configured. Try again shortly.",
|
||||
};
|
||||
return { status, message: messages[status] ?? "Suggestions could not be loaded. Try again shortly." };
|
||||
}
|
||||
|
||||
export function useSuggestionRead<T>(url: string) {
|
||||
const [result, setResult] = useState<{ data: T | null; error: ReadFailure | null }>({ data: null, error: null });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch(url, { credentials: "same-origin", cache: "no-store", signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new ReadError(response.status);
|
||||
return await response.json() as T;
|
||||
})
|
||||
.then((data) => { if (!controller.signal.aborted) setResult({ data, error: null }); })
|
||||
.catch((error: unknown) => { if (!controller.signal.aborted) setResult({ data: null, error: readFailure(error) }); });
|
||||
return () => controller.abort();
|
||||
}, [url, attempt]);
|
||||
return { ...result, retry: () => { setResult({ data: null, error: null }); setAttempt((value) => value + 1); } };
|
||||
}
|
||||
|
||||
export function SuggestionBadges({ suggestion }: { suggestion: Suggestion }) {
|
||||
return <div className="flex flex-wrap gap-2 font-mono text-[10px] font-bold uppercase tracking-wider">
|
||||
<span className={`border border-ink px-2 py-1 ${suggestion.archived ? "bg-canvas text-muted" : "bg-signal text-ink"}`}>{suggestion.archived ? "Archived" : "Active"}</span>
|
||||
{suggestion.locked && <span className="border border-line px-2 py-1 text-muted">Locked</span>}
|
||||
{suggestion.tags.map((tag) => <span className="border border-line px-2 py-1 text-muted" key={tag.id}>{tag.name}</span>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function SuggestionFailure({ error, retry }: { error: ReadFailure; retry: () => void }) {
|
||||
const authError = error.status === 401 || error.status === 403;
|
||||
return <div className="border-l-4 border-accent bg-panel p-6" role="alert">
|
||||
<h2 className="font-display text-2xl font-black uppercase">{authError ? "Admin access required" : "Unable to load suggestions"}</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-muted">{error.message}</p>
|
||||
<div className="mt-5">{authError ? <Link className="font-mono text-xs font-bold underline underline-offset-4" href="/admin/login">Admin sign-in</Link> : <button className={suggestionControl} onClick={retry} type="button">Try again</button>}</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function useSuggestionPages() {
|
||||
const [state, setState] = useState<{ cursors: (string | undefined)[]; hasPaged: boolean }>({ cursors: [undefined], hasPaged: false });
|
||||
return {
|
||||
cursor: state.cursors.at(-1), page: state.cursors.length, focusOnMount: state.hasPaged,
|
||||
onNext: (cursor: string) => setState((current) => ({ cursors: [...current.cursors, cursor], hasPaged: true })),
|
||||
onPrevious: () => setState((current) => ({ cursors: current.cursors.length > 1 ? current.cursors.slice(0, -1) : current.cursors, hasPaged: true })),
|
||||
reset: () => setState({ cursors: [undefined], hasPaged: false }),
|
||||
};
|
||||
}
|
||||
|
||||
export type SuggestionPaging = { focusOnMount: boolean; onNext: (cursor: string) => void; onPrevious: () => void; page: number };
|
||||
export function SuggestionPagination({ nextCursor, onNext, onPrevious, page, focusOnMount }: SuggestionPaging & { nextCursor: string | null }) {
|
||||
const label = useRef<HTMLParagraphElement>(null);
|
||||
useEffect(() => { if (focusOnMount) label.current?.focus(); }, [focusOnMount]);
|
||||
return <nav aria-label="Pagination" className="mt-8 flex flex-wrap items-center justify-between gap-3 border-t border-line pt-6">
|
||||
<button className={suggestionControl} disabled={page <= 1} onClick={onPrevious} type="button">Previous page</button>
|
||||
<p aria-live="polite" className="font-mono text-[10px] uppercase tracking-wider text-muted" ref={label} tabIndex={-1}>Page {page}</p>
|
||||
<button className={suggestionControl} disabled={!nextCursor} onClick={() => { if (nextCursor) onNext(nextCursor); }} type="button">Next page</button>
|
||||
</nav>;
|
||||
}
|
||||
|
||||
export function SuggestionLoading({ children }: { children: string }) {
|
||||
return <div className="border border-line bg-panel p-8" role="status"><p className="font-mono text-xs uppercase tracking-widest">{children}</p><div aria-hidden="true" className="mt-6 h-1 w-20 bg-accent" /></div>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { SuggestionsBrowser } from "./suggestions-browser";
|
||||
|
||||
const suggestion = { id: "100000000000000009", title: "More railway stations", authorId: "100000000000000003", createdAt: "2026-01-01T00:00:00Z", archived: false, locked: false, tags: [{ id: "100000000000000004", name: "World" }], messageCount: 3, discordUrl: "https://discord.com/channels/100000000000000001/100000000000000009" };
|
||||
it("switches archives, pages forward and back, and resets pagination on status change", async () => {
|
||||
const fetcher = vi.fn().mockImplementation(async (url: string) => {
|
||||
const archived = url.includes("status=archived");
|
||||
const next = url.includes("cursor=");
|
||||
return Response.json({ items: [{ ...suggestion, title: archived ? (next ? "Earlier archived idea" : "Archived idea") : "Active idea", archived }], nextCursor: next ? null : "2026-01-01T00:00:00Z" });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionsBrowser />);
|
||||
await screen.findByRole("link", { name: "Active idea" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Archived" }));
|
||||
await screen.findByRole("link", { name: "Archived idea" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
|
||||
await screen.findByRole("link", { name: "Earlier archived idea" });
|
||||
expect(fetcher.mock.lastCall?.[0]).toContain("cursor=2026-01-01T00%3A00%3A00Z");
|
||||
expect(document.activeElement?.textContent).toBe("Page 2");
|
||||
expect((screen.getByRole("button", { name: "Next page" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Previous page" }));
|
||||
await screen.findByRole("link", { name: "Archived idea" });
|
||||
expect(document.activeElement?.textContent).toBe("Page 1");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Active" }));
|
||||
await screen.findByRole("link", { name: "Active idea" });
|
||||
expect(fetcher.mock.lastCall?.[0]).toBe("/api/suggestions?status=active&limit=25");
|
||||
expect((screen.getByRole("button", { name: "Previous page" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
it("shows an explicit empty state", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ items: [], nextCursor: null })));
|
||||
render(<SuggestionsBrowser />);
|
||||
expect(await screen.findByText("No suggestions on this page.")).toBeTruthy();
|
||||
});
|
||||
it("retries failed reads and distinguishes expired admin access", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ detail: "private upstream text" }, { status: 503 })).mockResolvedValueOnce(Response.json({ items: [], nextCursor: null }));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionsBrowser />);
|
||||
expect((await screen.findByRole("alert")).textContent).not.toContain("private upstream text");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
await screen.findByText("No suggestions on this page.");
|
||||
fetcher.mockResolvedValueOnce(Response.json({}, { status: 401 }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Archived" }));
|
||||
expect((await screen.findByRole("link", { name: "Admin sign-in" })).getAttribute("href")).toBe("/admin/login");
|
||||
expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
|
||||
});
|
||||
it("ignores a late response from the previously selected status", async () => {
|
||||
let resolveActive!: (response: Response) => void;
|
||||
const fetcher = vi.fn().mockImplementationOnce(() => new Promise<Response>((resolve) => { resolveActive = resolve; })).mockResolvedValueOnce(Response.json({ items: [{ ...suggestion, title: "Archived result", archived: true }], nextCursor: null }));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionsBrowser />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Archived" }));
|
||||
await screen.findByRole("link", { name: "Archived result" });
|
||||
resolveActive(Response.json({ items: [suggestion], nextCursor: null }));
|
||||
await waitFor(() => expect(fetcher.mock.calls[0]?.[1].signal.aborted).toBe(true));
|
||||
expect(screen.queryByRole("link", { name: "More railway stations" })).toBeNull();
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
it("loads and displays suggestions with tags and links through the admin API", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue(Response.json({ items: [suggestion], nextCursor: null }));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(<SuggestionsBrowser />);
|
||||
expect(screen.getByRole("status").textContent).toContain("Loading suggestions");
|
||||
const link = await screen.findByRole("link", { name: "More railway stations" });
|
||||
expect(link.getAttribute("href")).toBe(`/admin/suggestions/${suggestion.id}`);
|
||||
expect(screen.getByText("World")).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Open More railway stations in Discord" }).getAttribute("href")).toBe(suggestion.discordUrl);
|
||||
expect(fetcher).toHaveBeenCalledWith("/api/suggestions?status=active&limit=25", expect.objectContaining({ credentials: "same-origin", cache: "no-store" }));
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type { SuggestionPage } from "@/lib/discord/suggestion-types";
|
||||
import { SuggestionBadges, SuggestionFailure, SuggestionLoading, SuggestionPagination, suggestionControl, useSuggestionRead, useSuggestionPages, type SuggestionPaging } from "./suggestion-shared";
|
||||
|
||||
export function SuggestionsBrowser() {
|
||||
const [status, setStatus] = useState<"active" | "archived">("active");
|
||||
const paging = useSuggestionPages();
|
||||
const query = new URLSearchParams({ status, limit: "25" });
|
||||
if (paging.cursor) query.set("cursor", paging.cursor);
|
||||
const url = `/api/suggestions?${query}`;
|
||||
return <section aria-label="Suggestions" className="mt-10">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div aria-label="Suggestion status" className="flex gap-2">
|
||||
{(["active", "archived"] as const).map((value) => <button aria-pressed={status === value} className={`${suggestionControl} ${status === value ? "bg-ink text-panel" : "bg-panel"}`} key={value} onClick={() => { setStatus(value); paging.reset(); }} type="button">{value === "active" ? "Active" : "Archived"}</button>)}
|
||||
</div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-wider text-muted">{status === "active" ? "Newest ideas first" : "Most recently archived first"}</p>
|
||||
</div>
|
||||
<SuggestionList key={url} paging={paging} url={url} />
|
||||
</section>;
|
||||
}
|
||||
|
||||
function SuggestionList({ url, paging }: { url: string; paging: SuggestionPaging }) {
|
||||
const { data, error, retry } = useSuggestionRead<SuggestionPage>(url);
|
||||
if (error) return <SuggestionFailure error={error} retry={retry} />;
|
||||
if (!data) return <SuggestionLoading>Loading suggestions…</SuggestionLoading>;
|
||||
return <><ol className="divide-y divide-line border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
{!data.items.length && <li className="p-10"><h2 className="font-display text-2xl font-black uppercase">No suggestions on this page.</h2><p className="mt-3 text-sm text-muted">Try the other status, or return to the previous page.</p></li>}
|
||||
{data.items.map((suggestion, index) => <li className="grid gap-5 p-6 sm:grid-cols-[3rem_1fr_auto] sm:p-8" key={suggestion.id}>
|
||||
<span aria-hidden="true" className="font-mono text-sm text-muted">{String(index + 1).padStart(2, "0")}</span>
|
||||
<div className="min-w-0">
|
||||
<SuggestionBadges suggestion={suggestion} />
|
||||
<h2 className="mt-4 break-words font-display text-2xl font-black sm:text-3xl"><Link className="underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/suggestions/${suggestion.id}`}>{suggestion.title}</Link></h2>
|
||||
<p className="mt-3 break-words font-mono text-[10px] leading-5 text-muted"><time dateTime={suggestion.createdAt}>{suggestion.createdAt}</time><br />Author / {suggestion.authorId}</p>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-6 sm:flex-col sm:items-end">
|
||||
<p className="font-mono text-[10px] uppercase text-muted">~{suggestion.messageCount} messages</p>
|
||||
<a aria-label={`Open ${suggestion.title} in Discord`} className="font-mono text-[10px] font-bold uppercase underline underline-offset-4 hover:text-accent" href={suggestion.discordUrl} rel="noopener noreferrer" target="_blank">Discord ↗</a>
|
||||
</div>
|
||||
</li>)}
|
||||
</ol><SuggestionPagination nextCursor={data.nextCursor} {...paging} /></>;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 UTC–2026-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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function recordAdminSubjectEvent(
|
||||
subject: string,
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
options: { correlationId?: string } = {},
|
||||
) {
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
@@ -18,6 +19,7 @@ export async function recordAdminSubjectEvent(
|
||||
source: "/web/admin",
|
||||
subject,
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
correlationId: options.correlationId,
|
||||
data: { ...data, adminEmail: admin.email, adminName: admin.name },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
|
||||
import { exportJWK, generateKeyPair, SignJWT, type JWTPayload } from "jose";
|
||||
|
||||
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||
vi.mock("./admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
const issuer = "https://sso.example/realms/operators";
|
||||
const audience = "portal-admin";
|
||||
let keys: Awaited<ReturnType<typeof generateKeyPair>>;
|
||||
let jwks: { keys: unknown[] };
|
||||
let authorize: typeof import("./admin-api-auth").authorizeAdminApi;
|
||||
const fetcher = vi.fn();
|
||||
|
||||
beforeAll(async () => {
|
||||
keys = await generateKeyPair("RS256");
|
||||
jwks = { keys: [{ ...await exportJWK(keys.publicKey), kid: "test-key", alg: "RS256", use: "sig" }] };
|
||||
});
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv("KEYCLOAK_ISSUER_URL", issuer);
|
||||
vi.stubEnv("KEYCLOAK_CLIENT_ID", audience);
|
||||
vi.stubEnv("KEYCLOAK_CLIENT_SECRET", ""); // Machine verification needs no client secret.
|
||||
auth.session.mockResolvedValue({ user: { name: "Browser Admin", email: "admin@example.test", roles: ["ops"] } });
|
||||
fetcher.mockImplementation(async () => Response.json(jwks));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
authorize = (await import("./admin-api-auth")).authorizeAdminApi;
|
||||
});
|
||||
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
|
||||
|
||||
async function token(overrides: JWTPayload = {}, header: Record<string, unknown> = {}) {
|
||||
return new SignJWT({
|
||||
iss: issuer, aud: audience, sub: "machine-subject", exp: Math.floor(Date.now() / 1000) + 300,
|
||||
resource_access: { [audience]: { roles: ["ops"] } },
|
||||
...overrides,
|
||||
}).setProtectedHeader({ alg: "RS256", kid: "test-key", ...header }).sign(keys.privateKey);
|
||||
}
|
||||
function request(bearer: string) {
|
||||
return new Request("https://portal.example/api/admin/whoami", { headers: { authorization: `Bearer ${bearer}` } });
|
||||
}
|
||||
async function rejected(bearer: string, status = 401) {
|
||||
const result = await authorize(request(bearer));
|
||||
expect(result.identity).toBeUndefined();
|
||||
expect(result.response?.status).toBe(status);
|
||||
expect(result.response?.headers.get("content-type")).toBe("application/problem+json");
|
||||
expect(result.response?.headers.get("cache-control")).toBe("no-store");
|
||||
expect(result.response?.headers.get("www-authenticate")).toBe(status === 401 ? 'Bearer realm="admin-api"' : null);
|
||||
expect(await result.response?.json()).toMatchObject({ status, instance: "/api/admin/whoami" });
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
it("verifies real RS256 signatures and caches only the configured issuer JWKS", async () => {
|
||||
const signed = await token({ email: "private@example.test", name: "Private", arbitrary: "private" }, { jku: "https://attacker.example/keys" });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
expect(await authorize(request(signed))).toEqual({ identity: {
|
||||
authenticationMethod: "bearer", subject: "machine-subject", name: null, email: null,
|
||||
} });
|
||||
}
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetcher.mock.calls[0]?.[0])).toBe(`${issuer}/protocol/openid-connect/certs`);
|
||||
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual", method: "GET" });
|
||||
});
|
||||
it("accepts an audience list and case-insensitive bearer scheme", async () => {
|
||||
const signed = await token({ aud: ["other", audience] });
|
||||
const req = new Request(request(signed), { headers: { authorization: `bEaReR ${signed}` } });
|
||||
expect((await authorize(req)).identity?.subject).toBe("machine-subject");
|
||||
});
|
||||
it.each([
|
||||
["expired", { exp: 1 }], ["missing expiry", { exp: undefined }],
|
||||
["missing subject", { sub: undefined }], ["empty subject", { sub: "" }], ["blank subject", { sub: " " }],
|
||||
["future nbf", { nbf: 9999999999 }], ["wrong issuer", { iss: "https://attacker.example" }],
|
||||
["wrong audience", { aud: "another-client" }], ["missing audience", { aud: undefined }],
|
||||
] satisfies [string, JWTPayload][])("rejects %s despite an available privileged browser session", async (_name, claims) => {
|
||||
await rejected(await token(claims));
|
||||
});
|
||||
it("rejects tampering with a signed payload", async () => {
|
||||
const parts = (await token()).split(".");
|
||||
const payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString());
|
||||
parts[1] = Buffer.from(JSON.stringify({ ...payload, sub: "tampered" })).toString("base64url");
|
||||
await rejected(parts.join("."));
|
||||
});
|
||||
it.each([
|
||||
undefined, {}, { [audience]: { roles: [] } }, { another: { roles: ["ops"] } },
|
||||
{ [audience]: { roles: "ops" } }, { [audience]: { roles: ["player"] } },
|
||||
])("requires the configured client role, never a realm role (%j)", async (resource_access) => {
|
||||
await rejected(await token({ resource_access, realm_access: { roles: ["ops"] } }), 403);
|
||||
});
|
||||
it("rejects HS256 algorithm confusion", async () => {
|
||||
const signed = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: 9999999999 })
|
||||
.setProtectedHeader({ alg: "HS256", kid: "test-key" }).sign(new TextEncoder().encode("test-only-key-with-at-least-32-bytes"));
|
||||
await rejected(signed);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects an otherwise valid but non-allowlisted asymmetric algorithm", async () => {
|
||||
const ec = await generateKeyPair("ES256");
|
||||
fetcher.mockImplementation(async () => Response.json({ keys: [{ ...await exportJWK(ec.publicKey), kid: "ec-key" }] }));
|
||||
const signed = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: 9999999999 })
|
||||
.setProtectedHeader({ alg: "ES256", kid: "ec-key" }).sign(ec.privateKey);
|
||||
await rejected(signed);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects an unknown signing key", async () => { await rejected(await token({}, { kid: "unknown" })); });
|
||||
it("rejects a signature from an untrusted key even when its kid matches", async () => {
|
||||
const other = await generateKeyPair("RS256");
|
||||
const signed = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: 9999999999 })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "test-key" }).sign(other.privateKey);
|
||||
await rejected(signed);
|
||||
});
|
||||
it("coalesces concurrent JWKS reads and refreshes rotated keys after cooldown", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
const signed = await token();
|
||||
const results = await Promise.all(Array.from({ length: 8 }, () => authorize(request(signed))));
|
||||
expect(results.every((result) => result.identity?.subject === "machine-subject")).toBe(true);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const rotated = await generateKeyPair("RS256");
|
||||
fetcher.mockImplementation(async () => Response.json({ keys: [{ ...await exportJWK(rotated.publicKey), kid: "rotated-key", alg: "RS256" }] }));
|
||||
const next = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: Math.floor(Date.now() / 1000) + 300, resource_access: { [audience]: { roles: ["ops"] } } })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "rotated-key" }).sign(rotated.privateKey);
|
||||
await rejected(next);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
vi.setSystemTime(Date.now() + 31_000);
|
||||
expect((await authorize(request(next))).identity?.subject).toBe("machine");
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it.each(["redirect", "non-JSON", "invalid JWKS", "HTTP failure"])("fails closed on a %s JWKS response", async (kind) => {
|
||||
fetcher.mockImplementation(async () => {
|
||||
if (kind === "redirect") return new Response(null, { status: 302, headers: { location: "https://attacker.example/keys" } });
|
||||
if (kind === "non-JSON") return new Response("private malformed response");
|
||||
if (kind === "invalid JWKS") return Response.json({ keys: "private malformed keys" });
|
||||
return new Response("private upstream failure", { status: 500 });
|
||||
});
|
||||
await rejected(await token(), 503);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("fails closed and sanitizes a JWKS transport outage", async () => {
|
||||
fetcher.mockRejectedValue(new Error("private network details"));
|
||||
const result = await authorize(request(await token()));
|
||||
expect(result.response?.status).toBe(503);
|
||||
expect(await result.response?.text()).not.toContain("private");
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["", "http://sso.example/realms/operators", "not a URL", `${issuer}?query=1`, "https://user:password@sso.example/realm"])("fails closed on unsafe/missing issuer configuration %s", async (value) => {
|
||||
vi.stubEnv("KEYCLOAK_ISSUER_URL", value);
|
||||
await rejected(await token(), 503);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it("fails closed on missing client ID", async () => {
|
||||
vi.stubEnv("KEYCLOAK_CLIENT_ID", "");
|
||||
await rejected(await token(), 503);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([null, { user: {} }, { user: { roles: ["player"] } }])("preserves missing/unauthorized browser behavior (%j)", async (session) => {
|
||||
auth.session.mockResolvedValue(session);
|
||||
const result = await authorize(new Request("https://portal.example/api/admin/whoami"));
|
||||
expect(result.response?.status).toBe(session ? 403 : 401);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it("preserves browser identity without requiring machine configuration", async () => {
|
||||
vi.stubEnv("KEYCLOAK_ISSUER_URL", "");
|
||||
expect(await authorize(new Request("https://portal.example/api/admin/whoami"))).toEqual({ identity: {
|
||||
authenticationMethod: "session", subject: null, name: "Browser Admin", email: "admin@example.test",
|
||||
} });
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { createRemoteJWKSet, errors, jwtVerify } from "jose";
|
||||
import { problemDetails } from "@minecraft-account-manager/contracts";
|
||||
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||
import { adminAuthOptions, requiredAdminRole } from "./admin-auth";
|
||||
|
||||
export type AdminApiIdentity = {
|
||||
authenticationMethod: "session" | "bearer";
|
||||
subject: string | null;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
type Authorization = { identity: AdminApiIdentity; response?: never } | { response: Response; identity?: never };
|
||||
|
||||
function failure(request: Request, status: 401 | 403 | 503): Authorization {
|
||||
const problems = {
|
||||
401: ["unauthorized", "Authentication required", "Supply valid administrator credentials."],
|
||||
403: ["forbidden", "Administrator role required", "This API is restricted to administrators."],
|
||||
503: ["admin-auth-unavailable", "Authentication unavailable", "Administrator authentication is temporarily unavailable."],
|
||||
} as const;
|
||||
const [code, title, detail] = problems[status];
|
||||
const response = problemResponse(problemDetails(`urn:error:${code}`, title, status, detail, problemInstance(request)));
|
||||
if (status === 401) response.headers.set("www-authenticate", 'Bearer realm="admin-api"');
|
||||
return { response };
|
||||
}
|
||||
|
||||
// One bounded, process-local resolver. jose coalesces fetches and refreshes rotated keys.
|
||||
let remote: { issuer: string; keys: ReturnType<typeof createRemoteJWKSet> } | undefined;
|
||||
function bearerConfiguration() {
|
||||
const issuer = process.env.KEYCLOAK_ISSUER_URL?.trim() ?? "";
|
||||
const audience = process.env.KEYCLOAK_CLIENT_ID?.trim() ?? "";
|
||||
const url = new URL(issuer);
|
||||
if (!audience || url.protocol !== "https:" || url.username || url.password || url.search || url.hash) {
|
||||
throw new Error("Invalid administrator authentication configuration");
|
||||
}
|
||||
if (!remote || remote.issuer !== issuer) {
|
||||
// Never discover a key URL from untrusted token claims or headers (jku/x5u/iss).
|
||||
const jwksUrl = new URL(`${issuer.replace(/\/$/, "")}/protocol/openid-connect/certs`);
|
||||
remote = { issuer, keys: createRemoteJWKSet(jwksUrl, {
|
||||
timeoutDuration: 5_000, cooldownDuration: 30_000, cacheMaxAge: 600_000,
|
||||
}) };
|
||||
}
|
||||
return { issuer, audience, keys: remote.keys };
|
||||
}
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
async function authorizeBearer(request: Request): Promise<Authorization> {
|
||||
const match = /^Bearer +([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/i.exec(request.headers.get("authorization") ?? "");
|
||||
if (!match) return failure(request, 401);
|
||||
let configuration: ReturnType<typeof bearerConfiguration>;
|
||||
try {
|
||||
configuration = bearerConfiguration();
|
||||
} catch {
|
||||
return failure(request, 503);
|
||||
}
|
||||
try {
|
||||
const { issuer, audience, keys } = configuration;
|
||||
const { payload } = await jwtVerify(match[1]!, keys, {
|
||||
issuer, audience, algorithms: ["RS256"], requiredClaims: ["exp", "sub"],
|
||||
});
|
||||
if (typeof payload.sub !== "string" || !payload.sub.trim()) return failure(request, 401);
|
||||
const access = payload.resource_access;
|
||||
const client = record(access) && Object.hasOwn(access, audience) ? access[audience] : undefined;
|
||||
const roles = record(client) ? client.roles : undefined;
|
||||
if (!Array.isArray(roles) || !roles.includes(requiredAdminRole)) return failure(request, 403);
|
||||
return { identity: { authenticationMethod: "bearer", subject: payload.sub, name: null, email: null } };
|
||||
} catch (error) {
|
||||
// Verification failures are invalid credentials; transport/configuration failures are unavailable.
|
||||
const invalid = error instanceof errors.JWTClaimValidationFailed || error instanceof errors.JWTExpired
|
||||
|| error instanceof errors.JWSInvalid || error instanceof errors.JWTInvalid
|
||||
|| error instanceof errors.JWSSignatureVerificationFailed || error instanceof errors.JOSEAlgNotAllowed
|
||||
|| error instanceof errors.JWKSNoMatchingKey || error instanceof errors.JOSENotSupported;
|
||||
return failure(request, invalid ? 401 : 503);
|
||||
}
|
||||
}
|
||||
|
||||
/** API-only authorization; never use bearer tokens to authorize browser actions. */
|
||||
export async function authorizeAdminApi(request: Request): Promise<Authorization> {
|
||||
// Presence, including an empty/unsupported header, is authoritative. Never fall back.
|
||||
if (request.headers.has("authorization")) return authorizeBearer(request);
|
||||
try {
|
||||
const session = await getServerSession(adminAuthOptions);
|
||||
if (!session) return failure(request, 401);
|
||||
const roles = (session.user as { roles?: unknown } | undefined)?.roles;
|
||||
if (!Array.isArray(roles) || !roles.includes(requiredAdminRole)) return failure(request, 403);
|
||||
return { identity: {
|
||||
authenticationMethod: "session", subject: null,
|
||||
name: session.user?.name ?? null, email: session.user?.email ?? null,
|
||||
} };
|
||||
} catch {
|
||||
return failure(request, 503);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type SuggestionTag = { id: string; name: string };
|
||||
export type Suggestion = {
|
||||
id: string;
|
||||
title: string;
|
||||
authorId: string;
|
||||
createdAt: string;
|
||||
archived: boolean;
|
||||
locked: boolean;
|
||||
tags: SuggestionTag[];
|
||||
messageCount: number;
|
||||
discordUrl: string;
|
||||
};
|
||||
export type SuggestionMessage = {
|
||||
id: string;
|
||||
author: { id: string; name: string };
|
||||
content: string;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: { emoji: string; count: number }[];
|
||||
discordUrl: string;
|
||||
};
|
||||
export type SuggestionPage = { items: Suggestion[]; nextCursor: string | null };
|
||||
export type MessagePage = { items: SuggestionMessage[]; nextCursor: string | null };
|
||||
export type SuggestionDetail = Suggestion & { originalPost: SuggestionMessage | null };
|
||||
@@ -0,0 +1,47 @@
|
||||
import { problemDetails } from "@minecraft-account-manager/contracts";
|
||||
import { authorizeAdminApi } from "@/lib/auth/admin-api-auth";
|
||||
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||
import { createSuggestionsClient, SuggestionsError } from "./suggestions";
|
||||
|
||||
type Client = ReturnType<typeof createSuggestionsClient>;
|
||||
let runtime: { token: string; guildId: string; forumId: string; client: Client } | undefined;
|
||||
function getClient() {
|
||||
const token = process.env.DISCORD_BOT_TOKEN?.trim() ?? "";
|
||||
const guildId = process.env.DISCORD_GUILD_ID?.trim() ?? "";
|
||||
const forumId = process.env.DISCORD_SUGGESTIONS_FORUM_ID?.trim() ?? "";
|
||||
if (!runtime || runtime.token !== token || runtime.guildId !== guildId || runtime.forumId !== forumId) {
|
||||
runtime = { token, guildId, forumId, client: createSuggestionsClient({ token, guildId, forumId }) };
|
||||
}
|
||||
return runtime.client;
|
||||
}
|
||||
|
||||
export async function suggestionsApi(request: Request, operation: (client: Client) => Promise<unknown>) {
|
||||
const authorization = await authorizeAdminApi(request);
|
||||
if (authorization.response) return authorization.response;
|
||||
try {
|
||||
return Response.json(await operation(getClient()), { headers: { "cache-control": "no-store" } });
|
||||
} catch (error) {
|
||||
const safe = error instanceof SuggestionsError ? error : new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
|
||||
const titles: Record<number, string> = { 400: "Invalid request", 401: "Authentication required", 403: "Administrator role required", 404: "Suggestion not found", 405: "Method not allowed", 503: "Suggestions unavailable" };
|
||||
const response = problemResponse(problemDetails(`urn:error:${safe.code}`, titles[safe.status] ?? "Suggestions unavailable", safe.status, safe.message, problemInstance(request)));
|
||||
if (safe.retryAfter) response.headers.set("retry-after", String(safe.retryAfter));
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export function suggestionQuery(request: Request, list = false) {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const allowed = list ? ["limit", "cursor", "status"] : ["limit", "cursor"];
|
||||
for (const key of params.keys()) {
|
||||
if (!allowed.includes(key) || params.getAll(key).length !== 1 || !params.get(key)) throw new SuggestionsError(400, "invalid-request", "Unsupported or repeated query parameter.");
|
||||
}
|
||||
const rawLimit = params.get("limit");
|
||||
if (rawLimit !== null && !/^\d{1,3}$/.test(rawLimit)) throw new SuggestionsError(400, "invalid-request", "Limit must be between 1 and 100.");
|
||||
return { limit: rawLimit === null ? undefined : Number(rawLimit), cursor: params.get("cursor") ?? undefined, ...(list ? { status: params.get("status") ?? undefined } : {}) };
|
||||
}
|
||||
|
||||
export function suggestionsReadOnly(request: Request) {
|
||||
return suggestionsApi(request, async () => {
|
||||
throw new SuggestionsError(405, "method-not-allowed", "Suggestions are read-only. Use GET.");
|
||||
}).then((response) => { if (response.status === 405) response.headers.set("allow", "GET, HEAD"); return response; });
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { createSuggestionsClient } from "./suggestions";
|
||||
afterEach(() => vi.useRealTimers());
|
||||
const guildId = "100000000000000001";
|
||||
const forumId = "100000000000000002";
|
||||
const threadId = "100000000000000009";
|
||||
const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11, name: "More railway stations", owner_id: "100000000000000003", applied_tags: ["100000000000000004"], message_count: 3, thread_metadata: { archived: false, locked: false, archive_timestamp: "2026-01-01T00:00:00.000Z" } };
|
||||
function setup(responses: Record<string, unknown>) {
|
||||
const fetcher = vi.fn<typeof fetch>(async (input) => {
|
||||
const path = String(input).replace("https://discord.com/api/v10", "");
|
||||
if (path.includes("/threads/archived/public") && Number(new URL(String(input)).searchParams.get("limit")) < 2) {
|
||||
return Response.json({ code: 50035, message: "Invalid Form Body", errors: { limit: { _errors: [{ code: "NUMBER_TYPE_MIN", message: "int value should be greater than or equal to 2." }] } } }, { status: 400 });
|
||||
}
|
||||
if (!(path in responses)) throw new Error(`Unexpected path: ${path}`);
|
||||
const value = responses[path];
|
||||
return value instanceof Response ? value : Response.json(value);
|
||||
});
|
||||
const client = createSuggestionsClient({ token: "test-token", guildId, forumId, fetch: fetcher });
|
||||
return { client, fetcher };
|
||||
}
|
||||
it("reads archived forum pages using Discord's archive timestamp cursor", async () => {
|
||||
const cursor = "2026-01-01T00:00:00Z";
|
||||
const { client } = setup({
|
||||
[`/channels/${forumId}`]: forum,
|
||||
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } }], has_more: true },
|
||||
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
|
||||
});
|
||||
expect(await client.list({ status: "archived", limit: 1 })).toMatchObject({ items: [{ archived: true }], nextCursor: cursor });
|
||||
expect(await client.list({ status: "archived", limit: 1, cursor })).toEqual({ items: [], nextCursor: null });
|
||||
});
|
||||
|
||||
it.each([true, false])("returns one archived post without skipping buffered posts when has_more=%s", async (hasMore) => {
|
||||
const cursor = "2026-01-01T00:00:00.123456Z";
|
||||
const newest = { ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: "2026-01-01T00:00:00.123456+00:00" } };
|
||||
const older = { ...newest, id: "100000000000000008", thread_metadata: { ...newest.thread_metadata, archive_timestamp: "2026-01-01T00:00:00.123455+00:00" } };
|
||||
const { client } = setup({
|
||||
[`/channels/${forumId}`]: forum,
|
||||
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [newest, older], has_more: hasMore },
|
||||
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [older], has_more: false },
|
||||
});
|
||||
const first = await client.list({ status: "archived", limit: 1 });
|
||||
expect(first.items.map(({ id }) => id)).toEqual([newest.id]);
|
||||
expect(first.nextCursor).toBe(cursor);
|
||||
const last = await client.list({ status: "archived", limit: 1, cursor: first.nextCursor! });
|
||||
expect(last.items.map(({ id }) => id)).toEqual([older.id]);
|
||||
expect(last.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it.each([1, 2, 25, 100])("preserves archive request bounds and terminal pages for limit=%s", async (limit) => {
|
||||
const { client } = setup({
|
||||
[`/channels/${forumId}`]: forum,
|
||||
[`/channels/${forumId}/threads/archived/public?limit=${Math.max(2, limit)}`]: { threads: [thread], has_more: false },
|
||||
});
|
||||
const result = await client.list({ status: "archived", limit });
|
||||
expect(result.items.map(({ id }) => id)).toEqual([threadId]);
|
||||
expect(result.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it.each([true, false])("does not invent a cursor for an empty archive response with has_more=%s", async (hasMore) => {
|
||||
const { client } = setup({
|
||||
[`/channels/${forumId}`]: forum,
|
||||
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [], has_more: hasMore },
|
||||
});
|
||||
expect(await client.list({ status: "archived", limit: 1 })).toEqual({ items: [], nextCursor: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ parent_id: "100000000000000099" },
|
||||
{ guild_id: "100000000000000099" },
|
||||
{ type: 12 },
|
||||
])("filters unrelated archives before slicing and choosing a continuation: %j", async (outside) => {
|
||||
const cursor = "2026-01-01T00:00:00Z";
|
||||
const unrelated = { ...thread, ...outside, id: "100000000000000007", thread_metadata: { ...thread.thread_metadata, archive_timestamp: "2025-12-31T00:00:00Z" } };
|
||||
const newerUnrelated = { ...unrelated, thread_metadata: { ...unrelated.thread_metadata, archive_timestamp: "2026-01-02T00:00:00Z" } };
|
||||
for (const threads of [[newerUnrelated, thread], [thread, unrelated]]) {
|
||||
for (const hasMore of [false, true]) {
|
||||
const { client } = setup({
|
||||
[`/channels/${forumId}`]: forum,
|
||||
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads, has_more: hasMore },
|
||||
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
|
||||
});
|
||||
const result = await client.list({ status: "archived", limit: 1 });
|
||||
expect(result.items.map(({ id }) => id)).toEqual([threadId]);
|
||||
expect(result.nextCursor).toBe(hasMore ? cursor : null);
|
||||
if (result.nextCursor) expect(await client.list({ status: "archived", limit: 1, cursor: result.nextCursor })).toEqual({ items: [], nextCursor: null });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each([true, false])("preserves progress for a fully filtered archive page with has_more=%s", async (hasMore) => {
|
||||
const cursor = "2026-01-01T00:00:00Z";
|
||||
const { client } = setup({
|
||||
[`/channels/${forumId}`]: forum,
|
||||
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [{ ...thread, parent_id: "100000000000000099" }], has_more: hasMore },
|
||||
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archive_timestamp: "2025-12-31T00:00:00Z" } }], has_more: false },
|
||||
});
|
||||
const result = await client.list({ status: "archived", limit: 1 });
|
||||
expect(result).toEqual({ items: [], nextCursor: hasMore ? cursor : null });
|
||||
if (result.nextCursor) {
|
||||
const next = await client.list({ status: "archived", limit: 1, cursor: result.nextCursor });
|
||||
expect(next.items.map(({ id }) => id)).toEqual([threadId]);
|
||||
expect(next.nextCursor).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes offset archive timestamps without losing cursor precision", async () => {
|
||||
const raw = "2026-01-01T00:00:00.123456+00:00";
|
||||
const cursor = "2026-01-01T00:00:00.123456Z";
|
||||
const { client } = setup({
|
||||
[`/channels/${forumId}`]: forum,
|
||||
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: raw } }], has_more: true },
|
||||
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
|
||||
});
|
||||
const first = await client.list({ status: "archived", limit: 1 });
|
||||
expect(first.nextCursor).toBe(cursor);
|
||||
expect(await client.list({ status: "archived", limit: 1, cursor: first.nextCursor! })).toEqual({ items: [], nextCursor: null });
|
||||
});
|
||||
const message = { id: threadId, content: "Please add stations", timestamp: "2026-01-01T00:00:00.000Z", edited_timestamp: null, author: { id: "100000000000000003", username: "builder", global_name: "Builder" }, reactions: [{ emoji: { name: "👍" }, count: 4 }] };
|
||||
it("returns the starter post and paginates discussion with authors and reactions", async () => {
|
||||
const { client } = setup({
|
||||
[`/channels/${forumId}`]: forum,
|
||||
[`/channels/${threadId}`]: thread,
|
||||
[`/channels/${threadId}/messages/${threadId}`]: message,
|
||||
[`/channels/${threadId}/messages?limit=1`]: [{ ...message, id: "100000000000000020" }],
|
||||
[`/channels/${threadId}/messages?limit=1&before=100000000000000020`]: [],
|
||||
});
|
||||
expect(await client.detail(threadId)).toMatchObject({ title: thread.name, originalPost: { content: message.content, author: { name: "Builder" }, reactions: [{ emoji: "👍", count: 4 }], createdAt: "2026-01-01T00:00:00Z" } });
|
||||
expect(await client.messages(threadId, { limit: 1 })).toMatchObject({ items: [{ id: "100000000000000020" }], nextCursor: "100000000000000020" });
|
||||
expect(await client.messages(threadId, { limit: 1, cursor: "100000000000000020" })).toEqual({ items: [], nextCursor: null });
|
||||
});
|
||||
|
||||
it.each(["detail", "messages"] as const)("blocks %s of a thread outside the forum before reading messages", async (method) => {
|
||||
const { client, fetcher } = setup({ [`/channels/${forumId}`]: forum, [`/channels/${threadId}`]: { ...thread, parent_id: "100000000000000099" } });
|
||||
await expect(client[method](threadId)).rejects.toMatchObject({ status: 404 });
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("keeps a deleted starter post distinguishable from an empty message", async () => {
|
||||
const { client } = setup({ [`/channels/${forumId}`]: forum, [`/channels/${threadId}`]: thread, [`/channels/${threadId}/messages/${threadId}`]: new Response(null, { status: 404 }) });
|
||||
expect(await client.detail(threadId)).toMatchObject({ originalPost: null });
|
||||
});
|
||||
it("backs off on Discord rate limits without exposing Discord error bodies", async () => {
|
||||
const { client, fetcher } = setup({ [`/channels/${forumId}`]: Response.json({ retry_after: 2.5, message: "secret upstream details" }, { status: 429 }) });
|
||||
await expect(client.list()).rejects.toMatchObject({ status: 503, code: "discord-rate-limited", retryAfter: 3 });
|
||||
await expect(client.list()).rejects.toMatchObject({ status: 503, code: "discord-rate-limited" });
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each([401, 403, 404, 500])("translates Discord %s into a safe service error", async (status) => {
|
||||
const { client } = setup({ [`/channels/${forumId}`]: new Response("sensitive error", { status }) });
|
||||
await expect(client.list()).rejects.toMatchObject({ status: 503 });
|
||||
await expect(client.list()).rejects.not.toThrow("sensitive error");
|
||||
});
|
||||
it("sanitizes network failures", async () => {
|
||||
const client = createSuggestionsClient({ token: "test", guildId, forumId, fetch: vi.fn().mockRejectedValue(new Error("token leaked by upstream")) });
|
||||
await expect(client.list()).rejects.toMatchObject({ status: 503, message: "Discord suggestions are unavailable." });
|
||||
});
|
||||
|
||||
it("coalesces concurrent reads and refreshes expired cache entries", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { client, fetcher } = setup({ [`/channels/${forumId}`]: forum, [`/guilds/${guildId}/threads/active`]: { threads: [thread] } });
|
||||
await Promise.all([client.list(), client.list()]);
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
vi.advanceTimersByTime(30_001);
|
||||
await client.list();
|
||||
expect(fetcher).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
it.each([{ status: "all" }, { limit: 0 }, { limit: 101 }, { limit: 1.5 }, { cursor: "../secret" }, { status: "archived", cursor: "bad-date" }])("validates list query %j before network access", async (query) => {
|
||||
const { client, fetcher } = setup({});
|
||||
await expect(client.list(query)).rejects.toMatchObject({ status: 400 });
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["detail", "messages"] as const)("validates %s IDs before network access", async (method) => {
|
||||
const { client, fetcher } = setup({});
|
||||
await expect(client[method]("../secret")).rejects.toMatchObject({ status: 400 });
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([{ ...forumPlaceholder(), type: 0 }, { ...forumPlaceholder(), guild_id: "100000000000000099" }])("refuses a non-forum or wrong-guild configured channel", async (value) => {
|
||||
const { client, fetcher } = setup({ [`/channels/${forumId}`]: value });
|
||||
await expect(client.list()).rejects.toMatchObject({ status: 503 });
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("bounds concurrent upstream requests instead of flooding Discord", async () => {
|
||||
const releases: (() => void)[] = [];
|
||||
const fetcher = vi.fn<typeof fetch>(async (input) => {
|
||||
if (String(input).endsWith(`/channels/${forumId}`)) return Response.json(forum);
|
||||
if (String(input).endsWith("/threads/active")) return Response.json({ threads: [] });
|
||||
return new Promise<Response>((resolve) => { releases.push(() => resolve(new Response(null, { status: 404 }))); });
|
||||
});
|
||||
const client = createSuggestionsClient({ token: "test", guildId, forumId, fetch: fetcher });
|
||||
await client.list();
|
||||
const results = Array.from({ length: 9 }, (_, index) => client.detail(`1000000000000001${index.toString().padStart(2, "0")}`).catch((error: unknown) => error));
|
||||
await vi.waitFor(() => expect(fetcher.mock.calls.length).toBeGreaterThanOrEqual(10));
|
||||
const count = releases.length;
|
||||
releases.forEach((release) => release());
|
||||
const errors = await Promise.all(results);
|
||||
expect(count).toBe(8);
|
||||
expect(errors).toContainEqual(expect.objectContaining({ code: "discord-busy", status: 503 }));
|
||||
});
|
||||
function forumPlaceholder() { return { id: forumId, guild_id: guildId, type: 15 }; }
|
||||
|
||||
const forum = { id: forumId, guild_id: guildId, type: 15, available_tags: [{ id: "100000000000000004", name: "World" }] };
|
||||
|
||||
it("lists only configured-forum active suggestions, resolves tags and paginates newest first", async () => {
|
||||
const { client, fetcher } = setup({
|
||||
[`/channels/${forumId}`]: forum,
|
||||
[`/guilds/${guildId}/threads/active`]: { threads: [
|
||||
{ ...thread, id: "100000000000000008" }, thread,
|
||||
{ ...thread, id: "100000000000000010", parent_id: "100000000000000099" },
|
||||
] },
|
||||
});
|
||||
const first = await client.list({ limit: 1 });
|
||||
expect(first).toMatchObject({ items: [{ id: threadId, title: "More railway stations", tags: [{ name: "World" }], discordUrl: `https://discord.com/channels/${guildId}/${threadId}` }], nextCursor: threadId });
|
||||
const second = await client.list({ limit: 1, cursor: threadId });
|
||||
expect(second).toMatchObject({ items: [{ id: "100000000000000008" }], nextCursor: null });
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ headers: { Authorization: "Bot test-token" }, cache: "no-store", redirect: "error" });
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { MessagePage, SuggestionDetail, SuggestionMessage, Suggestion, SuggestionPage, SuggestionTag } from "./suggestion-types";
|
||||
|
||||
type Forum = { id: string; guild_id: string; type: number; available_tags: SuggestionTag[] };
|
||||
type Thread = {
|
||||
id: string; guild_id?: string; parent_id: string; type: number; name: string; owner_id: string;
|
||||
applied_tags?: string[]; message_count?: number;
|
||||
thread_metadata: { archived: boolean; locked: boolean; archive_timestamp: string };
|
||||
};
|
||||
type Message = {
|
||||
id: string; content: string; timestamp: string; edited_timestamp?: string | null;
|
||||
author: { id: string; username: string; global_name?: string | null };
|
||||
reactions?: { emoji: { id?: string | null; name: string | null }; count: number }[];
|
||||
};
|
||||
export type ListQuery = { status?: string; cursor?: string; limit?: number };
|
||||
|
||||
export class SuggestionsError extends Error {
|
||||
constructor(public readonly status: number, public readonly code: string, message: string, public readonly retryAfter?: number) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
const snowflake = /^[1-9]\d{16,19}$/;
|
||||
function checkId(id: string) {
|
||||
if (!snowflake.test(id)) throw new SuggestionsError(400, "invalid-request", "A valid Discord ID is required.");
|
||||
}
|
||||
function limitValue(limit = 25) {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new SuggestionsError(400, "invalid-request", "Limit must be between 1 and 100.");
|
||||
return limit;
|
||||
}
|
||||
function timestamp(value: string | number) {
|
||||
return new Date(value).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
function archiveCursor(value: string) {
|
||||
// Discord timestamps may use +00:00 and microseconds. Date alone truncates
|
||||
// that precision, potentially skipping posts at the archive page boundary.
|
||||
const fraction = value.match(/\.(\d{1,6})(?:Z|[+-]\d{2}:\d{2})$/)?.[1];
|
||||
return new Date(value).toISOString().replace(/\.\d{3}Z$/, fraction && /[1-9]/.test(fraction) ? `.${fraction}Z` : "Z");
|
||||
}
|
||||
|
||||
export function createSuggestionsClient(options: { token: string; guildId: string; forumId: string; fetch?: typeof fetch }) {
|
||||
const { token, guildId, forumId } = options;
|
||||
const fetcher = options.fetch ?? fetch;
|
||||
const cache = new Map<string, { expires: number; value: unknown }>();
|
||||
let retryAt = 0;
|
||||
let activeRequests = 0;
|
||||
const pending = new Map<string, Promise<unknown>>();
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const existing = pending.get(path);
|
||||
if (existing) return existing as Promise<T>;
|
||||
const request = load<T>(path);
|
||||
pending.set(path, request);
|
||||
try { return await request; } finally { pending.delete(path); }
|
||||
}
|
||||
async function load<T>(path: string): Promise<T> {
|
||||
const cached = cache.get(path);
|
||||
if (cached && cached.expires > Date.now()) return cached.value as T;
|
||||
if (Date.now() < retryAt) throw new SuggestionsError(503, "discord-rate-limited", "Discord is rate limited. Try again shortly.", Math.ceil((retryAt - Date.now()) / 1000));
|
||||
if (activeRequests >= 8) throw new SuggestionsError(503, "discord-busy", "Suggestions are busy. Try again shortly.", 1);
|
||||
activeRequests += 1;
|
||||
try {
|
||||
const response = await fetcher(`https://discord.com/api/v10${path}`, {
|
||||
headers: { Authorization: `Bot ${token}` }, cache: "no-store", redirect: "error", signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (response.status === 429) {
|
||||
const body = await response.json().catch(() => null) as { retry_after?: number } | null;
|
||||
const raw = Number(body?.retry_after ?? response.headers.get("retry-after") ?? 1);
|
||||
const seconds = Number.isFinite(raw) && raw > 0 ? Math.ceil(raw) : 1;
|
||||
retryAt = Date.now() + seconds * 1000;
|
||||
throw new SuggestionsError(503, "discord-rate-limited", "Discord is rate limited. Try again shortly.", seconds);
|
||||
}
|
||||
if (response.status === 404) throw new SuggestionsError(404, "suggestion-not-found", "The suggestion or message was not found.");
|
||||
if (!response.ok) throw new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
|
||||
const value: unknown = await response.json();
|
||||
if (cache.size >= 200) cache.delete(cache.keys().next().value!);
|
||||
cache.set(path, { value, expires: Date.now() + 30_000 });
|
||||
return value as T;
|
||||
} catch (error) {
|
||||
if (error instanceof SuggestionsError) throw error;
|
||||
throw new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
|
||||
} finally {
|
||||
activeRequests -= 1;
|
||||
}
|
||||
}
|
||||
async function getForum() {
|
||||
if (!token || !snowflake.test(guildId) || !snowflake.test(forumId)) throw new SuggestionsError(503, "suggestions-not-configured", "Discord suggestions are not configured.");
|
||||
const forum = await get<Forum>(`/channels/${forumId}`).catch((error: unknown) => {
|
||||
if (error instanceof SuggestionsError && error.status === 404) throw new SuggestionsError(503, "suggestions-not-configured", "The configured suggestions forum is unavailable.");
|
||||
throw error;
|
||||
});
|
||||
if (forum.id !== forumId || forum.guild_id !== guildId || forum.type !== 15) throw new SuggestionsError(503, "suggestions-not-configured", "The configured channel must be a forum in the configured guild.");
|
||||
return forum;
|
||||
}
|
||||
function belongs(thread: Thread) {
|
||||
return thread.parent_id === forumId && (!thread.guild_id || thread.guild_id === guildId) && thread.type === 11;
|
||||
}
|
||||
function summary(thread: Thread, forum: Forum): Suggestion {
|
||||
return {
|
||||
id: thread.id, title: thread.name, authorId: thread.owner_id,
|
||||
createdAt: timestamp(Number((BigInt(thread.id) >> 22n) + 1420070400000n)),
|
||||
archived: thread.thread_metadata.archived, locked: thread.thread_metadata.locked,
|
||||
tags: (forum.available_tags ?? []).filter((tag) => thread.applied_tags?.includes(tag.id)).map(({ id, name }) => ({ id, name })),
|
||||
messageCount: thread.message_count ?? 0, discordUrl: `https://discord.com/channels/${guildId}/${thread.id}`,
|
||||
};
|
||||
}
|
||||
async function getThread(id: string) {
|
||||
checkId(id);
|
||||
const forum = await getForum();
|
||||
const thread = await get<Thread>(`/channels/${id}`);
|
||||
if (thread.id !== id || !belongs(thread)) throw new SuggestionsError(404, "suggestion-not-found", "The suggestion was not found in the configured forum.");
|
||||
return { thread, forum };
|
||||
}
|
||||
function messageView(message: Message, threadId: string): SuggestionMessage {
|
||||
return {
|
||||
id: message.id, content: message.content,
|
||||
author: { id: message.author.id, name: message.author.global_name || message.author.username },
|
||||
createdAt: timestamp(message.timestamp), editedAt: message.edited_timestamp ? timestamp(message.edited_timestamp) : null,
|
||||
reactions: (message.reactions ?? []).map(({ emoji, count }) => ({ emoji: emoji.id ? `:${emoji.name ?? "emoji"}:` : emoji.name ?? "emoji", count })),
|
||||
discordUrl: `https://discord.com/channels/${guildId}/${threadId}/${message.id}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
async detail(id: string): Promise<SuggestionDetail> {
|
||||
const { thread, forum } = await getThread(id);
|
||||
const message = await get<Message>(`/channels/${id}/messages/${id}`).catch((error: unknown) => {
|
||||
if (error instanceof SuggestionsError && error.status === 404) return null;
|
||||
throw error;
|
||||
});
|
||||
return { ...summary(thread, forum), originalPost: message ? messageView(message, id) : null };
|
||||
},
|
||||
async messages(id: string, query: { cursor?: string; limit?: number } = {}): Promise<MessagePage> {
|
||||
const limit = limitValue(query.limit);
|
||||
if (query.cursor) checkId(query.cursor);
|
||||
await getThread(id);
|
||||
const before = query.cursor ? `&before=${query.cursor}` : "";
|
||||
const messages = await get<Message[]>(`/channels/${id}/messages?limit=${limit}${before}`);
|
||||
return { items: messages.map((message) => messageView(message, id)), nextCursor: messages.length === limit ? messages.at(-1)!.id : null };
|
||||
},
|
||||
async list(query: ListQuery = {}): Promise<SuggestionPage> {
|
||||
const limit = limitValue(query.limit);
|
||||
const status = query.status ?? "active";
|
||||
if (status !== "active" && status !== "archived") throw new SuggestionsError(400, "invalid-request", "Status must be active or archived.");
|
||||
if (query.cursor) {
|
||||
if (status === "active") checkId(query.cursor);
|
||||
else if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/.test(query.cursor) || !Number.isFinite(Date.parse(query.cursor))) {
|
||||
throw new SuggestionsError(400, "invalid-request", "The archive cursor must be a UTC timestamp.");
|
||||
}
|
||||
}
|
||||
const forum = await getForum();
|
||||
if (status === "archived") {
|
||||
const before = query.cursor ? `&before=${encodeURIComponent(query.cursor)}` : "";
|
||||
const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${Math.max(2, limit)}${before}`);
|
||||
const threads = data.threads.filter(belongs);
|
||||
const page = threads.slice(0, limit);
|
||||
// Resume after the last returned post, not the extra post fetched for
|
||||
// Discord's minimum limit. Fully filtered pages must still advance.
|
||||
const last = page.at(-1) ?? data.threads.at(-1);
|
||||
return {
|
||||
items: page.map((thread) => summary(thread, forum)),
|
||||
nextCursor: (data.has_more || threads.length > limit) && last ? archiveCursor(last.thread_metadata.archive_timestamp) : null,
|
||||
};
|
||||
}
|
||||
const data = await get<{ threads: Thread[] }>(`/guilds/${guildId}/threads/active`);
|
||||
const threads = data.threads.filter(belongs).sort((a, b) => BigInt(a.id) > BigInt(b.id) ? -1 : 1)
|
||||
.filter((thread) => !query.cursor || BigInt(thread.id) < BigInt(query.cursor));
|
||||
const page = threads.slice(0, limit);
|
||||
return { items: page.map((thread) => summary(thread, forum)), nextCursor: threads.length > limit ? page.at(-1)!.id : null };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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 UTC–2026-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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
it("links the public contract and documents safe client-credentials usage", () => {
|
||||
const read = (path: string) => readFileSync(resolve(process.cwd(), "../..", path), "utf8");
|
||||
expect(read("README.md")).toContain("[OpenAPI 3.1](openapi.yaml)");
|
||||
expect(read("README.md")).toContain("/openapi.yaml");
|
||||
const docs = read("docs/admin-api-authentication.md");
|
||||
expect(docs).toContain("/protocol/openid-connect/token");
|
||||
expect(docs).toContain('"grant_type": "client_credentials"');
|
||||
expect(docs).toContain("getpass.getpass");
|
||||
expect(docs).toContain("No helper is installed");
|
||||
expect(docs).toContain("Do not enable shell tracing");
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { GET } from "../app/openapi.yaml/route";
|
||||
|
||||
it("serves the canonical YAML bytes publicly, without rewriting or authentication", async () => {
|
||||
const response = await GET();
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/yaml; charset=utf-8");
|
||||
expect(Buffer.from(await response.arrayBuffer())).toEqual(readFileSync(resolve(process.cwd(), "../../openapi.yaml")));
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { resolve, relative } from "node:path";
|
||||
import SwaggerParser from "@apidevtools/swagger-parser";
|
||||
import { parse } from "yaml";
|
||||
import { assert, expect, it } from "vitest";
|
||||
import { velocityAccessRequestSchema, velocityConnectionRequestSchema } from "@minecraft-account-manager/contracts";
|
||||
import { assertResponse, assertSchema, loadContract } from "@/test/openapi-contract";
|
||||
|
||||
const root = resolve(process.cwd(), "../..");
|
||||
const canonical = resolve(root, "openapi.yaml");
|
||||
it("publishes a valid OpenAPI 3.1 contract covering every application API", async () => {
|
||||
expect(existsSync(canonical), "root openapi.yaml must exist").toBe(true);
|
||||
const doc = parse(readFileSync(canonical, "utf8"));
|
||||
expect(doc.openapi).toBe("3.1.0");
|
||||
await SwaggerParser.validate(structuredClone(doc));
|
||||
const api = resolve(process.cwd(), "src/app/api");
|
||||
const routes = readdirSync(api, { recursive: true, withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name === "route.ts")
|
||||
.map((entry) => relative(api, resolve(entry.parentPath, entry.name)).replace(/\/route\.ts$/, "").replace(/\[([^\]]+)\]/g, "{$1}"))
|
||||
.filter((path) => !["route.ts", "{...path}", "auth/{...nextauth}"].includes(path))
|
||||
.map((path) => `/api/${path}`);
|
||||
expect(Object.keys(doc.paths).filter((path) => path.startsWith("/api/")).sort()).toEqual(routes.sort());
|
||||
for (const path of routes) {
|
||||
const source = readFileSync(resolve(api, path.slice(5).replace(/\{([^}]+)\}/g, "[$1]"), "route.ts"), "utf8");
|
||||
const methods = [...source.matchAll(/export (?:async )?(?:function|const) (GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g)].map((match) => match[1]!.toLowerCase());
|
||||
for (const method of methods) expect(doc.paths[path][method], `${method} ${path} must be documented`).toBeDefined();
|
||||
if (methods.includes("get")) expect(doc.paths[path].head).toBeDefined();
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const [path, item] of Object.entries(doc.paths) as [string, Record<string, Record<string, unknown>>][]) {
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
if (!["get", "head", "post", "put", "patch", "delete", "options"].includes(method)) continue;
|
||||
expect(operation.summary, `${method} ${path}`).toBeTruthy();
|
||||
expect(operation.description).toBeTruthy();
|
||||
expect(ids.has(operation.operationId as string)).toBe(false);
|
||||
ids.add(operation.operationId as string);
|
||||
expect(operation.operationId).toBeTruthy();
|
||||
if (path.startsWith("/api/suggestions") || path === "/api/admin/whoami") {
|
||||
expect(operation.security).toEqual([{ AdminSession: [] }, { AdminBearer: [] }]);
|
||||
} else if (path.startsWith("/api/velocity") && method === "post") {
|
||||
expect(operation.security).toEqual([{ VelocitySecret: [] }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
it("validates examples as JSON Schema 2020-12 and against real Velocity request parsers", async () => {
|
||||
const doc = await loadContract();
|
||||
for (const item of Object.values(doc.paths)) for (const operation of Object.values(item)) {
|
||||
for (const response of Object.values(operation.responses)) {
|
||||
for (const content of Object.values(response.content ?? {})) {
|
||||
for (const example of Object.values(content.examples ?? {})) assertSchema(content.schema, example.value);
|
||||
}
|
||||
}
|
||||
for (const content of Object.values(operation.requestBody?.content ?? {})) {
|
||||
for (const example of Object.values(content.examples ?? {})) assertSchema(content.schema, example.value);
|
||||
}
|
||||
}
|
||||
for (const [kind, schema] of [["access", velocityAccessRequestSchema], ["connection", velocityConnectionRequestSchema]] as const) {
|
||||
const content = doc.paths[`/api/velocity/${kind}`]?.post?.requestBody?.content["application/json"];
|
||||
assert(content);
|
||||
for (const example of Object.values(content.examples ?? {})) expect(schema.safeParse(example.value).success).toBe(true);
|
||||
}
|
||||
});
|
||||
it("contract checking rejects wrong status, media type and response data", async () => {
|
||||
await expect(assertResponse("/api/admin/whoami", "get", Response.json({ authenticationMethod: "bearer", subject: null, name: null, email: null }, { headers: { "cache-control": "no-store" } }))).rejects.toThrow();
|
||||
await expect(assertResponse("/api/admin/whoami", "get", new Response("{}", { headers: { "cache-control": "no-store" } }))).rejects.toThrow();
|
||||
await expect(assertResponse("/api/admin/whoami", "get", Response.json({}, { status: 418 }))).rejects.toThrow();
|
||||
const wrongStatus = Response.json({ type: "urn:error:unauthorized", title: "Unauthorized", status: 403 }, { status: 401, headers: { "content-type": "application/problem+json", "cache-control": "no-store", "www-authenticate": 'Bearer realm="admin-api"' } });
|
||||
await expect(assertResponse("/api/admin/whoami", "get", wrongStatus)).rejects.toThrow();
|
||||
});
|
||||
it("rejects an invalid OpenAPI document (not just parseable YAML)", async () => {
|
||||
await expect(SwaggerParser.validate({ openapi: "3.1.0", info: { title: "Broken", version: "1" }, paths: { "/broken": { get: { responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/Missing" } } } } } } } } } as never)).rejects.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildRconCommandHistory, normalizeRconHistoryFilters } from "./rcon-command-history";
|
||||
|
||||
const correlationId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function event(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
time: new Date("2026-08-14T01:00:00Z"),
|
||||
correlationId,
|
||||
data: {
|
||||
command: "say hello operators",
|
||||
serverId: "33333333-3333-4333-8333-333333333333",
|
||||
name: "Season 4",
|
||||
adminEmail: "admin@example.test",
|
||||
adminName: "Admin",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RCON command history", () => {
|
||||
it("normalizes bounded search filters and accepts only known servers", () => {
|
||||
expect(normalizeRconHistoryFilters({
|
||||
command: [" say hello ", "ignored"],
|
||||
admin: " admin@example.test ",
|
||||
server: "33333333-3333-4333-8333-333333333333",
|
||||
}, ["33333333-3333-4333-8333-333333333333"])).toEqual({
|
||||
command: "say hello",
|
||||
admin: "admin@example.test",
|
||||
serverId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(normalizeRconHistoryFilters({ server: "unknown" }, [])).toEqual({
|
||||
command: "",
|
||||
admin: "",
|
||||
serverId: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("pairs requested commands with their completion outcome without exposing responses", () => {
|
||||
const requested = event();
|
||||
const completed = event({
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
data: { success: false, reason: "timeout", durationMs: 5001 },
|
||||
});
|
||||
|
||||
expect(buildRconCommandHistory([requested], [completed])).toEqual([{
|
||||
eventId: requested.id,
|
||||
time: requested.time,
|
||||
command: "say hello operators",
|
||||
serverId: "33333333-3333-4333-8333-333333333333",
|
||||
serverName: "Season 4",
|
||||
adminEmail: "admin@example.test",
|
||||
adminName: "Admin",
|
||||
status: "failed",
|
||||
reason: "timeout",
|
||||
durationMs: 5001,
|
||||
}]);
|
||||
expect(JSON.stringify(buildRconCommandHistory([requested], [completed]))).not.toContain("response");
|
||||
});
|
||||
|
||||
it("marks a requested command pending when no completion event exists", () => {
|
||||
expect(buildRconCommandHistory([event()], [event({ correlationId: null })])[0]?.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
export const RCON_COMMAND_REQUESTED = "games.minecraft.account-manager.rcon.command.requested";
|
||||
export const RCON_COMMAND_COMPLETED = "games.minecraft.account-manager.rcon.command.completed";
|
||||
|
||||
export type RconHistoryEvent = {
|
||||
id: string;
|
||||
time: Date;
|
||||
correlationId: string | null;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RconCommandHistoryRow = {
|
||||
eventId: string;
|
||||
time: Date;
|
||||
command: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
adminEmail: string | null;
|
||||
adminName: string | null;
|
||||
status: "pending" | "succeeded" | "failed";
|
||||
reason: string | null;
|
||||
durationMs: number | null;
|
||||
};
|
||||
|
||||
type SearchParams = Record<string, string | string[] | undefined>;
|
||||
|
||||
function first(value: string | string[] | undefined) {
|
||||
return (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
|
||||
}
|
||||
|
||||
function text(data: Record<string, unknown>, key: string) {
|
||||
const value = data[key];
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
export function normalizeRconHistoryFilters(query: SearchParams, availableServerIds: string[]) {
|
||||
const requestedServerId = first(query.server);
|
||||
return {
|
||||
command: first(query.command).slice(0, 1024),
|
||||
admin: first(query.admin).slice(0, 320),
|
||||
serverId: availableServerIds.includes(requestedServerId) ? requestedServerId : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRconCommandHistory(
|
||||
requestedEvents: RconHistoryEvent[],
|
||||
completedEvents: RconHistoryEvent[],
|
||||
): RconCommandHistoryRow[] {
|
||||
const completions = new Map(completedEvents
|
||||
.filter((event) => event.correlationId)
|
||||
.map((event) => [event.correlationId, event]));
|
||||
|
||||
return requestedEvents.flatMap((event) => {
|
||||
const command = text(event.data, "command");
|
||||
const serverId = text(event.data, "serverId");
|
||||
const serverName = text(event.data, "name");
|
||||
if (!command || !serverId || !serverName) return [];
|
||||
|
||||
const completed = event.correlationId ? completions.get(event.correlationId) : undefined;
|
||||
const success = completed?.data.success;
|
||||
const duration = completed?.data.durationMs;
|
||||
return [{
|
||||
eventId: event.id,
|
||||
time: event.time,
|
||||
command,
|
||||
serverId,
|
||||
serverName,
|
||||
adminEmail: text(event.data, "adminEmail"),
|
||||
adminName: text(event.data, "adminName"),
|
||||
status: success === true ? "succeeded" as const : success === false ? "failed" as const : "pending" as const,
|
||||
reason: completed ? text(completed.data, "reason") : null,
|
||||
durationMs: typeof duration === "number" && Number.isFinite(duration) ? duration : null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "./rcon-credentials";
|
||||
|
||||
const key = randomBytes(32).toString("base64");
|
||||
const otherKey = randomBytes(32).toString("base64");
|
||||
const connectionId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
describe("RCON credential encryption", () => {
|
||||
it("round trips with randomized authenticated encryption", () => {
|
||||
const first = encryptRconPassword("super-secret", connectionId, key);
|
||||
const second = encryptRconPassword("super-secret", connectionId, key);
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first).not.toContain("super-secret");
|
||||
expect(decryptRconPassword(first, connectionId, key)).toBe("super-secret");
|
||||
expect(decryptRconPassword(second, connectionId, key)).toBe("super-secret");
|
||||
});
|
||||
|
||||
it("fails closed for tampering, another connection, or another key", () => {
|
||||
const encrypted = encryptRconPassword("super-secret", connectionId, key);
|
||||
expect(() => decryptRconPassword(`${encrypted}x`, connectionId, key)).toThrow("RCON credential unavailable");
|
||||
expect(() => decryptRconPassword(encrypted, "22222222-2222-4222-8222-222222222222", key)).toThrow("RCON credential unavailable");
|
||||
expect(() => decryptRconPassword(encrypted, connectionId, otherKey)).toThrow("RCON credential unavailable");
|
||||
});
|
||||
|
||||
it("requires an exact 32-byte deployment key", () => {
|
||||
expect(() => encryptRconPassword("secret", connectionId, "not-base64")).toThrow("RCON credential key is not configured");
|
||||
});
|
||||
|
||||
it("creates a keyed, versioned command digest", () => {
|
||||
const digest = rconCommandDigest("say secret message", key);
|
||||
expect(digest).toMatch(/^hmac-sha256:v1:[a-f0-9]{64}$/u);
|
||||
expect(digest).not.toContain("secret message");
|
||||
expect(rconCommandDigest("say secret message", key)).toBe(digest);
|
||||
expect(rconCommandDigest("say secret message", otherKey)).not.toBe(digest);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||||
|
||||
const VERSION = "v1";
|
||||
const KEY_BYTES = 32;
|
||||
const IV_BYTES = 12;
|
||||
|
||||
function explicitKey(encoded: string) {
|
||||
const key = Buffer.from(encoded, "base64");
|
||||
if (key.length !== KEY_BYTES || key.toString("base64").replace(/=+$/u, "") !== encoded.trim().replace(/=+$/u, "")) {
|
||||
throw new Error("invalid key");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function credentialKey(encoded: string | undefined, purpose: "credential" | "audit" = "credential") {
|
||||
if (encoded !== undefined) return explicitKey(encoded);
|
||||
const configured = purpose === "credential" ? process.env.RCON_CREDENTIAL_KEY : process.env.RCON_AUDIT_KEY;
|
||||
if (configured) return explicitKey(configured);
|
||||
const authSecret = process.env.AUTH_SECRET;
|
||||
if (!authSecret) throw new Error("missing key");
|
||||
return createHash("sha256").update(`minecraft-account-manager:rcon:${purpose}:v1\0${authSecret}`, "utf8").digest();
|
||||
}
|
||||
|
||||
function additionalData(connectionId: string) {
|
||||
return Buffer.from(`${VERSION}:${connectionId}`, "utf8");
|
||||
}
|
||||
|
||||
function decodeBase64url(value: string) {
|
||||
const decoded = Buffer.from(value, "base64url");
|
||||
if (decoded.toString("base64url") !== value) throw new Error("invalid envelope");
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export function encryptRconPassword(password: string, connectionId: string, encodedKey?: string) {
|
||||
let key: Buffer;
|
||||
try {
|
||||
key = credentialKey(encodedKey);
|
||||
} catch {
|
||||
throw new Error("RCON credential key is not configured");
|
||||
}
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
|
||||
cipher.setAAD(additionalData(connectionId));
|
||||
const ciphertext = Buffer.concat([cipher.update(password, "utf8"), cipher.final()]);
|
||||
return [VERSION, iv.toString("base64url"), cipher.getAuthTag().toString("base64url"), ciphertext.toString("base64url")].join(":");
|
||||
}
|
||||
|
||||
export function rconCommandDigest(command: string, encodedKey?: string) {
|
||||
let key: Buffer;
|
||||
try {
|
||||
key = credentialKey(encodedKey, "audit");
|
||||
} catch {
|
||||
throw new Error("RCON audit key is not configured");
|
||||
}
|
||||
return `hmac-sha256:v1:${createHmac("sha256", key).update(command, "utf8").digest("hex")}`;
|
||||
}
|
||||
|
||||
export function decryptRconPassword(envelope: string, connectionId: string, encodedKey?: string) {
|
||||
try {
|
||||
const key = credentialKey(encodedKey);
|
||||
const [version, ivValue, tagValue, ciphertextValue, extra] = envelope.split(":");
|
||||
if (version !== VERSION || !ivValue || !tagValue || !ciphertextValue || extra) throw new Error("invalid envelope");
|
||||
const iv = decodeBase64url(ivValue);
|
||||
const tag = decodeBase64url(tagValue);
|
||||
const ciphertext = decodeBase64url(ciphertextValue);
|
||||
if (iv.length !== IV_BYTES || tag.length !== 16) throw new Error("invalid envelope");
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
|
||||
decipher.setAAD(additionalData(connectionId));
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||
} catch {
|
||||
throw new Error("RCON credential unavailable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { executeRcon, testRconConnection, type RconTransport } from "./rcon-gateway";
|
||||
|
||||
function transport(overrides: Partial<RconTransport> = {}): RconTransport {
|
||||
return {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
send: vi.fn().mockResolvedValue("20 players online"),
|
||||
end: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RCON gateway", () => {
|
||||
it("authenticates a connection without sending a command", async () => {
|
||||
const client = transport();
|
||||
await expect(testRconConnection({ host: "season4", port: 25575, password: "secret" }, () => client)).resolves.toEqual({ ok: true });
|
||||
expect(client.connect).toHaveBeenCalledOnce();
|
||||
expect(client.send).not.toHaveBeenCalled();
|
||||
expect(client.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("executes one command and always closes the connection", async () => {
|
||||
const client = transport();
|
||||
await expect(executeRcon({ host: "season4", port: 25575, password: "secret" }, "list", () => client)).resolves.toEqual({
|
||||
ok: true,
|
||||
response: "20 players online",
|
||||
});
|
||||
expect(client.send).toHaveBeenCalledWith("list");
|
||||
expect(client.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns safe categorized failures and closes failed clients", async () => {
|
||||
const client = transport({ connect: vi.fn().mockRejectedValue(new Error("password secret rejected")) });
|
||||
await expect(testRconConnection({ host: "season4", port: 25575, password: "secret" }, () => client)).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "unavailable",
|
||||
});
|
||||
expect(client.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects concurrent work for the same connection", async () => {
|
||||
let release!: () => void;
|
||||
const pending = new Promise<string>((resolve) => { release = () => resolve("done"); });
|
||||
const firstClient = transport({ send: vi.fn().mockReturnValue(pending) });
|
||||
const first = executeRcon({ id: "server-one", host: "season4", port: 25575, password: "secret" }, "list", () => firstClient);
|
||||
await vi.waitFor(() => expect(firstClient.send).toHaveBeenCalled());
|
||||
|
||||
await expect(executeRcon({ id: "server-one", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "busy",
|
||||
});
|
||||
release();
|
||||
await first;
|
||||
});
|
||||
|
||||
it("bounds total concurrent work", async () => {
|
||||
let release!: () => void;
|
||||
const pendingResponse = new Promise<string>((resolve) => { release = () => resolve("done"); });
|
||||
const clients = Array.from({ length: 8 }, () => transport({ send: vi.fn().mockReturnValue(pendingResponse) }));
|
||||
const active = clients.map((client, index) => executeRcon({
|
||||
id: `server-${index}`,
|
||||
host: `season-${index}`,
|
||||
port: 25575,
|
||||
password: "secret",
|
||||
}, "list", () => client));
|
||||
await vi.waitFor(() => expect(clients.every((client) => vi.mocked(client.send).mock.calls.length === 1)).toBe(true));
|
||||
|
||||
await expect(executeRcon({ id: "server-ninth", host: "season-9", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "busy",
|
||||
});
|
||||
release();
|
||||
await Promise.all(active);
|
||||
});
|
||||
|
||||
it("times out the complete operation, aborts the socket, and releases the connection", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = transport({
|
||||
send: vi.fn().mockReturnValue(new Promise(() => undefined)),
|
||||
destroy: vi.fn(),
|
||||
});
|
||||
const pending = executeRcon({ id: "server-timeout", host: "season4", port: 25575, password: "secret" }, "list", () => client);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await expect(pending).resolves.toEqual({ ok: false, reason: "timeout" });
|
||||
expect(client.destroy).toHaveBeenCalledOnce();
|
||||
|
||||
await expect(executeRcon({ id: "server-timeout", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: true,
|
||||
response: "20 players online",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let stalled cleanup retain a connection lock", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = transport({ end: vi.fn().mockReturnValue(new Promise(() => undefined)) });
|
||||
const pending = executeRcon({ id: "server-cleanup", host: "season4", port: 25575, password: "secret" }, "list", () => client);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(pending).resolves.toEqual({ ok: true, response: "20 players online" });
|
||||
|
||||
await expect(executeRcon({ id: "server-cleanup", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: true,
|
||||
response: "20 players online",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Rcon } from "rcon-client";
|
||||
import { sanitizeRconOutput } from "./rcon-validation";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
const CLEANUP_TIMEOUT_MS = 1_000;
|
||||
const MAX_ACTIVE_CONNECTIONS = 8;
|
||||
const activeConnections = new Set<string>();
|
||||
|
||||
type Connection = { id?: string; host: string; port: number; password: string };
|
||||
type FailureReason = "busy" | "timeout" | "unavailable";
|
||||
|
||||
export interface RconTransport {
|
||||
connect(): Promise<unknown>;
|
||||
send(command: string): Promise<string>;
|
||||
end(): Promise<unknown>;
|
||||
destroy?(): void;
|
||||
}
|
||||
|
||||
type TransportFactory = (connection: Connection) => RconTransport;
|
||||
|
||||
class RconDeadlineError extends Error {}
|
||||
|
||||
async function deadline<T>(operation: Promise<T>, timeout: () => void, timeoutMs = TIMEOUT_MS) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
timeout();
|
||||
reject(new RconDeadlineError("RCON operation timed out"));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultTransport(connection: Connection): RconTransport {
|
||||
const client = new Rcon({
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
password: connection.password,
|
||||
timeout: TIMEOUT_MS,
|
||||
maxPending: 1,
|
||||
});
|
||||
return {
|
||||
connect: () => client.connect(),
|
||||
send: (command) => client.send(command),
|
||||
end: async () => {
|
||||
if (!client.socket) return;
|
||||
if (client.socket.connecting || !client.socket.writable) {
|
||||
client.socket.destroy();
|
||||
return;
|
||||
}
|
||||
await client.end();
|
||||
},
|
||||
destroy: () => client.socket?.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
function failure(error: unknown): { ok: false; reason: FailureReason } {
|
||||
return { ok: false, reason: error instanceof RconDeadlineError ? "timeout" : "unavailable" };
|
||||
}
|
||||
|
||||
async function withTransport<T>(
|
||||
connection: Connection,
|
||||
operation: (transport: RconTransport) => Promise<T>,
|
||||
factory: TransportFactory,
|
||||
): Promise<T | { ok: false; reason: FailureReason }> {
|
||||
const key = connection.id ?? `${connection.host}:${connection.port}`;
|
||||
if (activeConnections.has(key) || activeConnections.size >= MAX_ACTIVE_CONNECTIONS) {
|
||||
return { ok: false, reason: "busy" };
|
||||
}
|
||||
activeConnections.add(key);
|
||||
let transport: RconTransport | null = null;
|
||||
try {
|
||||
transport = factory(connection);
|
||||
return await deadline(operation(transport), () => transport?.destroy?.());
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
} finally {
|
||||
if (transport) {
|
||||
await deadline(transport.end(), () => transport?.destroy?.(), CLEANUP_TIMEOUT_MS).catch(() => undefined);
|
||||
}
|
||||
activeConnections.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function testRconConnection(connection: Connection, factory: TransportFactory = defaultTransport) {
|
||||
return withTransport(connection, async (transport) => {
|
||||
await transport.connect();
|
||||
return { ok: true as const };
|
||||
}, factory);
|
||||
}
|
||||
|
||||
export async function executeRcon(connection: Connection, command: string, factory: TransportFactory = defaultTransport) {
|
||||
return withTransport(connection, async (transport) => {
|
||||
await transport.connect();
|
||||
const response = await transport.send(command);
|
||||
return { ok: true as const, response: sanitizeRconOutput(response) };
|
||||
}, factory);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeRconOutput, validateRconCommand, validateRconConnection } from "./rcon-validation";
|
||||
|
||||
describe("RCON validation", () => {
|
||||
it("normalizes any valid DNS hostname and port without deployment configuration", () => {
|
||||
expect(validateRconConnection({
|
||||
name: " Season 4 ",
|
||||
host: "SEASON4.SOMC.SVC.CLUSTER.LOCAL",
|
||||
port: "25575",
|
||||
password: "correct horse battery staple",
|
||||
}, { passwordRequired: true })).toEqual({
|
||||
name: "Season 4",
|
||||
host: "season4.somc.svc.cluster.local",
|
||||
port: 25575,
|
||||
password: "correct horse battery staple",
|
||||
});
|
||||
|
||||
expect(validateRconConnection({
|
||||
name: "Creative",
|
||||
host: "creative.example.net",
|
||||
port: "43210",
|
||||
password: "secret",
|
||||
}, { passwordRequired: true })).toEqual({
|
||||
name: "Creative",
|
||||
host: "creative.example.net",
|
||||
port: 43210,
|
||||
password: "secret",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects IP literals and malformed DNS hostnames", () => {
|
||||
for (const host of ["10.0.0.1", "2001:db8::1", "season4.", "-season4.example", "season4..example"]) {
|
||||
expect(validateRconConnection({ name: "Server", host, port: "25575", password: "secret" }, {
|
||||
passwordRequired: true,
|
||||
})).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("allows a blank replacement password only while editing", () => {
|
||||
expect(validateRconConnection({ name: "Server", host: "season4.somc.svc.cluster.local", port: "25575", password: "" }, {
|
||||
passwordRequired: false,
|
||||
})?.password).toBeNull();
|
||||
expect(validateRconConnection({ name: "Server", host: "season4.somc.svc.cluster.local", port: "25575", password: "" }, {
|
||||
passwordRequired: true,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("bounds commands by UTF-8 bytes and rejects control characters", () => {
|
||||
expect(validateRconCommand(" list ")).toBe("list");
|
||||
expect(validateRconCommand("say first\nsay second")).toBeNull();
|
||||
expect(validateRconCommand("say \u001b[31mred")).toBeNull();
|
||||
expect(validateRconCommand(`say ${"😀".repeat(300)}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("strips output controls and bounds output by UTF-8 bytes", () => {
|
||||
expect(sanitizeRconOutput("ok\u001b[31mred\u0000done")).toBe("ok[31mreddone");
|
||||
expect(Buffer.byteLength(sanitizeRconOutput("😀".repeat(20_000)), "utf8")).toBeLessThanOrEqual(65_536);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
const HOST_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u;
|
||||
const MAX_COMMAND_BYTES = 1_024;
|
||||
const MAX_OUTPUT_BYTES = 65_536;
|
||||
|
||||
export type ValidRconConnection = {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
password: string | null;
|
||||
};
|
||||
|
||||
export function validateRconConnection(
|
||||
input: { name: unknown; host: unknown; port: unknown; password: unknown },
|
||||
options: { passwordRequired: boolean },
|
||||
): ValidRconConnection | null {
|
||||
const name = typeof input.name === "string" ? input.name.trim() : "";
|
||||
const host = typeof input.host === "string" ? input.host.trim().toLowerCase() : "";
|
||||
const portText = typeof input.port === "string" || typeof input.port === "number" ? String(input.port).trim() : "";
|
||||
const passwordText = typeof input.password === "string" ? input.password : "";
|
||||
const port = Number(portText);
|
||||
|
||||
if (!name || name.length > 100 || CONTROL_PATTERN.test(name)) return null;
|
||||
if (!host || host.endsWith(".") || isIP(host) !== 0 || !HOST_PATTERN.test(host)) return null;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) return null;
|
||||
if (passwordText.length > 512 || CONTROL_PATTERN.test(passwordText)) return null;
|
||||
if (options.passwordRequired && !passwordText) return null;
|
||||
|
||||
return { name, host, port, password: passwordText || null };
|
||||
}
|
||||
|
||||
export function validateRconCommand(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const command = value.trim();
|
||||
if (!command || CONTROL_PATTERN.test(command) || Buffer.byteLength(command, "utf8") > MAX_COMMAND_BYTES) return null;
|
||||
return command;
|
||||
}
|
||||
|
||||
export function sanitizeRconOutput(value: string) {
|
||||
const safe = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, "");
|
||||
if (Buffer.byteLength(safe, "utf8") <= MAX_OUTPUT_BYTES) return safe;
|
||||
let result = "";
|
||||
let bytes = 0;
|
||||
for (const character of safe) {
|
||||
const size = Buffer.byteLength(character, "utf8");
|
||||
if (bytes + size > MAX_OUTPUT_BYTES) break;
|
||||
result += character;
|
||||
bytes += size;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import SwaggerParser from "@apidevtools/swagger-parser";
|
||||
import Ajv2020 from "ajv/dist/2020";
|
||||
import addFormats from "ajv-formats";
|
||||
import { parse } from "yaml";
|
||||
import { assert, expect } from "vitest";
|
||||
|
||||
type Schema = Record<string, unknown>;
|
||||
type ResponseContract = {
|
||||
headers?: Record<string, { schema: Schema; description?: string }>;
|
||||
content?: Record<string, { schema: Schema; examples?: Record<string, { value: unknown }> }>;
|
||||
};
|
||||
type Operation = { security?: Record<string, string[]>[]; responses: Record<string, ResponseContract>; requestBody?: { content: Record<string, { schema: Schema; examples?: Record<string, { value: unknown }> }> } };
|
||||
export type Contract = { paths: Record<string, Record<string, Operation>> };
|
||||
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
||||
addFormats(ajv);
|
||||
let contract: Promise<Contract> | undefined;
|
||||
export function loadContract() {
|
||||
return contract ??= SwaggerParser.dereference(parse(readFileSync(resolve(process.cwd(), "../../openapi.yaml"), "utf8"))).then((doc) => doc as unknown as Contract);
|
||||
}
|
||||
export function assertSchema(schema: Schema, value: unknown) {
|
||||
const validate = ajv.compile(schema);
|
||||
expect(validate(value), JSON.stringify(validate.errors)).toBe(true);
|
||||
}
|
||||
/** Clone preserves the body for existing behavior assertions. Never mock handlers. */
|
||||
export async function assertResponse(path: string, method: string, response: Response) {
|
||||
const doc = await loadContract();
|
||||
const operation = doc.paths[path]?.[method.toLowerCase()];
|
||||
assert(operation, `undocumented operation: ${method} ${path}`);
|
||||
const expected = operation.responses[String(response.status)];
|
||||
assert(expected, `undocumented HTTP ${response.status}: ${method} ${path}`);
|
||||
for (const [name, header] of Object.entries(expected.headers ?? {})) {
|
||||
const value = response.headers.get(name);
|
||||
if (name.toLowerCase() === "retry-after" && value === null) continue;
|
||||
expect(value, `missing ${name}`).not.toBeNull();
|
||||
assertSchema(header.schema, header.schema.type === "integer" ? Number(value) : value);
|
||||
}
|
||||
const body = response.clone();
|
||||
if (!expected.content) {
|
||||
expect(await body.text()).toBe("");
|
||||
return;
|
||||
}
|
||||
const mediaType = response.headers.get("content-type")?.split(";", 1)[0];
|
||||
expect(mediaType).toBeTruthy();
|
||||
const content = expected.content[mediaType!];
|
||||
assert(content, `undocumented Content-Type: ${mediaType}`);
|
||||
const value = await body.json();
|
||||
assertSchema(content.schema, value);
|
||||
if (mediaType === "application/problem+json") expect(value.status).toBe(response.status);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { cp, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { assertResponse } from "./openapi-contract";
|
||||
|
||||
it.runIf(process.env.OPENAPI_STANDALONE_TEST === "1")("serves canonical bytes and implicit HEAD from isolated standalone/container layout", async () => {
|
||||
const source = resolve(process.cwd(), ".next/standalone");
|
||||
const directory = await mkdtemp(resolve(tmpdir(), "portal-openapi-"));
|
||||
const socket = createServer();
|
||||
socket.listen(0, "127.0.0.1");
|
||||
await once(socket, "listening");
|
||||
const port = (socket.address() as { port: number }).port;
|
||||
await new Promise<void>((done) => socket.close(() => done()));
|
||||
let child: ReturnType<typeof spawn> | undefined;
|
||||
try {
|
||||
await cp(source, directory, { recursive: true });
|
||||
// This mirrors the existing Dockerfile: standalone plus the separate static tree.
|
||||
await cp(resolve(process.cwd(), ".next/static"), resolve(directory, "apps/web/.next/static"), { recursive: true });
|
||||
const canonical = await readFile(resolve(process.cwd(), "../../openapi.yaml"));
|
||||
expect(await readFile(resolve(directory, "openapi.yaml"))).toEqual(canonical);
|
||||
child = spawn(process.execPath, ["apps/web/server.js"], {
|
||||
cwd: directory,
|
||||
env: { PATH: process.env.PATH, NODE_ENV: "production", HOSTNAME: "127.0.0.1", PORT: String(port) },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const server = child;
|
||||
await new Promise<void>((done, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("Standalone startup timed out")), 20_000);
|
||||
server.once("error", (error) => { clearTimeout(timer); reject(error); });
|
||||
server.once("exit", (code) => { clearTimeout(timer); reject(new Error(`Standalone exited: ${code}`)); });
|
||||
server.stdout!.on("data", (chunk: Buffer) => {
|
||||
if (chunk.toString().includes("Ready")) { clearTimeout(timer); done(); }
|
||||
});
|
||||
// Drain stderr, but do not forward arbitrary server output into test logs.
|
||||
server.stderr!.resume();
|
||||
});
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const response = await fetch(`${origin}/openapi.yaml`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/yaml; charset=utf-8");
|
||||
expect(Buffer.from(await response.arrayBuffer())).toEqual(canonical);
|
||||
const head = await fetch(`${origin}/openapi.yaml`, { method: "HEAD" });
|
||||
expect(head.status).toBe(200);
|
||||
expect(await head.text()).toBe("");
|
||||
for (const path of ["/api/admin/whoami", "/api/suggestions", "/api/suggestions/{id}", "/api/suggestions/{id}/messages"]) {
|
||||
const url = `${origin}${path.replace("{id}", "100000000000000009")}`;
|
||||
for (const method of ["GET", "HEAD"]) {
|
||||
const result = await fetch(url, { method, headers: { authorization: "Bearer invalid" } });
|
||||
expect(result.status).toBe(401);
|
||||
await assertResponse(path, method, result);
|
||||
}
|
||||
}
|
||||
for (const kind of ["access", "connection"]) {
|
||||
const path = `/api/velocity/${kind}`;
|
||||
const result = await fetch(origin + path, { method: "HEAD" });
|
||||
expect(result.status).toBe(405);
|
||||
await assertResponse(path, "head", result);
|
||||
}
|
||||
} finally {
|
||||
if (child && child.exitCode === null) {
|
||||
const stopped = once(child, "exit");
|
||||
child.kill("SIGTERM");
|
||||
const timer = setTimeout(() => child?.kill("SIGKILL"), 3000);
|
||||
await stopped;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
okf_version: "0.1"
|
||||
---
|
||||
|
||||
# Minecraft Account Manager User Stories
|
||||
|
||||
This OKF bundle is the product record for implemented and proposed behavior. Story status and acceptance criteria are maintained alongside code changes.
|
||||
|
||||
## Player Experience
|
||||
|
||||
* [US-001 — Enter through Discord](us-001-discord-entry.md) - Direct portal visitors are guided to the configured Discord server.
|
||||
* [US-002 — Authenticate with a Discord magic link](us-002-discord-magic-link.md) - Discord users receive secure, private, single-use portal links.
|
||||
* [US-003 — Complete first-time onboarding](us-003-onboarding.md) - New users provide a name, connect a Java account, and confirm Discord identity.
|
||||
* [US-004 — Validate Minecraft accounts](us-004-minecraft-validation.md) - Java usernames resolve through Mojang with explicit unverified overrides.
|
||||
* [US-005 — Manage linked accounts](us-005-user-dashboard.md) - Users manage names, accounts, primaries, and security history.
|
||||
* [US-006 — Keep Discord nicknames synchronized](us-006-discord-nickname.md) - Names and primary accounts determine the guild nickname.
|
||||
|
||||
## Network and Game Access
|
||||
|
||||
* [US-007 — Enrich login IPs](us-007-ip-intelligence.md) - Portal and game login events include cached ProxyCheck location and network data.
|
||||
* [US-008 — Block anonymized account additions](us-008-vpn-blocking.md) - VPN, proxy, Tor, and unknown networks cannot add accounts.
|
||||
* [US-009 — Enforce registration at Velocity](us-009-velocity-admission.md) - The proxy admits positively identified registered Java accounts only.
|
||||
|
||||
## Administration and Governance
|
||||
|
||||
* [US-010 — Preserve an audit trail](us-010-audit-events.md) - Security and account activity is stored as CloudEvents-style events.
|
||||
* [US-011 — Authenticate administrators with SSO](us-011-admin-sso.md) - Keycloak and a required role protect the operator console.
|
||||
* [US-012 — Operate settings and audit views](us-012-admin-operations.md) - Administrators configure denial messaging and inspect events.
|
||||
* [US-013 — Manage users as an administrator](us-013-admin-user-management.md) - Administrators search users and manage names and Minecraft accounts.
|
||||
* [US-014 — Receive standardized API errors](us-014-problem-details.md) - Application APIs return RFC 9457 Problem Details.
|
||||
* [US-015 — Deploy and operate securely](us-015-platform-operations.md) - Operators have reproducible builds, migrations, credentials, and security controls.
|
||||
* [US-016 — Build and publish versioned releases](us-016-automated-releases.md) - Gitea Actions publish the Velocity JAR and web and migration images.
|
||||
* [US-017 — Control admission with groups](us-017-group-access.md) - Each user has one effective group that explicitly controls Minecraft access.
|
||||
* [US-018 — Monitor community account activity](us-018-admin-dashboard.md) - Administrators review daily users, confirmed connections, locations, denials, and risky networks.
|
||||
* [US-019 — Manage groups efficiently](us-019-admin-group-management.md) - Administrators manage group identity, policies, membership, and creation through focused confirmed workflows.
|
||||
|
||||
# Tracking
|
||||
|
||||
See the [design update log](log.md) for high-level changes. New work starts by creating or updating a story and its acceptance criteria.
|
||||
@@ -1,31 +0,0 @@
|
||||
# Design Update Log
|
||||
|
||||
## 2026-08-02
|
||||
|
||||
* **Refine**: Replace admin group cards with a policy table, confirmed modal workflows, editable group details, and reusable effective-member management.
|
||||
* **Add**: Provide Users-page group assignment, effective-group VPN/proxy/Tor exceptions for game admission, and independent configurable denial messages.
|
||||
* **Fix**: Treat malformed ProxyCheck proxy signals as unknown and classify every authenticated Velocity login before identity resolution.
|
||||
* **Fix**: Replace the dashboard's pre-enrichment network label with enriched company, ASN, connection type, Proxy/VPN status, and risk fields.
|
||||
* **Fix**: Group collocated map users into count-badged markers with complete nickname tooltips and per-user interactive-map links.
|
||||
* **Refine**: Replace registration counts with daily active users, collapse enriched VPN activity per user, add opt-in OpenStreetMap zoom, show managed nickname tooltips, and measure active Minecraft accounts from confirmed Velocity connections.
|
||||
* **Governance**: Require user review and explicit confirmation of relevant OKF story changes before future implementation work.
|
||||
|
||||
## 2026-08-01
|
||||
|
||||
* **Extend**: Plot each user's latest approximate location on an accessible, server-rendered Natural Earth world map in the operations dashboard.
|
||||
* **Refine**: Make group assignment exclusive with default fallback, add group deletion, automatically synchronize Discord nicknames with status notices, expose filterable event details, add an SSR operations dashboard, and improve accessibility.
|
||||
* **Extend**: Add SoMC Portal branding, live Discord identity details, admin guild configuration visibility, and fail-closed group-based Minecraft admission.
|
||||
* **Refine**: Group repeated access networks, confirm linked Discord nickname changes before mutation, and add DMG Games sponsorship attribution.
|
||||
* **Extend**: Add shared Pino logging with credential redaction and actionable web and Discord runtime diagnostics.
|
||||
* **Fix**: Build magic-link redirects from the configured public portal URL instead of the reverse proxy's internal request origin.
|
||||
* **Verify**: Confirmed `v1.1.1` left all pre-existing `latest` digests unchanged while publishing versioned artifacts.
|
||||
* **Refine**: Removed mutable `latest` publication so all deployable artifacts use explicit semantic versions.
|
||||
* **Verify**: Confirmed the `v1.1.0` Discord bot image and matching web, migration, and Velocity artifacts.
|
||||
* **Extend**: Added a releasable Discord bot image and a dependency-free web health endpoint for Kubernetes deployment.
|
||||
* **Verify**: Confirmed the initial `v1.0.0` release, public Velocity JAR, and versioned and `latest` web and migration image manifests.
|
||||
* **Create**: Added Gitea CI and semantic-release pipelines for downloadable Velocity JARs and versioned web and migration images.
|
||||
* **Document**: Added container deployment order, artifact names, and required repository secrets.
|
||||
* **Refine**: Corrected the Velocity Java and Gradle namespace to the repository owner's `games.dmg` reverse domain.
|
||||
* **Create**: Established the OKF v0.1 [user-story index](index.md).
|
||||
* **Document**: Captured the implemented player portal, Discord authentication, onboarding, account management, network intelligence, Velocity admission, auditing, administration, API error, and operational stories.
|
||||
* **Governance**: Added repository agent guidance and automated OKF validation for story-driven development.
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Enter the account portal through Discord
|
||||
description: Direct visitors are guided to the configured Discord community and its account commands.
|
||||
tags: [player, portal, discord, onboarding]
|
||||
timestamp: 2026-08-01T22:34:31Z
|
||||
story_id: US-001
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a prospective player, I want the portal to direct me to the community Discord, so that I can begin registration through the trusted entry point.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Given an unauthenticated visitor, when they open the portal, then they are told to run `/register` or `/account` in Discord.
|
||||
- [x] Given a configured invite URL, when the visitor selects the join action, then the Discord invite opens in a new browser context.
|
||||
- [x] Given a configured guild ID, when the visitor selects the app action, then a `discord://` guild link is opened.
|
||||
- [x] Given an unauthenticated protected-page request, when authorization fails, then the visitor returns to the portal with prominent Discord instructions.
|
||||
- [x] Every portal page credits Social Minecraft sponsorship by DMG Games and links to `https://dmg.games`.
|
||||
- [x] Portal branding uses the SoMC Portal name and a dedicated favicon.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/page.tsx`](../apps/web/src/app/page.tsx)
|
||||
- [`apps/web/src/lib/auth/user-session.ts`](../apps/web/src/lib/auth/user-session.ts)
|
||||
- [`apps/web/src/components/site-footer.tsx`](../apps/web/src/components/site-footer.tsx)
|
||||
- [`apps/web/src/app/icon.svg`](../apps/web/src/app/icon.svg)
|
||||
- Configuration: `DISCORD_GUILD_ID`, `DISCORD_INVITE_URL`
|
||||
|
||||
# Validation
|
||||
|
||||
Covered by the Next.js production build and protected-route session checks.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Discord magic-link authentication](us-002-discord-magic-link.md)
|
||||
- [First-time onboarding](us-003-onboarding.md)
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Authenticate with a Discord magic link
|
||||
description: Discord users receive private single-use links that establish secure portal sessions.
|
||||
tags: [player, discord, authentication, security]
|
||||
timestamp: 2026-08-01T20:43:46Z
|
||||
story_id: US-002
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a Discord community member, I want `/register` and `/account` to issue a private sign-in link, so that I can access the portal without creating another password.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Given the configured guild, when a user runs `/register` or `/account`, then the bot responds ephemerally with a private link.
|
||||
- [x] Given a generated link, then the raw login token is never stored in PostgreSQL.
|
||||
- [x] Given a login token, then it expires after ten minutes and can be consumed only once.
|
||||
- [x] Given repeated link requests, then requests are rate limited per Discord user and older active links are invalidated.
|
||||
- [x] Given a valid link, when it is consumed, then the Discord user is created or refreshed and a secure seven-day session is established.
|
||||
- [x] Given a magic-link result behind a reverse proxy, then the browser is redirected through the configured public application URL rather than an internal container address.
|
||||
- [x] Given an invalid, expired, or consumed link, then the user sees a safe recovery page instructing them to request another link.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/discord-bot/src/index.ts`](../apps/discord-bot/src/index.ts)
|
||||
- [`packages/auth/src/index.ts`](../packages/auth/src/index.ts)
|
||||
- [`packages/database/src/auth-repository.ts`](../packages/database/src/auth-repository.ts)
|
||||
- [`apps/web/src/app/auth/discord/route.ts`](../apps/web/src/app/auth/discord/route.ts)
|
||||
- [`apps/web/src/lib/application-url.ts`](../apps/web/src/lib/application-url.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`packages/auth/test/magic-link.test.ts`](../packages/auth/test/magic-link.test.ts)
|
||||
- [`apps/web/src/lib/application-url.test.ts`](../apps/web/src/lib/application-url.test.ts)
|
||||
- Discord command and authentication workspaces pass TypeScript validation.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Enter through Discord](us-001-discord-entry.md)
|
||||
- [Preserve an audit trail](us-010-audit-events.md)
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Complete first-time onboarding
|
||||
description: New users establish their preferred identity and first Minecraft account.
|
||||
tags: [player, onboarding, minecraft, discord]
|
||||
timestamp: 2026-08-01T18:43:58Z
|
||||
story_id: US-003
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a newly authenticated player, I want a guided setup flow, so that my preferred name, Minecraft identity, and Discord nickname are configured correctly.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Given a new Discord user, when they enter the portal, then they receive a personalized welcome.
|
||||
- [x] Given the first onboarding step, when the user enters a valid preferred name, then it is stored for their profile.
|
||||
- [x] Given the Minecraft step, when a valid Java username is submitted from an allowed network, then it is verified and added as primary.
|
||||
- [x] Given an unverifiable but syntactically valid username, then the user must explicitly confirm before continuing.
|
||||
- [x] Given a name and primary account, then the expected Discord nickname is previewed before any guild update.
|
||||
- [x] Given confirmation and a successful Discord update, then onboarding is marked complete and the dashboard opens.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/welcome/page.tsx`](../apps/web/src/app/welcome/page.tsx)
|
||||
- [`apps/web/src/app/welcome/minecraft/page.tsx`](../apps/web/src/app/welcome/minecraft/page.tsx)
|
||||
- [`apps/web/src/app/welcome/discord/page.tsx`](../apps/web/src/app/welcome/discord/page.tsx)
|
||||
- [`apps/web/src/app/welcome/actions.ts`](../apps/web/src/app/welcome/actions.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
Onboarding routes are protected by database-backed sessions and included in production route generation.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
|
||||
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
|
||||
- [Block anonymized account additions](us-008-vpn-blocking.md)
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Validate Minecraft accounts
|
||||
description: Java Edition usernames resolve to canonical Mojang identities with controlled override behavior.
|
||||
tags: [player, minecraft, mojang, identity]
|
||||
timestamp: 2026-08-01T18:43:58Z
|
||||
story_id: US-004
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a player, I want submitted Minecraft usernames checked against Mojang, so that the server can identify my online-mode Java account reliably.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Given a syntactically valid username, when it is submitted, then validation occurs server-side against the fixed Mojang endpoint.
|
||||
- [x] Given a Mojang match, then the canonical username and compact UUID are stored.
|
||||
- [x] Given no Mojang match, then the user or administrator must explicitly confirm an unverified override.
|
||||
- [x] Given malformed input, then it cannot be stored even through an override.
|
||||
- [x] Given an active UUID or case-insensitive username already registered, then another active registration is rejected.
|
||||
- [x] Given a later online-mode game login for an unverified account, then its UUID can be safely backfilled after username matching.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`packages/minecraft/src/index.ts`](../packages/minecraft/src/index.ts)
|
||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||
- User and administrator account actions under [`apps/web/src/app`](../apps/web/src/app)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts)
|
||||
- Database partial unique indexes preserve active identity invariants.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [First-time onboarding](us-003-onboarding.md)
|
||||
- [Velocity game admission](us-009-velocity-admission.md)
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Manage linked accounts from the dashboard
|
||||
description: Authenticated users maintain their profile and active Java Edition accounts.
|
||||
tags: [player, dashboard, minecraft, profile]
|
||||
timestamp: 2026-08-01T23:10:59Z
|
||||
story_id: US-005
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a registered player, I want to manage my profile and linked Minecraft accounts, so that my whitelist identity remains current.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Given an authenticated user, then only their own profile, accounts, and IP observations are visible and mutable.
|
||||
- [x] The user can update their preferred name.
|
||||
- [x] The user can add Mojang-verified or explicitly confirmed accounts from an allowed network.
|
||||
- [x] The user can soft-remove an active account.
|
||||
- [x] The user can choose exactly one active primary account.
|
||||
- [x] Removing a primary account promotes another active account when one exists.
|
||||
- [x] Name, primary, and account-removal changes automatically synchronize the expected Discord nickname and report the result.
|
||||
- [x] The dashboard shows recent portal and game IP observations with classification and available location.
|
||||
- [x] The dashboard shows the user's Discord display name, username, guild nickname, and immutable Discord ID.
|
||||
- [x] The dashboard shows the single effective access group and whether it grants Minecraft access.
|
||||
- [x] The user can revoke the current session by signing out.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/account/page.tsx`](../apps/web/src/app/account/page.tsx)
|
||||
- [`apps/web/src/app/account/actions.ts`](../apps/web/src/app/account/actions.ts)
|
||||
- [`apps/web/src/app/auth/actions.ts`](../apps/web/src/app/auth/actions.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
Server actions verify the current session and constrain every account lookup by the authenticated user ID. Nickname result announcements are covered by [`apps/web/src/components/nickname-notice.test.tsx`](../apps/web/src/components/nickname-notice.test.tsx).
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
|
||||
- [Enrich login IPs](us-007-ip-intelligence.md)
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Keep Discord nicknames synchronized
|
||||
description: Preferred names and primary Minecraft usernames determine community guild nicknames.
|
||||
tags: [player, admin, discord, identity]
|
||||
timestamp: 2026-08-01T23:10:59Z
|
||||
story_id: US-006
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a community member, I want my Discord nickname to reflect my preferred name and primary Minecraft account, so that other players can identify me consistently.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Given a preferred name and primary account, then the nickname format is `First name (MinecraftUsername)`.
|
||||
- [x] Given Discord's 32-character limit, then the preferred-name portion is shortened while preserving the Minecraft username.
|
||||
- [x] Given no remaining Minecraft account, then synchronization uses `First name (TBD)`.
|
||||
- [x] User name, first-account, primary, and account-removal changes synchronize the nickname automatically without a second confirmation step.
|
||||
- [x] Successful synchronization shows the exact new nickname in a dismissible status notice.
|
||||
- [x] Discord failures show an assertive error notice without falsely claiming synchronization completed.
|
||||
- [x] Administrator name, primary, and primary-removal operations synchronize the nickname automatically.
|
||||
- [x] A protected administrative retry action can synchronize the current desired nickname.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`packages/minecraft/src/index.ts`](../packages/minecraft/src/index.ts)
|
||||
- [`apps/web/src/app/account/actions.ts`](../apps/web/src/app/account/actions.ts)
|
||||
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
|
||||
- [`apps/web/src/components/nickname-notice.tsx`](../apps/web/src/components/nickname-notice.tsx)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`packages/minecraft/test/discord.test.ts`](../packages/minecraft/test/discord.test.ts)
|
||||
- Nickname length and fallback behavior are covered in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts).
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Manage linked accounts](us-005-user-dashboard.md)
|
||||
- [Administer users](us-013-admin-user-management.md)
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Enrich portal and game login IPs
|
||||
description: Login audit events include cached approximate location and network intelligence from ProxyCheck.io.
|
||||
tags: [security, network, audit, proxycheck]
|
||||
timestamp: 2026-08-02T14:12:43Z
|
||||
story_id: US-007
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an operator, I want portal and registered game logins enriched with network context, so that suspicious access can be investigated.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Given a public login IP, then ProxyCheck can provide city, region, country, coordinates, timezone, ASN, provider, risk, and anonymity classification.
|
||||
- [x] Results are cached in PostgreSQL for 48 hours by default.
|
||||
- [x] Provider failures are cached briefly and do not deny portal or registered game login.
|
||||
- [x] Private, loopback, reserved, documentation, and mapped-private addresses are never sent to ProxyCheck.
|
||||
- [x] Forwarded web IP headers are ignored unless trusted-proxy handling is explicitly enabled.
|
||||
- [x] Every authenticated Velocity login request uses the cached ProxyCheck path before identity resolution, preventing account-creation races from bypassing network policy.
|
||||
- [x] Login events and IP observations retain the available classification and approximate location.
|
||||
- [x] Users and administrators can see available location and classification in audit views.
|
||||
- [x] Repeated access observations are summarized by IPv4 /24 or IPv6 /64 network with counts, sources, and latest activity.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`packages/network/src/index.ts`](../packages/network/src/index.ts)
|
||||
- [`apps/web/src/lib/ip-intelligence.ts`](../apps/web/src/lib/ip-intelligence.ts)
|
||||
- [`apps/web/src/app/auth/discord/route.ts`](../apps/web/src/app/auth/discord/route.ts)
|
||||
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`packages/network/test/proxycheck.test.ts`](../packages/network/test/proxycheck.test.ts)
|
||||
- [`packages/network/test/client-ip.test.ts`](../packages/network/test/client-ip.test.ts)
|
||||
- [`packages/network/test/address-groups.test.ts`](../packages/network/test/address-groups.test.ts)
|
||||
- [`apps/web/src/lib/access-address-groups.test.ts`](../apps/web/src/lib/access-address-groups.test.ts)
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Block anonymized additions](us-008-vpn-blocking.md)
|
||||
- [Preserve an audit trail](us-010-audit-events.md)
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Block account additions from anonymized networks
|
||||
description: User Minecraft-account additions fail closed for VPN, proxy, Tor, or unknown IP classifications.
|
||||
tags: [security, vpn, proxy, minecraft]
|
||||
timestamp: 2026-08-02T14:12:43Z
|
||||
story_id: US-008
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an operator, I want account additions blocked from anonymized networks, so that whitelist identities are established from attributable connections.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] VPN, proxy, and Tor classifications block user account additions.
|
||||
- [x] Unknown or unavailable classification blocks additions rather than failing open.
|
||||
- [x] Hosting-provider ranges can be blocked through deployment configuration.
|
||||
- [x] Normal portal use and game login are not denied solely because intelligence is unavailable.
|
||||
- [x] Blocked users receive a clear recovery message without provider internals.
|
||||
- [x] Blocked and classification-unavailable attempts create distinct audit events with safe intelligence details.
|
||||
- [x] Administrative account additions remain available as an authorized recovery path.
|
||||
- [x] Administrators see enriched risky-network observations collapsed to one latest summary per user.
|
||||
- [x] Game admission enforces confirmed VPN, proxy, and Tor classifications according to the user's effective-group exception policy.
|
||||
- [x] Account-addition blocking remains unchanged and independent from the game-admission exception.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/lib/ip-intelligence.ts`](../apps/web/src/lib/ip-intelligence.ts)
|
||||
- [`apps/web/src/app/welcome/actions.ts`](../apps/web/src/app/welcome/actions.ts)
|
||||
- [`apps/web/src/app/account/actions.ts`](../apps/web/src/app/account/actions.ts)
|
||||
- Configuration: `PROXYCHECK_API_KEY`, `BLOCK_HOSTING_IPS`, `TRUST_PROXY`
|
||||
|
||||
# Validation
|
||||
|
||||
The fail-closed classification policy and provider mappings are covered by [`packages/network/test/proxycheck.test.ts`](../packages/network/test/proxycheck.test.ts).
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Enrich login IPs](us-007-ip-intelligence.md)
|
||||
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
|
||||
@@ -1,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)
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Preserve a CloudEvents-style audit trail
|
||||
description: Authentication, UI, account, Discord, network, and game actions create searchable immutable-style events.
|
||||
tags: [audit, cloudevents, security, events]
|
||||
timestamp: 2026-08-02T00:12:32Z
|
||||
story_id: US-010
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an operator, I want security and identity activity recorded consistently, so that incidents and account changes can be reconstructed and later published to Kafka.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Events preserve CloudEvents-style ID, specification version, source, type, subject, time, content type, and JSON data.
|
||||
- [x] Events can include user actor, IP address, and correlation ID.
|
||||
- [x] Portal access, magic-link creation and consumption, account changes, nickname changes, VPN blocks, game decisions, and confirmed proxy connections are recorded.
|
||||
- [x] Username changes learned from Velocity create their own event.
|
||||
- [x] Administrative actions include the acting SSO identity in event data.
|
||||
- [x] Events can be filtered by operator-friendly view and selected event types globally and from an individual user view.
|
||||
- [x] Every listed event links to a detail page showing its complete CloudEvents envelope and formatted JSON data.
|
||||
- [x] `published_at` reserves an outbox path for future Kafka publishing.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`packages/database/src/events.ts`](../packages/database/src/events.ts)
|
||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||
- [`apps/web/src/lib/audit.ts`](../apps/web/src/lib/audit.ts)
|
||||
- [`apps/web/src/app/admin/(console)/events/page.tsx`](../apps/web/src/app/admin/%28console%29/events/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/events/[eventId]/page.tsx`](../apps/web/src/app/admin/%28console%29/events/%5BeventId%5D/page.tsx)
|
||||
|
||||
# Validation
|
||||
|
||||
The shared CloudEvent contract is covered by [`packages/contracts/test/contracts.test.ts`](../packages/contracts/test/contracts.test.ts), and event-producing routes pass full type and production-build validation.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Enrich login IPs](us-007-ip-intelligence.md)
|
||||
- [Administer users](us-013-admin-user-management.md)
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Authenticate administrators with Keycloak SSO
|
||||
description: The operator console requires a Keycloak identity with the configured administrator role.
|
||||
tags: [admin, keycloak, oidc, authentication]
|
||||
timestamp: 2026-08-01T18:43:58Z
|
||||
story_id: US-011
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an administrator, I want to authenticate through organizational SSO, so that privileged operations use centrally managed identities and roles.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Admin authentication uses Keycloak OpenID Connect authorization code flow.
|
||||
- [x] Sign-in is denied when the configured required role is absent.
|
||||
- [x] Realm and configured-client roles are extracted from fresh Keycloak tokens.
|
||||
- [x] Admin console layouts redirect unauthenticated or unauthorized users to the admin login page.
|
||||
- [x] Every privileged server action independently rechecks the admin session and role.
|
||||
- [x] Admin sessions use signed JWT behavior managed by NextAuth.
|
||||
- [x] Administrators can sign out and return to the restricted login page.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/lib/auth/admin-auth.ts`](../apps/web/src/lib/auth/admin-auth.ts)
|
||||
- [`apps/web/src/lib/auth/require-admin.ts`](../apps/web/src/lib/auth/require-admin.ts)
|
||||
- [`apps/web/src/app/admin`](../apps/web/src/app/admin)
|
||||
- [`docs/admin-oidc-keycloak-setup.md`](../docs/admin-oidc-keycloak-setup.md)
|
||||
|
||||
# Validation
|
||||
|
||||
OIDC role extraction is covered by [`packages/auth/test/oidc-roles.test.ts`](../packages/auth/test/oidc-roles.test.ts).
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Operate settings and audit views](us-012-admin-operations.md)
|
||||
- [Administer users](us-013-admin-user-management.md)
|
||||
@@ -1,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)
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Manage users as an administrator
|
||||
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
|
||||
tags: [admin, users, minecraft, discord]
|
||||
timestamp: 2026-08-02T15:03:59Z
|
||||
story_id: US-013
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an administrator, I want to manage a user's identity and Minecraft accounts, so that support issues can be resolved without direct database access.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Administrators can search by preferred name, Discord username or ID, Minecraft username, or UUID.
|
||||
- [x] Search results show onboarding state, primary username, and active account count.
|
||||
- [x] A user detail view shows Discord display name, username, guild nickname, immutable ID, active accounts, groups, recent events, and recent IP observations.
|
||||
- [x] Administrators can update the preferred name and synchronize Discord.
|
||||
- [x] Administrators can add Mojang-verified accounts or explicitly override an unverified username.
|
||||
- [x] Administrators can remove an account only after a visible confirmation step.
|
||||
- [x] Removing a primary account selects a replacement or falls back to the preferred-name nickname.
|
||||
- [x] Administrators can set a new primary account and automatically update Discord.
|
||||
- [x] Every action rechecks role and account ownership and records the acting administrator.
|
||||
- [x] Discord failures do not falsely persist the requested name, primary, or removal change.
|
||||
- [x] Each row in the administrator user registry shows the user's effective group in an accessible dropdown.
|
||||
- [x] Selecting a group immediately applies the assignment; selecting `everyone` removes the explicit assignment.
|
||||
- [x] Group changes preserve the active user search and show accessible success or error feedback.
|
||||
- [x] Registry assignment changes revalidate administrator authorization, user existence, and group existence, and audit the previous and new effective groups.
|
||||
- [x] User rows and group-assignment controls are reusable between the Users registry and group-member details.
|
||||
- [x] Group details show only the group's effective members with identity, Discord, primary-account, account-count, status, and group columns.
|
||||
- [x] Changing a user's group requires modal confirmation and choosing `everyone` removes the explicit assignment.
|
||||
- [x] Moving a member to another group removes that user from the current effective-member list after confirmation.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/admin/(console)/users/page.tsx`](../apps/web/src/app/admin/%28console%29/users/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/users/[userId]/page.tsx`](../apps/web/src/app/admin/%28console%29/users/%5BuserId%5D/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
|
||||
- [`apps/web/src/components/user-group-select.tsx`](../apps/web/src/components/user-group-select.tsx)
|
||||
- [`apps/web/src/components/admin-user-table.tsx`](../apps/web/src/components/admin-user-table.tsx)
|
||||
|
||||
# Validation
|
||||
|
||||
Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts). Privileged routes pass TypeScript, lint, Semgrep, and production build checks.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Administrator SSO](us-011-admin-sso.md)
|
||||
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
|
||||
- [Control Minecraft admission with groups](us-017-group-access.md)
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Receive standardized API errors
|
||||
description: Application-owned HTTP APIs expose RFC 9457 Problem Details matching game-ingest-server conventions.
|
||||
tags: [api, errors, rfc9457, contracts]
|
||||
timestamp: 2026-08-01T18:43:58Z
|
||||
story_id: US-014
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an API consumer, I want errors returned as standardized Problem Details, so that failures can be handled consistently across game services.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Error responses use `application/problem+json`.
|
||||
- [x] Responses require `type`, `title`, and `status` and optionally include `detail`, `instance`, and `extensions`.
|
||||
- [x] Invalid Velocity payloads include machine-readable Zod issues under `extensions.issues`.
|
||||
- [x] Missing credentials, expired requests, replays, unsupported media, unsupported methods, unknown routes, and service failures have stable `urn:error:*` types.
|
||||
- [x] Unexpected application-owned Velocity errors are converted to safe `503` problems without internal details.
|
||||
- [x] A normal whitelist denial remains a successful `200` authorization decision.
|
||||
- [x] Browser form redirects remain accessible HTML flows and OAuth protocol responses remain owned by NextAuth.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`packages/contracts/src/index.ts`](../packages/contracts/src/index.ts)
|
||||
- [`apps/web/src/lib/problem-response.ts`](../apps/web/src/lib/problem-response.ts)
|
||||
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
||||
- [`docs/api-errors.md`](../docs/api-errors.md)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`packages/contracts/test/problem-details.test.ts`](../packages/contracts/test/problem-details.test.ts)
|
||||
- [`apps/web/src/app/api/velocity/access/route.test.ts`](../apps/web/src/app/api/velocity/access/route.test.ts)
|
||||
- Unknown-route and response-helper tests in the web workspace.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Velocity game admission](us-009-velocity-admission.md)
|
||||
- [Deploy and operate securely](us-015-platform-operations.md)
|
||||
|
||||
# Citations
|
||||
|
||||
[1] [RFC 9457 — Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457)
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Deploy and operate the platform securely
|
||||
description: Operators have repeatable builds, migrations, credential provisioning, configuration, and security checks.
|
||||
tags: [operations, security, database, deployment]
|
||||
timestamp: 2026-08-01T23:10:59Z
|
||||
story_id: US-015
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a platform operator, I want reproducible deployment and security controls, so that the portal, bot, database, and proxy can be operated safely.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] The repository is an npm TypeScript workspace with separate web, bot, contract, database, network, and Minecraft modules.
|
||||
- [x] PostgreSQL is available through Docker Compose for local use.
|
||||
- [x] Drizzle changes use generated, versioned migrations rather than schema push.
|
||||
- [x] Velocity credentials can be provisioned or rotated with a one-time-displayed token stored only as a hash.
|
||||
- [x] The Velocity Gradle wrapper produces a tested shaded JAR.
|
||||
- [x] Environment examples document database, Keycloak, Discord, trusted proxy, and ProxyCheck settings without secrets.
|
||||
- [x] The web application sets CSP, framing, MIME, referrer, and permissions headers.
|
||||
- [x] Database-backed user and administrator pages render as dynamic React Server Components with server-side data access.
|
||||
- [x] Core pages provide keyboard focus indication, a skip link, labelled controls, table semantics, live status messaging, sufficient text contrast, and reduced-motion support.
|
||||
- [x] The web runtime provides a dependency-free health endpoint for orchestration probes.
|
||||
- [x] Web and Discord bot runtimes emit structured Pino logs with credential-field redaction and safe operational context.
|
||||
- [x] npm dependency audit and Semgrep security review complete without findings at the last verified change.
|
||||
- [x] Architecture, Keycloak, API error, security, bot, and Velocity operating documentation is available.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`package.json`](../package.json)
|
||||
- [`compose.yml`](../compose.yml)
|
||||
- [`packages/database/drizzle`](../packages/database/drizzle)
|
||||
- [`packages/database/scripts/create-plugin-credential.ts`](../packages/database/scripts/create-plugin-credential.ts)
|
||||
- [`plugins/velocity/build.gradle.kts`](../plugins/velocity/build.gradle.kts)
|
||||
- [`apps/web/next.config.ts`](../apps/web/next.config.ts)
|
||||
- [`packages/logging/src/index.ts`](../packages/logging/src/index.ts)
|
||||
- [`docs/accessibility.md`](../docs/accessibility.md)
|
||||
|
||||
# Validation
|
||||
|
||||
Use `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, `npm run velocity:build`, `npm audit`, and `npm run design:validate`. Structured logging redaction is covered by [`packages/logging/test/logger.test.ts`](../packages/logging/test/logger.test.ts).
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Administrator SSO](us-011-admin-sso.md)
|
||||
- [Standardize API errors](us-014-problem-details.md)
|
||||
- [Build and publish versioned releases](us-016-automated-releases.md)
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Build and publish versioned releases
|
||||
description: Gitea Actions validate every change and publish semantically versioned Velocity and container artifacts.
|
||||
tags: [operations, ci, release, velocity, docker]
|
||||
timestamp: 2026-08-01T20:05:49Z
|
||||
story_id: US-016
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As a platform operator, I want automated validation and semantic releases, so that deployable web, migration, and Velocity artifacts are reproducible and downloadable.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Pushes and pull requests run OKF validation, linting, type checks, tests, the web build, and the Velocity build.
|
||||
- [x] Pull requests validate conventional commit messages.
|
||||
- [x] CI uploads the development Velocity JAR as a workflow artifact.
|
||||
- [x] Main-branch conventional commits determine the next semantic version and create a `vMAJOR.MINOR.PATCH` tag.
|
||||
- [x] A release build embeds the semantic version in the Velocity plugin and JAR filename.
|
||||
- [x] A public Gitea release exposes the versioned Velocity JAR as a downloadable asset.
|
||||
- [x] Releases publish semantically versioned web runtime images to the Gitea registry.
|
||||
- [x] Releases publish semantically versioned Discord bot images to the Gitea registry.
|
||||
- [x] Releases publish semantically versioned migration images that run versioned Drizzle migrations.
|
||||
- [x] Releases do not publish mutable container tags such as `latest`.
|
||||
- [x] Runtime containers use unprivileged users and exclude development source and secrets where practical.
|
||||
- [x] Operators are told which repository secrets must be configured before the first push.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [CI workflow](../.gitea/workflows/ci.yml)
|
||||
- [Release workflow](../.gitea/workflows/release.yml)
|
||||
- [Semantic Release configuration](../.releaserc)
|
||||
- [Web and migration Docker targets](../Dockerfile)
|
||||
- [Velocity Gradle build](../plugins/velocity/build.gradle.kts)
|
||||
- [Release and deployment guide](../docs/releases.md)
|
||||
|
||||
# Validation
|
||||
|
||||
Local OKF, lint, typecheck, test, Next.js build, and versioned Velocity JAR checks pass. Initial Gitea CI and release runs succeeded. Release `v1.0.0` provides a publicly downloadable JAR whose Velocity metadata reports `1.0.0`. Registry manifests were resolved for the published semantic-version tags. Release `v1.1.0` also publishes resolvable versioned web, Discord bot, and migration manifests and a public Velocity JAR whose metadata reports `1.1.0`. Release `v1.1.1` published immutable semantic-version tags only; prior `latest` digests remained unchanged. Pull-request commitlint configuration is present; its conditional execution will be exercised by the first pull request.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Deploy and operate securely](us-015-platform-operations.md)
|
||||
- [Velocity game admission](us-009-velocity-admission.md)
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Control Minecraft admission with groups
|
||||
description: Administrators assign users to groups and enable Minecraft access through explicit group policy.
|
||||
tags: [admin, groups, authorization, velocity, security]
|
||||
timestamp: 2026-08-02T15:03:59Z
|
||||
story_id: US-017
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an administrator, I want to organize registered users into access groups, so that server admission can be enabled for selected communities while remaining off by default.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] A registered user can have at most one explicit group assignment.
|
||||
- [x] Users without an explicit assignment fall back to the protected `everyone` group.
|
||||
- [x] The `everyone` group remains created with Minecraft access disabled.
|
||||
- [x] Administrators can create groups with access disabled by default and move users between groups.
|
||||
- [x] Administrators can enable or disable Minecraft admission for each group.
|
||||
- [x] Admission follows only the user's effective group; default and explicit-group access are never combined.
|
||||
- [x] Administrators can delete non-default groups, returning affected users to `everyone`.
|
||||
- [x] The protected default group cannot be deleted.
|
||||
- [x] Group creation, membership, and access-policy changes are audited.
|
||||
- [x] Users and administrators can inspect the user's single effective group assignment.
|
||||
- [x] Every group has an independently configurable VPN/proxy/Tor exception policy.
|
||||
- [x] The protected `everyone` group and newly created groups disallow VPN, proxy, and Tor connections by default.
|
||||
- [x] Confirmed VPN, proxy, or Tor game connections are denied unless the user's single effective group allows anonymized networks.
|
||||
- [x] Clear and hosting classifications are not denied by this group policy, and unavailable intelligence does not independently deny a registered player.
|
||||
- [x] VPN policy changes are authorized server-side and audited.
|
||||
- [x] Group creation can explicitly initialize Minecraft and VPN/proxy/Tor policies while retaining deny-by-default controls.
|
||||
- [x] List and detail policy changes use the same confirmation workflow.
|
||||
- [x] Effective member counts include unassigned users who fall back to `everyone`.
|
||||
- [x] Group names and descriptions are validated and editable server-side.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||
- [`packages/database/drizzle/0002_simple_queen_noir.sql`](../packages/database/drizzle/0002_simple_queen_noir.sql)
|
||||
- [`packages/database/drizzle/0003_smiling_silver_samurai.sql`](../packages/database/drizzle/0003_smiling_silver_samurai.sql)
|
||||
- [`packages/database/drizzle/0004_zippy_silver_centurion.sql`](../packages/database/drizzle/0004_zippy_silver_centurion.sql)
|
||||
- [`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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user