/** * lib/url-conventions.ts — Single source of truth for the API URL conventions. * Imported by BOTH backend scaffolders (`scaffold-controller`, * `scaffold-screen-controller`, `scaffold-external-api`) AND the frontend * `scaffold-api-client`, plus the static parity audit `audit-dev-wire`. * * Three strata exist; nothing else is legal: * - **Integration** /api/{module}/{section} (machine-to-machine CRUD) * The route is NOT a fixed literal: the integration controller declares * `[NavRoute("module.section")]` and the platform's * `NavigationRouteModelProvider` REWRITES the route at runtime to * `/api/{module}/{section}` (it clears any `[Route]` selector). The CLI * mirrors that mapping with `buildNavApiPath(navRoute)`. The historical * `/api/v1/integration/{plural}` literal was a guaranteed 404 — the * backend never served it (the convention had already rewritten it away). * - **Screen-driven** /api/screens/{plural-kebab}/{view} (UI-facing, shaped by pagespec) * A literal `[Route]` with NO `[NavRoute]` → not rewritten by the platform. * - **Public** /api/v1/export/{catalogue-code} (THIRD-PARTY, machine-to-machine) * A literal `[Route]` under the ONLY prefix the platform's * `ExternalAppRouteGuardMiddleware` whitelists for an external-app * principal. Emitted by `scaffold-external-api`; the code grammar and the * catalogue contract live in `lib/external-api-catalog.ts`. * NOTE — the integration stratum is NOT reachable by an external app: * `/api/{module}/{section}` is off the whitelist and 403s `route_blocked`. * * Deriving BOTH sides (C# controller and TS service) from the SAME navRoute * makes drift mathematically impossible. */ import { toKebabCase, navRouteToUrlPath } from './string-utils.js' import { PUBLIC_STRATUM_PREFIX } from './external-api-catalog.js' export const SCREEN_STRATUM_PREFIX = '/api/screens' // Stratum classification used by the static parity audit (`audit-dev-wire`). // - 'screens' — served at `/api/screens/...` by `scaffold-screen-controller` // (a literal `[Route]`, NOT rewritten by the platform). // - 'integration' — the CRUD stratum. At runtime the platform rewrites every // integration controller's route from `[NavRoute]` to // `/api/{module}/{section}` (see buildNavApiPath), so the // integration stratum is ANY `/api/*` path that is not a // screen route — there is no fixed `/api/v1/integration` // prefix at runtime. // - 'public' — served at `/api/v1/export/{code}` by `scaffold-external-api` // (a literal `[Route]` under the external-app whitelist). // Consumed by third parties, never by the SPA — so // `audit-dev-wire` must NOT report it as a backend orphan. // - 'unknown' — not an `/api/*` path at all (external / malformed). export type Stratum = 'integration' | 'screens' | 'public' | 'unknown' /** * Build the integration-stratum base path for an entity FROM ITS NAVROUTE — the * SAME path the platform serves at runtime. `NavigationRouteModelProvider` * resolves `[NavRoute("module.section")]` to `/api/{module}/{section}` (registry * 1:1 mapping `api/{app}/{mod}`, or `navRoute.Replace('.', '/')` fallback). * `navRouteToUrlPath` mirrors that mapping (dot-path → kebab slash-path). * * buildNavApiPath('configuration.types-clients') → '/api/configuration/types-clients' * buildNavApiPath('repertoire.clients') → '/api/repertoire/clients' * * Single source of truth: the navRoute is emitted by `scaffold-controller` * (`[NavRoute]`), consumed by `scaffold-api-client` (`API_PATH`) and * `scaffold-component` (FK lookup), and resolved by `audit-dev-wire`. * Front == back by construction. */ export function buildNavApiPath(navRoute: string): string { return `/api/${navRouteToUrlPath(navRoute)}` } /** * Build the screen-driven base path for an entity. * Used by `scaffold-screen-controller` and by `scaffold-api-client` in * `useScreens=true` mode. * * buildScreenRoute('demandes') → '/api/screens/demandes' */ export function buildScreenRoute(pluralKebab: string): string { return `${SCREEN_STRATUM_PREFIX}/${pluralKebab}` } /** * Per-view suffix appended to a screen-driven base path. * Used by both sides so that the controller's `[HttpVerb("")]` and * the api-client's URL string come from the same lookup. * * buildScreenViewPath('list') → '/list' * buildScreenViewPath('detail') → '/detail/{id:guid}' * buildScreenViewPath('form') → '/form' * buildScreenViewPath('dashboard') → '/dashboard' (one endpoint per dashboard * returning { widgets: Record }) * * Hub views (`section-home`, `app-home`, `module-home`) deliberately have no * suffix — they are Slot containers that emit no API call. */ export type ScreenView = 'list' | 'detail' | 'form' | 'dashboard' export function buildScreenViewPath(view: ScreenView): string { switch (view) { case 'list': return '/list' case 'detail': return '/detail/{id:guid}' case 'form': return '/form' case 'dashboard': return '/dashboard' } } /** * Compute the URL-segment plural for an entity name. PascalCase → kebab-case * with proper handling of consecutive uppercase letters (`HRDepartment` → * `hr-department`). Equivalent across all scaffolders — never reimplement. */ export function pluralSegment(pluralName: string): string { return toKebabCase(pluralName) } /** * Canonicalise an arbitrary route literal (as emitted on either side) into its * stratum + remainder. Used by `audit-dev-wire` to route fix messages — the * actual orphan detection matches the frontend call against the NavRoute-resolved * backend index, NOT against a fixed prefix. * * canonicaliseRoute('/api/configuration/types-clients/lookup') * → { stratum: 'integration', rest: 'configuration/types-clients/lookup' } * canonicaliseRoute('/api/screens/demandes/dashboard/consolidated') * → { stratum: 'screens', rest: 'demandes/dashboard/consolidated' } * canonicaliseRoute('/api/v1/export/crm-factures/{id:guid}') * → { stratum: 'public', rest: 'crm-factures/{id:guid}' } * canonicaliseRoute('https://example.com/x') * → { stratum: 'unknown', rest: 'https://example.com/x' } * * Screens and public are the two strata with a fixed prefix, and public is * tested FIRST — `/api/v1/export/...` also starts with `/api/`, so the * integration fallback would otherwise swallow it. Every other `/api/*` path is * an integration route (the platform rewrites integration controllers to * `/api/{module}/{section}` from `[NavRoute]`). Only a non-`/api` URL is 'unknown'. */ export function canonicaliseRoute(raw: string): { stratum: Stratum; rest: string } { const trimmed = raw.replace(/^\/+/, '/') if (trimmed.startsWith(`${SCREEN_STRATUM_PREFIX}/`)) { return { stratum: 'screens', rest: trimmed.slice(SCREEN_STRATUM_PREFIX.length + 1) } } if (trimmed === SCREEN_STRATUM_PREFIX) { return { stratum: 'screens', rest: '' } } if (trimmed.toLowerCase().startsWith(`${PUBLIC_STRATUM_PREFIX}/`)) { return { stratum: 'public', rest: trimmed.slice(PUBLIC_STRATUM_PREFIX.length + 1) } } if (trimmed.toLowerCase() === PUBLIC_STRATUM_PREFIX) { return { stratum: 'public', rest: '' } } if (trimmed.startsWith('/api/')) { return { stratum: 'integration', rest: trimmed.slice('/api/'.length) } } if (trimmed === '/api') { return { stratum: 'integration', rest: '' } } return { stratum: 'unknown', rest: trimmed.replace(/^\/?/, '') } } // NOTE — custom-action path segments are NOT built here. The single source of // truth is `lib/page-spec-actions.ts` → `expectedControllerRoute` (C# route, // `{id:guid}/`) and `expectedUrlPath` (TS service, `/{id}/`), // which the dev audits already consume. Both stratums (integration AND screens) // use the SAME `{id:guid}/` row form so the frontend URL matches by // construction — there is no `detail/` prefix divergence. A former // `buildCustomActionPath` helper lived here, unused, and emitted a `detail/` // screen variant with no frontend counterpart; it was removed (2026-06-28).