diff --git a/apps/web/src/app/account/actions.ts b/apps/web/src/app/account/actions.ts index 758935e..97492d8 100644 --- a/apps/web/src/app/account/actions.ts +++ b/apps/web/src/app/account/actions.ts @@ -6,20 +6,56 @@ import { and, eq, isNull, ne } from "drizzle-orm"; import { redirect } from "next/navigation"; import { recordUserEvent } from "@/lib/audit"; import { db } from "@/lib/database"; +import { hasDiscordNicknameConfirmation } from "@/lib/dashboard-change-confirmation"; import { requireCurrentUser } from "@/lib/auth/user-session"; import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence"; +import { logger } from "@/lib/logger"; const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/; export async function updateFirstName(formData: FormData) { const user = await requireCurrentUser(); const firstName = String(formData.get("firstName") ?? "").trim(); + const confirmed = hasDiscordNicknameConfirmation(formData); if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) { redirect("/account?error=invalid-name"); } - await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id)); + + const [primary] = await db.select({ username: minecraftAccounts.username }).from(minecraftAccounts).where( + and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)), + ).limit(1); + if (!primary) redirect("/account?error=nickname-not-configured"); + if (!confirmed) redirect(`/account?pendingName=${encodeURIComponent(firstName)}`); + + const guildId = process.env.DISCORD_GUILD_ID?.trim(); + const botToken = process.env.DISCORD_BOT_TOKEN?.trim(); + if (!guildId || !botToken) redirect("/account?error=nickname-not-configured"); + const nickname = formatDiscordNickname(firstName, primary.username); + + try { + await db.transaction(async (tx) => { + await tx.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id)); + await updateGuildNickname({ + guildId, + discordUserId: user.discordUserId, + nickname, + botToken, + }); + }); + } catch (error) { + logger.error( + { err: error, event: "account.first_name_update_failed" }, + "Failed to update the user name and Discord nickname", + ); + redirect(`/account?error=nickname-update-failed&pendingName=${encodeURIComponent(firstName)}`); + } + await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName }); - redirect("/account?confirmNickname=1"); + await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", { + nickname, + operation: "update-first-name", + }); + redirect("/account?nicknameUpdated=1"); } export async function addMinecraftAccount(formData: FormData) { @@ -75,23 +111,58 @@ export async function addMinecraftAccount(formData: FormData) { export async function setPrimaryAccount(formData: FormData) { const user = await requireCurrentUser(); const accountId = String(formData.get("accountId") ?? ""); + const confirmed = hasDiscordNicknameConfirmation(formData); + const [requestedAccount] = await db.select({ id: minecraftAccounts.id, username: minecraftAccounts.username }).from(minecraftAccounts).where( + and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), + ).limit(1); - const changed = await db.transaction(async (tx) => { - const [account] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where( - and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), - ).limit(1); - if (!account) return false; + if (!requestedAccount) redirect("/account?error=unknown-account"); + if (!user.firstName) redirect("/account?error=nickname-not-configured"); + if (!confirmed) redirect(`/account?pendingPrimary=${encodeURIComponent(requestedAccount.id)}`); - await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where( - and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), + const guildId = process.env.DISCORD_GUILD_ID?.trim(); + const botToken = process.env.DISCORD_BOT_TOKEN?.trim(); + if (!guildId || !botToken) redirect("/account?error=nickname-not-configured"); + const nickname = formatDiscordNickname(user.firstName, requestedAccount.username); + + let changed = false; + try { + changed = await db.transaction(async (tx) => { + const [account] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where( + and(eq(minecraftAccounts.id, requestedAccount.id), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), + ).limit(1); + if (!account) return false; + + await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where( + and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), + ); + await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id)); + await updateGuildNickname({ + guildId, + discordUserId: user.discordUserId, + nickname, + botToken, + }); + return true; + }); + } catch (error) { + logger.error( + { err: error, event: "account.primary_update_failed" }, + "Failed to update the primary account and Discord nickname", ); - await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id)); - return true; - }); + redirect(`/account?error=nickname-update-failed&pendingPrimary=${encodeURIComponent(requestedAccount.id)}`); + } if (!changed) redirect("/account?error=unknown-account"); - await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", { accountId }); - redirect("/account?confirmNickname=1"); + await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", { + accountId: requestedAccount.id, + nickname, + }); + await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", { + nickname, + operation: "set-primary-account", + }); + redirect("/account?nicknameUpdated=1"); } export async function removeMinecraftAccount(formData: FormData) { diff --git a/apps/web/src/app/account/page.tsx b/apps/web/src/app/account/page.tsx index 488eedb..71775a1 100644 --- a/apps/web/src/app/account/page.tsx +++ b/apps/web/src/app/account/page.tsx @@ -4,6 +4,7 @@ import { and, desc, eq, isNull } from "drizzle-orm"; import { logout } from "@/app/auth/actions"; import { db } from "@/lib/database"; import { requireCurrentUser } from "@/lib/auth/user-session"; +import { groupAccessAddresses } from "@/lib/access-address-groups"; import { intelligenceSummary } from "@/lib/event-ip-summary"; import { addMinecraftAccount, @@ -13,6 +14,10 @@ import { updateFirstName, } from "./actions"; +function queryValue(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + const errorMessages: Record = { "invalid-name": "Enter a valid name between 1 and 50 characters.", "invalid-username": "Java usernames use 3–16 letters, numbers, or underscores.", @@ -27,10 +32,12 @@ const errorMessages: Record = { export default async function AccountPage({ searchParams, }: { - searchParams: Promise>; + searchParams: Promise>; }) { const user = await requireCurrentUser("/account"); const query = await searchParams; + const error = queryValue(query.error); + const unverified = queryValue(query.unverified); const [accounts, observations] = await Promise.all([ db .select() @@ -50,12 +57,31 @@ export default async function AccountPage({ .leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress)) .where(eq(ipObservations.userId, user.id)) .orderBy(desc(ipObservations.observedAt)) - .limit(20), + .limit(100), ]); + const addressGroups = groupAccessAddresses(observations); const primary = accounts.find((account) => account.isPrimary); const desiredNickname = user.firstName && primary ? formatDiscordNickname(user.firstName, primary.username) : null; + const pendingName = queryValue(query.pendingName)?.trim(); + const pendingPrimaryId = queryValue(query.pendingPrimary); + const pendingPrimary = accounts.find((account) => account.id === pendingPrimaryId); + const pendingChange = pendingName && pendingName.length <= 50 && primary + ? { + kind: "name" as const, + label: `Change your name to ${pendingName}`, + nickname: formatDiscordNickname(pendingName, primary.username), + firstName: pendingName, + } + : pendingPrimary && user.firstName + ? { + kind: "primary" as const, + label: `Make ${pendingPrimary.username} your primary account`, + nickname: formatDiscordNickname(user.firstName, pendingPrimary.username), + accountId: pendingPrimary.id, + } + : null; return (
@@ -68,14 +94,35 @@ export default async function AccountPage({
- {query.error && ( + {error && (

- {errorMessages[query.error] ?? "The requested change could not be completed."} + {errorMessages[error] ?? "The requested change could not be completed."}

)} - {query.nicknameUpdated &&

Discord nickname updated

} + {queryValue(query.nicknameUpdated) &&

Profile and Discord nickname updated

} - {query.confirmNickname && desiredNickname && ( + {pendingChange && ( +
+
+

Review linked identity change

+

{pendingChange.label}

+

Nothing changes until you confirm. This will also update your Discord nickname to:

+

{pendingChange.nickname}

+
+
+
+ {pendingChange.kind === "name" + ? + : } + + +
+ Cancel +
+
+ )} + + {queryValue(query.confirmNickname) && desiredNickname && (

Confirm Discord change

@@ -108,7 +155,7 @@ export default async function AccountPage({

{account.minecraftUuid ?? "UUID will be learned at game login"}

- {!account.isPrimary &&
} + {!account.isPrimary &&
}
@@ -116,11 +163,11 @@ export default async function AccountPage({ {accounts.length === 0 &&

No active Minecraft accounts. Add one before joining the server.

} - {query.unverified ? ( + {unverified ? (
-

Mojang couldn’t verify “{query.unverified}”

+

Mojang couldn’t verify “{unverified}”

Continue only if you are certain the spelling is correct.

- + Cancel
@@ -135,11 +182,24 @@ export default async function AccountPage({

Recent security activity

Access addresses

- {observations.length ? ( + {addressGroups.length ? (
- {observations.map((observation) => { - const summary = intelligenceSummary(observation.intelligence); - return

{observation.ipAddress}

{summary.location ?? "Location unavailable"} · {summary.classification ?? observation.classification}

{observation.source} · {observation.observedAt.toISOString()}
; +

Similar IPv4 /24 and IPv6 /64 networks are grouped. Counts cover your 100 most recent observations.

+ {addressGroups.map((group) => { + const summary = intelligenceSummary(group.intelligence); + return ( +
+
+
+

{group.network}

+ {group.count} {group.count === 1 ? "observation" : "observations"} +
+

Latest address {group.latestAddress}

+

{summary.location ?? "Location unavailable"} · {summary.classification ?? group.classification}

+
+ {group.sources.join(" + ")}
Last seen {group.latestObservedAt.toISOString()}
+
+ ); })}
) :

No web or game access addresses have been recorded yet.

} @@ -151,8 +211,9 @@ export default async function AccountPage({

Profile

- {desiredNickname &&

Discord preview: {desiredNickname}

} - + {desiredNickname &&

Current Discord nickname: {desiredNickname}

} +

You will review the new Discord nickname before anything changes.

+ diff --git a/apps/web/src/app/admin/(console)/users/[userId]/page.tsx b/apps/web/src/app/admin/(console)/users/[userId]/page.tsx index c5ebebd..e8fc88f 100644 --- a/apps/web/src/app/admin/(console)/users/[userId]/page.tsx +++ b/apps/web/src/app/admin/(console)/users/[userId]/page.tsx @@ -3,6 +3,7 @@ import { events, ipObservations, minecraftAccounts, users } from "@minecraft-acc import { and, desc, eq, isNull, or } from "drizzle-orm"; import Link from "next/link"; import { notFound } from "next/navigation"; +import { groupAccessAddresses } from "@/lib/access-address-groups"; import { db } from "@/lib/database"; import { eventIpSummary } from "@/lib/event-ip-summary"; import { @@ -60,8 +61,11 @@ export default async function AdminUserPage({ .from(ipObservations) .where(eq(ipObservations.userId, user.id)) .orderBy(desc(ipObservations.observedAt)) - .limit(20), + .limit(100), ]); + const addressGroups = groupAccessAddresses( + observations.map((observation) => ({ ...observation, intelligence: null })), + ); const primary = accounts.find((account) => account.isPrimary); const nickname = user.firstName ? formatManagedDiscordNickname(user.firstName, primary?.username ?? null) @@ -162,9 +166,19 @@ export default async function AdminUserPage({

Recent addresses

+

Grouped by IPv4 /24 or IPv6 /64 network across the 100 most recent observations.

- {observations.map((observation) =>

{observation.ipAddress}

{observation.source} · {observation.observedAt.toISOString()}

)} - {!observations.length &&

No addresses recorded.

} + {addressGroups.map((group) => ( +
+
+

{group.network}

+ ×{group.count} +
+

{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}

+

Latest {group.latestAddress}

+
+ ))} + {!addressGroups.length &&

No addresses recorded.

}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index d14f978..66b0ae4 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; +import { SiteFooter } from "@/components/site-footer"; import "./globals.css"; export const metadata: Metadata = { @@ -10,7 +11,10 @@ export const metadata: Metadata = { export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { return ( - {children} + +
{children}
+ + ); } diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 907ef25..8157a5e 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -84,10 +84,6 @@ export default async function HomePage({
-
- Java Edition only - Unknown players are denied by default -
); diff --git a/apps/web/src/components/site-footer.test.tsx b/apps/web/src/components/site-footer.test.tsx new file mode 100644 index 0000000..f45150b --- /dev/null +++ b/apps/web/src/components/site-footer.test.tsx @@ -0,0 +1,13 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { SiteFooter } from "./site-footer"; + +describe("SiteFooter", () => { + it("credits DMG Games with the requested sponsor link", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain("Social Minecraft is sponsored by"); + expect(markup).toContain('href="https://dmg.games"'); + expect(markup).toContain("DMG Games."); + }); +}); diff --git a/apps/web/src/components/site-footer.tsx b/apps/web/src/components/site-footer.tsx new file mode 100644 index 0000000..a255e19 --- /dev/null +++ b/apps/web/src/components/site-footer.tsx @@ -0,0 +1,15 @@ +export function SiteFooter() { + return ( + + ); +} diff --git a/apps/web/src/lib/access-address-groups.test.ts b/apps/web/src/lib/access-address-groups.test.ts new file mode 100644 index 0000000..a3bb96d --- /dev/null +++ b/apps/web/src/lib/access-address-groups.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { groupAccessAddresses } from "./access-address-groups"; + +describe("groupAccessAddresses", () => { + it("collapses repeated observations from the same network into one recent summary", () => { + const groups = groupAccessAddresses([ + { id: "old", ipAddress: "198.51.100.21", source: "web", classification: "clear", observedAt: new Date("2026-08-01T10:00:00Z"), intelligence: null }, + { id: "new", ipAddress: "198.51.100.240", source: "game", classification: "clear", observedAt: new Date("2026-08-01T12:00:00Z"), intelligence: { provider: "proxycheck" } }, + { id: "other", ipAddress: "203.0.113.9", source: "web", classification: "vpn", observedAt: new Date("2026-08-01T11:00:00Z"), intelligence: null }, + ]); + + expect(groups).toHaveLength(2); + expect(groups[0]).toMatchObject({ + network: "198.51.100.0/24", + latestAddress: "198.51.100.240", + sources: ["game", "web"], + count: 2, + classification: "clear", + intelligence: { provider: "proxycheck" }, + }); + expect(groups[0]?.latestObservedAt.toISOString()).toBe("2026-08-01T12:00:00.000Z"); + }); +}); diff --git a/apps/web/src/lib/access-address-groups.ts b/apps/web/src/lib/access-address-groups.ts new file mode 100644 index 0000000..c71374f --- /dev/null +++ b/apps/web/src/lib/access-address-groups.ts @@ -0,0 +1,60 @@ +import { addressGroup } from "@minecraft-account-manager/network"; + +type AccessObservation = { + id: string; + ipAddress: string; + source: string; + classification: string; + observedAt: Date; + intelligence: Record | null; +}; + +export type AccessAddressGroup = { + network: string; + latestAddress: string; + sources: string[]; + count: number; + firstObservedAt: Date; + latestObservedAt: Date; + classification: string; + intelligence: Record | null; +}; + +export function groupAccessAddresses(observations: AccessObservation[]) { + const groups = new Map }>(); + + for (const observation of observations) { + const network = addressGroup(observation.ipAddress); + const existing = groups.get(network); + if (!existing) { + groups.set(network, { + network, + latestAddress: observation.ipAddress, + sources: [], + sourceSet: new Set([observation.source]), + count: 1, + firstObservedAt: observation.observedAt, + latestObservedAt: observation.observedAt, + classification: observation.classification, + intelligence: observation.intelligence, + }); + continue; + } + + existing.count += 1; + existing.sourceSet.add(observation.source); + if (observation.observedAt < existing.firstObservedAt) { + existing.firstObservedAt = observation.observedAt; + } + if (observation.observedAt > existing.latestObservedAt) { + existing.latestAddress = observation.ipAddress; + existing.latestObservedAt = observation.observedAt; + existing.classification = observation.classification; + existing.intelligence = observation.intelligence; + } + } + + return [...groups.values()] + .map(({ sourceSet, ...group }) => ({ ...group, sources: [...sourceSet].sort() })) + .sort((left, right) => right.latestObservedAt.getTime() - left.latestObservedAt.getTime()); +} diff --git a/apps/web/src/lib/dashboard-change-confirmation.test.ts b/apps/web/src/lib/dashboard-change-confirmation.test.ts new file mode 100644 index 0000000..132bc6f --- /dev/null +++ b/apps/web/src/lib/dashboard-change-confirmation.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { hasDiscordNicknameConfirmation } from "./dashboard-change-confirmation"; + +describe("dashboard identity change confirmation", () => { + it("accepts only the explicit Discord nickname confirmation value", () => { + expect(hasDiscordNicknameConfirmation(new FormData())).toBe(false); + + const declined = new FormData(); + declined.set("confirmDiscordNickname", "no"); + expect(hasDiscordNicknameConfirmation(declined)).toBe(false); + + const confirmed = new FormData(); + confirmed.set("confirmDiscordNickname", "yes"); + expect(hasDiscordNicknameConfirmation(confirmed)).toBe(true); + }); +}); diff --git a/apps/web/src/lib/dashboard-change-confirmation.ts b/apps/web/src/lib/dashboard-change-confirmation.ts new file mode 100644 index 0000000..b2f1357 --- /dev/null +++ b/apps/web/src/lib/dashboard-change-confirmation.ts @@ -0,0 +1,3 @@ +export function hasDiscordNicknameConfirmation(formData: FormData) { + return formData.get("confirmDiscordNickname") === "yes"; +} diff --git a/design/log.md b/design/log.md index 904af9c..d81d747 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,7 @@ ## 2026-08-01 +* **Refine**: Group repeated access networks, confirm linked Discord nickname changes before mutation, and add DMG Games sponsorship attribution. * **Extend**: Add shared Pino logging with credential redaction and actionable web and Discord runtime diagnostics. * **Fix**: Build magic-link redirects from the configured public portal URL instead of the reverse proxy's internal request origin. * **Verify**: Confirmed `v1.1.1` left all pre-existing `latest` digests unchanged while publishing versioned artifacts. diff --git a/design/us-001-discord-entry.md b/design/us-001-discord-entry.md index 88416c6..73fc2ac 100644 --- a/design/us-001-discord-entry.md +++ b/design/us-001-discord-entry.md @@ -3,7 +3,7 @@ type: User Story title: Enter the account portal through Discord description: Direct visitors are guided to the configured Discord community and its account commands. tags: [player, portal, discord, onboarding] -timestamp: 2026-08-01T18:43:58Z +timestamp: 2026-08-01T22:04:17Z story_id: US-001 status: verified --- @@ -18,11 +18,13 @@ As a prospective player, I want the portal to direct me to the community Discord - [x] Given a configured invite URL, when the visitor selects the join action, then the Discord invite opens in a new browser context. - [x] Given a configured guild ID, when the visitor selects the app action, then a `discord://` guild link is opened. - [x] Given an unauthenticated protected-page request, when authorization fails, then the visitor returns to the portal with prominent Discord instructions. +- [x] Every portal page credits Social Minecraft sponsorship by DMG Games and links to `https://dmg.games`. # Implementation - [`apps/web/src/app/page.tsx`](../apps/web/src/app/page.tsx) - [`apps/web/src/lib/auth/user-session.ts`](../apps/web/src/lib/auth/user-session.ts) +- [`apps/web/src/components/site-footer.tsx`](../apps/web/src/components/site-footer.tsx) - Configuration: `DISCORD_GUILD_ID`, `DISCORD_INVITE_URL` # Validation diff --git a/design/us-005-user-dashboard.md b/design/us-005-user-dashboard.md index 7431f5f..3554e07 100644 --- a/design/us-005-user-dashboard.md +++ b/design/us-005-user-dashboard.md @@ -3,7 +3,7 @@ type: User Story title: Manage linked accounts from the dashboard description: Authenticated users maintain their profile and active Java Edition accounts. tags: [player, dashboard, minecraft, profile] -timestamp: 2026-08-01T18:43:58Z +timestamp: 2026-08-01T22:04:17Z story_id: US-005 status: verified --- @@ -20,7 +20,7 @@ As a registered player, I want to manage my profile and linked Minecraft account - [x] The user can soft-remove an active account. - [x] The user can choose exactly one active primary account. - [x] Removing a primary account promotes another active account when one exists. -- [x] Name and primary changes show the expected Discord nickname and require confirmation. +- [x] Name and primary changes preview the expected Discord nickname and require explicit confirmation before either profile mutation occurs. - [x] The dashboard shows recent portal and game IP observations with classification and available location. - [x] The user can revoke the current session by signing out. @@ -32,7 +32,7 @@ As a registered player, I want to manage my profile and linked Minecraft account # Validation -Server actions verify the current session and constrain every account lookup by the authenticated user ID. +Server actions verify the current session, constrain every account lookup by the authenticated user ID, and require the explicit Discord confirmation field before name or primary-account mutations. Confirmation parsing is covered by [`apps/web/src/lib/dashboard-change-confirmation.test.ts`](../apps/web/src/lib/dashboard-change-confirmation.test.ts). # Related Stories diff --git a/design/us-007-ip-intelligence.md b/design/us-007-ip-intelligence.md index 80b185b..1a5a278 100644 --- a/design/us-007-ip-intelligence.md +++ b/design/us-007-ip-intelligence.md @@ -3,7 +3,7 @@ type: User Story title: Enrich portal and game login IPs description: Login audit events include cached approximate location and network intelligence from ProxyCheck.io. tags: [security, network, audit, proxycheck] -timestamp: 2026-08-01T18:43:58Z +timestamp: 2026-08-01T22:04:17Z story_id: US-007 status: verified --- @@ -22,6 +22,7 @@ As an operator, I want portal and registered game logins enriched with network c - [x] Unknown game accounts do not trigger paid ProxyCheck lookups. - [x] Login events and IP observations retain the available classification and approximate location. - [x] Users and administrators can see available location and classification in audit views. +- [x] Repeated access observations are summarized by IPv4 /24 or IPv6 /64 network with counts, sources, and latest activity. # Implementation @@ -34,6 +35,8 @@ As an operator, I want portal and registered game logins enriched with network c - [`packages/network/test/proxycheck.test.ts`](../packages/network/test/proxycheck.test.ts) - [`packages/network/test/client-ip.test.ts`](../packages/network/test/client-ip.test.ts) +- [`packages/network/test/address-groups.test.ts`](../packages/network/test/address-groups.test.ts) +- [`apps/web/src/lib/access-address-groups.test.ts`](../apps/web/src/lib/access-address-groups.test.ts) # Related Stories diff --git a/packages/network/src/index.ts b/packages/network/src/index.ts index 5a382bb..bb9055b 100644 --- a/packages/network/src/index.ts +++ b/packages/network/src/index.ts @@ -118,6 +118,26 @@ export class ProxyCheckProvider implements IpIntelligenceProvider { } } +export function addressGroup(ipAddress: string) { + const hostAddress = ipAddress.split("/", 1)[0] ?? ipAddress; + if (!isIP(hostAddress)) return ipAddress; + + let address = ipaddr.parse(hostAddress); + if (address instanceof ipaddr.IPv6 && address.isIPv4MappedAddress()) { + address = address.toIPv4Address(); + } + + const prefixLength = address.kind() === "ipv4" ? 24 : 64; + const bytes = address.toByteArray(); + for (let bit = prefixLength; bit < bytes.length * 8; bit += 1) { + const byteIndex = Math.floor(bit / 8); + const bitMask = 1 << (7 - (bit % 8)); + bytes[byteIndex] = (bytes[byteIndex] ?? 0) & ~bitMask; + } + + return `${ipaddr.fromByteArray(bytes).toString()}/${prefixLength}`; +} + export function isPublicIp(ipAddress: string) { if (!isIP(ipAddress)) return false; diff --git a/packages/network/test/address-groups.test.ts b/packages/network/test/address-groups.test.ts new file mode 100644 index 0000000..05bb486 --- /dev/null +++ b/packages/network/test/address-groups.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { addressGroup } from "../src/index"; + +describe("access address groups", () => { + it("groups nearby IPv4 and IPv6 addresses by their stable network prefix", () => { + expect(addressGroup("198.51.100.21")).toBe("198.51.100.0/24"); + expect(addressGroup("198.51.100.240")).toBe("198.51.100.0/24"); + expect(addressGroup("198.51.100.99/32")).toBe("198.51.100.0/24"); + expect(addressGroup("2001:db8:abcd:1234:1111::1")).toBe("2001:db8:abcd:1234::/64"); + expect(addressGroup("2001:db8:abcd:1234:ffff::9")).toBe("2001:db8:abcd:1234::/64"); + }); +});