From 2c1595745ddfdee7223c818135d7df16f8242253 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Mon, 12 Oct 2026 22:13:18 -0400 Subject: [PATCH] hmp-core: Match returning players by any announced client ID A client announces only the IDs it detected on that launch, so Steam or Discord can be missing from one session to the next. hmp-core keyed the account on the first ID in identityOrder and stored no others, so a session that arrived with only a hardware ID got a new, empty account while the player's characters stayed on the old one. Every announced ID is now linked to the account and later sessions match through any of them. An account first created from a lower-ranked ID is adopted when a higher-ranked one appears, unless that account already holds a different ID of that kind, which means a second player on a shared machine. IDs linked to another account are never moved; a warning names both accounts so staff can reconcile them. Verified providers still match on their own principal only. --- CHANGELOG.md | 9 ++++ resources/hmp-core/README.md | 8 +++ resources/hmp-core/server/core.ts | 66 +++++++++++++++++++------ resources/hmp-core/server/internal.ts | 3 +- resources/hmp-core/server/repository.ts | 9 ++++ resources/hmp-core/test/core.test.ts | 66 ++++++++++++++++++++++++- 6 files changed, 143 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 483b429..faa9c92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ pre-`1.0.0` policy documented in [COMPATIBILITY.md](COMPATIBILITY.md#version-pol ## [Unreleased] +### Fixed + +- `hmp-core` no longer gives a returning player a new, empty account when their client announces + fewer identifiers than before, such as only a hardware ID because Steam or Discord wasn't detected. + Accounts were keyed on the first identifier in `identityOrder` alone. Every announced identifier is + now linked to the account, and later sessions match through any of them. Identifiers already linked + to a different account are not moved; the server logs a warning naming both accounts so staff can + reconcile them. + ## [0.8.1] - 2026-10-10 ### Changed diff --git a/resources/hmp-core/README.md b/resources/hmp-core/README.md index 4f5e68d..ecd7fe3 100644 --- a/resources/hmp-core/README.md +++ b/resources/hmp-core/README.md @@ -14,6 +14,14 @@ only exposes client-announced Steam, Discord and hardware identifiers. According fallback stores them as `client-steam`, `client-discord` or `client-hardware` with trust level `asserted`. They must not be treated as proof for sensitive authorization. +A client announces only the identifiers it detected on that launch, so Steam or Discord can be missing +from one session to the next. Every announced identifier is linked to the account, and a later session +is matched through any of them, ranked by `identityOrder`. An identifier already linked to another +account is never moved; the server logs a warning naming both account IDs instead. If a new +higher-ranked identifier arrives with one that matches an account that already holds a different +identifier of that kind, as with two Steam users on one machine, the player gets a separate account. +Remove `hardwareId` from `identityOrder` to stop matching and linking hardware IDs. + A later MafiaHub authentication resource can register a higher-priority server-validated provider: ```js diff --git a/resources/hmp-core/server/core.ts b/resources/hmp-core/server/core.ts index bafcea4..1a26a01 100644 --- a/resources/hmp-core/server/core.ts +++ b/resources/hmp-core/server/core.ts @@ -1,5 +1,6 @@ import type { HmpCore, + HmpCoreAccount, HmpCoreCharacter, HmpCoreGroup, HmpCoreIdentityProvider, @@ -14,6 +15,7 @@ const IDENTITY_FIELDS: Readonly> = Object.freeze({ discordId: "client-discord", hardwareId: "client-hardware", }); +const CLIENT_PROVIDER = "hmp-client-identity"; function fail(code: string, message: string): Error & { code: string } { const error = new Error(message); @@ -74,6 +76,10 @@ function createCore(options: CoreOptions) { if (logger && typeof logger.error === "function") logger.error(`${message}: ${error instanceof Error ? error.message : String(error)}`); } + function logWarn(message: string): void { + if (logger && typeof logger.warn === "function") logger.warn(message); + } + async function emit(name: string, payload: unknown): Promise { if (!events || typeof events.emit !== "function") return; try { await events.emit(name, payload); } @@ -102,7 +108,7 @@ function createCore(options: CoreOptions) { function removeIdentityProvidersForResource(resource: string): number { let removed = 0; for (const [name, provider] of providers) { - if (provider.resource === resource && name !== "hmp-client-identity") { + if (provider.resource === resource && name !== CLIENT_PROVIDER) { providers.delete(name); removed += 1; } @@ -110,30 +116,58 @@ function createCore(options: CoreOptions) { return removed; } - async function resolveIdentity(player: Player): Promise { + async function resolveIdentity(player: Player): Promise<{ principal: HmpCorePrincipal; provider: string }> { const ordered = [...providers.values()].sort((a, b) => b.priority - a.priority || a.name.localeCompare(b.name)); for (const provider of ordered) { const result = await provider.resolve(player); - if (result) return normalizePrincipal(result, provider.trust); + if (result) return { principal: normalizePrincipal(result, provider.trust), provider: provider.name }; } throw fail("HMP_CORE_IDENTITY_MISSING", "No usable identity was supplied for this player"); } + function clientIdentities(player: Player): HmpCorePrincipal[] { + const identities: HmpCorePrincipal[] = []; + for (const field of config.identityOrder || Object.keys(IDENTITY_FIELDS)) { + const provider = IDENTITY_FIELDS[field]; + const subject = String((player as unknown as Record)[field] ?? "").trim(); + if (provider && /^\d+$/.test(subject)) identities.push({ provider, subject, trust: "asserted" }); + } + return identities; + } + registerProvider({ - name: "hmp-client-identity", + name: CLIENT_PROVIDER, resource: "hmp-core", priority: -1000, trust: "asserted", - resolve(player: Player) { - for (const field of config.identityOrder || Object.keys(IDENTITY_FIELDS)) { - const provider = IDENTITY_FIELDS[field]; - const subject = String((player as unknown as Record)[field] ?? "").trim(); - if (provider && /^\d+$/.test(subject)) return { provider, subject, trust: "asserted" }; - } - return null; - }, + resolve: (player: Player) => clientIdentities(player)[0] || null, }); + // A client announces only the IDs it detected on that launch, so Steam or Discord can be missing + // from one session to the next. The account is found through any announced ID and remembers them all. + async function accountForClientIdentities(identities: HmpCorePrincipal[], displayName: string): Promise { + const owners = await Promise.all(identities.map((identity) => repository.findIdentity(identity.provider, identity.subject))); + let match = owners.findIndex(Boolean); + if (match > 0) { + // The IDs ranked above the match are new. An account that already holds a different ID of one + // of those kinds belongs to someone else on a shared machine, not to this player. + const held = new Set((await repository.listIdentities(owners[match]!.account.id)).map((identity) => identity.provider)); + if (identities.slice(0, match).some((identity) => held.has(identity.provider))) match = -1; + } + const primary = Math.max(match, 0); + const account = await repository.findOrCreateAccount(identities[primary], displayName); + for (const [index, identity] of identities.entries()) { + const owner = owners[index]; + if (owner && owner.account.id !== account.id) { + logWarn(`[hmp-core] Account ${account.id} shares a ${identity.provider} ID with account ${owner.account.id}; the accounts were not merged`); + } else if (!owner && index !== primary) { + try { await repository.linkIdentity(account.id, identity); } + catch (error) { logError(`[hmp-core] could not link ${identity.provider} to account ${account.id}`, error); } + } + } + return account; + } + function getSession(player: Player | number): HmpCoreSession | null { try { return sessionsByPlayer.get(playerId(player)) || null; } catch (_) { return null; } @@ -161,9 +195,11 @@ function createCore(options: CoreOptions) { const token = Symbol(`connect:${id}`); attempts.set(id, token); try { - const principal = await resolveIdentity(player); + const { principal, provider } = await resolveIdentity(player); const displayName = String(player.nickname || `Player ${id}`).trim().slice(0, 80) || `Player ${id}`; - const account = await repository.findOrCreateAccount(principal, displayName); + const account = provider === CLIENT_PROVIDER + ? await accountForClientIdentities(clientIdentities(player), displayName) + : await repository.findOrCreateAccount(principal, displayName); if (attempts.get(id) !== token) return null; const existing = [...sessionsByPlayer.values()].filter((session) => session.account.id === account.id && session.playerId !== id); @@ -385,7 +421,7 @@ function createCore(options: CoreOptions) { } const status = (): HmpCoreStatus => Object.freeze({ state, lastError, sessions: sessionsByPlayer.size, identityProviders: providers.size, uptimeMs: Date.now() - startedAt }); - const identity = Object.freeze({ register: registerProvider, unregister: unregisterProvider, list: () => [...providers.values()].map(({ resolve, ...entry }) => entry), resolve: resolveIdentity }); + const identity = Object.freeze({ register: registerProvider, unregister: unregisterProvider, list: () => [...providers.values()].map(({ resolve, ...entry }) => entry), resolve: async (player: Player) => (await resolveIdentity(player)).principal }); const accounts = Object.freeze({ getByPlayer: (player: Player | number) => getSession(player)?.account || null, getById: (id: number) => repository.findAccountById(positiveId(id, "accountId")), diff --git a/resources/hmp-core/server/internal.ts b/resources/hmp-core/server/internal.ts index 2154b23..f056a64 100644 --- a/resources/hmp-core/server/internal.ts +++ b/resources/hmp-core/server/internal.ts @@ -31,6 +31,7 @@ export interface Repository { findIdentity(provider: string, subject: string): Promise; findOrCreateAccount(principal: HmpCorePrincipal, displayName: string): Promise; linkIdentity(accountId: number, principal: HmpCorePrincipal): Promise; + listIdentities(accountId: number): Promise; findAccountById(id: number): Promise; touchAccount(id: number, displayName: string): Promise; listCharacters(accountId: number): Promise; @@ -56,7 +57,7 @@ export interface CoreOptions { migrations?: HmpMySQLMigration[]; events?: CoreEvents | null; listPlayers?: () => Player[]; - logger?: Pick; + logger?: Pick & Partial>; config?: Partial; } diff --git a/resources/hmp-core/server/repository.ts b/resources/hmp-core/server/repository.ts index 0b1fdfb..721ff06 100644 --- a/resources/hmp-core/server/repository.ts +++ b/resources/hmp-core/server/repository.ts @@ -139,6 +139,14 @@ function createRepository(database: Database): Repository { return true; } + async function listIdentities(accountId: number): Promise { + const rows = await database.query( + "SELECT provider, subject, trust_level FROM hmp_account_identities WHERE account_id = ?", + [accountId], + ); + return rows.map((row) => ({ provider: String(row.provider), subject: String(row.subject), trust: row.trust_level ?? "asserted" })); + } + const findAccountById = async (id: number): Promise => accountRow(await database.single( "SELECT id, display_name, created_at, last_seen_at FROM hmp_accounts WHERE id = ?", [id], @@ -232,6 +240,7 @@ function createRepository(database: Database): Repository { findIdentity, findOrCreateAccount, linkIdentity, + listIdentities, findAccountById, touchAccount, listCharacters, diff --git a/resources/hmp-core/test/core.test.ts b/resources/hmp-core/test/core.test.ts index dd7f99f..59ef0aa 100644 --- a/resources/hmp-core/test/core.test.ts +++ b/resources/hmp-core/test/core.test.ts @@ -59,6 +59,12 @@ function memoryRepository() { identities.set(key, { accountId, trust: principal.trust }); return true; }, + async listIdentities(accountId: number) { + return [...identities.entries()].filter(([, link]) => link.accountId === accountId).map(([key, link]) => { + const [provider, subject] = key.split(":"); + return { provider, subject, trust: link.trust }; + }); + }, async findAccountById(id: number) { return accounts.get(id) || null; }, async touchAccount(id: number, displayName: string) { const account = accounts.get(id); @@ -98,6 +104,7 @@ function memoryRepository() { function setup(overrides: Partial = {}) { const emitted: RecordedEvent[] = []; + const warnings: string[] = []; const migrated: Array<{ resource: string; list: HmpMySQLMigration[] }> = []; const repository = memoryRepository(); const database = { @@ -110,7 +117,7 @@ function setup(overrides: Partial = {}) { migrations, events: { emit: (name: string, payload: unknown) => emitted.push({ name, payload }) }, listPlayers: () => [], - logger: { error: () => true }, + logger: { error: () => true, warn: (message: unknown) => warnings.push(String(message)) > 0 }, config: { maxCharacters: 2, autoSelectSingleCharacter: false, @@ -122,7 +129,7 @@ function setup(overrides: Partial = {}) { ...overrides, }, }); - return { core, repository, emitted, migrated }; + return { core, repository, emitted, migrated, warnings }; } test("starts with the resource-owned schema and creates an asserted account session", async () => { @@ -143,6 +150,61 @@ test("starts with the resource-owned schema and creates an asserted account sess assert.ok(emitted.some((event) => event.name === "hmp:session:ready")); }); +test("keeps the account when a later session announces fewer client identifiers", async () => { + const { core } = setup(); + await core.start(); + const full = player(1, "Poppy", { steamId: "100", discordId: "200", hardwareId: "300" }); + const account = (await core.connect(full))?.account; + assert.ok(account); + const character = await core.characters.create(full, { name: "Poppy Sweeting" }); + await core.disconnect(full); + + for (const ids of [{ hardwareId: "300" }, { discordId: "200" }, { discordId: "200", hardwareId: "300" }]) { + const partial = player(2, "Player", ids); + assert.strictEqual((await core.connect(partial))?.account.id, account.id, `matched by ${Object.keys(ids).join("+")}`); + assert.deepStrictEqual((await core.characters.list(partial)).map((item) => item.id), [character.id]); + await core.disconnect(partial); + } +}); + +test("adopts an account first created from a lower-ranked identifier", async () => { + const { core, warnings } = setup(); + await core.start(); + const hardwareOnly = player(1, "Player", { hardwareId: "300" }); + const account = (await core.connect(hardwareOnly))?.account; + assert.ok(account); + await core.disconnect(hardwareOnly); + + const withSteam = player(2, "Poppy", { steamId: "100", hardwareId: "300" }); + const session = await core.connect(withSteam); + assert.strictEqual(session?.account.id, account.id); + assert.deepStrictEqual(session.principal, { provider: "client-steam", subject: "100", trust: "asserted" }); + await core.disconnect(withSteam); + + const steamOnly = player(3, "Poppy", { steamId: "100" }); + assert.strictEqual((await core.connect(steamOnly))?.account.id, account.id); + assert.deepStrictEqual(warnings, []); +}); + +test("keeps players who share a machine on separate accounts", async () => { + const { core, warnings } = setup(); + await core.start(); + const first = player(1, "First", { steamId: "100", hardwareId: "300" }); + const firstAccount = (await core.connect(first))?.account; + assert.ok(firstAccount); + await core.disconnect(first); + + const sibling = player(2, "Sibling", { steamId: "101", hardwareId: "300" }); + const siblingAccount = (await core.connect(sibling))?.account; + assert.ok(siblingAccount); + assert.notStrictEqual(siblingAccount.id, firstAccount.id); + assert.deepStrictEqual(warnings, [`[hmp-core] Account ${siblingAccount.id} shares a client-hardware ID with account ${firstAccount.id}; the accounts were not merged`]); + await core.disconnect(sibling); + + const back = player(3, "First", { steamId: "100", hardwareId: "300" }); + assert.strictEqual((await core.connect(back))?.account.id, firstAccount.id); +}); + test("manages character slots, scoped groups and JSON metadata", async () => { const { core, emitted } = setup(); await core.start();