feat(admission): add scheduled group access
CI / validate (push) Successful in 6m15s
Release / release (push) Successful in 7m49s

This commit is contained in:
dmg
2026-08-02 14:04:48 -04:00
parent eb1c5b6de4
commit c131465ff5
32 changed files with 2446 additions and 59 deletions
@@ -1,4 +1,4 @@
import { groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { groupAccessWindows, groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
import type { ReactNode } from "react";
import Link from "next/link";
@@ -6,16 +6,18 @@ import { notFound } from "next/navigation";
import { AdminModalForm } from "@/components/admin-modal-form";
import { AdminUserTable } from "@/components/admin-user-table";
import { GroupPolicyControl } from "@/components/group-policy-control";
import { GroupScheduleEditor, GroupScheduleSummary } from "@/components/group-schedule-editor";
import { db } from "@/lib/database";
import { isEffectiveGroupMember } from "@/lib/group-management";
import { assignUserGroupFromRegistry } from "../../users/actions";
import { deleteGroup, setGroupAccess, setGroupAnonymizedNetworkAccess, updateGroupDetails } from "../actions";
import { deleteGroup, replaceGroupSchedule, setGroupAccess, setGroupAnonymizedNetworkAccess, updateGroupDetails } from "../actions";
const savedMessages: Record<string, string> = {
created: "Group created.",
details: "Group details updated.",
access: "Minecraft access policy updated.",
"network-access": "VPN, proxy, and Tor policy updated.",
schedule: "Weekly access schedule updated.",
group: "Member group updated.",
};
@@ -23,6 +25,7 @@ const errorMessages: Record<string, string> = {
"invalid-group": "Enter a valid name and a description of no more than 500 characters.",
"duplicate-group": "A group with that name already exists.",
"invalid-group-assignment": "The user or destination group no longer exists. No membership change was applied.",
"invalid-schedule": "Use valid, non-overlapping weekly access windows. Start and end cannot be identical.",
};
export const dynamic = "force-dynamic";
@@ -39,7 +42,7 @@ export default async function GroupPage({
const [group] = await db.select().from(groups).where(eq(groups.id, groupId)).limit(1);
if (!group) notFound();
const [allUsers, allGroups, memberships] = await Promise.all([
const [allUsers, allGroups, memberships, accessWindows] = await Promise.all([
db.select({
id: users.id,
firstName: users.firstName,
@@ -65,6 +68,11 @@ export default async function GroupPage({
.from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
db.select({ userId: userGroupMemberships.userId, groupId: userGroupMemberships.groupId })
.from(userGroupMemberships),
db.select({
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id))
.orderBy(groupAccessWindows.startMinuteOfWeek),
]);
const assignmentByUser = Object.fromEntries(memberships.map((membership) => [membership.userId, membership.groupId]));
const memberUsers = allUsers.filter((user) => isEffectiveGroupMember(user.id, assignmentByUser, group));
@@ -101,8 +109,17 @@ export default async function GroupPage({
<section aria-labelledby="group-policy-heading" className="mt-10 border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
<div className="border-b border-line pb-4"><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Admission controls</p><h2 className="mt-2 font-display text-3xl font-black uppercase" id="group-policy-heading">Group policies</h2></div>
<div className="mt-6 grid gap-6 sm:grid-cols-2">
<PolicyDetail description="Controls whether members can connect to Minecraft." label="Minecraft access"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="Minecraft access" returnLocation="detail" /></PolicyDetail>
<PolicyDetail description="Allows confirmed VPN, proxy, and Tor connections." label="VPN / proxy / Tor"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="VPN / proxy / Tor" returnLocation="detail" /></PolicyDetail>
<PolicyDetail description="Controls whether members can connect to Minecraft. Disabled access always overrides the schedule." label="Minecraft access"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="Minecraft access" returnLocation="detail" /></PolicyDetail>
<PolicyDetail description="Allows confirmed VPN, proxy, and Tor connections after access and schedule checks pass." label="VPN / proxy / Tor"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="VPN / proxy / Tor" returnLocation="detail" /></PolicyDetail>
</div>
<div className="mt-7 border-t border-line pt-6">
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
<div className="max-w-2xl"><h3 className="font-mono text-xs font-bold uppercase">Weekly access schedule</h3><p className="mt-2 text-xs leading-5 text-muted">When Minecraft access is enabled, members may log in only during these recurring UTC windows. Existing sessions are not disconnected when a window ends.</p><div className="mt-4"><GroupScheduleSummary windows={accessWindows} /></div></div>
<AdminModalForm action={replaceGroupSchedule} description={`Replace the complete weekly access schedule for ${group.name}. Minecraft access must still be enabled.`} submitLabel="Save schedule" title={`Schedule ${group.name}`} triggerClassName="shrink-0 border border-ink px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-wider" triggerLabel="Edit schedule">
<input name="groupId" type="hidden" value={group.id} />
<GroupScheduleEditor windows={accessWindows} />
</AdminModalForm>
</div>
</div>
</section>
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const actionState = vi.hoisted(() => ({
selected: [] as unknown[][],
inserted: [] as unknown[],
deleted: 0,
authorized: 0,
failAudit: false,
}));
vi.mock("@/lib/auth/require-admin", () => ({
requireAdminSession: async () => {
actionState.authorized += 1;
return { email: "admin@example.test", name: "Admin" };
},
}));
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
vi.mock("next/navigation", () => ({
redirect: (path: string) => {
throw new Error(`REDIRECT:${path}`);
},
}));
vi.mock("@/lib/database", () => {
function selection(response: unknown[]) {
const chain: Record<string, unknown> = {};
for (const method of ["from", "where", "orderBy"]) chain[method] = () => chain;
chain.limit = () => Promise.resolve(response);
chain.then = (resolve: (value: unknown[]) => unknown, reject: (reason: unknown) => unknown) =>
Promise.resolve(response).then(resolve, reject);
return chain;
}
const tx = {
execute: async () => undefined,
select: () => selection(actionState.selected.shift() ?? []),
delete: () => ({ where: async () => { actionState.deleted += 1; } }),
insert: () => ({
values: async (value: unknown) => {
if (actionState.failAudit && !Array.isArray(value)) throw new Error("audit unavailable");
actionState.inserted.push(value);
},
}),
};
return { db: { transaction: async (callback: (transaction: typeof tx) => Promise<unknown>) => callback(tx) } };
});
import { replaceGroupSchedule } from "./actions";
function scheduleForm() {
const formData = new FormData();
formData.set("groupId", "11111111-1111-4111-8111-111111111111");
formData.append("startMinuteOfWeek", "6960");
formData.append("endMinuteOfWeek", "7200");
return formData;
}
describe("replaceGroupSchedule", () => {
beforeEach(() => {
actionState.selected = [
[{ id: "11111111-1111-4111-8111-111111111111", name: "Friday friends" }],
[{ startMinuteOfWeek: 480, endMinuteOfWeek: 540 }],
];
actionState.inserted = [];
actionState.deleted = 0;
actionState.authorized = 0;
actionState.failAudit = false;
});
it("reauthorizes and replaces all windows with an audit in one transaction", async () => {
await expect(replaceGroupSchedule(scheduleForm())).rejects.toThrow("REDIRECT:/admin/groups/11111111-1111-4111-8111-111111111111?saved=schedule");
expect(actionState.authorized).toBe(1);
expect(actionState.deleted).toBe(1);
expect(actionState.inserted[0]).toEqual([{
groupId: "11111111-1111-4111-8111-111111111111",
startMinuteOfWeek: 6960,
endMinuteOfWeek: 7200,
}]);
expect(actionState.inserted[1]).toEqual(expect.objectContaining({
type: "games.minecraft.account-manager.group.schedule-updated",
data: expect.objectContaining({
previousWindows: [{ startMinuteOfWeek: 480, endMinuteOfWeek: 540 }],
windows: [{ startMinuteOfWeek: 6960, endMinuteOfWeek: 7200 }],
adminEmail: "admin@example.test",
}),
}));
});
it("does not report success when the atomic audit write fails", async () => {
actionState.failAudit = true;
await expect(replaceGroupSchedule(scheduleForm())).rejects.toThrow("audit unavailable");
expect(actionState.inserted).toEqual([[{
groupId: "11111111-1111-4111-8111-111111111111",
startMinuteOfWeek: 6960,
endMinuteOfWeek: 7200,
}]]);
});
});
@@ -1,7 +1,7 @@
"use server";
import { randomUUID } from "node:crypto";
import { events, groups, userGroupMemberships } from "@minecraft-account-manager/database";
import { events, groupAccessWindows, groups, userGroupMemberships } from "@minecraft-account-manager/database";
import { getClientIp } from "@minecraft-account-manager/network";
import { and, eq, ne, sql } from "drizzle-orm";
import { headers } from "next/headers";
@@ -9,6 +9,7 @@ import { redirect } from "next/navigation";
import { requireAdminSession } from "@/lib/auth/require-admin";
import { db } from "@/lib/database";
import { editableGroupName, groupSlug, validateGroupDetails } from "@/lib/group-management";
import { parseScheduleWindows } from "@/lib/group-schedule";
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -175,6 +176,41 @@ export async function setGroupAnonymizedNetworkAccess(formData: FormData) {
return updateGroupPolicy(formData, "anonymized-networks");
}
export async function replaceGroupSchedule(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
const windows = parseScheduleWindows(formData);
if (!UUID_PATTERN.test(groupId) || !windows) redirect(groupPath(groupId, "error=invalid-schedule"));
const ipAddress = await auditContext();
const updated = await db.transaction(async (tx) => {
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
const [group] = await tx.select({ id: groups.id, name: groups.name }).from(groups)
.where(eq(groups.id, groupId)).limit(1);
if (!group) return null;
const previous = await tx.select({
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id));
await tx.delete(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id));
if (windows.length) {
await tx.insert(groupAccessWindows).values(windows.map((window) => ({ ...window, groupId: group.id })));
}
await tx.insert(events).values({
id: randomUUID(),
source: "/web/admin",
type: "games.minecraft.account-manager.group.schedule-updated",
subject: `group/${group.id}`,
time: new Date(),
data: auditData(admin, { name: group.name, previousWindows: previous, windows }),
ipAddress: ipAddress ?? null,
});
return group;
});
if (!updated) redirect("/admin/groups?error=unknown-group");
redirect(groupPath(updated.id, "saved=schedule"));
}
export async function deleteGroup(formData: FormData) {
const admin = await requireAdminSession();
const groupId = String(formData.get("groupId") ?? "");
@@ -17,6 +17,7 @@ export default async function SettingsPage({
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 guildId = process.env.DISCORD_GUILD_ID?.trim();
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
@@ -40,9 +41,10 @@ export default async function SettingsPage({
<fieldset className="space-y-7">
<legend className="font-display text-2xl font-black uppercase">Minecraft denial messages</legend>
<p className="text-sm leading-6 text-muted">Each plain-text message is returned for one admission outcome. Messages must be between 10 and 500 characters.</p>
<AdmissionMessageField description="Shown when the Minecraft identity is not registered." label="Registration required" name="registrationMessage" value={messages.registrationMessage} />
<p className="text-sm leading-6 text-muted">Each plain-text template is returned for one admission outcome. Messages must be between 10 and 500 characters. Registration, group, and network templates support <code>{"{player}"}</code> and <code>{"{group}"}</code>.</p>
<AdmissionMessageField description="Shown when the Minecraft identity is not registered. The unresolved group is everyone." label="Registration required" name="registrationMessage" value={messages.registrationMessage} />
<AdmissionMessageField description="Shown when the effective group has Minecraft access disabled." label="Group access disabled" name="groupAccessDeniedMessage" value={messages.groupAccessDeniedMessage} />
<AdmissionMessageField description="Shown outside a scheduled access window. Also supports {next_start} and {next_end}; generated times explicitly use UTC." label="Scheduled access denied" name="scheduledAccessDeniedMessage" value={messages.scheduledAccessDeniedMessage} />
<AdmissionMessageField description="Shown for VPN, proxy, or Tor connections when the effective group has no exception." label="VPN, proxy, or Tor denied" name="vpnDeniedMessage" value={messages.vpnDeniedMessage} />
</fieldset>
<button className="mt-8 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-[0.16em] text-canvas hover:bg-accent" type="submit">Save configuration</button>
@@ -60,7 +62,7 @@ function AdmissionMessageField({
}: {
description: string;
label: string;
name: "registrationMessage" | "groupAccessDeniedMessage" | "vpnDeniedMessage";
name: "registrationMessage" | "groupAccessDeniedMessage" | "scheduledAccessDeniedMessage" | "vpnDeniedMessage";
value: string;
}) {
const descriptionId = `${name}-description`;
@@ -0,0 +1,136 @@
import { hashToken } from "@minecraft-account-manager/auth";
import { beforeEach, describe, expect, it, vi } from "vitest";
const databaseState = vi.hoisted(() => ({
responses: [] as unknown[][],
inserted: [] as Array<Record<string, unknown>>,
isolationLevel: "",
}));
vi.mock("@/lib/database", () => {
function selection(response: unknown[]) {
const chain: Record<string, unknown> = {};
for (const method of ["from", "where", "innerJoin", "leftJoin", "orderBy"]) {
chain[method] = () => chain;
}
chain.limit = () => Promise.resolve(response);
chain.then = (resolve: (value: unknown[]) => unknown, reject: (reason: unknown) => unknown) =>
Promise.resolve(response).then(resolve, reject);
return chain;
}
const tx = {
select: () => selection(databaseState.responses.shift() ?? []),
insert: () => ({
values: (value: Record<string, unknown>) => {
databaseState.inserted.push(value);
return Promise.resolve();
},
}),
delete: () => ({ where: () => Promise.resolve() }),
update: () => ({ set: () => ({ where: () => Promise.resolve() }) }),
};
return {
db: {
select: () => selection(databaseState.responses.shift() ?? []),
transaction: async (callback: (transaction: typeof tx) => Promise<unknown>, options: { isolationLevel?: string }) => {
databaseState.isolationLevel = options?.isolationLevel ?? "";
return callback(tx);
},
},
};
});
vi.mock("@/lib/ip-intelligence", () => ({
getIpIntelligence: async () => ({ classification: "clear" }),
toAuditIpData: () => ({ classification: "clear" }),
}));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
import { POST } from "./route";
const messages = {
registrationMessage: "Register {player} in {group}.",
groupAccessDeniedMessage: "Disabled {player} in {group}.",
vpnDeniedMessage: "Network denied for {player} in {group}.",
scheduledAccessDeniedMessage: "Scheduled {player} in {group}: {next_start} / {next_end}.",
};
function utcMinuteOfWeek(value: Date) {
return ((value.getUTCDay() + 6) % 7) * 1440 + value.getUTCHours() * 60 + value.getUTCMinutes();
}
function normalized(value: number) {
return (value + 10080) % 10080;
}
function request() {
return new Request("http://localhost/api/velocity/access", {
method: "POST",
headers: { authorization: "Bearer route-secret", "content-type": "application/json" },
body: JSON.stringify({
requestId: "11111111-1111-4111-8111-111111111111",
serverId: "velocity-main",
minecraftUuid: "0123456789abcdef0123456789abcdef",
username: "AlexMC",
ipAddress: "203.0.113.10",
occurredAt: new Date().toISOString(),
}),
});
}
function arrange(group: { accessEnabled: boolean; anonymizedNetworksAllowed: boolean }, windows: Array<{ startMinuteOfWeek: number; endMinuteOfWeek: number }>) {
databaseState.responses = [
[{ secretHash: hashToken("route-secret") }],
[messages],
[{ id: "account-id", userId: "user-id", minecraftUuid: "0123456789abcdef0123456789abcdef", username: "AlexMC" }],
[{ id: "group-id", name: "Friday friends", ...group }],
[{ id: "everyone-id", name: "everyone", accessEnabled: false, anonymizedNetworksAllowed: false }],
windows,
];
}
describe("Velocity scheduled admission integration", () => {
beforeEach(() => {
databaseState.responses = [];
databaseState.inserted = [];
databaseState.isolationLevel = "";
});
it("loads effective-group windows and returns a rendered schedule denial", async () => {
const minute = utcMinuteOfWeek(new Date());
arrange(
{ accessEnabled: true, anonymizedNetworksAllowed: false },
[{ startMinuteOfWeek: normalized(minute + 60), endMinuteOfWeek: normalized(minute + 120) }],
);
const response = await POST(request());
const body = await response.json();
expect(body.allowed).toBe(false);
expect(body.message).toMatch(/^Scheduled AlexMC in Friday friends: .* UTC \/ .* UTC\.$/);
expect(databaseState.isolationLevel).toBe("repeatable read");
expect(databaseState.inserted).toContainEqual(expect.objectContaining({
type: "games.minecraft.account-manager.game.login.denied",
data: expect.objectContaining({ reason: "schedule_disallowed", accessGroup: "Friday friends" }),
}));
});
it("fails closed for malformed persisted windows while disabled access retains precedence", async () => {
const malformed = [
{ startMinuteOfWeek: 100, endMinuteOfWeek: 200 },
{ startMinuteOfWeek: 150, endMinuteOfWeek: 250 },
];
arrange({ accessEnabled: true, anonymizedNetworksAllowed: true }, malformed);
expect(await (await POST(request())).json()).toMatchObject({ allowed: false, message: expect.stringContaining("unavailable") });
expect(databaseState.inserted).toContainEqual(expect.objectContaining({
data: expect.objectContaining({ reason: "schedule_disallowed" }),
}));
databaseState.inserted = [];
arrange({ accessEnabled: false, anonymizedNetworksAllowed: true }, malformed);
expect(await (await POST(request())).json()).toEqual({ allowed: false, message: "Disabled AlexMC in Friday friends." });
expect(databaseState.inserted).toContainEqual(expect.objectContaining({
data: expect.objectContaining({ reason: "group_access_disabled" }),
}));
});
});
+35 -8
View File
@@ -1,9 +1,10 @@
import { randomUUID } from "node:crypto";
import { gameAdmissionDenialReason, isRequestTimestampFresh, resolveEffectiveGroup, verifyHashedToken } from "@minecraft-account-manager/auth";
import { isRequestTimestampFresh, resolveEffectiveGroup, verifyHashedToken } from "@minecraft-account-manager/auth";
import { problemDetails, velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
import {
appSettings,
events,
groupAccessWindows,
groups,
ipObservations,
minecraftAccounts,
@@ -13,9 +14,10 @@ import {
} from "@minecraft-account-manager/database";
import { and, eq, isNull, lt, sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES } from "@/lib/admission-settings";
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";
@@ -115,10 +117,12 @@ async function handleVelocityAccess(request: Request) {
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()));
@@ -187,7 +191,13 @@ async function handleVelocityAccess(request: Request) {
classification: intelligence.classification,
observedAt: occurredAt,
});
return { allowed: false as const, message: admissionDenialMessage("not_registered", admissionMessages) };
return {
allowed: false as const,
message: renderAdmissionMessage(admissionDenialMessage("not_registered", admissionMessages), {
player: input.username,
group: "everyone",
}),
};
}
const [explicitGroup] = await tx
@@ -202,9 +212,22 @@ async function handleVelocityAccess(request: Request) {
.where(eq(groups.isDefault, true))
.limit(1);
const effectiveGroup = resolveEffectiveGroup(explicitGroup ?? null, defaultGroup ?? null);
const denialReason = gameAdmissionDenialReason(effectiveGroup ?? null, intelligence.classification);
if (denialReason) {
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}`,
@@ -217,6 +240,10 @@ async function handleVelocityAccess(request: Request) {
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,
@@ -234,7 +261,7 @@ async function handleVelocityAccess(request: Request) {
});
return {
allowed: false as const,
message: admissionDenialMessage(denialReason, admissionMessages),
message: policyDecision.message,
};
}
@@ -297,7 +324,7 @@ async function handleVelocityAccess(request: Request) {
});
return { allowed: true as const, message: "Account approved." };
});
}, { isolationLevel: "repeatable read" });
return NextResponse.json(decision);
}