@oimlsmart/platform-server 0.1.0

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.
Files changed (48) hide show
  1. package/README.md +92 -0
  2. package/migrations/0001_init.sql +69 -0
  3. package/migrations/0002_identity.sql +24 -0
  4. package/migrations/0003_federation_peers.sql +21 -0
  5. package/migrations/0003_users_rbac.sql +8 -0
  6. package/migrations/0004_oidc_op.sql +61 -0
  7. package/migrations/0005_upstream_providers.sql +34 -0
  8. package/migrations/0006_op_accounts.sql +28 -0
  9. package/migrations/0007_org_join_requests.sql +26 -0
  10. package/migrations/0008_op_client_roles.sql +19 -0
  11. package/migrations/0009_account_console.sql +32 -0
  12. package/migrations/0009_sso_states.sql +13 -0
  13. package/migrations/0010_notify_events.sql +23 -0
  14. package/migrations/0011_op_launch.sql +18 -0
  15. package/migrations/0011_org_memberships.sql +62 -0
  16. package/migrations/0012_notify_subscriptions.sql +57 -0
  17. package/migrations/0012_strong_auth.sql +109 -0
  18. package/migrations/0013_org_registry.sql +53 -0
  19. package/migrations/0014_notify_inbox.sql +34 -0
  20. package/migrations/0015_certificate_holder_attribution.sql +63 -0
  21. package/migrations/0016_instrument_registrations.sql +78 -0
  22. package/package.json +52 -0
  23. package/src/client-info.ts +25 -0
  24. package/src/context.ts +31 -0
  25. package/src/github.ts +284 -0
  26. package/src/mailer.ts +309 -0
  27. package/src/oidc.ts +369 -0
  28. package/src/profile/node.ts +83 -0
  29. package/src/profile.ts +582 -0
  30. package/src/rbac/node.ts +42 -0
  31. package/src/rbac.ts +53 -0
  32. package/src/session.ts +45 -0
  33. package/src/store/d1.ts +2850 -0
  34. package/src/store/sqlite/entities.ts +71 -0
  35. package/src/store/sqlite/events.ts +82 -0
  36. package/src/store/sqlite/factors-store.ts +348 -0
  37. package/src/store/sqlite/notify.ts +247 -0
  38. package/src/store/sqlite/op-accounts-store.ts +470 -0
  39. package/src/store/sqlite/op-store.ts +280 -0
  40. package/src/store/sqlite/schema.sql +745 -0
  41. package/src/store/sqlite/store.ts +1390 -0
  42. package/src/store/sqlite/upstream-store.ts +148 -0
  43. package/src/store/sqlite.ts +1027 -0
  44. package/src/store.ts +1826 -0
  45. package/src/vocab/index.ts +12 -0
  46. package/src/vocab/permissions.ts +398 -0
  47. package/src/vocab/rbac.ts +281 -0
  48. package/src/vocab/roles.ts +162 -0
@@ -0,0 +1,2850 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The D1 ServerStore (TODO.cs-e2e/14 — the Cloudflare deployment):
3
+ // the SAME surface as the SQLite store (server/db/store.ts +
4
+ // entities.ts) against a Cloudflare D1 binding — D1 IS SQLite, so the
5
+ // schema (schema.sql, and the canonical migration set under
6
+ // migrations/) and every statement port directly; only the driver
7
+ // calls change (prepare/bind/run, async).
8
+ //
9
+ // WORKER-SAFE: the only import is the D1 TYPE (erased at build) — no
10
+ // node built-ins, no better-sqlite3. The binding arrives from the
11
+ // Worker entry (server/cloudflare.ts), which installs this store when
12
+ // env.DB is present — the binding-presence profile switch, the same
13
+ // pattern ENTITY_BACKEND uses client-side.
14
+ //
15
+ // Local proof: the smart monorepo's browser/src/__tests__/
16
+ // d1-store.test.ts runs this class against a better-sqlite3-backed D1
17
+ // facade (the binding contract with real semantics) plus the real
18
+ // workerd binding through scripts/cloudflare-smoke.ts there.
19
+ // ═══════════════════════════════════════════════════════════════════
20
+
21
+ import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types'
22
+ import {
23
+ DEMO_PASSWORD,
24
+ type AdvanceCounterResult,
25
+ type AuthUserPayload,
26
+ type CompleteEmailChangeResult,
27
+ type CompleteEnrollmentResult,
28
+ type EmailChangeToken,
29
+ type EnrollmentToken,
30
+ type EntityChange,
31
+ type EntityRow,
32
+ type EventKeyFilter,
33
+ type FederationPeer,
34
+ type IdentityApproval,
35
+ type IdentityLink,
36
+ type IdentityProvider,
37
+ type MfaPending,
38
+ type NotifyEntityMute,
39
+ type NotifyInboxState,
40
+ type NotifyPreferences,
41
+ type NotifyRule,
42
+ type OAuthInitialAssignment,
43
+ type OidcAccessToken,
44
+ type OidcAuthorization,
45
+ type OidcClient,
46
+ type OidcClientLaunch,
47
+ type OidcCode,
48
+ type OidcKeyRow,
49
+ type OpAccountErasure,
50
+ type OpClientRoleAssignment,
51
+ type OpLiveSession,
52
+ type OrgJoinRequest,
53
+ type OrgMembership,
54
+ type OrgMembershipState,
55
+ type CertificateHolderClaim,
56
+ type CertificateHolderOrg,
57
+ type OrgRegistryContact,
58
+ type OrgRegistryOrg,
59
+ type OrgRegistryState,
60
+ type InstrumentRegistration,
61
+ type InstrumentRegistrationLifecycle,
62
+ type InstrumentRegistrationScopeStatus,
63
+ type PlatformEvent,
64
+ resolveOrgContext,
65
+ type RecoveryCodeState,
66
+ type ServerStore,
67
+ type SessionView,
68
+ type SsoSignInState,
69
+ type TotpSecret,
70
+ type UserAdminRow,
71
+ type WebauthnChallenge,
72
+ type WebauthnCredential,
73
+ } from '../store'
74
+ // TODO.federation/01 — the account plan follows the deployment profile
75
+ // (the Worker's seed route installs it from the env binding first; the
76
+ // slot defaults to the hub profile, which is exactly DEMO_ACCOUNTS).
77
+ import { getInstanceProfile, seedAccountsForProfile } from '../profile'
78
+
79
+ interface UserRecord {
80
+ id: string
81
+ email: string
82
+ name: string
83
+ role: string
84
+ org_id: string | null
85
+ avatar_url: string | null
86
+ roles: string | null
87
+ active: number
88
+ provider: string
89
+ email_verified_at?: string | null
90
+ }
91
+
92
+ function parseRoles(raw: string | null): string[] | undefined {
93
+ if (!raw) return undefined
94
+ try {
95
+ const parsed = JSON.parse(raw) as unknown
96
+ return Array.isArray(parsed) && parsed.length ? parsed.filter((v): v is string => typeof v === 'string') : undefined
97
+ } catch {
98
+ return undefined
99
+ }
100
+ }
101
+
102
+ function toPayload(user: UserRecord, avatarUrl?: string): AuthUserPayload {
103
+ const roles = parseRoles(user.roles)
104
+ return {
105
+ id: user.id,
106
+ email: user.email,
107
+ name: user.name,
108
+ role: user.role,
109
+ ...(roles?.length ? { roles } : {}),
110
+ orgId: user.org_id ?? null,
111
+ avatarUrl: avatarUrl ?? user.avatar_url ?? undefined,
112
+ provider: user.provider,
113
+ // TODO.identity/06: the primary address's verification state.
114
+ emailVerifiedAt: user.email_verified_at ?? null,
115
+ }
116
+ }
117
+
118
+ /** The identity_approvals row → the seam's camelCase shape. */
119
+ function toIdentityApproval(row: Record<string, unknown>): IdentityApproval {
120
+ return {
121
+ id: row.id as string,
122
+ email: row.email as string,
123
+ name: row.name as string,
124
+ issuer: row.issuer as string,
125
+ sub: row.sub as string,
126
+ claimsJson: (row.claims_json as string | null) ?? null,
127
+ status: row.status as IdentityApproval['status'],
128
+ decidedRole: (row.decided_role as string | null) ?? null,
129
+ decidedOrg: (row.decided_org as string | null) ?? null,
130
+ decidedBy: (row.decided_by as string | null) ?? null,
131
+ createdAt: row.created_at as string,
132
+ lastSeen: (row.last_seen as string | null) ?? (row.created_at as string),
133
+ decidedAt: (row.decided_at as string | null) ?? null,
134
+ }
135
+ }
136
+
137
+ /** The federation_peers row → the seam's camelCase shape
138
+ * (TODO.federation/04). */
139
+ function toFederationPeer(row: Record<string, unknown>): FederationPeer {
140
+ return {
141
+ id: row.id as string,
142
+ name: row.name as string,
143
+ roles: row.roles as string,
144
+ descriptorUrl: (row.descriptor_url as string | null) ?? null,
145
+ descriptorJson: row.descriptor_json as string,
146
+ pinnedVia: row.pinned_via as FederationPeer['pinnedVia'],
147
+ connectivity: row.connectivity as FederationPeer['connectivity'],
148
+ status: row.status as FederationPeer['status'],
149
+ addedAt: row.added_at as string,
150
+ addedBy: (row.added_by as string | null) ?? null,
151
+ refreshedAt: (row.refreshed_at as string | null) ?? null,
152
+ revokedAt: (row.revoked_at as string | null) ?? null,
153
+ revokedBy: (row.revoked_by as string | null) ?? null,
154
+ }
155
+ }
156
+
157
+ function toAdminRow(user: UserRecord & { last_login?: string | null; provider?: string }): UserAdminRow {
158
+ return {
159
+ id: user.id,
160
+ email: user.email,
161
+ name: user.name,
162
+ role: user.role,
163
+ roles: parseRoles(user.roles) ?? [user.role],
164
+ orgId: user.org_id ?? null,
165
+ active: user.active !== 0,
166
+ provider: user.provider ?? 'demo',
167
+ lastLogin: user.last_login ?? null,
168
+ emailVerifiedAt: user.email_verified_at ?? null,
169
+ }
170
+ }
171
+
172
+ /** The workflow tables the reset wipe covers, in a fixed order. Ranged
173
+ * rounds address rows by rowid (stable under deletion, so a resumed
174
+ * round never re-scans); every table here is an ordinary rowid table
175
+ * (none WITHOUT ROWID — schema.sql). The TODO.notify/01 event store
176
+ * wipes with them — its rows reference the workflow entities a reset
177
+ * removes (the feed's read-time visibility gate would drop the orphans
178
+ * anyway; wiping keeps the demo honest). */
179
+ const WIPE_TABLES = ['entity_changes', 'evidence_records', 'entities', 'events', 'instrument_registrations'] as const
180
+
181
+ export class D1ServerStore implements ServerStore {
182
+ constructor(private readonly db: D1Database) {}
183
+
184
+ private stmt(sql: string, ...params: unknown[]): D1PreparedStatement {
185
+ return this.db.prepare(sql).bind(...params)
186
+ }
187
+
188
+ // TODO.federation/12: the roles/active columns arrive with migration
189
+ // 0002 — a dev D1 migrated from the pre-RBAC 0001 lacks them, so the
190
+ // user methods ensure them defensively (PRAGMA probe + ALTER),
191
+ // memoized per store.
192
+ private usersColumnsEnsured: Promise<void> | null = null
193
+
194
+ private ensureUserColumns(): Promise<void> {
195
+ if (!this.usersColumnsEnsured) {
196
+ this.usersColumnsEnsured = (async () => {
197
+ const cols = await this.db.prepare('PRAGMA table_info(users)').all<{ name: string }>()
198
+ const names = new Set(cols.results.map(c => c.name))
199
+ if (!names.has('roles')) await this.db.prepare('ALTER TABLE users ADD COLUMN roles TEXT').run()
200
+ if (!names.has('active')) await this.db.prepare('ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1').run()
201
+ // TODO.identity/06 (the account console): the address's
202
+ // verification state.
203
+ if (!names.has('email_verified_at')) await this.db.prepare('ALTER TABLE users ADD COLUMN email_verified_at TEXT').run()
204
+ })()
205
+ }
206
+ return this.usersColumnsEnsured
207
+ }
208
+
209
+ // TODO.identity/06 (the account console's sessions section): the
210
+ // sign-in context columns arrive with migration 0009 — a dev D1
211
+ // migrated from before it lacks them, so the session methods ensure
212
+ // them defensively (the ensureUserColumns posture, memoized per store).
213
+ private sessionColumnsEnsured: Promise<void> | null = null
214
+
215
+ private ensureSessionColumns(): Promise<void> {
216
+ if (!this.sessionColumnsEnsured) {
217
+ this.sessionColumnsEnsured = (async () => {
218
+ const cols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
219
+ const names = new Set(cols.results.map(c => c.name))
220
+ if (!names.has('user_agent')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN user_agent TEXT').run()
221
+ if (!names.has('ip')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN ip TEXT').run()
222
+ if (!names.has('last_seen_at')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN last_seen_at TEXT').run()
223
+ // TODO.identity-sso/02+03: the sign-in provenance.
224
+ if (!names.has('amr')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN amr TEXT').run()
225
+ })()
226
+ }
227
+ return this.sessionColumnsEnsured
228
+ }
229
+
230
+ // TODO.identity/11 (the multi-org membership model): the
231
+ // org_memberships table, the session's active-org stamp and the
232
+ // token-flow context columns arrive with migration 0011 — a dev D1
233
+ // migrated from before it lacks them, so the membership/session/token
234
+ // methods ensure them defensively (the ensureUserColumns posture,
235
+ // memoized per store). The backfill (the migration's twin) is
236
+ // IDEMPOTENT and rides the ensure: every org-bound account's primary
237
+ // membership mirrors the legacy columns.
238
+ private membershipSupportEnsured: Promise<void> | null = null
239
+
240
+ private ensureMembershipSupport(): Promise<void> {
241
+ if (!this.membershipSupportEnsured) {
242
+ this.membershipSupportEnsured = (async () => {
243
+ await this.db.prepare(
244
+ `CREATE TABLE IF NOT EXISTS org_memberships (
245
+ id TEXT PRIMARY KEY,
246
+ user_id TEXT NOT NULL REFERENCES users(id),
247
+ org_id TEXT NOT NULL,
248
+ roles TEXT NOT NULL DEFAULT '[]',
249
+ state TEXT NOT NULL DEFAULT 'active',
250
+ is_primary INTEGER NOT NULL DEFAULT 0,
251
+ invited_by TEXT,
252
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
253
+ activated_at TEXT,
254
+ disabled_at TEXT,
255
+ disabled_by TEXT,
256
+ UNIQUE (user_id, org_id)
257
+ )`,
258
+ ).run()
259
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_org ON org_memberships (org_id, state)').run()
260
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_user ON org_memberships (user_id, state)').run()
261
+ const sessionCols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
262
+ if (!sessionCols.results.some(c => c.name === 'active_org')) {
263
+ await this.db.prepare('ALTER TABLE sessions ADD COLUMN active_org TEXT').run()
264
+ }
265
+ const codeCols = await this.db.prepare('PRAGMA table_info(oidc_codes)').all<{ name: string }>()
266
+ if (!codeCols.results.some(c => c.name === 'context_org')) {
267
+ await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN context_org TEXT').run()
268
+ }
269
+ const accessCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
270
+ if (!accessCols.results.some(c => c.name === 'context_org')) {
271
+ await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN context_org TEXT').run()
272
+ }
273
+ await this.db.prepare(
274
+ `INSERT OR IGNORE INTO org_memberships (id, user_id, org_id, roles, state, is_primary, activated_at)
275
+ SELECT 'mbr-' || id, id, org_id,
276
+ CASE WHEN roles IS NOT NULL AND roles != '' THEN roles ELSE json_array(role) END,
277
+ 'active', 1, COALESCE(last_login, created_at)
278
+ FROM users WHERE org_id IS NOT NULL`,
279
+ ).run()
280
+ })()
281
+ }
282
+ return this.membershipSupportEnsured
283
+ }
284
+
285
+ // TODO.identity-features/05 (the organization registry): the
286
+ // org_registry table arrives with migration 0013 — a dev D1 migrated
287
+ // from before it lacks the table, so the registry methods ensure it
288
+ // defensively (the ensureMembershipSupport posture, memoized per
289
+ // store).
290
+ private orgRegistrySupportEnsured: Promise<void> | null = null
291
+
292
+ private ensureOrgRegistrySupport(): Promise<void> {
293
+ if (!this.orgRegistrySupportEnsured) {
294
+ this.orgRegistrySupportEnsured = (async () => {
295
+ await this.db.prepare(
296
+ `CREATE TABLE IF NOT EXISTS org_registry (
297
+ id TEXT PRIMARY KEY,
298
+ name TEXT NOT NULL,
299
+ short_name TEXT,
300
+ kind TEXT,
301
+ country TEXT,
302
+ contacts TEXT NOT NULL DEFAULT '[]',
303
+ participant_ref TEXT,
304
+ state TEXT NOT NULL DEFAULT 'active',
305
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
306
+ created_by TEXT,
307
+ updated_at TEXT,
308
+ updated_by TEXT,
309
+ disabled_at TEXT,
310
+ disabled_by TEXT
311
+ )`,
312
+ ).run()
313
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_registry_state ON org_registry (state)').run()
314
+ })()
315
+ }
316
+ return this.orgRegistrySupportEnsured
317
+ }
318
+
319
+ // TODO.register/02 (the register's holder-org attribution): the
320
+ // certificate_holder_orgs / certificate_holder_claims tables arrive with
321
+ // migration 0015 — a dev D1 migrated from before it lacks them, so the
322
+ // attribution/claim methods ensure them defensively (the
323
+ // ensureOrgRegistrySupport posture, memoized per store).
324
+ private holderAttributionSupportEnsured: Promise<void> | null = null
325
+
326
+ private ensureHolderAttributionSupport(): Promise<void> {
327
+ if (!this.holderAttributionSupportEnsured) {
328
+ this.holderAttributionSupportEnsured = (async () => {
329
+ await this.db.prepare(
330
+ `CREATE TABLE IF NOT EXISTS certificate_holder_orgs (
331
+ certificate_id TEXT PRIMARY KEY,
332
+ org_id TEXT NOT NULL,
333
+ org_name TEXT NOT NULL,
334
+ source TEXT NOT NULL,
335
+ attributed_at TEXT NOT NULL,
336
+ attributed_by TEXT,
337
+ claim_id TEXT
338
+ )`,
339
+ ).run()
340
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_orgs_org ON certificate_holder_orgs (org_id)').run()
341
+ await this.db.prepare(
342
+ `CREATE TABLE IF NOT EXISTS certificate_holder_claims (
343
+ id TEXT PRIMARY KEY,
344
+ certificate_id TEXT NOT NULL,
345
+ claimant_org_id TEXT NOT NULL,
346
+ claimant_org_name TEXT NOT NULL,
347
+ matched_holder_name TEXT NOT NULL,
348
+ claimed_by TEXT NOT NULL,
349
+ state TEXT NOT NULL DEFAULT 'pending',
350
+ decided_by TEXT,
351
+ decided_at TEXT,
352
+ refusal_reason TEXT,
353
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
354
+ )`,
355
+ ).run()
356
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_cert ON certificate_holder_claims (certificate_id)').run()
357
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_state ON certificate_holder_claims (state)').run()
358
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_org ON certificate_holder_claims (claimant_org_id)').run()
359
+ })()
360
+ }
361
+ return this.holderAttributionSupportEnsured
362
+ }
363
+
364
+ // TODO.register/03 (the instrument register): the
365
+ // instrument_registrations table arrives with migration 0016 — a dev
366
+ // D1 migrated from before it lacks the table, so the register methods
367
+ // ensure it defensively (the ensureOrgRegistrySupport posture,
368
+ // memoized per store).
369
+ private instrumentRegistrationSupportEnsured: Promise<void> | null = null
370
+
371
+ private ensureInstrumentRegistrationSupport(): Promise<void> {
372
+ if (!this.instrumentRegistrationSupportEnsured) {
373
+ this.instrumentRegistrationSupportEnsured = (async () => {
374
+ await this.db.prepare(
375
+ `CREATE TABLE IF NOT EXISTS instrument_registrations (
376
+ id TEXT PRIMARY KEY,
377
+ certificate_id TEXT NOT NULL,
378
+ holder_org_id TEXT NOT NULL,
379
+ standard_id TEXT NOT NULL,
380
+ serial_number TEXT NOT NULL,
381
+ manufacture_date TEXT,
382
+ designations TEXT NOT NULL DEFAULT '{}',
383
+ scope_status TEXT NOT NULL,
384
+ scope_detail TEXT,
385
+ lifecycle TEXT NOT NULL DEFAULT 'registered',
386
+ registered_at TEXT NOT NULL DEFAULT (datetime('now')),
387
+ registered_by TEXT,
388
+ updated_at TEXT,
389
+ updated_by TEXT,
390
+ UNIQUE (certificate_id, serial_number)
391
+ )`,
392
+ ).run()
393
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_certificate ON instrument_registrations (certificate_id)').run()
394
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_holder ON instrument_registrations (holder_org_id)').run()
395
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_lifecycle ON instrument_registrations (lifecycle)').run()
396
+ })()
397
+ }
398
+ return this.instrumentRegistrationSupportEnsured
399
+ }
400
+
401
+ // TODO.identity-sso/02+03: the amr provenance columns on the OIDC
402
+ // flow rows arrive with migration 0012 — the OIDC methods ensure them
403
+ // defensively (the same memoized posture as the session/user columns),
404
+ // so a dev D1 migrated from before the wave never 500s the core flow.
405
+ private oidcColumnsEnsured: Promise<void> | null = null
406
+
407
+ private ensureOidcColumns(): Promise<void> {
408
+ if (!this.oidcColumnsEnsured) {
409
+ this.oidcColumnsEnsured = (async () => {
410
+ const codeCols = await this.db.prepare('PRAGMA table_info(oidc_codes)').all<{ name: string }>()
411
+ if (!codeCols.results.some(c => c.name === 'amr')) {
412
+ await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN amr TEXT').run()
413
+ }
414
+ const tokenCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
415
+ if (!tokenCols.results.some(c => c.name === 'amr')) {
416
+ await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN amr TEXT').run()
417
+ }
418
+ })()
419
+ }
420
+ return this.oidcColumnsEnsured
421
+ }
422
+
423
+ // ── users / sessions ─────────────────────────────────────────────
424
+
425
+ async seedDemoAccounts(): Promise<void> {
426
+ const statements: D1PreparedStatement[] = []
427
+ for (const account of seedAccountsForProfile(getInstanceProfile())) {
428
+ // The roles column carries the account's declared FULL set (NULL =
429
+ // the primary role only); the align clears a stale set honestly
430
+ // (the SQLite seed's rule).
431
+ const roles = account.roles?.length ? JSON.stringify(account.roles) : null
432
+ statements.push(this.stmt(
433
+ `INSERT OR IGNORE INTO users (id, email, name, provider, provider_account_id, role, org_id, roles)
434
+ VALUES (?, ?, ?, 'demo', ?, ?, ?, ?)`,
435
+ crypto.randomUUID(), account.email, account.name, account.email, account.role, account.orgId, roles,
436
+ ))
437
+ // Align existing demo rows with the current role/org assignments —
438
+ // INSERT OR IGNORE alone would leave them stale (same rule as the
439
+ // SQLite seed).
440
+ statements.push(this.stmt(
441
+ `UPDATE users SET name = ?, role = ?, org_id = ?, roles = ? WHERE email = ? AND provider = 'demo'`,
442
+ account.name, account.role, account.orgId, roles, account.email,
443
+ ))
444
+ }
445
+ // A profile with no demo cast (the production posture) is an honest
446
+ // no-op, never an error — D1's batch rejects an empty statement
447
+ // list ("No SQL statements detected").
448
+ if (statements.length === 0) return
449
+ await this.db.batch(statements)
450
+ // TODO.identity/11: the org-bound seed accounts' primary memberships
451
+ // ride the mirror (idempotent — the seed runs at every boot).
452
+ for (const account of seedAccountsForProfile(getInstanceProfile())) {
453
+ if (!account.orgId) continue
454
+ const row = await this.stmt('SELECT id FROM users WHERE email = ?', account.email).first<{ id: string }>()
455
+ if (row) await this.syncPrimaryMembership(row.id)
456
+ }
457
+ }
458
+
459
+ async authenticateDemo(email: string, password: string): Promise<AuthUserPayload | null> {
460
+ await this.ensureUserColumns()
461
+ const user = await this.stmt("SELECT * FROM users WHERE email = ? AND provider = 'demo'", email)
462
+ .first<UserRecord>()
463
+ if (!user) return null
464
+ // A deactivated account refuses sign-in (TODO.federation/12).
465
+ if (user.active === 0) return null
466
+ if (password !== DEMO_PASSWORD) return null
467
+ await this.stmt("UPDATE users SET last_login = datetime('now') WHERE id = ?", user.id).run()
468
+ return toPayload(user)
469
+ }
470
+
471
+ async findOrCreateOAuthUser(
472
+ provider: string,
473
+ providerAccountId: string,
474
+ email: string,
475
+ name: string,
476
+ avatarUrl?: string,
477
+ // The INITIAL role/org — applied only on CREATE (an existing
478
+ // account keeps its local assignment).
479
+ initial?: OAuthInitialAssignment,
480
+ ): Promise<AuthUserPayload> {
481
+ const existing = await this.stmt(
482
+ 'SELECT * FROM users WHERE provider = ? AND provider_account_id = ?', provider, providerAccountId,
483
+ ).first<UserRecord>()
484
+
485
+ if (existing) {
486
+ await this.stmt("UPDATE users SET last_login = datetime('now'), avatar_url = ? WHERE id = ?",
487
+ avatarUrl ?? null, existing.id).run()
488
+ return toPayload(existing, avatarUrl)
489
+ }
490
+
491
+ const id = crypto.randomUUID()
492
+ const role = initial?.role ?? 'user'
493
+ const orgId = initial?.orgId ?? null
494
+ await this.stmt(
495
+ "INSERT INTO users (id, email, name, avatar_url, provider, provider_account_id, role, org_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
496
+ id, email, name, avatarUrl ?? null, provider, providerAccountId, role, orgId,
497
+ ).run()
498
+ if (orgId) await this.syncPrimaryMembership(id) // TODO.identity/11 — the mirror
499
+ return { id, email, name, role, orgId, avatarUrl }
500
+ }
501
+
502
+ async createSession(
503
+ userId: string,
504
+ opts?: { idTokenHint?: string | null; userAgent?: string | null; ip?: string | null; amr?: string[] | null },
505
+ ): Promise<string> {
506
+ await this.ensureSessionColumns()
507
+ const token = crypto.randomUUID()
508
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
509
+ await this.stmt(
510
+ 'INSERT INTO sessions (id, user_id, token, expires_at, id_token_hint, user_agent, ip, last_seen_at, amr) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
511
+ crypto.randomUUID(), userId, token, expiresAt, opts?.idTokenHint ?? null, opts?.userAgent ?? null, opts?.ip ?? null, null,
512
+ opts?.amr?.length ? JSON.stringify(opts.amr) : null,
513
+ ).run()
514
+ return token
515
+ }
516
+
517
+ async touchLastLogin(userId: string): Promise<void> {
518
+ await this.stmt("UPDATE users SET last_login = datetime('now') WHERE id = ?", userId).run()
519
+ }
520
+
521
+ async getSessionIdTokenHint(token: string): Promise<string | null> {
522
+ const row = await this.stmt(
523
+ "SELECT id_token_hint FROM sessions WHERE token = ? AND expires_at > datetime('now')",
524
+ token,
525
+ ).first<{ id_token_hint: string | null }>()
526
+ return row?.id_token_hint ?? null
527
+ }
528
+
529
+ async getSessionUser(token: string): Promise<AuthUserPayload | null> {
530
+ await this.ensureUserColumns()
531
+ await this.ensureSessionColumns()
532
+ await this.ensureMembershipSupport()
533
+ // The session joins the LIVE user row (TODO.federation/12): a role
534
+ // reassignment takes effect on the next request; deactivation ends
535
+ // the session at once.
536
+ const session = await this.stmt(
537
+ `SELECT s.user_id, s.active_org, s.amr, u.email, u.name, u.role, u.roles, u.org_id, u.avatar_url, u.provider, u.email_verified_at
538
+ FROM sessions s JOIN users u ON s.user_id = u.id
539
+ WHERE s.token = ? AND s.expires_at > datetime('now') AND u.active = 1`,
540
+ token,
541
+ ).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 }>()
542
+ if (!session) return null
543
+ // TODO.identity/06: the last-active stamp, throttled to one write
544
+ // per minute per session (the sessions section shows it).
545
+ await this.stmt(
546
+ "UPDATE sessions SET last_seen_at = datetime('now') WHERE token = ? AND (last_seen_at IS NULL OR last_seen_at < datetime('now', '-60 seconds'))",
547
+ token,
548
+ ).run()
549
+ const amr = parseRoles(session.amr)
550
+ const payload: AuthUserPayload = {
551
+ id: session.user_id,
552
+ email: session.email,
553
+ name: session.name,
554
+ role: session.role,
555
+ ...(parseRoles(session.roles)?.length ? { roles: parseRoles(session.roles) } : {}),
556
+ orgId: session.org_id ?? null,
557
+ avatarUrl: session.avatar_url ?? undefined,
558
+ provider: session.provider,
559
+ emailVerifiedAt: session.email_verified_at ?? null,
560
+ ...(amr?.length ? { amr } : {}),
561
+ }
562
+ // TODO.identity/11: the active-org context (the membership model) —
563
+ // the payload's org/roles follow the session's stamped context.
564
+ const activeOrg = session.active_org ?? null
565
+ const active = activeOrg ? await this.getOrgMembership(payload.id, activeOrg) : null
566
+ const primary = payload.orgId ? await this.getOrgMembership(payload.id, payload.orgId) : null
567
+ const resolved = resolveOrgContext(payload, { activeOrg, active, primary })
568
+ if (activeOrg && !(active && active.state === 'active')) {
569
+ // The stale stamp never lingers (the membership ended mid-session).
570
+ await this.stmt('UPDATE sessions SET active_org = NULL WHERE user_id = ? AND active_org = ?', payload.id, activeOrg).run()
571
+ }
572
+ return { ...payload, orgId: resolved.orgId, roles: resolved.roles }
573
+ }
574
+
575
+ async deleteSession(token: string): Promise<void> {
576
+ await this.stmt('DELETE FROM sessions WHERE token = ?', token).run()
577
+ }
578
+
579
+ async cleanExpiredSessions(): Promise<void> {
580
+ await this.stmt("DELETE FROM sessions WHERE expires_at <= datetime('now')").run()
581
+ }
582
+
583
+ async listDemoAccounts(): Promise<Array<{ email: string; name: string; role: string }>> {
584
+ const res = await this.stmt(
585
+ "SELECT email, name, role FROM users WHERE provider = 'demo' ORDER BY role, name",
586
+ ).all<{ email: string; name: string; role: string }>()
587
+ return res.results
588
+ }
589
+
590
+ // ── identity federation (TODO.federation/10) ───────────────────────
591
+
592
+ async findUserByEmail(email: string): Promise<AuthUserPayload | null> {
593
+ const user = await this.stmt('SELECT * FROM users WHERE email = ?', email).first<UserRecord>()
594
+ return user ? toPayload(user) : null
595
+ }
596
+
597
+ /** TODO.identity/01 — the OP's token endpoint resolves the code's
598
+ * user_id through this. */
599
+ async getUserById(id: string): Promise<AuthUserPayload | null> {
600
+ const user = await this.stmt('SELECT * FROM users WHERE id = ?', id).first<UserRecord>()
601
+ return user ? toPayload(user) : null
602
+ }
603
+
604
+ async findUserByProvider(provider: string, providerAccountId: string): Promise<AuthUserPayload | null> {
605
+ const user = await this.stmt(
606
+ 'SELECT * FROM users WHERE provider = ? AND provider_account_id = ?', provider, providerAccountId,
607
+ ).first<UserRecord>()
608
+ return user ? toPayload(user) : null
609
+ }
610
+
611
+ async provisionSsoUser(input: {
612
+ email: string
613
+ name: string
614
+ provider: string
615
+ providerAccountId: string
616
+ role: string
617
+ orgId: string | null
618
+ }): Promise<AuthUserPayload> {
619
+ const id = crypto.randomUUID()
620
+ await this.stmt(
621
+ "INSERT INTO users (id, email, name, provider, provider_account_id, role, org_id, last_login) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))",
622
+ id, input.email, input.name, input.provider, input.providerAccountId, input.role, input.orgId,
623
+ ).run()
624
+ if (input.orgId) await this.syncPrimaryMembership(id) // TODO.identity/11 — the mirror
625
+ return { id, email: input.email, name: input.name, role: input.role, orgId: input.orgId }
626
+ }
627
+
628
+ async linkProviderIdentity(userId: string, provider: string, providerAccountId: string): Promise<void> {
629
+ await this.stmt(
630
+ "UPDATE users SET provider = ?, provider_account_id = ?, last_login = datetime('now') WHERE id = ?",
631
+ provider, providerAccountId, userId,
632
+ ).run()
633
+ }
634
+
635
+ async updateUserRoleOrg(userId: string, role: string, orgId: string | null): Promise<void> {
636
+ await this.stmt('UPDATE users SET role = ?, org_id = ? WHERE id = ?', role, orgId, userId).run()
637
+ if (orgId) await this.syncPrimaryMembership(userId) // TODO.identity/11 — the mirror
638
+ }
639
+
640
+ // The approval queue rows port directly (D1 is SQLite).
641
+
642
+ private async approvalRow(where: string, ...params: unknown[]) {
643
+ const row = await this.stmt(`SELECT * FROM identity_approvals WHERE ${where}`, ...params)
644
+ .first<Record<string, unknown>>()
645
+ return row ? toIdentityApproval(row) : null
646
+ }
647
+
648
+ async upsertIdentityApproval(input: {
649
+ email: string
650
+ name: string
651
+ issuer: string
652
+ sub: string
653
+ claimsJson: string | null
654
+ }): Promise<IdentityApproval> {
655
+ await this.stmt(
656
+ `INSERT INTO identity_approvals (id, email, name, issuer, sub, claims_json, last_seen)
657
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
658
+ ON CONFLICT (issuer, sub) DO UPDATE SET
659
+ email = excluded.email, name = excluded.name, claims_json = excluded.claims_json,
660
+ last_seen = datetime('now')`,
661
+ crypto.randomUUID(), input.email, input.name, input.issuer, input.sub, input.claimsJson,
662
+ ).run()
663
+ return (await this.approvalRow('issuer = ? AND sub = ?', input.issuer, input.sub))!
664
+ }
665
+
666
+ async getIdentityApproval(issuer: string, sub: string): Promise<IdentityApproval | null> {
667
+ return this.approvalRow('issuer = ? AND sub = ?', issuer, sub)
668
+ }
669
+
670
+ async listIdentityApprovals(status?: IdentityApproval['status']): Promise<IdentityApproval[]> {
671
+ const res = status
672
+ ? await this.stmt('SELECT * FROM identity_approvals WHERE status = ? ORDER BY created_at', status).all<Record<string, unknown>>()
673
+ : await this.stmt('SELECT * FROM identity_approvals ORDER BY created_at').all<Record<string, unknown>>()
674
+ return res.results.map(toIdentityApproval)
675
+ }
676
+
677
+ async decideIdentityApproval(
678
+ id: string,
679
+ decision: { status: 'approved' | 'rejected'; role?: string; orgId?: string | null; decidedBy: string },
680
+ ): Promise<IdentityApproval | null> {
681
+ await this.stmt(
682
+ `UPDATE identity_approvals SET status = ?, decided_role = ?, decided_org = ?, decided_by = ?, decided_at = datetime('now')
683
+ WHERE id = ?`,
684
+ decision.status, decision.role ?? null, decision.orgId ?? null, decision.decidedBy, id,
685
+ ).run()
686
+ return this.approvalRow('id = ?', id)
687
+ }
688
+
689
+ // ── the SSO sign-in state jar (TODO.identity/04) ───────────────────
690
+ // The rows port directly (D1 is SQLite); the consume's UPDATE flip is
691
+ // the atomic single-use guarantee across isolates.
692
+
693
+ async putSsoState(input: { state: string; nonce: string; verifier: string; ttlMs: number }): Promise<void> {
694
+ // The expiry sweep rides the write: stale rows are inert (consume
695
+ // checks the expiry) but never accumulate.
696
+ await this.stmt('DELETE FROM sso_states WHERE expires_at <= ?', new Date().toISOString()).run()
697
+ await this.stmt(
698
+ 'INSERT INTO sso_states (state, nonce, verifier, expires_at) VALUES (?, ?, ?, ?)',
699
+ input.state, input.nonce, input.verifier, new Date(Date.now() + input.ttlMs).toISOString(),
700
+ ).run()
701
+ }
702
+
703
+ /** Atomically consume the state: the UPDATE flips consumed_at exactly
704
+ * once (a replay loses the race and answers null). An EXPIRED row is
705
+ * consumed too — never a second chance. */
706
+ async consumeSsoState(state: string): Promise<SsoSignInState | null> {
707
+ const res = await this.stmt(
708
+ "UPDATE sso_states SET consumed_at = datetime('now') WHERE state = ? AND consumed_at IS NULL", state,
709
+ ).run()
710
+ if ((res.meta.changes ?? 0) === 0) return null
711
+ const row = await this.stmt('SELECT state, nonce, verifier, expires_at FROM sso_states WHERE state = ?', state)
712
+ .first<Record<string, unknown>>()
713
+ if (!row) return null
714
+ if (new Date(row.expires_at as string).getTime() <= Date.now()) return null
715
+ return {
716
+ state: row.state as string,
717
+ nonce: row.nonce as string,
718
+ verifier: row.verifier as string,
719
+ expiresAt: row.expires_at as string,
720
+ }
721
+ }
722
+
723
+ // ── federation peers (TODO.federation/04) ───────────────────────────
724
+ // The peer rows port directly (D1 is SQLite).
725
+
726
+ async listFederationPeers(status?: FederationPeer['status']): Promise<FederationPeer[]> {
727
+ const res = status
728
+ ? await this.stmt('SELECT * FROM federation_peers WHERE status = ? ORDER BY added_at', status).all<Record<string, unknown>>()
729
+ : await this.stmt('SELECT * FROM federation_peers ORDER BY added_at').all<Record<string, unknown>>()
730
+ return res.results.map(toFederationPeer)
731
+ }
732
+
733
+ async getFederationPeer(id: string): Promise<FederationPeer | null> {
734
+ const row = await this.stmt('SELECT * FROM federation_peers WHERE id = ?', id).first<Record<string, unknown>>()
735
+ return row ? toFederationPeer(row) : null
736
+ }
737
+
738
+ async upsertFederationPeer(input: {
739
+ id: string
740
+ name: string
741
+ roles: string
742
+ descriptorUrl: string | null
743
+ descriptorJson: string
744
+ pinnedVia: FederationPeer['pinnedVia']
745
+ connectivity: FederationPeer['connectivity']
746
+ addedBy: string | null
747
+ }): Promise<FederationPeer> {
748
+ // The same upsert semantics as the SQLite store: an update stamps
749
+ // refreshed_at and never resurrects a revoked peer.
750
+ await this.stmt(
751
+ `INSERT INTO federation_peers (id, name, roles, descriptor_url, descriptor_json, pinned_via, connectivity, added_by)
752
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
753
+ ON CONFLICT (id) DO UPDATE SET
754
+ name = excluded.name, roles = excluded.roles,
755
+ descriptor_url = excluded.descriptor_url, descriptor_json = excluded.descriptor_json,
756
+ pinned_via = excluded.pinned_via, connectivity = excluded.connectivity,
757
+ refreshed_at = datetime('now')`,
758
+ input.id, input.name, input.roles, input.descriptorUrl, input.descriptorJson, input.pinnedVia, input.connectivity, input.addedBy,
759
+ ).run()
760
+ return (await this.getFederationPeer(input.id))!
761
+ }
762
+
763
+ async revokeFederationPeer(id: string, revokedBy: string): Promise<FederationPeer | null> {
764
+ await this.stmt(
765
+ `UPDATE federation_peers SET status = 'revoked', revoked_at = datetime('now'), revoked_by = ? WHERE id = ?`,
766
+ revokedBy, id,
767
+ ).run()
768
+ return this.getFederationPeer(id)
769
+ }
770
+
771
+ // ── user administration (TODO.federation/12) ─────────────────────
772
+
773
+ async listUsers(): Promise<UserAdminRow[]> {
774
+ await this.ensureUserColumns()
775
+ const res = await this.stmt('SELECT * FROM users ORDER BY name')
776
+ .all<UserRecord & { last_login?: string | null; provider?: string }>()
777
+ return res.results.map(toAdminRow)
778
+ }
779
+
780
+ async createLocalUser(input: {
781
+ email: string
782
+ name: string
783
+ role: string
784
+ roles?: string[]
785
+ orgId?: string | null
786
+ }): Promise<UserAdminRow> {
787
+ await this.ensureUserColumns()
788
+ const id = crypto.randomUUID()
789
+ const roles = input.roles?.length ? input.roles : [input.role]
790
+ await this.stmt(
791
+ `INSERT INTO users (id, email, name, provider, provider_account_id, role, roles, org_id)
792
+ VALUES (?, ?, ?, 'demo', ?, ?, ?, ?)`,
793
+ id, input.email, input.name, input.email, input.role, JSON.stringify(roles), input.orgId ?? null,
794
+ ).run()
795
+ if (input.orgId) await this.syncPrimaryMembership(id) // TODO.identity/11 — the mirror
796
+ const row = await this.stmt('SELECT * FROM users WHERE id = ?', id).first<UserRecord>()
797
+ return toAdminRow(row!)
798
+ }
799
+
800
+ async setUserRoles(id: string, role: string, roles: string[]): Promise<boolean> {
801
+ await this.ensureUserColumns()
802
+ const res = await this.stmt(
803
+ 'UPDATE users SET role = ?, roles = ? WHERE id = ?',
804
+ role, JSON.stringify(roles.length ? roles : [role]), id,
805
+ ).run()
806
+ // TODO.identity/11 — the mirror (a no-op for org-free accounts).
807
+ if ((res.meta.changes ?? 0) > 0) await this.syncPrimaryMembership(id)
808
+ return (res.meta.changes ?? 0) > 0
809
+ }
810
+
811
+ async setUserActive(id: string, active: boolean): Promise<boolean> {
812
+ await this.ensureUserColumns()
813
+ const res = await this.stmt('UPDATE users SET active = ? WHERE id = ?', active ? 1 : 0, id).run()
814
+ return (res.meta.changes ?? 0) > 0
815
+ }
816
+
817
+ // ── the OIDC Provider (TODO.identity/01) ───────────────────────────
818
+ // The OP rows port directly (D1 is SQLite) — the same statements as
819
+ // op-store.ts's sync half.
820
+
821
+ private static toOidcClient(row: Record<string, unknown>): OidcClient {
822
+ return {
823
+ clientId: row.client_id as string,
824
+ name: row.name as string,
825
+ secretHash: (row.secret_hash as string | null) ?? null,
826
+ redirectUris: JSON.parse(row.redirect_uris as string) as string[],
827
+ claimsPolicy: row.claims_policy ? JSON.parse(row.claims_policy as string) as OidcClient['claimsPolicy'] : null,
828
+ // The SSO-home launch metadata (migration 0011): no launch_url =
829
+ // the client never appears on the launcher. A pre-0011 database
830
+ // reads the columns as absent — launch stays null, the honest
831
+ // default.
832
+ launch: row.launch_url
833
+ ? {
834
+ url: row.launch_url as string,
835
+ icon: (row.launch_icon as string | null) ?? null,
836
+ description: (row.launch_description as string | null) ?? null,
837
+ visibility: ((row.launch_visibility as string | null) ?? 'roles') as OidcClientLaunch['visibility'],
838
+ }
839
+ : null,
840
+ status: row.status as OidcClient['status'],
841
+ createdAt: row.created_at as string,
842
+ createdBy: (row.created_by as string | null) ?? null,
843
+ }
844
+ }
845
+
846
+ private static toOidcAuthorization(row: Record<string, unknown>): OidcAuthorization {
847
+ return {
848
+ id: row.id as string,
849
+ clientId: row.client_id as string,
850
+ redirectUri: row.redirect_uri as string,
851
+ scope: row.scope as string,
852
+ state: row.state as string,
853
+ nonce: (row.nonce as string | null) ?? null,
854
+ codeChallenge: row.code_challenge as string,
855
+ userId: (row.user_id as string | null) ?? null,
856
+ decision: (row.decision as OidcAuthorization['decision']) ?? null,
857
+ createdAt: row.created_at as string,
858
+ expiresAt: row.expires_at as string,
859
+ }
860
+ }
861
+
862
+ async getOidcClient(clientId: string): Promise<OidcClient | null> {
863
+ const row = await this.stmt('SELECT * FROM oidc_clients WHERE client_id = ?', clientId).first<Record<string, unknown>>()
864
+ return row ? D1ServerStore.toOidcClient(row) : null
865
+ }
866
+
867
+ async listOidcClients(): Promise<OidcClient[]> {
868
+ const res = await this.stmt('SELECT * FROM oidc_clients ORDER BY created_at, client_id').all<Record<string, unknown>>()
869
+ return res.results.map(D1ServerStore.toOidcClient)
870
+ }
871
+
872
+ async upsertOidcClient(input: {
873
+ clientId: string
874
+ name: string
875
+ secretHash: string | null
876
+ redirectUris: string[]
877
+ claimsPolicy: { claims: string[] } | null
878
+ createdBy?: string | null
879
+ }): Promise<OidcClient> {
880
+ await this.stmt(
881
+ `INSERT INTO oidc_clients (client_id, name, secret_hash, redirect_uris, claims_policy, created_by)
882
+ VALUES (?, ?, ?, ?, ?, ?)
883
+ ON CONFLICT (client_id) DO UPDATE SET
884
+ name = excluded.name,
885
+ secret_hash = excluded.secret_hash,
886
+ redirect_uris = excluded.redirect_uris,
887
+ claims_policy = excluded.claims_policy`,
888
+ input.clientId, input.name, input.secretHash,
889
+ JSON.stringify(input.redirectUris),
890
+ input.claimsPolicy ? JSON.stringify(input.claimsPolicy) : null,
891
+ input.createdBy ?? null,
892
+ ).run()
893
+ return (await this.getOidcClient(input.clientId))!
894
+ }
895
+
896
+ async setOidcClientStatus(clientId: string, status: OidcClient['status']): Promise<OidcClient | null> {
897
+ const res = await this.stmt('UPDATE oidc_clients SET status = ? WHERE client_id = ?', status, clientId).run()
898
+ return (res.meta.changes ?? 0) > 0 ? this.getOidcClient(clientId) : null
899
+ }
900
+
901
+ async setOidcClientLaunch(clientId: string, launch: OidcClientLaunch | null): Promise<OidcClient | null> {
902
+ const res = await this.stmt(
903
+ `UPDATE oidc_clients SET launch_url = ?, launch_icon = ?, launch_description = ?, launch_visibility = ?
904
+ WHERE client_id = ?`,
905
+ launch?.url ?? null,
906
+ launch?.icon ?? null,
907
+ launch?.description ?? null,
908
+ launch?.visibility ?? 'roles',
909
+ clientId,
910
+ ).run()
911
+ return (res.meta.changes ?? 0) > 0 ? this.getOidcClient(clientId) : null
912
+ }
913
+
914
+ async createOidcAuthorization(input: {
915
+ id: string
916
+ clientId: string
917
+ redirectUri: string
918
+ scope: string
919
+ state: string
920
+ nonce: string | null
921
+ codeChallenge: string
922
+ userId: string | null
923
+ ttlMs: number
924
+ }): Promise<OidcAuthorization> {
925
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
926
+ await this.stmt(
927
+ `INSERT INTO oidc_authorizations
928
+ (id, client_id, redirect_uri, scope, state, nonce, code_challenge, user_id, expires_at)
929
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
930
+ input.id, input.clientId, input.redirectUri, input.scope, input.state,
931
+ input.nonce, input.codeChallenge, input.userId, expiresAt,
932
+ ).run()
933
+ return (await this.getOidcAuthorization(input.id))!
934
+ }
935
+
936
+ async getOidcAuthorization(id: string): Promise<OidcAuthorization | null> {
937
+ const row = await this.stmt('SELECT * FROM oidc_authorizations WHERE id = ?', id).first<Record<string, unknown>>()
938
+ return row ? D1ServerStore.toOidcAuthorization(row) : null
939
+ }
940
+
941
+ async decideOidcAuthorization(
942
+ id: string,
943
+ decision: { userId: string; decision: 'allow' | 'deny' },
944
+ ): Promise<OidcAuthorization | null> {
945
+ // The decision binds to the row's OWN account and flips atomically.
946
+ const res = await this.stmt(
947
+ 'UPDATE oidc_authorizations SET decision = ? WHERE id = ? AND decision IS NULL AND user_id = ?',
948
+ decision.decision, id, decision.userId,
949
+ ).run()
950
+ return (res.meta.changes ?? 0) > 0 ? this.getOidcAuthorization(id) : null
951
+ }
952
+
953
+ async createOidcCode(input: {
954
+ code: string
955
+ clientId: string
956
+ redirectUri: string
957
+ scope: string
958
+ nonce: string | null
959
+ codeChallenge: string
960
+ userId: string
961
+ /** TODO.identity/11: the session's stamped active-org context at the
962
+ * consent decision (NULL = the primary context). */
963
+ contextOrg?: string | null
964
+ /** TODO.identity-sso/02+03: the consenting session's amr provenance
965
+ * (stored as JSON; the token endpoint emits it as the ID token's
966
+ * amr). Absent = no provenance recorded. */
967
+ amr?: string[] | null
968
+ ttlMs: number
969
+ }): Promise<void> {
970
+ await this.ensureMembershipSupport()
971
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
972
+ await this.ensureOidcColumns()
973
+ await this.stmt(
974
+ `INSERT INTO oidc_codes (code, client_id, redirect_uri, scope, nonce, code_challenge, user_id, context_org, amr, expires_at)
975
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
976
+ input.code, input.clientId, input.redirectUri, input.scope, input.nonce, input.codeChallenge, input.userId, input.contextOrg ?? null,
977
+ input.amr?.length ? JSON.stringify(input.amr) : null, expiresAt,
978
+ ).run()
979
+ }
980
+
981
+ /** Atomically consume the code: the UPDATE flips consumed_at exactly
982
+ * once — a replay loses the race and answers null (→ invalid_grant).
983
+ * An expired code is consumed too (never a second chance). */
984
+ async consumeOidcCode(code: string): Promise<OidcCode | null> {
985
+ await this.ensureMembershipSupport()
986
+ const res = await this.stmt(
987
+ "UPDATE oidc_codes SET consumed_at = datetime('now') WHERE code = ? AND consumed_at IS NULL", code,
988
+ ).run()
989
+ if ((res.meta.changes ?? 0) === 0) return null
990
+ const row = await this.stmt('SELECT * FROM oidc_codes WHERE code = ?', code).first<Record<string, unknown>>()
991
+ if (!row) return null
992
+ if (new Date(row.expires_at as string).getTime() <= Date.now()) return null
993
+ return {
994
+ code: row.code as string,
995
+ clientId: row.client_id as string,
996
+ redirectUri: row.redirect_uri as string,
997
+ scope: row.scope as string,
998
+ nonce: (row.nonce as string | null) ?? null,
999
+ codeChallenge: row.code_challenge as string,
1000
+ userId: row.user_id as string,
1001
+ contextOrg: (row.context_org as string | null) ?? null,
1002
+ amr: parseRoles((row.amr as string | null) ?? null) ?? null,
1003
+ expiresAt: row.expires_at as string,
1004
+ }
1005
+ }
1006
+
1007
+ async createOidcAccessToken(input: {
1008
+ token: string
1009
+ userId: string
1010
+ clientId: string
1011
+ scope: string
1012
+ /** The granting code's context (userinfo answers the ID token's
1013
+ * claims). */
1014
+ contextOrg?: string | null
1015
+ /** TODO.identity-sso/02+03: the authorizing authentication's amr —
1016
+ * userinfo answers the same truth the ID token carried. */
1017
+ amr?: string[] | null
1018
+ ttlMs: number
1019
+ }): Promise<void> {
1020
+ await this.ensureMembershipSupport()
1021
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
1022
+ await this.ensureOidcColumns()
1023
+ await this.stmt(
1024
+ 'INSERT INTO oidc_access_tokens (token, user_id, client_id, scope, context_org, amr, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
1025
+ input.token, input.userId, input.clientId, input.scope, input.contextOrg ?? null,
1026
+ input.amr?.length ? JSON.stringify(input.amr) : null, expiresAt,
1027
+ ).run()
1028
+ }
1029
+
1030
+ async getOidcAccessToken(token: string): Promise<OidcAccessToken | null> {
1031
+ await this.ensureMembershipSupport()
1032
+ const row = await this.stmt(
1033
+ "SELECT * FROM oidc_access_tokens WHERE token = ? AND expires_at > datetime('now')", token,
1034
+ ).first<Record<string, unknown>>()
1035
+ if (!row) return null
1036
+ return {
1037
+ token: row.token as string,
1038
+ userId: row.user_id as string,
1039
+ clientId: row.client_id as string,
1040
+ scope: row.scope as string,
1041
+ contextOrg: (row.context_org as string | null) ?? null,
1042
+ amr: parseRoles((row.amr as string | null) ?? null) ?? null,
1043
+ expiresAt: row.expires_at as string,
1044
+ }
1045
+ }
1046
+
1047
+ async listOidcKeys(): Promise<OidcKeyRow[]> {
1048
+ const res = await this.stmt('SELECT * FROM oidc_keys ORDER BY created_at, kid').all<Record<string, unknown>>()
1049
+ return res.results.map(row => ({
1050
+ kid: row.kid as string,
1051
+ publicJwk: row.public_jwk as string,
1052
+ status: row.status as OidcKeyRow['status'],
1053
+ createdAt: row.created_at as string,
1054
+ retiredAt: (row.retired_at as string | null) ?? null,
1055
+ }))
1056
+ }
1057
+
1058
+ async upsertOidcKey(input: { kid: string; publicJwk: string }): Promise<void> {
1059
+ await this.stmt('INSERT OR IGNORE INTO oidc_keys (kid, public_jwk) VALUES (?, ?)', input.kid, input.publicJwk).run()
1060
+ }
1061
+
1062
+ async retireOidcKey(kid: string): Promise<void> {
1063
+ await this.stmt(
1064
+ "UPDATE oidc_keys SET status = 'retired', retired_at = datetime('now') WHERE kid = ? AND status = 'active'", kid,
1065
+ ).run()
1066
+ }
1067
+
1068
+ // ── the upstream providers (TODO.identity/08) ─────────────────────
1069
+ // The provider + link rows port directly (D1 is SQLite).
1070
+
1071
+ private static toIdentityProvider(row: Record<string, unknown>): IdentityProvider {
1072
+ return {
1073
+ id: row.id as string,
1074
+ kind: row.kind as IdentityProvider['kind'],
1075
+ displayName: row.display_name as string,
1076
+ brandMark: (row.brand_mark as string | null) ?? null,
1077
+ issuer: (row.issuer as string | null) ?? null,
1078
+ clientId: row.client_id as string,
1079
+ clientSecretRef: (row.client_secret_ref as string | null) ?? null,
1080
+ scopes: (row.scopes as string | null) ?? null,
1081
+ enabled: row.enabled === 1,
1082
+ createdAt: row.created_at as string,
1083
+ createdBy: (row.created_by as string | null) ?? null,
1084
+ updatedAt: (row.updated_at as string | null) ?? null,
1085
+ }
1086
+ }
1087
+
1088
+ async listIdentityProviders(): Promise<IdentityProvider[]> {
1089
+ const res = await this.stmt('SELECT * FROM identity_providers ORDER BY created_at, id').all<Record<string, unknown>>()
1090
+ return res.results.map(D1ServerStore.toIdentityProvider)
1091
+ }
1092
+
1093
+ async getIdentityProvider(id: string): Promise<IdentityProvider | null> {
1094
+ const row = await this.stmt('SELECT * FROM identity_providers WHERE id = ?', id).first<Record<string, unknown>>()
1095
+ return row ? D1ServerStore.toIdentityProvider(row) : null
1096
+ }
1097
+
1098
+ async upsertIdentityProvider(input: {
1099
+ id: string
1100
+ kind: IdentityProvider['kind']
1101
+ displayName: string
1102
+ brandMark?: string | null
1103
+ issuer?: string | null
1104
+ clientId: string
1105
+ clientSecretRef?: string | null
1106
+ scopes?: string | null
1107
+ enabled?: boolean
1108
+ createdBy?: string | null
1109
+ }): Promise<IdentityProvider> {
1110
+ await this.stmt(
1111
+ `INSERT INTO identity_providers
1112
+ (id, kind, display_name, brand_mark, issuer, client_id, client_secret_ref, scopes, enabled, created_by)
1113
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1114
+ ON CONFLICT (id) DO UPDATE SET
1115
+ kind = excluded.kind,
1116
+ display_name = excluded.display_name,
1117
+ brand_mark = excluded.brand_mark,
1118
+ issuer = excluded.issuer,
1119
+ client_id = excluded.client_id,
1120
+ client_secret_ref = excluded.client_secret_ref,
1121
+ scopes = excluded.scopes,
1122
+ enabled = excluded.enabled,
1123
+ updated_at = datetime('now')`,
1124
+ input.id, input.kind, input.displayName, input.brandMark ?? null, input.issuer ?? null,
1125
+ input.clientId, input.clientSecretRef ?? null, input.scopes ?? null,
1126
+ // Disabled by default (the schema's DEFAULT 0): a deliberate
1127
+ // enable makes the provider visible.
1128
+ input.enabled ? 1 : 0, input.createdBy ?? null,
1129
+ ).run()
1130
+ return (await this.getIdentityProvider(input.id))!
1131
+ }
1132
+
1133
+ async setIdentityProviderEnabled(id: string, enabled: boolean): Promise<IdentityProvider | null> {
1134
+ const res = await this.stmt(
1135
+ "UPDATE identity_providers SET enabled = ?, updated_at = datetime('now') WHERE id = ?",
1136
+ enabled ? 1 : 0, id,
1137
+ ).run()
1138
+ return (res.meta.changes ?? 0) > 0 ? this.getIdentityProvider(id) : null
1139
+ }
1140
+
1141
+ async deleteIdentityProvider(id: string): Promise<boolean> {
1142
+ const res = await this.stmt('DELETE FROM identity_providers WHERE id = ?', id).run()
1143
+ return (res.meta.changes ?? 0) > 0
1144
+ }
1145
+
1146
+ // ── the linked identities (TODO.identity/02's shape, 08's flows) ────
1147
+ // ── the OP's account model (TODO.identity/02) ──────────────────────
1148
+ // The account rows port directly (D1 is SQLite) — the same statements
1149
+ // as op-accounts-store.ts's sync half.
1150
+
1151
+ private static toIdentityLink(row: Record<string, unknown>): IdentityLink {
1152
+ return {
1153
+ id: row.id as string,
1154
+ userId: row.user_id as string,
1155
+ provider: row.provider as string,
1156
+ providerAccountId: row.provider_account_id as string,
1157
+ linkedAt: row.linked_at as string,
1158
+ linkedBy: (row.linked_by as string | null) ?? null,
1159
+ }
1160
+ }
1161
+
1162
+ async listIdentityLinks(userId: string): Promise<IdentityLink[]> {
1163
+ const res = await this.stmt('SELECT * FROM identity_links WHERE user_id = ? ORDER BY linked_at, provider', userId)
1164
+ .all<Record<string, unknown>>()
1165
+ return res.results.map(D1ServerStore.toIdentityLink)
1166
+ }
1167
+
1168
+ async findIdentityLink(provider: string, providerAccountId: string): Promise<IdentityLink | null> {
1169
+ const row = await this.stmt('SELECT * FROM identity_links WHERE provider = ? AND provider_account_id = ?', provider, providerAccountId)
1170
+ .first<Record<string, unknown>>()
1171
+ return row ? D1ServerStore.toIdentityLink(row) : null
1172
+ }
1173
+
1174
+ /** Create the link; NULL on the UNIQUE(provider, provider_account_id)
1175
+ * conflict — the pair is already linked (to any account). */
1176
+ async createIdentityLink(input: {
1177
+ userId: string
1178
+ provider: string
1179
+ providerAccountId: string
1180
+ linkedBy?: string | null
1181
+ }): Promise<IdentityLink | null> {
1182
+ const res = await this.stmt(
1183
+ 'INSERT OR IGNORE INTO identity_links (id, user_id, provider, provider_account_id, linked_by) VALUES (?, ?, ?, ?, ?)',
1184
+ crypto.randomUUID(), input.userId, input.provider, input.providerAccountId, input.linkedBy ?? null,
1185
+ ).run()
1186
+ if ((res.meta.changes ?? 0) === 0) return null
1187
+ return this.findIdentityLink(input.provider, input.providerAccountId)
1188
+ }
1189
+
1190
+ async deleteIdentityLink(userId: string, provider: string): Promise<boolean> {
1191
+ const res = await this.stmt('DELETE FROM identity_links WHERE user_id = ? AND provider = ?', userId, provider).run()
1192
+ return (res.meta.changes ?? 0) > 0
1193
+ }
1194
+
1195
+ private static toEnrollmentToken(row: Record<string, unknown>): EnrollmentToken {
1196
+ return {
1197
+ token: row.token as string,
1198
+ userId: row.user_id as string,
1199
+ createdBy: (row.created_by as string | null) ?? null,
1200
+ createdAt: row.created_at as string,
1201
+ expiresAt: row.expires_at as string,
1202
+ consumedAt: (row.consumed_at as string | null) ?? null,
1203
+ }
1204
+ }
1205
+
1206
+ async createOpAccount(input: {
1207
+ email: string
1208
+ name: string
1209
+ role: string
1210
+ createdBy?: string | null
1211
+ }): Promise<UserAdminRow | null> {
1212
+ const id = crypto.randomUUID()
1213
+ try {
1214
+ await this.stmt(
1215
+ "INSERT INTO users (id, email, name, provider, role) VALUES (?, ?, ?, 'password', ?)",
1216
+ id, input.email.trim().toLowerCase(), input.name.trim(), input.role,
1217
+ ).run()
1218
+ } catch (e) {
1219
+ if (String((e as Error).message).includes('UNIQUE')) return null
1220
+ throw e
1221
+ }
1222
+ const row = await this.stmt('SELECT * FROM users WHERE id = ?', id).first<UserRecord & { last_login?: string | null; provider?: string }>()
1223
+ return toAdminRow(row!)
1224
+ }
1225
+
1226
+ /** The password sign-in's lookup: the credential + the active flag, by
1227
+ * (normalized) email. The credential's EXISTENCE is the qualifier. */
1228
+ async getPasswordLogin(email: string): Promise<{ userId: string; hash: string; active: boolean } | null> {
1229
+ const row = await this.stmt(
1230
+ `SELECT u.id AS user_id, u.active AS active, p.hash AS hash
1231
+ FROM users u JOIN passwords p ON p.user_id = u.id
1232
+ WHERE u.email = ?`,
1233
+ email.trim().toLowerCase(),
1234
+ ).first<{ user_id: string; active: number; hash: string }>()
1235
+ if (!row) return null
1236
+ return { userId: row.user_id, hash: row.hash, active: row.active !== 0 }
1237
+ }
1238
+
1239
+ async setPasswordHash(userId: string, hash: string, setBy?: string | null): Promise<void> {
1240
+ await this.stmt(
1241
+ `INSERT INTO passwords (user_id, hash, set_by) VALUES (?, ?, ?)
1242
+ ON CONFLICT (user_id) DO UPDATE SET hash = excluded.hash, set_at = datetime('now'), set_by = excluded.set_by`,
1243
+ userId, hash, setBy ?? null,
1244
+ ).run()
1245
+ }
1246
+
1247
+ /** The sign-in methods the account holds (the account page's
1248
+ * password-set state + the admin list's posture). TODO.identity-sso/02:
1249
+ * the passkeys count — a passkey is a PRIMARY sign-in method. */
1250
+ async countSignInMethods(userId: string): Promise<{ password: boolean; links: number; passkeys: number }> {
1251
+ const pw = await this.stmt('SELECT COUNT(*) AS n FROM passwords WHERE user_id = ?', userId).first<{ n: number }>()
1252
+ const links = await this.stmt('SELECT COUNT(*) AS n FROM identity_links WHERE user_id = ?', userId).first<{ n: number }>()
1253
+ const passkeys = await this.stmt('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?', userId).first<{ n: number }>()
1254
+ return { password: (pw?.n ?? 0) > 0, links: links?.n ?? 0, passkeys: passkeys?.n ?? 0 }
1255
+ }
1256
+
1257
+ async createEnrollmentToken(input: {
1258
+ token: string
1259
+ userId: string
1260
+ createdBy?: string | null
1261
+ ttlMs: number
1262
+ }): Promise<EnrollmentToken> {
1263
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
1264
+ await this.stmt(
1265
+ 'INSERT INTO enrollment_tokens (token, user_id, created_by, expires_at) VALUES (?, ?, ?, ?)',
1266
+ input.token, input.userId, input.createdBy ?? null, expiresAt,
1267
+ ).run()
1268
+ return (await this.getEnrollmentToken(input.token))!
1269
+ }
1270
+
1271
+ async getEnrollmentToken(token: string): Promise<EnrollmentToken | null> {
1272
+ const row = await this.stmt('SELECT * FROM enrollment_tokens WHERE token = ?', token).first<Record<string, unknown>>()
1273
+ return row ? D1ServerStore.toEnrollmentToken(row) : null
1274
+ }
1275
+
1276
+ /** Complete the enrollment: consume the token ATOMICALLY (a concurrent
1277
+ * double-submit loses the consumed_at race), judge the expiry (an
1278
+ * expired link is burned, never redeemed), then set the password. */
1279
+ async completeEnrollment(token: string, passwordHash: string, setBy?: string | null): Promise<CompleteEnrollmentResult> {
1280
+ const res = await this.stmt(
1281
+ "UPDATE enrollment_tokens SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL", token,
1282
+ ).run()
1283
+ if ((res.meta.changes ?? 0) === 0) return { kind: 'unknown' }
1284
+ const row = (await this.getEnrollmentToken(token))!
1285
+ if (new Date(row.expiresAt).getTime() <= Date.now()) return { kind: 'expired' }
1286
+ await this.setPasswordHash(row.userId, passwordHash, setBy)
1287
+ // TODO.identity/06: the invite ceremony doubles as the address's
1288
+ // verification (the administrator-delivered one-time link).
1289
+ await this.stmt("UPDATE users SET email_verified_at = datetime('now') WHERE id = ?", row.userId).run()
1290
+ return { kind: 'ok', userId: row.userId }
1291
+ }
1292
+
1293
+ /** The account's live sessions, `current` computed in SQL against the
1294
+ * presenting token — the token value itself never leaves the store. */
1295
+ async listUserSessions(userId: string, currentToken?: string): Promise<SessionView[]> {
1296
+ await this.ensureSessionColumns()
1297
+ const res = await this.stmt(
1298
+ `SELECT id, created_at, expires_at, last_seen_at, user_agent, ip, (token = ?) AS is_current
1299
+ FROM sessions
1300
+ WHERE user_id = ? AND expires_at > datetime('now')
1301
+ ORDER BY created_at DESC`,
1302
+ currentToken ?? '', userId,
1303
+ ).all<Record<string, unknown>>()
1304
+ return res.results.map(row => ({
1305
+ id: row.id as string,
1306
+ createdAt: row.created_at as string,
1307
+ expiresAt: row.expires_at as string,
1308
+ lastSeenAt: (row.last_seen_at as string | null) ?? null,
1309
+ userAgent: (row.user_agent as string | null) ?? null,
1310
+ ip: (row.ip as string | null) ?? null,
1311
+ current: Number(row.is_current) === 1,
1312
+ }))
1313
+ }
1314
+
1315
+ /** Revoke ONE of the account's own sessions (the user_id clause makes
1316
+ * another account's session id a no-op). */
1317
+ async deleteSessionById(userId: string, sessionId: string): Promise<boolean> {
1318
+ const res = await this.stmt('DELETE FROM sessions WHERE id = ? AND user_id = ?', sessionId, userId).run()
1319
+ return (res.meta.changes ?? 0) > 0
1320
+ }
1321
+
1322
+ /** Every live session across accounts (expired excluded), `current`
1323
+ * computed in SQL against the presenting token — the aggregate admin
1324
+ * read (TODO.identity-sso/01); the token never leaves the store. */
1325
+ async listOpLiveSessions(currentToken?: string): Promise<OpLiveSession[]> {
1326
+ await this.ensureSessionColumns()
1327
+ const res = await this.stmt(
1328
+ `SELECT id, user_id, created_at, expires_at, last_seen_at, user_agent, ip, (token = ?) AS is_current
1329
+ FROM sessions
1330
+ WHERE expires_at > datetime('now')
1331
+ ORDER BY created_at DESC`,
1332
+ currentToken ?? '',
1333
+ ).all<Record<string, unknown>>()
1334
+ return res.results.map(row => ({
1335
+ id: row.id as string,
1336
+ userId: row.user_id as string,
1337
+ createdAt: row.created_at as string,
1338
+ expiresAt: row.expires_at as string,
1339
+ lastSeenAt: (row.last_seen_at as string | null) ?? null,
1340
+ userAgent: (row.user_agent as string | null) ?? null,
1341
+ ip: (row.ip as string | null) ?? null,
1342
+ current: Number(row.is_current) === 1,
1343
+ }))
1344
+ }
1345
+
1346
+ /** The administrator's revoke-all (TODO.identity-sso/01's light act):
1347
+ * every session of the account, none kept; answers the count. */
1348
+ async deleteAllUserSessions(userId: string): Promise<number> {
1349
+ const res = await this.stmt('DELETE FROM sessions WHERE user_id = ?', userId).run()
1350
+ return res.meta.changes ?? 0
1351
+ }
1352
+
1353
+ // ── the central user registry (TODO.identity/03) ──────────────────
1354
+
1355
+ private static toClientRoleAssignment(row: Record<string, unknown>): OpClientRoleAssignment {
1356
+ return {
1357
+ userId: row.user_id as string,
1358
+ clientId: row.client_id as string,
1359
+ roles: JSON.parse(row.roles as string) as string[],
1360
+ assignedBy: (row.assigned_by as string | null) ?? null,
1361
+ createdAt: row.created_at as string,
1362
+ updatedAt: (row.updated_at as string | null) ?? null,
1363
+ }
1364
+ }
1365
+
1366
+ async listOpClientRoles(userId: string): Promise<OpClientRoleAssignment[]> {
1367
+ const res = await this.stmt(
1368
+ 'SELECT * FROM op_client_roles WHERE user_id = ? ORDER BY client_id', userId,
1369
+ ).all<Record<string, unknown>>()
1370
+ return res.results.map(D1ServerStore.toClientRoleAssignment)
1371
+ }
1372
+
1373
+ /** EVERY per-client assignment across accounts (TODO.identity-sso/01's
1374
+ * live access review). */
1375
+ async listAllOpClientRoles(): Promise<OpClientRoleAssignment[]> {
1376
+ const res = await this.stmt(
1377
+ 'SELECT * FROM op_client_roles ORDER BY user_id, client_id',
1378
+ ).all<Record<string, unknown>>()
1379
+ return res.results.map(D1ServerStore.toClientRoleAssignment)
1380
+ }
1381
+
1382
+ /** The assignment for ONE client: NULL = no row (the account default);
1383
+ * an EMPTY array = the explicit "no roles on this client". */
1384
+ async getOpClientRoles(userId: string, clientId: string): Promise<string[] | null> {
1385
+ const row = await this.stmt(
1386
+ 'SELECT roles FROM op_client_roles WHERE user_id = ? AND client_id = ?', userId, clientId,
1387
+ ).first<{ roles: string }>()
1388
+ return row ? (JSON.parse(row.roles) as string[]) : null
1389
+ }
1390
+
1391
+ async setOpClientRoles(userId: string, clientId: string, roles: string[], assignedBy: string | null): Promise<void> {
1392
+ await this.stmt(
1393
+ `INSERT INTO op_client_roles (user_id, client_id, roles, assigned_by)
1394
+ VALUES (?, ?, ?, ?)
1395
+ ON CONFLICT (user_id, client_id) DO UPDATE SET
1396
+ roles = excluded.roles,
1397
+ assigned_by = excluded.assigned_by,
1398
+ updated_at = datetime('now')`,
1399
+ userId, clientId, JSON.stringify(roles), assignedBy,
1400
+ ).run()
1401
+ }
1402
+
1403
+ async deleteOpClientRoles(userId: string, clientId: string): Promise<boolean> {
1404
+ const res = await this.stmt('DELETE FROM op_client_roles WHERE user_id = ? AND client_id = ?', userId, clientId).run()
1405
+ return (res.meta.changes ?? 0) > 0
1406
+ }
1407
+
1408
+ /** The deactivation's revocation half: every live session, every issued
1409
+ * access token, every unconsumed code and pending authorization goes.
1410
+ * The user row STAYS (the history). */
1411
+ async revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; codes: number; authorizations: number }> {
1412
+ const sessions = await this.stmt('DELETE FROM sessions WHERE user_id = ?', userId).run()
1413
+ const accessTokens = await this.stmt('DELETE FROM oidc_access_tokens WHERE user_id = ?', userId).run()
1414
+ const codes = await this.stmt('DELETE FROM oidc_codes WHERE user_id = ? AND consumed_at IS NULL', userId).run()
1415
+ const authorizations = await this.stmt('DELETE FROM oidc_authorizations WHERE user_id = ? AND decision IS NULL', userId).run()
1416
+ return {
1417
+ sessions: sessions.meta.changes ?? 0,
1418
+ accessTokens: accessTokens.meta.changes ?? 0,
1419
+ codes: codes.meta.changes ?? 0,
1420
+ authorizations: authorizations.meta.changes ?? 0,
1421
+ }
1422
+ }
1423
+
1424
+ /** The registry's ERASURE act (the offboarding runbook's delete path):
1425
+ * every credential, token, link and per-client assignment removed; the
1426
+ * user row anonymized in place (provider 'erased' — it drops out of
1427
+ * every account surface; the tombstone keeps the audit chain's
1428
+ * entity_id resolvable). Answers the counts, null when absent. */
1429
+ async eraseOpAccount(userId: string): Promise<OpAccountErasure | null> {
1430
+ await this.ensureMembershipSupport()
1431
+ const row = await this.stmt('SELECT 1 AS ok FROM users WHERE id = ?', userId).first<{ ok: number }>()
1432
+ if (!row) return null
1433
+ const revoked = await this.revokeOpUserCredentials(userId)
1434
+ const links = await this.stmt('DELETE FROM identity_links WHERE user_id = ?', userId).run()
1435
+ const clientRoles = await this.stmt('DELETE FROM op_client_roles WHERE user_id = ?', userId).run()
1436
+ // TODO.identity/11: the memberships go too (the tombstone acts for
1437
+ // no organization).
1438
+ const memberships = await this.stmt('DELETE FROM org_memberships WHERE user_id = ?', userId).run()
1439
+ const passwords = await this.stmt('DELETE FROM passwords WHERE user_id = ?', userId).run()
1440
+ const enrollments = await this.stmt('DELETE FROM enrollment_tokens WHERE user_id = ?', userId).run()
1441
+ const emailChanges = await this.stmt('DELETE FROM email_change_tokens WHERE user_id = ?', userId).run()
1442
+ // TODO.identity-sso/02+03: the factor registry follows the account
1443
+ // into erasure (passkeys, TOTP secrets, recovery codes, pending
1444
+ // ceremony state).
1445
+ const passkeys = await this.stmt('DELETE FROM webauthn_credentials WHERE user_id = ?', userId).run()
1446
+ const totp = await this.stmt('DELETE FROM totp_secrets WHERE user_id = ?', userId).run()
1447
+ const recovery = await this.stmt('DELETE FROM recovery_codes WHERE user_id = ?', userId).run()
1448
+ const challenges = await this.stmt('DELETE FROM webauthn_challenges WHERE user_id = ?', userId).run()
1449
+ const mfa = await this.stmt('DELETE FROM mfa_pending WHERE user_id = ?', userId).run()
1450
+ await this.stmt(
1451
+ `UPDATE users SET
1452
+ email = ?, name = 'Deleted account', provider = 'erased',
1453
+ role = 'viewer', roles = NULL, org_id = NULL,
1454
+ avatar_url = NULL, email_verified_at = NULL, active = 0
1455
+ WHERE id = ?`,
1456
+ `deleted-${userId}@erased.invalid`, userId,
1457
+ ).run()
1458
+ return {
1459
+ ...revoked,
1460
+ links: links.meta.changes ?? 0,
1461
+ clientRoles: clientRoles.meta.changes ?? 0,
1462
+ memberships: memberships.meta.changes ?? 0,
1463
+ tokens: (passwords.meta.changes ?? 0) + (enrollments.meta.changes ?? 0) + (emailChanges.meta.changes ?? 0),
1464
+ factors: (passkeys.meta.changes ?? 0) + (totp.meta.changes ?? 0) + (recovery.meta.changes ?? 0)
1465
+ + (challenges.meta.changes ?? 0) + (mfa.meta.changes ?? 0),
1466
+ }
1467
+ }
1468
+
1469
+ /** The registry's edit act (name/email). The email UNIQUE conflict
1470
+ * throws 'unique' (the route maps it to a 409, never a silent take). */
1471
+ async updateOpAccount(id: string, input: { name?: string; email?: string }): Promise<boolean> {
1472
+ if (input.email !== undefined) {
1473
+ try {
1474
+ // TODO.identity/06: an admin-set address never went through the
1475
+ // verify-new-email ceremony, so the verification state resets.
1476
+ await this.stmt('UPDATE users SET email = ?, email_verified_at = NULL WHERE id = ?', input.email.trim().toLowerCase(), id).run()
1477
+ } catch (e) {
1478
+ if (String((e as Error).message).includes('UNIQUE')) throw new Error(`unique: ${input.email}`)
1479
+ throw e
1480
+ }
1481
+ }
1482
+ if (input.name !== undefined) {
1483
+ await this.stmt('UPDATE users SET name = ? WHERE id = ?', input.name.trim(), id).run()
1484
+ }
1485
+ const row = await this.stmt('SELECT 1 AS ok FROM users WHERE id = ?', id).first<{ ok: number }>()
1486
+ return !!row
1487
+ }
1488
+
1489
+ /** The last OP-side sign-in per account, FROM THE AUDIT CHAIN: the
1490
+ * newest auditEvents row whose action is a sign-in
1491
+ * ('account.sign_in' / 'upstream_sign_in') per entity_id. */
1492
+ async lastAccountSignIns(): Promise<Record<string, string>> {
1493
+ const res = await this.stmt(
1494
+ `SELECT data FROM entities
1495
+ WHERE store = 'auditEvents'
1496
+ AND (data LIKE '%"action":"account.sign_in"%' OR data LIKE '%"action":"upstream_sign_in"%')`,
1497
+ ).all<{ data: string }>()
1498
+ const out: Record<string, string> = {}
1499
+ for (const { data } of res.results) {
1500
+ try {
1501
+ const event = JSON.parse(data) as { entity_id?: string; timestamp?: string }
1502
+ if (typeof event.entity_id !== 'string' || typeof event.timestamp !== 'string') continue
1503
+ if (!out[event.entity_id] || event.timestamp > out[event.entity_id]!) {
1504
+ out[event.entity_id] = event.timestamp
1505
+ }
1506
+ } catch { /* a malformed audit row is skipped, never trusted */ }
1507
+ }
1508
+ return out
1509
+ }
1510
+
1511
+ // ── the account console (TODO.identity/06) ─────────────────────────
1512
+
1513
+ /** The profile edit's write (the display name). */
1514
+ async updateUserName(userId: string, name: string): Promise<boolean> {
1515
+ const res = await this.stmt('UPDATE users SET name = ? WHERE id = ?', name.trim(), userId).run()
1516
+ return (res.meta.changes ?? 0) > 0
1517
+ }
1518
+
1519
+ /** The avatar write (the account console's upload/remove; NULL = the
1520
+ * initials). */
1521
+ async setUserAvatar(userId: string, avatarUrl: string | null): Promise<boolean> {
1522
+ const res = await this.stmt('UPDATE users SET avatar_url = ? WHERE id = ?', avatarUrl, userId).run()
1523
+ return (res.meta.changes ?? 0) > 0
1524
+ }
1525
+
1526
+ /** Remove the account's password credential (the route holds the
1527
+ * at-least-one-method guard). */
1528
+ async deletePasswordHash(userId: string): Promise<boolean> {
1529
+ const res = await this.stmt('DELETE FROM passwords WHERE user_id = ?', userId).run()
1530
+ return (res.meta.changes ?? 0) > 0
1531
+ }
1532
+
1533
+ /** Revoke every session of the account EXCEPT the presenting one. */
1534
+ async deleteOtherSessions(userId: string, keepToken: string): Promise<number> {
1535
+ const res = await this.stmt('DELETE FROM sessions WHERE user_id = ? AND token != ?', userId, keepToken).run()
1536
+ return res.meta.changes ?? 0
1537
+ }
1538
+
1539
+ private static toEmailChangeToken(row: Record<string, unknown>): EmailChangeToken {
1540
+ return {
1541
+ token: row.token as string,
1542
+ userId: row.user_id as string,
1543
+ newEmail: row.new_email as string,
1544
+ deliveredBy: row.delivered_by === 'mailer' ? 'mailer' : 'shown',
1545
+ createdAt: row.created_at as string,
1546
+ expiresAt: row.expires_at as string,
1547
+ consumedAt: (row.consumed_at as string | null) ?? null,
1548
+ }
1549
+ }
1550
+
1551
+ /** Mint the ceremony's token; the account's earlier pending rows are
1552
+ * VOIDED first (only the newest link works). */
1553
+ async createEmailChangeToken(input: {
1554
+ token: string
1555
+ userId: string
1556
+ newEmail: string
1557
+ deliveredBy: 'mailer' | 'shown'
1558
+ ttlMs: number
1559
+ }): Promise<EmailChangeToken> {
1560
+ await this.stmt(
1561
+ "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND consumed_at IS NULL",
1562
+ input.userId,
1563
+ ).run()
1564
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
1565
+ await this.stmt(
1566
+ 'INSERT INTO email_change_tokens (token, user_id, new_email, delivered_by, expires_at) VALUES (?, ?, ?, ?, ?)',
1567
+ input.token, input.userId, input.newEmail.trim().toLowerCase(), input.deliveredBy, expiresAt,
1568
+ ).run()
1569
+ return (await this.getEmailChangeToken(input.token))!
1570
+ }
1571
+
1572
+ async getEmailChangeToken(token: string): Promise<EmailChangeToken | null> {
1573
+ const row = await this.stmt('SELECT * FROM email_change_tokens WHERE token = ?', token).first<Record<string, unknown>>()
1574
+ return row ? D1ServerStore.toEmailChangeToken(row) : null
1575
+ }
1576
+
1577
+ /** The account's pending change (the newest live row), so the console
1578
+ * can show it. */
1579
+ async getPendingEmailChange(userId: string): Promise<EmailChangeToken | null> {
1580
+ const row = await this.stmt(
1581
+ `SELECT * FROM email_change_tokens
1582
+ WHERE user_id = ? AND consumed_at IS NULL AND expires_at > datetime('now')
1583
+ ORDER BY created_at DESC LIMIT 1`,
1584
+ userId,
1585
+ ).first<Record<string, unknown>>()
1586
+ return row ? D1ServerStore.toEmailChangeToken(row) : null
1587
+ }
1588
+
1589
+ /** Complete the ceremony: consume ATOMICALLY (a presented link works
1590
+ * exactly once, expired or not), judge the expiry, re-check the
1591
+ * address's uniqueness (a conflict burns the token honestly), then
1592
+ * move the account's email. A 'mailer'-delivered token verifies the
1593
+ * address; a shown one never does. */
1594
+ async completeEmailChange(token: string): Promise<CompleteEmailChangeResult> {
1595
+ const res = await this.stmt(
1596
+ "UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL", token,
1597
+ ).run()
1598
+ if ((res.meta.changes ?? 0) === 0) return { kind: 'unknown' }
1599
+ const row = (await this.getEmailChangeToken(token))!
1600
+ if (new Date(row.expiresAt).getTime() <= Date.now()) return { kind: 'expired' }
1601
+ const taken = await this.stmt('SELECT id FROM users WHERE email = ?', row.newEmail).first<{ id: string }>()
1602
+ if (taken) return { kind: 'conflict' }
1603
+ const verified = row.deliveredBy === 'mailer'
1604
+ await this.stmt(
1605
+ `UPDATE users SET email = ?, email_verified_at = ${verified ? "datetime('now')" : 'NULL'} WHERE id = ?`,
1606
+ row.newEmail, row.userId,
1607
+ ).run()
1608
+ return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
1609
+ }
1610
+
1611
+ // ── strong authentication: the factor registry (TODO.identity-sso/02 + /03) ──
1612
+ // The same SQL as the SQLite half (store/sqlite/factors-store.ts): the
1613
+ // one-time consumes are guarded UPDATEs, the counter advance is the
1614
+ // clone-guarded UPDATE, the throttles ride the rows — the database is
1615
+ // the proof, never a per-isolate Map.
1616
+
1617
+ async createWebauthnChallenge(input: {
1618
+ challenge: string
1619
+ userId: string | null
1620
+ kind: WebauthnChallenge['kind']
1621
+ ttlMs: number
1622
+ }): Promise<void> {
1623
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
1624
+ // The sweep rides the write (the putSsoState pattern).
1625
+ await this.stmt("DELETE FROM webauthn_challenges WHERE expires_at <= datetime('now')").run()
1626
+ await this.stmt(
1627
+ 'INSERT INTO webauthn_challenges (challenge, user_id, kind, expires_at) VALUES (?, ?, ?, ?)',
1628
+ input.challenge, input.userId, input.kind, expiresAt,
1629
+ ).run()
1630
+ }
1631
+
1632
+ async consumeWebauthnChallenge(challenge: string): Promise<WebauthnChallenge | null> {
1633
+ const res = await this.stmt(
1634
+ "UPDATE webauthn_challenges SET consumed_at = datetime('now') WHERE challenge = ? AND consumed_at IS NULL", challenge,
1635
+ ).run()
1636
+ if ((res.meta.changes ?? 0) === 0) return null
1637
+ const row = await this.stmt('SELECT * FROM webauthn_challenges WHERE challenge = ?', challenge).first<Record<string, unknown>>()
1638
+ if (!row) return null
1639
+ if (new Date(row.expires_at as string).getTime() <= Date.now()) return null
1640
+ return D1ServerStore.toWebauthnChallenge(row)
1641
+ }
1642
+
1643
+ async createWebauthnCredential(input: {
1644
+ credentialId: string
1645
+ userId: string
1646
+ name: string
1647
+ publicKeyCose: string
1648
+ signCount: number
1649
+ aaguid: string | null
1650
+ transports: string[]
1651
+ ip?: string | null
1652
+ }): Promise<WebauthnCredential | null> {
1653
+ try {
1654
+ await this.stmt(
1655
+ `INSERT INTO webauthn_credentials
1656
+ (credential_id, user_id, name, public_key, sign_count, aaguid, transports, last_ip)
1657
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1658
+ input.credentialId, input.userId, input.name, input.publicKeyCose,
1659
+ Math.max(0, Math.floor(input.signCount)), input.aaguid, JSON.stringify(input.transports),
1660
+ input.ip ?? null,
1661
+ ).run()
1662
+ } catch (e) {
1663
+ if (String((e as Error).message).includes('UNIQUE')) return null
1664
+ throw e
1665
+ }
1666
+ return this.getWebauthnCredential(input.credentialId)
1667
+ }
1668
+
1669
+ async listWebauthnCredentials(userId: string): Promise<WebauthnCredential[]> {
1670
+ const res = await this.stmt(
1671
+ 'SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at, credential_id', userId,
1672
+ ).all<Record<string, unknown>>()
1673
+ return res.results.map(D1ServerStore.toWebauthnCredential)
1674
+ }
1675
+
1676
+ async getWebauthnCredential(credentialId: string): Promise<WebauthnCredential | null> {
1677
+ const row = await this.stmt(
1678
+ 'SELECT * FROM webauthn_credentials WHERE credential_id = ?', credentialId,
1679
+ ).first<Record<string, unknown>>()
1680
+ return row ? D1ServerStore.toWebauthnCredential(row) : null
1681
+ }
1682
+
1683
+ async deleteWebauthnCredential(userId: string, credentialId: string): Promise<boolean> {
1684
+ const res = await this.stmt(
1685
+ 'DELETE FROM webauthn_credentials WHERE credential_id = ? AND user_id = ?', credentialId, userId,
1686
+ ).run()
1687
+ return (res.meta.changes ?? 0) > 0
1688
+ }
1689
+
1690
+ async advanceWebauthnCounter(credentialId: string, newCount: number, opts?: { ip?: string | null }): Promise<AdvanceCounterResult> {
1691
+ const count = Math.max(0, Math.floor(newCount))
1692
+ const res = await this.stmt(
1693
+ `UPDATE webauthn_credentials
1694
+ SET sign_count = ?, last_used_at = datetime('now'), last_ip = ?
1695
+ WHERE credential_id = ? AND ((sign_count = 0 AND ? = 0) OR sign_count < ?)`,
1696
+ count, opts?.ip ?? null, credentialId, count, count,
1697
+ ).run()
1698
+ if ((res.meta.changes ?? 0) > 0) return 'ok'
1699
+ return (await this.getWebauthnCredential(credentialId)) ? 'regressed' : 'unknown'
1700
+ }
1701
+
1702
+ async createTotpSecret(input: { id: string; userId: string; name: string; secret: string }): Promise<TotpSecret> {
1703
+ await this.stmt(
1704
+ 'INSERT INTO totp_secrets (id, user_id, name, secret) VALUES (?, ?, ?, ?)',
1705
+ input.id, input.userId, input.name, input.secret,
1706
+ ).run()
1707
+ return (await this.getTotpSecret(input.id))!
1708
+ }
1709
+
1710
+ async listTotpSecrets(userId: string): Promise<TotpSecret[]> {
1711
+ const res = await this.stmt(
1712
+ 'SELECT * FROM totp_secrets WHERE user_id = ? ORDER BY created_at, id', userId,
1713
+ ).all<Record<string, unknown>>()
1714
+ return res.results.map(D1ServerStore.toTotpSecret)
1715
+ }
1716
+
1717
+ async getTotpSecret(id: string): Promise<TotpSecret | null> {
1718
+ const row = await this.stmt('SELECT * FROM totp_secrets WHERE id = ?', id).first<Record<string, unknown>>()
1719
+ return row ? D1ServerStore.toTotpSecret(row) : null
1720
+ }
1721
+
1722
+ async markTotpSecretVerified(id: string, userId: string, name: string): Promise<boolean> {
1723
+ const res = await this.stmt(
1724
+ "UPDATE totp_secrets SET verified_at = datetime('now'), name = ? WHERE id = ? AND user_id = ? AND verified_at IS NULL",
1725
+ name, id, userId,
1726
+ ).run()
1727
+ return (res.meta.changes ?? 0) > 0
1728
+ }
1729
+
1730
+ async recordTotpEnrollFailure(id: string, userId: string): Promise<number> {
1731
+ const res = await this.stmt(
1732
+ "UPDATE totp_secrets SET fail_count = fail_count + 1, last_failure_at = datetime('now') WHERE id = ? AND user_id = ? AND verified_at IS NULL",
1733
+ id, userId,
1734
+ ).run()
1735
+ if ((res.meta.changes ?? 0) === 0) return 0
1736
+ const row = await this.stmt('SELECT fail_count AS n FROM totp_secrets WHERE id = ?', id).first<{ n: number }>()
1737
+ return row?.n ?? 0
1738
+ }
1739
+
1740
+ async markTotpSecretUsed(id: string, opts?: { ip?: string | null }): Promise<void> {
1741
+ await this.stmt(
1742
+ "UPDATE totp_secrets SET last_used_at = datetime('now'), last_ip = ? WHERE id = ?",
1743
+ opts?.ip ?? null, id,
1744
+ ).run()
1745
+ }
1746
+
1747
+ async deleteTotpSecret(userId: string, id: string): Promise<boolean> {
1748
+ const res = await this.stmt('DELETE FROM totp_secrets WHERE id = ? AND user_id = ?', id, userId).run()
1749
+ return (res.meta.changes ?? 0) > 0
1750
+ }
1751
+
1752
+ /** The regenerate: the old batch goes, the new hashes land — D1's
1753
+ * batch is all-or-nothing (the sqlite half's transaction). */
1754
+ async replaceRecoveryCodes(userId: string, batch: string, codeHashes: string[]): Promise<void> {
1755
+ const statements: D1PreparedStatement[] = [
1756
+ this.stmt('DELETE FROM recovery_codes WHERE user_id = ?', userId),
1757
+ ...codeHashes.map(hash => this.stmt(
1758
+ 'INSERT INTO recovery_codes (id, user_id, batch, code_hash) VALUES (?, ?, ?, ?)',
1759
+ crypto.randomUUID(), userId, batch, hash,
1760
+ )),
1761
+ ]
1762
+ await this.db.batch(statements)
1763
+ }
1764
+
1765
+ async recoveryCodeState(userId: string): Promise<RecoveryCodeState> {
1766
+ const row = await this.stmt(
1767
+ `SELECT COUNT(*) AS total,
1768
+ SUM(CASE WHEN consumed_at IS NULL THEN 1 ELSE 0 END) AS remaining,
1769
+ MAX(created_at) AS created_at
1770
+ FROM recovery_codes WHERE user_id = ?`, userId,
1771
+ ).first<{ total: number; remaining: number | null; created_at: string | null }>()
1772
+ return {
1773
+ total: row?.total ?? 0,
1774
+ remaining: row?.remaining ?? 0,
1775
+ createdAt: (row?.total ?? 0) > 0 ? (row?.created_at ?? null) : null,
1776
+ }
1777
+ }
1778
+
1779
+ async consumeRecoveryCode(userId: string, codeHash: string): Promise<boolean> {
1780
+ const res = await this.stmt(
1781
+ "UPDATE recovery_codes SET consumed_at = datetime('now') WHERE user_id = ? AND code_hash = ? AND consumed_at IS NULL",
1782
+ userId, codeHash,
1783
+ ).run()
1784
+ return (res.meta.changes ?? 0) > 0
1785
+ }
1786
+
1787
+ async createMfaPending(input: { token: string; userId: string; amr: string[]; ttlMs: number }): Promise<void> {
1788
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
1789
+ await this.stmt("DELETE FROM mfa_pending WHERE expires_at <= datetime('now')").run()
1790
+ await this.stmt(
1791
+ 'INSERT INTO mfa_pending (token, user_id, amr, expires_at) VALUES (?, ?, ?, ?)',
1792
+ input.token, input.userId, JSON.stringify(input.amr), expiresAt,
1793
+ ).run()
1794
+ }
1795
+
1796
+ async getMfaPending(token: string): Promise<MfaPending | null> {
1797
+ const row = await this.stmt('SELECT * FROM mfa_pending WHERE token = ?', token).first<Record<string, unknown>>()
1798
+ return row ? D1ServerStore.toMfaPending(row) : null
1799
+ }
1800
+
1801
+ async consumeMfaPending(token: string): Promise<MfaPending | null> {
1802
+ const res = await this.stmt(
1803
+ "UPDATE mfa_pending SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL", token,
1804
+ ).run()
1805
+ if ((res.meta.changes ?? 0) === 0) return null
1806
+ const row = await this.stmt('SELECT * FROM mfa_pending WHERE token = ?', token).first<Record<string, unknown>>()
1807
+ if (!row) return null
1808
+ if (new Date(row.expires_at as string).getTime() <= Date.now()) return null
1809
+ return D1ServerStore.toMfaPending(row)
1810
+ }
1811
+
1812
+ async recordMfaPendingFailure(token: string): Promise<MfaPending | null> {
1813
+ const res = await this.stmt(
1814
+ "UPDATE mfa_pending SET fail_count = fail_count + 1, last_failure_at = datetime('now') WHERE token = ? AND consumed_at IS NULL",
1815
+ token,
1816
+ ).run()
1817
+ if ((res.meta.changes ?? 0) === 0) return null
1818
+ const row = await this.stmt('SELECT * FROM mfa_pending WHERE token = ?', token).first<Record<string, unknown>>()
1819
+ return row ? D1ServerStore.toMfaPending(row) : null
1820
+ }
1821
+
1822
+ // ── organization administration (TODO.identity/10) ────────────────
1823
+
1824
+ /** The store's time columns arrive in two shapes (datetime('now')'s
1825
+ * naive UTC 'YYYY-MM-DD HH:MM:SS' from the DEFAULT writes, and the ISO
1826
+ * strings the code paths write); the API answers ISO always —
1827
+ * Date.parse would read the naive shape as LOCAL time and the routes'
1828
+ * age math would misfire off-UTC. */
1829
+ private static storeTimeToIso(value: string | null): string | null {
1830
+ if (value === null) return null
1831
+ if (value.includes('T')) return value
1832
+ return value.replace(' ', 'T') + 'Z'
1833
+ }
1834
+
1835
+ private static toWebauthnCredential(row: Record<string, unknown>): WebauthnCredential {
1836
+ return {
1837
+ credentialId: row.credential_id as string,
1838
+ userId: row.user_id as string,
1839
+ name: row.name as string,
1840
+ publicKeyCose: row.public_key as string,
1841
+ signCount: Number(row.sign_count ?? 0),
1842
+ aaguid: (row.aaguid as string | null) ?? null,
1843
+ transports: parseRoles((row.transports as string | null) ?? null) ?? [],
1844
+ createdAt: D1ServerStore.storeTimeToIso(row.created_at as string)!,
1845
+ lastUsedAt: D1ServerStore.storeTimeToIso((row.last_used_at as string | null) ?? null),
1846
+ lastIp: (row.last_ip as string | null) ?? null,
1847
+ }
1848
+ }
1849
+
1850
+ private static toTotpSecret(row: Record<string, unknown>): TotpSecret {
1851
+ return {
1852
+ id: row.id as string,
1853
+ userId: row.user_id as string,
1854
+ name: row.name as string,
1855
+ secret: row.secret as string,
1856
+ failCount: Number(row.fail_count ?? 0),
1857
+ lastFailureAt: D1ServerStore.storeTimeToIso((row.last_failure_at as string | null) ?? null),
1858
+ createdAt: D1ServerStore.storeTimeToIso(row.created_at as string)!,
1859
+ verifiedAt: D1ServerStore.storeTimeToIso((row.verified_at as string | null) ?? null),
1860
+ lastUsedAt: D1ServerStore.storeTimeToIso((row.last_used_at as string | null) ?? null),
1861
+ lastIp: (row.last_ip as string | null) ?? null,
1862
+ }
1863
+ }
1864
+
1865
+ private static toWebauthnChallenge(row: Record<string, unknown>): WebauthnChallenge {
1866
+ return {
1867
+ challenge: row.challenge as string,
1868
+ userId: (row.user_id as string | null) ?? null,
1869
+ kind: row.kind as WebauthnChallenge['kind'],
1870
+ createdAt: D1ServerStore.storeTimeToIso(row.created_at as string)!,
1871
+ expiresAt: D1ServerStore.storeTimeToIso(row.expires_at as string)!,
1872
+ consumedAt: D1ServerStore.storeTimeToIso((row.consumed_at as string | null) ?? null),
1873
+ }
1874
+ }
1875
+
1876
+ private static toMfaPending(row: Record<string, unknown>): MfaPending {
1877
+ return {
1878
+ token: row.token as string,
1879
+ userId: row.user_id as string,
1880
+ amr: parseRoles((row.amr as string | null) ?? null) ?? [],
1881
+ failCount: Number(row.fail_count ?? 0),
1882
+ lastFailureAt: D1ServerStore.storeTimeToIso((row.last_failure_at as string | null) ?? null),
1883
+ createdAt: D1ServerStore.storeTimeToIso(row.created_at as string)!,
1884
+ expiresAt: D1ServerStore.storeTimeToIso(row.expires_at as string)!,
1885
+ consumedAt: D1ServerStore.storeTimeToIso((row.consumed_at as string | null) ?? null),
1886
+ }
1887
+ }
1888
+
1889
+ private static toOrgJoinRequest(row: Record<string, unknown>): OrgJoinRequest {
1890
+ return {
1891
+ id: row.id as string,
1892
+ name: row.name as string,
1893
+ email: row.email as string,
1894
+ orgId: (row.org_id as string | null) ?? null,
1895
+ orgNameText: (row.org_name_text as string | null) ?? null,
1896
+ requestedRole: row.requested_role as string,
1897
+ note: (row.note as string | null) ?? null,
1898
+ status: row.status as OrgJoinRequest['status'],
1899
+ decidedBy: (row.decided_by as string | null) ?? null,
1900
+ decidedAt: (row.decided_at as string | null) ?? null,
1901
+ refusalReason: (row.refusal_reason as string | null) ?? null,
1902
+ invitedUserId: (row.invited_user_id as string | null) ?? null,
1903
+ createdAt: row.created_at as string,
1904
+ }
1905
+ }
1906
+
1907
+ async createOrgJoinRequest(input: {
1908
+ name: string
1909
+ email: string
1910
+ orgId: string | null
1911
+ orgNameText: string | null
1912
+ requestedRole: string
1913
+ note?: string | null
1914
+ }): Promise<OrgJoinRequest> {
1915
+ const id = crypto.randomUUID()
1916
+ await this.stmt(
1917
+ `INSERT INTO org_join_requests (id, name, email, org_id, org_name_text, requested_role, note)
1918
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
1919
+ id, input.name, input.email, input.orgId, input.orgNameText, input.requestedRole, input.note ?? null,
1920
+ ).run()
1921
+ return (await this.getOrgJoinRequest(id))!
1922
+ }
1923
+
1924
+ async getOrgJoinRequest(id: string): Promise<OrgJoinRequest | null> {
1925
+ const row = await this.stmt('SELECT * FROM org_join_requests WHERE id = ?', id).first<Record<string, unknown>>()
1926
+ return row ? D1ServerStore.toOrgJoinRequest(row) : null
1927
+ }
1928
+
1929
+ async listOrgJoinRequests(filter?: {
1930
+ scope?: 'org' | 'unregistered' | 'all'
1931
+ orgId?: string
1932
+ status?: OrgJoinRequest['status']
1933
+ }): Promise<OrgJoinRequest[]> {
1934
+ const scope = filter?.scope ?? 'all'
1935
+ const where: string[] = []
1936
+ const args: unknown[] = []
1937
+ if (scope === 'org') { where.push('org_id = ?'); args.push(filter?.orgId ?? '') }
1938
+ if (scope === 'unregistered') where.push('org_id IS NULL')
1939
+ if (filter?.status) { where.push('status = ?'); args.push(filter.status) }
1940
+ const sql = `SELECT * FROM org_join_requests${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at`
1941
+ const res = await this.stmt(sql, ...args).all<Record<string, unknown>>()
1942
+ return res.results.map(D1ServerStore.toOrgJoinRequest)
1943
+ }
1944
+
1945
+ /** The decision — atomic on 'pending' (a double decide loses honestly). */
1946
+ async decideOrgJoinRequest(
1947
+ id: string,
1948
+ decision: {
1949
+ status: 'approved' | 'refused'
1950
+ decidedBy: string
1951
+ refusalReason?: string | null
1952
+ invitedUserId?: string | null
1953
+ },
1954
+ ): Promise<OrgJoinRequest | null> {
1955
+ const res = await this.stmt(
1956
+ `UPDATE org_join_requests
1957
+ SET status = ?, decided_by = ?, decided_at = datetime('now'), refusal_reason = ?, invited_user_id = ?
1958
+ WHERE id = ? AND status = 'pending'`,
1959
+ decision.status, decision.decidedBy, decision.refusalReason ?? null, decision.invitedUserId ?? null, id,
1960
+ ).run()
1961
+ if ((res.meta.changes ?? 0) === 0) return null
1962
+ return this.getOrgJoinRequest(id)
1963
+ }
1964
+
1965
+ async findPendingOrgJoinRequestByEmail(email: string): Promise<OrgJoinRequest | null> {
1966
+ const row = await this.stmt(
1967
+ "SELECT * FROM org_join_requests WHERE email = ? AND status = 'pending' ORDER BY created_at", email,
1968
+ ).first<Record<string, unknown>>()
1969
+ return row ? D1ServerStore.toOrgJoinRequest(row) : null
1970
+ }
1971
+
1972
+ // ── organization memberships (TODO.identity/11 — the multi-org model) ──
1973
+ // The rows port directly (D1 is SQLite) — the same statements as the
1974
+ // SQLite store's membership section. THE DUAL-READ DOCTRINE: the users
1975
+ // row's org_id/roles columns stay the backward-compatible read (the
1976
+ // PRIMARY membership's mirror); the mirror rides every legacy writer.
1977
+
1978
+ private static toOrgMembership(row: Record<string, unknown>): OrgMembership {
1979
+ let roles: string[] = []
1980
+ try {
1981
+ const parsed = JSON.parse(row.roles as string) as unknown
1982
+ if (Array.isArray(parsed)) roles = parsed.filter((v): v is string => typeof v === 'string')
1983
+ } catch { /* a malformed roles cell reads as the empty set */ }
1984
+ return {
1985
+ id: row.id as string,
1986
+ userId: row.user_id as string,
1987
+ orgId: row.org_id as string,
1988
+ roles,
1989
+ state: row.state as OrgMembershipState,
1990
+ isPrimary: row.is_primary === 1,
1991
+ invitedBy: (row.invited_by as string | null) ?? null,
1992
+ createdAt: row.created_at as string,
1993
+ activatedAt: (row.activated_at as string | null) ?? null,
1994
+ disabledAt: (row.disabled_at as string | null) ?? null,
1995
+ disabledBy: (row.disabled_by as string | null) ?? null,
1996
+ }
1997
+ }
1998
+
1999
+ /** THE MIRROR (the dual-read doctrine's write half): re-project the
2000
+ * PRIMARY membership from the users row's legacy columns. A DISABLED
2001
+ * row keeps its state (only roles + the primary mark move). */
2002
+ private async syncPrimaryMembership(userId: string): Promise<void> {
2003
+ await this.ensureMembershipSupport()
2004
+ const user = await this.stmt('SELECT id, role, roles, org_id FROM users WHERE id = ?', userId)
2005
+ .first<{ id: string; role: string; roles: string | null; org_id: string | null }>()
2006
+ if (!user || !user.org_id) return
2007
+ const roles = parseRoles(user.roles) ?? [user.role]
2008
+ await this.db.batch([
2009
+ this.stmt('UPDATE org_memberships SET is_primary = 0 WHERE user_id = ? AND org_id != ? AND is_primary = 1', userId, user.org_id),
2010
+ this.stmt(
2011
+ `INSERT INTO org_memberships (id, user_id, org_id, roles, state, is_primary, activated_at)
2012
+ VALUES (?, ?, ?, ?, 'active', 1, datetime('now'))
2013
+ ON CONFLICT (user_id, org_id) DO UPDATE SET roles = excluded.roles, is_primary = 1`,
2014
+ crypto.randomUUID(), userId, user.org_id, JSON.stringify(roles),
2015
+ ),
2016
+ ])
2017
+ }
2018
+
2019
+ async listOrgMemberships(userId: string): Promise<OrgMembership[]> {
2020
+ await this.ensureMembershipSupport()
2021
+ const res = await this.stmt(
2022
+ 'SELECT * FROM org_memberships WHERE user_id = ? ORDER BY is_primary DESC, created_at', userId,
2023
+ ).all<Record<string, unknown>>()
2024
+ return res.results.map(D1ServerStore.toOrgMembership)
2025
+ }
2026
+
2027
+ async listOrgMembers(orgId: string): Promise<OrgMembership[]> {
2028
+ await this.ensureMembershipSupport()
2029
+ const res = await this.stmt(
2030
+ 'SELECT * FROM org_memberships WHERE org_id = ? ORDER BY created_at', orgId,
2031
+ ).all<Record<string, unknown>>()
2032
+ return res.results.map(D1ServerStore.toOrgMembership)
2033
+ }
2034
+
2035
+ async getOrgMembership(userId: string, orgId: string): Promise<OrgMembership | null> {
2036
+ await this.ensureMembershipSupport()
2037
+ const row = await this.stmt('SELECT * FROM org_memberships WHERE user_id = ? AND org_id = ?', userId, orgId)
2038
+ .first<Record<string, unknown>>()
2039
+ return row ? D1ServerStore.toOrgMembership(row) : null
2040
+ }
2041
+
2042
+ /** Create the membership; NULL on the (user, org) conflict — the
2043
+ * honest "already a member". */
2044
+ async createOrgMembership(input: {
2045
+ userId: string
2046
+ orgId: string
2047
+ roles: string[]
2048
+ state: OrgMembershipState
2049
+ invitedBy?: string | null
2050
+ }): Promise<OrgMembership | null> {
2051
+ await this.ensureMembershipSupport()
2052
+ const res = await this.stmt(
2053
+ `INSERT OR IGNORE INTO org_memberships (id, user_id, org_id, roles, state, invited_by, activated_at)
2054
+ VALUES (?, ?, ?, ?, ?, ?, CASE WHEN ? = 'active' THEN datetime('now') ELSE NULL END)`,
2055
+ crypto.randomUUID(), input.userId, input.orgId, JSON.stringify(input.roles), input.state,
2056
+ input.invitedBy ?? null, input.state,
2057
+ ).run()
2058
+ if ((res.meta.changes ?? 0) === 0) return null
2059
+ return this.getOrgMembership(input.userId, input.orgId)
2060
+ }
2061
+
2062
+ /** Replace the per-org role set; the PRIMARY membership's write mirrors
2063
+ * into the users row (the dual-write — the legacy read stays
2064
+ * identical). */
2065
+ async setOrgMembershipRoles(userId: string, orgId: string, roles: string[]): Promise<boolean> {
2066
+ await this.ensureMembershipSupport()
2067
+ const res = await this.stmt('UPDATE org_memberships SET roles = ? WHERE user_id = ? AND org_id = ?',
2068
+ JSON.stringify(roles), userId, orgId).run()
2069
+ if ((res.meta.changes ?? 0) === 0) return false
2070
+ const membership = await this.getOrgMembership(userId, orgId)
2071
+ if (membership?.isPrimary) {
2072
+ const user = await this.stmt('SELECT role FROM users WHERE id = ?', userId).first<{ role: string }>()
2073
+ if (user) {
2074
+ const primaryRole = roles.includes(user.role) ? user.role : (roles[0] ?? user.role)
2075
+ await this.stmt('UPDATE users SET role = ?, roles = ? WHERE id = ?',
2076
+ primaryRole, JSON.stringify(roles.length ? roles : [primaryRole]), userId).run()
2077
+ }
2078
+ }
2079
+ return true
2080
+ }
2081
+
2082
+ /** The lifecycle act (stamps; disabling also clears the account's
2083
+ * sessions' active-org stamps pointing at the org). */
2084
+ async setOrgMembershipState(
2085
+ userId: string,
2086
+ orgId: string,
2087
+ state: OrgMembershipState,
2088
+ actor?: string | null,
2089
+ ): Promise<OrgMembership | null> {
2090
+ await this.ensureMembershipSupport()
2091
+ const existing = await this.getOrgMembership(userId, orgId)
2092
+ if (!existing) return null
2093
+ if (state === 'active') {
2094
+ await this.stmt(
2095
+ "UPDATE org_memberships SET state = 'active', activated_at = datetime('now'), disabled_at = NULL, disabled_by = NULL WHERE user_id = ? AND org_id = ?",
2096
+ userId, orgId,
2097
+ ).run()
2098
+ } else if (state === 'disabled') {
2099
+ await this.db.batch([
2100
+ this.stmt(
2101
+ "UPDATE org_memberships SET state = 'disabled', disabled_at = datetime('now'), disabled_by = ? WHERE user_id = ? AND org_id = ?",
2102
+ actor ?? null, userId, orgId,
2103
+ ),
2104
+ this.stmt('UPDATE sessions SET active_org = NULL WHERE user_id = ? AND active_org = ?', userId, orgId),
2105
+ ])
2106
+ } else {
2107
+ await this.stmt("UPDATE org_memberships SET state = 'invited' WHERE user_id = ? AND org_id = ?", userId, orgId).run()
2108
+ }
2109
+ return this.getOrgMembership(userId, orgId)
2110
+ }
2111
+
2112
+ /** Remove the row (the declined invitation; the erasure's cleanup). */
2113
+ async deleteOrgMembership(userId: string, orgId: string): Promise<boolean> {
2114
+ await this.ensureMembershipSupport()
2115
+ const res = await this.stmt('DELETE FROM org_memberships WHERE user_id = ? AND org_id = ?', userId, orgId).run()
2116
+ if ((res.meta.changes ?? 0) > 0) {
2117
+ await this.stmt('UPDATE sessions SET active_org = NULL WHERE user_id = ? AND active_org = ?', userId, orgId).run()
2118
+ }
2119
+ return (res.meta.changes ?? 0) > 0
2120
+ }
2121
+
2122
+ /** The session's stamped active-org context (NULL = the primary
2123
+ * context; also NULL for an unknown/expired token). */
2124
+ async getSessionActiveOrg(token: string): Promise<string | null> {
2125
+ await this.ensureMembershipSupport()
2126
+ const row = await this.stmt("SELECT active_org FROM sessions WHERE token = ? AND expires_at > datetime('now')", token)
2127
+ .first<{ active_org: string | null }>()
2128
+ return row?.active_org ?? null
2129
+ }
2130
+
2131
+ /** Stamp the session's active-org context; NULL clears to the primary
2132
+ * context. */
2133
+ async setSessionActiveOrg(token: string, orgId: string | null): Promise<boolean> {
2134
+ await this.ensureMembershipSupport()
2135
+ const res = await this.stmt("UPDATE sessions SET active_org = ? WHERE token = ? AND expires_at > datetime('now')",
2136
+ orgId, token).run()
2137
+ return (res.meta.changes ?? 0) > 0
2138
+ }
2139
+
2140
+ // ── the organization registry (TODO.identity-features/05) ──────────
2141
+ // The identity service's OWN org registry — the rows the membership
2142
+ // graph references by id. The lifecycle acts are the routes'; these
2143
+ // mirror the SQLite store's registry section one-for-one.
2144
+
2145
+ private static toOrgRegistryOrg(row: Record<string, unknown>): OrgRegistryOrg {
2146
+ let contacts: OrgRegistryContact[] = []
2147
+ try {
2148
+ const parsed = JSON.parse((row.contacts as string) ?? '[]') as unknown
2149
+ if (Array.isArray(parsed)) {
2150
+ contacts = parsed
2151
+ .filter((e): e is Record<string, unknown> => !!e && typeof e === 'object')
2152
+ .map(e => ({ name: typeof e.name === 'string' && e.name.trim() ? e.name.trim() : null, email: typeof e.email === 'string' ? e.email.trim() : '' }))
2153
+ .filter(e => e.email.includes('@'))
2154
+ }
2155
+ } catch { /* a malformed contacts cell reads as the empty list */ }
2156
+ return {
2157
+ id: row.id as string,
2158
+ name: row.name as string,
2159
+ shortName: (row.short_name as string | null) ?? null,
2160
+ kind: (row.kind as string | null) ?? null,
2161
+ country: (row.country as string | null) ?? null,
2162
+ contacts,
2163
+ participantRef: (row.participant_ref as string | null) ?? null,
2164
+ state: row.state as OrgRegistryState,
2165
+ createdAt: row.created_at as string,
2166
+ createdBy: (row.created_by as string | null) ?? null,
2167
+ updatedAt: (row.updated_at as string | null) ?? null,
2168
+ updatedBy: (row.updated_by as string | null) ?? null,
2169
+ disabledAt: (row.disabled_at as string | null) ?? null,
2170
+ disabledBy: (row.disabled_by as string | null) ?? null,
2171
+ }
2172
+ }
2173
+
2174
+ async listOrgRegistryOrgs(): Promise<OrgRegistryOrg[]> {
2175
+ await this.ensureOrgRegistrySupport()
2176
+ const res = await this.stmt('SELECT * FROM org_registry').all<Record<string, unknown>>()
2177
+ return res.results.map(D1ServerStore.toOrgRegistryOrg).sort((a, b) => a.name.localeCompare(b.name))
2178
+ }
2179
+
2180
+ async getOrgRegistryOrg(id: string): Promise<OrgRegistryOrg | null> {
2181
+ await this.ensureOrgRegistrySupport()
2182
+ const row = await this.stmt('SELECT * FROM org_registry WHERE id = ?', id).first<Record<string, unknown>>()
2183
+ return row ? D1ServerStore.toOrgRegistryOrg(row) : null
2184
+ }
2185
+
2186
+ /** Add the organization; NULL on the id conflict (the slug is taken). */
2187
+ async createOrgRegistryOrg(input: {
2188
+ id: string
2189
+ name: string
2190
+ shortName?: string | null
2191
+ kind?: string | null
2192
+ country?: string | null
2193
+ contacts?: OrgRegistryContact[]
2194
+ participantRef?: string | null
2195
+ createdBy?: string | null
2196
+ }): Promise<OrgRegistryOrg | null> {
2197
+ await this.ensureOrgRegistrySupport()
2198
+ const res = await this.stmt(
2199
+ `INSERT OR IGNORE INTO org_registry (id, name, short_name, kind, country, contacts, participant_ref, created_by)
2200
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
2201
+ input.id, input.name, input.shortName ?? null, input.kind ?? null, input.country ?? null,
2202
+ JSON.stringify(input.contacts ?? []), input.participantRef ?? null, input.createdBy ?? null,
2203
+ ).run()
2204
+ if ((res.meta.changes ?? 0) === 0) return null
2205
+ return this.getOrgRegistryOrg(input.id)
2206
+ }
2207
+
2208
+ /** Edit the display data (the id never moves); stamps updated_at/by. */
2209
+ async updateOrgRegistryOrg(
2210
+ id: string,
2211
+ patch: {
2212
+ name?: string
2213
+ shortName?: string | null
2214
+ kind?: string | null
2215
+ country?: string | null
2216
+ contacts?: OrgRegistryContact[]
2217
+ participantRef?: string | null
2218
+ },
2219
+ actor?: string | null,
2220
+ ): Promise<OrgRegistryOrg | null> {
2221
+ await this.ensureOrgRegistrySupport()
2222
+ const sets: string[] = []
2223
+ const params: unknown[] = []
2224
+ if (patch.name !== undefined) { sets.push('name = ?'); params.push(patch.name) }
2225
+ if (patch.shortName !== undefined) { sets.push('short_name = ?'); params.push(patch.shortName) }
2226
+ if (patch.kind !== undefined) { sets.push('kind = ?'); params.push(patch.kind) }
2227
+ if (patch.country !== undefined) { sets.push('country = ?'); params.push(patch.country) }
2228
+ if (patch.contacts !== undefined) { sets.push('contacts = ?'); params.push(JSON.stringify(patch.contacts)) }
2229
+ if (patch.participantRef !== undefined) { sets.push('participant_ref = ?'); params.push(patch.participantRef) }
2230
+ sets.push("updated_at = datetime('now')", 'updated_by = ?')
2231
+ params.push(actor ?? null)
2232
+ const res = await this.stmt(`UPDATE org_registry SET ${sets.join(', ')} WHERE id = ?`, ...params, id).run()
2233
+ if ((res.meta.changes ?? 0) === 0) return null
2234
+ return this.getOrgRegistryOrg(id)
2235
+ }
2236
+
2237
+ /** The lifecycle act (stamps; re-enable clears the disable stamps —
2238
+ * the memberships stay as they are). */
2239
+ async setOrgRegistryOrgState(id: string, state: OrgRegistryState, actor?: string | null): Promise<OrgRegistryOrg | null> {
2240
+ await this.ensureOrgRegistrySupport()
2241
+ const existing = await this.getOrgRegistryOrg(id)
2242
+ if (!existing) return null
2243
+ if (state === 'disabled') {
2244
+ await this.stmt("UPDATE org_registry SET state = 'disabled', disabled_at = datetime('now'), disabled_by = ? WHERE id = ?",
2245
+ actor ?? null, id).run()
2246
+ } else {
2247
+ await this.stmt("UPDATE org_registry SET state = 'active', disabled_at = NULL, disabled_by = NULL WHERE id = ?",
2248
+ id).run()
2249
+ }
2250
+ return this.getOrgRegistryOrg(id)
2251
+ }
2252
+
2253
+ /** The erasure-adjacent hard delete (the route guards it). */
2254
+ async deleteOrgRegistryOrg(id: string): Promise<boolean> {
2255
+ await this.ensureOrgRegistrySupport()
2256
+ const res = await this.stmt('DELETE FROM org_registry WHERE id = ?', id).run()
2257
+ return (res.meta.changes ?? 0) > 0
2258
+ }
2259
+
2260
+ // ── the register's holder-org attribution (TODO.register/02) ────────
2261
+
2262
+ private static toCertificateHolderOrg(row: Record<string, unknown>): CertificateHolderOrg {
2263
+ return {
2264
+ certificateId: row.certificate_id as string,
2265
+ orgId: row.org_id as string,
2266
+ orgName: row.org_name as string,
2267
+ source: row.source as CertificateHolderOrg['source'],
2268
+ attributedAt: row.attributed_at as string,
2269
+ attributedBy: (row.attributed_by as string | null) ?? null,
2270
+ claimId: (row.claim_id as string | null) ?? null,
2271
+ }
2272
+ }
2273
+
2274
+ /** INSERT-IF-ABSENT — the first attribution wins (NULL on an existing
2275
+ * row, never a silent overwrite). */
2276
+ async attributeCertificateHolderOrg(input: {
2277
+ certificateId: string
2278
+ orgId: string
2279
+ orgName: string
2280
+ source: CertificateHolderOrg['source']
2281
+ attributedAt: string
2282
+ attributedBy?: string | null
2283
+ claimId?: string | null
2284
+ }): Promise<CertificateHolderOrg | null> {
2285
+ await this.ensureHolderAttributionSupport()
2286
+ const res = await this.stmt(
2287
+ `INSERT OR IGNORE INTO certificate_holder_orgs
2288
+ (certificate_id, org_id, org_name, source, attributed_at, attributed_by, claim_id)
2289
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
2290
+ input.certificateId, input.orgId, input.orgName, input.source,
2291
+ input.attributedAt, input.attributedBy ?? null, input.claimId ?? null,
2292
+ ).run()
2293
+ if ((res.meta.changes ?? 0) === 0) return null
2294
+ return this.getCertificateHolderOrg(input.certificateId)
2295
+ }
2296
+
2297
+ async getCertificateHolderOrg(certificateId: string): Promise<CertificateHolderOrg | null> {
2298
+ await this.ensureHolderAttributionSupport()
2299
+ const row = await this.stmt('SELECT * FROM certificate_holder_orgs WHERE certificate_id = ?', certificateId)
2300
+ .first<Record<string, unknown>>()
2301
+ return row ? D1ServerStore.toCertificateHolderOrg(row) : null
2302
+ }
2303
+
2304
+ async listCertificateHolderOrgs(filter?: { orgId?: string }): Promise<CertificateHolderOrg[]> {
2305
+ await this.ensureHolderAttributionSupport()
2306
+ const res = filter?.orgId
2307
+ ? await this.stmt('SELECT * FROM certificate_holder_orgs WHERE org_id = ?', filter.orgId).all<Record<string, unknown>>()
2308
+ : await this.stmt('SELECT * FROM certificate_holder_orgs').all<Record<string, unknown>>()
2309
+ return res.results.map(D1ServerStore.toCertificateHolderOrg)
2310
+ }
2311
+
2312
+ private static toCertificateHolderClaim(row: Record<string, unknown>): CertificateHolderClaim {
2313
+ return {
2314
+ id: row.id as string,
2315
+ certificateId: row.certificate_id as string,
2316
+ claimantOrgId: row.claimant_org_id as string,
2317
+ claimantOrgName: row.claimant_org_name as string,
2318
+ matchedHolderName: row.matched_holder_name as string,
2319
+ claimedBy: row.claimed_by as string,
2320
+ state: row.state as CertificateHolderClaim['state'],
2321
+ decidedBy: (row.decided_by as string | null) ?? null,
2322
+ decidedAt: (row.decided_at as string | null) ?? null,
2323
+ refusalReason: (row.refusal_reason as string | null) ?? null,
2324
+ createdAt: row.created_at as string,
2325
+ }
2326
+ }
2327
+
2328
+ async createCertificateHolderClaim(input: {
2329
+ certificateId: string
2330
+ claimantOrgId: string
2331
+ claimantOrgName: string
2332
+ matchedHolderName: string
2333
+ claimedBy: string
2334
+ }): Promise<CertificateHolderClaim> {
2335
+ await this.ensureHolderAttributionSupport()
2336
+ const id = crypto.randomUUID()
2337
+ await this.stmt(
2338
+ `INSERT INTO certificate_holder_claims
2339
+ (id, certificate_id, claimant_org_id, claimant_org_name, matched_holder_name, claimed_by)
2340
+ VALUES (?, ?, ?, ?, ?, ?)`,
2341
+ id, input.certificateId, input.claimantOrgId, input.claimantOrgName, input.matchedHolderName, input.claimedBy,
2342
+ ).run()
2343
+ return (await this.getCertificateHolderClaim(id))!
2344
+ }
2345
+
2346
+ async getCertificateHolderClaim(id: string): Promise<CertificateHolderClaim | null> {
2347
+ await this.ensureHolderAttributionSupport()
2348
+ const row = await this.stmt('SELECT * FROM certificate_holder_claims WHERE id = ?', id)
2349
+ .first<Record<string, unknown>>()
2350
+ return row ? D1ServerStore.toCertificateHolderClaim(row) : null
2351
+ }
2352
+
2353
+ async listCertificateHolderClaims(filter?: {
2354
+ state?: CertificateHolderClaim['state']
2355
+ claimantOrgId?: string
2356
+ certificateId?: string
2357
+ }): Promise<CertificateHolderClaim[]> {
2358
+ await this.ensureHolderAttributionSupport()
2359
+ const where: string[] = []
2360
+ const params: unknown[] = []
2361
+ if (filter?.state) { where.push('state = ?'); params.push(filter.state) }
2362
+ if (filter?.claimantOrgId) { where.push('claimant_org_id = ?'); params.push(filter.claimantOrgId) }
2363
+ if (filter?.certificateId) { where.push('certificate_id = ?'); params.push(filter.certificateId) }
2364
+ const res = await this.stmt(
2365
+ `SELECT * FROM certificate_holder_claims${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at`,
2366
+ ...params,
2367
+ ).all<Record<string, unknown>>()
2368
+ return res.results.map(D1ServerStore.toCertificateHolderClaim)
2369
+ }
2370
+
2371
+ /** The estate admin's decision — ATOMIC on 'pending' (a double
2372
+ * decision loses the race, answering null). */
2373
+ async decideCertificateHolderClaim(
2374
+ id: string,
2375
+ decision: { status: 'confirmed' | 'refused'; decidedBy: string; refusalReason?: string | null },
2376
+ ): Promise<CertificateHolderClaim | null> {
2377
+ await this.ensureHolderAttributionSupport()
2378
+ const res = await this.stmt(
2379
+ `UPDATE certificate_holder_claims
2380
+ SET state = ?, decided_by = ?, decided_at = datetime('now'), refusal_reason = ?
2381
+ WHERE id = ? AND state = 'pending'`,
2382
+ decision.status, decision.decidedBy, decision.refusalReason ?? null, id,
2383
+ ).run()
2384
+ if ((res.meta.changes ?? 0) === 0) return null
2385
+ return this.getCertificateHolderClaim(id)
2386
+ }
2387
+
2388
+ async findPendingCertificateHolderClaim(certificateId: string): Promise<CertificateHolderClaim | null> {
2389
+ await this.ensureHolderAttributionSupport()
2390
+ const row = await this.stmt(
2391
+ "SELECT * FROM certificate_holder_claims WHERE certificate_id = ? AND state = 'pending'",
2392
+ certificateId,
2393
+ ).first<Record<string, unknown>>()
2394
+ return row ? D1ServerStore.toCertificateHolderClaim(row) : null
2395
+ }
2396
+
2397
+ // ── the instrument register (TODO.register/03) ─────────────────────
2398
+ // The platform-side serial register — the rows the registration
2399
+ // interface's cones read (the route's; browser/server/routes/
2400
+ // registrations.ts). These mirror the SQLite store's register section
2401
+ // one-for-one.
2402
+
2403
+ private static toInstrumentRegistration(row: Record<string, unknown>): InstrumentRegistration {
2404
+ let designations: Record<string, unknown> = {}
2405
+ try {
2406
+ const parsed = JSON.parse((row.designations as string) ?? '{}') as unknown
2407
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) designations = parsed as Record<string, unknown>
2408
+ } catch { /* a malformed designations cell reads as the empty object, never trusted */ }
2409
+ return {
2410
+ id: row.id as string,
2411
+ certificateId: row.certificate_id as string,
2412
+ holderOrgId: row.holder_org_id as string,
2413
+ standardId: row.standard_id as string,
2414
+ serialNumber: row.serial_number as string,
2415
+ manufactureDate: (row.manufacture_date as string | null) ?? null,
2416
+ designations,
2417
+ scopeStatus: row.scope_status as InstrumentRegistrationScopeStatus,
2418
+ scopeDetail: (row.scope_detail as string | null) ?? null,
2419
+ lifecycle: row.lifecycle as InstrumentRegistrationLifecycle,
2420
+ registeredAt: row.registered_at as string,
2421
+ registeredBy: (row.registered_by as string | null) ?? null,
2422
+ updatedAt: (row.updated_at as string | null) ?? null,
2423
+ updatedBy: (row.updated_by as string | null) ?? null,
2424
+ }
2425
+ }
2426
+
2427
+ async listInstrumentRegistrations(): Promise<InstrumentRegistration[]> {
2428
+ await this.ensureInstrumentRegistrationSupport()
2429
+ const res = await this.stmt('SELECT * FROM instrument_registrations').all<Record<string, unknown>>()
2430
+ return res.results
2431
+ .map(D1ServerStore.toInstrumentRegistration)
2432
+ .sort((a, b) => a.certificateId.localeCompare(b.certificateId) || a.serialNumber.localeCompare(b.serialNumber))
2433
+ }
2434
+
2435
+ async listInstrumentRegistrationsForCertificate(certificateId: string): Promise<InstrumentRegistration[]> {
2436
+ await this.ensureInstrumentRegistrationSupport()
2437
+ const res = await this.stmt('SELECT * FROM instrument_registrations WHERE certificate_id = ?', certificateId).all<Record<string, unknown>>()
2438
+ return res.results.map(D1ServerStore.toInstrumentRegistration).sort((a, b) => a.serialNumber.localeCompare(b.serialNumber))
2439
+ }
2440
+
2441
+ async listInstrumentRegistrationsForHolder(holderOrgId: string): Promise<InstrumentRegistration[]> {
2442
+ await this.ensureInstrumentRegistrationSupport()
2443
+ const res = await this.stmt('SELECT * FROM instrument_registrations WHERE holder_org_id = ?', holderOrgId).all<Record<string, unknown>>()
2444
+ return res.results.map(D1ServerStore.toInstrumentRegistration).sort((a, b) => a.serialNumber.localeCompare(b.serialNumber))
2445
+ }
2446
+
2447
+ async getInstrumentRegistration(id: string): Promise<InstrumentRegistration | null> {
2448
+ await this.ensureInstrumentRegistrationSupport()
2449
+ const row = await this.stmt('SELECT * FROM instrument_registrations WHERE id = ?', id).first<Record<string, unknown>>()
2450
+ return row ? D1ServerStore.toInstrumentRegistration(row) : null
2451
+ }
2452
+
2453
+ /** Register the instrument; NULL on the (certificate, serial)
2454
+ * conflict (the route's honest 409). */
2455
+ async createInstrumentRegistration(input: {
2456
+ id: string
2457
+ certificateId: string
2458
+ holderOrgId: string
2459
+ standardId: string
2460
+ serialNumber: string
2461
+ manufactureDate?: string | null
2462
+ designations?: Record<string, unknown>
2463
+ scopeStatus: InstrumentRegistrationScopeStatus
2464
+ scopeDetail?: string | null
2465
+ registeredBy?: string | null
2466
+ }): Promise<InstrumentRegistration | null> {
2467
+ await this.ensureInstrumentRegistrationSupport()
2468
+ const res = await this.stmt(
2469
+ `INSERT OR IGNORE INTO instrument_registrations
2470
+ (id, certificate_id, holder_org_id, standard_id, serial_number, manufacture_date, designations, scope_status, scope_detail, registered_by)
2471
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2472
+ input.id, input.certificateId, input.holderOrgId, input.standardId, input.serialNumber,
2473
+ input.manufactureDate ?? null, JSON.stringify(input.designations ?? {}),
2474
+ input.scopeStatus, input.scopeDetail ?? null, input.registeredBy ?? null,
2475
+ ).run()
2476
+ if ((res.meta.changes ?? 0) === 0) return null
2477
+ return this.getInstrumentRegistration(input.id)
2478
+ }
2479
+
2480
+ /** The lifecycle act (the transition rule is the route's); stamps
2481
+ * updated_at/by. NULL when the register does not carry the id. */
2482
+ async setInstrumentRegistrationLifecycle(
2483
+ id: string,
2484
+ lifecycle: InstrumentRegistrationLifecycle,
2485
+ actor?: string | null,
2486
+ ): Promise<InstrumentRegistration | null> {
2487
+ await this.ensureInstrumentRegistrationSupport()
2488
+ const res = await this.stmt(
2489
+ "UPDATE instrument_registrations SET lifecycle = ?, updated_at = datetime('now'), updated_by = ? WHERE id = ?",
2490
+ lifecycle, actor ?? null, id,
2491
+ ).run()
2492
+ if ((res.meta.changes ?? 0) === 0) return null
2493
+ return this.getInstrumentRegistration(id)
2494
+ }
2495
+
2496
+ // ── the workflow entity store + change journal ───────────────────
2497
+
2498
+ async listEntities(store: string): Promise<EntityRow[]> {
2499
+ const res = await this.stmt(
2500
+ 'SELECT store, id, org_id, data, updated_at FROM entities WHERE store = ?', store,
2501
+ ).all<EntityRow>()
2502
+ return res.results
2503
+ }
2504
+
2505
+ async getEntity(store: string, id: string): Promise<EntityRow | undefined> {
2506
+ const row = await this.stmt(
2507
+ 'SELECT store, id, org_id, data, updated_at FROM entities WHERE store = ? AND id = ?', store, id,
2508
+ ).first<EntityRow>()
2509
+ return row ?? undefined
2510
+ }
2511
+
2512
+ async putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void> {
2513
+ // The upsert + its journal entry ride ONE batch — D1 batches are
2514
+ // all-or-nothing, the same atomicity the SQLite path gets from its
2515
+ // transaction.
2516
+ await this.db.batch([
2517
+ this.stmt(
2518
+ `INSERT INTO entities (store, id, org_id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))
2519
+ ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`,
2520
+ store, id, orgId, data,
2521
+ ),
2522
+ this.stmt('INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)', store, 'persist', id),
2523
+ ])
2524
+ }
2525
+
2526
+ async deleteEntity(store: string, id: string): Promise<boolean> {
2527
+ const res = await this.stmt('DELETE FROM entities WHERE store = ? AND id = ?', store, id).run()
2528
+ const gone = (res.meta.changes ?? 0) > 0
2529
+ if (gone) {
2530
+ await this.stmt('INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)', store, 'remove', id).run()
2531
+ }
2532
+ return gone
2533
+ }
2534
+
2535
+ async changesAfter(seq: number, limit = 500): Promise<EntityChange[]> {
2536
+ const res = await this.stmt(
2537
+ 'SELECT seq, store, type, id, at FROM entity_changes WHERE seq > ? ORDER BY seq LIMIT ?', seq, limit,
2538
+ ).all<EntityChange>()
2539
+ return res.results
2540
+ }
2541
+
2542
+ async latestChangeSeq(): Promise<number> {
2543
+ const row = await this.stmt('SELECT MAX(seq) AS seq FROM entity_changes').first<{ seq: number | null }>()
2544
+ return row?.seq ?? 0
2545
+ }
2546
+
2547
+ // ── the platform event store (TODO.notify/01) ─────────────────────
2548
+ // The event rows port directly (D1 is SQLite) — the same statements
2549
+ // as sqlite/events.ts's sync half.
2550
+
2551
+ private static toPlatformEvent(row: Record<string, unknown>): PlatformEvent {
2552
+ return {
2553
+ seq: row.seq as number,
2554
+ id: row.id as string,
2555
+ domain: row.domain as string,
2556
+ entityId: row.entity_id as string,
2557
+ action: row.action as string,
2558
+ payload: row.payload as string,
2559
+ at: row.at as string,
2560
+ }
2561
+ }
2562
+
2563
+ async appendEvent(input: {
2564
+ id: string
2565
+ domain: string
2566
+ entityId: string
2567
+ action: string
2568
+ payload: string
2569
+ }): Promise<PlatformEvent> {
2570
+ await this.stmt(
2571
+ 'INSERT INTO events (id, domain, entity_id, action, payload) VALUES (?, ?, ?, ?, ?)',
2572
+ input.id, input.domain, input.entityId, input.action, input.payload,
2573
+ ).run()
2574
+ const row = await this.stmt('SELECT * FROM events WHERE id = ?', input.id).first<Record<string, unknown>>()
2575
+ return D1ServerStore.toPlatformEvent(row!)
2576
+ }
2577
+
2578
+ async eventsAfter(seq: number, limit = 500): Promise<PlatformEvent[]> {
2579
+ const res = await this.stmt(
2580
+ 'SELECT * FROM events WHERE seq > ? ORDER BY seq LIMIT ?', seq, limit,
2581
+ ).all<Record<string, unknown>>()
2582
+ return res.results.map(D1ServerStore.toPlatformEvent)
2583
+ }
2584
+
2585
+ async latestEventSeq(): Promise<number> {
2586
+ const row = await this.stmt('SELECT MAX(seq) AS seq FROM events').first<{ seq: number | null }>()
2587
+ return row?.seq ?? 0
2588
+ }
2589
+
2590
+ async getEvent(id: string): Promise<PlatformEvent | null> {
2591
+ const row = await this.stmt('SELECT * FROM events WHERE id = ?', id).first<Record<string, unknown>>()
2592
+ return row ? D1ServerStore.toPlatformEvent(row) : null
2593
+ }
2594
+
2595
+ /** The subscription grammar's SQL resolution: the pinned columns match
2596
+ * by equality; the free legs stay out of the WHERE. */
2597
+ async eventsMatching(filter: EventKeyFilter, limit = 500): Promise<PlatformEvent[]> {
2598
+ const where: string[] = []
2599
+ const args: unknown[] = []
2600
+ if (filter.domain !== undefined) { where.push('domain = ?'); args.push(filter.domain) }
2601
+ if (filter.entityId !== undefined) { where.push('entity_id = ?'); args.push(filter.entityId) }
2602
+ if (filter.action !== undefined) { where.push('action = ?'); args.push(filter.action) }
2603
+ const sql = `SELECT * FROM events${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY seq LIMIT ?`
2604
+ const res = await this.stmt(sql, ...args, limit).all<Record<string, unknown>>()
2605
+ return res.results.map(D1ServerStore.toPlatformEvent)
2606
+ }
2607
+
2608
+ // ── the notification subscriptions store (TODO.notify/02) ─────────
2609
+ // The SAME statements as sqlite/notify.ts's sync half (D1 is SQLite).
2610
+
2611
+ private static toNotifyRule(row: Record<string, unknown>): NotifyRule {
2612
+ return {
2613
+ id: row.id as string,
2614
+ userId: row.user_id as string,
2615
+ pattern: row.pattern as string,
2616
+ domain: row.domain as string,
2617
+ entityId: (row.entity_id as string | null) ?? null,
2618
+ action: (row.action as string | null) ?? null,
2619
+ mode: row.mode as NotifyRule['mode'],
2620
+ channelOverrides: (row.channel_overrides as string | null) ?? null,
2621
+ createdAt: row.created_at as string,
2622
+ }
2623
+ }
2624
+
2625
+ async listNotifyRules(userId: string): Promise<NotifyRule[]> {
2626
+ const res = await this.stmt(
2627
+ 'SELECT * FROM notify_rules WHERE user_id = ? ORDER BY created_at, id', userId,
2628
+ ).all<Record<string, unknown>>()
2629
+ return res.results.map(D1ServerStore.toNotifyRule)
2630
+ }
2631
+
2632
+ async putNotifyRule(input: {
2633
+ id: string
2634
+ userId: string
2635
+ pattern: string
2636
+ domain: string
2637
+ entityId: string | null
2638
+ action: string | null
2639
+ mode: NotifyRule['mode']
2640
+ channelOverrides: string | null
2641
+ }): Promise<NotifyRule> {
2642
+ await this.stmt(
2643
+ `INSERT INTO notify_rules (id, user_id, pattern, domain, entity_id, action, mode, channel_overrides)
2644
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2645
+ ON CONFLICT (user_id, pattern) DO UPDATE SET
2646
+ domain = excluded.domain,
2647
+ entity_id = excluded.entity_id,
2648
+ action = excluded.action,
2649
+ mode = excluded.mode,
2650
+ channel_overrides = excluded.channel_overrides`,
2651
+ input.id, input.userId, input.pattern, input.domain, input.entityId, input.action, input.mode, input.channelOverrides,
2652
+ ).run()
2653
+ const row = await this.stmt(
2654
+ 'SELECT * FROM notify_rules WHERE user_id = ? AND pattern = ?', input.userId, input.pattern,
2655
+ ).first<Record<string, unknown>>()
2656
+ return D1ServerStore.toNotifyRule(row!)
2657
+ }
2658
+
2659
+ async deleteNotifyRule(userId: string, pattern: string): Promise<boolean> {
2660
+ const res = await this.stmt(
2661
+ 'DELETE FROM notify_rules WHERE user_id = ? AND pattern = ?', userId, pattern,
2662
+ ).run()
2663
+ return (res.meta.changes ?? 0) > 0
2664
+ }
2665
+
2666
+ /** The resolution's reverse match: a rule covers the event when its
2667
+ * pinned legs equal the event's columns (NULL = the wild leg). */
2668
+ async notifyRulesForEvent(filter: { domain: string; entityId: string; action: string }): Promise<NotifyRule[]> {
2669
+ const res = await this.stmt(
2670
+ `SELECT * FROM notify_rules
2671
+ WHERE domain = ? AND (entity_id IS NULL OR entity_id = ?) AND (action IS NULL OR action = ?)
2672
+ ORDER BY created_at, id`,
2673
+ filter.domain, filter.entityId, filter.action,
2674
+ ).all<Record<string, unknown>>()
2675
+ return res.results.map(D1ServerStore.toNotifyRule)
2676
+ }
2677
+
2678
+ private static toNotifyEntityMute(row: Record<string, unknown>): NotifyEntityMute {
2679
+ return {
2680
+ id: row.id as string,
2681
+ userId: row.user_id as string,
2682
+ domain: row.domain as string,
2683
+ entityId: row.entity_id as string,
2684
+ createdAt: row.created_at as string,
2685
+ }
2686
+ }
2687
+
2688
+ async listNotifyEntityMutes(userId: string): Promise<NotifyEntityMute[]> {
2689
+ const res = await this.stmt(
2690
+ 'SELECT * FROM notify_entity_mutes WHERE user_id = ? ORDER BY created_at, id', userId,
2691
+ ).all<Record<string, unknown>>()
2692
+ return res.results.map(D1ServerStore.toNotifyEntityMute)
2693
+ }
2694
+
2695
+ async putNotifyEntityMute(input: { id: string; userId: string; domain: string; entityId: string }): Promise<NotifyEntityMute> {
2696
+ await this.stmt(
2697
+ `INSERT INTO notify_entity_mutes (id, user_id, domain, entity_id) VALUES (?, ?, ?, ?)
2698
+ ON CONFLICT (user_id, domain, entity_id) DO NOTHING`,
2699
+ input.id, input.userId, input.domain, input.entityId,
2700
+ ).run()
2701
+ const row = await this.stmt(
2702
+ 'SELECT * FROM notify_entity_mutes WHERE user_id = ? AND domain = ? AND entity_id = ?',
2703
+ input.userId, input.domain, input.entityId,
2704
+ ).first<Record<string, unknown>>()
2705
+ return D1ServerStore.toNotifyEntityMute(row!)
2706
+ }
2707
+
2708
+ async deleteNotifyEntityMute(userId: string, domain: string, entityId: string): Promise<boolean> {
2709
+ const res = await this.stmt(
2710
+ 'DELETE FROM notify_entity_mutes WHERE user_id = ? AND domain = ? AND entity_id = ?',
2711
+ userId, domain, entityId,
2712
+ ).run()
2713
+ return (res.meta.changes ?? 0) > 0
2714
+ }
2715
+
2716
+ async notifyEntityMutesForEvent(domain: string, entityId: string): Promise<NotifyEntityMute[]> {
2717
+ const res = await this.stmt(
2718
+ 'SELECT * FROM notify_entity_mutes WHERE domain = ? AND entity_id = ? ORDER BY created_at, id',
2719
+ domain, entityId,
2720
+ ).all<Record<string, unknown>>()
2721
+ return res.results.map(D1ServerStore.toNotifyEntityMute)
2722
+ }
2723
+
2724
+ private static toNotifyPreferences(row: Record<string, unknown>): NotifyPreferences {
2725
+ return {
2726
+ userId: row.user_id as string,
2727
+ channels: row.channels as string,
2728
+ updatedAt: row.updated_at as string,
2729
+ }
2730
+ }
2731
+
2732
+ async getNotifyPreferences(userId: string): Promise<NotifyPreferences | null> {
2733
+ const row = await this.stmt(
2734
+ 'SELECT * FROM notify_preferences WHERE user_id = ?', userId,
2735
+ ).first<Record<string, unknown>>()
2736
+ return row ? D1ServerStore.toNotifyPreferences(row) : null
2737
+ }
2738
+
2739
+ async putNotifyPreferences(userId: string, channels: string): Promise<NotifyPreferences> {
2740
+ await this.stmt(
2741
+ `INSERT INTO notify_preferences (user_id, channels) VALUES (?, ?)
2742
+ ON CONFLICT (user_id) DO UPDATE SET channels = excluded.channels, updated_at = datetime('now')`,
2743
+ userId, channels,
2744
+ ).run()
2745
+ const row = await this.stmt(
2746
+ 'SELECT * FROM notify_preferences WHERE user_id = ?', userId,
2747
+ ).first<Record<string, unknown>>()
2748
+ return D1ServerStore.toNotifyPreferences(row!)
2749
+ }
2750
+
2751
+ // ── the inbox state (TODO.notify/03) ──────────────────────────────
2752
+ // The SAME statements as sqlite/notify.ts's sync half (D1 is SQLite).
2753
+
2754
+ private static toNotifyInboxState(row: Record<string, unknown>): NotifyInboxState {
2755
+ return {
2756
+ userId: row.user_id as string,
2757
+ eventId: row.event_id as string,
2758
+ readAt: (row.read_at as string | null) ?? null,
2759
+ doneAt: (row.done_at as string | null) ?? null,
2760
+ createdAt: row.created_at as string,
2761
+ }
2762
+ }
2763
+
2764
+ async listNotifyInboxStates(userId: string): Promise<NotifyInboxState[]> {
2765
+ const res = await this.stmt(
2766
+ 'SELECT * FROM notify_inbox_state WHERE user_id = ? ORDER BY created_at, event_id', userId,
2767
+ ).all<Record<string, unknown>>()
2768
+ return res.results.map(D1ServerStore.toNotifyInboxState)
2769
+ }
2770
+
2771
+ /** The marker write: each PRESENT flag stamps (datetime('now')) or
2772
+ * clears (NULL) its column; absent flags keep. */
2773
+ async putNotifyInboxState(input: {
2774
+ userId: string
2775
+ eventId: string
2776
+ read?: boolean
2777
+ done?: boolean
2778
+ }): Promise<NotifyInboxState> {
2779
+ await this.stmt(
2780
+ 'INSERT OR IGNORE INTO notify_inbox_state (user_id, event_id) VALUES (?, ?)',
2781
+ input.userId, input.eventId,
2782
+ ).run()
2783
+ if (input.read !== undefined) {
2784
+ await this.stmt(
2785
+ "UPDATE notify_inbox_state SET read_at = CASE WHEN ? THEN datetime('now') ELSE NULL END WHERE user_id = ? AND event_id = ?",
2786
+ input.read ? 1 : 0, input.userId, input.eventId,
2787
+ ).run()
2788
+ }
2789
+ if (input.done !== undefined) {
2790
+ await this.stmt(
2791
+ "UPDATE notify_inbox_state SET done_at = CASE WHEN ? THEN datetime('now') ELSE NULL END WHERE user_id = ? AND event_id = ?",
2792
+ input.done ? 1 : 0, input.userId, input.eventId,
2793
+ ).run()
2794
+ }
2795
+ const row = await this.stmt(
2796
+ 'SELECT * FROM notify_inbox_state WHERE user_id = ? AND event_id = ?',
2797
+ input.userId, input.eventId,
2798
+ ).first<Record<string, unknown>>()
2799
+ return D1ServerStore.toNotifyInboxState(row!)
2800
+ }
2801
+
2802
+ // ── provisioning / dev support ───────────────────────────────────
2803
+
2804
+ async wipeWorkflowStores(range?: { after: number; through: number }): Promise<number> {
2805
+ // The register table joins the wipe defensively (a dev D1 migrated
2806
+ // from before migration 0016 lacks it — the ensure posture).
2807
+ await this.ensureInstrumentRegistrationSupport()
2808
+ // The wipe's tables in one batch (all-or-nothing, the putEntity
2809
+ // pattern). A ranged round charges one bounded statement per table;
2810
+ // the range-less form is the direct-call default (small stores,
2811
+ // tests) — the budgeted reset phase always passes a range.
2812
+ const statements = range
2813
+ ? WIPE_TABLES.map(t => this.db.prepare(`DELETE FROM ${t} WHERE rowid > ? AND rowid <= ?`).bind(range.after, range.through))
2814
+ : WIPE_TABLES.map(t => this.db.prepare(`DELETE FROM ${t}`))
2815
+ const results = await this.db.batch(statements)
2816
+ return results.reduce((rows, r) => rows + (r.meta.changes ?? 0), 0)
2817
+ }
2818
+
2819
+ async workflowStoreRowCeiling(): Promise<number> {
2820
+ await this.ensureInstrumentRegistrationSupport()
2821
+ const row = await this.stmt(
2822
+ `SELECT MAX(ceiling) AS ceiling FROM (
2823
+ SELECT MAX(rowid) AS ceiling FROM entity_changes
2824
+ UNION ALL SELECT MAX(rowid) FROM evidence_records
2825
+ UNION ALL SELECT MAX(rowid) FROM entities
2826
+ UNION ALL SELECT MAX(rowid) FROM events
2827
+ UNION ALL SELECT MAX(rowid) FROM instrument_registrations)`,
2828
+ ).first<{ ceiling: number | null }>()
2829
+ return row?.ceiling ?? 0
2830
+ }
2831
+
2832
+ async countEntities(): Promise<number> {
2833
+ const row = await this.stmt('SELECT COUNT(*) AS n FROM entities').first<{ n: number }>()
2834
+ return row?.n ?? 0
2835
+ }
2836
+ }
2837
+
2838
+ /** The worker entry's install: one store per binding, memoized (the
2839
+ * store is a stateless facade over the binding — safe to share across
2840
+ * the isolate's concurrent requests). */
2841
+ const byBinding = new WeakMap<D1Database, D1ServerStore>()
2842
+
2843
+ export function d1StoreFor(binding: D1Database): D1ServerStore {
2844
+ let store = byBinding.get(binding)
2845
+ if (!store) {
2846
+ store = new D1ServerStore(binding)
2847
+ byBinding.set(binding, store)
2848
+ }
2849
+ return store
2850
+ }