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
+50
View File
@@ -0,0 +1,50 @@
import { getClientIp } from "@minecraft-account-manager/network";
import { ipObservations, recordEvent } from "@minecraft-account-manager/database";
import { headers } from "next/headers";
import { db } from "@/lib/database";
const UI_ACCESSED = "games.minecraft.account-manager.ui.accessed";
export async function recordUserEvent(
user: { id: string },
type: string,
data: Record<string, unknown>,
) {
const requestHeaders = await headers();
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
return recordEvent(db, {
type,
source: "/web",
subject: `user/${user.id}`,
actorUserId: user.id,
ipAddress: ipAddress ?? undefined,
data,
});
}
export async function recordAuthenticatedUiAccess(
user: { id: string },
path: string,
) {
const requestHeaders = await headers();
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
await Promise.all([
recordEvent(db, {
type: UI_ACCESSED,
source: "/web",
subject: `user/${user.id}`,
actorUserId: user.id,
ipAddress: ipAddress ?? undefined,
data: { path },
}),
ipAddress
? db.insert(ipObservations).values({
userId: user.id,
source: "web",
ipAddress,
classification: "unknown",
})
: Promise.resolve(),
]);
}
+58
View File
@@ -0,0 +1,58 @@
import { extractOidcRoles } from "@minecraft-account-manager/auth";
import type { NextAuthOptions } from "next-auth";
import KeycloakProvider from "next-auth/providers/keycloak";
const issuer = process.env.KEYCLOAK_ISSUER_URL?.trim() ?? "";
const clientId = process.env.KEYCLOAK_CLIENT_ID?.trim() ?? "";
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET?.trim() ?? "";
export const requiredAdminRole =
process.env.KEYCLOAK_REQUIRED_ROLE?.trim() || "minecraft-account-manager-admin";
export const isAdminOidcConfigured = Boolean(issuer && clientId && clientSecret);
export const adminAuthOptions: NextAuthOptions = {
secret: process.env.AUTH_SECRET,
providers: isAdminOidcConfigured
? [
KeycloakProvider({
issuer,
clientId,
clientSecret,
authorization: { params: { scope: "openid email profile" } },
}),
]
: [],
pages: { signIn: "/admin/login" },
session: { strategy: "jwt" },
callbacks: {
async signIn({ profile, account }) {
if (!isAdminOidcConfigured) return false;
return extractOidcRoles({
clientId,
profile,
idToken: account?.id_token,
accessToken: account?.access_token,
}).includes(requiredAdminRole);
},
async jwt({ token, profile, account }) {
if (profile || account?.id_token || account?.access_token) {
token.roles = extractOidcRoles({
clientId,
profile,
idToken: account?.id_token,
accessToken: account?.access_token,
});
}
token.roles ??= [];
return token;
},
async session({ session, token }) {
if (session.user) {
(session.user as typeof session.user & { roles: string[] }).roles = Array.isArray(token.roles)
? token.roles.filter((role): role is string => typeof role === "string")
: [];
}
return session;
},
},
};
+20
View File
@@ -0,0 +1,20 @@
import { hashToken, SESSION_COOKIE_NAME } from "@minecraft-account-manager/auth";
import { findUserBySessionToken } from "@minecraft-account-manager/database";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { recordAuthenticatedUiAccess } from "@/lib/audit";
import { db } from "@/lib/database";
export async function getCurrentUser() {
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
if (!token) return null;
return findUserBySessionToken(db, hashToken(token));
}
export async function requireCurrentUser(accessPath?: string) {
const user = await getCurrentUser();
if (!user) redirect("/?portal=1");
if (accessPath) await recordAuthenticatedUiAccess(user, accessPath);
return user;
}
+18
View File
@@ -0,0 +1,18 @@
import { createDatabase } from "@minecraft-account-manager/database";
const databaseUrl =
process.env.DATABASE_URL ??
"postgresql://minecraft:minecraft@localhost:5432/minecraft_accounts";
const globalDatabase = globalThis as typeof globalThis & {
accountManagerDatabase?: ReturnType<typeof createDatabase>;
};
export const database =
globalDatabase.accountManagerDatabase ?? createDatabase(databaseUrl);
if (process.env.NODE_ENV !== "production") {
globalDatabase.accountManagerDatabase = database;
}
export const db = database.db;