feat(core): scaffold account manager platform
This commit is contained in:
@@ -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=
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Discord command handling is added in Phase 2 after the one-time login service exists.
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -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"]),
|
||||||
|
]);
|
||||||
Vendored
+6
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const postcssConfig = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default postcssConfig;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">></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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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:
|
||||||
@@ -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.
|
||||||
@@ -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`
|
||||||
Generated
+9849
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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`),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "drizzle.config.ts"]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user