1import type { Request, Response, NextFunction } from "express"; 2 3/** 4 * Versioning + deprecation (TASK-0127, docs/api/versioning.md). 5 * 6 * `/api/v1` is the STABLE public surface: breaking changes ship only in a 7 * new major (`/api/v2`). A route is deprecated by ADDING it to 8 * {@link DEPRECATED_ROUTES} (method + path, `:param` segments match 9 * literally); every response from that route then carries: 10 * 11 * Deprecation: true 12 * Sunset: <RFC 1123 date> (minimum 6 months from deprecation) 13 * 14 * and the route appears as `deprecated: true` in `docs/api/openapi.yaml` 15 * (kept by hand — the contract test enforces the rest). 16 * 17 * The map is intentionally EMPTY at Phase-13 ship: the mechanism + a 18 * documented example are what the spec asks for (the synthetic example 19 * route is exercised in `deprecation.test.ts` via {@link createDeprecationMiddleware}, 20 * not added to the live router). 21 */ 22export interface DeprecatedRoute { 23 /** RFC 1123 date, e.g. "Tue, 01 Jul 2026 00:00:00 GMT". */ 24 sunset: string; 25 /** Optional human note (surfaced in the docs site only). */ 26 reason?: string; 27} 28 29/** Key = `${METHOD} ${path}` (path in request form, e.g. `/api/v1/…`). */ 30export const DEPRECATED_ROUTES: Record<string, DeprecatedRoute> = { 31 // Example (mechanism documentation — NOT a live route; the contract test 32 // asserts the map's shape instead): 33 // "POST /api/v1/workspaces/:id/pages/duplicate-legacy": { 34 // sunset: "Tue, 01 Jul 2026 00:00:00 GMT", 35 // reason: "Use POST /workspaces/:id/pages/:id/duplicate" 36 // } 37}; 38 39/** 40 * Compile a `"METHOD /api/v1/path/with/:params"` key into a predicate that 41 * matches a concrete request. `:param` segments match exactly one non-slash 42 * segment; literal segments match byte-for-byte. (Express's router already 43 * owns the live route; this map is a DEPRECATION overlay, so it must 44 * independently match the request path — a param-aware exact match.) 45 */ 46function routeMatcher(methodPath: string): (method: string, path: string) => boolean { 47 const space = methodPath.indexOf(" "); 48 const method = methodPath.slice(0, space).toUpperCase(); 49 const template = methodPath.slice(space + 1); 50 const segments = template.split("/"); 51 return (reqMethod: string, reqPath: string) => { 52 if (reqMethod.toUpperCase() !== method) return false; 53 const reqSegs = reqPath.split("/"); 54 if (reqSegs.length !== segments.length) return false; 55 for (let i = 0; i < segments.length; i++) { 56 const tpl = segments[i]; 57 if (tpl.startsWith(":")) continue; // any single segment 58 if (tpl !== reqSegs[i]) return false; 59 } 60 return true; 61 }; 62} 63 64/** 65 * Build an Express middleware for a route→config map. Exported separately 66 * so the synthetic-route test (and a future per-deployment override) can 67 * pass its own map. `:param` template segments match concrete ids. 68 */ 69export function createDeprecationMiddleware( 70 routes: Record<string, DeprecatedRoute> = DEPRECATED_ROUTES 71): (req: Request, res: Response, next: NextFunction) => void { 72 const matchers = Object.keys(routes).map((key) => ({ 73 match: routeMatcher(key), 74 cfg: routes[key] 75 })); 76 return (req, res, next) => { 77 if (req.path.startsWith("/api/v1/")) { 78 for (const { match, cfg } of matchers) { 79 if (match(req.method, req.path)) { 80 res.setHeader("Deprecation", "true"); 81 res.setHeader("Sunset", cfg.sunset); 82 break; 83 } 84 } 85 } 86 next(); 87 }; 88} 89 90/** The default-mapped middleware (wired in main.ts + the test app). */ 91export const deprecationMiddleware = createDeprecationMiddleware(); 92