@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
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
|
+
}
|