From c131465ff544497cea453b4d39542b764bec15f3 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sun, 2 Aug 2026 14:04:48 -0400 Subject: [PATCH] feat(admission): add scheduled group access --- README.md | 5 +- .../admin/(console)/groups/[groupId]/page.tsx | 27 +- .../(console)/groups/actions.schedule.test.ts | 98 ++ .../src/app/admin/(console)/groups/actions.ts | 38 +- .../src/app/admin/(console)/settings/page.tsx | 8 +- .../velocity/access/route.schedule.test.ts | 136 ++ apps/web/src/app/api/velocity/access/route.ts | 43 +- apps/web/src/components/admin-modal-form.tsx | 10 +- .../components/group-schedule-editor.test.tsx | 55 + .../src/components/group-schedule-editor.tsx | 143 ++ apps/web/src/lib/admission-settings.test.ts | 45 +- apps/web/src/lib/admission-settings.ts | 43 +- .../web/src/lib/game-admission-policy.test.ts | 61 + apps/web/src/lib/game-admission-policy.ts | 43 + apps/web/src/lib/group-schedule.test.ts | 116 ++ apps/web/src/lib/group-schedule.ts | 119 ++ design/index.md | 1 + design/log.md | 1 + design/us-009-velocity-admission.md | 3 + design/us-012-admin-operations.md | 6 +- design/us-017-group-access.md | 3 + design/us-019-admin-group-management.md | 3 + design/us-020-scheduled-group-access.md | 51 + docs/accessibility.md | 3 +- docs/architecture.md | 3 +- docs/security-review.md | 6 +- packages/auth/src/index.ts | 2 + packages/auth/test/group-access.test.ts | 10 +- .../database/drizzle/0005_young_vertigo.sql | 12 + .../database/drizzle/meta/0005_snapshot.json | 1379 +++++++++++++++++ packages/database/drizzle/meta/_journal.json | 7 + packages/database/src/schema.ts | 25 + 32 files changed, 2446 insertions(+), 59 deletions(-) create mode 100644 apps/web/src/app/admin/(console)/groups/actions.schedule.test.ts create mode 100644 apps/web/src/app/api/velocity/access/route.schedule.test.ts create mode 100644 apps/web/src/components/group-schedule-editor.test.tsx create mode 100644 apps/web/src/components/group-schedule-editor.tsx create mode 100644 apps/web/src/lib/game-admission-policy.test.ts create mode 100644 apps/web/src/lib/game-admission-policy.ts create mode 100644 apps/web/src/lib/group-schedule.test.ts create mode 100644 apps/web/src/lib/group-schedule.ts create mode 100644 design/us-020-scheduled-group-access.md create mode 100644 packages/database/drizzle/0005_young_vertigo.sql create mode 100644 packages/database/drizzle/meta/0005_snapshot.json diff --git a/README.md b/README.md index 3f81c47..41c0a4e 100644 --- a/README.md +++ b/README.md @@ -77,11 +77,12 @@ The token is displayed once and stored only as a SHA-256 hash. - PostgreSQL and Drizzle ORM - Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role - Admin user search, account management, event exploration, DAU and confirmed-connection metrics, toggleable Natural Earth/OpenStreetMap user-location views, and automatic Discord nickname synchronization -- Exclusive group admission: unassigned users fall back to protected `everyone`, and administrators manage effective membership, access, and VPN/proxy/Tor exceptions through confirmed group workflows +- Exclusive group admission: unassigned users fall back to protected `everyone`, and administrators manage effective membership, access, recurring UTC login windows, and VPN/proxy/Tor exceptions through confirmed group workflows - Deployment-managed Discord guild ID and invite URL - discord.js bot with `/register` and `/account` - Java Edition online-mode accounts only -- Velocity admission checks are fail closed +- Velocity admission checks are fail closed; disabled group access overrides recurring schedules, which are evaluated only at login +- Static denial-message templates support validated player/group variables and next scheduled UTC window guidance - ProxyCheck.io geolocation and VPN/proxy/Tor detection with a 48-hour PostgreSQL cache and group-scoped game-connection exceptions See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities, [`docs/api-errors.md`](docs/api-errors.md) for the RFC 9457 API error contract, and [`docs/security-review.md`](docs/security-review.md) for implemented controls and production requirements, and [`docs/accessibility.md`](docs/accessibility.md) for the WCAG-oriented interface review. diff --git a/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx b/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx index f17997b..47bbb93 100644 --- a/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx +++ b/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx @@ -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 = { 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 = { "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({

Admission controls

Group policies

- - + + +
+
+
+

Weekly access schedule

When Minecraft access is enabled, members may log in only during these recurring UTC windows. Existing sessions are not disconnected when a window ends.

+ + + + +
diff --git a/apps/web/src/app/admin/(console)/groups/actions.schedule.test.ts b/apps/web/src/app/admin/(console)/groups/actions.schedule.test.ts new file mode 100644 index 0000000..ea6aaad --- /dev/null +++ b/apps/web/src/app/admin/(console)/groups/actions.schedule.test.ts @@ -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 = {}; + 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) => 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, + }]]); + }); +}); diff --git a/apps/web/src/app/admin/(console)/groups/actions.ts b/apps/web/src/app/admin/(console)/groups/actions.ts index 2ef5a99..c19c533 100644 --- a/apps/web/src/app/admin/(console)/groups/actions.ts +++ b/apps/web/src/app/admin/(console)/groups/actions.ts @@ -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") ?? ""); diff --git a/apps/web/src/app/admin/(console)/settings/page.tsx b/apps/web/src/app/admin/(console)/settings/page.tsx index ac27e0a..0a9765d 100644 --- a/apps/web/src/app/admin/(console)/settings/page.tsx +++ b/apps/web/src/app/admin/(console)/settings/page.tsx @@ -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({
Minecraft denial messages -

Each plain-text message is returned for one admission outcome. Messages must be between 10 and 500 characters.

- +

Each plain-text template is returned for one admission outcome. Messages must be between 10 and 500 characters. Registration, group, and network templates support {"{player}"} and {"{group}"}.

+ +
@@ -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`; diff --git a/apps/web/src/app/api/velocity/access/route.schedule.test.ts b/apps/web/src/app/api/velocity/access/route.schedule.test.ts new file mode 100644 index 0000000..a0b0f85 --- /dev/null +++ b/apps/web/src/app/api/velocity/access/route.schedule.test.ts @@ -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>, + isolationLevel: "", +})); + +vi.mock("@/lib/database", () => { + function selection(response: unknown[]) { + const chain: Record = {}; + 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) => { + 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, 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" }), + })); + }); +}); diff --git a/apps/web/src/app/api/velocity/access/route.ts b/apps/web/src/app/api/velocity/access/route.ts index 6e52f2f..e18421f 100644 --- a/apps/web/src/app/api/velocity/access/route.ts +++ b/apps/web/src/app/api/velocity/access/route.ts @@ -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); } diff --git a/apps/web/src/components/admin-modal-form.tsx b/apps/web/src/components/admin-modal-form.tsx index a4cd9d7..a1df77e 100644 --- a/apps/web/src/components/admin-modal-form.tsx +++ b/apps/web/src/components/admin-modal-form.tsx @@ -29,6 +29,7 @@ export function AdminModalForm({ const titleId = useId(); const descriptionId = useId(); const [submitting, setSubmitting] = useState(false); + const [dialogGeneration, setDialogGeneration] = useState(0); return ( <> + + + ); + })} + + + ); +} + +function ScheduleBoundary({ day, label, onDay, onTime, time }: { + day: number; + label: string; + onDay: (day: number) => void; + onTime: (time: string) => void; + time: string; +}) { + return ( +
+ {label} +
+ + +
+
+ ); +} + +export function GroupScheduleSummary({ windows }: { windows: WeeklyAccessWindow[] }) { + const { offset, zone } = useBrowserClock(); + if (!windows.length) return

No schedule restrictions. Enabled members may attempt to join at any time.

; + return ( +
+

Current browser-local equivalent: {zone}. UTC remains authoritative.

+
    + {windows.map((window, index) => { + const local = utcWindowToLocal(window, offset); + return
  1. {formatWeeklyMinute(local.startMinuteOfWeek)}–{formatWeeklyMinute(local.endMinuteOfWeek)}{formatWeeklyMinute(window.startMinuteOfWeek)} UTC–{formatWeeklyMinute(window.endMinuteOfWeek)} UTC
  2. ; + })} +
+
+ ); +} diff --git a/apps/web/src/lib/admission-settings.test.ts b/apps/web/src/lib/admission-settings.test.ts index 53e90d6..cf003ed 100644 --- a/apps/web/src/lib/admission-settings.test.ts +++ b/apps/web/src/lib/admission-settings.test.ts @@ -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; +} diff --git a/apps/web/src/lib/admission-settings.ts b/apps/web/src/lib/admission-settings.ts index 24b189f..5083194 100644 --- a/apps/web/src/lib/admission-settings.ts +++ b/apps/web/src/lib/admission-settings.ts @@ -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) { 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) { + return template.replace(TEMPLATE_VARIABLE, (match, variable: string) => variables[variable] ?? match); } diff --git a/apps/web/src/lib/game-admission-policy.test.ts b/apps/web/src/lib/game-admission-policy.test.ts new file mode 100644 index 0000000..18d1db3 --- /dev/null +++ b/apps/web/src/lib/game-admission-policy.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/lib/game-admission-policy.ts b/apps/web/src/lib/game-admission-policy.ts new file mode 100644 index 0000000..5b0f683 --- /dev/null +++ b/apps/web/src/lib/game-admission-policy.ts @@ -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, + }; +} diff --git a/apps/web/src/lib/group-schedule.test.ts b/apps/web/src/lib/group-schedule.test.ts new file mode 100644 index 0000000..654831f --- /dev/null +++ b/apps/web/src/lib/group-schedule.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/lib/group-schedule.ts b/apps/web/src/lib/group-schedule.ts new file mode 100644 index 0000000..7e2c63d --- /dev/null +++ b/apps/web/src/lib/group-schedule.ts @@ -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`; +} diff --git a/design/index.md b/design/index.md index a5edb47..2872142 100644 --- a/design/index.md +++ b/design/index.md @@ -33,6 +33,7 @@ This OKF bundle is the product record for implemented and proposed behavior. Sto * [US-017 — Control admission with groups](us-017-group-access.md) - Each user has one effective group that explicitly controls Minecraft access. * [US-018 — Monitor community account activity](us-018-admin-dashboard.md) - Administrators review daily users, confirmed connections, locations, denials, and risky networks. * [US-019 — Manage groups efficiently](us-019-admin-group-management.md) - Administrators manage group identity, policies, membership, and creation through focused confirmed workflows. +* [US-020 — Schedule group access in UTC](us-020-scheduled-group-access.md) - Enabled groups may be restricted to recurring weekly UTC windows with static denial-message templates. # Tracking diff --git a/design/log.md b/design/log.md index 157420f..dca51d6 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,7 @@ ## 2026-08-02 +* **Extend**: Add recurring UTC group-access windows, browser-local schedule editing, and validated static denial-message variables. * **Refine**: Replace admin group cards with a policy table, confirmed modal workflows, editable group details, and reusable effective-member management. * **Add**: Provide Users-page group assignment, effective-group VPN/proxy/Tor exceptions for game admission, and independent configurable denial messages. * **Fix**: Treat malformed ProxyCheck proxy signals as unknown and classify every authenticated Velocity login before identity resolution. diff --git a/design/us-009-velocity-admission.md b/design/us-009-velocity-admission.md index eb1aaa7..f2419a8 100644 --- a/design/us-009-velocity-admission.md +++ b/design/us-009-velocity-admission.md @@ -23,11 +23,14 @@ As a registered player, I want the Velocity proxy to recognize my approved Java - [x] Successful fallback backfills UUID and canonical username. - [x] Changed usernames are persisted and audited. - [x] Registered players are allowed only when their single effective group has access enabled; explicit assignments override the default group. +- [x] Disabled group access overrides every schedule; enabled groups with weekly windows admit logins only during an active UTC window. +- [x] Schedule policy is checked before VPN/proxy/Tor policy and is enforced only at login. - [x] Unknown players, group-disabled players, API failures, malformed responses, and unauthorized requests fail closed with registration guidance. - [x] The plugin records the real Velocity connection IP and supports Java Edition online mode only. - [x] After admission, Velocity reports `PostLoginEvent` as best-effort authenticated telemetry without disconnecting an admitted player when reporting fails. - [x] Confirmed-connection reports use fresh timestamps and database replay protection. - [x] Group-disabled and VPN/proxy/Tor-policy denials return distinct operator-configured messages. +- [x] Schedule denials return the configured static template with the effective group, player, and next UTC window. - [x] The default anonymized-network message directs the player to contact a host for an exception. - [x] API failures, malformed responses, and unauthorized requests retain fail-closed plugin fallback behavior. diff --git a/design/us-012-admin-operations.md b/design/us-012-admin-operations.md index 4c4f61b..2b0878f 100644 --- a/design/us-012-admin-operations.md +++ b/design/us-012-admin-operations.md @@ -21,8 +21,9 @@ As an administrator, I want operational settings and audit visibility, so that I - [x] Event views show type, subject, IP, classification, and approximate location when available. - [x] Admin console access itself creates an audit event with the SSO identity. - [x] Settings, users, and events are linked from the shared admin navigation. -- [x] Administrators can independently configure registration-required, group-access-disabled, and VPN/proxy/Tor-denied game messages. -- [x] Every message is validated server-side and has a safe default. +- [x] Administrators can independently configure registration-required, group-access-disabled, schedule-denied, and VPN/proxy/Tor-denied game-message templates. +- [x] Registration, group, and network templates accept only `{player}` and `{group}`; schedule templates also accept `{next_start}` and `{next_end}`. +- [x] Every template is validated server-side and has a safe default. - [x] Admission-message changes are audited with the administrator identity without logging credentials. # Implementation @@ -31,6 +32,7 @@ As an administrator, I want operational settings and audit visibility, so that I - [`apps/web/src/app/admin/(console)/actions.ts`](../apps/web/src/app/admin/%28console%29/actions.ts) - [`apps/web/src/lib/admission-settings.ts`](../apps/web/src/lib/admission-settings.ts) - [`packages/database/drizzle/0004_zippy_silver_centurion.sql`](../packages/database/drizzle/0004_zippy_silver_centurion.sql) +- [`packages/database/drizzle/0005_young_vertigo.sql`](../packages/database/drizzle/0005_young_vertigo.sql) - [`apps/web/src/app/admin/(console)/events/page.tsx`](../apps/web/src/app/admin/%28console%29/events/page.tsx) # Validation diff --git a/design/us-017-group-access.md b/design/us-017-group-access.md index 50c5526..c11405b 100644 --- a/design/us-017-group-access.md +++ b/design/us-017-group-access.md @@ -19,6 +19,8 @@ As an administrator, I want to organize registered users into access groups, so - [x] The `everyone` group remains created with Minecraft access disabled. - [x] Administrators can create groups with access disabled by default and move users between groups. - [x] Administrators can enable or disable Minecraft admission for each group. +- [x] Disabled Minecraft admission always denies group members; enabled admission may additionally be restricted by recurring UTC windows. +- [x] Groups without windows retain unrestricted scheduling, and groups with windows admit logins only during an active window. - [x] Admission follows only the user's effective group; default and explicit-group access are never combined. - [x] Administrators can delete non-default groups, returning affected users to `everyone`. - [x] The protected default group cannot be deleted. @@ -40,6 +42,7 @@ As an administrator, I want to organize registered users into access groups, so - [`packages/database/drizzle/0002_simple_queen_noir.sql`](../packages/database/drizzle/0002_simple_queen_noir.sql) - [`packages/database/drizzle/0003_smiling_silver_samurai.sql`](../packages/database/drizzle/0003_smiling_silver_samurai.sql) - [`packages/database/drizzle/0004_zippy_silver_centurion.sql`](../packages/database/drizzle/0004_zippy_silver_centurion.sql) +- [`packages/database/drizzle/0005_young_vertigo.sql`](../packages/database/drizzle/0005_young_vertigo.sql) - [`apps/web/src/app/admin/(console)/groups/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/page.tsx) - [`apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/%5BgroupId%5D/page.tsx) - [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts) diff --git a/design/us-019-admin-group-management.md b/design/us-019-admin-group-management.md index 1844ac2..cbc0d3d 100644 --- a/design/us-019-admin-group-management.md +++ b/design/us-019-admin-group-management.md @@ -24,6 +24,8 @@ As an administrator, I want a concise group policy table and focused group detai - [x] Non-default groups can be deleted only after modal confirmation, returning all affected users to `everyone`. - [x] Group identity, policy, creation, and deletion mutations commit atomically with their audit events. - [x] Modal controls support keyboard operation, focus management, cancellation, and clear pending state. +- [x] Group details summarize recurring UTC access windows in the browser's local timezone. +- [x] Administrators use a confirmed modal to add, remove, and replace multiple non-overlapping windows. # Implementation @@ -32,6 +34,7 @@ As an administrator, I want a concise group policy table and focused group detai - [`apps/web/src/app/admin/(console)/groups/actions.ts`](../apps/web/src/app/admin/%28console%29/groups/actions.ts) - [`apps/web/src/components/admin-modal-form.tsx`](../apps/web/src/components/admin-modal-form.tsx) - [`apps/web/src/components/group-policy-control.tsx`](../apps/web/src/components/group-policy-control.tsx) +- [`apps/web/src/components/group-schedule-editor.tsx`](../apps/web/src/components/group-schedule-editor.tsx) - [`apps/web/src/lib/group-management.ts`](../apps/web/src/lib/group-management.ts) # Validation diff --git a/design/us-020-scheduled-group-access.md b/design/us-020-scheduled-group-access.md new file mode 100644 index 0000000..460915b --- /dev/null +++ b/design/us-020-scheduled-group-access.md @@ -0,0 +1,51 @@ +--- +type: User Story +title: Schedule group access in UTC +description: Administrators restrict enabled groups to recurring weekly UTC windows and provide static denial-message templates. +tags: [admin, groups, scheduling, velocity, templates, security] +timestamp: 2026-08-02T17:42:26Z +story_id: US-020 +status: verified +--- + +# User Story + +As an administrator, I want an enabled group to have recurring access windows, so that its members can join only during approved weekly periods and receive useful denial guidance. + +# Acceptance Criteria + +- [x] A group can have zero or more recurring weekly access windows stored and evaluated in UTC. +- [x] The browser shows each UTC window's current equivalent in the administrator's local timezone while clearly identifying UTC as authoritative. +- [x] Administrators can add and remove multiple windows, including windows that cross the end of the UTC week. +- [x] Window starts are inclusive and window ends are exclusive. +- [x] No configured windows preserve unrestricted scheduling behavior while Minecraft access is enabled. +- [x] Disabled Minecraft access always denies admission, regardless of schedule. +- [x] Enabled Minecraft access with configured windows allows login only inside an active window. +- [x] VPN/proxy/Tor policy is evaluated only after group access and schedule policy pass. +- [x] Schedule enforcement occurs at login and does not disconnect an existing session when a window ends. +- [x] Schedule changes require confirmation, reauthorize the administrator, and commit atomically with an audit event. +- [x] Malformed or overlapping schedule data is rejected; malformed persisted policy fails closed. +- [x] Registration, group-disabled, and VPN/proxy/Tor templates support `{player}` and `{group}`. +- [x] Schedule-denied templates additionally support `{next_start}` and `{next_end}` for the earliest upcoming UTC window. +- [x] Unknown template variables, control characters, and invalid lengths are rejected server-side. +- [x] Registration denials use `everyone` when no effective group can be resolved. + +# Implementation + +- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts) +- [`packages/database/drizzle/0005_young_vertigo.sql`](../packages/database/drizzle/0005_young_vertigo.sql) +- [`apps/web/src/lib/group-schedule.ts`](../apps/web/src/lib/group-schedule.ts) +- [`apps/web/src/lib/admission-settings.ts`](../apps/web/src/lib/admission-settings.ts) +- [`apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/%5BgroupId%5D/page.tsx) +- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts) + +# Validation + +UTC recurrence, multiple-window selection, local conversion, malformed schedules, template validation, policy precedence, and schedule-editor interactions are covered by automated tests. Drizzle generation, migration preflight, TypeScript, lint, build, security checks, and OKF validation must pass. + +# Related Stories + +- [Enforce registration at Velocity](us-009-velocity-admission.md) +- [Operate settings and audit views](us-012-admin-operations.md) +- [Control Minecraft admission with groups](us-017-group-access.md) +- [Manage groups efficiently](us-019-admin-group-management.md) diff --git a/docs/accessibility.md b/docs/accessibility.md index 44e6b3c..b467f8e 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -13,7 +13,8 @@ Player account management, administrator navigation, dashboard metrics and chart - Preserved reduced-motion behavior and disabled decorative cursor animation when requested. - Added labels or accessible names to search, Minecraft username, settings, group, and event-filter controls. - Added reusable per-user group dropdowns that open labelled confirmation dialogs, restore the prior selection on cancellation, prevent dismissal while pending, and provide live progress and result feedback. -- Group creation, policy, editing, and deletion use native modal dialogs with keyboard cancellation, focus management, descriptive confirmation text, and disabled pending controls. +- Group creation, policy, schedule editing, metadata editing, and deletion use scrollable native modal dialogs with keyboard cancellation, focus management, descriptive confirmation text, and disabled pending controls. +- Weekly schedule rows use labelled fieldsets, weekday and time controls, explicit exclusive-end wording, authoritative UTC values, and browser-local equivalents with the detected timezone. - Added `fieldset` and `legend` semantics to multi-select event-type filters. - Added table captions, column scopes, and row scopes to administrator data tables. - Added `role=status` with polite announcements for successful nickname changes and `role=alert` with assertive announcements for errors. diff --git a/docs/architecture.md b/docs/architecture.md index 78bb18d..151ac25 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,7 +16,7 @@ The bot creates private login links in response to `/register` and `/account`. D Velocity sends the authenticated Java UUID, current username, source IP, server ID, request ID, and occurrence time. The API matches UUID first. Username fallback is allowed only when the stored account has no UUID, after which UUID and canonical username are updated. -The admission decision is fail closed. Unknown players, disabled effective groups, disallowed confirmed VPN/proxy/Tor connections, invalid responses, expired requests, authentication failures, and unavailable API responses are denied. Registration, group-access, and anonymized-network denials use independent operator-configured messages; transport and service failures retain the plugin's local fallback. After admission succeeds, `PostLoginEvent` reports a confirmed proxy connection through a fresh, authenticated, replay-protected request. Connection telemetry is best effort and never disconnects an already admitted player. +The admission decision is fail closed. Unknown players, disabled effective groups, out-of-window scheduled groups, disallowed confirmed VPN/proxy/Tor connections, invalid responses, expired requests, authentication failures, and unavailable API responses are denied. Policy order is fixed: registration, enabled effective group, recurring UTC schedule, then anonymized-network exception. Zero schedule windows mean no time restriction; configured starts are inclusive and ends are exclusive. Scheduling is checked only at login and never disconnects an admitted player. Registration, group-access, schedule, and anonymized-network denials use independent operator-configured static templates; validated variables provide player, group, and next-window UTC guidance without executable expressions. Transport and service failures retain the plugin's local fallback. After admission succeeds, `PostLoginEvent` reports a confirmed proxy connection through a fresh, authenticated, replay-protected request. Connection telemetry is best effort and never disconnects an already admitted player. ## Trust boundaries @@ -32,6 +32,7 @@ The admission decision is fail closed. Unknown players, disabled effective group - A Discord user ID maps to one user. - An active Minecraft UUID or case-insensitive username maps to one account. - A user has at most one active primary Minecraft account. +- Group schedule boundaries are integer UTC minutes of the recurring Monday-based week; malformed or overlapping persisted windows fail closed during admission. - Removed accounts are soft deleted to retain audit history. - Audit events are CloudEvents-shaped, append-only application records. - `published_at` reserves an outbox-style path for later Kafka publishing. diff --git a/docs/security-review.md b/docs/security-review.md index 6e9cbb9..332a8f2 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -24,9 +24,11 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut - Velocity credentials are high-entropy bearer tokens stored only as hashes. - Velocity admission and confirmed-connection requests have a 45-second clock window and database-unique request IDs for cross-instance replay prevention. - Velocity and its API fail closed. -- Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback. +- Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback. Disabled access always overrides scheduling. +- Enabled groups with recurring UTC windows admit only during an active login window. Window boundaries and overlap are validated server-side, malformed persisted policy fails closed, and connected sessions are not re-evaluated. - Confirmed VPN, proxy, and Tor game connections are denied unless that same effective group has an explicit exception; `everyone` and new groups default to no exception. -- Group, VPN-policy, identity, and membership mutations re-check the Keycloak administrator role server-side; registry assignments, policy changes, group edits, creation, deletion, and message settings commit atomically with their audit events. +- Static admission templates use a fixed allowlist of non-executable variables. Unknown placeholders and control characters are rejected, and rendered values remain plain JSON/text. +- Group, schedule, VPN-policy, identity, and membership mutations re-check the Keycloak administrator role server-side; registry assignments, policy changes, group edits, creation, deletion, and message settings commit atomically with their audit events. - Group identity creation/rename and membership assignment/deletion use compatible PostgreSQL advisory and row locks to prevent duplicate names, stale audit records, or membership/deletion races. The protected default name and deletion restriction are enforced server-side. - Every bearer-authenticated Velocity login uses cached IP intelligence before identity resolution, preventing account-creation races from bypassing network policy; malformed provider proxy signals classify as unknown. - Event filters accept only event types already present in the ledger, and event detail routes remain role-protected. diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index af196ad..dede670 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -125,8 +125,10 @@ export function isGameNetworkAllowed( export function gameAdmissionDenialReason( group: { accessEnabled: boolean; anonymizedNetworksAllowed: boolean } | null, classification: "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor", + scheduleAllowed = true, ) { if (!group?.accessEnabled) return "group_access_disabled" as const; + if (!scheduleAllowed) return "schedule_disallowed" as const; if (!isGameNetworkAllowed(classification, group.anonymizedNetworksAllowed)) { return "anonymized_network_disallowed" as const; } diff --git a/packages/auth/test/group-access.test.ts b/packages/auth/test/group-access.test.ts index f9c6db9..e7565fc 100644 --- a/packages/auth/test/group-access.test.ts +++ b/packages/auth/test/group-access.test.ts @@ -31,12 +31,14 @@ describe("group-based admission", () => { } }); - it("prioritizes disabled group access before the network exception policy", () => { - expect(gameAdmissionDenialReason({ accessEnabled: false, anonymizedNetworksAllowed: false }, "vpn")) + it("prioritizes disabled group access, then schedule, then network policy", () => { + expect(gameAdmissionDenialReason({ accessEnabled: false, anonymizedNetworksAllowed: false }, "vpn", false)) .toBe("group_access_disabled"); - expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: false }, "vpn")) + expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: false }, "vpn", false)) + .toBe("schedule_disallowed"); + expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: false }, "vpn", true)) .toBe("anonymized_network_disallowed"); - expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: true }, "vpn")) + expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: true }, "vpn", true)) .toBeNull(); }); }); diff --git a/packages/database/drizzle/0005_young_vertigo.sql b/packages/database/drizzle/0005_young_vertigo.sql new file mode 100644 index 0000000..150ee95 --- /dev/null +++ b/packages/database/drizzle/0005_young_vertigo.sql @@ -0,0 +1,12 @@ +CREATE TABLE "group_access_windows" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "group_id" uuid NOT NULL, + "start_minute_of_week" integer NOT NULL, + "end_minute_of_week" integer NOT NULL, + "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL, + CONSTRAINT "group_access_windows_minute_range_check" CHECK ("group_access_windows"."start_minute_of_week" >= 0 and "group_access_windows"."start_minute_of_week" < 10080 and "group_access_windows"."end_minute_of_week" >= 0 and "group_access_windows"."end_minute_of_week" < 10080 and "group_access_windows"."start_minute_of_week" <> "group_access_windows"."end_minute_of_week") +); +--> statement-breakpoint +ALTER TABLE "app_settings" ADD COLUMN "scheduled_access_denied_message" text DEFAULT 'Your group is only allowed access from {next_start} to {next_end}.' NOT NULL;--> statement-breakpoint +ALTER TABLE "group_access_windows" ADD CONSTRAINT "group_access_windows_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "group_access_windows_group_idx" ON "group_access_windows" USING btree ("group_id"); \ No newline at end of file diff --git a/packages/database/drizzle/meta/0005_snapshot.json b/packages/database/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..87b7235 --- /dev/null +++ b/packages/database/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1379 @@ +{ + "id": "0a2f498a-0596-4471-8c1a-2e457572636d", + "prevId": "37b4bb6b-f83c-46ab-8370-ffa5a4f16e9e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_settings": { + "name": "app_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "registration_message": { + "name": "registration_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Please register your Minecraft account before joining.'" + }, + "group_access_denied_message": { + "name": "group_access_denied_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Your account group does not currently have server access. Contact a host if you believe this is a mistake.'" + }, + "vpn_denied_message": { + "name": "vpn_denied_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception.'" + }, + "scheduled_access_denied_message": { + "name": "scheduled_access_denied_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Your group is only allowed access from {next_start} to {next_end}.'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "spec_version": { + "name": "spec_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "data_content_type": { + "name": "data_content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'application/json'" + }, + "data_schema": { + "name": "data_schema", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_time_idx": { + "name": "events_time_idx", + "columns": [ + { + "expression": "time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_type_time_idx": { + "name": "events_type_time_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_subject_time_idx": { + "name": "events_subject_time_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_unpublished_idx": { + "name": "events_unpublished_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"published_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_actor_user_id_users_id_fk": { + "name": "events_actor_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_access_windows": { + "name": "group_access_windows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "start_minute_of_week": { + "name": "start_minute_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_minute_of_week": { + "name": "end_minute_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_access_windows_group_idx": { + "name": "group_access_windows_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_access_windows_group_id_groups_id_fk": { + "name": "group_access_windows_group_id_groups_id_fk", + "tableFrom": "group_access_windows", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_access_windows_minute_range_check": { + "name": "group_access_windows_minute_range_check", + "value": "\"group_access_windows\".\"start_minute_of_week\" >= 0 and \"group_access_windows\".\"start_minute_of_week\" < 10080 and \"group_access_windows\".\"end_minute_of_week\" >= 0 and \"group_access_windows\".\"end_minute_of_week\" < 10080 and \"group_access_windows\".\"start_minute_of_week\" <> \"group_access_windows\".\"end_minute_of_week\"" + } + }, + "isRLSEnabled": false + }, + "public.groups": { + "name": "groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "anonymized_networks_allowed": { + "name": "anonymized_networks_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "groups_slug_uidx": { + "name": "groups_slug_uidx", + "columns": [ + { + "expression": "lower(\"slug\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "groups_one_default_uidx": { + "name": "groups_one_default_uidx", + "columns": [ + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"groups\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ip_intelligence": { + "name": "ip_intelligence", + "schema": "", + "columns": { + "ip_address": { + "name": "ip_address", + "type": "inet", + "primaryKey": true, + "notNull": true + }, + "classification": { + "name": "classification", + "type": "ip_classification", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_response": { + "name": "raw_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ip_observations": { + "name": "ip_observations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "minecraft_account_id": { + "name": "minecraft_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "ip_observation_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "inet", + "primaryKey": false, + "notNull": true + }, + "minecraft_uuid": { + "name": "minecraft_uuid", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "classification": { + "name": "classification", + "type": "ip_classification", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ip_observations_user_observed_idx": { + "name": "ip_observations_user_observed_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "observed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ip_observations_account_observed_idx": { + "name": "ip_observations_account_observed_idx", + "columns": [ + { + "expression": "minecraft_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "observed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ip_observations_user_id_users_id_fk": { + "name": "ip_observations_user_id_users_id_fk", + "tableFrom": "ip_observations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ip_observations_minecraft_account_id_minecraft_accounts_id_fk": { + "name": "ip_observations_minecraft_account_id_minecraft_accounts_id_fk", + "tableFrom": "ip_observations", + "tableTo": "minecraft_accounts", + "columnsFrom": [ + "minecraft_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.login_codes": { + "name": "login_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_global_name": { + "name": "discord_global_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "login_codes_token_hash_uidx": { + "name": "login_codes_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_codes_discord_user_idx": { + "name": "login_codes_discord_user_idx", + "columns": [ + { + "expression": "discord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_codes_expires_idx": { + "name": "login_codes_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.minecraft_accounts": { + "name": "minecraft_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "minecraft_uuid": { + "name": "minecraft_uuid", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "validation_status": { + "name": "validation_status", + "type": "minecraft_validation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "minecraft_accounts_user_idx": { + "name": "minecraft_accounts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "minecraft_accounts_active_uuid_uidx": { + "name": "minecraft_accounts_active_uuid_uidx", + "columns": [ + { + "expression": "minecraft_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"minecraft_accounts\".\"deleted_at\" is null and \"minecraft_accounts\".\"minecraft_uuid\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "minecraft_accounts_active_username_uidx": { + "name": "minecraft_accounts_active_username_uidx", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"minecraft_accounts\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "minecraft_accounts_one_primary_per_user_uidx": { + "name": "minecraft_accounts_one_primary_per_user_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"minecraft_accounts\".\"is_primary\" = true and \"minecraft_accounts\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "minecraft_accounts_user_id_users_id_fk": { + "name": "minecraft_accounts_user_id_users_id_fk", + "tableFrom": "minecraft_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_credentials": { + "name": "plugin_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_credentials_server_id_uidx": { + "name": "plugin_credentials_server_id_uidx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_requests": { + "name": "plugin_requests", + "schema": "", + "columns": { + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_requests_expires_idx": { + "name": "plugin_requests_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_requests_server_id_plugin_credentials_server_id_fk": { + "name": "plugin_requests_server_id_plugin_credentials_server_id_fk", + "tableFrom": "plugin_requests", + "tableTo": "plugin_credentials", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_token_hash_uidx": { + "name": "sessions_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_idx": { + "name": "sessions_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_group_memberships": { + "name": "user_group_memberships", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_group_memberships_user_uidx": { + "name": "user_group_memberships_user_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_group_memberships_group_idx": { + "name": "user_group_memberships_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_group_memberships_user_id_users_id_fk": { + "name": "user_group_memberships_user_id_users_id_fk", + "tableFrom": "user_group_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_group_memberships_group_id_groups_id_fk": { + "name": "user_group_memberships_group_id_groups_id_fk", + "tableFrom": "user_group_memberships", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_global_name": { + "name": "discord_global_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_discord_user_id_uidx": { + "name": "users_discord_user_id_uidx", + "columns": [ + { + "expression": "discord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.ip_classification": { + "name": "ip_classification", + "schema": "public", + "values": [ + "unknown", + "clear", + "vpn", + "proxy", + "hosting", + "tor" + ] + }, + "public.ip_observation_source": { + "name": "ip_observation_source", + "schema": "public", + "values": [ + "web", + "game" + ] + }, + "public.minecraft_validation_status": { + "name": "minecraft_validation_status", + "schema": "public", + "values": [ + "verified", + "user_confirmed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index 9c3608b..8481db2 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1785678753029, "tag": "0004_zippy_silver_centurion", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1785692345708, + "tag": "0005_young_vertigo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index 3e8445d..f2ac2f8 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -1,7 +1,9 @@ import { sql } from "drizzle-orm"; import { boolean, + check, index, + integer, inet, jsonb, pgEnum, @@ -80,6 +82,26 @@ export const groups = pgTable( ], ); +export const groupAccessWindows = pgTable( + "group_access_windows", + { + id: uuid("id").primaryKey().defaultRandom(), + groupId: uuid("group_id") + .notNull() + .references(() => groups.id, { onDelete: "cascade" }), + startMinuteOfWeek: integer("start_minute_of_week").notNull(), + endMinuteOfWeek: integer("end_minute_of_week").notNull(), + createdAt: createdAt(), + }, + (table) => [ + index("group_access_windows_group_idx").on(table.groupId), + check( + "group_access_windows_minute_range_check", + sql`${table.startMinuteOfWeek} >= 0 and ${table.startMinuteOfWeek} < 10080 and ${table.endMinuteOfWeek} >= 0 and ${table.endMinuteOfWeek} < 10080 and ${table.startMinuteOfWeek} <> ${table.endMinuteOfWeek}`, + ), + ], +); + export const userGroupMemberships = pgTable( "user_group_memberships", { @@ -178,6 +200,9 @@ export const appSettings = pgTable("app_settings", { vpnDeniedMessage: text("vpn_denied_message") .notNull() .default("VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception."), + scheduledAccessDeniedMessage: text("scheduled_access_denied_message") + .notNull() + .default("Your group is only allowed access from {next_start} to {next_end}."), ...timestamps(), });