feat(platform): add Discord onboarding and Velocity gate
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,4 +10,6 @@ export function createDatabase(databaseUrl: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export * from "./auth-repository";
|
||||
export * from "./events";
|
||||
export * from "./schema";
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "drizzle.config.ts"]
|
||||
"include": ["src/**/*.ts", "scripts/**/*.ts", "drizzle.config.ts"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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})`);
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "types": ["vitest/globals"] },
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "types": ["node", "vitest/globals"] },
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user