@oimlsmart/platform-server 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ -- Migration 0021 — the remembered consent grants (TODO.identity-features/12):
2
+ -- the OP remembers the account holder's "Allow" per (user, client, scope
3
+ -- set), so a repeat authorization the grant COVERS skips the consent page
4
+ -- (the OIDC-correct behavior — the page shows again only when the request
5
+ -- carries prompt=consent, when the granted set no longer covers the asked
6
+ -- scopes, or when the holder revoked the access from the account console).
7
+ --
8
+ -- The doctrines:
9
+ -- - ONE LIVE grant per (user_id, client_id, scope): the partial unique
10
+ -- index keys the live rows only (a live grant = revoked_at IS NULL) —
11
+ -- a revoked triple's re-allow lands a FRESH row and the history keeps
12
+ -- the revoked one;
13
+ -- - scope is the CANONICAL spelling (the scope SET, space-joined,
14
+ -- deduped, sorted — store.ts's normalizeOidcScopeSet): 'profile openid'
15
+ -- and 'openid profile' are the same grant, so the unique triple holds
16
+ -- honestly;
17
+ -- - the skip check's COVERAGE math (the granted set ⊇ the requested set)
18
+ -- is the store's (consentGrantCovers over the live rows), never a LIKE
19
+ -- scan in SQL;
20
+ -- - revocation flips revoked_at (the row STAYS — the audit chain carries
21
+ -- the grant + the revoke, the row is their resolvable record); the
22
+ -- account erasure removes the rows outright (a dead account's grants
23
+ -- die with it — the personal_access_tokens doctrine, migration 0020).
24
+ -- schema.sql carries the same end state for fresh databases —
25
+ -- test/migrations.test.ts pins the UNION of every migration to
26
+ -- schema.sql's CREATE set.
27
+
28
+ CREATE TABLE IF NOT EXISTS oidc_consent_grants (
29
+ id TEXT PRIMARY KEY,
30
+ user_id TEXT NOT NULL REFERENCES users(id),
31
+ client_id TEXT NOT NULL,
32
+ -- The granted scope set, the canonical space-joined spelling
33
+ -- (normalizeOidcScopeSet).
34
+ scope TEXT NOT NULL,
35
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
36
+ revoked_at TEXT
37
+ );
38
+ -- One LIVE grant per (user, client, scope set) — the predicate keeps the
39
+ -- revoked rows out of the index, so the re-allow after a revoke inserts
40
+ -- cleanly.
41
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_oidc_consent_grants_live
42
+ ON oidc_consent_grants (user_id, client_id, scope) WHERE revoked_at IS NULL;
43
+ -- The account console's "apps they can access" read.
44
+ CREATE INDEX IF NOT EXISTS idx_oidc_consent_grants_user ON oidc_consent_grants (user_id);
@@ -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.6",
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,
@@ -47,7 +49,10 @@ import {
47
49
  type OidcClient,
48
50
  type OidcClientLaunch,
49
51
  type OidcCode,
52
+ type OidcConsentGrant,
50
53
  type OidcKeyRow,
54
+ consentGrantCovers,
55
+ normalizeOidcScopeSet,
51
56
  type OpAccountErasure,
52
57
  type OpClientRoleAssignment,
53
58
  type OpLiveSession,
@@ -478,6 +483,65 @@ export class D1ServerStore implements ServerStore {
478
483
  return this.personalAccessTokenSupportEnsured
479
484
  }
480
485
 
486
+ // TODO.identity-features/12 (the remembered consent grants): the
487
+ // oidc_consent_grants table arrives with migration 0021 — a dev D1
488
+ // migrated from before it lacks the table, so the grant methods ensure
489
+ // it defensively (the ensurePersonalAccessTokenSupport posture,
490
+ // memoized per store).
491
+ private consentGrantSupportEnsured: Promise<void> | null = null
492
+
493
+ private ensureConsentGrantSupport(): Promise<void> {
494
+ if (!this.consentGrantSupportEnsured) {
495
+ this.consentGrantSupportEnsured = (async () => {
496
+ await this.db.prepare(
497
+ `CREATE TABLE IF NOT EXISTS oidc_consent_grants (
498
+ id TEXT PRIMARY KEY,
499
+ user_id TEXT NOT NULL REFERENCES users(id),
500
+ client_id TEXT NOT NULL,
501
+ scope TEXT NOT NULL,
502
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
503
+ revoked_at TEXT
504
+ )`,
505
+ ).run()
506
+ await this.db.prepare(
507
+ 'CREATE UNIQUE INDEX IF NOT EXISTS idx_oidc_consent_grants_live ON oidc_consent_grants (user_id, client_id, scope) WHERE revoked_at IS NULL',
508
+ ).run()
509
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_oidc_consent_grants_user ON oidc_consent_grants (user_id)').run()
510
+ })()
511
+ }
512
+ return this.consentGrantSupportEnsured
513
+ }
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
+
481
545
  // ── users / sessions ─────────────────────────────────────────────
482
546
 
483
547
  async seedDemoAccounts(): Promise<void> {
@@ -1125,6 +1189,78 @@ export class D1ServerStore implements ServerStore {
1125
1189
  ).run()
1126
1190
  }
1127
1191
 
1192
+ // ── the remembered consent grants (TODO.identity-features/12) ─────
1193
+
1194
+ private static toConsentGrant(row: Record<string, unknown>): OidcConsentGrant {
1195
+ return {
1196
+ id: row.id as string,
1197
+ userId: row.user_id as string,
1198
+ clientId: row.client_id as string,
1199
+ scope: row.scope as string,
1200
+ createdAt: D1ServerStore.storeTimeToIso(row.created_at as string)!,
1201
+ revokedAt: D1ServerStore.storeTimeToIso((row.revoked_at as string | null) ?? null),
1202
+ }
1203
+ }
1204
+
1205
+ async getConsentGrant(userId: string, clientId: string, scope: string): Promise<OidcConsentGrant | null> {
1206
+ await this.ensureConsentGrantSupport()
1207
+ // The skip check's coverage math is the store.ts helper's, never a
1208
+ // LIKE scan: the account's live rows for the client, the freshest
1209
+ // covering grant wins.
1210
+ const res = await this.stmt(
1211
+ 'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND client_id = ? AND revoked_at IS NULL ORDER BY created_at DESC, rowid DESC',
1212
+ userId, clientId,
1213
+ ).all<Record<string, unknown>>()
1214
+ for (const row of res.results) {
1215
+ if (consentGrantCovers(row.scope as string, scope)) return D1ServerStore.toConsentGrant(row)
1216
+ }
1217
+ return null
1218
+ }
1219
+
1220
+ async recordConsentGrant(input: { userId: string; clientId: string; scope: string }): Promise<OidcConsentGrant> {
1221
+ await this.ensureConsentGrantSupport()
1222
+ const scope = normalizeOidcScopeSet(input.scope)
1223
+ // The upsert targets the partial unique index: a live triple's row
1224
+ // refreshes its stamp (the re-affirmed consent); a REVOKED triple's
1225
+ // re-allow falls out of the index's predicate and inserts FRESH —
1226
+ // the history survives.
1227
+ await this.stmt(
1228
+ `INSERT INTO oidc_consent_grants (id, user_id, client_id, scope)
1229
+ VALUES (?, ?, ?, ?)
1230
+ ON CONFLICT (user_id, client_id, scope) WHERE revoked_at IS NULL
1231
+ DO UPDATE SET created_at = datetime('now')`,
1232
+ crypto.randomUUID(), input.userId, input.clientId, scope,
1233
+ ).run()
1234
+ const row = await this.stmt(
1235
+ 'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND client_id = ? AND scope = ? AND revoked_at IS NULL',
1236
+ input.userId, input.clientId, scope,
1237
+ ).first<Record<string, unknown>>()
1238
+ if (!row) throw new Error('recordConsentGrant: the upsert left no live row')
1239
+ return D1ServerStore.toConsentGrant(row)
1240
+ }
1241
+
1242
+ async listConsentGrants(userId: string): Promise<OidcConsentGrant[]> {
1243
+ await this.ensureConsentGrantSupport()
1244
+ // The console's list: the LIVE grants only (the revoked rows ride the
1245
+ // audit chain), newest first — created_at is second-resolution, the
1246
+ // rowid breaks the tie.
1247
+ const res = await this.stmt(
1248
+ 'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC, rowid DESC',
1249
+ userId,
1250
+ ).all<Record<string, unknown>>()
1251
+ return res.results.map(D1ServerStore.toConsentGrant)
1252
+ }
1253
+
1254
+ /** The guarded revoke: the owner's LIVE row flips, once. */
1255
+ async revokeConsentGrant(id: string, userId: string): Promise<boolean> {
1256
+ await this.ensureConsentGrantSupport()
1257
+ const res = await this.stmt(
1258
+ "UPDATE oidc_consent_grants SET revoked_at = datetime('now') WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
1259
+ id, userId,
1260
+ ).run()
1261
+ return (res.meta.changes ?? 0) > 0
1262
+ }
1263
+
1128
1264
  // ── the upstream providers (TODO.identity/08) ─────────────────────
1129
1265
  // The provider + link rows port directly (D1 is SQLite).
1130
1266
 
@@ -1270,6 +1406,13 @@ export class D1ServerStore implements ServerStore {
1270
1406
  createdBy?: string | null
1271
1407
  }): Promise<UserAdminRow | null> {
1272
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
1273
1416
  try {
1274
1417
  await this.stmt(
1275
1418
  "INSERT INTO users (id, email, name, provider, role) VALUES (?, ?, ?, 'password', ?)",
@@ -1284,14 +1427,28 @@ export class D1ServerStore implements ServerStore {
1284
1427
  }
1285
1428
 
1286
1429
  /** The password sign-in's lookup: the credential + the active flag, by
1287
- * (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. */
1288
1435
  async getPasswordLogin(email: string): Promise<{ userId: string; hash: string; active: boolean } | null> {
1289
- const row = await this.stmt(
1436
+ await this.ensureAccountEmailSupport()
1437
+ const normalized = email.trim().toLowerCase()
1438
+ let row = await this.stmt(
1290
1439
  `SELECT u.id AS user_id, u.active AS active, p.hash AS hash
1291
1440
  FROM users u JOIN passwords p ON p.user_id = u.id
1292
1441
  WHERE u.email = ?`,
1293
- email.trim().toLowerCase(),
1442
+ normalized,
1294
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
+ }
1295
1452
  if (!row) return null
1296
1453
  return { userId: row.user_id, hash: row.hash, active: row.active !== 0 }
1297
1454
  }
@@ -1512,6 +1669,14 @@ export class D1ServerStore implements ServerStore {
1512
1669
  // again).
1513
1670
  await this.ensurePersonalAccessTokenSupport()
1514
1671
  const personalAccessTokens = await this.stmt('DELETE FROM personal_access_tokens WHERE user_id = ?', userId).run()
1672
+ // TODO.identity-features/12: the remembered consent grants die with
1673
+ // the account (a tombstone never skips a consent page again).
1674
+ await this.ensureConsentGrantSupport()
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()
1515
1680
  await this.stmt(
1516
1681
  `UPDATE users SET
1517
1682
  email = ?, name = 'Deleted account', provider = 'erased',
@@ -1529,13 +1694,21 @@ export class D1ServerStore implements ServerStore {
1529
1694
  factors: (passkeys.meta.changes ?? 0) + (totp.meta.changes ?? 0) + (recovery.meta.changes ?? 0)
1530
1695
  + (challenges.meta.changes ?? 0) + (mfa.meta.changes ?? 0),
1531
1696
  personalAccessTokens: personalAccessTokens.meta.changes ?? 0,
1697
+ consentGrants: consentGrants.meta.changes ?? 0,
1698
+ emails: emails.meta.changes ?? 0,
1532
1699
  }
1533
1700
  }
1534
1701
 
1535
1702
  /** The registry's edit act (name/email). The email UNIQUE conflict
1536
- * 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. */
1537
1707
  async updateOpAccount(id: string, input: { name?: string; email?: string }): Promise<boolean> {
1538
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}`)
1539
1712
  try {
1540
1713
  // TODO.identity/06: an admin-set address never went through the
1541
1714
  // verify-new-email ceremony, so the verification state resets.
@@ -1608,29 +1781,46 @@ export class D1ServerStore implements ServerStore {
1608
1781
  userId: row.user_id as string,
1609
1782
  newEmail: row.new_email as string,
1610
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',
1611
1787
  createdAt: row.created_at as string,
1612
1788
  expiresAt: row.expires_at as string,
1613
1789
  consumedAt: (row.consumed_at as string | null) ?? null,
1614
1790
  }
1615
1791
  }
1616
1792
 
1617
- /** Mint the ceremony's token; the account's earlier pending rows are
1618
- * VOIDED first (only the newest link works). */
1793
+ /** Mint the ceremony's token. The void rule keeps ONE live link per
1794
+ * ceremony target: a 'change' request voids the account's earlier
1795
+ * pending 'change' rows (only the newest change link works — the
1796
+ * pre-01 doctrine); an 'add' request voids the account's earlier
1797
+ * pending 'add' rows FOR THE SAME address (other addresses' links
1798
+ * stand). */
1619
1799
  async createEmailChangeToken(input: {
1620
1800
  token: string
1621
1801
  userId: string
1622
1802
  newEmail: string
1623
1803
  deliveredBy: 'mailer' | 'shown'
1804
+ kind?: 'change' | 'add'
1624
1805
  ttlMs: number
1625
1806
  }): Promise<EmailChangeToken> {
1626
- await this.stmt(
1627
- "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND consumed_at IS NULL",
1628
- input.userId,
1629
- ).run()
1807
+ await this.ensureAccountEmailSupport()
1808
+ const kind = input.kind ?? 'change'
1809
+ if (kind === 'change') {
1810
+ await this.stmt(
1811
+ "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL",
1812
+ input.userId,
1813
+ ).run()
1814
+ } else {
1815
+ await this.stmt(
1816
+ "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'add' AND new_email = ? AND consumed_at IS NULL",
1817
+ input.userId, input.newEmail.trim().toLowerCase(),
1818
+ ).run()
1819
+ }
1630
1820
  const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
1631
1821
  await this.stmt(
1632
- 'INSERT INTO email_change_tokens (token, user_id, new_email, delivered_by, expires_at) VALUES (?, ?, ?, ?, ?)',
1633
- 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,
1634
1824
  ).run()
1635
1825
  return (await this.getEmailChangeToken(input.token))!
1636
1826
  }
@@ -1640,12 +1830,15 @@ export class D1ServerStore implements ServerStore {
1640
1830
  return row ? D1ServerStore.toEmailChangeToken(row) : null
1641
1831
  }
1642
1832
 
1643
- /** The account's pending change (the newest live row), so the console
1644
- * 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. */
1645
1837
  async getPendingEmailChange(userId: string): Promise<EmailChangeToken | null> {
1838
+ await this.ensureAccountEmailSupport()
1646
1839
  const row = await this.stmt(
1647
1840
  `SELECT * FROM email_change_tokens
1648
- 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')
1649
1842
  ORDER BY created_at DESC LIMIT 1`,
1650
1843
  userId,
1651
1844
  ).first<Record<string, unknown>>()
@@ -1653,20 +1846,34 @@ export class D1ServerStore implements ServerStore {
1653
1846
  }
1654
1847
 
1655
1848
  /** Complete the ceremony: consume ATOMICALLY (a presented link works
1656
- * exactly once, expired or not), judge the expiry, re-check the
1657
- * address's uniqueness (a conflict burns the token honestly), then
1658
- * move the account's email. A 'mailer'-delivered token verifies the
1659
- * address; a shown one never does. */
1849
+ * exactly once, expired or not), judge the expiry, then act on the
1850
+ * kind. 'change' (the pre-01 primary replacement): re-check the
1851
+ * address's uniqueness across BOTH address tables (a conflict burns
1852
+ * the token honestly an additional row anywhere holds the address
1853
+ * too, this account's included), then move users.email. 'add' (the
1854
+ * per-address verification): the account_emails row landed unverified
1855
+ * at the request; the completion stamps it (a row removed meanwhile
1856
+ * burns the link as 'unknown'). A 'mailer'-delivered token verifies
1857
+ * the address; a shown one never does. */
1660
1858
  async completeEmailChange(token: string): Promise<CompleteEmailChangeResult> {
1859
+ await this.ensureAccountEmailSupport()
1661
1860
  const res = await this.stmt(
1662
1861
  "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL", token,
1663
1862
  ).run()
1664
1863
  if ((res.meta.changes ?? 0) === 0) return { kind: 'unknown' }
1665
1864
  const row = (await this.getEmailChangeToken(token))!
1666
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
+ }
1667
1873
  const taken = await this.stmt('SELECT id FROM users WHERE email = ?', row.newEmail).first<{ id: string }>()
1668
1874
  if (taken) return { kind: 'conflict' }
1669
- const verified = row.deliveredBy === 'mailer'
1875
+ const takenAdditional = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', row.newEmail).first<{ user_id: string }>()
1876
+ if (takenAdditional) return { kind: 'conflict' }
1670
1877
  await this.stmt(
1671
1878
  `UPDATE users SET email = ?, email_verified_at = ${verified ? "datetime('now')" : 'NULL'} WHERE id = ?`,
1672
1879
  row.newEmail, row.userId,
@@ -1674,6 +1881,116 @@ export class D1ServerStore implements ServerStore {
1674
1881
  return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
1675
1882
  }
1676
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
+
1677
1994
  // ── strong authentication: the factor registry (TODO.identity-sso/02 + /03) ──
1678
1995
  // The same SQL as the SQLite half (store/sqlite/factors-store.ts): the
1679
1996
  // one-time consumes are guarded UPDATEs, the counter advance is the
@@ -0,0 +1,85 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The remembered consent grants' SQLite half (TODO.identity-features/12)
3
+ // — the sync implementations behind the ServerStore consent-grant
4
+ // methods (sqlite-server-store.ts delegates here one-for-one, the
5
+ // pat-store.ts pattern). The D1 store implements the same surface in
6
+ // d1.ts.
7
+ //
8
+ // The doctrines carried:
9
+ // - ONE LIVE grant per (user, client, scope set): the partial unique
10
+ // index (migration 0021) is the backstop; the record path's upsert
11
+ // targets it, so a repeat allow refreshes the live row's stamp and a
12
+ // REVOKED triple's re-allow lands a fresh row (the history survives);
13
+ // - the scope cell is the CANONICAL spelling (normalizeOidcScopeSet) —
14
+ // written normalized, read defensively (a hand-edited row still reads
15
+ // as a set, never trusted as a string match);
16
+ // - the revoke is a GUARDED update (the owner's live row flips, once);
17
+ // - the erasure (op-accounts-store.ts's eraseOpAccount) removes the
18
+ // rows outright — a dead account's grants die with it.
19
+ //
20
+ // NODE-ONLY: better-sqlite3 through ./store's getDb. The Worker bundle
21
+ // never sees this module.
22
+ // ═══════════════════════════════════════════════════════════════════
23
+
24
+ import { randomUUID } from 'crypto'
25
+ import { getDb } from './store'
26
+ import { storeTimeToIso } from './factors-store'
27
+ import { consentGrantCovers, normalizeOidcScopeSet, type OidcConsentGrant } from '../../store'
28
+
29
+ function toConsentGrant(row: Record<string, unknown>): OidcConsentGrant {
30
+ return {
31
+ id: row.id as string,
32
+ userId: row.user_id as string,
33
+ clientId: row.client_id as string,
34
+ scope: row.scope as string,
35
+ createdAt: storeTimeToIso(row.created_at as string)!,
36
+ revokedAt: storeTimeToIso((row.revoked_at as string | null) ?? null),
37
+ }
38
+ }
39
+
40
+ /** The authorize endpoint's remembered-consent read: the account's LIVE
41
+ * grant for this client whose scope set COVERS the requested set (the
42
+ * freshest first, when several cover). */
43
+ export function getConsentGrant(userId: string, clientId: string, scope: string): OidcConsentGrant | null {
44
+ const rows = getDb().prepare(
45
+ 'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND client_id = ? AND revoked_at IS NULL ORDER BY created_at DESC, rowid DESC',
46
+ ).all(userId, clientId) as Array<Record<string, unknown>>
47
+ for (const row of rows) {
48
+ if (consentGrantCovers(row.scope as string, scope)) return toConsentGrant(row)
49
+ }
50
+ return null
51
+ }
52
+
53
+ /** The consent decision's remember (the allow): the upsert on the live
54
+ * triple refreshes the stamp; a revoked triple's re-allow inserts fresh
55
+ * (the partial unique index's predicate keeps the revoked row out of the
56
+ * collision). Answers the live row. */
57
+ export function recordConsentGrant(input: { userId: string; clientId: string; scope: string }): OidcConsentGrant {
58
+ const scope = normalizeOidcScopeSet(input.scope)
59
+ getDb().prepare(
60
+ `INSERT INTO oidc_consent_grants (id, user_id, client_id, scope)
61
+ VALUES (?, ?, ?, ?)
62
+ ON CONFLICT (user_id, client_id, scope) WHERE revoked_at IS NULL
63
+ DO UPDATE SET created_at = datetime('now')`,
64
+ ).run(randomUUID(), input.userId, input.clientId, scope)
65
+ const row = getDb().prepare(
66
+ 'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND client_id = ? AND scope = ? AND revoked_at IS NULL',
67
+ ).get(input.userId, input.clientId, scope) as Record<string, unknown> | undefined
68
+ if (!row) throw new Error('recordConsentGrant: the upsert left no live row')
69
+ return toConsentGrant(row)
70
+ }
71
+
72
+ /** The console's list: the account's LIVE grants, newest first. */
73
+ export function listConsentGrants(userId: string): OidcConsentGrant[] {
74
+ const rows = getDb().prepare(
75
+ 'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC, rowid DESC',
76
+ ).all(userId) as Array<Record<string, unknown>>
77
+ return rows.map(toConsentGrant)
78
+ }
79
+
80
+ /** The guarded revoke: the owner's LIVE row flips, once. */
81
+ export function revokeConsentGrant(id: string, userId: string): boolean {
82
+ return getDb().prepare(
83
+ "UPDATE oidc_consent_grants SET revoked_at = datetime('now') WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
84
+ ).run(id, userId).changes > 0
85
+ }