@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.
- package/README.md +92 -0
- package/migrations/0001_init.sql +69 -0
- package/migrations/0002_identity.sql +24 -0
- package/migrations/0003_federation_peers.sql +21 -0
- package/migrations/0003_users_rbac.sql +8 -0
- package/migrations/0004_oidc_op.sql +61 -0
- package/migrations/0005_upstream_providers.sql +34 -0
- package/migrations/0006_op_accounts.sql +28 -0
- package/migrations/0007_org_join_requests.sql +26 -0
- package/migrations/0008_op_client_roles.sql +19 -0
- package/migrations/0009_account_console.sql +32 -0
- package/migrations/0009_sso_states.sql +13 -0
- package/migrations/0010_notify_events.sql +23 -0
- package/migrations/0011_op_launch.sql +18 -0
- package/migrations/0011_org_memberships.sql +62 -0
- package/migrations/0012_notify_subscriptions.sql +57 -0
- package/migrations/0012_strong_auth.sql +109 -0
- package/migrations/0013_org_registry.sql +53 -0
- package/migrations/0014_notify_inbox.sql +34 -0
- package/migrations/0015_certificate_holder_attribution.sql +63 -0
- package/migrations/0016_instrument_registrations.sql +78 -0
- package/package.json +52 -0
- package/src/client-info.ts +25 -0
- package/src/context.ts +31 -0
- package/src/github.ts +284 -0
- package/src/mailer.ts +309 -0
- package/src/oidc.ts +369 -0
- package/src/profile/node.ts +83 -0
- package/src/profile.ts +582 -0
- package/src/rbac/node.ts +42 -0
- package/src/rbac.ts +53 -0
- package/src/session.ts +45 -0
- package/src/store/d1.ts +2850 -0
- package/src/store/sqlite/entities.ts +71 -0
- package/src/store/sqlite/events.ts +82 -0
- package/src/store/sqlite/factors-store.ts +348 -0
- package/src/store/sqlite/notify.ts +247 -0
- package/src/store/sqlite/op-accounts-store.ts +470 -0
- package/src/store/sqlite/op-store.ts +280 -0
- package/src/store/sqlite/schema.sql +745 -0
- package/src/store/sqlite/store.ts +1390 -0
- package/src/store/sqlite/upstream-store.ts +148 -0
- package/src/store/sqlite.ts +1027 -0
- package/src/store.ts +1826 -0
- package/src/vocab/index.ts +12 -0
- package/src/vocab/permissions.ts +398 -0
- package/src/vocab/rbac.ts +281 -0
- 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);
|