#!/usr/bin/env node /** * cli:derive-rbac-grants — entry point. * * Deterministic transcription of the BA RBAC matrices (`rbac.md` human rows + * machine-owned derived-lookups block + `acteur.md`) into the seed's RBAC * fragment — replaces the Phase-0 hand transcription of `/ba-develop` * (§ "RBAC transcription"), the point where a granted permission could * silently vanish between the BA and the database. * * Invocation: * npx --prefer-offline tsx skills/business-analyse/create-rbac/cli/derive-rbac-grants/index.ts \ * --spec '{"baRoot":".smartstack/ba","mode":"derive"}' [--workdir ] * * - mode=derive → per-app RBAC fragment (`actors[]` / `permissions[]` / * `rolePermissions[]`) on stdout (and `--out `): Phase 0 pastes it * VERBATIM into the CoreSeedSpec slices. Read-only on the BA tree. * - mode=check → bidirectional parity vs `.smartstack/core-seed/*.state.json` * (missing-grant / extra-grant / unmatched-actor). Drift is DATA — exit 0; * the verdict belongs to audit-dev-core DEV-CORE-011. */ import { parseArgs } from 'node:util' import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { executeEnvelope, failExecute, generateEnvelope, printEnvelope, } from '../../../../lib/output.js' import { loadAppActors, type BaActor } from '../../../../lib/ba-actors.js' import { loadModuleRbacRows } from '../../../../lib/ba-rbac-rows.js' import { validateSpec, discoverModuleFolders } from './validate.js' import { deriveRbacGrants, parseDerivedRows, type GrantSources } from './derive.js' import { checkAgainstStates, type StateLike } from './check.js' import type { DeriveRbacGrantsReport } from './types.js' const COMMAND = 'derive-rbac-grants' /** Schema marker of scaffold-core-seed's state files (mirrored — the state * shape is pinned by CoreSeedState + the cross-skill drift test). */ const CORE_SEED_STATE_SCHEMA = 'smartstack/core-seed-state' const CORE_SEED_STATE_DIR = '.smartstack/core-seed' function loadStates(projectPath: string): { states: StateLike[]; warnings: string[] } { const dir = join(projectPath, CORE_SEED_STATE_DIR) const warnings: string[] = [] if (!existsSync(dir)) { warnings.push(`${dir} not found — no seeded state to compare (run Phase 0 / scaffold-core-seed first).`) return { states: [], warnings } } const states: StateLike[] = [] for (const name of readdirSync(dir).filter((n) => n.endsWith('.state.json')).sort()) { const path = join(dir, name) try { const raw = JSON.parse(readFileSync(path, 'utf8')) as Record if (raw.$schema !== CORE_SEED_STATE_SCHEMA) { warnings.push(`${path}: unexpected $schema "${String(raw.$schema)}" — skipped.`) continue } states.push({ application: String(raw.application ?? ''), roles: Array.isArray(raw.roles) ? (raw.roles as StateLike['roles']) : [], rolePermissions: Array.isArray(raw.rolePermissions) ? (raw.rolePermissions as StateLike['rolePermissions']) : [], }) } catch { warnings.push(`${path}: unreadable/invalid JSON — skipped.`) } } return { states, warnings } } function main(): void { const { values } = parseArgs({ options: { spec: { type: 'string' }, workdir: { type: 'string' }, }, strict: true, }) if (!values.spec) { printEnvelope(failExecute(COMMAND, ['--spec is required'])) process.exit(1) } let raw: unknown try { raw = JSON.parse(values.spec) } catch { printEnvelope(failExecute(COMMAND, ['Invalid JSON in --spec'])) process.exit(1) } const validation = validateSpec(raw, values.workdir) if (!validation.valid || !validation.spec || !validation.resolvedBaRoot || !validation.appFolders) { printEnvelope(failExecute(COMMAND, validation.errors)) process.exit(1) } const spec = validation.spec const baRoot = validation.resolvedBaRoot // ── fs-backed sources (lazy caches) ──────────────────────────────────── const actorsCache = new Map() const listDirs = (dir: string, re: RegExp): string[] => { try { return readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.') && re.test(e.name)) .map((e) => e.name) .sort() } catch { return [] } } const actorWarnings: string[] = [] const sources: GrantSources = { appFolders: validation.appFolders, actorsOf: (app) => { if (!actorsCache.has(app)) { const loaded = loadAppActors(baRoot, app) actorWarnings.push(...loaded.warnings.map((w) => `${app}/acteur.md: ${w}`)) actorsCache.set(app, loaded.exists ? loaded.actors : null) } return actorsCache.get(app)! }, modulesOf: (app) => discoverModuleFolders(baRoot, app), humanRowsOf: (app, module) => { const loaded = loadModuleRbacRows(baRoot, app, module) return loaded.exists ? loaded.rows : null }, derivedRowsOf: (app, module) => { const path = join(baRoot, app, module, 'rbac.md') if (!existsSync(path)) return [] try { return parseDerivedRows(readFileSync(path, 'utf8')) } catch { return [] } }, sectionsOf: (app, module) => listDirs(join(baRoot, app, module), /^[a-z]/), resourcesOf: (app, module, section) => listDirs(join(baRoot, app, module, section), /^[a-z]/), } const core = deriveRbacGrants(sources) const warnings = [...actorWarnings, ...core.warnings] const resolutionStep = core.needsResolution.length > 0 ? [ `${core.needsResolution.length} row(s) need resolution (unknown actor / invalid path / collision) — ` + `fix the BA docs, then re-run; unresolved rows are NEVER transported silently.`, ] : [] if (spec.mode === 'derive') { const report: DeriveRbacGrantsReport = { mode: 'derive', ...core, warnings } const filesCreated: string[] = [] if (validation.resolvedOut) { writeFileSync( validation.resolvedOut, JSON.stringify({ apps: core.apps, needsResolution: core.needsResolution }, null, 2), 'utf8', ) report.outWritten = validation.resolvedOut filesCreated.push(validation.resolvedOut) } printEnvelope( generateEnvelope(COMMAND, { data: { ...core.totals, report }, filesCreated, warnings, nextSteps: [ ...resolutionStep, 'Paste each app fragment VERBATIM into its CoreSeedSpec slice: actors → roles[], permissions[] (authored, non-floor), rolePermissions[] — never re-type a row by hand.', 'The floor is NOT in the fragment (scaffold-core-seed derives it from the nav tree); grandfathered `.access` grants ARE included.', 'After seeding, `"mode":"check"` compares the state files to this derivation (audit-dev-core DEV-CORE-011).', ], }), ) process.exit(0) } // ── mode=check ───────────────────────────────────────────────────────── const { states, warnings: stateWarnings } = loadStates(validation.resolvedProjectPath!) const check = checkAgainstStates(core.apps, states) const report: DeriveRbacGrantsReport = { mode: 'check', ...core, warnings, check } const missing = check.apps.reduce((n, a) => n + a.missingGrants.length, 0) const extra = check.apps.reduce((n, a) => n + a.extraGrants.length, 0) const unmatched = check.apps.reduce((n, a) => n + a.unmatchedActors.length, 0) printEnvelope( executeEnvelope(COMMAND, { success: true, data: { ...core.totals, upToDate: check.upToDate, missingGrants: missing, extraGrants: extra, unmatchedActors: unmatched, statesFound: states.length, }, report, warnings: [...warnings, ...stateWarnings], nextSteps: check.upToDate ? ['Seeded grants match the BA matrices — nothing to do.'] : [ 'missing-grant → a BA-specified right is not seeded: re-run Phase 0 with the derive fragment (never hand-add a row).', 'extra-grant → a seeded right no BA row explains: either author the missing rbac.md row (the BA is the SSOT) or regenerate the seed without it.', ...resolutionStep, 'Drift is DATA (exit 0) — the verdict belongs to audit-dev-core DEV-CORE-011.', ], }), ) process.exit(0) } main()