feat(community): SSO to native game account bridge, phases 2 and 3
Some checks failed
Server CI / Lint (push) Failing after 2m0s
Server CI / Typecheck (push) Successful in 3m28s

Adds the player-facing half of game account management: a panel on
/community/servers/[id] that shows your linked account for that server,
creates one, and changes its password.

None of these emulators speak OIDC and none will - each ships its own
login against its own auth database. So this is a bridge, not SSO: one
Drop identity tied to one native account per server, enforced by a unique
index on (userId, serverSlug). That constraint IS the feature.

Drop owns identity, authorization, the link table and the UI. The
per-game DB access and password crypto stay in mmo-portal's provider API
(SRP6 verifiers, EQEmu hashing) - code that writes into live auth DBs and
fails silently when subtly wrong.

The password never touches Drop's database; it goes straight through to
the provider, which turns it into whatever that emulator wants.

Notable details:
- GameServer.slug names the game, provider.key names the emulator. They
  agree for eqemu and daoc but wow != trinitycore, so the mapping is an
  explicit three-entry constant, not an assumption.
- Panel renders NOTHING when a server has no account backend (Impostor is
  a lobby with no identity), rather than a dead button.
- 'backend unreachable' is kept distinct from 'you have no account' - the
  latter would invite a duplicate during an outage.
- Provision creates in the game first, then links. The reverse would
  leave a link pointing at a nonexistent account, which reads as
  corruption to every later call.
- Authorization is just an authenticated Drop session with the ACL: Drop
  access and game access already imply each other, so a third gate could
  only disagree with them.

Typecheck clean.
This commit is contained in:
wdunn001 2026-08-12 17:04:33 -04:00
parent 1e1f005f76
commit a74ed9eded
11 changed files with 919 additions and 0 deletions

View file

@ -0,0 +1,229 @@
<!--
The SSO identity <-> native game account bridge, player-facing.
Used on /community/servers/[id] and (M6 phase 5) on /account/settings for
the cross-game view, which is why it takes a serverId + slug rather than
reading the route.
RENDERS NOTHING when the server has no account backend (`supported: false`)
-- Impostor is a lobby with no identity at all, and a game can be in the
registry before its DB is wired. A dead panel would be worse than no panel.
The password never reaches Drop's database. It is posted straight through to
the provider service, which turns it into whatever that emulator's auth DB
wants. Drop stores only the link.
-->
<template>
<div v-if="panel?.supported" class="flex flex-col gap-y-2">
<h2 class="text-lg font-bold font-display text-zinc-100">
{{ $t("community.gameAccount.title") }}
</h2>
<!-- Backend down. Deliberately distinct from "you have no account": we
genuinely cannot tell, and saying otherwise would invite a player to
create a duplicate. -->
<div
v-if="!panel.available"
class="rounded-lg bg-zinc-800/50 p-4 text-sm text-zinc-400"
>
{{ $t("community.gameAccount.unavailable") }}
</div>
<!-- Linked -->
<div
v-else-if="panel.linked && panel.account"
class="rounded-lg bg-zinc-800/50 p-4 flex flex-col gap-y-3"
>
<div class="flex items-center justify-between flex-wrap gap-2">
<div>
<p class="text-sm font-semibold text-zinc-100">
{{ panel.account.username }}
</p>
<p class="text-xs text-zinc-500">
<span v-if="panel.account.created_at">
{{ $t("community.gameAccount.created") }}
{{ new Date(panel.account.created_at).toLocaleDateString() }}
</span>
<span v-if="panel.account.last_login">
&middot; {{ $t("community.gameAccount.lastLogin") }}
{{ new Date(panel.account.last_login).toLocaleDateString() }}
</span>
</p>
</div>
<div class="flex items-center gap-x-2">
<span
v-if="panel.account.banned"
class="rounded bg-red-900/60 px-2 py-0.5 text-xs text-red-200"
>{{ $t("community.gameAccount.banned") }}</span
>
<span
v-else-if="panel.account.locked"
class="rounded bg-amber-900/60 px-2 py-0.5 text-xs text-amber-200"
>{{ $t("community.gameAccount.locked") }}</span
>
<button
type="button"
class="rounded-md bg-zinc-700 px-3 py-1.5 text-sm font-semibold text-zinc-100 hover:bg-zinc-600 duration-200"
@click="mode = mode === 'password' ? null : 'password'"
>
{{ $t("community.gameAccount.changePassword") }}
</button>
</div>
</div>
<ul
v-if="panel.characters?.length"
class="flex flex-wrap gap-2 border-t border-zinc-700/50 pt-3"
>
<li
v-for="c in panel.characters"
:key="String(c.guid ?? c.name)"
class="rounded bg-zinc-900/60 px-2 py-1 text-xs text-zinc-300"
>
{{ c.name }}
<span v-if="c.level" class="text-zinc-500">&middot; {{ c.level }}</span>
</li>
</ul>
</div>
<!-- Not linked yet -->
<div v-else class="rounded-lg bg-zinc-800/50 p-4 flex flex-col gap-y-3">
<p class="text-sm text-zinc-300">
{{ $t("community.gameAccount.noAccount") }}
</p>
<button
type="button"
class="w-fit rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 duration-200"
@click="mode = mode === 'provision' ? null : 'provision'"
>
{{ $t("community.gameAccount.create") }}
</button>
</div>
<!-- Form: shared by both actions. Username only matters for provisioning;
the password field is identical either way. -->
<form
v-if="mode"
class="rounded-lg bg-zinc-900/60 p-4 flex flex-col gap-y-3"
@submit.prevent="submit"
>
<div v-if="mode === 'provision'" class="flex flex-col gap-y-1">
<label class="text-xs font-semibold text-zinc-400" for="ga-username">{{
$t("community.gameAccount.username")
}}</label>
<input
id="ga-username"
v-model="username"
autocomplete="off"
class="rounded-md bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div class="flex flex-col gap-y-1">
<label class="text-xs font-semibold text-zinc-400" for="ga-password">{{
$t("community.gameAccount.password")
}}</label>
<input
id="ga-password"
v-model="password"
type="password"
autocomplete="new-password"
class="rounded-md bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:ring-1 focus:ring-blue-500"
/>
<!-- Said plainly because it is the single most confusing thing about
this feature: the game password is NOT the SSO password. -->
<p class="text-xs text-zinc-500">
{{ $t("community.gameAccount.passwordHint") }}
</p>
</div>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<p v-if="notice" class="text-sm text-green-400">{{ notice }}</p>
<div class="flex items-center gap-x-2">
<button
type="submit"
:disabled="busy"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 disabled:opacity-50 duration-200"
>
{{ busy ? $t("common.loading") : $t("common.save") }}
</button>
<button
type="button"
class="rounded-md bg-zinc-800 px-4 py-2 text-sm font-semibold text-zinc-300 hover:bg-zinc-700 duration-200"
@click="reset"
>
{{ $t("common.cancel") }}
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{ serverId: string }>();
interface Panel {
supported: boolean;
available: boolean;
linked: boolean;
account: {
account_id: string;
username: string;
created_at?: string | null;
last_login?: string | null;
locked: boolean;
banned: boolean;
} | null;
characters: Record<string, unknown>[] | null;
}
const mode = ref<"provision" | "password" | null>(null);
const username = ref("");
const password = ref("");
const error = ref("");
const notice = ref("");
const busy = ref(false);
const { data: panel, refresh } = await useFetch<Panel>(
() => `/api/v1/community/servers/${props.serverId}/account`,
);
function reset() {
mode.value = null;
username.value = "";
password.value = "";
error.value = "";
notice.value = "";
}
async function submit() {
error.value = "";
notice.value = "";
busy.value = true;
try {
await $fetch(`/api/v1/community/servers/${props.serverId}/account`, {
method: "POST",
body: {
action: mode.value,
username: username.value,
password: password.value,
},
});
notice.value = "";
reset();
await refresh();
} catch (e) {
// statusMessage carries the provider's own wording for a rejected name or
// password (422) -- surfaced verbatim, because the emulator is the
// authority on its own rules.
const err = e as { statusMessage?: string; data?: { statusMessage?: string } };
error.value =
err?.data?.statusMessage ??
err?.statusMessage ??
"Something went wrong.";
} finally {
busy.value = false;
}
}
</script>

View file

@ -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…",

View file

@ -100,6 +100,12 @@
{{ $t("community.servers.viewTitle") }}
</a>
<!-- Renders nothing unless this server actually has an account backend
(see GameAccountPanel.vue). Placed after "how to join" on purpose:
the account is the thing you need BEFORE the join instructions are
any use. -->
<GameAccountPanel :server-id="server.id" />
<div v-if="history.length > 0" class="flex flex-col gap-y-2">
<h2 class="text-lg font-bold font-display text-zinc-100">
{{ $t("community.servers.recentChecks") }}

View file

@ -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");

View file

@ -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])
}

View file

@ -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);
});

View file

@ -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;
}
});

View file

@ -68,6 +68,12 @@ export const userACLDescriptions: ObjectFromList<typeof userACLs> = {
"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.",
};

View file

@ -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:";

View file

@ -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<string, string> = {
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<T>(
path: string,
init: { method?: string; body?: unknown } = {},
): Promise<T> {
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<ProviderInfoT[]> {
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<ProviderInfoT | null> {
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<ProviderAccountT | null> {
const key = providerKeyForSlug(slug);
if (!key) return null;
try {
const raw = await call<unknown>(
`/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<ProviderAccountT> {
const key = providerKeyForSlug(slug);
if (!key) throw new GameProviderUnavailable("no provider for this server");
const raw = await call<unknown>(`/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<void> {
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<Record<string, unknown>[]> {
const key = providerKeyForSlug(slug);
if (!key) return [];
const body = await call<{ characters: Record<string, unknown>[] }>(
`/internal/v1/providers/${key}/accounts/${encodeURIComponent(accountId)}/characters`,
);
return body.characters ?? [];
}

View file

@ -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<string, unknown>[] | 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<GameAccountPanel> {
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<string, unknown>[] | 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<Record<string, GameAccountPanel>> {
const links = await prisma.gameAccountLink.findMany({ where: { userId } });
const out: Record<string, GameAccountPanel> = {};
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<ProviderAccountT> {
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<void> {
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 };