feat(admission): add scheduled group access
This commit is contained in:
@@ -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" }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export function AdminModalForm({
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [dialogGeneration, setDialogGeneration] = useState(0);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -36,7 +37,10 @@ export function AdminModalForm({
|
||||
aria-pressed={triggerPressed}
|
||||
className={triggerClassName ?? "font-mono text-[10px] font-bold uppercase underline underline-offset-4"}
|
||||
disabled={submitting}
|
||||
onClick={() => dialogRef.current?.showModal()}
|
||||
onClick={() => {
|
||||
setDialogGeneration((generation) => generation + 1);
|
||||
dialogRef.current?.showModal();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{triggerLabel}
|
||||
@@ -44,7 +48,7 @@ export function AdminModalForm({
|
||||
<dialog
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
className="admin-modal m-auto w-[min(92vw,36rem)] border border-ink bg-panel p-0 text-ink shadow-[10px_10px_0_var(--color-shadow)] backdrop:bg-ink/70"
|
||||
className="admin-modal m-auto max-h-[90vh] w-[min(92vw,36rem)] overflow-y-auto border border-ink bg-panel p-0 text-ink shadow-[10px_10px_0_var(--color-shadow)] backdrop:bg-ink/70"
|
||||
onCancel={(event) => { if (submitting) event.preventDefault(); }}
|
||||
ref={dialogRef}
|
||||
>
|
||||
@@ -52,7 +56,7 @@ export function AdminModalForm({
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-[0.2em] text-accent">Confirm operation</p>
|
||||
<h2 className="mt-3 font-display text-3xl font-black uppercase" id={titleId}>{title}</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-muted" id={descriptionId}>{description}</p>
|
||||
{children && <div className="mt-6">{children}</div>}
|
||||
{children && <div className="mt-6" key={dialogGeneration}>{children}</div>}
|
||||
<ModalActions dialogRef={dialogRef} intent={intent} onPendingChange={setSubmitting} submitLabel={submitLabel} />
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { AdminModalForm } from "./admin-modal-form";
|
||||
import { GroupScheduleEditor, GroupScheduleSummary } from "./group-schedule-editor";
|
||||
|
||||
beforeEach(() => {
|
||||
HTMLDialogElement.prototype.showModal = function showModal() { this.open = true; };
|
||||
HTMLDialogElement.prototype.close = function close() {
|
||||
this.open = false;
|
||||
this.dispatchEvent(new Event("close"));
|
||||
};
|
||||
});
|
||||
|
||||
describe("GroupScheduleEditor", () => {
|
||||
it("shows UTC authority, browser-local equivalents, and repeatable windows", () => {
|
||||
const { container } = render(<GroupScheduleEditor windows={[{
|
||||
startMinuteOfWeek: 6960,
|
||||
endMinuteOfWeek: 7199,
|
||||
}]} />);
|
||||
|
||||
expect(screen.getByText(/stored and enforced in UTC/i)).toBeTruthy();
|
||||
expect(screen.getAllByRole("group", { name: /access window/i })).toHaveLength(1);
|
||||
expect(container.querySelectorAll('input[name="startMinuteOfWeek"]')).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add window/i }));
|
||||
expect(screen.getAllByRole("group", { name: /access window/i })).toHaveLength(2);
|
||||
expect(container.querySelectorAll('input[name="startMinuteOfWeek"]')).toHaveLength(2);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /remove window/i })[0]!);
|
||||
expect(screen.getAllByRole("group", { name: /access window/i })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("discards an abandoned draft when its confirmation dialog is reopened", () => {
|
||||
render(<AdminModalForm action={async () => undefined} description="Confirm schedule." submitLabel="Save schedule" title="Schedule group" triggerLabel="Edit schedule"><GroupScheduleEditor windows={[{ startMinuteOfWeek: 6960, endMinuteOfWeek: 7200 }]} /></AdminModalForm>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit schedule" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Add window" }));
|
||||
expect(within(dialog).getAllByRole("group", { name: /access window/i })).toHaveLength(2);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit schedule" }));
|
||||
expect(within(dialog).getAllByRole("group", { name: /access window/i })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("summarizes an unrestricted group and configured local equivalents", () => {
|
||||
const { container, rerender } = render(<GroupScheduleSummary windows={[]} />);
|
||||
expect(container.textContent).toMatch(/no schedule restrictions/i);
|
||||
|
||||
rerender(<GroupScheduleSummary windows={[{ startMinuteOfWeek: 6960, endMinuteOfWeek: 7199 }]} />);
|
||||
expect(container.textContent).toMatch(/current browser-local equivalent/i);
|
||||
expect(container.textContent).toContain("Friday 20:00 UTC");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
formatWeeklyMinute,
|
||||
localWindowToUtc,
|
||||
utcWindowToLocal,
|
||||
type WeeklyAccessWindow,
|
||||
} from "@/lib/group-schedule";
|
||||
|
||||
const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const;
|
||||
|
||||
interface EditableWindow extends WeeklyAccessWindow {
|
||||
key: number;
|
||||
}
|
||||
|
||||
function minuteParts(minuteOfWeek: number) {
|
||||
const day = Math.floor(minuteOfWeek / 1440);
|
||||
const minute = minuteOfWeek % 1440;
|
||||
return {
|
||||
day,
|
||||
time: `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
function withDay(minuteOfWeek: number, day: number) {
|
||||
return day * 1440 + (minuteOfWeek % 1440);
|
||||
}
|
||||
|
||||
function withTime(minuteOfWeek: number, time: string) {
|
||||
const [hour, minute] = time.split(":").map(Number);
|
||||
return Math.floor(minuteOfWeek / 1440) * 1440 + (hour ?? 0) * 60 + (minute ?? 0);
|
||||
}
|
||||
|
||||
const subscribeToBrowserClock = () => () => undefined;
|
||||
|
||||
function useBrowserClock() {
|
||||
const offset = useSyncExternalStore(
|
||||
subscribeToBrowserClock,
|
||||
() => new Date().getTimezoneOffset(),
|
||||
() => 0,
|
||||
);
|
||||
const zone = useSyncExternalStore(
|
||||
subscribeToBrowserClock,
|
||||
() => Intl.DateTimeFormat().resolvedOptions().timeZone || "browser local time",
|
||||
() => "UTC",
|
||||
);
|
||||
return { offset, zone };
|
||||
}
|
||||
|
||||
export function GroupScheduleEditor({ windows }: { windows: WeeklyAccessWindow[] }) {
|
||||
const { offset, zone } = useBrowserClock();
|
||||
const [editable, setEditable] = useState<EditableWindow[]>(
|
||||
windows.map((window, key) => ({ ...window, key })),
|
||||
);
|
||||
const nextKey = useRef(windows.length);
|
||||
|
||||
function update(key: number, field: "startMinuteOfWeek" | "endMinuteOfWeek", value: number) {
|
||||
setEditable((current) => current.map((window) => {
|
||||
if (window.key !== key) return window;
|
||||
const local = { ...utcWindowToLocal(window, offset), [field]: value };
|
||||
return { ...localWindowToUtc(local, offset), key };
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm leading-6 text-muted">
|
||||
Schedules are stored and enforced in UTC. The editor shows the current browser-local equivalent in <strong className="text-ink">{zone}</strong>; it may shift when your local daylight-saving offset changes.
|
||||
</p>
|
||||
{!editable.length && <p className="border-l-2 border-signal pl-4 text-sm">No windows means no schedule restrictions while Minecraft access is enabled.</p>}
|
||||
{editable.map((window, index) => {
|
||||
const local = utcWindowToLocal(window, offset);
|
||||
const start = minuteParts(local.startMinuteOfWeek);
|
||||
const end = minuteParts(local.endMinuteOfWeek);
|
||||
return (
|
||||
<fieldset aria-label={`Access window ${index + 1}`} className="border border-line p-4" key={window.key}>
|
||||
<legend className="px-2 font-mono text-[10px] font-bold uppercase tracking-wider">Access window {index + 1}</legend>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<ScheduleBoundary day={start.day} label="Starts" onDay={(day) => update(window.key, "startMinuteOfWeek", withDay(local.startMinuteOfWeek, day))} onTime={(time) => update(window.key, "startMinuteOfWeek", withTime(local.startMinuteOfWeek, time))} time={start.time} />
|
||||
<ScheduleBoundary day={end.day} label="Ends (exclusive)" onDay={(day) => update(window.key, "endMinuteOfWeek", withDay(local.endMinuteOfWeek, day))} onTime={(time) => update(window.key, "endMinuteOfWeek", withTime(local.endMinuteOfWeek, time))} time={end.time} />
|
||||
</div>
|
||||
<input name="startMinuteOfWeek" type="hidden" value={window.startMinuteOfWeek} />
|
||||
<input name="endMinuteOfWeek" type="hidden" value={window.endMinuteOfWeek} />
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="font-mono text-[9px] uppercase text-muted">UTC: {formatWeeklyMinute(window.startMinuteOfWeek)}–{formatWeeklyMinute(window.endMinuteOfWeek)}</p>
|
||||
<button className="font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4" onClick={() => setEditable((current) => current.filter((item) => item.key !== window.key))} type="button">Remove window {index + 1}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
className="border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={editable.length >= 50}
|
||||
onClick={() => {
|
||||
const key = nextKey.current++;
|
||||
setEditable((current) => [...current, {
|
||||
key,
|
||||
...localWindowToUtc({
|
||||
startMinuteOfWeek: 4 * 1440 + 20 * 60,
|
||||
endMinuteOfWeek: 5 * 1440,
|
||||
}, offset),
|
||||
}]);
|
||||
}}
|
||||
type="button"
|
||||
>Add window</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleBoundary({ day, label, onDay, onTime, time }: {
|
||||
day: number;
|
||||
label: string;
|
||||
onDay: (day: number) => void;
|
||||
onTime: (time: string) => void;
|
||||
time: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<span className="block text-xs font-bold">{label}</span>
|
||||
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2">
|
||||
<label><span className="sr-only">{label} weekday</span><select className="w-full border border-line bg-canvas px-3 py-2 text-sm" onChange={(event) => onDay(Number(event.target.value))} value={day}>{DAYS.map((name, value) => <option key={name} value={value}>{name}</option>)}</select></label>
|
||||
<label><span className="sr-only">{label} time</span><input className="border border-line bg-canvas px-3 py-2 text-sm" onChange={(event) => onTime(event.target.value)} required type="time" value={time} /></label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupScheduleSummary({ windows }: { windows: WeeklyAccessWindow[] }) {
|
||||
const { offset, zone } = useBrowserClock();
|
||||
if (!windows.length) return <p className="text-sm text-muted">No schedule restrictions. Enabled members may attempt to join at any time.</p>;
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted">Current browser-local equivalent: {zone}. UTC remains authoritative.</p>
|
||||
<ol className="mt-3 space-y-2">
|
||||
{windows.map((window, index) => {
|
||||
const local = utcWindowToLocal(window, offset);
|
||||
return <li className="border-l-2 border-accent pl-3 text-sm" key={`${window.startMinuteOfWeek}-${window.endMinuteOfWeek}-${index}`}><span className="font-bold">{formatWeeklyMinute(local.startMinuteOfWeek)}–{formatWeeklyMinute(local.endMinuteOfWeek)}</span><span className="mt-1 block font-mono text-[9px] uppercase text-muted">{formatWeeklyMinute(window.startMinuteOfWeek)} UTC–{formatWeeklyMinute(window.endMinuteOfWeek)} UTC</span></li>;
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES, parseAdmissionMessages } from "./admission-settings";
|
||||
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES, parseAdmissionMessages, renderAdmissionMessage } from "./admission-settings";
|
||||
|
||||
describe("admission message settings", () => {
|
||||
it("normalizes three independently configured denial messages", () => {
|
||||
const formData = new FormData();
|
||||
formData.set("registrationMessage", " Register your account before joining. ");
|
||||
formData.set("groupAccessDeniedMessage", "This group cannot access the server right now.");
|
||||
formData.set("vpnDeniedMessage", "VPN access requires a host-approved exception.");
|
||||
it("normalizes independently configured denial templates with allowed variables", () => {
|
||||
const formData = validMessages();
|
||||
formData.set("registrationMessage", " Register {player} before joining {group}. ");
|
||||
formData.set("scheduledAccessDeniedMessage", "{player}, {group} may join from {next_start} to {next_end}.");
|
||||
|
||||
expect(parseAdmissionMessages(formData)).toEqual({
|
||||
registrationMessage: "Register your account before joining.",
|
||||
groupAccessDeniedMessage: "This group cannot access the server right now.",
|
||||
vpnDeniedMessage: "VPN access requires a host-approved exception.",
|
||||
registrationMessage: "Register {player} before joining {group}.",
|
||||
groupAccessDeniedMessage: "{player} cannot access the server with {group}.",
|
||||
vpnDeniedMessage: "VPN access for {player} in {group} requires an exception.",
|
||||
scheduledAccessDeniedMessage: "{player}, {group} may join from {next_start} to {next_end}.",
|
||||
});
|
||||
});
|
||||
|
||||
it("selects the configured message for each admission denial reason", () => {
|
||||
expect(admissionDenialMessage("not_registered", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.registrationMessage);
|
||||
expect(admissionDenialMessage("group_access_disabled", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage);
|
||||
expect(admissionDenialMessage("schedule_disallowed", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.scheduledAccessDeniedMessage);
|
||||
expect(admissionDenialMessage("anonymized_network_disallowed", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage);
|
||||
});
|
||||
|
||||
it("rejects missing, short, overlong, or control-character messages", () => {
|
||||
for (const invalid of ["short", "a".repeat(501), "Denied\nInjected"] as const) {
|
||||
const formData = new FormData();
|
||||
formData.set("registrationMessage", DEFAULT_ADMISSION_MESSAGES.registrationMessage);
|
||||
formData.set("groupAccessDeniedMessage", DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage);
|
||||
it("renders static variables without evaluating expressions", () => {
|
||||
expect(renderAdmissionMessage("{player} uses {group}; next: {next_start}–{next_end}.", {
|
||||
player: "AlexMC",
|
||||
group: "Friday friends",
|
||||
next_start: "2026-08-07 20:00 UTC",
|
||||
next_end: "2026-08-07 23:59 UTC",
|
||||
})).toBe("AlexMC uses Friday friends; next: 2026-08-07 20:00 UTC–2026-08-07 23:59 UTC.");
|
||||
});
|
||||
|
||||
it("rejects missing, short, overlong, control-character, or unsupported-variable messages", () => {
|
||||
for (const invalid of ["short", "a".repeat(501), "Denied\nInjected", "Denied for {next_start}.", "Denied for {unknown}."] as const) {
|
||||
const formData = validMessages();
|
||||
formData.set("vpnDeniedMessage", invalid);
|
||||
expect(parseAdmissionMessages(formData)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function validMessages() {
|
||||
const formData = new FormData();
|
||||
formData.set("registrationMessage", "Register {player} before joining {group}.");
|
||||
formData.set("groupAccessDeniedMessage", "{player} cannot access the server with {group}.");
|
||||
formData.set("vpnDeniedMessage", "VPN access for {player} in {group} requires an exception.");
|
||||
formData.set("scheduledAccessDeniedMessage", "{group} may join from {next_start} to {next_end}.");
|
||||
return formData;
|
||||
}
|
||||
|
||||
@@ -2,36 +2,53 @@ export interface AdmissionMessages {
|
||||
registrationMessage: string;
|
||||
groupAccessDeniedMessage: string;
|
||||
vpnDeniedMessage: string;
|
||||
scheduledAccessDeniedMessage: string;
|
||||
}
|
||||
|
||||
export type AdmissionDenialReason =
|
||||
| "not_registered"
|
||||
| "group_access_disabled"
|
||||
| "schedule_disallowed"
|
||||
| "anonymized_network_disallowed";
|
||||
|
||||
export const DEFAULT_ADMISSION_MESSAGES: AdmissionMessages = {
|
||||
registrationMessage: "Please register your Minecraft account before joining.",
|
||||
groupAccessDeniedMessage: "Your account group does not currently have server access. Contact a host if you believe this is a mistake.",
|
||||
vpnDeniedMessage: "VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception.",
|
||||
scheduledAccessDeniedMessage: "Your group is only allowed access from {next_start} to {next_end}.",
|
||||
} as const;
|
||||
|
||||
export function admissionDenialMessage(
|
||||
reason: "not_registered" | "group_access_disabled" | "anonymized_network_disallowed",
|
||||
messages: AdmissionMessages,
|
||||
) {
|
||||
export function admissionDenialMessage(reason: AdmissionDenialReason, messages: AdmissionMessages) {
|
||||
if (reason === "not_registered") return messages.registrationMessage;
|
||||
if (reason === "group_access_disabled") return messages.groupAccessDeniedMessage;
|
||||
if (reason === "schedule_disallowed") return messages.scheduledAccessDeniedMessage;
|
||||
return messages.vpnDeniedMessage;
|
||||
}
|
||||
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
||||
const TEMPLATE_VARIABLE = /\{([a-z_]+)\}/g;
|
||||
const COMMON_VARIABLES = new Set(["player", "group"]);
|
||||
const SCHEDULE_VARIABLES = new Set(["player", "group", "next_start", "next_end"]);
|
||||
|
||||
function messageValue(formData: FormData, name: string) {
|
||||
type MessageName = keyof AdmissionMessages;
|
||||
|
||||
function messageValue(formData: FormData, name: MessageName, allowedVariables: Set<string>) {
|
||||
const value = String(formData.get(name) ?? "").trim();
|
||||
return value.length >= 10 && value.length <= 500 && !CONTROL_CHARACTERS.test(value)
|
||||
? value
|
||||
: null;
|
||||
if (value.length < 10 || value.length > 500 || CONTROL_CHARACTERS.test(value)) return null;
|
||||
const withoutVariables = value.replace(TEMPLATE_VARIABLE, (match, variable: string) =>
|
||||
allowedVariables.has(variable) ? "" : match);
|
||||
return /[{}]/.test(withoutVariables) ? null : value;
|
||||
}
|
||||
|
||||
export function parseAdmissionMessages(formData: FormData) {
|
||||
const registrationMessage = messageValue(formData, "registrationMessage");
|
||||
const groupAccessDeniedMessage = messageValue(formData, "groupAccessDeniedMessage");
|
||||
const vpnDeniedMessage = messageValue(formData, "vpnDeniedMessage");
|
||||
if (!registrationMessage || !groupAccessDeniedMessage || !vpnDeniedMessage) return null;
|
||||
return { registrationMessage, groupAccessDeniedMessage, vpnDeniedMessage };
|
||||
const registrationMessage = messageValue(formData, "registrationMessage", COMMON_VARIABLES);
|
||||
const groupAccessDeniedMessage = messageValue(formData, "groupAccessDeniedMessage", COMMON_VARIABLES);
|
||||
const vpnDeniedMessage = messageValue(formData, "vpnDeniedMessage", COMMON_VARIABLES);
|
||||
const scheduledAccessDeniedMessage = messageValue(formData, "scheduledAccessDeniedMessage", SCHEDULE_VARIABLES);
|
||||
if (!registrationMessage || !groupAccessDeniedMessage || !vpnDeniedMessage || !scheduledAccessDeniedMessage) return null;
|
||||
return { registrationMessage, groupAccessDeniedMessage, vpnDeniedMessage, scheduledAccessDeniedMessage };
|
||||
}
|
||||
|
||||
export function renderAdmissionMessage(template: string, variables: Record<string, string>) {
|
||||
return template.replace(TEMPLATE_VARIABLE, (match, variable: string) => variables[variable] ?? match);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_ADMISSION_MESSAGES } from "./admission-settings";
|
||||
import { evaluateRegisteredPlayerAdmission } from "./game-admission-policy";
|
||||
|
||||
const group = {
|
||||
name: "Friday friends",
|
||||
accessEnabled: true,
|
||||
anonymizedNetworksAllowed: false,
|
||||
};
|
||||
const fridayWindow = { startMinuteOfWeek: 6960, endMinuteOfWeek: 7200 };
|
||||
|
||||
describe("registered player admission policy", () => {
|
||||
it("lets disabled Minecraft access override an active schedule", () => {
|
||||
const decision = evaluateRegisteredPlayerAdmission({
|
||||
group: { ...group, accessEnabled: false },
|
||||
windows: [fridayWindow],
|
||||
classification: "clear",
|
||||
now: new Date("2026-08-07T21:00:00Z"),
|
||||
player: "AlexMC",
|
||||
messages: DEFAULT_ADMISSION_MESSAGES,
|
||||
});
|
||||
expect(decision.reason).toBe("group_access_disabled");
|
||||
});
|
||||
|
||||
it("denies an enabled group outside its schedule with the next UTC window", () => {
|
||||
const decision = evaluateRegisteredPlayerAdmission({
|
||||
group,
|
||||
windows: [fridayWindow],
|
||||
classification: "vpn",
|
||||
now: new Date("2026-08-08T01:00:00Z"),
|
||||
player: "AlexMC",
|
||||
messages: {
|
||||
...DEFAULT_ADMISSION_MESSAGES,
|
||||
scheduledAccessDeniedMessage: "{player} in {group}: {next_start}–{next_end}.",
|
||||
},
|
||||
});
|
||||
expect(decision.reason).toBe("schedule_disallowed");
|
||||
expect(decision.message).toBe("AlexMC in Friday friends: 2026-08-14 20:00 UTC–2026-08-15 00:00 UTC.");
|
||||
});
|
||||
|
||||
it("applies network policy only after group access and schedule pass", () => {
|
||||
const denied = evaluateRegisteredPlayerAdmission({
|
||||
group,
|
||||
windows: [fridayWindow],
|
||||
classification: "vpn",
|
||||
now: new Date("2026-08-07T21:00:00Z"),
|
||||
player: "AlexMC",
|
||||
messages: DEFAULT_ADMISSION_MESSAGES,
|
||||
});
|
||||
expect(denied.reason).toBe("anonymized_network_disallowed");
|
||||
|
||||
expect(evaluateRegisteredPlayerAdmission({
|
||||
group: { ...group, anonymizedNetworksAllowed: true },
|
||||
windows: [fridayWindow],
|
||||
classification: "vpn",
|
||||
now: new Date("2026-08-07T21:00:00Z"),
|
||||
player: "AlexMC",
|
||||
messages: DEFAULT_ADMISSION_MESSAGES,
|
||||
}).allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { gameAdmissionDenialReason } from "@minecraft-account-manager/auth";
|
||||
import {
|
||||
admissionDenialMessage,
|
||||
renderAdmissionMessage,
|
||||
type AdmissionMessages,
|
||||
} from "./admission-settings";
|
||||
import {
|
||||
evaluateGroupSchedule,
|
||||
formatScheduleInstant,
|
||||
type WeeklyAccessWindow,
|
||||
} from "./group-schedule";
|
||||
|
||||
interface EffectiveGroupPolicy {
|
||||
name: string;
|
||||
accessEnabled: boolean;
|
||||
anonymizedNetworksAllowed: boolean;
|
||||
}
|
||||
|
||||
interface RegisteredPlayerAdmissionInput {
|
||||
group: EffectiveGroupPolicy | null;
|
||||
windows: WeeklyAccessWindow[];
|
||||
classification: "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor";
|
||||
now: Date;
|
||||
player: string;
|
||||
messages: AdmissionMessages;
|
||||
}
|
||||
|
||||
export function evaluateRegisteredPlayerAdmission(input: RegisteredPlayerAdmissionInput) {
|
||||
const schedule = evaluateGroupSchedule(input.windows, input.now);
|
||||
const reason = gameAdmissionDenialReason(input.group, input.classification, schedule.allowed);
|
||||
if (!reason) return { allowed: true as const, reason: null, message: null, nextWindow: null };
|
||||
return {
|
||||
allowed: false as const,
|
||||
reason,
|
||||
message: renderAdmissionMessage(admissionDenialMessage(reason, input.messages), {
|
||||
player: input.player,
|
||||
group: input.group?.name ?? "everyone",
|
||||
next_start: schedule.nextWindow ? formatScheduleInstant(schedule.nextWindow.start) : "unavailable",
|
||||
next_end: schedule.nextWindow ? formatScheduleInstant(schedule.nextWindow.end) : "unavailable",
|
||||
}),
|
||||
nextWindow: schedule.nextWindow,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
evaluateGroupSchedule,
|
||||
localWindowToUtc,
|
||||
parseScheduleWindows,
|
||||
utcWindowToLocal,
|
||||
type WeeklyAccessWindow,
|
||||
} from "./group-schedule";
|
||||
|
||||
const fridayEvening: WeeklyAccessWindow = {
|
||||
startMinuteOfWeek: 4 * 24 * 60 + 20 * 60,
|
||||
endMinuteOfWeek: 4 * 24 * 60 + 23 * 60 + 59,
|
||||
};
|
||||
|
||||
describe("weekly group access schedules", () => {
|
||||
it("allows an enabled group at any time when no schedule is configured", () => {
|
||||
expect(evaluateGroupSchedule([], new Date("2026-08-07T19:00:00Z"))).toEqual({
|
||||
allowed: true,
|
||||
nextWindow: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows only inside a UTC window and identifies the next window when denied", () => {
|
||||
expect(evaluateGroupSchedule([fridayEvening], new Date("2026-08-07T21:30:00Z")).allowed).toBe(true);
|
||||
|
||||
const denied = evaluateGroupSchedule([fridayEvening], new Date("2026-08-08T01:00:00Z"));
|
||||
expect(denied.allowed).toBe(false);
|
||||
expect(denied.nextWindow).toEqual({
|
||||
start: new Date("2026-08-14T20:00:00.000Z"),
|
||||
end: new Date("2026-08-14T23:59:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("uses inclusive starts and exclusive ends across the UTC week boundary", () => {
|
||||
const sundayNight = { startMinuteOfWeek: 6 * 1440 + 23 * 60, endMinuteOfWeek: 2 * 60 };
|
||||
expect(evaluateGroupSchedule([sundayNight], new Date("2026-08-09T23:00:00Z")).allowed).toBe(true);
|
||||
expect(evaluateGroupSchedule([sundayNight], new Date("2026-08-10T01:59:59Z")).allowed).toBe(true);
|
||||
expect(evaluateGroupSchedule([sundayNight], new Date("2026-08-10T02:00:00Z")).allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("selects the earliest upcoming window when several are configured", () => {
|
||||
const mondayMorning = { startMinuteOfWeek: 8 * 60, endMinuteOfWeek: 9 * 60 };
|
||||
const decision = evaluateGroupSchedule(
|
||||
[fridayEvening, mondayMorning],
|
||||
new Date("2026-08-08T01:00:00Z"),
|
||||
);
|
||||
expect(decision.nextWindow?.start).toEqual(new Date("2026-08-10T08:00:00.000Z"));
|
||||
expect(decision.nextWindow?.end).toEqual(new Date("2026-08-10T09:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("fails closed for malformed persisted policy", () => {
|
||||
expect(evaluateGroupSchedule([
|
||||
{ startMinuteOfWeek: 100, endMinuteOfWeek: 200 },
|
||||
{ startMinuteOfWeek: 150, endMinuteOfWeek: 250 },
|
||||
], new Date("2026-08-03T02:30:00Z"))).toEqual({ allowed: false, nextWindow: null });
|
||||
});
|
||||
|
||||
it("rejects malformed and overlapping submitted windows", () => {
|
||||
const valid = new FormData();
|
||||
valid.append("startMinuteOfWeek", "6960");
|
||||
valid.append("endMinuteOfWeek", "7199");
|
||||
valid.append("startMinuteOfWeek", "480");
|
||||
valid.append("endMinuteOfWeek", "540");
|
||||
expect(parseScheduleWindows(valid)).toEqual([
|
||||
{ startMinuteOfWeek: 480, endMinuteOfWeek: 540 },
|
||||
fridayEvening,
|
||||
]);
|
||||
|
||||
const overlapping = new FormData();
|
||||
overlapping.append("startMinuteOfWeek", "100");
|
||||
overlapping.append("endMinuteOfWeek", "200");
|
||||
overlapping.append("startMinuteOfWeek", "150");
|
||||
overlapping.append("endMinuteOfWeek", "250");
|
||||
expect(parseScheduleWindows(overlapping)).toBeNull();
|
||||
|
||||
const wrappingOverlap = new FormData();
|
||||
wrappingOverlap.append("startMinuteOfWeek", String(6 * 1440 + 23 * 60));
|
||||
wrappingOverlap.append("endMinuteOfWeek", String(2 * 60));
|
||||
wrappingOverlap.append("startMinuteOfWeek", String(60));
|
||||
wrappingOverlap.append("endMinuteOfWeek", String(3 * 60));
|
||||
expect(parseScheduleWindows(wrappingOverlap)).toBeNull();
|
||||
|
||||
const mismatched = new FormData();
|
||||
mismatched.append("startMinuteOfWeek", "100");
|
||||
expect(parseScheduleWindows(mismatched)).toBeNull();
|
||||
|
||||
const invalid = new FormData();
|
||||
invalid.append("startMinuteOfWeek", "10080");
|
||||
invalid.append("endMinuteOfWeek", "0");
|
||||
expect(parseScheduleWindows(invalid)).toBeNull();
|
||||
|
||||
for (const malformedValue of ["", " ", "+1", "0x10", "1e2", "1.5"]) {
|
||||
const malformed = new FormData();
|
||||
malformed.append("startMinuteOfWeek", malformedValue);
|
||||
malformed.append("endMinuteOfWeek", "2");
|
||||
expect(parseScheduleWindows(malformed)).toBeNull();
|
||||
}
|
||||
|
||||
const tooMany = new FormData();
|
||||
for (let index = 0; index < 51; index += 1) {
|
||||
tooMany.append("startMinuteOfWeek", String(index * 2));
|
||||
tooMany.append("endMinuteOfWeek", String(index * 2 + 1));
|
||||
}
|
||||
expect(parseScheduleWindows(tooMany)).toBeNull();
|
||||
});
|
||||
|
||||
it("converts browser-local weekly values to authoritative UTC and back", () => {
|
||||
const local = { startMinuteOfWeek: 4 * 1440 + 20 * 60, endMinuteOfWeek: 4 * 1440 + 23 * 60 };
|
||||
const utc = localWindowToUtc(local, 420);
|
||||
expect(utc).toEqual({
|
||||
startMinuteOfWeek: 5 * 1440 + 3 * 60,
|
||||
endMinuteOfWeek: 5 * 1440 + 6 * 60,
|
||||
});
|
||||
expect(utcWindowToLocal(utc, 420)).toEqual(local);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
export const MINUTES_PER_WEEK = 7 * 24 * 60;
|
||||
const MAX_WINDOWS = 50;
|
||||
|
||||
export interface WeeklyAccessWindow {
|
||||
startMinuteOfWeek: number;
|
||||
endMinuteOfWeek: number;
|
||||
}
|
||||
|
||||
interface ScheduleDecision {
|
||||
allowed: boolean;
|
||||
nextWindow: { start: Date; end: Date } | null;
|
||||
}
|
||||
|
||||
function normalizedMinute(value: number) {
|
||||
return ((value % MINUTES_PER_WEEK) + MINUTES_PER_WEEK) % MINUTES_PER_WEEK;
|
||||
}
|
||||
|
||||
function validWindow(window: WeeklyAccessWindow) {
|
||||
return Number.isInteger(window.startMinuteOfWeek)
|
||||
&& Number.isInteger(window.endMinuteOfWeek)
|
||||
&& window.startMinuteOfWeek >= 0
|
||||
&& window.startMinuteOfWeek < MINUTES_PER_WEEK
|
||||
&& window.endMinuteOfWeek >= 0
|
||||
&& window.endMinuteOfWeek < MINUTES_PER_WEEK
|
||||
&& window.startMinuteOfWeek !== window.endMinuteOfWeek;
|
||||
}
|
||||
|
||||
function segments(window: WeeklyAccessWindow) {
|
||||
return window.endMinuteOfWeek > window.startMinuteOfWeek
|
||||
? [[window.startMinuteOfWeek, window.endMinuteOfWeek] as const]
|
||||
: [
|
||||
[window.startMinuteOfWeek, MINUTES_PER_WEEK] as const,
|
||||
[0, window.endMinuteOfWeek] as const,
|
||||
];
|
||||
}
|
||||
|
||||
export function validateScheduleWindows(windows: WeeklyAccessWindow[]) {
|
||||
if (windows.length > MAX_WINDOWS || windows.some((window) => !validWindow(window))) return null;
|
||||
for (let left = 0; left < windows.length; left += 1) {
|
||||
for (let right = left + 1; right < windows.length; right += 1) {
|
||||
const overlaps = segments(windows[left]!).some(([leftStart, leftEnd]) =>
|
||||
segments(windows[right]!).some(([rightStart, rightEnd]) =>
|
||||
leftStart < rightEnd && rightStart < leftEnd));
|
||||
if (overlaps) return null;
|
||||
}
|
||||
}
|
||||
return [...windows].sort((left, right) => left.startMinuteOfWeek - right.startMinuteOfWeek);
|
||||
}
|
||||
|
||||
export function parseScheduleWindows(formData: FormData) {
|
||||
const starts = formData.getAll("startMinuteOfWeek").map(String);
|
||||
const ends = formData.getAll("endMinuteOfWeek").map(String);
|
||||
if (starts.length !== ends.length) return null;
|
||||
const decimalInteger = /^(0|[1-9]\d*)$/;
|
||||
if (starts.some((value) => !decimalInteger.test(value)) || ends.some((value) => !decimalInteger.test(value))) {
|
||||
return null;
|
||||
}
|
||||
return validateScheduleWindows(starts.map((start, index) => ({
|
||||
startMinuteOfWeek: Number(start),
|
||||
endMinuteOfWeek: Number(ends[index]),
|
||||
})));
|
||||
}
|
||||
|
||||
function utcWeekStart(now: Date) {
|
||||
const dayFromMonday = (now.getUTCDay() + 6) % 7;
|
||||
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - dayFromMonday);
|
||||
}
|
||||
|
||||
function windowDuration(window: WeeklyAccessWindow) {
|
||||
return normalizedMinute(window.endMinuteOfWeek - window.startMinuteOfWeek);
|
||||
}
|
||||
|
||||
export function evaluateGroupSchedule(windows: WeeklyAccessWindow[], now: Date): ScheduleDecision {
|
||||
if (!windows.length) return { allowed: true, nextWindow: null };
|
||||
const valid = validateScheduleWindows(windows);
|
||||
if (!valid || !Number.isFinite(now.getTime())) return { allowed: false, nextWindow: null };
|
||||
|
||||
const weekStart = utcWeekStart(now);
|
||||
const occurrences = valid.flatMap((window) => [-1, 0, 1].map((weekOffset) => {
|
||||
const start = new Date(weekStart + (weekOffset * MINUTES_PER_WEEK + window.startMinuteOfWeek) * 60_000);
|
||||
const end = new Date(start.getTime() + windowDuration(window) * 60_000);
|
||||
return { start, end };
|
||||
}));
|
||||
if (occurrences.some(({ start, end }) => now >= start && now < end)) {
|
||||
return { allowed: true, nextWindow: null };
|
||||
}
|
||||
const nextWindow = occurrences
|
||||
.filter(({ start }) => start > now)
|
||||
.sort((left, right) => left.start.getTime() - right.start.getTime())[0] ?? null;
|
||||
return { allowed: false, nextWindow };
|
||||
}
|
||||
|
||||
export function localWindowToUtc(window: WeeklyAccessWindow, browserOffsetMinutes: number) {
|
||||
return {
|
||||
startMinuteOfWeek: normalizedMinute(window.startMinuteOfWeek + browserOffsetMinutes),
|
||||
endMinuteOfWeek: normalizedMinute(window.endMinuteOfWeek + browserOffsetMinutes),
|
||||
};
|
||||
}
|
||||
|
||||
export function utcWindowToLocal(window: WeeklyAccessWindow, browserOffsetMinutes: number) {
|
||||
return {
|
||||
startMinuteOfWeek: normalizedMinute(window.startMinuteOfWeek - browserOffsetMinutes),
|
||||
endMinuteOfWeek: normalizedMinute(window.endMinuteOfWeek - browserOffsetMinutes),
|
||||
};
|
||||
}
|
||||
|
||||
const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const;
|
||||
|
||||
export function formatWeeklyMinute(minuteOfWeek: number) {
|
||||
const minute = normalizedMinute(minuteOfWeek);
|
||||
const day = WEEKDAYS[Math.floor(minute / (24 * 60))];
|
||||
const hour = Math.floor((minute % (24 * 60)) / 60);
|
||||
const minuteOfHour = minute % 60;
|
||||
return `${day} ${String(hour).padStart(2, "0")}:${String(minuteOfHour).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatScheduleInstant(value: Date) {
|
||||
return `${value.toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
||||
}
|
||||
Reference in New Issue
Block a user