-
Blocklist / Ops
+
SoMC Portal / Ops
diff --git a/apps/web/src/app/admin/(console)/page.tsx b/apps/web/src/app/admin/(console)/page.tsx
index a1dcdfe..63aac4d 100644
--- a/apps/web/src/app/admin/(console)/page.tsx
+++ b/apps/web/src/app/admin/(console)/page.tsx
@@ -11,6 +11,8 @@ export default async function AdminPage({
const query = await searchParams;
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
const message = settings?.registrationMessage ?? "Please register your Minecraft account before joining.";
+ const guildId = process.env.DISCORD_GUILD_ID?.trim();
+ const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
return (
@@ -19,9 +21,9 @@ export default async function AdminPage({
System settings
Server gate
The Discord guild and invite are deployment environment settings. Operators can adjust the message shown to denied Minecraft players here.
-
- - Guild:
- {process.env.DISCORD_GUILD_ID ? "configured" : "missing"}
- - Invite:
- {process.env.DISCORD_INVITE_URL ? "configured" : "missing"}
+
+ - Guild ID
- {guildId ?? "Missing"}
+
diff --git a/apps/web/src/app/admin/(console)/users/[userId]/page.tsx b/apps/web/src/app/admin/(console)/users/[userId]/page.tsx
index e8fc88f..6b1e06a 100644
--- a/apps/web/src/app/admin/(console)/users/[userId]/page.tsx
+++ b/apps/web/src/app/admin/(console)/users/[userId]/page.tsx
@@ -1,10 +1,11 @@
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
-import { events, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
+import { events, groups, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
import { and, desc, eq, isNull, or } from "drizzle-orm";
import Link from "next/link";
import { notFound } from "next/navigation";
import { groupAccessAddresses } from "@/lib/access-address-groups";
import { db } from "@/lib/database";
+import { discordIdentity } from "@/lib/discord-identity";
import { eventIpSummary } from "@/lib/event-ip-summary";
import {
addUserMinecraftAccount,
@@ -44,7 +45,7 @@ export default async function AdminUserPage({
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user) notFound();
- const [accounts, recentEvents, observations] = await Promise.all([
+ const [accounts, recentEvents, observations, discord, accessGroups] = await Promise.all([
db
.select()
.from(minecraftAccounts)
@@ -62,6 +63,12 @@ export default async function AdminUserPage({
.where(eq(ipObservations.userId, user.id))
.orderBy(desc(ipObservations.observedAt))
.limit(100),
+ discordIdentity(user),
+ db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, isDefault: groups.isDefault })
+ .from(groups)
+ .leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
+ .where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
+ .orderBy(desc(groups.isDefault), groups.name),
]);
const addressGroups = groupAccessAddresses(
observations.map((observation) => ({ ...observation, intelligence: null })),
@@ -78,7 +85,12 @@ export default async function AdminUserPage({
User record
{user.firstName ?? "Name needed"}
-
@{user.discordUsername} · {user.discordUserId}
+
+ - Discord name
- {discord.globalName ?? discord.username}
+ - Discord username
- @{discord.username}
+ - Guild nickname
- {discord.nickname ?? "No guild nickname"}
+ - Discord ID
- {discord.id}
+
Expected Discord nickname
@@ -164,6 +176,13 @@ export default async function AdminUserPage({
+
+
+
+ {accessGroups.map((group) =>
{group.name}{group.isDefault ? " · default" : ""}{group.accessEnabled ? "On" : "Off"}
)}
+
+
+
Recent addresses
Grouped by IPv4 /24 or IPv6 /64 network across the 100 most recent observations.
diff --git a/apps/web/src/app/admin/(console)/users/page.tsx b/apps/web/src/app/admin/(console)/users/page.tsx
index 9e79c83..826cd88 100644
--- a/apps/web/src/app/admin/(console)/users/page.tsx
+++ b/apps/web/src/app/admin/(console)/users/page.tsx
@@ -15,6 +15,7 @@ export default async function AdminUsersPage({
? or(
ilike(users.firstName, pattern),
ilike(users.discordUsername, pattern),
+ ilike(users.discordGlobalName, pattern),
eq(users.discordUserId, search),
sql`exists (
select 1 from ${minecraftAccounts}
@@ -33,6 +34,7 @@ export default async function AdminUsersPage({
id: users.id,
firstName: users.firstName,
discordUsername: users.discordUsername,
+ discordGlobalName: users.discordGlobalName,
discordUserId: users.discordUserId,
onboardingCompletedAt: users.onboardingCompletedAt,
primaryUsername: minecraftAccounts.username,
@@ -85,7 +87,7 @@ export default async function AdminUsersPage({
{results.map((user) => (
| {user.firstName ?? "Name needed"} |
- @{user.discordUsername} {user.discordUserId} |
+ {user.discordGlobalName ?? user.discordUsername} @{user.discordUsername} {user.discordUserId} |
{user.primaryUsername ?? "—"} |
{user.accountCount} |
{user.onboardingCompletedAt ? "Ready" : "Onboarding"} |
diff --git a/apps/web/src/app/api/velocity/access/route.ts b/apps/web/src/app/api/velocity/access/route.ts
index 8c0fd6c..40055b6 100644
--- a/apps/web/src/app/api/velocity/access/route.ts
+++ b/apps/web/src/app/api/velocity/access/route.ts
@@ -1,15 +1,17 @@
import { randomUUID } from "node:crypto";
-import { isRequestTimestampFresh, verifyHashedToken } from "@minecraft-account-manager/auth";
+import { enabledAccessGroup, isRequestTimestampFresh, verifyHashedToken } from "@minecraft-account-manager/auth";
import { problemDetails, velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
import {
appSettings,
events,
+ groups,
ipObservations,
minecraftAccounts,
pluginCredentials,
pluginRequests,
+ userGroupMemberships,
} from "@minecraft-account-manager/database";
-import { and, eq, isNull, lt, sql } from "drizzle-orm";
+import { and, eq, isNull, lt, or, sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/lib/database";
import { isUniqueConstraintViolation } from "@/lib/database-errors";
@@ -210,6 +212,42 @@ async function handleVelocityAccess(request: Request) {
return { allowed: false as const, message: denialMessage };
}
+ const assignedGroups = await tx
+ .select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled })
+ .from(groups)
+ .leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
+ .where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, account.userId)));
+ const enabledGroup = enabledAccessGroup(assignedGroups);
+
+ if (!enabledGroup) {
+ await tx.insert(events).values({
+ id: randomUUID(),
+ source: `/velocity/${input.serverId}`,
+ type: "games.minecraft.account-manager.game.login.denied",
+ subject: `minecraft-account/${account.id}`,
+ time: occurredAt,
+ actorUserId: account.userId,
+ data: {
+ username: input.username,
+ reason: "group_access_disabled",
+ ipIntelligence: auditIpData,
+ },
+ ipAddress: input.ipAddress,
+ correlationId: input.requestId,
+ });
+ await tx.insert(ipObservations).values({
+ userId: account.userId,
+ minecraftAccountId: account.id,
+ source: "game",
+ ipAddress: input.ipAddress,
+ minecraftUuid: input.minecraftUuid,
+ username: input.username,
+ classification: intelligence.classification,
+ observedAt: occurredAt,
+ });
+ return { allowed: false as const, message: "Your account group does not currently have server access." };
+ }
+
if (account.minecraftUuid !== input.minecraftUuid || account.username !== input.username) {
await tx
.update(minecraftAccounts)
@@ -259,6 +297,7 @@ async function handleVelocityAccess(request: Request) {
previousUsername: account.username === input.username ? null : account.username,
uuidBackfilled: account.minecraftUuid === null,
ipIntelligence: auditIpData,
+ accessGroup: enabledGroup.name,
},
ipAddress: input.ipAddress,
correlationId: input.requestId,
diff --git a/apps/web/src/app/icon.svg b/apps/web/src/app/icon.svg
new file mode 100644
index 0000000..594ffbb
--- /dev/null
+++ b/apps/web/src/app/icon.svg
@@ -0,0 +1,7 @@
+
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 66b0ae4..167bab3 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -4,7 +4,7 @@ import { SiteFooter } from "@/components/site-footer";
import "./globals.css";
export const metadata: Metadata = {
- title: "Blocklist — Minecraft Account Manager",
+ title: "SoMC Portal — Minecraft Account Manager",
description: "Connect your Discord identity to approved Minecraft accounts.",
};
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 8157a5e..efbfbd0 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -18,11 +18,11 @@ export default async function HomePage({
-
+
B
- Blocklist
+ SoMC Portal
diff --git a/apps/web/src/lib/audit.ts b/apps/web/src/lib/audit.ts
index 13053bb..3cb7526 100644
--- a/apps/web/src/lib/audit.ts
+++ b/apps/web/src/lib/audit.ts
@@ -5,9 +5,9 @@ import { db } from "@/lib/database";
const UI_ACCESSED = "games.minecraft.account-manager.ui.accessed";
-export async function recordAdminEvent(
+export async function recordAdminSubjectEvent(
admin: { email: string | null; name: string | null },
- targetUserId: string,
+ subject: string,
type: string,
data: Record,
) {
@@ -16,12 +16,21 @@ export async function recordAdminEvent(
return recordEvent(db, {
type,
source: "/web/admin",
- subject: `user/${targetUserId}`,
+ subject,
ipAddress: ipAddress ?? undefined,
data: { ...data, adminEmail: admin.email, adminName: admin.name },
});
}
+export async function recordAdminEvent(
+ admin: { email: string | null; name: string | null },
+ targetUserId: string,
+ type: string,
+ data: Record,
+) {
+ return recordAdminSubjectEvent(admin, `user/${targetUserId}`, type, data);
+}
+
export async function recordUserEvent(
user: { id: string },
type: string,
diff --git a/apps/web/src/lib/discord-identity.ts b/apps/web/src/lib/discord-identity.ts
new file mode 100644
index 0000000..c918bb3
--- /dev/null
+++ b/apps/web/src/lib/discord-identity.ts
@@ -0,0 +1,33 @@
+import { getGuildMemberIdentity } from "@minecraft-account-manager/minecraft";
+import { logger } from "@/lib/logger";
+
+export async function discordIdentity(input: {
+ discordUserId: string;
+ discordUsername: string;
+ discordGlobalName: string | null;
+}) {
+ const guildId = process.env.DISCORD_GUILD_ID?.trim();
+ const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
+ const fallback = {
+ id: input.discordUserId,
+ username: input.discordUsername,
+ globalName: input.discordGlobalName,
+ nickname: null as string | null,
+ live: false,
+ };
+ if (!guildId || !botToken) return fallback;
+
+ try {
+ return { ...await getGuildMemberIdentity({
+ guildId,
+ discordUserId: input.discordUserId,
+ botToken,
+ }), live: true };
+ } catch (error) {
+ logger.warn(
+ { err: error, event: "discord.identity_lookup_failed", discordUserId: input.discordUserId },
+ "Discord guild identity lookup failed",
+ );
+ return fallback;
+ }
+}
diff --git a/design/index.md b/design/index.md
index 9db5b10..f912109 100644
--- a/design/index.md
+++ b/design/index.md
@@ -30,6 +30,7 @@ This OKF bundle is the product record for implemented and proposed behavior. Sto
* [US-014 — Receive standardized API errors](us-014-problem-details.md) - Application APIs return RFC 9457 Problem Details.
* [US-015 — Deploy and operate securely](us-015-platform-operations.md) - Operators have reproducible builds, migrations, credentials, and security controls.
* [US-016 — Build and publish versioned releases](us-016-automated-releases.md) - Gitea Actions publish the Velocity JAR and web and migration images.
+* [US-017 — Control admission with groups](us-017-group-access.md) - Administrators assign users to groups that explicitly grant Minecraft access.
# Tracking
diff --git a/design/log.md b/design/log.md
index d81d747..97da897 100644
--- a/design/log.md
+++ b/design/log.md
@@ -2,6 +2,7 @@
## 2026-08-01
+* **Extend**: Add SoMC Portal branding, live Discord identity details, admin guild configuration visibility, and fail-closed group-based Minecraft admission.
* **Refine**: Group repeated access networks, confirm linked Discord nickname changes before mutation, and add DMG Games sponsorship attribution.
* **Extend**: Add shared Pino logging with credential redaction and actionable web and Discord runtime diagnostics.
* **Fix**: Build magic-link redirects from the configured public portal URL instead of the reverse proxy's internal request origin.
diff --git a/design/us-001-discord-entry.md b/design/us-001-discord-entry.md
index 73fc2ac..17d1eee 100644
--- a/design/us-001-discord-entry.md
+++ b/design/us-001-discord-entry.md
@@ -3,7 +3,7 @@ type: User Story
title: Enter the account portal through Discord
description: Direct visitors are guided to the configured Discord community and its account commands.
tags: [player, portal, discord, onboarding]
-timestamp: 2026-08-01T22:04:17Z
+timestamp: 2026-08-01T22:34:31Z
story_id: US-001
status: verified
---
@@ -19,12 +19,14 @@ As a prospective player, I want the portal to direct me to the community Discord
- [x] Given a configured guild ID, when the visitor selects the app action, then a `discord://` guild link is opened.
- [x] Given an unauthenticated protected-page request, when authorization fails, then the visitor returns to the portal with prominent Discord instructions.
- [x] Every portal page credits Social Minecraft sponsorship by DMG Games and links to `https://dmg.games`.
+- [x] Portal branding uses the SoMC Portal name and a dedicated favicon.
# Implementation
- [`apps/web/src/app/page.tsx`](../apps/web/src/app/page.tsx)
- [`apps/web/src/lib/auth/user-session.ts`](../apps/web/src/lib/auth/user-session.ts)
- [`apps/web/src/components/site-footer.tsx`](../apps/web/src/components/site-footer.tsx)
+- [`apps/web/src/app/icon.svg`](../apps/web/src/app/icon.svg)
- Configuration: `DISCORD_GUILD_ID`, `DISCORD_INVITE_URL`
# Validation
diff --git a/design/us-005-user-dashboard.md b/design/us-005-user-dashboard.md
index 3554e07..f4ee124 100644
--- a/design/us-005-user-dashboard.md
+++ b/design/us-005-user-dashboard.md
@@ -3,7 +3,7 @@ type: User Story
title: Manage linked accounts from the dashboard
description: Authenticated users maintain their profile and active Java Edition accounts.
tags: [player, dashboard, minecraft, profile]
-timestamp: 2026-08-01T22:04:17Z
+timestamp: 2026-08-01T22:34:31Z
story_id: US-005
status: verified
---
@@ -22,6 +22,8 @@ As a registered player, I want to manage my profile and linked Minecraft account
- [x] Removing a primary account promotes another active account when one exists.
- [x] Name and primary changes preview the expected Discord nickname and require explicit confirmation before either profile mutation occurs.
- [x] The dashboard shows recent portal and game IP observations with classification and available location.
+- [x] The dashboard shows the user's Discord display name, username, guild nickname, and immutable Discord ID.
+- [x] The dashboard shows effective access groups and whether each group grants Minecraft access.
- [x] The user can revoke the current session by signing out.
# Implementation
diff --git a/design/us-009-velocity-admission.md b/design/us-009-velocity-admission.md
index de2987e..02c6cd8 100644
--- a/design/us-009-velocity-admission.md
+++ b/design/us-009-velocity-admission.md
@@ -3,7 +3,7 @@ type: User Story
title: Enforce registration at the Velocity proxy
description: Online-mode Java connections are admitted only after a fail-closed account-manager decision.
tags: [minecraft, velocity, whitelist, security]
-timestamp: 2026-08-01T18:52:20Z
+timestamp: 2026-08-01T22:34:31Z
story_id: US-009
status: verified
---
@@ -22,7 +22,8 @@ As a registered player, I want the Velocity proxy to recognize my approved Java
- [x] Username fallback applies only when the stored account has no UUID.
- [x] Successful fallback backfills UUID and canonical username.
- [x] Changed usernames are persisted and audited.
-- [x] Unknown players, API failures, malformed responses, and unauthorized requests fail closed with registration guidance.
+- [x] Registered players are allowed only when at least one assigned group has access enabled.
+- [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.
# Implementation
@@ -41,3 +42,4 @@ As a registered player, I want the Velocity proxy to recognize my approved Java
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
- [Standardize API errors](us-014-problem-details.md)
+- [Control Minecraft admission with groups](us-017-group-access.md)
diff --git a/design/us-012-admin-operations.md b/design/us-012-admin-operations.md
index 4e5f995..78adbde 100644
--- a/design/us-012-admin-operations.md
+++ b/design/us-012-admin-operations.md
@@ -3,7 +3,7 @@ type: User Story
title: Operate settings and audit views
description: Authorized administrators control server messaging and investigate recent platform events.
tags: [admin, settings, audit, operations]
-timestamp: 2026-08-01T18:43:58Z
+timestamp: 2026-08-01T22:34:31Z
story_id: US-012
status: verified
---
@@ -14,7 +14,7 @@ As an administrator, I want operational settings and audit visibility, so that I
# Acceptance Criteria
-- [x] The admin console reports whether deployment-managed Discord guild and invite settings are configured.
+- [x] The admin console shows the deployment-managed Discord guild ID and linked invite URL.
- [x] An authorized administrator can update the denied-player registration message.
- [x] Settings actions validate message length server-side.
- [x] Administrators can browse the latest 100 events.
diff --git a/design/us-013-admin-user-management.md b/design/us-013-admin-user-management.md
index cf767a6..c6085c8 100644
--- a/design/us-013-admin-user-management.md
+++ b/design/us-013-admin-user-management.md
@@ -3,7 +3,7 @@ type: User Story
title: Manage users as an administrator
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
tags: [admin, users, minecraft, discord]
-timestamp: 2026-08-01T18:43:58Z
+timestamp: 2026-08-01T22:34:31Z
story_id: US-013
status: verified
---
@@ -16,7 +16,7 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
- [x] Administrators can search by preferred name, Discord username or ID, Minecraft username, or UUID.
- [x] Search results show onboarding state, primary username, and active account count.
-- [x] A user detail view shows Discord identity, active accounts, recent events, and recent IP observations.
+- [x] A user detail view shows Discord display name, username, guild nickname, immutable ID, active accounts, groups, recent events, and recent IP observations.
- [x] Administrators can update the preferred name and synchronize Discord.
- [x] Administrators can add Mojang-verified accounts or explicitly override an unverified username.
- [x] Administrators can remove an account only after a visible confirmation step.
@@ -39,3 +39,4 @@ Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test
- [Administrator SSO](us-011-admin-sso.md)
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
+- [Control Minecraft admission with groups](us-017-group-access.md)
diff --git a/design/us-017-group-access.md b/design/us-017-group-access.md
new file mode 100644
index 0000000..97db361
--- /dev/null
+++ b/design/us-017-group-access.md
@@ -0,0 +1,44 @@
+---
+type: User Story
+title: Control Minecraft admission with groups
+description: Administrators assign users to groups and enable Minecraft access through explicit group policy.
+tags: [admin, groups, authorization, velocity, security]
+timestamp: 2026-08-01T22:36:20Z
+story_id: US-017
+status: verified
+---
+
+# User Story
+
+As an administrator, I want to organize registered users into access groups, so that server admission can be enabled for selected communities while remaining off by default.
+
+# Acceptance Criteria
+
+- [x] Every registered user implicitly belongs to the protected `everyone` group.
+- [x] The `everyone` group is created with Minecraft access disabled.
+- [x] Administrators can create groups with access disabled by default.
+- [x] Administrators can add and remove users from non-default groups.
+- [x] Administrators can enable or disable Minecraft admission for each group.
+- [x] A registered player is admitted when any assigned group has access enabled.
+- [x] A registered player is denied when none of their groups has access enabled.
+- [x] Group creation, membership, and access-policy changes are audited.
+- [x] Users and administrators can inspect the user's effective group assignments.
+
+# Implementation
+
+- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
+- [`packages/database/drizzle/0002_simple_queen_noir.sql`](../packages/database/drizzle/0002_simple_queen_noir.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)
+
+# Validation
+
+- [`packages/auth/test/group-access.test.ts`](../packages/auth/test/group-access.test.ts)
+- Drizzle migration generation, TypeScript validation, tests, lint, and the production build must pass.
+
+# Related Stories
+
+- [Enforce registration at Velocity](us-009-velocity-admission.md)
+- [Manage users as an administrator](us-013-admin-user-management.md)
+- [Preserve an audit trail](us-010-audit-events.md)
diff --git a/docs/security-review.md b/docs/security-review.md
index c111f23..c62631f 100644
--- a/docs/security-review.md
+++ b/docs/security-review.md
@@ -24,12 +24,14 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
- Velocity credentials are high-entropy bearer tokens stored only as hashes.
- Velocity 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 at least one enabled access group; the implicit `everyone` group starts disabled.
+- Group and membership mutations re-check the Keycloak administrator role server-side and are audited.
- ORM-parameterized queries are used throughout.
- CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured.
- Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured.
- Private and reserved addresses are not sent to ProxyCheck.io; lookup results are cached to reduce disclosure and API usage.
- Portal and game login events include approximate network location and VPN/proxy classification when available.
-- Secrets are excluded from logs and repository configuration.
+- Structured Pino logging redacts credential fields, and secrets are excluded from logs and repository configuration.
## Outstanding production requirements
diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts
index 5a09cf2..e4343ce 100644
--- a/packages/auth/src/index.ts
+++ b/packages/auth/src/index.ts
@@ -111,6 +111,10 @@ export function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
+export function enabledAccessGroup(assignedGroups: T[]) {
+ return assignedGroups.find((group) => group.accessEnabled) ?? null;
+}
+
export function verifyHashedToken(providedToken: string, expectedHash: string) {
const provided = Buffer.from(hashToken(providedToken), "utf8");
const expected = Buffer.from(expectedHash, "utf8");
diff --git a/packages/auth/test/group-access.test.ts b/packages/auth/test/group-access.test.ts
new file mode 100644
index 0000000..3d27654
--- /dev/null
+++ b/packages/auth/test/group-access.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from "vitest";
+import { enabledAccessGroup } from "../src/index";
+
+describe("group-based admission", () => {
+ it("denies default-off users and allows access when any assigned group is enabled", () => {
+ expect(enabledAccessGroup([{ name: "everyone", accessEnabled: false }])).toBeNull();
+ expect(enabledAccessGroup([
+ { name: "everyone", accessEnabled: false },
+ { name: "ops", accessEnabled: true },
+ ])).toEqual({ name: "ops", accessEnabled: true });
+ });
+});
diff --git a/packages/database/drizzle/0002_simple_queen_noir.sql b/packages/database/drizzle/0002_simple_queen_noir.sql
new file mode 100644
index 0000000..92b69ce
--- /dev/null
+++ b/packages/database/drizzle/0002_simple_queen_noir.sql
@@ -0,0 +1,25 @@
+CREATE TABLE "groups" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "name" varchar(50) NOT NULL,
+ "slug" varchar(50) NOT NULL,
+ "description" text,
+ "access_enabled" boolean DEFAULT false NOT NULL,
+ "is_default" boolean DEFAULT false NOT NULL,
+ "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "user_group_memberships" (
+ "user_id" uuid NOT NULL,
+ "group_id" uuid NOT NULL,
+ "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "user_group_memberships" ADD CONSTRAINT "user_group_memberships_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "user_group_memberships" ADD CONSTRAINT "user_group_memberships_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE UNIQUE INDEX "groups_slug_uidx" ON "groups" USING btree (lower("slug"));--> statement-breakpoint
+CREATE UNIQUE INDEX "groups_one_default_uidx" ON "groups" USING btree ("is_default") WHERE "groups"."is_default" = true;--> statement-breakpoint
+CREATE UNIQUE INDEX "user_group_memberships_user_group_uidx" ON "user_group_memberships" USING btree ("user_id","group_id");--> statement-breakpoint
+CREATE INDEX "user_group_memberships_group_idx" ON "user_group_memberships" USING btree ("group_id");--> statement-breakpoint
+INSERT INTO "groups" ("name", "slug", "description", "access_enabled", "is_default")
+VALUES ('everyone', 'everyone', 'Default group containing every registered user.', false, true);
\ No newline at end of file
diff --git a/packages/database/drizzle/meta/0002_snapshot.json b/packages/database/drizzle/meta/0002_snapshot.json
new file mode 100644
index 0000000..5fd5ba4
--- /dev/null
+++ b/packages/database/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,1277 @@
+{
+ "id": "f787ddd6-318e-4c93-aece-16ee95317091",
+ "prevId": "46aeb150-fc23-4257-bff9-df2bcd95be65",
+ "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.'"
+ },
+ "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.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
+ },
+ "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_group_uidx": {
+ "name": "user_group_memberships_user_group_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "group_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 62ac11d..4ed7fbd 100644
--- a/packages/database/drizzle/meta/_journal.json
+++ b/packages/database/drizzle/meta/_journal.json
@@ -15,6 +15,13 @@
"when": 1785605590058,
"tag": "0001_silent_ultragirl",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1785623198008,
+ "tag": "0002_simple_queen_noir",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/database/src/auth-repository.ts b/packages/database/src/auth-repository.ts
index 7651285..100c511 100644
--- a/packages/database/src/auth-repository.ts
+++ b/packages/database/src/auth-repository.ts
@@ -113,6 +113,7 @@ export async function findUserBySessionToken(db: Database, tokenHash: string, no
id: users.id,
discordUserId: users.discordUserId,
discordUsername: users.discordUsername,
+ discordGlobalName: users.discordGlobalName,
firstName: users.firstName,
onboardingCompletedAt: users.onboardingCompletedAt,
sessionExpiresAt: sessions.expiresAt,
diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts
index 6402175..1969679 100644
--- a/packages/database/src/schema.ts
+++ b/packages/database/src/schema.ts
@@ -62,6 +62,40 @@ export const users = pgTable(
(table) => [uniqueIndex("users_discord_user_id_uidx").on(table.discordUserId)],
);
+export const groups = pgTable(
+ "groups",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ name: varchar("name", { length: 50 }).notNull(),
+ slug: varchar("slug", { length: 50 }).notNull(),
+ description: text("description"),
+ accessEnabled: boolean("access_enabled").notNull().default(false),
+ isDefault: boolean("is_default").notNull().default(false),
+ ...timestamps(),
+ },
+ (table) => [
+ uniqueIndex("groups_slug_uidx").on(sql`lower(${table.slug})`),
+ uniqueIndex("groups_one_default_uidx").on(table.isDefault).where(sql`${table.isDefault} = true`),
+ ],
+);
+
+export const userGroupMemberships = pgTable(
+ "user_group_memberships",
+ {
+ userId: uuid("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ groupId: uuid("group_id")
+ .notNull()
+ .references(() => groups.id, { onDelete: "cascade" }),
+ createdAt: createdAt(),
+ },
+ (table) => [
+ uniqueIndex("user_group_memberships_user_group_uidx").on(table.userId, table.groupId),
+ index("user_group_memberships_group_idx").on(table.groupId),
+ ],
+);
+
export const minecraftAccounts = pgTable(
"minecraft_accounts",
{
diff --git a/packages/minecraft/src/index.ts b/packages/minecraft/src/index.ts
index 21002b7..f324e36 100644
--- a/packages/minecraft/src/index.ts
+++ b/packages/minecraft/src/index.ts
@@ -46,6 +46,46 @@ export function formatManagedDiscordNickname(
return [...firstName.trim()].slice(0, DISCORD_NICKNAME_LIMIT).join("").trimEnd();
}
+export interface DiscordGuildIdentity {
+ id: string;
+ username: string;
+ globalName: string | null;
+ nickname: string | null;
+}
+
+export async function getGuildMemberIdentity(
+ input: { guildId: string; discordUserId: string; botToken: string },
+ request: typeof fetch = fetch,
+): Promise {
+ const response = await request(
+ `https://discord.com/api/v10/guilds/${input.guildId}/members/${input.discordUserId}`,
+ {
+ headers: {
+ authorization: `Bot ${input.botToken}`,
+ accept: "application/json",
+ },
+ cache: "no-store",
+ },
+ );
+ if (!response.ok) throw new Error(`Discord guild member lookup failed (${response.status})`);
+
+ const payload: unknown = await response.json();
+ if (!payload || typeof payload !== "object") throw new Error("Discord guild member lookup returned invalid data");
+ const member = payload as { nick?: unknown; user?: unknown };
+ if (!member.user || typeof member.user !== "object") throw new Error("Discord guild member lookup omitted user data");
+ const user = member.user as { id?: unknown; username?: unknown; global_name?: unknown };
+ if (typeof user.id !== "string" || typeof user.username !== "string") {
+ throw new Error("Discord guild member lookup returned invalid user data");
+ }
+
+ return {
+ id: user.id,
+ username: user.username,
+ globalName: typeof user.global_name === "string" ? user.global_name : null,
+ nickname: typeof member.nick === "string" ? member.nick : null,
+ };
+}
+
export async function updateGuildNickname(
input: {
guildId: string;
diff --git a/packages/minecraft/test/discord.test.ts b/packages/minecraft/test/discord.test.ts
index dac00d8..3af6ad6 100644
--- a/packages/minecraft/test/discord.test.ts
+++ b/packages/minecraft/test/discord.test.ts
@@ -1,5 +1,25 @@
import { describe, expect, it, vi } from "vitest";
-import { updateGuildNickname } from "../src/index";
+import { getGuildMemberIdentity, updateGuildNickname } from "../src/index";
+
+describe("Discord guild identity", () => {
+ it("reads the member username, global name, nickname, and immutable ID", async () => {
+ const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({
+ nick: "Sam (Notch)",
+ user: { id: "987654321098765432", username: "samcraft", global_name: "Sam" },
+ }), { status: 200, headers: { "content-type": "application/json" } }));
+
+ await expect(getGuildMemberIdentity({
+ guildId: "123456789012345678",
+ discordUserId: "987654321098765432",
+ botToken: "secret",
+ }, request)).resolves.toEqual({
+ id: "987654321098765432",
+ username: "samcraft",
+ globalName: "Sam",
+ nickname: "Sam (Notch)",
+ });
+ });
+});
describe("Discord nickname updates", () => {
it("updates a member in the configured guild using bot authentication", async () => {