@oimlsmart/platform-server 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,
@@ -186,6 +188,73 @@ function toAdminRow(user: UserRecord & { last_login?: string | null; provider?:
186
188
  * store wipes alongside (its rows reference the wiped events). */
187
189
  const WIPE_TABLES = ['entity_changes', 'evidence_records', 'entities', 'events', 'instrument_registrations', 'notify_deliveries'] as const
188
190
 
191
+ // ── The ensure chains' memo scope (the 2026-09 portal-load audit, R2) ──
192
+ // Every defensive ensure below (a dev-database heal: the PRAGMA probes,
193
+ // the CREATE … IF NOT EXISTS, the idempotent membership backfill) was
194
+ // memoized on the store INSTANCE — but the Worker consumers install a
195
+ // store per request (the binding factory memoizes per binding OBJECT and
196
+ // the Workers runtime hands each request its own env, so the binding's
197
+ // identity is not guaranteed to recur), and the audit measured the
198
+ // ~13-statement chain on every authenticated request (~0.6–0.9 s on the
199
+ // demo hub's D1). The ensures are idempotent by construction, so the
200
+ // honest scope is the (binding, chain) pair held at MODULE scope: the
201
+ // Worker's isolate persists across requests and the consumer pins the
202
+ // binding (the smart repo's server/cloudflare.ts), so the same
203
+ // D1Database object recurs and the chain runs once per isolate. A
204
+ // consumer whose runtime rotates binding objects degrades to the old
205
+ // per-request posture — correct, just unmemoized. A REJECTED ensure
206
+ // evicts itself: the next call retries the heal.
207
+ interface EnsureMemos {
208
+ usersColumns: Promise<void> | null
209
+ sessionColumns: Promise<void> | null
210
+ membershipSupport: Promise<void> | null
211
+ orgRegistrySupport: Promise<void> | null
212
+ holderAttributionSupport: Promise<void> | null
213
+ instrumentRegistrationSupport: Promise<void> | null
214
+ oidcColumns: Promise<void> | null
215
+ personalAccessTokenSupport: Promise<void> | null
216
+ consentGrantSupport: Promise<void> | null
217
+ accountEmailSupport: Promise<void> | null
218
+ notifyDeliverySupport: Promise<void> | null
219
+ }
220
+
221
+ const ensureMemosByBinding = new WeakMap<D1Database, EnsureMemos>()
222
+
223
+ /** Run the chain once per (binding, slot); a rejection evicts itself so
224
+ * the next caller retries. */
225
+ function ensured(binding: D1Database, slot: keyof EnsureMemos, run: () => Promise<void>): Promise<void> {
226
+ let memos = ensureMemosByBinding.get(binding)
227
+ if (!memos) {
228
+ memos = {
229
+ usersColumns: null, sessionColumns: null, membershipSupport: null,
230
+ orgRegistrySupport: null, holderAttributionSupport: null,
231
+ instrumentRegistrationSupport: null, oidcColumns: null,
232
+ personalAccessTokenSupport: null, consentGrantSupport: null,
233
+ accountEmailSupport: null, notifyDeliverySupport: null,
234
+ }
235
+ ensureMemosByBinding.set(binding, memos)
236
+ }
237
+ let pending = memos[slot]
238
+ if (!pending) {
239
+ pending = run()
240
+ memos[slot] = pending
241
+ pending.catch(() => { if (memos[slot] === pending) memos[slot] = null })
242
+ }
243
+ return pending
244
+ }
245
+
246
+ // TODO.identity/06's last-active stamp (getSessionUser's write): the
247
+ // DB-side 60 s WHERE clause stays the source of truth (a second
248
+ // isolate's write still lands through it); the in-isolate cache below
249
+ // simply never ISSUES the write the row would refuse — the audit's R2
250
+ // measured that write on every authenticated request. Keyed on the
251
+ // session token (per-isolate), capped so a long-lived isolate's map
252
+ // stays bounded (a cap reset only re-issues a write the DB clause then
253
+ // throttles — never a correctness change).
254
+ const lastSeenWrites = new Map<string, number>()
255
+ const LAST_SEEN_THROTTLE_MS = 60_000
256
+ const LAST_SEEN_CACHE_CAP = 4096
257
+
189
258
  export class D1ServerStore implements ServerStore {
190
259
  constructor(private readonly db: D1Database) {}
191
260
 
@@ -196,43 +265,34 @@ export class D1ServerStore implements ServerStore {
196
265
  // TODO.federation/12: the roles/active columns arrive with migration
197
266
  // 0002 — a dev D1 migrated from the pre-RBAC 0001 lacks them, so the
198
267
  // user methods ensure them defensively (PRAGMA probe + ALTER),
199
- // memoized per store.
200
- private usersColumnsEnsured: Promise<void> | null = null
201
-
268
+ // memoized per (binding, chain) at module scope (the header note).
202
269
  private ensureUserColumns(): Promise<void> {
203
- if (!this.usersColumnsEnsured) {
204
- this.usersColumnsEnsured = (async () => {
205
- const cols = await this.db.prepare('PRAGMA table_info(users)').all<{ name: string }>()
206
- const names = new Set(cols.results.map(c => c.name))
207
- if (!names.has('roles')) await this.db.prepare('ALTER TABLE users ADD COLUMN roles TEXT').run()
208
- if (!names.has('active')) await this.db.prepare('ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1').run()
209
- // TODO.identity/06 (the account console): the address's
210
- // verification state.
211
- if (!names.has('email_verified_at')) await this.db.prepare('ALTER TABLE users ADD COLUMN email_verified_at TEXT').run()
212
- })()
213
- }
214
- return this.usersColumnsEnsured
270
+ return ensured(this.db, 'usersColumns', async () => {
271
+ const cols = await this.db.prepare('PRAGMA table_info(users)').all<{ name: string }>()
272
+ const names = new Set(cols.results.map(c => c.name))
273
+ if (!names.has('roles')) await this.db.prepare('ALTER TABLE users ADD COLUMN roles TEXT').run()
274
+ if (!names.has('active')) await this.db.prepare('ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1').run()
275
+ // TODO.identity/06 (the account console): the address's
276
+ // verification state.
277
+ if (!names.has('email_verified_at')) await this.db.prepare('ALTER TABLE users ADD COLUMN email_verified_at TEXT').run()
278
+ })
215
279
  }
216
280
 
217
281
  // TODO.identity/06 (the account console's sessions section): the
218
282
  // sign-in context columns arrive with migration 0009 — a dev D1
219
283
  // migrated from before it lacks them, so the session methods ensure
220
- // them defensively (the ensureUserColumns posture, memoized per store).
221
- private sessionColumnsEnsured: Promise<void> | null = null
222
-
284
+ // them defensively (the ensureUserColumns posture, memoized per
285
+ // (binding, chain) at module scope).
223
286
  private ensureSessionColumns(): Promise<void> {
224
- if (!this.sessionColumnsEnsured) {
225
- this.sessionColumnsEnsured = (async () => {
226
- const cols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
227
- const names = new Set(cols.results.map(c => c.name))
228
- if (!names.has('user_agent')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN user_agent TEXT').run()
229
- if (!names.has('ip')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN ip TEXT').run()
230
- if (!names.has('last_seen_at')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN last_seen_at TEXT').run()
231
- // TODO.identity-sso/02+03: the sign-in provenance.
232
- if (!names.has('amr')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN amr TEXT').run()
233
- })()
234
- }
235
- return this.sessionColumnsEnsured
287
+ return ensured(this.db, 'sessionColumns', async () => {
288
+ const cols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
289
+ const names = new Set(cols.results.map(c => c.name))
290
+ if (!names.has('user_agent')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN user_agent TEXT').run()
291
+ if (!names.has('ip')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN ip TEXT').run()
292
+ if (!names.has('last_seen_at')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN last_seen_at TEXT').run()
293
+ // TODO.identity-sso/02+03: the sign-in provenance.
294
+ if (!names.has('amr')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN amr TEXT').run()
295
+ })
236
296
  }
237
297
 
238
298
  // TODO.identity/11 (the multi-org membership model): the
@@ -240,274 +300,267 @@ export class D1ServerStore implements ServerStore {
240
300
  // token-flow context columns arrive with migration 0011 — a dev D1
241
301
  // migrated from before it lacks them, so the membership/session/token
242
302
  // methods ensure them defensively (the ensureUserColumns posture,
243
- // memoized per store). The backfill (the migration's twin) is
244
- // IDEMPOTENT and rides the ensure: every org-bound account's primary
245
- // membership mirrors the legacy columns.
246
- private membershipSupportEnsured: Promise<void> | null = null
247
-
303
+ // memoized per (binding, chain) at module scope). The backfill (the
304
+ // migration's twin) is IDEMPOTENT and rides the ensure: every
305
+ // org-bound account's primary membership mirrors the legacy columns.
248
306
  private ensureMembershipSupport(): Promise<void> {
249
- if (!this.membershipSupportEnsured) {
250
- this.membershipSupportEnsured = (async () => {
251
- await this.db.prepare(
252
- `CREATE TABLE IF NOT EXISTS org_memberships (
253
- id TEXT PRIMARY KEY,
254
- user_id TEXT NOT NULL REFERENCES users(id),
255
- org_id TEXT NOT NULL,
256
- roles TEXT NOT NULL DEFAULT '[]',
257
- state TEXT NOT NULL DEFAULT 'active',
258
- is_primary INTEGER NOT NULL DEFAULT 0,
259
- invited_by TEXT,
260
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
261
- activated_at TEXT,
262
- disabled_at TEXT,
263
- disabled_by TEXT,
264
- UNIQUE (user_id, org_id)
265
- )`,
266
- ).run()
267
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_org ON org_memberships (org_id, state)').run()
268
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_user ON org_memberships (user_id, state)').run()
269
- // TODO.identity-features/09 (the org-member data cone): the cone
270
- // column arrives with migration 0017 a dev D1 predating it
271
- // grows the column here. NULL = org-wide: existing memberships
272
- // keep their posture silently.
273
- const membershipCols = await this.db.prepare('PRAGMA table_info(org_memberships)').all<{ name: string }>()
274
- if (!membershipCols.results.some(c => c.name === 'cone')) {
275
- await this.db.prepare('ALTER TABLE org_memberships ADD COLUMN cone TEXT').run()
276
- }
277
- const sessionCols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
278
- if (!sessionCols.results.some(c => c.name === 'active_org')) {
279
- await this.db.prepare('ALTER TABLE sessions ADD COLUMN active_org TEXT').run()
280
- }
281
- const codeCols = await this.db.prepare('PRAGMA table_info(oidc_codes)').all<{ name: string }>()
282
- if (!codeCols.results.some(c => c.name === 'context_org')) {
283
- await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN context_org TEXT').run()
284
- }
285
- const accessCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
286
- if (!accessCols.results.some(c => c.name === 'context_org')) {
287
- await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN context_org TEXT').run()
288
- }
289
- await this.db.prepare(
290
- `INSERT OR IGNORE INTO org_memberships (id, user_id, org_id, roles, state, is_primary, activated_at)
291
- SELECT 'mbr-' || id, id, org_id,
292
- CASE WHEN roles IS NOT NULL AND roles != '' THEN roles ELSE json_array(role) END,
293
- 'active', 1, COALESCE(last_login, created_at)
294
- FROM users WHERE org_id IS NOT NULL`,
295
- ).run()
296
- })()
297
- }
298
- return this.membershipSupportEnsured
307
+ return ensured(this.db, 'membershipSupport', async () => {
308
+ await this.db.prepare(
309
+ `CREATE TABLE IF NOT EXISTS org_memberships (
310
+ id TEXT PRIMARY KEY,
311
+ user_id TEXT NOT NULL REFERENCES users(id),
312
+ org_id TEXT NOT NULL,
313
+ roles TEXT NOT NULL DEFAULT '[]',
314
+ state TEXT NOT NULL DEFAULT 'active',
315
+ is_primary INTEGER NOT NULL DEFAULT 0,
316
+ invited_by TEXT,
317
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
318
+ activated_at TEXT,
319
+ disabled_at TEXT,
320
+ disabled_by TEXT,
321
+ UNIQUE (user_id, org_id)
322
+ )`,
323
+ ).run()
324
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_org ON org_memberships (org_id, state)').run()
325
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_user ON org_memberships (user_id, state)').run()
326
+ // TODO.identity-features/09 (the org-member data cone): the cone
327
+ // column arrives with migration 0017 a dev D1 predating it
328
+ // grows the column here. NULL = org-wide: existing memberships
329
+ // keep their posture silently.
330
+ const membershipCols = await this.db.prepare('PRAGMA table_info(org_memberships)').all<{ name: string }>()
331
+ if (!membershipCols.results.some(c => c.name === 'cone')) {
332
+ await this.db.prepare('ALTER TABLE org_memberships ADD COLUMN cone TEXT').run()
333
+ }
334
+ const sessionCols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
335
+ if (!sessionCols.results.some(c => c.name === 'active_org')) {
336
+ await this.db.prepare('ALTER TABLE sessions ADD COLUMN active_org TEXT').run()
337
+ }
338
+ const codeCols = await this.db.prepare('PRAGMA table_info(oidc_codes)').all<{ name: string }>()
339
+ if (!codeCols.results.some(c => c.name === 'context_org')) {
340
+ await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN context_org TEXT').run()
341
+ }
342
+ const accessCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
343
+ if (!accessCols.results.some(c => c.name === 'context_org')) {
344
+ await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN context_org TEXT').run()
345
+ }
346
+ await this.db.prepare(
347
+ `INSERT OR IGNORE INTO org_memberships (id, user_id, org_id, roles, state, is_primary, activated_at)
348
+ SELECT 'mbr-' || id, id, org_id,
349
+ CASE WHEN roles IS NOT NULL AND roles != '' THEN roles ELSE json_array(role) END,
350
+ 'active', 1, COALESCE(last_login, created_at)
351
+ FROM users WHERE org_id IS NOT NULL`,
352
+ ).run()
353
+ })
299
354
  }
300
355
 
301
356
  // TODO.identity-features/05 (the organization registry): the
302
357
  // org_registry table arrives with migration 0013 — a dev D1 migrated
303
358
  // from before it lacks the table, so the registry methods ensure it
304
359
  // defensively (the ensureMembershipSupport posture, memoized per
305
- // store).
306
- private orgRegistrySupportEnsured: Promise<void> | null = null
307
-
360
+ // (binding, chain) at module scope).
308
361
  private ensureOrgRegistrySupport(): Promise<void> {
309
- if (!this.orgRegistrySupportEnsured) {
310
- this.orgRegistrySupportEnsured = (async () => {
311
- await this.db.prepare(
312
- `CREATE TABLE IF NOT EXISTS org_registry (
313
- id TEXT PRIMARY KEY,
314
- name TEXT NOT NULL,
315
- short_name TEXT,
316
- kind TEXT,
317
- country TEXT,
318
- contacts TEXT NOT NULL DEFAULT '[]',
319
- participant_ref TEXT,
320
- state TEXT NOT NULL DEFAULT 'active',
321
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
322
- created_by TEXT,
323
- updated_at TEXT,
324
- updated_by TEXT,
325
- disabled_at TEXT,
326
- disabled_by TEXT
327
- )`,
328
- ).run()
329
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_registry_state ON org_registry (state)').run()
330
- // TODO.identity-features/10 (the OIML Member category): the
331
- // designation links + the CS status facet arrive with migration
332
- // 0019 a dev D1 predating it grows the columns here (the
333
- // PRAGMA probe + ALTER posture of ensureUserColumns). NULL = not
334
- // recorded: existing rows keep their posture silently.
335
- const cols = await this.db.prepare('PRAGMA table_info(org_registry)').all<{ name: string }>()
336
- const names = new Set(cols.results.map(c => c.name))
337
- if (!names.has('designated_by')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN designated_by TEXT').run()
338
- if (!names.has('proposed_by')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN proposed_by TEXT').run()
339
- if (!names.has('cs_status')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN cs_status TEXT').run()
340
- })()
341
- }
342
- return this.orgRegistrySupportEnsured
362
+ return ensured(this.db, 'orgRegistrySupport', async () => {
363
+ await this.db.prepare(
364
+ `CREATE TABLE IF NOT EXISTS org_registry (
365
+ id TEXT PRIMARY KEY,
366
+ name TEXT NOT NULL,
367
+ short_name TEXT,
368
+ kind TEXT,
369
+ country TEXT,
370
+ contacts TEXT NOT NULL DEFAULT '[]',
371
+ participant_ref TEXT,
372
+ state TEXT NOT NULL DEFAULT 'active',
373
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
374
+ created_by TEXT,
375
+ updated_at TEXT,
376
+ updated_by TEXT,
377
+ disabled_at TEXT,
378
+ disabled_by TEXT
379
+ )`,
380
+ ).run()
381
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_registry_state ON org_registry (state)').run()
382
+ // TODO.identity-features/10 (the OIML Member category): the
383
+ // designation links + the CS status facet arrive with migration
384
+ // 0019 a dev D1 predating it grows the columns here (the
385
+ // PRAGMA probe + ALTER posture of ensureUserColumns). NULL = not
386
+ // recorded: existing rows keep their posture silently.
387
+ const cols = await this.db.prepare('PRAGMA table_info(org_registry)').all<{ name: string }>()
388
+ const names = new Set(cols.results.map(c => c.name))
389
+ if (!names.has('designated_by')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN designated_by TEXT').run()
390
+ if (!names.has('proposed_by')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN proposed_by TEXT').run()
391
+ if (!names.has('cs_status')) await this.db.prepare('ALTER TABLE org_registry ADD COLUMN cs_status TEXT').run()
392
+ })
343
393
  }
344
394
 
345
395
  // TODO.register/02 (the register's holder-org attribution): the
346
396
  // certificate_holder_orgs / certificate_holder_claims tables arrive with
347
397
  // migration 0015 — a dev D1 migrated from before it lacks them, so the
348
398
  // attribution/claim methods ensure them defensively (the
349
- // ensureOrgRegistrySupport posture, memoized per store).
350
- private holderAttributionSupportEnsured: Promise<void> | null = null
351
-
399
+ // ensureOrgRegistrySupport posture, memoized per (binding, chain) at
400
+ // module scope).
352
401
  private ensureHolderAttributionSupport(): Promise<void> {
353
- if (!this.holderAttributionSupportEnsured) {
354
- this.holderAttributionSupportEnsured = (async () => {
355
- await this.db.prepare(
356
- `CREATE TABLE IF NOT EXISTS certificate_holder_orgs (
357
- certificate_id TEXT PRIMARY KEY,
358
- org_id TEXT NOT NULL,
359
- org_name TEXT NOT NULL,
360
- source TEXT NOT NULL,
361
- attributed_at TEXT NOT NULL,
362
- attributed_by TEXT,
363
- claim_id TEXT
364
- )`,
365
- ).run()
366
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_orgs_org ON certificate_holder_orgs (org_id)').run()
367
- await this.db.prepare(
368
- `CREATE TABLE IF NOT EXISTS certificate_holder_claims (
369
- id TEXT PRIMARY KEY,
370
- certificate_id TEXT NOT NULL,
371
- claimant_org_id TEXT NOT NULL,
372
- claimant_org_name TEXT NOT NULL,
373
- matched_holder_name TEXT NOT NULL,
374
- claimed_by TEXT NOT NULL,
375
- state TEXT NOT NULL DEFAULT 'pending',
376
- decided_by TEXT,
377
- decided_at TEXT,
378
- refusal_reason TEXT,
379
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
380
- )`,
381
- ).run()
382
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_cert ON certificate_holder_claims (certificate_id)').run()
383
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_state ON certificate_holder_claims (state)').run()
384
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_org ON certificate_holder_claims (claimant_org_id)').run()
385
- })()
386
- }
387
- return this.holderAttributionSupportEnsured
402
+ return ensured(this.db, 'holderAttributionSupport', async () => {
403
+ await this.db.prepare(
404
+ `CREATE TABLE IF NOT EXISTS certificate_holder_orgs (
405
+ certificate_id TEXT PRIMARY KEY,
406
+ org_id TEXT NOT NULL,
407
+ org_name TEXT NOT NULL,
408
+ source TEXT NOT NULL,
409
+ attributed_at TEXT NOT NULL,
410
+ attributed_by TEXT,
411
+ claim_id TEXT
412
+ )`,
413
+ ).run()
414
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_orgs_org ON certificate_holder_orgs (org_id)').run()
415
+ await this.db.prepare(
416
+ `CREATE TABLE IF NOT EXISTS certificate_holder_claims (
417
+ id TEXT PRIMARY KEY,
418
+ certificate_id TEXT NOT NULL,
419
+ claimant_org_id TEXT NOT NULL,
420
+ claimant_org_name TEXT NOT NULL,
421
+ matched_holder_name TEXT NOT NULL,
422
+ claimed_by TEXT NOT NULL,
423
+ state TEXT NOT NULL DEFAULT 'pending',
424
+ decided_by TEXT,
425
+ decided_at TEXT,
426
+ refusal_reason TEXT,
427
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
428
+ )`,
429
+ ).run()
430
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_cert ON certificate_holder_claims (certificate_id)').run()
431
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_state ON certificate_holder_claims (state)').run()
432
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_org ON certificate_holder_claims (claimant_org_id)').run()
433
+ })
388
434
  }
389
435
 
390
436
  // TODO.register/03 (the instrument register): the
391
437
  // instrument_registrations table arrives with migration 0016 — a dev
392
438
  // D1 migrated from before it lacks the table, so the register methods
393
439
  // ensure it defensively (the ensureOrgRegistrySupport posture,
394
- // memoized per store).
395
- private instrumentRegistrationSupportEnsured: Promise<void> | null = null
396
-
440
+ // memoized per (binding, chain) at module scope).
397
441
  private ensureInstrumentRegistrationSupport(): Promise<void> {
398
- if (!this.instrumentRegistrationSupportEnsured) {
399
- this.instrumentRegistrationSupportEnsured = (async () => {
400
- await this.db.prepare(
401
- `CREATE TABLE IF NOT EXISTS instrument_registrations (
402
- id TEXT PRIMARY KEY,
403
- certificate_id TEXT NOT NULL,
404
- holder_org_id TEXT NOT NULL,
405
- standard_id TEXT NOT NULL,
406
- serial_number TEXT NOT NULL,
407
- manufacture_date TEXT,
408
- designations TEXT NOT NULL DEFAULT '{}',
409
- scope_status TEXT NOT NULL,
410
- scope_detail TEXT,
411
- lifecycle TEXT NOT NULL DEFAULT 'registered',
412
- registered_at TEXT NOT NULL DEFAULT (datetime('now')),
413
- registered_by TEXT,
414
- updated_at TEXT,
415
- updated_by TEXT,
416
- UNIQUE (certificate_id, serial_number)
417
- )`,
418
- ).run()
419
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_certificate ON instrument_registrations (certificate_id)').run()
420
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_holder ON instrument_registrations (holder_org_id)').run()
421
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_lifecycle ON instrument_registrations (lifecycle)').run()
422
- })()
423
- }
424
- return this.instrumentRegistrationSupportEnsured
442
+ return ensured(this.db, 'instrumentRegistrationSupport', async () => {
443
+ await this.db.prepare(
444
+ `CREATE TABLE IF NOT EXISTS instrument_registrations (
445
+ id TEXT PRIMARY KEY,
446
+ certificate_id TEXT NOT NULL,
447
+ holder_org_id TEXT NOT NULL,
448
+ standard_id TEXT NOT NULL,
449
+ serial_number TEXT NOT NULL,
450
+ manufacture_date TEXT,
451
+ designations TEXT NOT NULL DEFAULT '{}',
452
+ scope_status TEXT NOT NULL,
453
+ scope_detail TEXT,
454
+ lifecycle TEXT NOT NULL DEFAULT 'registered',
455
+ registered_at TEXT NOT NULL DEFAULT (datetime('now')),
456
+ registered_by TEXT,
457
+ updated_at TEXT,
458
+ updated_by TEXT,
459
+ UNIQUE (certificate_id, serial_number)
460
+ )`,
461
+ ).run()
462
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_certificate ON instrument_registrations (certificate_id)').run()
463
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_holder ON instrument_registrations (holder_org_id)').run()
464
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_lifecycle ON instrument_registrations (lifecycle)').run()
465
+ })
425
466
  }
426
467
 
427
468
  // TODO.identity-sso/02+03: the amr provenance columns on the OIDC
428
469
  // flow rows arrive with migration 0012 — the OIDC methods ensure them
429
- // defensively (the same memoized posture as the session/user columns),
430
- // so a dev D1 migrated from before the wave never 500s the core flow.
431
- private oidcColumnsEnsured: Promise<void> | null = null
432
-
470
+ // defensively (the same memoized posture as the session/user columns,
471
+ // per (binding, chain) at module scope), so a dev D1 migrated from
472
+ // before the wave never 500s the core flow.
433
473
  private ensureOidcColumns(): Promise<void> {
434
- if (!this.oidcColumnsEnsured) {
435
- this.oidcColumnsEnsured = (async () => {
436
- const codeCols = await this.db.prepare('PRAGMA table_info(oidc_codes)').all<{ name: string }>()
437
- if (!codeCols.results.some(c => c.name === 'amr')) {
438
- await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN amr TEXT').run()
439
- }
440
- const tokenCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
441
- if (!tokenCols.results.some(c => c.name === 'amr')) {
442
- await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN amr TEXT').run()
443
- }
444
- })()
445
- }
446
- return this.oidcColumnsEnsured
474
+ return ensured(this.db, 'oidcColumns', async () => {
475
+ const codeCols = await this.db.prepare('PRAGMA table_info(oidc_codes)').all<{ name: string }>()
476
+ if (!codeCols.results.some(c => c.name === 'amr')) {
477
+ await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN amr TEXT').run()
478
+ }
479
+ const tokenCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
480
+ if (!tokenCols.results.some(c => c.name === 'amr')) {
481
+ await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN amr TEXT').run()
482
+ }
483
+ })
447
484
  }
448
485
 
449
486
  // TODO.identity-features/08 (the personal access tokens): the
450
487
  // personal_access_tokens table arrives with migration 0020 — a dev D1
451
488
  // migrated from before it lacks the table, so the PAT methods ensure
452
489
  // it defensively (the ensureOrgRegistrySupport posture, memoized per
453
- // store).
454
- private personalAccessTokenSupportEnsured: Promise<void> | null = null
455
-
490
+ // (binding, chain) at module scope).
456
491
  private ensurePersonalAccessTokenSupport(): Promise<void> {
457
- if (!this.personalAccessTokenSupportEnsured) {
458
- this.personalAccessTokenSupportEnsured = (async () => {
459
- await this.db.prepare(
460
- `CREATE TABLE IF NOT EXISTS personal_access_tokens (
461
- id TEXT PRIMARY KEY,
462
- user_id TEXT NOT NULL REFERENCES users(id),
463
- name TEXT NOT NULL,
464
- token_hash TEXT NOT NULL,
465
- token_prefix TEXT NOT NULL,
466
- scopes TEXT NOT NULL DEFAULT '[]',
467
- org_context TEXT,
468
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
469
- expires_at TEXT NOT NULL,
470
- last_used_at TEXT,
471
- last_exchange_audit_at TEXT,
472
- expiry_notified_at TEXT,
473
- revoked_at TEXT,
474
- revoked_by TEXT,
475
- UNIQUE (token_hash)
476
- )`,
477
- ).run()
478
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_personal_access_tokens_user ON personal_access_tokens (user_id)').run()
479
- })()
480
- }
481
- return this.personalAccessTokenSupportEnsured
492
+ return ensured(this.db, 'personalAccessTokenSupport', async () => {
493
+ await this.db.prepare(
494
+ `CREATE TABLE IF NOT EXISTS personal_access_tokens (
495
+ id TEXT PRIMARY KEY,
496
+ user_id TEXT NOT NULL REFERENCES users(id),
497
+ name TEXT NOT NULL,
498
+ token_hash TEXT NOT NULL,
499
+ token_prefix TEXT NOT NULL,
500
+ scopes TEXT NOT NULL DEFAULT '[]',
501
+ org_context TEXT,
502
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
503
+ expires_at TEXT NOT NULL,
504
+ last_used_at TEXT,
505
+ last_exchange_audit_at TEXT,
506
+ expiry_notified_at TEXT,
507
+ revoked_at TEXT,
508
+ revoked_by TEXT,
509
+ UNIQUE (token_hash)
510
+ )`,
511
+ ).run()
512
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_personal_access_tokens_user ON personal_access_tokens (user_id)').run()
513
+ })
482
514
  }
483
515
 
484
516
  // TODO.identity-features/12 (the remembered consent grants): the
485
517
  // oidc_consent_grants table arrives with migration 0021 — a dev D1
486
518
  // migrated from before it lacks the table, so the grant methods ensure
487
519
  // it defensively (the ensurePersonalAccessTokenSupport posture,
488
- // memoized per store).
489
- private consentGrantSupportEnsured: Promise<void> | null = null
490
-
520
+ // memoized per (binding, chain) at module scope).
491
521
  private ensureConsentGrantSupport(): Promise<void> {
492
- if (!this.consentGrantSupportEnsured) {
493
- this.consentGrantSupportEnsured = (async () => {
494
- await this.db.prepare(
495
- `CREATE TABLE IF NOT EXISTS oidc_consent_grants (
496
- id TEXT PRIMARY KEY,
497
- user_id TEXT NOT NULL REFERENCES users(id),
498
- client_id TEXT NOT NULL,
499
- scope TEXT NOT NULL,
500
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
501
- revoked_at TEXT
502
- )`,
503
- ).run()
504
- await this.db.prepare(
505
- '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',
506
- ).run()
507
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_oidc_consent_grants_user ON oidc_consent_grants (user_id)').run()
508
- })()
509
- }
510
- return this.consentGrantSupportEnsured
522
+ return ensured(this.db, 'consentGrantSupport', async () => {
523
+ await this.db.prepare(
524
+ `CREATE TABLE IF NOT EXISTS oidc_consent_grants (
525
+ id TEXT PRIMARY KEY,
526
+ user_id TEXT NOT NULL REFERENCES users(id),
527
+ client_id TEXT NOT NULL,
528
+ scope TEXT NOT NULL,
529
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
530
+ revoked_at TEXT
531
+ )`,
532
+ ).run()
533
+ await this.db.prepare(
534
+ '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',
535
+ ).run()
536
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_oidc_consent_grants_user ON oidc_consent_grants (user_id)').run()
537
+ })
538
+ }
539
+
540
+ // TODO.identity-features/01 (multiple emails per account): the
541
+ // account_emails table + the email_change_tokens.kind column arrive
542
+ // with migration 0022 — a dev D1 migrated from before it lacks both,
543
+ // so the address methods ensure them defensively (the
544
+ // ensureConsentGrantSupport posture, memoized per (binding, chain) at
545
+ // module scope).
546
+ private ensureAccountEmailSupport(): Promise<void> {
547
+ return ensured(this.db, 'accountEmailSupport', async () => {
548
+ await this.db.prepare(
549
+ `CREATE TABLE IF NOT EXISTS account_emails (
550
+ user_id TEXT NOT NULL REFERENCES users(id),
551
+ email TEXT NOT NULL,
552
+ verified_at TEXT,
553
+ added_by TEXT,
554
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
555
+ PRIMARY KEY (user_id, email)
556
+ )`,
557
+ ).run()
558
+ await this.db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_account_emails_email ON account_emails (email)').run()
559
+ const tokenCols = await this.db.prepare('PRAGMA table_info(email_change_tokens)').all<{ name: string }>()
560
+ if (!tokenCols.results.some(c => c.name === 'kind')) {
561
+ await this.db.prepare("ALTER TABLE email_change_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'change'").run()
562
+ }
563
+ })
511
564
  }
512
565
 
513
566
  // ── users / sessions ─────────────────────────────────────────────
@@ -616,7 +669,28 @@ export class D1ServerStore implements ServerStore {
616
669
  return row?.id_token_hint ?? null
617
670
  }
618
671
 
672
+ /** TODO.identity/06's last-active stamp, throttled to one ISSUED write
673
+ * per minute per session per isolate — the DB-side 60 s WHERE clause
674
+ * stays the source of truth across isolates (the module-scope note);
675
+ * the in-isolate cache only skips issuing a write the row would
676
+ * refuse. A failed write is never cached (the cache set follows the
677
+ * await), so the next request retries. */
678
+ private async stampSessionLastSeen(token: string): Promise<void> {
679
+ const now = Date.now()
680
+ if (now - (lastSeenWrites.get(token) ?? 0) < LAST_SEEN_THROTTLE_MS) return
681
+ await this.stmt(
682
+ "UPDATE sessions SET last_seen_at = datetime('now') WHERE token = ? AND (last_seen_at IS NULL OR last_seen_at < datetime('now', '-60 seconds'))",
683
+ token,
684
+ ).run()
685
+ if (lastSeenWrites.size >= LAST_SEEN_CACHE_CAP) lastSeenWrites.clear()
686
+ lastSeenWrites.set(token, now)
687
+ }
688
+
619
689
  async getSessionUser(token: string): Promise<AuthUserPayload | null> {
690
+ // The ensure chains are memoized per (binding, chain) at module scope
691
+ // (the header note) — past the isolate's first request these three
692
+ // awaits are memo hits, and the per-request work that remains is the
693
+ // session read itself.
620
694
  await this.ensureUserColumns()
621
695
  await this.ensureSessionColumns()
622
696
  await this.ensureMembershipSupport()
@@ -630,12 +704,6 @@ export class D1ServerStore implements ServerStore {
630
704
  token,
631
705
  ).first<{ user_id: string; active_org: string | null; amr: string | null; email: string; name: string; role: string; roles: string | null; org_id: string | null; avatar_url: string | null; provider: string; email_verified_at: string | null }>()
632
706
  if (!session) return null
633
- // TODO.identity/06: the last-active stamp, throttled to one write
634
- // per minute per session (the sessions section shows it).
635
- await this.stmt(
636
- "UPDATE sessions SET last_seen_at = datetime('now') WHERE token = ? AND (last_seen_at IS NULL OR last_seen_at < datetime('now', '-60 seconds'))",
637
- token,
638
- ).run()
639
707
  const amr = parseRoles(session.amr)
640
708
  const payload: AuthUserPayload = {
641
709
  id: session.user_id,
@@ -650,10 +718,16 @@ export class D1ServerStore implements ServerStore {
650
718
  ...(amr?.length ? { amr } : {}),
651
719
  }
652
720
  // TODO.identity/11: the active-org context (the membership model) —
653
- // the payload's org/roles follow the session's stamped context.
721
+ // the payload's org/roles follow the session's stamped context. The
722
+ // three post-JOIN statements (the throttled last-active stamp, the
723
+ // two membership reads) are independent, so they round-trip together
724
+ // (the audit's R2: sequential statements are the latency).
654
725
  const activeOrg = session.active_org ?? null
655
- const active = activeOrg ? await this.getOrgMembership(payload.id, activeOrg) : null
656
- const primary = payload.orgId ? await this.getOrgMembership(payload.id, payload.orgId) : null
726
+ const [, active, primary] = await Promise.all([
727
+ this.stampSessionLastSeen(token),
728
+ activeOrg ? this.getOrgMembership(payload.id, activeOrg) : null,
729
+ payload.orgId ? this.getOrgMembership(payload.id, payload.orgId) : null,
730
+ ])
657
731
  const resolved = resolveOrgContext(payload, { activeOrg, active, primary })
658
732
  if (activeOrg && !(active && active.state === 'active')) {
659
733
  // The stale stamp never lingers (the membership ended mid-session).
@@ -1374,6 +1448,13 @@ export class D1ServerStore implements ServerStore {
1374
1448
  createdBy?: string | null
1375
1449
  }): Promise<UserAdminRow | null> {
1376
1450
  const id = crypto.randomUUID()
1451
+ // TODO.identity-features/01: the address must be free across BOTH
1452
+ // address tables — an additional on another account blocks the
1453
+ // address as a new account's primary (an address names at most one
1454
+ // account; the users.email UNIQUE remains the race backstop).
1455
+ await this.ensureAccountEmailSupport()
1456
+ const additional = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', input.email.trim().toLowerCase()).first<{ user_id: string }>()
1457
+ if (additional) return null
1377
1458
  try {
1378
1459
  await this.stmt(
1379
1460
  "INSERT INTO users (id, email, name, provider, role) VALUES (?, ?, ?, 'password', ?)",
@@ -1388,14 +1469,28 @@ export class D1ServerStore implements ServerStore {
1388
1469
  }
1389
1470
 
1390
1471
  /** The password sign-in's lookup: the credential + the active flag, by
1391
- * (normalized) email. The credential's EXISTENCE is the qualifier. */
1472
+ * (normalized) email. The credential's EXISTENCE is the qualifier.
1473
+ * TODO.identity-features/01: the address resolves by ANY of the
1474
+ * account's VERIFIED addresses — the primary first (the primary owner
1475
+ * always wins, the deterministic rule), then a proven account_emails
1476
+ * row; an unverified additional never resolves. */
1392
1477
  async getPasswordLogin(email: string): Promise<{ userId: string; hash: string; active: boolean } | null> {
1393
- const row = await this.stmt(
1478
+ await this.ensureAccountEmailSupport()
1479
+ const normalized = email.trim().toLowerCase()
1480
+ let row = await this.stmt(
1394
1481
  `SELECT u.id AS user_id, u.active AS active, p.hash AS hash
1395
1482
  FROM users u JOIN passwords p ON p.user_id = u.id
1396
1483
  WHERE u.email = ?`,
1397
- email.trim().toLowerCase(),
1484
+ normalized,
1398
1485
  ).first<{ user_id: string; active: number; hash: string }>()
1486
+ if (!row) {
1487
+ row = await this.stmt(
1488
+ `SELECT u.id AS user_id, u.active AS active, p.hash AS hash
1489
+ FROM users u JOIN passwords p ON p.user_id = u.id
1490
+ WHERE u.id = (SELECT user_id FROM account_emails WHERE email = ? AND verified_at IS NOT NULL)`,
1491
+ normalized,
1492
+ ).first<{ user_id: string; active: number; hash: string }>()
1493
+ }
1399
1494
  if (!row) return null
1400
1495
  return { userId: row.user_id, hash: row.hash, active: row.active !== 0 }
1401
1496
  }
@@ -1620,6 +1715,10 @@ export class D1ServerStore implements ServerStore {
1620
1715
  // the account (a tombstone never skips a consent page again).
1621
1716
  await this.ensureConsentGrantSupport()
1622
1717
  const consentGrants = await this.stmt('DELETE FROM oidc_consent_grants WHERE user_id = ?', userId).run()
1718
+ // TODO.identity-features/01: the additional addresses die with the
1719
+ // account (a tombstone's addresses never resolve a sign-in again).
1720
+ await this.ensureAccountEmailSupport()
1721
+ const emails = await this.stmt('DELETE FROM account_emails WHERE user_id = ?', userId).run()
1623
1722
  await this.stmt(
1624
1723
  `UPDATE users SET
1625
1724
  email = ?, name = 'Deleted account', provider = 'erased',
@@ -1638,13 +1737,20 @@ export class D1ServerStore implements ServerStore {
1638
1737
  + (challenges.meta.changes ?? 0) + (mfa.meta.changes ?? 0),
1639
1738
  personalAccessTokens: personalAccessTokens.meta.changes ?? 0,
1640
1739
  consentGrants: consentGrants.meta.changes ?? 0,
1740
+ emails: emails.meta.changes ?? 0,
1641
1741
  }
1642
1742
  }
1643
1743
 
1644
1744
  /** 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). */
1745
+ * throws 'unique' (the route maps it to a 409, never a silent take).
1746
+ * TODO.identity-features/01: the conflict read spans BOTH address
1747
+ * tables — an additional row (on any account, this one included)
1748
+ * holds the address too. */
1646
1749
  async updateOpAccount(id: string, input: { name?: string; email?: string }): Promise<boolean> {
1647
1750
  if (input.email !== undefined) {
1751
+ await this.ensureAccountEmailSupport()
1752
+ const additional = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', input.email.trim().toLowerCase()).first<{ user_id: string }>()
1753
+ if (additional) throw new Error(`unique: ${input.email}`)
1648
1754
  try {
1649
1755
  // TODO.identity/06: an admin-set address never went through the
1650
1756
  // verify-new-email ceremony, so the verification state resets.
@@ -1717,29 +1823,46 @@ export class D1ServerStore implements ServerStore {
1717
1823
  userId: row.user_id as string,
1718
1824
  newEmail: row.new_email as string,
1719
1825
  deliveredBy: row.delivered_by === 'mailer' ? 'mailer' : 'shown',
1826
+ // TODO.identity-features/01: rows predating the kind column (or a
1827
+ // store over a pre-0022 database) read as the legacy ceremony.
1828
+ kind: row.kind === 'add' ? 'add' : 'change',
1720
1829
  createdAt: row.created_at as string,
1721
1830
  expiresAt: row.expires_at as string,
1722
1831
  consumedAt: (row.consumed_at as string | null) ?? null,
1723
1832
  }
1724
1833
  }
1725
1834
 
1726
- /** Mint the ceremony's token; the account's earlier pending rows are
1727
- * VOIDED first (only the newest link works). */
1835
+ /** Mint the ceremony's token. The void rule keeps ONE live link per
1836
+ * ceremony target: a 'change' request voids the account's earlier
1837
+ * pending 'change' rows (only the newest change link works — the
1838
+ * pre-01 doctrine); an 'add' request voids the account's earlier
1839
+ * pending 'add' rows FOR THE SAME address (other addresses' links
1840
+ * stand). */
1728
1841
  async createEmailChangeToken(input: {
1729
1842
  token: string
1730
1843
  userId: string
1731
1844
  newEmail: string
1732
1845
  deliveredBy: 'mailer' | 'shown'
1846
+ kind?: 'change' | 'add'
1733
1847
  ttlMs: number
1734
1848
  }): Promise<EmailChangeToken> {
1735
- await this.stmt(
1736
- "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND consumed_at IS NULL",
1737
- input.userId,
1738
- ).run()
1849
+ await this.ensureAccountEmailSupport()
1850
+ const kind = input.kind ?? 'change'
1851
+ if (kind === 'change') {
1852
+ await this.stmt(
1853
+ "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL",
1854
+ input.userId,
1855
+ ).run()
1856
+ } else {
1857
+ await this.stmt(
1858
+ "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'add' AND new_email = ? AND consumed_at IS NULL",
1859
+ input.userId, input.newEmail.trim().toLowerCase(),
1860
+ ).run()
1861
+ }
1739
1862
  const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
1740
1863
  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,
1864
+ 'INSERT INTO email_change_tokens (token, user_id, new_email, delivered_by, kind, expires_at) VALUES (?, ?, ?, ?, ?, ?)',
1865
+ input.token, input.userId, input.newEmail.trim().toLowerCase(), input.deliveredBy, kind, expiresAt,
1743
1866
  ).run()
1744
1867
  return (await this.getEmailChangeToken(input.token))!
1745
1868
  }
@@ -1749,12 +1872,15 @@ export class D1ServerStore implements ServerStore {
1749
1872
  return row ? D1ServerStore.toEmailChangeToken(row) : null
1750
1873
  }
1751
1874
 
1752
- /** The account's pending change (the newest live row), so the console
1753
- * can show it. */
1875
+ /** The account's pending PRIMARY change (the newest live 'change'
1876
+ * row), so the console can show it. The per-address verifications are
1877
+ * the account_emails rows' own state (verified_at NULL = waiting),
1878
+ * never a pending read here. */
1754
1879
  async getPendingEmailChange(userId: string): Promise<EmailChangeToken | null> {
1880
+ await this.ensureAccountEmailSupport()
1755
1881
  const row = await this.stmt(
1756
1882
  `SELECT * FROM email_change_tokens
1757
- WHERE user_id = ? AND consumed_at IS NULL AND expires_at > datetime('now')
1883
+ WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL AND expires_at > datetime('now')
1758
1884
  ORDER BY created_at DESC LIMIT 1`,
1759
1885
  userId,
1760
1886
  ).first<Record<string, unknown>>()
@@ -1762,20 +1888,34 @@ export class D1ServerStore implements ServerStore {
1762
1888
  }
1763
1889
 
1764
1890
  /** Complete the ceremony: consume ATOMICALLY (a presented link works
1765
- * exactly once, expired or not), judge the expiry, re-check the
1766
- * address's uniqueness (a conflict burns the token honestly), then
1767
- * move the account's email. A 'mailer'-delivered token verifies the
1768
- * address; a shown one never does. */
1891
+ * exactly once, expired or not), judge the expiry, then act on the
1892
+ * kind. 'change' (the pre-01 primary replacement): re-check the
1893
+ * address's uniqueness across BOTH address tables (a conflict burns
1894
+ * the token honestly an additional row anywhere holds the address
1895
+ * too, this account's included), then move users.email. 'add' (the
1896
+ * per-address verification): the account_emails row landed unverified
1897
+ * at the request; the completion stamps it (a row removed meanwhile
1898
+ * burns the link as 'unknown'). A 'mailer'-delivered token verifies
1899
+ * the address; a shown one never does. */
1769
1900
  async completeEmailChange(token: string): Promise<CompleteEmailChangeResult> {
1901
+ await this.ensureAccountEmailSupport()
1770
1902
  const res = await this.stmt(
1771
1903
  "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL", token,
1772
1904
  ).run()
1773
1905
  if ((res.meta.changes ?? 0) === 0) return { kind: 'unknown' }
1774
1906
  const row = (await this.getEmailChangeToken(token))!
1775
1907
  if (new Date(row.expiresAt).getTime() <= Date.now()) return { kind: 'expired' }
1908
+ const verified = row.deliveredBy === 'mailer'
1909
+ if (row.kind === 'add') {
1910
+ const standing = await this.stmt('SELECT 1 AS ok FROM account_emails WHERE user_id = ? AND email = ?', row.userId, row.newEmail).first<{ ok: number }>()
1911
+ if (!standing) return { kind: 'unknown' }
1912
+ if (verified) await this.markAccountEmailVerified(row.userId, row.newEmail)
1913
+ return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
1914
+ }
1776
1915
  const taken = await this.stmt('SELECT id FROM users WHERE email = ?', row.newEmail).first<{ id: string }>()
1777
1916
  if (taken) return { kind: 'conflict' }
1778
- const verified = row.deliveredBy === 'mailer'
1917
+ const takenAdditional = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', row.newEmail).first<{ user_id: string }>()
1918
+ if (takenAdditional) return { kind: 'conflict' }
1779
1919
  await this.stmt(
1780
1920
  `UPDATE users SET email = ?, email_verified_at = ${verified ? "datetime('now')" : 'NULL'} WHERE id = ?`,
1781
1921
  row.newEmail, row.userId,
@@ -1783,6 +1923,116 @@ export class D1ServerStore implements ServerStore {
1783
1923
  return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
1784
1924
  }
1785
1925
 
1926
+ // ── multiple emails per account (TODO.identity-features/01) ────────
1927
+
1928
+ private static toAccountEmail(row: Record<string, unknown>, isPrimary: boolean): AccountEmail {
1929
+ return {
1930
+ userId: row.user_id as string,
1931
+ email: row.email as string,
1932
+ verifiedAt: (row.verified_at as string | null) ?? null,
1933
+ isPrimary,
1934
+ addedBy: (row.added_by as string | null) ?? null,
1935
+ createdAt: row.created_at as string,
1936
+ }
1937
+ }
1938
+
1939
+ /** The account's addresses: the PRIMARY first (the users row's email +
1940
+ * its verification stamp), then the additional account_emails rows
1941
+ * (oldest first). */
1942
+ async listAccountEmails(userId: string): Promise<AccountEmail[]> {
1943
+ await this.ensureAccountEmailSupport()
1944
+ const primary = await this.stmt(
1945
+ 'SELECT id AS user_id, email, email_verified_at AS verified_at, created_at FROM users WHERE id = ?', userId,
1946
+ ).first<Record<string, unknown>>()
1947
+ const res = await this.stmt(
1948
+ 'SELECT * FROM account_emails WHERE user_id = ? ORDER BY created_at, email', userId,
1949
+ ).all<Record<string, unknown>>()
1950
+ const out: AccountEmail[] = []
1951
+ if (primary) out.push(D1ServerStore.toAccountEmail(primary, true))
1952
+ out.push(...res.results.map(r => D1ServerStore.toAccountEmail(r, false)))
1953
+ return out
1954
+ }
1955
+
1956
+ /** Resolve the account by ANY of its addresses: the primary always
1957
+ * names it (and the primary owner always wins — the deterministic
1958
+ * rule); an additional ONLY when verified. */
1959
+ async findUserByAnyEmail(email: string): Promise<AuthUserPayload | null> {
1960
+ await this.ensureAccountEmailSupport()
1961
+ const normalized = email.trim().toLowerCase()
1962
+ const primary = await this.stmt('SELECT * FROM users WHERE email = ?', normalized).first<UserRecord>()
1963
+ if (primary) return toPayload(primary)
1964
+ const owner = await this.stmt(
1965
+ 'SELECT user_id FROM account_emails WHERE email = ? AND verified_at IS NOT NULL', normalized,
1966
+ ).first<{ user_id: string }>()
1967
+ if (!owner) return null
1968
+ const user = await this.stmt('SELECT * FROM users WHERE id = ?', owner.user_id).first<UserRecord>()
1969
+ return user ? toPayload(user) : null
1970
+ }
1971
+
1972
+ /** Add an ADDITIONAL address (normalized lowercase; the row lands
1973
+ * UNVERIFIED). The account's own existing row answers 'present' (the
1974
+ * idempotent re-add); any other hold of the address — a primary
1975
+ * anywhere (this account's included) or another account's additional
1976
+ * — answers 'conflict'. The unique index is the race backstop. */
1977
+ async addAccountEmail(userId: string, email: string, addedBy?: string | null): Promise<AddAccountEmailResult> {
1978
+ await this.ensureAccountEmailSupport()
1979
+ const normalized = email.trim().toLowerCase()
1980
+ const takenPrimary = await this.stmt('SELECT id FROM users WHERE email = ?', normalized).first<{ id: string }>()
1981
+ if (takenPrimary) return 'conflict'
1982
+ const existing = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', normalized).first<{ user_id: string }>()
1983
+ if (existing) return existing.user_id === userId ? 'present' : 'conflict'
1984
+ try {
1985
+ await this.stmt(
1986
+ 'INSERT INTO account_emails (user_id, email, added_by) VALUES (?, ?, ?)',
1987
+ userId, normalized, addedBy ?? null,
1988
+ ).run()
1989
+ } catch (e) {
1990
+ if (String((e as Error).message).includes('UNIQUE')) return 'conflict'
1991
+ throw e
1992
+ }
1993
+ return 'added'
1994
+ }
1995
+
1996
+ /** The verification ceremony's stamp on the account's OWN row: the
1997
+ * guarded UPDATE flips verified_at, once. */
1998
+ async markAccountEmailVerified(userId: string, email: string): Promise<boolean> {
1999
+ await this.ensureAccountEmailSupport()
2000
+ const res = await this.stmt(
2001
+ "UPDATE account_emails SET verified_at = datetime('now') WHERE user_id = ? AND email = ? AND verified_at IS NULL",
2002
+ userId, email.trim().toLowerCase(),
2003
+ ).run()
2004
+ return (res.meta.changes ?? 0) > 0
2005
+ }
2006
+
2007
+ /** Promote a VERIFIED additional to primary: the promoted address
2008
+ * becomes users.email with its verification stamp; the outgoing
2009
+ * primary takes the row's place in account_emails with ITS stamp
2010
+ * (it stays a verified additional — sign-in by it keeps working). */
2011
+ async setPrimaryAccountEmail(userId: string, email: string): Promise<'ok' | 'unknown' | 'unverified'> {
2012
+ await this.ensureAccountEmailSupport()
2013
+ const normalized = email.trim().toLowerCase()
2014
+ const row = await this.stmt('SELECT * FROM account_emails WHERE user_id = ? AND email = ?', userId, normalized).first<Record<string, unknown>>()
2015
+ if (!row) return 'unknown'
2016
+ if (!row.verified_at) return 'unverified'
2017
+ const current = await this.stmt('SELECT email, email_verified_at FROM users WHERE id = ?', userId).first<{ email: string; email_verified_at: string | null }>()
2018
+ if (!current) return 'unknown'
2019
+ await this.stmt('UPDATE users SET email = ?, email_verified_at = ? WHERE id = ?', normalized, row.verified_at as string, userId).run()
2020
+ await this.stmt('DELETE FROM account_emails WHERE user_id = ? AND email = ?', userId, normalized).run()
2021
+ await this.stmt('INSERT INTO account_emails (user_id, email, verified_at) VALUES (?, ?, ?)', userId, current.email, current.email_verified_at).run()
2022
+ return 'ok'
2023
+ }
2024
+
2025
+ /** Remove an ADDITIONAL address. The primary refuses honestly
2026
+ * ('primary' — promote another address first). */
2027
+ async removeAccountEmail(userId: string, email: string): Promise<'ok' | 'primary' | 'unknown'> {
2028
+ await this.ensureAccountEmailSupport()
2029
+ const normalized = email.trim().toLowerCase()
2030
+ const current = await this.stmt('SELECT email FROM users WHERE id = ?', userId).first<{ email: string }>()
2031
+ if (current?.email === normalized) return 'primary'
2032
+ const res = await this.stmt('DELETE FROM account_emails WHERE user_id = ? AND email = ?', userId, normalized).run()
2033
+ return (res.meta.changes ?? 0) > 0 ? 'ok' : 'unknown'
2034
+ }
2035
+
1786
2036
  // ── strong authentication: the factor registry (TODO.identity-sso/02 + /03) ──
1787
2037
  // The same SQL as the SQLite half (store/sqlite/factors-store.ts): the
1788
2038
  // one-time consumes are guarded UPDATEs, the counter advance is the
@@ -2318,6 +2568,14 @@ export class D1ServerStore implements ServerStore {
2318
2568
  return res.results.map(D1ServerStore.toOrgMembership)
2319
2569
  }
2320
2570
 
2571
+ async listAllOrgMemberships(): Promise<OrgMembership[]> {
2572
+ await this.ensureMembershipSupport()
2573
+ const res = await this.stmt(
2574
+ 'SELECT * FROM org_memberships ORDER BY org_id, created_at',
2575
+ ).all<Record<string, unknown>>()
2576
+ return res.results.map(D1ServerStore.toOrgMembership)
2577
+ }
2578
+
2321
2579
  async getOrgMembership(userId: string, orgId: string): Promise<OrgMembership | null> {
2322
2580
  await this.ensureMembershipSupport()
2323
2581
  const row = await this.stmt('SELECT * FROM org_memberships WHERE user_id = ? AND org_id = ?', userId, orgId)
@@ -3112,31 +3370,27 @@ export class D1ServerStore implements ServerStore {
3112
3370
  // The SAME statements as sqlite/notify.ts's sync half (D1 is SQLite).
3113
3371
  // The defensive ensure mirrors the instrument_registrations posture:
3114
3372
  // a dev D1 migrated from before migration 0018 lacks the table.
3115
-
3116
- private notifyDeliverySupportEnsured: Promise<void> | null = null
3373
+ // (Memoized per (binding, chain) at module scope — the header note.)
3117
3374
 
3118
3375
  private ensureNotifyDeliverySupport(): Promise<void> {
3119
- if (!this.notifyDeliverySupportEnsured) {
3120
- this.notifyDeliverySupportEnsured = (async () => {
3121
- await this.db.prepare(
3122
- `CREATE TABLE IF NOT EXISTS notify_deliveries (
3123
- id TEXT PRIMARY KEY,
3124
- event_id TEXT NOT NULL,
3125
- user_id TEXT NOT NULL,
3126
- reason TEXT NOT NULL,
3127
- email TEXT NOT NULL,
3128
- email_status TEXT,
3129
- email_at TEXT,
3130
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
3131
- UNIQUE (event_id, user_id)
3132
- )`,
3133
- ).run()
3134
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_event ON notify_deliveries (event_id)').run()
3135
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_user ON notify_deliveries (user_id)').run()
3136
- await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_status ON notify_deliveries (email_status)').run()
3137
- })()
3138
- }
3139
- return this.notifyDeliverySupportEnsured
3376
+ return ensured(this.db, 'notifyDeliverySupport', async () => {
3377
+ await this.db.prepare(
3378
+ `CREATE TABLE IF NOT EXISTS notify_deliveries (
3379
+ id TEXT PRIMARY KEY,
3380
+ event_id TEXT NOT NULL,
3381
+ user_id TEXT NOT NULL,
3382
+ reason TEXT NOT NULL,
3383
+ email TEXT NOT NULL,
3384
+ email_status TEXT,
3385
+ email_at TEXT,
3386
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
3387
+ UNIQUE (event_id, user_id)
3388
+ )`,
3389
+ ).run()
3390
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_event ON notify_deliveries (event_id)').run()
3391
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_user ON notify_deliveries (user_id)').run()
3392
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notify_deliveries_status ON notify_deliveries (email_status)').run()
3393
+ })
3140
3394
  }
3141
3395
 
3142
3396
  private static toNotifyDelivery(row: Record<string, unknown>): NotifyDelivery {