import type * as http from 'node:http'; import type { TRouteHandler } from './server.interfaces.js'; import type { NonceManager } from './server.classes.nonce.js'; import { AcmeServerError } from './server.classes.jws.verifier.js'; interface IRoute { method: string; pattern: string; segments: string[]; handler: TRouteHandler; } /** * Minimal HTTP router for the ACME server. * Supports parameterized paths like /order/:id. */ export class AcmeRouter { private routes: IRoute[] = []; private nonceManager: NonceManager; constructor(nonceManager: NonceManager) { this.nonceManager = nonceManager; } addRoute(method: string, pattern: string, handler: TRouteHandler): void { this.routes.push({ method: method.toUpperCase(), pattern, segments: pattern.split('/').filter(Boolean), handler, }); } async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise { const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const method = (req.method || 'GET').toUpperCase(); const pathSegments = url.pathname.split('/').filter(Boolean); // Always add a fresh nonce to every response res.setHeader('Replay-Nonce', this.nonceManager.generate()); res.setHeader('Cache-Control', 'no-store'); // Find matching route for (const route of this.routes) { if (route.method !== method) continue; const params = this.matchPath(route.segments, pathSegments); if (params === null) continue; try { const body = method === 'POST' ? await this.parseBody(req) : undefined; await route.handler(req, res, params, body); } catch (err) { this.sendError(res, err); } return; } // No route found this.sendError(res, new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Not found')); } private matchPath( routeSegments: string[], pathSegments: string[], ): Record | null { if (routeSegments.length !== pathSegments.length) return null; const params: Record = {}; for (let i = 0; i < routeSegments.length; i++) { if (routeSegments[i].startsWith(':')) { params[routeSegments[i].slice(1)] = pathSegments[i]; } else if (routeSegments[i] !== pathSegments[i]) { return null; } } return params; } private parseBody(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => chunks.push(chunk)); req.on('end', () => { const raw = Buffer.concat(chunks).toString('utf-8'); if (!raw) { resolve(undefined); return; } try { resolve(JSON.parse(raw)); } catch { reject(new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JSON body')); } }); req.on('error', reject); }); } private sendError(res: http.ServerResponse, err: unknown): void { if (err instanceof AcmeServerError) { res.writeHead(err.status, { 'Content-Type': 'application/problem+json' }); res.end(JSON.stringify({ type: err.type, detail: err.detail, status: err.status, })); } else { const message = err instanceof Error ? err.message : 'Internal server error'; res.writeHead(500, { 'Content-Type': 'application/problem+json' }); res.end(JSON.stringify({ type: 'urn:ietf:params:acme:error:serverInternal', detail: message, status: 500, })); } } }