diff --git a/apps/web/src/app/admin/(console)/page.tsx b/apps/web/src/app/admin/(console)/page.tsx index 83014e8..0ae6a6c 100644 --- a/apps/web/src/app/admin/(console)/page.tsx +++ b/apps/web/src/app/admin/(console)/page.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map"; import { db } from "@/lib/database"; import { fillDailySeries, mergeRiskActivity, type DailyCount } from "@/lib/admin-metrics"; -import { parseUserLocation } from "@/lib/user-location-map"; +import { parseUserLocation, parseUserNetwork } from "@/lib/user-location-map"; export const dynamic = "force-dynamic"; @@ -43,7 +43,7 @@ export default async function AdminDashboardPage() { name: users.firstName, discordUsername: users.discordUsername, primaryUsername: minecraftAccounts.username, - classification: ipObservations.classification, + classification: ipIntelligence.classification, source: ipObservations.source, observedAt: ipObservations.observedAt, intelligence: ipIntelligence.rawResponse, @@ -108,6 +108,7 @@ export default async function AdminDashboardPage() { 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, @@ -117,6 +118,10 @@ export default async function AdminDashboardPage() { 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, }]; diff --git a/apps/web/src/components/user-world-map.test.tsx b/apps/web/src/components/user-world-map.test.tsx index e5e996a..edf45db 100644 --- a/apps/web/src/components/user-world-map.test.tsx +++ b/apps/web/src/components/user-world-map.test.tsx @@ -13,6 +13,10 @@ describe("UserWorldMap", () => { 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"), }, { @@ -23,7 +27,11 @@ describe("UserWorldMap", () => { latitude: 37.4057, longitude: -122.0774, location: "Mountain View, California, US", - classification: "clear", + classification: "vpn", + networkProvider: "Proton AG", + networkAsn: "AS62371", + connectionType: "VPN", + proxy: true, source: "web", observedAt: new Date("2026-08-01T13:00:00Z"), }]} unavailableCount={2} />); @@ -38,6 +46,13 @@ describe("UserWorldMap", () => { expect(markup).toMatch(/]*>2<\/text>/); expect(markup).toContain('
'); 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"); diff --git a/apps/web/src/components/user-world-map.tsx b/apps/web/src/components/user-world-map.tsx index 2af8b15..457c285 100644 --- a/apps/web/src/components/user-world-map.tsx +++ b/apps/web/src/components/user-world-map.tsx @@ -23,6 +23,10 @@ export interface UserMapLocation { longitude: number; location: string; classification: string; + networkProvider: string | null; + networkAsn: string | null; + connectionType: string | null; + proxy: boolean | null; source: string; observedAt: Date; } @@ -98,12 +102,12 @@ export function UserWorldMap({ locations, unavailableCount }: { locations: UserM
group.count > 1)}> View accessible location list
- - - +
Latest approximate registered-user locations
UserLocationNetworkSourceLast observed
+ + - {locations.map((user) => )} - {!locations.length && } + {locations.map((user) => )} + {!locations.length && }
Latest approximate registered-user locations and enriched network details
UserLocationNetworkConnectionProxy/VPNRiskSourceLast observed
{user.nickname}@{user.discordUsername}{user.location}{user.classification}{user.source}
No user observations currently include valid coordinates.
{user.nickname}@{user.discordUsername}{user.location}{user.networkProvider ?? "Unknown"}{user.networkAsn && {user.networkAsn}}{user.connectionType ?? "Unknown"}{user.proxy === null ? "Unknown" : user.proxy ? "Yes" : "No"}{user.classification}{user.source}
No user observations currently include valid coordinates.
diff --git a/apps/web/src/lib/user-location-map.test.ts b/apps/web/src/lib/user-location-map.test.ts index 75d9806..1ecf5d8 100644 --- a/apps/web/src/lib/user-location-map.test.ts +++ b/apps/web/src/lib/user-location-map.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { groupMapLocations, parseUserLocation, projectWorldPoint } from "./user-location-map"; +import { groupMapLocations, parseUserLocation, parseUserNetwork, projectWorldPoint } from "./user-location-map"; describe("user location map", () => { it("extracts a valid approximate location from cached IP intelligence", () => { @@ -19,6 +19,29 @@ describe("user location map", () => { }); }); + 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(); diff --git a/apps/web/src/lib/user-location-map.ts b/apps/web/src/lib/user-location-map.ts index 223fdfe..d1f18a5 100644 --- a/apps/web/src/lib/user-location-map.ts +++ b/apps/web/src/lib/user-location-map.ts @@ -6,6 +6,17 @@ function objectValue(value: unknown): UnknownMap | null { : 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); @@ -18,6 +29,28 @@ export interface ParsedUserLocation { 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); diff --git a/design/log.md b/design/log.md index 1793886..dd7ada6 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,7 @@ ## 2026-08-02 +* **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. diff --git a/design/us-018-admin-dashboard.md b/design/us-018-admin-dashboard.md index 47a0936..8431768 100644 --- a/design/us-018-admin-dashboard.md +++ b/design/us-018-admin-dashboard.md @@ -3,7 +3,7 @@ type: User Story title: Monitor community account activity description: Administrators use a server-rendered dashboard to review daily activity, confirmed connections, locations, denials, and risky networks. tags: [admin, dashboard, metrics, security, maps, ssr] -timestamp: 2026-08-02T01:23:21Z +timestamp: 2026-08-02T12:05:27Z story_id: US-018 status: verified --- @@ -23,6 +23,10 @@ As an administrator, I want an operational dashboard of account and game activit - [x] Grouped-marker hover and keyboard focus list every managed Discord nickname at that location. - [x] Interactive grouped markers open a popup with links to every corresponding user record. - [x] Single-user markers retain their direct nickname tooltip and user-record link. +- [x] The location list identifies the enriched network company and ASN when available. +- [x] The location list shows ProxyCheck's connection type separately from its risk classification. +- [x] The location list shows the provider's proxy/VPN signal as an explicit Yes or No value. +- [x] Unknown is shown only for individual enriched fields that are unavailable, including existing cached responses. - [x] The dashboard graphs distinct daily active users by UTC day for the previous 14 days with understandable date labels. - [x] Monthly active users count distinct users observed through portal or game activity in the previous 30 days. - [x] Monthly active Minecraft accounts count distinct accounts with a confirmed Velocity post-login connection in the previous 30 days. @@ -44,7 +48,7 @@ As an administrator, I want an operational dashboard of account and game activit # Validation - Missing-day chart behavior and per-user VPN collapsing are covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts). -- Coordinate parsing, normalized location grouping, projection, count badges, complete grouped tooltips, linked markers, text fallback, and attribution are covered by the user-world-map tests. +- Coordinate parsing, backward-compatible ProxyCheck network parsing, normalized location grouping, projection, count badges, complete grouped tooltips, linked markers, semantic network columns, text fallback, and attribution are covered by the user-world-map tests. - The Next.js production build reports the dashboard and database-backed console pages as dynamic server-rendered routes. # Related Stories diff --git a/docs/accessibility.md b/docs/accessibility.md index 8328ee2..69ee987 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -20,6 +20,7 @@ Player account management, administrator navigation, dashboard metrics and 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. - Kept destructive account and group actions behind native keyboard-operable `details` confirmation disclosures. diff --git a/docs/architecture.md b/docs/architecture.md index 0c952f9..c0fb823 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,7 @@ The admission decision is fail closed. Unknown players, invalid responses, expir ## 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 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. ## Event naming diff --git a/docs/security-review.md b/docs/security-review.md index fcac942..fa52ea0 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -33,7 +33,7 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut - CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are 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. -- 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. ## Outstanding production requirements diff --git a/packages/network/src/index.ts b/packages/network/src/index.ts index bb9055b..5af693b 100644 --- a/packages/network/src/index.ts +++ b/packages/network/src/index.ts @@ -16,6 +16,8 @@ export interface IpLocation { export interface IpNetwork { asn: string | null; provider: string | null; + connectionType?: string | null; + proxy?: boolean | null; } export interface IpIntelligenceResult { @@ -112,6 +114,10 @@ export class ProxyCheckProvider implements IpIntelligenceProvider { network: { asn: stringValue(data.asn), 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, }; diff --git a/packages/network/test/proxycheck.test.ts b/packages/network/test/proxycheck.test.ts index e308578..715db85 100644 --- a/packages/network/test/proxycheck.test.ts +++ b/packages/network/test/proxycheck.test.ts @@ -42,7 +42,7 @@ describe("ProxyCheck.io intelligence", () => { longitude: -122.0775, timezone: "America/Los_Angeles", }, - network: { asn: "AS15169", provider: "Google LLC" }, + network: { asn: "AS15169", provider: "Google LLC", connectionType: "Business", proxy: false }, }); expect(request).toHaveBeenCalledWith( expect.stringContaining("https://proxycheck.io/v2/8.8.8.8"),