+
+
+
diff --git a/server/i18n/locales/en_us.json b/server/i18n/locales/en_us.json
index 9ec6e528..4b618b89 100644
--- a/server/i18n/locales/en_us.json
+++ b/server/i18n/locales/en_us.json
@@ -289,6 +289,20 @@
"viewAll": "View all {arrow}",
"yourFriends": "Your friends"
},
+ "gameAccount": {
+ "title": "Game account",
+ "noAccount": "You do not have an account on this server yet. Create one to log in with the game client.",
+ "create": "Create game account",
+ "changePassword": "Change password",
+ "username": "Game account username",
+ "password": "Game account password",
+ "passwordHint": "This is the password you type into the game client. It is separate from your Drop sign-in and is stored by the game server, not by Drop.",
+ "created": "created",
+ "lastLogin": "last login",
+ "locked": "Locked",
+ "banned": "Banned",
+ "unavailable": "This server's account system is not responding, so we cannot check your account right now."
+ },
"issues": {
"addComment": "Add comment",
"addCommentPlaceholder": "Add a comment…",
diff --git a/server/pages/community/servers/[id]/index.vue b/server/pages/community/servers/[id]/index.vue
index ecd2aaa7..ec1e28e3 100644
--- a/server/pages/community/servers/[id]/index.vue
+++ b/server/pages/community/servers/[id]/index.vue
@@ -100,6 +100,12 @@
{{ $t("community.servers.viewTitle") }}
+
+
+
{{ $t("community.servers.recentChecks") }}
diff --git a/server/prisma/migrations/20260812120000_m6_game_account_link/migration.sql b/server/prisma/migrations/20260812120000_m6_game_account_link/migration.sql
new file mode 100644
index 00000000..578ecdf3
--- /dev/null
+++ b/server/prisma/migrations/20260812120000_m6_game_account_link/migration.sql
@@ -0,0 +1,27 @@
+-- M6: SSO identity <-> native game account bridge.
+-- See docs/design/0002-game-account-management.md and the GameAccountLink
+-- header in prisma/models/community-servers.prisma.
+--
+-- The unique index is the feature, not a constraint: one Drop identity maps
+-- to exactly one native account per server.
+
+-- CreateTable
+CREATE TABLE "GameAccountLink" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "serverSlug" TEXT NOT NULL,
+ "accountId" TEXT NOT NULL,
+ "accountUsername" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "GameAccountLink_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "GameAccountLink_userId_serverSlug_key" ON "GameAccountLink"("userId", "serverSlug");
+
+-- CreateIndex
+CREATE INDEX "GameAccountLink_serverSlug_idx" ON "GameAccountLink"("serverSlug");
+
+-- CreateIndex
+CREATE INDEX "GameAccountLink_userId_idx" ON "GameAccountLink"("userId");
diff --git a/server/prisma/models/community-servers.prisma b/server/prisma/models/community-servers.prisma
index e91c27f8..923184e9 100644
--- a/server/prisma/models/community-servers.prisma
+++ b/server/prisma/models/community-servers.prisma
@@ -235,3 +235,47 @@ model IssueComment {
@@index([issueId, createdAt])
}
+
+// --- M6: SSO identity <-> native game account ------------------------------
+//
+// THE WHOLE POINT: none of these emulators speak OIDC and none of them will.
+// EQEmu, TrinityCore and OpenDAoC each ship their own login against their own
+// auth database. So this is not SSO for the game servers -- it is a BRIDGE:
+// one Drop identity tied to one native game account per server, so a player
+// has a single place to create and reset credentials they will then type into
+// a game client that has never heard of Authentik.
+// See docs/design/0002-game-account-management.md.
+//
+// ONE ACCOUNT PER USER PER SERVER is the feature, not a limitation -- "tie my
+// identity to my account on that server" only means one thing. Enforced by
+// @@unique([userId, serverSlug]).
+//
+// serverSlug is GameServer.slug, NOT a relation -- same reasoning as
+// ChatRoom.serverKey above. It also deliberately survives a GameServer row
+// being deleted and re-added: the link is to the SERVER as a concept, and
+// losing a player's account mapping because a registry row was recreated
+// would be data loss with no upside.
+//
+// accountId is provider-defined and OPAQUE -- TEXT even where the game's
+// native id is numeric (TrinityCore's account.id). That is what lets one
+// column work for every backend; mmo-portal's own link table made the same
+// call for the same reason. Providers convert at their own boundary.
+//
+// NEVER INFERRED FROM A NAME MATCH. Every row is written explicitly at
+// provisioning time. A game account whose username happens to equal a Drop
+// username is not that user's account.
+model GameAccountLink {
+ id String @id @default(uuid())
+
+ userId String
+ serverSlug String
+
+ accountId String
+ accountUsername String
+
+ createdAt DateTime @default(now())
+
+ @@unique([userId, serverSlug])
+ @@index([serverSlug])
+ @@index([userId])
+}
diff --git a/server/server/api/v1/community/servers/[id]/account.get.ts b/server/server/api/v1/community/servers/[id]/account.get.ts
new file mode 100644
index 00000000..a0e43efe
--- /dev/null
+++ b/server/server/api/v1/community/servers/[id]/account.get.ts
@@ -0,0 +1,34 @@
+import aclManager from "~/server/internal/acls";
+import { getGameServer } from "~/server/internal/community/gameServerService";
+import { getPanel } from "~/server/internal/community/gameAccountService";
+
+// Read-only view of the caller's own game account on this server.
+//
+// Never throws for a provider outage -- getPanel() reports it as
+// available:false and the page still renders. An unreachable game DB is a
+// normal state here, not an error worth a 500.
+//
+// Only ever returns the CALLER's account: the link lookup is keyed on their
+// user id, so there is no id to tamper with in the first place.
+export default defineEventHandler(async (h3) => {
+ const user = await aclManager.getUserACL(h3, [
+ "community:game-accounts:read",
+ ]);
+ if (!user) throw createError({ statusCode: 403 });
+
+ const id = getRouterParam(h3, "id");
+ if (!id)
+ throw createError({
+ statusCode: 400,
+ statusMessage: "No server id in route.",
+ });
+
+ // Resolve through the registry rather than trusting a slug from the client:
+ // the route takes a GameServer id, and visibility rules live in
+ // getGameServer (a private server must 404 for someone who cannot see it).
+ const server = await getGameServer(id, user.id, user.admin);
+ if (!server)
+ throw createError({ statusCode: 404, statusMessage: "Server not found." });
+
+ return getPanel(user.id, server.slug);
+});
diff --git a/server/server/api/v1/community/servers/[id]/account.post.ts b/server/server/api/v1/community/servers/[id]/account.post.ts
new file mode 100644
index 00000000..9467fab5
--- /dev/null
+++ b/server/server/api/v1/community/servers/[id]/account.post.ts
@@ -0,0 +1,97 @@
+import { ArkErrors, type } from "arktype";
+import aclManager from "~/server/internal/acls";
+import { getGameServer } from "~/server/internal/community/gameServerService";
+import {
+ GameAccountConflict,
+ GameProviderUnavailable,
+ GameProviderUserError,
+ changePassword,
+ provision,
+} from "~/server/internal/community/gameAccountService";
+
+// Provision a game account, or change the password on the existing one.
+//
+// AUTHORIZATION IS JUST "IS THIS A SIGNED-IN DROP USER WITH THE ACL". If a
+// user can reach Drop and reach the game, they may have an account -- Drop
+// membership implies the first, the game-access group implies the second, and
+// a third gate here could only disagree with them. See
+// docs/design/0002-game-account-management.md.
+//
+// The password NEVER touches Drop's database. It goes straight through to the
+// provider service, which turns it into whatever that emulator's auth DB
+// wants (an SRP6 verifier, EQEmu's own hash). Drop stores only the link.
+
+const AccountWrite = type({
+ action: "'provision' | 'password'",
+ "username?": "string",
+ password: "string",
+ "email?": "string",
+});
+
+export default defineEventHandler(async (h3) => {
+ const user = await aclManager.getUserACL(h3, [
+ "community:game-accounts:write",
+ ]);
+ if (!user) throw createError({ statusCode: 403 });
+
+ const id = getRouterParam(h3, "id");
+ if (!id)
+ throw createError({ statusCode: 400, statusMessage: "No server id in route." });
+
+ const server = await getGameServer(id, user.id, user.admin);
+ if (!server)
+ throw createError({ statusCode: 404, statusMessage: "Server not found." });
+
+ const body = AccountWrite(await readBody(h3));
+ if (body instanceof ArkErrors)
+ throw createError({ statusCode: 400, statusMessage: body.summary });
+
+ // Length only. Every emulator has its own character-set and length rules and
+ // its own opinion about case, so the PROVIDER is the authority -- it returns
+ // a user-facing message (422 -> GameProviderUserError) that we surface
+ // verbatim rather than duplicating three sets of rules here and getting them
+ // subtly wrong.
+ if (body.password.length < 6)
+ throw createError({
+ statusCode: 400,
+ statusMessage: "Password must be at least 6 characters.",
+ });
+
+ try {
+ if (body.action === "provision") {
+ const username = body.username?.trim();
+ if (!username)
+ throw createError({
+ statusCode: 400,
+ statusMessage: "A username is required.",
+ });
+ const account = await provision(
+ user.id,
+ server.slug,
+ username,
+ body.password,
+ body.email ?? "",
+ );
+ return { ok: true, account };
+ }
+
+ await changePassword(user.id, server.slug, body.password);
+ return { ok: true };
+ } catch (err) {
+ // Conflict: already linked, or a concurrent provision won the race.
+ if (err instanceof GameAccountConflict)
+ throw createError({ statusCode: 409, statusMessage: err.message });
+ // Provider validation (name taken, rejected password) -- meant for the
+ // player verbatim.
+ if (err instanceof GameProviderUserError)
+ throw createError({ statusCode: 422, statusMessage: err.message });
+ // Backend down / not configured. Not the player's fault and not their
+ // problem to read, so the message stays generic.
+ if (err instanceof GameProviderUnavailable)
+ throw createError({
+ statusCode: 503,
+ statusMessage: "The game server's account system is unavailable.",
+ });
+ throw err;
+ }
+});
diff --git a/server/server/internal/acls/descriptions.ts b/server/server/internal/acls/descriptions.ts
index bc9922d1..9d0296af 100644
--- a/server/server/internal/acls/descriptions.ts
+++ b/server/server/internal/acls/descriptions.ts
@@ -68,6 +68,12 @@ export const userACLDescriptions: ObjectFromList = {
"community:issues:new": "File a new issue report.",
"community:issues:comment": "Comment on an issue report.",
+ // --- M6 ---
+ "community:game-accounts:read":
+ "See your linked game-server accounts and their characters.",
+ "community:game-accounts:write":
+ "Create a game-server account and change its password.",
+
"system-data:listen":
"Connect to a websocket to receive system data updates.",
};
diff --git a/server/server/internal/acls/index.ts b/server/server/internal/acls/index.ts
index 080f84cc..2971c5fd 100644
--- a/server/server/internal/acls/index.ts
+++ b/server/server/internal/acls/index.ts
@@ -57,6 +57,10 @@ export const userACLs = [
"community:issues:new",
"community:issues:comment",
+ // --- M6: SSO <-> native game account bridge ---
+ "community:game-accounts:read",
+ "community:game-accounts:write",
+
"system-data:listen",
] as const;
const userACLPrefix = "user:";
diff --git a/server/server/internal/community/gameAccountProvider.ts b/server/server/internal/community/gameAccountProvider.ts
new file mode 100644
index 00000000..7a55223a
--- /dev/null
+++ b/server/server/internal/community/gameAccountProvider.ts
@@ -0,0 +1,241 @@
+// Client for mmo-portal's internal provider API (/internal/v1/*).
+//
+// WHY AN EXTERNAL SERVICE AT ALL
+// The per-game password crypto -- SRP6 verifiers for TrinityCore, EQEmu's own
+// hashing, OpenDAoC's -- writes into live auth databases that players
+// authenticate against, and it fails SILENTLY when subtly wrong. That code is
+// already written, already proven, and already reaches every game DB. It is
+// not being reimplemented in TypeScript for language tidiness.
+// See docs/design/0002-game-account-management.md.
+//
+// Drop owns identity, authorization, the link table and the UI. This module
+// is a thin transport: it does not decide who may do anything.
+//
+// The provider service is LAN-only and authenticated with a shared secret
+// that proves "a service on our LAN", never "this user may do this".
+
+import { type } from "arktype";
+
+// --- config -----------------------------------------------------------------
+
+// Unset base URL disables the whole feature (see isConfigured). Callers must
+// check that rather than letting fetches fail one by one -- a server page
+// should render no account panel at all, not a broken one.
+const BASE = process.env.GAME_PROVIDER_API?.replace(/\/+$/, "") ?? "";
+const TOKEN = process.env.GAME_PROVIDER_TOKEN ?? "";
+const TIMEOUT_MS = parseInt(process.env.GAME_PROVIDER_TIMEOUT_MS ?? "8000");
+
+export function isConfigured(): boolean {
+ return Boolean(BASE && TOKEN);
+}
+
+// --- slug -> provider key ---------------------------------------------------
+//
+// GameServer.slug names the GAME; provider.key names the EMULATOR. They agree
+// for two of three and do not for WoW, so this is an explicit map rather than
+// an assumption that slug === key.
+//
+// Do NOT "fix" this by renaming either side. GameServer.slug is load-bearing
+// for chatService's KNOWN_SERVERS match (see community-servers.prisma), and
+// provider.key is the registry lookup across mmo-portal's routes, templates
+// and its own link rows. mmo-portal's tests assert the exact key set so a
+// rename there breaks a test rather than silently breaking this mapping.
+const SLUG_TO_PROVIDER: Record = {
+ eqemu: "eqemu",
+ daoc: "daoc",
+ wow: "trinitycore",
+};
+
+export function providerKeyForSlug(slug: string): string | null {
+ return SLUG_TO_PROVIDER[slug] ?? null;
+}
+
+// --- wire types -------------------------------------------------------------
+
+export const ProviderCapabilities = type({
+ characters: "boolean",
+ set_privilege_level: "boolean",
+ ban: "boolean",
+ lock: "boolean",
+ set_expansion: "boolean",
+});
+
+export const ProviderInfo = type({
+ key: "string",
+ display_name: "string",
+ description: "string",
+ configured: "boolean",
+ capabilities: ProviderCapabilities,
+});
+
+export const ProviderAccount = type({
+ account_id: "string",
+ username: "string",
+ "email?": "string | null",
+ "created_at?": "string | null",
+ "last_login?": "string | null",
+ online: "boolean",
+ locked: "boolean",
+ banned: "boolean",
+ "ban_reason?": "string | null",
+ privilege_level: "number",
+ "raw?": "unknown",
+});
+
+export type ProviderInfoT = typeof ProviderInfo.infer;
+export type ProviderAccountT = typeof ProviderAccount.infer;
+
+/** A provider-side failure the END USER should see verbatim (name taken,
+ * invalid password). Distinct from an outage so callers can render it as a
+ * form error rather than an incident -- mmo-portal signals it with 422. */
+export class GameProviderUserError extends Error {}
+
+/** Backend unreachable / misconfigured. Not the user's fault, never their
+ * problem to read. */
+export class GameProviderUnavailable extends Error {}
+
+// --- transport --------------------------------------------------------------
+
+async function call(
+ path: string,
+ init: { method?: string; body?: unknown } = {},
+): Promise {
+ if (!isConfigured())
+ throw new GameProviderUnavailable("game provider API is not configured");
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
+ try {
+ const res = await fetch(`${BASE}${path}`, {
+ method: init.method ?? "GET",
+ headers: {
+ "X-Internal-Token": TOKEN,
+ ...(init.body ? { "Content-Type": "application/json" } : {}),
+ },
+ body: init.body ? JSON.stringify(init.body) : undefined,
+ signal: controller.signal,
+ });
+
+ if (res.status === 422) {
+ // Provider validation error -- message is meant for the player.
+ const detail = await res
+ .json()
+ .then((j: { detail?: string }) => j?.detail)
+ .catch(() => undefined);
+ throw new GameProviderUserError(detail ?? "The game server rejected that.");
+ }
+ if (res.status === 404) throw new GameProviderNotFound();
+ if (res.status === 501)
+ throw new GameProviderUnavailable("operation not supported by this server");
+ if (!res.ok)
+ throw new GameProviderUnavailable(`provider API ${res.status}`);
+
+ return (await res.json()) as T;
+ } catch (err) {
+ if (err instanceof GameProviderUserError) throw err;
+ if (err instanceof GameProviderNotFound) throw err;
+ if (err instanceof GameProviderUnavailable) throw err;
+ // AbortError, DNS, connection refused -- all "the service is not there".
+ throw new GameProviderUnavailable(
+ err instanceof Error ? err.message : "provider API unreachable",
+ );
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/** The account (or provider) genuinely does not exist -- as opposed to the
+ * backend being down, which mmo-portal reports as 503. Keeping these apart is
+ * the difference between "you have no account yet" and "we cannot tell". */
+export class GameProviderNotFound extends Error {}
+
+// --- operations -------------------------------------------------------------
+
+export async function listProviders(): Promise {
+ const body = await call<{ providers: unknown[] }>("/internal/v1/providers");
+ const out: ProviderInfoT[] = [];
+ for (const raw of body.providers) {
+ const parsed = ProviderInfo(raw);
+ if (!(parsed instanceof type.errors)) out.push(parsed);
+ }
+ return out;
+}
+
+export async function getProvider(slug: string): Promise {
+ const key = providerKeyForSlug(slug);
+ if (!key) return null;
+ const all = await listProviders();
+ return all.find((p) => p.key === key) ?? null;
+}
+
+export async function getAccount(
+ slug: string,
+ accountId: string,
+): Promise {
+ const key = providerKeyForSlug(slug);
+ if (!key) return null;
+ try {
+ const raw = await call(
+ `/internal/v1/providers/${key}/accounts/${encodeURIComponent(accountId)}`,
+ );
+ const parsed = ProviderAccount(raw);
+ return parsed instanceof type.errors ? null : parsed;
+ } catch (err) {
+ if (err instanceof GameProviderNotFound) return null;
+ throw err;
+ }
+}
+
+export async function accountExists(
+ slug: string,
+ username: string,
+): Promise<{ exists: boolean; reachable: boolean }> {
+ const key = providerKeyForSlug(slug);
+ if (!key) return { exists: false, reachable: false };
+ return call<{ exists: boolean; reachable: boolean }>(
+ `/internal/v1/providers/${key}/account-exists?username=${encodeURIComponent(username)}`,
+ );
+}
+
+export async function createAccount(
+ slug: string,
+ username: string,
+ password: string,
+ email = "",
+): Promise {
+ const key = providerKeyForSlug(slug);
+ if (!key) throw new GameProviderUnavailable("no provider for this server");
+ const raw = await call(`/internal/v1/providers/${key}/accounts`, {
+ method: "POST",
+ body: { username, password, email },
+ });
+ const parsed = ProviderAccount(raw);
+ if (parsed instanceof type.errors)
+ throw new GameProviderUnavailable("provider returned an unexpected shape");
+ return parsed;
+}
+
+export async function setPassword(
+ slug: string,
+ accountId: string,
+ password: string,
+): Promise {
+ const key = providerKeyForSlug(slug);
+ if (!key) throw new GameProviderUnavailable("no provider for this server");
+ await call(
+ `/internal/v1/providers/${key}/accounts/${encodeURIComponent(accountId)}/password`,
+ { method: "POST", body: { password } },
+ );
+}
+
+export async function getCharacters(
+ slug: string,
+ accountId: string,
+): Promise[]> {
+ const key = providerKeyForSlug(slug);
+ if (!key) return [];
+ const body = await call<{ characters: Record[] }>(
+ `/internal/v1/providers/${key}/accounts/${encodeURIComponent(accountId)}/characters`,
+ );
+ return body.characters ?? [];
+}
diff --git a/server/server/internal/community/gameAccountService.ts b/server/server/internal/community/gameAccountService.ts
new file mode 100644
index 00000000..71d48898
--- /dev/null
+++ b/server/server/internal/community/gameAccountService.ts
@@ -0,0 +1,217 @@
+// Game-account orchestration: the SSO identity <-> native game account bridge.
+//
+// Drop owns identity, authorization and the link table; mmo-portal's provider
+// API owns the game DB work and the password crypto. This module is the seam
+// between them. See docs/design/0002-game-account-management.md.
+//
+// AUTHORIZATION MODEL, and it is deliberately thin: if a user can reach Drop
+// and reach the game, they may have an account. Drop membership already
+// implies the former and the game-access group already implies the latter, so
+// a third check here could only ever DISAGREE with the two that exist. Every
+// function takes a userId that the ROUTE has already authenticated; nothing
+// here re-derives permission.
+
+import prisma from "../db/database";
+import {
+ GameProviderUnavailable,
+ GameProviderUserError,
+ createAccount,
+ getAccount,
+ getCharacters,
+ getProvider,
+ isConfigured,
+ providerKeyForSlug,
+ setPassword,
+ type ProviderAccountT,
+} from "./gameAccountProvider";
+
+export interface GameAccountPanel {
+ /** False when this server has no account backend at all (Impostor), or the
+ * provider service is switched off. The UI renders NOTHING in that case --
+ * not an error, not an empty state. */
+ supported: boolean;
+ /** The backend exists but is unreachable right now. Distinct from
+ * `supported: false`: the panel should say "can't check" rather than
+ * pretend the player has no account. */
+ available: boolean;
+ linked: boolean;
+ account: ProviderAccountT | null;
+ characters: Record[] | null;
+ capabilities: { characters: boolean } | null;
+ /** Operator-facing reason the panel is degraded; never rendered to players
+ * as-is, but useful in logs and admin views. */
+ reason?: string;
+}
+
+/** Read the panel state for one user on one server. Never throws for an
+ * unreachable backend -- an outage is a normal, expected state here and the
+ * page must still render. */
+export async function getPanel(
+ userId: string,
+ serverSlug: string,
+): Promise {
+ const empty: GameAccountPanel = {
+ supported: false,
+ available: false,
+ linked: false,
+ account: null,
+ characters: null,
+ capabilities: null,
+ };
+
+ if (!isConfigured())
+ return { ...empty, reason: "provider API not configured" };
+ if (!providerKeyForSlug(serverSlug))
+ return { ...empty, reason: "no provider for this server" };
+
+ let provider;
+ try {
+ provider = await getProvider(serverSlug);
+ } catch (err) {
+ return {
+ ...empty,
+ supported: true,
+ reason: err instanceof Error ? err.message : "provider API unreachable",
+ };
+ }
+ // configured=false means the game genuinely has no account backend wired
+ // (or none at all) -- render nothing rather than a dead button.
+ if (!provider || !provider.configured)
+ return { ...empty, reason: "provider not configured" };
+
+ const link = await prisma.gameAccountLink.findUnique({
+ where: { userId_serverSlug: { userId, serverSlug } },
+ });
+
+ if (!link)
+ return {
+ supported: true,
+ available: true,
+ linked: false,
+ account: null,
+ characters: null,
+ capabilities: { characters: provider.capabilities.characters },
+ };
+
+ let account: ProviderAccountT | null = null;
+ let characters: Record[] | null = null;
+ try {
+ account = await getAccount(serverSlug, link.accountId);
+ if (account && provider.capabilities.characters) {
+ // Characters are best-effort: a character DB being down must not stop
+ // the account itself rendering.
+ characters = await getCharacters(serverSlug, link.accountId).catch(
+ () => null,
+ );
+ }
+ } catch (err) {
+ return {
+ supported: true,
+ available: false,
+ linked: true,
+ account: null,
+ characters: null,
+ capabilities: { characters: provider.capabilities.characters },
+ reason: err instanceof Error ? err.message : "provider API unreachable",
+ };
+ }
+
+ return {
+ supported: true,
+ available: true,
+ linked: true,
+ account,
+ characters,
+ capabilities: { characters: provider.capabilities.characters },
+ };
+}
+
+/** Every linked account for a user, across every server -- the
+ * /account/settings cross-game view. Provider state is fetched per server;
+ * a single unreachable backend degrades only its own entry. */
+export async function listPanelsForUser(
+ userId: string,
+): Promise> {
+ const links = await prisma.gameAccountLink.findMany({ where: { userId } });
+ const out: Record = {};
+ await Promise.all(
+ links.map(async (l) => {
+ out[l.serverSlug] = await getPanel(userId, l.serverSlug);
+ }),
+ );
+ return out;
+}
+
+export class GameAccountConflict extends Error {}
+
+/** Provision a native game account and link it to this Drop identity.
+ *
+ * ONE PER USER PER SERVER is enforced here AND by a unique index. The index
+ * is the real guard -- two concurrent provisions would otherwise both pass
+ * the pre-check and create two game accounts, of which only one could be
+ * linked. The pre-check exists to give a clean message in the common case;
+ * the catch below handles the race. */
+export async function provision(
+ userId: string,
+ serverSlug: string,
+ username: string,
+ password: string,
+ email = "",
+): Promise {
+ const existing = await prisma.gameAccountLink.findUnique({
+ where: { userId_serverSlug: { userId, serverSlug } },
+ });
+ if (existing)
+ throw new GameAccountConflict(
+ "You already have an account linked for this server.",
+ );
+
+ const provider = await getProvider(serverSlug);
+ if (!provider || !provider.configured)
+ throw new GameProviderUnavailable("This server has no account system.");
+
+ // Create in the GAME first, then link. If the link write fails we have an
+ // orphaned game account, which is recoverable (the player can be re-linked
+ // by an admin). The reverse -- link first -- would leave a link pointing at
+ // an account that does not exist, which reads as data corruption to every
+ // later call.
+ const account = await createAccount(serverSlug, username, password, email);
+
+ try {
+ await prisma.gameAccountLink.create({
+ data: {
+ userId,
+ serverSlug,
+ accountId: account.account_id,
+ accountUsername: account.username,
+ },
+ });
+ } catch {
+ // Unique index tripped: a concurrent request linked first. The game
+ // account just created is orphaned; say so plainly rather than pretending
+ // this succeeded.
+ throw new GameAccountConflict(
+ "An account was just linked for this server. The account created here " +
+ "may need cleaning up by an admin.",
+ );
+ }
+
+ return account;
+}
+
+/** Change the password on the player's own linked account. The link lookup IS
+ * the authorization: a user can only ever reach their own accountId. */
+export async function changePassword(
+ userId: string,
+ serverSlug: string,
+ password: string,
+): Promise {
+ const link = await prisma.gameAccountLink.findUnique({
+ where: { userId_serverSlug: { userId, serverSlug } },
+ });
+ if (!link)
+ throw new GameAccountConflict("You have no account linked for this server.");
+ await setPassword(serverSlug, link.accountId, password);
+}
+
+export { GameProviderUnavailable, GameProviderUserError };