feat(platform): add Discord onboarding and Velocity gate

This commit is contained in:
dmg
2026-08-01 13:45:18 -04:00
parent 9d305e5dc9
commit c5de0a1810
80 changed files with 4840 additions and 1572 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@minecraft-account-manager/auth",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^25.0.3",
"typescript": "^5.9.3",
"vitest": "^4.1.0"
}
}
+177
View File
@@ -0,0 +1,177 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
const LOGIN_CODE_TTL_MS = 10 * 60 * 1_000;
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
export const SESSION_COOKIE_NAME = "minecraft_account_session";
type ClaimMap = Record<string, unknown>;
function claimMap(value: unknown): ClaimMap {
return value && typeof value === "object" && !Array.isArray(value) ? (value as ClaimMap) : {};
}
function stringList(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function tokenClaims(token: string | undefined): ClaimMap {
const payload = token?.split(".")[1];
if (!payload) return {};
try {
return claimMap(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
} catch {
return {};
}
}
export function extractOidcRoles(input: {
clientId: string;
profile?: unknown;
idToken?: string;
accessToken?: string;
}) {
const sources = [claimMap(input.profile), tokenClaims(input.idToken), tokenClaims(input.accessToken)];
const roles: string[] = [];
for (const source of sources) {
roles.push(...stringList(source.roles), ...stringList(source.groups));
roles.push(...stringList(claimMap(source.realm_access).roles));
const clientRoles = claimMap(claimMap(source.resource_access)[input.clientId]);
roles.push(...stringList(clientRoles.roles));
}
return [...new Set(roles)];
}
export interface DiscordIdentity {
id: string;
username: string;
globalName?: string | null;
}
export interface PendingLoginCode {
tokenHash: string;
discordUserId: string;
discordUsername: string;
discordGlobalName: string | null;
expiresAt: Date;
createdAt: Date;
}
export interface AuthUser {
id: string;
discordUserId: string;
discordUsername: string;
firstName: string | null;
}
export interface AuthRepository {
saveLoginCode(code: PendingLoginCode): Promise<void>;
exchangeLoginCode(input: {
loginCodeHash: string;
sessionTokenHash: string;
sessionExpiresAt: Date;
now: Date;
}): Promise<{ user: AuthUser; isNewUser: boolean } | null>;
}
interface MagicLinkDependencies {
repository: AuthRepository;
appUrl: string;
now?: () => Date;
randomToken?: () => string;
}
interface ExchangeDependencies {
repository: AuthRepository;
now?: () => Date;
randomToken?: () => string;
}
export class LoginRateLimitedError extends Error {
constructor() {
super("Please wait before requesting another login link.");
this.name = "LoginRateLimitedError";
}
}
export class InvalidLoginCodeError extends Error {
constructor() {
super("The login link is invalid, expired, or has already been used.");
this.name = "InvalidLoginCodeError";
}
}
export function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export function verifyHashedToken(providedToken: string, expectedHash: string) {
const provided = Buffer.from(hashToken(providedToken), "utf8");
const expected = Buffer.from(expectedHash, "utf8");
return provided.length === expected.length && timingSafeEqual(provided, expected);
}
export function isRequestTimestampFresh(occurredAt: Date, now: Date, maxClockSkewMs: number) {
return (
Number.isFinite(occurredAt.getTime()) &&
Math.abs(now.getTime() - occurredAt.getTime()) <= maxClockSkewMs
);
}
function secureToken() {
return randomBytes(32).toString("base64url");
}
export async function createMagicLink(
identity: DiscordIdentity,
dependencies: MagicLinkDependencies,
) {
const now = dependencies.now?.() ?? new Date();
const token = dependencies.randomToken?.() ?? secureToken();
const expiresAt = new Date(now.getTime() + LOGIN_CODE_TTL_MS);
await dependencies.repository.saveLoginCode({
tokenHash: hashToken(token),
discordUserId: identity.id,
discordUsername: identity.username,
discordGlobalName: identity.globalName ?? null,
expiresAt,
createdAt: now,
});
const url = new URL("/auth/discord", dependencies.appUrl);
url.searchParams.set("code", token);
return { url: url.toString(), expiresAt };
}
export async function exchangeMagicLink(code: string, dependencies: ExchangeDependencies) {
if (!code) {
throw new InvalidLoginCodeError();
}
const now = dependencies.now?.() ?? new Date();
const sessionToken = dependencies.randomToken?.() ?? secureToken();
const result = await dependencies.repository.exchangeLoginCode({
loginCodeHash: hashToken(code),
sessionTokenHash: hashToken(sessionToken),
sessionExpiresAt: new Date(now.getTime() + SESSION_TTL_MS),
now,
});
if (!result) {
throw new InvalidLoginCodeError();
}
return {
...result,
sessionToken,
sessionExpiresAt: new Date(now.getTime() + SESSION_TTL_MS),
};
}
+104
View File
@@ -0,0 +1,104 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
createMagicLink,
exchangeMagicLink,
InvalidLoginCodeError,
type AuthRepository,
type PendingLoginCode,
} from "../src/index";
class MemoryAuthRepository implements AuthRepository {
loginCode: PendingLoginCode | undefined;
consumedHash: string | undefined;
async saveLoginCode(code: PendingLoginCode) {
this.loginCode = code;
}
async exchangeLoginCode(input: Parameters<AuthRepository["exchangeLoginCode"]>[0]) {
this.consumedHash = input.loginCodeHash;
if (
!this.loginCode ||
this.loginCode.tokenHash !== input.loginCodeHash ||
this.loginCode.expiresAt <= input.now
) {
return null;
}
this.loginCode = undefined;
return {
user: {
id: "01JQ0000000000000000000000",
discordUserId: "123456789012345678",
discordUsername: "steve",
firstName: null,
},
isNewUser: true,
};
}
}
const identity = {
id: "123456789012345678",
username: "steve",
globalName: "Steve",
};
const now = new Date("2026-08-01T12:00:00.000Z");
function hash(value: string) {
return createHash("sha256").update(value).digest("hex");
}
describe("Discord magic-link authentication", () => {
it("returns a link while persisting only the token hash", async () => {
const repository = new MemoryAuthRepository();
const result = await createMagicLink(identity, {
repository,
appUrl: "https://accounts.example.com",
now: () => now,
randomToken: () => "private-login-token",
});
expect(result.url).toBe("https://accounts.example.com/auth/discord?code=private-login-token");
expect(result.expiresAt).toEqual(new Date("2026-08-01T12:10:00.000Z"));
expect(repository.loginCode).toMatchObject({
tokenHash: hash("private-login-token"),
discordUserId: identity.id,
discordUsername: identity.username,
});
expect(JSON.stringify(repository.loginCode)).not.toContain("private-login-token");
});
it("exchanges a valid one-time code for a session", async () => {
const repository = new MemoryAuthRepository();
await createMagicLink(identity, {
repository,
appUrl: "https://accounts.example.com",
now: () => now,
randomToken: () => "private-login-token",
});
const result = await exchangeMagicLink("private-login-token", {
repository,
now: () => new Date("2026-08-01T12:01:00.000Z"),
randomToken: () => "private-session-token",
});
expect(result.sessionToken).toBe("private-session-token");
expect(result.user.discordUserId).toBe(identity.id);
expect(repository.consumedHash).toBe(hash("private-login-token"));
});
it("rejects an expired or already-consumed code", async () => {
const repository = new MemoryAuthRepository();
await expect(
exchangeMagicLink("missing-token", {
repository,
now: () => now,
randomToken: () => "private-session-token",
}),
).rejects.toBeInstanceOf(InvalidLoginCodeError);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { extractOidcRoles } from "../src/index";
function unsignedToken(payload: Record<string, unknown>) {
return `header.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.signature`;
}
describe("OIDC role extraction", () => {
it("combines realm and configured-client roles from Keycloak tokens", () => {
const roles = extractOidcRoles({
clientId: "minecraft-account-manager-admin",
profile: { groups: ["support"] },
accessToken: unsignedToken({
realm_access: { roles: ["minecraft-account-manager-admin"] },
resource_access: {
"minecraft-account-manager-admin": { roles: ["settings-editor"] },
},
}),
});
expect(roles).toEqual([
"support",
"minecraft-account-manager-admin",
"settings-editor",
]);
});
it("treats malformed token payloads as having no roles", () => {
expect(extractOidcRoles({ clientId: "admin", accessToken: "invalid" })).toEqual([]);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { hashToken, isRequestTimestampFresh, verifyHashedToken } from "../src/index";
describe("plugin request authentication", () => {
it("compares an opaque token with its stored hash", () => {
const storedHash = hashToken("correct-high-entropy-token");
expect(verifyHashedToken("correct-high-entropy-token", storedHash)).toBe(true);
expect(verifyHashedToken("wrong-token", storedHash)).toBe(false);
expect(verifyHashedToken("correct-high-entropy-token", "malformed")).toBe(false);
});
it("rejects stale and excessively future-dated requests", () => {
const now = new Date("2026-08-01T12:00:00.000Z");
expect(isRequestTimestampFresh(new Date("2026-08-01T11:59:30.000Z"), now, 45_000)).toBe(true);
expect(isRequestTimestampFresh(new Date("2026-08-01T11:59:14.000Z"), now, 45_000)).toBe(false);
expect(isRequestTimestampFresh(new Date("2026-08-01T12:00:46.000Z"), now, 45_000)).toBe(false);
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
@@ -0,0 +1,10 @@
CREATE TABLE "plugin_requests" (
"request_id" uuid PRIMARY KEY NOT NULL,
"server_id" text NOT NULL,
"received_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp (3) with time zone NOT NULL
);
--> statement-breakpoint
ALTER TABLE "plugin_requests" ADD CONSTRAINT "plugin_requests_server_id_plugin_credentials_server_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."plugin_credentials"("server_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "plugin_requests_expires_idx" ON "plugin_requests" USING btree ("expires_at");--> statement-breakpoint
ALTER TABLE "app_settings" DROP COLUMN "discord_guild_id";
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,13 @@
"when": 1785603505066,
"tag": "0000_supreme_human_fly",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1785605590058,
"tag": "0001_silent_ultragirl",
"breakpoints": true
}
]
}
+3
View File
@@ -10,14 +10,17 @@
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"plugin:create-credential": "tsx scripts/create-plugin-credential.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@minecraft-account-manager/auth": "*",
"drizzle-orm": "^0.45.1",
"postgres": "^3.4.8"
},
"devDependencies": {
"drizzle-kit": "^0.31.10",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
@@ -0,0 +1,29 @@
import { randomBytes } from "node:crypto";
import { hashToken } from "@minecraft-account-manager/auth";
import { eq } from "drizzle-orm";
import { createDatabase, pluginCredentials } from "../src/index";
const serverId = process.argv[2]?.trim();
if (!serverId || !/^[a-z0-9][a-z0-9_-]{1,99}$/i.test(serverId)) {
throw new Error("Usage: npm run plugin:create-credential --workspace @minecraft-account-manager/database -- <server-id>");
}
const databaseUrl = process.env.DATABASE_URL?.trim();
if (!databaseUrl) throw new Error("DATABASE_URL is required");
const token = randomBytes(32).toString("base64url");
const { db, client } = createDatabase(databaseUrl);
const now = new Date();
await db
.insert(pluginCredentials)
.values({ serverId, secretHash: hashToken(token) })
.onConflictDoUpdate({
target: pluginCredentials.serverId,
set: { secretHash: hashToken(token), revokedAt: null, updatedAt: now },
});
console.log(`Server ID: ${serverId}`);
console.log(`API token: ${token}`);
console.log("Store this token in the Velocity plugin configuration now; it will not be shown again.");
await client.end();
+132
View File
@@ -0,0 +1,132 @@
import { LoginRateLimitedError, type AuthRepository } from "@minecraft-account-manager/auth";
import { and, desc, eq, gt, isNull, lt, sql } from "drizzle-orm";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import * as schema from "./schema";
import { loginCodes, sessions, users } from "./schema";
type Database = PostgresJsDatabase<typeof schema>;
export function createAuthRepository(db: Database): AuthRepository {
return {
async saveLoginCode(code) {
await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${code.discordUserId}))`);
const [latestCode] = await tx
.select({ createdAt: loginCodes.createdAt })
.from(loginCodes)
.where(eq(loginCodes.discordUserId, code.discordUserId))
.orderBy(desc(loginCodes.createdAt))
.limit(1);
if (latestCode && latestCode.createdAt > new Date(code.createdAt.getTime() - 30_000)) {
throw new LoginRateLimitedError();
}
await tx.delete(loginCodes).where(lt(loginCodes.expiresAt, code.createdAt));
await tx
.update(loginCodes)
.set({ consumedAt: code.createdAt })
.where(
and(
eq(loginCodes.discordUserId, code.discordUserId),
isNull(loginCodes.consumedAt),
),
);
await tx.insert(loginCodes).values({
tokenHash: code.tokenHash,
discordUserId: code.discordUserId,
discordUsername: code.discordUsername,
discordGlobalName: code.discordGlobalName,
expiresAt: code.expiresAt,
createdAt: code.createdAt,
});
});
},
async exchangeLoginCode(input) {
return db.transaction(async (tx) => {
const [loginCode] = await tx
.update(loginCodes)
.set({ consumedAt: input.now })
.where(
and(
eq(loginCodes.tokenHash, input.loginCodeHash),
isNull(loginCodes.consumedAt),
gt(loginCodes.expiresAt, input.now),
),
)
.returning();
if (!loginCode) {
return null;
}
const [existingUser] = await tx
.select({ id: users.id })
.from(users)
.where(eq(users.discordUserId, loginCode.discordUserId))
.limit(1);
const [user] = await tx
.insert(users)
.values({
discordUserId: loginCode.discordUserId,
discordUsername: loginCode.discordUsername,
discordGlobalName: loginCode.discordGlobalName,
})
.onConflictDoUpdate({
target: users.discordUserId,
set: {
discordUsername: loginCode.discordUsername,
discordGlobalName: loginCode.discordGlobalName,
updatedAt: input.now,
},
})
.returning({
id: users.id,
discordUserId: users.discordUserId,
discordUsername: users.discordUsername,
firstName: users.firstName,
});
if (!user) {
throw new Error("Failed to create or update the Discord user");
}
await tx.insert(sessions).values({
userId: user.id,
tokenHash: input.sessionTokenHash,
expiresAt: input.sessionExpiresAt,
lastSeenAt: input.now,
createdAt: input.now,
});
return { user, isNewUser: !existingUser };
});
},
};
}
export async function findUserBySessionToken(db: Database, tokenHash: string, now = new Date()) {
const [user] = await db
.select({
id: users.id,
discordUserId: users.discordUserId,
discordUsername: users.discordUsername,
firstName: users.firstName,
onboardingCompletedAt: users.onboardingCompletedAt,
sessionExpiresAt: sessions.expiresAt,
})
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(
and(
eq(sessions.tokenHash, tokenHash),
isNull(sessions.revokedAt),
gt(sessions.expiresAt, now),
),
)
.limit(1);
return user ?? null;
}
+35
View File
@@ -0,0 +1,35 @@
import { randomUUID } from "node:crypto";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import * as schema from "./schema";
import { events } from "./schema";
type Database = PostgresJsDatabase<typeof schema>;
export interface RecordEventInput {
type: string;
source: string;
subject?: string;
data?: Record<string, unknown>;
actorUserId?: string;
ipAddress?: string;
correlationId?: string;
time?: Date;
}
export async function recordEvent(db: Database, input: RecordEventInput) {
const id = randomUUID();
await db.insert(events).values({
id,
specVersion: "1.0",
source: input.source,
type: input.type,
subject: input.subject,
time: input.time ?? new Date(),
dataContentType: "application/json",
data: input.data ?? {},
actorUserId: input.actorUserId,
ipAddress: input.ipAddress,
correlationId: input.correlationId,
});
return id;
}
+2
View File
@@ -10,4 +10,6 @@ export function createDatabase(databaseUrl: string) {
};
}
export * from "./auth-repository";
export * from "./events";
export * from "./schema";
+15 -1
View File
@@ -134,7 +134,6 @@ export const sessions = pgTable(
export const appSettings = pgTable("app_settings", {
id: text("id").primaryKey().default("default"),
discordGuildId: text("discord_guild_id"),
registrationMessage: text("registration_message")
.notNull()
.default("Please register your Minecraft account before joining."),
@@ -186,6 +185,21 @@ export const pluginCredentials = pgTable(
(table) => [uniqueIndex("plugin_credentials_server_id_uidx").on(table.serverId)],
);
export const pluginRequests = pgTable(
"plugin_requests",
{
requestId: uuid("request_id").primaryKey(),
serverId: text("server_id")
.notNull()
.references(() => pluginCredentials.serverId, { onDelete: "cascade" }),
receivedAt: timestamp("received_at", { withTimezone: true, mode: "date", precision: 3 })
.notNull()
.defaultNow(),
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date", precision: 3 }).notNull(),
},
(table) => [index("plugin_requests_expires_idx").on(table.expiresAt)],
);
export const events = pgTable(
"events",
{
+1 -1
View File
@@ -3,5 +3,5 @@
"compilerOptions": {
"types": ["node"]
},
"include": ["src/**/*.ts", "drizzle.config.ts"]
"include": ["src/**/*.ts", "scripts/**/*.ts", "drizzle.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@minecraft-account-manager/minecraft",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^4.1.0"
}
}
+65
View File
@@ -0,0 +1,65 @@
const MINECRAFT_USERNAME = /^[A-Za-z0-9_]{3,16}$/;
const MINECRAFT_UUID = /^[0-9a-f]{32}$/i;
const DISCORD_NICKNAME_LIMIT = 32;
export interface JavaProfile {
uuid: string;
username: string;
}
export async function lookupJavaProfile(
username: string,
request: typeof fetch = fetch,
): Promise<JavaProfile | null> {
const candidate = username.trim();
if (!MINECRAFT_USERNAME.test(candidate)) return null;
const response = await request(
`https://api.mojang.com/users/profiles/minecraft/${encodeURIComponent(candidate)}`,
{ headers: { accept: "application/json" }, cache: "no-store" },
);
if (response.status === 204 || response.status === 404) return null;
if (!response.ok) throw new Error(`Mojang profile lookup failed (${response.status})`);
const profile: unknown = await response.json();
if (!profile || typeof profile !== "object") return null;
const { id, name } = profile as { id?: unknown; name?: unknown };
if (typeof id !== "string" || !MINECRAFT_UUID.test(id)) return null;
if (typeof name !== "string" || !MINECRAFT_USERNAME.test(name)) return null;
return { uuid: id.toLowerCase(), username: name };
}
export function formatDiscordNickname(firstName: string, minecraftUsername: string) {
const suffix = ` (${minecraftUsername})`;
const availableCharacters = DISCORD_NICKNAME_LIMIT - [...suffix].length;
const shortenedName = [...firstName.trim()].slice(0, Math.max(1, availableCharacters)).join("").trimEnd();
return `${shortenedName}${suffix}`;
}
export async function updateGuildNickname(
input: {
guildId: string;
discordUserId: string;
nickname: string;
botToken: string;
},
request: typeof fetch = fetch,
) {
const response = await request(
`https://discord.com/api/v10/guilds/${input.guildId}/members/${input.discordUserId}`,
{
method: "PATCH",
headers: {
authorization: `Bot ${input.botToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ nick: input.nickname }),
},
);
if (!response.ok) {
throw new Error(`Discord nickname update failed (${response.status})`);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { updateGuildNickname } from "../src/index";
describe("Discord nickname updates", () => {
it("updates a member in the configured guild using bot authentication", async () => {
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }));
await updateGuildNickname(
{ guildId: "123456789012345678", discordUserId: "987654321098765432", nickname: "Sam (Notch)", botToken: "secret" },
request,
);
expect(request).toHaveBeenCalledWith(
"https://discord.com/api/v10/guilds/123456789012345678/members/987654321098765432",
expect.objectContaining({ method: "PATCH", body: JSON.stringify({ nick: "Sam (Notch)" }) }),
);
});
it("reports Discord permission failures without pretending the nickname changed", async () => {
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response("Missing Permissions", { status: 403 }));
await expect(
updateGuildNickname(
{ guildId: "123456789012345678", discordUserId: "987654321098765432", nickname: "Sam (Notch)", botToken: "secret" },
request,
),
).rejects.toThrow("Discord nickname update failed (403)");
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from "vitest";
import { formatDiscordNickname, lookupJavaProfile } from "../src/index";
describe("Java Edition profiles", () => {
it("returns Mojang's canonical UUID and username", async () => {
const request = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ id: "069a79f444e94726a5befca90e38aaf5", name: "Notch" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
await expect(lookupJavaProfile("notch", request)).resolves.toEqual({
uuid: "069a79f444e94726a5befca90e38aaf5",
username: "Notch",
});
});
it("returns null when Mojang does not recognize the username", async () => {
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }));
await expect(lookupJavaProfile("UnknownPlayer", request)).resolves.toBeNull();
});
it("preserves the Minecraft username while fitting Discord's nickname limit", () => {
expect(formatDiscordNickname("Alexandria Catherine", "SixteenCharName1")).toBe(
"Alexandria Ca (SixteenCharName1)",
);
expect(formatDiscordNickname("Sam", "Notch")).toBe("Sam (Notch)");
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "types": ["vitest/globals"] },
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@minecraft-account-manager/network",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"scripts": { "test": "vitest run", "typecheck": "tsc --noEmit" },
"devDependencies": { "@types/node": "^25.0.3", "typescript": "^5.9.3", "vitest": "^4.1.0" }
}
+30
View File
@@ -0,0 +1,30 @@
import { isIP } from "node:net";
export type IpClassification = "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor";
export interface IpIntelligenceResult {
classification: IpClassification;
provider: string | null;
rawResponse?: Record<string, unknown>;
}
export interface IpIntelligenceProvider {
classify(ipAddress: string): Promise<IpIntelligenceResult>;
}
export class NoopIpIntelligenceProvider implements IpIntelligenceProvider {
async classify(_ipAddress: string): Promise<IpIntelligenceResult> {
return { classification: "unknown", provider: null };
}
}
export function getClientIp(headers: Headers, trustProxy: boolean) {
if (!trustProxy) return null;
const candidate =
headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
headers.get("x-real-ip")?.trim() ||
null;
return candidate && isIP(candidate) ? candidate : null;
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { getClientIp, NoopIpIntelligenceProvider } from "../src/index";
describe("client IP extraction", () => {
it("ignores spoofable forwarding headers unless a trusted proxy is configured", () => {
const headers = new Headers({ "x-forwarded-for": "203.0.113.1" });
expect(getClientIp(headers, false)).toBeNull();
});
it("uses the first valid address supplied by a trusted proxy", () => {
const headers = new Headers({ "x-forwarded-for": "203.0.113.1, 10.0.0.2" });
expect(getClientIp(headers, true)).toBe("203.0.113.1");
});
it("rejects malformed proxy values", () => {
expect(getClientIp(new Headers({ "x-forwarded-for": "not-an-ip" }), true)).toBeNull();
});
});
describe("VPN intelligence", () => {
it("explicitly reports unknown when no provider is configured", async () => {
await expect(new NoopIpIntelligenceProvider().classify("203.0.113.1")).resolves.toEqual({
classification: "unknown",
provider: null,
});
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "types": ["node", "vitest/globals"] },
"include": ["src/**/*.ts", "test/**/*.ts"]
}