/** * cli:derive-related-tabs — rbac.ts * * rbac.md → set of permission strings. Collection is deliberately dumb: * every backtick-quoted token shaped like a permission * (`module.section[.resource].action`, kebab segments, ≥3 segments) is kept * VERBATIM — no segment parsing, no normalisation. Matching downstream * (RTV-004, derive's permission fallback) is verbatim too. */ import { readdirSync, readFileSync, existsSync } from 'node:fs' import { join } from 'node:path' /** Backtick-quoted `x.y.z`(-or-more)-shaped kebab token. */ const PERMISSION_TOKEN_RE = /`([a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*){2,})`/g export function parseRbacPermissions(content: string): Set { const permissions = new Set() for (const m of content.matchAll( new RegExp(PERMISSION_TOKEN_RE.source, PERMISSION_TOKEN_RE.flags), )) { permissions.add(m[1]) } return permissions } /** * Union of every `///rbac.md` — the related entity of a * tab (and thus its `.read` permission) can live in a sibling module. */ export function loadRbacPermissions(baRoot: string, app: string): Set { const permissions = new Set() const appDir = join(baRoot, app) if (!existsSync(appDir)) return permissions let entries try { entries = readdirSync(appDir, { withFileTypes: true }) } catch { return permissions } for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { if (!entry.isDirectory() || entry.name.startsWith('_') || entry.name.startsWith('.')) continue const rbacPath = join(appDir, entry.name, 'rbac.md') if (!existsSync(rbacPath)) continue try { for (const p of parseRbacPermissions(readFileSync(rbacPath, 'utf8'))) { permissions.add(p) } } catch { // unreadable rbac.md — permissions simply stay unresolved } } return permissions } /** * Union of every `rbac.md` of EVERY application under `/`. * * A tab pointing at another application carries that application's permission * (`facturation.factures.read`), which by construction is declared in the OTHER app's * rbac.md. Checked against a single-app union it reads as "does not exist" — RTV-004 * would warn on a perfectly correct permission. Applications are the top-level folders; * `_`/`.`-prefixed ones are not applications. */ export function loadRbacPermissionsAcrossApps(baRoot: string): Set { const permissions = new Set() if (!existsSync(baRoot)) return permissions let entries try { entries = readdirSync(baRoot, { withFileTypes: true }) } catch { return permissions } for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { if (!entry.isDirectory() || entry.name.startsWith('_') || entry.name.startsWith('.')) continue for (const p of loadRbacPermissions(baRoot, entry.name)) permissions.add(p) } return permissions }