diff --git a/apps/web/src/middleware/withRequestId.test.ts b/apps/web/src/middleware/withRequestId.test.ts new file mode 100644 index 0000000000..5bf803ffd2 --- /dev/null +++ b/apps/web/src/middleware/withRequestId.test.ts @@ -0,0 +1,60 @@ +import { type NextFetchEvent, NextRequest, NextResponse } from 'next/server'; +import type { NextRequestWithAuth } from 'next-auth/middleware'; +import { REQUEST_ID_HEADER, withRequestId } from './withRequestId'; + +const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function makeRequest(headers: Record = {}): NextRequestWithAuth { + const request = new NextRequest('http://localhost:3000/api/trpc/session.list', { + headers, + }) as NextRequestWithAuth; + request.nextauth = { token: null }; + return request; +} + +const stubEvent = {} as NextFetchEvent; + +function jsonMiddleware(body: Record, init?: ResponseInit) { + return async () => NextResponse.json(body, init); +} + +describe('withRequestId', () => { + it('reuses the incoming x-request-id on the response', async () => { + const middleware = withRequestId(jsonMiddleware({ ok: true })); + const response = await middleware( + makeRequest({ 'x-request-id': 'req-from-client' }), + stubEvent + ); + + expect(response?.headers.get(REQUEST_ID_HEADER)).toBe('req-from-client'); + }); + + it('generates a UUID when the client sends no x-request-id', async () => { + const middleware = withRequestId(jsonMiddleware({ ok: true })); + const response = await middleware(makeRequest(), stubEvent); + + expect(response?.headers.get(REQUEST_ID_HEADER)).toMatch(UUID_V4_PATTERN); + }); + + it('generates a distinct UUID per response', async () => { + const middleware = withRequestId(jsonMiddleware({ ok: true })); + const first = await middleware(makeRequest(), stubEvent); + const second = await middleware(makeRequest(), stubEvent); + + const firstId = first?.headers.get(REQUEST_ID_HEADER); + const secondId = second?.headers.get(REQUEST_ID_HEADER); + expect(firstId).toMatch(UUID_V4_PATTERN); + expect(secondId).toMatch(UUID_V4_PATTERN); + expect(firstId).not.toBe(secondId); + }); + + it('stamps early-return responses, such as blocked clients', async () => { + const middleware = withRequestId( + jsonMiddleware({ error: 'upgrade_required' }, { status: 426 }) + ); + const response = await middleware(makeRequest(), stubEvent); + + expect(response?.status).toBe(426); + expect(response?.headers.get(REQUEST_ID_HEADER)).toMatch(UUID_V4_PATTERN); + }); +}); diff --git a/apps/web/src/middleware/withRequestId.ts b/apps/web/src/middleware/withRequestId.ts new file mode 100644 index 0000000000..5872bf9508 --- /dev/null +++ b/apps/web/src/middleware/withRequestId.ts @@ -0,0 +1,18 @@ +import { type NextFetchEvent } from 'next/server'; +import type { MiddlewareFactory } from '@/middleware/types'; +import type { NextMiddlewareWithAuth, NextRequestWithAuth } from 'next-auth/middleware'; + +export const REQUEST_ID_HEADER = 'x-request-id'; + +// Ensures every HTTP response carries an x-request-id header. It reuses the +// incoming x-request-id when the client sends one, otherwise generates a UUID. +export const withRequestId: MiddlewareFactory = (nextMiddleware: NextMiddlewareWithAuth) => { + return async (request: NextRequestWithAuth, nextFetchEvent: NextFetchEvent) => { + const response = await nextMiddleware(request, nextFetchEvent); + if (response) { + const requestId = request.headers.get(REQUEST_ID_HEADER) ?? crypto.randomUUID(); + response.headers.set(REQUEST_ID_HEADER, requestId); + } + return response; + }; +}; diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 4d761db9d0..aff8ef4695 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'; import { withAuthenticatedAdminApiRoutes } from './middleware/withAuthenticatedAdminApiRoutes'; import { withBlockedClients } from './middleware/withBlockedClients'; import { withKiloEditorCookie } from './middleware/withKiloEditorCookie'; +import { withRequestId } from './middleware/withRequestId'; import { buildContentSecurityPolicy, getConfiguredConnectSrcOrigins, @@ -39,8 +40,8 @@ function baseProxy(request: NextRequestWithAuth) { return response; } -export const proxy = withBlockedClients( - withAuthenticatedAdminApiRoutes(withKiloEditorCookie(baseProxy)) +export const proxy = withRequestId( + withBlockedClients(withAuthenticatedAdminApiRoutes(withKiloEditorCookie(baseProxy))) ); export const config = {