mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
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:
200
packages/core/src/user/identity.test.ts
Normal file
200
packages/core/src/user/identity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
215
packages/core/src/user/identity.ts
Normal file
215
packages/core/src/user/identity.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
@@ -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`)
|
||||
]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user