Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19a5d04178 | ||
|
|
86c87153b4 | ||
|
|
5e693e2cdd | ||
|
|
9440c651b6 |
@@ -24,3 +24,6 @@ IP_INTELLIGENCE_PROVIDER=proxycheck
|
||||
PROXYCHECK_API_KEY=
|
||||
IP_INTELLIGENCE_CACHE_HOURS=48
|
||||
BLOCK_HOSTING_IPS=false
|
||||
|
||||
# Structured Pino logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
+4
-1
@@ -10,6 +10,7 @@ COPY apps/discord-bot/package.json ./apps/discord-bot/package.json
|
||||
COPY packages/auth/package.json ./packages/auth/package.json
|
||||
COPY packages/contracts/package.json ./packages/contracts/package.json
|
||||
COPY packages/database/package.json ./packages/database/package.json
|
||||
COPY packages/logging/package.json ./packages/logging/package.json
|
||||
COPY packages/minecraft/package.json ./packages/minecraft/package.json
|
||||
COPY packages/network/package.json ./packages/network/package.json
|
||||
RUN npm ci
|
||||
@@ -25,6 +26,7 @@ LABEL org.opencontainers.image.title="Minecraft Account Manager" \
|
||||
org.opencontainers.image.source="https://git.garvis.dev/dmg/minecraft-account-manager"
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
APP_VERSION=${VERSION} \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
PORT=3000
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
@@ -40,7 +42,8 @@ LABEL org.opencontainers.image.title="Minecraft Account Manager Discord Bot" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.source="https://git.garvis.dev/dmg/minecraft-account-manager"
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_ENV=production \
|
||||
APP_VERSION=${VERSION}
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
COPY --chown=app:app package.json package-lock.json tsconfig.base.json ./
|
||||
COPY --chown=app:app apps/discord-bot ./apps/discord-bot
|
||||
|
||||
@@ -3,3 +3,4 @@ APP_URL=http://localhost:3000
|
||||
DISCORD_BOT_TOKEN=
|
||||
DISCORD_APPLICATION_ID=
|
||||
DISCORD_GUILD_ID=
|
||||
LOG_LEVEL=info
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@minecraft-account-manager/auth": "*",
|
||||
"@minecraft-account-manager/contracts": "*",
|
||||
"@minecraft-account-manager/database": "*",
|
||||
"@minecraft-account-manager/logging": "*",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1"
|
||||
|
||||
@@ -2,10 +2,22 @@ import "dotenv/config";
|
||||
import { REST, Routes } from "discord.js";
|
||||
import { commands } from "./commands";
|
||||
import { requiredEnvironment } from "./config";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
|
||||
const applicationId = requiredEnvironment("DISCORD_APPLICATION_ID");
|
||||
const rest = new REST({ version: "10" }).setToken(token);
|
||||
|
||||
await rest.put(Routes.applicationCommands(applicationId), { body: commands });
|
||||
console.log(`Deployed ${commands.length} global Discord commands.`);
|
||||
try {
|
||||
await rest.put(Routes.applicationCommands(applicationId), { body: commands });
|
||||
logger.info(
|
||||
{ event: "discord.commands_deployed", commandCount: commands.length },
|
||||
"Deployed global Discord commands",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.fatal(
|
||||
{ err: error, event: "discord.commands_deploy_failed" },
|
||||
"Failed to deploy global Discord commands",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
GatewayIntentBits,
|
||||
} from "discord.js";
|
||||
import { commandNames, requiredEnvironment } from "./config";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
|
||||
const appUrl = requiredEnvironment("APP_URL");
|
||||
@@ -24,7 +25,10 @@ const authRepository = createAuthRepository(db);
|
||||
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
||||
|
||||
client.once(Events.ClientReady, (readyClient) => {
|
||||
console.log(`Discord bot ready as ${readyClient.user.tag}`);
|
||||
logger.info(
|
||||
{ event: "discord.ready", botUserId: readyClient.user.id, botUsername: readyClient.user.username },
|
||||
"Discord bot is ready",
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.InteractionCreate, async (interaction) => {
|
||||
@@ -71,9 +75,17 @@ client.on(Events.InteractionCreate, async (interaction) => {
|
||||
await interaction.editReply("Please wait 30 seconds before requesting another private account link.");
|
||||
return;
|
||||
}
|
||||
console.error("Failed to create Discord account link", error);
|
||||
logger.error(
|
||||
{ err: error, event: "discord.magic_link_failed", command: interaction.commandName },
|
||||
"Failed to create Discord account link",
|
||||
);
|
||||
await interaction.editReply("I could not create an account link. Please try again shortly.");
|
||||
}
|
||||
});
|
||||
|
||||
await client.login(token);
|
||||
try {
|
||||
await client.login(token);
|
||||
} catch (error) {
|
||||
logger.fatal({ err: error, event: "discord.login_failed" }, "Discord bot login failed");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createLogger } from "@minecraft-account-manager/logging";
|
||||
|
||||
export const logger = createLogger("minecraft-account-manager-discord-bot");
|
||||
@@ -34,6 +34,7 @@ const nextConfig: NextConfig = {
|
||||
transpilePackages: [
|
||||
"@minecraft-account-manager/contracts",
|
||||
"@minecraft-account-manager/database",
|
||||
"@minecraft-account-manager/logging",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@minecraft-account-manager/auth": "*",
|
||||
"@minecraft-account-manager/contracts": "*",
|
||||
"@minecraft-account-manager/database": "*",
|
||||
"@minecraft-account-manager/logging": "*",
|
||||
"@minecraft-account-manager/minecraft": "*",
|
||||
"@minecraft-account-manager/network": "*",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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<string, string> = {
|
||||
export default async function AccountPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
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 (
|
||||
<main className="min-h-screen bg-canvas px-6 py-10 text-ink sm:py-16">
|
||||
@@ -68,14 +94,35 @@ export default async function AccountPage({
|
||||
<form action={logout}><button className="font-mono text-xs font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Sign out</button></form>
|
||||
</header>
|
||||
|
||||
{query.error && (
|
||||
{error && (
|
||||
<p className="mt-8 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">
|
||||
{errorMessages[query.error] ?? "The requested change could not be completed."}
|
||||
{errorMessages[error] ?? "The requested change could not be completed."}
|
||||
</p>
|
||||
)}
|
||||
{query.nicknameUpdated && <p className="mt-8 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider">Discord nickname updated</p>}
|
||||
{queryValue(query.nicknameUpdated) && <p className="mt-8 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider">Profile and Discord nickname updated</p>}
|
||||
|
||||
{query.confirmNickname && desiredNickname && (
|
||||
{pendingChange && (
|
||||
<section className="mt-8 border border-accent bg-panel p-6 shadow-[6px_6px_0_var(--color-accent)] sm:flex sm:items-center sm:justify-between sm:gap-8">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Review linked identity change</p>
|
||||
<h2 className="mt-3 font-display text-2xl font-black uppercase">{pendingChange.label}</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-muted">Nothing changes until you confirm. This will also update your Discord nickname to:</p>
|
||||
<p className="mt-2 font-display text-2xl font-black">{pendingChange.nickname}</p>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap items-center gap-4 sm:mt-0 sm:justify-end">
|
||||
<form action={pendingChange.kind === "name" ? updateFirstName : setPrimaryAccount}>
|
||||
{pendingChange.kind === "name"
|
||||
? <input name="firstName" type="hidden" value={pendingChange.firstName} />
|
||||
: <input name="accountId" type="hidden" value={pendingChange.accountId} />}
|
||||
<input name="confirmDiscordNickname" type="hidden" value="yes" />
|
||||
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Confirm both changes</button>
|
||||
</form>
|
||||
<a className="font-mono text-[10px] font-bold uppercase underline underline-offset-4" href="/account">Cancel</a>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{queryValue(query.confirmNickname) && desiredNickname && (
|
||||
<section className="mt-8 border border-accent bg-panel p-6 shadow-[6px_6px_0_var(--color-accent)] sm:flex sm:items-center sm:justify-between sm:gap-8">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Confirm Discord change</p>
|
||||
@@ -108,7 +155,7 @@ export default async function AccountPage({
|
||||
<p className="mt-2 break-all font-mono text-[10px] text-muted">{account.minecraftUuid ?? "UUID will be learned at game login"}</p>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
{!account.isPrimary && <form action={setPrimaryAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Make primary</button></form>}
|
||||
{!account.isPrimary && <form action={setPrimaryAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Review primary change</button></form>}
|
||||
<form action={removeMinecraftAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider text-accent underline underline-offset-4" type="submit">Remove</button></form>
|
||||
</div>
|
||||
</article>
|
||||
@@ -116,11 +163,11 @@ export default async function AccountPage({
|
||||
{accounts.length === 0 && <p className="py-8 text-muted">No active Minecraft accounts. Add one before joining the server.</p>}
|
||||
</div>
|
||||
|
||||
{query.unverified ? (
|
||||
{unverified ? (
|
||||
<form action={addMinecraftAccount} className="border-l-2 border-accent bg-panel p-6">
|
||||
<h3 className="font-display text-xl font-black uppercase">Mojang couldn’t verify “{query.unverified}”</h3>
|
||||
<h3 className="font-display text-xl font-black uppercase">Mojang couldn’t verify “{unverified}”</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-muted">Continue only if you are certain the spelling is correct.</p>
|
||||
<input name="username" type="hidden" value={query.unverified} /><input name="confirmUnverified" type="hidden" value="yes" />
|
||||
<input name="username" type="hidden" value={unverified} /><input name="confirmUnverified" type="hidden" value="yes" />
|
||||
<button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Add anyway</button>
|
||||
<a className="ml-5 font-mono text-[10px] font-bold uppercase underline" href="/account">Cancel</a>
|
||||
</form>
|
||||
@@ -135,11 +182,24 @@ export default async function AccountPage({
|
||||
<section>
|
||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Recent security activity</p>
|
||||
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Access addresses</h2>
|
||||
{observations.length ? (
|
||||
{addressGroups.length ? (
|
||||
<div className="divide-y divide-line font-mono text-xs">
|
||||
{observations.map((observation) => {
|
||||
const summary = intelligenceSummary(observation.intelligence);
|
||||
return <div className="grid grid-cols-[1fr_auto] gap-4 py-4" key={observation.id}><div><p>{observation.ipAddress}</p><p className="mt-1 text-[10px] text-muted">{summary.location ?? "Location unavailable"} · {summary.classification ?? observation.classification}</p></div><span className="text-muted">{observation.source} · {observation.observedAt.toISOString()}</span></div>;
|
||||
<p className="py-3 text-[10px] leading-5 text-muted">Similar IPv4 /24 and IPv6 /64 networks are grouped. Counts cover your 100 most recent observations.</p>
|
||||
{addressGroups.map((group) => {
|
||||
const summary = intelligenceSummary(group.intelligence);
|
||||
return (
|
||||
<div className="grid gap-3 py-4 sm:grid-cols-[1fr_auto] sm:items-start" key={group.network}>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="font-bold">{group.network}</p>
|
||||
<span className="border border-line px-2 py-1 text-[9px] uppercase tracking-wider text-muted">{group.count} {group.count === 1 ? "observation" : "observations"}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-muted">Latest address {group.latestAddress}</p>
|
||||
<p className="mt-1 text-[10px] text-muted">{summary.location ?? "Location unavailable"} · {summary.classification ?? group.classification}</p>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted sm:text-right">{group.sources.join(" + ")}<br />Last seen {group.latestObservedAt.toISOString()}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <p className="py-6 text-sm text-muted">No web or game access addresses have been recorded yet.</p>}
|
||||
@@ -151,8 +211,9 @@ export default async function AccountPage({
|
||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Profile</p>
|
||||
<label className="mt-5 block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="firstName">What we call you</label>
|
||||
<input className="mt-3 w-full border border-line bg-canvas px-4 py-3 outline-none focus:border-accent" defaultValue={user.firstName ?? ""} id="firstName" maxLength={50} name="firstName" required />
|
||||
{desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Discord preview: <strong className="text-ink">{desiredNickname}</strong></p>}
|
||||
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider" type="submit">Save name</button>
|
||||
{desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Current Discord nickname: <strong className="text-ink">{desiredNickname}</strong></p>}
|
||||
<p className="mt-3 text-xs leading-5 text-muted">You will review the new Discord nickname before anything changes.</p>
|
||||
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider" type="submit">Review name change</button>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
|
||||
<section className="border border-line bg-panel p-6">
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Recent addresses</p>
|
||||
<p className="mt-3 text-[10px] leading-5 text-muted">Grouped by IPv4 /24 or IPv6 /64 network across the 100 most recent observations.</p>
|
||||
<div className="mt-4 divide-y divide-line">
|
||||
{observations.map((observation) => <div className="py-3" key={observation.id}><p className="font-mono text-xs">{observation.ipAddress}</p><p className="mt-1 font-mono text-[9px] text-muted">{observation.source} · {observation.observedAt.toISOString()}</p></div>)}
|
||||
{!observations.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
|
||||
{addressGroups.map((group) => (
|
||||
<div className="py-3" key={group.network}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-mono text-xs font-bold">{group.network}</p>
|
||||
<span className="font-mono text-[9px] text-muted">×{group.count}</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[9px] text-muted">{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}</p>
|
||||
<p className="mt-1 break-all font-mono text-[9px] text-muted">Latest {group.latestAddress}</p>
|
||||
</div>
|
||||
))}
|
||||
{!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/database";
|
||||
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
||||
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||
|
||||
const MAX_CLOCK_SKEW_MS = 45_000;
|
||||
@@ -284,7 +285,10 @@ export async function POST(request: Request) {
|
||||
));
|
||||
}
|
||||
|
||||
console.error("Velocity access request failed");
|
||||
logger.error(
|
||||
{ err: error, event: "velocity.access_failed", instance },
|
||||
"Velocity access request failed",
|
||||
);
|
||||
return problemResponse(problemDetails(
|
||||
"urn:error:service-unavailable",
|
||||
"Service unavailable",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createAuthRepository, ipObservations, recordEvent } from "@minecraft-ac
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { applicationUrl } from "@/lib/application-url";
|
||||
import { db } from "@/lib/database";
|
||||
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
||||
|
||||
@@ -38,7 +39,7 @@ export async function GET(request: NextRequest) {
|
||||
]);
|
||||
|
||||
const destination = result.user.firstName ? "/account" : "/welcome";
|
||||
const response = NextResponse.redirect(new URL(destination, request.url));
|
||||
const response = NextResponse.redirect(applicationUrl(destination));
|
||||
response.cookies.set(SESSION_COOKIE_NAME, result.sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
@@ -49,7 +50,7 @@ export async function GET(request: NextRequest) {
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidLoginCodeError) {
|
||||
return NextResponse.redirect(new URL("/auth/error", request.url));
|
||||
return NextResponse.redirect(applicationUrl("/auth/error"));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body className="flex min-h-screen flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
<SiteFooter />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,10 +84,6 @@ export default async function HomePage({
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<footer className="flex flex-col gap-3 border-t border-line pt-5 font-mono text-[10px] uppercase tracking-[0.18em] text-muted sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>Java Edition only</span>
|
||||
<span>Unknown players are denied by default</span>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SiteFooter } from "./site-footer";
|
||||
|
||||
describe("SiteFooter", () => {
|
||||
it("credits DMG Games with the requested sponsor link", () => {
|
||||
const markup = renderToStaticMarkup(<SiteFooter />);
|
||||
|
||||
expect(markup).toContain("Social Minecraft is sponsored by");
|
||||
expect(markup).toContain('href="https://dmg.games"');
|
||||
expect(markup).toContain("DMG Games.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-line bg-panel px-6 py-6 text-center font-mono text-[10px] uppercase tracking-[0.16em] text-muted">
|
||||
Social Minecraft is sponsored by{" "}
|
||||
<a
|
||||
className="font-bold text-ink underline decoration-accent underline-offset-4 transition-colors hover:text-accent"
|
||||
href="https://dmg.games"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
DMG Games.
|
||||
</a>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { addressGroup } from "@minecraft-account-manager/network";
|
||||
|
||||
type AccessObservation = {
|
||||
id: string;
|
||||
ipAddress: string;
|
||||
source: string;
|
||||
classification: string;
|
||||
observedAt: Date;
|
||||
intelligence: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type AccessAddressGroup = {
|
||||
network: string;
|
||||
latestAddress: string;
|
||||
sources: string[];
|
||||
count: number;
|
||||
firstObservedAt: Date;
|
||||
latestObservedAt: Date;
|
||||
classification: string;
|
||||
intelligence: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function groupAccessAddresses(observations: AccessObservation[]) {
|
||||
const groups = new Map<string, AccessAddressGroup & { sourceSet: Set<string> }>();
|
||||
|
||||
for (const observation of observations) {
|
||||
const network = addressGroup(observation.ipAddress);
|
||||
const existing = groups.get(network);
|
||||
if (!existing) {
|
||||
groups.set(network, {
|
||||
network,
|
||||
latestAddress: observation.ipAddress,
|
||||
sources: [],
|
||||
sourceSet: new Set([observation.source]),
|
||||
count: 1,
|
||||
firstObservedAt: observation.observedAt,
|
||||
latestObservedAt: observation.observedAt,
|
||||
classification: observation.classification,
|
||||
intelligence: observation.intelligence,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
existing.sourceSet.add(observation.source);
|
||||
if (observation.observedAt < existing.firstObservedAt) {
|
||||
existing.firstObservedAt = observation.observedAt;
|
||||
}
|
||||
if (observation.observedAt > existing.latestObservedAt) {
|
||||
existing.latestAddress = observation.ipAddress;
|
||||
existing.latestObservedAt = observation.observedAt;
|
||||
existing.classification = observation.classification;
|
||||
existing.intelligence = observation.intelligence;
|
||||
}
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
.map(({ sourceSet, ...group }) => ({ ...group, sources: [...sourceSet].sort() }))
|
||||
.sort((left, right) => right.latestObservedAt.getTime() - left.latestObservedAt.getTime());
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applicationUrl } from "./application-url";
|
||||
|
||||
describe("applicationUrl", () => {
|
||||
it("builds browser redirects from the configured public application URL", () => {
|
||||
expect(applicationUrl("/welcome", "https://portal.somc.club"))
|
||||
.toEqual(new URL("https://portal.somc.club/welcome"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export function applicationUrl(path: string, baseUrl = process.env.APP_URL) {
|
||||
if (!baseUrl) throw new Error("APP_URL is required to build public application URLs");
|
||||
return new URL(path, baseUrl);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function hasDiscordNicknameConfirmation(formData: FormData) {
|
||||
return formData.get("confirmDiscordNickname") === "yes";
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ipIntelligence } from "@minecraft-account-manager/database";
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { db } from "@/lib/database";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const classifications = new Set<IpClassification>([
|
||||
"unknown",
|
||||
@@ -77,6 +78,13 @@ export async function getIpIntelligence(
|
||||
options: { now?: Date; forceRefresh?: boolean } = {},
|
||||
): Promise<IpIntelligenceResult> {
|
||||
if (!isPublicIp(ipAddress)) {
|
||||
logger.warn(
|
||||
{
|
||||
event: "ip_intelligence.skipped",
|
||||
reason: "non_public_address",
|
||||
},
|
||||
"IP intelligence lookup skipped for a non-public client address",
|
||||
);
|
||||
return { classification: "unknown", provider: null };
|
||||
}
|
||||
|
||||
@@ -96,14 +104,23 @@ export async function getIpIntelligence(
|
||||
const result = await provider.classify(ipAddress);
|
||||
await cacheResult(ipAddress, result, now, cacheHours() * 60 * 60_000);
|
||||
return result;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const providerName = process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null;
|
||||
const result: IpIntelligenceResult = {
|
||||
classification: "unknown",
|
||||
provider: process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null,
|
||||
provider: providerName,
|
||||
lookupError: true,
|
||||
};
|
||||
await cacheResult(ipAddress, result, now, 5 * 60_000).catch(() => undefined);
|
||||
console.error("IP intelligence lookup failed");
|
||||
await cacheResult(ipAddress, result, now, 5 * 60_000).catch((cacheError) => {
|
||||
logger.error(
|
||||
{ err: cacheError, event: "ip_intelligence.cache_failed", provider: providerName },
|
||||
"Failed to cache an IP intelligence lookup error",
|
||||
);
|
||||
});
|
||||
logger.error(
|
||||
{ err: error, event: "ip_intelligence.lookup_failed", provider: providerName },
|
||||
"IP intelligence lookup failed",
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -134,6 +151,15 @@ export async function checkAccountAdditionNetwork() {
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
if (!ipAddress) {
|
||||
logger.warn(
|
||||
{
|
||||
event: "client_ip.unavailable",
|
||||
trustProxy: process.env.TRUST_PROXY === "true",
|
||||
forwardedForPresent: requestHeaders.has("x-forwarded-for"),
|
||||
realIpPresent: requestHeaders.has("x-real-ip"),
|
||||
},
|
||||
"Client IP address was unavailable for account addition",
|
||||
);
|
||||
return {
|
||||
allowed: false as const,
|
||||
reason: "unavailable" as const,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createLogger } from "@minecraft-account-manager/logging";
|
||||
|
||||
export const logger = createLogger("minecraft-account-manager-web");
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## 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.
|
||||
* **Refine**: Removed mutable `latest` publication so all deployable artifacts use explicit semantic versions.
|
||||
* **Verify**: Confirmed the `v1.1.0` Discord bot image and matching web, migration, and Velocity artifacts.
|
||||
* **Extend**: Added a releasable Discord bot image and a dependency-free web health endpoint for Kubernetes deployment.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Authenticate with a Discord magic link
|
||||
description: Discord users receive private single-use links that establish secure portal sessions.
|
||||
tags: [player, discord, authentication, security]
|
||||
timestamp: 2026-08-01T18:43:58Z
|
||||
timestamp: 2026-08-01T20:43:46Z
|
||||
story_id: US-002
|
||||
status: verified
|
||||
---
|
||||
@@ -19,6 +19,7 @@ As a Discord community member, I want `/register` and `/account` to issue a priv
|
||||
- [x] Given a login token, then it expires after ten minutes and can be consumed only once.
|
||||
- [x] Given repeated link requests, then requests are rate limited per Discord user and older active links are invalidated.
|
||||
- [x] Given a valid link, when it is consumed, then the Discord user is created or refreshed and a secure seven-day session is established.
|
||||
- [x] Given a magic-link result behind a reverse proxy, then the browser is redirected through the configured public application URL rather than an internal container address.
|
||||
- [x] Given an invalid, expired, or consumed link, then the user sees a safe recovery page instructing them to request another link.
|
||||
|
||||
# Implementation
|
||||
@@ -27,10 +28,12 @@ As a Discord community member, I want `/register` and `/account` to issue a priv
|
||||
- [`packages/auth/src/index.ts`](../packages/auth/src/index.ts)
|
||||
- [`packages/database/src/auth-repository.ts`](../packages/database/src/auth-repository.ts)
|
||||
- [`apps/web/src/app/auth/discord/route.ts`](../apps/web/src/app/auth/discord/route.ts)
|
||||
- [`apps/web/src/lib/application-url.ts`](../apps/web/src/lib/application-url.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`packages/auth/test/magic-link.test.ts`](../packages/auth/test/magic-link.test.ts)
|
||||
- [`apps/web/src/lib/application-url.test.ts`](../apps/web/src/lib/application-url.test.ts)
|
||||
- Discord command and authentication workspaces pass TypeScript validation.
|
||||
|
||||
# Related Stories
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Deploy and operate the platform securely
|
||||
description: Operators have repeatable builds, migrations, credential provisioning, configuration, and security checks.
|
||||
tags: [operations, security, database, deployment]
|
||||
timestamp: 2026-08-01T19:46:09Z
|
||||
timestamp: 2026-08-01T21:37:26Z
|
||||
story_id: US-015
|
||||
status: verified
|
||||
---
|
||||
@@ -22,6 +22,7 @@ As a platform operator, I want reproducible deployment and security controls, so
|
||||
- [x] Environment examples document database, Keycloak, Discord, trusted proxy, and ProxyCheck settings without secrets.
|
||||
- [x] The web application sets CSP, framing, MIME, referrer, and permissions headers.
|
||||
- [x] The web runtime provides a dependency-free health endpoint for orchestration probes.
|
||||
- [x] Web and Discord bot runtimes emit structured Pino logs with credential-field redaction and safe operational context.
|
||||
- [x] npm dependency audit and Semgrep security review complete without findings at the last verified change.
|
||||
- [x] Architecture, Keycloak, API error, security, bot, and Velocity operating documentation is available.
|
||||
|
||||
@@ -33,10 +34,11 @@ As a platform operator, I want reproducible deployment and security controls, so
|
||||
- [`packages/database/scripts/create-plugin-credential.ts`](../packages/database/scripts/create-plugin-credential.ts)
|
||||
- [`plugins/velocity/build.gradle.kts`](../plugins/velocity/build.gradle.kts)
|
||||
- [`apps/web/next.config.ts`](../apps/web/next.config.ts)
|
||||
- [`packages/logging/src/index.ts`](../packages/logging/src/index.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
Use `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, `npm run velocity:build`, `npm audit`, and `npm run design:validate`.
|
||||
Use `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, `npm run velocity:build`, `npm audit`, and `npm run design:validate`. Structured logging redaction is covered by [`packages/logging/test/logger.test.ts`](../packages/logging/test/logger.test.ts).
|
||||
|
||||
# Related Stories
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ type: User Story
|
||||
title: Build and publish versioned releases
|
||||
description: Gitea Actions validate every change and publish semantically versioned Velocity and container artifacts.
|
||||
tags: [operations, ci, release, velocity, docker]
|
||||
timestamp: 2026-08-01T19:56:39Z
|
||||
timestamp: 2026-08-01T20:05:49Z
|
||||
story_id: US-016
|
||||
status: implemented
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
@@ -23,7 +23,7 @@ As a platform operator, I want automated validation and semantic releases, so th
|
||||
- [x] Releases publish semantically versioned web runtime images to the Gitea registry.
|
||||
- [x] Releases publish semantically versioned Discord bot images to the Gitea registry.
|
||||
- [x] Releases publish semantically versioned migration images that run versioned Drizzle migrations.
|
||||
- [ ] Releases do not publish mutable container tags such as `latest`.
|
||||
- [x] Releases do not publish mutable container tags such as `latest`.
|
||||
- [x] Runtime containers use unprivileged users and exclude development source and secrets where practical.
|
||||
- [x] Operators are told which repository secrets must be configured before the first push.
|
||||
|
||||
@@ -38,7 +38,7 @@ As a platform operator, I want automated validation and semantic releases, so th
|
||||
|
||||
# Validation
|
||||
|
||||
Local OKF, lint, typecheck, test, Next.js build, and versioned Velocity JAR checks pass. Initial Gitea CI and release runs succeeded. Release `v1.0.0` provides a publicly downloadable JAR whose Velocity metadata reports `1.0.0`. Registry manifests were resolved for the published semantic-version tags. Release `v1.1.0` also publishes resolvable versioned web, Discord bot, and migration manifests and a public Velocity JAR whose metadata reports `1.1.0`. Pull-request commitlint configuration is present; its conditional execution will be exercised by the first pull request.
|
||||
Local OKF, lint, typecheck, test, Next.js build, and versioned Velocity JAR checks pass. Initial Gitea CI and release runs succeeded. Release `v1.0.0` provides a publicly downloadable JAR whose Velocity metadata reports `1.0.0`. Registry manifests were resolved for the published semantic-version tags. Release `v1.1.0` also publishes resolvable versioned web, Discord bot, and migration manifests and a public Velocity JAR whose metadata reports `1.1.0`. Release `v1.1.1` published immutable semantic-version tags only; prior `latest` digests remained unchanged. Pull-request commitlint configuration is present; its conditional execution will be exercised by the first pull request.
|
||||
|
||||
# Related Stories
|
||||
|
||||
|
||||
Generated
+155
@@ -22,6 +22,7 @@
|
||||
"@minecraft-account-manager/auth": "*",
|
||||
"@minecraft-account-manager/contracts": "*",
|
||||
"@minecraft-account-manager/database": "*",
|
||||
"@minecraft-account-manager/logging": "*",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1"
|
||||
@@ -38,6 +39,7 @@
|
||||
"@minecraft-account-manager/auth": "*",
|
||||
"@minecraft-account-manager/contracts": "*",
|
||||
"@minecraft-account-manager/database": "*",
|
||||
"@minecraft-account-manager/logging": "*",
|
||||
"@minecraft-account-manager/minecraft": "*",
|
||||
"@minecraft-account-manager/network": "*",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
@@ -1781,6 +1783,10 @@
|
||||
"resolved": "apps/discord-bot",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@minecraft-account-manager/logging": {
|
||||
"resolved": "packages/logging",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@minecraft-account-manager/minecraft": {
|
||||
"resolved": "packages/minecraft",
|
||||
"link": true
|
||||
@@ -2038,6 +2044,12 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
|
||||
@@ -3810,6 +3822,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
@@ -7052,6 +7073,15 @@
|
||||
"node": "^10.13.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||
@@ -7220,6 +7250,43 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -7317,6 +7384,22 @@
|
||||
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
|
||||
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -7360,6 +7443,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
@@ -7388,6 +7477,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -7600,6 +7698,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -7834,6 +7941,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -7864,6 +7980,15 @@
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
@@ -8106,6 +8231,24 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream/node_modules/real-require": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
|
||||
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -9215,6 +9358,18 @@
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"packages/logging": {
|
||||
"name": "@minecraft-account-manager/logging",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"pino": "^10.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"packages/minecraft": {
|
||||
"name": "@minecraft-account-manager/minecraft",
|
||||
"version": "0.1.0",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@minecraft-account-manager/logging",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"pino": "^10.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import pino, { type DestinationStream, type Logger } from "pino";
|
||||
|
||||
const redactedPaths = [
|
||||
"apiKey",
|
||||
"authorization",
|
||||
"password",
|
||||
"token",
|
||||
"*.apiKey",
|
||||
"*.authorization",
|
||||
"*.password",
|
||||
"*.token",
|
||||
"headers.authorization",
|
||||
"req.headers.authorization",
|
||||
];
|
||||
|
||||
export function createLogger(
|
||||
service: string,
|
||||
options: { destination?: DestinationStream } = {},
|
||||
): Logger {
|
||||
return pino(
|
||||
{
|
||||
level: process.env.LOG_LEVEL?.trim() || "info",
|
||||
base: {
|
||||
service,
|
||||
environment: process.env.NODE_ENV ?? "development",
|
||||
version: process.env.APP_VERSION ?? "development",
|
||||
},
|
||||
redact: {
|
||||
paths: redactedPaths,
|
||||
censor: "[Redacted]",
|
||||
},
|
||||
},
|
||||
options.destination,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Writable } from "node:stream";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLogger } from "../src/index";
|
||||
|
||||
function captureLog() {
|
||||
let output = "";
|
||||
const destination = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
output += chunk.toString();
|
||||
callback();
|
||||
},
|
||||
});
|
||||
return { destination, read: () => JSON.parse(output.trim()) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
describe("structured application logging", () => {
|
||||
it("emits service metadata and redacts credential fields", () => {
|
||||
const capture = captureLog();
|
||||
const logger = createLogger("account-manager-test", { destination: capture.destination });
|
||||
|
||||
logger.info({ token: "secret-token", apiKey: "secret-key", operation: "test" }, "Test event");
|
||||
|
||||
expect(capture.read()).toMatchObject({
|
||||
service: "account-manager-test",
|
||||
token: "[Redacted]",
|
||||
apiKey: "[Redacted]",
|
||||
operation: "test",
|
||||
msg: "Test event",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "types": ["node", "vitest/globals"] },
|
||||
"include": ["src/**/*.ts", "test/**/*.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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user