feat(core): scaffold account manager platform

This commit is contained in:
dmg
2026-08-01 13:03:26 -04:00
commit 9d305e5dc9
36 changed files with 11898 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
DATABASE_URL=postgresql://minecraft:minecraft@localhost:5432/minecraft_accounts
APP_URL=http://localhost:3000
SESSION_SECRET=
# Admin OIDC / Keycloak
AUTH_SECRET=
KEYCLOAK_ISSUER_URL=
KEYCLOAK_CLIENT_ID=minecraft-account-manager-admin
KEYCLOAK_CLIENT_SECRET=
KEYCLOAK_REQUIRED_ROLE=minecraft-account-manager-admin
# Discord
DISCORD_BOT_TOKEN=
DISCORD_APPLICATION_ID=
# Optional VPN intelligence provider (deferred for v1)
IP_INTELLIGENCE_PROVIDER=none
IP_INTELLIGENCE_API_KEY=
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.next/
dist/
coverage/
.env
.env.local
*.log
.DS_Store
+61
View File
@@ -0,0 +1,61 @@
# Minecraft Account Manager
A Discord-first account registry for a private Java Edition Minecraft network. Players link Discord to one or more Minecraft accounts, while a fail-closed Velocity plugin admits only registered accounts.
## Workspace layout
- `apps/web` — Next.js 16 web application and API, styled with Tailwind CSS v4
- `apps/discord-bot` — discord.js slash-command bot
- `packages/contracts` — shared Zod contracts and CloudEvents types
- `packages/database` — PostgreSQL Drizzle schema and versioned migrations
- `plugins/velocity` — Velocity admission plugin (planned)
## Requirements
- Node.js 22+
- npm 11+
- Docker with Compose, or PostgreSQL 17+
## Local setup
```bash
cp .env.example .env.local
docker compose up -d postgres
npm install
npm run db:migrate
npm run dev
```
Open `http://localhost:3000`.
## Validation
```bash
npm test
npm run typecheck
npm run lint
npm run build
```
## Database workflow
Always create and apply versioned migrations:
```bash
npm run db:generate
npm run db:migrate
```
Do not use `drizzle push`; it bypasses the reviewed migration history and can cause destructive schema changes.
## Confirmed product decisions
- PostgreSQL and Drizzle ORM
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
- Admin-managed Discord guild configuration
- discord.js bot with `/register` and `/account`
- Java Edition online-mode accounts only
- Velocity admission checks are fail closed
- VPN detection is represented in the schema but may remain disabled in the first release until a provider is selected
See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities.
+5
View File
@@ -0,0 +1,5 @@
# Discord bot
The bot will provide ephemeral `/register` and `/account` responses containing short-lived one-time links. The target guild is read from admin-managed application settings rather than a deployment-only environment variable.
Implementation begins in Phase 2 alongside the one-time login service.
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@minecraft-account-manager/discord-bot",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@minecraft-account-manager/contracts": "*",
"discord.js": "^14.25.1"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}
+2
View File
@@ -0,0 +1,2 @@
// Discord command handling is added in Phase 2 after the one-time login service exists.
export {};
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
export default defineConfig([
...nextVitals,
...nextTypescript,
globalIgnores([".next/**", "next-env.d.ts"]),
]);
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+12
View File
@@ -0,0 +1,12 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["postgres"],
transpilePackages: [
"@minecraft-account-manager/contracts",
"@minecraft-account-manager/database",
],
};
export default nextConfig;
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@minecraft-account-manager/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@minecraft-account-manager/contracts": "*",
"@minecraft-account-manager/database": "*",
"next": "^16.2.1",
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "^25.0.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.1",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
const postcssConfig = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default postcssConfig;
+79
View File
@@ -0,0 +1,79 @@
@import "tailwindcss";
@theme inline {
--color-canvas: var(--canvas);
--color-panel: var(--panel);
--color-ink: var(--ink);
--color-muted: var(--muted);
--color-line: var(--line);
--color-accent: var(--accent);
--color-signal: var(--signal);
--color-shadow: var(--shadow);
--font-display: "Arial Narrow", "Roboto Condensed", sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
:root {
--canvas: #d9d1bb;
--panel: #eee8d8;
--ink: #171916;
--muted: #57594f;
--line: #9e9a88;
--accent: #bc3f24;
--signal: #b5d452;
--shadow: #262a23;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background: var(--canvas);
}
::selection {
background: var(--accent);
color: var(--panel);
}
.terrain {
position: absolute;
inset: 0;
opacity: 0.2;
background-image:
linear-gradient(90deg, transparent 31px, rgba(23, 25, 22, 0.13) 32px),
linear-gradient(transparent 31px, rgba(23, 25, 22, 0.13) 32px);
background-size: 32px 32px;
mask-image: linear-gradient(to bottom right, black, transparent 72%);
}
.text-outline {
color: transparent;
-webkit-text-stroke: 1.5px var(--ink);
}
.cursor {
animation: blink 1s steps(2, jump-none) infinite;
}
@keyframes blink {
50% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.cursor {
animation: none;
}
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
export const metadata: Metadata = {
title: "Blocklist — Minecraft Account Manager",
description: "Connect your Discord identity to approved Minecraft accounts.",
};
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+70
View File
@@ -0,0 +1,70 @@
const steps = [
["01", "Open Discord", "Run /register or /account in the community server."],
["02", "Link your account", "Use the private, one-time link sent by the bot."],
["03", "Join the server", "Add your Java account and connect once approved."],
] as const;
export default function HomePage() {
return (
<main className="relative min-h-screen overflow-hidden bg-canvas text-ink">
<div className="terrain" aria-hidden="true" />
<div className="relative mx-auto flex min-h-screen max-w-7xl flex-col px-6 pb-10 pt-7 sm:px-10 lg:px-16">
<header className="flex items-center justify-between border-b border-line pb-5">
<a className="flex items-center gap-3" href="#top" aria-label="Blocklist home">
<span className="grid size-9 place-items-center border border-accent bg-accent text-sm font-black text-canvas shadow-[4px_4px_0_var(--color-shadow)]">
B
</span>
<span className="font-display text-sm font-bold uppercase tracking-[0.22em]">Blocklist</span>
</a>
<span className="hidden items-center gap-2 font-mono text-xs uppercase tracking-widest text-muted sm:flex">
<span className="size-2 bg-signal shadow-[0_0_12px_var(--color-signal)]" />
Registration online
</span>
</header>
<section id="top" className="grid flex-1 items-center gap-14 py-20 lg:grid-cols-[1.15fr_0.85fr] lg:py-24">
<div>
<p className="mb-7 font-mono text-xs font-semibold uppercase tracking-[0.3em] text-accent">
Discord Java Edition
</p>
<h1 className="max-w-4xl font-display text-6xl font-black uppercase leading-[0.86] tracking-[-0.07em] sm:text-8xl lg:text-[7.5rem]">
Your name.
<span className="block text-outline">Your blocks.</span>
One account.
</h1>
<p className="mt-9 max-w-xl border-l-2 border-accent pl-5 text-base leading-7 text-muted sm:text-lg">
Securely connect Discord to your approved Minecraft accounts. No passwords. No public links. Just a quick handoff from the server you already know.
</p>
</div>
<aside className="relative border border-line bg-panel/90 p-6 shadow-[10px_10px_0_var(--color-shadow)] backdrop-blur sm:p-8">
<span className="absolute -right-px -top-px bg-accent px-3 py-1 font-mono text-[10px] font-bold uppercase tracking-widest text-canvas">
Start here
</span>
<h2 className="font-display text-2xl font-bold uppercase tracking-tight">Three steps to the gate</h2>
<ol className="mt-8 space-y-1">
{steps.map(([number, title, description]) => (
<li key={number} className="group grid grid-cols-[3rem_1fr] gap-4 border-t border-line py-5 first:border-t-0">
<span className="font-mono text-xs font-bold text-accent">{number}</span>
<div>
<h3 className="font-display text-sm font-bold uppercase tracking-wider">{title}</h3>
<p className="mt-2 text-sm leading-6 text-muted">{description}</p>
</div>
</li>
))}
</ol>
<div className="mt-5 bg-ink px-5 py-4 font-mono text-xs leading-6 text-canvas">
<span className="text-signal">&gt;</span> Open Discord and type <strong>/register</strong>
<span className="cursor ml-1 inline-block h-4 w-2 bg-signal align-middle" />
</div>
</aside>
</section>
<footer className="flex flex-col gap-3 border-t border-line pt-5 font-mono text-[10px] uppercase tracking-[0.18em] text-muted sm:flex-row sm:items-center sm:justify-between">
<span>Java Edition only</span>
<span>Unknown players are denied by default</span>
</footer>
</div>
</main>
);
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["dom", "dom.iterable", "esnext"],
"noEmit": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+19
View File
@@ -0,0 +1,19 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: minecraft_accounts
POSTGRES_USER: minecraft
POSTGRES_PASSWORD: minecraft
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U minecraft -d minecraft_accounts"]
interval: 5s
timeout: 5s
retries: 10
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:
+24
View File
@@ -0,0 +1,24 @@
# Admin OIDC setup
The admin console will use Keycloak OIDC and JWT-backed Auth.js sessions, following the established pattern in the sibling Retro application.
## Application environment
- `AUTH_SECRET`
- `APP_URL`
- `KEYCLOAK_ISSUER_URL`
- `KEYCLOAK_CLIENT_ID`
- `KEYCLOAK_CLIENT_SECRET`
- `KEYCLOAK_REQUIRED_ROLE` (defaults to `minecraft-account-manager-admin`)
## Keycloak client
Create a confidential OpenID Connect client with standard authorization code flow enabled and direct access grants disabled.
Recommended client ID: `minecraft-account-manager-admin`
Allow exact callback and logout URLs for each environment. Avoid wildcard origins and redirect URLs.
Create the realm role `minecraft-account-manager-admin` and assign it directly or through an admin group. Ensure realm roles are emitted in `realm_access.roles`.
The admin console will reject sign-in when the required role is absent, even when Keycloak authentication itself succeeds.
+54
View File
@@ -0,0 +1,54 @@
# Architecture
## Services
### Web application
The Next.js application owns user onboarding, account management, admin configuration, server-side Minecraft profile validation, sessions, and the HTTP API used by Discord and Velocity integrations.
User authentication begins with an opaque, short-lived, single-use token created for a Discord user. Only a cryptographic hash of the token is persisted. Admin authentication is a separate Keycloak OIDC flow and requires the `minecraft-account-manager-admin` role.
### Discord bot
The bot creates private login links in response to `/register` and `/account`. Discord user IDs are the canonical Discord identity; mutable usernames are snapshots only. Nickname updates target the guild selected in admin settings.
### Velocity plugin
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 decision is fail closed. Unknown players, invalid responses, expired requests, authentication failures, and unavailable API responses are denied with the configured registration message.
## Trust boundaries
- Browser input is untrusted. Minecraft profile resolution occurs on the server.
- Forwarded IP headers are accepted only from configured reverse proxies.
- Discord IDs come from bot-authenticated requests or one-time-code records, not browser fields.
- Velocity requests will use per-server credentials, timestamps, and request IDs to support authentication and replay prevention.
- Session and one-time-code values are random and stored only as hashes.
- Exact IP addresses are sensitive data and require an explicit retention policy before production deployment.
## Database invariants
- 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.
- 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.
## IP intelligence
IP observations and cached classifications are modeled independently from any provider. Until a provider is configured, addresses remain `unknown`; the application must not claim that VPN checks occurred. When enabled, account creation can require a `clear` classification and record denied attempts as events.
## Event naming
Events use reverse-DNS names beneath `games.minecraft.account-manager`, including:
- `games.minecraft.account-manager.ui.accessed`
- `games.minecraft.account-manager.auth.magic-link.consumed`
- `games.minecraft.account-manager.minecraft-account.added`
- `games.minecraft.account-manager.minecraft-account.removed`
- `games.minecraft.account-manager.discord.nickname.updated`
- `games.minecraft.account-manager.network.vpn-blocked`
- `games.minecraft.account-manager.game.login.allowed`
- `games.minecraft.account-manager.game.login.denied`
+9849
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "minecraft-account-manager",
"version": "0.1.0",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"build": "npm run build --workspaces --if-present",
"dev": "npm run dev --workspace @minecraft-account-manager/web",
"lint": "npm run lint --workspaces --if-present",
"test": "npm run test --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present",
"db:generate": "npm run db:generate --workspace @minecraft-account-manager/database",
"db:migrate": "npm run db:migrate --workspace @minecraft-account-manager/database"
},
"engines": {
"node": ">=22"
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@minecraft-account-manager/contracts",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^4.3.6"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^4.1.0"
}
}
+47
View File
@@ -0,0 +1,47 @@
import { z } from "zod";
const isoDateTimeSchema = z.iso.datetime({ offset: true });
const minecraftUuidSchema = z
.string()
.regex(/^[0-9a-f]{32}$/i, "Expected a compact Java Edition UUID");
const minecraftUsernameSchema = z
.string()
.regex(/^[A-Za-z0-9_]{3,16}$/, "Expected a valid Java Edition username");
export const cloudEventSchema = z.object({
id: z.uuid(),
specversion: z.literal("1.0"),
source: z.string().startsWith("/"),
type: z.string().min(1),
subject: z.string().min(1).optional(),
time: isoDateTimeSchema,
datacontenttype: z.literal("application/json").default("application/json"),
dataschema: z.url().optional(),
data: z.record(z.string(), z.unknown()),
});
export type CloudEvent = z.infer<typeof cloudEventSchema>;
export const velocityAccessRequestSchema = z.object({
requestId: z.uuid(),
serverId: z.string().min(1).max(100),
minecraftUuid: minecraftUuidSchema,
username: minecraftUsernameSchema,
ipAddress: z.union([z.ipv4(), z.ipv6()]),
occurredAt: isoDateTimeSchema,
});
export type VelocityAccessRequest = z.infer<typeof velocityAccessRequestSchema>;
export const velocityAccessResponseSchema = z.discriminatedUnion("allowed", [
z.object({
allowed: z.literal(true),
message: z.string().min(1).optional(),
}),
z.object({
allowed: z.literal(false),
message: z.string().min(1),
}),
]);
export type VelocityAccessResponse = z.infer<typeof velocityAccessResponseSchema>;
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
cloudEventSchema,
velocityAccessRequestSchema,
velocityAccessResponseSchema,
} from "../src/index";
describe("shared service contracts", () => {
it("accepts a game login event in CloudEvents format", () => {
const event = cloudEventSchema.parse({
id: "8cf5b035-4368-4fa8-b9bb-2835da752f20",
specversion: "1.0",
source: "/velocity/main",
type: "games.minecraft.account-manager.game.login.allowed",
subject: "minecraft-account/8667ba71b85a4004af54457a9734eed7",
time: "2026-03-06T12:00:00.000Z",
datacontenttype: "application/json",
data: { username: "Notch" },
});
expect(event.specversion).toBe("1.0");
});
it("requires Velocity to send a Java UUID, username, and IP address", () => {
const request = velocityAccessRequestSchema.parse({
requestId: "6f1fd065-5298-42fa-b1ad-87a8916c6a8a",
serverId: "velocity-main",
minecraftUuid: "069a79f444e94726a5befca90e38aaf5",
username: "Notch",
ipAddress: "203.0.113.10",
occurredAt: "2026-03-06T12:00:00.000Z",
});
expect(request.username).toBe("Notch");
expect(() =>
velocityAccessRequestSchema.parse({ ...request, minecraftUuid: "not-a-uuid" }),
).toThrow();
});
it("only returns explicit allow or deny decisions to Velocity", () => {
expect(
velocityAccessResponseSchema.parse({
allowed: false,
message: "Please register your Minecraft account before joining.",
}),
).toEqual({
allowed: false,
message: "Please register your Minecraft account before joining.",
});
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: "./src/schema.ts",
out: "./drizzle",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgresql://minecraft:minecraft@localhost:5432/minecraft_accounts",
},
strict: true,
verbose: true,
});
@@ -0,0 +1,128 @@
CREATE TYPE "public"."ip_classification" AS ENUM('unknown', 'clear', 'vpn', 'proxy', 'hosting', 'tor');--> statement-breakpoint
CREATE TYPE "public"."ip_observation_source" AS ENUM('web', 'game');--> statement-breakpoint
CREATE TYPE "public"."minecraft_validation_status" AS ENUM('verified', 'user_confirmed');--> statement-breakpoint
CREATE TABLE "app_settings" (
"id" text PRIMARY KEY DEFAULT 'default' NOT NULL,
"discord_guild_id" text,
"registration_message" text DEFAULT 'Please register your Minecraft account before joining.' 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 "events" (
"id" uuid PRIMARY KEY NOT NULL,
"spec_version" text DEFAULT '1.0' NOT NULL,
"source" text NOT NULL,
"type" text NOT NULL,
"subject" text,
"time" timestamp (3) with time zone NOT NULL,
"data_content_type" text DEFAULT 'application/json' NOT NULL,
"data_schema" text,
"data" jsonb NOT NULL,
"actor_user_id" uuid,
"ip_address" "inet",
"correlation_id" uuid,
"published_at" timestamp (3) with time zone,
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ip_intelligence" (
"ip_address" "inet" PRIMARY KEY NOT NULL,
"classification" "ip_classification" DEFAULT 'unknown' NOT NULL,
"provider" text,
"raw_response" jsonb,
"checked_at" timestamp (3) with time zone,
"expires_at" timestamp (3) with time zone,
"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 "ip_observations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid,
"minecraft_account_id" uuid,
"source" "ip_observation_source" NOT NULL,
"ip_address" "inet" NOT NULL,
"minecraft_uuid" varchar(32),
"username" varchar(16),
"classification" "ip_classification" DEFAULT 'unknown' NOT NULL,
"observed_at" timestamp (3) with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "login_codes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"token_hash" text NOT NULL,
"discord_user_id" text NOT NULL,
"discord_username" text NOT NULL,
"discord_global_name" text,
"expires_at" timestamp (3) with time zone NOT NULL,
"consumed_at" timestamp (3) with time zone,
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "minecraft_accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"minecraft_uuid" varchar(32),
"username" varchar(16) NOT NULL,
"validation_status" "minecraft_validation_status" NOT NULL,
"is_primary" boolean DEFAULT false NOT NULL,
"last_verified_at" timestamp (3) with time zone,
"deleted_at" timestamp (3) with time zone,
"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 "plugin_credentials" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"server_id" text NOT NULL,
"secret_hash" text NOT NULL,
"revoked_at" timestamp (3) with time zone,
"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 "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamp (3) with time zone NOT NULL,
"last_seen_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
"revoked_at" timestamp (3) with time zone,
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"discord_user_id" text NOT NULL,
"discord_username" text NOT NULL,
"discord_global_name" text,
"first_name" text,
"onboarding_completed_at" timestamp (3) with time zone,
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "events" ADD CONSTRAINT "events_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ip_observations" ADD CONSTRAINT "ip_observations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ip_observations" ADD CONSTRAINT "ip_observations_minecraft_account_id_minecraft_accounts_id_fk" FOREIGN KEY ("minecraft_account_id") REFERENCES "public"."minecraft_accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "minecraft_accounts" ADD CONSTRAINT "minecraft_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "events_time_idx" ON "events" USING btree ("time");--> statement-breakpoint
CREATE INDEX "events_type_time_idx" ON "events" USING btree ("type","time");--> statement-breakpoint
CREATE INDEX "events_subject_time_idx" ON "events" USING btree ("subject","time");--> statement-breakpoint
CREATE INDEX "events_unpublished_idx" ON "events" USING btree ("created_at") WHERE "events"."published_at" is null;--> statement-breakpoint
CREATE INDEX "ip_observations_user_observed_idx" ON "ip_observations" USING btree ("user_id","observed_at");--> statement-breakpoint
CREATE INDEX "ip_observations_account_observed_idx" ON "ip_observations" USING btree ("minecraft_account_id","observed_at");--> statement-breakpoint
CREATE UNIQUE INDEX "login_codes_token_hash_uidx" ON "login_codes" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "login_codes_discord_user_idx" ON "login_codes" USING btree ("discord_user_id");--> statement-breakpoint
CREATE INDEX "login_codes_expires_idx" ON "login_codes" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "minecraft_accounts_user_idx" ON "minecraft_accounts" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "minecraft_accounts_active_uuid_uidx" ON "minecraft_accounts" USING btree ("minecraft_uuid") WHERE "minecraft_accounts"."deleted_at" is null and "minecraft_accounts"."minecraft_uuid" is not null;--> statement-breakpoint
CREATE UNIQUE INDEX "minecraft_accounts_active_username_uidx" ON "minecraft_accounts" USING btree (lower("username")) WHERE "minecraft_accounts"."deleted_at" is null;--> statement-breakpoint
CREATE UNIQUE INDEX "minecraft_accounts_one_primary_per_user_uidx" ON "minecraft_accounts" USING btree ("user_id") WHERE "minecraft_accounts"."is_primary" = true and "minecraft_accounts"."deleted_at" is null;--> statement-breakpoint
CREATE UNIQUE INDEX "plugin_credentials_server_id_uidx" ON "plugin_credentials" USING btree ("server_id");--> statement-breakpoint
CREATE UNIQUE INDEX "sessions_token_hash_uidx" ON "sessions" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "sessions_expires_idx" ON "sessions" USING btree ("expires_at");--> statement-breakpoint
CREATE UNIQUE INDEX "users_discord_user_id_uidx" ON "users" USING btree ("discord_user_id");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1785603505066,
"tag": "0000_supreme_human_fly",
"breakpoints": true
}
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@minecraft-account-manager/database",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./schema": "./src/schema.ts"
},
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.45.1",
"postgres": "^3.4.8"
},
"devDependencies": {
"drizzle-kit": "^0.31.10",
"typescript": "^5.9.3"
}
}
+13
View File
@@ -0,0 +1,13 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
export function createDatabase(databaseUrl: string) {
const client = postgres(databaseUrl, { max: 10 });
return {
client,
db: drizzle(client, { schema }),
};
}
export * from "./schema";
+213
View File
@@ -0,0 +1,213 @@
import { sql } from "drizzle-orm";
import {
boolean,
index,
inet,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
} from "drizzle-orm/pg-core";
function timestamps() {
return {
createdAt: timestamp("created_at", { withTimezone: true, mode: "date", precision: 3 })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date", precision: 3 })
.notNull()
.defaultNow(),
};
}
function createdAt() {
return timestamp("created_at", { withTimezone: true, mode: "date", precision: 3 })
.notNull()
.defaultNow();
}
export const minecraftValidationStatus = pgEnum("minecraft_validation_status", [
"verified",
"user_confirmed",
]);
export const ipObservationSource = pgEnum("ip_observation_source", ["web", "game"]);
export const ipClassification = pgEnum("ip_classification", [
"unknown",
"clear",
"vpn",
"proxy",
"hosting",
"tor",
]);
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
discordUserId: text("discord_user_id").notNull(),
discordUsername: text("discord_username").notNull(),
discordGlobalName: text("discord_global_name"),
firstName: text("first_name"),
onboardingCompletedAt: timestamp("onboarding_completed_at", {
withTimezone: true,
mode: "date",
precision: 3,
}),
...timestamps(),
},
(table) => [uniqueIndex("users_discord_user_id_uidx").on(table.discordUserId)],
);
export const minecraftAccounts = pgTable(
"minecraft_accounts",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
minecraftUuid: varchar("minecraft_uuid", { length: 32 }),
username: varchar("username", { length: 16 }).notNull(),
validationStatus: minecraftValidationStatus("validation_status").notNull(),
isPrimary: boolean("is_primary").notNull().default(false),
lastVerifiedAt: timestamp("last_verified_at", { withTimezone: true, mode: "date", precision: 3 }),
deletedAt: timestamp("deleted_at", { withTimezone: true, mode: "date", precision: 3 }),
...timestamps(),
},
(table) => [
index("minecraft_accounts_user_idx").on(table.userId),
uniqueIndex("minecraft_accounts_active_uuid_uidx")
.on(table.minecraftUuid)
.where(sql`${table.deletedAt} is null and ${table.minecraftUuid} is not null`),
uniqueIndex("minecraft_accounts_active_username_uidx")
.on(sql`lower(${table.username})`)
.where(sql`${table.deletedAt} is null`),
uniqueIndex("minecraft_accounts_one_primary_per_user_uidx")
.on(table.userId)
.where(sql`${table.isPrimary} = true and ${table.deletedAt} is null`),
],
);
export const loginCodes = pgTable(
"login_codes",
{
id: uuid("id").primaryKey().defaultRandom(),
tokenHash: text("token_hash").notNull(),
discordUserId: text("discord_user_id").notNull(),
discordUsername: text("discord_username").notNull(),
discordGlobalName: text("discord_global_name"),
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date", precision: 3 }).notNull(),
consumedAt: timestamp("consumed_at", { withTimezone: true, mode: "date", precision: 3 }),
createdAt: createdAt(),
},
(table) => [
uniqueIndex("login_codes_token_hash_uidx").on(table.tokenHash),
index("login_codes_discord_user_idx").on(table.discordUserId),
index("login_codes_expires_idx").on(table.expiresAt),
],
);
export const sessions = pgTable(
"sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
tokenHash: text("token_hash").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date", precision: 3 }).notNull(),
lastSeenAt: timestamp("last_seen_at", { withTimezone: true, mode: "date", precision: 3 })
.notNull()
.defaultNow(),
revokedAt: timestamp("revoked_at", { withTimezone: true, mode: "date", precision: 3 }),
createdAt: createdAt(),
},
(table) => [
uniqueIndex("sessions_token_hash_uidx").on(table.tokenHash),
index("sessions_user_idx").on(table.userId),
index("sessions_expires_idx").on(table.expiresAt),
],
);
export const appSettings = pgTable("app_settings", {
id: text("id").primaryKey().default("default"),
discordGuildId: text("discord_guild_id"),
registrationMessage: text("registration_message")
.notNull()
.default("Please register your Minecraft account before joining."),
...timestamps(),
});
export const ipIntelligence = pgTable("ip_intelligence", {
ipAddress: inet("ip_address").primaryKey(),
classification: ipClassification("classification").notNull().default("unknown"),
provider: text("provider"),
rawResponse: jsonb("raw_response").$type<Record<string, unknown>>(),
checkedAt: timestamp("checked_at", { withTimezone: true, mode: "date", precision: 3 }),
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date", precision: 3 }),
...timestamps(),
});
export const ipObservations = pgTable(
"ip_observations",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
minecraftAccountId: uuid("minecraft_account_id").references(() => minecraftAccounts.id, {
onDelete: "set null",
}),
source: ipObservationSource("source").notNull(),
ipAddress: inet("ip_address").notNull(),
minecraftUuid: varchar("minecraft_uuid", { length: 32 }),
username: varchar("username", { length: 16 }),
classification: ipClassification("classification").notNull().default("unknown"),
observedAt: timestamp("observed_at", { withTimezone: true, mode: "date", precision: 3 })
.notNull()
.defaultNow(),
},
(table) => [
index("ip_observations_user_observed_idx").on(table.userId, table.observedAt),
index("ip_observations_account_observed_idx").on(table.minecraftAccountId, table.observedAt),
],
);
export const pluginCredentials = pgTable(
"plugin_credentials",
{
id: uuid("id").primaryKey().defaultRandom(),
serverId: text("server_id").notNull(),
secretHash: text("secret_hash").notNull(),
revokedAt: timestamp("revoked_at", { withTimezone: true, mode: "date", precision: 3 }),
...timestamps(),
},
(table) => [uniqueIndex("plugin_credentials_server_id_uidx").on(table.serverId)],
);
export const events = pgTable(
"events",
{
id: uuid("id").primaryKey(),
specVersion: text("spec_version").notNull().default("1.0"),
source: text("source").notNull(),
type: text("type").notNull(),
subject: text("subject"),
time: timestamp("time", { withTimezone: true, mode: "date", precision: 3 }).notNull(),
dataContentType: text("data_content_type").notNull().default("application/json"),
dataSchema: text("data_schema"),
data: jsonb("data").$type<Record<string, unknown>>().notNull(),
actorUserId: uuid("actor_user_id").references(() => users.id, { onDelete: "set null" }),
ipAddress: inet("ip_address"),
correlationId: uuid("correlation_id"),
publishedAt: timestamp("published_at", { withTimezone: true, mode: "date", precision: 3 }),
createdAt: createdAt(),
},
(table) => [
index("events_time_idx").on(table.time),
index("events_type_time_idx").on(table.type, table.time),
index("events_subject_time_idx").on(table.subject, table.time),
index("events_unpublished_idx").on(table.createdAt).where(sql`${table.publishedAt} is null`),
],
);
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["src/**/*.ts", "drizzle.config.ts"]
}
View File
+3
View File
@@ -0,0 +1,3 @@
# Velocity plugin
The fail-closed Velocity admission plugin will be implemented after the web API contract is complete. Unknown players and unavailable API responses will be denied with the configured registration message.
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"allowJs": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
}
}