Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71856bb869 | ||
|
|
24808b0f8c | ||
|
|
aa0b757814 | ||
|
|
ebc7c7df17 | ||
|
|
9116107917 |
@@ -7,9 +7,11 @@ The `design/` directory is the OKF v0.1 product record for this repository. Use
|
|||||||
Before changing behavior:
|
Before changing behavior:
|
||||||
|
|
||||||
1. Read `design/index.md` and every story related to the requested 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.
|
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:
|
While implementing:
|
||||||
|
|
||||||
|
|||||||
@@ -76,12 +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, event exploration, operational metrics, 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
|
- Exclusive group admission: unassigned users fall back to protected `everyone`, and only the effective group's access and VPN/proxy/Tor exception settings apply
|
||||||
- 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 and group-scoped game-connection exceptions
|
||||||
|
|
||||||
See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities, [`docs/api-errors.md`](docs/api-errors.md) for the RFC 9457 API error contract, and [`docs/security-review.md`](docs/security-review.md) for implemented controls and production requirements, and [`docs/accessibility.md`](docs/accessibility.md) for the WCAG-oriented interface review.
|
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.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const contentSecurityPolicy = [
|
|||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
`script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}`,
|
`script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}`,
|
||||||
"style-src 'self' 'unsafe-inline'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"img-src 'self' data:",
|
"img-src 'self' data: https://tile.openstreetmap.org",
|
||||||
"font-src 'self'",
|
"font-src 'self'",
|
||||||
"connect-src 'self'",
|
"connect-src 'self'",
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
|
|||||||
@@ -17,17 +17,24 @@
|
|||||||
"@minecraft-account-manager/logging": "*",
|
"@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",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"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/leaflet": "^1.9.22",
|
||||||
"@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",
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default async function AccountPage({
|
|||||||
.orderBy(desc(ipObservations.observedAt))
|
.orderBy(desc(ipObservations.observedAt))
|
||||||
.limit(100),
|
.limit(100),
|
||||||
discordIdentity(user),
|
discordIdentity(user),
|
||||||
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, isDefault: groups.isDefault })
|
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed, isDefault: groups.isDefault })
|
||||||
.from(groups)
|
.from(groups)
|
||||||
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
|
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
|
||||||
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
|
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
|
||||||
@@ -192,8 +192,8 @@ export default async function AccountPage({
|
|||||||
</form>
|
</form>
|
||||||
<section className="mt-8 border border-line bg-panel p-6">
|
<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>
|
<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>}
|
{effectiveGroup ? <div className="mt-4 flex flex-wrap items-center justify-between gap-3"><span className="font-mono text-xs font-bold">{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</span><div className="flex gap-2"><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><span className="border border-line px-2 py-1 font-mono text-[9px] font-bold uppercase">VPN {effectiveGroup.anonymizedNetworksAllowed ? "allowed" : "denied"}</span></div></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>
|
<p className="mt-4 text-xs leading-5 text-muted">Your effective group alone determines Minecraft and VPN/proxy/Tor access.</p>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,32 +1,44 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { appSettings } from "@minecraft-account-manager/database";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { appSettings, events } from "@minecraft-account-manager/database";
|
||||||
|
import { getClientIp } from "@minecraft-account-manager/network";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { parseAdmissionMessages } from "@/lib/admission-settings";
|
||||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
|
|
||||||
export async function saveDiscordSettings(formData: FormData) {
|
export async function saveAdmissionSettings(formData: FormData) {
|
||||||
await requireAdminSession();
|
const admin = await requireAdminSession();
|
||||||
|
const messages = parseAdmissionMessages(formData);
|
||||||
|
if (!messages) redirect("/admin/settings?error=invalid-message");
|
||||||
|
|
||||||
const registrationMessage = String(formData.get("registrationMessage") ?? "").trim();
|
const requestHeaders = await headers();
|
||||||
|
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||||
if (registrationMessage.length < 10 || registrationMessage.length > 500) {
|
await db.transaction(async (tx) => {
|
||||||
redirect("/admin/settings?error=invalid-message");
|
const [previous] = await tx.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||||
}
|
const changedFields = (Object.keys(messages) as Array<keyof typeof messages>)
|
||||||
|
.filter((field) => previous?.[field] !== messages[field]);
|
||||||
await db
|
await tx.insert(appSettings)
|
||||||
.insert(appSettings)
|
.values({ id: "default", ...messages })
|
||||||
.values({
|
.onConflictDoUpdate({
|
||||||
id: "default",
|
target: appSettings.id,
|
||||||
registrationMessage,
|
set: { ...messages, updatedAt: new Date() },
|
||||||
})
|
});
|
||||||
.onConflictDoUpdate({
|
if (changedFields.length) {
|
||||||
target: appSettings.id,
|
await tx.insert(events).values({
|
||||||
set: {
|
id: randomUUID(),
|
||||||
registrationMessage,
|
source: "/web/admin",
|
||||||
updatedAt: new Date(),
|
type: "games.minecraft.account-manager.settings.admission-messages-updated",
|
||||||
},
|
subject: "settings/default",
|
||||||
});
|
time: new Date(),
|
||||||
|
data: { changedFields, adminEmail: admin.email, adminName: admin.name },
|
||||||
|
ipAddress: ipAddress ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
redirect("/admin/settings?saved=1");
|
redirect("/admin/settings?saved=1");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { asc, eq } from "drizzle-orm";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { addGroupMember, assignDefaultGroup, deleteGroup, removeGroupMember, setGroupAccess } from "../actions";
|
import { addGroupMember, assignDefaultGroup, deleteGroup, removeGroupMember, setGroupAccess, setGroupAnonymizedNetworkAccess } from "../actions";
|
||||||
|
|
||||||
const savedMessages: Record<string, string> = {
|
const savedMessages: Record<string, string> = {
|
||||||
created: "Group created with access disabled.",
|
created: "Group created with access disabled.",
|
||||||
access: "Group access policy updated.",
|
access: "Group access policy updated.",
|
||||||
|
"network-access": "Group VPN, proxy, and Tor policy updated.",
|
||||||
"member-added": "User assigned to the group.",
|
"member-added": "User assigned to the group.",
|
||||||
"member-removed": "User returned to the default group.",
|
"member-removed": "User returned to the default group.",
|
||||||
};
|
};
|
||||||
@@ -54,13 +55,22 @@ export default async function GroupPage({
|
|||||||
<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>
|
<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>
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
|
||||||
</div>
|
</div>
|
||||||
<form action={setGroupAccess} className="border-l-2 border-accent pl-5">
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
<input name="groupId" type="hidden" value={group.id} />
|
<form action={setGroupAccess} className="border-l-2 border-accent pl-5">
|
||||||
<input name="accessEnabled" type="hidden" value={group.accessEnabled ? "no" : "yes"} />
|
<input name="groupId" type="hidden" value={group.id} />
|
||||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Minecraft admission</p>
|
<input name="accessEnabled" type="hidden" value={group.accessEnabled ? "no" : "yes"} />
|
||||||
<p className="mt-2 font-display text-2xl font-black uppercase">{group.accessEnabled ? "Allowed" : "Denied"}</p>
|
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Minecraft admission</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>
|
<p className="mt-2 font-display text-2xl font-black uppercase">{group.accessEnabled ? "Allowed" : "Denied"}</p>
|
||||||
</form>
|
<button className="mt-3 font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn access {group.accessEnabled ? "off" : "on"}</button>
|
||||||
|
</form>
|
||||||
|
<form action={setGroupAnonymizedNetworkAccess} className="border-l-2 border-accent pl-5">
|
||||||
|
<input name="groupId" type="hidden" value={group.id} />
|
||||||
|
<input name="anonymizedNetworksAllowed" type="hidden" value={group.anonymizedNetworksAllowed ? "no" : "yes"} />
|
||||||
|
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">VPN / proxy / Tor</p>
|
||||||
|
<p className="mt-2 font-display text-2xl font-black uppercase">{group.anonymizedNetworksAllowed ? "Allowed" : "Denied"}</p>
|
||||||
|
<button className="mt-3 font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn exceptions {group.anonymizedNetworksAllowed ? "off" : "on"}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">{savedMessages[query.saved] ?? "Group updated."}</p>}
|
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">{savedMessages[query.saved] ?? "Group updated."}</p>}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export async function createGroup(formData: FormData) {
|
|||||||
slug,
|
slug,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
accessEnabled: false,
|
accessEnabled: false,
|
||||||
|
anonymizedNetworksAllowed: false,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
}).returning({ id: groups.id });
|
}).returning({ id: groups.id });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -44,6 +45,7 @@ export async function createGroup(formData: FormData) {
|
|||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
accessEnabled: false,
|
accessEnabled: false,
|
||||||
|
anonymizedNetworksAllowed: false,
|
||||||
});
|
});
|
||||||
redirect(groupPath(group.id, "saved=created"));
|
redirect(groupPath(group.id, "saved=created"));
|
||||||
}
|
}
|
||||||
@@ -65,6 +67,33 @@ export async function setGroupAccess(formData: FormData) {
|
|||||||
redirect(groupPath(group.id, "saved=access"));
|
redirect(groupPath(group.id, "saved=access"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setGroupAnonymizedNetworkAccess(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const groupId = String(formData.get("groupId") ?? "");
|
||||||
|
const anonymizedNetworksAllowed = formData.get("anonymizedNetworksAllowed") === "yes";
|
||||||
|
if (!UUID_PATTERN.test(groupId)) redirect("/admin/groups?error=unknown-group");
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||||
|
|
||||||
|
const group = await db.transaction(async (tx) => {
|
||||||
|
const [updated] = await tx.update(groups).set({ anonymizedNetworksAllowed, updatedAt: new Date() })
|
||||||
|
.where(eq(groups.id, groupId)).returning({ id: groups.id, name: groups.name });
|
||||||
|
if (!updated) return null;
|
||||||
|
await tx.insert(events).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
source: "/web/admin",
|
||||||
|
type: "games.minecraft.account-manager.group.anonymized-network-access-updated",
|
||||||
|
subject: `group/${updated.id}`,
|
||||||
|
time: new Date(),
|
||||||
|
data: { name: updated.name, anonymizedNetworksAllowed, adminEmail: admin.email, adminName: admin.name },
|
||||||
|
ipAddress: ipAddress ?? null,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
if (!group) redirect("/admin/groups?error=unknown-group");
|
||||||
|
redirect(groupPath(group.id, "saved=network-access"));
|
||||||
|
}
|
||||||
|
|
||||||
export async function addGroupMember(formData: FormData) {
|
export async function addGroupMember(formData: FormData) {
|
||||||
const admin = await requireAdminSession();
|
const admin = await requireAdminSession();
|
||||||
const groupId = String(formData.get("groupId") ?? "");
|
const groupId = String(formData.get("groupId") ?? "");
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-1 font-mono text-[10px] text-muted">{group.slug} · {memberCount} members</p>
|
<p className="mt-1 font-mono text-[10px] text-muted">{group.slug} · {memberCount} members</p>
|
||||||
</div>
|
</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 className="flex flex-col items-end gap-2"><span className={`px-3 py-2 font-mono text-[9px] font-bold uppercase ${group.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{group.accessEnabled ? "Access on" : "Access off"}</span><span className="font-mono text-[9px] font-bold uppercase text-muted">VPN {group.anonymizedNetworksAllowed ? "allowed" : "denied"}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 min-h-12 text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
|
<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">
|
<div className="mt-5 flex items-center justify-between gap-4 border-t border-line pt-4">
|
||||||
@@ -76,7 +76,7 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi
|
|||||||
<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>
|
<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>
|
</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>
|
<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>
|
<p className="mt-4 text-xs text-muted">New groups start with Minecraft access and VPN/proxy/Tor exceptions disabled.</p>
|
||||||
<button className="mt-6 border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Create group</button>
|
<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>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { events, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
|
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 Link from "next/link";
|
||||||
|
import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { fillDailySeries, type DailyCount } from "@/lib/admin-metrics";
|
import { fillDailySeries, mergeRiskActivity, type DailyCount } from "@/lib/admin-metrics";
|
||||||
|
import { parseUserLocation, parseUserNetwork } from "@/lib/user-location-map";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -13,28 +16,56 @@ export default async function AdminDashboardPage() {
|
|||||||
fourteenDaysAgo.setUTCHours(0, 0, 0, 0);
|
fourteenDaysAgo.setUTCHours(0, 0, 0, 0);
|
||||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1_000);
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1_000);
|
||||||
|
|
||||||
const [registrationRows, [totals], [monthlyActive], riskyActivity, [recentDenials]] = await Promise.all([
|
const [dailyActiveRows, [totals], [monthlyActive], [monthlyAccounts], locationRows, riskyLatestRows, riskySummaryRows, [recentDenials]] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
day: sql<string>`to_char(date_trunc('day', ${users.createdAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
|
day: sql<string>`to_char(date_trunc('day', ${ipObservations.observedAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
|
||||||
count: count(),
|
count: countDistinct(ipObservations.userId),
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(ipObservations)
|
||||||
.where(gte(users.createdAt, fourteenDaysAgo))
|
.where(and(gte(ipObservations.observedAt, fourteenDaysAgo), isNotNull(ipObservations.userId)))
|
||||||
.groupBy(sql`date_trunc('day', ${users.createdAt} at time zone 'UTC')`)
|
.groupBy(sql`date_trunc('day', ${ipObservations.observedAt} at time zone 'UTC')`)
|
||||||
.orderBy(sql`date_trunc('day', ${users.createdAt} 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: count(users.id) }).from(users),
|
||||||
db.select({
|
db.select({
|
||||||
users: countDistinct(ipObservations.userId),
|
users: countDistinct(ipObservations.userId),
|
||||||
accounts: countDistinct(ipObservations.minecraftAccountId),
|
|
||||||
}).from(ipObservations).where(and(
|
}).from(ipObservations).where(and(
|
||||||
gte(ipObservations.observedAt, thirtyDaysAgo),
|
gte(ipObservations.observedAt, thirtyDaysAgo),
|
||||||
isNotNull(ipObservations.userId),
|
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
|
db
|
||||||
.select({
|
.selectDistinctOn([ipObservations.userId], {
|
||||||
|
userId: ipObservations.userId,
|
||||||
|
name: users.firstName,
|
||||||
|
discordUsername: users.discordUsername,
|
||||||
|
primaryUsername: minecraftAccounts.username,
|
||||||
|
classification: ipIntelligence.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))
|
||||||
|
.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`,
|
||||||
|
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
|
||||||
|
.selectDistinctOn([ipObservations.userId], {
|
||||||
id: ipObservations.id,
|
id: ipObservations.id,
|
||||||
classification: ipObservations.classification,
|
classification: ipIntelligence.classification,
|
||||||
observedAt: ipObservations.observedAt,
|
observedAt: ipObservations.observedAt,
|
||||||
source: ipObservations.source,
|
source: ipObservations.source,
|
||||||
userId: users.id,
|
userId: users.id,
|
||||||
@@ -43,17 +74,58 @@ export default async function AdminDashboardPage() {
|
|||||||
accountUsername: minecraftAccounts.username,
|
accountUsername: minecraftAccounts.username,
|
||||||
})
|
})
|
||||||
.from(ipObservations)
|
.from(ipObservations)
|
||||||
.leftJoin(users, eq(users.id, ipObservations.userId))
|
.innerJoin(users, eq(users.id, ipObservations.userId))
|
||||||
.leftJoin(minecraftAccounts, eq(minecraftAccounts.id, ipObservations.minecraftAccountId))
|
.leftJoin(minecraftAccounts, eq(minecraftAccounts.id, ipObservations.minecraftAccountId))
|
||||||
.where(inArray(ipObservations.classification, ["vpn", "proxy", "tor"]))
|
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||||
.orderBy(desc(ipObservations.observedAt))
|
.where(and(
|
||||||
.limit(10),
|
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<string[]>`array_agg(distinct ${ipIntelligence.classification}::text order by ${ipIntelligence.classification}::text)`,
|
||||||
|
sources: sql<string[]>`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(
|
db.select({ count: count() }).from(events).where(and(
|
||||||
eq(events.type, "games.minecraft.account-manager.game.login.denied"),
|
eq(events.type, "games.minecraft.account-manager.game.login.denied"),
|
||||||
gte(events.time, oneDayAgo),
|
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 [];
|
||||||
|
const network = parseUserNetwork(row.intelligence);
|
||||||
|
return [{
|
||||||
|
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,
|
||||||
|
classification: row.classification,
|
||||||
|
networkProvider: network.provider,
|
||||||
|
networkAsn: network.asn,
|
||||||
|
connectionType: network.connectionType,
|
||||||
|
proxy: network.proxy,
|
||||||
|
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">
|
||||||
@@ -63,18 +135,20 @@ export default async function AdminDashboardPage() {
|
|||||||
<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-5 max-w-2xl text-sm leading-6 text-muted">Live, server-rendered registration, activity, and network-risk signals from the account registry.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section aria-label="Key metrics" className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<UserWorldMap locations={locations} unavailableCount={Math.max(0, (totals?.users ?? 0) - locations.length)} />
|
||||||
|
|
||||||
|
<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="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="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="Active Minecraft accounts" value={monthlyAccounts?.accounts ?? 0} detail="Confirmed connections · 30 days" />
|
||||||
<Metric label="Login denials" value={recentDenials?.count ?? 0} detail="Past 24 hours" accent />
|
<Metric label="Login denials" value={recentDenials?.count ?? 0} detail="Past 24 hours" accent />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="mt-10 grid gap-8 lg:grid-cols-[1.3fr_0.7fr]">
|
<div className="mt-10 grid gap-8 lg:grid-cols-[1.3fr_0.7fr]">
|
||||||
<RegistrationChart data={registrations} />
|
<DailyActiveChart data={dailyActive} />
|
||||||
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
<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 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>
|
<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 risky network activity</h2><p className="mt-2 text-xs text-muted">Collapsed per user across VPN, proxy, and Tor observations from the past 30 days.</p></div>
|
||||||
<Link className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" href="/admin/events?category=security">All security events</Link>
|
<Link className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" href="/admin/events?category=security">All security events</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 divide-y divide-line">
|
<div className="mt-5 divide-y divide-line">
|
||||||
@@ -83,9 +157,9 @@ export default async function AdminDashboardPage() {
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<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>}
|
{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>
|
<p className="mt-1 text-xs text-muted">{activity.accountUsername ?? "No Minecraft account"} · {activity.sources.join(" + ")} · {activity.count} {activity.count === 1 ? "observation" : "observations"}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">{activity.classification}</span>
|
<span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">{activity.classifications.join(" + ")}</span>
|
||||||
</div>
|
</div>
|
||||||
<time className="mt-2 block font-mono text-[9px] text-muted" dateTime={activity.observedAt.toISOString()}>{activity.observedAt.toISOString()}</time>
|
<time className="mt-2 block font-mono text-[9px] text-muted" dateTime={activity.observedAt.toISOString()}>{activity.observedAt.toISOString()}</time>
|
||||||
</article>
|
</article>
|
||||||
@@ -108,7 +182,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 width = 720;
|
||||||
const height = 260;
|
const height = 260;
|
||||||
const padding = 32;
|
const padding = 32;
|
||||||
@@ -121,22 +195,21 @@ function RegistrationChart({ data }: { data: DailyCount[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
<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>
|
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Activity signal</p>
|
||||||
<h2 className="mt-2 font-display text-2xl font-black uppercase">New users by day</h2>
|
<h2 className="mt-2 font-display text-2xl font-black uppercase">Daily active users</h2>
|
||||||
<svg aria-labelledby="registration-chart-title registration-chart-description" className="mt-6 h-auto w-full" role="img" viewBox={`0 0 ${width} ${height}`}>
|
<svg aria-labelledby="daily-active-chart-title daily-active-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>
|
<title id="daily-active-chart-title">Daily active users 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>
|
<desc id="daily-active-chart-description">Distinct daily users range from zero to {maximum}. Date-labelled values follow the chart.</desc>
|
||||||
<line stroke="var(--line)" strokeWidth="1" x1={padding} x2={width - padding} y1={height - padding} y2={height - padding} />
|
<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" />
|
<polyline fill="none" points={points} stroke="var(--accent)" strokeLinecap="square" strokeLinejoin="miter" strokeWidth="4" />
|
||||||
{data.map((entry, index) => {
|
{data.map((entry, index) => {
|
||||||
const [x, y] = points.split(" ")[index]!.split(",");
|
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>;
|
return <circle cx={x} cy={y} fill="var(--panel)" key={entry.day} r="5" stroke="var(--ink)" strokeWidth="3"><title>{entry.day}: {entry.count} active users</title></circle>;
|
||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
<dl className="mt-4 grid grid-cols-7 gap-2 border-t border-line pt-4 text-center">
|
<dl className="mt-4 grid grid-cols-7 gap-2 border-t border-line pt-4 text-center sm:grid-cols-[repeat(14,minmax(0,1fr))]">
|
||||||
{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>)}
|
{data.map((entry) => <div key={entry.day}><dt className="font-mono text-[8px] text-muted"><time dateTime={entry.day}>{entry.day.slice(5)}</time></dt><dd className="mt-1 font-mono text-xs font-bold">{entry.count}</dd></div>)}
|
||||||
</dl>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { appSettings } from "@minecraft-account-manager/database";
|
import { appSettings } from "@minecraft-account-manager/database";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { DEFAULT_ADMISSION_MESSAGES } from "@/lib/admission-settings";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { saveDiscordSettings } from "../actions";
|
import { saveAdmissionSettings } from "../actions";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -12,7 +13,11 @@ export default async function SettingsPage({
|
|||||||
}) {
|
}) {
|
||||||
const query = await searchParams;
|
const query = await searchParams;
|
||||||
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
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 messages = {
|
||||||
|
registrationMessage: settings?.registrationMessage ?? DEFAULT_ADMISSION_MESSAGES.registrationMessage,
|
||||||
|
groupAccessDeniedMessage: settings?.groupAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage,
|
||||||
|
vpnDeniedMessage: settings?.vpnDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage,
|
||||||
|
};
|
||||||
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
||||||
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
|
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
|
||||||
|
|
||||||
@@ -29,23 +34,50 @@ export default async function SettingsPage({
|
|||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form action={saveDiscordSettings} className="border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)] sm:p-9">
|
<form action={saveAdmissionSettings} 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.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>}
|
{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>
|
<fieldset className="space-y-7">
|
||||||
<textarea
|
<legend className="font-display text-2xl font-black uppercase">Minecraft denial messages</legend>
|
||||||
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"
|
<p className="text-sm leading-6 text-muted">Each plain-text message is returned for one admission outcome. Messages must be between 10 and 500 characters.</p>
|
||||||
defaultValue={message}
|
<AdmissionMessageField description="Shown when the Minecraft identity is not registered." label="Registration required" name="registrationMessage" value={messages.registrationMessage} />
|
||||||
id="registrationMessage"
|
<AdmissionMessageField description="Shown when the effective group has Minecraft access disabled." label="Group access disabled" name="groupAccessDeniedMessage" value={messages.groupAccessDeniedMessage} />
|
||||||
maxLength={500}
|
<AdmissionMessageField description="Shown for VPN, proxy, or Tor connections when the effective group has no exception." label="VPN, proxy, or Tor denied" name="vpnDeniedMessage" value={messages.vpnDeniedMessage} />
|
||||||
minLength={10}
|
</fieldset>
|
||||||
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>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AdmissionMessageField({
|
||||||
|
description,
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
description: string;
|
||||||
|
label: string;
|
||||||
|
name: "registrationMessage" | "groupAccessDeniedMessage" | "vpnDeniedMessage";
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
const descriptionId = `${name}-description`;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block font-mono text-xs font-bold uppercase tracking-wider" htmlFor={name}>{label}</label>
|
||||||
|
<p className="mt-2 text-xs leading-5 text-muted" id={descriptionId}>{description}</p>
|
||||||
|
<textarea
|
||||||
|
aria-describedby={descriptionId}
|
||||||
|
className="mt-3 min-h-28 w-full resize-y border border-line bg-canvas px-4 py-3 text-sm leading-6 outline-none focus:border-accent"
|
||||||
|
defaultValue={value}
|
||||||
|
id={name}
|
||||||
|
maxLength={500}
|
||||||
|
minLength={10}
|
||||||
|
name={name}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default async function AdminUserPage({
|
|||||||
.orderBy(desc(ipObservations.observedAt))
|
.orderBy(desc(ipObservations.observedAt))
|
||||||
.limit(100),
|
.limit(100),
|
||||||
discordIdentity(user),
|
discordIdentity(user),
|
||||||
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, isDefault: groups.isDefault })
|
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed, isDefault: groups.isDefault })
|
||||||
.from(groups)
|
.from(groups)
|
||||||
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
|
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
|
||||||
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
|
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
|
||||||
@@ -208,7 +208,7 @@ export default async function AdminUserPage({
|
|||||||
|
|
||||||
<section className="border border-line bg-panel p-6">
|
<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>
|
<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>}
|
{effectiveGroup ? <div className="mt-4 flex flex-wrap 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><div className="flex gap-2"><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><span className="border border-line px-2 py-1 font-mono text-[9px] font-bold uppercase">VPN {effectiveGroup.anonymizedNetworksAllowed ? "allowed" : "denied"}</span></div></div> : <p className="mt-4 text-sm text-accent">No effective group configured.</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="border border-line bg-panel p-6">
|
<section className="border border-line bg-panel p-6">
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
formatManagedDiscordNickname,
|
formatManagedDiscordNickname,
|
||||||
lookupJavaProfile,
|
lookupJavaProfile,
|
||||||
updateGuildNickname,
|
updateGuildNickname,
|
||||||
} from "@minecraft-account-manager/minecraft";
|
} from "@minecraft-account-manager/minecraft";
|
||||||
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
|
import { events, groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||||
import { and, eq, isNull, ne } from "drizzle-orm";
|
import { getClientIp } from "@minecraft-account-manager/network";
|
||||||
|
import { and, eq, isNull, ne, sql } from "drizzle-orm";
|
||||||
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { recordAdminEvent } from "@/lib/audit";
|
import { recordAdminEvent } from "@/lib/audit";
|
||||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||||
@@ -19,6 +22,12 @@ function userPath(userId: string, query?: string) {
|
|||||||
return `/admin/users/${encodeURIComponent(userId)}${query ? `?${query}` : ""}`;
|
return `/admin/users/${encodeURIComponent(userId)}${query ? `?${query}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function userRegistryPath(search: string, result: "saved=group" | "error=invalid-group-assignment") {
|
||||||
|
const query = new URLSearchParams(result);
|
||||||
|
if (search) query.set("q", search);
|
||||||
|
return `/admin/users?${query.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function targetUser(userId: string) {
|
async function targetUser(userId: string) {
|
||||||
if (!UUID_PATTERN.test(userId)) return null;
|
if (!UUID_PATTERN.test(userId)) return null;
|
||||||
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);
|
||||||
@@ -72,6 +81,61 @@ async function recordSyncFailure(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function assignUserGroupFromRegistry(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const userId = String(formData.get("userId") ?? "");
|
||||||
|
const groupId = String(formData.get("groupId") ?? "");
|
||||||
|
const search = String(formData.get("search") ?? "").trim().slice(0, 100);
|
||||||
|
if (!UUID_PATTERN.test(userId) || !UUID_PATTERN.test(groupId)) redirect(userRegistryPath(search, "error=invalid-group-assignment"));
|
||||||
|
|
||||||
|
const [[user], [targetGroup]] = await Promise.all([
|
||||||
|
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
|
||||||
|
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
|
||||||
|
]);
|
||||||
|
if (!user || !targetGroup) redirect(userRegistryPath(search, "error=invalid-group-assignment"));
|
||||||
|
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||||
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.execute(sql`select ${users.id} from ${users} where ${users.id} = ${user.id} for update`);
|
||||||
|
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);
|
||||||
|
if (targetGroup.isDefault) {
|
||||||
|
await tx.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
|
||||||
|
} else {
|
||||||
|
await tx.insert(userGroupMemberships).values({ userId: user.id, groupId: targetGroup.id })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: userGroupMemberships.userId,
|
||||||
|
set: { groupId: targetGroup.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.insert(events).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
source: "/web/admin",
|
||||||
|
type: "games.minecraft.account-manager.group.assignment-updated",
|
||||||
|
subject: `user/${user.id}`,
|
||||||
|
time: new Date(),
|
||||||
|
data: {
|
||||||
|
groupId: targetGroup.id,
|
||||||
|
groupName: targetGroup.name,
|
||||||
|
previousGroupId: previous?.id ?? null,
|
||||||
|
previousGroupName: previous?.name ?? "everyone",
|
||||||
|
adminEmail: admin.email,
|
||||||
|
adminName: admin.name,
|
||||||
|
},
|
||||||
|
ipAddress: ipAddress ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
redirect(userRegistryPath(search, "error=invalid-group-assignment"));
|
||||||
|
}
|
||||||
|
redirect(userRegistryPath(search, "saved=group"));
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateUserName(formData: FormData) {
|
export async function updateUserName(formData: FormData) {
|
||||||
const admin = await requireAdminSession();
|
const admin = await requireAdminSession();
|
||||||
const userId = String(formData.get("userId") ?? "");
|
const userId = String(formData.get("userId") ?? "");
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
|
import { groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||||
import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { UserGroupSelect } from "@/components/user-group-select";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
|
import { assignUserGroupFromRegistry } from "./actions";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function AdminUsersPage({
|
export default async function AdminUsersPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ q?: string; error?: string }>;
|
searchParams: Promise<{ q?: string; error?: string; saved?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const query = await searchParams;
|
const query = await searchParams;
|
||||||
const search = query.q?.trim().slice(0, 100) ?? "";
|
const search = query.q?.trim().slice(0, 100) ?? "";
|
||||||
@@ -31,33 +33,41 @@ export default async function AdminUsersPage({
|
|||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const results = await db
|
const [results, allGroups, memberships] = await Promise.all([
|
||||||
.select({
|
db
|
||||||
id: users.id,
|
.select({
|
||||||
firstName: users.firstName,
|
id: users.id,
|
||||||
discordUsername: users.discordUsername,
|
firstName: users.firstName,
|
||||||
discordGlobalName: users.discordGlobalName,
|
discordUsername: users.discordUsername,
|
||||||
discordUserId: users.discordUserId,
|
discordGlobalName: users.discordGlobalName,
|
||||||
onboardingCompletedAt: users.onboardingCompletedAt,
|
discordUserId: users.discordUserId,
|
||||||
primaryUsername: minecraftAccounts.username,
|
onboardingCompletedAt: users.onboardingCompletedAt,
|
||||||
accountCount: sql<number>`(
|
primaryUsername: minecraftAccounts.username,
|
||||||
select count(*)::int from ${minecraftAccounts} account_count
|
accountCount: sql<number>`(
|
||||||
where account_count.user_id = ${users.id}
|
select count(*)::int from ${minecraftAccounts} account_count
|
||||||
and account_count.deleted_at is null
|
where account_count.user_id = ${users.id}
|
||||||
)`,
|
and account_count.deleted_at is null
|
||||||
})
|
)`,
|
||||||
.from(users)
|
})
|
||||||
.leftJoin(
|
.from(users)
|
||||||
minecraftAccounts,
|
.leftJoin(
|
||||||
and(
|
minecraftAccounts,
|
||||||
eq(minecraftAccounts.userId, users.id),
|
and(
|
||||||
eq(minecraftAccounts.isPrimary, true),
|
eq(minecraftAccounts.userId, users.id),
|
||||||
isNull(minecraftAccounts.deletedAt),
|
eq(minecraftAccounts.isPrimary, true),
|
||||||
),
|
isNull(minecraftAccounts.deletedAt),
|
||||||
)
|
),
|
||||||
.where(where)
|
)
|
||||||
.orderBy(users.firstName, users.discordUsername)
|
.where(where)
|
||||||
.limit(100);
|
.orderBy(users.firstName, users.discordUsername)
|
||||||
|
.limit(100),
|
||||||
|
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault })
|
||||||
|
.from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
|
||||||
|
db.select({ userId: userGroupMemberships.userId, groupId: userGroupMemberships.groupId })
|
||||||
|
.from(userGroupMemberships),
|
||||||
|
]);
|
||||||
|
const defaultGroup = allGroups.find((group) => group.isDefault);
|
||||||
|
const groupByUser = new Map(memberships.map((membership) => [membership.userId, membership.groupId]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||||
@@ -79,13 +89,14 @@ 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" role="alert">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">{query.error === "invalid-group-assignment" ? "The user or group no longer exists. No group change was applied." : "The requested user could not be found."}</p>}
|
||||||
|
{query.saved === "group" && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">User group updated.</p>}
|
||||||
|
|
||||||
<div className="mt-8 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
<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-[900px] border-collapse text-left">
|
||||||
<caption className="sr-only">Registered portal users</caption>
|
<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" 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>
|
<tr><th className="p-4" scope="col">User</th><th className="p-4" scope="col">Discord</th><th className="p-4" scope="col">Primary</th><th className="p-4" scope="col">Accounts</th><th className="p-4" scope="col">Group</th><th className="p-4" scope="col">Status</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-line">
|
<tbody className="divide-y divide-line">
|
||||||
{results.map((user) => (
|
{results.map((user) => (
|
||||||
@@ -94,10 +105,11 @@ export default async function AdminUsersPage({
|
|||||||
<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"><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">{defaultGroup ? <UserGroupSelect action={assignUserGroupFromRegistry} effectiveGroupId={groupByUser.get(user.id) ?? defaultGroup.id} groups={allGroups} search={search} userId={user.id} userLabel={user.firstName ?? user.discordUsername} /> : <span className="text-xs text-accent">Default group missing</span>}</td>
|
||||||
<td className="p-4"><span className={`border px-2 py-1 font-mono text-[9px] uppercase tracking-wider ${user.onboardingCompletedAt ? "border-line text-muted" : "border-accent text-accent"}`}>{user.onboardingCompletedAt ? "Ready" : "Onboarding"}</span></td>
|
<td className="p-4"><span className={`border px-2 py-1 font-mono text-[9px] uppercase tracking-wider ${user.onboardingCompletedAt ? "border-line text-muted" : "border-accent text-accent"}`}>{user.onboardingCompletedAt ? "Ready" : "Onboarding"}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{!results.length && <tr><td className="p-8 text-muted" colSpan={5}>No users match that search.</td></tr>}
|
{!results.length && <tr><td className="p-8 text-muted" colSpan={6}>No users match that search.</td></tr>}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { isRequestTimestampFresh, resolveEffectiveGroup, verifyHashedToken } from "@minecraft-account-manager/auth";
|
import { gameAdmissionDenialReason, 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,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} 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 { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES } from "@/lib/admission-settings";
|
||||||
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";
|
||||||
@@ -20,7 +21,6 @@ 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;
|
||||||
const DEFAULT_DENIAL_MESSAGE = "Please register your Minecraft account before joining.";
|
|
||||||
|
|
||||||
function methodNotAllowed(request: Request) {
|
function methodNotAllowed(request: Request) {
|
||||||
const response = problemResponse(problemDetails(
|
const response = problemResponse(problemDetails(
|
||||||
@@ -111,35 +111,13 @@ async function handleVelocityAccess(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||||
const denialMessage = settings?.registrationMessage ?? DEFAULT_DENIAL_MESSAGE;
|
const admissionMessages = {
|
||||||
|
registrationMessage: settings?.registrationMessage ?? DEFAULT_ADMISSION_MESSAGES.registrationMessage,
|
||||||
|
groupAccessDeniedMessage: settings?.groupAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage,
|
||||||
|
vpnDeniedMessage: settings?.vpnDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage,
|
||||||
|
};
|
||||||
|
|
||||||
let [knownAccount] = await db
|
const intelligence = await getIpIntelligence(input.ipAddress);
|
||||||
.select({ id: minecraftAccounts.id })
|
|
||||||
.from(minecraftAccounts)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(minecraftAccounts.minecraftUuid, input.minecraftUuid),
|
|
||||||
isNull(minecraftAccounts.deletedAt),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (!knownAccount) {
|
|
||||||
[knownAccount] = await db
|
|
||||||
.select({ id: minecraftAccounts.id })
|
|
||||||
.from(minecraftAccounts)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
isNull(minecraftAccounts.minecraftUuid),
|
|
||||||
sql`lower(${minecraftAccounts.username}) = lower(${input.username})`,
|
|
||||||
isNull(minecraftAccounts.deletedAt),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const intelligence = knownAccount
|
|
||||||
? await getIpIntelligence(input.ipAddress)
|
|
||||||
: { classification: "unknown" as const, provider: null };
|
|
||||||
const auditIpData = toAuditIpData(intelligence);
|
const auditIpData = toAuditIpData(intelligence);
|
||||||
|
|
||||||
const decision = await db.transaction(async (tx) => {
|
const decision = await db.transaction(async (tx) => {
|
||||||
@@ -209,23 +187,24 @@ async function handleVelocityAccess(request: Request) {
|
|||||||
classification: intelligence.classification,
|
classification: intelligence.classification,
|
||||||
observedAt: occurredAt,
|
observedAt: occurredAt,
|
||||||
});
|
});
|
||||||
return { allowed: false as const, message: denialMessage };
|
return { allowed: false as const, message: admissionDenialMessage("not_registered", admissionMessages) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [explicitGroup] = await tx
|
const [explicitGroup] = await tx
|
||||||
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled })
|
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed })
|
||||||
.from(userGroupMemberships)
|
.from(userGroupMemberships)
|
||||||
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
||||||
.where(eq(userGroupMemberships.userId, account.userId))
|
.where(eq(userGroupMemberships.userId, account.userId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const [defaultGroup] = await tx
|
const [defaultGroup] = await tx
|
||||||
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled })
|
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed })
|
||||||
.from(groups)
|
.from(groups)
|
||||||
.where(eq(groups.isDefault, true))
|
.where(eq(groups.isDefault, true))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const effectiveGroup = resolveEffectiveGroup(explicitGroup ?? null, defaultGroup ?? null);
|
const effectiveGroup = resolveEffectiveGroup(explicitGroup ?? null, defaultGroup ?? null);
|
||||||
|
|
||||||
if (!effectiveGroup?.accessEnabled) {
|
const denialReason = gameAdmissionDenialReason(effectiveGroup ?? null, intelligence.classification);
|
||||||
|
if (denialReason) {
|
||||||
await tx.insert(events).values({
|
await tx.insert(events).values({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
source: `/velocity/${input.serverId}`,
|
source: `/velocity/${input.serverId}`,
|
||||||
@@ -235,7 +214,9 @@ async function handleVelocityAccess(request: Request) {
|
|||||||
actorUserId: account.userId,
|
actorUserId: account.userId,
|
||||||
data: {
|
data: {
|
||||||
username: input.username,
|
username: input.username,
|
||||||
reason: "group_access_disabled",
|
reason: denialReason,
|
||||||
|
accessGroup: effectiveGroup?.name ?? null,
|
||||||
|
accessGroupId: effectiveGroup?.id ?? null,
|
||||||
ipIntelligence: auditIpData,
|
ipIntelligence: auditIpData,
|
||||||
},
|
},
|
||||||
ipAddress: input.ipAddress,
|
ipAddress: input.ipAddress,
|
||||||
@@ -251,9 +232,14 @@ async function handleVelocityAccess(request: Request) {
|
|||||||
classification: intelligence.classification,
|
classification: intelligence.classification,
|
||||||
observedAt: occurredAt,
|
observedAt: occurredAt,
|
||||||
});
|
});
|
||||||
return { allowed: false as const, message: "Your account group does not currently have server access." };
|
return {
|
||||||
|
allowed: false as const,
|
||||||
|
message: admissionDenialMessage(denialReason, admissionMessages),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!effectiveGroup) throw new Error("Effective access group is unavailable after admission approval");
|
||||||
|
|
||||||
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)
|
||||||
@@ -304,6 +290,7 @@ async function handleVelocityAccess(request: Request) {
|
|||||||
uuidBackfilled: account.minecraftUuid === null,
|
uuidBackfilled: account.minecraftUuid === null,
|
||||||
ipIntelligence: auditIpData,
|
ipIntelligence: auditIpData,
|
||||||
accessGroup: effectiveGroup.name,
|
accessGroup: effectiveGroup.name,
|
||||||
|
accessGroupId: effectiveGroup.id,
|
||||||
},
|
},
|
||||||
ipAddress: input.ipAddress,
|
ipAddress: input.ipAddress,
|
||||||
correlationId: input.requestId,
|
correlationId: input.requestId,
|
||||||
|
|||||||
@@ -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<string, unknown>[],
|
||||||
|
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<unknown>) => callback({
|
||||||
|
delete: () => ({ where: async () => undefined }),
|
||||||
|
insert: () => ({
|
||||||
|
values: async (value: Record<string, unknown>) => {
|
||||||
|
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<string, unknown> = {}) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@import "leaflet/dist/leaflet.css";
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -58,6 +59,34 @@ body {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svg a:hover .map-marker,
|
||||||
|
svg a:focus .map-marker {
|
||||||
|
stroke: var(--ink);
|
||||||
|
stroke-width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker-tooltip {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker-link:hover .map-marker-tooltip,
|
||||||
|
.map-marker-link:focus .map-marker-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-user-cluster {
|
||||||
|
display: grid !important;
|
||||||
|
place-items: center;
|
||||||
|
border: 3px solid var(--panel);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--panel);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 0 0 1px var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--panel);
|
color: var(--panel);
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { groupMapLocations } from "@/lib/user-location-map";
|
||||||
|
import type { UserMapLocation } from "./user-world-map";
|
||||||
|
|
||||||
|
export function MapViewToggle({ locations, children }: { locations: UserMapLocation[]; children: ReactNode }) {
|
||||||
|
const [view, setView] = useState<"overview" | "interactive">("overview");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6">
|
||||||
|
<div aria-label="Map view" className="flex flex-wrap gap-2" role="group">
|
||||||
|
<button aria-controls="map-overview-panel" aria-pressed={view === "overview"} className={`border px-4 py-2 font-mono text-[10px] font-bold uppercase ${view === "overview" ? "border-ink bg-ink text-canvas" : "border-line"}`} id="map-overview-tab" onClick={() => setView("overview")} type="button">World overview</button>
|
||||||
|
<button aria-controls="map-interactive-panel" aria-pressed={view === "interactive"} className={`border px-4 py-2 font-mono text-[10px] font-bold uppercase ${view === "interactive" ? "border-ink bg-ink text-canvas" : "border-line"}`} id="map-interactive-tab" onClick={() => setView("interactive")} type="button">Interactive OpenStreetMap</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 max-w-2xl text-[10px] leading-4 text-muted">Selecting the interactive view requests map tiles from OpenStreetMap, which receives your IP address, the portal origin, and the geographic area being viewed.</p>
|
||||||
|
<div aria-labelledby="map-overview-tab" hidden={view !== "overview"} id="map-overview-panel" role="region">{children}</div>
|
||||||
|
<div aria-labelledby="map-interactive-tab" hidden={view !== "interactive"} id="map-interactive-panel" role="region">
|
||||||
|
{view === "interactive" && <InteractiveMap locations={locations} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InteractiveMap({ locations }: { locations: UserMapLocation[] }) {
|
||||||
|
const container = useRef<HTMLDivElement>(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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>',
|
||||||
|
maxZoom: 19,
|
||||||
|
referrerPolicy: "strict-origin-when-cross-origin",
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const bounds: [number, number][] = [];
|
||||||
|
for (const group of groupMapLocations(locations)) {
|
||||||
|
const firstUser = group.locations[0]!;
|
||||||
|
const isGrouped = group.count > 1;
|
||||||
|
const marker = isGrouped
|
||||||
|
? leaflet.marker([group.latitude, group.longitude], {
|
||||||
|
icon: leaflet.divIcon({
|
||||||
|
className: "map-user-cluster",
|
||||||
|
html: `<span aria-hidden="true">${group.count}</span>`,
|
||||||
|
iconAnchor: [18, 18],
|
||||||
|
iconSize: [36, 36],
|
||||||
|
}),
|
||||||
|
keyboard: true,
|
||||||
|
}).addTo(map)
|
||||||
|
: leaflet.circleMarker([group.latitude, group.longitude], {
|
||||||
|
radius: 8,
|
||||||
|
color: "#eee8d8",
|
||||||
|
weight: 3,
|
||||||
|
fillColor: "#a32f1b",
|
||||||
|
fillOpacity: 1,
|
||||||
|
}).addTo(map);
|
||||||
|
const tooltip = document.createElement("span");
|
||||||
|
tooltip.textContent = isGrouped
|
||||||
|
? `${group.count} users · ${group.nicknames.join(" · ")}`
|
||||||
|
: `${firstUser.nickname} · ${firstUser.location}`;
|
||||||
|
marker.bindTooltip(tooltip, { direction: "top" });
|
||||||
|
|
||||||
|
if (isGrouped) {
|
||||||
|
const popup = document.createElement("div");
|
||||||
|
const heading = document.createElement("strong");
|
||||||
|
heading.textContent = `${group.count} users near ${firstUser.location}`;
|
||||||
|
popup.append(heading);
|
||||||
|
const list = document.createElement("ul");
|
||||||
|
for (const user of group.locations) {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = `/admin/users/${user.userId}`;
|
||||||
|
link.textContent = user.nickname;
|
||||||
|
item.append(link);
|
||||||
|
list.append(item);
|
||||||
|
}
|
||||||
|
popup.append(list);
|
||||||
|
marker.bindPopup(popup);
|
||||||
|
} else {
|
||||||
|
marker.on("click", () => window.location.assign(`/admin/users/${firstUser.userId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = marker.getElement();
|
||||||
|
const label = isGrouped
|
||||||
|
? `${group.count} users near ${firstUser.location}: ${group.nicknames.join(", ")}`
|
||||||
|
: `${firstUser.nickname}, ${firstUser.location}`;
|
||||||
|
element?.setAttribute("aria-label", label);
|
||||||
|
element?.setAttribute("role", isGrouped ? "button" : "link");
|
||||||
|
element?.setAttribute("tabindex", "0");
|
||||||
|
if (isGrouped) {
|
||||||
|
element?.setAttribute("aria-haspopup", "dialog");
|
||||||
|
element?.setAttribute("aria-expanded", "false");
|
||||||
|
marker.on("popupopen", () => element?.setAttribute("aria-expanded", "true"));
|
||||||
|
marker.on("popupclose", () => element?.setAttribute("aria-expanded", "false"));
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
if (isGrouped) marker.openPopup();
|
||||||
|
else window.location.assign(`/admin/users/${firstUser.userId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bounds.push([group.latitude, group.longitude]);
|
||||||
|
}
|
||||||
|
if (bounds.length) map.fitBounds(bounds, { padding: [40, 40], maxZoom: 6 });
|
||||||
|
cleanup = () => map.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
}, [locations]);
|
||||||
|
|
||||||
|
return <div aria-label="Interactive map of latest approximate user locations" className="mt-3 h-[32rem] max-h-[70vh] min-h-80 border border-line" ref={container} role="region" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { UserGroupSelect } from "./user-group-select";
|
||||||
|
|
||||||
|
describe("UserGroupSelect", () => {
|
||||||
|
it("renders the effective group and preserves the active search", () => {
|
||||||
|
const markup = renderToStaticMarkup(<UserGroupSelect
|
||||||
|
action={async () => undefined}
|
||||||
|
effectiveGroupId="group-ops"
|
||||||
|
groups={[{ id: "group-everyone", name: "everyone" }, { id: "group-ops", name: "Ops" }]}
|
||||||
|
search="alex smith"
|
||||||
|
userId="user-one"
|
||||||
|
userLabel="Alex"
|
||||||
|
/>);
|
||||||
|
|
||||||
|
expect(markup).toContain('aria-label="Group for Alex"');
|
||||||
|
expect(markup).toContain('<option value="group-ops" selected="">Ops</option>');
|
||||||
|
expect(markup).toContain('<input type="hidden" name="search" value="alex smith"/>');
|
||||||
|
expect(markup).toContain("Apply group");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormStatus } from "react-dom";
|
||||||
|
|
||||||
|
export function UserGroupSelect({
|
||||||
|
action,
|
||||||
|
effectiveGroupId,
|
||||||
|
groups,
|
||||||
|
search,
|
||||||
|
userId,
|
||||||
|
userLabel,
|
||||||
|
}: {
|
||||||
|
action: (formData: FormData) => Promise<void>;
|
||||||
|
effectiveGroupId: string;
|
||||||
|
groups: Array<{ id: string; name: string }>;
|
||||||
|
search: string;
|
||||||
|
userId: string;
|
||||||
|
userLabel: string;
|
||||||
|
}) {
|
||||||
|
const helpId = `group-help-${userId}`;
|
||||||
|
return (
|
||||||
|
<form action={action} className="flex items-center gap-2">
|
||||||
|
<input name="userId" type="hidden" value={userId} />
|
||||||
|
<input name="search" type="hidden" value={search} />
|
||||||
|
<span className="sr-only" id={helpId}>Changing this selection applies the group immediately.</span>
|
||||||
|
<GroupSelectControl effectiveGroupId={effectiveGroupId} groups={groups} helpId={helpId} userLabel={userLabel} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupSelectControl({
|
||||||
|
effectiveGroupId,
|
||||||
|
groups,
|
||||||
|
helpId,
|
||||||
|
userLabel,
|
||||||
|
}: {
|
||||||
|
effectiveGroupId: string;
|
||||||
|
groups: Array<{ id: string; name: string }>;
|
||||||
|
helpId: string;
|
||||||
|
userLabel: string;
|
||||||
|
}) {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
aria-describedby={helpId}
|
||||||
|
aria-label={`Group for ${userLabel}`}
|
||||||
|
className="max-w-44 border border-line bg-canvas px-3 py-2 font-mono text-xs outline-none focus:border-accent disabled:cursor-wait disabled:opacity-60"
|
||||||
|
defaultValue={effectiveGroupId}
|
||||||
|
disabled={pending}
|
||||||
|
name="groupId"
|
||||||
|
onChange={(event) => event.currentTarget.form?.requestSubmit()}
|
||||||
|
>
|
||||||
|
{groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<span aria-live="polite" className="sr-only">{pending ? "Updating group." : ""}</span>
|
||||||
|
<button className="sr-only focus:not-sr-only" disabled={pending} type="submit">Apply group</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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",
|
||||||
|
nickname: "Dani (Steve)",
|
||||||
|
latitude: 37.4056,
|
||||||
|
longitude: -122.0775,
|
||||||
|
location: "Mountain View, California, US",
|
||||||
|
classification: "clear",
|
||||||
|
networkProvider: "Comcast Cable Communications, LLC",
|
||||||
|
networkAsn: "AS7922",
|
||||||
|
connectionType: "Residential",
|
||||||
|
proxy: false,
|
||||||
|
source: "game",
|
||||||
|
observedAt: new Date("2026-08-01T12:00:00Z"),
|
||||||
|
}, {
|
||||||
|
userId: "22222222-2222-4222-8222-222222222222",
|
||||||
|
name: "Alex",
|
||||||
|
discordUsername: "alex",
|
||||||
|
nickname: "Alex (AlexMC)",
|
||||||
|
latitude: 37.4057,
|
||||||
|
longitude: -122.0774,
|
||||||
|
location: "Mountain View, California, US",
|
||||||
|
classification: "vpn",
|
||||||
|
networkProvider: "Proton AG",
|
||||||
|
networkAsn: "AS62371",
|
||||||
|
connectionType: "VPN",
|
||||||
|
proxy: true,
|
||||||
|
source: "web",
|
||||||
|
observedAt: new Date("2026-08-01T13: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("Dani (Steve)");
|
||||||
|
expect(markup).toContain("Alex (AlexMC)");
|
||||||
|
expect(markup).toContain("2 users near Mountain View, California, US");
|
||||||
|
expect(markup).toMatch(/<text[^>]*>2<\/text>/);
|
||||||
|
expect(markup).toContain('<details class="mt-5 border-t border-line pt-4" id="map-location-list" open="">');
|
||||||
|
expect(markup).toContain("Mountain View, California, US");
|
||||||
|
expect(markup).toContain("Comcast Cable Communications, LLC");
|
||||||
|
expect(markup).toContain("AS7922");
|
||||||
|
expect(markup).toContain("Residential");
|
||||||
|
expect(markup).toContain("Proton AG");
|
||||||
|
expect(markup).toContain(">Proxy/VPN<");
|
||||||
|
expect(markup).toContain(">Yes<");
|
||||||
|
expect(markup).toContain(">No<");
|
||||||
|
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");
|
||||||
|
|
||||||
|
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,117 @@
|
|||||||
|
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";
|
||||||
|
import { groupMapLocations } from "@/lib/user-location-map";
|
||||||
|
import { MapViewToggle } from "./map-view-toggle";
|
||||||
|
|
||||||
|
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;
|
||||||
|
nickname: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
location: string;
|
||||||
|
classification: string;
|
||||||
|
networkProvider: string | null;
|
||||||
|
networkAsn: string | null;
|
||||||
|
connectionType: string | null;
|
||||||
|
proxy: boolean | null;
|
||||||
|
source: string;
|
||||||
|
observedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserWorldMap({ locations, unavailableCount }: { locations: UserMapLocation[]; unavailableCount: number }) {
|
||||||
|
const locationGroups = groupMapLocations(locations);
|
||||||
|
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>
|
||||||
|
|
||||||
|
<MapViewToggle locations={locations}>
|
||||||
|
<div className="mt-3 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>
|
||||||
|
{locationGroups.map((group) => {
|
||||||
|
const projected = projection([group.longitude, group.latitude]);
|
||||||
|
if (!projected) return null;
|
||||||
|
const x = Math.min(WIDTH - 16, Math.max(16, projected[0]));
|
||||||
|
const y = Math.min(HEIGHT - 16, Math.max(16, projected[1]));
|
||||||
|
const markerRadius = group.count > 1 ? 13 : 7;
|
||||||
|
const longestNickname = Math.max(...group.nicknames.map((nickname) => nickname.length));
|
||||||
|
const tooltipColumns = Math.ceil(group.nicknames.length / 10);
|
||||||
|
const tooltipRows = Math.ceil(group.nicknames.length / tooltipColumns);
|
||||||
|
const tooltipWidth = Math.min(WIDTH - 8, Math.max(110, longestNickname * 8 + 24) * tooltipColumns);
|
||||||
|
const tooltipColumnWidth = tooltipWidth / tooltipColumns;
|
||||||
|
const tooltipHeight = tooltipRows * 18 + 10;
|
||||||
|
const tooltipX = Math.min(WIDTH - tooltipWidth - 4, Math.max(4, x - tooltipWidth / 2));
|
||||||
|
const preferredTooltipY = y > tooltipHeight + 18 ? y - tooltipHeight - 12 : y + 18;
|
||||||
|
const tooltipY = Math.min(HEIGHT - tooltipHeight - 4, Math.max(4, preferredTooltipY));
|
||||||
|
const firstUser = group.locations[0]!;
|
||||||
|
const label = group.count === 1
|
||||||
|
? `${firstUser.nickname}, ${firstUser.location}`
|
||||||
|
: `${group.count} users near ${firstUser.location}: ${group.nicknames.join(", ")}`;
|
||||||
|
return (
|
||||||
|
<a aria-label={label} className="map-marker-link" href={group.count === 1 ? `/admin/users/${firstUser.userId}` : "#map-location-list"} key={group.key}>
|
||||||
|
<circle className="map-marker-target" cx={x} cy={y} fill="none" pointerEvents="stroke" r={markerRadius} stroke="transparent" strokeWidth="24" vectorEffect="non-scaling-stroke">
|
||||||
|
<title>{label}</title>
|
||||||
|
</circle>
|
||||||
|
<circle className="map-marker" cx={x} cy={y} fill="var(--accent)" pointerEvents="none" r={markerRadius} stroke="var(--panel)" strokeWidth="3" />
|
||||||
|
{group.count > 1 && <text aria-hidden="true" dominantBaseline="middle" fill="var(--panel)" fontFamily="var(--font-mono)" fontSize="12" fontWeight="700" pointerEvents="none" textAnchor="middle" x={x} y={y}>{group.count}</text>}
|
||||||
|
<g aria-hidden="true" className="map-marker-tooltip" pointerEvents="none">
|
||||||
|
<rect fill="var(--ink)" height={tooltipHeight} rx="2" width={tooltipWidth} x={tooltipX} y={tooltipY} />
|
||||||
|
{group.nicknames.map((nickname, index) => {
|
||||||
|
const column = Math.floor(index / tooltipRows);
|
||||||
|
const row = index % tooltipRows;
|
||||||
|
return <text dominantBaseline="middle" fill="var(--panel)" fontFamily="var(--font-mono)" fontSize="12" key={`${nickname}-${index}`} textAnchor="middle" x={tooltipX + tooltipColumnWidth * (column + 0.5)} y={tooltipY + 14 + row * 18}>{nickname}</text>;
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</MapViewToggle>
|
||||||
|
<p className="mt-2 text-right font-mono text-[9px] text-muted">Map boundaries: Natural Earth, public domain</p>
|
||||||
|
|
||||||
|
<details className="mt-5 border-t border-line pt-4" id="map-location-list" open={locationGroups.some((group) => group.count > 1)}>
|
||||||
|
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase underline underline-offset-4">View accessible location list</summary>
|
||||||
|
<div className="mt-4 overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[980px] border-collapse text-left text-xs">
|
||||||
|
<caption className="sr-only">Latest approximate registered-user locations and enriched network details</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">Connection</th><th className="p-3" scope="col">Proxy/VPN</th><th className="p-3" scope="col">Risk</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.nickname}</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"><span className="block">{user.networkProvider ?? "Unknown"}</span>{user.networkAsn && <span className="mt-1 block font-mono text-[9px] text-muted">{user.networkAsn}</span>}</td><td className="p-3">{user.connectionType ?? "Unknown"}</td><td className="p-3 font-mono font-bold uppercase">{user.proxy === null ? "Unknown" : user.proxy ? "Yes" : "No"}</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={8}>No user observations currently include valid coordinates.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { fillDailySeries } from "./admin-metrics";
|
import { fillDailySeries, mergeRiskActivity } from "./admin-metrics";
|
||||||
|
|
||||||
describe("admin dashboard metrics", () => {
|
describe("admin dashboard metrics", () => {
|
||||||
it("fills missing UTC registration days with zero", () => {
|
it("fills missing UTC registration days with zero", () => {
|
||||||
@@ -13,4 +13,20 @@ describe("admin dashboard metrics", () => {
|
|||||||
{ day: "2026-08-01", count: 1 },
|
{ 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" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,19 @@ export interface DailyCount {
|
|||||||
count: number;
|
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) {
|
export function fillDailySeries(rows: DailyCount[], end: Date, days: number) {
|
||||||
const counts = new Map(rows.map((row) => [row.day, Number(row.count)]));
|
const counts = new Map(rows.map((row) => [row.day, Number(row.count)]));
|
||||||
const endDay = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate()));
|
const endDay = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate()));
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES, parseAdmissionMessages } from "./admission-settings";
|
||||||
|
|
||||||
|
describe("admission message settings", () => {
|
||||||
|
it("normalizes three independently configured denial messages", () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("registrationMessage", " Register your account before joining. ");
|
||||||
|
formData.set("groupAccessDeniedMessage", "This group cannot access the server right now.");
|
||||||
|
formData.set("vpnDeniedMessage", "VPN access requires a host-approved exception.");
|
||||||
|
|
||||||
|
expect(parseAdmissionMessages(formData)).toEqual({
|
||||||
|
registrationMessage: "Register your account before joining.",
|
||||||
|
groupAccessDeniedMessage: "This group cannot access the server right now.",
|
||||||
|
vpnDeniedMessage: "VPN access requires a host-approved exception.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects the configured message for each admission denial reason", () => {
|
||||||
|
expect(admissionDenialMessage("not_registered", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.registrationMessage);
|
||||||
|
expect(admissionDenialMessage("group_access_disabled", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage);
|
||||||
|
expect(admissionDenialMessage("anonymized_network_disallowed", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing, short, overlong, or control-character messages", () => {
|
||||||
|
for (const invalid of ["short", "a".repeat(501), "Denied\nInjected"] as const) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("registrationMessage", DEFAULT_ADMISSION_MESSAGES.registrationMessage);
|
||||||
|
formData.set("groupAccessDeniedMessage", DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage);
|
||||||
|
formData.set("vpnDeniedMessage", invalid);
|
||||||
|
expect(parseAdmissionMessages(formData)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export interface AdmissionMessages {
|
||||||
|
registrationMessage: string;
|
||||||
|
groupAccessDeniedMessage: string;
|
||||||
|
vpnDeniedMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_ADMISSION_MESSAGES: AdmissionMessages = {
|
||||||
|
registrationMessage: "Please register your Minecraft account before joining.",
|
||||||
|
groupAccessDeniedMessage: "Your account group does not currently have server access. Contact a host if you believe this is a mistake.",
|
||||||
|
vpnDeniedMessage: "VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception.",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function admissionDenialMessage(
|
||||||
|
reason: "not_registered" | "group_access_disabled" | "anonymized_network_disallowed",
|
||||||
|
messages: AdmissionMessages,
|
||||||
|
) {
|
||||||
|
if (reason === "not_registered") return messages.registrationMessage;
|
||||||
|
if (reason === "group_access_disabled") return messages.groupAccessDeniedMessage;
|
||||||
|
return messages.vpnDeniedMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
||||||
|
|
||||||
|
function messageValue(formData: FormData, name: string) {
|
||||||
|
const value = String(formData.get(name) ?? "").trim();
|
||||||
|
return value.length >= 10 && value.length <= 500 && !CONTROL_CHARACTERS.test(value)
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAdmissionMessages(formData: FormData) {
|
||||||
|
const registrationMessage = messageValue(formData, "registrationMessage");
|
||||||
|
const groupAccessDeniedMessage = messageValue(formData, "groupAccessDeniedMessage");
|
||||||
|
const vpnDeniedMessage = messageValue(formData, "vpnDeniedMessage");
|
||||||
|
if (!registrationMessage || !groupAccessDeniedMessage || !vpnDeniedMessage) return null;
|
||||||
|
return { registrationMessage, groupAccessDeniedMessage, vpnDeniedMessage };
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ describe("event filters", () => {
|
|||||||
it("classifies events into operator-friendly views", () => {
|
it("classifies events into operator-friendly views", () => {
|
||||||
expect(eventCategory("games.minecraft.account-manager.group.deleted")).toBe("groups");
|
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.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.network.vpn-blocked")).toBe("security");
|
||||||
expect(eventCategory("games.minecraft.account-manager.auth.magic-link.consumed")).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");
|
expect(eventCategory("games.minecraft.account-manager.discord.nickname.updated")).toBe("identity");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export type EventCategory = (typeof eventCategoryValues)[number];
|
|||||||
export function eventCategory(type: string): Exclude<EventCategory, "all"> {
|
export function eventCategory(type: string): Exclude<EventCategory, "all"> {
|
||||||
if (type.includes(".group.")) return "groups";
|
if (type.includes(".group.")) return "groups";
|
||||||
if (type.includes(".network.") || type.includes(".auth.") || type.includes("authentication") || type.includes("replay")) return "security";
|
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";
|
if (type.includes(".discord.") || type.includes(".user.") || type.includes("minecraft-account")) return "identity";
|
||||||
return "operations";
|
return "operations";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { groupMapLocations, parseUserLocation, parseUserNetwork, 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("extracts enriched network fields from existing ProxyCheck cache entries", () => {
|
||||||
|
expect(parseUserNetwork({
|
||||||
|
network: { asn: "AS7922", provider: "Comcast Cable Communications, LLC" },
|
||||||
|
rawResponse: {
|
||||||
|
status: "ok",
|
||||||
|
"203.0.113.10": { type: "Residential", proxy: "no" },
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
asn: "AS7922",
|
||||||
|
provider: "Comcast Cable Communications, LLC",
|
||||||
|
connectionType: "Residential",
|
||||||
|
proxy: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers normalized network fields and preserves unavailable values", () => {
|
||||||
|
expect(parseUserNetwork({
|
||||||
|
network: { asn: "AS62371", provider: "Proton AG", connectionType: "VPN", proxy: true },
|
||||||
|
rawResponse: { "198.51.100.5": { type: "Residential", proxy: "no" } },
|
||||||
|
})).toEqual({ asn: "AS62371", provider: "Proton AG", connectionType: "VPN", proxy: true });
|
||||||
|
expect(parseUserNetwork({ network: {} })).toEqual({ asn: null, provider: null, connectionType: null, proxy: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing and out-of-range coordinates", () => {
|
||||||
|
expect(parseUserLocation({ location: { latitude: 91, longitude: 0 } })).toBeNull();
|
||||||
|
expect(parseUserLocation({ location: { city: "Unknown" } })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups users sharing approximate coordinates without hiding their identities", () => {
|
||||||
|
const groups = groupMapLocations([
|
||||||
|
{ userId: "one", nickname: "Dani (Steve)", latitude: 37.4056, longitude: -122.0775 },
|
||||||
|
{ userId: "two", nickname: "Alex (AlexMC)", latitude: 37.4057, longitude: -122.0774 },
|
||||||
|
{ userId: "three", nickname: "Sam (Notch)", latitude: 51.5, longitude: -0.12 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(groups).toHaveLength(2);
|
||||||
|
expect(groups[0]).toMatchObject({ count: 2, nicknames: ["Alex (AlexMC)", "Dani (Steve)"] });
|
||||||
|
expect(groups[0]?.locations.map((location) => location.userId)).toEqual(["one", "two"]);
|
||||||
|
expect(groups[1]).toMatchObject({ count: 1, nicknames: ["Sam (Notch)"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes signed zero and the antimeridian before grouping", () => {
|
||||||
|
const groups = groupMapLocations([
|
||||||
|
{ nickname: "West", latitude: -0.004, longitude: 180 },
|
||||||
|
{ nickname: "East", latitude: 0.004, longitude: -180 },
|
||||||
|
]);
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0]).toMatchObject({ count: 2, key: "0:-180", latitude: 0, longitude: -180 });
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
type UnknownMap = Record<string, unknown>;
|
||||||
|
|
||||||
|
function objectValue(value: unknown): UnknownMap | null {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? value as UnknownMap
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyValue(value: unknown) {
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (typeof value === "string" && value.toLowerCase() === "yes") return true;
|
||||||
|
if (typeof value === "string" && value.toLowerCase() === "no") return false;
|
||||||
|
return 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 interface ParsedUserNetwork {
|
||||||
|
asn: string | null;
|
||||||
|
provider: string | null;
|
||||||
|
connectionType: string | null;
|
||||||
|
proxy: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUserNetwork(value: unknown): ParsedUserNetwork {
|
||||||
|
const intelligence = objectValue(value);
|
||||||
|
const network = objectValue(intelligence?.network);
|
||||||
|
const providerResponse = objectValue(intelligence?.rawResponse);
|
||||||
|
const legacyDetails = Object.values(providerResponse ?? {})
|
||||||
|
.map(objectValue)
|
||||||
|
.find((details) => details && ("type" in details || "proxy" in details));
|
||||||
|
return {
|
||||||
|
asn: stringValue(network?.asn),
|
||||||
|
provider: stringValue(network?.provider),
|
||||||
|
connectionType: stringValue(network?.connectionType) ?? stringValue(legacyDetails?.type),
|
||||||
|
proxy: proxyValue(network?.proxy) ?? proxyValue(legacyDetails?.proxy),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 groupMapLocations<T extends {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
nickname: string;
|
||||||
|
}>(locations: T[]) {
|
||||||
|
const grouped = new Map<string, { latitude: number; longitude: number; locations: T[] }>();
|
||||||
|
for (const location of locations) {
|
||||||
|
const roundedLatitude = Number(location.latitude.toFixed(2));
|
||||||
|
const latitude = roundedLatitude === 0 ? 0 : roundedLatitude;
|
||||||
|
const roundedLongitude = Number(location.longitude.toFixed(2));
|
||||||
|
const longitude = Math.abs(roundedLongitude) === 180 ? -180 : roundedLongitude;
|
||||||
|
const key = `${latitude}:${longitude}`;
|
||||||
|
const group = grouped.get(key);
|
||||||
|
if (group) group.locations.push(location);
|
||||||
|
else grouped.set(key, { latitude, longitude, locations: [location] });
|
||||||
|
}
|
||||||
|
return [...grouped.entries()].map(([key, group]) => ({
|
||||||
|
key,
|
||||||
|
latitude: group.latitude,
|
||||||
|
longitude: group.longitude,
|
||||||
|
count: group.locations.length,
|
||||||
|
nicknames: [...group.locations.map((location) => location.nickname)].sort((left, right) => left.localeCompare(right)),
|
||||||
|
locations: group.locations,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectWorldPoint(latitude: number, longitude: number, width: number, height: number) {
|
||||||
|
return {
|
||||||
|
x: ((longitude + 180) / 360) * width,
|
||||||
|
y: ((90 - latitude) / 180) * height,
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -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-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-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
|
# Tracking
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
# Design Update Log
|
# Design Update Log
|
||||||
|
|
||||||
|
## 2026-08-02
|
||||||
|
|
||||||
|
* **Add**: Provide Users-page group assignment, effective-group VPN/proxy/Tor exceptions for game admission, and independent configurable denial messages.
|
||||||
|
* **Fix**: Treat malformed ProxyCheck proxy signals as unknown and classify every authenticated Velocity login before identity resolution.
|
||||||
|
* **Fix**: Replace the dashboard's pre-enrichment network label with enriched company, ASN, connection type, Proxy/VPN status, and risk fields.
|
||||||
|
* **Fix**: Group collocated map users into count-badged markers with complete nickname tooltips and per-user interactive-map links.
|
||||||
|
* **Refine**: Replace registration counts with daily active users, collapse enriched VPN activity per user, add opt-in OpenStreetMap zoom, show managed nickname tooltips, and measure active Minecraft accounts from confirmed Velocity connections.
|
||||||
|
* **Governance**: Require user review and explicit confirmation of relevant OKF story changes before future implementation work.
|
||||||
|
|
||||||
## 2026-08-01
|
## 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.
|
* **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.
|
* **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.
|
* **Refine**: Group repeated access networks, confirm linked Discord nickname changes before mutation, and add DMG Games sponsorship attribution.
|
||||||
|
|||||||
@@ -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-01T22:04:17Z
|
timestamp: 2026-08-02T14:12:43Z
|
||||||
story_id: US-007
|
story_id: US-007
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -19,7 +19,7 @@ As an operator, I want portal and registered game logins enriched with network c
|
|||||||
- [x] Provider failures are cached briefly and do not deny portal or registered game login.
|
- [x] Provider failures are cached briefly and do not deny portal or registered game login.
|
||||||
- [x] Private, loopback, reserved, documentation, and mapped-private addresses are never sent to ProxyCheck.
|
- [x] Private, loopback, reserved, documentation, and mapped-private addresses are never sent to ProxyCheck.
|
||||||
- [x] Forwarded web IP headers are ignored unless trusted-proxy handling is explicitly enabled.
|
- [x] Forwarded web IP headers are ignored unless trusted-proxy handling is explicitly enabled.
|
||||||
- [x] Unknown game accounts do not trigger paid ProxyCheck lookups.
|
- [x] Every authenticated Velocity login request uses the cached ProxyCheck path before identity resolution, preventing account-creation races from bypassing network policy.
|
||||||
- [x] Login events and IP observations retain the available classification and approximate location.
|
- [x] 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.
|
- [x] Repeated access observations are summarized by IPv4 /24 or IPv6 /64 network with counts, sources, and latest activity.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Block account additions from anonymized networks
|
title: Block account additions from anonymized networks
|
||||||
description: User Minecraft-account additions fail closed for VPN, proxy, Tor, or unknown IP classifications.
|
description: User Minecraft-account additions fail closed for VPN, proxy, Tor, or unknown IP classifications.
|
||||||
tags: [security, vpn, proxy, minecraft]
|
tags: [security, vpn, proxy, minecraft]
|
||||||
timestamp: 2026-08-01T18:43:58Z
|
timestamp: 2026-08-02T14:12:43Z
|
||||||
story_id: US-008
|
story_id: US-008
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -21,6 +21,9 @@ 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 users receive a clear recovery message without provider internals.
|
||||||
- [x] Blocked and classification-unavailable attempts create distinct audit events with safe intelligence details.
|
- [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] Administrative account additions remain available as an authorized recovery path.
|
||||||
|
- [x] Administrators see enriched risky-network observations collapsed to one latest summary per user.
|
||||||
|
- [x] Game admission enforces confirmed VPN, proxy, and Tor classifications according to the user's effective-group exception policy.
|
||||||
|
- [x] Account-addition blocking remains unchanged and independent from the game-admission exception.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
|
|
||||||
|
|||||||
@@ -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-01T23:10:59Z
|
timestamp: 2026-08-02T14:12:43Z
|
||||||
story_id: US-009
|
story_id: US-009
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -25,11 +25,17 @@ 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] 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] 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.
|
||||||
|
- [x] After admission, Velocity reports `PostLoginEvent` as best-effort authenticated telemetry without disconnecting an admitted player when reporting fails.
|
||||||
|
- [x] Confirmed-connection reports use fresh timestamps and database replay protection.
|
||||||
|
- [x] Group-disabled and VPN/proxy/Tor-policy denials return distinct operator-configured messages.
|
||||||
|
- [x] The default anonymized-network message directs the player to contact a host for an exception.
|
||||||
|
- [x] API failures, malformed responses, and unauthorized requests retain fail-closed plugin fallback behavior.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
|
|
||||||
- [`plugins/velocity`](../plugins/velocity)
|
- [`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/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/contracts/src/index.ts`](../packages/contracts/src/index.ts)
|
||||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||||
|
|
||||||
|
|||||||
@@ -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-01T23:10:59Z
|
timestamp: 2026-08-02T00:12:32Z
|
||||||
story_id: US-010
|
story_id: US-010
|
||||||
status: verified
|
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 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] 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] 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 filtered by operator-friendly view and selected event types globally and from an individual user view.
|
- [x] Events can be filtered by operator-friendly view and selected event types globally and from an individual user view.
|
||||||
|
|||||||
@@ -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-01T22:34:31Z
|
timestamp: 2026-08-02T14:12:43Z
|
||||||
story_id: US-012
|
story_id: US-012
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -21,11 +21,16 @@ As an administrator, I want operational settings and audit visibility, so that I
|
|||||||
- [x] Event views show type, subject, IP, classification, and approximate location when available.
|
- [x] Event views show type, subject, IP, classification, and approximate location when available.
|
||||||
- [x] Admin console access itself creates an audit event with the SSO identity.
|
- [x] Admin console access itself creates an audit event with the SSO identity.
|
||||||
- [x] Settings, users, and events are linked from the shared admin navigation.
|
- [x] Settings, users, and events are linked from the shared admin navigation.
|
||||||
|
- [x] Administrators can independently configure registration-required, group-access-disabled, and VPN/proxy/Tor-denied game messages.
|
||||||
|
- [x] Every message is validated server-side and has a safe default.
|
||||||
|
- [x] Admission-message changes are audited with the administrator identity without logging credentials.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
|
|
||||||
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
|
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
|
||||||
- [`apps/web/src/app/admin/(console)/actions.ts`](../apps/web/src/app/admin/%28console%29/actions.ts)
|
- [`apps/web/src/app/admin/(console)/actions.ts`](../apps/web/src/app/admin/%28console%29/actions.ts)
|
||||||
|
- [`apps/web/src/lib/admission-settings.ts`](../apps/web/src/lib/admission-settings.ts)
|
||||||
|
- [`packages/database/drizzle/0004_zippy_silver_centurion.sql`](../packages/database/drizzle/0004_zippy_silver_centurion.sql)
|
||||||
- [`apps/web/src/app/admin/(console)/events/page.tsx`](../apps/web/src/app/admin/%28console%29/events/page.tsx)
|
- [`apps/web/src/app/admin/(console)/events/page.tsx`](../apps/web/src/app/admin/%28console%29/events/page.tsx)
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
|
|||||||
@@ -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-01T22:34:31Z
|
timestamp: 2026-08-02T14:12:43Z
|
||||||
story_id: US-013
|
story_id: US-013
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -24,12 +24,17 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
|
|||||||
- [x] Administrators can set a new primary account and automatically update Discord.
|
- [x] Administrators can set a new primary account and automatically update Discord.
|
||||||
- [x] Every action rechecks role and account ownership and records the acting administrator.
|
- [x] Every action rechecks role and account ownership and records the acting administrator.
|
||||||
- [x] Discord failures do not falsely persist the requested name, primary, or removal change.
|
- [x] Discord failures do not falsely persist the requested name, primary, or removal change.
|
||||||
|
- [x] Each row in the administrator user registry shows the user's effective group in an accessible dropdown.
|
||||||
|
- [x] Selecting a group immediately applies the assignment; selecting `everyone` removes the explicit assignment.
|
||||||
|
- [x] Group changes preserve the active user search and show accessible success or error feedback.
|
||||||
|
- [x] Registry assignment changes revalidate administrator authorization, user existence, and group existence, and audit the previous and new effective groups.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
|
|
||||||
- [`apps/web/src/app/admin/(console)/users/page.tsx`](../apps/web/src/app/admin/%28console%29/users/page.tsx)
|
- [`apps/web/src/app/admin/(console)/users/page.tsx`](../apps/web/src/app/admin/%28console%29/users/page.tsx)
|
||||||
- [`apps/web/src/app/admin/(console)/users/[userId]/page.tsx`](../apps/web/src/app/admin/%28console%29/users/%5BuserId%5D/page.tsx)
|
- [`apps/web/src/app/admin/(console)/users/[userId]/page.tsx`](../apps/web/src/app/admin/%28console%29/users/%5BuserId%5D/page.tsx)
|
||||||
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
|
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
|
||||||
|
- [`apps/web/src/components/user-group-select.tsx`](../apps/web/src/components/user-group-select.tsx)
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Control Minecraft admission with groups
|
title: Control Minecraft admission with groups
|
||||||
description: Administrators assign users to groups and enable Minecraft access through explicit group policy.
|
description: Administrators assign users to groups and enable Minecraft access through explicit group policy.
|
||||||
tags: [admin, groups, authorization, velocity, security]
|
tags: [admin, groups, authorization, velocity, security]
|
||||||
timestamp: 2026-08-01T23:10:59Z
|
timestamp: 2026-08-02T14:12:43Z
|
||||||
story_id: US-017
|
story_id: US-017
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -24,12 +24,18 @@ As an administrator, I want to organize registered users into access groups, so
|
|||||||
- [x] The protected default group cannot be deleted.
|
- [x] The protected default group cannot be deleted.
|
||||||
- [x] Group creation, membership, and access-policy changes are audited.
|
- [x] Group creation, membership, and access-policy changes are audited.
|
||||||
- [x] Users and administrators can inspect the user's single effective group assignment.
|
- [x] Users and administrators can inspect the user's single effective group assignment.
|
||||||
|
- [x] Every group has an independently configurable VPN/proxy/Tor exception policy.
|
||||||
|
- [x] The protected `everyone` group and newly created groups disallow VPN, proxy, and Tor connections by default.
|
||||||
|
- [x] Confirmed VPN, proxy, or Tor game connections are denied unless the user's single effective group allows anonymized networks.
|
||||||
|
- [x] Clear and hosting classifications are not denied by this group policy, and unavailable intelligence does not independently deny a registered player.
|
||||||
|
- [x] VPN policy changes are authorized server-side and audited.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
|
|
||||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
- [`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/0002_simple_queen_noir.sql`](../packages/database/drizzle/0002_simple_queen_noir.sql)
|
||||||
- [`packages/database/drizzle/0003_smiling_silver_samurai.sql`](../packages/database/drizzle/0003_smiling_silver_samurai.sql)
|
- [`packages/database/drizzle/0003_smiling_silver_samurai.sql`](../packages/database/drizzle/0003_smiling_silver_samurai.sql)
|
||||||
|
- [`packages/database/drizzle/0004_zippy_silver_centurion.sql`](../packages/database/drizzle/0004_zippy_silver_centurion.sql)
|
||||||
- [`apps/web/src/app/admin/(console)/groups/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/page.tsx)
|
- [`apps/web/src/app/admin/(console)/groups/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/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)
|
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
type: User Story
|
type: User Story
|
||||||
title: Monitor community account activity
|
title: Monitor community account activity
|
||||||
description: Administrators use a server-rendered dashboard to review registrations, monthly activity, denials, and risky networks.
|
description: Administrators use a server-rendered dashboard to review daily activity, confirmed connections, locations, denials, and risky networks.
|
||||||
tags: [admin, dashboard, metrics, security, ssr]
|
tags: [admin, dashboard, metrics, security, maps, ssr]
|
||||||
timestamp: 2026-08-01T23:10:59Z
|
timestamp: 2026-08-02T12:05:27Z
|
||||||
story_id: US-018
|
story_id: US-018
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -15,13 +15,25 @@ As an administrator, I want an operational dashboard of account and game activit
|
|||||||
# Acceptance Criteria
|
# Acceptance Criteria
|
||||||
|
|
||||||
- [x] The administrator landing page is a dashboard rather than a settings form.
|
- [x] The administrator landing page is a dashboard rather than a settings form.
|
||||||
- [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] Users sharing approximate coordinates render as one grouped marker with a visible count in both map views.
|
||||||
|
- [x] Grouped-marker hover and keyboard focus list every managed Discord nickname at that location.
|
||||||
|
- [x] Interactive grouped markers open a popup with links to every corresponding user record.
|
||||||
|
- [x] Single-user markers retain their direct nickname tooltip and user-record link.
|
||||||
|
- [x] The location list identifies the enriched network company and ASN when available.
|
||||||
|
- [x] The location list shows ProxyCheck's connection type separately from its risk classification.
|
||||||
|
- [x] The location list shows the provider's proxy/VPN signal as an explicit Yes or No value.
|
||||||
|
- [x] Unknown is shown only for individual enriched fields that are unavailable, including existing cached responses.
|
||||||
|
- [x] The dashboard graphs distinct daily active users by UTC day for the previous 14 days with understandable date labels.
|
||||||
- [x] Monthly active users count distinct users observed through portal or game activity in the previous 30 days.
|
- [x] Monthly active 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] 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] 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.
|
- [x] Deployment-managed guild settings and denial messaging remain available on a dedicated settings page.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
@@ -29,10 +41,14 @@ As an administrator, I want an operational dashboard of account and game activit
|
|||||||
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
|
- [`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/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/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
|
# 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, backward-compatible ProxyCheck network parsing, normalized location grouping, projection, count badges, complete grouped tooltips, linked markers, semantic network columns, text fallback, and attribution are covered by the user-world-map tests.
|
||||||
- The Next.js production build reports the dashboard and database-backed console pages as dynamic server-rendered routes.
|
- The Next.js production build reports the dashboard and database-backed console pages as dynamic server-rendered routes.
|
||||||
|
|
||||||
# Related Stories
|
# Related Stories
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Accessibility review
|
# Accessibility review
|
||||||
|
|
||||||
Review date: 2026-08-01
|
Review date: 2026-08-02
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -12,12 +12,17 @@ Player account management, administrator navigation, dashboard metrics and chart
|
|||||||
- Darkened the accent color so accent text reaches at least 4.5:1 contrast on both canvas and panel backgrounds.
|
- 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.
|
- 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 labels or accessible names to search, Minecraft username, settings, group, and event-filter controls.
|
||||||
|
- Added per-user group dropdowns with immediate-change instructions, keyboard submission fallback, and live success or error feedback.
|
||||||
- Added `fieldset` and `legend` semantics to multi-select event-type filters.
|
- Added `fieldset` and `legend` semantics to multi-select event-type filters.
|
||||||
- Added table captions, column scopes, and row scopes to administrator data tables.
|
- 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 `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.
|
- 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.
|
- 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.
|
||||||
|
- Collocated users share a visible count badge; hover and focus tooltips announce every nickname, while interactive grouped markers expose per-user popup links.
|
||||||
|
- The semantic location table separates network company, connection type, Proxy/VPN status, and risk classification under explicit column headers.
|
||||||
|
- 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.
|
- Added explicit new-tab context to the external Discord invite link.
|
||||||
- Kept destructive account and group actions behind native keyboard-operable `details` confirmation disclosures.
|
- 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.
|
- Allowed administrator navigation to wrap at narrow viewport widths instead of overflowing.
|
||||||
|
|||||||
+7
-3
@@ -29,12 +29,16 @@ Every error response has media type `application/problem+json` and the shape:
|
|||||||
|
|
||||||
| Type | Status | Meaning |
|
| 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: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: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: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:unsupported-media-type` | 415 | The request does not use `application/json` |
|
||||||
| `urn:error:service-unavailable` | 503 | A safe access decision could not be completed |
|
| `urn:error:service-unavailable` | 503 | A safe access decision could not be completed |
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
### Web application
|
### 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, and dashboard aggregation execute on the server and return rendered HTML.
|
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.
|
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.
|
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, disabled effective groups, disallowed confirmed VPN/proxy/Tor connections, invalid responses, expired requests, authentication failures, and unavailable API responses are denied. Registration, group-access, and anonymized-network denials use independent operator-configured messages; transport and service failures retain the plugin's local fallback. After admission succeeds, `PostLoginEvent` reports a confirmed proxy connection through a fresh, authenticated, replay-protected request. Connection telemetry is best effort and never disconnects an already admitted player.
|
||||||
|
|
||||||
## Trust boundaries
|
## Trust boundaries
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ The decision is fail closed. Unknown players, invalid responses, expired request
|
|||||||
|
|
||||||
## IP intelligence
|
## IP intelligence
|
||||||
|
|
||||||
ProxyCheck.io supplies approximate city/region/country, coordinates, timezone, ASN, provider, risk, and VPN/proxy/Tor classification. Results are cached in PostgreSQL for 48 hours by default. Portal and game login events are enriched when data is available; lookup failures do not deny login. User Minecraft-account additions fail closed for unknown, VPN, proxy, or Tor classifications and record denied attempts. Hosting-provider blocking is optional through `BLOCK_HOSTING_IPS=true`. Private and reserved addresses are never sent to ProxyCheck.
|
ProxyCheck.io supplies approximate city/region/country, coordinates, timezone, ASN, network company, connection type, proxy signal, risk, and VPN/proxy/Tor classification. Results are cached in PostgreSQL for 48 hours by default. Portal logins and every bearer-authenticated Velocity login are enriched through the cache before identity resolution; lookup failures do not independently deny a registered player. Confirmed VPN, proxy, and Tor game connections require an exception on the player's effective group. User Minecraft-account additions fail closed for unknown, VPN, proxy, or Tor classifications and record denied attempts. Hosting-provider blocking is optional through `BLOCK_HOSTING_IPS=true`. Private and reserved addresses are never sent to ProxyCheck.
|
||||||
|
|
||||||
## Event naming
|
## Event naming
|
||||||
|
|
||||||
@@ -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.network.vpn-blocked`
|
||||||
- `games.minecraft.account-manager.game.login.allowed`
|
- `games.minecraft.account-manager.game.login.allowed`
|
||||||
- `games.minecraft.account-manager.game.login.denied`
|
- `games.minecraft.account-manager.game.login.denied`
|
||||||
|
- `games.minecraft.account-manager.game.player.connected`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Security review
|
# Security review
|
||||||
|
|
||||||
Review date: 2026-08-01
|
Review date: 2026-08-02
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -22,16 +22,20 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
|
|||||||
- User mutations verify ownership server-side.
|
- User mutations verify ownership server-side.
|
||||||
- Mojang lookup is server-side and targets a fixed host, avoiding client-forged validation and SSRF.
|
- 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 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.
|
- Velocity and its API fail closed.
|
||||||
- Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback.
|
- Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback.
|
||||||
- Group and membership mutations re-check the Keycloak administrator role server-side; destructive group deletion and its audit event commit atomically.
|
- Confirmed VPN, proxy, and Tor game connections are denied unless that same effective group has an explicit exception; `everyone` and new groups default to no exception.
|
||||||
|
- Group, VPN-policy, and membership mutations re-check the Keycloak administrator role server-side; registry assignments, VPN-policy changes, message settings, and destructive group deletion commit atomically with their audit events.
|
||||||
|
- Every bearer-authenticated Velocity login uses cached IP intelligence before identity resolution, preventing account-creation races from bypassing network policy; malformed provider proxy signals classify as unknown.
|
||||||
- Event filters accept only event types already present in the ledger, and event detail routes remain role-protected.
|
- Event filters accept only event types already present in the ledger, and event detail routes remain role-protected.
|
||||||
|
- The administrator-only map defaults to bundled Natural Earth boundaries. OpenStreetMap tile requests begin only after an explicit operator opt-in; marker coordinates are not transmitted as data, but the requested tiles disclose the viewed geographic extent along with the administrator's IP and portal origin.
|
||||||
|
- Grouped-map popup labels and links are created with DOM `textContent` and server-rendered React escaping rather than interpolated HTML.
|
||||||
- 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. The administrator-only location list also exposes enriched network company, ASN, connection type, and the provider's proxy signal.
|
||||||
- Structured Pino logging redacts credential fields, and 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
|
||||||
|
|||||||
Generated
+121
-1
@@ -42,17 +42,24 @@
|
|||||||
"@minecraft-account-manager/logging": "*",
|
"@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",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"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/leaflet": "^1.9.22",
|
||||||
"@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",
|
||||||
@@ -2724,6 +2731,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",
|
||||||
@@ -2738,6 +2755,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",
|
||||||
@@ -2752,6 +2776,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.9.5",
|
"version": "25.9.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
|
||||||
@@ -2781,6 +2815,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",
|
||||||
@@ -4084,6 +4139,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",
|
||||||
@@ -4129,6 +4190,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",
|
||||||
@@ -5718,6 +5803,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",
|
||||||
@@ -6332,6 +6426,12 @@
|
|||||||
"node": ">=0.10"
|
"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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -8337,6 +8437,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",
|
||||||
@@ -9261,6 +9375,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",
|
||||||
|
|||||||
@@ -115,6 +115,24 @@ export function resolveEffectiveGroup<T>(explicitGroup: T | null, defaultGroup:
|
|||||||
return explicitGroup ?? defaultGroup;
|
return explicitGroup ?? defaultGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isGameNetworkAllowed(
|
||||||
|
classification: "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor",
|
||||||
|
anonymizedNetworksAllowed: boolean,
|
||||||
|
) {
|
||||||
|
return anonymizedNetworksAllowed || !["vpn", "proxy", "tor"].includes(classification);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gameAdmissionDenialReason(
|
||||||
|
group: { accessEnabled: boolean; anonymizedNetworksAllowed: boolean } | null,
|
||||||
|
classification: "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor",
|
||||||
|
) {
|
||||||
|
if (!group?.accessEnabled) return "group_access_disabled" as const;
|
||||||
|
if (!isGameNetworkAllowed(classification, group.anonymizedNetworksAllowed)) {
|
||||||
|
return "anonymized_network_disallowed" as const;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
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");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveEffectiveGroup } from "../src/index";
|
import { gameAdmissionDenialReason, isGameNetworkAllowed, resolveEffectiveGroup } from "../src/index";
|
||||||
|
|
||||||
describe("group-based admission", () => {
|
describe("group-based admission", () => {
|
||||||
const everyone = { name: "everyone", accessEnabled: false };
|
const everyone = { name: "everyone", accessEnabled: false };
|
||||||
@@ -17,4 +17,26 @@ describe("group-based admission", () => {
|
|||||||
const limited = { name: "limited", accessEnabled: false };
|
const limited = { name: "limited", accessEnabled: false };
|
||||||
expect(resolveEffectiveGroup(limited, enabledDefault)?.accessEnabled).toBe(false);
|
expect(resolveEffectiveGroup(limited, enabledDefault)?.accessEnabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("denies confirmed anonymized game networks unless the effective group allows them", () => {
|
||||||
|
for (const classification of ["vpn", "proxy", "tor"] as const) {
|
||||||
|
expect(isGameNetworkAllowed(classification, false)).toBe(false);
|
||||||
|
expect(isGameNetworkAllowed(classification, true)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply the group exception policy to clear, hosting, or unavailable intelligence", () => {
|
||||||
|
for (const classification of ["clear", "hosting", "unknown"] as const) {
|
||||||
|
expect(isGameNetworkAllowed(classification, false)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes disabled group access before the network exception policy", () => {
|
||||||
|
expect(gameAdmissionDenialReason({ accessEnabled: false, anonymizedNetworksAllowed: false }, "vpn"))
|
||||||
|
.toBe("group_access_disabled");
|
||||||
|
expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: false }, "vpn"))
|
||||||
|
.toBe("anonymized_network_disallowed");
|
||||||
|
expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: true }, "vpn"))
|
||||||
|
.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ export const velocityAccessRequestSchema = z.object({
|
|||||||
|
|
||||||
export type VelocityAccessRequest = z.infer<typeof velocityAccessRequestSchema>;
|
export type VelocityAccessRequest = z.infer<typeof velocityAccessRequestSchema>;
|
||||||
|
|
||||||
|
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<typeof velocityConnectionRequestSchema>;
|
||||||
|
|
||||||
export const velocityAccessResponseSchema = z.discriminatedUnion("allowed", [
|
export const velocityAccessResponseSchema = z.discriminatedUnion("allowed", [
|
||||||
z.object({
|
z.object({
|
||||||
allowed: z.literal(true),
|
allowed: z.literal(true),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
cloudEventSchema,
|
cloudEventSchema,
|
||||||
velocityAccessRequestSchema,
|
velocityAccessRequestSchema,
|
||||||
velocityAccessResponseSchema,
|
velocityAccessResponseSchema,
|
||||||
|
velocityConnectionRequestSchema,
|
||||||
} from "../src/index";
|
} from "../src/index";
|
||||||
|
|
||||||
describe("shared service contracts", () => {
|
describe("shared service contracts", () => {
|
||||||
@@ -37,6 +38,19 @@ describe("shared service contracts", () => {
|
|||||||
).toThrow();
|
).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", () => {
|
it("only returns explicit allow or deny decisions to Velocity", () => {
|
||||||
expect(
|
expect(
|
||||||
velocityAccessResponseSchema.parse({
|
velocityAccessResponseSchema.parse({
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "app_settings" ADD COLUMN "group_access_denied_message" text DEFAULT 'Your account group does not currently have server access. Contact a host if you believe this is a mistake.' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "app_settings" ADD COLUMN "vpn_denied_message" text DEFAULT 'VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception.' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "groups" ADD COLUMN "anonymized_networks_allowed" boolean DEFAULT false NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
|||||||
"when": 1785625186545,
|
"when": 1785625186545,
|
||||||
"tag": "0003_smiling_silver_samurai",
|
"tag": "0003_smiling_silver_samurai",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785678753029,
|
||||||
|
"tag": "0004_zippy_silver_centurion",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -70,6 +70,7 @@ export const groups = pgTable(
|
|||||||
slug: varchar("slug", { length: 50 }).notNull(),
|
slug: varchar("slug", { length: 50 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
accessEnabled: boolean("access_enabled").notNull().default(false),
|
accessEnabled: boolean("access_enabled").notNull().default(false),
|
||||||
|
anonymizedNetworksAllowed: boolean("anonymized_networks_allowed").notNull().default(false),
|
||||||
isDefault: boolean("is_default").notNull().default(false),
|
isDefault: boolean("is_default").notNull().default(false),
|
||||||
...timestamps(),
|
...timestamps(),
|
||||||
},
|
},
|
||||||
@@ -171,6 +172,12 @@ export const appSettings = pgTable("app_settings", {
|
|||||||
registrationMessage: text("registration_message")
|
registrationMessage: text("registration_message")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("Please register your Minecraft account before joining."),
|
.default("Please register your Minecraft account before joining."),
|
||||||
|
groupAccessDeniedMessage: text("group_access_denied_message")
|
||||||
|
.notNull()
|
||||||
|
.default("Your account group does not currently have server access. Contact a host if you believe this is a mistake."),
|
||||||
|
vpnDeniedMessage: text("vpn_denied_message")
|
||||||
|
.notNull()
|
||||||
|
.default("VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception."),
|
||||||
...timestamps(),
|
...timestamps(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface IpLocation {
|
|||||||
export interface IpNetwork {
|
export interface IpNetwork {
|
||||||
asn: string | null;
|
asn: string | null;
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
|
connectionType?: string | null;
|
||||||
|
proxy?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IpIntelligenceResult {
|
export interface IpIntelligenceResult {
|
||||||
@@ -49,7 +51,9 @@ function numberValue(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function proxyClassification(proxy: unknown, type: unknown): IpClassification {
|
function proxyClassification(proxy: unknown, type: unknown): IpClassification {
|
||||||
if (String(proxy).toLowerCase() !== "yes") return "clear";
|
const normalizedProxy = String(proxy).toLowerCase();
|
||||||
|
if (normalizedProxy === "no") return "clear";
|
||||||
|
if (normalizedProxy !== "yes") return "unknown";
|
||||||
const normalizedType = String(type ?? "").toLowerCase();
|
const normalizedType = String(type ?? "").toLowerCase();
|
||||||
if (normalizedType.includes("tor")) return "tor";
|
if (normalizedType.includes("tor")) return "tor";
|
||||||
if (normalizedType.includes("vpn")) return "vpn";
|
if (normalizedType.includes("vpn")) return "vpn";
|
||||||
@@ -112,6 +116,10 @@ export class ProxyCheckProvider implements IpIntelligenceProvider {
|
|||||||
network: {
|
network: {
|
||||||
asn: stringValue(data.asn),
|
asn: stringValue(data.asn),
|
||||||
provider: stringValue(data.provider) ?? stringValue(data.organisation),
|
provider: stringValue(data.provider) ?? stringValue(data.organisation),
|
||||||
|
connectionType: stringValue(data.type),
|
||||||
|
proxy: String(data.proxy).toLowerCase() === "yes"
|
||||||
|
? true
|
||||||
|
: String(data.proxy).toLowerCase() === "no" ? false : null,
|
||||||
},
|
},
|
||||||
rawResponse: root,
|
rawResponse: root,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe("ProxyCheck.io intelligence", () => {
|
|||||||
longitude: -122.0775,
|
longitude: -122.0775,
|
||||||
timezone: "America/Los_Angeles",
|
timezone: "America/Los_Angeles",
|
||||||
},
|
},
|
||||||
network: { asn: "AS15169", provider: "Google LLC" },
|
network: { asn: "AS15169", provider: "Google LLC", connectionType: "Business", proxy: false },
|
||||||
});
|
});
|
||||||
expect(request).toHaveBeenCalledWith(
|
expect(request).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("https://proxycheck.io/v2/8.8.8.8"),
|
expect.stringContaining("https://proxycheck.io/v2/8.8.8.8"),
|
||||||
@@ -50,6 +50,14 @@ describe("ProxyCheck.io intelligence", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats missing or malformed proxy signals as unknown", async () => {
|
||||||
|
for (const proxy of [undefined, null, "maybe"] as const) {
|
||||||
|
const request = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy, type: "Residential" }));
|
||||||
|
await expect(new ProxyCheckProvider({ apiKey: "secret", request }).classify(ipAddress))
|
||||||
|
.resolves.toMatchObject({ classification: "unknown", network: { proxy: null } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("maps VPN and Tor responses to explicit classifications", async () => {
|
it("maps VPN and Tor responses to explicit classifications", async () => {
|
||||||
const vpnRequest = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy: "yes", type: "VPN" }));
|
const vpnRequest = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy: "yes", type: "VPN" }));
|
||||||
const torRequest = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy: "yes", type: "TOR" }));
|
const torRequest = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy: "yes", type: "TOR" }));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Velocity admission plugin
|
# 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, unknown-account, group-disabled, and group-policy VPN/proxy/Tor 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
|
## Download or build
|
||||||
|
|
||||||
@@ -31,4 +31,4 @@ npm run plugin:create-credential --workspace @minecraft-account-manager/database
|
|||||||
|
|
||||||
Copy the displayed token into the plugin's `api-token`. Configure the HTTPS account-manager URL and ensure `server-id` matches. Restrict access to the plugin configuration because it contains the bearer token, then restart Velocity.
|
Copy the displayed token into the plugin's `api-token`. Configure the HTTPS account-manager URL and ensure `server-id` matches. Restrict access to the plugin configuration because it contains the bearer token, then restart Velocity.
|
||||||
|
|
||||||
The proxy must run in online mode. Unknown players and API failures receive the configured registration message.
|
The proxy must run in online mode. Unknown players, disabled groups, and disallowed VPN/proxy/Tor connections receive their operator-configured API message. API failures receive the plugin's local registration fallback.
|
||||||
|
|||||||
@@ -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<String> 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(
|
private record AccessRequest(
|
||||||
String requestId,
|
String requestId,
|
||||||
String serverId,
|
String serverId,
|
||||||
|
|||||||
+20
-1
@@ -5,9 +5,11 @@ import com.velocitypowered.api.event.EventTask;
|
|||||||
import com.velocitypowered.api.event.Subscribe;
|
import com.velocitypowered.api.event.Subscribe;
|
||||||
import com.velocitypowered.api.event.ResultedEvent;
|
import com.velocitypowered.api.event.ResultedEvent;
|
||||||
import com.velocitypowered.api.event.connection.LoginEvent;
|
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.event.proxy.ProxyInitializeEvent;
|
||||||
import com.velocitypowered.api.plugin.Plugin;
|
import com.velocitypowered.api.plugin.Plugin;
|
||||||
import com.velocitypowered.api.plugin.annotation.DataDirectory;
|
import com.velocitypowered.api.plugin.annotation.DataDirectory;
|
||||||
|
import com.velocitypowered.api.proxy.ProxyServer;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import net.kyori.adventure.text.Component;
|
import net.kyori.adventure.text.Component;
|
||||||
@@ -22,13 +24,15 @@ import org.slf4j.Logger;
|
|||||||
public final class MinecraftAccountManagerPlugin {
|
public final class MinecraftAccountManagerPlugin {
|
||||||
private final Logger logger;
|
private final Logger logger;
|
||||||
private final Path dataDirectory;
|
private final Path dataDirectory;
|
||||||
|
private final ProxyServer proxyServer;
|
||||||
private volatile AccountManagerClient accountManagerClient;
|
private volatile AccountManagerClient accountManagerClient;
|
||||||
private volatile String fallbackMessage = "Please register your Minecraft account in Discord before joining.";
|
private volatile String fallbackMessage = "Please register your Minecraft account in Discord before joining.";
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public MinecraftAccountManagerPlugin(Logger logger, @DataDirectory Path dataDirectory) {
|
public MinecraftAccountManagerPlugin(Logger logger, @DataDirectory Path dataDirectory, ProxyServer proxyServer) {
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.dataDirectory = dataDirectory;
|
this.dataDirectory = dataDirectory;
|
||||||
|
this.proxyServer = proxyServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Subscribe
|
@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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ package games.dmg.accountmanager;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
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.time.Duration;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class AccountManagerClientTest {
|
class AccountManagerClientTest {
|
||||||
@@ -27,4 +33,55 @@ class AccountManagerClientTest {
|
|||||||
assertFalse(decision.allowed());
|
assertFalse(decision.allowed());
|
||||||
assertEquals("Register through Discord.", decision.message());
|
assertEquals("Register through Discord.", decision.message());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsConfirmedConnectionsToTheAuthenticatedEndpoint() throws IOException {
|
||||||
|
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
|
AtomicReference<String> body = new AtomicReference<>();
|
||||||
|
AtomicReference<String> 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user