diff --git a/docs/design/0002-game-account-management.md b/docs/design/0002-game-account-management.md
index 76d9487b..04f45df8 100644
--- a/docs/design/0002-game-account-management.md
+++ b/docs/design/0002-game-account-management.md
@@ -244,8 +244,37 @@ anything, including scripts.
would only create disagreement between two existing ones.
- **One account per user per server.** Unique on `(userId, serverSlug)`.
+## Status
+
+| Phase | State |
+|---|---|
+| 1. provider-svc `/internal/v1/*` | **DONE, deployed.** mmo-portal `dd31f44`. Verified live against the real TrinityCore and EQEmu auth DBs; 401 without a token; router not mounted at all when `INTERNAL_API_TOKEN` is unset. |
+| 2. Drop read path | **DONE.** `GameAccountLink`, `account.get.ts`, `GameAccountPanel.vue`. |
+| 3. Drop write path | **DONE.** `account.post.ts` — provision + password change. |
+| 4. Admin dashboard | not started |
+| 5. `/account/settings` cross-game view | not started — the panel component already takes a `serverId`, so it is a loop over links, not new UI |
+| 6. Retire mmo-portal account routes | not started, and deliberately last |
+
+### Things learned building 1-3, which the doc had wrong
+
+- **`slug != key` for WoW.** Drop names the game, the provider names the
+ emulator. Now an explicit three-entry map in `gameAccountProvider.ts`,
+ asserted by mmo-portal's tests.
+- **`daoc` is unconfigured on the live portal.** The stack sets `TC_*` and
+ `EQEMU_*` but no `DAOC_AUTH_DB_HOST`, so OpenDAoC reports
+ `configured=false` and its panel correctly renders nothing. Real gap, not
+ a bug.
+- **Prisma here uses the multi-file schema.** `prisma generate --schema
+ prisma/schema.prisma` silently produces a client with NO models; it must
+ point at the `prisma/` FOLDER.
+- **The fork's build needs real git history.** `postinstall` runs `git
+ rev-parse --short HEAD`, so a `git archive` tarball fails the build. Clone
+ (from Forgejo on .88) rather than export.
+
## Open
- **Does `LinkedAuthMec.credentials` carry the Authentik `sub`?** Determines
whether the existing mmo-portal links migrate by join or by one-time
- re-confirmation. Verify before building phase 2; do not assume.
+ re-confirmation. Not yet answered — phases 2 and 3 were built without
+ depending on it, so an empty panel is the current, correct behaviour for a
+ user whose account predates the bridge.
diff --git a/server/components/GameAccountPanel.vue b/server/components/GameAccountPanel.vue
index 95623f4d..25220995 100644
--- a/server/components/GameAccountPanel.vue
+++ b/server/components/GameAccountPanel.vue
@@ -91,13 +91,26 @@
{{ $t("community.gameAccount.noAccount") }}
-
+
+
+
+
+
+ {{ $t("community.gameAccount.recoverHint") }}
+
+
+ {{ $t("community.gameAccount.manageAll") }}
+
diff --git a/server/server/api/v1/community/servers/[id]/account.post.ts b/server/server/api/v1/community/servers/[id]/account.post.ts
index 9467fab5..f263c63c 100644
--- a/server/server/api/v1/community/servers/[id]/account.post.ts
+++ b/server/server/api/v1/community/servers/[id]/account.post.ts
@@ -7,6 +7,7 @@ import {
GameProviderUserError,
changePassword,
provision,
+ recoverExistingLink,
} from "~/server/internal/community/gameAccountService";
// Provision a game account, or change the password on the existing one.
@@ -22,9 +23,9 @@ import {
// wants (an SRP6 verifier, EQEmu's own hash). Drop stores only the link.
const AccountWrite = type({
- action: "'provision' | 'password'",
+ action: "'provision' | 'password' | 'recover'",
"username?": "string",
- password: "string",
+ "password?": "string",
"email?": "string",
});
@@ -51,11 +52,18 @@ export default defineEventHandler(async (h3) => {
// 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.",
- });
+ if (body.action !== "recover") {
+ if (!body.password)
+ throw createError({
+ statusCode: 400,
+ statusMessage: "A password is required.",
+ });
+ if (body.password.length < 6)
+ throw createError({
+ statusCode: 400,
+ statusMessage: "Password must be at least 6 characters.",
+ });
+ }
try {
if (body.action === "provision") {
@@ -69,13 +77,18 @@ export default defineEventHandler(async (h3) => {
user.id,
server.slug,
username,
- body.password,
+ body.password!,
body.email ?? "",
);
return { ok: true, account };
}
- await changePassword(user.id, server.slug, body.password);
+ if (body.action === "recover") {
+ const account = await recoverExistingLink(user.id, server.slug);
+ return { ok: true, account, recovered: true };
+ }
+
+ await changePassword(user.id, server.slug, body.password!);
return { ok: true };
} catch (err) {
// Conflict: already linked, or a concurrent provision won the race.
diff --git a/server/server/internal/community/gameAccountProvider.ts b/server/server/internal/community/gameAccountProvider.ts
index 7a55223a..7142f1f1 100644
--- a/server/server/internal/community/gameAccountProvider.ts
+++ b/server/server/internal/community/gameAccountProvider.ts
@@ -197,6 +197,24 @@ export async function accountExists(
);
}
+export async function listAccounts(
+ slug: string,
+ limit = 500,
+): Promise {
+ const key = providerKeyForSlug(slug);
+ if (!key) return [];
+ const capped = Math.max(1, Math.min(limit, 5000));
+ const body = await call<{ accounts: unknown[] }>(
+ `/internal/v1/providers/${key}/accounts?limit=${capped}`,
+ );
+ const out: ProviderAccountT[] = [];
+ for (const raw of body.accounts ?? []) {
+ const parsed = ProviderAccount(raw);
+ if (!(parsed instanceof type.errors)) out.push(parsed);
+ }
+ return out;
+}
+
export async function createAccount(
slug: string,
username: string,
diff --git a/server/server/internal/community/gameAccountService.ts b/server/server/internal/community/gameAccountService.ts
index 71d48898..091cb665 100644
--- a/server/server/internal/community/gameAccountService.ts
+++ b/server/server/internal/community/gameAccountService.ts
@@ -20,6 +20,7 @@ import {
getCharacters,
getProvider,
isConfigured,
+ listAccounts,
providerKeyForSlug,
setPassword,
type ProviderAccountT,
@@ -144,6 +145,10 @@ export async function listPanelsForUser(
export class GameAccountConflict extends Error {}
+function normalize(value: string | null | undefined): string {
+ return (value ?? "").trim().toLowerCase();
+}
+
/** 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
@@ -199,6 +204,70 @@ export async function provision(
return account;
}
+/** Recover a missing Drop<->game link by matching an existing account that is
+ * provably the same person (same username and same email). This is for
+ * migration drift / accidental row loss, not for claiming arbitrary names. */
+export async function recoverExistingLink(
+ userId: string,
+ serverSlug: string,
+): 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.");
+
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { username: true, email: true },
+ });
+ if (!user) throw new GameAccountConflict("User not found.");
+
+ const all = await listAccounts(serverSlug, 1000);
+ const sameUsername = all.filter(
+ (a) => normalize(a.username) === normalize(user.username),
+ );
+ const exactMatches = sameUsername.filter(
+ (a) => normalize(a.email) !== "" && normalize(a.email) === normalize(user.email),
+ );
+
+ if (exactMatches.length === 0) {
+ throw new GameAccountConflict(
+ "No recoverable account match was found. We only auto-link when both " +
+ "username and email match your Drop profile exactly.",
+ );
+ }
+ if (exactMatches.length > 1) {
+ throw new GameAccountConflict(
+ "More than one matching game account was found. Ask an admin to relink manually.",
+ );
+ }
+
+ const match = exactMatches[0];
+ try {
+ await prisma.gameAccountLink.create({
+ data: {
+ userId,
+ serverSlug,
+ accountId: match.account_id,
+ accountUsername: match.username,
+ },
+ });
+ } catch {
+ throw new GameAccountConflict(
+ "An account was just linked for this server. Refresh and try again.",
+ );
+ }
+
+ return match;
+}
+
/** 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(