diff --git a/apps/indexer/src/middleware/cors.test.ts b/apps/indexer/src/middleware/cors.test.ts index 89382f54..e49a8e2b 100644 --- a/apps/indexer/src/middleware/cors.test.ts +++ b/apps/indexer/src/middleware/cors.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { Hono } from 'hono'; -import { cors } from './cors'; +import fastify from 'fastify'; +import { cors, resolveAllowedOrigins, parseAllowedOrigins } from './cors.js'; /** * Unit tests locking the CORS policy defined in ./cors.ts (issue #1196, #1289). @@ -8,246 +8,363 @@ import { cors } from './cors'; * These tests pin the observable contract of the middleware so that any * change to the policy (allowed origin, methods, headers, preflight handling) * fails CI rather than silently shipping a weaker policy. + * + * Uses Fastify's built-in inject() API — no external HTTP call required. */ function buildApp(options?: Parameters[0]) { - const app = new Hono(); - app.use('*', cors(options)); - app.get('/health', (c) => c.json({ ok: true })); - app.post('/tx', (c) => c.json({ ok: true })); + const app = fastify({ logger: false }); + const corsMiddleware = cors(options); + + // Register the cors handler as a global hook. + app.addHook('onRequest', async (req, reply) => { + const result = await corsMiddleware(req, reply); + // cors() returns the reply when it short-circuits OPTIONS; + // returning undefined lets Fastify continue to the route handler. + return result; + }); + + app.get('/health', async (_req, reply) => { + return reply.status(200).send({ ok: true }); + }); + + app.post('/tx', async (_req, reply) => { + return reply.status(200).send({ ok: true }); + }); + return app; } -describe('cors middleware', () => { - let warnSpy: ReturnType; +// --------------------------------------------------------------------------- +// resolveAllowedOrigins / parseAllowedOrigins unit tests +// --------------------------------------------------------------------------- - beforeEach(() => { - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); +describe('resolveAllowedOrigins', () => { + afterEach(() => { + delete process.env.CORS_ALLOWED_ORIGINS; }); - afterEach(() => { - warnSpy.mockRestore(); + it('returns [] when env var is unset', () => { + delete process.env.CORS_ALLOWED_ORIGINS; + expect(resolveAllowedOrigins(undefined)).toEqual([]); }); - it('sets the allow-origin header on simple requests', async () => { + it('returns [] for an empty string', () => { + expect(resolveAllowedOrigins('')).toEqual([]); + }); + + it('returns [] for a whitespace-only string', () => { + expect(resolveAllowedOrigins(' ')).toEqual([]); + }); + + it('parses a single origin', () => { + expect(resolveAllowedOrigins('https://app.vatix.io')).toEqual([ + 'https://app.vatix.io', + ]); + }); + + it('parses multiple origins, trimming whitespace', () => { + expect( + resolveAllowedOrigins( + ' https://app.vatix.io , https://admin.vatix.io ', + ), + ).toEqual(['https://app.vatix.io', 'https://admin.vatix.io']); + }); + + it('drops empty entries between commas', () => { + expect( + resolveAllowedOrigins('https://app.vatix.io,,https://admin.vatix.io'), + ).toEqual(['https://app.vatix.io', 'https://admin.vatix.io']); + }); +}); + +describe('parseAllowedOrigins', () => { + it('returns [] for undefined', () => { + expect(parseAllowedOrigins(undefined)).toEqual([]); + }); + + it('returns [] for empty string', () => { + expect(parseAllowedOrigins('')).toEqual([]); + }); + + it('returns [] for whitespace-only', () => { + expect(parseAllowedOrigins(' \t ')).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// cors() middleware – wildcard (no allowlist) mode +// --------------------------------------------------------------------------- + +describe('cors middleware — wildcard mode (no allowlist)', () => { + it('sets Access-Control-Allow-Origin: * on simple requests', async () => { const app = buildApp(); - const res = await app.request('/health', { - headers: { Origin: 'https://app.vatix.io' }, + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { origin: 'https://app.vatix.io' }, }); - expect(res.status).toBe(200); - expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe('*'); }); - it('sets the allow-origin header even when Origin is absent', async () => { + it('sets Access-Control-Allow-Origin: * even when Origin is absent', async () => { const app = buildApp(); - const res = await app.request('/health'); + const res = await app.inject({ method: 'GET', url: '/health' }); - expect(res.status).toBe(200); - expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe('*'); }); it('advertises the allowed methods', async () => { const app = buildApp(); - const res = await app.request('/health', { - headers: { Origin: 'https://app.vatix.io' }, + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { origin: 'https://app.vatix.io' }, }); - expect(res.headers.get('Access-Control-Allow-Methods')).toBe( + expect(res.headers['access-control-allow-methods']).toBe( 'GET, POST, PUT, PATCH, DELETE, OPTIONS', ); }); it('advertises the allowed headers', async () => { const app = buildApp(); - const res = await app.request('/health', { - headers: { Origin: 'https://app.vatix.io' }, + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { origin: 'https://app.vatix.io' }, }); - expect(res.headers.get('Access-Control-Allow-Headers')).toBe( + expect(res.headers['access-control-allow-headers']).toBe( 'Content-Type, Authorization, X-Correlation-Id, X-Request-Id', ); }); - it('does not enable credentials by default', async () => { + it('does not enable credentials in wildcard mode (incompatible with *)', async () => { const app = buildApp(); - const res = await app.request('/health', { - headers: { Origin: 'https://app.vatix.io' }, + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { origin: 'https://app.vatix.io' }, }); - // Wildcard origin is incompatible with credentialed requests; the policy - // must not advertise credentials unless the origin is explicitly echoed. - expect(res.headers.get('Access-Control-Allow-Credentials')).toBeNull(); + expect(res.headers['access-control-allow-credentials']).toBeUndefined(); }); - it('answers preflight OPTIONS with 204 and no body', async () => { + it('answers preflight OPTIONS with 204 and CORS headers', async () => { const app = buildApp(); - const res = await app.request('/tx', { + const res = await app.inject({ method: 'OPTIONS', + url: '/tx', headers: { - Origin: 'https://app.vatix.io', - 'Access-Control-Request-Method': 'POST', - 'Access-Control-Request-Headers': 'content-type, authorization', + origin: 'https://app.vatix.io', + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'content-type, authorization', }, }); - expect(res.status).toBe(204); - expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); - expect(res.headers.get('Access-Control-Allow-Methods')).toContain('POST'); - expect(res.headers.get('Access-Control-Allow-Headers')).toContain('Authorization'); - expect(await res.text()).toBe(''); + expect(res.statusCode).toBe(204); + expect(res.headers['access-control-allow-origin']).toBe('*'); + expect(res.headers['access-control-allow-methods']).toContain('POST'); + expect(res.headers['access-control-allow-headers']).toContain( + 'Authorization', + ); + expect(res.body).toBe(''); }); it('does not invoke downstream handlers for preflight requests', async () => { - const app = new Hono(); - const handler = vi.fn((c) => c.json({ ok: true })); - app.use('*', cors()); + const app = fastify({ logger: false }); + const handler = vi.fn(async (_req: unknown, reply: { status: (n: number) => { send: (d: unknown) => unknown } }) => + reply.status(200).send({ ok: true }) + ); + const corsMiddleware = cors(); + app.addHook('onRequest', async (req, reply) => { + return corsMiddleware(req, reply); + }); app.post('/tx', handler); - const res = await app.request('/tx', { + const res = await app.inject({ method: 'OPTIONS', - headers: { Origin: 'https://app.vatix.io' }, + url: '/tx', + headers: { origin: 'https://app.vatix.io' }, }); - expect(res.status).toBe(204); + expect(res.statusCode).toBe(204); expect(handler).not.toHaveBeenCalled(); }); - it('still applies the policy to unknown origins (fail-closed wildcard)', async () => { + it('does not reflect an unknown origin (wildcard, not echo)', async () => { const app = buildApp(); - const res = await app.request('/health', { - headers: { Origin: 'https://evil.example.com' }, + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { origin: 'https://evil.example.com' }, }); - // The policy is a public wildcard: unknown origins are not echoed back, - // and no per-origin allowlist is implied. Locking this prevents an - // accidental switch to reflecting arbitrary origins. - expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); - expect(res.headers.get('Access-Control-Allow-Origin')).not.toBe( + expect(res.headers['access-control-allow-origin']).toBe('*'); + expect(res.headers['access-control-allow-origin']).not.toBe( 'https://evil.example.com', ); }); - it('does not reflect an untrusted origin on preflight', async () => { + it('passes non-preflight requests through to downstream handlers', async () => { const app = buildApp(); - const res = await app.request('/tx', { - method: 'OPTIONS', + const res = await app.inject({ + method: 'POST', + url: '/tx', headers: { - Origin: 'https://evil.example.com', - 'Access-Control-Request-Method': 'POST', + origin: 'https://app.vatix.io', + 'content-type': 'application/json', }, + body: JSON.stringify({ amount: '1' }), }); - expect(res.status).toBe(204); - expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); - expect(res.headers.get('Access-Control-Allow-Origin')).not.toBe( - 'https://evil.example.com', - ); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ok: true }); }); +}); - it('tolerates malformed Origin values without throwing', async () => { - const app = buildApp(); - const res = await app.request('/health', { - headers: { Origin: 'not a valid origin' }, +// --------------------------------------------------------------------------- +// cors() middleware — allowlist mode +// --------------------------------------------------------------------------- + +describe('cors middleware — allowlist mode', () => { + const allowedOrigins = ['https://app.vatix.io', 'https://admin.vatix.io']; + + it('echoes a listed origin in Access-Control-Allow-Origin', async () => { + const app = buildApp({ allowedOrigins }); + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { origin: 'https://app.vatix.io' }, }); - expect(res.status).toBe(200); - expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe( + 'https://app.vatix.io', + ); + expect(res.headers['access-control-allow-credentials']).toBe('true'); }); - it('passes non-preflight requests through to downstream handlers', async () => { - const app = buildApp(); - const res = await app.request('/tx', { - method: 'POST', - headers: { Origin: 'https://app.vatix.io', 'Content-Type': 'application/json' }, - body: JSON.stringify({ amount: '1' }), + it('sets Vary: Origin when in allowlist mode', async () => { + const app = buildApp({ allowedOrigins }); + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { origin: 'https://app.vatix.io' }, }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ ok: true }); + expect(res.headers['vary']).toBe('Origin'); }); - describe('allowlist mode', () => { - const allowedOrigins = ['https://app.vatix.io', 'https://admin.vatix.io']; + it('omits Access-Control-Allow-Origin for a non-allowlisted origin', async () => { + const app = buildApp({ allowedOrigins }); + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { origin: 'https://evil.example.com' }, + }); - it('echoes a listed origin on simple requests', async () => { - const app = buildApp({ allowedOrigins }); - const res = await app.request('/health', { - headers: { Origin: 'https://app.vatix.io' }, - }); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + expect(res.headers['access-control-allow-credentials']).toBeUndefined(); + }); - expect(res.status).toBe(200); - expect(res.headers.get('Access-Control-Allow-Origin')).toBe( - 'https://app.vatix.io', - ); - expect(res.headers.get('Access-Control-Allow-Credentials')).toBe('true'); + it('omits Access-Control-Allow-Origin when Origin is absent', async () => { + const app = buildApp({ allowedOrigins }); + const res = await app.inject({ + method: 'GET', + url: '/health', }); - it('does not send an allow-origin header for an unlisted origin', async () => { - const app = buildApp({ allowedOrigins }); - const res = await app.request('/health', { - headers: { Origin: 'https://evil.example.com' }, - }); + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('never emits Access-Control-Allow-Origin: * in allowlist mode', async () => { + const app = buildApp({ allowedOrigins }); - expect(res.status).toBe(200); - expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull(); - expect(res.headers.get('Access-Control-Allow-Credentials')).toBeNull(); + // Listed origin + const res1 = await app.inject({ + method: 'GET', + url: '/health', + headers: { origin: 'https://app.vatix.io' }, }); + expect(res1.headers['access-control-allow-origin']).not.toBe('*'); - it('does not send an allow-origin header when Origin is absent', async () => { - const app = buildApp({ allowedOrigins }); - const res = await app.request('/health'); + // Unlisted origin + const res2 = await app.inject({ + method: 'GET', + url: '/health', + headers: { origin: 'https://evil.example.com' }, + }); + expect(res2.headers['access-control-allow-origin']).not.toBe('*'); + }); - expect(res.status).toBe(200); - expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull(); + it('echoes a listed origin on preflight with 204', async () => { + const app = buildApp({ allowedOrigins }); + const res = await app.inject({ + method: 'OPTIONS', + url: '/tx', + headers: { + origin: 'https://admin.vatix.io', + 'access-control-request-method': 'POST', + }, }); - it('echoes a listed origin on preflight with 204 and no body', async () => { - const app = buildApp({ allowedOrigins }); - const res = await app.request('/tx', { - method: 'OPTIONS', - headers: { - Origin: 'https://admin.vatix.io', - 'Access-Control-Request-Method': 'POST', - }, - }); + expect(res.statusCode).toBe(204); + expect(res.headers['access-control-allow-origin']).toBe( + 'https://admin.vatix.io', + ); + expect(res.headers['access-control-allow-methods']).toContain('POST'); + expect(res.body).toBe(''); + }); - expect(res.status).toBe(204); - expect(res.headers.get('Access-Control-Allow-Origin')).toBe( - 'https://admin.vatix.io', - ); - expect(res.headers.get('Access-Control-Allow-Methods')).toContain('POST'); - expect(await res.text()).toBe(''); + it('omits Access-Control-Allow-Origin on preflight for non-allowlisted origin', async () => { + const app = buildApp({ allowedOrigins }); + const res = await app.inject({ + method: 'OPTIONS', + url: '/tx', + headers: { + origin: 'https://evil.example.com', + 'access-control-request-method': 'POST', + }, }); - it('does not echo an unlisted origin on preflight', async () => { - const app = buildApp({ allowedOrigins }); - const res = await app.request('/tx', { - method: 'OPTIONS', - headers: { - Origin: 'https://evil.example.com', - 'Access-Control-Request-Method': 'POST', - }, - }); + expect(res.statusCode).toBe(204); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + expect(res.body).toBe(''); + }); - expect(res.status).toBe(204); - expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull(); - expect(await res.text()).toBe(''); + it('does not invoke downstream handlers for preflight requests in allowlist mode', async () => { + const app = fastify({ logger: false }); + const handler = vi.fn(async (_req: unknown, reply: { status: (n: number) => { send: (d: unknown) => unknown } }) => + reply.status(200).send({ ok: true }) + ); + const corsMiddleware = cors({ allowedOrigins }); + app.addHook('onRequest', async (req, reply) => { + return corsMiddleware(req, reply); + }); + app.post('/tx', handler); + + const res = await app.inject({ + method: 'OPTIONS', + url: '/tx', + headers: { origin: 'https://app.vatix.io' }, }); - it('does not invoke downstream handlers for preflight requests', async () => { - const app = new Hono(); - const handler = vi.fn((c) => c.json({ ok: true })); - app.use('*', cors({ allowedOrigins })); - app.post('/tx', handler); + expect(res.statusCode).toBe(204); + expect(handler).not.toHaveBeenCalled(); + }); - const res = await app.request('/tx', { - method: 'OPTIONS', - headers: { Origin: 'https://app.vatix.io' }, - }); + it('Vary: Origin is set even when origin is absent in allowlist mode', async () => { + const app = buildApp({ allowedOrigins }); + const res = await app.inject({ method: 'GET', url: '/health' }); - expect(res.status).toBe(204); - expect(handler).not.toHaveBeenCalled(); - }); + // Vary must be set regardless so caches don't serve one response for all origins. + expect(res.headers['vary']).toBe('Origin'); }); }); diff --git a/apps/indexer/src/middleware/cors.ts b/apps/indexer/src/middleware/cors.ts index 19f7a18d..a7208a52 100644 --- a/apps/indexer/src/middleware/cors.ts +++ b/apps/indexer/src/middleware/cors.ts @@ -1,4 +1,5 @@ -import type { Context, Next } from 'hono'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import fastifyCors from '@fastify/cors'; /** * Stable error codes for body-limit rejections (issue #1164). @@ -35,11 +36,11 @@ export function resolveBodyLimitBytes( * Extract a correlation id from the request, generating one when absent so * every rejection is traceable without leaking request contents. */ -export function getCorrelationId(c: Context): string { +export function getCorrelationId(req: FastifyRequest): string { const header = - c.req.header('x-correlation-id') ?? - c.req.header('x-request-id') ?? - c.req.header('x-trace-id'); + (req.headers['x-correlation-id'] as string | undefined) ?? + (req.headers['x-request-id'] as string | undefined) ?? + (req.headers['x-trace-id'] as string | undefined); if (header && header.trim().length > 0) { return header.trim(); } @@ -47,75 +48,15 @@ export function getCorrelationId(c: Context): string { } /** - * Body size limit middleware (issue #1164). + * Parse a comma-separated allowlist of origins into a normalized array. + * Empty entries (from leading/trailing commas or whitespace) are dropped. + * Returns an empty array when `raw` is undefined, null, or blank — callers + * interpret that as "no restriction" (wildcard mode). * - * Enforces a configurable maximum request body size on external HTTP - * entrypoints. Oversized bodies are rejected fail-closed with HTTP 413 and a - * stable error code. Runs before authz/rate-limit handlers so untrusted - * clients cannot bypass the policy by sending large payloads. - */ -export function bodyLimit(options: { maxBytes?: number } = {}) { - const maxBytes = options.maxBytes ?? resolveBodyLimitBytes(); - - return async (c: Context, next: Next) => { - const method = c.req.method.toUpperCase(); - const hasBody = method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS'; - - if (hasBody) { - const contentLength = c.req.header('content-length'); - if (contentLength !== undefined) { - const declared = Number(contentLength); - if (Number.isFinite(declared) && declared > maxBytes) { - return rejectBodyTooLarge(c, maxBytes, declared); - } - } - - // Guard against missing/forged Content-Length by measuring the actual - // payload. Fail-closed: reject when the real size exceeds the limit. - const body = await c.req.raw.clone().arrayBuffer(); - if (body.byteLength > maxBytes) { - return rejectBodyTooLarge(c, maxBytes, body.byteLength); - } - } - - await next(); - }; -} - -function rejectBodyTooLarge(c: Context, maxBytes: number, actualBytes: number) { - const correlationId = getCorrelationId(c); - // Ops-safe log: no request contents or secrets, only sizes and ids. - console.warn( - JSON.stringify({ - event: 'body_limit_rejected', - code: BODY_LIMIT_ERROR_CODE, - correlationId, - method: c.req.method, - path: c.req.path, - maxBytes, - actualBytes, - }), - ); - - return c.json( - { - error: { - code: BODY_LIMIT_ERROR_CODE, - message: 'Request body exceeds the configured size limit', - correlationId, - maxBytes, - }, - }, - 413, - ); -} - -/** - * Parse a comma-separated allowlist of origins into a normalized set. - * Empty entries are ignored; origins are compared case-insensitively. + * @param raw - Raw env-var value, e.g. "https://app.vatix.io, https://admin.vatix.io" */ export function parseAllowedOrigins(raw: string | undefined): string[] { - if (!raw) return []; + if (raw == null || raw.trim() === '') return []; return raw .split(',') .map((origin) => origin.trim()) @@ -124,48 +65,110 @@ export function parseAllowedOrigins(raw: string | undefined): string[] { /** * Resolve the configured CORS allowlist from the environment. - * Returns an empty list when unset, preserving the wildcard default. + * + * Reads `CORS_ALLOWED_ORIGINS` (comma-separated list of origins). + * Returns `[]` when the variable is unset, empty, or whitespace-only. + * Never falls back to `*` — callers decide whether an empty list means + * "open wildcard" (dev) or "deny all" (hardened production). + * + * @param raw - Optional override; defaults to process.env.CORS_ALLOWED_ORIGINS. */ export function resolveAllowedOrigins( raw: string | undefined = typeof process !== 'undefined' - ? process.env?.INDEXER_CORS_ALLOWED_ORIGINS + ? process.env?.CORS_ALLOWED_ORIGINS : undefined, ): string[] { return parseAllowedOrigins(raw); } /** - * CORS middleware. Kept intact; body-limit helpers above are additive so - * existing consumers of this module are unaffected. + * Fastify CORS plugin for the indexer HTTP server. + * + * Behaviour: + * - When `allowedOrigins` is empty (env var unset), the policy falls through + * to a wildcard `*` — this is dev/local mode. + * - When `allowedOrigins` is non-empty, only listed origins are echoed back + * in `Access-Control-Allow-Origin`; all others receive no CORS headers. + * `Vary: Origin` is set so CDN/proxy caches store per-origin variants. + * - Preflight `OPTIONS` requests always return 204. + * - `Access-Control-Allow-Origin: *` is NEVER emitted when an allowlist is + * configured. * - * When no allowlist is configured the policy is a public wildcard. When an - * allowlist is provided, only listed origins are echoed back; unlisted origins - * receive no allow-origin header. Credentials are only advertised when an - * explicit origin is echoed. + * Wire it up via `app.register(indexerCorsPlugin)`. + */ +export async function indexerCorsPlugin(app: FastifyInstance): Promise { + const allowedOrigins = resolveAllowedOrigins(); + + await app.register(fastifyCors, { + origin: + allowedOrigins.length === 0 + ? '*' + : (requestOrigin, callback) => { + if (!requestOrigin) { + // No Origin header — no CORS headers set (not a cross-origin request). + callback(null, false); + return; + } + const allowed = allowedOrigins.some( + (o) => o.toLowerCase() === requestOrigin.toLowerCase(), + ); + callback(null, allowed ? requestOrigin : false); + }, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: [ + 'Content-Type', + 'Authorization', + 'X-Correlation-Id', + 'X-Request-Id', + ], + // Credentials are only meaningful when an explicit origin is echoed. + credentials: allowedOrigins.length > 0, + }); +} + +/** + * Standalone CORS middleware factory (non-Fastify contexts / unit tests). + * + * Accepts an explicit `allowedOrigins` list or falls back to + * `resolveAllowedOrigins()`. When the list is non-empty: + * - Only an allowlisted request Origin is echoed back. + * - Non-allowlisted or absent Origin → no `Access-Control-Allow-Origin` header. + * - `Vary: Origin` is always set so caches do not collapse per-origin responses. + * + * When the list is empty the function behaves as a public wildcard (dev mode). + * `Access-Control-Allow-Origin: *` is NEVER emitted when an allowlist is + * configured. */ export function cors(options: { allowedOrigins?: string[] } = {}) { const allowedOrigins = options.allowedOrigins ?? resolveAllowedOrigins(); - const allowlist = new Set(allowedOrigins.map((origin) => origin.toLowerCase())); - const echoMode = allowlist.size > 0; + const allowSet = new Set(allowedOrigins.map((o) => o.toLowerCase())); + const echoMode = allowSet.size > 0; - return async (c: Context, next: Next) => { - const requestOrigin = c.req.header('origin'); + return async (req: FastifyRequest, reply: FastifyReply) => { + const requestOrigin = req.headers['origin'] as string | undefined; if (!echoMode) { - c.header('Access-Control-Allow-Origin', '*'); - } else if (requestOrigin && allowlist.has(requestOrigin.toLowerCase())) { - c.header('Access-Control-Allow-Origin', requestOrigin); - c.header('Access-Control-Allow-Credentials', 'true'); - c.header('Vary', 'Origin'); + reply.header('Access-Control-Allow-Origin', '*'); + } else { + reply.header('Vary', 'Origin'); + if (requestOrigin && allowSet.has(requestOrigin.toLowerCase())) { + reply.header('Access-Control-Allow-Origin', requestOrigin); + reply.header('Access-Control-Allow-Credentials', 'true'); + } + // Non-allowlisted or absent origin → no Access-Control-Allow-Origin set. } - c.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); - c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Correlation-Id, X-Request-Id'); - - if (c.req.method === 'OPTIONS') { - return c.body(null, 204); + reply.header( + 'Access-Control-Allow-Methods', + 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + ); + reply.header( + 'Access-Control-Allow-Headers', + 'Content-Type, Authorization, X-Correlation-Id, X-Request-Id', + ); + + if (req.method === 'OPTIONS') { + return reply.status(204).send(''); } - - await next(); }; } diff --git a/docs/runbooks/incident-runbook.md b/docs/runbooks/incident-runbook.md index 08070c54..ba2451e9 100644 --- a/docs/runbooks/incident-runbook.md +++ b/docs/runbooks/incident-runbook.md @@ -1050,7 +1050,7 @@ VALUES ('', '', 'ADJUDICATE_CHALLENGE', 'CHALLENGED', 'RE #### Step 4: Restart finalization job and re-run ```bash -docker restart vatix-finalization-job +docker restart vatix-finalization-worker # or pm2 restart finalization ``` diff --git a/scripts/load/order-placement-soak.js b/scripts/load/order-placement-soak.js index 7b58c590..d5888f12 100644 --- a/scripts/load/order-placement-soak.js +++ b/scripts/load/order-placement-soak.js @@ -18,6 +18,7 @@ import { Trend } from 'k6/metrics'; const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; const MARKET_ID = __ENV.MARKET_ID; +const AUTH_HEADER = __ENV.AUTH_HEADER || ''; const rejectionTrend = new Trend('order_rejections', true); @@ -31,7 +32,18 @@ if (!LOCAL_HOSTS.some((host) => BASE_URL.includes(host))) { } if (!MARKET_ID) { - throw new Error('MARKET_ID is required: k6 run -e MARKET_ID= ...'); + throw new Error( + `MARKET_ID is required. Provide it with: ` + + `k6 run -e MARKET_ID= scripts/load/order-placement-soak.js`, + ); +} + +if (!AUTH_HEADER) { + console.warn( + '[order-placement-soak] AUTH_HEADER is not set — running unauthenticated. ' + + 'Provide it with: k6 run -e AUTH_HEADER= ... if the target enforces auth.', + ); +} } export const options = { @@ -58,7 +70,10 @@ const payload = JSON.stringify({ }); const params = { - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(AUTH_HEADER ? { Authorization: AUTH_HEADER } : {}), + }, }; export default function () { diff --git a/tests/config/incident-runbook-schema.test.ts b/tests/config/incident-runbook-schema.test.ts new file mode 100644 index 00000000..b679efda --- /dev/null +++ b/tests/config/incident-runbook-schema.test.ts @@ -0,0 +1,350 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const RUNBOOK_PATH = resolve( + process.cwd(), + "docs/runbooks/incident-runbook.md" +); +const COMPOSE_PATH = resolve(process.cwd(), "docker-compose.yml"); +const COMPOSE_DOC_PATH = resolve(process.cwd(), "docs/docker-compose.md"); +const SCHEMA_PATH = resolve(process.cwd(), "prisma/schema.prisma"); + +// --------------------------------------------------------------------------- +// Container-name helpers (existing) +// --------------------------------------------------------------------------- + +function extractRunbookContainerNames(content: string): Set { + const names = new Set(); + for (const match of content.matchAll(/\b(vatix-[a-z-]+)\b/g)) { + names.add(match[1]); + } + return names; +} + +function extractComposeContainerNames(content: string): Set { + const names = new Set(); + for (const match of content.matchAll(/container_name:\s*(vatix-[a-z-]+)/g)) { + names.add(match[1]); + } + return names; +} + +// --------------------------------------------------------------------------- +// Prisma schema parsing helpers +// --------------------------------------------------------------------------- + +/** + * Extract the body of a top-level Prisma block (model or enum) by counting + * braces, so nested `{ }` inside field defaults or @@unique/@@index directives + * don't terminate the match prematurely. + * + * Returns an array of `[name, body]` pairs for every block with the given + * keyword (`model` or `enum`). + */ +function extractPrismaBlocks( + schema: string, + keyword: "model" | "enum", +): Array<[string, string]> { + const results: Array<[string, string]> = []; + const headerRe = new RegExp(`^${keyword}\\s+(\\w+)\\s*\\{`, "gm"); + let headerMatch: RegExpExecArray | null; + + while ((headerMatch = headerRe.exec(schema)) !== null) { + const name = headerMatch[1]; + let depth = 1; + let i = headerMatch.index + headerMatch[0].length; + const bodyStart = i; + + while (i < schema.length && depth > 0) { + if (schema[i] === "{") depth++; + else if (schema[i] === "}") depth--; + i++; + } + + // `i` is now one past the closing `}`. + const body = schema.slice(bodyStart, i - 1); + results.push([name, body]); + } + + return results; +} + +/** + * Parse prisma/schema.prisma and return: + * - `tables`: the set of SQL table names (from @@map or the model name) + * - `enumMembers`: all enum member values across all enums + */ +function parsePrismaSchema(schema: string): { + tables: Set; + enumMembers: Set; +} { + const tables = new Set(); + const enumMembers = new Set(); + + // Extract model → SQL table name mappings. + for (const [modelName, body] of extractPrismaBlocks(schema, "model")) { + // Check for @@map("table_name") — the authoritative SQL table name. + const mapMatch = body.match(/@@map\("([^"]+)"\)/); + if (mapMatch) { + tables.add(mapMatch[1]); + } else { + // No @@map → Prisma uses the model name as-is for the SQL table. + tables.add(modelName); + } + } + + // Extract all enum member names. + for (const [, body] of extractPrismaBlocks(schema, "enum")) { + for (const line of body.split("\n")) { + const trimmed = line.trim(); + // Skip blank lines, doc comments, and block-level directives (@@). + if ( + !trimmed || + trimmed.startsWith("//") || + trimmed.startsWith("///") || + trimmed.startsWith("@@") + ) { + continue; + } + // Enum member name is the first identifier token. + const memberMatch = trimmed.match(/^(\w+)/); + if (memberMatch) { + enumMembers.add(memberMatch[1]); + } + } + } + + return { tables, enumMembers }; +} + +// --------------------------------------------------------------------------- +// Runbook SQL extraction helpers +// --------------------------------------------------------------------------- + +/** + * Extract all content inside fenced ```sql ... ``` blocks in a Markdown file. + */ +function extractSqlBlocks(markdown: string): string[] { + const blocks: string[] = []; + const fencedRe = /```sql\s*([\s\S]*?)```/gi; + for (const match of markdown.matchAll(fencedRe)) { + blocks.push(match[1]); + } + return blocks; +} + +/** + * Strip single-line SQL comments (`-- ...`) from a SQL snippet so that + * keywords and identifiers in comments don't get picked up as table/column + * references. + */ +function stripSqlLineComments(sql: string): string { + return sql + .split("\n") + .map((line) => { + const idx = line.indexOf("--"); + return idx >= 0 ? line.slice(0, idx) : line; + }) + .join("\n"); +} + +/** + * Extract table names referenced in FROM/JOIN/INSERT INTO/UPDATE/DELETE FROM + * clauses inside an SQL snippet. + * Returns lowercase identifiers only (no schema prefix, no aliases). + */ +function extractTableNamesFromSql(sql: string): Set { + const tables = new Set(); + const stripped = stripSqlLineComments(sql); + // Matches: FROM tbl, JOIN tbl, INTO tbl, UPDATE tbl + // The table name must start with a letter or underscore (not a number). + const tableRefRe = + /\b(?:FROM|JOIN|INTO|UPDATE)\s+([a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)?)/gi; + for (const match of stripped.matchAll(tableRefRe)) { + // Strip a schema prefix (e.g. "pg_catalog.pg_locks" → "pg_locks"). + const raw = match[1].toLowerCase(); + // Skip SQL keywords that appear after FROM/INTO/etc in expressions + // (e.g. "DO UPDATE SET …" or "FROM STEP …" after comment stripping). + const SQL_NON_TABLE_KEYWORDS = new Set([ + "set", + "select", + "where", + "values", + "returning", + "on", + "as", + ]); + if (SQL_NON_TABLE_KEYWORDS.has(raw)) continue; + const name = raw.includes(".") ? raw.split(".").pop()! : raw; + tables.add(name); + } + return tables; +} + +/** + * Extract single-quoted string literals used in WHERE comparisons that look + * like Prisma enum values — i.e. ALL_CAPS identifiers like `'CHALLENGED'` or + * `'ACTIVE'`. + * + * Only matches literals that appear on the right-hand side of a column + * comparison (`column = 'VALUE'` or `column IN ('VALUE', ...)`), not free-form + * strings in INSERT VALUES or string concatenations. This prevents action + * strings like 'FINALIZE' (a VarChar column value) from being mistaken for + * Prisma enum members. + * + * Lowercase values (`'ingestion'`, `'testnet'`, `'idle'`, etc.) are + * intentionally excluded — they are plain string constants, not enum members. + */ +function extractEnumLiteralsFromSql(sql: string): Set { + const literals = new Set(); + const stripped = stripSqlLineComments(sql); + // Match: identifier = 'ALL_CAPS_VALUE' (no lowercase letters) + // Must have at least 2 uppercase characters so single-letter values are skipped. + // No `i` flag — we deliberately require the literal to be uppercase. + const comparisonRe = /\b\w+\s*(?:=|IN\s*\()\s*'([A-Z][A-Z0-9_]+)'/g; + for (const match of stripped.matchAll(comparisonRe)) { + literals.add(match[1]); + } + return literals; +} + +// --------------------------------------------------------------------------- +// PostgreSQL system / infrastructure tables that live outside our schema +// --------------------------------------------------------------------------- +const PG_SYSTEM_TABLES = new Set([ + "pg_stat_activity", + "pg_locks", + "pg_stat_user_tables", + "pg_tables", + "pg_stat_statements", + "pg_catalog", + "information_schema", +]); + +// These tables are referenced in the runbook but are not in the Prisma schema +// (e.g. an `events` table that is Postgres-native or a legacy `audit_log`). +// We maintain an explicit exclusion list so new runbook additions that reference +// a real Prisma table can't hide behind this list by accident. +const RUNBOOK_UNMODELED_TABLES = new Set([ + // `events` appears in Incident 1 SQL but has no corresponding Prisma model; + // it is a raw PostgreSQL table written by the indexer outside Prisma's ORM. + "events", + // `audit_log` is referenced as a target for manual-intervention inserts but + // has no Prisma model definition in the current schema. + "audit_log", +]); + +describe("incident runbook (config scope, min-039)", () => { + // ------------------------------------------------------------------------- + // Existing container-name assertions — MUST remain untouched + // ------------------------------------------------------------------------- + + it("runbook exists and references docker container names", () => { + const runbook = readFileSync(RUNBOOK_PATH, "utf8"); + const referenced = extractRunbookContainerNames(runbook); + expect(referenced.size).toBeGreaterThan(0); + expect(referenced).toContain("vatix-backend"); + expect(referenced).toContain("vatix-indexer"); + expect(referenced).toContain("vatix-postgres"); + expect(referenced).toContain("vatix-redis"); + }); + + it("every container referenced in the runbook is defined in docker-compose.yml", () => { + const runbook = readFileSync(RUNBOOK_PATH, "utf8"); + const compose = readFileSync(COMPOSE_PATH, "utf8"); + const referenced = extractRunbookContainerNames(runbook); + const defined = extractComposeContainerNames(compose); + + for (const name of referenced) { + expect(defined.has(name)).toBe(true); + } + }); + + it("docker-compose.yml container names are documented in docs/docker-compose.md", () => { + const compose = readFileSync(COMPOSE_PATH, "utf8"); + const doc = readFileSync(COMPOSE_DOC_PATH, "utf8"); + const defined = extractComposeContainerNames(compose); + + for (const name of defined) { + expect(doc).toContain(name); + } + }); + + it("docs/docker-compose.md links to the incident runbook", () => { + const doc = readFileSync(COMPOSE_DOC_PATH, "utf8"); + expect(doc).toContain("docs/runbooks/incident-runbook.md"); + }); + + // ------------------------------------------------------------------------- + // NEW: Schema drift — SQL in the runbook must reference known tables/enums + // ------------------------------------------------------------------------- + + it("prisma/schema.prisma is parseable and contains expected models", () => { + const schema = readFileSync(SCHEMA_PATH, "utf8"); + const { tables, enumMembers } = parsePrismaSchema(schema); + + // Sanity-check that parsing produced results; a zero count means the + // regex broke, not that the schema is empty. + expect(tables.size).toBeGreaterThan(0); + expect(enumMembers.size).toBeGreaterThan(0); + + // Spot-check a few known tables and enum members so a silent parse failure + // is caught immediately. + expect(tables).toContain("markets"); + expect(tables).toContain("orders"); + expect(tables).toContain("resolution_candidates"); + expect(enumMembers).toContain("ACTIVE"); + expect(enumMembers).toContain("CHALLENGED"); + }); + + it("every table name in runbook SQL blocks exists in prisma/schema.prisma", () => { + const runbook = readFileSync(RUNBOOK_PATH, "utf8"); + const schema = readFileSync(SCHEMA_PATH, "utf8"); + + const { tables: schemaTables } = parsePrismaSchema(schema); + const sqlBlocks = extractSqlBlocks(runbook); + + const missingTables: string[] = []; + + for (const block of sqlBlocks) { + const referencedTables = extractTableNamesFromSql(block); + for (const table of referencedTables) { + if (PG_SYSTEM_TABLES.has(table)) continue; + if (RUNBOOK_UNMODELED_TABLES.has(table)) continue; + if (!schemaTables.has(table)) { + missingTables.push(table); + } + } + } + + expect( + missingTables, + `Runbook SQL references tables not found in prisma/schema.prisma: ${[...new Set(missingTables)].join(", ")}`, + ).toEqual([]); + }); + + it("every status literal in runbook SQL blocks exists in a prisma/schema.prisma enum", () => { + const runbook = readFileSync(RUNBOOK_PATH, "utf8"); + const schema = readFileSync(SCHEMA_PATH, "utf8"); + + const { enumMembers } = parsePrismaSchema(schema); + const sqlBlocks = extractSqlBlocks(runbook); + + const missingLiterals: string[] = []; + + for (const block of sqlBlocks) { + const literals = extractEnumLiteralsFromSql(block); + for (const literal of literals) { + if (!enumMembers.has(literal)) { + missingLiterals.push(literal); + } + } + } + + expect( + missingLiterals, + `Runbook SQL uses enum literals not found in any prisma/schema.prisma enum: ${[...new Set(missingLiterals)].join(", ")}`, + ).toEqual([]); + }); +}); diff --git a/tests/matching/cross-book-sweep.test.ts b/tests/matching/cross-book-sweep.test.ts index fdd2dfb8..a188ea8f 100644 --- a/tests/matching/cross-book-sweep.test.ts +++ b/tests/matching/cross-book-sweep.test.ts @@ -113,6 +113,20 @@ describe("cross-book sweep fill scenarios (snapshots)", () => { expect(result).toMatchSnapshot(); expect(noBook.getOrderCount()).toBe(1); expect(noBook.getDepth(10).asks[0].price).toBe(0.55); + + // --- New assertions: swept-book post-state --- + // Taker qty=70: fully consumes yes-maker-1 (qty=30) then takes 40 from yes-maker-2 + expect(result.trades).toHaveLength(2); + const totalFilled = result.trades.reduce((sum, t) => sum + t.quantity, 0); + expect(totalFilled).toBe(70); + expect(result.remainingOrder).toBeNull(); + + // yes-maker-1 (qty=30) fully consumed → removed from book + // yes-maker-2 (qty=50) partially filled: 50 - 40 = 10 remaining + const yesDepth = yesBook.getDepth(10); + expect(yesDepth.asks).toHaveLength(1); + expect(yesDepth.asks[0].price).toBe(0.45); + expect(yesDepth.asks[0].quantity).toBe(10); }); it("sweeps the NO book across price levels while the YES book stays untouched", () => { @@ -163,6 +177,20 @@ describe("cross-book sweep fill scenarios (snapshots)", () => { expect(result).toMatchSnapshot(); expect(yesBook.getOrderCount()).toBe(1); expect(yesBook.getDepth(10).asks[0].price).toBe(0.6); + + // --- New assertions: swept-book post-state --- + // Taker qty=65: fully consumes no-maker-1 (qty=20) then takes 45 from no-maker-2 + expect(result.trades).toHaveLength(2); + const totalFilled = result.trades.reduce((sum, t) => sum + t.quantity, 0); + expect(totalFilled).toBe(65); + expect(result.remainingOrder).toBeNull(); + + // no-maker-1 (qty=20) fully consumed → removed from book + // no-maker-2 (qty=60) partially filled: 60 - 45 = 15 remaining + const noDepth = noBook.getDepth(10); + expect(noDepth.asks).toHaveLength(1); + expect(noDepth.asks[0].price).toBe(0.35); + expect(noDepth.asks[0].quantity).toBe(15); }); it("independently sweeps both YES and NO books for the same market in one run", () => { @@ -235,5 +263,31 @@ describe("cross-book sweep fill scenarios (snapshots)", () => { // Each book only ever produced trades scoped to its own outcome. expect(yesResult.trades.every((t) => t.outcome === "YES")).toBe(true); expect(noResult.trades.every((t) => t.outcome === "NO")).toBe(true); + + // --- New assertions: YES book post-state --- + // yesTaker qty=60: fully consumes yes-maker-1 (qty=40) then takes 20 from yes-maker-2 + expect(yesResult.trades).toHaveLength(2); + const yesFilled = yesResult.trades.reduce((sum, t) => sum + t.quantity, 0); + expect(yesFilled).toBe(60); + expect(yesResult.remainingOrder).toBeNull(); + + // yes-maker-1 (qty=40) fully consumed; yes-maker-2 (qty=40) partial: 40 - 20 = 20 remaining + const yesDepth = yesBook.getDepth(10); + expect(yesDepth.asks).toHaveLength(1); + expect(yesDepth.asks[0].price).toBe(0.55); + expect(yesDepth.asks[0].quantity).toBe(20); + + // --- New assertions: NO book post-state --- + // noTaker qty=60: fully consumes no-maker-1 (qty=40) then takes 20 from no-maker-2 + expect(noResult.trades).toHaveLength(2); + const noFilled = noResult.trades.reduce((sum, t) => sum + t.quantity, 0); + expect(noFilled).toBe(60); + expect(noResult.remainingOrder).toBeNull(); + + // no-maker-1 (qty=40) fully consumed; no-maker-2 (qty=40) partial: 40 - 20 = 20 remaining + const noDepth = noBook.getDepth(10); + expect(noDepth.asks).toHaveLength(1); + expect(noDepth.asks[0].price).toBe(0.48); + expect(noDepth.asks[0].quantity).toBe(20); }); });