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

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

View File

@@ -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');
});
}

View File

@@ -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'; import { id, timestamps } from '../db/types.js';
export const UserTable = pgTable('user', { export const UserTable = pgTable(
'user',
{
...id, ...id,
...timestamps, ...timestamps,
name: text('name').notNull(), 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'), email: text('email'),
emailVerified: boolean('email_verified').notNull().default(false), emailVerified: boolean('email_verified').notNull().default(false),
image: text('image') 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`)
]
);