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();