@oimlsmart/platform-server 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ -- Migration 0022 — multiple emails per account (TODO.identity-features/01):
2
+ -- the account gains a primary + additional addresses, each verified
3
+ -- independently, the sign-in and the recovery paths resolving by ANY
4
+ -- verified one.
5
+ --
6
+ -- The model:
7
+ -- - the PRIMARY address stays users.email (+ users.email_verified_at):
8
+ -- the OIDC `email` claim never changes shape, every existing reader
9
+ -- (the claims, the registry, the audit chain) is undisturbed, and the
10
+ -- claims keep carrying the primary on a switch;
11
+ -- - account_emails carries the ADDITIONAL addresses only — one row per
12
+ -- (account, address), verified_at NULL until the per-address ceremony
13
+ -- proves the mailbox. An address is globally unique across the estate
14
+ -- of addresses: the unique index keys it here, and the store's writes
15
+ -- check BOTH tables (an additional on account A blocks the address as
16
+ -- a primary or an additional anywhere else);
17
+ -- - the "primary" attribute is represented by WHERE the address lives
18
+ -- (the users row vs account_emails), never a flag to keep in sync —
19
+ -- the store's setPrimaryAccountEmail swaps the two residences with
20
+ -- their verification stamps;
21
+ -- - email_change_tokens gains `kind`: 'change' (the pre-0022 primary
22
+ -- replacement — the default, so existing rows read honestly) and
23
+ -- 'add' (the per-address verification of an account_emails row; the
24
+ -- row lands unverified at the request and the token's completion
25
+ -- stamps verified_at). The same one-time, 24 h, atomically-consumed
26
+ -- doctrine carries both.
27
+ -- schema.sql carries the same end state for fresh databases —
28
+ -- test/migrations.test.ts pins the UNION of every migration to
29
+ -- schema.sql's CREATE set.
30
+
31
+ CREATE TABLE IF NOT EXISTS account_emails (
32
+ user_id TEXT NOT NULL REFERENCES users(id),
33
+ email TEXT NOT NULL,
34
+ verified_at TEXT,
35
+ added_by TEXT,
36
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
37
+ PRIMARY KEY (user_id, email)
38
+ );
39
+ -- An address names at most ONE account across the estate (the sign-in
40
+ -- and recovery resolutions depend on it); the users.email UNIQUE covers
41
+ -- the primaries, this index the additionals, and the store's writes
42
+ -- check across both.
43
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_account_emails_email ON account_emails (email);
44
+
45
+ ALTER TABLE email_change_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'change';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimlsmart/platform-server",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "The OIML SMART platform server kernel: the store seam (ServerStore + the D1 and SQLite implementations), the canonical D1 migration set both deployments apply, the instance profile, the mailer, the RBAC map, the OIDC/OAuth client cones, and the shared role/permission vocabulary. Consumed by the smart monorepo (browser/) and the identity service.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/store/d1.ts CHANGED
@@ -21,6 +21,8 @@
21
21
  import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types'
22
22
  import {
23
23
  DEMO_PASSWORD,
24
+ type AccountEmail,
25
+ type AddAccountEmailResult,
24
26
  type AdvanceCounterResult,
25
27
  type AuthUserPayload,
26
28
  type CompleteEmailChangeResult,
@@ -510,6 +512,36 @@ export class D1ServerStore implements ServerStore {
510
512
  return this.consentGrantSupportEnsured
511
513
  }
512
514
 
515
+ // TODO.identity-features/01 (multiple emails per account): the
516
+ // account_emails table + the email_change_tokens.kind column arrive
517
+ // with migration 0022 — a dev D1 migrated from before it lacks both,
518
+ // so the address methods ensure them defensively (the
519
+ // ensureConsentGrantSupport posture, memoized per store).
520
+ private accountEmailSupportEnsured: Promise<void> | null = null
521
+
522
+ private ensureAccountEmailSupport(): Promise<void> {
523
+ if (!this.accountEmailSupportEnsured) {
524
+ this.accountEmailSupportEnsured = (async () => {
525
+ await this.db.prepare(
526
+ `CREATE TABLE IF NOT EXISTS account_emails (
527
+ user_id TEXT NOT NULL REFERENCES users(id),
528
+ email TEXT NOT NULL,
529
+ verified_at TEXT,
530
+ added_by TEXT,
531
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
532
+ PRIMARY KEY (user_id, email)
533
+ )`,
534
+ ).run()
535
+ await this.db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_account_emails_email ON account_emails (email)').run()
536
+ const tokenCols = await this.db.prepare('PRAGMA table_info(email_change_tokens)').all<{ name: string }>()
537
+ if (!tokenCols.results.some(c => c.name === 'kind')) {
538
+ await this.db.prepare("ALTER TABLE email_change_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'change'").run()
539
+ }
540
+ })()
541
+ }
542
+ return this.accountEmailSupportEnsured
543
+ }
544
+
513
545
  // ── users / sessions ─────────────────────────────────────────────
514
546
 
515
547
  async seedDemoAccounts(): Promise<void> {
@@ -1374,6 +1406,13 @@ export class D1ServerStore implements ServerStore {
1374
1406
  createdBy?: string | null
1375
1407
  }): Promise<UserAdminRow | null> {
1376
1408
  const id = crypto.randomUUID()
1409
+ // TODO.identity-features/01: the address must be free across BOTH
1410
+ // address tables — an additional on another account blocks the
1411
+ // address as a new account's primary (an address names at most one
1412
+ // account; the users.email UNIQUE remains the race backstop).
1413
+ await this.ensureAccountEmailSupport()
1414
+ const additional = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', input.email.trim().toLowerCase()).first<{ user_id: string }>()
1415
+ if (additional) return null
1377
1416
  try {
1378
1417
  await this.stmt(
1379
1418
  "INSERT INTO users (id, email, name, provider, role) VALUES (?, ?, ?, 'password', ?)",
@@ -1388,14 +1427,28 @@ export class D1ServerStore implements ServerStore {
1388
1427
  }
1389
1428
 
1390
1429
  /** The password sign-in's lookup: the credential + the active flag, by
1391
- * (normalized) email. The credential's EXISTENCE is the qualifier. */
1430
+ * (normalized) email. The credential's EXISTENCE is the qualifier.
1431
+ * TODO.identity-features/01: the address resolves by ANY of the
1432
+ * account's VERIFIED addresses — the primary first (the primary owner
1433
+ * always wins, the deterministic rule), then a proven account_emails
1434
+ * row; an unverified additional never resolves. */
1392
1435
  async getPasswordLogin(email: string): Promise<{ userId: string; hash: string; active: boolean } | null> {
1393
- const row = await this.stmt(
1436
+ await this.ensureAccountEmailSupport()
1437
+ const normalized = email.trim().toLowerCase()
1438
+ let row = await this.stmt(
1394
1439
  `SELECT u.id AS user_id, u.active AS active, p.hash AS hash
1395
1440
  FROM users u JOIN passwords p ON p.user_id = u.id
1396
1441
  WHERE u.email = ?`,
1397
- email.trim().toLowerCase(),
1442
+ normalized,
1398
1443
  ).first<{ user_id: string; active: number; hash: string }>()
1444
+ if (!row) {
1445
+ row = await this.stmt(
1446
+ `SELECT u.id AS user_id, u.active AS active, p.hash AS hash
1447
+ FROM users u JOIN passwords p ON p.user_id = u.id
1448
+ WHERE u.id = (SELECT user_id FROM account_emails WHERE email = ? AND verified_at IS NOT NULL)`,
1449
+ normalized,
1450
+ ).first<{ user_id: string; active: number; hash: string }>()
1451
+ }
1399
1452
  if (!row) return null
1400
1453
  return { userId: row.user_id, hash: row.hash, active: row.active !== 0 }
1401
1454
  }
@@ -1620,6 +1673,10 @@ export class D1ServerStore implements ServerStore {
1620
1673
  // the account (a tombstone never skips a consent page again).
1621
1674
  await this.ensureConsentGrantSupport()
1622
1675
  const consentGrants = await this.stmt('DELETE FROM oidc_consent_grants WHERE user_id = ?', userId).run()
1676
+ // TODO.identity-features/01: the additional addresses die with the
1677
+ // account (a tombstone's addresses never resolve a sign-in again).
1678
+ await this.ensureAccountEmailSupport()
1679
+ const emails = await this.stmt('DELETE FROM account_emails WHERE user_id = ?', userId).run()
1623
1680
  await this.stmt(
1624
1681
  `UPDATE users SET
1625
1682
  email = ?, name = 'Deleted account', provider = 'erased',
@@ -1638,13 +1695,20 @@ export class D1ServerStore implements ServerStore {
1638
1695
  + (challenges.meta.changes ?? 0) + (mfa.meta.changes ?? 0),
1639
1696
  personalAccessTokens: personalAccessTokens.meta.changes ?? 0,
1640
1697
  consentGrants: consentGrants.meta.changes ?? 0,
1698
+ emails: emails.meta.changes ?? 0,
1641
1699
  }
1642
1700
  }
1643
1701
 
1644
1702
  /** The registry's edit act (name/email). The email UNIQUE conflict
1645
- * throws 'unique' (the route maps it to a 409, never a silent take). */
1703
+ * throws 'unique' (the route maps it to a 409, never a silent take).
1704
+ * TODO.identity-features/01: the conflict read spans BOTH address
1705
+ * tables — an additional row (on any account, this one included)
1706
+ * holds the address too. */
1646
1707
  async updateOpAccount(id: string, input: { name?: string; email?: string }): Promise<boolean> {
1647
1708
  if (input.email !== undefined) {
1709
+ await this.ensureAccountEmailSupport()
1710
+ const additional = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', input.email.trim().toLowerCase()).first<{ user_id: string }>()
1711
+ if (additional) throw new Error(`unique: ${input.email}`)
1648
1712
  try {
1649
1713
  // TODO.identity/06: an admin-set address never went through the
1650
1714
  // verify-new-email ceremony, so the verification state resets.
@@ -1717,29 +1781,46 @@ export class D1ServerStore implements ServerStore {
1717
1781
  userId: row.user_id as string,
1718
1782
  newEmail: row.new_email as string,
1719
1783
  deliveredBy: row.delivered_by === 'mailer' ? 'mailer' : 'shown',
1784
+ // TODO.identity-features/01: rows predating the kind column (or a
1785
+ // store over a pre-0022 database) read as the legacy ceremony.
1786
+ kind: row.kind === 'add' ? 'add' : 'change',
1720
1787
  createdAt: row.created_at as string,
1721
1788
  expiresAt: row.expires_at as string,
1722
1789
  consumedAt: (row.consumed_at as string | null) ?? null,
1723
1790
  }
1724
1791
  }
1725
1792
 
1726
- /** Mint the ceremony's token; the account's earlier pending rows are
1727
- * VOIDED first (only the newest link works). */
1793
+ /** Mint the ceremony's token. The void rule keeps ONE live link per
1794
+ * ceremony target: a 'change' request voids the account's earlier
1795
+ * pending 'change' rows (only the newest change link works — the
1796
+ * pre-01 doctrine); an 'add' request voids the account's earlier
1797
+ * pending 'add' rows FOR THE SAME address (other addresses' links
1798
+ * stand). */
1728
1799
  async createEmailChangeToken(input: {
1729
1800
  token: string
1730
1801
  userId: string
1731
1802
  newEmail: string
1732
1803
  deliveredBy: 'mailer' | 'shown'
1804
+ kind?: 'change' | 'add'
1733
1805
  ttlMs: number
1734
1806
  }): Promise<EmailChangeToken> {
1735
- await this.stmt(
1736
- "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND consumed_at IS NULL",
1737
- input.userId,
1738
- ).run()
1807
+ await this.ensureAccountEmailSupport()
1808
+ const kind = input.kind ?? 'change'
1809
+ if (kind === 'change') {
1810
+ await this.stmt(
1811
+ "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL",
1812
+ input.userId,
1813
+ ).run()
1814
+ } else {
1815
+ await this.stmt(
1816
+ "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'add' AND new_email = ? AND consumed_at IS NULL",
1817
+ input.userId, input.newEmail.trim().toLowerCase(),
1818
+ ).run()
1819
+ }
1739
1820
  const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
1740
1821
  await this.stmt(
1741
- 'INSERT INTO email_change_tokens (token, user_id, new_email, delivered_by, expires_at) VALUES (?, ?, ?, ?, ?)',
1742
- input.token, input.userId, input.newEmail.trim().toLowerCase(), input.deliveredBy, expiresAt,
1822
+ 'INSERT INTO email_change_tokens (token, user_id, new_email, delivered_by, kind, expires_at) VALUES (?, ?, ?, ?, ?, ?)',
1823
+ input.token, input.userId, input.newEmail.trim().toLowerCase(), input.deliveredBy, kind, expiresAt,
1743
1824
  ).run()
1744
1825
  return (await this.getEmailChangeToken(input.token))!
1745
1826
  }
@@ -1749,12 +1830,15 @@ export class D1ServerStore implements ServerStore {
1749
1830
  return row ? D1ServerStore.toEmailChangeToken(row) : null
1750
1831
  }
1751
1832
 
1752
- /** The account's pending change (the newest live row), so the console
1753
- * can show it. */
1833
+ /** The account's pending PRIMARY change (the newest live 'change'
1834
+ * row), so the console can show it. The per-address verifications are
1835
+ * the account_emails rows' own state (verified_at NULL = waiting),
1836
+ * never a pending read here. */
1754
1837
  async getPendingEmailChange(userId: string): Promise<EmailChangeToken | null> {
1838
+ await this.ensureAccountEmailSupport()
1755
1839
  const row = await this.stmt(
1756
1840
  `SELECT * FROM email_change_tokens
1757
- WHERE user_id = ? AND consumed_at IS NULL AND expires_at > datetime('now')
1841
+ WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL AND expires_at > datetime('now')
1758
1842
  ORDER BY created_at DESC LIMIT 1`,
1759
1843
  userId,
1760
1844
  ).first<Record<string, unknown>>()
@@ -1762,20 +1846,34 @@ export class D1ServerStore implements ServerStore {
1762
1846
  }
1763
1847
 
1764
1848
  /** Complete the ceremony: consume ATOMICALLY (a presented link works
1765
- * exactly once, expired or not), judge the expiry, re-check the
1766
- * address's uniqueness (a conflict burns the token honestly), then
1767
- * move the account's email. A 'mailer'-delivered token verifies the
1768
- * address; a shown one never does. */
1849
+ * exactly once, expired or not), judge the expiry, then act on the
1850
+ * kind. 'change' (the pre-01 primary replacement): re-check the
1851
+ * address's uniqueness across BOTH address tables (a conflict burns
1852
+ * the token honestly an additional row anywhere holds the address
1853
+ * too, this account's included), then move users.email. 'add' (the
1854
+ * per-address verification): the account_emails row landed unverified
1855
+ * at the request; the completion stamps it (a row removed meanwhile
1856
+ * burns the link as 'unknown'). A 'mailer'-delivered token verifies
1857
+ * the address; a shown one never does. */
1769
1858
  async completeEmailChange(token: string): Promise<CompleteEmailChangeResult> {
1859
+ await this.ensureAccountEmailSupport()
1770
1860
  const res = await this.stmt(
1771
1861
  "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL", token,
1772
1862
  ).run()
1773
1863
  if ((res.meta.changes ?? 0) === 0) return { kind: 'unknown' }
1774
1864
  const row = (await this.getEmailChangeToken(token))!
1775
1865
  if (new Date(row.expiresAt).getTime() <= Date.now()) return { kind: 'expired' }
1866
+ const verified = row.deliveredBy === 'mailer'
1867
+ if (row.kind === 'add') {
1868
+ const standing = await this.stmt('SELECT 1 AS ok FROM account_emails WHERE user_id = ? AND email = ?', row.userId, row.newEmail).first<{ ok: number }>()
1869
+ if (!standing) return { kind: 'unknown' }
1870
+ if (verified) await this.markAccountEmailVerified(row.userId, row.newEmail)
1871
+ return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
1872
+ }
1776
1873
  const taken = await this.stmt('SELECT id FROM users WHERE email = ?', row.newEmail).first<{ id: string }>()
1777
1874
  if (taken) return { kind: 'conflict' }
1778
- const verified = row.deliveredBy === 'mailer'
1875
+ const takenAdditional = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', row.newEmail).first<{ user_id: string }>()
1876
+ if (takenAdditional) return { kind: 'conflict' }
1779
1877
  await this.stmt(
1780
1878
  `UPDATE users SET email = ?, email_verified_at = ${verified ? "datetime('now')" : 'NULL'} WHERE id = ?`,
1781
1879
  row.newEmail, row.userId,
@@ -1783,6 +1881,116 @@ export class D1ServerStore implements ServerStore {
1783
1881
  return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
1784
1882
  }
1785
1883
 
1884
+ // ── multiple emails per account (TODO.identity-features/01) ────────
1885
+
1886
+ private static toAccountEmail(row: Record<string, unknown>, isPrimary: boolean): AccountEmail {
1887
+ return {
1888
+ userId: row.user_id as string,
1889
+ email: row.email as string,
1890
+ verifiedAt: (row.verified_at as string | null) ?? null,
1891
+ isPrimary,
1892
+ addedBy: (row.added_by as string | null) ?? null,
1893
+ createdAt: row.created_at as string,
1894
+ }
1895
+ }
1896
+
1897
+ /** The account's addresses: the PRIMARY first (the users row's email +
1898
+ * its verification stamp), then the additional account_emails rows
1899
+ * (oldest first). */
1900
+ async listAccountEmails(userId: string): Promise<AccountEmail[]> {
1901
+ await this.ensureAccountEmailSupport()
1902
+ const primary = await this.stmt(
1903
+ 'SELECT id AS user_id, email, email_verified_at AS verified_at, created_at FROM users WHERE id = ?', userId,
1904
+ ).first<Record<string, unknown>>()
1905
+ const res = await this.stmt(
1906
+ 'SELECT * FROM account_emails WHERE user_id = ? ORDER BY created_at, email', userId,
1907
+ ).all<Record<string, unknown>>()
1908
+ const out: AccountEmail[] = []
1909
+ if (primary) out.push(D1ServerStore.toAccountEmail(primary, true))
1910
+ out.push(...res.results.map(r => D1ServerStore.toAccountEmail(r, false)))
1911
+ return out
1912
+ }
1913
+
1914
+ /** Resolve the account by ANY of its addresses: the primary always
1915
+ * names it (and the primary owner always wins — the deterministic
1916
+ * rule); an additional ONLY when verified. */
1917
+ async findUserByAnyEmail(email: string): Promise<AuthUserPayload | null> {
1918
+ await this.ensureAccountEmailSupport()
1919
+ const normalized = email.trim().toLowerCase()
1920
+ const primary = await this.stmt('SELECT * FROM users WHERE email = ?', normalized).first<UserRecord>()
1921
+ if (primary) return toPayload(primary)
1922
+ const owner = await this.stmt(
1923
+ 'SELECT user_id FROM account_emails WHERE email = ? AND verified_at IS NOT NULL', normalized,
1924
+ ).first<{ user_id: string }>()
1925
+ if (!owner) return null
1926
+ const user = await this.stmt('SELECT * FROM users WHERE id = ?', owner.user_id).first<UserRecord>()
1927
+ return user ? toPayload(user) : null
1928
+ }
1929
+
1930
+ /** Add an ADDITIONAL address (normalized lowercase; the row lands
1931
+ * UNVERIFIED). The account's own existing row answers 'present' (the
1932
+ * idempotent re-add); any other hold of the address — a primary
1933
+ * anywhere (this account's included) or another account's additional
1934
+ * — answers 'conflict'. The unique index is the race backstop. */
1935
+ async addAccountEmail(userId: string, email: string, addedBy?: string | null): Promise<AddAccountEmailResult> {
1936
+ await this.ensureAccountEmailSupport()
1937
+ const normalized = email.trim().toLowerCase()
1938
+ const takenPrimary = await this.stmt('SELECT id FROM users WHERE email = ?', normalized).first<{ id: string }>()
1939
+ if (takenPrimary) return 'conflict'
1940
+ const existing = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', normalized).first<{ user_id: string }>()
1941
+ if (existing) return existing.user_id === userId ? 'present' : 'conflict'
1942
+ try {
1943
+ await this.stmt(
1944
+ 'INSERT INTO account_emails (user_id, email, added_by) VALUES (?, ?, ?)',
1945
+ userId, normalized, addedBy ?? null,
1946
+ ).run()
1947
+ } catch (e) {
1948
+ if (String((e as Error).message).includes('UNIQUE')) return 'conflict'
1949
+ throw e
1950
+ }
1951
+ return 'added'
1952
+ }
1953
+
1954
+ /** The verification ceremony's stamp on the account's OWN row: the
1955
+ * guarded UPDATE flips verified_at, once. */
1956
+ async markAccountEmailVerified(userId: string, email: string): Promise<boolean> {
1957
+ await this.ensureAccountEmailSupport()
1958
+ const res = await this.stmt(
1959
+ "UPDATE account_emails SET verified_at = datetime('now') WHERE user_id = ? AND email = ? AND verified_at IS NULL",
1960
+ userId, email.trim().toLowerCase(),
1961
+ ).run()
1962
+ return (res.meta.changes ?? 0) > 0
1963
+ }
1964
+
1965
+ /** Promote a VERIFIED additional to primary: the promoted address
1966
+ * becomes users.email with its verification stamp; the outgoing
1967
+ * primary takes the row's place in account_emails with ITS stamp
1968
+ * (it stays a verified additional — sign-in by it keeps working). */
1969
+ async setPrimaryAccountEmail(userId: string, email: string): Promise<'ok' | 'unknown' | 'unverified'> {
1970
+ await this.ensureAccountEmailSupport()
1971
+ const normalized = email.trim().toLowerCase()
1972
+ const row = await this.stmt('SELECT * FROM account_emails WHERE user_id = ? AND email = ?', userId, normalized).first<Record<string, unknown>>()
1973
+ if (!row) return 'unknown'
1974
+ if (!row.verified_at) return 'unverified'
1975
+ const current = await this.stmt('SELECT email, email_verified_at FROM users WHERE id = ?', userId).first<{ email: string; email_verified_at: string | null }>()
1976
+ if (!current) return 'unknown'
1977
+ await this.stmt('UPDATE users SET email = ?, email_verified_at = ? WHERE id = ?', normalized, row.verified_at as string, userId).run()
1978
+ await this.stmt('DELETE FROM account_emails WHERE user_id = ? AND email = ?', userId, normalized).run()
1979
+ await this.stmt('INSERT INTO account_emails (user_id, email, verified_at) VALUES (?, ?, ?)', userId, current.email, current.email_verified_at).run()
1980
+ return 'ok'
1981
+ }
1982
+
1983
+ /** Remove an ADDITIONAL address. The primary refuses honestly
1984
+ * ('primary' — promote another address first). */
1985
+ async removeAccountEmail(userId: string, email: string): Promise<'ok' | 'primary' | 'unknown'> {
1986
+ await this.ensureAccountEmailSupport()
1987
+ const normalized = email.trim().toLowerCase()
1988
+ const current = await this.stmt('SELECT email FROM users WHERE id = ?', userId).first<{ email: string }>()
1989
+ if (current?.email === normalized) return 'primary'
1990
+ const res = await this.stmt('DELETE FROM account_emails WHERE user_id = ? AND email = ?', userId, normalized).run()
1991
+ return (res.meta.changes ?? 0) > 0 ? 'ok' : 'unknown'
1992
+ }
1993
+
1786
1994
  // ── strong authentication: the factor registry (TODO.identity-sso/02 + /03) ──
1787
1995
  // The same SQL as the SQLite half (store/sqlite/factors-store.ts): the
1788
1996
  // one-time consumes are guarded UPDATEs, the counter advance is the
@@ -13,6 +13,9 @@
13
13
  import { randomUUID } from 'crypto'
14
14
  import { getDb } from './store'
15
15
  import type {
16
+ AccountEmail,
17
+ AddAccountEmailResult,
18
+ AuthUserPayload,
16
19
  CompleteEmailChangeResult,
17
20
  CompleteEnrollmentResult,
18
21
  EmailChangeToken,
@@ -23,6 +26,27 @@ import type {
23
26
  UserAdminRow,
24
27
  } from '../../store'
25
28
 
29
+ /** The AuthUserPayload projection of a users row (the sqlite/store.ts
30
+ * userPayload shape — the full assigned role set rides along). */
31
+ function accountPayload(row: Record<string, unknown>): AuthUserPayload {
32
+ let roles: string[] | undefined
33
+ try {
34
+ const parsed = JSON.parse((row.roles as string | null) ?? 'null') as unknown
35
+ roles = Array.isArray(parsed) && parsed.length ? parsed.filter((v): v is string => typeof v === 'string') : undefined
36
+ } catch { roles = undefined }
37
+ return {
38
+ id: row.id as string,
39
+ email: row.email as string,
40
+ name: row.name as string,
41
+ role: row.role as string,
42
+ ...(roles?.length ? { roles } : {}),
43
+ orgId: (row.org_id as string | null) ?? null,
44
+ avatarUrl: (row.avatar_url as string | null) ?? undefined,
45
+ provider: row.provider as string,
46
+ emailVerifiedAt: (row.email_verified_at as string | null) ?? null,
47
+ }
48
+ }
49
+
26
50
  function toEnrollmentToken(row: Record<string, unknown>): EnrollmentToken {
27
51
  return {
28
52
  token: row.token as string,
@@ -35,7 +59,9 @@ function toEnrollmentToken(row: Record<string, unknown>): EnrollmentToken {
35
59
  }
36
60
 
37
61
  /** Create the OP password account. Answers null when the email is taken
38
- * (the invite route's 409; the UNIQUE constraint is the race backstop). */
62
+ * (the invite route's 409; the UNIQUE constraint is the race backstop).
63
+ * TODO.identity-features/01: the taken read spans BOTH address tables —
64
+ * an additional on another account blocks the address as a primary. */
39
65
  export function createOpAccount(input: {
40
66
  email: string
41
67
  name: string
@@ -44,6 +70,8 @@ export function createOpAccount(input: {
44
70
  }): UserAdminRow | null {
45
71
  const db = getDb()
46
72
  const id = randomUUID()
73
+ const additional = db.prepare('SELECT user_id FROM account_emails WHERE email = ?').get(input.email.trim().toLowerCase())
74
+ if (additional) return null
47
75
  try {
48
76
  db.prepare(
49
77
  "INSERT INTO users (id, email, name, provider, role) VALUES (?, ?, ?, 'password', ?)",
@@ -68,13 +96,26 @@ export function createOpAccount(input: {
68
96
 
69
97
  /** The password sign-in's lookup: the credential + the active flag, by
70
98
  * (normalized) email. The credential's EXISTENCE is the qualifier — an
71
- * account that holds a password may sign in with it. */
99
+ * account that holds a password may sign in with it.
100
+ * TODO.identity-features/01: the address resolves by ANY of the
101
+ * account's VERIFIED addresses — the primary first (the primary owner
102
+ * always wins, the deterministic rule), then a proven account_emails
103
+ * row; an unverified additional never resolves. */
72
104
  export function getPasswordLogin(email: string): { userId: string; hash: string; active: boolean } | null {
73
- const row = getDb().prepare(
105
+ const db = getDb()
106
+ const normalized = email.trim().toLowerCase()
107
+ let row = db.prepare(
74
108
  `SELECT u.id AS user_id, u.active AS active, p.hash AS hash
75
109
  FROM users u JOIN passwords p ON p.user_id = u.id
76
110
  WHERE u.email = ?`,
77
- ).get(email.trim().toLowerCase()) as Record<string, unknown> | undefined
111
+ ).get(normalized) as Record<string, unknown> | undefined
112
+ if (!row) {
113
+ row = db.prepare(
114
+ `SELECT u.id AS user_id, u.active AS active, p.hash AS hash
115
+ FROM users u JOIN passwords p ON p.user_id = u.id
116
+ WHERE u.id = (SELECT user_id FROM account_emails WHERE email = ? AND verified_at IS NOT NULL)`,
117
+ ).get(normalized) as Record<string, unknown> | undefined
118
+ }
78
119
  if (!row) return null
79
120
  return { userId: row.user_id as string, hash: row.hash as string, active: row.active !== 0 }
80
121
  }
@@ -272,10 +313,15 @@ export function revokeOpUserCredentials(userId: string): { sessions: number; acc
272
313
  /** The registry's edit act (name/email). The email UNIQUE conflict
273
314
  * throws 'unique' (the route maps it to a 409, never a silent take).
274
315
  * TODO.identity/06: an admin-set address never went through the
275
- * verify-new-email ceremony, so the verification state resets. */
316
+ * verify-new-email ceremony, so the verification state resets.
317
+ * TODO.identity-features/01: the conflict read spans BOTH address
318
+ * tables — an additional row (on any account, this one included) holds
319
+ * the address too. */
276
320
  export function updateOpAccount(id: string, input: { name?: string; email?: string }): boolean {
277
321
  const db = getDb()
278
322
  if (input.email !== undefined) {
323
+ const additional = db.prepare('SELECT user_id FROM account_emails WHERE email = ?').get(input.email.trim().toLowerCase())
324
+ if (additional) throw new Error(`unique: ${input.email}`)
279
325
  try {
280
326
  db.prepare('UPDATE users SET email = ?, email_verified_at = NULL WHERE id = ?').run(input.email.trim().toLowerCase(), id)
281
327
  } catch (e) {
@@ -314,6 +360,7 @@ export function eraseOpAccount(userId: string): {
314
360
  factors: number
315
361
  personalAccessTokens: number
316
362
  consentGrants: number
363
+ emails: number
317
364
  } | null {
318
365
  const db = getDb()
319
366
  const row = db.prepare('SELECT 1 AS ok FROM users WHERE id = ?').get(userId)
@@ -343,6 +390,9 @@ export function eraseOpAccount(userId: string): {
343
390
  // TODO.identity-features/12: the remembered consent grants die with the
344
391
  // account (a tombstone never skips a consent page again).
345
392
  const consentGrants = db.prepare('DELETE FROM oidc_consent_grants WHERE user_id = ?').run(userId).changes
393
+ // TODO.identity-features/01: the additional addresses die with the
394
+ // account (a tombstone's addresses never resolve a sign-in again).
395
+ const emails = db.prepare('DELETE FROM account_emails WHERE user_id = ?').run(userId).changes
346
396
  db.prepare(
347
397
  `UPDATE users SET
348
398
  email = ?, name = 'Deleted account', provider = 'erased',
@@ -350,7 +400,7 @@ export function eraseOpAccount(userId: string): {
350
400
  avatar_url = NULL, email_verified_at = NULL, active = 0
351
401
  WHERE id = ?`,
352
402
  ).run(`deleted-${userId}@erased.invalid`, userId)
353
- return { ...revoked, links, clientRoles, memberships, tokens, factors, personalAccessTokens, consentGrants }
403
+ return { ...revoked, links, clientRoles, memberships, tokens, factors, personalAccessTokens, consentGrants, emails }
354
404
  }
355
405
 
356
406
  /** The last OP-side sign-in per account, FROM THE AUDIT CHAIN: the
@@ -413,29 +463,44 @@ function toEmailChangeToken(row: Record<string, unknown>): EmailChangeToken {
413
463
  userId: row.user_id as string,
414
464
  newEmail: row.new_email as string,
415
465
  deliveredBy: row.delivered_by === 'mailer' ? 'mailer' : 'shown',
466
+ // TODO.identity-features/01: rows predating the kind column read as
467
+ // the legacy ceremony.
468
+ kind: row.kind === 'add' ? 'add' : 'change',
416
469
  createdAt: row.created_at as string,
417
470
  expiresAt: row.expires_at as string,
418
471
  consumedAt: (row.consumed_at as string | null) ?? null,
419
472
  }
420
473
  }
421
474
 
422
- /** Mint the ceremony's token; the account's earlier pending rows are
423
- * VOIDED first (only the newest link works). */
475
+ /** Mint the ceremony's token. The void rule keeps ONE live link per
476
+ * ceremony target: a 'change' request voids the account's earlier
477
+ * pending 'change' rows (only the newest change link works — the
478
+ * pre-01 doctrine); an 'add' request voids the account's earlier
479
+ * pending 'add' rows FOR THE SAME address (other addresses' links
480
+ * stand). */
424
481
  export function createEmailChangeToken(input: {
425
482
  token: string
426
483
  userId: string
427
484
  newEmail: string
428
485
  deliveredBy: 'mailer' | 'shown'
486
+ kind?: 'change' | 'add'
429
487
  ttlMs: number
430
488
  }): EmailChangeToken {
431
489
  const db = getDb()
432
- db.prepare(
433
- "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND consumed_at IS NULL",
434
- ).run(input.userId)
490
+ const kind = input.kind ?? 'change'
491
+ if (kind === 'change') {
492
+ db.prepare(
493
+ "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL",
494
+ ).run(input.userId)
495
+ } else {
496
+ db.prepare(
497
+ "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'add' AND new_email = ? AND consumed_at IS NULL",
498
+ ).run(input.userId, input.newEmail.trim().toLowerCase())
499
+ }
435
500
  const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
436
501
  db.prepare(
437
- 'INSERT INTO email_change_tokens (token, user_id, new_email, delivered_by, expires_at) VALUES (?, ?, ?, ?, ?)',
438
- ).run(input.token, input.userId, input.newEmail.trim().toLowerCase(), input.deliveredBy, expiresAt)
502
+ 'INSERT INTO email_change_tokens (token, user_id, new_email, delivered_by, kind, expires_at) VALUES (?, ?, ?, ?, ?, ?)',
503
+ ).run(input.token, input.userId, input.newEmail.trim().toLowerCase(), input.deliveredBy, kind, expiresAt)
439
504
  return getEmailChangeToken(input.token)!
440
505
  }
441
506
 
@@ -444,22 +509,29 @@ export function getEmailChangeToken(token: string): EmailChangeToken | null {
444
509
  return row ? toEmailChangeToken(row) : null
445
510
  }
446
511
 
447
- /** The account's pending change (the newest live row), so the console
448
- * can show it. */
512
+ /** The account's pending PRIMARY change (the newest live 'change' row),
513
+ * so the console can show it. The per-address verifications are the
514
+ * account_emails rows' own state (verified_at NULL = waiting), never a
515
+ * pending read here. */
449
516
  export function getPendingEmailChange(userId: string): EmailChangeToken | null {
450
517
  const row = getDb().prepare(
451
518
  `SELECT * FROM email_change_tokens
452
- WHERE user_id = ? AND consumed_at IS NULL AND expires_at > datetime('now')
519
+ WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL AND expires_at > datetime('now')
453
520
  ORDER BY created_at DESC LIMIT 1`,
454
521
  ).get(userId) as Record<string, unknown> | undefined
455
522
  return row ? toEmailChangeToken(row) : null
456
523
  }
457
524
 
458
525
  /** Complete the ceremony: consume ATOMICALLY (a presented link works
459
- * exactly once, expired or not), judge the expiry, re-check the
460
- * address's uniqueness (a conflict burns the token honestly), then move
461
- * the account's email. A 'mailer'-delivered token verifies the address;
462
- * a shown one applies the change with the address staying unverified. */
526
+ * exactly once, expired or not), judge the expiry, then act on the
527
+ * kind. 'change' (the pre-01 primary replacement): re-check the
528
+ * address's uniqueness across BOTH address tables (a conflict burns the
529
+ * token honestly an additional row anywhere holds the address too,
530
+ * this account's included), then move users.email. 'add' (the
531
+ * per-address verification): the account_emails row landed unverified
532
+ * at the request; the completion stamps it (a row removed meanwhile
533
+ * burns the link as 'unknown'). A 'mailer'-delivered token verifies the
534
+ * address; a shown one never does. */
463
535
  export function completeEmailChange(token: string): CompleteEmailChangeResult {
464
536
  const db = getDb()
465
537
  const res = db.prepare(
@@ -468,11 +540,123 @@ export function completeEmailChange(token: string): CompleteEmailChangeResult {
468
540
  if (res.changes === 0) return { kind: 'unknown' }
469
541
  const row = getEmailChangeToken(token)!
470
542
  if (new Date(row.expiresAt).getTime() <= Date.now()) return { kind: 'expired' }
543
+ const verified = row.deliveredBy === 'mailer'
544
+ if (row.kind === 'add') {
545
+ const standing = db.prepare('SELECT 1 AS ok FROM account_emails WHERE user_id = ? AND email = ?').get(row.userId, row.newEmail)
546
+ if (!standing) return { kind: 'unknown' }
547
+ if (verified) markAccountEmailVerified(row.userId, row.newEmail)
548
+ return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
549
+ }
471
550
  const taken = db.prepare('SELECT id FROM users WHERE email = ?').get(row.newEmail) as { id: string } | undefined
472
551
  if (taken) return { kind: 'conflict' }
473
- const verified = row.deliveredBy === 'mailer'
552
+ const takenAdditional = db.prepare('SELECT user_id FROM account_emails WHERE email = ?').get(row.newEmail) as { user_id: string } | undefined
553
+ if (takenAdditional) return { kind: 'conflict' }
474
554
  db.prepare(
475
555
  `UPDATE users SET email = ?, email_verified_at = ${verified ? "datetime('now')" : 'NULL'} WHERE id = ?`,
476
556
  ).run(row.newEmail, row.userId)
477
557
  return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
478
558
  }
559
+
560
+ // ── multiple emails per account (TODO.identity-features/01) ──────────
561
+
562
+ function toAccountEmail(row: Record<string, unknown>, isPrimary: boolean): AccountEmail {
563
+ return {
564
+ userId: row.user_id as string,
565
+ email: row.email as string,
566
+ verifiedAt: (row.verified_at as string | null) ?? null,
567
+ isPrimary,
568
+ addedBy: (row.added_by as string | null) ?? null,
569
+ createdAt: row.created_at as string,
570
+ }
571
+ }
572
+
573
+ /** The account's addresses: the PRIMARY first (the users row's email +
574
+ * its verification stamp), then the additional account_emails rows
575
+ * (oldest first). */
576
+ export function listAccountEmails(userId: string): AccountEmail[] {
577
+ const db = getDb()
578
+ const primary = db.prepare(
579
+ 'SELECT id AS user_id, email, email_verified_at AS verified_at, created_at FROM users WHERE id = ?',
580
+ ).get(userId) as Record<string, unknown> | undefined
581
+ const rows = db.prepare(
582
+ 'SELECT * FROM account_emails WHERE user_id = ? ORDER BY created_at, email',
583
+ ).all(userId) as Array<Record<string, unknown>>
584
+ const out: AccountEmail[] = []
585
+ if (primary) out.push(toAccountEmail(primary, true))
586
+ out.push(...rows.map(r => toAccountEmail(r, false)))
587
+ return out
588
+ }
589
+
590
+ /** Resolve the account by ANY of its addresses (normalized): the primary
591
+ * always names it (and the primary owner always wins — the
592
+ * deterministic rule); an additional ONLY when verified. */
593
+ export function findUserByAnyEmail(email: string): AuthUserPayload | null {
594
+ const db = getDb()
595
+ const normalized = email.trim().toLowerCase()
596
+ const primary = db.prepare('SELECT * FROM users WHERE email = ?').get(normalized) as Record<string, unknown> | undefined
597
+ if (primary) return accountPayload(primary)
598
+ const owner = db.prepare(
599
+ 'SELECT user_id FROM account_emails WHERE email = ? AND verified_at IS NOT NULL',
600
+ ).get(normalized) as { user_id: string } | undefined
601
+ if (!owner) return null
602
+ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(owner.user_id) as Record<string, unknown> | undefined
603
+ return user ? accountPayload(user) : null
604
+ }
605
+
606
+ /** Add an ADDITIONAL address (normalized lowercase; the row lands
607
+ * UNVERIFIED). The account's own existing row answers 'present' (the
608
+ * idempotent re-add); any other hold of the address — a primary
609
+ * anywhere (this account's included) or another account's additional —
610
+ * answers 'conflict'. The unique index is the race backstop. */
611
+ export function addAccountEmail(userId: string, email: string, addedBy?: string | null): AddAccountEmailResult {
612
+ const db = getDb()
613
+ const normalized = email.trim().toLowerCase()
614
+ const takenPrimary = db.prepare('SELECT id FROM users WHERE email = ?').get(normalized)
615
+ if (takenPrimary) return 'conflict'
616
+ const existing = db.prepare('SELECT user_id FROM account_emails WHERE email = ?').get(normalized) as { user_id: string } | undefined
617
+ if (existing) return existing.user_id === userId ? 'present' : 'conflict'
618
+ try {
619
+ db.prepare('INSERT INTO account_emails (user_id, email, added_by) VALUES (?, ?, ?)').run(userId, normalized, addedBy ?? null)
620
+ } catch (e) {
621
+ if (String((e as Error).message).includes('UNIQUE')) return 'conflict'
622
+ throw e
623
+ }
624
+ return 'added'
625
+ }
626
+
627
+ /** The verification ceremony's stamp on the account's OWN row: the
628
+ * guarded UPDATE flips verified_at, once. */
629
+ export function markAccountEmailVerified(userId: string, email: string): boolean {
630
+ return getDb().prepare(
631
+ "UPDATE account_emails SET verified_at = datetime('now') WHERE user_id = ? AND email = ? AND verified_at IS NULL",
632
+ ).run(userId, email.trim().toLowerCase()).changes > 0
633
+ }
634
+
635
+ /** Promote a VERIFIED additional to primary: the promoted address
636
+ * becomes users.email with its verification stamp; the outgoing
637
+ * primary takes the row's place in account_emails with ITS stamp (it
638
+ * stays a verified additional — sign-in by it keeps working). */
639
+ export function setPrimaryAccountEmail(userId: string, email: string): 'ok' | 'unknown' | 'unverified' {
640
+ const db = getDb()
641
+ const normalized = email.trim().toLowerCase()
642
+ const row = db.prepare('SELECT * FROM account_emails WHERE user_id = ? AND email = ?').get(userId, normalized) as Record<string, unknown> | undefined
643
+ if (!row) return 'unknown'
644
+ if (!row.verified_at) return 'unverified'
645
+ const current = db.prepare('SELECT email, email_verified_at FROM users WHERE id = ?').get(userId) as { email: string; email_verified_at: string | null } | undefined
646
+ if (!current) return 'unknown'
647
+ db.prepare('UPDATE users SET email = ?, email_verified_at = ? WHERE id = ?').run(normalized, row.verified_at as string, userId)
648
+ db.prepare('DELETE FROM account_emails WHERE user_id = ? AND email = ?').run(userId, normalized)
649
+ db.prepare('INSERT INTO account_emails (user_id, email, verified_at) VALUES (?, ?, ?)').run(userId, current.email, current.email_verified_at)
650
+ return 'ok'
651
+ }
652
+
653
+ /** Remove an ADDITIONAL address. The primary refuses honestly
654
+ * ('primary' — promote another address first). */
655
+ export function removeAccountEmail(userId: string, email: string): 'ok' | 'primary' | 'unknown' {
656
+ const db = getDb()
657
+ const normalized = email.trim().toLowerCase()
658
+ const current = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined
659
+ if (current?.email === normalized) return 'primary'
660
+ const res = db.prepare('DELETE FROM account_emails WHERE user_id = ? AND email = ?').run(userId, normalized)
661
+ return res.changes > 0 ? 'ok' : 'unknown'
662
+ }
@@ -444,11 +444,15 @@ CREATE INDEX IF NOT EXISTS idx_enrollment_tokens_user ON enrollment_tokens (user
444
444
  -- was displayed to the signed-in holder; completing it applies the change
445
445
  -- but the address stays unverified, honestly). A fresh request voids the
446
446
  -- account's earlier pending rows: only the newest link works.
447
+ -- TODO.identity-features/01: kind names the ceremony — 'change' (the
448
+ -- primary replacement above) or 'add' (the per-address verification of an
449
+ -- account_emails row; completion stamps the row's verified_at).
447
450
  CREATE TABLE IF NOT EXISTS email_change_tokens (
448
451
  token TEXT PRIMARY KEY,
449
452
  user_id TEXT NOT NULL REFERENCES users(id),
450
453
  new_email TEXT NOT NULL,
451
454
  delivered_by TEXT NOT NULL DEFAULT 'shown',
455
+ kind TEXT NOT NULL DEFAULT 'change',
452
456
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
453
457
  expires_at TEXT NOT NULL,
454
458
  consumed_at TEXT
@@ -849,3 +853,25 @@ CREATE TABLE IF NOT EXISTS oidc_consent_grants (
849
853
  CREATE UNIQUE INDEX IF NOT EXISTS idx_oidc_consent_grants_live
850
854
  ON oidc_consent_grants (user_id, client_id, scope) WHERE revoked_at IS NULL;
851
855
  CREATE INDEX IF NOT EXISTS idx_oidc_consent_grants_user ON oidc_consent_grants (user_id);
856
+
857
+ -- ═══════════════════════════════════════════════════════════════════
858
+ -- Multiple emails per account (TODO.identity-features/01): the account
859
+ -- carries a primary + additional addresses. The PRIMARY stays users.email
860
+ -- (the OIDC `email` claim never changes shape); account_emails carries
861
+ -- the ADDITIONAL addresses, one row per (account, address), verified_at
862
+ -- NULL until the per-address ceremony (email_change_tokens kind 'add')
863
+ -- proves the mailbox. The unique index makes an additional address name
864
+ -- at most one account; the store's writes check across BOTH tables, and
865
+ -- the sign-in/recovery resolutions prefer the primary owner
866
+ -- deterministically. The D1 migration set carries the identical end
867
+ -- state (0022_account_emails.sql).
868
+ -- ═══════════════════════════════════════════════════════════════════
869
+ CREATE TABLE IF NOT EXISTS account_emails (
870
+ user_id TEXT NOT NULL REFERENCES users(id),
871
+ email TEXT NOT NULL,
872
+ verified_at TEXT,
873
+ added_by TEXT,
874
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
875
+ PRIMARY KEY (user_id, email)
876
+ );
877
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_account_emails_email ON account_emails (email);
@@ -144,6 +144,16 @@ function migrateAuthTables(db: Database.Database): void {
144
144
  if (!amrTokenCols.some(c => c.name === 'amr')) {
145
145
  db.exec('ALTER TABLE oidc_access_tokens ADD COLUMN amr TEXT')
146
146
  }
147
+ // TODO.identity-features/01 (multiple emails per account): the
148
+ // ceremony token's kind column arrives with migration 0022 — a dev
149
+ // file predating it grows the column here ('change' — every existing
150
+ // row is the legacy primary-replacement ceremony). The account_emails
151
+ // table itself arrives via schema.sql's CREATE IF NOT EXISTS on every
152
+ // boot.
153
+ const emailChangeCols = db.prepare('PRAGMA table_info(email_change_tokens)').all() as Array<{ name: string }>
154
+ if (emailChangeCols.length && !emailChangeCols.some(c => c.name === 'kind')) {
155
+ db.exec("ALTER TABLE email_change_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'change'")
156
+ }
147
157
  }
148
158
 
149
159
  // AuthUserPayload lives in ./backend (see the re-export above).
@@ -143,6 +143,7 @@ import {
143
143
  upsertIdentityProvider,
144
144
  } from './sqlite/upstream-store'
145
145
  import {
146
+ addAccountEmail,
146
147
  completeEmailChange,
147
148
  completeEnrollment,
148
149
  countSignInMethods,
@@ -155,24 +156,29 @@ import {
155
156
  deletePasswordHash,
156
157
  deleteSessionById,
157
158
  eraseOpAccount,
159
+ findUserByAnyEmail,
158
160
  getEmailChangeToken,
159
161
  getEnrollmentToken,
160
162
  getOpClientRoles,
161
163
  getPasswordLogin,
162
164
  getPendingEmailChange,
163
165
  lastAccountSignIns,
166
+ listAccountEmails,
164
167
  listAllOpClientRoles,
165
168
  listOpClientRoles,
166
169
  listOpLiveSessions,
167
170
  listUserSessions,
171
+ markAccountEmailVerified,
172
+ removeAccountEmail,
168
173
  revokeOpUserCredentials,
169
174
  setOpClientRoles,
170
175
  setPasswordHash,
176
+ setPrimaryAccountEmail,
171
177
  setUserAvatar,
172
178
  updateOpAccount,
173
179
  updateUserName,
174
180
  } from './sqlite/op-accounts-store'
175
- import { installStore, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EventKeyFilter, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type NotifyDelivery, type NotifyDeliveryStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcConsentGrant, type OidcKeyRow, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PersonalAccessToken, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
181
+ import { installStore, type AccountEmail, type AddAccountEmailResult, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EventKeyFilter, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type NotifyDelivery, type NotifyDeliveryStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcConsentGrant, type OidcKeyRow, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PersonalAccessToken, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
176
182
  import {
177
183
  advanceWebauthnCounter,
178
184
  consumeMfaPending,
@@ -781,6 +787,7 @@ export function createSqliteServerStore(): ServerStore {
781
787
  userId: string
782
788
  newEmail: string
783
789
  deliveredBy: 'mailer' | 'shown'
790
+ kind?: 'change' | 'add'
784
791
  ttlMs: number
785
792
  }): Promise<EmailChangeToken> {
786
793
  return createEmailChangeToken(input)
@@ -795,6 +802,26 @@ export function createSqliteServerStore(): ServerStore {
795
802
  return completeEmailChange(token)
796
803
  },
797
804
 
805
+ // ── multiple emails per account (TODO.identity-features/01) ──
806
+ async listAccountEmails(userId: string): Promise<AccountEmail[]> {
807
+ return listAccountEmails(userId)
808
+ },
809
+ async findUserByAnyEmail(email: string): Promise<AuthUserPayload | null> {
810
+ return findUserByAnyEmail(email)
811
+ },
812
+ async addAccountEmail(userId: string, email: string, addedBy?: string | null): Promise<AddAccountEmailResult> {
813
+ return addAccountEmail(userId, email, addedBy)
814
+ },
815
+ async markAccountEmailVerified(userId: string, email: string): Promise<boolean> {
816
+ return markAccountEmailVerified(userId, email)
817
+ },
818
+ async setPrimaryAccountEmail(userId: string, email: string): Promise<'ok' | 'unknown' | 'unverified'> {
819
+ return setPrimaryAccountEmail(userId, email)
820
+ },
821
+ async removeAccountEmail(userId: string, email: string): Promise<'ok' | 'primary' | 'unknown'> {
822
+ return removeAccountEmail(userId, email)
823
+ },
824
+
798
825
  // ── strong authentication: the factor registry (TODO.identity-sso/02 + /03) ──
799
826
  async createWebauthnChallenge(input: {
800
827
  challenge: string
package/src/store.ts CHANGED
@@ -590,7 +590,10 @@ export interface OpClientRoleAssignment {
590
590
  * account's pending ceremony state). TODO.identity-features/08 adds
591
591
  * `personalAccessTokens`: the developer-token rows (a dead account's
592
592
  * tokens die with it). TODO.identity-features/12 adds `consentGrants`:
593
- * the remembered consent rows (a dead account's grants die with it). */
593
+ * the remembered consent rows (a dead account's grants die with it).
594
+ * TODO.identity-features/01 adds `emails`: the additional-address rows
595
+ * (every address of the account goes — the tombstone's primary is the
596
+ * anonymized users.email). */
594
597
  export interface OpAccountErasure {
595
598
  sessions: number
596
599
  accessTokens: number
@@ -603,21 +606,28 @@ export interface OpAccountErasure {
603
606
  factors: number
604
607
  personalAccessTokens: number
605
608
  consentGrants: number
609
+ emails: number
606
610
  }
607
611
 
608
612
  // ── the account console (TODO.identity/06) ───────────────────────────
609
613
 
610
- /** The verify-new-email ceremony's row (the enrollment link's doctrine:
614
+ /** The verify-an-address ceremony's row (the enrollment link's doctrine:
611
615
  * a 256-bit random token backed by the D1 row; one-time, 24 h).
612
616
  * deliveredBy records the channel the link traveled: 'mailer' (sent to
613
617
  * the NEW address; completing verifies it) or 'shown' (no mailer
614
618
  * configured, the link was displayed to the signed-in holder; the change
615
- * applies but the address stays unverified, honestly). */
619
+ * applies but the address stays unverified, honestly).
620
+ * TODO.identity-features/01: kind names the ceremony — 'change' (the
621
+ * primary-address replacement; completion moves users.email) or 'add'
622
+ * (the per-address verification of an account_emails row; completion
623
+ * stamps the row's verified_at). Rows predating the kind column read
624
+ * 'change' (the migration's default). */
616
625
  export interface EmailChangeToken {
617
626
  token: string
618
627
  userId: string
619
628
  newEmail: string
620
629
  deliveredBy: 'mailer' | 'shown'
630
+ kind: 'change' | 'add'
621
631
  createdAt: string
622
632
  expiresAt: string
623
633
  consumedAt: string | null
@@ -635,6 +645,38 @@ export type CompleteEmailChangeResult =
635
645
  * (the token is burned; the change must start over). */
636
646
  | { kind: 'conflict' }
637
647
 
648
+ // ── multiple emails per account (TODO.identity-features/01) ──────────
649
+
650
+ /** One of the account's addresses. The PRIMARY is the users row's email
651
+ * (isPrimary — the OIDC `email` claim's source, never an
652
+ * account_emails row); the ADDITIONAL addresses are the account_emails
653
+ * rows. verifiedAt NULL = the mailbox is unproven: an unverified
654
+ * additional NEVER names the account (not to sign-in, not to recovery,
655
+ * never a notification's target). */
656
+ export interface AccountEmail {
657
+ userId: string
658
+ email: string
659
+ verifiedAt: string | null
660
+ isPrimary: boolean
661
+ /** Who added the row (the holder's session email, an admin's); NULL on
662
+ * the primary line (the users row carries no such provenance). */
663
+ addedBy: string | null
664
+ createdAt: string
665
+ }
666
+
667
+ /** The additional-address add's honest outcomes. */
668
+ export type AddAccountEmailResult =
669
+ /** The row landed (unverified — the verification ceremony follows). */
670
+ | 'added'
671
+ /** The account ALREADY carries the address as an additional (the add
672
+ * is an idempotent no-op; the route re-sends the verification for an
673
+ * unverified row). */
674
+ | 'present'
675
+ /** Another account holds the address (as its primary or an
676
+ * additional), or it IS this account's primary — an address names at
677
+ * most one account across the estate. */
678
+ | 'conflict'
679
+
638
680
  // ── strong authentication: the factor registry (TODO.identity-sso/02 + /03)
639
681
 
640
682
  /** A registered passkey (the webauthn_credentials row). publicKeyCose is
@@ -1581,7 +1623,13 @@ export interface ServerStore {
1581
1623
  /** The password sign-in's lookup: the credential + the account's
1582
1624
  * active flag by email (normalized). Null = no such credential — the
1583
1625
  * route still runs one full-cost verify (the timing-shape rule,
1584
- * auth/passwords.ts). The hash never leaves the server. */
1626
+ * auth/passwords.ts). The hash never leaves the server.
1627
+ * TODO.identity-features/01: the address resolves by ANY of the
1628
+ * account's VERIFIED addresses — the primary (users.email) or a
1629
+ * proven account_emails row. An unverified additional never resolves
1630
+ * (the mailbox is unproven), and a primary owner always wins over an
1631
+ * additional row (the deterministic rule — a stray duplicate shadows,
1632
+ * never ambiguates). */
1585
1633
  getPasswordLogin(email: string): Promise<{ userId: string; hash: string; active: boolean } | null>
1586
1634
  /** Set/replace the account's password credential (enrollment
1587
1635
  * completion, the account page's change). */
@@ -1680,15 +1728,20 @@ export interface ServerStore {
1680
1728
  * "sign out everywhere else" action + the password change's
1681
1729
  * best-practice revocation). Answers the revoked count. */
1682
1730
  deleteOtherSessions(userId: string, keepToken: string): Promise<number>
1683
- /** Mint the verify-new-email ceremony's token. A fresh request VOIDS
1684
- * the account's earlier pending rows (only the newest link works).
1685
- * deliveredBy is stamped at request time and decides whether
1686
- * completion may verify the address. */
1731
+ /** Mint the verify-an-address ceremony's token. The void rule keeps
1732
+ * one live link per ceremony target: a 'change' request VOIDS the
1733
+ * account's earlier pending 'change' rows (only the newest change
1734
+ * link works the pre-01 doctrine); an 'add' request voids the
1735
+ * account's earlier pending 'add' rows FOR THE SAME address (other
1736
+ * addresses' links stand). deliveredBy is stamped at request time and
1737
+ * decides whether completion may verify the address. kind defaults
1738
+ * 'change'. */
1687
1739
  createEmailChangeToken(input: {
1688
1740
  token: string
1689
1741
  userId: string
1690
1742
  newEmail: string
1691
1743
  deliveredBy: 'mailer' | 'shown'
1744
+ kind?: 'change' | 'add'
1692
1745
  ttlMs: number
1693
1746
  }): Promise<EmailChangeToken>
1694
1747
  getEmailChangeToken(token: string): Promise<EmailChangeToken | null>
@@ -1696,12 +1749,51 @@ export interface ServerStore {
1696
1749
  * so the console can show it. */
1697
1750
  getPendingEmailChange(userId: string): Promise<EmailChangeToken | null>
1698
1751
  /** Complete the ceremony: consume the token ATOMICALLY (a presented
1699
- * link works exactly once, expired or not), judge the expiry, re-check
1700
- * the address's uniqueness (a conflict burns the token honestly), then
1701
- * move the account's email. verified = the token traveled by mailer
1702
- * (mailbox proven); a shown link never verifies. */
1752
+ * link works exactly once, expired or not), judge the expiry, then
1753
+ * act on the kind: 'change' re-checks the address's uniqueness across
1754
+ * BOTH address tables (a conflict burns the token honestly) and moves
1755
+ * the account's primary (users.email); 'add' stamps the
1756
+ * account_emails row's verified_at (a row removed between request and
1757
+ * completion answers 'unknown'). verified = the token traveled by
1758
+ * mailer (mailbox proven); a shown link never verifies. */
1703
1759
  completeEmailChange(token: string): Promise<CompleteEmailChangeResult>
1704
1760
 
1761
+ // ── multiple emails per account (TODO.identity-features/01) ──
1762
+ /** The account's addresses, the PRIMARY first (the users row's email +
1763
+ * its verification stamp), then the additional account_emails rows
1764
+ * (oldest first). The console's emails section and the security-mail
1765
+ * fan-out read this. */
1766
+ listAccountEmails(userId: string): Promise<AccountEmail[]>
1767
+ /** Resolve the account by ANY of its addresses (normalized): the
1768
+ * primary always names it; an additional ONLY when verified (an
1769
+ * unproven address never names the account — not to sign-in, not to
1770
+ * recovery). The primary owner wins over an additional row (the
1771
+ * deterministic rule). */
1772
+ findUserByAnyEmail(email: string): Promise<AuthUserPayload | null>
1773
+ /** Add an ADDITIONAL address (normalized lowercase; the row lands
1774
+ * UNVERIFIED — the verify-an-address ceremony's kind 'add' token
1775
+ * proves the mailbox). The tagged result names the outcome; the
1776
+ * unique index + the cross-table check make an address name at most
1777
+ * one account. */
1778
+ addAccountEmail(userId: string, email: string, addedBy?: string | null): Promise<AddAccountEmailResult>
1779
+ /** The verification ceremony's stamp on the account's OWN additional
1780
+ * row (the kind 'add' completion): verified_at flips, once (the
1781
+ * guarded update). Answers false when no such row stands. */
1782
+ markAccountEmailVerified(userId: string, email: string): Promise<boolean>
1783
+ /** Promote a VERIFIED additional to primary: the promoted address
1784
+ * becomes users.email (its verification stamp travels, so the claims
1785
+ * stay verified), and the previous primary takes the row's place in
1786
+ * account_emails with ITS stamp (it stays a verified additional —
1787
+ * sign-in by it keeps working). 'unknown' = no such additional row;
1788
+ * 'unverified' = the row stands unproven (a primary is always
1789
+ * proven). */
1790
+ setPrimaryAccountEmail(userId: string, email: string): Promise<'ok' | 'unknown' | 'unverified'>
1791
+ /** Remove an ADDITIONAL address. 'primary' = the address IS the
1792
+ * account's primary (promote another first — the primary is never
1793
+ * removed from under the holder); 'unknown' = no such additional
1794
+ * row. */
1795
+ removeAccountEmail(userId: string, email: string): Promise<'ok' | 'primary' | 'unknown'>
1796
+
1705
1797
  // ── strong authentication: the factor registry (TODO.identity-sso/02 + /03) ──
1706
1798
  /** The one-time WebAuthn ceremony challenge. The challenge value IS the
1707
1799
  * key (the clientDataJSON binds it); expires_at = now + ttlMs. */