Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions resources/hmp-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 51 additions & 15 deletions resources/hmp-core/server/core.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
HmpCore,
HmpCoreAccount,
HmpCoreCharacter,
HmpCoreGroup,
HmpCoreIdentityProvider,
Expand All @@ -14,6 +15,7 @@ const IDENTITY_FIELDS: Readonly<Record<string, string>> = 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);
Expand Down Expand Up @@ -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<void> {
if (!events || typeof events.emit !== "function") return;
try { await events.emit(name, payload); }
Expand Down Expand Up @@ -102,38 +108,66 @@ 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;
}
}
return removed;
}

async function resolveIdentity(player: Player): Promise<HmpCorePrincipal> {
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<string, unknown>)[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<string, unknown>)[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<HmpCoreAccount> {
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<Player> | null {
try { return sessionsByPlayer.get(playerId(player)) || null; }
catch (_) { return null; }
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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")),
Expand Down
3 changes: 2 additions & 1 deletion resources/hmp-core/server/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface Repository {
findIdentity(provider: string, subject: string): Promise<IdentityResult | null>;
findOrCreateAccount(principal: HmpCorePrincipal, displayName: string): Promise<HmpCoreAccount>;
linkIdentity(accountId: number, principal: HmpCorePrincipal): Promise<boolean>;
listIdentities(accountId: number): Promise<HmpCorePrincipal[]>;
findAccountById(id: number): Promise<HmpCoreAccount | null>;
touchAccount(id: number, displayName: string): Promise<number>;
listCharacters(accountId: number): Promise<HmpCoreCharacter[]>;
Expand All @@ -56,7 +57,7 @@ export interface CoreOptions {
migrations?: HmpMySQLMigration[];
events?: CoreEvents | null;
listPlayers?: () => Player[];
logger?: Pick<HmpLogger, "error">;
logger?: Pick<HmpLogger, "error"> & Partial<Pick<HmpLogger, "warn">>;
config?: Partial<CoreConfig>;
}

Expand Down
9 changes: 9 additions & 0 deletions resources/hmp-core/server/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ function createRepository(database: Database): Repository {
return true;
}

async function listIdentities(accountId: number): Promise<HmpCorePrincipal[]> {
const rows = await database.query<AccountRow[]>(
"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<HmpCoreAccount | null> => accountRow(await database.single<AccountRow>(
"SELECT id, display_name, created_at, last_seen_at FROM hmp_accounts WHERE id = ?",
[id],
Expand Down Expand Up @@ -232,6 +240,7 @@ function createRepository(database: Database): Repository {
findIdentity,
findOrCreateAccount,
linkIdentity,
listIdentities,
findAccountById,
touchAccount,
listCharacters,
Expand Down
66 changes: 64 additions & 2 deletions resources/hmp-core/test/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -98,6 +104,7 @@ function memoryRepository() {

function setup(overrides: Partial<CoreConfig> = {}) {
const emitted: RecordedEvent[] = [];
const warnings: string[] = [];
const migrated: Array<{ resource: string; list: HmpMySQLMigration[] }> = [];
const repository = memoryRepository();
const database = {
Expand All @@ -110,7 +117,7 @@ function setup(overrides: Partial<CoreConfig> = {}) {
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,
Expand All @@ -122,7 +129,7 @@ function setup(overrides: Partial<CoreConfig> = {}) {
...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 () => {
Expand All @@ -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();
Expand Down
Loading