feat(platform): add Discord onboarding and Velocity gate
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { isRequestTimestampFresh, verifyHashedToken } from "@minecraft-account-manager/auth";
|
||||
import { velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
|
||||
import {
|
||||
appSettings,
|
||||
events,
|
||||
ipObservations,
|
||||
minecraftAccounts,
|
||||
pluginCredentials,
|
||||
pluginRequests,
|
||||
} from "@minecraft-account-manager/database";
|
||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
const MAX_CLOCK_SKEW_MS = 45_000;
|
||||
const DEFAULT_DENIAL_MESSAGE = "Please register your Minecraft account before joining.";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authorization = request.headers.get("authorization") ?? "";
|
||||
const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
|
||||
if (!token) return NextResponse.json({ allowed: false, message: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const parsed = velocityAccessRequestSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ allowed: false, message: "Invalid access request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
const occurredAt = new Date(input.occurredAt);
|
||||
if (!isRequestTimestampFresh(occurredAt, new Date(), MAX_CLOCK_SKEW_MS)) {
|
||||
return NextResponse.json({ allowed: false, message: "Expired access request" }, { status: 401 });
|
||||
}
|
||||
|
||||
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 NextResponse.json({ allowed: false, message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||
const denialMessage = settings?.registrationMessage ?? DEFAULT_DENIAL_MESSAGE;
|
||||
|
||||
try {
|
||||
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" },
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.requestId,
|
||||
});
|
||||
await tx.insert(ipObservations).values({
|
||||
source: "game",
|
||||
ipAddress: input.ipAddress,
|
||||
minecraftUuid: input.minecraftUuid,
|
||||
username: input.username,
|
||||
classification: "unknown",
|
||||
observedAt: occurredAt,
|
||||
});
|
||||
return { allowed: false as const, message: denialMessage };
|
||||
}
|
||||
|
||||
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: "unknown",
|
||||
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,
|
||||
},
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.requestId,
|
||||
});
|
||||
|
||||
return { allowed: true as const, message: "Account approved." };
|
||||
});
|
||||
|
||||
return NextResponse.json(decision);
|
||||
} catch (error) {
|
||||
console.error("Velocity access decision failed", error);
|
||||
return NextResponse.json(
|
||||
{ allowed: false, message: denialMessage },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user