From da65cca4f2f098c7114407c0102b5e1fa0a3a244 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 00:01:15 +0300 Subject: [PATCH] feat(core): an account is an email address, and Steam is a connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/core/src/user/identity.test.ts | 200 ++++++++++++++++++++++ packages/core/src/user/identity.ts | 215 ++++++++++++++++++++++++ packages/core/src/user/user.sql.ts | 42 ++++- 3 files changed, 448 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/user/identity.test.ts create mode 100644 packages/core/src/user/identity.ts diff --git a/packages/core/src/user/identity.test.ts b/packages/core/src/user/identity.test.ts new file mode 100644 index 00000000..d42305f7 --- /dev/null +++ b/packages/core/src/user/identity.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/core/src/user/identity.ts b/packages/core/src/user/identity.ts new file mode 100644 index 00000000..a07c07be --- /dev/null +++ b/packages/core/src/user/identity.ts @@ -0,0 +1,215 @@ +import { and, eq, isNull } from 'drizzle-orm'; +import z from 'zod'; + +import { Database } from '../db/index.js'; +import { ErrorCodes, VisibleError } from '../error.js'; +import { fn } from '../fn.js'; +import { Identifier } from '../id.js'; +import { User } from './index.js'; +import { LinkedAccount } from './linked-account.js'; +import { LinkedAccountTable } from './linked-account.sql.js'; + +const STEAM_ID_RE = /^\d{17}$/; + +/** + * Email is the root of an account, so two spellings of one address must not be + * two accounts. Case and surrounding whitespace are the two ways the same + * address arrives looking different; both are removed at the edge, before + * anything is stored or compared, and the unique index in the database + * assumes it has been. + */ +const Email = z.string().trim().toLowerCase().pipe(z.email()); + +export namespace Identity { + /** + * How many Steam accounts one person may hang off their account. + * + * This is not — and cannot be — a database constraint. A unique index makes a + * value unique; it cannot count the rows that share a foreign key, so there is + * no index shape that says "at most four of these". The number lives here and + * a direct write to the table can still exceed it. Anyone reading the schema + * and looking for the rule will not find one, which is why it is written down + * in the migration as well as here. ref(d-0048) + * + * Four comes from the size of a household that shares a game library and from + * the account switcher needing to fit on one row. It is a product constraint + * and not a measured one. + */ + export const MAX_STEAM_ACCOUNTS = 4; + + /** + * The account behind a verified email address, created if it is new. + * + * This is the only way a user comes into existence. Everything else — a + * Steam account, an SSH key — attaches to a user that already exists, + * which is what makes losing one of them survivable. ref(d-0048) + * + * Idempotent on the address: verifying the same mailbox twice is one + * person signing in twice, not two accounts. + */ + export const fromVerifiedEmail = fn( + z.object({ email: Email, name: z.string().optional() }), + async (input) => { + const email = input.email; + return Database.transaction(async () => { + const existing = await User.fromEmail(email); + if (existing) { + // An address that was attached but never confirmed is + // confirmed now: getting here means a code was redeemed. + if (!existing.emailVerified) { + await User.setEmail({ id: existing.id, email, emailVerified: true }); + } + return { userID: existing.id, created: false }; + } + + const userID = Identifier.ascending('user'); + await User.create({ + id: userID, + name: input.name?.trim() || email.split('@')[0]!, + email, + emailVerified: true, + image: null + }); + return { userID, created: true }; + }); + } + ); + + /** + * Attach a verified email to an account that never had one. + * + * Accounts predate the rule that email is the root, so a live database + * holds users with no address at all. Each one is claimed exactly once, + * here, and afterwards it is an ordinary account. + */ + export const claimWithEmail = fn( + z.object({ userId: z.string(), email: Email }), + async (input) => { + const email = input.email; + return Database.transaction(async () => { + const holder = await User.fromEmail(email); + if (holder && holder.id !== input.userId) { + throw new VisibleError( + 'already_exists', + ErrorCodes.Validation.ALREADY_EXISTS, + 'That email address already belongs to another account' + ); + } + const updated = await User.setEmail({ + id: input.userId, + email, + emailVerified: true + }); + if (!updated) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'No such account' + ); + } + return updated; + }); + } + ); + + /** + * The account a Steam sign-in belongs to, or an error. + * + * Signing in with Steam never creates anything. A Steam account is a link + * on a user, so one that names no user is a person who has not signed up — + * an answer the interface has to render, not a reason to mint a row. The + * accounts that predate this are exactly the ones a link already exists + * for, so they keep working without a special case. ref(d-0048) + */ + export const resolveSteamLogin = fn( + z.object({ steamId: z.string().regex(STEAM_ID_RE, 'must be a 17-digit Steam ID') }), + async (input) => { + const link = await LinkedAccount.findByProvider({ + provider: 'steam', + providerAccountId: input.steamId + }); + if (!link) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'This Steam account is not connected to an account. Sign in with your email address first, then connect Steam from your settings.' + ); + } + return { userID: link.userId, linkedAccountID: link.id }; + } + ); + + /** + * Hang a Steam account off a user, up to {@link MAX_STEAM_ACCOUNTS}. + * + * The count and the insert are one transaction because they are one + * decision: read the four, then write the fifth, and two concurrent calls + * each see four. + */ + export const linkSteam = fn( + z.object({ + userId: z.string(), + steamId: z.string().regex(STEAM_ID_RE, 'must be a 17-digit Steam ID'), + profile: z.record(z.string(), z.unknown()).nullable().optional() + }), + async (input) => { + return Database.transaction(async (tx) => { + const existing = await LinkedAccount.findByProvider({ + provider: 'steam', + providerAccountId: input.steamId + }); + if (existing) { + if (existing.userId !== input.userId) { + throw new VisibleError( + 'already_exists', + ErrorCodes.Validation.ALREADY_EXISTS, + 'That Steam account is already connected to a different account' + ); + } + if (input.profile) { + await LinkedAccount.updateProfile({ id: existing.id, profile: input.profile }); + } + return existing.id; + } + + // `for update` on the rows already there, so a second caller + // holding at the cap waits rather than counting alongside. + const held = await tx + .select({ id: LinkedAccountTable.id }) + .from(LinkedAccountTable) + .where( + and( + eq(LinkedAccountTable.userId, input.userId), + eq(LinkedAccountTable.provider, 'steam'), + isNull(LinkedAccountTable.timeDeleted) + ) + ) + .for('update'); + + if (held.length >= MAX_STEAM_ACCOUNTS) { + throw new VisibleError( + 'validation', + ErrorCodes.Validation.INVALID_STATE, + `An account can have at most ${MAX_STEAM_ACCOUNTS} Steam accounts connected. Disconnect one before adding another.` + ); + } + + const id = Identifier.ascending('linkedAccount'); + await LinkedAccount.create({ + id, + userId: input.userId, + provider: 'steam', + providerAccountId: input.steamId, + profile: input.profile ?? null + }); + return id; + }); + } + ); + + /** Every Steam account connected to this user, oldest first. */ + export const listSteam = fn(z.string(), async (userId) => { + const links = await LinkedAccount.listByUser(userId); + return links.filter((link) => link.provider === 'steam'); + }); +} diff --git a/packages/core/src/user/user.sql.ts b/packages/core/src/user/user.sql.ts index b21ba1eb..40499edb 100644 --- a/packages/core/src/user/user.sql.ts +++ b/packages/core/src/user/user.sql.ts @@ -1,12 +1,36 @@ -import { boolean, pgTable, text } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { boolean, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { id, timestamps } from '../db/types.js'; -export const UserTable = pgTable('user', { - ...id, - ...timestamps, - name: text('name').notNull(), - email: text('email'), - emailVerified: boolean('email_verified').notNull().default(false), - image: text('image') -}); +export const UserTable = pgTable( + 'user', + { + ...id, + ...timestamps, + name: text('name').notNull(), + /** + * The address the account is rooted in. + * + * Nullable only because accounts exist that predate the rule — every + * one of those was made by signing in with a gaming account and was + * never asked for an address. A new account cannot be created without + * one. ref(d-0048) + */ + email: text('email'), + emailVerified: boolean('email_verified').notNull().default(false), + image: text('image') + }, + (t) => [ + // One address, one account, which is what makes it a root identity + // rather than a contact detail. Partial because the accounts made + // before this rule have no address at all, and "no address" is not a + // value two of them can collide on. + // + // The column holds a trimmed, lower-cased address; nothing here + // enforces that, so anything writing it has to normalize first. + uniqueIndex('user_email_unique') + .on(t.email) + .where(sql`email is not null and time_deleted is null`) + ] +);