feat(platform): add Discord onboarding and Velocity gate
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,4 +10,6 @@ export function createDatabase(databaseUrl: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export * from "./auth-repository";
|
||||
export * from "./events";
|
||||
export * from "./schema";
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "drizzle.config.ts"]
|
||||
"include": ["src/**/*.ts", "scripts/**/*.ts", "drizzle.config.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user