@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/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
|
+
}
|
package/src/rbac/node.ts
ADDED
|
@@ -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
|
+
}
|