diff --git a/server/components/GameAccountPanel.vue b/server/components/GameAccountPanel.vue new file mode 100644 index 00000000..95623f4d --- /dev/null +++ b/server/components/GameAccountPanel.vue @@ -0,0 +1,229 @@ + + + + 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 };