@oimlsmart/platform-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +92 -0
  2. package/migrations/0001_init.sql +69 -0
  3. package/migrations/0002_identity.sql +24 -0
  4. package/migrations/0003_federation_peers.sql +21 -0
  5. package/migrations/0003_users_rbac.sql +8 -0
  6. package/migrations/0004_oidc_op.sql +61 -0
  7. package/migrations/0005_upstream_providers.sql +34 -0
  8. package/migrations/0006_op_accounts.sql +28 -0
  9. package/migrations/0007_org_join_requests.sql +26 -0
  10. package/migrations/0008_op_client_roles.sql +19 -0
  11. package/migrations/0009_account_console.sql +32 -0
  12. package/migrations/0009_sso_states.sql +13 -0
  13. package/migrations/0010_notify_events.sql +23 -0
  14. package/migrations/0011_op_launch.sql +18 -0
  15. package/migrations/0011_org_memberships.sql +62 -0
  16. package/migrations/0012_notify_subscriptions.sql +57 -0
  17. package/migrations/0012_strong_auth.sql +109 -0
  18. package/migrations/0013_org_registry.sql +53 -0
  19. package/migrations/0014_notify_inbox.sql +34 -0
  20. package/migrations/0015_certificate_holder_attribution.sql +63 -0
  21. package/migrations/0016_instrument_registrations.sql +78 -0
  22. package/package.json +52 -0
  23. package/src/client-info.ts +25 -0
  24. package/src/context.ts +31 -0
  25. package/src/github.ts +284 -0
  26. package/src/mailer.ts +309 -0
  27. package/src/oidc.ts +369 -0
  28. package/src/profile/node.ts +83 -0
  29. package/src/profile.ts +582 -0
  30. package/src/rbac/node.ts +42 -0
  31. package/src/rbac.ts +53 -0
  32. package/src/session.ts +45 -0
  33. package/src/store/d1.ts +2850 -0
  34. package/src/store/sqlite/entities.ts +71 -0
  35. package/src/store/sqlite/events.ts +82 -0
  36. package/src/store/sqlite/factors-store.ts +348 -0
  37. package/src/store/sqlite/notify.ts +247 -0
  38. package/src/store/sqlite/op-accounts-store.ts +470 -0
  39. package/src/store/sqlite/op-store.ts +280 -0
  40. package/src/store/sqlite/schema.sql +745 -0
  41. package/src/store/sqlite/store.ts +1390 -0
  42. package/src/store/sqlite/upstream-store.ts +148 -0
  43. package/src/store/sqlite.ts +1027 -0
  44. package/src/store.ts +1826 -0
  45. package/src/vocab/index.ts +12 -0
  46. package/src/vocab/permissions.ts +398 -0
  47. package/src/vocab/rbac.ts +281 -0
  48. package/src/vocab/roles.ts +162 -0
@@ -0,0 +1,745 @@
1
+ CREATE TABLE IF NOT EXISTS users (
2
+ id TEXT PRIMARY KEY,
3
+ email TEXT UNIQUE NOT NULL,
4
+ name TEXT NOT NULL,
5
+ avatar_url TEXT,
6
+ provider TEXT NOT NULL DEFAULT 'demo',
7
+ provider_account_id TEXT,
8
+ role TEXT NOT NULL DEFAULT 'user',
9
+ -- Organization linkage: manufacturer id (applicant), IA oiml_code (ia_officer),
10
+ -- TL oiml_id (tl_operator); NULL for cs_admin/admin/viewer.
11
+ org_id TEXT,
12
+ -- TODO.federation/12 (RBAC): the FULL assigned role set as a JSON array
13
+ -- (role stays the section-gating primary; roles drives permissions).
14
+ -- NULL = the single primary role only. active=0 deactivates the account
15
+ -- (sessions stop resolving, demo sign-in refuses).
16
+ roles TEXT,
17
+ active INTEGER NOT NULL DEFAULT 1,
18
+ -- TODO.identity/06: the primary address's verification state. Set by the
19
+ -- enrollment ceremony (the administrator-delivered setup link) and by an
20
+ -- email change whose token was DELIVERED BY THE MAILER to the new
21
+ -- address; NULL when nothing ever proved the mailbox (a change confirmed
22
+ -- through an on-screen link stays unverified, honestly).
23
+ email_verified_at TEXT,
24
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
25
+ last_login TEXT
26
+ );
27
+
28
+ CREATE TABLE IF NOT EXISTS sessions (
29
+ id TEXT PRIMARY KEY,
30
+ user_id TEXT NOT NULL REFERENCES users(id),
31
+ token TEXT UNIQUE NOT NULL,
32
+ expires_at TEXT NOT NULL,
33
+ -- TODO.federation/10: the SSO sign-in's id_token, kept for
34
+ -- RP-initiated logout (id_token_hint). NULL for demo/GitHub sessions.
35
+ id_token_hint TEXT,
36
+ -- TODO.identity/06 (the account console's sessions section): the sign-in
37
+ -- context. user_agent/ip are stamped at creation (NULL when the request
38
+ -- carried none); last_seen_at is touched by session resolution, throttled
39
+ -- to one write per minute per session.
40
+ user_agent TEXT,
41
+ ip TEXT,
42
+ last_seen_at TEXT,
43
+ -- TODO.identity/11 (the multi-org membership model): the session's
44
+ -- ACTIVE-ORG context — the account acts AS this org (the org_memberships
45
+ -- row's per-org role set applies). NULL = the primary context (the
46
+ -- account's org_id binding — the pre-memberships behavior).
47
+ active_org TEXT,
48
+ -- TODO.identity-sso/02+03 (the strong-authentication wave): the sign-in
49
+ -- provenance as a JSON array of RFC 8176 amr values ('pwd', 'otp',
50
+ -- 'webauthn', 'hwk', the OP-private 'recovery'). NULL = no OP-side
51
+ -- credential event recorded (an upstream-provider sign-in).
52
+ amr TEXT,
53
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
54
+ );
55
+
56
+ -- TODO.federation/10 — the SSO approval queue: an authenticated OIDC
57
+ -- user no claim-mapping rule matched (and no defaultRole declared) gets
58
+ -- NO account and NO session until an administrator approves with a role
59
+ -- (+ org) or rejects. One row per (issuer, sub); repeat sign-ins
60
+ -- refresh last_seen.
61
+ CREATE TABLE IF NOT EXISTS identity_approvals (
62
+ id TEXT PRIMARY KEY,
63
+ email TEXT NOT NULL,
64
+ name TEXT NOT NULL,
65
+ issuer TEXT NOT NULL,
66
+ sub TEXT NOT NULL,
67
+ claims_json TEXT,
68
+ status TEXT NOT NULL DEFAULT 'pending',
69
+ decided_role TEXT,
70
+ decided_org TEXT,
71
+ decided_by TEXT,
72
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
73
+ last_seen TEXT,
74
+ decided_at TEXT,
75
+ UNIQUE (issuer, sub)
76
+ );
77
+
78
+ -- TODO.federation/04 — the federation peer registry: a pinned
79
+ -- counterparty instance (descriptor fetched + validated, or pasted
80
+ -- out-of-band). The intake verifies signatures against ACTIVE peers'
81
+ -- published keys; a revoked peer's keys never verify (the row stays —
82
+ -- the audit trail + the revocations list carry the history).
83
+ CREATE TABLE IF NOT EXISTS federation_peers (
84
+ id TEXT PRIMARY KEY,
85
+ name TEXT NOT NULL,
86
+ roles TEXT NOT NULL,
87
+ descriptor_url TEXT,
88
+ descriptor_json TEXT NOT NULL,
89
+ pinned_via TEXT NOT NULL DEFAULT 'url',
90
+ connectivity TEXT NOT NULL DEFAULT 'verified',
91
+ status TEXT NOT NULL DEFAULT 'active',
92
+ added_at TEXT NOT NULL DEFAULT (datetime('now')),
93
+ added_by TEXT,
94
+ refreshed_at TEXT,
95
+ revoked_at TEXT,
96
+ revoked_by TEXT
97
+ );
98
+
99
+ -- The workflow entity store (TODO.ops/07 — server-side persistence):
100
+ -- one JSON document per entity, keyed by (store, id) — the same shape
101
+ -- the browser's IndexedDB stores hold, so the two repository backends
102
+ -- are contract-identical. org_id carries the scoping column when the
103
+ -- entity declares one (server-side enforcement for org-bound roles).
104
+ CREATE TABLE IF NOT EXISTS entities (
105
+ store TEXT NOT NULL,
106
+ id TEXT NOT NULL,
107
+ org_id TEXT,
108
+ data TEXT NOT NULL,
109
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
110
+ PRIMARY KEY (store, id)
111
+ );
112
+ CREATE INDEX IF NOT EXISTS idx_entities_store_org ON entities (store, org_id);
113
+
114
+ -- The change journal: every write appends (seq, store, type, id) —
115
+ -- the SSE stream tails it (each client filters its stores).
116
+ CREATE TABLE IF NOT EXISTS entity_changes (
117
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
118
+ store TEXT NOT NULL,
119
+ type TEXT NOT NULL,
120
+ id TEXT NOT NULL,
121
+ at TEXT NOT NULL DEFAULT (datetime('now'))
122
+ );
123
+
124
+ -- TODO.notify/01 — the platform event store (the notification system's
125
+ -- source of truth): one row per DECLARED notifiable act (the catalog,
126
+ -- browser/src/notify/catalog.ts, is deliberate — not every write is an
127
+ -- event). The hierarchical key <domain>/<entity-id>/<action> is SPLIT
128
+ -- into columns so the subscription grammar's prefixes resolve in SQL
129
+ -- (WHERE domain = ? AND entity_id = ?), never a string scan; the
130
+ -- composed key is derived at read. payload is the catalog row's JSON
131
+ -- envelope (the summary line, the deep link, the actors, the entity's
132
+ -- store for the read-time visibility gate). seq is the feed cursor;
133
+ -- written inside the acting request's envelope, never blocking the
134
+ -- triggering flow (the mailer doctrine).
135
+ CREATE TABLE IF NOT EXISTS events (
136
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
137
+ id TEXT UNIQUE NOT NULL,
138
+ domain TEXT NOT NULL,
139
+ entity_id TEXT NOT NULL,
140
+ action TEXT NOT NULL,
141
+ payload TEXT NOT NULL,
142
+ at TEXT NOT NULL DEFAULT (datetime('now'))
143
+ );
144
+ CREATE INDEX IF NOT EXISTS idx_events_domain_entity ON events (domain, entity_id);
145
+ CREATE INDEX IF NOT EXISTS idx_events_domain_action ON events (domain, action);
146
+
147
+ -- TODO.notify/02 — the subscriptions store (the notification system's
148
+ -- per-user rules, the GitHub shape). Three tables:
149
+ --
150
+ -- notify_rules: the per-user rule rows (mode subscribe|mute) on a key
151
+ -- pattern in the catalog's grammar (application/**, certificate/**/
152
+ -- issued, test-run/asg-…-001/**, or an exact key). The pattern's
153
+ -- pinned legs are SPLIT into columns (domain is always pinned;
154
+ -- entity_id/action NULL = the wild leg) so the recipient
155
+ -- resolution's REVERSE match — every user's rules covering one event —
156
+ -- resolves in SQL (WHERE domain = ? AND (entity_id IS NULL OR
157
+ -- entity_id = ?) AND (action IS NULL OR action = ?)), never a string
158
+ -- scan. channel_overrides is the subscribe row's per-rule email
159
+ -- override (JSON; NULL = the category preference rules on); a mute
160
+ -- row never carries one.
161
+ CREATE TABLE IF NOT EXISTS notify_rules (
162
+ id TEXT PRIMARY KEY,
163
+ user_id TEXT NOT NULL REFERENCES users(id),
164
+ pattern TEXT NOT NULL,
165
+ domain TEXT NOT NULL,
166
+ entity_id TEXT,
167
+ action TEXT,
168
+ mode TEXT NOT NULL,
169
+ channel_overrides TEXT,
170
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
171
+ UNIQUE (user_id, pattern)
172
+ );
173
+ CREATE INDEX IF NOT EXISTS idx_notify_rules_user ON notify_rules (user_id);
174
+ CREATE INDEX IF NOT EXISTS idx_notify_rules_event ON notify_rules (domain, entity_id, action);
175
+
176
+ -- notify_entity_mutes: the thread-level mutes (the entity-page bell's
177
+ -- Muted state; the email footer's one-click unsubscribe — TODO.notify/
178
+ -- 04 — sets the same row). A mute wins over every candidate class,
179
+ -- subscriptions included; the access is unchanged.
180
+ CREATE TABLE IF NOT EXISTS notify_entity_mutes (
181
+ id TEXT PRIMARY KEY,
182
+ user_id TEXT NOT NULL REFERENCES users(id),
183
+ domain TEXT NOT NULL,
184
+ entity_id TEXT NOT NULL,
185
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
186
+ UNIQUE (user_id, domain, entity_id)
187
+ );
188
+ CREATE INDEX IF NOT EXISTS idx_notify_entity_mutes_entity ON notify_entity_mutes (domain, entity_id);
189
+
190
+ -- notify_preferences: one row per user — the per-category (the event
191
+ -- catalog's domains) email posture as a JSON map { "<domain>":
192
+ -- "immediate"|"digest"|"off" }. A domain ABSENT falls back to the
193
+ -- catalog row's own email default; the inbox always carries the
194
+ -- event. No row at all = every category on its catalog default.
195
+ CREATE TABLE IF NOT EXISTS notify_preferences (
196
+ user_id TEXT PRIMARY KEY REFERENCES users(id),
197
+ channels TEXT NOT NULL DEFAULT '{}',
198
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
199
+ );
200
+
201
+ -- TODO.notify/03 — the inbox state: the per-user per-event read markers
202
+ -- (migration 0014). One row per (user, event), written lazily at the
203
+ -- first act on the inbox row: read_at stamps the mark-read, done_at the
204
+ -- archive (a done row leaves the feed; the marker keeps the state). The
205
+ -- feed itself is COMPUTED at read (events × the user's rules × the
206
+ -- visibility gate) — this table is the state the computation joins, and
207
+ -- a marker on a wiped event simply never joins (the user's state is
208
+ -- their own, never the workflow's). NO foreign keys, deliberately: the
209
+ -- event store's wipe (the demo reset) and its retention sweep must never
210
+ -- FK-block on the user's own markers; the write path's guard is the
211
+ -- integrity (a marker lands only on an event that exists, is visible and
212
+ -- is the caller's).
213
+ CREATE TABLE IF NOT EXISTS notify_inbox_state (
214
+ user_id TEXT NOT NULL,
215
+ event_id TEXT NOT NULL,
216
+ read_at TEXT,
217
+ done_at TEXT,
218
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
219
+ PRIMARY KEY (user_id, event_id)
220
+ );
221
+ CREATE INDEX IF NOT EXISTS idx_notify_inbox_state_user ON notify_inbox_state (user_id);
222
+
223
+ -- The evidence store (TODO.ops/09 — the monitor daemon's durable
224
+ -- streams): append-only records across restarts. The adapter contract
225
+ -- (src/evidence-store/adapter.ts) is tiny: append, query, getByIds,
226
+ -- counts.
227
+ CREATE TABLE IF NOT EXISTS evidence_records (
228
+ id TEXT PRIMARY KEY,
229
+ kind TEXT NOT NULL,
230
+ twin_id TEXT,
231
+ monitor_id TEXT,
232
+ at TEXT NOT NULL,
233
+ data TEXT NOT NULL
234
+ );
235
+ CREATE INDEX IF NOT EXISTS idx_evidence_kind_at ON evidence_records (kind, at);
236
+ CREATE INDEX IF NOT EXISTS idx_evidence_twin ON evidence_records (twin_id, at);
237
+ CREATE INDEX IF NOT EXISTS idx_evidence_monitor ON evidence_records (monitor_id, at);
238
+
239
+ -- ═══════════════════════════════════════════════════════════════════
240
+ -- TODO.identity/01 — the OIDC Provider (id.oimlsmart.org). Everything
241
+ -- below must survive Worker isolates, so it lives in the database —
242
+ -- NEVER a per-process Map (the GitHub-flow lesson, auth/github.ts).
243
+ -- ═══════════════════════════════════════════════════════════════════
244
+
245
+ -- The client registry: the relying parties (platform instances) allowed
246
+ -- to request tokens. secret_hash NULL = a public client (PKCE carries
247
+ -- the proof); claims_policy JSON names the claims the ID token carries
248
+ -- for this client (the instances' fed-10 claim mapping consumes them).
249
+ CREATE TABLE IF NOT EXISTS oidc_clients (
250
+ client_id TEXT PRIMARY KEY,
251
+ name TEXT NOT NULL,
252
+ secret_hash TEXT,
253
+ redirect_uris TEXT NOT NULL,
254
+ claims_policy TEXT,
255
+ -- The SSO home's launch metadata (migration 0011): launch_url NULL =
256
+ -- the client never appears on the post-login launcher; visibility is
257
+ -- the not-admitted posture ('roles' hide, 'request' the request-access
258
+ -- state, 'open' never gated).
259
+ launch_url TEXT,
260
+ launch_icon TEXT,
261
+ launch_description TEXT,
262
+ launch_visibility TEXT NOT NULL DEFAULT 'roles',
263
+ status TEXT NOT NULL DEFAULT 'active',
264
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
265
+ created_by TEXT
266
+ );
267
+
268
+ -- A pending authorization between /op/authorize's validation and the
269
+ -- consent decision (the decision POST may land on another isolate).
270
+ -- user_id is stamped when the signed-in session resolves.
271
+ CREATE TABLE IF NOT EXISTS oidc_authorizations (
272
+ id TEXT PRIMARY KEY,
273
+ client_id TEXT NOT NULL,
274
+ redirect_uri TEXT NOT NULL,
275
+ scope TEXT NOT NULL,
276
+ state TEXT NOT NULL,
277
+ nonce TEXT,
278
+ code_challenge TEXT NOT NULL,
279
+ user_id TEXT,
280
+ decision TEXT,
281
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
282
+ expires_at TEXT NOT NULL
283
+ );
284
+
285
+ -- The one-time authorization codes. consumed_at flips atomically at the
286
+ -- exchange (UPDATE … WHERE consumed_at IS NULL) — a replayed code loses
287
+ -- the race and gets invalid_grant.
288
+ CREATE TABLE IF NOT EXISTS oidc_codes (
289
+ code TEXT PRIMARY KEY,
290
+ client_id TEXT NOT NULL,
291
+ redirect_uri TEXT NOT NULL,
292
+ scope TEXT NOT NULL,
293
+ nonce TEXT,
294
+ code_challenge TEXT NOT NULL,
295
+ user_id TEXT NOT NULL,
296
+ -- TODO.identity/11: the active-org context the consent decision was made
297
+ -- under (NULL = the primary context); the token endpoint re-judges it
298
+ -- against the live membership before emitting the claims.
299
+ context_org TEXT,
300
+ -- TODO.identity-sso/02+03: the consenting session's amr provenance (a
301
+ -- JSON array; NULL = none recorded), carried into the ID token.
302
+ amr TEXT,
303
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
304
+ expires_at TEXT NOT NULL,
305
+ consumed_at TEXT
306
+ );
307
+
308
+ -- The issued access tokens (the userinfo endpoint resolves them).
309
+ CREATE TABLE IF NOT EXISTS oidc_access_tokens (
310
+ token TEXT PRIMARY KEY,
311
+ user_id TEXT NOT NULL,
312
+ client_id TEXT NOT NULL,
313
+ scope TEXT NOT NULL,
314
+ -- TODO.identity/11: the granting code's context — userinfo answers the
315
+ -- SAME claims the ID token carried.
316
+ context_org TEXT,
317
+ -- TODO.identity-sso/02+03: the authorizing authentication's amr
318
+ -- provenance — userinfo answers the same truth the ID token carried.
319
+ amr TEXT,
320
+ expires_at TEXT NOT NULL,
321
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
322
+ );
323
+
324
+ -- The OP's signing-key rotation history: the PUBLIC halves. JWKS serves
325
+ -- every row not retired beyond the token lifetime, so a rotation never
326
+ -- strands an in-flight ID token. The private half rides the
327
+ -- OP_SIGNING_KEY secret, never the database.
328
+ CREATE TABLE IF NOT EXISTS oidc_keys (
329
+ kid TEXT PRIMARY KEY,
330
+ public_jwk TEXT NOT NULL,
331
+ status TEXT NOT NULL DEFAULT 'active',
332
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
333
+ retired_at TEXT
334
+ );
335
+
336
+ -- ═══════════════════════════════════════════════════════════════════
337
+ -- TODO.identity/08 — the upstream provider registry (the OP's sign-in
338
+ -- methods: GitHub + Google + Apple + Entra + generic OIDC). Adding a
339
+ -- provider is a ROW, never a code fork.
340
+ -- ═══════════════════════════════════════════════════════════════════
341
+
342
+ -- The upstream registry. kind: 'github' (the OAuth web flow) or 'oidc'
343
+ -- (discovery + code + PKCE against the issuer — Google, Entra, Apple,
344
+ -- a generic Keycloak; Apple's documented quirks key on the issuer host,
345
+ -- auth/upstream/registry.ts). The client SECRET is never stored:
346
+ -- client_secret_ref names an environment variable ('env:<NAME>'), the
347
+ -- same discipline as OIDC_CLIENT_SECRET_REF. enabled=0 rows stay
348
+ -- invisible to the login page and refuse flows.
349
+ CREATE TABLE IF NOT EXISTS identity_providers (
350
+ id TEXT PRIMARY KEY,
351
+ kind TEXT NOT NULL,
352
+ display_name TEXT NOT NULL,
353
+ brand_mark TEXT,
354
+ issuer TEXT,
355
+ client_id TEXT NOT NULL,
356
+ client_secret_ref TEXT,
357
+ scopes TEXT,
358
+ enabled INTEGER NOT NULL DEFAULT 0,
359
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
360
+ created_by TEXT,
361
+ updated_at TEXT
362
+ );
363
+
364
+ -- The linked identities (TODO.identity/02's spec shape: user, provider,
365
+ -- provider_account_id, linked_at, linked_by). THE MATCH RULE: an
366
+ -- upstream sign-in resolves by (provider, provider_account_id) — NEVER
367
+ -- by email alone. UNIQUE(provider, provider_account_id): one upstream
368
+ -- account links to exactly one OIML SMART account.
369
+ CREATE TABLE IF NOT EXISTS identity_links (
370
+ id TEXT PRIMARY KEY,
371
+ user_id TEXT NOT NULL REFERENCES users(id),
372
+ provider TEXT NOT NULL,
373
+ provider_account_id TEXT NOT NULL,
374
+ linked_at TEXT NOT NULL DEFAULT (datetime('now')),
375
+ linked_by TEXT,
376
+ UNIQUE (provider, provider_account_id)
377
+ );
378
+ CREATE INDEX IF NOT EXISTS idx_identity_links_user ON identity_links (user_id);
379
+
380
+ -- ═══════════════════════════════════════════════════════════════════
381
+ -- TODO.identity/02 — the OP's account model: real password accounts
382
+ -- (invite-only) and the enrollment links. Same rule as item 01:
383
+ -- everything survives Worker isolates in the database.
384
+ -- ═══════════════════════════════════════════════════════════════════
385
+
386
+ -- The password credentials, APART from the users row: only OP accounts
387
+ -- (provider='password') carry a row, and no SELECT * on users ever reads
388
+ -- credential material. hash is self-describing (pbkdf2:<iters>:<salt>:
389
+ -- <digest>, auth/passwords.ts) so a cost change never strands an account.
390
+ CREATE TABLE IF NOT EXISTS passwords (
391
+ user_id TEXT PRIMARY KEY REFERENCES users(id),
392
+ hash TEXT NOT NULL,
393
+ set_at TEXT NOT NULL DEFAULT (datetime('now')),
394
+ set_by TEXT
395
+ );
396
+
397
+ -- The invite-only enrollment links: an admin creates the account, the
398
+ -- user receives this one-time setup link (24 h) and sets their password.
399
+ -- consumed_at flips atomically at completion (UPDATE … WHERE consumed_at
400
+ -- IS NULL — the oidc_codes pattern), so a presented link works exactly
401
+ -- once; an expired one is burned on presentation, never redeemed later.
402
+ CREATE TABLE IF NOT EXISTS enrollment_tokens (
403
+ token TEXT PRIMARY KEY,
404
+ user_id TEXT NOT NULL REFERENCES users(id),
405
+ created_by TEXT,
406
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
407
+ expires_at TEXT NOT NULL,
408
+ consumed_at TEXT
409
+ );
410
+ CREATE INDEX IF NOT EXISTS idx_enrollment_tokens_user ON enrollment_tokens (user_id);
411
+
412
+ -- TODO.identity/06 — the account console's email change ceremony. Same
413
+ -- doctrine as the enrollment links: a 256-bit random token backed by this
414
+ -- row (one-time, 24 h, atomically consumed). delivered_by records HOW the
415
+ -- link reached the user: 'mailer' (TODO.identity/09's send path; completing
416
+ -- it verifies the new address) or 'shown' (no mailer configured, the link
417
+ -- was displayed to the signed-in holder; completing it applies the change
418
+ -- but the address stays unverified, honestly). A fresh request voids the
419
+ -- account's earlier pending rows: only the newest link works.
420
+ CREATE TABLE IF NOT EXISTS email_change_tokens (
421
+ token TEXT PRIMARY KEY,
422
+ user_id TEXT NOT NULL REFERENCES users(id),
423
+ new_email TEXT NOT NULL,
424
+ delivered_by TEXT NOT NULL DEFAULT 'shown',
425
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
426
+ expires_at TEXT NOT NULL,
427
+ consumed_at TEXT
428
+ );
429
+ CREATE INDEX IF NOT EXISTS idx_email_change_tokens_user ON email_change_tokens (user_id);
430
+
431
+ -- ═══════════════════════════════════════════════════════════════════
432
+ -- TODO.identity/10 — delegated organization administration: the
433
+ -- self-service join requests. A staff member asks for an account naming
434
+ -- their organization FROM THE PARTICIPANTS REGISTER (org_id set — the
435
+ -- request lands with the ORG's admin); the "my organization is not
436
+ -- listed" path leaves org_id NULL and carries the free-text name in
437
+ -- org_name_text — those land with BIML (the new-organizations queue).
438
+ -- The decision is atomic on status='pending' (a double decide loses);
439
+ -- approval records the invited account (invited_user_id).
440
+ -- ═══════════════════════════════════════════════════════════════════
441
+ CREATE TABLE IF NOT EXISTS org_join_requests (
442
+ id TEXT PRIMARY KEY,
443
+ name TEXT NOT NULL,
444
+ email TEXT NOT NULL,
445
+ -- The selected REGISTERED participant org (NULL = the not-listed path).
446
+ org_id TEXT,
447
+ -- The free-text organization name when org_id is NULL (BIML's queue).
448
+ org_name_text TEXT,
449
+ -- The role asked for (bounded by the org's kind at submit AND at
450
+ -- approval; 'org_admin' on the not-listed path — the requester becomes
451
+ -- the org's administrator after BIML's verification).
452
+ requested_role TEXT NOT NULL,
453
+ note TEXT,
454
+ status TEXT NOT NULL DEFAULT 'pending',
455
+ decided_by TEXT,
456
+ decided_at TEXT,
457
+ refusal_reason TEXT,
458
+ invited_user_id TEXT,
459
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
460
+ );
461
+ CREATE INDEX IF NOT EXISTS idx_org_join_requests_org ON org_join_requests (org_id, status);
462
+
463
+ -- ═══════════════════════════════════════════════════════════════════
464
+ -- TODO.identity/03 — the central user registry: the PER-CLIENT role
465
+ -- assignments. The account's OP-side role set (users.role/roles) is its
466
+ -- federation-wide default; a row here overrides it for ONE relying
467
+ -- party (an oidc_clients row): the ID token issued to that client
468
+ -- carries these roles, filtered by the client's claims-policy role
469
+ -- allowlist (oidc_clients.claims_policy.roles). roles='[]' is the
470
+ -- explicit "no roles on this client" (the instance's approval-queue
471
+ -- posture) — distinct from NO ROW, which restores the account default.
472
+ -- ═══════════════════════════════════════════════════════════════════
473
+ CREATE TABLE IF NOT EXISTS op_client_roles (
474
+ user_id TEXT NOT NULL REFERENCES users(id),
475
+ client_id TEXT NOT NULL,
476
+ roles TEXT NOT NULL,
477
+ assigned_by TEXT,
478
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
479
+ updated_at TEXT,
480
+ PRIMARY KEY (user_id, client_id)
481
+ );
482
+
483
+ -- TODO.identity/04 — the relying party's OIDC sign-in state jar (the
484
+ -- /signin/oidc → /callback/oidc round trip's one-time state: the nonce +
485
+ -- the PKCE verifier). STORE-BACKED so the Worker's isolates share it —
486
+ -- the per-process Map intermittently failed the state check across
487
+ -- isolates (the GitHub-flow lesson, retired for SSO too).
488
+ CREATE TABLE IF NOT EXISTS sso_states (
489
+ state TEXT PRIMARY KEY,
490
+ nonce TEXT NOT NULL,
491
+ verifier TEXT NOT NULL,
492
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
493
+ expires_at TEXT NOT NULL,
494
+ consumed_at TEXT
495
+ );
496
+
497
+ -- ═══════════════════════════════════════════════════════════════════
498
+ -- TODO.identity/11 — the multi-organization membership model. One row
499
+ -- per (account, org): the PER-ORG role set + the lifecycle state
500
+ -- (invited → active ⇄ disabled). The account acts AS one org at a time
501
+ -- (the session's active_org stamp); the OP's claims carry the active
502
+ -- org's role set, and a relying party never learns the other
503
+ -- memberships.
504
+ --
505
+ -- THE DUAL-READ DOCTRINE: the users row's org_id/roles columns stay the
506
+ -- backward-compatible read — the PRIMARY membership's mirror
507
+ -- (is_primary=1) — until every consumer reads the memberships. The
508
+ -- store mirrors every legacy write into the primary row, and every
509
+ -- membership write on the primary row back into the columns.
510
+ -- ═══════════════════════════════════════════════════════════════════
511
+ CREATE TABLE IF NOT EXISTS org_memberships (
512
+ id TEXT PRIMARY KEY,
513
+ user_id TEXT NOT NULL REFERENCES users(id),
514
+ org_id TEXT NOT NULL,
515
+ -- The per-org role set (JSON array); the claims the account's tokens
516
+ -- carry when acting AS this org.
517
+ roles TEXT NOT NULL DEFAULT '[]',
518
+ state TEXT NOT NULL DEFAULT 'active',
519
+ is_primary INTEGER NOT NULL DEFAULT 0,
520
+ invited_by TEXT,
521
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
522
+ activated_at TEXT,
523
+ disabled_at TEXT,
524
+ disabled_by TEXT,
525
+ UNIQUE (user_id, org_id)
526
+ );
527
+ CREATE INDEX IF NOT EXISTS idx_org_memberships_org ON org_memberships (org_id, state);
528
+ CREATE INDEX IF NOT EXISTS idx_org_memberships_user ON org_memberships (user_id, state);
529
+ -- TODO.identity-sso/02 + /03 — the strong-authentication wave: the
530
+ -- factor registry (passkeys, TOTP authenticator apps, recovery codes),
531
+ -- the one-time ceremony state, and the provenance columns above. The
532
+ -- D1 migration set carries the identical end state (0012_strong_auth.sql).
533
+ -- ═══════════════════════════════════════════════════════════════════
534
+
535
+ -- The passkeys. credential_id is the authenticator's own (base64url);
536
+ -- public_key the COSE key bytes (base64url) as the attestation carried
537
+ -- them; sign_count the signature counter — a REGRESSED count on an
538
+ -- assertion is the clone signal (the advance is a guarded UPDATE, the
539
+ -- regression refuses + audits). aaguid + transports record what the
540
+ -- browser declared (attestation is 'none' at this assurance level —
541
+ -- display hints for the console, never proof).
542
+ CREATE TABLE IF NOT EXISTS webauthn_credentials (
543
+ credential_id TEXT PRIMARY KEY,
544
+ user_id TEXT NOT NULL REFERENCES users(id),
545
+ name TEXT NOT NULL,
546
+ public_key TEXT NOT NULL,
547
+ sign_count INTEGER NOT NULL DEFAULT 0,
548
+ aaguid TEXT,
549
+ transports TEXT,
550
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
551
+ last_used_at TEXT,
552
+ last_ip TEXT
553
+ );
554
+ CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials (user_id);
555
+
556
+ -- The TOTP authenticator apps (RFC 6238: 30 s step, 6 digits, HMAC-SHA-1).
557
+ -- verified_at NULL = the PENDING enrollment — it activates ONLY on the
558
+ -- first valid code; the enrollment verify is throttled hard (fail_count +
559
+ -- last_failure_at: the six-digit window invites brute force). The secret
560
+ -- is base32 and never leaves the server after the enrollment answer.
561
+ CREATE TABLE IF NOT EXISTS totp_secrets (
562
+ id TEXT PRIMARY KEY,
563
+ user_id TEXT NOT NULL REFERENCES users(id),
564
+ name TEXT NOT NULL,
565
+ secret TEXT NOT NULL,
566
+ fail_count INTEGER NOT NULL DEFAULT 0,
567
+ last_failure_at TEXT,
568
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
569
+ verified_at TEXT,
570
+ last_used_at TEXT,
571
+ last_ip TEXT
572
+ );
573
+ CREATE INDEX IF NOT EXISTS idx_totp_secrets_user ON totp_secrets (user_id);
574
+
575
+ -- The recovery codes: generated at the first factor's enrollment, shown
576
+ -- once, stored HASHED (SHA-256 of the normalized code — 80 bits of random
577
+ -- per code, so the unsalted hash resists the offline attack), one time
578
+ -- each (consumed_at flips atomically). Regeneration REPLACES the account's
579
+ -- set (batch marks the generation; the old set goes with the audit event).
580
+ CREATE TABLE IF NOT EXISTS recovery_codes (
581
+ id TEXT PRIMARY KEY,
582
+ user_id TEXT NOT NULL REFERENCES users(id),
583
+ batch TEXT NOT NULL,
584
+ code_hash TEXT NOT NULL,
585
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
586
+ consumed_at TEXT
587
+ );
588
+ CREATE INDEX IF NOT EXISTS idx_recovery_codes_user ON recovery_codes (user_id);
589
+
590
+ -- The one-time WebAuthn ceremony challenges (the database-is-the-proof
591
+ -- doctrine — the sso_states/enrollment_tokens pattern): short TTL,
592
+ -- consumed atomically. user_id binds the registration + the second-factor
593
+ -- assertion to the account; the PASSWORDLESS assertion's row carries NULL
594
+ -- (the asserted credential id resolves the account).
595
+ CREATE TABLE IF NOT EXISTS webauthn_challenges (
596
+ challenge TEXT PRIMARY KEY,
597
+ user_id TEXT REFERENCES users(id),
598
+ kind TEXT NOT NULL,
599
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
600
+ expires_at TEXT NOT NULL,
601
+ consumed_at TEXT
602
+ );
603
+ CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user ON webauthn_challenges (user_id);
604
+
605
+ -- The pending second-factor sign-in: the password verified, the account
606
+ -- holds factors, the session waits on the factor. One-time (consumed at
607
+ -- completion), short TTL; the per-account throttle rides the row
608
+ -- (fail_count + last_failure_at give the backoff ladder; the cap burns
609
+ -- the attempt — the audit event + the account's lockout email).
610
+ CREATE TABLE IF NOT EXISTS mfa_pending (
611
+ token TEXT PRIMARY KEY,
612
+ user_id TEXT NOT NULL REFERENCES users(id),
613
+ amr TEXT NOT NULL,
614
+ fail_count INTEGER NOT NULL DEFAULT 0,
615
+ last_failure_at TEXT,
616
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
617
+ expires_at TEXT NOT NULL,
618
+ consumed_at TEXT
619
+ );
620
+ CREATE INDEX IF NOT EXISTS idx_mfa_pending_user ON mfa_pending (user_id);
621
+
622
+ -- ═══════════════════════════════════════════════════════════════════
623
+ -- TODO.identity-features/05 — the organization registry: organizations
624
+ -- as first-class citizens of the identity plane. One row per org: the
625
+ -- stable SLUG id (for a participant org the OIML code IS the id — the
626
+ -- platform resolves the org claim against its own participant registry
627
+ -- directly, the mapping is identity), the display data, the OPTIONAL
628
+ -- participant_ref annotation (the link's documentation, never a key),
629
+ -- and the lifecycle state (active ⇄ disabled — the honest removal; the
630
+ -- erasure-adjacent hard delete is the route's guarded act).
631
+ --
632
+ -- The membership graph (org_memberships above) references the org by
633
+ -- its id — deliberately WITHOUT a foreign key: the scheme-side
634
+ -- participants register (the entity store) and this identity-side
635
+ -- registry never merge (the spec's §4), and a membership row's honesty
636
+ -- (its lifecycle state) never depends on a join.
637
+ -- The D1 migration set carries the identical end state
638
+ -- (0013_org_registry.sql).
639
+ -- ═══════════════════════════════════════════════════════════════════
640
+ CREATE TABLE IF NOT EXISTS org_registry (
641
+ id TEXT PRIMARY KEY,
642
+ name TEXT NOT NULL,
643
+ short_name TEXT,
644
+ -- The participant kind (the OIML-CS program's four); NULL = a
645
+ -- non-participant org (the estate operator's own org, a consumer).
646
+ kind TEXT,
647
+ country TEXT,
648
+ -- The contacts (a JSON array of { name, email }; a malformed entry is
649
+ -- skipped on read, never trusted).
650
+ contacts TEXT NOT NULL DEFAULT '[]',
651
+ -- The participant-link annotation (which participant record the org
652
+ -- mirrors); documentation only.
653
+ participant_ref TEXT,
654
+ state TEXT NOT NULL DEFAULT 'active',
655
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
656
+ created_by TEXT,
657
+ updated_at TEXT,
658
+ updated_by TEXT,
659
+ disabled_at TEXT,
660
+ disabled_by TEXT
661
+ );
662
+ CREATE INDEX IF NOT EXISTS idx_org_registry_state ON org_registry (state);
663
+
664
+ -- ═══════════════════════════════════════════════════════════════════
665
+ -- TODO.register/02 — the register's holder-org attribution: the
666
+ -- certificate's holder as the OP-minted ORG id. The hub stores the
667
+ -- descriptor the federation registration package carried (or the
668
+ -- confirmed legacy-row claim) as its OWN row — never a write into the
669
+ -- sender's imported artifact. One row per certificate: the first
670
+ -- attribution wins. The org display name is denormalized (the register
671
+ -- reads correctly across a later rename).
672
+ --
673
+ -- The claim act's state machine sits beside it: a pre-program (or
674
+ -- records-mode / CSV) registered certificate carries a free-text holder
675
+ -- and no descriptor; the manufacturer org's administrator claims by
676
+ -- holder-name match (the matched name snapshots as the evidence), an
677
+ -- estate admin confirms or refuses (atomic on 'pending'), and the audit
678
+ -- chain carries both acts. A refused claim never blocks a fresh one; a
679
+ -- confirmed claim is terminal.
680
+ -- The D1 migration set carries the identical end state
681
+ -- (0015_certificate_holder_attribution.sql).
682
+ -- ═══════════════════════════════════════════════════════════════════
683
+ CREATE TABLE IF NOT EXISTS certificate_holder_orgs (
684
+ certificate_id TEXT PRIMARY KEY,
685
+ org_id TEXT NOT NULL,
686
+ org_name TEXT NOT NULL,
687
+ source TEXT NOT NULL,
688
+ attributed_at TEXT NOT NULL,
689
+ attributed_by TEXT,
690
+ claim_id TEXT
691
+ );
692
+ CREATE INDEX IF NOT EXISTS idx_certificate_holder_orgs_org ON certificate_holder_orgs (org_id);
693
+
694
+ CREATE TABLE IF NOT EXISTS certificate_holder_claims (
695
+ id TEXT PRIMARY KEY,
696
+ certificate_id TEXT NOT NULL,
697
+ claimant_org_id TEXT NOT NULL,
698
+ claimant_org_name TEXT NOT NULL,
699
+ matched_holder_name TEXT NOT NULL,
700
+ claimed_by TEXT NOT NULL,
701
+ state TEXT NOT NULL DEFAULT 'pending',
702
+ decided_by TEXT,
703
+ decided_at TEXT,
704
+ refusal_reason TEXT,
705
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
706
+ );
707
+ CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_cert ON certificate_holder_claims (certificate_id);
708
+ CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_state ON certificate_holder_claims (state);
709
+ CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_org ON certificate_holder_claims (claimant_org_id);
710
+ -- TODO.register/03 — the instrument register: the per-serial
711
+ -- registration of individual instruments under a type certificate's
712
+ -- scope. One row per registered instrument: the certificate it rides
713
+ -- under, the holder organization (the manufacturer org id, referenced
714
+ -- WITHOUT a foreign key — the identity plane and this platform-side
715
+ -- register never merge, the org_memberships posture), the
716
+ -- Recommendation, the manufacture date, the per-serial designations the
717
+ -- scope check evaluated (JSON), the scope verdict recorded AT
718
+ -- REGISTRATION (in_scope / scope_unverified — the records-mode honest
719
+ -- degradation; a REFUSED declaration never lands a row), and the
720
+ -- lifecycle (registered / out_of_service / withdrawn). One serial
721
+ -- number per certificate (the UNIQUE) — the same physical unit never
722
+ -- registers twice under one type certificate.
723
+ -- The D1 migration set carries the identical end state
724
+ -- (0016_instrument_registrations.sql).
725
+ -- ═══════════════════════════════════════════════════════════════════
726
+ CREATE TABLE IF NOT EXISTS instrument_registrations (
727
+ id TEXT PRIMARY KEY,
728
+ certificate_id TEXT NOT NULL,
729
+ holder_org_id TEXT NOT NULL,
730
+ standard_id TEXT NOT NULL,
731
+ serial_number TEXT NOT NULL,
732
+ manufacture_date TEXT,
733
+ designations TEXT NOT NULL DEFAULT '{}',
734
+ scope_status TEXT NOT NULL,
735
+ scope_detail TEXT,
736
+ lifecycle TEXT NOT NULL DEFAULT 'registered',
737
+ registered_at TEXT NOT NULL DEFAULT (datetime('now')),
738
+ registered_by TEXT,
739
+ updated_at TEXT,
740
+ updated_by TEXT,
741
+ UNIQUE (certificate_id, serial_number)
742
+ );
743
+ CREATE INDEX IF NOT EXISTS idx_instrument_registrations_certificate ON instrument_registrations (certificate_id);
744
+ CREATE INDEX IF NOT EXISTS idx_instrument_registrations_holder ON instrument_registrations (holder_org_id);
745
+ CREATE INDEX IF NOT EXISTS idx_instrument_registrations_lifecycle ON instrument_registrations (lifecycle);