@oimlsmart/platform-server 0.1.7 → 0.1.9

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.
@@ -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).
@@ -893,6 +903,13 @@ export function listOrgMembers(orgId: string): OrgMembership[] {
893
903
  return rows.map(membershipPayload)
894
904
  }
895
905
 
906
+ export function listAllOrgMemberships(): OrgMembership[] {
907
+ const rows = getDb().prepare(
908
+ 'SELECT * FROM org_memberships ORDER BY org_id, created_at',
909
+ ).all() as OrgMembershipRow[]
910
+ return rows.map(membershipPayload)
911
+ }
912
+
896
913
  export function getOrgMembership(userId: string, orgId: string): OrgMembership | null {
897
914
  const row = getDb().prepare('SELECT * FROM org_memberships WHERE user_id = ? AND org_id = ?')
898
915
  .get(userId, orgId) as OrgMembershipRow | undefined
@@ -56,6 +56,7 @@ import {
56
56
  listInstrumentRegistrationsForCertificate,
57
57
  listInstrumentRegistrationsForHolder,
58
58
  listOrgJoinRequests,
59
+ listAllOrgMemberships,
59
60
  listOrgMembers,
60
61
  listOrgMemberships,
61
62
  listOrgRegistryOrgs,
@@ -143,6 +144,7 @@ import {
143
144
  upsertIdentityProvider,
144
145
  } from './sqlite/upstream-store'
145
146
  import {
147
+ addAccountEmail,
146
148
  completeEmailChange,
147
149
  completeEnrollment,
148
150
  countSignInMethods,
@@ -155,24 +157,29 @@ import {
155
157
  deletePasswordHash,
156
158
  deleteSessionById,
157
159
  eraseOpAccount,
160
+ findUserByAnyEmail,
158
161
  getEmailChangeToken,
159
162
  getEnrollmentToken,
160
163
  getOpClientRoles,
161
164
  getPasswordLogin,
162
165
  getPendingEmailChange,
163
166
  lastAccountSignIns,
167
+ listAccountEmails,
164
168
  listAllOpClientRoles,
165
169
  listOpClientRoles,
166
170
  listOpLiveSessions,
167
171
  listUserSessions,
172
+ markAccountEmailVerified,
173
+ removeAccountEmail,
168
174
  revokeOpUserCredentials,
169
175
  setOpClientRoles,
170
176
  setPasswordHash,
177
+ setPrimaryAccountEmail,
171
178
  setUserAvatar,
172
179
  updateOpAccount,
173
180
  updateUserName,
174
181
  } 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'
182
+ 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
183
  import {
177
184
  advanceWebauthnCounter,
178
185
  consumeMfaPending,
@@ -414,6 +421,9 @@ export function createSqliteServerStore(): ServerStore {
414
421
  async listOrgMembers(orgId: string): Promise<OrgMembership[]> {
415
422
  return listOrgMembers(orgId)
416
423
  },
424
+ async listAllOrgMemberships(): Promise<OrgMembership[]> {
425
+ return listAllOrgMemberships()
426
+ },
417
427
  async getOrgMembership(userId: string, orgId: string): Promise<OrgMembership | null> {
418
428
  return getOrgMembership(userId, orgId)
419
429
  },
@@ -781,6 +791,7 @@ export function createSqliteServerStore(): ServerStore {
781
791
  userId: string
782
792
  newEmail: string
783
793
  deliveredBy: 'mailer' | 'shown'
794
+ kind?: 'change' | 'add'
784
795
  ttlMs: number
785
796
  }): Promise<EmailChangeToken> {
786
797
  return createEmailChangeToken(input)
@@ -795,6 +806,26 @@ export function createSqliteServerStore(): ServerStore {
795
806
  return completeEmailChange(token)
796
807
  },
797
808
 
809
+ // ── multiple emails per account (TODO.identity-features/01) ──
810
+ async listAccountEmails(userId: string): Promise<AccountEmail[]> {
811
+ return listAccountEmails(userId)
812
+ },
813
+ async findUserByAnyEmail(email: string): Promise<AuthUserPayload | null> {
814
+ return findUserByAnyEmail(email)
815
+ },
816
+ async addAccountEmail(userId: string, email: string, addedBy?: string | null): Promise<AddAccountEmailResult> {
817
+ return addAccountEmail(userId, email, addedBy)
818
+ },
819
+ async markAccountEmailVerified(userId: string, email: string): Promise<boolean> {
820
+ return markAccountEmailVerified(userId, email)
821
+ },
822
+ async setPrimaryAccountEmail(userId: string, email: string): Promise<'ok' | 'unknown' | 'unverified'> {
823
+ return setPrimaryAccountEmail(userId, email)
824
+ },
825
+ async removeAccountEmail(userId: string, email: string): Promise<'ok' | 'primary' | 'unknown'> {
826
+ return removeAccountEmail(userId, email)
827
+ },
828
+
798
829
  // ── strong authentication: the factor registry (TODO.identity-sso/02 + /03) ──
799
830
  async createWebauthnChallenge(input: {
800
831
  challenge: string