#!/usr/bin/env node /** * cli:scaffold-data-scope * * Emits the row-level data-scope wiring of client extension entities (the * POLICY half — `scaffold-entity --spec '{"dataScope":…}'` emits the COLUMN * half). Per entity: `{Entity}ScopePolicy.cs` + the `ApplyDataScopeFilter` * line in the DbContext (`<<< DATA-SCOPE-FILTERS >>>` markers) + the * `AddSingleton` line in DependencyInjection.cs * (`<<< DATA-SCOPE-POLICIES-DI >>>` markers). Idempotent (full-spec marker * replacement, mirror of scaffold-extension-search). * * Usage: * npx --prefer-offline tsx .../scaffold-data-scope/index.ts --spec-file spec.json --outdir * npx --prefer-offline tsx .../scaffold-data-scope/index.ts --spec '' [--dry-run] */ import { parseArgs } from 'node:util'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { validate } from './validate.js'; import { patchDbContext, patchDi, renderPolicies } from './generate.js'; import { generateEnvelope, failGenerate, printEnvelope } from '../../../../../lib/output.js'; import type { DataScopeSpec } from './types.js'; const COMMAND = 'scaffold-data-scope'; /** Locate the client DbContext file that hosts OnExtensionModelCreating. */ function resolveContextHost(outdir: string, appCode: string, contextType: string): string | null { const candidates = [ `src/${appCode}.Infrastructure/Persistence/${contextType}.cs`, `src/${appCode}.Infrastructure/Persistence/Contexts/${contextType}.cs`, `src/${appCode}.Infrastructure/${contextType}.cs`, ]; for (const candidate of candidates) { const fullPath = resolve(join(outdir, candidate)); if (existsSync(fullPath)) return fullPath; } return null; } /** Locate the client Infrastructure DI file (same candidates as the sibling seam CLIs). */ function resolveDiHost(outdir: string, appCode: string): string | null { const candidates = [ `src/${appCode}.Infrastructure/DependencyInjection.cs`, `src/${appCode}.Infrastructure/ServiceCollectionExtensions.cs`, `src/${appCode}.Infrastructure/InfrastructureModule.cs`, ]; for (const candidate of candidates) { const fullPath = resolve(join(outdir, candidate)); if (existsSync(fullPath)) return fullPath; } return null; } function main(): void { const { values } = parseArgs({ options: { spec: { type: 'string' }, 'spec-file': { type: 'string' }, outdir: { type: 'string' }, 'dry-run': { type: 'boolean', default: false }, }, strict: true, }); const rawJson = values['spec-file'] ? safeRead(values['spec-file'] as string) : ((values.spec as string) ?? null); if (rawJson === null) { printEnvelope(failGenerate(COMMAND, ['Either --spec or --spec-file is required'])); process.exit(1); } let raw: unknown; try { raw = JSON.parse(rawJson); } catch { printEnvelope(failGenerate(COMMAND, ['Invalid JSON in spec'])); process.exit(1); } const validation = validate(raw); if (!validation.valid || !validation.data) { printEnvelope(failGenerate(COMMAND, validation.errors)); process.exit(1); } const spec: DataScopeSpec = validation.data; const outdir = (values.outdir as string) ?? spec.projectPath; const dryRun = !!values['dry-run']; const warnings: string[] = [...validation.warnings]; const policies = renderPolicies(spec); const contextHostPath = resolveContextHost(outdir, spec.appCode, spec.contextType); const diHostPath = resolveDiHost(outdir, spec.appCode); if (dryRun) { printEnvelope(generateEnvelope(COMMAND, { data: { dryRun: true, policies: policies.map(p => p.path), contextHostPath, diHostPath, scoped: spec.entities.map(e => `${e.entityName} (${e.mode})`), }, warnings, nextSteps: [`Would scope ${spec.entities.length} entit${spec.entities.length === 1 ? 'y' : 'ies'}: ${spec.entities.map(e => e.entityName).join(', ')}.`], })); return; } // 1. Policy files (overwrite — they are fully derived from the spec). const filesCreated: string[] = []; for (const file of policies) { const p = resolve(join(outdir, file.path)); mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, file.content, 'utf-8'); filesCreated.push(p); } // 2. DbContext patch — mounts the named "DataScope" filter (the actual row filtering). const filesModified: string[] = []; if (contextHostPath !== null) { const source = readFileSync(contextHostPath, 'utf-8'); const next = patchDbContext(source, spec); if (next !== null) { writeFileSync(contextHostPath, next, 'utf-8'); filesModified.push(contextHostPath); } else if (!/OnExtensionModelCreating\s*\(\s*ModelBuilder/.test(source)) { warnings.push(`${spec.contextType} does not override OnExtensionModelCreating(ModelBuilder) — the DATA-SCOPE-FILTERS block was not applied; mount the filters manually.`); } if (!/ICurrentUserAccessor/.test(source)) { warnings.push( `${spec.contextType} does not forward ICurrentUserAccessor to the SmartStackExtensionDbContext base — ` + `the DataScope filter stays INERT (system context). Upgrade the constructor: ` + `(DbContextOptions<${spec.contextType}> options, ICurrentTenantService? tenantService = null, ` + `ICurrentUserAccessor? currentUserAccessor = null, IServiceProvider? serviceProvider = null) : ` + `base(options, tenantService, currentUserAccessor, serviceProvider).`, ); } } else { warnings.push(`No ${spec.contextType}.cs found under src/${spec.appCode}.Infrastructure/ — mount the filters manually.`); } // 3. DI patch — feeds the platform DataScopeRegistry. if (diHostPath !== null) { const source = readFileSync(diHostPath, 'utf-8'); const next = patchDi(source, spec); if (next !== null) { writeFileSync(diHostPath, next, 'utf-8'); filesModified.push(diHostPath); } } else { warnings.push(`No DependencyInjection.cs found under src/${spec.appCode}.Infrastructure/ — register the policies manually.`); } printEnvelope(generateEnvelope(COMMAND, { data: { scoped: spec.entities.map(e => `${e.entityName} (${e.mode})`), contextPatched: contextHostPath !== null && filesModified.includes(contextHostPath), diPatched: diHostPath !== null && filesModified.includes(diHostPath), contextHostPath, diHostPath, }, filesCreated, filesModified, warnings, nextSteps: [ 'Run `dotnet build` to verify the policies compile.', 'Seed check: each scoped read needs its sibling "{path}.read.all" permission row (scaffold-core-seed emits it from the BA matrix).', 'NEVER add [RequireDataScope(typeof(), …)] on extension controllers — the platform guard is Core-only; the GET {id} 404s out-of-scope rows via the named filter.', ], })); } function safeRead(path: string): string { try { return readFileSync(path, 'utf-8'); } catch (err) { printEnvelope(failGenerate(COMMAND, [`Failed to read --spec-file ${path}: ${err instanceof Error ? err.message : String(err)}`])); process.exit(1); } } main();