feat(core): an account is an email address, and Steam is a connection

Signing in with Steam used to create the account. That made a second Steam
account a second person, and it made losing a Steam account lose everything
attached to it — the boxes, the team, the billing history.

Invert it. A user comes into existence by verifying an email address and
nothing else; a Steam account hangs off a user that already exists, capped at
four. Signing in with Steam resolves an account and refuses when there is
none, so the accounts made before this keep working — they already have the
connection this looks for — while nothing new is created behind a persona.

The cap lives here rather than in the schema because a unique index cannot
count the rows sharing a foreign key. The email column gains a partial unique
index instead, which is the constraint that can be expressed, and the address
is trimmed and lower-cased at the edge so two spellings are not two accounts.
This commit is contained in:
Wanjohi
2026-09-05 00:01:15 +03:00
parent 4eff67a11a
commit da65cca4f2
3 changed files with 448 additions and 9 deletions
+200
View File
@@ -0,0 +1,200 @@
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import { testDb } from '../db/test.js';
import { Identifier } from '../id.js';
import { Identity } from './identity.js';
import { User } from './index.js';
import { LinkedAccount } from './linked-account.js';
const sql = testDb();
const createdUserIDs: string[] = [];
function steamID(n: number): string {
return String(76561197960299000n + BigInt(n));
}
function email(n: number): string {
return `identity-fixture-${n}@example.test`;
}
function track(userID: string) {
createdUserIDs.push(userID);
return userID;
}
async function cleanup() {
createdUserIDs.length = 0;
// By fixture shape rather than by tracked id: a test that throws before it
// records the row it made would otherwise leave one behind, and the next
// run would read it as an account that already existed.
await sql`delete from "user" where email like 'identity-fixture-%@example.test'`;
await sql`
delete from "user" u
where exists (
select 1 from linked_account l
where l.user_id = u.id
and l.provider = 'steam'
and l.provider_account_id like '765611979602990%'
)
`;
}
async function countUsers(): Promise<number> {
const rows = await sql`select count(*)::int as n from "user"`;
return rows[0]!.n as number;
}
/** A user as the database holds them today: made by Steam, with no email. */
async function legacySteamUser(n: number) {
const userID = track(Identifier.ascending('user'));
await User.create({
id: userID,
name: `legacy-${n}`,
email: undefined,
emailVerified: false,
image: null
});
const linkID = Identifier.ascending('linkedAccount');
await LinkedAccount.create({
id: linkID,
userId: userID,
provider: 'steam',
providerAccountId: steamID(n),
profile: null
});
return { userID, linkID, steamId: steamID(n) };
}
afterAll(async () => {
await cleanup();
await sql.end();
});
describe('Identity.resolveSteamLogin', () => {
beforeEach(cleanup);
test('a Steam account made before email existed still signs in to the same user', async () => {
const legacy = await legacySteamUser(1);
const resolved = await Identity.resolveSteamLogin({ steamId: legacy.steamId });
expect(resolved.userID).toBe(legacy.userID);
expect(resolved.linkedAccountID).toBe(legacy.linkID);
});
test('an unknown Steam account is refused and creates no user', async () => {
const before = await countUsers();
let thrown: any = null;
try {
await Identity.resolveSteamLogin({ steamId: steamID(99) });
} catch (err) {
thrown = err;
}
expect(thrown).not.toBeNull();
expect(thrown.type).toBe('not_found');
expect(await countUsers()).toBe(before);
});
});
describe('Identity.fromVerifiedEmail', () => {
beforeEach(cleanup);
test('a verified email creates the user, and nothing else is needed', async () => {
const result = await Identity.fromVerifiedEmail({ email: email(1), name: 'Player One' });
track(result.userID);
expect(result.created).toBe(true);
const user = await User.fromID(result.userID);
expect(user?.email).toBe(email(1));
expect(user?.emailVerified).toBe(true);
});
test('the same address resolves to the same user rather than a second one', async () => {
const first = await Identity.fromVerifiedEmail({ email: email(2) });
track(first.userID);
const before = await countUsers();
const second = await Identity.fromVerifiedEmail({ email: ` ${email(2).toUpperCase()} ` });
expect(second.userID).toBe(first.userID);
expect(second.created).toBe(false);
expect(await countUsers()).toBe(before);
});
});
describe('input the flow refuses before it reaches the database', () => {
// `fn()` parses before it calls, so a rejected input throws where it is
// written rather than resolving to a rejected promise later on.
test('an address that is not one never becomes an account', () => {
expect(() => Identity.fromVerifiedEmail({ email: 'not-an-address' })).toThrow();
});
test('a Steam id of the wrong shape is not looked up', () => {
expect(() => Identity.resolveSteamLogin({ steamId: '123' })).toThrow();
});
});
describe('Identity.linkSteam', () => {
beforeEach(cleanup);
test('a fifth Steam account is refused and the fourth still stands', async () => {
const { userID } = await Identity.fromVerifiedEmail({ email: email(3) });
track(userID);
for (let n = 10; n < 14; n++) {
await Identity.linkSteam({ userId: userID, steamId: steamID(n) });
}
let thrown: any = null;
try {
await Identity.linkSteam({ userId: userID, steamId: steamID(14) });
} catch (err) {
thrown = err;
}
expect(thrown).not.toBeNull();
expect(thrown.code).toBe('invalid_state');
const links = await LinkedAccount.listByUser(userID);
expect(links.filter((l) => l.provider === 'steam')).toHaveLength(Identity.MAX_STEAM_ACCOUNTS);
});
test('relinking the same Steam account is not a fifth account', async () => {
const { userID } = await Identity.fromVerifiedEmail({ email: email(4) });
track(userID);
const first = await Identity.linkSteam({ userId: userID, steamId: steamID(20) });
const again = await Identity.linkSteam({ userId: userID, steamId: steamID(20) });
expect(again).toBe(first);
});
test('a Steam account already held by somebody else is a conflict', async () => {
const legacy = await legacySteamUser(30);
const { userID } = await Identity.fromVerifiedEmail({ email: email(5) });
track(userID);
let thrown: any = null;
try {
await Identity.linkSteam({ userId: userID, steamId: legacy.steamId });
} catch (err) {
thrown = err;
}
expect(thrown).not.toBeNull();
expect(thrown.type).toBe('already_exists');
});
test('a legacy user is claimed by attaching an email, and keeps its Steam link', async () => {
const legacy = await legacySteamUser(40);
const claimed = await Identity.claimWithEmail({ userId: legacy.userID, email: email(6) });
expect(claimed.email).toBe(email(6));
expect(claimed.emailVerified).toBe(true);
const resolved = await Identity.resolveSteamLogin({ steamId: legacy.steamId });
expect(resolved.userID).toBe(legacy.userID);
});
});