@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.
- package/migrations/0022_account_emails.sql +45 -0
- package/package.json +1 -1
- package/src/store/d1.ts +563 -309
- package/src/store/sqlite/op-accounts-store.ts +205 -21
- package/src/store/sqlite/schema.sql +26 -0
- package/src/store/sqlite/store.ts +17 -0
- package/src/store/sqlite.ts +32 -1
- package/src/store.ts +109 -12
|
@@ -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
|
|
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(
|
|
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
|
|
423
|
-
*
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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),
|
|
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,
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
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
|
|
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
|
package/src/store/sqlite.ts
CHANGED
|
@@ -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
|