/** * uat-plan/project-roles.ts — PURE projection of discovery data onto roles. * * Turns the plain DiscoveryResult into the plan's `routes[]` and `endpoints[]`: * - each nav node expands into one Route per registered view (list/detail/create/…), * - every Route/Endpoint carries the ground-truth `access` / `expected_by_role` * map computed by rbac-matrix (wildcard-aware). * * No I/O, no Date, no randomness — same input, same output (byte-stable plans). * Route/URL/strategy rules mirror the frontend contract in scaffold-routes: * list/section-home → base URL (menu_click); detail → /:id, edit → /:id/edit * (click_row_in_parent, never goto+seedId — F7); create → /create (goto). * In M1 `subsets[]` is empty: implicit suffixes are ROUTES, not in-page elements; * the dynamic in-page subsets (tabs/buttons) are added by M6. */ import { buildAccessMap, ANONYMOUS_ROLE } from '../lib/rbac-matrix.js'; import { SUFFIX_URL, BASE_VIEW_KEYS } from '../../../lib/implicit-suffixes.js'; import type { Route, Endpoint } from '../lib/plantest-schema.js'; import type { DiscoveredNavRoute, DiscoveredEndpoint, ImplicitView } from './types.js'; /** Privilege-descending order for the well-known SmartStack roles. */ const ROLE_PRIORITY = ['super-admin', 'admin', 'manager', 'contributor', 'viewer']; /** * Canonical, deterministic role ordering: known roles by descending privilege, * unknown roles ASCII-sorted after them, `anonymous` always last. Dedupes. */ export function canonicalRoleOrder(roles: readonly string[]): string[] { const uniq = [...new Set(roles)]; const rest = uniq.filter((r) => r !== ANONYMOUS_ROLE); const anon = uniq.filter((r) => r === ANONYMOUS_ROLE); rest.sort((a, b) => { const ia = ROLE_PRIORITY.indexOf(a); const ib = ROLE_PRIORITY.indexOf(b); if (ia !== -1 && ib !== -1) return ia - ib; if (ia !== -1) return -1; if (ib !== -1) return 1; return a < b ? -1 : a > b ? 1 : 0; }); return [...rest, ...anon]; } /** Views that render the node's BASE route (no implicit suffix). */ const BASE_VIEWS = new Set(BASE_VIEW_KEYS as ImplicitView[]); /** UPPER_SNAKE route id from a component key + view (unique by construction; invariant 1). */ export function routeIdFor(componentKey: string, view: ImplicitView): string { const base = componentKey.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase(); if (BASE_VIEWS.has(view)) return base; return `${base}_${String(view).replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}`; } /** Component key for a view (base key for base views, `${key}.${view}` for suffix pages). */ export function componentKeyFor(componentKey: string, view: ImplicitView): string { return BASE_VIEWS.has(view) ? componentKey : `${componentKey}.${view}`; } /** The implicit view suffixes — SSOT moved to lib/implicit-suffixes.ts (re-exported for compat). */ export { IMPLICIT_VIEW_SUFFIXES } from '../../../lib/implicit-suffixes.js'; /** Final URL for a view, mirroring the DynamicRouter IMPLICIT_SUFFIXES contract. */ export function urlForView(baseRoute: string, view: ImplicitView): string { if (BASE_VIEWS.has(view)) return baseRoute; const suffix = SUFFIX_URL[view as string]; return suffix ? `${baseRoute}${suffix}` : `${baseRoute}/${view}`; } /** Navigation strategy: id-bearing (`/:id`) pages click a row in the parent list (F7). */ export function navStrategyForView(view: ImplicitView): Route['navigation_strategy'] { if (BASE_VIEWS.has(view)) return 'menu_click'; const suffix = SUFFIX_URL[view as string]; return suffix && suffix.includes('/:id') ? 'click_row_in_parent' : 'goto'; } /** Expand one nav node into its plan Routes — one per registered view. */ export function expandNavRoute( node: DiscoveredNavRoute, roles: readonly string[], grantsByRole: Readonly>, ): Route[] { const access = buildAccessMap(roles, node.permission, grantsByRole); const seen = new Set(); const out: Route[] = []; for (const view of node.views) { if (seen.has(view)) continue; seen.add(view); const strategy = navStrategyForView(view); const route: Route = { id: routeIdFor(node.componentKey, view), component_key: componentKeyFor(node.componentKey, view), route: urlForView(node.baseRoute, view), navigation_strategy: strategy, expect: { access_by_role: true, no_console_error: true, ...(node.label ? { title: node.label } : {}), }, access: { ...access }, subsets: [], }; if (node.permission) route.permission = node.permission; if (strategy === 'click_row_in_parent') { route.parent_route = node.baseRoute; route.on_empty_list = 'INDETERMINATE'; } out.push(route); } return out; } /** Project every nav node onto roles, preserving discovery order. */ export function projectRoutes( routes: readonly DiscoveredNavRoute[], roles: readonly string[], grantsByRole: Readonly>, ): Route[] { return routes.flatMap((node) => expandNavRoute(node, roles, grantsByRole)); } /** Fallback success status when a controller declares no ProducesResponseType (creates→201 come from the parse). */ export function okStatusFor(_method: DiscoveredEndpoint['method']): number { return 200; } /** Strip the `/api/` (or leading `/`) prefix to a stable endpoint id path. */ function endpointIdPath(route: string): string { return route.replace(/^\/?api\//i, '').replace(/^\//, ''); } /** Expected status per role: anonymous→401, granted (or ungated)→ok, otherwise→403. */ export function expectedStatusByRole( roles: readonly string[], permission: string | undefined, grantsByRole: Readonly>, okStatus: number, ): Record { const access = buildAccessMap(roles, permission, grantsByRole); const out: Record = {}; for (const role of roles) { const verdict = access[role]; out[role] = verdict === 'redirect_login' ? 401 : verdict === 'allowed' ? okStatus : 403; } return out; } /** Project discovered controller actions into the plan's `endpoints[]`. */ export function projectEndpoints( endpoints: readonly DiscoveredEndpoint[], roles: readonly string[], grantsByRole: Readonly>, ): Endpoint[] { return endpoints.map((e) => { const ok = e.okStatus ?? okStatusFor(e.method); // [AllowAnonymous] => no auth gate: every role (incl. anonymous) gets the success status. const expected_by_role = e.allowAnonymous ? Object.fromEntries(roles.map((r) => [r, ok])) : expectedStatusByRole(roles, e.permission, grantsByRole, ok); const endpoint: Endpoint = { id: `${e.method.toLowerCase()}:${endpointIdPath(e.route)}`, method: e.method, route: e.route, expected_by_role, }; if (e.controller) endpoint.controller = e.controller; if (e.permission) endpoint.permission = e.permission; if (e.permissionSource) endpoint.permission_source = e.permissionSource; if (e.ungated) endpoint.ungated = true; return endpoint; }); }