feat(api): add secure Fastify skeleton
This commit is contained in:
@@ -5,6 +5,13 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./src/index.ts",
|
"exports": "./src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test": "vitest run --root ../.. apps/api/src/app.test.ts apps/api/src/start.test.ts",
|
||||||
"typecheck": "tsc -p tsconfig.json"
|
"typecheck": "tsc -p tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fastify": "5.10.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "24.13.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildAppOptions {
|
||||||
|
readonly readiness?: () => ReadinessResult | Promise<ReadinessResult>;
|
||||||
|
readonly registerRoutes?: (app: FastifyInstance) => void | Promise<void>;
|
||||||
|
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<Record<number, string>> = {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
+10
-1
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<FastifyInstance, 'listen'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startApi(options: StartApiOptions = {}): Promise<void> {
|
||||||
|
const app = options.app ?? buildApp({ logger: {} });
|
||||||
|
await app.listen(createListenOptions(options.environment ?? {}));
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src"
|
"rootDir": "src",
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+31
-8
@@ -32,9 +32,17 @@ importers:
|
|||||||
version: 8.53.0(eslint@9.39.2)(typescript@5.9.3)
|
version: 8.53.0(eslint@9.39.2)(typescript@5.9.3)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.0.18
|
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: {}
|
apps/web: {}
|
||||||
|
|
||||||
@@ -450,6 +458,9 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/node@24.13.3':
|
||||||
|
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.53.0':
|
'@typescript-eslint/eslint-plugin@8.53.0':
|
||||||
resolution: {integrity: sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==}
|
resolution: {integrity: sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -1136,6 +1147,9 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
undici-types@7.18.2:
|
||||||
|
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||||
|
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||||
|
|
||||||
@@ -1509,6 +1523,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@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)':
|
'@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:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -1609,13 +1627,13 @@ snapshots:
|
|||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.1.0
|
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:
|
dependencies:
|
||||||
'@vitest/spy': 4.0.18
|
'@vitest/spy': 4.0.18
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.3.6
|
vite: 7.3.6(@types/node@24.13.3)
|
||||||
|
|
||||||
'@vitest/pretty-format@4.0.18':
|
'@vitest/pretty-format@4.0.18':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2234,11 +2252,13 @@ snapshots:
|
|||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
undici-types@7.18.2: {}
|
||||||
|
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
vite@7.3.6:
|
vite@7.3.6(@types/node@24.13.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.28.1
|
esbuild: 0.28.1
|
||||||
fdir: 6.5.0(picomatch@4.0.5)
|
fdir: 6.5.0(picomatch@4.0.5)
|
||||||
@@ -2247,12 +2267,13 @@ snapshots:
|
|||||||
rollup: 4.62.2
|
rollup: 4.62.2
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/node': 24.13.3
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
vitest@4.0.18:
|
vitest@4.0.18(@types/node@24.13.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 4.0.18
|
'@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/pretty-format': 4.0.18
|
||||||
'@vitest/runner': 4.0.18
|
'@vitest/runner': 4.0.18
|
||||||
'@vitest/snapshot': 4.0.18
|
'@vitest/snapshot': 4.0.18
|
||||||
@@ -2269,8 +2290,10 @@ snapshots:
|
|||||||
tinyexec: 1.2.4
|
tinyexec: 1.2.4
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
vite: 7.3.6
|
vite: 7.3.6(@types/node@24.13.3)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 24.13.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- jiti
|
- jiti
|
||||||
- less
|
- less
|
||||||
|
|||||||
Reference in New Issue
Block a user