diff --git a/apps/api/package.json b/apps/api/package.json index b622218..94d5bd8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,6 +5,13 @@ "type": "module", "exports": "./src/index.ts", "scripts": { + "test": "vitest run --root ../.. apps/api/src/app.test.ts apps/api/src/start.test.ts", "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "fastify": "5.10.0" + }, + "devDependencies": { + "@types/node": "24.13.3" } } diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts new file mode 100644 index 0000000..1452a56 --- /dev/null +++ b/apps/api/src/app.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { Writable } from 'node:stream'; +import { + API_DEFAULT_HOST, + API_DEFAULT_PORT, + buildApp, + createListenOptions, + REDACT_PATHS, +} from './app.js'; + +const registerProbe = async (app: FastifyInstance): Promise => { + app.post( + '/api/v1/test-only/schema-probe', + { + schema: { + body: { + type: 'object', + additionalProperties: false, + required: ['name'], + properties: { name: { type: 'string', minLength: 1 } }, + }, + }, + }, + async (request) => ({ name: (request.body as { name: string }).name }), + ); + app.get('/api/v1/test-only/failure', async () => { + throw new Error('internal secret detail'); + }); +}; + +describe('Fastify API skeleton', () => { + it('is injectable without listening and exposes health/readiness with server request IDs', async () => { + const app = buildApp({ readiness: () => ({ ready: true, checks: { bootstrap: 'ready' } }) }); + const health = await app.inject({ + method: 'GET', + url: '/healthz', + headers: { 'x-request-id': 'attacker-controlled' }, + }); + const ready = await app.inject({ method: 'GET', url: '/readyz' }); + + expect(health.statusCode).toBe(200); + expect(health.json()).toEqual({ status: 'ok' }); + expect(ready.statusCode).toBe(200); + expect(ready.json()).toEqual({ ready: true, checks: { bootstrap: 'ready' } }); + expect(health.headers['x-request-id']).toMatch(/^[0-9a-f-]{36}$/); + expect(health.headers['x-request-id']).not.toBe('attacker-controlled'); + await app.close(); + }, 15_000); + + it('returns RFC Problem Details for schema failures and unexpected errors without leaking detail', async () => { + let logs = ''; + const logStream = new Writable({ + write(chunk, _encoding, callback) { + logs += String(chunk); + callback(); + }, + }); + const app = buildApp({ logger: { stream: logStream }, registerRoutes: registerProbe }); + const invalid = await app.inject({ + method: 'POST', + url: '/api/v1/test-only/schema-probe', + headers: { 'content-type': 'application/json' }, + payload: {}, + }); + expect(invalid.statusCode).toBe(400); + expect(invalid.headers['content-type']).toContain('application/problem+json'); + expect(invalid.json()).toMatchObject({ + type: 'about:blank', + title: 'Bad Request', + status: 400, + code: 'VALIDATION_FAILED', + requestId: invalid.headers['x-request-id'], + }); + expect(invalid.json().validation).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: expect.any(String), + code: expect.any(String), + message: expect.any(String), + }), + ]), + ); + + const failure = await app.inject({ method: 'GET', url: '/api/v1/test-only/failure' }); + expect(failure.statusCode).toBe(500); + expect(failure.headers['content-type']).toContain('application/problem+json'); + expect(JSON.stringify(failure.json())).not.toContain('internal secret detail'); + expect(logs).toContain('request failed'); + expect(logs).not.toContain('internal secret detail'); + await app.close(); + }); + + it.each([ + { + name: 'malformed JSON', + request: { + method: 'POST' as const, + url: '/api/v1/test-only/schema-probe', + headers: { 'content-type': 'application/json' }, + payload: '{', + }, + status: 400, + }, + { + name: 'unsupported media type', + request: { + method: 'POST' as const, + url: '/api/v1/test-only/schema-probe', + headers: { 'content-type': 'application/x-unsupported' }, + payload: 'x', + }, + status: 415, + }, + { + name: 'oversized body', + request: { + method: 'POST' as const, + url: '/api/v1/test-only/schema-probe', + headers: { 'content-type': 'application/json' }, + payload: JSON.stringify({ name: 'x'.repeat(1_048_577) }), + }, + status: 413, + }, + ])('preserves Fastify client status for $name', async ({ request, status }) => { + const app = buildApp({ registerRoutes: registerProbe }); + const response = await app.inject(request); + expect(response.statusCode).toBe(status); + expect(response.headers['content-type']).toContain('application/problem+json'); + expect(response.json()).toMatchObject({ status, requestId: response.headers['x-request-id'] }); + await app.close(); + }); + + it('rejects non-loopback Host and cross-origin browser requests before route execution', async () => { + const app = buildApp({ registerRoutes: registerProbe }); + const foreignHost = await app.inject({ + method: 'GET', + url: '/healthz', + headers: { host: 'example.com' }, + }); + expect(foreignHost.statusCode).toBe(403); + expect(foreignHost.headers['content-type']).toContain('application/problem+json'); + + const crossOrigin = await app.inject({ + method: 'GET', + url: '/healthz', + headers: { host: '127.0.0.1:8790', origin: 'http://localhost:8790' }, + }); + expect(crossOrigin.statusCode).toBe(403); + + const sameOrigin = await app.inject({ + method: 'GET', + url: '/healthz', + headers: { host: '127.0.0.1:8790', origin: 'http://127.0.0.1:8790' }, + }); + expect(sameOrigin.statusCode).toBe(200); + + for (const host of [ + '127.0.0.1@example.com', + '127.0.0.1/path', + '[::ffff:127.0.0.1]:8790', + '2130706433', + '0177.0.0.1', + '0x7f.0.0.1', + '127.1', + '127.0.1', + '[0:0:0:0:0:0:0:1]', + ]) { + const response = await app.inject({ method: 'GET', url: '/healthz', headers: { host } }); + expect(response.statusCode, host).toBe(403); + } + for (const host of ['localhost:8790', '[::1]:8790']) { + const response = await app.inject({ + method: 'GET', + url: '/healthz', + headers: { host, origin: `http://${host}` }, + }); + expect(response.statusCode, host).toBe(200); + } + for (const origin of [ + 'https://127.0.0.1:8790', + 'http://evil@127.0.0.1:8790', + 'http://127.0.0.1:8790/path', + 'http://2130706433:8790', + ]) { + const response = await app.inject({ + method: 'GET', + url: '/healthz', + headers: { host: '127.0.0.1:8790', origin }, + }); + expect(response.statusCode, origin).toBe(403); + } + await app.close(); + }); + + it('returns 503 with structured readiness checks when dependencies are not ready', async () => { + const app = buildApp({ + readiness: () => ({ ready: false, checks: { database: 'unavailable' } }), + }); + const response = await app.inject({ method: 'GET', url: '/readyz' }); + expect(response.statusCode).toBe(503); + expect(response.json()).toEqual({ ready: false, checks: { database: 'unavailable' } }); + await app.close(); + }); + + it('defaults to a separate loopback listener and rejects unsafe listen overrides', () => { + expect(API_DEFAULT_HOST).toBe('127.0.0.1'); + expect(API_DEFAULT_PORT).not.toBe(8788); + expect(createListenOptions({})).toEqual({ host: API_DEFAULT_HOST, port: API_DEFAULT_PORT }); + expect(createListenOptions({ API_HOST: '::1' })).toEqual({ + host: '::1', + port: API_DEFAULT_PORT, + }); + expect(() => createListenOptions({ API_HOST: '0.0.0.0' })).toThrow(/loopback/i); + expect(() => createListenOptions({ API_PORT: '8788' })).toThrow(/reserved|legacy/i); + expect(() => createListenOptions({ API_PORT: 'not-a-port' })).toThrow(/port/i); + }); + + it('defines immutable logger redaction for credentials, confirmation headers and cookies', () => { + expect(REDACT_PATHS).toEqual( + expect.arrayContaining([ + 'req.headers.authorization', + 'req.headers.cookie', + 'req.headers.x-confirmation-token', + 'req.body.password', + 'res.headers.set-cookie', + ]), + ); + expect(new Set(REDACT_PATHS).size).toBe(REDACT_PATHS.length); + }); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..d31dbf7 --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,279 @@ +import { randomUUID } from 'node:crypto'; +import type { Writable } from 'node:stream'; +import Fastify, { + type FastifyInstance, + type FastifyServerOptions, + type FastifyRequest, +} from 'fastify'; + +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.authorization', + 'req.headers.cookie', + 'req.headers.x-confirmation-token', + 'req.body.password', + 'req.body.confirmationToken', + 'res.headers.set-cookie', +]); + +export interface ReadinessResult { + readonly ready: boolean; + readonly checks: Readonly>; +} + +export interface BuildAppOptions { + readonly readiness?: () => ReadinessResult | Promise; + readonly registerRoutes?: (app: FastifyInstance) => void | Promise; + readonly logger?: false | { readonly stream?: Writable }; +} + +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'); + 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, + }; + const app = Fastify(fastifyOptions); + const readiness = options.readiness ?? (() => ({ ready: true, checks: { bootstrap: 'ready' } })); + + app.addHook('onRequest', async (request, reply) => { + reply.header('X-Request-Id', request.id); + 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, + ), + ); + } + 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; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 09904b6..7ea398f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1 +1,10 @@ -export const apiWorkspaceReady = true; +export { + API_DEFAULT_HOST, + API_DEFAULT_PORT, + REDACT_PATHS, + buildApp, + createListenOptions, +} from './app.js'; +export type { BuildAppOptions, ListenEnvironment, ListenOptions, ReadinessResult } from './app.js'; +export { startApi } from './start.js'; +export type { StartApiOptions } from './start.js'; diff --git a/apps/api/src/start.test.ts b/apps/api/src/start.test.ts new file mode 100644 index 0000000..e09964a --- /dev/null +++ b/apps/api/src/start.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { API_DEFAULT_HOST, API_DEFAULT_PORT } from './app.js'; +import { startApi } from './start.js'; + +describe('API startup boundary', () => { + it('consumes the validated loopback listener without opening a real socket', async () => { + const calls: unknown[] = []; + await startApi({ + app: { + listen: async (options) => { + calls.push(options); + return 'http://127.0.0.1:8790'; + }, + }, + }); + expect(calls).toEqual([{ host: API_DEFAULT_HOST, port: API_DEFAULT_PORT }]); + }); + + it('rejects the legacy port before invoking listen', async () => { + let called = false; + await expect( + startApi({ + environment: { API_PORT: '8788' }, + app: { + listen: async () => { + called = true; + return ''; + }, + }, + }), + ).rejects.toThrow(/reserved|legacy/i); + expect(called).toBe(false); + }); +}); diff --git a/apps/api/src/start.ts b/apps/api/src/start.ts new file mode 100644 index 0000000..55911ad --- /dev/null +++ b/apps/api/src/start.ts @@ -0,0 +1,12 @@ +import type { FastifyInstance } from 'fastify'; +import { buildApp, createListenOptions, type ListenEnvironment } from './app.js'; + +export interface StartApiOptions { + readonly environment?: ListenEnvironment; + readonly app?: Pick; +} + +export async function startApi(options: StartApiOptions = {}): Promise { + const app = options.app ?? buildApp({ logger: {} }); + await app.listen(createListenOptions(options.environment ?? {})); +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 4491d1b..3dff0dc 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "rootDir": "src" + "rootDir": "src", + "types": ["node"] }, "include": ["src/**/*.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 746a9ad..eb4bc8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,9 +32,17 @@ importers: version: 8.53.0(eslint@9.39.2)(typescript@5.9.3) vitest: specifier: 4.0.18 - version: 4.0.18 + version: 4.0.18(@types/node@24.13.3) - apps/api: {} + apps/api: + dependencies: + fastify: + specifier: 5.10.0 + version: 5.10.0 + devDependencies: + '@types/node': + specifier: 24.13.3 + version: 24.13.3 apps/web: {} @@ -450,6 +458,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@typescript-eslint/eslint-plugin@8.53.0': resolution: {integrity: sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1136,6 +1147,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1509,6 +1523,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + '@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -1609,13 +1627,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.0.18(vite@7.3.6)': + '@vitest/mocker@4.0.18(vite@7.3.6(@types/node@24.13.3))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6 + vite: 7.3.6(@types/node@24.13.3) '@vitest/pretty-format@4.0.18': dependencies: @@ -2234,11 +2252,13 @@ snapshots: typescript@5.9.3: {} + undici-types@7.18.2: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - vite@7.3.6: + vite@7.3.6(@types/node@24.13.3): dependencies: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.5) @@ -2247,12 +2267,13 @@ snapshots: rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 24.13.3 fsevents: 2.3.3 - vitest@4.0.18: + vitest@4.0.18(@types/node@24.13.3): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.6) + '@vitest/mocker': 4.0.18(vite@7.3.6(@types/node@24.13.3)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -2269,8 +2290,10 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 7.3.6 + vite: 7.3.6(@types/node@24.13.3) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 transitivePeerDependencies: - jiti - less