/** * cli:build-manifest — derive-roles.ts * * Derives each entity's role matrix from the SEEDED state * (`/.smartstack/core-seed/.state.json`) instead of the * hand-fed spec arrays. The manual fields WIN when authored (explicit * override); when a field is EMPTY and a state exists, the derivation fills * it — which is what makes the `permission-negative` scenarios actually run: * with the historical `default([])`, `rolesWithoutAccess` was hand-fed by * nobody, so ZERO negative tests were ever emitted — a silent pass on the * whole authorization axis (audit finding H10, UI half). * * Derivation per entity (permission paths of the seed grammar): * read = `{app}.{module}.{section}.read` (the `.read.all` tier counts) * create / update / delete = same shape * rolesWithoutAccess = seeded app roles holding NEITHER read nor read.all * * Lenient on the state file (missing/invalid → no derivation, a warning) — * the shape is pinned by scaffold-core-seed/state.ts (CoreSeedState). */ import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { toKebabCase } from '../../../../../lib/string-utils.js' import type { ManifestEntity } from './types.js' export const CORE_SEED_STATE_SCHEMA = 'smartstack/core-seed-state' export interface StateGrants { /** Seeded role codes of the app (test users exist for each). */ roles: string[] /** roleCode → set of granted permission paths. */ grantsByRole: Map> } export function loadStateGrants( projectPath: string, appCode: string, ): { grants: StateGrants | null; warning?: string } { const statePath = join(projectPath, '.smartstack', 'core-seed', `${appCode}.state.json`) if (!existsSync(statePath)) { return { grants: null, warning: `${statePath} not found — role matrices stay as authored (run scaffold-core-seed / Phase 0 to enable derivation).`, } } try { const raw = JSON.parse(readFileSync(statePath, 'utf8')) as Record if (raw.$schema !== CORE_SEED_STATE_SCHEMA) { return { grants: null, warning: `${statePath}: unexpected $schema — role derivation skipped.` } } const roles = Array.isArray(raw.roles) ? (raw.roles as { code?: unknown }[]).map((r) => String(r.code ?? '')).filter(Boolean) : [] const grantsByRole = new Map>() for (const code of roles) grantsByRole.set(code, new Set()) if (Array.isArray(raw.rolePermissions)) { for (const g of raw.rolePermissions as { roleCode?: unknown; permissionPath?: unknown }[]) { const role = String(g.roleCode ?? '') const perm = String(g.permissionPath ?? '') if (!role || !perm) continue const set = grantsByRole.get(role) ?? new Set() set.add(perm) grantsByRole.set(role, set) } } return { grants: { roles, grantsByRole } } } catch { return { grants: null, warning: `${statePath}: unreadable/invalid JSON — role derivation skipped.` } } } /** Roles granted `{base}.{action}` (read also honours the `.read.all` tier). */ function rolesWith(grants: StateGrants, base: string, action: string): string[] { const exact = `${base}.${action}` const tier = action === 'read' ? `${base}.read.all` : null return grants.roles .filter((role) => { const set = grants.grantsByRole.get(role) if (!set) return false return set.has(exact) || (tier !== null && set.has(tier)) }) .sort() } /** * Fill the EMPTY role arrays of an entity from the state. Authored arrays are * never touched — explicit intent wins over derivation. */ export function deriveEntityRoles( entity: ManifestEntity, grants: StateGrants, appCode: string, module: string, ): ManifestEntity { // Seeded paths are kebab-lowercase; a PascalCase `module` ("Staff", // "TypesAffaire") composed verbatim matched NOTHING → rolesWithRead = [] // → rolesWithoutAccess = EVERY role = a wall of false negative tests // (conformity-audit finding). Normalize both free-form segments. const base = `${appCode}.${toKebabCase(module)}.${toKebabCase(entity.section)}` const withRead = rolesWith(grants, base, 'read') return { ...entity, rolesWithRead: entity.rolesWithRead.length > 0 ? entity.rolesWithRead : withRead, rolesWithCreate: entity.rolesWithCreate.length > 0 ? entity.rolesWithCreate : rolesWith(grants, base, 'create'), rolesWithUpdate: entity.rolesWithUpdate.length > 0 ? entity.rolesWithUpdate : rolesWith(grants, base, 'update'), rolesWithDelete: entity.rolesWithDelete.length > 0 ? entity.rolesWithDelete : rolesWith(grants, base, 'delete'), rolesWithoutAccess: entity.rolesWithoutAccess.length > 0 ? entity.rolesWithoutAccess : grants.roles.filter((r) => !withRead.includes(r)).sort(), } }