Files
minecraft-account-manager/apps/web/src/app/api/velocity/access/route.ts
T
dmg c131465ff5
CI / validate (push) Successful in 6m15s
Release / release (push) Successful in 7m49s
feat(admission): add scheduled group access
2026-08-02 14:04:48 -04:00

360 lines
13 KiB
TypeScript

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,
));
}
}