From ebc7c7df1753c13a053669507e9a579e963255a0 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 1 Aug 2026 20:28:32 -0400 Subject: [PATCH] feat(dashboard): refine activity telemetry and maps --- AGENTS.md | 6 +- README.md | 2 +- apps/web/next.config.ts | 2 +- apps/web/package.json | 2 + apps/web/src/app/admin/(console)/page.tsx | 94 ++++++++---- .../app/api/velocity/connection/route.test.ts | 134 +++++++++++++++++ .../src/app/api/velocity/connection/route.ts | 142 ++++++++++++++++++ apps/web/src/app/globals.css | 10 ++ apps/web/src/components/map-view-toggle.tsx | 82 ++++++++++ .../src/components/user-world-map.test.tsx | 8 + apps/web/src/components/user-world-map.tsx | 23 ++- apps/web/src/lib/admin-metrics.test.ts | 18 ++- apps/web/src/lib/admin-metrics.ts | 13 ++ apps/web/src/lib/event-filters.test.ts | 1 + apps/web/src/lib/event-filters.ts | 2 +- design/index.md | 2 +- design/log.md | 5 + design/us-008-vpn-blocking.md | 3 +- design/us-009-velocity-admission.md | 5 +- design/us-010-audit-events.md | 4 +- design/us-018-admin-dashboard.md | 24 +-- docs/accessibility.md | 5 +- docs/api-errors.md | 10 +- docs/architecture.md | 5 +- docs/security-review.md | 6 +- package-lock.json | 18 +++ packages/contracts/src/index.ts | 10 ++ packages/contracts/test/contracts.test.ts | 14 ++ plugins/velocity/README.md | 2 +- .../accountmanager/AccountManagerClient.java | 37 +++++ .../MinecraftAccountManagerPlugin.java | 21 ++- .../AccountManagerClientTest.java | 57 +++++++ 32 files changed, 695 insertions(+), 72 deletions(-) create mode 100644 apps/web/src/app/api/velocity/connection/route.test.ts create mode 100644 apps/web/src/app/api/velocity/connection/route.ts create mode 100644 apps/web/src/components/map-view-toggle.tsx diff --git a/AGENTS.md b/AGENTS.md index c405d4b..e60cfe8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,9 +7,11 @@ The `design/` directory is the OKF v0.1 product record for this repository. Use Before changing behavior: 1. Read `design/index.md` and every story related to the requested behavior. -2. Update an existing story or create a new `design/us-NNN-short-name.md` story before implementation. +2. Draft updates to an existing story or create a new `design/us-NNN-short-name.md` story before implementation. 3. Define observable acceptance criteria using user or operator language. -4. Set story status to `proposed` or `in-progress` while the work is incomplete. +4. Present the relevant new or updated stories and acceptance criteria to the user for review, and wait for explicit confirmation before changing implementation code. +5. Incorporate requested story changes before proceeding. +6. Set story status to `proposed` or `in-progress` while the work is incomplete. While implementing: diff --git a/README.md b/README.md index 40a92e4..6df62ce 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ The token is displayed once and stored only as a SHA-256 hash. - PostgreSQL and Drizzle ORM - Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role -- Admin user search, account management, event exploration, operational metrics, an open-data user-location world map, and automatic Discord nickname synchronization +- Admin user search, account management, event exploration, DAU and confirmed-connection metrics, toggleable Natural Earth/OpenStreetMap user-location views, and automatic Discord nickname synchronization - Exclusive group admission: unassigned users fall back to protected `everyone`, and only the effective group's access setting applies - Deployment-managed Discord guild ID and invite URL - discord.js bot with `/register` and `/account` diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 5cbfdd1..17188b2 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -4,7 +4,7 @@ const contentSecurityPolicy = [ "default-src 'self'", `script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}`, "style-src 'self' 'unsafe-inline'", - "img-src 'self' data:", + "img-src 'self' data: https://tile.openstreetmap.org", "font-src 'self'", "connect-src 'self'", "object-src 'none'", diff --git a/apps/web/package.json b/apps/web/package.json index cb99e05..13d22cc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "@minecraft-account-manager/network": "*", "d3-geo": "^3.1.1", "drizzle-orm": "^0.45.1", + "leaflet": "^1.9.4", "next": "^16.2.1", "next-auth": "^4.24.13", "react": "^19.2.3", @@ -29,6 +30,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4.2.1", "@types/d3-geo": "^3.1.1", + "@types/leaflet": "^1.9.22", "@types/node": "^25.0.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/apps/web/src/app/admin/(console)/page.tsx b/apps/web/src/app/admin/(console)/page.tsx index b851166..83014e8 100644 --- a/apps/web/src/app/admin/(console)/page.tsx +++ b/apps/web/src/app/admin/(console)/page.tsx @@ -1,9 +1,10 @@ import { events, ipIntelligence, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database"; -import { and, count, countDistinct, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm"; +import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft"; +import { and, count, countDistinct, desc, eq, gte, inArray, isNotNull, isNull, sql } from "drizzle-orm"; import Link from "next/link"; import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map"; import { db } from "@/lib/database"; -import { fillDailySeries, type DailyCount } from "@/lib/admin-metrics"; +import { fillDailySeries, mergeRiskActivity, type DailyCount } from "@/lib/admin-metrics"; import { parseUserLocation } from "@/lib/user-location-map"; export const dynamic = "force-dynamic"; @@ -15,29 +16,33 @@ export default async function AdminDashboardPage() { fourteenDaysAgo.setUTCHours(0, 0, 0, 0); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1_000); - const [registrationRows, [totals], [monthlyActive], locationRows, riskyActivity, [recentDenials]] = await Promise.all([ + const [dailyActiveRows, [totals], [monthlyActive], [monthlyAccounts], locationRows, riskyLatestRows, riskySummaryRows, [recentDenials]] = await Promise.all([ db .select({ - day: sql`to_char(date_trunc('day', ${users.createdAt} at time zone 'UTC'), 'YYYY-MM-DD')`, - count: count(), + day: sql`to_char(date_trunc('day', ${ipObservations.observedAt} at time zone 'UTC'), 'YYYY-MM-DD')`, + count: countDistinct(ipObservations.userId), }) - .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')`), + .from(ipObservations) + .where(and(gte(ipObservations.observedAt, fourteenDaysAgo), isNotNull(ipObservations.userId))) + .groupBy(sql`date_trunc('day', ${ipObservations.observedAt} at time zone 'UTC')`) + .orderBy(sql`date_trunc('day', ${ipObservations.observedAt} 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.select({ accounts: countDistinct(events.subject) }).from(events).where(and( + eq(events.type, "games.minecraft.account-manager.game.player.connected"), + gte(events.time, thirtyDaysAgo), + )), db .selectDistinctOn([ipObservations.userId], { userId: ipObservations.userId, name: users.firstName, discordUsername: users.discordUsername, + primaryUsername: minecraftAccounts.username, classification: ipObservations.classification, source: ipObservations.source, observedAt: ipObservations.observedAt, @@ -46,6 +51,11 @@ export default async function AdminDashboardPage() { .from(ipObservations) .innerJoin(users, eq(users.id, ipObservations.userId)) .innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress)) + .leftJoin(minecraftAccounts, and( + eq(minecraftAccounts.userId, users.id), + eq(minecraftAccounts.isPrimary, true), + isNull(minecraftAccounts.deletedAt), + )) .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`, @@ -53,9 +63,9 @@ export default async function AdminDashboardPage() { )) .orderBy(ipObservations.userId, desc(ipObservations.observedAt), desc(ipObservations.id)), db - .select({ + .selectDistinctOn([ipObservations.userId], { id: ipObservations.id, - classification: ipObservations.classification, + classification: ipIntelligence.classification, observedAt: ipObservations.observedAt, source: ipObservations.source, userId: users.id, @@ -64,17 +74,37 @@ export default async function AdminDashboardPage() { accountUsername: minecraftAccounts.username, }) .from(ipObservations) - .leftJoin(users, eq(users.id, ipObservations.userId)) + .innerJoin(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), + .innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress)) + .where(and( + isNotNull(ipObservations.userId), + gte(ipObservations.observedAt, thirtyDaysAgo), + inArray(ipIntelligence.classification, ["vpn", "proxy", "tor"]), + )) + .orderBy(ipObservations.userId, desc(ipObservations.observedAt), desc(ipObservations.id)), + db + .select({ + userId: ipObservations.userId, + count: count(), + classifications: sql`array_agg(distinct ${ipIntelligence.classification}::text order by ${ipIntelligence.classification}::text)`, + sources: sql`array_agg(distinct ${ipObservations.source}::text order by ${ipObservations.source}::text)`, + }) + .from(ipObservations) + .innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress)) + .where(and( + isNotNull(ipObservations.userId), + gte(ipObservations.observedAt, thirtyDaysAgo), + inArray(ipIntelligence.classification, ["vpn", "proxy", "tor"]), + )) + .groupBy(ipObservations.userId), 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 dailyActive = fillDailySeries(dailyActiveRows as DailyCount[], now, 14); + const riskyActivity = mergeRiskActivity(riskyLatestRows, riskySummaryRows).slice(0, 10); const locations = locationRows.flatMap((row): UserMapLocation[] => { const parsed = parseUserLocation(row.intelligence); if (!parsed || !row.userId) return []; @@ -82,6 +112,7 @@ export default async function AdminDashboardPage() { userId: row.userId, name: row.name ?? row.discordUsername, discordUsername: row.discordUsername, + nickname: formatManagedDiscordNickname(row.name ?? row.discordUsername, row.primaryUsername ?? null), latitude: parsed.latitude, longitude: parsed.longitude, location: parsed.label, @@ -104,15 +135,15 @@ export default async function AdminDashboardPage() {
- +
- +
-

Network review

Recent VPN activity

+

Network review

Recent risky network activity

Collapsed per user across VPN, proxy, and Tor observations from the past 30 days.

All security events
@@ -121,9 +152,9 @@ export default async function AdminDashboardPage() {
{activity.userId ? {activity.firstName ?? activity.discordUsername ?? "Unknown user"} : Unknown user} -

{activity.accountUsername ?? "No Minecraft account"} · {activity.source}

+

{activity.accountUsername ?? "No Minecraft account"} · {activity.sources.join(" + ")} · {activity.count} {activity.count === 1 ? "observation" : "observations"}

- {activity.classification} + {activity.classifications.join(" + ")}
@@ -146,7 +177,7 @@ function Metric({ label, value, detail, accent = false }: { label: string; value ); } -function RegistrationChart({ data }: { data: DailyCount[] }) { +function DailyActiveChart({ data }: { data: DailyCount[] }) { const width = 720; const height = 260; const padding = 32; @@ -159,22 +190,21 @@ function RegistrationChart({ data }: { data: DailyCount[] }) { return (
-

Growth signal

-

New users by day

- - New user registrations over the last 14 days - Daily registrations range from zero to {maximum}. A text summary follows the chart. +

Activity signal

+

Daily active users

+ + Daily active users over the last 14 days + Distinct daily users range from zero to {maximum}. Date-labelled values follow the chart. {data.map((entry, index) => { const [x, y] = points.split(" ")[index]!.split(","); - return {entry.day}: {entry.count} new users; + return {entry.day}: {entry.count} active users; })} -
- {data.map((entry) =>
{entry.day}
{entry.count}
)} +
+ {data.map((entry) =>
{entry.count}
)}
-
); } diff --git a/apps/web/src/app/api/velocity/connection/route.test.ts b/apps/web/src/app/api/velocity/connection/route.test.ts new file mode 100644 index 0000000..9740d15 --- /dev/null +++ b/apps/web/src/app/api/velocity/connection/route.test.ts @@ -0,0 +1,134 @@ +import { hashToken } from "@minecraft-account-manager/auth"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const databaseState = vi.hoisted(() => ({ + account: { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", userId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" } as { id: string; userId: string } | null, + inserts: [] as Record[], + credentialHash: "" as string | null, + replay: false, +})); + +vi.mock("@/lib/database", () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => databaseState.credentialHash ? [{ secretHash: databaseState.credentialHash }] : [], + }), + }), + }), + transaction: async (callback: (tx: unknown) => Promise) => callback({ + delete: () => ({ where: async () => undefined }), + insert: () => ({ + values: async (value: Record) => { + if (databaseState.replay && "requestId" in value) { + throw { code: "23505", constraint_name: "plugin_requests_pkey" }; + } + databaseState.inserts.push(value); + }, + }), + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => databaseState.account ? [databaseState.account] : [], + }), + }), + }), + }), + }, +})); + +import { GET, POST } from "./route"; + +function validRequest(overrides: Record = {}) { + return new Request("http://localhost/api/velocity/connection", { + method: "POST", + headers: { authorization: "Bearer valid-token", "content-type": "application/json" }, + body: JSON.stringify({ + requestId: "8dd9dbdc-020a-4077-983c-77747522de8f", + serverId: "velocity-main", + minecraftUuid: "069a79f444e94726a5befca90e38aaf5", + username: "Notch", + occurredAt: new Date().toISOString(), + ...overrides, + }), + }); +} + +describe("Velocity connection reporting endpoint", () => { + beforeEach(() => { + databaseState.account = { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", userId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }; + databaseState.inserts = []; + databaseState.credentialHash = hashToken("valid-token"); + databaseState.replay = false; + }); + + it("rejects methods other than POST with Problem Details", async () => { + const response = GET(new Request("http://localhost/api/velocity/connection")); + expect(response.status).toBe(405); + expect(response.headers.get("content-type")).toContain("application/problem+json"); + expect(response.headers.get("allow")).toBe("POST"); + }); + + it("requires a server credential", async () => { + const response = await POST(new Request("http://localhost/api/velocity/connection", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + })); + expect(response.status).toBe(401); + }); + + it("validates the report before database access", async () => { + const response = await POST(new Request("http://localhost/api/velocity/connection", { + method: "POST", + headers: { authorization: "Bearer test", "content-type": "application/json" }, + body: JSON.stringify({ username: "bad name" }), + })); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ type: "urn:error:invalid-velocity-connection-request", status: 400 }); + }); + + it("rejects invalid or revoked server credentials", async () => { + databaseState.credentialHash = null; + const response = await POST(validRequest()); + expect(response.status).toBe(401); + expect(databaseState.inserts).toHaveLength(0); + }); + + it("rejects stale reports before recording them", async () => { + const response = await POST(validRequest({ occurredAt: "2026-01-01T00:00:00.000Z" })); + expect(response.status).toBe(401); + expect(databaseState.inserts).toHaveLength(0); + }); + + it("authenticates and atomically records a confirmed account connection", async () => { + const response = await POST(validRequest()); + expect(response.status).toBe(204); + expect(databaseState.inserts).toEqual(expect.arrayContaining([ + expect.objectContaining({ requestId: "8dd9dbdc-020a-4077-983c-77747522de8f", serverId: "velocity-main" }), + expect.objectContaining({ + type: "games.minecraft.account-manager.game.player.connected", + subject: "minecraft-account/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + actorUserId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + }), + ])); + }); + + it("rejects replayed request IDs", async () => { + databaseState.replay = true; + const response = await POST(validRequest()); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + type: "urn:error:replayed-velocity-connection-request", + status: 409, + }); + }); + + it("does not record an event for an unknown account", async () => { + databaseState.account = null; + const response = await POST(validRequest()); + expect(response.status).toBe(404); + expect(databaseState.inserts).toHaveLength(1); + }); +}); diff --git a/apps/web/src/app/api/velocity/connection/route.ts b/apps/web/src/app/api/velocity/connection/route.ts new file mode 100644 index 0000000..beb13b3 --- /dev/null +++ b/apps/web/src/app/api/velocity/connection/route.ts @@ -0,0 +1,142 @@ +import { randomUUID } from "node:crypto"; +import { isRequestTimestampFresh, verifyHashedToken } from "@minecraft-account-manager/auth"; +import { problemDetails, velocityConnectionRequestSchema } from "@minecraft-account-manager/contracts"; +import { events, minecraftAccounts, pluginCredentials, pluginRequests } from "@minecraft-account-manager/database"; +import { and, eq, isNull, lt } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db } from "@/lib/database"; +import { isUniqueConstraintViolation } from "@/lib/database-errors"; +import { logger } from "@/lib/logger"; +import { problemInstance, problemResponse } from "@/lib/problem-response"; + +const MAX_CLOCK_SKEW_MS = 45_000; + +function methodNotAllowed(request: Request) { + const response = problemResponse(problemDetails( + "urn:error:method-not-allowed", + "Method not allowed", + 405, + "This endpoint only accepts POST requests.", + problemInstance(request), + )); + response.headers.set("allow", "POST"); + return response; +} + +export const GET = methodNotAllowed; +export const PUT = methodNotAllowed; +export const PATCH = methodNotAllowed; +export const DELETE = methodNotAllowed; + +export async function POST(request: Request) { + const instance = problemInstance(request); + const authorization = request.headers.get("authorization") ?? ""; + const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : ""; + if (!token) return problemResponse(problemDetails( + "urn:error:unauthorized", + "Unauthorized", + 401, + "A valid Velocity server credential is required.", + instance, + )); + + const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (mediaType !== "application/json") return problemResponse(problemDetails( + "urn:error:unsupported-media-type", + "Unsupported media type", + 415, + "Velocity connection reports must use application/json.", + instance, + )); + + const parsed = velocityConnectionRequestSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return problemResponse(problemDetails( + "urn:error:invalid-velocity-connection-request", + "Invalid Velocity connection report", + 400, + "The request body does not match the required Velocity connection contract.", + instance, + { issues: parsed.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message, code: issue.code })) }, + )); + + const input = parsed.data; + const occurredAt = new Date(input.occurredAt); + if (!isRequestTimestampFresh(occurredAt, new Date(), MAX_CLOCK_SKEW_MS)) return problemResponse(problemDetails( + "urn:error:expired-velocity-connection-request", + "Expired Velocity connection report", + 401, + "The request timestamp is outside the allowed clock-skew window.", + instance, + )); + + const [credential] = await db + .select({ secretHash: pluginCredentials.secretHash }) + .from(pluginCredentials) + .where(and(eq(pluginCredentials.serverId, input.serverId), isNull(pluginCredentials.revokedAt))) + .limit(1); + if (!credential || !verifyHashedToken(token, credential.secretHash)) return problemResponse(problemDetails( + "urn:error:unauthorized", + "Unauthorized", + 401, + "The Velocity server credential is invalid or revoked.", + instance, + )); + + try { + const recorded = await db.transaction(async (tx) => { + await tx.delete(pluginRequests).where(lt(pluginRequests.expiresAt, new Date())); + await tx.insert(pluginRequests).values({ + requestId: input.requestId, + serverId: input.serverId, + receivedAt: new Date(), + expiresAt: new Date(Date.now() + 5 * 60_000), + }); + const [account] = await tx + .select({ id: minecraftAccounts.id, userId: minecraftAccounts.userId }) + .from(minecraftAccounts) + .where(and(eq(minecraftAccounts.minecraftUuid, input.minecraftUuid), isNull(minecraftAccounts.deletedAt))) + .limit(1); + if (!account) return false; + + await tx.insert(events).values({ + id: randomUUID(), + source: `/velocity/${input.serverId}`, + type: "games.minecraft.account-manager.game.player.connected", + subject: `minecraft-account/${account.id}`, + time: occurredAt, + actorUserId: account.userId, + correlationId: input.requestId, + data: { + username: input.username, + minecraftUuid: input.minecraftUuid, + serverId: input.serverId, + }, + }); + return true; + }); + if (!recorded) return problemResponse(problemDetails( + "urn:error:unknown-minecraft-account", + "Unknown Minecraft account", + 404, + "The connected Minecraft account is no longer registered.", + instance, + )); + return new NextResponse(null, { status: 204 }); + } catch (error) { + if (isUniqueConstraintViolation(error, "plugin_requests_pkey")) return problemResponse(problemDetails( + "urn:error:replayed-velocity-connection-request", + "Velocity request replayed", + 409, + "This Velocity request ID has already been processed.", + instance, + )); + logger.error({ err: error, event: "velocity.connection_report_failed" }, "Failed to record a confirmed Velocity connection"); + return problemResponse(problemDetails( + "urn:error:service-unavailable", + "Service unavailable", + 503, + "The connection report could not be recorded.", + instance, + )); + } +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 4293ce6..5b7e7e4 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,3 +1,4 @@ +@import "leaflet/dist/leaflet.css"; @import "tailwindcss"; @theme inline { @@ -64,6 +65,15 @@ svg a:focus .map-marker { stroke-width: 6px; } +.map-marker-tooltip { + opacity: 0; +} + +.map-marker-link:hover .map-marker-tooltip, +.map-marker-link:focus .map-marker-tooltip { + opacity: 1; +} + ::selection { background: var(--accent); color: var(--panel); diff --git a/apps/web/src/components/map-view-toggle.tsx b/apps/web/src/components/map-view-toggle.tsx new file mode 100644 index 0000000..e4904d9 --- /dev/null +++ b/apps/web/src/components/map-view-toggle.tsx @@ -0,0 +1,82 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { UserMapLocation } from "./user-world-map"; + +export function MapViewToggle({ locations, children }: { locations: UserMapLocation[]; children: ReactNode }) { + const [view, setView] = useState<"overview" | "interactive">("overview"); + + return ( +
+
+ + +
+

Selecting the interactive view requests map tiles from OpenStreetMap, which receives your IP address, the portal origin, and the geographic area being viewed.

+ + +
+ ); +} + +function InteractiveMap({ locations }: { locations: UserMapLocation[] }) { + const container = useRef(null); + + useEffect(() => { + if (!container.current) return; + let cancelled = false; + let cleanup = () => {}; + + void import("leaflet").then((leaflet) => { + if (cancelled || !container.current) return; + const map = leaflet.map(container.current, { minZoom: 1, worldCopyJump: true }).setView([20, 0], 2); + leaflet.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: '© OpenStreetMap contributors', + maxZoom: 19, + referrerPolicy: "strict-origin-when-cross-origin", + }).addTo(map); + + const bounds: [number, number][] = []; + for (const user of locations) { + const marker = leaflet.circleMarker([user.latitude, user.longitude], { + radius: 8, + color: "#eee8d8", + weight: 3, + fillColor: "#a32f1b", + fillOpacity: 1, + }).addTo(map); + const tooltip = document.createElement("span"); + tooltip.textContent = `${user.nickname} · ${user.location}`; + marker.bindTooltip(tooltip, { direction: "top" }); + const userPath = `/admin/users/${user.userId}`; + marker.on("click", () => window.location.assign(userPath)); + const element = marker.getElement(); + element?.setAttribute("aria-label", `${user.nickname}, ${user.location}`); + element?.setAttribute("role", "link"); + element?.setAttribute("tabindex", "0"); + element?.addEventListener("focus", () => marker.openTooltip()); + element?.addEventListener("blur", () => marker.closeTooltip()); + element?.addEventListener("keydown", (event) => { + const keyboardEvent = event as KeyboardEvent; + if (keyboardEvent.key === "Enter" || keyboardEvent.key === " ") { + keyboardEvent.preventDefault(); + window.location.assign(userPath); + } + }); + bounds.push([user.latitude, user.longitude]); + } + if (bounds.length) map.fitBounds(bounds, { padding: [40, 40], maxZoom: 6 }); + cleanup = () => map.remove(); + }); + + return () => { + cancelled = true; + cleanup(); + }; + }, [locations]); + + return
; +} diff --git a/apps/web/src/components/user-world-map.test.tsx b/apps/web/src/components/user-world-map.test.tsx index 42bdf53..6d984f4 100644 --- a/apps/web/src/components/user-world-map.test.tsx +++ b/apps/web/src/components/user-world-map.test.tsx @@ -8,6 +8,7 @@ describe("UserWorldMap", () => { userId: "11111111-1111-4111-8111-111111111111", name: "Dani", discordUsername: "dani", + nickname: "Dani (Steve)", latitude: 37.4056, longitude: -122.0775, location: "Mountain View, California, US", @@ -20,7 +21,14 @@ describe("UserWorldMap", () => { 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("Dani (Steve)"); expect(markup).toContain("Mountain View, California, US"); + expect(markup).toContain("World overview"); + expect(markup).toContain("Interactive OpenStreetMap"); + expect(markup).toContain("OpenStreetMap, which receives your IP address"); + expect(markup).toContain("map-marker-tooltip"); + expect(markup).toContain('id="map-overview-panel"'); + expect(markup).not.toContain("tile.openstreetmap.org"); expect(markup).toContain("Natural Earth, public domain"); expect(markup).toContain("2 without coordinates"); diff --git a/apps/web/src/components/user-world-map.tsx b/apps/web/src/components/user-world-map.tsx index 7e1a4c1..0d9df18 100644 --- a/apps/web/src/components/user-world-map.tsx +++ b/apps/web/src/components/user-world-map.tsx @@ -4,6 +4,7 @@ import { geoEquirectangular, geoPath } from "d3-geo"; import { feature } from "topojson-client"; import countriesTopologyJson from "world-atlas/countries-110m.json"; import Link from "next/link"; +import { MapViewToggle } from "./map-view-toggle"; const WIDTH = 1_000; const HEIGHT = 500; @@ -16,6 +17,7 @@ export interface UserMapLocation { userId: string; name: string; discordUsername: string; + nickname: string; latitude: number; longitude: number; location: string; @@ -35,7 +37,8 @@ export function UserWorldMap({ locations, unavailableCount }: { locations: UserM

{locations.length} mapped · {unavailableCount} without coordinates. Locations are approximate IP intelligence, not precise device positions.

-
+ +
Latest approximate location for registered users An open-data world map with one linked marker for every user whose latest geolocated observation has valid coordinates. A complete text list follows. @@ -52,18 +55,26 @@ export function UserWorldMap({ locations, unavailableCount }: { locations: UserM 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])); + const tooltipWidth = Math.min(260, Math.max(110, user.nickname.length * 8 + 24)); + const tooltipX = Math.min(WIDTH - tooltipWidth - 4, Math.max(4, x - tooltipWidth / 2)); + const tooltipY = y > 46 ? y - 38 : y + 18; return ( - - - - {user.name} · {user.location} · {user.classification} + + + {user.nickname} · {user.location} · {user.classification} + + ); })}
+

Map boundaries: Natural Earth, public domain

@@ -73,7 +84,7 @@ export function UserWorldMap({ locations, unavailableCount }: { locations: UserM Latest approximate registered-user locations UserLocationNetworkSourceLast observed - {locations.map((user) => {user.name}@{user.discordUsername}{user.location}{user.classification}{user.source})} + {locations.map((user) => {user.nickname}@{user.discordUsername}{user.location}{user.classification}{user.source})} {!locations.length && No user observations currently include valid coordinates.} diff --git a/apps/web/src/lib/admin-metrics.test.ts b/apps/web/src/lib/admin-metrics.test.ts index cc16ef4..aaae41d 100644 --- a/apps/web/src/lib/admin-metrics.test.ts +++ b/apps/web/src/lib/admin-metrics.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { fillDailySeries } from "./admin-metrics"; +import { fillDailySeries, mergeRiskActivity } from "./admin-metrics"; describe("admin dashboard metrics", () => { it("fills missing UTC registration days with zero", () => { @@ -13,4 +13,20 @@ describe("admin dashboard metrics", () => { { day: "2026-08-01", count: 1 }, ]); }); + + it("merges complete per-user VPN summaries with each user's latest observation", () => { + const latest = [ + { userId: "user-2", classification: "tor", observedAt: new Date("2026-08-01T11:00:00Z") }, + { userId: "user-1", classification: "proxy", observedAt: new Date("2026-08-01T12:00:00Z") }, + ]; + const summaries = [ + { userId: "user-1", count: 2000, classifications: ["proxy", "vpn"], sources: ["game", "web"] }, + { userId: "user-2", count: 1, classifications: ["tor"], sources: ["web"] }, + ]; + + expect(mergeRiskActivity(latest, summaries)).toEqual([ + expect.objectContaining({ userId: "user-1", count: 2000, classification: "proxy", classifications: ["proxy", "vpn"], sources: ["game", "web"] }), + expect.objectContaining({ userId: "user-2", count: 1, classification: "tor" }), + ]); + }); }); diff --git a/apps/web/src/lib/admin-metrics.ts b/apps/web/src/lib/admin-metrics.ts index 28cff09..6718771 100644 --- a/apps/web/src/lib/admin-metrics.ts +++ b/apps/web/src/lib/admin-metrics.ts @@ -3,6 +3,19 @@ export interface DailyCount { count: number; } +export function mergeRiskActivity< + T extends { userId: string; observedAt: Date }, + S extends { userId: string | null }, +>(latestRows: T[], summaryRows: S[]) { + const summaries = new Map(summaryRows.flatMap((summary) => summary.userId ? [[summary.userId, summary] as const] : [])); + return latestRows + .flatMap((activity) => { + const summary = summaries.get(activity.userId); + return summary ? [{ ...activity, ...summary }] : []; + }) + .sort((left, right) => right.observedAt.getTime() - left.observedAt.getTime()); +} + 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())); diff --git a/apps/web/src/lib/event-filters.test.ts b/apps/web/src/lib/event-filters.test.ts index 5a04d9f..93fda6f 100644 --- a/apps/web/src/lib/event-filters.test.ts +++ b/apps/web/src/lib/event-filters.test.ts @@ -10,6 +10,7 @@ describe("event filters", () => { 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.game.player.connected")).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"); diff --git a/apps/web/src/lib/event-filters.ts b/apps/web/src/lib/event-filters.ts index dde8d19..54d317d 100644 --- a/apps/web/src/lib/event-filters.ts +++ b/apps/web/src/lib/event-filters.ts @@ -4,7 +4,7 @@ export type EventCategory = (typeof eventCategoryValues)[number]; export function eventCategory(type: string): Exclude { 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(".game.")) return "admission"; if (type.includes(".discord.") || type.includes(".user.") || type.includes("minecraft-account")) return "identity"; return "operations"; } diff --git a/design/index.md b/design/index.md index 68b2c47..8b457d7 100644 --- a/design/index.md +++ b/design/index.md @@ -31,7 +31,7 @@ This OKF bundle is the product record for implemented and proposed behavior. Sto * [US-015 — Deploy and operate securely](us-015-platform-operations.md) - Operators have reproducible builds, migrations, credentials, and security controls. * [US-016 — Build and publish versioned releases](us-016-automated-releases.md) - Gitea Actions publish the Velocity JAR and web and migration images. * [US-017 — Control admission with groups](us-017-group-access.md) - Each user has one effective group that explicitly controls Minecraft access. -* [US-018 — Monitor community account activity](us-018-admin-dashboard.md) - Administrators review registrations, monthly activity, denials, and risky networks. +* [US-018 — Monitor community account activity](us-018-admin-dashboard.md) - Administrators review daily users, confirmed connections, locations, denials, and risky networks. # Tracking diff --git a/design/log.md b/design/log.md index fb2c025..7e70a51 100644 --- a/design/log.md +++ b/design/log.md @@ -1,5 +1,10 @@ # Design Update Log +## 2026-08-02 + +* **Refine**: Replace registration counts with daily active users, collapse enriched VPN activity per user, add opt-in OpenStreetMap zoom, show managed nickname tooltips, and measure active Minecraft accounts from confirmed Velocity connections. +* **Governance**: Require user review and explicit confirmation of relevant OKF story changes before future implementation work. + ## 2026-08-01 * **Extend**: Plot each user's latest approximate location on an accessible, server-rendered Natural Earth world map in the operations dashboard. diff --git a/design/us-008-vpn-blocking.md b/design/us-008-vpn-blocking.md index 2dfddf1..d738d6c 100644 --- a/design/us-008-vpn-blocking.md +++ b/design/us-008-vpn-blocking.md @@ -3,7 +3,7 @@ type: User Story title: Block account additions from anonymized networks description: User Minecraft-account additions fail closed for VPN, proxy, Tor, or unknown IP classifications. tags: [security, vpn, proxy, minecraft] -timestamp: 2026-08-01T18:43:58Z +timestamp: 2026-08-02T00:12:32Z story_id: US-008 status: verified --- @@ -21,6 +21,7 @@ As an operator, I want account additions blocked from anonymized networks, so th - [x] Blocked users receive a clear recovery message without provider internals. - [x] Blocked and classification-unavailable attempts create distinct audit events with safe intelligence details. - [x] Administrative account additions remain available as an authorized recovery path. +- [x] Administrators see enriched risky-network observations collapsed to one latest summary per user. # Implementation diff --git a/design/us-009-velocity-admission.md b/design/us-009-velocity-admission.md index 6e7f22b..d123f9f 100644 --- a/design/us-009-velocity-admission.md +++ b/design/us-009-velocity-admission.md @@ -3,7 +3,7 @@ type: User Story title: Enforce registration at the Velocity proxy description: Online-mode Java connections are admitted only after a fail-closed account-manager decision. tags: [minecraft, velocity, whitelist, security] -timestamp: 2026-08-01T23:10:59Z +timestamp: 2026-08-02T00:12:32Z story_id: US-009 status: verified --- @@ -25,11 +25,14 @@ As a registered player, I want the Velocity proxy to recognize my approved Java - [x] Registered players are allowed only when their single effective group has access enabled; explicit assignments override the default group. - [x] Unknown players, group-disabled players, API failures, malformed responses, and unauthorized requests fail closed with registration guidance. - [x] The plugin records the real Velocity connection IP and supports Java Edition online mode only. +- [x] After admission, Velocity reports `PostLoginEvent` as best-effort authenticated telemetry without disconnecting an admitted player when reporting fails. +- [x] Confirmed-connection reports use fresh timestamps and database replay protection. # Implementation - [`plugins/velocity`](../plugins/velocity) - [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts) +- [`apps/web/src/app/api/velocity/connection/route.ts`](../apps/web/src/app/api/velocity/connection/route.ts) - [`packages/contracts/src/index.ts`](../packages/contracts/src/index.ts) - [`packages/database/src/schema.ts`](../packages/database/src/schema.ts) diff --git a/design/us-010-audit-events.md b/design/us-010-audit-events.md index 107c444..35fa520 100644 --- a/design/us-010-audit-events.md +++ b/design/us-010-audit-events.md @@ -3,7 +3,7 @@ type: User Story title: Preserve a CloudEvents-style audit trail description: Authentication, UI, account, Discord, network, and game actions create searchable immutable-style events. tags: [audit, cloudevents, security, events] -timestamp: 2026-08-01T23:10:59Z +timestamp: 2026-08-02T00:12:32Z story_id: US-010 status: verified --- @@ -16,7 +16,7 @@ As an operator, I want security and identity activity recorded consistently, so - [x] Events preserve CloudEvents-style ID, specification version, source, type, subject, time, content type, and JSON data. - [x] Events can include user actor, IP address, and correlation ID. -- [x] Portal access, magic-link creation and consumption, account changes, nickname changes, VPN blocks, and game decisions are recorded. +- [x] Portal access, magic-link creation and consumption, account changes, nickname changes, VPN blocks, game decisions, and confirmed proxy connections are recorded. - [x] Username changes learned from Velocity create their own event. - [x] Administrative actions include the acting SSO identity in event data. - [x] Events can be filtered by operator-friendly view and selected event types globally and from an individual user view. diff --git a/design/us-018-admin-dashboard.md b/design/us-018-admin-dashboard.md index 9ec379f..d4d7e6a 100644 --- a/design/us-018-admin-dashboard.md +++ b/design/us-018-admin-dashboard.md @@ -1,9 +1,9 @@ --- 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 +description: Administrators use a server-rendered dashboard to review daily activity, confirmed connections, locations, denials, and risky networks. +tags: [admin, dashboard, metrics, security, maps, ssr] +timestamp: 2026-08-02T00:12:32Z story_id: US-018 status: verified --- @@ -15,16 +15,17 @@ As an administrator, I want an operational dashboard of account and game activit # Acceptance Criteria - [x] The administrator landing page is a dashboard rather than a settings form. -- [x] 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] A server-rendered Natural Earth overview plots each user's latest observation with valid approximate coordinates. +- [x] Administrators can opt into a zoomable OpenStreetMap view without removing the default overview. +- [x] OpenStreetMap tiles load only after the administrator selects the interactive view and retain required attribution. +- [x] Map markers show the managed Discord nickname on hover or keyboard focus, link to user records, and have an accessible text-table equivalent. +- [x] The dashboard graphs distinct daily active users by UTC day for the previous 14 days with understandable date labels. - [x] Monthly active users count distinct users observed through portal or game activity in the previous 30 days. -- [x] Monthly active Minecraft accounts count distinct linked accounts observed in the previous 30 days. +- [x] Monthly active Minecraft accounts count distinct accounts with a confirmed Velocity post-login connection in the previous 30 days. - [x] The dashboard shows login denials from the previous 24 hours. -- [x] Recent VPN, proxy, and Tor observations link to affected user records. +- [x] Recent VPN, proxy, and Tor observations use enriched ProxyCheck classifications, collapse repeated rows per user, and show counts, sources, and latest activity. - [x] The graph includes an accessible title, description, point labels, and textual values. -- [x] Dashboard queries and rendering execute server-side without client-side data fetching. +- [x] Dashboard queries and initial rendering execute server-side; only the opt-in pan-and-zoom map hydrates client-side. - [x] Deployment-managed guild settings and denial messaging remain available on a dedicated settings page. # Implementation @@ -33,11 +34,12 @@ As an administrator, I want an operational dashboard of account and game activit - [`apps/web/src/app/admin/(console)/settings/page.tsx`](../apps/web/src/app/admin/%28console%29/settings/page.tsx) - [`apps/web/src/lib/admin-metrics.ts`](../apps/web/src/lib/admin-metrics.ts) - [`apps/web/src/components/user-world-map.tsx`](../apps/web/src/components/user-world-map.tsx) +- [`apps/web/src/components/map-view-toggle.tsx`](../apps/web/src/components/map-view-toggle.tsx) - [`apps/web/src/lib/user-location-map.ts`](../apps/web/src/lib/user-location-map.ts) # Validation -- Missing-day chart behavior is covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts). +- Missing-day chart behavior and per-user VPN collapsing are covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts). - Coordinate parsing, 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. diff --git a/docs/accessibility.md b/docs/accessibility.md index acdb4ee..3ac3a3e 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -1,6 +1,6 @@ # Accessibility review -Review date: 2026-08-01 +Review date: 2026-08-02 ## Scope @@ -17,8 +17,9 @@ Player account management, administrator navigation, dashboard metrics and chart - 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 an accessible title, description, date labels, per-point labels, and textual values to the daily-active-user chart. - Added labelled, keyboard-linked world-map markers plus a complete semantic table equivalent for approximate user locations. +- Added keyboard-operable tabs for the server-rendered overview and opt-in interactive OpenStreetMap view. - 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. diff --git a/docs/api-errors.md b/docs/api-errors.md index 7b0930c..b1cf1f5 100644 --- a/docs/api-errors.md +++ b/docs/api-errors.md @@ -29,12 +29,16 @@ Every error response has media type `application/problem+json` and the shape: | Type | Status | Meaning | | --- | ---: | --- | -| `urn:error:invalid-velocity-access-request` | 400 | Request JSON does not satisfy the shared Velocity contract | +| `urn:error:invalid-velocity-access-request` | 400 | Access request JSON does not satisfy the shared Velocity contract | +| `urn:error:invalid-velocity-connection-request` | 400 | Confirmed-connection JSON does not satisfy the shared Velocity contract | | `urn:error:unauthorized` | 401 | Velocity bearer credential is missing, invalid, or revoked | -| `urn:error:expired-velocity-access-request` | 401 | Request timestamp is outside the accepted clock-skew window | +| `urn:error:expired-velocity-access-request` | 401 | Access timestamp is outside the accepted clock-skew window | +| `urn:error:expired-velocity-connection-request` | 401 | Connection timestamp is outside the accepted clock-skew window | | `urn:error:not-found` | 404 | Unknown application-owned API route | +| `urn:error:unknown-minecraft-account` | 404 | Connection telemetry references an inactive or unknown account | | `urn:error:method-not-allowed` | 405 | The endpoint does not support the requested HTTP method | -| `urn:error:replayed-velocity-access-request` | 409 | Request ID was already processed | +| `urn:error:replayed-velocity-access-request` | 409 | Admission request ID was already processed | +| `urn:error:replayed-velocity-connection-request` | 409 | Confirmed-connection request ID was already processed | | `urn:error:unsupported-media-type` | 415 | The request does not use `application/json` | | `urn:error:service-unavailable` | 503 | A safe access decision could not be completed | diff --git a/docs/architecture.md b/docs/architecture.md index 8d29287..0c952f9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ ### Web application -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. +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 initial Natural Earth user-location map execute on the server and return rendered HTML. Administrators can opt into a hydrated Leaflet/OpenStreetMap view; OSM receives requests only for viewed map tiles, while user marker coordinates remain local to the browser. 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. @@ -16,7 +16,7 @@ The bot creates private login links in response to `/register` and `/account`. D Velocity sends the authenticated Java UUID, current username, source IP, server ID, request ID, and occurrence time. The API matches UUID first. Username fallback is allowed only when the stored account has no UUID, after which UUID and canonical username are updated. -The decision is fail closed. Unknown players, invalid responses, expired requests, authentication failures, and unavailable API responses are denied with the configured registration message. +The admission decision is fail closed. Unknown players, invalid responses, expired requests, authentication failures, and unavailable API responses are denied with the configured registration message. After admission succeeds, `PostLoginEvent` reports a confirmed proxy connection through a fresh, authenticated, replay-protected request. Connection telemetry is best effort and never disconnects an already admitted player. ## Trust boundaries @@ -52,3 +52,4 @@ Events use reverse-DNS names beneath `games.minecraft.account-manager`, includin - `games.minecraft.account-manager.network.vpn-blocked` - `games.minecraft.account-manager.game.login.allowed` - `games.minecraft.account-manager.game.login.denied` +- `games.minecraft.account-manager.game.player.connected` diff --git a/docs/security-review.md b/docs/security-review.md index a60fa8f..ce0cef0 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -1,6 +1,6 @@ # Security review -Review date: 2026-08-01 +Review date: 2026-08-02 ## Scope @@ -22,12 +22,12 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut - User mutations verify ownership server-side. - Mojang lookup is server-side and targets a fixed host, avoiding client-forged validation and SSRF. - Velocity credentials are high-entropy bearer tokens stored only as hashes. -- Velocity requests have a 45-second clock window and database-unique request IDs for cross-instance replay prevention. +- Velocity admission and confirmed-connection requests have a 45-second clock window and database-unique request IDs for cross-instance replay prevention. - Velocity and its API fail closed. - Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback. - 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. +- The administrator-only map defaults to bundled Natural Earth boundaries. OpenStreetMap tile requests begin only after an explicit operator opt-in; marker coordinates are not transmitted as data, but the requested tiles disclose the viewed geographic extent along with the administrator's IP and portal origin. - ORM-parameterized queries are used throughout. - CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured. - Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured. diff --git a/package-lock.json b/package-lock.json index 0a66d15..87d6400 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "@minecraft-account-manager/network": "*", "d3-geo": "^3.1.1", "drizzle-orm": "^0.45.1", + "leaflet": "^1.9.4", "next": "^16.2.1", "next-auth": "^4.24.13", "react": "^19.2.3", @@ -54,6 +55,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4.2.1", "@types/d3-geo": "^3.1.1", + "@types/leaflet": "^1.9.22", "@types/node": "^25.0.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -2774,6 +2776,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "25.9.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", @@ -6414,6 +6426,12 @@ "node": ">=0.10" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index eef0ed1..4b64a43 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -33,6 +33,16 @@ export const velocityAccessRequestSchema = z.object({ export type VelocityAccessRequest = z.infer; +export const velocityConnectionRequestSchema = z.object({ + requestId: z.uuid(), + serverId: z.string().min(1).max(100), + minecraftUuid: minecraftUuidSchema, + username: minecraftUsernameSchema, + occurredAt: isoDateTimeSchema, +}); + +export type VelocityConnectionRequest = z.infer; + export const velocityAccessResponseSchema = z.discriminatedUnion("allowed", [ z.object({ allowed: z.literal(true), diff --git a/packages/contracts/test/contracts.test.ts b/packages/contracts/test/contracts.test.ts index 008e8d9..3664c45 100644 --- a/packages/contracts/test/contracts.test.ts +++ b/packages/contracts/test/contracts.test.ts @@ -3,6 +3,7 @@ import { cloudEventSchema, velocityAccessRequestSchema, velocityAccessResponseSchema, + velocityConnectionRequestSchema, } from "../src/index"; describe("shared service contracts", () => { @@ -37,6 +38,19 @@ describe("shared service contracts", () => { ).toThrow(); }); + it("validates a confirmed Velocity connection report", () => { + const request = velocityConnectionRequestSchema.parse({ + requestId: "8dd9dbdc-020a-4077-983c-77747522de8f", + serverId: "velocity-main", + minecraftUuid: "069a79f444e94726a5befca90e38aaf5", + username: "Notch", + occurredAt: "2026-03-06T12:00:01.000Z", + }); + + expect(request.username).toBe("Notch"); + expect(() => velocityConnectionRequestSchema.parse({ ...request, username: "bad name" })).toThrow(); + }); + it("only returns explicit allow or deny decisions to Velocity", () => { expect( velocityAccessResponseSchema.parse({ diff --git a/plugins/velocity/README.md b/plugins/velocity/README.md index bd5f8bc..7b8cdf1 100644 --- a/plugins/velocity/README.md +++ b/plugins/velocity/README.md @@ -1,6 +1,6 @@ # Velocity admission plugin -The plugin checks every online-mode Java login against the account-manager API. It fails closed: unavailable, unauthorized, stale, replayed, malformed, and unknown requests are denied. +The plugin checks every online-mode Java login against the account-manager API. It fails closed: unavailable, unauthorized, stale, replayed, malformed, and unknown requests are denied. After a player completes proxy login, the plugin sends best-effort `PostLoginEvent` telemetry used for confirmed-connection activity metrics; reporting failure is logged without disconnecting the player. ## Download or build diff --git a/plugins/velocity/src/main/java/games/dmg/accountmanager/AccountManagerClient.java b/plugins/velocity/src/main/java/games/dmg/accountmanager/AccountManagerClient.java index 84f55e2..2f1e01d 100644 --- a/plugins/velocity/src/main/java/games/dmg/accountmanager/AccountManagerClient.java +++ b/plugins/velocity/src/main/java/games/dmg/accountmanager/AccountManagerClient.java @@ -59,6 +59,43 @@ final class AccountManagerClient { } } + boolean reportConnected(UUID minecraftUuid, String username) { + String compactUuid = minecraftUuid.toString().replace("-", "").toLowerCase(); + ConnectionRequest payload = new ConnectionRequest( + UUID.randomUUID().toString(), + config.serverId(), + compactUuid, + username, + Instant.now().toString() + ); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(config.apiUrl() + "/api/velocity/connection")) + .timeout(config.timeout()) + .header("Authorization", "Bearer " + config.apiToken()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload))) + .build(); + + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() == 204; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return false; + } catch (IOException | RuntimeException exception) { + return false; + } + } + + private record ConnectionRequest( + String requestId, + String serverId, + String minecraftUuid, + String username, + String occurredAt + ) {} + private record AccessRequest( String requestId, String serverId, diff --git a/plugins/velocity/src/main/java/games/dmg/accountmanager/MinecraftAccountManagerPlugin.java b/plugins/velocity/src/main/java/games/dmg/accountmanager/MinecraftAccountManagerPlugin.java index e88771c..9b31abb 100644 --- a/plugins/velocity/src/main/java/games/dmg/accountmanager/MinecraftAccountManagerPlugin.java +++ b/plugins/velocity/src/main/java/games/dmg/accountmanager/MinecraftAccountManagerPlugin.java @@ -5,9 +5,11 @@ import com.velocitypowered.api.event.EventTask; import com.velocitypowered.api.event.Subscribe; import com.velocitypowered.api.event.ResultedEvent; import com.velocitypowered.api.event.connection.LoginEvent; +import com.velocitypowered.api.event.connection.PostLoginEvent; import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; import com.velocitypowered.api.plugin.Plugin; import com.velocitypowered.api.plugin.annotation.DataDirectory; +import com.velocitypowered.api.proxy.ProxyServer; import java.io.IOException; import java.nio.file.Path; import net.kyori.adventure.text.Component; @@ -22,13 +24,15 @@ import org.slf4j.Logger; public final class MinecraftAccountManagerPlugin { private final Logger logger; private final Path dataDirectory; + private final ProxyServer proxyServer; private volatile AccountManagerClient accountManagerClient; private volatile String fallbackMessage = "Please register your Minecraft account in Discord before joining."; @Inject - public MinecraftAccountManagerPlugin(Logger logger, @DataDirectory Path dataDirectory) { + public MinecraftAccountManagerPlugin(Logger logger, @DataDirectory Path dataDirectory, ProxyServer proxyServer) { this.logger = logger; this.dataDirectory = dataDirectory; + this.proxyServer = proxyServer; } @Subscribe @@ -66,4 +70,19 @@ public final class MinecraftAccountManagerPlugin { } }); } + + @Subscribe + public void onPostLogin(PostLoginEvent event) { + proxyServer.getScheduler().buildTask(this, () -> { + AccountManagerClient client = accountManagerClient; + if (client == null) return; + boolean recorded = client.reportConnected( + event.getPlayer().getUniqueId(), + event.getPlayer().getUsername() + ); + if (!recorded) { + logger.warn("Could not report confirmed Minecraft connection for {} ({})", event.getPlayer().getUsername(), event.getPlayer().getUniqueId()); + } + }).schedule(); + } } diff --git a/plugins/velocity/src/test/java/games/dmg/accountmanager/AccountManagerClientTest.java b/plugins/velocity/src/test/java/games/dmg/accountmanager/AccountManagerClientTest.java index cc4909f..bf2c8aa 100644 --- a/plugins/velocity/src/test/java/games/dmg/accountmanager/AccountManagerClientTest.java +++ b/plugins/velocity/src/test/java/games/dmg/accountmanager/AccountManagerClientTest.java @@ -2,9 +2,15 @@ package games.dmg.accountmanager; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; class AccountManagerClientTest { @@ -27,4 +33,55 @@ class AccountManagerClientTest { assertFalse(decision.allowed()); assertEquals("Register through Discord.", decision.message()); } + + @Test + void reportsConfirmedConnectionsToTheAuthenticatedEndpoint() throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + AtomicReference body = new AtomicReference<>(); + AtomicReference authorization = new AtomicReference<>(); + server.createContext("/api/velocity/connection", exchange -> { + body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + exchange.sendResponseHeaders(204, -1); + exchange.close(); + }); + server.start(); + try { + PluginConfig config = new PluginConfig( + "http://127.0.0.1:" + server.getAddress().getPort(), + "velocity-test", + "test-token", + Duration.ofSeconds(2), + "Register through Discord." + ); + + assertTrue(new AccountManagerClient(config).reportConnected( + UUID.fromString("069a79f4-44e9-4726-a5be-fca90e38aaf5"), + "Notch" + )); + assertEquals("Bearer test-token", authorization.get()); + assertTrue(body.get().contains("\"minecraftUuid\":\"069a79f444e94726a5befca90e38aaf5\"")); + assertTrue(body.get().contains("\"username\":\"Notch\"")); + } finally { + server.stop(0); + } + } + + @Test + void connectionReportingIsBestEffortWhenTheApiCannotBeReached() { + PluginConfig config = new PluginConfig( + "http://127.0.0.1:1", + "velocity-test", + "test-token", + Duration.ofMillis(100), + "Register through Discord." + ); + + boolean recorded = new AccountManagerClient(config).reportConnected( + UUID.fromString("069a79f4-44e9-4726-a5be-fca90e38aaf5"), + "Notch" + ); + + assertFalse(recorded); + } }