#!/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()