import { AUDIT_ENDPOINTS, type AuditEndpointMeta } from './audit-endpoints'; /** What a matched request resolved to. */ export interface MatchedAuditEndpoint { /** Human-readable action, e.g. "Approve contract". */ title: string; /** Primary entity, e.g. "Contract". */ type: string; /** The route template, e.g. `/api/contracts/:id/cancel`. */ routePath: string; /** First path parameter of the template, when the route has one. */ resourceId: string | null; } interface CompiledRoute { regex: RegExp; /** Param names in capture-group order, e.g. ['id', 'stepId']. */ paramNames: string[]; routePath: string; meta: AuditEndpointMeta; /** Literal (non-parameter) segment count — used to rank specificity. */ staticSegments: number; } const ESCAPE_REGEX = /[.*+?^${}()|[\]\\]/g; /** * Two keys in AUDIT_ENDPOINTS point at the same path: one route is declared by * two different controllers, so the generator suffixed the second with * ` [modules/...controller.ts]` to keep both entries. Only the path itself is * matchable, so the suffix is stripped here. */ function stripSourceSuffix(key: string): string { const bracket = key.indexOf(' ['); return bracket === -1 ? key : key.slice(0, bracket); } /** * Compile one `"/api/contracts/:id/cancel"` template into an anchored regex. * * A parameter matches a single path segment only (`[^/]+`), so * `/api/contracts/:id` cannot swallow `/api/contracts/:id/cancel`. */ function compileTemplate(path: string): { regex: RegExp; paramNames: string[] } { const paramNames: string[] = []; const pattern = path .split('/') .map((segment) => { if (!segment.startsWith(':')) { return segment.replace(ESCAPE_REGEX, '\\$&'); } paramNames.push(segment.slice(1)); return '([^/]+)'; }) .join('/'); return { regex: new RegExp(`^${pattern}$`), paramNames }; } /** * Method-bucketed lookup table for the audited routes. * * A direct `AUDIT_ENDPOINTS[url]` lookup cannot work: the keys are templates * with `:params` while a live request carries real ids and a query string, so * every parameterized route — most of the 488 — would miss. Templates are * compiled to regexes once at module load and matched per request. * * Within a method, routes are ordered by literal-segment count descending, so * a specific route always wins over a parameterized one that could also match * (`/api/routes/:id/permanent` before `/api/routes/:id`). */ class AuditEndpointMatcher { private readonly byMethod = new Map(); constructor() { for (const [key, meta] of Object.entries(AUDIT_ENDPOINTS)) { const [method, rawPath] = stripSourceSuffix(key).split(' '); if (!method || !rawPath) continue; const { regex, paramNames } = compileTemplate(rawPath); const bucket = this.byMethod.get(method) ?? []; bucket.push({ regex, paramNames, routePath: rawPath, meta, staticSegments: rawPath .split('/') .filter((s) => s && !s.startsWith(':')).length, }); this.byMethod.set(method, bucket); } for (const bucket of this.byMethod.values()) { bucket.sort((a, b) => b.staticSegments - a.staticSegments); } } /** * Resolve a live request to its audit metadata, or null when the route is * not audited (every GET, and anything absent from AUDIT_ENDPOINTS). * * `url` may include a query string; it is ignored for matching. */ match(method: string, url: string): MatchedAuditEndpoint | null { const bucket = this.byMethod.get(method.toUpperCase()); if (!bucket) return null; const path = stripQuery(url); for (const route of bucket) { const result = route.regex.exec(path); if (!result) continue; const [title, , type] = route.meta; return { title, type, routePath: route.routePath, // The first path parameter is the affected record in this API's // conventions (`/api/contracts/:id/...`). Routes with no parameter // (a create) legitimately have no resource id yet. resourceId: route.paramNames.length > 0 ? result[1] : null, }; } return null; } } /** Strip query string and hash from a URL, leaving the path. */ export function stripQuery(url: string): string { const queryIndex = url.indexOf('?'); const path = queryIndex === -1 ? url : url.slice(0, queryIndex); const hashIndex = path.indexOf('#'); return hashIndex === -1 ? path : path.slice(0, hashIndex); } /** Compiled once at module load and shared by the interceptor. */ export const auditEndpointMatcher = new AuditEndpointMatcher();