/** * uat-plan/discover.ts — Live discovery + correlation. * * Pulls the navigation skeleton, permissions and role grants from the running * app's DB (sql-discovery), reads the generated componentRegistry for the views * each nav node exposes, parses controllers for the API axis, filters everything * to the requested path scope, and resolves permissions against the LIVE permission * set (read→view→bare). The correlation helpers are PURE + unit-tested; `discover()` * is the thin I/O glue (the only part that touches SQL or the filesystem). */ import type { ParsedConnection } from '../../../lib/appsettings.js'; import { findFiles, readText } from '../../../lib/fs.js'; import { discoverNav, discoverPermissions, discoverRolePermissions, discoverRoleCatalog, findReplacementCharCorruption, groupGrantsByRole, type NavNode, } from '../../../lib/sql-discovery.js'; import { IMPLICIT_VIEW_SUFFIXES } from './project-roles.js'; import { computeSignature } from './generate.js'; import { discoverEndpoints, type ParsedEndpoint } from './discover-endpoints.js'; import type { DiscoveredNavRoute, DiscoveredEndpoint, DiscoveryResult, ImplicitView, } from './types.js'; // ── PURE correlation helpers ──────────────────────────────────────────────── /** Scope path "administration/users" → component-key prefix "administration.users". */ export function pathToPrefix(path: string): string { return path .split('/') .map((s) => s.trim()) .filter(Boolean) .join('.'); } /** Is a component key within the scope prefix (itself or a dot-boundary descendant)? */ export function inScope(componentKey: string, prefix: string): boolean { return prefix === '' || componentKey === prefix || componentKey.startsWith(`${prefix}.`); } /** Build navId → dotted component key by walking each node's parent-code chain. PURE. */ export function buildComponentKeys(nodes: readonly NavNode[]): Map { const byId = new Map(nodes.map((n) => [n.id, n])); const keyOf = new Map(); const compute = (n: NavNode, seen: Set): string => { const cached = keyOf.get(n.id); if (cached) return cached; seen.add(n.id); const parent = n.parentId && !seen.has(n.parentId) ? byId.get(n.parentId) : undefined; const key = parent ? `${compute(parent, seen)}.${n.code}` : n.code; keyOf.set(n.id, key); return key; }; for (const n of nodes) compute(n, new Set()); return keyOf; } /** * Extract dotted component keys from `PageRegistry.register('a.b.c', …)`. PURE. * The `PAGE_KEYS.X` enum form is counted (pageKeysOnly) but not resolvable to a * dot-key without the enum table — the caller warns + degrades to nav-DB routes. */ export function parseRegistry(content: string): { keys: string[]; pageKeysOnly: number } { const keys: string[] = []; let pageKeysOnly = 0; const re = /PageRegistry\.register\(\s*(?:'([^']+)'|"([^"]+)"|(PAGE_KEYS\.\w+))/g; for (const m of content.matchAll(re)) { const key = m[1] ?? m[2]; if (key) keys.push(key); else if (m[3]) pageKeysOnly++; } return { keys, pageKeysOnly }; } /** Resolve a page's required permission against the live set: read → view → bare. PURE. */ export function resolvePagePermission( componentKey: string, permissionPaths: ReadonlySet, ): string | undefined { for (const candidate of [`${componentKey}.read`, `${componentKey}.view`, componentKey]) { if (permissionPaths.has(candidate)) return candidate; } return undefined; } /** Candidate permission actions per HTTP verb. */ const VERB_ACTIONS: Record = { GET: ['read', 'view'], POST: ['create'], PUT: ['update'], PATCH: ['update'], DELETE: ['delete'], }; /** * @deprecated The verb→action guess mis-typed every non-CRUD endpoint (a * `POST …/approve` gated `.Approve` was planned as `.create` — the verdict * computed for the WRONG permission) while the parser was already capturing * the action's real `[RequirePermission]` expression. `correlateEndpoints` * now resolves the DECLARED expression (`resolveDeclaredPermission`) and * treats its absence as what it is — an UNGATED endpoint, flagged, never * re-gated by a guess. Kept only for external callers/tests of the legacy * behaviour. */ export function resolveEndpointPermission( navRoute: string | undefined, method: string, permissionPaths: ReadonlySet, route?: string, ): string | undefined { if (!navRoute) return undefined; const verbActions = VERB_ACTIONS[method] ?? []; const actions = route !== undefined && /\/lookup$/.test(route) ? ['lookup', ...verbActions] : verbActions; for (const action of actions) { const candidate = `${navRoute}.${action}`; if (permissionPaths.has(candidate)) return candidate; } if (permissionPaths.has(navRoute)) return navRoute; return actions[0] ? `${navRoute}.${actions[0]}` : undefined; } /** PascalCase permission-constant segment → path action (`ReadAll` → `read.all`). */ export function constActionToPathAction(constName: string): string { const kebab = constName .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') .replace(/([a-z0-9])([A-Z])/g, '$1-$2') .toLowerCase(); return kebab === 'read-all' ? 'read.all' : kebab; } export interface DeclaredResolution { permission: string; source: 'declared' | 'declared-unseeded'; warnings: string[]; } /** * Resolve the DECLARED `[RequirePermission(...)]` expression(s) of an action * to a live permission path. Multi-arg gates apply ANY semantics — the first * argument that resolves in the live set wins (the v3.62 `/lookup` dual gate * yields `….lookup` first, its minimal grant). A literal string argument is * the path itself; a C# constant (`{Mod}Permissions.{Section}.{Action}`) * contributes `{navRoute}.{kebab(Action)}` — and its Section segment is * cross-checked against the navRoute (the silent-rebind visibility). When no * candidate exists in the live set, the FIRST candidate is kept with source * `declared-unseeded`: the endpoint stays gated exactly as deployed (every * role denied) and the lost-seed drift becomes a loud warning instead of a * silently certified 403. PURE. */ export function resolveDeclaredPermission( exprs: readonly string[], navRoute: string, permissionPaths: ReadonlySet, ): DeclaredResolution | null { const warnings: string[] = []; const candidates: string[] = []; for (const expr of exprs) { const literal = expr.match(/^"([^"]+)"$/); if (literal) { candidates.push(literal[1]); continue; } const segs = expr.split('.').map((s) => s.trim()).filter(Boolean); if (segs.length === 0) continue; const action = constActionToPathAction(segs[segs.length - 1]); if (segs.length >= 2) { const sectionKebab = constActionToPathAction(segs[segs.length - 2]); const navSection = navRoute.split('.').pop(); if (navSection && sectionKebab !== navSection && sectionKebab !== 'permissions') { warnings.push( `[RequirePermission(${expr})] section '${sectionKebab}' differs from the controller's ` + `navRoute section '${navSection}' — verify the constant (silent-rebind class).`, ); } } candidates.push(`${navRoute}.${action}`); } if (candidates.length === 0) return null; for (const c of candidates) { if (permissionPaths.has(c)) return { permission: c, source: 'declared', warnings }; } warnings.push( `declared permission '${candidates[0]}' exists in NO live permission row — every role will be ` + `denied on this endpoint (unseeded-permission drift; check the seed / DEV-CORE-011).`, ); return { permission: candidates[0], source: 'declared-unseeded', warnings }; } /** Correlate in-scope navigable nav nodes with their registry views + resolved permissions. PURE. */ export function correlateRoutes( nodes: readonly NavNode[], keyOf: ReadonlyMap, registryKeys: ReadonlySet, permissionPaths: ReadonlySet, prefix: string, ): DiscoveredNavRoute[] { const out: DiscoveredNavRoute[] = []; for (const node of nodes) { const componentKey = keyOf.get(node.id); if (!componentKey || !inScope(componentKey, prefix) || !node.route) continue; const views: ImplicitView[] = ['list']; for (const suffix of IMPLICIT_VIEW_SUFFIXES) { if (registryKeys.has(`${componentKey}.${suffix}`)) views.push(suffix); } const route: DiscoveredNavRoute = { componentKey, baseRoute: node.route, navKind: node.kind, views, }; if (node.label) route.label = node.label; const permission = resolvePagePermission(componentKey, permissionPaths); if (permission) route.permission = permission; const parentKey = node.parentId ? keyOf.get(node.parentId) : undefined; if (parentKey) route.parentComponentKey = parentKey; out.push(route); } out.sort((a, b) => (a.componentKey < b.componentKey ? -1 : a.componentKey > b.componentKey ? 1 : 0)); return out; } /** * Map the last URL segment of each nav route → its component key, for correlating * the screen stratum (`/api/screens/{plural}`, which carries no [NavRoute]) back to * the section that owns it. Plurals shared by two sections are dropped (ambiguous). PURE. */ export function buildSectionByPlural( navRoutes: readonly DiscoveredNavRoute[], ): Map { const byPlural = new Map(); const ambiguous = new Set(); for (const r of navRoutes) { const seg = r.baseRoute.split('/').filter(Boolean).pop(); if (!seg) continue; const existing = byPlural.get(seg); if (existing && existing !== r.componentKey) ambiguous.add(seg); else byPlural.set(seg, r.componentKey); } for (const seg of ambiguous) byPlural.delete(seg); return byPlural; } /** * Filter parsed endpoints to scope + resolve their permissions. Deterministically * ordered. Screen-stratum endpoints (`/api/screens/{plural}`) carry no [NavRoute], * so their inferred `screens.{plural}` navRoute is re-mapped to the owning section * (via `navRoutes`) — otherwise the whole screen-driven API surface the frontend * calls would be silently dropped. PURE. */ export function correlateEndpoints( parsed: readonly ParsedEndpoint[], permissionPaths: ReadonlySet, prefix: string, navRoutes: readonly DiscoveredNavRoute[] = [], application = '', warnings: string[] = [], ): DiscoveredEndpoint[] { const sectionByPlural = buildSectionByPlural(navRoutes); const out: DiscoveredEndpoint[] = []; // The permission comes from the action's OWN [RequirePermission] expression // (resolveDeclaredPermission); its absence means the endpoint is UNGATED — // flagged, never re-gated by the legacy verb→action guess (which planned a // `POST …/approve` gated `.Approve` as `.create`: wrong-permission verdicts). const gate = (endpoint: DiscoveredEndpoint, e: ParsedEndpoint, navKey: string): void => { if (e.allowAnonymous) { endpoint.allowAnonymous = true; return; } if (e.permissionExprs && e.permissionExprs.length > 0) { const resolved = resolveDeclaredPermission(e.permissionExprs, navKey, permissionPaths); if (resolved) { endpoint.permission = resolved.permission; endpoint.permissionSource = resolved.source; warnings.push(...resolved.warnings.map((w) => `${e.method} ${e.route}: ${w}`)); return; } } endpoint.ungated = true; warnings.push( `${e.method} ${e.route}: NO [RequirePermission] and no [AllowAnonymous] — every authenticated ` + `role passes. The plan expects that behaviour but it is a DEFECT (DEV-API-033): fix the ` + `controller, never treat this row as a certification.`, ); }; for (const e of parsed) { if (!e.navRoute) continue; // Screen stratum: resolve the real section from the plural, then scope + gate on IT. if (e.navRoute.startsWith('screens.')) { const plural = e.navRoute.split('.')[1]; const componentKey = plural ? sectionByPlural.get(plural) : undefined; if (!componentKey || !inScope(componentKey, prefix)) continue; const endpoint: DiscoveredEndpoint = { method: e.method, route: e.route }; if (e.controller) endpoint.controller = e.controller; endpoint.navRoute = componentKey; gate(endpoint, e, componentKey); if (e.okStatus !== undefined) endpoint.okStatus = e.okStatus; out.push(endpoint); continue; } // Integration stratum: client controllers carry an APP-LESS [NavRoute] // (`{module}.{section}`) while the scope prefix is app-rooted — the // dot-boundary match alone dropped the WHOLE integration surface of a // 3-level client app (the golden fixture was a 2-level platform app, so // the case was untested). Requalify with the application code when that — // and only that — makes the endpoint land in scope. let navKey = e.navRoute; if ( !inScope(navKey, prefix) && application !== '' && navKey.split('.')[0] !== application && inScope(`${application}.${navKey}`, prefix) ) { navKey = `${application}.${navKey}`; } if (!inScope(navKey, prefix)) continue; const endpoint: DiscoveredEndpoint = { method: e.method, route: e.route }; if (e.controller) endpoint.controller = e.controller; endpoint.navRoute = navKey; gate(endpoint, e, navKey); if (e.okStatus !== undefined) endpoint.okStatus = e.okStatus; out.push(endpoint); } out.sort((a, b) => { const ka = `${a.method}:${a.route}`; const kb = `${b.method}:${b.route}`; return ka < kb ? -1 : ka > kb ? 1 : 0; }); return out; } // ── I/O ───────────────────────────────────────────────────────────────────── export interface DiscoverOptions { /** API project dir (controllers). */ apiDir: string; /** Web app dir (componentRegistry under src/extensions). */ webDir: string; /** Scope: `Application[/Module[/Section[/Resource]]]`. */ path: string; connection: ParsedConnection; /** Authoritative role set (e.g. from provisioning). Default: roles that hold ≥1 grant. */ rolesOverride?: string[]; sqlcmdPath?: string; /** Parse + include the API axis (`endpoints[]`). */ includeApi: boolean; } export interface DiscoverOutcome { result: DiscoveryResult; warnings: string[]; } /** Read every generated registry file under web/src/extensions and collect component keys. */ async function readRegistryKeys(webDir: string): Promise<{ keys: Set; pageKeysOnly: number }> { if (!webDir) return { keys: new Set(), pageKeysOnly: 0 }; const files = await findFiles('**/extensions/**/*.{ts,tsx}', { cwd: webDir }); const keys = new Set(); let pageKeysOnly = 0; for (const file of files) { const parsed = parseRegistry(await readText(file)); for (const k of parsed.keys) keys.add(k); pageKeysOnly += parsed.pageKeysOnly; } return { keys, pageKeysOnly }; } /** Live discovery: SQL skeleton + registry views + controllers, scoped + reconciled. */ export async function discover(opts: DiscoverOptions): Promise { const warnings: string[] = []; const prefix = pathToPrefix(opts.path); const application = prefix.split('.')[0] || prefix; // SQL (synchronous — execFileSync under the hood). const navNodes = discoverNav(opts.connection, { sqlcmdPath: opts.sqlcmdPath }); const permissions = discoverPermissions(opts.connection, { sqlcmdPath: opts.sqlcmdPath }); const roleGrants = discoverRolePermissions(opts.connection, { sqlcmdPath: opts.sqlcmdPath }); const roleCatalogRows = discoverRoleCatalog(opts.connection, { sqlcmdPath: opts.sqlcmdPath }); // A U+FFFD in a discovered name means the sqlcmd stdout was decoded through the wrong // code page — the original byte is GONE, and every downstream match would fail under // another name ("role not found") three phases later. Refuse to write a corrupted plan. const corrupted = findReplacementCharCorruption({ nav: navNodes, permissions, grants: roleGrants, roleNames: roleCatalogRows.flatMap((r) => [r.name, ...(r.code ? [r.code] : [])]), }); if (corrupted.length > 0) { throw new Error( `Discovery output contains U+FFFD (encoding corruption) in ${corrupted.length} name(s): ` + corrupted.slice(0, 5).map((c) => `${c.source} "${c.value}"`).join(', ') + ` — sqlcmd emitted a non-UTF-8 code page (OEM/CP850). The CLI forces UTF-8 (-f 65001); ` + `upgrade sqlcmd (ODBC >= 13 or go-sqlcmd) or remove any sqlcmdPath override, then re-run /uat plan. ` + `Refusing to write a plan with corrupted names.`, ); } const keyOf = buildComponentKeys(navNodes); const permissionPaths = new Set(permissions.map((p) => p.path)); const { keys: registryKeys, pageKeysOnly } = await readRegistryKeys(opts.webDir); if (registryKeys.size === 0 && pageKeysOnly > 0) { warnings.push( `componentRegistry uses the PAGE_KEYS enum form (${pageKeysOnly} entries) — suffix views are not resolvable; routes are limited to nav-DB list pages.`, ); } const routes = correlateRoutes(navNodes, keyOf, registryKeys, permissionPaths, prefix); if (routes.length === 0) { warnings.push(`No navigable routes found under path "${opts.path}" (prefix "${prefix}").`); } const endpoints = opts.includeApi ? correlateEndpoints( await discoverEndpoints(opts.apiDir), permissionPaths, prefix, routes, application, warnings, ) : []; // Include EVERY role, not only those with ≥1 grant (ROLE_PERMISSIONS_SQL is an inner // join that hides zero-grant roles). A zero-grant role is worth testing — it should be // denied everywhere. rolesOverride (from provisioning) still wins when provided. const allRoleNames = roleCatalogRows.map((r) => r.name); const roleNames = opts.rolesOverride ?? [...new Set([...allRoleNames, ...roleGrants.map((g) => g.roleName)])]; const roles = roleNames.includes('anonymous') ? [...roleNames] : [...roleNames, 'anonymous']; const grantsByRole = groupGrantsByRole(roleGrants); // Role identities keyed by name, restricted to the roles the plan will carry // (under rolesOverride an unknown name simply has no entry — the schema's // catalog/roles bijection invariant surfaces it). const inPlan = new Set(roles); const roleCatalog = Object.fromEntries( roleCatalogRows .filter((r) => inPlan.has(r.name)) .map((r) => [r.name, { id: r.id, ...(r.code ? { code: r.code } : {}) }]), ); // The catalog is part of the rbac signature: an id/code change (or a rename of a // zero-grant role, invisible to the grants) must drift the plan. Side effect once: // every pre-catalog plan drifts and regenerates on its first refreshPlan=auto run. const signature = computeSignature({ nav: JSON.stringify(navNodes), rbac: JSON.stringify({ grants: roleGrants, catalog: roleCatalogRows }), registry: JSON.stringify([...registryKeys].sort()), }); return { result: { application, path: opts.path, routes, endpoints, rbac: { roles, grantsByRole }, roleCatalog, signature, }, warnings, }; }