@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.
- package/migrations/0022_account_emails.sql +45 -0
- package/package.json +1 -1
- package/src/store/d1.ts +228 -20
- package/src/store/sqlite/op-accounts-store.ts +205 -21
- package/src/store/sqlite/schema.sql +26 -0
- package/src/store/sqlite/store.ts +10 -0
- package/src/store/sqlite.ts +28 -1
- package/src/store.ts +104 -12
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1727
|
-
*
|
|
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.
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
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
|
|
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,
|
|
1766
|
-
*
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
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
|
|
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
|
|
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).
|
package/src/store/sqlite.ts
CHANGED
|
@@ -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-
|
|
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-
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
1686
|
-
*
|
|
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,
|
|
1700
|
-
*
|
|
1701
|
-
*
|
|
1702
|
-
* (
|
|
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. */
|