import { randomUUID } from "node:crypto"; import { isRequestTimestampFresh, resolveEffectiveGroup, verifyHashedToken } from "@minecraft-account-manager/auth"; import { problemDetails, velocityAccessRequestSchema } from "@minecraft-account-manager/contracts"; import { appSettings, events, groupAccessWindows, groups, ipObservations, minecraftAccounts, pluginCredentials, pluginRequests, userGroupMemberships, } from "@minecraft-account-manager/database"; import { and, eq, isNull, lt, sql } from "drizzle-orm"; import { NextResponse } from "next/server"; import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES, renderAdmissionMessage } from "@/lib/admission-settings"; import { db } from "@/lib/database"; import { isUniqueConstraintViolation } from "@/lib/database-errors"; import { evaluateRegisteredPlayerAdmission } from "@/lib/game-admission-policy"; 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; function methodNotAllowed(request: Request) { const response = problemResponse(problemDetails( "urn:error:method-not-allowed", "Method not allowed", 405, "This endpoint only accepts POST requests.", problemInstance(request), )); response.headers.set("allow", "POST"); return response; } export const GET = methodNotAllowed; export const PUT = methodNotAllowed; export const PATCH = methodNotAllowed; export const DELETE = methodNotAllowed; async function handleVelocityAccess(request: Request) { const instance = problemInstance(request); const authorization = request.headers.get("authorization") ?? ""; const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : ""; if (!token) { return problemResponse(problemDetails( "urn:error:unauthorized", "Unauthorized", 401, "A valid Velocity server credential is required.", instance, )); } const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); if (mediaType !== "application/json") { return problemResponse(problemDetails( "urn:error:unsupported-media-type", "Unsupported media type", 415, "Velocity access requests must use application/json.", instance, )); } const parsed = velocityAccessRequestSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return problemResponse(problemDetails( "urn:error:invalid-velocity-access-request", "Invalid Velocity access request", 400, "The request body does not match the required Velocity access contract.", instance, { issues: parsed.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message, code: issue.code, })), }, )); } const input = parsed.data; const occurredAt = new Date(input.occurredAt); if (!isRequestTimestampFresh(occurredAt, new Date(), MAX_CLOCK_SKEW_MS)) { return problemResponse(problemDetails( "urn:error:expired-velocity-access-request", "Expired Velocity access request", 401, "The request timestamp is outside the allowed clock-skew window.", instance, )); } const [credential] = await db .select({ secretHash: pluginCredentials.secretHash }) .from(pluginCredentials) .where(and(eq(pluginCredentials.serverId, input.serverId), isNull(pluginCredentials.revokedAt))) .limit(1); if (!credential || !verifyHashedToken(token, credential.secretHash)) { return problemResponse(problemDetails( "urn:error:unauthorized", "Unauthorized", 401, "The Velocity server credential is invalid or revoked.", instance, )); } const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1); const admissionMessages = { registrationMessage: settings?.registrationMessage ?? DEFAULT_ADMISSION_MESSAGES.registrationMessage, groupAccessDeniedMessage: settings?.groupAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage, vpnDeniedMessage: settings?.vpnDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage, scheduledAccessDeniedMessage: settings?.scheduledAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.scheduledAccessDeniedMessage, }; const intelligence = await getIpIntelligence(input.ipAddress); const auditIpData = toAuditIpData(intelligence); const decisionAt = new Date(); const decision = await db.transaction(async (tx) => { await tx.delete(pluginRequests).where(lt(pluginRequests.expiresAt, new Date())); await tx.insert(pluginRequests).values({ requestId: input.requestId, serverId: input.serverId, receivedAt: new Date(), expiresAt: new Date(Date.now() + 5 * 60_000), }); let [account] = await tx .select({ id: minecraftAccounts.id, userId: minecraftAccounts.userId, minecraftUuid: minecraftAccounts.minecraftUuid, username: minecraftAccounts.username, }) .from(minecraftAccounts) .where( and( eq(minecraftAccounts.minecraftUuid, input.minecraftUuid), isNull(minecraftAccounts.deletedAt), ), ) .limit(1); if (!account) { [account] = await tx .select({ id: minecraftAccounts.id, userId: minecraftAccounts.userId, minecraftUuid: minecraftAccounts.minecraftUuid, username: minecraftAccounts.username, }) .from(minecraftAccounts) .where( and( isNull(minecraftAccounts.minecraftUuid), sql`lower(${minecraftAccounts.username}) = lower(${input.username})`, isNull(minecraftAccounts.deletedAt), ), ) .limit(1); } if (!account) { await tx.insert(events).values({ id: randomUUID(), source: `/velocity/${input.serverId}`, type: "games.minecraft.account-manager.game.login.denied", subject: `minecraft-account/${input.minecraftUuid}`, time: occurredAt, data: { username: input.username, reason: "not_registered", ipIntelligence: auditIpData, }, ipAddress: input.ipAddress, correlationId: input.requestId, }); await tx.insert(ipObservations).values({ source: "game", ipAddress: input.ipAddress, minecraftUuid: input.minecraftUuid, username: input.username, classification: intelligence.classification, observedAt: occurredAt, }); return { allowed: false as const, message: renderAdmissionMessage(admissionDenialMessage("not_registered", admissionMessages), { player: input.username, group: "everyone", }), }; } const [explicitGroup] = await tx .select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed }) .from(userGroupMemberships) .innerJoin(groups, eq(groups.id, userGroupMemberships.groupId)) .where(eq(userGroupMemberships.userId, account.userId)) .limit(1); const [defaultGroup] = await tx .select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed }) .from(groups) .where(eq(groups.isDefault, true)) .limit(1); const effectiveGroup = resolveEffectiveGroup(explicitGroup ?? null, defaultGroup ?? null); const accessWindows = effectiveGroup ? await tx.select({ startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek, endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek, }).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, effectiveGroup.id)) : []; const policyDecision = evaluateRegisteredPlayerAdmission({ group: effectiveGroup ?? null, windows: accessWindows, classification: intelligence.classification, now: decisionAt, player: input.username, messages: admissionMessages, }); if (!policyDecision.allowed) { const denialReason = policyDecision.reason; await tx.insert(events).values({ id: randomUUID(), source: `/velocity/${input.serverId}`, type: "games.minecraft.account-manager.game.login.denied", subject: `minecraft-account/${account.id}`, time: occurredAt, actorUserId: account.userId, data: { username: input.username, reason: denialReason, accessGroup: effectiveGroup?.name ?? null, accessGroupId: effectiveGroup?.id ?? null, nextScheduleWindow: policyDecision.nextWindow ? { start: policyDecision.nextWindow.start.toISOString(), end: policyDecision.nextWindow.end.toISOString(), } : null, ipIntelligence: auditIpData, }, ipAddress: input.ipAddress, correlationId: input.requestId, }); await tx.insert(ipObservations).values({ userId: account.userId, minecraftAccountId: account.id, source: "game", ipAddress: input.ipAddress, minecraftUuid: input.minecraftUuid, username: input.username, classification: intelligence.classification, observedAt: occurredAt, }); return { allowed: false as const, message: policyDecision.message, }; } if (!effectiveGroup) throw new Error("Effective access group is unavailable after admission approval"); if (account.minecraftUuid !== input.minecraftUuid || account.username !== input.username) { await tx .update(minecraftAccounts) .set({ minecraftUuid: input.minecraftUuid, username: input.username, lastVerifiedAt: occurredAt, updatedAt: new Date(), }) .where(eq(minecraftAccounts.id, account.id)); if (account.username !== input.username) { await tx.insert(events).values({ id: randomUUID(), source: `/velocity/${input.serverId}`, type: "games.minecraft.account-manager.minecraft-account.username-changed", subject: `minecraft-account/${account.id}`, time: occurredAt, actorUserId: account.userId, data: { previousUsername: account.username, username: input.username }, ipAddress: input.ipAddress, correlationId: input.requestId, }); } } await tx.insert(ipObservations).values({ userId: account.userId, minecraftAccountId: account.id, source: "game", ipAddress: input.ipAddress, minecraftUuid: input.minecraftUuid, username: input.username, classification: intelligence.classification, observedAt: occurredAt, }); await tx.insert(events).values({ id: randomUUID(), source: `/velocity/${input.serverId}`, type: "games.minecraft.account-manager.game.login.allowed", subject: `minecraft-account/${account.id}`, time: occurredAt, actorUserId: account.userId, data: { username: input.username, minecraftUuid: input.minecraftUuid, previousUsername: account.username === input.username ? null : account.username, uuidBackfilled: account.minecraftUuid === null, ipIntelligence: auditIpData, accessGroup: effectiveGroup.name, accessGroupId: effectiveGroup.id, }, ipAddress: input.ipAddress, correlationId: input.requestId, }); return { allowed: true as const, message: "Account approved." }; }, { isolationLevel: "repeatable read" }); return NextResponse.json(decision); } export async function POST(request: Request) { const instance = problemInstance(request); try { return await handleVelocityAccess(request); } catch (error) { if (isUniqueConstraintViolation(error, "plugin_requests_pkey")) { return problemResponse(problemDetails( "urn:error:replayed-velocity-access-request", "Replayed Velocity access request", 409, "The request ID has already been processed.", instance, )); } logger.error( { err: error, event: "velocity.access_failed", instance }, "Velocity access request failed", ); return problemResponse(problemDetails( "urn:error:service-unavailable", "Service unavailable", 503, "The access decision could not be completed.", instance, )); } }