feat(community): SSO to native game account bridge, phases 2 and 3
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:
parent
1e1f005f76
commit
a74ed9eded
11 changed files with 919 additions and 0 deletions
229
server/components/GameAccountPanel.vue
Normal file
229
server/components/GameAccountPanel.vue
Normal 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">
|
||||
· {{ $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">· {{ 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>
|
||||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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") }}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
34
server/server/api/v1/community/servers/[id]/account.get.ts
Normal file
34
server/server/api/v1/community/servers/[id]/account.get.ts
Normal 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);
|
||||
});
|
||||
97
server/server/api/v1/community/servers/[id]/account.post.ts
Normal file
97
server/server/api/v1/community/servers/[id]/account.post.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
|
|
@ -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.",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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:";
|
||||
|
|
|
|||
241
server/server/internal/community/gameAccountProvider.ts
Normal file
241
server/server/internal/community/gameAccountProvider.ts
Normal 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 ?? [];
|
||||
}
|
||||
217
server/server/internal/community/gameAccountService.ts
Normal file
217
server/server/internal/community/gameAccountService.ts
Normal 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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue