@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
package/src/store.ts ADDED
@@ -0,0 +1,1826 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The server-store seam (TODO.cs-e2e/14 — the Cloudflare deployment).
3
+ //
4
+ // The routes never talk to a database driver directly: they go through
5
+ // the ServerStore, an ASYNC contract with two implementations:
6
+ //
7
+ // - SQLite (node, self-hosted): sqlite-server-store.ts wraps the
8
+ // pre-existing sync modules (store.ts / entities.ts) — better-sqlite3
9
+ // stays behind this interface, so the Worker bundle never imports
10
+ // it;
11
+ // - D1 (Cloudflare Workers): d1-store.ts implements the same surface
12
+ // against the D1 binding.
13
+ //
14
+ // The store is chosen by BINDING PRESENCE at the composition root: the
15
+ // Worker entry installs the D1 store when env.DB is bound (the same
16
+ // profile pattern ENTITY_BACKEND uses for the client); the node entry
17
+ // (server/index.ts) and the node scripts/tests install the SQLite one.
18
+ //
19
+ // This module is WORKER-SAFE: no node built-ins, no driver imports —
20
+ // every platform carries it.
21
+ // ═══════════════════════════════════════════════════════════════════
22
+
23
+ /** The INITIAL role/org for an OAuth-provisioned account
24
+ * (findOrCreateOAuthUser): applied ONLY when the account is created —
25
+ * an existing account keeps its locally assigned role/org (the
26
+ * admin's users-section decisions stand, TODO.federation/12). Absent:
27
+ * the historical defaults (role 'user', org null). */
28
+ export interface OAuthInitialAssignment {
29
+ role?: string
30
+ orgId?: string | null
31
+ }
32
+
33
+ export interface AuthUserPayload {
34
+ id: string
35
+ email: string
36
+ name: string
37
+ role: string
38
+ /** The FULL assigned role set (TODO.federation/12 RBAC) — `role` stays
39
+ * the section-gating primary; permissions resolve over this set
40
+ * (absent = the primary role only). */
41
+ roles?: string[]
42
+ orgId: string | null
43
+ avatarUrl?: string
44
+ /** TODO.identity/04: the account's sign-in provider family ('demo',
45
+ * 'github', 'oidc', the OP's account provider) — projected from the
46
+ * row by the row-backed reads (undefined where a constructor does not
47
+ * know it). The SSO cutover's moved-account guard reads it. */
48
+ provider?: string
49
+ /** TODO.identity/06: the primary address's verification state
50
+ * (users.email_verified_at; undefined on stores that do not project
51
+ * it, null = never verified). The account console shows it honestly. */
52
+ emailVerifiedAt?: string | null
53
+ /** TODO.identity-sso/02+03: the SESSION's authentication provenance
54
+ * (sessions.amr — the RFC 8176 values: 'pwd', 'otp', 'webauthn', 'hwk',
55
+ * plus the OP-private 'recovery'). Projected by the session-backed
56
+ * read (getSessionUser) only; ABSENT = no OP-side credential event
57
+ * recorded (an upstream-provider sign-in, a legacy row). */
58
+ amr?: string[]
59
+ }
60
+
61
+ // ── identity federation (TODO.federation/10) ─────────────────────────
62
+
63
+ /** The admin approval queue's row: an SSO-authenticated user no claim
64
+ * mapping rule matched (and no defaultRole is declared) — no account,
65
+ * no session, until an administrator approves with a role (+ org) or
66
+ * rejects. UNIQUE per (issuer, sub): a repeat sign-in refreshes
67
+ * last_seen, never duplicates. */
68
+ export interface IdentityApproval {
69
+ id: string
70
+ email: string
71
+ name: string
72
+ /** The issuing IdP (the configured issuer URL). */
73
+ issuer: string
74
+ /** The IdP's subject id. */
75
+ sub: string
76
+ /** The claims snapshot shown to the deciding admin (JSON). */
77
+ claimsJson: string | null
78
+ status: 'pending' | 'approved' | 'rejected'
79
+ decidedRole: string | null
80
+ decidedOrg: string | null
81
+ decidedBy: string | null
82
+ createdAt: string
83
+ lastSeen: string
84
+ decidedAt: string | null
85
+ }
86
+
87
+ /** The RP's one-time OIDC sign-in state (TODO.identity/04): the nonce +
88
+ * the PKCE verifier for one /signin/oidc → /callback/oidc round trip,
89
+ * store-backed (the sso_states table) so the Worker's isolates share
90
+ * it. Consumed atomically — a replay answers null. */
91
+ export interface SsoSignInState {
92
+ state: string
93
+ nonce: string
94
+ verifier: string
95
+ expiresAt: string
96
+ }
97
+
98
+ /** The admin view of a user (TODO.federation/12 — the instance settings
99
+ * users section + the users API). */
100
+ export interface UserAdminRow {
101
+ id: string
102
+ email: string
103
+ name: string
104
+ /** The section-gating primary role. */
105
+ role: string
106
+ /** The full assigned role set (drives permissions). */
107
+ roles: string[]
108
+ orgId: string | null
109
+ active: boolean
110
+ provider: string
111
+ lastLogin: string | null
112
+ /** TODO.identity/06: the primary address's verification state
113
+ * (users.email_verified_at; null = never verified). */
114
+ emailVerifiedAt?: string | null
115
+ }
116
+
117
+ export interface EntityRow {
118
+ store: string
119
+ id: string
120
+ org_id: string | null
121
+ data: string
122
+ updated_at: string
123
+ }
124
+
125
+ // ── federation peers (TODO.federation/04) ────────────────────────────
126
+
127
+ /** A pinned federation peer: the counterparty instance's descriptor,
128
+ * fetched + validated + pinned (or pasted out-of-band). The registry
129
+ * is the intake's trust base beyond locally-registered org keys — a
130
+ * REVOKED peer's keys never verify. Revocation flips status (the row
131
+ * stays: the audit + the revocations list carry the history). */
132
+ export interface FederationPeer {
133
+ /** The peer's descriptor instance.id (globally stable). */
134
+ id: string
135
+ name: string
136
+ /** JSON array of 'hub' | 'ia' | 'tl'. */
137
+ roles: string
138
+ /** Where the descriptor was pinned from (null for a pure manual paste). */
139
+ descriptorUrl: string | null
140
+ /** The pinned descriptor, whole (JSON). */
141
+ descriptorJson: string
142
+ pinnedVia: 'url' | 'manual' | 'directory'
143
+ /** 'verified' = the pin path probed a live endpoint; 'unverified' = the
144
+ * out-of-band paste (documented honestly in the UI). */
145
+ connectivity: 'verified' | 'unverified'
146
+ status: 'active' | 'revoked'
147
+ addedAt: string
148
+ addedBy: string | null
149
+ refreshedAt: string | null
150
+ revokedAt: string | null
151
+ revokedBy: string | null
152
+ }
153
+
154
+ export interface EntityChange {
155
+ seq: number
156
+ store: string
157
+ type: 'persist' | 'remove'
158
+ id: string
159
+ at: string
160
+ }
161
+
162
+ // ── the platform event store (TODO.notify/01) ────────────────────────
163
+
164
+ /** A notifiable platform event (TODO.notify/00's event model): one row
165
+ * per DECLARED act (the catalog is deliberate — not every write is an
166
+ * event). The hierarchical key `<domain>/<entity-id>/<action>`
167
+ * (`certificate/crt-acme-lc/issued`) is SPLIT into columns so the
168
+ * subscription grammar's prefixes resolve in SQL
169
+ * (`WHERE domain = ? AND entity_id = ?`), never a string scan; the
170
+ * composed key is derived at read, never stored. `payload` is the JSON
171
+ * envelope the catalog row declares (the summary line, the deep link,
172
+ * the actors, the entity's store for the read-time visibility gate).
173
+ * `seq` is the feed cursor (the entity_changes journal's pattern). */
174
+ export interface PlatformEvent {
175
+ seq: number
176
+ id: string
177
+ domain: string
178
+ entityId: string
179
+ action: string
180
+ payload: string
181
+ at: string
182
+ }
183
+
184
+ /** The key pattern's SQL legs: a subscription pattern (`application/**`,
185
+ * `certificate` + `issued` across the domain, one entity's
186
+ * `test-run/asg-…-001/**`) compiles to the columns it pins; absent legs
187
+ * stay free. */
188
+ export interface EventKeyFilter {
189
+ domain?: string
190
+ entityId?: string
191
+ action?: string
192
+ }
193
+
194
+ // ── the notification subscriptions store (TODO.notify/02) ────────────
195
+
196
+ /** The rule row's mode: 'subscribe' adds the user to the candidates of
197
+ * every event the pattern covers; 'mute' removes them (the pattern-level
198
+ * "unwatch" — the per-entity mute rows carry the thread-level one). */
199
+ export type NotifyRuleMode = 'subscribe' | 'mute'
200
+
201
+ /** A per-user subscription rule (TODO.notify/00's GitHub shape): the
202
+ * authored `pattern` in the catalog's grammar (`application/**`,
203
+ * `certificate` + `issued` across the domain, one entity's
204
+ * `test-run/asg-…-001/**`, or an exact key)
205
+ * with its pinned legs SPLIT into columns (domain always pinned;
206
+ * entity_id/action NULL = the wild leg) so the recipient resolution's
207
+ * reverse match — EVERY user's rules covering one event — resolves in
208
+ * SQL (`WHERE domain = ? AND (entity_id IS NULL OR entity_id = ?) …`),
209
+ * never a string scan. channel_overrides is the subscribe row's
210
+ * per-rule email override (JSON `{ "email": "immediate"|"digest"|"off"
211
+ * }`; NULL = the category preference rules on); a mute row never
212
+ * carries one. UNIQUE (user_id, pattern). */
213
+ export interface NotifyRule {
214
+ id: string
215
+ userId: string
216
+ pattern: string
217
+ domain: string
218
+ entityId: string | null
219
+ action: string | null
220
+ mode: NotifyRuleMode
221
+ channelOverrides: string | null
222
+ createdAt: string
223
+ }
224
+
225
+ /** The thread-level mute (TODO.notify/00: the "unwatch this thread" of
226
+ * the model — the entity-page bell's Muted state and the email
227
+ * footer's one-click unsubscribe both set it). Wins over every
228
+ * candidate class, subscriptions included; the access is unchanged. */
229
+ export interface NotifyEntityMute {
230
+ id: string
231
+ userId: string
232
+ domain: string
233
+ entityId: string
234
+ createdAt: string
235
+ }
236
+
237
+ /** The per-category email posture (TODO.notify/00's channel split): the
238
+ * inbox always carries the event; 'immediate' emails at once, 'digest'
239
+ * holds for the daily digest, 'off' stays in-app. */
240
+ export type NotifyChannelPreference = 'immediate' | 'digest' | 'off'
241
+
242
+ /** The user's preferences row: `channels` is the JSON map
243
+ * { "<domain>": "immediate"|"digest"|"off" } over the catalog's
244
+ * domains; a domain ABSENT falls back to the catalog row's own email
245
+ * default. One row per user, written at first preference write. */
246
+ export interface NotifyPreferences {
247
+ userId: string
248
+ channels: string
249
+ updatedAt: string
250
+ }
251
+
252
+ // ── the inbox state (TODO.notify/03) ─────────────────────────────────
253
+
254
+ /** The per-user per-event inbox marker (TODO.notify/00's "the inbox
255
+ * state (D1): per-user per-event state (read / done / saved) written
256
+ * lazily at read"): one row per (user, event), created at the first act
257
+ * on the inbox row. `readAt` stamps the mark-read (NULL = unread);
258
+ * `doneAt` stamps the archive (NULL = in the inbox — a done row leaves
259
+ * the feed, the marker keeps the state). 'saved' joins with wave 05.
260
+ * The feed is COMPUTED at read (events × the user's rules × the
261
+ * visibility gate); this table is the state that computation joins. A
262
+ * marker on a wiped event never joins (the user's state is their own,
263
+ * never the workflow's — the subscriptions store's posture). */
264
+ export interface NotifyInboxState {
265
+ userId: string
266
+ eventId: string
267
+ readAt: string | null
268
+ doneAt: string | null
269
+ createdAt: string
270
+ }
271
+
272
+ // ── the OIDC Provider (TODO.identity/01) ─────────────────────────────
273
+
274
+ /** A registered relying party (an instance allowed to request tokens).
275
+ * secretHash NULL = a public client (PKCE carries the proof);
276
+ * claimsPolicy is the parsed claims-policy JSON — `claims` names which
277
+ * claims the ID token carries for this client, and the OPTIONAL
278
+ * `roles` allowlist (TODO.identity/03) bounds WHICH roles those claims
279
+ * may carry: the OP never emits a role the client is not configured
280
+ * to receive (absent = the policy does not bound the role set). */
281
+ export interface OidcClient {
282
+ clientId: string
283
+ name: string
284
+ secretHash: string | null
285
+ /** The exact redirect URIs (string equality — no pattern matching). */
286
+ redirectUris: string[]
287
+ /** Parsed claims policy (null = profile+email claims only — no role
288
+ * claims leave the OP). */
289
+ claimsPolicy: OidcClientClaimsPolicy | null
290
+ /** The SSO-home launch metadata (null = the client never appears on
291
+ * the launcher). Managed through setOidcClientLaunch — the registry
292
+ * upsert never touches it, so a re-seed keeps the admin's edits. */
293
+ launch: OidcClientLaunch | null
294
+ status: 'active' | 'disabled'
295
+ createdAt: string
296
+ createdBy: string | null
297
+ }
298
+
299
+ /** The client registry's claims policy (TODO.identity/01 + /03). */
300
+ export interface OidcClientClaimsPolicy {
301
+ /** The claims the ID token carries for this client (roles, groups,
302
+ * org — profile+email ride the scopes). */
303
+ claims: string[]
304
+ /** OPTIONAL (TODO.identity/03): the closed allowlist of roles the
305
+ * role claims may carry for this client. ABSENT = no policy bound
306
+ * (the assignment set carries as-is). */
307
+ roles?: string[]
308
+ }
309
+
310
+ /** The client registry's launch metadata (the SSO home, the post-login
311
+ * launcher on the identity service): how a signed-in account meets the
312
+ * service. A client with NO launch row (launch_url NULL) never appears
313
+ * on the launcher — the machine-only clients stay off it. */
314
+ export interface OidcClientLaunch {
315
+ /** The service's sign-in start (an absolute http(s) URL): the card
316
+ * launches it, the live OP session lets the user straight in. */
317
+ url: string
318
+ /** The card's glyph: a name from the identity service's small icon
319
+ * set (null = the default launch glyph). */
320
+ icon: string | null
321
+ /** The card's one-line description (null = the name alone carries it). */
322
+ description: string | null
323
+ /** The visibility rule for an account the computed role set does NOT
324
+ * admit: 'roles' hides the card (the default), 'request' shows it
325
+ * with a plain request-access state, 'open' never gates (every
326
+ * signed-in account may launch — the service admits them all). */
327
+ visibility: 'roles' | 'request' | 'open'
328
+ }
329
+
330
+ /** A pending authorization between /op/authorize's validation and the
331
+ * consent decision. */
332
+ export interface OidcAuthorization {
333
+ id: string
334
+ clientId: string
335
+ redirectUri: string
336
+ scope: string
337
+ state: string
338
+ nonce: string | null
339
+ codeChallenge: string
340
+ userId: string | null
341
+ decision: 'allow' | 'deny' | null
342
+ createdAt: string
343
+ expiresAt: string
344
+ }
345
+
346
+ /** The one-time authorization code (consumed atomically at the token
347
+ * endpoint). */
348
+ export interface OidcCode {
349
+ code: string
350
+ clientId: string
351
+ redirectUri: string
352
+ scope: string
353
+ nonce: string | null
354
+ codeChallenge: string
355
+ userId: string
356
+ /** TODO.identity/11 — the ACTIVE-ORG CONTEXT the consent decision was
357
+ * made under (the session's stamped context at decide time; NULL =
358
+ * the account's primary context). The token endpoint re-judges it
359
+ * against the LIVE membership (a membership disabled mid-flow falls
360
+ * back to the primary context, never emits a dead org's claims). */
361
+ contextOrg: string | null
362
+ /** TODO.identity-sso/02+03: the consenting session's amr provenance
363
+ * (parsed from the row's JSON; null = none recorded). */
364
+ amr: string[] | null
365
+ expiresAt: string
366
+ }
367
+
368
+ /** An issued access token (the userinfo endpoint resolves it). */
369
+ export interface OidcAccessToken {
370
+ token: string
371
+ userId: string
372
+ clientId: string
373
+ scope: string
374
+ /** The context the granting code carried (userinfo answers the SAME
375
+ * claims the ID token did). */
376
+ contextOrg: string | null
377
+ /** TODO.identity-sso/02+03: the authorizing authentication's amr
378
+ * provenance — userinfo answers the same truth the ID token carried. */
379
+ amr: string[] | null
380
+ expiresAt: string
381
+ }
382
+
383
+ /** The OP key rotation history — the PUBLIC half only. */
384
+ export interface OidcKeyRow {
385
+ kid: string
386
+ publicJwk: string
387
+ status: 'active' | 'retired'
388
+ createdAt: string
389
+ retiredAt: string | null
390
+ }
391
+
392
+ // ── the upstream providers (TODO.identity/08) ────────────────────────
393
+
394
+ /** An upstream identity provider the OP links + accepts (a registry
395
+ * row — adding a provider is never a code fork). kind 'github' runs
396
+ * the OAuth web flow; kind 'oidc' runs discovery + code + PKCE against
397
+ * the issuer (Google/Entra/Apple/generic — Apple's documented quirks
398
+ * key on the issuer host, auth/upstream/registry.ts). The client
399
+ * SECRET is never stored: clientSecretRef names an env variable
400
+ * ('env:<NAME>'). */
401
+ export interface IdentityProvider {
402
+ id: string
403
+ kind: 'github' | 'oidc'
404
+ displayName: string
405
+ /** The brand-mark key the login/account pages map to an icon
406
+ * (github | google | apple | microsoft | oidc; null = generic). */
407
+ brandMark: string | null
408
+ /** The OIDC issuer URL (kind 'oidc'; NULL for github — its endpoints
409
+ * ride the GITHUB_*_BASE_URL env seam, auth/github.ts). */
410
+ issuer: string | null
411
+ clientId: string
412
+ /** 'env:<NAME>' — resolved per request, never stored resolved. */
413
+ clientSecretRef: string | null
414
+ /** The scope override (NULL = the kind's default: github 'read:user
415
+ * user:email'; oidc 'openid profile email'; Apple 'openid name
416
+ * email'). */
417
+ scopes: string | null
418
+ enabled: boolean
419
+ createdAt: string
420
+ createdBy: string | null
421
+ updatedAt: string | null
422
+ }
423
+
424
+ /** A linked upstream identity (TODO.identity/02's table shape, landed
425
+ * additively with 08's flows): THE match rule for an upstream sign-in
426
+ * — resolve by (provider, providerAccountId), NEVER by email alone. */
427
+ export interface IdentityLink {
428
+ id: string
429
+ userId: string
430
+ /** The identity_providers row id. */
431
+ provider: string
432
+ providerAccountId: string
433
+ linkedAt: string
434
+ /** Who performed the link (the account holder's email, or the admin's). */
435
+ linkedBy: string | null
436
+ }
437
+
438
+ // ── the OP's account model (TODO.identity/02) ────────────────────────
439
+
440
+ /** The invite-only enrollment link's row. One-time (consumed_at flips
441
+ * atomically at completion), 24 h TTL. */
442
+ export interface EnrollmentToken {
443
+ token: string
444
+ userId: string
445
+ createdBy: string | null
446
+ createdAt: string
447
+ expiresAt: string
448
+ consumedAt: string | null
449
+ }
450
+
451
+ /** The account page's session row — NEVER the token itself. `current`
452
+ * marks the row the presenting cookie resolves to (computed in SQL, so
453
+ * the token never leaves the store). TODO.identity/06 adds the sign-in
454
+ * context (user agent / IP at creation, last-active on resolution);
455
+ * NULLs render as "not recorded" on older rows. */
456
+ export interface SessionView {
457
+ id: string
458
+ createdAt: string
459
+ expiresAt: string
460
+ lastSeenAt: string | null
461
+ userAgent: string | null
462
+ ip: string | null
463
+ current: boolean
464
+ }
465
+
466
+ /** The aggregate live-session row (TODO.identity-sso/01 — the admin
467
+ * dashboard's "who is signed in NOW"): a SessionView plus the account
468
+ * it belongs to. The SessionView rule stands: NEVER the token itself;
469
+ * `current` marks the presenting administrator's own row (computed in
470
+ * SQL, so the token never leaves the store). */
471
+ export interface OpLiveSession extends SessionView {
472
+ userId: string
473
+ }
474
+
475
+ /** The enrollment completion's honest outcomes. */
476
+ export type CompleteEnrollmentResult =
477
+ | { kind: 'ok'; userId: string }
478
+ /** Never existed or already consumed (one-time means one-time — the
479
+ * two classes are deliberately indistinguishable). */
480
+ | { kind: 'unknown' }
481
+ /** Past the TTL: burned on presentation, never redeemable later. */
482
+ | { kind: 'expired' }
483
+
484
+ // ── the central user registry (TODO.identity/03) ─────────────────────
485
+
486
+ /** A PER-CLIENT role assignment (the op_client_roles row): the roles
487
+ * the account holds on ONE relying party. An empty `roles` is the
488
+ * explicit "no roles on this client" — distinct from NO ROW, which
489
+ * leaves the account's OP-side role set as that client's default. */
490
+ export interface OpClientRoleAssignment {
491
+ userId: string
492
+ clientId: string
493
+ roles: string[]
494
+ assignedBy: string | null
495
+ createdAt: string
496
+ updatedAt: string | null
497
+ }
498
+
499
+ /** The erasure act's removal counts (the audit event's metadata):
500
+ * revokeOpUserCredentials' four plus the links, the per-client
501
+ * assignments, the org memberships (TODO.identity/11), and the
502
+ * credential/token rows (passwords, enrollment tokens, email-change
503
+ * tokens). TODO.identity-sso/02+03 adds `factors`: the factor-registry
504
+ * rows removed (passkeys, TOTP secrets, recovery codes, and the
505
+ * account's pending ceremony state). */
506
+ export interface OpAccountErasure {
507
+ sessions: number
508
+ accessTokens: number
509
+ codes: number
510
+ authorizations: number
511
+ links: number
512
+ clientRoles: number
513
+ memberships: number
514
+ tokens: number
515
+ factors: number
516
+ }
517
+
518
+ // ── the account console (TODO.identity/06) ───────────────────────────
519
+
520
+ /** The verify-new-email ceremony's row (the enrollment link's doctrine:
521
+ * a 256-bit random token backed by the D1 row; one-time, 24 h).
522
+ * deliveredBy records the channel the link traveled: 'mailer' (sent to
523
+ * the NEW address; completing verifies it) or 'shown' (no mailer
524
+ * configured, the link was displayed to the signed-in holder; the change
525
+ * applies but the address stays unverified, honestly). */
526
+ export interface EmailChangeToken {
527
+ token: string
528
+ userId: string
529
+ newEmail: string
530
+ deliveredBy: 'mailer' | 'shown'
531
+ createdAt: string
532
+ expiresAt: string
533
+ consumedAt: string | null
534
+ }
535
+
536
+ /** The email change completion's honest outcomes. */
537
+ export type CompleteEmailChangeResult =
538
+ | { kind: 'ok'; userId: string; newEmail: string; verified: boolean }
539
+ /** Never existed or already consumed (indistinguishable, the
540
+ * enrollment rule). */
541
+ | { kind: 'unknown' }
542
+ /** Past the TTL: burned on presentation, never redeemable later. */
543
+ | { kind: 'expired' }
544
+ /** Another account took the address between request and completion
545
+ * (the token is burned; the change must start over). */
546
+ | { kind: 'conflict' }
547
+
548
+ // ── strong authentication: the factor registry (TODO.identity-sso/02 + /03)
549
+
550
+ /** A registered passkey (the webauthn_credentials row). publicKeyCose is
551
+ * the COSE key bytes (base64url) exactly as the attestation carried
552
+ * them; aaguid + transports record what the browser DECLARED at
553
+ * registration (attestation is 'none' — the console's display hints,
554
+ * never proof). signCount is the authenticator's signature counter. */
555
+ export interface WebauthnCredential {
556
+ credentialId: string
557
+ userId: string
558
+ name: string
559
+ publicKeyCose: string
560
+ signCount: number
561
+ aaguid: string | null
562
+ transports: string[]
563
+ createdAt: string
564
+ lastUsedAt: string | null
565
+ lastIp: string | null
566
+ }
567
+
568
+ /** The counter advance's honest outcomes (the clone rule): the guarded
569
+ * UPDATE either lands ('ok'), refuses a REGRESSED counter ('regressed'
570
+ * — the audit event's signal), or names a credential that is not there
571
+ * ('unknown'). A (0 → 0) pair is a software authenticator that never
572
+ * counts and passes; a zeroed-or-behind count against a started one
573
+ * regresses. */
574
+ export type AdvanceCounterResult = 'ok' | 'regressed' | 'unknown'
575
+
576
+ /** A TOTP authenticator app's row (RFC 6238). verifiedAt NULL = the
577
+ * PENDING enrollment (activates on the first valid code only);
578
+ * failCount + lastFailureAt carry the enrollment verify's throttle
579
+ * (the six-digit window invites brute force). `secret` is the base32
580
+ * seed — the store answers it, the ROUTES never return it after the
581
+ * enrollment answer. */
582
+ export interface TotpSecret {
583
+ id: string
584
+ userId: string
585
+ name: string
586
+ secret: string
587
+ failCount: number
588
+ lastFailureAt: string | null
589
+ createdAt: string
590
+ verifiedAt: string | null
591
+ lastUsedAt: string | null
592
+ lastIp: string | null
593
+ }
594
+
595
+ /** The recovery-code set's console state (never a hash, never a code):
596
+ * the generation's size, the unconsumed remainder, and when the current
597
+ * batch was minted. */
598
+ export interface RecoveryCodeState {
599
+ total: number
600
+ remaining: number
601
+ createdAt: string | null
602
+ }
603
+
604
+ /** The one-time WebAuthn ceremony challenge (the database-is-the-proof
605
+ * doctrine): userId binds the registration + the second-factor
606
+ * assertion; the passwordless assertion's row carries null (the
607
+ * asserted credential id resolves the account). */
608
+ export interface WebauthnChallenge {
609
+ challenge: string
610
+ userId: string | null
611
+ kind: 'register' | 'assert'
612
+ createdAt: string
613
+ expiresAt: string
614
+ consumedAt: string | null
615
+ }
616
+
617
+ /** The pending second-factor sign-in: the password verified, the session
618
+ * waits on the factor. amr carries the methods proven so far (parsed
619
+ * JSON, e.g. ['pwd']); failCount + lastFailureAt ride the per-account
620
+ * throttle ladder. */
621
+ export interface MfaPending {
622
+ token: string
623
+ userId: string
624
+ amr: string[]
625
+ failCount: number
626
+ lastFailureAt: string | null
627
+ createdAt: string
628
+ expiresAt: string
629
+ consumedAt: string | null
630
+ }
631
+
632
+
633
+ // ── organization administration (TODO.identity/10) ───────────────────
634
+
635
+ /** A self-service join request (the "Request an account" page). The
636
+ * queue routing is on the row: `orgId` set → the named ORG's admin
637
+ * decides (the org must be a REGISTERED participant org at submit and
638
+ * again at approval); `orgId` NULL + `orgNameText` → the "my
639
+ * organization is not listed" path — BIML's new-organizations queue
640
+ * (BIML verifies the participation, then the requester becomes the
641
+ * org's administrator). The decision is atomic on 'pending'; approval
642
+ * records the invited account in `invitedUserId`. */
643
+ export interface OrgJoinRequest {
644
+ id: string
645
+ name: string
646
+ email: string
647
+ /** The selected registry org (NULL = the not-listed path). */
648
+ orgId: string | null
649
+ /** The free-text organization name (the not-listed path only). */
650
+ orgNameText: string | null
651
+ /** The role asked for — bounded by the org's kind (the submit route
652
+ * and the approval both validate; 'org_admin' on the not-listed path). */
653
+ requestedRole: string
654
+ note: string | null
655
+ status: 'pending' | 'approved' | 'refused'
656
+ decidedBy: string | null
657
+ decidedAt: string | null
658
+ refusalReason: string | null
659
+ invitedUserId: string | null
660
+ createdAt: string
661
+ }
662
+
663
+ // ── the multi-organization membership model (TODO.identity/11) ───────
664
+
665
+ /** The membership lifecycle: INVITED (the org's admin added the account,
666
+ * the holder has not accepted yet — the account does not act for the
667
+ * org) → ACTIVE (the account holds the org's context: the per-org role
668
+ * set applies) → DISABLED (the org's admin or the scheme operator
669
+ * suspended the membership; reversible — re-activation is a deliberate
670
+ * act, never an automatic one). */
671
+ export type OrgMembershipState = 'invited' | 'active' | 'disabled'
672
+
673
+ /** An account's membership in ONE organization (the org_memberships
674
+ * row): the per-org role set + the lifecycle state. The PRIMARY
675
+ * membership (isPrimary) is the backward-compatible one: the users
676
+ * row's org_id/roles columns mirror it, so every consumer that still
677
+ * reads the legacy columns sees exactly the primary context (the
678
+ * dual-read doctrine — the columns stay until every consumer reads the
679
+ * memberships). */
680
+ export interface OrgMembership {
681
+ id: string
682
+ userId: string
683
+ orgId: string
684
+ /** The PER-ORG role set (JSON on the row): the roles the account holds
685
+ * when acting AS this org. */
686
+ roles: string[]
687
+ state: OrgMembershipState
688
+ isPrimary: boolean
689
+ invitedBy: string | null
690
+ createdAt: string
691
+ activatedAt: string | null
692
+ disabledAt: string | null
693
+ disabledBy: string | null
694
+ }
695
+
696
+ /** The effective org context: the org the account acts AS and the role
697
+ * set that context carries. The session payloads and the OP's token
698
+ * claims both resolve through resolveOrgContext, so the two never
699
+ * drift. */
700
+ export interface OrgContextResolution {
701
+ orgId: string | null
702
+ roles: string[]
703
+ }
704
+
705
+ /**
706
+ * THE CONTEXT RULE (TODO.identity/11 — the GitHub context-switch
707
+ * pattern, one pure function every reader shares). Given the account's
708
+ * legacy columns (role/roles/orgId), the session's stamped active org
709
+ * (NULL = the primary context), and the membership rows it names:
710
+ *
711
+ * - an ACTIVE-ORG context whose membership is ACTIVE resolves to that
712
+ * org + its per-org role set. An ORG-FREE account's account-level
713
+ * roles (the scheme's own staff: admin, cs_admin, …) ride EVERY
714
+ * context honestly; an org-bound account's legacy set is the
715
+ * primary membership's mirror and rides ONLY the primary context —
716
+ * a relying party never learns the other memberships.
717
+ * - a context whose membership is missing or no longer active (the
718
+ * org's admin disabled it mid-session) falls through to the primary
719
+ * rule — the stale context never emits a dead org's claims.
720
+ * - the PRIMARY context: no membership row at all answers the
721
+ * pre-memberships read (the dual-read fallback — a store predating
722
+ * the backfill behaves exactly as before); an ACTIVE primary carries
723
+ * the mirrored set (byte-identical with the legacy columns); an
724
+ * invited/disabled primary means the account does NOT act for the
725
+ * org — no org, no org roles.
726
+ */
727
+ export function resolveOrgContext(
728
+ user: { role: string; roles?: string[] | null; orgId: string | null },
729
+ context: { activeOrg: string | null; active: OrgMembership | null; primary: OrgMembership | null },
730
+ ): OrgContextResolution {
731
+ const accountRoles = user.roles?.length ? [...user.roles] : [user.role]
732
+ if (context.activeOrg) {
733
+ const m = context.active
734
+ if (m && m.orgId === context.activeOrg && m.state === 'active') {
735
+ const global = user.orgId ? [] : accountRoles
736
+ return { orgId: context.activeOrg, roles: [...new Set([...m.roles, ...global])] }
737
+ }
738
+ }
739
+ if (!user.orgId) return { orgId: null, roles: accountRoles }
740
+ const p = context.primary
741
+ if (!p) return { orgId: user.orgId, roles: accountRoles }
742
+ if (p.state !== 'active') return { orgId: null, roles: [] }
743
+ return { orgId: user.orgId, roles: [...new Set([...accountRoles, ...p.roles])] }
744
+ }
745
+
746
+ // ── the organization registry (TODO.identity-features/05) ────────────
747
+
748
+ /** The registry organization's lifecycle: ACTIVE (the membership graph
749
+ * admits it — memberships, assignments, and the join selector where it
750
+ * carries a participant kind) → DISABLED (the identity administrator's
751
+ * honest removal: the org's memberships disable, its members' per-org
752
+ * roles stop carrying; the row and the audit trail keep the history).
753
+ * The erasure-adjacent hard delete exists only for an org that never
754
+ * held a membership (the routes enforce it; the store removes rows). */
755
+ export type OrgRegistryState = 'active' | 'disabled'
756
+
757
+ /** One contact on the registry organization (the row's contacts JSON
758
+ * array; a malformed entry is skipped on read, never trusted). */
759
+ export interface OrgRegistryContact {
760
+ name: string | null
761
+ email: string
762
+ }
763
+
764
+ /** One organization on the identity service's OWN registry (the
765
+ * org_registry row) — the identity plane's membership graph as a
766
+ * first-class entity with its lifecycle (the identity administrator's
767
+ * add/edit/disable/remove).
768
+ *
769
+ * The id is the stable SLUG; for a participant organization the OIML
770
+ * code IS the id (TODO.identity-features/05 §4 — the platform resolves
771
+ * the org claim's value against its own participant registry directly:
772
+ * the same string on both sides, so the mapping is identity, never a
773
+ * lookup table). `kind` names the participant kind for a participant
774
+ * org (NULL = a non-participant org — the estate operator's own org, a
775
+ * scheme consumer); the program side bounds the assignable per-org
776
+ * roles by it. `participantRef` is the OPTIONAL annotation documenting
777
+ * which participant record the org mirrors (the link's documentation,
778
+ * never a key the store resolves). */
779
+ export interface OrgRegistryOrg {
780
+ id: string
781
+ /** The display name. */
782
+ name: string
783
+ shortName: string | null
784
+ /** The participant kind ('issuing-authority' | 'test-laboratory' |
785
+ * 'utilizer' | 'associate' on the OIML-CS program), NULL for a
786
+ * non-participant org. Opaque to the store. */
787
+ kind: string | null
788
+ country: string | null
789
+ contacts: OrgRegistryContact[]
790
+ participantRef: string | null
791
+ state: OrgRegistryState
792
+ createdAt: string
793
+ createdBy: string | null
794
+ updatedAt: string | null
795
+ updatedBy: string | null
796
+ disabledAt: string | null
797
+ disabledBy: string | null
798
+ }
799
+
800
+ // ── the register's holder-org attribution (TODO.register/02) ─────────
801
+
802
+ /** The hub-side attribution of a REGISTERED certificate to its holder
803
+ * organization — the OP-minted org id, never an instance-minted one.
804
+ * The hub's OWN row (the certificate_holder_orgs table): the registrar's
805
+ * act extracts the descriptor the federation registration package carried
806
+ * (source 'registration'), or the estate admin's claim confirmation writes
807
+ * it for a legacy row (source 'claim'). ONE row per certificate — the
808
+ * first attribution wins, a later writer never overwrites silently.
809
+ *
810
+ * The org display name is DENORMALIZED at attribution time: the register
811
+ * reads correctly even when the organization later renames (the
812
+ * register's permanence rule — the descriptor is the register's record,
813
+ * never a join against a live registry). */
814
+ export interface CertificateHolderOrg {
815
+ certificateId: string
816
+ orgId: string
817
+ orgName: string
818
+ source: 'registration' | 'claim'
819
+ attributedAt: string
820
+ /** The registrar / the confirming estate admin (the actor's name). */
821
+ attributedBy: string | null
822
+ /** The confirming claim (source 'claim' only). */
823
+ claimId: string | null
824
+ }
825
+
826
+ /** The legacy-row claim act's lifecycle: PENDING (the manufacturer org's
827
+ * administrator claimed the row by holder-name match — a claim is a
828
+ * claim until confirmed) → CONFIRMED (the estate admin's act; the
829
+ * attribution row lands) / REFUSED (terminal for THAT claim, with the
830
+ * written reason; a fresh claim may follow). */
831
+ export type CertificateHolderClaimState = 'pending' | 'confirmed' | 'refused'
832
+
833
+ export interface CertificateHolderClaim {
834
+ id: string
835
+ certificateId: string
836
+ claimantOrgId: string
837
+ /** The claiming org's display name at claim time (denormalized — the
838
+ * confirmed attribution's org_name comes from here). */
839
+ claimantOrgName: string
840
+ /** The certificate's free-text holder name the claim matched — the
841
+ * snapshot, the claim's evidence. */
842
+ matchedHolderName: string
843
+ /** The claiming account (the org admin's name). */
844
+ claimedBy: string
845
+ state: CertificateHolderClaimState
846
+ decidedBy: string | null
847
+ decidedAt: string | null
848
+ refusalReason: string | null
849
+ createdAt: string
850
+ }
851
+
852
+ // ── the instrument register (TODO.register/03) ──────────────────────
853
+
854
+ /** The registered instrument's lifecycle: REGISTERED (the declaration
855
+ * stood) ⇄ OUT_OF_SERVICE (the holder's mark — the instrument is out
856
+ * of service, the registration stands; a return to service re-marks
857
+ * it registered) → WITHDRAWN (terminal — the holder withdrew the
858
+ * instrument from the register; a withdrawn row never reopens). The
859
+ * route owns the transition rule; the store keeps the rows. */
860
+ export type InstrumentRegistrationLifecycle = 'registered' | 'out_of_service' | 'withdrawn'
861
+
862
+ /** The scope verdict recorded AT REGISTRATION (the executing-scope
863
+ * doctrine at the instrument level):
864
+ * - in_scope — the certificate's structured scope block (its
865
+ * classifications) covered the declared designations;
866
+ * - scope_unverified — the certificate carries NO structured scope
867
+ * block (the records-mode import's honest degradation): the check
868
+ * was unavailable, the registration is marked, and the issuing IA's
869
+ * oversight surface sees exactly this mark.
870
+ * The third verdict — refused — never lands a row: the route refuses
871
+ * the out-of-scope declaration with the reason. */
872
+ export type InstrumentRegistrationScopeStatus = 'in_scope' | 'scope_unverified'
873
+
874
+ /** One registered instrument (the instrument_registrations row): the
875
+ * serial number riding under a type certificate. The certificate's
876
+ * holder organization is referenced by id WITHOUT a foreign key (the
877
+ * identity plane and this platform-side register never merge — the
878
+ * org_memberships posture); a registration row's honesty never depends
879
+ * on a join. */
880
+ export interface InstrumentRegistration {
881
+ id: string
882
+ /** The certificate the serial rides under (the entity store's
883
+ * certificates row id). */
884
+ certificateId: string
885
+ /** The holder organization (the manufacturer org id). */
886
+ holderOrgId: string
887
+ /** The Recommendation the certificate belongs to. */
888
+ standardId: string
889
+ serialNumber: string
890
+ /** The ISO manufacture date, null when the declaration omitted it. */
891
+ manufactureDate: string | null
892
+ /** The per-serial designations the scope check evaluated (the JSON
893
+ * object; a malformed cell reads as the empty object, never
894
+ * trusted). */
895
+ designations: Record<string, unknown>
896
+ scopeStatus: InstrumentRegistrationScopeStatus
897
+ /** The verdict's record: the matched classification label
898
+ * (in_scope) or the unverified note (scope_unverified). */
899
+ scopeDetail: string | null
900
+ lifecycle: InstrumentRegistrationLifecycle
901
+ registeredAt: string
902
+ registeredBy: string | null
903
+ updatedAt: string | null
904
+ updatedBy: string | null
905
+ }
906
+
907
+ /** Per-store org fields for the READ visibility (the multi-party
908
+ * model: a row is visible when ANY named field equals the user's
909
+ * org). The field names are the entities' REAL ones (verified against
910
+ * the data model, 2026-08-03 — a guessed name hides everything).
911
+ *
912
+ * The instrument CATALOG (families/groups/models/samples) is NOT here
913
+ * on purpose: it is shared reference data — the IA evaluates against
914
+ * the model, the TL tests the sample (the 2026-08-03 zero-verdicts
915
+ * diagnosis: manufacturer-only catalog reads left the IA's verdict
916
+ * engine with no subject chain). Catalog WRITES are gated in
917
+ * writeAllowed. */
918
+ export const ORG_FIELDS: Record<string, string[]> = {
919
+ applications: ['manufacturer_id', 'issuing_authority_id'],
920
+ testRequests: ['requesting_authority_id', 'assigned_laboratory_id'],
921
+ testReports: ['laboratory_id'],
922
+ testAssignments: ['laboratory_id'],
923
+ testRuns: ['laboratory_id'],
924
+ evaluationReports: ['authority_id'],
925
+ modelEvaluations: ['laboratory_id'],
926
+ certificates: ['issuing_authority_id', 'model_family_id'],
927
+ acceptanceReviews: ['participant_id'],
928
+ // TODO.federation/02 — the engagement module, generalized by
929
+ // TODO.adoption/07 into the negotiation primitive: the two parties of
930
+ // the pair see the negotiation and its quotation/agreement — the
931
+ // applicant org and the IA on ia_applicant, the IA and the laboratory
932
+ // on ia_tl, the applicant org and the laboratory on tl_applicant; on
933
+ // the test-request quote leg the quotation carries the dispatching IA
934
+ // and the quoting laboratory (the Quotation/ConsultingAgreement
935
+ // records denormalize the party ids from their parent — the org fields
936
+ // are the entities' REAL fields, no parent-resolution leg).
937
+ engagements: ['manufacturer_id', 'issuing_authority_id', 'test_laboratory_id'],
938
+ quotations: ['manufacturer_id', 'issuing_authority_id', 'test_laboratory_id'],
939
+ consultingAgreements: ['manufacturer_id', 'issuing_authority_id', 'test_laboratory_id'],
940
+ // TODO.adoption/09 — the payment records: the two parties of the
941
+ // invoice pair both see (and upload evidence to) the record; the hub's
942
+ // platform roles (not org-bound) see the whole store. Never public.
943
+ paymentRecords: ['payer_id', 'payee_id'],
944
+ }
945
+
946
+ /** The instrument catalog stores (shared reference data on read;
947
+ * org-gated on write). */
948
+ export const CATALOG_STORES = new Set([
949
+ 'measuringInstrumentModelFamilies',
950
+ 'measuringInstrumentModelGroups',
951
+ 'measuringInstrumentModels',
952
+ 'measuringInstrumentSamples',
953
+ ])
954
+
955
+ /** The org a row is indexed under (the first declared org field's
956
+ * value; null when the entity declares none — shared reference data). */
957
+ export function orgIdOf(store: string, data: unknown): string | null {
958
+ const fields = ORG_FIELDS[store] ?? []
959
+ const rec = data as Record<string, unknown>
960
+ for (const f of fields) {
961
+ if (typeof rec[f] === 'string' && rec[f]) return rec[f] as string
962
+ }
963
+ return null
964
+ }
965
+
966
+ // Role model per TODO.new-paradigm/01: applicant | ia_officer | tl_operator |
967
+ // cs_admin, plus the pre-existing admin/viewer accounts. org_id links the user
968
+ // to their organization record in the browser-side entity graph:
969
+ // applicant → manufacturer id (sample data: mfr-acme, the ACME fictional manufacturer)
970
+ // ia_officer → issuing-authority oiml_code (EX1; XX1 = the pre-signature
971
+ // demo IA of the task-44 participant registry — its Declaration
972
+ // is unsigned, so the PD-08 cl. 5 issuance gate blocks it)
973
+ // tl_operator → test-laboratory oiml_id (21 = the example TL)
974
+ // mc_member / rc_member / executive_secretary → the OIML-CS organ roles of
975
+ // TODO.roadmap/44 (approval pipeline + participant registry)
976
+ // cs_admin/admin/viewer → null (no org)
977
+ export const DEMO_ACCOUNTS = [
978
+ { email: 'admin@oiml.org', name: 'OIML Admin', role: 'admin', orgId: null as string | null },
979
+ // TODO.register/02 — the ACME applicant also holds its org's org_admin
980
+ // (the OP-side manufacturer-org role, simulated on the demo cast until
981
+ // the identity wave lands it): the register's legacy-row claim act is
982
+ // the org administrator's. `roles` is the optional full role set (the
983
+ // users.roles column; absent = the primary role only).
984
+ { email: 'applicant@oiml.org', name: 'ACME Applicant', role: 'applicant', orgId: 'mfr-acme' as string | null, roles: ['applicant', 'org_admin'] },
985
+ { email: 'ia@oiml.org', name: 'IA Officer', role: 'ia_officer', orgId: 'EX1' as string | null },
986
+ { email: 'ia2@oiml.org', name: 'IA Officer (XX1)', role: 'ia_officer', orgId: 'XX1' as string | null },
987
+ { email: 'tl@oiml.org', name: 'TL Operator', role: 'tl_operator', orgId: '21' as string | null },
988
+ // The test operators hold their OWN accounts (the demonstration cast,
989
+ // docs/demo-personas.md): every run, evidence sign-off and report
990
+ // attributes to a person, never to a shared laboratory login.
991
+ { email: 'petra.horvat@etl.example.org', name: 'Ms. Petra Horvat', role: 'tl_operator', orgId: '21' as string | null },
992
+ { email: 'martin.berger@etl.example.org', name: 'Mr. Martin Berger', role: 'tl_operator', orgId: '21' as string | null },
993
+ { email: 'biml@oiml.org', name: 'BIML Officer', role: 'biml_officer', orgId: null as string | null },
994
+ { email: 'cs@oiml.org', name: 'CS Administrator', role: 'cs_admin', orgId: null as string | null },
995
+ { email: 'mc@oiml.org', name: 'MC Member', role: 'mc_member', orgId: null as string | null },
996
+ { email: 'rc@oiml.org', name: 'RC Member', role: 'rc_member', orgId: null as string | null },
997
+ { email: 'secretariat@oiml.org', name: 'Executive Secretary', role: 'executive_secretary', orgId: null as string | null },
998
+ // TODO.adoption/11 — the Utilizer's staff member (scheme_participant):
999
+ // declares Additional National Requirements for their country on the ANR
1000
+ // registry console; the declaration records the participant it acts for
1001
+ // (the CS registry's approval is the moderation gate).
1002
+ // TODO.adoption/10 — the ORG BINDING (ut-nmi-nl, the seeded NL Utilizer)
1003
+ // is the account's link into the participants register: the register's
1004
+ // participant depth resolves from it (the role alone never upgrades a
1005
+ // viewer — the org-registry's bounds keep the link assignable).
1006
+ { email: 'utilizer@oiml.org', name: 'Utilizer Officer (NL)', role: 'scheme_participant', orgId: 'ut-nmi-nl' as string | null },
1007
+ { email: 'viewer@oiml.org', name: 'Viewer', role: 'viewer', orgId: null as string | null },
1008
+ { email: 'developer@ribose.com', name: 'Ribose Developer', role: 'admin', orgId: null as string | null },
1009
+ ]
1010
+
1011
+ export const DEMO_PASSWORD = 'demo2026'
1012
+
1013
+ /** The async store contract the routes consume. Every method mirrors a
1014
+ * sync counterpart in store.ts / entities.ts — same SQL, same
1015
+ * semantics, awaited. */
1016
+ export interface ServerStore {
1017
+ // ── users / sessions (schema.sql's auth half) ──
1018
+ seedDemoAccounts(): Promise<void>
1019
+ authenticateDemo(email: string, password: string): Promise<AuthUserPayload | null>
1020
+ findOrCreateOAuthUser(
1021
+ provider: string,
1022
+ providerAccountId: string,
1023
+ email: string,
1024
+ name: string,
1025
+ avatarUrl?: string,
1026
+ /** The INITIAL role/org (OAuthInitialAssignment) — create-time only. */
1027
+ initial?: OAuthInitialAssignment,
1028
+ ): Promise<AuthUserPayload>
1029
+ /** TODO.identity/06: the sign-in context (the account console's
1030
+ * sessions section): the user agent + the client IP, stamped at
1031
+ * creation (server/auth/client-info.ts). TODO.identity-sso/02+03:
1032
+ * `amr` records the sign-in's provenance (the RFC 8176 list the ID
1033
+ * token later carries); absent = no OP-side credential event. */
1034
+ createSession(
1035
+ userId: string,
1036
+ opts?: { idTokenHint?: string | null; userAgent?: string | null; ip?: string | null; amr?: string[] | null },
1037
+ ): Promise<string>
1038
+ /** Stamp the account's last sign-in (TODO.identity/07 — the registry's
1039
+ * last-sign-in column). The OP's own sign-in paths call this on a
1040
+ * completed sign-in; the demo/OAuth paths bump it inline already. */
1041
+ touchLastLogin(userId: string): Promise<void>
1042
+ getSessionUser(token: string): Promise<AuthUserPayload | null>
1043
+ /** The SSO logout hint (TODO.federation/10): the id_token of the SSO
1044
+ * sign-in this session came from, for RP-initiated logout — NEVER
1045
+ * part of AuthUserPayload (the client never sees it). */
1046
+ getSessionIdTokenHint(token: string): Promise<string | null>
1047
+ deleteSession(token: string): Promise<void>
1048
+ cleanExpiredSessions(): Promise<void>
1049
+ listDemoAccounts(): Promise<Array<{ email: string; name: string; role: string }>>
1050
+
1051
+ // ── identity federation (TODO.federation/10) ──
1052
+ findUserByEmail(email: string): Promise<AuthUserPayload | null>
1053
+ /** The account by its id (TODO.identity/01 — the OP's token endpoint
1054
+ * resolves the code's user_id). */
1055
+ getUserById(id: string): Promise<AuthUserPayload | null>
1056
+ findUserByProvider(provider: string, providerAccountId: string): Promise<AuthUserPayload | null>
1057
+ /** Provision a NEW account from a validated SSO sign-in (the claim
1058
+ * mapping's mapped/default outcome). */
1059
+ provisionSsoUser(input: {
1060
+ email: string
1061
+ name: string
1062
+ provider: string
1063
+ providerAccountId: string
1064
+ role: string
1065
+ orgId: string | null
1066
+ }): Promise<AuthUserPayload>
1067
+ /** Link an SSO identity to an EXISTING account (verified-email
1068
+ * linking): the row takes the provider pair; its role/org stay as
1069
+ * the local assignment. */
1070
+ linkProviderIdentity(userId: string, provider: string, providerAccountId: string): Promise<void>
1071
+ /** The approval decision's write: the account's role (+ org). */
1072
+ updateUserRoleOrg(userId: string, role: string, orgId: string | null): Promise<void>
1073
+ /** Record/refresh the pending-approval row for (issuer, sub). */
1074
+ upsertIdentityApproval(input: {
1075
+ email: string
1076
+ name: string
1077
+ issuer: string
1078
+ sub: string
1079
+ claimsJson: string | null
1080
+ }): Promise<IdentityApproval>
1081
+ getIdentityApproval(issuer: string, sub: string): Promise<IdentityApproval | null>
1082
+ listIdentityApprovals(status?: IdentityApproval['status']): Promise<IdentityApproval[]>
1083
+ decideIdentityApproval(
1084
+ id: string,
1085
+ decision: { status: 'approved' | 'rejected'; role?: string; orgId?: string | null; decidedBy: string },
1086
+ ): Promise<IdentityApproval | null>
1087
+
1088
+ // ── the SSO sign-in state jar (TODO.identity/04) ──
1089
+ /** Store one OIDC sign-in attempt's one-time state (the nonce + the
1090
+ * PKCE verifier). STORE-BACKED so every Worker isolate sees it — the
1091
+ * per-process Map it replaces could lose the callback's state check
1092
+ * to a sibling isolate. */
1093
+ putSsoState(input: { state: string; nonce: string; verifier: string; ttlMs: number }): Promise<void>
1094
+ /** Atomically consume the state: answers the row exactly once (a
1095
+ * replay loses the consumed_at race), and an EXPIRED row is consumed
1096
+ * too — never a second chance. */
1097
+ consumeSsoState(state: string): Promise<SsoSignInState | null>
1098
+
1099
+ // ── federation peers (TODO.federation/04) ──
1100
+ listFederationPeers(status?: FederationPeer['status']): Promise<FederationPeer[]>
1101
+ getFederationPeer(id: string): Promise<FederationPeer | null>
1102
+ /** Add + refresh share this write (the pin path re-validates before
1103
+ * calling); refreshedAt stamps on update. */
1104
+ upsertFederationPeer(input: {
1105
+ id: string
1106
+ name: string
1107
+ roles: string
1108
+ descriptorUrl: string | null
1109
+ descriptorJson: string
1110
+ pinnedVia: FederationPeer['pinnedVia']
1111
+ connectivity: FederationPeer['connectivity']
1112
+ addedBy: string | null
1113
+ }): Promise<FederationPeer>
1114
+ revokeFederationPeer(id: string, revokedBy: string): Promise<FederationPeer | null>
1115
+
1116
+ // ── user administration (TODO.federation/12 — multi-user instances) ──
1117
+ listUsers(): Promise<UserAdminRow[]>
1118
+ createLocalUser(input: {
1119
+ email: string
1120
+ name: string
1121
+ role: string
1122
+ roles?: string[]
1123
+ orgId?: string | null
1124
+ }): Promise<UserAdminRow>
1125
+ setUserRoles(id: string, role: string, roles: string[]): Promise<boolean>
1126
+ setUserActive(id: string, active: boolean): Promise<boolean>
1127
+
1128
+ // ── the OIDC Provider (TODO.identity/01) ──
1129
+ /** The client registry (admin-managed; the bootstrap seed upserts). */
1130
+ getOidcClient(clientId: string): Promise<OidcClient | null>
1131
+ listOidcClients(): Promise<OidcClient[]>
1132
+ upsertOidcClient(input: {
1133
+ clientId: string
1134
+ name: string
1135
+ secretHash: string | null
1136
+ redirectUris: string[]
1137
+ claimsPolicy: OidcClientClaimsPolicy | null
1138
+ createdBy?: string | null
1139
+ }): Promise<OidcClient>
1140
+ setOidcClientStatus(clientId: string, status: OidcClient['status']): Promise<OidcClient | null>
1141
+ /** The SSO-home launch metadata write (the registry API + the
1142
+ * bootstrap seed): set the card, or null to take the client off the
1143
+ * launcher. Answers null when the client does not exist. */
1144
+ setOidcClientLaunch(clientId: string, launch: OidcClientLaunch | null): Promise<OidcClient | null>
1145
+ /** The pending authorizations (consent round trip). */
1146
+ createOidcAuthorization(input: {
1147
+ id: string
1148
+ clientId: string
1149
+ redirectUri: string
1150
+ scope: string
1151
+ state: string
1152
+ nonce: string | null
1153
+ codeChallenge: string
1154
+ userId: string | null
1155
+ ttlMs: number
1156
+ }): Promise<OidcAuthorization>
1157
+ getOidcAuthorization(id: string): Promise<OidcAuthorization | null>
1158
+ /** The consent decision: binds the row's OWN account (userId must
1159
+ * equal the row's stamped user) and flips the decision atomically —
1160
+ * a pending row that already carries a decision, or belongs to a
1161
+ * different account, answers null (the double-submit / cross-account
1162
+ * case fails honestly). */
1163
+ decideOidcAuthorization(
1164
+ id: string,
1165
+ decision: { userId: string; decision: 'allow' | 'deny' },
1166
+ ): Promise<OidcAuthorization | null>
1167
+ /** The one-time codes. */
1168
+ createOidcCode(input: {
1169
+ code: string
1170
+ clientId: string
1171
+ redirectUri: string
1172
+ scope: string
1173
+ nonce: string | null
1174
+ codeChallenge: string
1175
+ userId: string
1176
+ /** TODO.identity/11: the session's stamped active-org context at the
1177
+ * consent decision (NULL = the primary context). */
1178
+ contextOrg?: string | null
1179
+ /** TODO.identity-sso/02+03: the consenting session's amr provenance
1180
+ * (stored as JSON; the token endpoint emits it as the ID token's
1181
+ * amr). Absent = no provenance recorded. */
1182
+ amr?: string[] | null
1183
+ ttlMs: number
1184
+ }): Promise<void>
1185
+ /** Atomically consume the code: answers the row exactly once (a
1186
+ * replay/concurrent double-exchange loses the consumed_at race and
1187
+ * gets null → invalid_grant). An EXPIRED code also answers null. */
1188
+ consumeOidcCode(code: string): Promise<OidcCode | null>
1189
+ /** The access tokens (userinfo). */
1190
+ createOidcAccessToken(input: {
1191
+ token: string
1192
+ userId: string
1193
+ clientId: string
1194
+ scope: string
1195
+ /** The granting code's context (userinfo answers the ID token's
1196
+ * claims). */
1197
+ contextOrg?: string | null
1198
+ /** TODO.identity-sso/02+03: the authorizing authentication's amr —
1199
+ * userinfo answers the same truth the ID token carried. */
1200
+ amr?: string[] | null
1201
+ ttlMs: number
1202
+ }): Promise<void>
1203
+ getOidcAccessToken(token: string): Promise<OidcAccessToken | null>
1204
+ /** The key rotation history (public halves). */
1205
+ listOidcKeys(): Promise<OidcKeyRow[]>
1206
+ upsertOidcKey(input: { kid: string; publicJwk: string }): Promise<void>
1207
+ retireOidcKey(kid: string): Promise<void>
1208
+
1209
+ // ── the upstream providers (TODO.identity/08) ──
1210
+ /** The upstream registry (admin-managed; OP_UPSTREAM_SEED bootstraps).
1211
+ * Secrets are NEVER in these rows — clientSecretRef is an env name. */
1212
+ listIdentityProviders(): Promise<IdentityProvider[]>
1213
+ getIdentityProvider(id: string): Promise<IdentityProvider | null>
1214
+ upsertIdentityProvider(input: {
1215
+ id: string
1216
+ kind: IdentityProvider['kind']
1217
+ displayName: string
1218
+ brandMark?: string | null
1219
+ issuer?: string | null
1220
+ clientId: string
1221
+ clientSecretRef?: string | null
1222
+ scopes?: string | null
1223
+ enabled?: boolean
1224
+ createdBy?: string | null
1225
+ }): Promise<IdentityProvider>
1226
+ setIdentityProviderEnabled(id: string, enabled: boolean): Promise<IdentityProvider | null>
1227
+ deleteIdentityProvider(id: string): Promise<boolean>
1228
+
1229
+ // ── the linked identities (TODO.identity/02's shape, 08's flows) ──
1230
+ /** The account's linked upstream identities (the account surface). */
1231
+ listIdentityLinks(userId: string): Promise<IdentityLink[]>
1232
+ /** THE match rule's read: resolve (provider, providerAccountId) → the
1233
+ * link (and thereby the account). NEVER match by email alone. */
1234
+ findIdentityLink(provider: string, providerAccountId: string): Promise<IdentityLink | null>
1235
+ /** Create the link; answers NULL when (provider, providerAccountId)
1236
+ * is already linked (to any account — the UNIQUE constraint), the
1237
+ * honest conflict the route maps to a refusal. */
1238
+ createIdentityLink(input: {
1239
+ userId: string
1240
+ provider: string
1241
+ providerAccountId: string
1242
+ linkedBy?: string | null
1243
+ }): Promise<IdentityLink | null>
1244
+ /** Remove the account's link for a provider (the unlink action). */
1245
+ deleteIdentityLink(userId: string, provider: string): Promise<boolean>
1246
+
1247
+ // ── the OP's account model (TODO.identity/02) ──
1248
+ /** Create an OP password account (provider 'password', email
1249
+ * normalized lowercase). Answers null when the email is taken (the
1250
+ * invite route's 409; the UNIQUE constraint is the backstop). */
1251
+ createOpAccount(input: {
1252
+ email: string
1253
+ name: string
1254
+ role: string
1255
+ createdBy?: string | null
1256
+ }): Promise<UserAdminRow | null>
1257
+ /** The password sign-in's lookup: the credential + the account's
1258
+ * active flag by email (normalized). Null = no such credential — the
1259
+ * route still runs one full-cost verify (the timing-shape rule,
1260
+ * auth/passwords.ts). The hash never leaves the server. */
1261
+ getPasswordLogin(email: string): Promise<{ userId: string; hash: string; active: boolean } | null>
1262
+ /** Set/replace the account's password credential (enrollment
1263
+ * completion, the account page's change). */
1264
+ setPasswordHash(userId: string, hash: string, setBy?: string | null): Promise<void>
1265
+ /** The sign-in methods the account holds (the account page's
1266
+ * password-set state + the admin list's posture). TODO.identity-sso/02:
1267
+ * `passkeys` counts the registered passkeys — a passkey is a PRIMARY
1268
+ * sign-in method (passwordless), so the at-least-one-way-in guard
1269
+ * reads it alongside the password and the links. */
1270
+ countSignInMethods(userId: string): Promise<{ password: boolean; links: number; passkeys: number }>
1271
+ /** The enrollment links (invite-only). The token arrives from the
1272
+ * caller (auth/op/accounts.ts's mint); expires_at = now + ttlMs. */
1273
+ createEnrollmentToken(input: {
1274
+ token: string
1275
+ userId: string
1276
+ createdBy?: string | null
1277
+ ttlMs: number
1278
+ }): Promise<EnrollmentToken>
1279
+ getEnrollmentToken(token: string): Promise<EnrollmentToken | null>
1280
+ /** Complete the enrollment: consume the token ATOMICALLY (a presented
1281
+ * link works exactly once, expired or not), then set the password.
1282
+ * The tagged result lets the route answer honestly. */
1283
+ completeEnrollment(token: string, passwordHash: string, setBy?: string | null): Promise<CompleteEnrollmentResult>
1284
+ /** The account's live sessions (expired ones excluded), `current`
1285
+ * computed against the presenting token — never exposed. */
1286
+ listUserSessions(userId: string, currentToken?: string): Promise<SessionView[]>
1287
+ /** Revoke ONE of the account's own sessions (the user_id clause makes
1288
+ * another account's session id a no-op). */
1289
+ deleteSessionById(userId: string, sessionId: string): Promise<boolean>
1290
+ /** The aggregate "who is signed in NOW" read across accounts
1291
+ * (TODO.identity-sso/01's live-sessions surface): every live session
1292
+ * (expired excluded), `current` computed against the presenting
1293
+ * administrator's token. The account name/email join stays with the
1294
+ * caller (listUsers) — this read carries the session rows only. */
1295
+ listOpLiveSessions(currentToken?: string): Promise<OpLiveSession[]>
1296
+ /** The administrator's revoke-ALL of an account's sessions (the
1297
+ * dashboard's light act): EVERY session deleted, no kept exception
1298
+ * (the self-service's deleteOtherSessions keeps the presenting one;
1299
+ * this keeps none). Answers the revoked count (the audit event's
1300
+ * metadata). */
1301
+ deleteAllUserSessions(userId: string): Promise<number>
1302
+
1303
+ // ── the central user registry (TODO.identity/03) ──
1304
+ /** The account's per-client role assignments (the registry console's
1305
+ * per-client view), one row per client that carries an override. */
1306
+ listOpClientRoles(userId: string): Promise<OpClientRoleAssignment[]>
1307
+ /** EVERY per-client assignment across accounts (TODO.identity-sso/01's
1308
+ * live access review reads the privileged per-client grants without a
1309
+ * per-account loop). */
1310
+ listAllOpClientRoles(): Promise<OpClientRoleAssignment[]>
1311
+ /** The assignment for ONE client: the roles the ID token issued to
1312
+ * that client carries (pre-allowlist). NULL = no row — the account's
1313
+ * OP-side role set is that client's default (the pre-03 behavior).
1314
+ * An EMPTY array is the explicit "no roles on this client". */
1315
+ getOpClientRoles(userId: string, clientId: string): Promise<string[] | null>
1316
+ /** Upsert the per-client assignment (roles may be empty — the explicit
1317
+ * none). The route validates the set against the client's policy. */
1318
+ setOpClientRoles(userId: string, clientId: string, roles: string[], assignedBy: string | null): Promise<void>
1319
+ /** Clear the per-client assignment (the account default is restored). */
1320
+ deleteOpClientRoles(userId: string, clientId: string): Promise<boolean>
1321
+ /** The deactivation's revocation half: delete EVERY live session, every
1322
+ * issued OIDC access token, every unconsumed authorization code and
1323
+ * every pending authorization of the account. Answers the counts (the
1324
+ * audit event's metadata). The user row STAYS (the history). */
1325
+ revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; codes: number; authorizations: number }>
1326
+ /** The offboarding runbook's DELETE path (the erasure): every credential,
1327
+ * token, link and per-client assignment removed, the user row anonymized
1328
+ * in place (provider 'erased' — it drops out of every account surface;
1329
+ * the tombstone keeps the audit chain's entity_id resolvable). Answers
1330
+ * the counts, or null when the account does not exist. */
1331
+ eraseOpAccount(userId: string): Promise<OpAccountErasure | null>
1332
+ /** Edit the account row's name/email (the registry's edit act).
1333
+ * Answers false when the account does not exist; the email UNIQUE
1334
+ * conflict surfaces as the 'unique' error (the route's 409). */
1335
+ updateOpAccount(id: string, input: { name?: string; email?: string }): Promise<boolean>
1336
+ /** The last OP-side sign-in per account, read FROM THE AUDIT CHAIN:
1337
+ * the latest auditEvents row whose action is a sign-in
1338
+ * ('account.sign_in' — the password login; 'upstream_sign_in' — a
1339
+ * linked-provider sign-in) per entity_id. Answers userId → ISO
1340
+ * timestamp; accounts that never signed in are absent. */
1341
+ lastAccountSignIns(): Promise<Record<string, string>>
1342
+
1343
+ // ── the account console (TODO.identity/06) ──
1344
+ /** The profile edit's write (the display name). Answers false when the
1345
+ * account is gone. */
1346
+ updateUserName(userId: string, name: string): Promise<boolean>
1347
+ /** The avatar write (users.avatar_url: the upload's serving URL, the
1348
+ * linked provider's picture, NULL for the initials). Answers false when
1349
+ * the account is gone. */
1350
+ setUserAvatar(userId: string, avatarUrl: string | null): Promise<boolean>
1351
+ /** Remove the account's password credential (the sign-in METHODS
1352
+ * section's remove action; the route holds the at-least-one-method
1353
+ * guard). Answers false when no credential was set. */
1354
+ deletePasswordHash(userId: string): Promise<boolean>
1355
+ /** Revoke every session of the account EXCEPT the presenting one (the
1356
+ * "sign out everywhere else" action + the password change's
1357
+ * best-practice revocation). Answers the revoked count. */
1358
+ deleteOtherSessions(userId: string, keepToken: string): Promise<number>
1359
+ /** Mint the verify-new-email ceremony's token. A fresh request VOIDS
1360
+ * the account's earlier pending rows (only the newest link works).
1361
+ * deliveredBy is stamped at request time and decides whether
1362
+ * completion may verify the address. */
1363
+ createEmailChangeToken(input: {
1364
+ token: string
1365
+ userId: string
1366
+ newEmail: string
1367
+ deliveredBy: 'mailer' | 'shown'
1368
+ ttlMs: number
1369
+ }): Promise<EmailChangeToken>
1370
+ getEmailChangeToken(token: string): Promise<EmailChangeToken | null>
1371
+ /** The account's pending change (the newest unconsumed, unexpired row),
1372
+ * so the console can show it. */
1373
+ getPendingEmailChange(userId: string): Promise<EmailChangeToken | null>
1374
+ /** Complete the ceremony: consume the token ATOMICALLY (a presented
1375
+ * link works exactly once, expired or not), judge the expiry, re-check
1376
+ * the address's uniqueness (a conflict burns the token honestly), then
1377
+ * move the account's email. verified = the token traveled by mailer
1378
+ * (mailbox proven); a shown link never verifies. */
1379
+ completeEmailChange(token: string): Promise<CompleteEmailChangeResult>
1380
+
1381
+ // ── strong authentication: the factor registry (TODO.identity-sso/02 + /03) ──
1382
+ /** The one-time WebAuthn ceremony challenge. The challenge value IS the
1383
+ * key (the clientDataJSON binds it); expires_at = now + ttlMs. */
1384
+ createWebauthnChallenge(input: {
1385
+ challenge: string
1386
+ userId: string | null
1387
+ kind: WebauthnChallenge['kind']
1388
+ ttlMs: number
1389
+ }): Promise<void>
1390
+ /** Atomically consume the challenge: answers the row exactly once (a
1391
+ * replay loses the consumed_at race); an EXPIRED row is consumed too
1392
+ * and answers null — never a second chance. */
1393
+ consumeWebauthnChallenge(challenge: string): Promise<WebauthnChallenge | null>
1394
+ /** Register the passkey. Answers NULL when the credential id is already
1395
+ * registered (to any account — the PRIMARY KEY is the race backstop;
1396
+ * the route maps it to the honest conflict). */
1397
+ createWebauthnCredential(input: {
1398
+ credentialId: string
1399
+ userId: string
1400
+ name: string
1401
+ publicKeyCose: string
1402
+ signCount: number
1403
+ aaguid: string | null
1404
+ transports: string[]
1405
+ ip?: string | null
1406
+ }): Promise<WebauthnCredential | null>
1407
+ /** The account's passkeys (the console's factors section), oldest first. */
1408
+ listWebauthnCredentials(userId: string): Promise<WebauthnCredential[]>
1409
+ /** The assertion's lookup by the authenticator's credential id. */
1410
+ getWebauthnCredential(credentialId: string): Promise<WebauthnCredential | null>
1411
+ /** Revoke the account's own passkey (the user_id clause makes another
1412
+ * account's credential id a no-op). */
1413
+ deleteWebauthnCredential(userId: string, credentialId: string): Promise<boolean>
1414
+ /** The signature counter's advance, GUARDED (the clone rule): the
1415
+ * UPDATE lands only when the presented count legitimately follows the
1416
+ * stored one (both zero = the authenticator never counts; otherwise
1417
+ * strictly greater). 'regressed' is the clone signal — the route
1418
+ * fails the assertion and audits. A landed advance stamps
1419
+ * last_used_at/last_ip. */
1420
+ advanceWebauthnCounter(credentialId: string, newCount: number, opts?: { ip?: string | null }): Promise<AdvanceCounterResult>
1421
+
1422
+ /** Start the TOTP enrollment (the PENDING row — verified_at NULL; it
1423
+ * activates at markTotpSecretVerified, never before). */
1424
+ createTotpSecret(input: { id: string; userId: string; name: string; secret: string }): Promise<TotpSecret>
1425
+ /** The account's TOTP rows, pending ones included (the route filters;
1426
+ * the console lists the verified only). */
1427
+ listTotpSecrets(userId: string): Promise<TotpSecret[]>
1428
+ getTotpSecret(id: string): Promise<TotpSecret | null>
1429
+ /** Activate the enrollment: verified_at (+ the final name) set where
1430
+ * the row is still pending and belongs to the account. */
1431
+ markTotpSecretVerified(id: string, userId: string, name: string): Promise<boolean>
1432
+ /** The enrollment verify's throttle: fail_count++ + last_failure_at,
1433
+ * answering the fresh count (the route's backoff + cap read it). */
1434
+ recordTotpEnrollFailure(id: string, userId: string): Promise<number>
1435
+ /** A verified secret's sign-in use: last_used_at/last_ip stamped. */
1436
+ markTotpSecretUsed(id: string, opts?: { ip?: string | null }): Promise<void>
1437
+ /** Revoke the account's own TOTP factor (pending or verified). */
1438
+ deleteTotpSecret(userId: string, id: string): Promise<boolean>
1439
+
1440
+ /** Replace the account's recovery-code set WHOLE (the regenerate: the
1441
+ * old batch is deleted, the new hashes land, one transaction/batch).
1442
+ * The hashes are SHA-256 of the normalized codes — the plaintext is
1443
+ * shown once and never stored. */
1444
+ replaceRecoveryCodes(userId: string, batch: string, codeHashes: string[]): Promise<void>
1445
+ /** The console's honest state (counts + the batch's age — never a hash). */
1446
+ recoveryCodeState(userId: string): Promise<RecoveryCodeState>
1447
+ /** The one-time use: consumed_at flips atomically WHERE the hash matches
1448
+ * an unconsumed row of the account — true exactly once per code. */
1449
+ consumeRecoveryCode(userId: string, codeHash: string): Promise<boolean>
1450
+
1451
+ /** The pending second-factor sign-in row (the password verified; the
1452
+ * session waits on the factor). amr is the methods proven so far. */
1453
+ createMfaPending(input: { token: string; userId: string; amr: string[]; ttlMs: number }): Promise<void>
1454
+ /** The verify attempt's read (NOT consuming — failures keep it alive
1455
+ * under the throttle ladder until the cap or the TTL). */
1456
+ getMfaPending(token: string): Promise<MfaPending | null>
1457
+ /** The completion: consumed ATOMICALLY (a concurrent completion loses
1458
+ * the race and answers null); an EXPIRED row is consumed too, never
1459
+ * redeemed later. */
1460
+ consumeMfaPending(token: string): Promise<MfaPending | null>
1461
+ /** The failure ladder: fail_count++ + last_failure_at, answering the
1462
+ * fresh row (null when the token is gone). */
1463
+ recordMfaPendingFailure(token: string): Promise<MfaPending | null>
1464
+
1465
+ // ── organization administration (TODO.identity/10) ──
1466
+ /** File a join request (the public "Request an account" page). */
1467
+ createOrgJoinRequest(input: {
1468
+ name: string
1469
+ email: string
1470
+ orgId: string | null
1471
+ orgNameText: string | null
1472
+ requestedRole: string
1473
+ note?: string | null
1474
+ }): Promise<OrgJoinRequest>
1475
+ getOrgJoinRequest(id: string): Promise<OrgJoinRequest | null>
1476
+ /** The queue reads. scope 'org' = one org's queue (orgId required);
1477
+ * 'unregistered' = BIML's new-organizations queue (org_id IS NULL);
1478
+ * 'all' = every request (BIML's oversight). Default: 'all'. */
1479
+ listOrgJoinRequests(filter?: {
1480
+ scope?: 'org' | 'unregistered' | 'all'
1481
+ orgId?: string
1482
+ status?: OrgJoinRequest['status']
1483
+ }): Promise<OrgJoinRequest[]>
1484
+ /** The decision — ATOMIC on 'pending': an already-decided row answers
1485
+ * null (a double approve/refuse loses the race honestly). */
1486
+ decideOrgJoinRequest(
1487
+ id: string,
1488
+ decision: {
1489
+ status: 'approved' | 'refused'
1490
+ decidedBy: string
1491
+ refusalReason?: string | null
1492
+ invitedUserId?: string | null
1493
+ },
1494
+ ): Promise<OrgJoinRequest | null>
1495
+ /** A PENDING request from the same email exists (the duplicate guard —
1496
+ * decided requests never block a fresh ask). */
1497
+ findPendingOrgJoinRequestByEmail(email: string): Promise<OrgJoinRequest | null>
1498
+
1499
+ // ── the multi-organization membership model (TODO.identity/11) ──
1500
+ // The org_memberships table is the account × org × per-org role set
1501
+ // with the lifecycle state. THE DUAL-READ DOCTRINE: the users row's
1502
+ // org_id/roles columns stay the backward-compatible read (the PRIMARY
1503
+ // membership's mirror) until every consumer reads the memberships —
1504
+ // the store mirrors every legacy write into the primary membership
1505
+ // row, and resolveOrgContext falls back to the columns when no
1506
+ // membership row exists.
1507
+ /** The account's memberships, every state (the console's
1508
+ * Organizations section, the admin's per-user page). */
1509
+ listOrgMemberships(userId: string): Promise<OrgMembership[]>
1510
+ /** One org's memberships (the per-org view), every state. */
1511
+ listOrgMembers(orgId: string): Promise<OrgMembership[]>
1512
+ getOrgMembership(userId: string, orgId: string): Promise<OrgMembership | null>
1513
+ /** Create the membership — the org's admin inviting an EXISTING
1514
+ * account (state 'invited': the holder accepts from the account
1515
+ * console), or the join-request approval's grant (state 'active':
1516
+ * both consents are on record). NULL on the (user, org) conflict —
1517
+ * the honest "already a member". */
1518
+ createOrgMembership(input: {
1519
+ userId: string
1520
+ orgId: string
1521
+ roles: string[]
1522
+ state: OrgMembershipState
1523
+ invitedBy?: string | null
1524
+ }): Promise<OrgMembership | null>
1525
+ /** Replace the per-org role set. A PRIMARY membership's write mirrors
1526
+ * into the users row's roles (and the section-gating primary role
1527
+ * when it fell out of the set) — the dual-write keeps the legacy
1528
+ * read identical. Answers false when no membership exists. */
1529
+ setOrgMembershipRoles(userId: string, orgId: string, roles: string[]): Promise<boolean>
1530
+ /** The lifecycle act: invited → active (the holder's accept, or the
1531
+ * approval), active ⇄ disabled (the org's admin / the scheme
1532
+ * operator). Stamps activated_at / disabled_at(+by); disabling also
1533
+ * clears the account's sessions' active-org stamps pointing at the
1534
+ * org (the context ends honestly). Answers null when no membership
1535
+ * exists. */
1536
+ setOrgMembershipState(
1537
+ userId: string,
1538
+ orgId: string,
1539
+ state: OrgMembershipState,
1540
+ actor?: string | null,
1541
+ ): Promise<OrgMembership | null>
1542
+ /** Remove the row — the holder declining an invitation, and the
1543
+ * erasure's cleanup. (The routes refuse the PRIMARY membership: the
1544
+ * primary binding moves through the account's role/org assignment,
1545
+ * never through a delete.) */
1546
+ deleteOrgMembership(userId: string, orgId: string): Promise<boolean>
1547
+ /** The session's stamped active-org context (NULL = the primary
1548
+ * context; also NULL for an unknown/expired token). */
1549
+ getSessionActiveOrg(token: string): Promise<string | null>
1550
+ /** Stamp the session's active-org context (the account console's
1551
+ * switcher; the route validates the membership first). NULL clears
1552
+ * to the primary context. */
1553
+ setSessionActiveOrg(token: string, orgId: string | null): Promise<boolean>
1554
+
1555
+ // ── the organization registry (TODO.identity-features/05) ──
1556
+ // The identity service's OWN org registry (the org_registry table):
1557
+ // the first-class organizations the membership graph above references
1558
+ // by id (the slug; the participant orgs' OIML codes). The lifecycle
1559
+ // acts are the routes' — the disable CASCADE (every active membership
1560
+ // of the org disabling honestly) is the route's loop over
1561
+ // setOrgMembershipState; the store keeps the rows.
1562
+ /** Every registry organization, every state, name-ordered. */
1563
+ listOrgRegistryOrgs(): Promise<OrgRegistryOrg[]>
1564
+ getOrgRegistryOrg(id: string): Promise<OrgRegistryOrg | null>
1565
+ /** Add the organization. NULL on the id conflict (the slug is taken —
1566
+ * the route's honest 409). */
1567
+ createOrgRegistryOrg(input: {
1568
+ id: string
1569
+ name: string
1570
+ shortName?: string | null
1571
+ kind?: string | null
1572
+ country?: string | null
1573
+ contacts?: OrgRegistryContact[]
1574
+ participantRef?: string | null
1575
+ createdBy?: string | null
1576
+ }): Promise<OrgRegistryOrg | null>
1577
+ /** Edit the display data (the id is the stable slug — never editable);
1578
+ * stamps updated_at/by. NULL when the registry does not carry the
1579
+ * org. */
1580
+ updateOrgRegistryOrg(
1581
+ id: string,
1582
+ patch: {
1583
+ name?: string
1584
+ shortName?: string | null
1585
+ kind?: string | null
1586
+ country?: string | null
1587
+ contacts?: OrgRegistryContact[]
1588
+ participantRef?: string | null
1589
+ },
1590
+ actor?: string | null,
1591
+ ): Promise<OrgRegistryOrg | null>
1592
+ /** The lifecycle act: disable (the honest removal — stamps
1593
+ * disabled_at/by) / re-enable (the disable stamps clear; the org's
1594
+ * memberships stay as they are — re-activation is the per-membership
1595
+ * deliberate act, never an automatic one). NULL when the registry
1596
+ * does not carry the org. */
1597
+ setOrgRegistryOrgState(id: string, state: OrgRegistryState, actor?: string | null): Promise<OrgRegistryOrg | null>
1598
+ /** The erasure-adjacent hard delete — the ROUTE refuses it while any
1599
+ * membership or join request references the org (the honest 409:
1600
+ * disable it instead); this removes the row only. */
1601
+ deleteOrgRegistryOrg(id: string): Promise<boolean>
1602
+
1603
+ // ── the register's holder-org attribution (TODO.register/02) ──
1604
+ // The certificate_holder_orgs / certificate_holder_claims tables (the
1605
+ // 0015 migration): the hub's record of WHICH OP org a registered
1606
+ // certificate belongs to, and the legacy-row claim act's state machine.
1607
+ /** The attribution write — INSERT-IF-ABSENT (the first attribution
1608
+ * wins): the new row, or NULL when the certificate already carries
1609
+ * one (the claim's confirmation reports the conflict honestly). */
1610
+ attributeCertificateHolderOrg(input: {
1611
+ certificateId: string
1612
+ orgId: string
1613
+ orgName: string
1614
+ source: CertificateHolderOrg['source']
1615
+ attributedAt: string
1616
+ attributedBy?: string | null
1617
+ claimId?: string | null
1618
+ }): Promise<CertificateHolderOrg | null>
1619
+ getCertificateHolderOrg(certificateId: string): Promise<CertificateHolderOrg | null>
1620
+ /** The surface's reads: one org's attributions (the manufacturer cone),
1621
+ * or every row (the estate cone — no filter). */
1622
+ listCertificateHolderOrgs(filter?: { orgId?: string }): Promise<CertificateHolderOrg[]>
1623
+ /** File the legacy-row claim (the manufacturer org admin's act). */
1624
+ createCertificateHolderClaim(input: {
1625
+ certificateId: string
1626
+ claimantOrgId: string
1627
+ claimantOrgName: string
1628
+ matchedHolderName: string
1629
+ claimedBy: string
1630
+ }): Promise<CertificateHolderClaim>
1631
+ getCertificateHolderClaim(id: string): Promise<CertificateHolderClaim | null>
1632
+ /** The queues: the estate admin's pending list (state filter), the
1633
+ * claiming org's own claims (claimantOrgId filter), one certificate's
1634
+ * claim history (certificateId filter). */
1635
+ listCertificateHolderClaims(filter?: {
1636
+ state?: CertificateHolderClaimState
1637
+ claimantOrgId?: string
1638
+ certificateId?: string
1639
+ }): Promise<CertificateHolderClaim[]>
1640
+ /** The estate admin's decision — ATOMIC on 'pending': an
1641
+ * already-decided claim answers null (a double confirm/refuse loses
1642
+ * the race honestly). */
1643
+ decideCertificateHolderClaim(
1644
+ id: string,
1645
+ decision: {
1646
+ status: 'confirmed' | 'refused'
1647
+ decidedBy: string
1648
+ refusalReason?: string | null
1649
+ },
1650
+ ): Promise<CertificateHolderClaim | null>
1651
+ /** A PENDING claim on this certificate exists (the one-active-claim
1652
+ * guard — decided claims never block a fresh claim). */
1653
+ findPendingCertificateHolderClaim(certificateId: string): Promise<CertificateHolderClaim | null>
1654
+ // ── the instrument register (TODO.register/03) ──
1655
+ // The platform-side serial register (the instrument_registrations
1656
+ // table): one row per registered instrument under a type certificate.
1657
+ // The scope check + the cones are the ROUTE's (browser/server/routes/
1658
+ // registrations.ts); the store keeps the rows. Every row the store
1659
+ // returns is a registration that STOOD — the refused declaration never
1660
+ // lands (the route answers it with the reason).
1661
+ /** Every registered instrument (the BIML/operator cone); ordered by
1662
+ * certificate then serial. */
1663
+ listInstrumentRegistrations(): Promise<InstrumentRegistration[]>
1664
+ /** The serials registered under one certificate (the certificate
1665
+ * detail's serials tab + the issuing IA's oversight cone). */
1666
+ listInstrumentRegistrationsForCertificate(certificateId: string): Promise<InstrumentRegistration[]>
1667
+ /** The serials one holder organization registered (the manufacturer's
1668
+ * own cone). */
1669
+ listInstrumentRegistrationsForHolder(holderOrgId: string): Promise<InstrumentRegistration[]>
1670
+ getInstrumentRegistration(id: string): Promise<InstrumentRegistration | null>
1671
+ /** Register the instrument. NULL on the (certificate_id,
1672
+ * serial_number) conflict — the same physical unit never registers
1673
+ * twice under one certificate (the route's honest 409). */
1674
+ createInstrumentRegistration(input: {
1675
+ id: string
1676
+ certificateId: string
1677
+ holderOrgId: string
1678
+ standardId: string
1679
+ serialNumber: string
1680
+ manufactureDate?: string | null
1681
+ designations?: Record<string, unknown>
1682
+ scopeStatus: InstrumentRegistrationScopeStatus
1683
+ scopeDetail?: string | null
1684
+ registeredBy?: string | null
1685
+ }): Promise<InstrumentRegistration | null>
1686
+ /** The lifecycle act (registered ⇄ out_of_service → withdrawn; the
1687
+ * transition RULE is the route's — withdrawn is terminal): stamps
1688
+ * updated_at/by. NULL when the register does not carry the id. */
1689
+ setInstrumentRegistrationLifecycle(
1690
+ id: string,
1691
+ lifecycle: InstrumentRegistrationLifecycle,
1692
+ actor?: string | null,
1693
+ ): Promise<InstrumentRegistration | null>
1694
+
1695
+ // ── the workflow entity store + change journal ──
1696
+ listEntities(store: string): Promise<EntityRow[]>
1697
+ getEntity(store: string, id: string): Promise<EntityRow | undefined>
1698
+ putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void>
1699
+ deleteEntity(store: string, id: string): Promise<boolean>
1700
+ changesAfter(seq: number, limit?: number): Promise<EntityChange[]>
1701
+ latestChangeSeq(): Promise<number>
1702
+
1703
+ // ── the platform event store (TODO.notify/01) ──
1704
+ /** Append one declared event (the emitter's write; one row inside the
1705
+ * acting request's envelope). Answers the stored row (seq + at read
1706
+ * back). */
1707
+ appendEvent(input: {
1708
+ id: string
1709
+ domain: string
1710
+ entityId: string
1711
+ action: string
1712
+ payload: string
1713
+ }): Promise<PlatformEvent>
1714
+ /** The feed's raw leg: events past the cursor, seq-ordered. The
1715
+ * visibility gate is the READER's layer (server/notify-feed.ts) —
1716
+ * never waived here, never duplicated into the SQL. */
1717
+ eventsAfter(seq: number, limit?: number): Promise<PlatformEvent[]>
1718
+ latestEventSeq(): Promise<number>
1719
+ /** The by-id read (the inbox state write's guard — TODO.notify/03: a
1720
+ * marker lands only on an event that exists and is the caller's). */
1721
+ getEvent(id: string): Promise<PlatformEvent | null>
1722
+ /** The subscription grammar's SQL resolution: the columns the pattern
1723
+ * pins, equality-matched (`WHERE domain = ? AND entity_id = ?` — the
1724
+ * column split's whole point, never a string scan on a composed key). */
1725
+ eventsMatching(filter: EventKeyFilter, limit?: number): Promise<PlatformEvent[]>
1726
+
1727
+ // ── the notification subscriptions store (TODO.notify/02) ──
1728
+ /** The user's own rule rows (both modes) — the settings page's list. */
1729
+ listNotifyRules(userId: string): Promise<NotifyRule[]>
1730
+ /** The upsert on UNIQUE (user_id, pattern): subscribe/unsubscribe and
1731
+ * pattern-mute ride the same write; the caller (the route) has already
1732
+ * compiled the pattern into its split legs. Answers the stored row. */
1733
+ putNotifyRule(input: {
1734
+ id: string
1735
+ userId: string
1736
+ pattern: string
1737
+ domain: string
1738
+ entityId: string | null
1739
+ action: string | null
1740
+ mode: NotifyRuleMode
1741
+ channelOverrides: string | null
1742
+ }): Promise<NotifyRule>
1743
+ /** Remove the user's rule on the exact pattern (unsubscribe /
1744
+ * un-mute). Idempotent: answers false when no row existed. */
1745
+ deleteNotifyRule(userId: string, pattern: string): Promise<boolean>
1746
+ /** THE RESOLUTION'S READ (cross-user): every rule row whose pinned
1747
+ * legs the event satisfies — `domain = ? AND (entity_id IS NULL OR
1748
+ * entity_id = ?) AND (action IS NULL OR action = ?)`. The split
1749
+ * columns make the reverse match an index walk. */
1750
+ notifyRulesForEvent(filter: { domain: string; entityId: string; action: string }): Promise<NotifyRule[]>
1751
+ /** The user's per-entity mutes (the settings page's muted-threads
1752
+ * list). */
1753
+ listNotifyEntityMutes(userId: string): Promise<NotifyEntityMute[]>
1754
+ /** The entity mute's write (the bell's Muted state, the email
1755
+ * footer's one-click unsubscribe). Idempotent on UNIQUE
1756
+ * (user_id, domain, entity_id). */
1757
+ putNotifyEntityMute(input: { id: string; userId: string; domain: string; entityId: string }): Promise<NotifyEntityMute>
1758
+ /** Clear the entity mute. Idempotent: false when nothing was muted. */
1759
+ deleteNotifyEntityMute(userId: string, domain: string, entityId: string): Promise<boolean>
1760
+ /** THE RESOLUTION'S entity-mute read (cross-user): every mute naming
1761
+ * the event's entity. */
1762
+ notifyEntityMutesForEvent(domain: string, entityId: string): Promise<NotifyEntityMute[]>
1763
+ /** The user's preferences row — NULL until the first preference write
1764
+ * (every category then follows the catalog defaults). */
1765
+ getNotifyPreferences(userId: string): Promise<NotifyPreferences | null>
1766
+ /** The preferences write (the whole channels map, validated by the
1767
+ * route). Answers the stored row. */
1768
+ putNotifyPreferences(userId: string, channels: string): Promise<NotifyPreferences>
1769
+
1770
+ // ── the inbox state (TODO.notify/03) ──
1771
+ /** The user's inbox markers (the feed's join: read/done per event).
1772
+ * Written lazily at the first act — most events carry no row. */
1773
+ listNotifyInboxStates(userId: string): Promise<NotifyInboxState[]>
1774
+ /** The marker write (the upsert on PRIMARY KEY (user_id, event_id)):
1775
+ * each PRESENT flag sets its stamp (datetime('now')) or clears it
1776
+ * (NULL); absent flags keep. Answers the stored row. */
1777
+ putNotifyInboxState(input: {
1778
+ userId: string
1779
+ eventId: string
1780
+ read?: boolean
1781
+ done?: boolean
1782
+ }): Promise<NotifyInboxState>
1783
+
1784
+ // ── provisioning / dev support ──
1785
+ /** The mutable workflow stores emptied (the dev-reset + demo reseed
1786
+ * leg): entities, entity_changes, evidence_records, events, and
1787
+ * instrument_registrations (TODO.register/03 — the register is
1788
+ * mutable workflow state; the e2e isolation resets it with the
1789
+ * rest). Users and sessions persist.
1790
+ * With `range`, ONE bounded round instead: only rows whose rowid
1791
+ * falls in (range.after, range.through] go, per table. The demo
1792
+ * reseed's reset phase walks these rounds under the slice budget —
1793
+ * an unbounded DELETE on a grown store dies against D1's per-
1794
+ * statement execution limits (the 2026-08 demo-reset flake), and
1795
+ * rowid ranges are stable under deletion, so rounds resume and
1796
+ * replay exactly. Returns the rows deleted (all tables summed). */
1797
+ wipeWorkflowStores(range?: { after: number; through: number }): Promise<number>
1798
+ /** The rowid ceiling across the wiped tables — the reset phase's
1799
+ * round planning (rounds = ceil(ceiling / chunk)). 0 on empty
1800
+ * stores. */
1801
+ workflowStoreRowCeiling(): Promise<number>
1802
+ countEntities(): Promise<number>
1803
+ }
1804
+
1805
+ let current: ServerStore | null = null
1806
+
1807
+ /** The composition roots install the store exactly once per process /
1808
+ * per binding. The stores are stateless facades over a connection or a
1809
+ * binding, so a per-isolate memoized install is safe across concurrent
1810
+ * requests. */
1811
+ export function installStore(store: ServerStore): void {
1812
+ current = store
1813
+ }
1814
+
1815
+ /** The installed store. Throws honestly when no composition root ran —
1816
+ * a route hit without an installed store is a wiring bug, never a
1817
+ * silent fallback. */
1818
+ export function getStore(): ServerStore {
1819
+ if (!current) {
1820
+ throw new Error(
1821
+ 'the server store is not installed — the composition root installs it '
1822
+ + '(server/index.ts installs SQLite on node; server/cloudflare.ts installs D1 on the Worker)',
1823
+ )
1824
+ }
1825
+ return current
1826
+ }