@oimlsmart/platform-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +92 -0
  2. package/migrations/0001_init.sql +69 -0
  3. package/migrations/0002_identity.sql +24 -0
  4. package/migrations/0003_federation_peers.sql +21 -0
  5. package/migrations/0003_users_rbac.sql +8 -0
  6. package/migrations/0004_oidc_op.sql +61 -0
  7. package/migrations/0005_upstream_providers.sql +34 -0
  8. package/migrations/0006_op_accounts.sql +28 -0
  9. package/migrations/0007_org_join_requests.sql +26 -0
  10. package/migrations/0008_op_client_roles.sql +19 -0
  11. package/migrations/0009_account_console.sql +32 -0
  12. package/migrations/0009_sso_states.sql +13 -0
  13. package/migrations/0010_notify_events.sql +23 -0
  14. package/migrations/0011_op_launch.sql +18 -0
  15. package/migrations/0011_org_memberships.sql +62 -0
  16. package/migrations/0012_notify_subscriptions.sql +57 -0
  17. package/migrations/0012_strong_auth.sql +109 -0
  18. package/migrations/0013_org_registry.sql +53 -0
  19. package/migrations/0014_notify_inbox.sql +34 -0
  20. package/migrations/0015_certificate_holder_attribution.sql +63 -0
  21. package/migrations/0016_instrument_registrations.sql +78 -0
  22. package/package.json +52 -0
  23. package/src/client-info.ts +25 -0
  24. package/src/context.ts +31 -0
  25. package/src/github.ts +284 -0
  26. package/src/mailer.ts +309 -0
  27. package/src/oidc.ts +369 -0
  28. package/src/profile/node.ts +83 -0
  29. package/src/profile.ts +582 -0
  30. package/src/rbac/node.ts +42 -0
  31. package/src/rbac.ts +53 -0
  32. package/src/session.ts +45 -0
  33. package/src/store/d1.ts +2850 -0
  34. package/src/store/sqlite/entities.ts +71 -0
  35. package/src/store/sqlite/events.ts +82 -0
  36. package/src/store/sqlite/factors-store.ts +348 -0
  37. package/src/store/sqlite/notify.ts +247 -0
  38. package/src/store/sqlite/op-accounts-store.ts +470 -0
  39. package/src/store/sqlite/op-store.ts +280 -0
  40. package/src/store/sqlite/schema.sql +745 -0
  41. package/src/store/sqlite/store.ts +1390 -0
  42. package/src/store/sqlite/upstream-store.ts +148 -0
  43. package/src/store/sqlite.ts +1027 -0
  44. package/src/store.ts +1826 -0
  45. package/src/vocab/index.ts +12 -0
  46. package/src/vocab/permissions.ts +398 -0
  47. package/src/vocab/rbac.ts +281 -0
  48. package/src/vocab/roles.ts +162 -0
@@ -0,0 +1,281 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The role → permission map (TODO.federation/12): which ROLES hold
3
+ // which ACTION PERMISSIONS (auth/permissions.ts) on this instance.
4
+ //
5
+ // The map is PROFILE DATA. A deployment profile (TODO.federation/01)
6
+ // carries an `rbac:` section — `{ <role>: [permissions] }` — that
7
+ // REPLACES the shipped default wholesale (the profile owns its role
8
+ // model; a per-role merge would invite silent drift). fed-01's profile
9
+ // loader passes the section to resolveRolePermissions(); until that
10
+ // lands, the server reads the section through the INSTANCE_PROFILE /
11
+ // INSTANCE_RBAC_JSON carriers (server/rbac-node.ts). The shipped
12
+ // DEFAULT below reproduces the hub's historical behavior exactly: every
13
+ // role keeps the actions it exercises today, and the hub profile ships
14
+ // this map unchanged (the byte-identical acceptance — the existing
15
+ // suite is the proof).
16
+ //
17
+ // The map also declares the NMI split-role vocabulary (B 18: an NMI's
18
+ // officers hold DISTINCT authorities — item 12's e2e): `case_officer`
19
+ // (review + dispatch, never the decision), `certification_officer`
20
+ // (the decision: finalize + issue), `signatory` (the report signature).
21
+ // The hub ships them UNUSED (no hub account holds them); an NMI profile
22
+ // assigns them to its officers and may strip ia_officer down to a
23
+ // reviewer. Declaring the vocabulary in the default map keeps every
24
+ // profile's assignable-role set available to the admin UI and to the
25
+ // users API's validation.
26
+ //
27
+ // OIDC (TODO.federation/10): the identity layer maps claims to roles
28
+ // through rolesFromClaims() below — the documented seam between the
29
+ // items. Item 10 owns the claim SOURCE (which claim key, per the
30
+ // profile's identity section); this module owns the claim→role
31
+ // semantics (never invent a role the map does not know).
32
+ //
33
+ // PLAIN TypeScript — no Vue, no node built-ins (the browser bundle, the
34
+ // node server and the Worker all import it).
35
+ // ═══════════════════════════════════════════════════════════════════
36
+
37
+ import {
38
+ ACTION_PERMISSIONS,
39
+ isActionPermission,
40
+ type ActionPermission,
41
+ } from './permissions'
42
+
43
+ /** role id → the permissions it holds. */
44
+ export type RolePermissionMap = Record<string, readonly ActionPermission[]>
45
+
46
+ /** The shipped hub default — today's behavior, enumerated. admin and
47
+ * cs_admin hold the whole catalog (today they write everything the org
48
+ * gate passes); the integrity test pins that. */
49
+ export const DEFAULT_ROLE_PERMISSIONS: RolePermissionMap = {
50
+ // The applicant (manufacturer org): its own application, shipments, and
51
+ // its side of the engagement funnel (sign / decline / withdraw).
52
+ // TODO.register/03 — and the instrument register: the manufacturer
53
+ // registers the serial numbers of the instruments it produces under a
54
+ // certificate its organization holds (the scope check applies — an
55
+ // out-of-scope declaration is refused with the reason), and marks
56
+ // their lifecycle.
57
+ applicant: ['application.submit', 'samples.ship', 'engagement.respond', 'serial.register'],
58
+ // The issuing authority's officer: the whole desk end to end (today's
59
+ // ia_officer — review, samples, dispatch, TR review, the evaluation,
60
+ // issuance, the IA-side lifecycle acts certificate-detail exposes, and
61
+ // the LOCAL registration act: with no separate BIML endpoint the IA's
62
+ // "Register with BIML" walks the certificate PENDING_REGISTRATION →
63
+ // ACTIVE itself (the single-instance posture; biml_registration.service).
64
+ // The engagement funnel (TODO.federation/02) is the IA's pre-application
65
+ // desk.
66
+ ia_officer: [
67
+ 'application.review', 'application.accept',
68
+ 'samples.request', 'samples.register', 'samples.manage',
69
+ 'dispatch.issue', 'tr.review', 'er.review', 'er.finalize',
70
+ 'certificate.issue', 'certificate.register', 'certificate.manage',
71
+ 'engagement.manage',
72
+ // TODO.adoption/02 — records mode: register the offline-produced
73
+ // evaluation chain (the Excel bridge).
74
+ 'records.register',
75
+ // TODO.adoption/07 — the IA is the REQUESTER on the dispatch quote
76
+ // leg and on ia_tl negotiations: it accepts or declines the
77
+ // laboratory's quotation.
78
+ 'negotiation.accept',
79
+ // TODO.adoption/09 — the payment records: the IA records an incoming
80
+ // IA↔TL arrangement invoice and marks its own settlements paid (the
81
+ // certificate fee's marked-paid act is the IA's).
82
+ 'payment.invoice', 'payment.mark_paid',
83
+ ],
84
+ // ── the NMI split (the vocabulary every profile may assign) ──
85
+ // Review + dispatch + the engagement desk, never the decision.
86
+ case_officer: [
87
+ 'application.review', 'application.accept',
88
+ 'samples.request', 'samples.register', 'samples.manage',
89
+ 'dispatch.issue', 'tr.review', 'er.review',
90
+ 'engagement.manage',
91
+ // TODO.adoption/07 — the case officer works the dispatch quote leg's
92
+ // commercial decision with the officer.
93
+ 'negotiation.accept',
94
+ ],
95
+ // The decision: finalize the evaluation, issue and manage the
96
+ // certificate (plus the review legs the decision reads) — and the local
97
+ // registration fallback, mirroring the IA's single-instance act.
98
+ certification_officer: [
99
+ 'application.review', 'tr.review', 'er.review', 'er.finalize',
100
+ 'certificate.issue', 'certificate.register', 'certificate.manage',
101
+ // TODO.adoption/02 — the records-mode registration carries the
102
+ // offline evaluation's decision: the deciding roles hold it.
103
+ 'records.register',
104
+ // TODO.adoption/09 — the deciding roles hold the IA's payment-record
105
+ // acts (the certificate fee's marked-paid record among them).
106
+ 'payment.invoice', 'payment.mark_paid',
107
+ ],
108
+ // The report-signature authority.
109
+ signatory: ['tr.sign'],
110
+ // The laboratory: answer the dispatch, run the tests, sign and submit
111
+ // the report, hold sample custody, run the verification pathways.
112
+ tl_operator: [
113
+ 'dispatch.respond', 'run.perform', 'tr.sign', 'tr.submit',
114
+ 'samples.custody', 'verification.perform', 'markings.manage',
115
+ // TODO.adoption/02 — records mode: register the offline-produced
116
+ // test report (the laboratory's own records).
117
+ 'records.register',
118
+ // TODO.adoption/07 — the laboratory is the quoting PROVIDER: it
119
+ // answers a dispatched test request with a quotation and drives its
120
+ // side of ia_tl / tl_applicant negotiations.
121
+ 'negotiation.quote',
122
+ // TODO.adoption/09 — the laboratory invoices the IA (the IA↔TL
123
+ // arrangement record) and uploads its side of the evidence.
124
+ 'payment.invoice',
125
+ ],
126
+ // The register operator (BIML): registration + the post-registration
127
+ // lifecycle its review console exposes.
128
+ // TODO.adoption/09 — and the collection desk: the hub's own certificate
129
+ // fee records are marked paid as the settlement arrives (B 18:2018 §9 b)
130
+ // names collecting the registration fees a BIML responsibility).
131
+ biml_officer: ['certificate.register', 'certificate.manage', 'payment.mark_paid'],
132
+ // The CS organs (TODO.roadmap/44): the MC votes, the RC recommends,
133
+ // the Executive Secretary administers the registry and operations —
134
+ // INCLUDING the post-issuance certificate acts the scheme-operations
135
+ // console performs (deregistration withdraws the certificate).
136
+ mc_member: ['participants.decide'],
137
+ rc_member: ['participants.review'],
138
+ executive_secretary: ['participants.review', 'participants.manage', 'operations.manage', 'certificate.manage', 'anr.review'],
139
+ // TODO.adoption/11 — the Utilizer/Associate staffer declares ANRs for
140
+ // the participant it acts for; the review stays with the CS registry.
141
+ scheme_participant: ['anr.declare'],
142
+ // TODO.adoption/05 — the market-surveillance authority account: a
143
+ // READ-ONLY register audience. No action permission (the role never
144
+ // acts); the register's per-scheme access tiers show it the full
145
+ // certificate view the public never gets (services/register-tiers.ts).
146
+ market_surveillance: [],
147
+ // Platform operations: the whole catalog (today's behavior — a
148
+ // non-org-bound cs_admin writes anything; keep it explicit).
149
+ cs_admin: [...ACTION_PERMISSIONS],
150
+ admin: [...ACTION_PERMISSIONS],
151
+ // TODO.identity/10 — delegated organization administration: the
152
+ // organization administrator (ONE per registered participant org,
153
+ // created by BIML after verification) manages its own org's people —
154
+ // invites, kind-bounded role assignments, deactivation — through the
155
+ // ORG-SCOPED permission (the users API scopes every read/write to the
156
+ // account's org binding; the role alone never widens the slice). The
157
+ // role carries no workflow authority.
158
+ org_admin: ['org.users.manage'],
159
+ // The read-only account holds no action permission.
160
+ viewer: [],
161
+ }
162
+
163
+ /** The assignable role vocabulary of a map (the users API + the admin
164
+ * UI validate assignments against it). */
165
+ export function mapRoles(map: RolePermissionMap): string[] {
166
+ return Object.keys(map)
167
+ }
168
+
169
+ /**
170
+ * Validate + normalize a profile's `rbac:` section into a map. The
171
+ * section REPLACES the default map when present (pass null/undefined
172
+ * for the default). Honest failures: an unknown permission id is a
173
+ * configuration bug and throws naming it; a role with an empty list is
174
+ * allowed (a deliberate no-authority role, cf. viewer).
175
+ */
176
+ export function resolveRolePermissions(profileRbac?: unknown): RolePermissionMap {
177
+ if (profileRbac === undefined || profileRbac === null) return DEFAULT_ROLE_PERMISSIONS
178
+ if (typeof profileRbac !== 'object' || Array.isArray(profileRbac)) {
179
+ throw new Error('rbac: the profile’s rbac section must be a map of role → permission list')
180
+ }
181
+ const out: RolePermissionMap = {}
182
+ for (const [role, perms] of Object.entries(profileRbac as Record<string, unknown>)) {
183
+ if (!Array.isArray(perms)) {
184
+ throw new Error(`rbac: role '${role}' must list its permissions (an array)`)
185
+ }
186
+ const valid: ActionPermission[] = []
187
+ for (const p of perms) {
188
+ if (typeof p !== 'string' || !isActionPermission(p)) {
189
+ throw new Error(`rbac: role '${role}' names unknown permission '${String(p)}' — the catalog (auth/permissions.ts) is the closed vocabulary`)
190
+ }
191
+ if (!valid.includes(p)) valid.push(p)
192
+ }
193
+ out[role] = valid
194
+ }
195
+ return out
196
+ }
197
+
198
+ /** The user's effective role set: the primary role first, then any
199
+ * additional assigned roles, deduped (the session payload's `roles`
200
+ * carries the full assigned set; `role` stays the section-gating
201
+ * primary). */
202
+ export function effectiveRolesOf(user: { role?: string | null; roles?: readonly string[] | null }): string[] {
203
+ const out: string[] = []
204
+ if (user.role) out.push(user.role)
205
+ for (const r of user.roles ?? []) {
206
+ if (r && !out.includes(r)) out.push(r)
207
+ }
208
+ return out
209
+ }
210
+
211
+ /** The permission set a role list holds under a map (union; unknown
212
+ * roles contribute nothing — honesty lives at assignment time). */
213
+ export function permissionsForRoles(
214
+ roles: readonly string[],
215
+ map: RolePermissionMap = DEFAULT_ROLE_PERMISSIONS,
216
+ ): Set<ActionPermission> {
217
+ const out = new Set<ActionPermission>()
218
+ for (const role of roles) {
219
+ for (const p of map[role] ?? []) out.add(p)
220
+ }
221
+ return out
222
+ }
223
+
224
+ /** Whether the role set holds ANY of the listed permissions (a
225
+ * transition's any-of set) — or the one permission when a single id is
226
+ * passed. */
227
+ export function rolesCan(
228
+ roles: readonly string[],
229
+ required: readonly ActionPermission[],
230
+ map: RolePermissionMap = DEFAULT_ROLE_PERMISSIONS,
231
+ ): boolean {
232
+ const held = permissionsForRoles(roles, map)
233
+ return required.some(p => held.has(p))
234
+ }
235
+
236
+ /** The roles holding a permission under the map (the denial hint's
237
+ * "held by: …" — a disabled action names the roles that CAN do it). */
238
+ export function roleHolders(
239
+ permission: ActionPermission,
240
+ map: RolePermissionMap = DEFAULT_ROLE_PERMISSIONS,
241
+ ): string[] {
242
+ return Object.entries(map)
243
+ .filter(([, perms]) => perms.includes(permission))
244
+ .map(([role]) => role)
245
+ .sort()
246
+ }
247
+
248
+ // ── The OIDC seam (TODO.federation/10 calls this) ────────────────────
249
+
250
+ /**
251
+ * Map OIDC claims to this instance's roles. Contract with item 10:
252
+ * item 10 owns the claim SOURCE (which claim carries the roles — a
253
+ * profile's identity section names it, default 'roles' then 'groups');
254
+ * THIS function owns the semantics: read the claim(s), accept an array
255
+ * or a space-delimited string (the two OIDC conventions), and keep only
256
+ * roles the instance's map knows — a claim can never invent a role, and
257
+ * an unknown role is dropped, never erroring the login (the approval
258
+ * queue, item 10, handles first-seen users; roles are only ever
259
+ * PROPOSED by claims and land subject to the instance's assignment
260
+ * rules).
261
+ */
262
+ export function rolesFromClaims(
263
+ claims: Record<string, unknown>,
264
+ map: RolePermissionMap = DEFAULT_ROLE_PERMISSIONS,
265
+ claimKeys: readonly string[] = ['roles', 'groups'],
266
+ ): string[] {
267
+ const known = new Set(mapRoles(map))
268
+ const out: string[] = []
269
+ for (const key of claimKeys) {
270
+ const raw = claims[key]
271
+ const values: string[] = Array.isArray(raw)
272
+ ? raw.filter((v): v is string => typeof v === 'string')
273
+ : typeof raw === 'string'
274
+ ? raw.split(/\s+/)
275
+ : []
276
+ for (const v of values) {
277
+ if (known.has(v) && !out.includes(v)) out.push(v)
278
+ }
279
+ }
280
+ return out
281
+ }
@@ -0,0 +1,162 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // Role model (TODO.new-paradigm/01) — shared by router guards, nav
3
+ // filtering and the login redirect. Plain module (no Vue) so it can be
4
+ // imported from anywhere without composable context.
5
+ // ═══════════════════════════════════════════════════════════════════
6
+
7
+ export const APP_ROLES = [
8
+ 'applicant',
9
+ 'ia_officer',
10
+ 'tl_operator',
11
+ 'biml_officer',
12
+ 'cs_admin',
13
+ 'mc_member',
14
+ 'rc_member',
15
+ 'executive_secretary',
16
+ 'admin',
17
+ 'viewer',
18
+ // TODO.federation/12 — the NMI split-role vocabulary (the RBAC default
19
+ // map, auth/rbac.ts, grants them their permission sets; section gating
20
+ // below seats them at the IA console). No hub account holds them.
21
+ 'case_officer',
22
+ 'certification_officer',
23
+ 'signatory',
24
+ // TODO.identity/10 — delegated organization administration: one org
25
+ // admin per registered participant org (created by BIML), managing its
26
+ // own org's people on the identity service (/op/admin/users).
27
+ 'org_admin',
28
+ // TODO.adoption/11 — Utilizer/Associate staff: declare Additional
29
+ // National Requirements for their country on the ANR registry console
30
+ // (the declaration carries the participant it acts for; the CS
31
+ // registry's approval is the moderation gate).
32
+ 'scheme_participant',
33
+ // TODO.adoption/05 — the market-surveillance authority account: the
34
+ // register's authority audience for the schemes that reserve their
35
+ // full certificate view to the authorities (brief_public /
36
+ // authority_only access tiers). Read-only by construction.
37
+ 'market_surveillance',
38
+ ] as const
39
+ export type AppRole = (typeof APP_ROLES)[number]
40
+
41
+ /** The IA-desk role family (TODO.federation/12): the roles seated at the
42
+ * IA console, org-bound to their issuing authority — ia_officer and the
43
+ * NMI split roles. Server-side org/catalog legs test the FAMILY (never
44
+ * the literal 'ia_officer') so the split roles inherit the desk's org
45
+ * posture; the ACTION line between them is the RBAC map's. */
46
+ export const IA_DESK_ROLES: readonly string[] = ['ia_officer', 'case_officer', 'certification_officer', 'signatory']
47
+
48
+ /** Home route per role — mismatch redirects land here. */
49
+ export function roleHome(role: string | null | undefined): string {
50
+ switch (role) {
51
+ case 'applicant': return '/app/portal'
52
+ case 'ia_officer': return '/app/ia'
53
+ case 'tl_operator': return '/app/lab'
54
+ case 'biml_officer': return '/app/biml'
55
+ case 'cs_admin': return '/app/cs'
56
+ case 'mc_member':
57
+ case 'rc_member': return '/app/cs/approvals'
58
+ case 'executive_secretary': return '/app/cs/participants'
59
+ // TODO.adoption/11 — the Utilizer/Associate staffer lands on the ANR
60
+ // registry console (the only /app/cs surface the role may enter).
61
+ case 'scheme_participant': return '/app/cs/anr'
62
+ // TODO.adoption/05 — the market-surveillance authority's surface is
63
+ // the register (its read-only authority view).
64
+ case 'market_surveillance': return '/app/register'
65
+ // The NMI split roles work the IA console (TODO.federation/12).
66
+ case 'case_officer':
67
+ case 'certification_officer':
68
+ case 'signatory': return '/app/ia'
69
+ // TODO.identity/10 — the org admin's console lives on the identity
70
+ // service (the account store); the page itself answers honestly on a
71
+ // profile that does not serve organization administration.
72
+ case 'org_admin': return '/op/admin/users'
73
+ default: return '/app'
74
+ }
75
+ }
76
+
77
+ export interface RoleSectionRule {
78
+ /** Path prefix (matched exactly or followed by '/'). */
79
+ prefix: string
80
+ /** Roles allowed in the section; 'admin' is always allowed everywhere. */
81
+ roles: AppRole[]
82
+ }
83
+
84
+ /**
85
+ * Role-scoped app sections. Order matters — first match wins, so more
86
+ * specific prefixes (/app/cs/lab-inbox, /app/cs/approvals) come before
87
+ * their parent (/app/cs).
88
+ * /app/standards/* (References + workflow lists) and /app/library stay
89
+ * accessible to every authenticated role.
90
+ *
91
+ * TODO.roadmap/44 organ roles: the approval-pipeline board
92
+ * (/app/cs/approvals) is the MC/RC working surface; the participant
93
+ * registry + Declaration editor (/app/cs/participants) is the Executive
94
+ * Secretary's surface. Both stay open to cs_admin (scheme operations).
95
+ * TODO.roadmap/45: the scheme-operations console (/app/cs/operations —
96
+ * appeals, complaints, misuse, deregistration) is the Executive
97
+ * Secretary's post-issuance surface.
98
+ */
99
+ export const ROLE_SECTION_RULES: RoleSectionRule[] = [
100
+ // TODO.federation/02 — the engagement conversion hands off to the
101
+ // application wizard (?engagement=<id>); in the NMI profile the IA
102
+ // officer drives it for the applicant (the white-gloves seam — fed-03
103
+ // completes the on-behalf-of provenance). More specific than
104
+ // /app/portal, so it must come first (first match wins).
105
+ { prefix: '/app/portal/applications/new', roles: ['applicant', 'ia_officer'] },
106
+ // TODO.cs-e2e/05a — /app/portal/projects/<id> (the applicant's read-only
107
+ // TEP view) rides the /app/portal prefix: the applicant's own section.
108
+ { prefix: '/app/portal', roles: ['applicant'] },
109
+ // TODO.cs-e2e/02 — the Type Evaluation Project hub: the IA officer's
110
+ // working page, readable by cs_admin (scheme oversight). More specific
111
+ // than /app/ia, so it must come first (first match wins).
112
+ // TODO.federation/12: the NMI split roles (case/certification officer,
113
+ // signatory) sit at the IA console — RBAC (auth/permissions.ts) draws
114
+ // the finer line BETWEEN them inside these pages.
115
+ { prefix: '/app/ia/projects', roles: ['ia_officer', 'cs_admin', 'case_officer', 'certification_officer', 'signatory'] },
116
+ { prefix: '/app/ia', roles: ['ia_officer', 'case_officer', 'certification_officer', 'signatory'] },
117
+ // TODO.cs-e2e/05a — /app/lab/projects/<id> (the laboratory's scoped TEP
118
+ // context view) rides the /app/lab prefix: the laboratory's own section.
119
+ { prefix: '/app/lab', roles: ['tl_operator'] },
120
+ // TODO.register/02 — the register's owner view: the manufacturer org's
121
+ // own rows, the IA desk's issued cone, the estate's all-seeing cone +
122
+ // the holder-claim queue. The module path rule (auth/modules.ts) rides
123
+ // the register module.
124
+ { prefix: '/app/my-certificates', roles: ['applicant', 'ia_officer', 'case_officer', 'certification_officer', 'signatory', 'biml_officer', 'cs_admin'] },
125
+ // TODO.deploying/10 — the BIML portal: the registry operator's surface
126
+ // (open to cs_admin for scheme operations).
127
+ { prefix: '/app/biml', roles: ['biml_officer', 'cs_admin'] },
128
+ { prefix: '/app/cs/lab-inbox', roles: ['tl_operator'] },
129
+ { prefix: '/app/cs/approvals', roles: ['mc_member', 'rc_member', 'executive_secretary', 'cs_admin'] },
130
+ { prefix: '/app/cs/participants', roles: ['executive_secretary', 'cs_admin'] },
131
+ { prefix: '/app/cs/operations', roles: ['executive_secretary', 'cs_admin'] },
132
+ // TODO.adoption/11 — the ANR registry console: the Utilizer/Associate
133
+ // staffer (scheme_participant) declares here; the CS registry moderates.
134
+ // More specific than /app/cs, so it must come first (first match wins).
135
+ { prefix: '/app/cs/anr', roles: ['scheme_participant', 'executive_secretary', 'cs_admin'] },
136
+ // TODO.cs-e2e/13.15 — Data management is the workspace's own export/
137
+ // import surface, and the profile indicator's degradation banner names
138
+ // it as the way out for EVERY role; it must stay reachable by every
139
+ // signed-in account or the banner would send users to a refused page.
140
+ { prefix: '/app/cs/data', roles: [...APP_ROLES] },
141
+ { prefix: '/app/cs', roles: ['cs_admin'] },
142
+ // TODO.roadmap/29 — the v3 runtime surfaces (coverage pages, the
143
+ // live-twin console) are scheme-operator surfaces.
144
+ { prefix: '/app/coverage', roles: ['cs_admin'] },
145
+ { prefix: '/app/twin', roles: ['cs_admin'] },
146
+ // TODO.v3 — the twin lab: the scheme operator AND the test laboratory
147
+ // (the guided run's tester is the TL's operator).
148
+ { prefix: '/app/twin-lab', roles: ['cs_admin', 'tl_operator'] },
149
+ ]
150
+
151
+ /** The section rule covering `path`, if any. */
152
+ export function sectionRuleFor(path: string): RoleSectionRule | undefined {
153
+ return ROLE_SECTION_RULES.find(r => path === r.prefix || path.startsWith(r.prefix + '/'))
154
+ }
155
+
156
+ /** Whether `role` may enter `path` per ROLE_SECTION_RULES (admin bypasses). */
157
+ export function canAccessPath(role: string | null | undefined, path: string): boolean {
158
+ const rule = sectionRuleFor(path)
159
+ if (!rule) return true
160
+ if (role === 'admin') return true
161
+ return !!role && (rule.roles as string[]).includes(role)
162
+ }