feat(platform): add Discord onboarding and Velocity gate

This commit is contained in:
dmg
2026-08-01 13:45:18 -04:00
parent 9d305e5dc9
commit c5de0a1810
80 changed files with 4840 additions and 1572 deletions
@@ -0,0 +1,10 @@
CREATE TABLE "plugin_requests" (
"request_id" uuid PRIMARY KEY NOT NULL,
"server_id" text NOT NULL,
"received_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp (3) with time zone NOT NULL
);
--> statement-breakpoint
ALTER TABLE "plugin_requests" ADD CONSTRAINT "plugin_requests_server_id_plugin_credentials_server_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."plugin_credentials"("server_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "plugin_requests_expires_idx" ON "plugin_requests" USING btree ("expires_at");--> statement-breakpoint
ALTER TABLE "app_settings" DROP COLUMN "discord_guild_id";
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,13 @@
"when": 1785603505066,
"tag": "0000_supreme_human_fly",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1785605590058,
"tag": "0001_silent_ultragirl",
"breakpoints": true
}
]
}
+3
View File
@@ -10,14 +10,17 @@
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"plugin:create-credential": "tsx scripts/create-plugin-credential.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@minecraft-account-manager/auth": "*",
"drizzle-orm": "^0.45.1",
"postgres": "^3.4.8"
},
"devDependencies": {
"drizzle-kit": "^0.31.10",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
@@ -0,0 +1,29 @@
import { randomBytes } from "node:crypto";
import { hashToken } from "@minecraft-account-manager/auth";
import { eq } from "drizzle-orm";
import { createDatabase, pluginCredentials } from "../src/index";
const serverId = process.argv[2]?.trim();
if (!serverId || !/^[a-z0-9][a-z0-9_-]{1,99}$/i.test(serverId)) {
throw new Error("Usage: npm run plugin:create-credential --workspace @minecraft-account-manager/database -- <server-id>");
}
const databaseUrl = process.env.DATABASE_URL?.trim();
if (!databaseUrl) throw new Error("DATABASE_URL is required");
const token = randomBytes(32).toString("base64url");
const { db, client } = createDatabase(databaseUrl);
const now = new Date();
await db
.insert(pluginCredentials)
.values({ serverId, secretHash: hashToken(token) })
.onConflictDoUpdate({
target: pluginCredentials.serverId,
set: { secretHash: hashToken(token), revokedAt: null, updatedAt: now },
});
console.log(`Server ID: ${serverId}`);
console.log(`API token: ${token}`);
console.log("Store this token in the Velocity plugin configuration now; it will not be shown again.");
await client.end();
+132
View File
@@ -0,0 +1,132 @@
import { LoginRateLimitedError, type AuthRepository } from "@minecraft-account-manager/auth";
import { and, desc, eq, gt, isNull, lt, sql } from "drizzle-orm";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import * as schema from "./schema";
import { loginCodes, sessions, users } from "./schema";
type Database = PostgresJsDatabase<typeof schema>;
export function createAuthRepository(db: Database): AuthRepository {
return {
async saveLoginCode(code) {
await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${code.discordUserId}))`);
const [latestCode] = await tx
.select({ createdAt: loginCodes.createdAt })
.from(loginCodes)
.where(eq(loginCodes.discordUserId, code.discordUserId))
.orderBy(desc(loginCodes.createdAt))
.limit(1);
if (latestCode && latestCode.createdAt > new Date(code.createdAt.getTime() - 30_000)) {
throw new LoginRateLimitedError();
}
await tx.delete(loginCodes).where(lt(loginCodes.expiresAt, code.createdAt));
await tx
.update(loginCodes)
.set({ consumedAt: code.createdAt })
.where(
and(
eq(loginCodes.discordUserId, code.discordUserId),
isNull(loginCodes.consumedAt),
),
);
await tx.insert(loginCodes).values({
tokenHash: code.tokenHash,
discordUserId: code.discordUserId,
discordUsername: code.discordUsername,
discordGlobalName: code.discordGlobalName,
expiresAt: code.expiresAt,
createdAt: code.createdAt,
});
});
},
async exchangeLoginCode(input) {
return db.transaction(async (tx) => {
const [loginCode] = await tx
.update(loginCodes)
.set({ consumedAt: input.now })
.where(
and(
eq(loginCodes.tokenHash, input.loginCodeHash),
isNull(loginCodes.consumedAt),
gt(loginCodes.expiresAt, input.now),
),
)
.returning();
if (!loginCode) {
return null;
}
const [existingUser] = await tx
.select({ id: users.id })
.from(users)
.where(eq(users.discordUserId, loginCode.discordUserId))
.limit(1);
const [user] = await tx
.insert(users)
.values({
discordUserId: loginCode.discordUserId,
discordUsername: loginCode.discordUsername,
discordGlobalName: loginCode.discordGlobalName,
})
.onConflictDoUpdate({
target: users.discordUserId,
set: {
discordUsername: loginCode.discordUsername,
discordGlobalName: loginCode.discordGlobalName,
updatedAt: input.now,
},
})
.returning({
id: users.id,
discordUserId: users.discordUserId,
discordUsername: users.discordUsername,
firstName: users.firstName,
});
if (!user) {
throw new Error("Failed to create or update the Discord user");
}
await tx.insert(sessions).values({
userId: user.id,
tokenHash: input.sessionTokenHash,
expiresAt: input.sessionExpiresAt,
lastSeenAt: input.now,
createdAt: input.now,
});
return { user, isNewUser: !existingUser };
});
},
};
}
export async function findUserBySessionToken(db: Database, tokenHash: string, now = new Date()) {
const [user] = await db
.select({
id: users.id,
discordUserId: users.discordUserId,
discordUsername: users.discordUsername,
firstName: users.firstName,
onboardingCompletedAt: users.onboardingCompletedAt,
sessionExpiresAt: sessions.expiresAt,
})
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(
and(
eq(sessions.tokenHash, tokenHash),
isNull(sessions.revokedAt),
gt(sessions.expiresAt, now),
),
)
.limit(1);
return user ?? null;
}
+35
View File
@@ -0,0 +1,35 @@
import { randomUUID } from "node:crypto";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import * as schema from "./schema";
import { events } from "./schema";
type Database = PostgresJsDatabase<typeof schema>;
export interface RecordEventInput {
type: string;
source: string;
subject?: string;
data?: Record<string, unknown>;
actorUserId?: string;
ipAddress?: string;
correlationId?: string;
time?: Date;
}
export async function recordEvent(db: Database, input: RecordEventInput) {
const id = randomUUID();
await db.insert(events).values({
id,
specVersion: "1.0",
source: input.source,
type: input.type,
subject: input.subject,
time: input.time ?? new Date(),
dataContentType: "application/json",
data: input.data ?? {},
actorUserId: input.actorUserId,
ipAddress: input.ipAddress,
correlationId: input.correlationId,
});
return id;
}
+2
View File
@@ -10,4 +10,6 @@ export function createDatabase(databaseUrl: string) {
};
}
export * from "./auth-repository";
export * from "./events";
export * from "./schema";
+15 -1
View File
@@ -134,7 +134,6 @@ export const sessions = pgTable(
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."),
@@ -186,6 +185,21 @@ export const pluginCredentials = pgTable(
(table) => [uniqueIndex("plugin_credentials_server_id_uidx").on(table.serverId)],
);
export const pluginRequests = pgTable(
"plugin_requests",
{
requestId: uuid("request_id").primaryKey(),
serverId: text("server_id")
.notNull()
.references(() => pluginCredentials.serverId, { onDelete: "cascade" }),
receivedAt: timestamp("received_at", { withTimezone: true, mode: "date", precision: 3 })
.notNull()
.defaultNow(),
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date", precision: 3 }).notNull(),
},
(table) => [index("plugin_requests_expires_idx").on(table.expiresAt)],
);
export const events = pgTable(
"events",
{
+1 -1
View File
@@ -3,5 +3,5 @@
"compilerOptions": {
"types": ["node"]
},
"include": ["src/**/*.ts", "drizzle.config.ts"]
"include": ["src/**/*.ts", "scripts/**/*.ts", "drizzle.config.ts"]
}