import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'; import { GATEWAY_AUTH_HEADER } from './runtime-config.js'; import type { Writable } from 'node:stream'; import Fastify, { type FastifyInstance, type FastifyServerOptions, type FastifyRequest, } from 'fastify'; import { UpstreamError } from './infrastructure/transport/upstream-error.js'; export const API_DEFAULT_HOST = '127.0.0.1' as const; export const API_DEFAULT_PORT = 8790 as const; const LEGACY_PORT = 8788; export const REDACT_PATHS = Object.freeze([ `req.headers.${GATEWAY_AUTH_HEADER}`, 'req.headers.authorization', 'req.headers.cookie', 'req.headers.x-confirmation-token', 'req.body.password', 'req.body.newPassword', 'req.body.confirmationToken', 'res.headers.set-cookie', ]); export interface ReadinessResult { readonly ready: boolean; readonly checks: Readonly>; } export interface BuildAppOptions { readonly gatewayToken?: string; readonly readiness?: () => ReadinessResult | Promise; readonly registerRoutes?: (app: FastifyInstance) => void | Promise; readonly logger?: false | { readonly stream?: Writable }; } export function secureTokenEqual( supplied: string, expected: string, compare: ( actual: NodeJS.ArrayBufferView, expected: NodeJS.ArrayBufferView, ) => boolean = timingSafeEqual, ): boolean { const digest = (value: string): Buffer => createHash('sha256').update(value, 'utf8').digest(); return compare(digest(supplied), digest(expected)); } export interface ListenEnvironment { readonly API_HOST?: string; readonly API_PORT?: string; } export interface ListenOptions { readonly host: string; readonly port: number; } interface ValidationIssue { readonly field: string; readonly code: string; readonly message: string; } const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']); const LOOPBACK_AUTHORITY = /^(?:127\.0\.0\.1|localhost|\[::1\])(?::([1-9]\d{0,4}))?$/i; const authority = (raw: string): { host: string; authority: string } | undefined => { const match = LOOPBACK_AUTHORITY.exec(raw); if (!match) return undefined; const port = match[1] === undefined ? undefined : Number(match[1]); if (port !== undefined && port > 65_535) return undefined; const separator = raw.lastIndexOf(':'); const hasPort = match[1] !== undefined; const host = hasPort ? raw.slice(0, separator) : raw; return { host: host.replace(/^\[|\]$/g, '').toLowerCase(), authority: raw.toLowerCase() }; }; const isLoopbackAuthority = (raw: string): boolean => { const parsed = authority(raw); return parsed !== undefined && LOOPBACK_HOSTS.has(parsed.host.toLowerCase()); }; const isSameOrigin = (origin: string, host: string): boolean => { const parsedHost = authority(host); return parsedHost !== undefined && origin.toLowerCase() === `http://${parsedHost.authority}`; }; const validationIssues = (error: unknown): readonly ValidationIssue[] => { if (!error || typeof error !== 'object' || !('validation' in error)) return []; const validation = (error as { validation?: unknown }).validation; if (!Array.isArray(validation)) return []; return validation.map((entry) => { const issue = entry as { keyword?: unknown; instancePath?: unknown; message?: unknown; params?: { missingProperty?: unknown }; }; const instancePath = typeof issue.instancePath === 'string' ? issue.instancePath : ''; const missingProperty = issue.params?.missingProperty; return { field: instancePath || (typeof missingProperty === 'string' ? `/${missingProperty}` : '/request'), code: typeof issue.keyword === 'string' ? issue.keyword : 'validation', message: typeof issue.message === 'string' ? issue.message : 'is invalid', }; }); }; const CLIENT_ERROR_TITLES: Readonly> = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed', 409: 'Conflict', 412: 'Precondition Failed', 413: 'Content Too Large', 415: 'Unsupported Media Type', 422: 'Unprocessable Content', 429: 'Too Many Requests', }; const clientStatus = (error: unknown): number | undefined => { if (!error || typeof error !== 'object' || !('statusCode' in error)) return undefined; const status = (error as { statusCode?: unknown }).statusCode; return typeof status === 'number' && status >= 400 && status < 500 ? status : undefined; }; const sendProblem = ( request: FastifyRequest, status: number, title: string, code: string, detail: string, validation?: readonly ValidationIssue[], ) => { const body: Record = { type: 'about:blank', title, status, code, detail, requestId: request.id, }; if (validation && validation.length > 0) body.validation = validation; return body; }; export function createListenOptions(environment: ListenEnvironment): ListenOptions { const host = environment.API_HOST ?? API_DEFAULT_HOST; if (!LOOPBACK_HOSTS.has(host.toLowerCase())) { throw new Error('API host must be an explicit loopback address'); } const rawPort = environment.API_PORT; const port = rawPort === undefined ? API_DEFAULT_PORT : Number(rawPort); if (!Number.isInteger(port) || port < 1 || port > 65_535) { throw new Error('API port must be an integer between 1 and 65535'); } if (port === LEGACY_PORT) throw new Error('API port 8788 is reserved for the legacy service'); if (port !== API_DEFAULT_PORT) throw new Error('API port is fixed at 8790'); return { host, port }; } export function buildApp(options: BuildAppOptions = {}): FastifyInstance { const logger = options.logger === false || options.logger === undefined ? false : { ...(options.logger.stream ? { stream: options.logger.stream } : {}), redact: { paths: [...REDACT_PATHS], censor: '[REDACTED]', }, }; const fastifyOptions: FastifyServerOptions = { genReqId: () => randomUUID(), logger, ajv: { customOptions: { removeAdditional: false, coerceTypes: false, }, }, }; const app = Fastify(fastifyOptions); const readiness = options.readiness ?? (() => ({ ready: false, checks: { bootstrap: 'unconfigured' } })); app.addHook('onRequest', async (request, reply) => { reply.header('X-Request-Id', request.id); if (options.gatewayToken !== undefined) { const supplied = request.headers[GATEWAY_AUTH_HEADER]; const authenticated = secureTokenEqual( typeof supplied === 'string' ? supplied : '', options.gatewayToken, ); if (!authenticated) { return reply .code(401) .type('application/problem+json') .send( sendProblem( request, 401, 'Unauthorized', 'GATEWAY_AUTH_REQUIRED', 'Trusted gateway authentication is required.', ), ); } } const host = request.headers.host; if (!host || !isLoopbackAuthority(host)) { return reply .code(403) .type('application/problem+json') .send( sendProblem( request, 403, 'Forbidden', 'UNTRUSTED_HOST', 'The Host header is not allowed.', ), ); } const origin = request.headers.origin; if (origin && !isSameOrigin(origin, host)) { return reply .code(403) .type('application/problem+json') .send( sendProblem( request, 403, 'Forbidden', 'CROSS_ORIGIN_REQUEST', 'The Origin header is not allowed.', ), ); } }); app.setErrorHandler((error, request, reply) => { const issues = validationIssues(error); if (issues.length > 0) { return reply .code(400) .type('application/problem+json') .send( sendProblem( request, 400, 'Bad Request', 'VALIDATION_FAILED', 'The request did not satisfy the endpoint schema.', issues, ), ); } if (error instanceof UpstreamError && error.code !== 'UPSTREAM_REQUEST_INVALID') { const status = error.code === 'UPSTREAM_INSECURE_AUTH' ? 400 : error.code === 'UPSTREAM_TIMEOUT' ? 504 : 502; const title = status === 400 ? 'Bad Request' : status === 504 ? 'Gateway Timeout' : 'Bad Gateway'; const detail = status === 400 ? 'Authentication requires an HTTPS instance origin.' : status === 504 ? 'The upstream instance did not respond in time.' : 'The upstream instance could not complete the request.'; return reply .code(status) .type('application/problem+json') .send(sendProblem(request, status, title, error.code, detail)); } const status = clientStatus(error); if (status !== undefined) { const title = CLIENT_ERROR_TITLES[status] ?? 'Bad Request'; return reply .code(status) .type('application/problem+json') .send( sendProblem( request, status, title, `HTTP_${status}`, 'The request could not be accepted.', ), ); } request.log.error( { errorType: error instanceof Error ? error.name : typeof error }, 'request failed', ); return reply .code(500) .type('application/problem+json') .send( sendProblem( request, 500, 'Internal Server Error', 'INTERNAL_ERROR', 'The request could not be completed.', ), ); }); app.setNotFoundHandler((request, reply) => reply .code(404) .type('application/problem+json') .send( sendProblem( request, 404, 'Not Found', 'NOT_FOUND', 'The requested resource does not exist.', ), ), ); app.get('/healthz', async () => ({ status: 'ok' })); app.get('/readyz', async (_request, reply) => { const result = await readiness(); if (!result.ready) reply.code(503); return result; }); if (options.registerRoutes) { app.register(async (scope) => options.registerRoutes?.(scope)); } return app; }