Compare commits

...
11 Commits
Author SHA1 Message Date
dmg 9116107917 feat(dashboard): map latest user locations
CI / validate (push) Successful in 5m20s
Release / release (push) Successful in 10m40s
2026-08-01 19:41:03 -04:00
dmg b7c0083647 feat(portal): add SSR operations and exclusive groups
CI / validate (push) Successful in 5m20s
Release / release (push) Successful in 6m56s
2026-08-01 19:21:23 -04:00
dmg b88097c15a feat(groups): add fail-closed admission management
CI / validate (push) Successful in 5m13s
Release / release (push) Successful in 7m8s
2026-08-01 18:36:45 -04:00
dmg 19a5d04178 feat(portal): refine account identity controls
CI / validate (push) Successful in 4m51s
Release / release (push) Successful in 6m36s
2026-08-01 18:06:30 -04:00
dmg 86c87153b4 feat(logging): add structured server diagnostics
CI / validate (push) Successful in 4m55s
Release / release (push) Successful in 9m46s
2026-08-01 17:37:52 -04:00
dmg 5e693e2cdd fix(auth): use public URL for magic-link redirects
CI / validate (push) Successful in 4m53s
Release / release (push) Successful in 6m30s
2026-08-01 16:44:01 -04:00
dmg 9440c651b6 docs(design): verify immutable release policy
CI / validate (push) Successful in 4m44s
Release / release (push) Successful in 4m52s
2026-08-01 16:06:07 -04:00
dmg ccb44fa253 fix(release): publish immutable image tags only
CI / validate (push) Successful in 5m5s
Release / release (push) Successful in 7m5s
2026-08-01 15:57:17 -04:00
dmg cee0378f8f docs(design): verify bot release artifact
CI / validate (push) Has been cancelled
Release / release (push) Has been cancelled
2026-08-01 15:56:02 -04:00
dmg 478f3a3b87 feat(deploy): publish Discord bot image
CI / validate (push) Successful in 4m39s
Release / release (push) Successful in 6m23s
2026-08-01 15:47:27 -04:00
dmg d91572b831 docs(design): verify release automation
CI / validate (push) Successful in 4m43s
Release / release (push) Successful in 4m50s
2026-08-01 15:19:09 -04:00
90 changed files with 5067 additions and 243 deletions
+3
View File
@@ -24,3 +24,6 @@ IP_INTELLIGENCE_PROVIDER=proxycheck
PROXYCHECK_API_KEY= PROXYCHECK_API_KEY=
IP_INTELLIGENCE_CACHE_HOURS=48 IP_INTELLIGENCE_CACHE_HOURS=48
BLOCK_HOSTING_IPS=false BLOCK_HOSTING_IPS=false
# Structured Pino logging
LOG_LEVEL=info
+13 -4
View File
@@ -130,10 +130,21 @@ jobs:
--target runner \ --target runner \
--build-arg VERSION="$VERSION" \ --build-arg VERSION="$VERSION" \
-t "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}" \ -t "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}" \
-t git.garvis.dev/dmg/minecraft-account-manager:latest \
. .
docker push "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}" docker push "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}"
docker push git.garvis.dev/dmg/minecraft-account-manager:latest
- name: Build and push Discord bot image
if: steps.release.outputs.created == 'true'
env:
VERSION: ${{ steps.release.outputs.version }}
run: |
docker build \
--platform linux/amd64 \
--target bot \
--build-arg VERSION="$VERSION" \
-t "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}" \
.
docker push "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}"
- name: Build and push migration image - name: Build and push migration image
if: steps.release.outputs.created == 'true' if: steps.release.outputs.created == 'true'
@@ -145,10 +156,8 @@ jobs:
--target migrate \ --target migrate \
--build-arg VERSION="$VERSION" \ --build-arg VERSION="$VERSION" \
-t "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}" \ -t "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}" \
-t git.garvis.dev/dmg/minecraft-account-manager-migrate:latest \
. .
docker push "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}" docker push "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}"
docker push git.garvis.dev/dmg/minecraft-account-manager-migrate:latest
- name: Create Gitea release and upload Velocity JAR - name: Create Gitea release and upload Velocity JAR
if: steps.release.outputs.created == 'true' if: steps.release.outputs.created == 'true'
+17
View File
@@ -10,6 +10,7 @@ COPY apps/discord-bot/package.json ./apps/discord-bot/package.json
COPY packages/auth/package.json ./packages/auth/package.json COPY packages/auth/package.json ./packages/auth/package.json
COPY packages/contracts/package.json ./packages/contracts/package.json COPY packages/contracts/package.json ./packages/contracts/package.json
COPY packages/database/package.json ./packages/database/package.json COPY packages/database/package.json ./packages/database/package.json
COPY packages/logging/package.json ./packages/logging/package.json
COPY packages/minecraft/package.json ./packages/minecraft/package.json COPY packages/minecraft/package.json ./packages/minecraft/package.json
COPY packages/network/package.json ./packages/network/package.json COPY packages/network/package.json ./packages/network/package.json
RUN npm ci RUN npm ci
@@ -25,6 +26,7 @@ LABEL org.opencontainers.image.title="Minecraft Account Manager" \
org.opencontainers.image.source="https://git.garvis.dev/dmg/minecraft-account-manager" org.opencontainers.image.source="https://git.garvis.dev/dmg/minecraft-account-manager"
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
APP_VERSION=${VERSION} \
HOSTNAME=0.0.0.0 \ HOSTNAME=0.0.0.0 \
PORT=3000 PORT=3000
RUN addgroup --system app && adduser --system --ingroup app app RUN addgroup --system app && adduser --system --ingroup app app
@@ -34,6 +36,21 @@ USER app
EXPOSE 3000 EXPOSE 3000
CMD ["node", "apps/web/server.js"] CMD ["node", "apps/web/server.js"]
FROM dependencies AS bot
ARG VERSION=development
LABEL org.opencontainers.image.title="Minecraft Account Manager Discord Bot" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.source="https://git.garvis.dev/dmg/minecraft-account-manager"
WORKDIR /app
ENV NODE_ENV=production \
APP_VERSION=${VERSION}
RUN addgroup --system app && adduser --system --ingroup app app
COPY --chown=app:app package.json package-lock.json tsconfig.base.json ./
COPY --chown=app:app apps/discord-bot ./apps/discord-bot
COPY --chown=app:app packages ./packages
USER app
CMD ["npm", "run", "start", "--workspace", "@minecraft-account-manager/discord-bot"]
FROM dependencies AS migrate FROM dependencies AS migrate
ARG VERSION=development ARG VERSION=development
LABEL org.opencontainers.image.title="Minecraft Account Manager Migrations" \ LABEL org.opencontainers.image.title="Minecraft Account Manager Migrations" \
+3 -2
View File
@@ -76,11 +76,12 @@ The token is displayed once and stored only as a SHA-256 hash.
- PostgreSQL and Drizzle ORM - PostgreSQL and Drizzle ORM
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role - Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
- Admin user search, account management, primary-account changes, and Discord nickname synchronization - Admin user search, account management, event exploration, operational metrics, an open-data user-location world map, and automatic Discord nickname synchronization
- Exclusive group admission: unassigned users fall back to protected `everyone`, and only the effective group's access setting applies
- Deployment-managed Discord guild ID and invite URL - Deployment-managed Discord guild ID and invite URL
- discord.js bot with `/register` and `/account` - discord.js bot with `/register` and `/account`
- Java Edition online-mode accounts only - Java Edition online-mode accounts only
- Velocity admission checks are fail closed - Velocity admission checks are fail closed
- ProxyCheck.io geolocation and VPN/proxy/Tor detection with a 48-hour PostgreSQL cache - ProxyCheck.io geolocation and VPN/proxy/Tor detection with a 48-hour PostgreSQL cache
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. See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities, [`docs/api-errors.md`](docs/api-errors.md) for the RFC 9457 API error contract, and [`docs/security-review.md`](docs/security-review.md) for implemented controls and production requirements, and [`docs/accessibility.md`](docs/accessibility.md) for the WCAG-oriented interface review.
+1
View File
@@ -3,3 +3,4 @@ APP_URL=http://localhost:3000
DISCORD_BOT_TOKEN= DISCORD_BOT_TOKEN=
DISCORD_APPLICATION_ID= DISCORD_APPLICATION_ID=
DISCORD_GUILD_ID= DISCORD_GUILD_ID=
LOG_LEVEL=info
+1
View File
@@ -13,6 +13,7 @@
"@minecraft-account-manager/auth": "*", "@minecraft-account-manager/auth": "*",
"@minecraft-account-manager/contracts": "*", "@minecraft-account-manager/contracts": "*",
"@minecraft-account-manager/database": "*", "@minecraft-account-manager/database": "*",
"@minecraft-account-manager/logging": "*",
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1" "drizzle-orm": "^0.45.1"
+14 -2
View File
@@ -2,10 +2,22 @@ import "dotenv/config";
import { REST, Routes } from "discord.js"; import { REST, Routes } from "discord.js";
import { commands } from "./commands"; import { commands } from "./commands";
import { requiredEnvironment } from "./config"; import { requiredEnvironment } from "./config";
import { logger } from "./logger";
const token = requiredEnvironment("DISCORD_BOT_TOKEN"); const token = requiredEnvironment("DISCORD_BOT_TOKEN");
const applicationId = requiredEnvironment("DISCORD_APPLICATION_ID"); const applicationId = requiredEnvironment("DISCORD_APPLICATION_ID");
const rest = new REST({ version: "10" }).setToken(token); const rest = new REST({ version: "10" }).setToken(token);
await rest.put(Routes.applicationCommands(applicationId), { body: commands }); try {
console.log(`Deployed ${commands.length} global Discord commands.`); await rest.put(Routes.applicationCommands(applicationId), { body: commands });
logger.info(
{ event: "discord.commands_deployed", commandCount: commands.length },
"Deployed global Discord commands",
);
} catch (error) {
logger.fatal(
{ err: error, event: "discord.commands_deploy_failed" },
"Failed to deploy global Discord commands",
);
process.exitCode = 1;
}
+15 -3
View File
@@ -14,6 +14,7 @@ import {
GatewayIntentBits, GatewayIntentBits,
} from "discord.js"; } from "discord.js";
import { commandNames, requiredEnvironment } from "./config"; import { commandNames, requiredEnvironment } from "./config";
import { logger } from "./logger";
const token = requiredEnvironment("DISCORD_BOT_TOKEN"); const token = requiredEnvironment("DISCORD_BOT_TOKEN");
const appUrl = requiredEnvironment("APP_URL"); const appUrl = requiredEnvironment("APP_URL");
@@ -24,7 +25,10 @@ const authRepository = createAuthRepository(db);
const client = new Client({ intents: [GatewayIntentBits.Guilds] }); const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, (readyClient) => { client.once(Events.ClientReady, (readyClient) => {
console.log(`Discord bot ready as ${readyClient.user.tag}`); logger.info(
{ event: "discord.ready", botUserId: readyClient.user.id, botUsername: readyClient.user.username },
"Discord bot is ready",
);
}); });
client.on(Events.InteractionCreate, async (interaction) => { client.on(Events.InteractionCreate, async (interaction) => {
@@ -71,9 +75,17 @@ client.on(Events.InteractionCreate, async (interaction) => {
await interaction.editReply("Please wait 30 seconds before requesting another private account link."); await interaction.editReply("Please wait 30 seconds before requesting another private account link.");
return; return;
} }
console.error("Failed to create Discord account link", error); logger.error(
{ err: error, event: "discord.magic_link_failed", command: interaction.commandName },
"Failed to create Discord account link",
);
await interaction.editReply("I could not create an account link. Please try again shortly."); await interaction.editReply("I could not create an account link. Please try again shortly.");
} }
}); });
await client.login(token); try {
await client.login(token);
} catch (error) {
logger.fatal({ err: error, event: "discord.login_failed" }, "Discord bot login failed");
process.exitCode = 1;
}
+3
View File
@@ -0,0 +1,3 @@
import { createLogger } from "@minecraft-account-manager/logging";
export const logger = createLogger("minecraft-account-manager-discord-bot");
+1
View File
@@ -34,6 +34,7 @@ const nextConfig: NextConfig = {
transpilePackages: [ transpilePackages: [
"@minecraft-account-manager/contracts", "@minecraft-account-manager/contracts",
"@minecraft-account-manager/database", "@minecraft-account-manager/database",
"@minecraft-account-manager/logging",
], ],
}; };
+7 -1
View File
@@ -14,19 +14,25 @@
"@minecraft-account-manager/auth": "*", "@minecraft-account-manager/auth": "*",
"@minecraft-account-manager/contracts": "*", "@minecraft-account-manager/contracts": "*",
"@minecraft-account-manager/database": "*", "@minecraft-account-manager/database": "*",
"@minecraft-account-manager/logging": "*",
"@minecraft-account-manager/minecraft": "*", "@minecraft-account-manager/minecraft": "*",
"@minecraft-account-manager/network": "*", "@minecraft-account-manager/network": "*",
"d3-geo": "^3.1.1",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"next": "^16.2.1", "next": "^16.2.1",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3" "react-dom": "^19.2.3",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"@types/d3-geo": "^3.1.1",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-next": "^16.2.1", "eslint-config-next": "^16.2.1",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
+125 -54
View File
@@ -1,25 +1,75 @@
"use server"; "use server";
import { formatDiscordNickname, lookupJavaProfile, updateGuildNickname } from "@minecraft-account-manager/minecraft";
import { minecraftAccounts, users } from "@minecraft-account-manager/database"; import { minecraftAccounts, users } from "@minecraft-account-manager/database";
import { formatManagedDiscordNickname, lookupJavaProfile, updateGuildNickname } from "@minecraft-account-manager/minecraft";
import { and, eq, isNull, ne } from "drizzle-orm"; import { and, eq, isNull, ne } from "drizzle-orm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { recordUserEvent } from "@/lib/audit"; import { recordUserEvent } from "@/lib/audit";
import { db } from "@/lib/database";
import { requireCurrentUser } from "@/lib/auth/user-session"; import { requireCurrentUser } from "@/lib/auth/user-session";
import { db } from "@/lib/database";
import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence"; import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence";
import { logger } from "@/lib/logger";
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/; const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
type CurrentUser = Awaited<ReturnType<typeof requireCurrentUser>>;
type NicknameSyncResult = "updated" | "not-configured" | "failed";
async function synchronizeNickname(user: CurrentUser, nickname: string, operation: string): Promise<NicknameSyncResult> {
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
if (!guildId || !botToken) return "not-configured";
try {
await updateGuildNickname({ guildId, discordUserId: user.discordUserId, nickname, botToken });
} catch (error) {
logger.error(
{ err: error, event: "account.discord_nickname_update_failed", operation },
"Failed to update the Discord guild nickname",
);
return "failed";
}
try {
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
nickname,
operation,
});
} catch (error) {
logger.error(
{ err: error, event: "account.discord_nickname_audit_failed", operation },
"Discord nickname updated but its audit event could not be recorded",
);
}
return "updated";
}
function nicknameResultUrl(nickname: string, synchronization: NicknameSyncResult, additionalQuery?: string) {
const result = synchronization === "updated"
? `nicknameUpdated=${encodeURIComponent(nickname)}`
: `error=${synchronization === "not-configured" ? "nickname-not-configured" : "nickname-update-failed"}&nicknameExpected=${encodeURIComponent(nickname)}`;
return `/account?${additionalQuery ? `${additionalQuery}&` : ""}${result}`;
}
export async function updateFirstName(formData: FormData) { export async function updateFirstName(formData: FormData) {
const user = await requireCurrentUser(); const user = await requireCurrentUser();
const firstName = String(formData.get("firstName") ?? "").trim(); const firstName = String(formData.get("firstName") ?? "").trim();
if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) { if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) {
redirect("/account?error=invalid-name"); redirect("/account?error=invalid-name");
} }
const [primary] = await db
.select({ username: minecraftAccounts.username })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)))
.limit(1);
const nickname = formatManagedDiscordNickname(firstName, primary?.username ?? null);
await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id)); await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id));
const synchronization = await synchronizeNickname(user, nickname, "update-first-name");
await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName }); await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName });
redirect("/account?confirmNickname=1"); redirect(nicknameResultUrl(nickname, synchronization));
} }
export async function addMinecraftAccount(formData: FormData) { export async function addMinecraftAccount(formData: FormData) {
@@ -46,40 +96,60 @@ export async function addMinecraftAccount(formData: FormData) {
const profile = await lookupJavaProfile(requestedUsername); const profile = await lookupJavaProfile(requestedUsername);
if (!profile && !confirmed) redirect(`/account?unverified=${encodeURIComponent(requestedUsername)}`); if (!profile && !confirmed) redirect(`/account?unverified=${encodeURIComponent(requestedUsername)}`);
const [existing] = await db.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where( const [existing] = await db
and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), .select({ id: minecraftAccounts.id })
).limit(1); .from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
let failed = false; const username = profile?.username ?? requestedUsername;
try { try {
await db.insert(minecraftAccounts).values({ await db.insert(minecraftAccounts).values({
userId: user.id, userId: user.id,
minecraftUuid: profile?.uuid ?? null, minecraftUuid: profile?.uuid ?? null,
username: profile?.username ?? requestedUsername, username,
validationStatus: profile ? "verified" : "user_confirmed", validationStatus: profile ? "verified" : "user_confirmed",
lastVerifiedAt: profile ? new Date() : null, lastVerifiedAt: profile ? new Date() : null,
isPrimary: !existing, isPrimary: !existing,
}); });
} catch { } catch {
failed = true; redirect("/account?error=already-registered");
} }
if (failed) redirect("/account?error=already-registered"); const nickname = !existing && user.firstName
? formatManagedDiscordNickname(user.firstName, username)
: null;
const synchronization = nickname
? await synchronizeNickname(user, nickname, "add-first-account")
: null;
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.added", { await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.added", {
username: profile?.username ?? requestedUsername, username,
minecraftUuid: profile?.uuid ?? null, minecraftUuid: profile?.uuid ?? null,
validationStatus: profile ? "verified" : "user_confirmed", validationStatus: profile ? "verified" : "user_confirmed",
}); });
redirect(existing ? "/account?added=1" : "/account?confirmNickname=1");
redirect(nickname && synchronization
? nicknameResultUrl(nickname, synchronization, "added=1")
: "/account?added=1");
} }
export async function setPrimaryAccount(formData: FormData) { export async function setPrimaryAccount(formData: FormData) {
const user = await requireCurrentUser(); const user = await requireCurrentUser();
const accountId = String(formData.get("accountId") ?? ""); const accountId = String(formData.get("accountId") ?? "");
const [requestedAccount] = await db
.select({ id: minecraftAccounts.id, username: minecraftAccounts.username })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
if (!requestedAccount) redirect("/account?error=unknown-account");
if (!user.firstName) redirect("/account?error=nickname-not-configured");
const changed = await db.transaction(async (tx) => { const changed = await db.transaction(async (tx) => {
const [account] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where( const [account] = await tx
and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), .select({ id: minecraftAccounts.id })
).limit(1); .from(minecraftAccounts)
.where(and(eq(minecraftAccounts.id, requestedAccount.id), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
if (!account) return false; if (!account) return false;
await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where( await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where(
@@ -88,61 +158,62 @@ export async function setPrimaryAccount(formData: FormData) {
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id)); await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
return true; return true;
}); });
if (!changed) redirect("/account?error=unknown-account"); if (!changed) redirect("/account?error=unknown-account");
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", { accountId });
redirect("/account?confirmNickname=1"); const nickname = formatManagedDiscordNickname(user.firstName, requestedAccount.username);
const synchronization = await synchronizeNickname(user, nickname, "set-primary-account");
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", {
accountId: requestedAccount.id,
nickname,
});
redirect(nicknameResultUrl(nickname, synchronization));
} }
export async function removeMinecraftAccount(formData: FormData) { export async function removeMinecraftAccount(formData: FormData) {
const user = await requireCurrentUser(); const user = await requireCurrentUser();
const accountId = String(formData.get("accountId") ?? ""); const accountId = String(formData.get("accountId") ?? "");
const removed = await db.transaction(async (tx) => { const result = await db.transaction(async (tx) => {
const [account] = await tx.select({ id: minecraftAccounts.id, isPrimary: minecraftAccounts.isPrimary }).from(minecraftAccounts).where( const [account] = await tx
and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), .select({ id: minecraftAccounts.id, isPrimary: minecraftAccounts.isPrimary })
).limit(1); .from(minecraftAccounts)
if (!account) return false; .where(and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
if (!account) return null;
await tx.update(minecraftAccounts).set({ deletedAt: new Date(), isPrimary: false, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id)); await tx.update(minecraftAccounts).set({ deletedAt: new Date(), isPrimary: false, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
let replacementUsername: string | null = null;
if (account.isPrimary) { if (account.isPrimary) {
const [replacement] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where( const [replacement] = await tx
and(eq(minecraftAccounts.userId, user.id), ne(minecraftAccounts.id, account.id), isNull(minecraftAccounts.deletedAt)), .select({ id: minecraftAccounts.id, username: minecraftAccounts.username })
).limit(1); .from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), ne(minecraftAccounts.id, account.id), isNull(minecraftAccounts.deletedAt)))
.limit(1);
if (replacement) { if (replacement) {
replacementUsername = replacement.username;
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, replacement.id)); await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, replacement.id));
} }
} else {
const [primary] = await tx
.select({ username: minecraftAccounts.username })
.from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)))
.limit(1);
replacementUsername = primary?.username ?? null;
} }
return true; return { replacementUsername };
}); });
if (!removed) redirect("/account?error=unknown-account"); if (!result) redirect("/account?error=unknown-account");
const nickname = user.firstName
? formatManagedDiscordNickname(user.firstName, result.replacementUsername)
: null;
const synchronization = nickname
? await synchronizeNickname(user, nickname, "remove-account")
: null;
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.removed", { accountId }); await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.removed", { accountId });
redirect("/account?removed=1&confirmNickname=1");
}
export async function confirmDashboardNickname() { redirect(nickname && synchronization
const user = await requireCurrentUser(); ? nicknameResultUrl(nickname, synchronization, "removed=1")
const [account] = await db.select({ username: minecraftAccounts.username }).from(minecraftAccounts).where( : "/account?removed=1");
and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)),
).limit(1);
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
if (!user.firstName || !account || !guildId || !botToken) redirect("/account?error=nickname-not-configured");
try {
await updateGuildNickname({
guildId,
discordUserId: user.discordUserId,
nickname: formatDiscordNickname(user.firstName, account.username),
botToken,
});
} catch {
redirect("/account?error=nickname-update-failed&confirmNickname=1");
}
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
nickname: formatDiscordNickname(user.firstName, account.username),
});
redirect("/account?nicknameUpdated=1");
} }
+77 -36
View File
@@ -1,18 +1,25 @@
import { formatDiscordNickname } from "@minecraft-account-manager/minecraft"; import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
import { ipIntelligence, ipObservations, minecraftAccounts } from "@minecraft-account-manager/database"; import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
import { and, desc, eq, isNull } from "drizzle-orm"; import { groups, ipIntelligence, ipObservations, minecraftAccounts, userGroupMemberships } from "@minecraft-account-manager/database";
import { and, desc, eq, isNull, or } from "drizzle-orm";
import { logout } from "@/app/auth/actions"; import { logout } from "@/app/auth/actions";
import { db } from "@/lib/database"; import { db } from "@/lib/database";
import { requireCurrentUser } from "@/lib/auth/user-session"; import { requireCurrentUser } from "@/lib/auth/user-session";
import { groupAccessAddresses } from "@/lib/access-address-groups";
import { discordIdentity } from "@/lib/discord-identity";
import { intelligenceSummary } from "@/lib/event-ip-summary"; import { intelligenceSummary } from "@/lib/event-ip-summary";
import { NicknameNotice } from "@/components/nickname-notice";
import { import {
addMinecraftAccount, addMinecraftAccount,
confirmDashboardNickname,
removeMinecraftAccount, removeMinecraftAccount,
setPrimaryAccount, setPrimaryAccount,
updateFirstName, updateFirstName,
} from "./actions"; } from "./actions";
function queryValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
const errorMessages: Record<string, string> = { const errorMessages: Record<string, string> = {
"invalid-name": "Enter a valid name between 1 and 50 characters.", "invalid-name": "Enter a valid name between 1 and 50 characters.",
"invalid-username": "Java usernames use 316 letters, numbers, or underscores.", "invalid-username": "Java usernames use 316 letters, numbers, or underscores.",
@@ -24,14 +31,18 @@ const errorMessages: Record<string, string> = {
"ip-check-unavailable": "We could not verify your network, so account addition is temporarily blocked.", "ip-check-unavailable": "We could not verify your network, so account addition is temporarily blocked.",
}; };
export const dynamic = "force-dynamic";
export default async function AccountPage({ export default async function AccountPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<Record<string, string | undefined>>; searchParams: Promise<Record<string, string | string[] | undefined>>;
}) { }) {
const user = await requireCurrentUser("/account"); const user = await requireCurrentUser("/account");
const query = await searchParams; const query = await searchParams;
const [accounts, observations] = await Promise.all([ const error = queryValue(query.error);
const unverified = queryValue(query.unverified);
const [accounts, observations, discord, availableGroups] = await Promise.all([
db db
.select() .select()
.from(minecraftAccounts) .from(minecraftAccounts)
@@ -50,12 +61,25 @@ export default async function AccountPage({
.leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress)) .leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
.where(eq(ipObservations.userId, user.id)) .where(eq(ipObservations.userId, user.id))
.orderBy(desc(ipObservations.observedAt)) .orderBy(desc(ipObservations.observedAt))
.limit(20), .limit(100),
discordIdentity(user),
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, isDefault: groups.isDefault })
.from(groups)
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
.orderBy(desc(groups.isDefault), groups.name),
]); ]);
const addressGroups = groupAccessAddresses(observations);
const primary = accounts.find((account) => account.isPrimary); const primary = accounts.find((account) => account.isPrimary);
const desiredNickname = user.firstName && primary const desiredNickname = user.firstName
? formatDiscordNickname(user.firstName, primary.username) ? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
: null; : null;
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
const nicknameUpdated = queryValue(query.nicknameUpdated);
const nicknameExpected = queryValue(query.nicknameExpected);
const nicknameError = error === "nickname-update-failed" || error === "nickname-not-configured" ? error : null;
return ( return (
<main className="min-h-screen bg-canvas px-6 py-10 text-ink sm:py-16"> <main className="min-h-screen bg-canvas px-6 py-10 text-ink sm:py-16">
@@ -68,25 +92,17 @@ export default async function AccountPage({
<form action={logout}><button className="font-mono text-xs font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Sign out</button></form> <form action={logout}><button className="font-mono text-xs font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Sign out</button></form>
</header> </header>
{query.error && ( {error && !nicknameError && (
<p className="mt-8 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent"> <p className="mt-8 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">
{errorMessages[query.error] ?? "The requested change could not be completed."} {errorMessages[error] ?? "The requested change could not be completed."}
</p> </p>
)} )}
{query.nicknameUpdated && <p className="mt-8 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider">Discord nickname updated</p>} <NicknameNotice
error={nicknameError
{query.confirmNickname && desiredNickname && ( ? `${errorMessages[nicknameError]}${nicknameExpected ? ` Your intended nickname is ${nicknameExpected}.` : ""}`
<section className="mt-8 border border-accent bg-panel p-6 shadow-[6px_6px_0_var(--color-accent)] sm:flex sm:items-center sm:justify-between sm:gap-8"> : undefined}
<div> nickname={nicknameUpdated}
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Confirm Discord change</p> />
<p className="mt-2 text-sm text-muted">Your community nickname will become</p>
<p className="mt-1 font-display text-2xl font-black">{desiredNickname}</p>
</div>
<form action={confirmDashboardNickname} className="mt-5 sm:mt-0">
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Confirm update</button>
</form>
</section>
)}
<div className="mt-12 grid gap-10 lg:grid-cols-[1.35fr_0.65fr]"> <div className="mt-12 grid gap-10 lg:grid-cols-[1.35fr_0.65fr]">
<div className="space-y-10"> <div className="space-y-10">
@@ -109,24 +125,24 @@ export default async function AccountPage({
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
{!account.isPrimary && <form action={setPrimaryAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Make primary</button></form>} {!account.isPrimary && <form action={setPrimaryAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Make primary</button></form>}
<form action={removeMinecraftAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider text-accent underline underline-offset-4" type="submit">Remove</button></form> <details className="relative"><summary className="cursor-pointer list-none font-mono text-[10px] font-bold uppercase tracking-wider text-accent underline underline-offset-4">Remove</summary><form action={removeMinecraftAccount} className="absolute right-0 z-10 mt-2 w-60 border border-accent bg-panel p-4 shadow-[5px_5px_0_var(--color-accent)]"><input name="accountId" type="hidden" value={account.id} /><p className="text-xs leading-5">Remove {account.username}? Your Discord nickname will update automatically.</p><button className="mt-3 bg-accent px-3 py-2 font-mono text-[9px] font-bold uppercase text-canvas" type="submit">Confirm removal</button></form></details>
</div> </div>
</article> </article>
))} ))}
{accounts.length === 0 && <p className="py-8 text-muted">No active Minecraft accounts. Add one before joining the server.</p>} {accounts.length === 0 && <p className="py-8 text-muted">No active Minecraft accounts. Add one before joining the server.</p>}
</div> </div>
{query.unverified ? ( {unverified ? (
<form action={addMinecraftAccount} className="border-l-2 border-accent bg-panel p-6"> <form action={addMinecraftAccount} className="border-l-2 border-accent bg-panel p-6">
<h3 className="font-display text-xl font-black uppercase">Mojang couldnt verify {query.unverified}</h3> <h3 className="font-display text-xl font-black uppercase">Mojang couldnt verify {unverified}</h3>
<p className="mt-2 text-sm leading-6 text-muted">Continue only if you are certain the spelling is correct.</p> <p className="mt-2 text-sm leading-6 text-muted">Continue only if you are certain the spelling is correct.</p>
<input name="username" type="hidden" value={query.unverified} /><input name="confirmUnverified" type="hidden" value="yes" /> <input name="username" type="hidden" value={unverified} /><input name="confirmUnverified" type="hidden" value="yes" />
<button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Add anyway</button> <button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Add anyway</button>
<a className="ml-5 font-mono text-[10px] font-bold uppercase underline" href="/account">Cancel</a> <a className="ml-5 font-mono text-[10px] font-bold uppercase underline" href="/account">Cancel</a>
</form> </form>
) : ( ) : (
<form action={addMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row"> <form action={addMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row">
<input className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required /> <input aria-label="Minecraft username" className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required />
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Add account</button> <button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Add account</button>
</form> </form>
)} )}
@@ -135,11 +151,24 @@ export default async function AccountPage({
<section> <section>
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Recent security activity</p> <p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Recent security activity</p>
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Access addresses</h2> <h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Access addresses</h2>
{observations.length ? ( {addressGroups.length ? (
<div className="divide-y divide-line font-mono text-xs"> <div className="divide-y divide-line font-mono text-xs">
{observations.map((observation) => { <p className="py-3 text-[10px] leading-5 text-muted">Similar IPv4 /24 and IPv6 /64 networks are grouped. Counts cover your 100 most recent observations.</p>
const summary = intelligenceSummary(observation.intelligence); {addressGroups.map((group) => {
return <div className="grid grid-cols-[1fr_auto] gap-4 py-4" key={observation.id}><div><p>{observation.ipAddress}</p><p className="mt-1 text-[10px] text-muted">{summary.location ?? "Location unavailable"} · {summary.classification ?? observation.classification}</p></div><span className="text-muted">{observation.source} · {observation.observedAt.toISOString()}</span></div>; const summary = intelligenceSummary(group.intelligence);
return (
<div className="grid gap-3 py-4 sm:grid-cols-[1fr_auto] sm:items-start" key={group.network}>
<div>
<div className="flex flex-wrap items-center gap-3">
<p className="font-bold">{group.network}</p>
<span className="border border-line px-2 py-1 text-[9px] uppercase tracking-wider text-muted">{group.count} {group.count === 1 ? "observation" : "observations"}</span>
</div>
<p className="mt-2 text-[10px] text-muted">Latest address {group.latestAddress}</p>
<p className="mt-1 text-[10px] text-muted">{summary.location ?? "Location unavailable"} · {summary.classification ?? group.classification}</p>
</div>
<span className="text-[10px] text-muted sm:text-right">{group.sources.join(" + ")}<br />Last seen {group.latestObservedAt.toISOString()}</span>
</div>
);
})} })}
</div> </div>
) : <p className="py-6 text-sm text-muted">No web or game access addresses have been recorded yet.</p>} ) : <p className="py-6 text-sm text-muted">No web or game access addresses have been recorded yet.</p>}
@@ -149,11 +178,23 @@ export default async function AccountPage({
<aside> <aside>
<form action={updateFirstName} className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]"> <form action={updateFirstName} className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Profile</p> <p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Profile</p>
<dl className="mt-5 space-y-3 border-b border-line pb-5 font-mono text-[10px]">
<div><dt className="uppercase tracking-wider text-muted">Discord name</dt><dd className="mt-1 break-all font-bold text-ink">{discord.globalName ?? discord.username}</dd></div>
<div><dt className="uppercase tracking-wider text-muted">Discord username</dt><dd className="mt-1 break-all text-ink">@{discord.username}</dd></div>
<div><dt className="uppercase tracking-wider text-muted">Guild nickname</dt><dd className="mt-1 break-all text-ink">{discord.nickname ?? "No guild nickname"}</dd></div>
<div><dt className="uppercase tracking-wider text-muted">Discord ID</dt><dd className="mt-1 break-all text-ink">{discord.id}</dd></div>
</dl>
<label className="mt-5 block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="firstName">What we call you</label> <label className="mt-5 block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="firstName">What we call you</label>
<input className="mt-3 w-full border border-line bg-canvas px-4 py-3 outline-none focus:border-accent" defaultValue={user.firstName ?? ""} id="firstName" maxLength={50} name="firstName" required /> <input className="mt-3 w-full border border-line bg-canvas px-4 py-3 outline-none focus:border-accent" defaultValue={user.firstName ?? ""} id="firstName" maxLength={50} name="firstName" required />
{desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Discord preview: <strong className="text-ink">{desiredNickname}</strong></p>} {desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Managed Discord nickname: <strong className="text-ink">{desiredNickname}</strong></p>}
<p className="mt-3 text-xs leading-5 text-muted">Saving automatically synchronizes this Discord guild nickname.</p>
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider" type="submit">Save name</button> <button className="mt-6 border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider" type="submit">Save name</button>
</form> </form>
<section className="mt-8 border border-line bg-panel p-6">
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Access groups</p>
{effectiveGroup ? <div className="mt-4 flex items-center justify-between gap-3"><span className="font-mono text-xs font-bold">{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</span><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${effectiveGroup.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{effectiveGroup.accessEnabled ? "Access on" : "Access off"}</span></div> : <p className="mt-4 text-sm text-accent">No default access group is configured.</p>}
<p className="mt-4 text-xs leading-5 text-muted">Your effective group alone determines Minecraft access.</p>
</section>
</aside> </aside>
</div> </div>
</section> </section>
+2 -2
View File
@@ -11,7 +11,7 @@ export async function saveDiscordSettings(formData: FormData) {
const registrationMessage = String(formData.get("registrationMessage") ?? "").trim(); const registrationMessage = String(formData.get("registrationMessage") ?? "").trim();
if (registrationMessage.length < 10 || registrationMessage.length > 500) { if (registrationMessage.length < 10 || registrationMessage.length > 500) {
redirect("/admin?error=invalid-message"); redirect("/admin/settings?error=invalid-message");
} }
await db await db
@@ -28,5 +28,5 @@ export async function saveDiscordSettings(formData: FormData) {
}, },
}); });
redirect("/admin?saved=1"); redirect("/admin/settings?saved=1");
} }
@@ -0,0 +1,56 @@
import { events } from "@minecraft-account-manager/database";
import { eq } from "drizzle-orm";
import Link from "next/link";
import { notFound } from "next/navigation";
import { db } from "@/lib/database";
import { eventIpSummary } from "@/lib/event-ip-summary";
export const dynamic = "force-dynamic";
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;
export default async function EventDetailPage({ params }: { params: Promise<{ eventId: string }> }) {
const { eventId } = await params;
if (!UUID_PATTERN.test(eventId)) notFound();
const [event] = await db.select().from(events).where(eq(events.id, eventId)).limit(1);
if (!event) notFound();
const network = eventIpSummary(event.data);
return (
<main className="mx-auto max-w-5xl px-6 py-12">
<Link className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline underline-offset-4" href="/admin/events"> Event explorer</Link>
<header className="mt-7 border-b border-line pb-8">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">CloudEvent detail</p>
<h1 className="mt-4 break-words font-display text-3xl font-black uppercase sm:text-5xl">{event.type}</h1>
<p className="mt-4 break-all font-mono text-xs text-muted">{event.id}</p>
</header>
<section className="mt-8 border border-line bg-panel p-6 shadow-[7px_7px_0_var(--color-shadow)]">
<h2 className="font-display text-2xl font-black uppercase">Envelope</h2>
<dl className="mt-5 grid gap-x-8 gap-y-5 sm:grid-cols-2">
<Detail label="Time"><time dateTime={event.time.toISOString()}>{event.time.toISOString()}</time></Detail>
<Detail label="Spec version">{event.specVersion}</Detail>
<Detail label="Source">{event.source}</Detail>
<Detail label="Subject">{event.subject ?? "Not provided"}</Detail>
<Detail label="Content type">{event.dataContentType}</Detail>
<Detail label="Data schema">{event.dataSchema ?? "Not provided"}</Detail>
<Detail label="Actor user">{event.actorUserId ? <Link className="underline underline-offset-4" href={`/admin/users/${event.actorUserId}`}>{event.actorUserId}</Link> : "Not provided"}</Detail>
<Detail label="Correlation ID">{event.correlationId ?? "Not provided"}</Detail>
<Detail label="IP address">{event.ipAddress ?? "Not provided"}</Detail>
<Detail label="Network">{network.classification || network.location ? `${network.classification ?? "unknown"} · ${network.location ?? "location unavailable"}` : "Not provided"}</Detail>
<Detail label="Published">{event.publishedAt ? event.publishedAt.toISOString() : "Pending publication"}</Detail>
<Detail label="Recorded">{event.createdAt.toISOString()}</Detail>
</dl>
</section>
<section className="mt-10">
<h2 className="font-display text-2xl font-black uppercase">Event data</h2>
<pre className="mt-4 overflow-x-auto border border-line bg-ink p-5 font-mono text-xs leading-6 text-canvas" tabIndex={0}>{JSON.stringify(event.data, null, 2)}</pre>
</section>
</main>
);
}
function Detail({ label, children }: { label: string; children: React.ReactNode }) {
return <div><dt className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">{label}</dt><dd className="mt-1 break-all text-sm">{children}</dd></div>;
}
@@ -1,34 +1,92 @@
import { events } from "@minecraft-account-manager/database"; import { events } from "@minecraft-account-manager/database";
import { desc } from "drizzle-orm"; import { desc, inArray } from "drizzle-orm";
import Link from "next/link";
import { db } from "@/lib/database"; import { db } from "@/lib/database";
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
import { eventIpSummary } from "@/lib/event-ip-summary"; import { eventIpSummary } from "@/lib/event-ip-summary";
export default async function EventsPage() { export const dynamic = "force-dynamic";
const recentEvents = await db.select().from(events).orderBy(desc(events.time)).limit(100);
function values(value: string | string[] | undefined) {
return Array.isArray(value) ? value : value ? [value] : [];
}
export default async function EventsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const query = await searchParams;
const typeRows = await db.select({ type: events.type }).from(events).groupBy(events.type).orderBy(events.type);
const availableTypes = typeRows.map((row) => row.type);
const category = normalizeEventCategory(values(query.category)[0]);
const selectedTypes = normalizeSelectedEventTypes(query.type, availableTypes);
const categoryTypes = category === "all"
? availableTypes
: availableTypes.filter((type) => eventCategory(type) === category);
const filteredTypes = selectedTypes.length
? selectedTypes.filter((type) => categoryTypes.includes(type))
: categoryTypes;
const recentEvents = filteredTypes.length
? await db.select().from(events).where(inArray(events.type, filteredTypes)).orderBy(desc(events.time)).limit(100)
: [];
return ( return (
<main className="mx-auto max-w-6xl px-6 py-14"> <main className="mx-auto max-w-6xl px-6 py-14">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">CloudEvents ledger</p> <p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">CloudEvents ledger</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase">Recent events</h1> <h1 className="mt-4 font-display text-5xl font-black uppercase">Event explorer</h1>
<div className="mt-10 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]"> <p className="mt-4 max-w-2xl text-sm leading-6 text-muted">Filter the immutable audit ledger, then open an event to inspect its complete CloudEvents envelope and data.</p>
<form className="mt-8 border border-line bg-panel p-6" method="get">
<div className="grid gap-6 md:grid-cols-[0.45fr_1.55fr]">
<label className="font-mono text-xs font-bold uppercase tracking-wider" htmlFor="event-category">
View
<select className="mt-3 block w-full border border-line bg-canvas px-3 py-3 font-sans text-sm font-normal normal-case" defaultValue={category} id="event-category" name="category">
{eventCategoryValues.map((value) => <option key={value} value={value}>{value === "all" ? "All activity" : value}</option>)}
</select>
</label>
<fieldset>
<legend className="font-mono text-xs font-bold uppercase tracking-wider">Event types</legend>
<details className="mt-3 border border-line bg-canvas p-4" open={selectedTypes.length > 0}>
<summary className="cursor-pointer font-mono text-[10px] font-bold uppercase underline underline-offset-4">{selectedTypes.length ? `${selectedTypes.length} selected` : "All types in this view"}</summary>
<div className="mt-4 grid max-h-64 gap-3 overflow-y-auto sm:grid-cols-2">
{availableTypes.map((type) => (
<label className="flex items-start gap-2 font-mono text-[10px] leading-4" key={type}>
<input className="mt-0.5 size-4 accent-[var(--accent)]" defaultChecked={selectedTypes.includes(type)} name="type" type="checkbox" value={type} />
<span className="break-all">{type}</span>
</label>
))}
</div>
</details>
</fieldset>
</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">Apply filters</button>
<Link className="self-center font-mono text-[10px] font-bold uppercase underline underline-offset-4" href="/admin/events">Clear filters</Link>
</div>
</form>
<p className="mt-8 font-mono text-[10px] uppercase tracking-widest text-muted" role="status">Showing {recentEvents.length} most recent matching events</p>
<div className="mt-3 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-[760px] border-collapse text-left">
<caption className="sr-only">Filtered account manager events</caption>
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted"> <thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
<tr><th className="p-4">Time</th><th className="p-4">Type</th><th className="p-4">Subject</th><th className="p-4">IP</th><th className="p-4">Network</th></tr> <tr><th className="p-4" scope="col">Time</th><th className="p-4" scope="col">Type</th><th className="p-4" scope="col">Subject</th><th className="p-4" scope="col">IP</th><th className="p-4" scope="col">Network</th></tr>
</thead> </thead>
<tbody className="divide-y divide-line text-xs"> <tbody className="divide-y divide-line text-xs">
{recentEvents.map((event) => { {recentEvents.map((event) => {
const ip = eventIpSummary(event.data); const ip = eventIpSummary(event.data);
return ( return (
<tr key={event.id}> <tr className="hover:bg-canvas/60" key={event.id}>
<td className="whitespace-nowrap p-4 font-mono text-muted">{event.time.toISOString()}</td> <td className="whitespace-nowrap p-4 font-mono text-muted"><time dateTime={event.time.toISOString()}>{event.time.toISOString()}</time></td>
<td className="p-4 font-mono font-bold">{event.type}</td> <th className="p-4 text-left font-mono font-bold" scope="row"><Link className="underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/events/${event.id}`}>{event.type}</Link></th>
<td className="p-4 font-mono text-muted">{event.subject ?? "—"}</td> <td className="p-4 font-mono text-muted">{event.subject ?? "—"}</td>
<td className="p-4 font-mono text-muted">{event.ipAddress ?? "—"}</td> <td className="p-4 font-mono text-muted">{event.ipAddress ?? "—"}</td>
<td className="p-4"><div className="font-mono text-[10px] font-bold uppercase">{ip.classification ?? "—"}</div><div className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"}</div></td> <td className="p-4"><div className="font-mono text-[10px] font-bold uppercase">{ip.classification ?? "—"}</div><div className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"}</div></td>
</tr> </tr>
); );
})} })}
{!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={5}>No events have been recorded.</td></tr>} {!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={5}>No events match these filters.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -0,0 +1,125 @@
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { asc, eq } from "drizzle-orm";
import Link from "next/link";
import { notFound } from "next/navigation";
import { db } from "@/lib/database";
import { addGroupMember, assignDefaultGroup, deleteGroup, removeGroupMember, setGroupAccess } from "../actions";
const savedMessages: Record<string, string> = {
created: "Group created with access disabled.",
access: "Group access policy updated.",
"member-added": "User assigned to the group.",
"member-removed": "User returned to the default group.",
};
export const dynamic = "force-dynamic";
export default async function GroupPage({
params,
searchParams,
}: {
params: Promise<{ groupId: string }>;
searchParams: Promise<{ saved?: string }>;
}) {
const { groupId } = await params;
const query = await searchParams;
const [group] = await db.select().from(groups).where(eq(groups.id, groupId)).limit(1);
if (!group) notFound();
const [allUsers, memberships] = await Promise.all([
db.select({
id: users.id,
firstName: users.firstName,
discordUsername: users.discordUsername,
discordGlobalName: users.discordGlobalName,
discordUserId: users.discordUserId,
}).from(users).orderBy(asc(users.discordUsername)),
db.select({
userId: userGroupMemberships.userId,
groupId: userGroupMemberships.groupId,
groupName: groups.name,
}).from(userGroupMemberships).innerJoin(groups, eq(groups.id, userGroupMemberships.groupId)),
]);
const assignmentByUser = new Map(memberships.map((membership) => [membership.userId, membership]));
const memberCount = group.isDefault
? allUsers.length - assignmentByUser.size
: memberships.filter((membership) => membership.groupId === group.id).length;
return (
<main className="mx-auto max-w-6xl px-6 py-12">
<Link className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline underline-offset-4" href="/admin/groups"> All groups</Link>
<header className="mt-7 flex flex-col gap-6 border-b border-line pb-8 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Access group</p>
<div className="mt-4 flex flex-wrap items-center gap-3"><h1 className="font-display text-5xl font-black uppercase sm:text-7xl">{group.name}</h1>{group.isDefault && <span className="bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase text-canvas">Default</span>}</div>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
</div>
<form action={setGroupAccess} className="border-l-2 border-accent pl-5">
<input name="groupId" type="hidden" value={group.id} />
<input name="accessEnabled" type="hidden" value={group.accessEnabled ? "no" : "yes"} />
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Minecraft admission</p>
<p className="mt-2 font-display text-2xl font-black uppercase">{group.accessEnabled ? "Allowed" : "Denied"}</p>
<button className="mt-3 font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn access {group.accessEnabled ? "off" : "on"}</button>
</form>
</header>
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">{savedMessages[query.saved] ?? "Group updated."}</p>}
<section className="mt-10">
<div className="flex items-end justify-between border-b border-line pb-4">
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Membership</p><h2 className="mt-2 font-display text-3xl font-black uppercase">Registered users</h2></div>
<span className="font-mono text-xs text-muted">{memberCount} members</span>
</div>
{group.isDefault && <p className="border-b border-line bg-panel px-5 py-4 text-sm text-muted">Users belong to <strong className="text-ink">everyone</strong> only while they have no explicit group assignment.</p>}
<div className="divide-y divide-line">
{allUsers.map((user) => {
const assignment = assignmentByUser.get(user.id);
const isMember = group.isDefault ? !assignment : assignment?.groupId === group.id;
return (
<article className="grid gap-4 py-5 sm:grid-cols-[1fr_auto] sm:items-center" key={user.id}>
<div>
<Link className="font-mono text-sm font-bold underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? user.discordGlobalName ?? user.discordUsername}</Link>
<p className="mt-1 font-mono text-[10px] text-muted">@{user.discordUsername} · {user.discordUserId}</p>
{!isMember && assignment && <p className="mt-1 text-xs text-muted">Currently assigned to {assignment.groupName}</p>}
</div>
{isMember ? (
group.isDefault ? <span className="font-mono text-[9px] font-bold uppercase text-muted">Default assignment</span> : (
<form action={removeGroupMember}>
<input name="groupId" type="hidden" value={group.id} />
<input name="userId" type="hidden" value={user.id} />
<button className="font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Return to everyone</button>
</form>
)
) : (
<form action={group.isDefault ? assignDefaultGroup : addGroupMember}>
<input name="groupId" type="hidden" value={group.id} />
<input name="userId" type="hidden" value={user.id} />
<button className="font-mono text-[9px] font-bold uppercase text-ink underline underline-offset-4" type="submit">Move to {group.name}</button>
</form>
)}
</article>
);
})}
{!allUsers.length && <p className="py-8 text-sm text-muted">No registered users yet.</p>}
</div>
</section>
{!group.isDefault && (
<section className="mt-12 border border-accent bg-panel p-6">
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Danger zone</p>
<h2 className="mt-3 font-display text-2xl font-black uppercase">Delete {group.name}</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted">Deleting this group returns its {memberCount} {memberCount === 1 ? "member" : "members"} to the protected default group. This cannot be undone.</p>
<details className="mt-5">
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4">Review deletion</summary>
<form action={deleteGroup} className="mt-4 flex flex-wrap items-center gap-4">
<input name="groupId" type="hidden" value={group.id} />
<input name="confirmDelete" type="hidden" value="yes" />
<button className="bg-accent px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Delete group permanently</button>
<span className="text-xs text-muted">Members will use everyone immediately.</span>
</form>
</details>
</section>
)}
</main>
);
}
@@ -0,0 +1,191 @@
"use server";
import { randomUUID } from "node:crypto";
import { events, groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { getClientIp } from "@minecraft-account-manager/network";
import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { recordAdminSubjectEvent } from "@/lib/audit";
import { requireAdminSession } from "@/lib/auth/require-admin";
import { db } from "@/lib/database";
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function groupPath(groupId: string, query?: string) {
return `/admin/groups/${encodeURIComponent(groupId)}${query ? `?${query}` : ""}`;
}
export async function createGroup(formData: FormData) {
const admin = await requireAdminSession();
const name = String(formData.get("name") ?? "").trim();
const slug = String(formData.get("slug") ?? "").trim().toLowerCase();
const description = String(formData.get("description") ?? "").trim();
if (name.length < 1 || name.length > 50 || !SLUG_PATTERN.test(slug) || slug.length > 50 || description.length > 500) {
redirect("/admin/groups?error=invalid-group");
}
let group: { id: string } | undefined;
try {
[group] = await db.insert(groups).values({
name,
slug,
description: description || null,
accessEnabled: false,
isDefault: false,
}).returning({ id: groups.id });
} catch {
redirect("/admin/groups?error=duplicate-group");
}
if (!group) redirect("/admin/groups?error=create-failed");
await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.created", {
name,
slug,
accessEnabled: false,
});
redirect(groupPath(group.id, "saved=created"));
}
export async function setGroupAccess(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const accessEnabled = formData.get("accessEnabled") === "yes";
if (!UUID_PATTERN.test(groupId)) redirect("/admin/groups?error=unknown-group");
const [group] = await db.update(groups).set({ accessEnabled, updatedAt: new Date() })
.where(eq(groups.id, groupId)).returning({ id: groups.id, name: groups.name });
if (!group) redirect("/admin/groups?error=unknown-group");
await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.access-updated", {
name: group.name,
accessEnabled,
});
redirect(groupPath(group.id, "saved=access"));
}
export async function addGroupMember(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const userId = String(formData.get("userId") ?? "");
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
const [[group], [user]] = await Promise.all([
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
]);
if (!group || !user || group.isDefault) redirect("/admin/groups?error=invalid-membership");
const previousGroup = await db.transaction(async (tx) => {
const [previous] = await tx
.select({ id: groups.id, name: groups.name })
.from(userGroupMemberships)
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
.where(eq(userGroupMemberships.userId, user.id))
.limit(1);
await tx.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
await tx.insert(userGroupMemberships).values({ groupId: group.id, userId: user.id });
return previous ?? null;
});
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.assignment-updated", {
groupId: group.id,
groupName: group.name,
previousGroupId: previousGroup?.id ?? null,
previousGroupName: previousGroup?.name ?? "everyone",
});
redirect(groupPath(group.id, "saved=member-added"));
}
export async function removeGroupMember(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const userId = String(formData.get("userId") ?? "");
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
const [group] = await db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault })
.from(groups).where(eq(groups.id, groupId)).limit(1);
if (!group || group.isDefault) redirect("/admin/groups?error=invalid-membership");
await db.delete(userGroupMemberships).where(and(
eq(userGroupMemberships.groupId, group.id),
eq(userGroupMemberships.userId, userId),
));
await recordAdminSubjectEvent(admin, `user/${userId}`, "games.minecraft.account-manager.group.assignment-removed", {
groupId: group.id,
groupName: group.name,
fallbackGroup: "everyone",
});
redirect(groupPath(group.id, "saved=member-removed"));
}
export async function assignDefaultGroup(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const userId = String(formData.get("userId") ?? "");
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
const [[defaultGroup], [user]] = await Promise.all([
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
]);
if (!defaultGroup?.isDefault || !user) redirect("/admin/groups?error=invalid-membership");
const [previous] = await db
.select({ id: groups.id, name: groups.name })
.from(userGroupMemberships)
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
.where(eq(userGroupMemberships.userId, user.id))
.limit(1);
await db.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.assignment-updated", {
groupId: defaultGroup.id,
groupName: defaultGroup.name,
previousGroupId: previous?.id ?? null,
previousGroupName: previous?.name ?? null,
});
redirect(groupPath(defaultGroup.id, "saved=member-added"));
}
export async function deleteGroup(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const confirmed = formData.get("confirmDelete") === "yes";
if (!UUID_PATTERN.test(groupId) || !confirmed) redirect("/admin/groups?error=invalid-delete");
const requestHeaders = await headers();
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
const deleted = await db.transaction(async (tx) => {
const [group] = await tx
.select({ id: groups.id, name: groups.name, slug: groups.slug, isDefault: groups.isDefault })
.from(groups)
.where(eq(groups.id, groupId))
.limit(1);
if (!group || group.isDefault) return null;
const members = await tx
.select({ userId: userGroupMemberships.userId })
.from(userGroupMemberships)
.where(eq(userGroupMemberships.groupId, group.id));
await tx.insert(events).values({
id: randomUUID(),
source: "/web/admin",
type: "games.minecraft.account-manager.group.deleted",
subject: `group/${group.id}`,
time: new Date(),
data: {
name: group.name,
slug: group.slug,
affectedUsers: members.length,
fallbackGroup: "everyone",
adminEmail: admin.email,
adminName: admin.name,
},
ipAddress: ipAddress ?? null,
});
await tx.delete(groups).where(eq(groups.id, group.id));
return group;
});
if (!deleted) redirect("/admin/groups?error=protected-group");
redirect("/admin/groups?saved=deleted");
}
@@ -0,0 +1,84 @@
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { asc, desc } from "drizzle-orm";
import Link from "next/link";
import { db } from "@/lib/database";
import { createGroup, setGroupAccess } from "./actions";
const errors: Record<string, string> = {
"invalid-group": "Enter a name and a lowercase slug containing letters, numbers, or hyphens.",
"duplicate-group": "That group slug already exists.",
"create-failed": "The group could not be created.",
"unknown-group": "That group no longer exists.",
"invalid-membership": "That membership change was invalid.",
"invalid-delete": "Confirm the group deletion before continuing.",
"protected-group": "The protected default group cannot be deleted.",
};
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([
db.select().from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
db.select({ groupId: userGroupMemberships.groupId }).from(userGroupMemberships),
db.select({ id: users.id }).from(users),
]);
const membershipCounts = new Map<string, number>();
for (const membership of memberships) {
membershipCounts.set(membership.groupId, (membershipCounts.get(membership.groupId) ?? 0) + 1);
}
const explicitlyAssignedUsers = memberships.length;
return (
<main className="mx-auto max-w-6xl px-6 py-14">
<header className="border-b border-line pb-8">
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Admission policy</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase">Access groups</h1>
<p className="mt-5 max-w-2xl leading-7 text-muted">Each user has one effective group. Users without an explicit assignment fall back to <strong className="text-ink">everyone</strong>; Minecraft admission follows only that groups access setting.</p>
</header>
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errors[query.error] ?? "The group operation failed."}</p>}
{query.saved === "deleted" && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">Group deleted. Its former members now use the default group.</p>}
<section className="mt-10 grid gap-5 md:grid-cols-2">
{allGroups.map((group) => {
const memberCount = group.isDefault ? registeredUsers.length - explicitlyAssignedUsers : membershipCounts.get(group.id) ?? 0;
return (
<article className="border border-line bg-panel p-6 shadow-[5px_5px_0_var(--color-shadow)]" key={group.id}>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-display text-2xl font-black uppercase">{group.name}</h2>
{group.isDefault && <span className="bg-ink px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">Default</span>}
</div>
<p className="mt-1 font-mono text-[10px] text-muted">{group.slug} · {memberCount} members</p>
</div>
<span className={`px-3 py-2 font-mono text-[9px] font-bold uppercase ${group.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{group.accessEnabled ? "Access on" : "Access off"}</span>
</div>
<p className="mt-4 min-h-12 text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
<div className="mt-5 flex items-center justify-between gap-4 border-t border-line pt-4">
<Link className="font-mono text-[10px] font-bold uppercase underline underline-offset-4" href={`/admin/groups/${group.id}`}>Manage members</Link>
<form action={setGroupAccess}>
<input name="groupId" type="hidden" value={group.id} />
<input name="accessEnabled" type="hidden" value={group.accessEnabled ? "no" : "yes"} />
<button className="font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn access {group.accessEnabled ? "off" : "on"}</button>
</form>
</div>
</article>
);
})}
</section>
<form action={createGroup} className="mt-12 border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)]">
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Create a group</p>
<div className="mt-5 grid gap-5 sm:grid-cols-2">
<label className="text-sm font-bold">Name<input className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={50} name="name" required /></label>
<label className="text-sm font-bold">Slug<input className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-mono font-normal outline-none focus:border-accent" maxLength={50} name="slug" pattern="[a-z0-9]+(?:-[a-z0-9]+)*" placeholder="ops" required /></label>
</div>
<label className="mt-5 block text-sm font-bold">Description<textarea className="mt-2 min-h-24 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={500} name="description" /></label>
<p className="mt-4 text-xs text-muted">New groups start with access disabled.</p>
<button className="mt-6 border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Create group</button>
</form>
</main>
);
}
+8 -4
View File
@@ -9,6 +9,8 @@ import { AdminSignOutButton } from "@/components/admin-sign-out-button";
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth"; import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
import { db } from "@/lib/database"; import { db } from "@/lib/database";
export const dynamic = "force-dynamic";
export default async function AdminConsoleLayout({ children }: { children: ReactNode }) { export default async function AdminConsoleLayout({ children }: { children: ReactNode }) {
const session = await getServerSession(adminAuthOptions); const session = await getServerSession(adminAuthOptions);
if (!session) redirect("/admin/login"); if (!session) redirect("/admin/login");
@@ -29,11 +31,13 @@ export default async function AdminConsoleLayout({ children }: { children: React
return ( return (
<div className="min-h-screen bg-canvas text-ink"> <div className="min-h-screen bg-canvas text-ink">
<header className="border-b border-line bg-panel"> <header className="border-b border-line bg-panel">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-5"> <div className="mx-auto flex max-w-6xl flex-wrap items-center gap-5 px-6 py-5">
<Link className="font-display text-sm font-black uppercase tracking-[0.2em]" href="/admin">Blocklist / Ops</Link> <Link className="font-display text-sm font-black uppercase tracking-[0.2em]" href="/admin">SoMC Portal / Ops</Link>
<nav className="ml-auto mr-8 flex gap-5 font-mono text-[10px] font-bold uppercase tracking-widest"> <nav aria-label="Administrator" className="ml-auto flex flex-wrap gap-5 font-mono text-[10px] font-bold uppercase tracking-widest">
<Link className="hover:text-accent" href="/admin">Settings</Link> <Link className="hover:text-accent" href="/admin">Dashboard</Link>
<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/users">Users</Link>
<Link className="hover:text-accent" href="/admin/groups">Groups</Link>
<Link className="hover:text-accent" href="/admin/events">Events</Link> <Link className="hover:text-accent" href="/admin/events">Events</Link>
</nav> </nav>
<AdminSignOutButton /> <AdminSignOutButton />
+170 -38
View File
@@ -1,48 +1,180 @@
import { eq } from "drizzle-orm"; import { events, ipIntelligence, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
import { appSettings } from "@minecraft-account-manager/database"; import { and, count, countDistinct, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
import Link from "next/link";
import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map";
import { db } from "@/lib/database"; import { db } from "@/lib/database";
import { saveDiscordSettings } from "./actions"; import { fillDailySeries, type DailyCount } from "@/lib/admin-metrics";
import { parseUserLocation } from "@/lib/user-location-map";
export default async function AdminPage({ export const dynamic = "force-dynamic";
searchParams,
}: { export default async function AdminDashboardPage() {
searchParams: Promise<{ saved?: string; error?: string }>; const now = new Date();
}) { const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1_000);
const query = await searchParams; const fourteenDaysAgo = new Date(now.getTime() - 13 * 24 * 60 * 60 * 1_000);
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1); fourteenDaysAgo.setUTCHours(0, 0, 0, 0);
const message = settings?.registrationMessage ?? "Please register your Minecraft account before joining."; const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1_000);
const [registrationRows, [totals], [monthlyActive], locationRows, riskyActivity, [recentDenials]] = await Promise.all([
db
.select({
day: sql<string>`to_char(date_trunc('day', ${users.createdAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
count: count(),
})
.from(users)
.where(gte(users.createdAt, fourteenDaysAgo))
.groupBy(sql`date_trunc('day', ${users.createdAt} at time zone 'UTC')`)
.orderBy(sql`date_trunc('day', ${users.createdAt} at time zone 'UTC')`),
db.select({ users: count(users.id) }).from(users),
db.select({
users: countDistinct(ipObservations.userId),
accounts: countDistinct(ipObservations.minecraftAccountId),
}).from(ipObservations).where(and(
gte(ipObservations.observedAt, thirtyDaysAgo),
isNotNull(ipObservations.userId),
)),
db
.selectDistinctOn([ipObservations.userId], {
userId: ipObservations.userId,
name: users.firstName,
discordUsername: users.discordUsername,
classification: ipObservations.classification,
source: ipObservations.source,
observedAt: ipObservations.observedAt,
intelligence: ipIntelligence.rawResponse,
})
.from(ipObservations)
.innerJoin(users, eq(users.id, ipObservations.userId))
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
.where(and(
isNotNull(ipObservations.userId),
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`,
))
.orderBy(ipObservations.userId, desc(ipObservations.observedAt), desc(ipObservations.id)),
db
.select({
id: ipObservations.id,
classification: ipObservations.classification,
observedAt: ipObservations.observedAt,
source: ipObservations.source,
userId: users.id,
firstName: users.firstName,
discordUsername: users.discordUsername,
accountUsername: minecraftAccounts.username,
})
.from(ipObservations)
.leftJoin(users, eq(users.id, ipObservations.userId))
.leftJoin(minecraftAccounts, eq(minecraftAccounts.id, ipObservations.minecraftAccountId))
.where(inArray(ipObservations.classification, ["vpn", "proxy", "tor"]))
.orderBy(desc(ipObservations.observedAt))
.limit(10),
db.select({ count: count() }).from(events).where(and(
eq(events.type, "games.minecraft.account-manager.game.login.denied"),
gte(events.time, oneDayAgo),
)),
]);
const registrations = fillDailySeries(registrationRows as DailyCount[], now, 14);
const locations = locationRows.flatMap((row): UserMapLocation[] => {
const parsed = parseUserLocation(row.intelligence);
if (!parsed || !row.userId) return [];
return [{
userId: row.userId,
name: row.name ?? row.discordUsername,
discordUsername: row.discordUsername,
latitude: parsed.latitude,
longitude: parsed.longitude,
location: parsed.label,
classification: row.classification,
source: row.source,
observedAt: row.observedAt,
}];
});
return ( return (
<main className="mx-auto max-w-6xl px-6 py-14"> <main className="mx-auto max-w-6xl px-6 py-14">
<div className="grid gap-10 lg:grid-cols-[0.7fr_1.3fr]"> <header className="border-b border-line pb-8">
<section> <p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Operations overview</p>
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">System settings</p> <h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">Dashboard</h1>
<h1 className="mt-4 font-display text-5xl font-black uppercase leading-none tracking-tight">Server gate</h1> <p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Live, server-rendered registration, activity, and network-risk signals from the account registry.</p>
<p className="mt-6 max-w-sm leading-7 text-muted">The Discord guild and invite are deployment environment settings. Operators can adjust the message shown to denied Minecraft players here.</p> </header>
<dl className="mt-7 space-y-2 font-mono text-[10px] uppercase tracking-wider text-muted">
<div><dt className="inline font-bold text-ink">Guild:</dt> <dd className="inline">{process.env.DISCORD_GUILD_ID ? "configured" : "missing"}</dd></div> <UserWorldMap locations={locations} unavailableCount={Math.max(0, (totals?.users ?? 0) - locations.length)} />
<div><dt className="inline font-bold text-ink">Invite:</dt> <dd className="inline">{process.env.DISCORD_INVITE_URL ? "configured" : "missing"}</dd></div>
</dl> <section aria-label="Key metrics" className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Metric label="Registered users" value={totals?.users ?? 0} detail="All time" />
<Metric label="Monthly active users" value={monthlyActive?.users ?? 0} detail="Distinct users · 30 days" />
<Metric label="Active Minecraft accounts" value={monthlyActive?.accounts ?? 0} detail="Distinct accounts · 30 days" />
<Metric label="Login denials" value={recentDenials?.count ?? 0} detail="Past 24 hours" accent />
</section>
<div className="mt-10 grid gap-8 lg:grid-cols-[1.3fr_0.7fr]">
<RegistrationChart data={registrations} />
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
<div className="flex items-start justify-between gap-4">
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Network review</p><h2 className="mt-2 font-display text-2xl font-black uppercase">Recent VPN activity</h2></div>
<Link className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" href="/admin/events?category=security">All security events</Link>
</div>
<div className="mt-5 divide-y divide-line">
{riskyActivity.map((activity) => (
<article className="py-4" key={activity.id}>
<div className="flex items-start justify-between gap-3">
<div>
{activity.userId ? <Link className="font-mono text-xs font-bold underline decoration-line underline-offset-4" href={`/admin/users/${activity.userId}`}>{activity.firstName ?? activity.discordUsername ?? "Unknown user"}</Link> : <span className="font-mono text-xs font-bold">Unknown user</span>}
<p className="mt-1 text-xs text-muted">{activity.accountUsername ?? "No Minecraft account"} · {activity.source}</p>
</div>
<span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">{activity.classification}</span>
</div>
<time className="mt-2 block font-mono text-[9px] text-muted" dateTime={activity.observedAt.toISOString()}>{activity.observedAt.toISOString()}</time>
</article>
))}
{!riskyActivity.length && <p className="py-6 text-sm text-muted">No recent VPN, proxy, or Tor observations.</p>}
</div>
</section> </section>
<form action={saveDiscordSettings} className="border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)] sm:p-9">
{query.saved && <p className="mb-6 border-l-2 border-signal pl-4 font-mono text-xs font-bold uppercase tracking-wider">Settings saved</p>}
{query.error && <p className="mb-6 border-l-2 border-accent pl-4 text-sm">Check the highlighted configuration values and try again.</p>}
<label className="block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="registrationMessage">Denied-player message</label>
<textarea
className="mt-3 min-h-32 w-full resize-y border border-line bg-canvas px-4 py-3 text-sm leading-6 outline-none focus:border-accent"
defaultValue={message}
id="registrationMessage"
maxLength={500}
minLength={10}
name="registrationMessage"
required
/>
<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>
</form>
</div> </div>
</main> </main>
); );
} }
function Metric({ label, value, detail, accent = false }: { label: string; value: number; detail: string; accent?: boolean }) {
return (
<article className={`border p-5 ${accent ? "border-accent bg-ink text-canvas" : "border-line bg-panel"}`}>
<p className={`font-mono text-[9px] font-bold uppercase tracking-widest ${accent ? "text-signal" : "text-muted"}`}>{label}</p>
<p className="mt-3 font-display text-5xl font-black">{value}</p>
<p className={`mt-2 text-xs ${accent ? "text-canvas" : "text-muted"}`}>{detail}</p>
</article>
);
}
function RegistrationChart({ data }: { data: DailyCount[] }) {
const width = 720;
const height = 260;
const padding = 32;
const maximum = Math.max(1, ...data.map((entry) => entry.count));
const points = data.map((entry, index) => {
const x = padding + index * ((width - padding * 2) / Math.max(1, data.length - 1));
const y = height - padding - (entry.count / maximum) * (height - padding * 2);
return `${x},${y}`;
}).join(" ");
return (
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Growth signal</p>
<h2 className="mt-2 font-display text-2xl font-black uppercase">New users by day</h2>
<svg aria-labelledby="registration-chart-title registration-chart-description" className="mt-6 h-auto w-full" role="img" viewBox={`0 0 ${width} ${height}`}>
<title id="registration-chart-title">New user registrations over the last 14 days</title>
<desc id="registration-chart-description">Daily registrations range from zero to {maximum}. A text summary follows the chart.</desc>
<line stroke="var(--line)" strokeWidth="1" x1={padding} x2={width - padding} y1={height - padding} y2={height - padding} />
<polyline fill="none" points={points} stroke="var(--accent)" strokeLinecap="square" strokeLinejoin="miter" strokeWidth="4" />
{data.map((entry, index) => {
const [x, y] = points.split(" ")[index]!.split(",");
return <circle cx={x} cy={y} fill="var(--panel)" key={entry.day} r="5" stroke="var(--ink)" strokeWidth="3"><title>{entry.day}: {entry.count} new users</title></circle>;
})}
</svg>
<dl className="mt-4 grid grid-cols-7 gap-2 border-t border-line pt-4 text-center">
{data.map((entry) => <div key={entry.day}><dt className="sr-only">{entry.day}</dt><dd className="font-mono text-xs font-bold">{entry.count}</dd></div>)}
</dl>
<div aria-hidden="true" className="mt-2 flex justify-between font-mono text-[9px] text-muted"><span>{data[0]?.day}</span><span>{data.at(-1)?.day}</span></div>
</section>
);
}
@@ -0,0 +1,51 @@
import { appSettings } from "@minecraft-account-manager/database";
import { eq } from "drizzle-orm";
import { db } from "@/lib/database";
import { saveDiscordSettings } from "../actions";
export const dynamic = "force-dynamic";
export default async function SettingsPage({
searchParams,
}: {
searchParams: Promise<{ saved?: string; error?: string }>;
}) {
const query = await searchParams;
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
const message = settings?.registrationMessage ?? "Please register your Minecraft account before joining.";
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
return (
<main className="mx-auto max-w-6xl px-6 py-14">
<div className="grid gap-10 lg:grid-cols-[0.7fr_1.3fr]">
<section>
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">System settings</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase leading-none tracking-tight">Server gate</h1>
<p className="mt-6 max-w-sm leading-7 text-muted">The Discord guild and invite are deployment environment settings. Operators can adjust the message shown to denied Minecraft players here.</p>
<dl className="mt-7 space-y-4 font-mono text-[10px] uppercase tracking-wider text-muted">
<div><dt className="font-bold text-ink">Guild ID</dt><dd className="mt-1 break-all normal-case">{guildId ?? "Missing"}</dd></div>
<div><dt className="font-bold text-ink">Invite URL</dt><dd className="mt-1 break-all normal-case">{inviteUrl ? <a className="text-ink underline decoration-accent underline-offset-4" href={inviteUrl} rel="noreferrer" target="_blank">{inviteUrl}<span className="sr-only"> (opens in a new tab)</span></a> : "Missing"}</dd></div>
</dl>
</section>
<form action={saveDiscordSettings} className="border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)] sm:p-9">
{query.saved && <p className="mb-6 border-l-2 border-signal pl-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">Settings saved</p>}
{query.error && <p className="mb-6 border-l-2 border-accent pl-4 text-sm" role="alert">Check the configuration value and try again.</p>}
<label className="block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="registrationMessage">Denied-player message</label>
<textarea
className="mt-3 min-h-32 w-full resize-y border border-line bg-canvas px-4 py-3 text-sm leading-6 outline-none focus:border-accent"
defaultValue={message}
id="registrationMessage"
maxLength={500}
minLength={10}
name="registrationMessage"
required
/>
<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>
</form>
</div>
</main>
);
}
@@ -1,9 +1,13 @@
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft"; import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
import { events, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database"; import { events, groups, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { and, desc, eq, isNull, or } from "drizzle-orm"; import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { groupAccessAddresses } from "@/lib/access-address-groups";
import { db } from "@/lib/database"; import { db } from "@/lib/database";
import { discordIdentity } from "@/lib/discord-identity";
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
import { eventIpSummary } from "@/lib/event-ip-summary"; import { eventIpSummary } from "@/lib/event-ip-summary";
import { import {
addUserMinecraftAccount, addUserMinecraftAccount,
@@ -31,37 +35,69 @@ const savedMessages: Record<string, string> = {
nickname: "Discord nickname synchronized.", nickname: "Discord nickname synchronized.",
}; };
export const dynamic = "force-dynamic";
function queryValues(value: string | string[] | undefined) {
return Array.isArray(value) ? value : value ? [value] : [];
}
export default async function AdminUserPage({ export default async function AdminUserPage({
params, params,
searchParams, searchParams,
}: { }: {
params: Promise<{ userId: string }>; params: Promise<{ userId: string }>;
searchParams: Promise<{ error?: string; saved?: string; unverified?: string }>; searchParams: Promise<Record<string, string | string[] | undefined>>;
}) { }) {
const { userId } = await params; const { userId } = await params;
const query = await searchParams; const query = await searchParams;
const error = queryValues(query.error)[0];
const saved = queryValues(query.saved)[0];
const unverified = queryValues(query.unverified)[0];
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user) notFound(); if (!user) notFound();
const [accounts, recentEvents, observations] = await Promise.all([ const userEventCondition = or(eq(events.subject, `user/${user.id}`), eq(events.actorUserId, user.id));
const eventTypeRows = await db.select({ type: events.type }).from(events).where(userEventCondition).groupBy(events.type).orderBy(events.type);
const availableEventTypes = eventTypeRows.map((row) => row.type);
const selectedCategory = normalizeEventCategory(queryValues(query.eventCategory)[0]);
const selectedEventTypes = normalizeSelectedEventTypes(query.eventType, availableEventTypes);
const categoryTypes = selectedCategory === "all"
? availableEventTypes
: availableEventTypes.filter((type) => eventCategory(type) === selectedCategory);
const filteredEventTypes = selectedEventTypes.length
? selectedEventTypes.filter((type) => categoryTypes.includes(type))
: categoryTypes;
const recentEventsQuery = filteredEventTypes.length
? db.select().from(events).where(and(userEventCondition, inArray(events.type, filteredEventTypes))).orderBy(desc(events.time)).limit(30)
: Promise.resolve([]);
const [accounts, recentEvents, observations, discord, availableGroups] = await Promise.all([
db db
.select() .select()
.from(minecraftAccounts) .from(minecraftAccounts)
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt))) .where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username), .orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
db recentEventsQuery,
.select()
.from(events)
.where(or(eq(events.subject, `user/${user.id}`), eq(events.actorUserId, user.id)))
.orderBy(desc(events.time))
.limit(30),
db db
.select() .select()
.from(ipObservations) .from(ipObservations)
.where(eq(ipObservations.userId, user.id)) .where(eq(ipObservations.userId, user.id))
.orderBy(desc(ipObservations.observedAt)) .orderBy(desc(ipObservations.observedAt))
.limit(20), .limit(100),
discordIdentity(user),
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, isDefault: groups.isDefault })
.from(groups)
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
.orderBy(desc(groups.isDefault), groups.name),
]); ]);
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 primary = accounts.find((account) => account.isPrimary); const primary = accounts.find((account) => account.isPrimary);
const nickname = user.firstName const nickname = user.firstName
? formatManagedDiscordNickname(user.firstName, primary?.username ?? null) ? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
@@ -74,7 +110,12 @@ export default async function AdminUserPage({
<div> <div>
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">User record</p> <p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">User record</p>
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">{user.firstName ?? "Name needed"}</h1> <h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">{user.firstName ?? "Name needed"}</h1>
<p className="mt-3 font-mono text-xs text-muted">@{user.discordUsername} · {user.discordUserId}</p> <dl className="mt-4 grid gap-x-8 gap-y-2 font-mono text-[10px] text-muted sm:grid-cols-2">
<div><dt className="uppercase tracking-wider">Discord name</dt><dd className="mt-1 text-xs text-ink">{discord.globalName ?? discord.username}</dd></div>
<div><dt className="uppercase tracking-wider">Discord username</dt><dd className="mt-1 text-xs text-ink">@{discord.username}</dd></div>
<div><dt className="uppercase tracking-wider">Guild nickname</dt><dd className="mt-1 text-xs text-ink">{discord.nickname ?? "No guild nickname"}</dd></div>
<div><dt className="uppercase tracking-wider">Discord ID</dt><dd className="mt-1 break-all text-xs text-ink">{discord.id}</dd></div>
</dl>
</div> </div>
<div className="border-l-2 border-accent pl-5"> <div className="border-l-2 border-accent pl-5">
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Expected Discord nickname</p> <p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Expected Discord nickname</p>
@@ -83,8 +124,8 @@ export default async function AdminUserPage({
</div> </div>
</header> </header>
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">{errorMessages[query.error] ?? "The requested operation failed."}</p>} {error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errorMessages[error] ?? "The requested operation failed."}</p>}
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider">{savedMessages[query.saved] ?? "Changes saved."}</p>} {saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">{savedMessages[saved] ?? "Changes saved."}</p>}
<div className="mt-10 grid gap-10 lg:grid-cols-[1.3fr_0.7fr]"> <div className="mt-10 grid gap-10 lg:grid-cols-[1.3fr_0.7fr]">
<div className="space-y-10"> <div className="space-y-10">
@@ -120,10 +161,10 @@ export default async function AdminUserPage({
{!accounts.length && <p className="py-7 text-sm text-muted">No active Minecraft accounts.</p>} {!accounts.length && <p className="py-7 text-sm text-muted">No active Minecraft accounts.</p>}
</div> </div>
{query.unverified ? ( {unverified ? (
<form action={addUserMinecraftAccount} className="border-l-2 border-accent bg-panel p-6"> <form action={addUserMinecraftAccount} className="border-l-2 border-accent bg-panel p-6">
<input name="userId" type="hidden" value={user.id} /><input name="username" type="hidden" value={query.unverified} /><input name="forceUnverified" type="hidden" value="yes" /> <input name="userId" type="hidden" value={user.id} /><input name="username" type="hidden" value={unverified} /><input name="forceUnverified" type="hidden" value="yes" />
<h3 className="font-display text-xl font-black uppercase">Mojang couldnt verify {query.unverified}</h3> <h3 className="font-display text-xl font-black uppercase">Mojang couldnt verify {unverified}</h3>
<p className="mt-2 text-sm leading-6 text-muted">Only override this when you have independently confirmed the spelling.</p> <p className="mt-2 text-sm leading-6 text-muted">Only override this when you have independently confirmed the spelling.</p>
<button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas" type="submit">Add unverified account</button> <button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas" type="submit">Add unverified account</button>
<a className="ml-5 font-mono text-[9px] font-bold uppercase underline" href={`/admin/users/${user.id}`}>Cancel</a> <a className="ml-5 font-mono text-[9px] font-bold uppercase underline" href={`/admin/users/${user.id}`}>Cancel</a>
@@ -131,7 +172,7 @@ export default async function AdminUserPage({
) : ( ) : (
<form action={addUserMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row"> <form action={addUserMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row">
<input name="userId" type="hidden" value={user.id} /> <input name="userId" type="hidden" value={user.id} />
<input className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required /> <input aria-label="Minecraft username" className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required />
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Verify and add</button> <button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Verify and add</button>
</form> </form>
)} )}
@@ -139,11 +180,16 @@ export default async function AdminUserPage({
<section> <section>
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Audit trail</p> <p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Audit trail</p>
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Recent events</h2> <h2 className="mt-2 font-display text-3xl font-black uppercase">Recent events</h2>
<form className="mt-4 grid gap-4 border-y border-line bg-panel p-4 sm:grid-cols-2" method="get">
<label className="font-mono text-[10px] font-bold uppercase" htmlFor="user-event-category">View<select className="mt-2 block w-full border border-line bg-canvas p-2 font-sans text-sm font-normal normal-case" defaultValue={selectedCategory} id="user-event-category" name="eventCategory">{eventCategoryValues.map((value) => <option key={value} value={value}>{value === "all" ? "All activity" : value}</option>)}</select></label>
<fieldset><legend className="font-mono text-[10px] font-bold uppercase">Types</legend><details className="mt-2 border border-line bg-canvas p-2"><summary className="cursor-pointer font-mono text-[9px] underline">{selectedEventTypes.length ? `${selectedEventTypes.length} selected` : "All types"}</summary><div className="mt-3 max-h-44 space-y-2 overflow-y-auto">{availableEventTypes.map((type) => <label className="flex items-start gap-2 font-mono text-[9px]" key={type}><input className="mt-0.5 size-4" defaultChecked={selectedEventTypes.includes(type)} name="eventType" type="checkbox" value={type} /><span className="break-all">{type}</span></label>)}</div></details></fieldset>
<div className="flex gap-4 sm:col-span-2"><button className="bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase text-canvas" type="submit">Filter events</button><Link className="self-center font-mono text-[9px] font-bold uppercase underline" href={`/admin/users/${user.id}`}>Clear</Link></div>
</form>
<div className="divide-y divide-line"> <div className="divide-y divide-line">
{recentEvents.map((event) => { {recentEvents.map((event) => {
const ip = eventIpSummary(event.data); const ip = eventIpSummary(event.data);
return <div className="grid gap-2 py-4 sm:grid-cols-[1fr_auto]" key={event.id}><div><p className="font-mono text-xs font-bold">{event.type}</p>{(ip.location || ip.classification) && <p className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"} · {ip.classification ?? "unknown"}</p>}</div><span className="font-mono text-[9px] text-muted">{event.time.toISOString()}</span></div>; return <article className="grid gap-2 py-4 sm:grid-cols-[1fr_auto]" key={event.id}><div><h3 className="font-mono text-xs font-bold"><Link className="underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/events/${event.id}`}>{event.type}</Link></h3>{(ip.location || ip.classification) && <p className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"} · {ip.classification ?? "unknown"}</p>}</div><time className="font-mono text-[9px] text-muted" dateTime={event.time.toISOString()}>{event.time.toISOString()}</time></article>;
})} })}
{!recentEvents.length && <p className="py-6 text-sm text-muted">No events recorded for this user.</p>} {!recentEvents.length && <p className="py-6 text-sm text-muted">No events recorded for this user.</p>}
</div> </div>
@@ -160,11 +206,26 @@ export default async function AdminUserPage({
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider" type="submit">Save and synchronize</button> <button className="mt-6 border border-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider" type="submit">Save and synchronize</button>
</form> </form>
<section className="border border-line bg-panel p-6">
<div className="flex items-center justify-between gap-3"><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Access groups</p><Link className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" href="/admin/groups">Manage</Link></div>
{effectiveGroup ? <div className="mt-4 flex items-center justify-between gap-3"><Link className="font-mono text-xs font-bold underline decoration-line underline-offset-4" href={`/admin/groups/${effectiveGroup.id}`}>{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</Link><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${effectiveGroup.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{effectiveGroup.accessEnabled ? "On" : "Off"}</span></div> : <p className="mt-4 text-sm text-accent">No effective group configured.</p>}
</section>
<section className="border border-line bg-panel p-6"> <section className="border border-line bg-panel p-6">
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Recent addresses</p> <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"> <div className="mt-4 divide-y divide-line">
{observations.map((observation) => <div className="py-3" key={observation.id}><p className="font-mono text-xs">{observation.ipAddress}</p><p className="mt-1 font-mono text-[9px] text-muted">{observation.source} · {observation.observedAt.toISOString()}</p></div>)} {addressGroups.map((group) => (
{!observations.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>} <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>
</div>
))}
{!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
</div> </div>
</section> </section>
</aside> </aside>
@@ -3,6 +3,8 @@ import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
import Link from "next/link"; import Link from "next/link";
import { db } from "@/lib/database"; import { db } from "@/lib/database";
export const dynamic = "force-dynamic";
export default async function AdminUsersPage({ export default async function AdminUsersPage({
searchParams, searchParams,
}: { }: {
@@ -15,6 +17,7 @@ export default async function AdminUsersPage({
? or( ? or(
ilike(users.firstName, pattern), ilike(users.firstName, pattern),
ilike(users.discordUsername, pattern), ilike(users.discordUsername, pattern),
ilike(users.discordGlobalName, pattern),
eq(users.discordUserId, search), eq(users.discordUserId, search),
sql`exists ( sql`exists (
select 1 from ${minecraftAccounts} select 1 from ${minecraftAccounts}
@@ -33,6 +36,7 @@ export default async function AdminUsersPage({
id: users.id, id: users.id,
firstName: users.firstName, firstName: users.firstName,
discordUsername: users.discordUsername, discordUsername: users.discordUsername,
discordGlobalName: users.discordGlobalName,
discordUserId: users.discordUserId, discordUserId: users.discordUserId,
onboardingCompletedAt: users.onboardingCompletedAt, onboardingCompletedAt: users.onboardingCompletedAt,
primaryUsername: minecraftAccounts.username, primaryUsername: minecraftAccounts.username,
@@ -64,6 +68,7 @@ export default async function AdminUsersPage({
</div> </div>
<form className="flex w-full max-w-md gap-2" method="get"> <form className="flex w-full max-w-md gap-2" method="get">
<input <input
aria-label="Search users"
className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-xs outline-none focus:border-accent" className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-xs outline-none focus:border-accent"
defaultValue={search} defaultValue={search}
name="q" name="q"
@@ -74,18 +79,19 @@ export default async function AdminUsersPage({
</form> </form>
</div> </div>
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">The requested user could not be found.</p>} {query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">The requested user could not be found.</p>}
<div className="mt-8 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]"> <div className="mt-8 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
<table className="w-full min-w-[760px] border-collapse text-left"> <table className="w-full min-w-[760px] border-collapse text-left">
<caption className="sr-only">Registered portal users</caption>
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted"> <thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
<tr><th className="p-4">User</th><th className="p-4">Discord</th><th className="p-4">Primary</th><th className="p-4">Accounts</th><th className="p-4">Status</th></tr> <tr><th className="p-4" scope="col">User</th><th className="p-4" scope="col">Discord</th><th className="p-4" scope="col">Primary</th><th className="p-4" scope="col">Accounts</th><th className="p-4" scope="col">Status</th></tr>
</thead> </thead>
<tbody className="divide-y divide-line"> <tbody className="divide-y divide-line">
{results.map((user) => ( {results.map((user) => (
<tr className="transition-colors hover:bg-canvas/60" key={user.id}> <tr className="transition-colors hover:bg-canvas/60" key={user.id}>
<td className="p-4"><Link className="font-display text-lg font-black underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? "Name needed"}</Link></td> <th className="p-4 text-left" scope="row"><Link className="font-display text-lg font-black underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? "Name needed"}</Link></th>
<td className="p-4"><div className="font-mono text-xs">@{user.discordUsername}</div><div className="mt-1 font-mono text-[9px] text-muted">{user.discordUserId}</div></td> <td className="p-4"><div className="font-mono text-xs font-bold">{user.discordGlobalName ?? user.discordUsername}</div><div className="mt-1 font-mono text-[10px] text-muted">@{user.discordUsername}</div><div className="mt-1 font-mono text-[9px] text-muted">{user.discordUserId}</div></td>
<td className="p-4 font-mono text-xs">{user.primaryUsername ?? "—"}</td> <td className="p-4 font-mono text-xs">{user.primaryUsername ?? "—"}</td>
<td className="p-4 font-mono text-xs">{user.accountCount}</td> <td className="p-4 font-mono text-xs">{user.accountCount}</td>
<td className="p-4"><span className={`border px-2 py-1 font-mono text-[9px] uppercase tracking-wider ${user.onboardingCompletedAt ? "border-line text-muted" : "border-accent text-accent"}`}>{user.onboardingCompletedAt ? "Ready" : "Onboarding"}</span></td> <td className="p-4"><span className={`border px-2 py-1 font-mono text-[9px] uppercase tracking-wider ${user.onboardingCompletedAt ? "border-line text-muted" : "border-accent text-accent"}`}>{user.onboardingCompletedAt ? "Ready" : "Onboarding"}</span></td>
+51 -2
View File
@@ -1,19 +1,22 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { isRequestTimestampFresh, verifyHashedToken } from "@minecraft-account-manager/auth"; import { isRequestTimestampFresh, resolveEffectiveGroup, verifyHashedToken } from "@minecraft-account-manager/auth";
import { problemDetails, velocityAccessRequestSchema } from "@minecraft-account-manager/contracts"; import { problemDetails, velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
import { import {
appSettings, appSettings,
events, events,
groups,
ipObservations, ipObservations,
minecraftAccounts, minecraftAccounts,
pluginCredentials, pluginCredentials,
pluginRequests, pluginRequests,
userGroupMemberships,
} from "@minecraft-account-manager/database"; } from "@minecraft-account-manager/database";
import { and, eq, isNull, lt, sql } from "drizzle-orm"; import { and, eq, isNull, lt, sql } from "drizzle-orm";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { db } from "@/lib/database"; import { db } from "@/lib/database";
import { isUniqueConstraintViolation } from "@/lib/database-errors"; import { isUniqueConstraintViolation } from "@/lib/database-errors";
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence"; import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
import { logger } from "@/lib/logger";
import { problemInstance, problemResponse } from "@/lib/problem-response"; import { problemInstance, problemResponse } from "@/lib/problem-response";
const MAX_CLOCK_SKEW_MS = 45_000; const MAX_CLOCK_SKEW_MS = 45_000;
@@ -209,6 +212,48 @@ async function handleVelocityAccess(request: Request) {
return { allowed: false as const, message: denialMessage }; return { allowed: false as const, message: denialMessage };
} }
const [explicitGroup] = await tx
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled })
.from(userGroupMemberships)
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
.where(eq(userGroupMemberships.userId, account.userId))
.limit(1);
const [defaultGroup] = await tx
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled })
.from(groups)
.where(eq(groups.isDefault, true))
.limit(1);
const effectiveGroup = resolveEffectiveGroup(explicitGroup ?? null, defaultGroup ?? null);
if (!effectiveGroup?.accessEnabled) {
await tx.insert(events).values({
id: randomUUID(),
source: `/velocity/${input.serverId}`,
type: "games.minecraft.account-manager.game.login.denied",
subject: `minecraft-account/${account.id}`,
time: occurredAt,
actorUserId: account.userId,
data: {
username: input.username,
reason: "group_access_disabled",
ipIntelligence: auditIpData,
},
ipAddress: input.ipAddress,
correlationId: input.requestId,
});
await tx.insert(ipObservations).values({
userId: account.userId,
minecraftAccountId: account.id,
source: "game",
ipAddress: input.ipAddress,
minecraftUuid: input.minecraftUuid,
username: input.username,
classification: intelligence.classification,
observedAt: occurredAt,
});
return { allowed: false as const, message: "Your account group does not currently have server access." };
}
if (account.minecraftUuid !== input.minecraftUuid || account.username !== input.username) { if (account.minecraftUuid !== input.minecraftUuid || account.username !== input.username) {
await tx await tx
.update(minecraftAccounts) .update(minecraftAccounts)
@@ -258,6 +303,7 @@ async function handleVelocityAccess(request: Request) {
previousUsername: account.username === input.username ? null : account.username, previousUsername: account.username === input.username ? null : account.username,
uuidBackfilled: account.minecraftUuid === null, uuidBackfilled: account.minecraftUuid === null,
ipIntelligence: auditIpData, ipIntelligence: auditIpData,
accessGroup: effectiveGroup.name,
}, },
ipAddress: input.ipAddress, ipAddress: input.ipAddress,
correlationId: input.requestId, correlationId: input.requestId,
@@ -284,7 +330,10 @@ export async function POST(request: Request) {
)); ));
} }
console.error("Velocity access request failed"); logger.error(
{ err: error, event: "velocity.access_failed", instance },
"Velocity access request failed",
);
return problemResponse(problemDetails( return problemResponse(problemDetails(
"urn:error:service-unavailable", "urn:error:service-unavailable",
"Service unavailable", "Service unavailable",
+3 -2
View File
@@ -3,6 +3,7 @@ import { createAuthRepository, ipObservations, recordEvent } from "@minecraft-ac
import { getClientIp } from "@minecraft-account-manager/network"; import { getClientIp } from "@minecraft-account-manager/network";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { applicationUrl } from "@/lib/application-url";
import { db } from "@/lib/database"; import { db } from "@/lib/database";
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence"; import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
@@ -38,7 +39,7 @@ export async function GET(request: NextRequest) {
]); ]);
const destination = result.user.firstName ? "/account" : "/welcome"; const destination = result.user.firstName ? "/account" : "/welcome";
const response = NextResponse.redirect(new URL(destination, request.url)); const response = NextResponse.redirect(applicationUrl(destination));
response.cookies.set(SESSION_COOKIE_NAME, result.sessionToken, { response.cookies.set(SESSION_COOKIE_NAME, result.sessionToken, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
@@ -49,7 +50,7 @@ export async function GET(request: NextRequest) {
return response; return response;
} catch (error) { } catch (error) {
if (error instanceof InvalidLoginCodeError) { if (error instanceof InvalidLoginCodeError) {
return NextResponse.redirect(new URL("/auth/error", request.url)); return NextResponse.redirect(applicationUrl("/auth/error"));
} }
throw error; throw error;
} }
+28 -1
View File
@@ -19,7 +19,7 @@
--ink: #171916; --ink: #171916;
--muted: #57594f; --muted: #57594f;
--line: #9e9a88; --line: #9e9a88;
--accent: #bc3f24; --accent: #a32f1b;
--signal: #b5d452; --signal: #b5d452;
--shadow: #262a23; --shadow: #262a23;
} }
@@ -37,6 +37,33 @@ body {
background: var(--canvas); background: var(--canvas);
} }
:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 3px;
}
.skip-link {
position: fixed;
left: 1rem;
top: 1rem;
z-index: 100;
transform: translateY(-200%);
background: var(--ink);
color: var(--panel);
padding: 0.75rem 1rem;
font-weight: 700;
}
.skip-link:focus {
transform: translateY(0);
}
svg a:hover .map-marker,
svg a:focus .map-marker {
stroke: var(--ink);
stroke-width: 6px;
}
::selection { ::selection {
background: var(--accent); background: var(--accent);
color: var(--panel); color: var(--panel);
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { GET } from "./route";
describe("health endpoint", () => {
it("reports process readiness without requiring external services", async () => {
const response = GET();
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ status: "ok" });
});
});
+10
View File
@@ -0,0 +1,10 @@
export function GET(): Response {
return Response.json(
{ status: "ok" },
{
headers: {
"Cache-Control": "no-store",
},
},
);
}
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="8" fill="#171916"/>
<path d="M9 9h21v21H9z" fill="#bc3f24"/>
<path d="M34 9h21v21H34zM9 34h21v21H9z" fill="#eee8d8"/>
<path d="M34 34h21v21H34z" fill="#b5d452"/>
<path d="M21 18h13v8H26v4h8v8H21z" fill="#171916"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

+7 -2
View File
@@ -1,16 +1,21 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { SiteFooter } from "@/components/site-footer";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Blocklist — Minecraft Account Manager", title: "SoMC Portal — Minecraft Account Manager",
description: "Connect your Discord identity to approved Minecraft accounts.", description: "Connect your Discord identity to approved Minecraft accounts.",
}; };
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
return ( return (
<html lang="en"> <html lang="en">
<body>{children}</body> <body className="flex min-h-screen flex-col">
<a className="skip-link" href="#main-content">Skip to main content</a>
<div className="flex-1" id="main-content" tabIndex={-1}>{children}</div>
<SiteFooter />
</body>
</html> </html>
); );
} }
+2 -6
View File
@@ -18,11 +18,11 @@ export default async function HomePage({
<div className="terrain" aria-hidden="true" /> <div className="terrain" aria-hidden="true" />
<div className="relative mx-auto flex min-h-screen max-w-7xl flex-col px-6 pb-10 pt-7 sm:px-10 lg:px-16"> <div className="relative mx-auto flex min-h-screen max-w-7xl flex-col px-6 pb-10 pt-7 sm:px-10 lg:px-16">
<header className="flex items-center justify-between border-b border-line pb-5"> <header className="flex items-center justify-between border-b border-line pb-5">
<a className="flex items-center gap-3" href="#top" aria-label="Blocklist home"> <a className="flex items-center gap-3" href="#top" aria-label="SoMC Portal home">
<span className="grid size-9 place-items-center border border-accent bg-accent text-sm font-black text-canvas shadow-[4px_4px_0_var(--color-shadow)]"> <span className="grid size-9 place-items-center border border-accent bg-accent text-sm font-black text-canvas shadow-[4px_4px_0_var(--color-shadow)]">
B B
</span> </span>
<span className="font-display text-sm font-bold uppercase tracking-[0.22em]">Blocklist</span> <span className="font-display text-sm font-bold uppercase tracking-[0.22em]">SoMC Portal</span>
</a> </a>
<span className="hidden items-center gap-2 font-mono text-xs uppercase tracking-widest text-muted sm:flex"> <span className="hidden items-center gap-2 font-mono text-xs uppercase tracking-widest text-muted sm:flex">
<span className="size-2 bg-signal shadow-[0_0_12px_var(--color-signal)]" /> <span className="size-2 bg-signal shadow-[0_0_12px_var(--color-signal)]" />
@@ -84,10 +84,6 @@ export default async function HomePage({
</aside> </aside>
</section> </section>
<footer className="flex flex-col gap-3 border-t border-line pt-5 font-mono text-[10px] uppercase tracking-[0.18em] text-muted sm:flex-row sm:items-center sm:justify-between">
<span>Java Edition only</span>
<span>Unknown players are denied by default</span>
</footer>
</div> </div>
</main> </main>
); );
@@ -0,0 +1,20 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { NicknameNotice } from "./nickname-notice";
describe("NicknameNotice", () => {
it("announces a synchronized nickname and provides a dismissal action", () => {
const markup = renderToStaticMarkup(<NicknameNotice nickname="Dani · Steve" />);
expect(markup).toContain('role="status"');
expect(markup).toContain("We updated your Discord nickname to");
expect(markup).toContain("Dani · Steve");
expect(markup).toContain("Awesome, thanks!");
});
it("announces synchronization errors assertively", () => {
const markup = renderToStaticMarkup(<NicknameNotice error="Discord rejected the update." />);
expect(markup).toContain('role="alert"');
expect(markup).toContain("Discord rejected the update.");
});
});
@@ -0,0 +1,20 @@
export function NicknameNotice({ nickname, error }: { nickname?: string; error?: string }) {
if (!nickname && !error) return null;
return (
<section
aria-live={error ? "assertive" : "polite"}
className={`mt-8 border-l-2 bg-panel px-5 py-4 ${error ? "border-accent" : "border-signal"}`}
role={error ? "alert" : "status"}
>
<p className="text-sm leading-6">
{error ? error : <>We updated your Discord nickname to <strong>{nickname}</strong>.</>}
</p>
<form action="/account" className="mt-3" method="get">
<button className="font-mono text-[10px] font-bold uppercase underline underline-offset-4" type="submit">
Awesome, thanks!
</button>
</form>
</section>
);
}
@@ -0,0 +1,13 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { SiteFooter } from "./site-footer";
describe("SiteFooter", () => {
it("credits DMG Games with the requested sponsor link", () => {
const markup = renderToStaticMarkup(<SiteFooter />);
expect(markup).toContain("Social Minecraft is sponsored by");
expect(markup).toContain('href="https://dmg.games"');
expect(markup).toContain("DMG Games.");
});
});
+15
View File
@@ -0,0 +1,15 @@
export function SiteFooter() {
return (
<footer className="border-t border-line bg-panel px-6 py-6 text-center font-mono text-[10px] uppercase tracking-[0.16em] text-muted">
Social Minecraft is sponsored by{" "}
<a
className="font-bold text-ink underline decoration-accent underline-offset-4 transition-colors hover:text-accent"
href="https://dmg.games"
rel="noreferrer"
target="_blank"
>
DMG Games.
</a>
</footer>
);
}
@@ -0,0 +1,39 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { UserWorldMap } from "./user-world-map";
describe("UserWorldMap", () => {
it("renders an accessible linked marker, text fallback, and open-data attribution", () => {
const markup = renderToStaticMarkup(<UserWorldMap locations={[{
userId: "11111111-1111-4111-8111-111111111111",
name: "Dani",
discordUsername: "dani",
latitude: 37.4056,
longitude: -122.0775,
location: "Mountain View, California, US",
classification: "clear",
source: "game",
observedAt: new Date("2026-08-01T12:00:00Z"),
}]} unavailableCount={2} />);
expect(markup).toContain('role="group"');
expect(markup).toContain('class="map-marker-target"');
expect(markup).toContain("Latest approximate location for registered users");
expect(markup).toContain('href="/admin/users/11111111-1111-4111-8111-111111111111"');
expect(markup).toContain("Mountain View, California, US");
expect(markup).toContain("Natural Earth, public domain");
expect(markup).toContain("2 without coordinates");
const countryPaths = [...markup.matchAll(/<path d="([^"]+)"/g)].map((match) => match[1] ?? "");
expect(countryPaths.length).toBeGreaterThan(100);
for (const path of countryPaths) {
const subpaths = path.split("M").slice(1);
for (const subpath of subpaths) {
const xCoordinates = [...subpath.matchAll(/(?:^|L)(-?\d+(?:\.\d+)?),/g)].map((match) => Number(match[1]));
for (let index = 1; index < xCoordinates.length; index += 1) {
expect(Math.abs(xCoordinates[index]! - xCoordinates[index - 1]!)).toBeLessThan(500);
}
}
}
});
});
@@ -0,0 +1,84 @@
import type { FeatureCollection } from "geojson";
import type { GeometryCollection, Topology } from "topojson-specification";
import { geoEquirectangular, geoPath } from "d3-geo";
import { feature } from "topojson-client";
import countriesTopologyJson from "world-atlas/countries-110m.json";
import Link from "next/link";
const WIDTH = 1_000;
const HEIGHT = 500;
const topology = countriesTopologyJson as unknown as Topology<{ countries: GeometryCollection }>;
const countries = feature(topology, topology.objects.countries) as FeatureCollection;
const projection = geoEquirectangular().fitExtent([[1, 1], [WIDTH - 1, HEIGHT - 1]], { type: "Sphere" });
const countryPath = geoPath(projection);
export interface UserMapLocation {
userId: string;
name: string;
discordUsername: string;
latitude: number;
longitude: number;
location: string;
classification: string;
source: string;
observedAt: Date;
}
export function UserWorldMap({ locations, unavailableCount }: { locations: UserMapLocation[]; unavailableCount: number }) {
return (
<section className="mt-8 border border-line bg-panel p-5 shadow-[8px_8px_0_var(--color-shadow)] sm:p-7">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Latest known location</p>
<h2 className="mt-2 font-display text-3xl font-black uppercase">Community world</h2>
</div>
<p className="max-w-sm text-xs leading-5 text-muted">{locations.length} mapped · {unavailableCount} without coordinates. Locations are approximate IP intelligence, not precise device positions.</p>
</div>
<div className="mt-6 overflow-hidden border border-line bg-[#b9d4d1]">
<svg aria-labelledby="user-world-map-title user-world-map-description" className="h-auto w-full" role="group" viewBox={`0 0 ${WIDTH} ${HEIGHT}`}>
<title id="user-world-map-title">Latest approximate location for registered users</title>
<desc id="user-world-map-description">An open-data world map with one linked marker for every user whose latest geolocated observation has valid coordinates. A complete text list follows.</desc>
<rect fill="#b9d4d1" height={HEIGHT} width={WIDTH} />
<g aria-hidden="true" fill="var(--canvas)" stroke="var(--line)" strokeWidth="0.7">
{countries.features.map((country, index) => {
const path = countryPath(country);
return path ? <path d={path} key={country.id ?? index} /> : null;
})}
</g>
<g>
{locations.map((user) => {
const projected = projection([user.longitude, user.latitude]);
if (!projected) return null;
const x = Math.min(WIDTH - 14, Math.max(14, projected[0]));
const y = Math.min(HEIGHT - 14, Math.max(14, projected[1]));
return (
<a aria-label={`${user.name}, ${user.location}, last seen ${user.observedAt.toISOString()}`} href={`/admin/users/${user.userId}`} key={user.userId}>
<circle className="map-marker-target" cx={x} cy={y} fill="none" pointerEvents="stroke" r="7" stroke="transparent" strokeWidth="24" vectorEffect="non-scaling-stroke" />
<circle className="map-marker" cx={x} cy={y} fill="var(--accent)" pointerEvents="none" r="7" stroke="var(--panel)" strokeWidth="3">
<title>{user.name} · {user.location} · {user.classification}</title>
</circle>
</a>
);
})}
</g>
</svg>
</div>
<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">
<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-[680px] border-collapse text-left text-xs">
<caption className="sr-only">Latest approximate registered-user locations</caption>
<thead className="border-b border-line font-mono text-[9px] uppercase tracking-wider text-muted"><tr><th className="py-3 pr-4" scope="col">User</th><th className="p-3" scope="col">Location</th><th className="p-3" scope="col">Network</th><th className="p-3" scope="col">Source</th><th className="py-3 pl-4" scope="col">Last observed</th></tr></thead>
<tbody className="divide-y divide-line">
{locations.map((user) => <tr key={user.userId}><th className="py-3 pr-4 text-left" scope="row"><Link className="font-mono font-bold underline underline-offset-4" href={`/admin/users/${user.userId}`}>{user.name}</Link><span className="mt-1 block font-mono text-[9px] font-normal text-muted">@{user.discordUsername}</span></th><td className="p-3">{user.location}</td><td className="p-3 font-mono uppercase">{user.classification}</td><td className="p-3">{user.source}</td><td className="py-3 pl-4 font-mono text-[9px]"><time dateTime={user.observedAt.toISOString()}>{user.observedAt.toISOString()}</time></td></tr>)}
{!locations.length && <tr><td className="py-6 text-muted" colSpan={5}>No user observations currently include valid coordinates.</td></tr>}
</tbody>
</table>
</div>
</details>
</section>
);
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { groupAccessAddresses } from "./access-address-groups";
describe("groupAccessAddresses", () => {
it("collapses repeated observations from the same network into one recent summary", () => {
const groups = groupAccessAddresses([
{ id: "old", ipAddress: "198.51.100.21", source: "web", classification: "clear", observedAt: new Date("2026-08-01T10:00:00Z"), intelligence: null },
{ id: "new", ipAddress: "198.51.100.240", source: "game", classification: "clear", observedAt: new Date("2026-08-01T12:00:00Z"), intelligence: { provider: "proxycheck" } },
{ id: "other", ipAddress: "203.0.113.9", source: "web", classification: "vpn", observedAt: new Date("2026-08-01T11:00:00Z"), intelligence: null },
]);
expect(groups).toHaveLength(2);
expect(groups[0]).toMatchObject({
network: "198.51.100.0/24",
latestAddress: "198.51.100.240",
sources: ["game", "web"],
count: 2,
classification: "clear",
intelligence: { provider: "proxycheck" },
});
expect(groups[0]?.latestObservedAt.toISOString()).toBe("2026-08-01T12:00:00.000Z");
});
});
+60
View File
@@ -0,0 +1,60 @@
import { addressGroup } from "@minecraft-account-manager/network";
type AccessObservation = {
id: string;
ipAddress: string;
source: string;
classification: string;
observedAt: Date;
intelligence: Record<string, unknown> | null;
};
export type AccessAddressGroup = {
network: string;
latestAddress: string;
sources: string[];
count: number;
firstObservedAt: Date;
latestObservedAt: Date;
classification: string;
intelligence: Record<string, unknown> | null;
};
export function groupAccessAddresses(observations: AccessObservation[]) {
const groups = new Map<string, AccessAddressGroup & { sourceSet: Set<string> }>();
for (const observation of observations) {
const network = addressGroup(observation.ipAddress);
const existing = groups.get(network);
if (!existing) {
groups.set(network, {
network,
latestAddress: observation.ipAddress,
sources: [],
sourceSet: new Set([observation.source]),
count: 1,
firstObservedAt: observation.observedAt,
latestObservedAt: observation.observedAt,
classification: observation.classification,
intelligence: observation.intelligence,
});
continue;
}
existing.count += 1;
existing.sourceSet.add(observation.source);
if (observation.observedAt < existing.firstObservedAt) {
existing.firstObservedAt = observation.observedAt;
}
if (observation.observedAt > existing.latestObservedAt) {
existing.latestAddress = observation.ipAddress;
existing.latestObservedAt = observation.observedAt;
existing.classification = observation.classification;
existing.intelligence = observation.intelligence;
}
}
return [...groups.values()]
.map(({ sourceSet, ...group }) => ({ ...group, sources: [...sourceSet].sort() }))
.sort((left, right) => right.latestObservedAt.getTime() - left.latestObservedAt.getTime());
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { fillDailySeries } from "./admin-metrics";
describe("admin dashboard metrics", () => {
it("fills missing UTC registration days with zero", () => {
expect(fillDailySeries(
[{ day: "2026-07-30", count: 2 }, { day: "2026-08-01", count: 1 }],
new Date("2026-08-01T22:00:00Z"),
3,
)).toEqual([
{ day: "2026-07-30", count: 2 },
{ day: "2026-07-31", count: 0 },
{ day: "2026-08-01", count: 1 },
]);
});
});
+15
View File
@@ -0,0 +1,15 @@
export interface DailyCount {
day: string;
count: number;
}
export function fillDailySeries(rows: DailyCount[], end: Date, days: number) {
const counts = new Map(rows.map((row) => [row.day, Number(row.count)]));
const endDay = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate()));
return Array.from({ length: days }, (_, index) => {
const date = new Date(endDay);
date.setUTCDate(endDay.getUTCDate() - (days - index - 1));
const day = date.toISOString().slice(0, 10);
return { day, count: counts.get(day) ?? 0 };
});
}
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { applicationUrl } from "./application-url";
describe("applicationUrl", () => {
it("builds browser redirects from the configured public application URL", () => {
expect(applicationUrl("/welcome", "https://portal.somc.club"))
.toEqual(new URL("https://portal.somc.club/welcome"));
});
});
+4
View File
@@ -0,0 +1,4 @@
export function applicationUrl(path: string, baseUrl = process.env.APP_URL) {
if (!baseUrl) throw new Error("APP_URL is required to build public application URLs");
return new URL(path, baseUrl);
}
+12 -3
View File
@@ -5,9 +5,9 @@ import { db } from "@/lib/database";
const UI_ACCESSED = "games.minecraft.account-manager.ui.accessed"; const UI_ACCESSED = "games.minecraft.account-manager.ui.accessed";
export async function recordAdminEvent( export async function recordAdminSubjectEvent(
admin: { email: string | null; name: string | null }, admin: { email: string | null; name: string | null },
targetUserId: string, subject: string,
type: string, type: string,
data: Record<string, unknown>, data: Record<string, unknown>,
) { ) {
@@ -16,12 +16,21 @@ export async function recordAdminEvent(
return recordEvent(db, { return recordEvent(db, {
type, type,
source: "/web/admin", source: "/web/admin",
subject: `user/${targetUserId}`, subject,
ipAddress: ipAddress ?? undefined, ipAddress: ipAddress ?? undefined,
data: { ...data, adminEmail: admin.email, adminName: admin.name }, data: { ...data, adminEmail: admin.email, adminName: admin.name },
}); });
} }
export async function recordAdminEvent(
admin: { email: string | null; name: string | null },
targetUserId: string,
type: string,
data: Record<string, unknown>,
) {
return recordAdminSubjectEvent(admin, `user/${targetUserId}`, type, data);
}
export async function recordUserEvent( export async function recordUserEvent(
user: { id: string }, user: { id: string },
type: string, type: string,
+33
View File
@@ -0,0 +1,33 @@
import { getGuildMemberIdentity } from "@minecraft-account-manager/minecraft";
import { logger } from "@/lib/logger";
export async function discordIdentity(input: {
discordUserId: string;
discordUsername: string;
discordGlobalName: string | null;
}) {
const guildId = process.env.DISCORD_GUILD_ID?.trim();
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
const fallback = {
id: input.discordUserId,
username: input.discordUsername,
globalName: input.discordGlobalName,
nickname: null as string | null,
live: false,
};
if (!guildId || !botToken) return fallback;
try {
return { ...await getGuildMemberIdentity({
guildId,
discordUserId: input.discordUserId,
botToken,
}), live: true };
} catch (error) {
logger.warn(
{ err: error, event: "discord.identity_lookup_failed", discordUserId: input.discordUserId },
"Discord guild identity lookup failed",
);
return fallback;
}
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { eventCategory, normalizeSelectedEventTypes } from "./event-filters";
describe("event filters", () => {
it("accepts only available event types and removes duplicates", () => {
expect(normalizeSelectedEventTypes(["login.allowed", "unknown", "login.allowed"], ["login.allowed", "group.updated"]))
.toEqual(["login.allowed"]);
});
it("classifies events into operator-friendly views", () => {
expect(eventCategory("games.minecraft.account-manager.group.deleted")).toBe("groups");
expect(eventCategory("games.minecraft.account-manager.game.login.denied")).toBe("admission");
expect(eventCategory("games.minecraft.account-manager.network.vpn-blocked")).toBe("security");
expect(eventCategory("games.minecraft.account-manager.auth.magic-link.consumed")).toBe("security");
expect(eventCategory("games.minecraft.account-manager.discord.nickname.updated")).toBe("identity");
});
});
+20
View File
@@ -0,0 +1,20 @@
export const eventCategoryValues = ["all", "admission", "security", "identity", "groups", "operations"] as const;
export type EventCategory = (typeof eventCategoryValues)[number];
export function eventCategory(type: string): Exclude<EventCategory, "all"> {
if (type.includes(".group.")) return "groups";
if (type.includes(".network.") || type.includes(".auth.") || type.includes("authentication") || type.includes("replay")) return "security";
if (type.includes(".game.login.")) return "admission";
if (type.includes(".discord.") || type.includes(".user.") || type.includes("minecraft-account")) return "identity";
return "operations";
}
export function normalizeEventCategory(value: string | undefined): EventCategory {
return eventCategoryValues.includes(value as EventCategory) ? value as EventCategory : "all";
}
export function normalizeSelectedEventTypes(value: string | string[] | undefined, availableTypes: string[]) {
const requested = Array.isArray(value) ? value : value ? [value] : [];
const available = new Set(availableTypes);
return [...new Set(requested.filter((type) => available.has(type)))].slice(0, 20);
}
+30 -4
View File
@@ -12,6 +12,7 @@ import { ipIntelligence } from "@minecraft-account-manager/database";
import { and, eq, gt } from "drizzle-orm"; import { and, eq, gt } from "drizzle-orm";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { db } from "@/lib/database"; import { db } from "@/lib/database";
import { logger } from "@/lib/logger";
const classifications = new Set<IpClassification>([ const classifications = new Set<IpClassification>([
"unknown", "unknown",
@@ -77,6 +78,13 @@ export async function getIpIntelligence(
options: { now?: Date; forceRefresh?: boolean } = {}, options: { now?: Date; forceRefresh?: boolean } = {},
): Promise<IpIntelligenceResult> { ): Promise<IpIntelligenceResult> {
if (!isPublicIp(ipAddress)) { if (!isPublicIp(ipAddress)) {
logger.warn(
{
event: "ip_intelligence.skipped",
reason: "non_public_address",
},
"IP intelligence lookup skipped for a non-public client address",
);
return { classification: "unknown", provider: null }; return { classification: "unknown", provider: null };
} }
@@ -96,14 +104,23 @@ export async function getIpIntelligence(
const result = await provider.classify(ipAddress); const result = await provider.classify(ipAddress);
await cacheResult(ipAddress, result, now, cacheHours() * 60 * 60_000); await cacheResult(ipAddress, result, now, cacheHours() * 60 * 60_000);
return result; return result;
} catch { } catch (error) {
const providerName = process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null;
const result: IpIntelligenceResult = { const result: IpIntelligenceResult = {
classification: "unknown", classification: "unknown",
provider: process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null, provider: providerName,
lookupError: true, lookupError: true,
}; };
await cacheResult(ipAddress, result, now, 5 * 60_000).catch(() => undefined); await cacheResult(ipAddress, result, now, 5 * 60_000).catch((cacheError) => {
console.error("IP intelligence lookup failed"); logger.error(
{ err: cacheError, event: "ip_intelligence.cache_failed", provider: providerName },
"Failed to cache an IP intelligence lookup error",
);
});
logger.error(
{ err: error, event: "ip_intelligence.lookup_failed", provider: providerName },
"IP intelligence lookup failed",
);
return result; return result;
} }
} }
@@ -134,6 +151,15 @@ export async function checkAccountAdditionNetwork() {
const requestHeaders = await headers(); const requestHeaders = await headers();
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true"); const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
if (!ipAddress) { if (!ipAddress) {
logger.warn(
{
event: "client_ip.unavailable",
trustProxy: process.env.TRUST_PROXY === "true",
forwardedForPresent: requestHeaders.has("x-forwarded-for"),
realIpPresent: requestHeaders.has("x-real-ip"),
},
"Client IP address was unavailable for account addition",
);
return { return {
allowed: false as const, allowed: false as const,
reason: "unavailable" as const, reason: "unavailable" as const,
+3
View File
@@ -0,0 +1,3 @@
import { createLogger } from "@minecraft-account-manager/logging";
export const logger = createLogger("minecraft-account-manager-web");
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { parseUserLocation, projectWorldPoint } from "./user-location-map";
describe("user location map", () => {
it("extracts a valid approximate location from cached IP intelligence", () => {
expect(parseUserLocation({
classification: "clear",
location: {
city: "Mountain View",
region: "California",
countryCode: "US",
latitude: 37.4056,
longitude: -122.0775,
},
})).toEqual({
latitude: 37.4056,
longitude: -122.0775,
label: "Mountain View, California, US",
});
});
it("rejects missing and out-of-range coordinates", () => {
expect(parseUserLocation({ location: { latitude: 91, longitude: 0 } })).toBeNull();
expect(parseUserLocation({ location: { city: "Unknown" } })).toBeNull();
});
it("projects longitude and latitude into an equirectangular SVG", () => {
expect(projectWorldPoint(0, 0, 800, 400)).toEqual({ x: 400, y: 200 });
expect(projectWorldPoint(90, 180, 800, 400)).toEqual({ x: 800, y: 0 });
});
});
+41
View File
@@ -0,0 +1,41 @@
type UnknownMap = Record<string, unknown>;
function objectValue(value: unknown): UnknownMap | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as UnknownMap
: null;
}
function coordinate(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
return null;
}
export interface ParsedUserLocation {
latitude: number;
longitude: number;
label: string;
}
export function parseUserLocation(value: unknown): ParsedUserLocation | null {
const intelligence = objectValue(value);
const location = objectValue(intelligence?.location);
if (!location) return null;
const latitude = coordinate(location.latitude);
const longitude = coordinate(location.longitude);
if (latitude === null || longitude === null || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return null;
}
const label = [location.city, location.region, location.countryCode ?? location.country]
.filter((part): part is string => typeof part === "string" && part.trim().length > 0)
.join(", ");
return { latitude, longitude, label: label || "Approximate location unavailable" };
}
export function projectWorldPoint(latitude: number, longitude: number, width: number, height: number) {
return {
x: ((longitude + 180) / 360) * width,
y: ((90 - latitude) / 180) * height,
};
}
+2
View File
@@ -30,6 +30,8 @@ This OKF bundle is the product record for implemented and proposed behavior. Sto
* [US-014 — Receive standardized API errors](us-014-problem-details.md) - Application APIs return RFC 9457 Problem Details. * [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-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-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 registrations, monthly activity, denials, and risky networks.
# Tracking # Tracking
+11
View File
@@ -2,6 +2,17 @@
## 2026-08-01 ## 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. * **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. * **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. * **Refine**: Corrected the Velocity Java and Gradle namespace to the repository owner's `games.dmg` reverse domain.
+5 -1
View File
@@ -3,7 +3,7 @@ type: User Story
title: Enter the account portal through Discord title: Enter the account portal through Discord
description: Direct visitors are guided to the configured Discord community and its account commands. description: Direct visitors are guided to the configured Discord community and its account commands.
tags: [player, portal, discord, onboarding] tags: [player, portal, discord, onboarding]
timestamp: 2026-08-01T18:43:58Z timestamp: 2026-08-01T22:34:31Z
story_id: US-001 story_id: US-001
status: verified status: verified
--- ---
@@ -18,11 +18,15 @@ As a prospective player, I want the portal to direct me to the community 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 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 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] 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 # Implementation
- [`apps/web/src/app/page.tsx`](../apps/web/src/app/page.tsx) - [`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/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` - Configuration: `DISCORD_GUILD_ID`, `DISCORD_INVITE_URL`
# Validation # Validation
+4 -1
View File
@@ -3,7 +3,7 @@ type: User Story
title: Authenticate with a Discord magic link title: Authenticate with a Discord magic link
description: Discord users receive private single-use links that establish secure portal sessions. description: Discord users receive private single-use links that establish secure portal sessions.
tags: [player, discord, authentication, security] tags: [player, discord, authentication, security]
timestamp: 2026-08-01T18:43:58Z timestamp: 2026-08-01T20:43:46Z
story_id: US-002 story_id: US-002
status: verified status: verified
--- ---
@@ -19,6 +19,7 @@ As a Discord community member, I want `/register` and `/account` to issue a priv
- [x] Given a login token, then it expires after ten minutes and can be consumed only once. - [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 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 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. - [x] Given an invalid, expired, or consumed link, then the user sees a safe recovery page instructing them to request another link.
# Implementation # Implementation
@@ -27,10 +28,12 @@ As a Discord community member, I want `/register` and `/account` to issue a priv
- [`packages/auth/src/index.ts`](../packages/auth/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) - [`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/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 # Validation
- [`packages/auth/test/magic-link.test.ts`](../packages/auth/test/magic-link.test.ts) - [`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. - Discord command and authentication workspaces pass TypeScript validation.
# Related Stories # Related Stories
+5 -3
View File
@@ -3,7 +3,7 @@ type: User Story
title: Manage linked accounts from the dashboard title: Manage linked accounts from the dashboard
description: Authenticated users maintain their profile and active Java Edition accounts. description: Authenticated users maintain their profile and active Java Edition accounts.
tags: [player, dashboard, minecraft, profile] tags: [player, dashboard, minecraft, profile]
timestamp: 2026-08-01T18:43:58Z timestamp: 2026-08-01T23:10:59Z
story_id: US-005 story_id: US-005
status: verified status: verified
--- ---
@@ -20,8 +20,10 @@ As a registered player, I want to manage my profile and linked Minecraft account
- [x] The user can soft-remove an active account. - [x] The user can soft-remove an active account.
- [x] The user can choose exactly one active primary account. - [x] The user can choose exactly one active primary account.
- [x] Removing a primary account promotes another active account when one exists. - [x] Removing a primary account promotes another active account when one exists.
- [x] Name and primary changes show the expected Discord nickname and require confirmation. - [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 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. - [x] The user can revoke the current session by signing out.
# Implementation # Implementation
@@ -32,7 +34,7 @@ As a registered player, I want to manage my profile and linked Minecraft account
# Validation # Validation
Server actions verify the current session and constrain every account lookup by the authenticated user ID. 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 # Related Stories
+6 -4
View File
@@ -3,7 +3,7 @@ type: User Story
title: Keep Discord nicknames synchronized title: Keep Discord nicknames synchronized
description: Preferred names and primary Minecraft usernames determine community guild nicknames. description: Preferred names and primary Minecraft usernames determine community guild nicknames.
tags: [player, admin, discord, identity] tags: [player, admin, discord, identity]
timestamp: 2026-08-01T18:43:58Z timestamp: 2026-08-01T23:10:59Z
story_id: US-006 story_id: US-006
status: verified status: verified
--- ---
@@ -16,10 +16,11 @@ As a community member, I want my Discord nickname to reflect my preferred name a
- [x] Given a preferred name and primary account, then the nickname format is `First name (MinecraftUsername)`. - [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 Discord's 32-character limit, then the preferred-name portion is shortened while preserving the Minecraft username.
- [x] Given no remaining Minecraft account, then administrative synchronization falls back to the preferred name. - [x] Given no remaining Minecraft account, then synchronization uses `First name (TBD)`.
- [x] User name and primary changes display the proposed nickname before confirmation. - [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] Administrator name, primary, and primary-removal operations synchronize the nickname automatically.
- [x] Discord failures are reported without falsely claiming the requested profile change completed.
- [x] A protected administrative retry action can synchronize the current desired nickname. - [x] A protected administrative retry action can synchronize the current desired nickname.
# Implementation # Implementation
@@ -27,6 +28,7 @@ As a community member, I want my Discord nickname to reflect my preferred name a
- [`packages/minecraft/src/index.ts`](../packages/minecraft/src/index.ts) - [`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/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/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 # Validation
+4 -1
View File
@@ -3,7 +3,7 @@ type: User Story
title: Enrich portal and game login IPs title: Enrich portal and game login IPs
description: Login audit events include cached approximate location and network intelligence from ProxyCheck.io. description: Login audit events include cached approximate location and network intelligence from ProxyCheck.io.
tags: [security, network, audit, proxycheck] tags: [security, network, audit, proxycheck]
timestamp: 2026-08-01T18:43:58Z timestamp: 2026-08-01T22:04:17Z
story_id: US-007 story_id: US-007
status: verified status: verified
--- ---
@@ -22,6 +22,7 @@ As an operator, I want portal and registered game logins enriched with network c
- [x] Unknown game accounts do not trigger paid ProxyCheck lookups. - [x] Unknown game accounts do not trigger paid ProxyCheck lookups.
- [x] Login events and IP observations retain the available classification and approximate location. - [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] 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 # Implementation
@@ -34,6 +35,8 @@ As an operator, I want portal and registered game logins enriched with network c
- [`packages/network/test/proxycheck.test.ts`](../packages/network/test/proxycheck.test.ts) - [`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/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 # Related Stories
+4 -2
View File
@@ -3,7 +3,7 @@ type: User Story
title: Enforce registration at the Velocity proxy title: Enforce registration at the Velocity proxy
description: Online-mode Java connections are admitted only after a fail-closed account-manager decision. description: Online-mode Java connections are admitted only after a fail-closed account-manager decision.
tags: [minecraft, velocity, whitelist, security] tags: [minecraft, velocity, whitelist, security]
timestamp: 2026-08-01T18:52:20Z timestamp: 2026-08-01T23:10:59Z
story_id: US-009 story_id: US-009
status: verified status: verified
--- ---
@@ -22,7 +22,8 @@ As a registered player, I want the Velocity proxy to recognize my approved Java
- [x] Username fallback applies only when the stored account has no UUID. - [x] Username fallback applies only when the stored account has no UUID.
- [x] Successful fallback backfills UUID and canonical username. - [x] Successful fallback backfills UUID and canonical username.
- [x] Changed usernames are persisted and audited. - [x] Changed usernames are persisted and audited.
- [x] Unknown players, API failures, malformed responses, and unauthorized requests fail closed with registration guidance. - [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] The plugin records the real Velocity connection IP and supports Java Edition online mode only.
# Implementation # Implementation
@@ -41,3 +42,4 @@ As a registered player, I want the Velocity proxy to recognize my approved Java
- [Validate Minecraft accounts](us-004-minecraft-validation.md) - [Validate Minecraft accounts](us-004-minecraft-validation.md)
- [Standardize API errors](us-014-problem-details.md) - [Standardize API errors](us-014-problem-details.md)
- [Control Minecraft admission with groups](us-017-group-access.md)
+4 -2
View File
@@ -3,7 +3,7 @@ type: User Story
title: Preserve a CloudEvents-style audit trail title: Preserve a CloudEvents-style audit trail
description: Authentication, UI, account, Discord, network, and game actions create searchable immutable-style events. description: Authentication, UI, account, Discord, network, and game actions create searchable immutable-style events.
tags: [audit, cloudevents, security, events] tags: [audit, cloudevents, security, events]
timestamp: 2026-08-01T18:43:58Z timestamp: 2026-08-01T23:10:59Z
story_id: US-010 story_id: US-010
status: verified status: verified
--- ---
@@ -19,7 +19,8 @@ As an operator, I want security and identity activity recorded consistently, so
- [x] Portal access, magic-link creation and consumption, account changes, nickname changes, VPN blocks, and game decisions are recorded. - [x] Portal access, magic-link creation and consumption, account changes, nickname changes, VPN blocks, and game decisions are recorded.
- [x] Username changes learned from Velocity create their own event. - [x] Username changes learned from Velocity create their own event.
- [x] Administrative actions include the acting SSO identity in event data. - [x] Administrative actions include the acting SSO identity in event data.
- [x] Events can be inspected globally and from an individual admin user view. - [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. - [x] `published_at` reserves an outbox path for future Kafka publishing.
# Implementation # Implementation
@@ -28,6 +29,7 @@ As an operator, I want security and identity activity recorded consistently, so
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts) - [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
- [`apps/web/src/lib/audit.ts`](../apps/web/src/lib/audit.ts) - [`apps/web/src/lib/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/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 # Validation
+2 -2
View File
@@ -3,7 +3,7 @@ type: User Story
title: Operate settings and audit views title: Operate settings and audit views
description: Authorized administrators control server messaging and investigate recent platform events. description: Authorized administrators control server messaging and investigate recent platform events.
tags: [admin, settings, audit, operations] tags: [admin, settings, audit, operations]
timestamp: 2026-08-01T18:43:58Z timestamp: 2026-08-01T22:34:31Z
story_id: US-012 story_id: US-012
status: verified status: verified
--- ---
@@ -14,7 +14,7 @@ As an administrator, I want operational settings and audit visibility, so that I
# Acceptance Criteria # Acceptance Criteria
- [x] The admin console reports whether deployment-managed Discord guild and invite settings are configured. - [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] An authorized administrator can update the denied-player registration message.
- [x] Settings actions validate message length server-side. - [x] Settings actions validate message length server-side.
- [x] Administrators can browse the latest 100 events. - [x] Administrators can browse the latest 100 events.
+3 -2
View File
@@ -3,7 +3,7 @@ type: User Story
title: Manage users as an administrator title: Manage users as an administrator
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames. description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
tags: [admin, users, minecraft, discord] tags: [admin, users, minecraft, discord]
timestamp: 2026-08-01T18:43:58Z timestamp: 2026-08-01T22:34:31Z
story_id: US-013 story_id: US-013
status: verified status: verified
--- ---
@@ -16,7 +16,7 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
- [x] Administrators can search by preferred name, Discord username or ID, Minecraft username, or UUID. - [x] 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] Search results show onboarding state, primary username, and active account count.
- [x] A user detail view shows Discord identity, active accounts, recent events, and recent IP observations. - [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 update the preferred name and synchronize Discord.
- [x] Administrators can add Mojang-verified accounts or explicitly override an unverified username. - [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] Administrators can remove an account only after a visible confirmation step.
@@ -39,3 +39,4 @@ Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test
- [Administrator SSO](us-011-admin-sso.md) - [Administrator SSO](us-011-admin-sso.md)
- [Synchronize Discord nicknames](us-006-discord-nickname.md) - [Synchronize Discord nicknames](us-006-discord-nickname.md)
- [Control Minecraft admission with groups](us-017-group-access.md)
+8 -2
View File
@@ -3,7 +3,7 @@ type: User Story
title: Deploy and operate the platform securely title: Deploy and operate the platform securely
description: Operators have repeatable builds, migrations, credential provisioning, configuration, and security checks. description: Operators have repeatable builds, migrations, credential provisioning, configuration, and security checks.
tags: [operations, security, database, deployment] tags: [operations, security, database, deployment]
timestamp: 2026-08-01T19:01:47Z timestamp: 2026-08-01T23:10:59Z
story_id: US-015 story_id: US-015
status: verified status: verified
--- ---
@@ -21,6 +21,10 @@ As a platform operator, I want reproducible deployment and security controls, so
- [x] The Velocity Gradle wrapper produces a tested shaded JAR. - [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] 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] 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] 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. - [x] Architecture, Keycloak, API error, security, bot, and Velocity operating documentation is available.
@@ -32,10 +36,12 @@ As a platform operator, I want reproducible deployment and security controls, so
- [`packages/database/scripts/create-plugin-credential.ts`](../packages/database/scripts/create-plugin-credential.ts) - [`packages/database/scripts/create-plugin-credential.ts`](../packages/database/scripts/create-plugin-credential.ts)
- [`plugins/velocity/build.gradle.kts`](../plugins/velocity/build.gradle.kts) - [`plugins/velocity/build.gradle.kts`](../plugins/velocity/build.gradle.kts)
- [`apps/web/next.config.ts`](../apps/web/next.config.ts) - [`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 # Validation
Use `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, `npm run velocity:build`, `npm audit`, and `npm run design:validate`. 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 # Related Stories
+12 -10
View File
@@ -3,9 +3,9 @@ type: User Story
title: Build and publish versioned releases title: Build and publish versioned releases
description: Gitea Actions validate every change and publish semantically versioned Velocity and container artifacts. description: Gitea Actions validate every change and publish semantically versioned Velocity and container artifacts.
tags: [operations, ci, release, velocity, docker] tags: [operations, ci, release, velocity, docker]
timestamp: 2026-08-01T19:01:47Z timestamp: 2026-08-01T20:05:49Z
story_id: US-016 story_id: US-016
status: implemented status: verified
--- ---
# User Story # User Story
@@ -14,14 +14,16 @@ As a platform operator, I want automated validation and semantic releases, so th
# Acceptance Criteria # Acceptance Criteria
- [ ] Pushes and pull requests run OKF validation, linting, type checks, tests, the web build, and the Velocity build. - [x] Pushes and pull requests run OKF validation, linting, type checks, tests, the web build, and the Velocity build.
- [ ] Pull requests validate conventional commit messages. - [x] Pull requests validate conventional commit messages.
- [ ] CI uploads the development Velocity JAR as a workflow artifact. - [x] CI uploads the development Velocity JAR as a workflow artifact.
- [ ] Main-branch conventional commits determine the next semantic version and create a `vMAJOR.MINOR.PATCH` tag. - [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 release build embeds the semantic version in the Velocity plugin and JAR filename.
- [ ] A public Gitea release exposes the versioned Velocity JAR as a downloadable asset. - [x] A public Gitea release exposes the versioned Velocity JAR as a downloadable asset.
- [ ] Releases publish versioned and `latest` web runtime images to the Gitea registry. - [x] Releases publish semantically versioned web runtime images to the Gitea registry.
- [ ] Releases publish versioned and `latest` migration images that run versioned Drizzle migrations. - [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] 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. - [x] Operators are told which repository secrets must be configured before the first push.
@@ -36,7 +38,7 @@ As a platform operator, I want automated validation and semantic releases, so th
# Validation # Validation
Local OKF, lint, typecheck, test, Next.js build, and versioned Velocity JAR checks pass. A test `1.2.3` JAR was generated with matching Velocity metadata. Workflow YAML parses successfully. Container builds and remote publication remain pending because the local Docker daemon is unavailable and the first push is intentionally paused until repository secrets are configured. 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 # Related Stories
+46
View File
@@ -0,0 +1,46 @@
---
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-01T23:10: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.
# 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)
- [`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)
+48
View File
@@ -0,0 +1,48 @@
---
type: User Story
title: Monitor community account activity
description: Administrators use a server-rendered dashboard to review registrations, monthly activity, denials, and risky networks.
tags: [admin, dashboard, metrics, security, ssr]
timestamp: 2026-08-01T23:32:54Z
story_id: US-018
status: verified
---
# User Story
As an administrator, I want an operational dashboard of account and game activity, so that I can understand community growth and quickly investigate access risks.
# Acceptance Criteria
- [x] The administrator landing page is a dashboard rather than a settings form.
- [x] An open-data world map plots each user's latest observation with valid approximate coordinates.
- [x] Map markers link to user records and have an accessible text-table equivalent.
- [x] Natural Earth boundaries are bundled and server-rendered without disclosing map or location requests to a third party.
- [x] The dashboard graphs new registered users by UTC day for the previous 14 days.
- [x] Monthly active users count distinct users observed through portal or game activity in the previous 30 days.
- [x] Monthly active Minecraft accounts count distinct linked accounts observed in the previous 30 days.
- [x] The dashboard shows login denials from the previous 24 hours.
- [x] Recent VPN, proxy, and Tor observations link to affected user records.
- [x] The graph includes an accessible title, description, point labels, and textual values.
- [x] Dashboard queries and rendering execute server-side without client-side data fetching.
- [x] Deployment-managed guild settings and denial messaging remain available on a dedicated settings page.
# Implementation
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
- [`apps/web/src/app/admin/(console)/settings/page.tsx`](../apps/web/src/app/admin/%28console%29/settings/page.tsx)
- [`apps/web/src/lib/admin-metrics.ts`](../apps/web/src/lib/admin-metrics.ts)
- [`apps/web/src/components/user-world-map.tsx`](../apps/web/src/components/user-world-map.tsx)
- [`apps/web/src/lib/user-location-map.ts`](../apps/web/src/lib/user-location-map.ts)
# Validation
- Missing-day chart behavior is covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts).
- Coordinate parsing, projection, linked markers, text fallback, and attribution are covered by the user-world-map tests.
- The Next.js production build reports the dashboard and database-backed console pages as dynamic server-rendered routes.
# Related Stories
- [Preserve a CloudEvents-style audit trail](us-010-audit-events.md)
- [Deploy and operate the platform securely](us-015-platform-operations.md)
- [Block anonymized account additions](us-008-vpn-blocking.md)
+35
View File
@@ -0,0 +1,35 @@
# Accessibility review
Review date: 2026-08-01
## Scope
Player account management, administrator navigation, dashboard metrics and chart, user records, group management, event filtering, event details, forms, tables, and status notifications.
## Implemented checks and improvements
- Added a keyboard-visible “Skip to main content” link and consistent high-visibility `:focus-visible` outlines.
- Darkened the accent color so accent text reaches at least 4.5:1 contrast on both canvas and panel backgrounds.
- Preserved reduced-motion behavior and disabled decorative cursor animation when requested.
- Added labels or accessible names to search, Minecraft username, settings, group, and event-filter controls.
- Added `fieldset` and `legend` semantics to multi-select event-type filters.
- Added table captions, column scopes, and row scopes to administrator data tables.
- Added `role=status` with polite announcements for successful nickname changes and `role=alert` with assertive announcements for errors.
- Added semantic `time` elements for audit and security activity timestamps.
- Made event JSON keyboard-focusable so horizontally overflowing content can be reviewed without a pointer.
- Added an accessible title, description, per-point labels, and textual values to the registration chart.
- Added labelled, keyboard-linked world-map markers plus a complete semantic table equivalent for approximate user locations.
- Added explicit new-tab context to the external Discord invite link.
- Kept destructive account and group actions behind native keyboard-operable `details` confirmation disclosures.
- Allowed administrator navigation to wrap at narrow viewport widths instead of overflowing.
## Validation
- ESLint with the Next.js ruleset passes.
- Component rendering tests verify nickname status and error announcement roles and dismissal text.
- The production build passes and reports database-backed player and administrator pages as dynamic server-rendered routes.
- Color contrast was calculated for the canvas, panel, muted text, accent text, and signal combinations used by the interface.
## Follow-up
Authenticated browser automation is still recommended in CI with axe-core and a test Keycloak realm. It should cover keyboard order, zoom to 200%, reflow at 320 CSS pixels, and screen-reader announcements against a running production build.
+1 -1
View File
@@ -4,7 +4,7 @@
### Web application ### Web application
The Next.js application owns user onboarding, account management, admin configuration, server-side Minecraft profile validation, sessions, and the HTTP API used by Discord and Velocity integrations. The Next.js application owns user onboarding, account management, admin configuration and metrics, server-side Minecraft profile validation, sessions, and the HTTP API used by Discord and Velocity integrations. Database-backed portal and console pages are dynamic React Server Components: authentication, queries, filtering, dashboard aggregation, and the Natural Earth user-location map execute on the server and return rendered HTML. Map boundaries are bundled open data, so rendering does not disclose administrator or user location requests to a map provider.
User authentication begins with an opaque, short-lived, single-use token created for a Discord user. Only a cryptographic hash of the token is persisted. Admin authentication is a separate Keycloak OIDC flow and requires the `minecraft-account-manager-admin` role. User authentication begins with an opaque, short-lived, single-use token created for a Discord user. Only a cryptographic hash of the token is persisted. Admin authentication is a separate Keycloak OIDC flow and requires the `minecraft-account-manager-admin` role.
+6 -3
View File
@@ -32,11 +32,14 @@ Each release creates:
- Gitea release asset `minecraft-account-manager-velocity-VERSION.jar` - Gitea release asset `minecraft-account-manager-velocity-VERSION.jar`
- `git.garvis.dev/dmg/minecraft-account-manager:VERSION` - `git.garvis.dev/dmg/minecraft-account-manager:VERSION`
- `git.garvis.dev/dmg/minecraft-account-manager:latest` - `git.garvis.dev/dmg/minecraft-account-manager-bot:VERSION`
- `git.garvis.dev/dmg/minecraft-account-manager-migrate:VERSION` - `git.garvis.dev/dmg/minecraft-account-manager-migrate:VERSION`
- `git.garvis.dev/dmg/minecraft-account-manager-migrate:latest`
Use immutable version tags for deployments. `latest` is a convenience pointer to the newest release. Only immutable semantic-version tags are published. Mutable tags such as `latest` must never be used in deployments or release-asset URLs.
## Discord bot
Run exactly one bot replica with the same immutable release version as the web application. It requires `DATABASE_URL`, `APP_URL`, `DISCORD_BOT_TOKEN`, and `DISCORD_GUILD_ID`. Deploy slash commands separately with the release image when command definitions change.
## Database migration ## Database migration
+5 -1
View File
@@ -24,12 +24,16 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
- Velocity credentials are high-entropy bearer tokens stored only as hashes. - Velocity credentials are high-entropy bearer tokens stored only as hashes.
- Velocity requests have a 45-second clock window and database-unique request IDs for cross-instance replay prevention. - Velocity requests have a 45-second clock window and database-unique request IDs for cross-instance replay prevention.
- Velocity and its API fail closed. - Velocity and its API fail closed.
- Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback.
- Group and membership mutations re-check the Keycloak administrator role server-side; destructive group deletion and its audit event commit atomically.
- Event filters accept only event types already present in the ledger, and event detail routes remain role-protected.
- The administrator-only location map uses bundled Natural Earth boundaries and approximate cached IP intelligence; it sends no coordinates or map requests to third parties.
- ORM-parameterized queries are used throughout. - ORM-parameterized queries are used throughout.
- CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured. - CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured.
- Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured. - Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured.
- Private and reserved addresses are not sent to ProxyCheck.io; lookup results are cached to reduce disclosure and API usage. - Private and reserved addresses are not sent to ProxyCheck.io; lookup results are cached to reduce disclosure and API usage.
- Portal and game login events include approximate network location and VPN/proxy classification when available. - Portal and game login events include approximate network location and VPN/proxy classification when available.
- Secrets are excluded from logs and repository configuration. - Structured Pino logging redacts credential fields, and secrets are excluded from logs and repository configuration.
## Outstanding production requirements ## Outstanding production requirements
+258 -1
View File
@@ -22,6 +22,7 @@
"@minecraft-account-manager/auth": "*", "@minecraft-account-manager/auth": "*",
"@minecraft-account-manager/contracts": "*", "@minecraft-account-manager/contracts": "*",
"@minecraft-account-manager/database": "*", "@minecraft-account-manager/database": "*",
"@minecraft-account-manager/logging": "*",
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1" "drizzle-orm": "^0.45.1"
@@ -38,19 +39,25 @@
"@minecraft-account-manager/auth": "*", "@minecraft-account-manager/auth": "*",
"@minecraft-account-manager/contracts": "*", "@minecraft-account-manager/contracts": "*",
"@minecraft-account-manager/database": "*", "@minecraft-account-manager/database": "*",
"@minecraft-account-manager/logging": "*",
"@minecraft-account-manager/minecraft": "*", "@minecraft-account-manager/minecraft": "*",
"@minecraft-account-manager/network": "*", "@minecraft-account-manager/network": "*",
"d3-geo": "^3.1.1",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"next": "^16.2.1", "next": "^16.2.1",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3" "react-dom": "^19.2.3",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"@types/d3-geo": "^3.1.1",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-next": "^16.2.1", "eslint-config-next": "^16.2.1",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
@@ -1781,6 +1788,10 @@
"resolved": "apps/discord-bot", "resolved": "apps/discord-bot",
"link": true "link": true
}, },
"node_modules/@minecraft-account-manager/logging": {
"resolved": "packages/logging",
"link": true
},
"node_modules/@minecraft-account-manager/minecraft": { "node_modules/@minecraft-account-manager/minecraft": {
"resolved": "packages/minecraft", "resolved": "packages/minecraft",
"link": true "link": true
@@ -2038,6 +2049,12 @@
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
}, },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
@@ -2712,6 +2729,16 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/deep-eql": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -2726,6 +2753,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2769,6 +2803,27 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/topojson-client": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*",
"@types/topojson-specification": "*"
}
},
"node_modules/@types/topojson-specification": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -3810,6 +3865,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/available-typed-arrays": { "node_modules/available-typed-arrays": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -4063,6 +4127,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4108,6 +4178,30 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -5697,6 +5791,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
@@ -7052,6 +7155,15 @@
"node": "^10.13.0 || >=12.0.0" "node": "^10.13.0 || >=12.0.0"
} }
}, },
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/openid-client": { "node_modules/openid-client": {
"version": "5.7.1", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
@@ -7220,6 +7332,43 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -7317,6 +7466,22 @@
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/process-warning": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -7360,6 +7525,12 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.8", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -7388,6 +7559,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -7600,6 +7780,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -7834,6 +8023,15 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -7864,6 +8062,15 @@
"source-map": "^0.6.0" "source-map": "^0.6.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stable-hash": { "node_modules/stable-hash": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -8106,6 +8313,24 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
"license": "MIT",
"dependencies": {
"real-require": "^1.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/thread-stream/node_modules/real-require": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -8194,6 +8419,20 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
"license": "ISC",
"dependencies": {
"commander": "2"
},
"bin": {
"topo2geo": "bin/topo2geo",
"topomerge": "bin/topomerge",
"topoquantize": "bin/topoquantize"
}
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -9118,6 +9357,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/world-atlas": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz",
"integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==",
"license": "ISC"
},
"node_modules/ws": { "node_modules/ws": {
"version": "8.21.1", "version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
@@ -9215,6 +9460,18 @@
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
}, },
"packages/logging": {
"name": "@minecraft-account-manager/logging",
"version": "0.1.0",
"dependencies": {
"pino": "^10.3.1"
},
"devDependencies": {
"@types/node": "^25.0.3",
"typescript": "^5.9.3",
"vitest": "^4.1.0"
}
},
"packages/minecraft": { "packages/minecraft": {
"name": "@minecraft-account-manager/minecraft", "name": "@minecraft-account-manager/minecraft",
"version": "0.1.0", "version": "0.1.0",
+4
View File
@@ -111,6 +111,10 @@ export function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex"); return createHash("sha256").update(token).digest("hex");
} }
export function resolveEffectiveGroup<T>(explicitGroup: T | null, defaultGroup: T | null) {
return explicitGroup ?? defaultGroup;
}
export function verifyHashedToken(providedToken: string, expectedHash: string) { export function verifyHashedToken(providedToken: string, expectedHash: string) {
const provided = Buffer.from(hashToken(providedToken), "utf8"); const provided = Buffer.from(hashToken(providedToken), "utf8");
const expected = Buffer.from(expectedHash, "utf8"); const expected = Buffer.from(expectedHash, "utf8");
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { resolveEffectiveGroup } from "../src/index";
describe("group-based admission", () => {
const everyone = { name: "everyone", accessEnabled: false };
it("uses the default group only when a user has no explicit assignment", () => {
expect(resolveEffectiveGroup(null, everyone)).toEqual(everyone);
expect(resolveEffectiveGroup({ name: "limited", accessEnabled: true }, everyone)).toEqual({
name: "limited",
accessEnabled: true,
});
});
it("does not combine default and explicitly assigned group access", () => {
const enabledDefault = { name: "everyone", accessEnabled: true };
const limited = { name: "limited", accessEnabled: false };
expect(resolveEffectiveGroup(limited, enabledDefault)?.accessEnabled).toBe(false);
});
});
@@ -0,0 +1,25 @@
CREATE TABLE "groups" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(50) NOT NULL,
"slug" varchar(50) NOT NULL,
"description" text,
"access_enabled" boolean DEFAULT false NOT NULL,
"is_default" boolean DEFAULT false NOT NULL,
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_group_memberships" (
"user_id" uuid NOT NULL,
"group_id" uuid NOT NULL,
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user_group_memberships" ADD CONSTRAINT "user_group_memberships_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_group_memberships" ADD CONSTRAINT "user_group_memberships_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "groups_slug_uidx" ON "groups" USING btree (lower("slug"));--> statement-breakpoint
CREATE UNIQUE INDEX "groups_one_default_uidx" ON "groups" USING btree ("is_default") WHERE "groups"."is_default" = true;--> statement-breakpoint
CREATE UNIQUE INDEX "user_group_memberships_user_group_uidx" ON "user_group_memberships" USING btree ("user_id","group_id");--> statement-breakpoint
CREATE INDEX "user_group_memberships_group_idx" ON "user_group_memberships" USING btree ("group_id");--> statement-breakpoint
INSERT INTO "groups" ("name", "slug", "description", "access_enabled", "is_default")
VALUES ('everyone', 'everyone', 'Default group containing every registered user.', false, true);
@@ -0,0 +1,11 @@
DROP INDEX "user_group_memberships_user_group_uidx";--> statement-breakpoint
DELETE FROM "user_group_memberships"
WHERE ctid IN (
SELECT ctid
FROM (
SELECT ctid, row_number() OVER (PARTITION BY "user_id" ORDER BY "created_at" DESC, "group_id") AS assignment_rank
FROM "user_group_memberships"
) ranked_assignments
WHERE assignment_rank > 1
);--> statement-breakpoint
CREATE UNIQUE INDEX "user_group_memberships_user_uidx" ON "user_group_memberships" USING btree ("user_id");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,20 @@
"when": 1785605590058, "when": 1785605590058,
"tag": "0001_silent_ultragirl", "tag": "0001_silent_ultragirl",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1785623198008,
"tag": "0002_simple_queen_noir",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1785625186545,
"tag": "0003_smiling_silver_samurai",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -113,6 +113,7 @@ export async function findUserBySessionToken(db: Database, tokenHash: string, no
id: users.id, id: users.id,
discordUserId: users.discordUserId, discordUserId: users.discordUserId,
discordUsername: users.discordUsername, discordUsername: users.discordUsername,
discordGlobalName: users.discordGlobalName,
firstName: users.firstName, firstName: users.firstName,
onboardingCompletedAt: users.onboardingCompletedAt, onboardingCompletedAt: users.onboardingCompletedAt,
sessionExpiresAt: sessions.expiresAt, sessionExpiresAt: sessions.expiresAt,
+34
View File
@@ -62,6 +62,40 @@ export const users = pgTable(
(table) => [uniqueIndex("users_discord_user_id_uidx").on(table.discordUserId)], (table) => [uniqueIndex("users_discord_user_id_uidx").on(table.discordUserId)],
); );
export const groups = pgTable(
"groups",
{
id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 50 }).notNull(),
slug: varchar("slug", { length: 50 }).notNull(),
description: text("description"),
accessEnabled: boolean("access_enabled").notNull().default(false),
isDefault: boolean("is_default").notNull().default(false),
...timestamps(),
},
(table) => [
uniqueIndex("groups_slug_uidx").on(sql`lower(${table.slug})`),
uniqueIndex("groups_one_default_uidx").on(table.isDefault).where(sql`${table.isDefault} = true`),
],
);
export const userGroupMemberships = pgTable(
"user_group_memberships",
{
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
groupId: uuid("group_id")
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
createdAt: createdAt(),
},
(table) => [
uniqueIndex("user_group_memberships_user_uidx").on(table.userId),
index("user_group_memberships_group_idx").on(table.groupId),
],
);
export const minecraftAccounts = pgTable( export const minecraftAccounts = pgTable(
"minecraft_accounts", "minecraft_accounts",
{ {
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@minecraft-account-manager/logging",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"pino": "^10.3.1"
},
"devDependencies": {
"@types/node": "^25.0.3",
"typescript": "^5.9.3",
"vitest": "^4.1.0"
}
}
+35
View File
@@ -0,0 +1,35 @@
import pino, { type DestinationStream, type Logger } from "pino";
const redactedPaths = [
"apiKey",
"authorization",
"password",
"token",
"*.apiKey",
"*.authorization",
"*.password",
"*.token",
"headers.authorization",
"req.headers.authorization",
];
export function createLogger(
service: string,
options: { destination?: DestinationStream } = {},
): Logger {
return pino(
{
level: process.env.LOG_LEVEL?.trim() || "info",
base: {
service,
environment: process.env.NODE_ENV ?? "development",
version: process.env.APP_VERSION ?? "development",
},
redact: {
paths: redactedPaths,
censor: "[Redacted]",
},
},
options.destination,
);
}
+31
View File
@@ -0,0 +1,31 @@
import { Writable } from "node:stream";
import { describe, expect, it } from "vitest";
import { createLogger } from "../src/index";
function captureLog() {
let output = "";
const destination = new Writable({
write(chunk, _encoding, callback) {
output += chunk.toString();
callback();
},
});
return { destination, read: () => JSON.parse(output.trim()) as Record<string, unknown> };
}
describe("structured application logging", () => {
it("emits service metadata and redacts credential fields", () => {
const capture = captureLog();
const logger = createLogger("account-manager-test", { destination: capture.destination });
logger.info({ token: "secret-token", apiKey: "secret-key", operation: "test" }, "Test event");
expect(capture.read()).toMatchObject({
service: "account-manager-test",
token: "[Redacted]",
apiKey: "[Redacted]",
operation: "test",
msg: "Test event",
});
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "types": ["node", "vitest/globals"] },
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+41 -1
View File
@@ -43,7 +43,47 @@ export function formatManagedDiscordNickname(
minecraftUsername: string | null, minecraftUsername: string | null,
) { ) {
if (minecraftUsername) return formatDiscordNickname(firstName, minecraftUsername); if (minecraftUsername) return formatDiscordNickname(firstName, minecraftUsername);
return [...firstName.trim()].slice(0, DISCORD_NICKNAME_LIMIT).join("").trimEnd(); return formatDiscordNickname(firstName, "TBD");
}
export interface DiscordGuildIdentity {
id: string;
username: string;
globalName: string | null;
nickname: string | null;
}
export async function getGuildMemberIdentity(
input: { guildId: string; discordUserId: string; botToken: string },
request: typeof fetch = fetch,
): Promise<DiscordGuildIdentity> {
const response = await request(
`https://discord.com/api/v10/guilds/${input.guildId}/members/${input.discordUserId}`,
{
headers: {
authorization: `Bot ${input.botToken}`,
accept: "application/json",
},
cache: "no-store",
},
);
if (!response.ok) throw new Error(`Discord guild member lookup failed (${response.status})`);
const payload: unknown = await response.json();
if (!payload || typeof payload !== "object") throw new Error("Discord guild member lookup returned invalid data");
const member = payload as { nick?: unknown; user?: unknown };
if (!member.user || typeof member.user !== "object") throw new Error("Discord guild member lookup omitted user data");
const user = member.user as { id?: unknown; username?: unknown; global_name?: unknown };
if (typeof user.id !== "string" || typeof user.username !== "string") {
throw new Error("Discord guild member lookup returned invalid user data");
}
return {
id: user.id,
username: user.username,
globalName: typeof user.global_name === "string" ? user.global_name : null,
nickname: typeof member.nick === "string" ? member.nick : null,
};
} }
export async function updateGuildNickname( export async function updateGuildNickname(
+21 -1
View File
@@ -1,5 +1,25 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { updateGuildNickname } from "../src/index"; import { getGuildMemberIdentity, updateGuildNickname } from "../src/index";
describe("Discord guild identity", () => {
it("reads the member username, global name, nickname, and immutable ID", async () => {
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
nick: "Sam (Notch)",
user: { id: "987654321098765432", username: "samcraft", global_name: "Sam" },
}), { status: 200, headers: { "content-type": "application/json" } }));
await expect(getGuildMemberIdentity({
guildId: "123456789012345678",
discordUserId: "987654321098765432",
botToken: "secret",
}, request)).resolves.toEqual({
id: "987654321098765432",
username: "samcraft",
globalName: "Sam",
nickname: "Sam (Notch)",
});
});
});
describe("Discord nickname updates", () => { describe("Discord nickname updates", () => {
it("updates a member in the configured guild using bot authentication", async () => { it("updates a member in the configured guild using bot authentication", async () => {
+4 -3
View File
@@ -28,8 +28,9 @@ describe("Java Edition profiles", () => {
expect(formatDiscordNickname("Sam", "Notch")).toBe("Sam (Notch)"); expect(formatDiscordNickname("Sam", "Notch")).toBe("Sam (Notch)");
}); });
it("falls back to the user's name when an admin removes their final account", () => { it("marks the Discord nickname TBD when the user has no Minecraft account", () => {
expect(formatManagedDiscordNickname("Alexandria Catherine", null)).toBe("Alexandria Catherine"); expect(formatManagedDiscordNickname("Alexandria Catherine", null)).toBe("Alexandria Catherine (TBD)");
expect(formatManagedDiscordNickname("A name that is definitely longer than Discord allows", null)).toHaveLength(32); expect(formatManagedDiscordNickname("A name that is definitely longer than Discord allows", null).length).toBeLessThanOrEqual(32);
expect(formatManagedDiscordNickname("A name that is definitely longer than Discord allows", null)).toMatch(/ \(TBD\)$/);
}); });
}); });
+20
View File
@@ -118,6 +118,26 @@ export class ProxyCheckProvider implements IpIntelligenceProvider {
} }
} }
export function addressGroup(ipAddress: string) {
const hostAddress = ipAddress.split("/", 1)[0] ?? ipAddress;
if (!isIP(hostAddress)) return ipAddress;
let address = ipaddr.parse(hostAddress);
if (address instanceof ipaddr.IPv6 && address.isIPv4MappedAddress()) {
address = address.toIPv4Address();
}
const prefixLength = address.kind() === "ipv4" ? 24 : 64;
const bytes = address.toByteArray();
for (let bit = prefixLength; bit < bytes.length * 8; bit += 1) {
const byteIndex = Math.floor(bit / 8);
const bitMask = 1 << (7 - (bit % 8));
bytes[byteIndex] = (bytes[byteIndex] ?? 0) & ~bitMask;
}
return `${ipaddr.fromByteArray(bytes).toString()}/${prefixLength}`;
}
export function isPublicIp(ipAddress: string) { export function isPublicIp(ipAddress: string) {
if (!isIP(ipAddress)) return false; if (!isIP(ipAddress)) return false;
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { addressGroup } from "../src/index";
describe("access address groups", () => {
it("groups nearby IPv4 and IPv6 addresses by their stable network prefix", () => {
expect(addressGroup("198.51.100.21")).toBe("198.51.100.0/24");
expect(addressGroup("198.51.100.240")).toBe("198.51.100.0/24");
expect(addressGroup("198.51.100.99/32")).toBe("198.51.100.0/24");
expect(addressGroup("2001:db8:abcd:1234:1111::1")).toBe("2001:db8:abcd:1234::/64");
expect(addressGroup("2001:db8:abcd:1234:ffff::9")).toBe("2001:db8:abcd:1234::/64");
});
});