@oimlsmart/platform-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +92 -0
  2. package/migrations/0001_init.sql +69 -0
  3. package/migrations/0002_identity.sql +24 -0
  4. package/migrations/0003_federation_peers.sql +21 -0
  5. package/migrations/0003_users_rbac.sql +8 -0
  6. package/migrations/0004_oidc_op.sql +61 -0
  7. package/migrations/0005_upstream_providers.sql +34 -0
  8. package/migrations/0006_op_accounts.sql +28 -0
  9. package/migrations/0007_org_join_requests.sql +26 -0
  10. package/migrations/0008_op_client_roles.sql +19 -0
  11. package/migrations/0009_account_console.sql +32 -0
  12. package/migrations/0009_sso_states.sql +13 -0
  13. package/migrations/0010_notify_events.sql +23 -0
  14. package/migrations/0011_op_launch.sql +18 -0
  15. package/migrations/0011_org_memberships.sql +62 -0
  16. package/migrations/0012_notify_subscriptions.sql +57 -0
  17. package/migrations/0012_strong_auth.sql +109 -0
  18. package/migrations/0013_org_registry.sql +53 -0
  19. package/migrations/0014_notify_inbox.sql +34 -0
  20. package/migrations/0015_certificate_holder_attribution.sql +63 -0
  21. package/migrations/0016_instrument_registrations.sql +78 -0
  22. package/package.json +52 -0
  23. package/src/client-info.ts +25 -0
  24. package/src/context.ts +31 -0
  25. package/src/github.ts +284 -0
  26. package/src/mailer.ts +309 -0
  27. package/src/oidc.ts +369 -0
  28. package/src/profile/node.ts +83 -0
  29. package/src/profile.ts +582 -0
  30. package/src/rbac/node.ts +42 -0
  31. package/src/rbac.ts +53 -0
  32. package/src/session.ts +45 -0
  33. package/src/store/d1.ts +2850 -0
  34. package/src/store/sqlite/entities.ts +71 -0
  35. package/src/store/sqlite/events.ts +82 -0
  36. package/src/store/sqlite/factors-store.ts +348 -0
  37. package/src/store/sqlite/notify.ts +247 -0
  38. package/src/store/sqlite/op-accounts-store.ts +470 -0
  39. package/src/store/sqlite/op-store.ts +280 -0
  40. package/src/store/sqlite/schema.sql +745 -0
  41. package/src/store/sqlite/store.ts +1390 -0
  42. package/src/store/sqlite/upstream-store.ts +148 -0
  43. package/src/store/sqlite.ts +1027 -0
  44. package/src/store.ts +1826 -0
  45. package/src/vocab/index.ts +12 -0
  46. package/src/vocab/permissions.ts +398 -0
  47. package/src/vocab/rbac.ts +281 -0
  48. package/src/vocab/roles.ts +162 -0
package/src/profile.ts ADDED
@@ -0,0 +1,582 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The deployment profile (TODO.federation/01) — one codebase, four
3
+ // deployment configurations (TODO.federation/00): the BIML-operated
4
+ // hub, an NMI's Issuing-Authority instance, a contracted Test
5
+ // Laboratory's instance, and the combined NMI-as-IA+TL. The profile
6
+ // declares who THIS instance is (its own organization identity), which
7
+ // role(s) it plays, which modules are on, its federation peers, and its
8
+ // branding. Profiles gate routes, nav, consoles, and seeds.
9
+ //
10
+ // This module is the WORKER-SAFE core: the schema, the module catalog,
11
+ // the defaults per role set (the 00 module map), the parser (profile
12
+ // YAML text → the effective profile), the isolate-level install slot
13
+ // (the same pattern db/backend.ts uses for the store), and the
14
+ // profile-aware account/organization seed plans. The NODE disk loader
15
+ // (INSTANCE_PROFILE env path, else <consumer root>/instance.profile.yaml
16
+ // — the consumer passes its root; the kernel never anchors at its own
17
+ // package root) lives in ./profile/node.ts — never imported by the
18
+ // Worker bundle.
19
+ //
20
+ // The DEFAULT is the hub profile: a boot with no profile file behaves
21
+ // byte-identically to the pre-profiles app. The client learns the
22
+ // profile from /api/config (see src/persistence/instance-profile.ts).
23
+ //
24
+ // Naming: this is the INSTANCE/deployment profile — a different concept
25
+ // from the PERSISTENCE profile (src/persistence/profile.ts, which store
26
+ // backend the workspace uses). The two never share a name.
27
+ // ═══════════════════════════════════════════════════════════════════
28
+
29
+ import { load as parseYaml } from 'js-yaml'
30
+ import { DEMO_ACCOUNTS } from './store'
31
+
32
+ // ── The vocabulary ──────────────────────────────────────────────────
33
+
34
+ export const PROFILE_ROLES = ['hub', 'ia', 'tl', 'identity'] as const
35
+ export type ProfileRole = (typeof PROFILE_ROLES)[number]
36
+
37
+ /** The modules profiles toggle (TODO.federation/00's module map; the
38
+ * `identity` module is TODO.identity/01's OIDC Provider). The
39
+ * catalog order is the canonical display/serialization order. */
40
+ export const INSTANCE_MODULES = [
41
+ 'portal', // the applicant self-service portal
42
+ 'ia-console', // the Issuing Authority console (applications → certificates)
43
+ 'tl-workbench',// the Test Laboratory workbench (requests → reports)
44
+ 'biml-console',// the BIML portal
45
+ 'register', // the certificate register + the public verify page (TODO.adoption/05: every certification body publishes one — the hub the OIML-CS register, the NMI its own schemes')
46
+ 'cs-admin', // the OIML-CS scheme-administration console (the participants registry…)
47
+ 'engagement', // the pre-application phase (inquiry → quotation → agreement; item 02)
48
+ 'federation', // the peer registry / outbox / inbox (items 04–06; hub carries the intake)
49
+ 'cnml', // CNML signing at certificate issue
50
+ 'twin', // the twin/sim binding (twin console, twin lab, sim bench)
51
+ 'engine', // the monitoring daemon (server-side; env-driven, listed for the settings view)
52
+ 'identity', // the OIDC Provider (TODO.identity/01): discovery, JWKS, authorize/consent, token, userinfo
53
+ ] as const
54
+ export type InstanceModule = (typeof INSTANCE_MODULES)[number]
55
+
56
+ export interface ProfileIdentity {
57
+ /** The id the workflow records reference for this instance's own org:
58
+ * the IA's oiml_code ('EX1'-style) when the instance plays `ia`; the
59
+ * TL's oiml_id in string form ('21'-style) when it plays only `tl`. */
60
+ org_id: string
61
+ org_name: string
62
+ /** The OIML-CS participant roles the instance's own org plays. */
63
+ role_codes: ProfileRole[]
64
+ /** The TL organization's id (its numeric oiml_id in string form) when
65
+ * the instance plays `tl` and the TL id differs from org_id — the
66
+ * combined NMI (ia+tl) case. Defaults to org_id. Must be numeric. */
67
+ tl_org_id?: string
68
+ country?: string
69
+ }
70
+
71
+ export interface StaffAccount {
72
+ email: string
73
+ name: string
74
+ role: string
75
+ /** Defaults to the identity org for the org-scoped roles
76
+ * (ia_officer → org_id, tl_operator → the TL org id). */
77
+ org_id?: string
78
+ }
79
+
80
+ export interface FederationPeer {
81
+ id: string
82
+ name?: string
83
+ roles?: string[]
84
+ endpoint?: string
85
+ /** The peer's submission public key — managed by TODO.federation/04;
86
+ * the profile only carries the placeholder list. */
87
+ public_key?: string
88
+ }
89
+
90
+ /** The resolved identity (post-parse): `country` settled to a string. */
91
+ export interface ResolvedIdentity extends ProfileIdentity {
92
+ country: string
93
+ }
94
+
95
+ /** The resolved staff row (post-parse): org_id settled to string|null. */
96
+ export interface ResolvedStaffAccount {
97
+ email: string
98
+ name: string
99
+ role: string
100
+ org_id: string | null
101
+ }
102
+
103
+ /** The EFFECTIVE profile — parsed, validated, and resolved (modules
104
+ * expanded from the role-set defaults when not spelled out, staff
105
+ * derived when not declared, the demo-personas flag settled). */
106
+ export interface InstanceProfile {
107
+ identity: ResolvedIdentity
108
+ roles: ProfileRole[]
109
+ modules: InstanceModule[]
110
+ peers: FederationPeer[]
111
+ branding: { name: string }
112
+ /** Seed the demo cast alongside the instance identity. Resolved from
113
+ * `demo_personas:` (the SEED_DEMO_PERSONAS env flag forces on, never
114
+ * off); the hub DEFAULTS it on (the historical default), so a hub
115
+ * carrying `demo_personas: false` skips the cast everywhere — the
116
+ * account seed, and the demo sign-in surface alongside
117
+ * (TODO.demo-ops/01, the production posture: identity.ts's
118
+ * demoAccountsEnabled reads this same flag). */
119
+ demoPersonas: boolean
120
+ /** TODO.demo-ops/04 — the demonstration-environment signage: `demo:
121
+ * true` renders the persistent "Demonstration environment" banner on
122
+ * every page (components/DemoBanner.vue, reading this flag through
123
+ * /api/config) and opens the guided-demo entry on the login page.
124
+ * Deliberately SEPARATE from demoPersonas: the cast flag answers
125
+ * "does this instance carry the fictional accounts" (a seeding and
126
+ * sign-in question), this one answers "is this whole instance a
127
+ * demonstration" (an honesty signal). They coincide on the demo
128
+ * instances (demo/nmi/tl); the PR previews keep the cast WITHOUT the
129
+ * banner (operator-facing, transient), and a training instance could
130
+ * carry the banner without the cast. Defaults off everywhere. */
131
+ demo: boolean
132
+ /** The instance's own accounts (non-hub profiles): the declared
133
+ * `staff:` or one derived account per played role + an instance
134
+ * admin. Empty on the hub (its cast is DEMO_ACCOUNTS). */
135
+ staff: ResolvedStaffAccount[]
136
+ }
137
+
138
+ // ── The defaults (the TODO.federation/00 module map) ────────────────
139
+
140
+ /** The module set a role set boots with when the profile does not spell
141
+ * `modules:` out — the 00 map, verbatim:
142
+ * hub : portal + ia-console + tl-workbench + biml-console + cs-admin
143
+ * + federation (intake) + cnml + twin
144
+ * ia : ia-console + engagement + federation + cnml + twin
145
+ * (portal OFF — the white-gloves posture; opt in by listing it)
146
+ * tl : tl-workbench + federation + twin
147
+ * ia+tl : the ia set + tl-workbench
148
+ * identity : identity only (TODO.identity/01 — the OIDC Provider
149
+ * instance serves the OP contract and nothing else)
150
+ * `engine` stays optional everywhere (the daemon mounts on
151
+ * ENGINE_CONFIG regardless; list it to advertise it in the settings
152
+ * view). cs-admin's read-only "directory cache" mode for ia/tl lands
153
+ * with the peer registry (TODO.federation/04) — until then the ia/tl
154
+ * profiles keep the module off. */
155
+ export function defaultModulesForRoles(roles: ProfileRole[]): InstanceModule[] {
156
+ const on = new Set<InstanceModule>()
157
+ if (roles.includes('identity')) {
158
+ on.add('identity')
159
+ } else if (roles.includes('hub')) {
160
+ for (const m of ['portal', 'ia-console', 'tl-workbench', 'biml-console', 'register', 'cs-admin', 'federation', 'cnml', 'twin'] as const) on.add(m)
161
+ } else {
162
+ on.add('federation')
163
+ on.add('twin')
164
+ if (roles.includes('ia')) {
165
+ on.add('ia-console')
166
+ on.add('engagement')
167
+ on.add('cnml')
168
+ // TODO.adoption/05: the NMI publishes its own register (its schemes'
169
+ // certificates with their access tiers); a pure laboratory has none.
170
+ on.add('register')
171
+ }
172
+ if (roles.includes('tl')) on.add('tl-workbench')
173
+ }
174
+ return INSTANCE_MODULES.filter(m => on.has(m))
175
+ }
176
+
177
+ /** The built-in hub profile — the default when no profile file exists.
178
+ * Everything the pre-profiles app did, named. */
179
+ export function defaultInstanceProfile(): InstanceProfile {
180
+ return {
181
+ identity: { org_id: 'biml', org_name: 'BIML', role_codes: ['hub'], country: '' },
182
+ roles: ['hub'],
183
+ modules: defaultModulesForRoles(['hub']),
184
+ peers: [],
185
+ branding: { name: 'OIML-CS SMART Platform' },
186
+ demoPersonas: true,
187
+ demo: false,
188
+ staff: [],
189
+ }
190
+ }
191
+
192
+ // ── Parse + validate + resolve ───────────────────────────────────────
193
+
194
+ function fail(message: string): never {
195
+ throw new Error(`instance profile: ${message}`)
196
+ }
197
+
198
+ function asStringList(value: unknown, field: string): string[] {
199
+ if (!Array.isArray(value) || value.some(v => typeof v !== 'string')) {
200
+ fail(`${field} must be a list of strings`)
201
+ }
202
+ return value as string[]
203
+ }
204
+
205
+ /** org_id → the local-part slug for derived account emails. */
206
+ function emailSlug(orgId: string): string {
207
+ const slug = orgId.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
208
+ return slug || 'instance'
209
+ }
210
+
211
+ /** The TL organization id for the identity (the oiml_id string form). */
212
+ export function tlOrgIdOf(identity: ProfileIdentity): string {
213
+ return identity.tl_org_id ?? identity.org_id
214
+ }
215
+
216
+ function deriveStaff(identity: ProfileIdentity, roles: ProfileRole[]): InstanceProfile['staff'] {
217
+ const slug = emailSlug(identity.org_id)
218
+ const staff: InstanceProfile['staff'] = [
219
+ { email: `admin@${slug}.example.org`, name: `${identity.org_name} Administrator`, role: 'admin', org_id: null },
220
+ ]
221
+ if (roles.includes('ia')) {
222
+ staff.push({ email: `officer@${slug}.example.org`, name: `${identity.org_name} Officer`, role: 'ia_officer', org_id: identity.org_id })
223
+ }
224
+ if (roles.includes('tl')) {
225
+ staff.push({ email: `operator@${slug}.example.org`, name: `${identity.org_name} Operator`, role: 'tl_operator', org_id: tlOrgIdOf(identity) })
226
+ }
227
+ return staff
228
+ }
229
+
230
+ export interface ParseEnv {
231
+ /** The SEED_DEMO_PERSONAS env flag resolution (node: process.env; the
232
+ * Worker: the binding) — true forces the demo cast on, anything else
233
+ * defers to the profile file. */
234
+ demoPersonas?: boolean
235
+ }
236
+
237
+ /**
238
+ * The parser: profile YAML text → the effective profile. `undefined`
239
+ * text means "no profile file" and answers the built-in hub default —
240
+ * the byte-identical-today posture. Throws (fails the boot) on a
241
+ * declared-but-invalid profile: a misdeclared instance must never boot
242
+ * into a guessed configuration.
243
+ *
244
+ * Tolerance rules (the shared profile file is read by three waves —
245
+ * TODO.federation/10's OIDC identity: seam, TODO.federation/09's
246
+ * branding: section, and this loader; unknown keys pass through):
247
+ * `identity.id`/`identity.name` are accepted as aliases of
248
+ * `org_id`/`org_name` (fed-09's fixtures use the short forms); `roles`
249
+ * omitted defaults to the identity's role_codes (else [hub]); an
250
+ * `identity` section without org fields is only valid on the hub role
251
+ * set (the hub seeds no identity org — the org fields matter exactly
252
+ * when the instance plays ia/tl).
253
+ */
254
+ export function parseInstanceProfile(yamlText: string | undefined, env?: ParseEnv): InstanceProfile {
255
+ if (yamlText === undefined) return defaultInstanceProfile()
256
+ const raw = parseYaml(yamlText) as unknown
257
+ if (raw === null || raw === undefined) return defaultInstanceProfile()
258
+ if (typeof raw !== 'object' || Array.isArray(raw)) fail('the profile must be a mapping')
259
+
260
+ const doc = raw as Record<string, unknown>
261
+
262
+ // identity (optional on the hub; required for ia/tl)
263
+ const identityRaw = doc.identity
264
+ if (typeof identityRaw !== 'undefined' && (typeof identityRaw !== 'object' || identityRaw === null)) {
265
+ fail('identity must be a mapping (org_id, org_name, role_codes — and/or the OIDC fields TODO.federation/10 reads)')
266
+ }
267
+ const identityIn = (identityRaw ?? {}) as Record<string, unknown>
268
+ // The fed-09 spelling (identity.id / identity.name) aliases the
269
+ // fed-01 one (org_id / org_name) — one file, both readers.
270
+ const orgId = identityIn.org_id ?? identityIn.id
271
+ const orgName = identityIn.org_name ?? identityIn.name
272
+
273
+ // roles (optional — defaults to the identity's role_codes, else hub)
274
+ const rolesIn = typeof doc.roles === 'undefined' ? undefined : asStringList(doc.roles, 'roles')
275
+ const roleCodesIn = asStringList(identityIn.role_codes ?? [], 'identity.role_codes') as ProfileRole[]
276
+ const roles = (rolesIn ?? (roleCodesIn.length ? roleCodesIn : ['hub'])) as ProfileRole[]
277
+ if (!roles.length) fail('roles must name at least one of [hub, ia, tl, identity]')
278
+ for (const r of roles) {
279
+ if (!(PROFILE_ROLES as readonly string[]).includes(r)) fail(`roles: unknown role "${r}"`)
280
+ }
281
+ if (roles.includes('hub') && roles.length > 1) fail('the hub role stands alone — a hub instance plays no ia/tl role')
282
+ // TODO.identity/01: the OIDC Provider is its own deployment — an
283
+ // identity instance plays no hub/ia/tl role alongside.
284
+ if (roles.includes('identity') && roles.length > 1) fail('the identity role stands alone — an identity instance plays no hub/ia/tl role')
285
+
286
+ const isHub = roles.includes('hub')
287
+ if (!isHub) {
288
+ if (typeof orgId !== 'string' || !orgId) fail('identity.org_id (or identity.id) is required on an ia/tl/identity instance (the org the workflow records reference)')
289
+ if (typeof orgName !== 'string' || !orgName) fail('identity.org_name (or identity.name) is required on an ia/tl/identity instance')
290
+ }
291
+ for (const rc of roleCodesIn) {
292
+ if (!(PROFILE_ROLES as readonly string[]).includes(rc)) fail(`identity.role_codes: unknown role code "${rc}"`)
293
+ }
294
+ if (typeof identityIn.tl_org_id !== 'undefined' && typeof identityIn.tl_org_id !== 'string' && typeof identityIn.tl_org_id !== 'number') {
295
+ fail('identity.tl_org_id must be the TL oiml_id (a number or its string form)')
296
+ }
297
+ const identity: ProfileIdentity = {
298
+ org_id: typeof orgId === 'string' && orgId ? orgId : 'biml',
299
+ org_name: typeof orgName === 'string' && orgName ? orgName : 'BIML',
300
+ role_codes: roleCodesIn.length ? roleCodesIn : roles,
301
+ ...(typeof identityIn.tl_org_id !== 'undefined' ? { tl_org_id: String(identityIn.tl_org_id) } : {}),
302
+ ...(typeof identityIn.country === 'string' ? { country: identityIn.country } : {}),
303
+ }
304
+
305
+ if (identity.role_codes.includes('tl')) {
306
+ const tlId = tlOrgIdOf(identity)
307
+ if (!/^\d+$/.test(tlId)) {
308
+ // NOT a boot failure: a non-registry TL id (fed-09's branding
309
+ // fixtures) skips the identity TL org record at seed time (the
310
+ // role surfaces still gate on the module map) — the seed logs the
311
+ // skip loudly. Declare identity.tl_org_id (numeric) for the
312
+ // registry-honest record.
313
+ console.warn(`instance profile: identity: TL id "${tlId}" is not a registry number (oiml_id) — the identity TL org record will not be seeded (set identity.tl_org_id to seed it)`)
314
+ }
315
+ }
316
+
317
+ // modules (optional — defaults per the role set). TWO forms converge
318
+ // on the same section: a LIST is the exact enabled set; a MAP of
319
+ // {module: boolean} is a delta over the role-set defaults (the
320
+ // TODO.federation/02 toggle spelling — `portal: true` opts the portal
321
+ // in on an NMI, `engagement: false` switches it off on the hub).
322
+ let modules: InstanceModule[]
323
+ if (typeof doc.modules === 'undefined') {
324
+ modules = defaultModulesForRoles(roles)
325
+ } else if (Array.isArray(doc.modules)) {
326
+ const declared = asStringList(doc.modules, 'modules')
327
+ for (const m of declared) {
328
+ if (!(INSTANCE_MODULES as readonly string[]).includes(m)) fail(`modules: unknown module "${m}" (the catalog: ${INSTANCE_MODULES.join(', ')})`)
329
+ }
330
+ const set = new Set(declared as InstanceModule[])
331
+ modules = INSTANCE_MODULES.filter(m => set.has(m))
332
+ } else if (typeof doc.modules === 'object' && doc.modules !== null) {
333
+ const set = new Set(defaultModulesForRoles(roles))
334
+ for (const [m, on] of Object.entries(doc.modules as Record<string, unknown>)) {
335
+ if (!(INSTANCE_MODULES as readonly string[]).includes(m)) fail(`modules: unknown module "${m}" (the catalog: ${INSTANCE_MODULES.join(', ')})`)
336
+ if (typeof on !== 'boolean') fail(`modules.${m} must be a boolean`)
337
+ if (on) set.add(m as InstanceModule)
338
+ else set.delete(m as InstanceModule)
339
+ }
340
+ modules = INSTANCE_MODULES.filter(m => set.has(m))
341
+ } else {
342
+ fail('modules must be a list (the exact enabled set) or a map of {module: boolean} (a delta over the role-set defaults)')
343
+ }
344
+
345
+ // peers (placeholder list — TODO.federation/04 manages them)
346
+ const peersRaw = doc.peers ?? []
347
+ if (!Array.isArray(peersRaw)) fail('peers must be a list')
348
+ const peers: FederationPeer[] = peersRaw.map((p, i) => {
349
+ if (typeof p !== 'object' || p === null || typeof (p as Record<string, unknown>).id !== 'string') {
350
+ fail(`peers[${i}] must carry at least an id`)
351
+ }
352
+ return p as FederationPeer
353
+ })
354
+
355
+ // branding
356
+ const brandingRaw = doc.branding as Record<string, unknown> | undefined
357
+ const branding = {
358
+ name: typeof brandingRaw?.name === 'string' && brandingRaw.name ? brandingRaw.name : identity.org_name,
359
+ }
360
+
361
+ // demo personas: the env flag forces on; else the file's field; else
362
+ // hub default on / non-hub default off.
363
+ const fileFlag = typeof doc.demo_personas === 'boolean' ? doc.demo_personas : undefined
364
+ const demoPersonas = env?.demoPersonas ?? fileFlag ?? roles.includes('hub')
365
+
366
+ // the demo signage (TODO.demo-ops/04): an explicit boolean, default
367
+ // off — the banner is a deliberate declaration, never inherited.
368
+ if (typeof doc.demo !== 'undefined' && typeof doc.demo !== 'boolean') {
369
+ fail('demo must be a boolean (true marks the instance a demonstration environment — the banner on every page)')
370
+ }
371
+ const demo = doc.demo === true
372
+
373
+ // staff (non-hub): declared, else derived from the identity.
374
+ let staff: InstanceProfile['staff'] = []
375
+ if (!roles.includes('hub')) {
376
+ const staffRaw = doc.staff
377
+ if (typeof staffRaw !== 'undefined') {
378
+ if (!Array.isArray(staffRaw)) fail('staff must be a list of {email, name, role, org_id?}')
379
+ staff = staffRaw.map((s, i) => {
380
+ if (typeof s !== 'object' || s === null) fail(`staff[${i}] must be a mapping`)
381
+ const rec = s as Record<string, unknown>
382
+ if (typeof rec.email !== 'string' || !rec.email) fail(`staff[${i}].email is required`)
383
+ if (typeof rec.name !== 'string' || !rec.name) fail(`staff[${i}].name is required`)
384
+ if (typeof rec.role !== 'string' || !rec.role) fail(`staff[${i}].role is required`)
385
+ const orgId = typeof rec.org_id === 'string' && rec.org_id
386
+ ? rec.org_id
387
+ : rec.role === 'tl_operator'
388
+ ? tlOrgIdOf(identity)
389
+ : rec.role === 'ia_officer'
390
+ ? identity.org_id
391
+ : null
392
+ return { email: rec.email, name: rec.name, role: rec.role, org_id: orgId }
393
+ })
394
+ } else {
395
+ staff = deriveStaff(identity, roles)
396
+ }
397
+ }
398
+
399
+ return {
400
+ identity: {
401
+ org_id: identity.org_id,
402
+ org_name: identity.org_name,
403
+ role_codes: identity.role_codes,
404
+ country: identity.country ?? '',
405
+ ...(identity.tl_org_id ? { tl_org_id: identity.tl_org_id } : {}),
406
+ },
407
+ roles,
408
+ modules,
409
+ peers,
410
+ branding,
411
+ demoPersonas,
412
+ demo,
413
+ staff,
414
+ }
415
+ }
416
+
417
+ /** The Worker half of the loader: the profile arrives as an inline YAML
418
+ * binding (INSTANCE_PROFILE_YAML), never a disk path. */
419
+ export function resolveInstanceProfileFromEnv(env: { INSTANCE_PROFILE_YAML?: string; SEED_DEMO_PERSONAS?: string }): InstanceProfile {
420
+ return parseInstanceProfile(env.INSTANCE_PROFILE_YAML, {
421
+ demoPersonas: env.SEED_DEMO_PERSONAS === 'true' || env.SEED_DEMO_PERSONAS === '1' ? true : undefined,
422
+ })
423
+ }
424
+
425
+ // ── The install slot (the db/backend.ts store pattern) ──────────────
426
+
427
+ let installed: InstanceProfile | null = null
428
+
429
+ /** The composition roots install the effective profile once per
430
+ * process / per isolate (node: profile-node.ts's loader; the Worker:
431
+ * per request from the env binding). */
432
+ export function installInstanceProfile(profile: InstanceProfile): void {
433
+ installed = profile
434
+ }
435
+
436
+ /** The installed profile — the built-in HUB default when nothing was
437
+ * installed, so a boot with no profile declaration behaves exactly as
438
+ * the pre-profiles app (tests included). */
439
+ export function getInstanceProfile(): InstanceProfile {
440
+ return installed ?? defaultInstanceProfile()
441
+ }
442
+
443
+ /** Test seam: re-arm the slot (the next getInstanceProfile reads the
444
+ * default again). */
445
+ export function resetInstanceProfileForTest(): void {
446
+ installed = null
447
+ }
448
+
449
+ // ── The public view (the /api/config payload) ───────────────────────
450
+
451
+ /** The profile shape the client sees. `staff` never leaves the server
452
+ * (accounts are not public); everything else is deployment metadata. */
453
+ export interface InstanceProfileView {
454
+ identity: InstanceProfile['identity']
455
+ roles: ProfileRole[]
456
+ modules: InstanceModule[]
457
+ peers: FederationPeer[]
458
+ branding: { name: string }
459
+ demoPersonas: boolean
460
+ /** The demonstration-environment signage flag (TODO.demo-ops/04). */
461
+ demo: boolean
462
+ }
463
+
464
+ export function publicProfileView(profile: InstanceProfile): InstanceProfileView {
465
+ return {
466
+ identity: profile.identity,
467
+ roles: profile.roles,
468
+ modules: profile.modules,
469
+ peers: profile.peers,
470
+ branding: profile.branding,
471
+ demoPersonas: profile.demoPersonas,
472
+ demo: profile.demo,
473
+ }
474
+ }
475
+
476
+ /** The effective module set projected as the full-catalog toggle map
477
+ * (the TODO.federation/02 `modules` key's shape on /api/config): every
478
+ * catalog id with its on/off — the toggle readers' fail-open rule then
479
+ * gates from the SAME truth as the instanceProfile view. */
480
+ export function projectModuleToggles(profile: InstanceProfile): Record<string, boolean> {
481
+ const on = new Set(profile.modules)
482
+ return Object.fromEntries(INSTANCE_MODULES.map(m => [m, on.has(m)]))
483
+ }
484
+
485
+ // ── The profile-aware seed plans ─────────────────────────────────────
486
+
487
+ export interface AccountSeed {
488
+ email: string
489
+ name: string
490
+ role: string
491
+ orgId: string | null
492
+ /** The FULL assigned role set (the users.roles column) — absent = the
493
+ * primary role only (TODO.register/02: the demo applicant's org_admin
494
+ * rides this). */
495
+ roles?: string[]
496
+ }
497
+
498
+ /**
499
+ * The accounts a profile seeds. The hub seeds the demo cast, exactly as
500
+ * the pre-profiles app always did — UNLESS the hub profile carries
501
+ * `demo_personas: false` (TODO.demo-ops/01, the production posture: no
502
+ * demo cast at all, and the demo sign-in endpoints refuse alongside —
503
+ * auth/identity.ts). An ia/tl instance seeds its own staff (declared or
504
+ * derived from the identity); the demo cast joins only when the profile
505
+ * carries the demo-personas flag.
506
+ */
507
+ export function seedAccountsForProfile(profile: InstanceProfile): AccountSeed[] {
508
+ if (profile.roles.includes('hub')) {
509
+ return profile.demoPersonas ? [...DEMO_ACCOUNTS] : []
510
+ }
511
+ const accounts: AccountSeed[] = profile.staff.map(s => ({
512
+ email: s.email,
513
+ name: s.name,
514
+ role: s.role,
515
+ orgId: s.org_id,
516
+ }))
517
+ if (profile.demoPersonas) {
518
+ const seen = new Set(accounts.map(a => a.email))
519
+ for (const account of DEMO_ACCOUNTS) {
520
+ if (!seen.has(account.email)) accounts.push(account)
521
+ }
522
+ }
523
+ return accounts
524
+ }
525
+
526
+ /**
527
+ * The instance's own organization record(s) (non-hub profiles) — one
528
+ * per role code the identity plays, in the SAME shapes the cs-data
529
+ * registry mappings produce (cs-organizations.ts): the IA record keyed
530
+ * on its oiml_code, the TL record on its oiml_id string. The hub seeds
531
+ * NO identity org (the participant-registry seed owns organizations
532
+ * there), so this answers [] for it.
533
+ */
534
+ export function identityOrgRecords(profile: InstanceProfile): Array<Record<string, unknown>> {
535
+ if (profile.roles.includes('hub')) return []
536
+ const { identity } = profile
537
+ const at = new Date().toISOString()
538
+ const address = { street: '', city: '', country: identity.country, postal_code: '' }
539
+ const contact = { person: '', email: '', phone: '' }
540
+ const records: Array<Record<string, unknown>> = []
541
+ if (identity.role_codes.includes('ia')) {
542
+ records.push({
543
+ id: identity.org_id,
544
+ kind: 'issuing-authority',
545
+ name: identity.org_name,
546
+ short_name: identity.org_id,
547
+ oiml_code: identity.org_id,
548
+ country: identity.country,
549
+ address,
550
+ contact,
551
+ oiml_scope: [],
552
+ utilized_laboratory_ids: [],
553
+ certificate_prefix: identity.org_id,
554
+ created: at,
555
+ modified: at,
556
+ })
557
+ }
558
+ if (identity.role_codes.includes('tl')) {
559
+ const tlId = tlOrgIdOf(identity)
560
+ // The TL record's oiml_id is the registry number (numeric by type):
561
+ // a non-numeric TL id (a branding-only fixture, TODO.federation/09)
562
+ // skips the record honestly — the parser already warned at boot.
563
+ if (/^\d+$/.test(tlId)) {
564
+ records.push({
565
+ id: tlId,
566
+ kind: 'test-laboratory',
567
+ name: identity.org_name,
568
+ short_name: identity.org_name,
569
+ oiml_id: Number(tlId),
570
+ parent_oiml_code: '',
571
+ address,
572
+ contact,
573
+ oiml_scope: [],
574
+ capabilities: [],
575
+ accreditation_scope: [],
576
+ created: at,
577
+ modified: at,
578
+ })
579
+ }
580
+ }
581
+ return records
582
+ }
@@ -0,0 +1,42 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The NODE half of the RBAC map resolution (TODO.federation/12): reads
3
+ // the instance's role → permission map from the environment at boot.
4
+ //
5
+ // Carriers, in precedence order:
6
+ // 1. INSTANCE_RBAC_JSON — the map as a JSON object
7
+ // (`{"case_officer": ["application.review", …], …}`); replaces the
8
+ // shipped default WHOLESALE (the profile owns its role model);
9
+ // 2. INSTANCE_PROFILE — the deployment profile file
10
+ // (TODO.federation/01's carrier); its top-level `rbac:` section is
11
+ // the map. This is the ADDITIVE reader: when fed-01's profile
12
+ // loader lands it resolves the section itself and installs through
13
+ // installRbacMap — same seam, one semantics
14
+ // (resolveRolePermissions).
15
+ //
16
+ // NODE-ONLY: fs + js-yaml. The Worker reads INSTANCE_RBAC_JSON from its
17
+ // bindings through effectiveRbacMap(env) instead (server/rbac.ts).
18
+ // ═══════════════════════════════════════════════════════════════════
19
+
20
+ import { readFileSync } from 'fs'
21
+ import yaml from 'js-yaml'
22
+ import { resolveRolePermissions, type RolePermissionMap } from '../vocab/rbac'
23
+
24
+ /** Resolve the instance's RBAC map from the node environment. Returns
25
+ * null when no carrier is set — the shipped default then applies (the
26
+ * hub profile; effectiveRbacMap falls through to it). Throws honestly
27
+ * on an unreadable profile or an invalid map (a deployment bug, never
28
+ * silently defaulted). */
29
+ export function loadNodeRbacMap(env: NodeJS.ProcessEnv = process.env): RolePermissionMap | null {
30
+ const json = env.INSTANCE_RBAC_JSON
31
+ if (json) {
32
+ return resolveRolePermissions(JSON.parse(json))
33
+ }
34
+ const profilePath = env.INSTANCE_PROFILE
35
+ if (profilePath) {
36
+ const profile = yaml.load(readFileSync(profilePath, 'utf-8')) as { rbac?: unknown } | null
37
+ if (profile && profile.rbac !== undefined && profile.rbac !== null) {
38
+ return resolveRolePermissions(profile.rbac)
39
+ }
40
+ }
41
+ return null
42
+ }
package/src/rbac.ts ADDED
@@ -0,0 +1,53 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The instance's effective RBAC map (TODO.federation/12; extracted into
3
+ // the platform-server kernel, TODO.identity-extract/01).
4
+ //
5
+ // Resolution order for the instance's map:
6
+ // 1. an INSTALLED map (rbac/node.ts reads INSTANCE_RBAC_JSON or
7
+ // the INSTANCE_PROFILE file's `rbac:` section at boot — the node
8
+ // posture; the composition root installs through installRbacMap);
9
+ // 2. the INSTANCE_RBAC_JSON env var (any runtime — the Worker's
10
+ // carrier, read per request through hono's env seam);
11
+ // 3. the shipped DEFAULT (vocab/rbac.ts — the hub profile).
12
+ //
13
+ // The write gate that ENFORCES the map on entity writes (the
14
+ // state-machine/permission computation over the program's data) stays
15
+ // with the platform (browser/server/rbac.ts) — it imports the program's
16
+ // generated state machines, which are not kernel content.
17
+ //
18
+ // WORKER-SAFE: no node built-ins (the file-reading half is
19
+ // rbac/node.ts).
20
+ // ═══════════════════════════════════════════════════════════════════
21
+
22
+ import {
23
+ DEFAULT_ROLE_PERMISSIONS,
24
+ resolveRolePermissions,
25
+ type RolePermissionMap,
26
+ } from './vocab/rbac'
27
+
28
+ // ── The instance's effective map ─────────────────────────────────────
29
+
30
+ let installed: RolePermissionMap | null = null
31
+ /** Composition roots install the resolved map (node: server/index.ts
32
+ * via rbac/node.ts). Passing null restores env/default resolution —
33
+ * the tests' reset lever. */
34
+ export function installRbacMap(map: RolePermissionMap | null): void {
35
+ installed = map
36
+ }
37
+
38
+ // The env-var parse is memoized per JSON string (the Worker resolves it
39
+ // per request; the string is deployment-static).
40
+ let envCache: { json: string; map: RolePermissionMap } | null = null
41
+
42
+ /** The instance's effective role → permission map. `env` is hono's
43
+ * runtimeEnv(c) at the route (process.env on node, the binding on the
44
+ * Worker) — may be omitted where no env is in scope. */
45
+ export function effectiveRbacMap(env?: Record<string, string | undefined>): RolePermissionMap {
46
+ if (installed) return installed
47
+ const json = env?.INSTANCE_RBAC_JSON
48
+ if (json) {
49
+ if (envCache?.json !== json) envCache = { json, map: resolveRolePermissions(JSON.parse(json)) }
50
+ return envCache!.map
51
+ }
52
+ return DEFAULT_ROLE_PERMISSIONS
53
+ }