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