Skip to content

Commit 8423bdc

Browse files
committed
fix(api): stamp requestId on internal auth and parse failures
1 parent d8af1ab commit 8423bdc

5 files changed

Lines changed: 171 additions & 7 deletions

File tree

apps/sim/lib/api/server/routes/internal-binary-route.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import { requireBinaryRouteDefinition } from '@/lib/api/server/routes/definition
55
import {
66
type InternalErrorPolicy,
77
InternalUnauthenticatedError,
8+
internalErrorResponse,
89
type internalSessionAuth,
910
} from '@/lib/api/server/routes/internal-json-route'
10-
import { withRequestId } from '@/lib/api/server/routes/request-id'
11+
import { responseWithRequestId, withRequestId } from '@/lib/api/server/routes/request-id'
1112
import type {
1213
BinaryApiRouteContract,
1314
BinaryResponseDescriptor,
@@ -82,14 +83,14 @@ export function defineInternalBinaryRoute<
8283
principal = await options.auth.authenticate()
8384
} catch (error) {
8485
if (error instanceof InternalUnauthenticatedError) {
85-
return NextResponse.json({ error: error.message }, { status: 401 })
86+
return createJsonErrorResponse(internalErrorResponse(401, { error: error.message }))
8687
}
8788
throw error
8889
}
8990

9091
await options.rateLimit.enforce(request, principal)
9192
const parsed = await parseRequest(options.contract, request, context ?? {})
92-
if (!parsed.success) return parsed.response
93+
if (!parsed.success) return responseWithRequestId(parsed.response)
9394

9495
try {
9596
const input = options.mapInput(parsed.data)

apps/sim/lib/api/server/routes/internal-json-route.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { z } from 'zod'
88
import { defineRouteContract } from '@/lib/api/contracts'
99
import {
1010
defineInternalJsonRoute,
11+
InternalUnauthenticatedError,
1112
internalErrorResponse,
1213
internalOrchestrationErrorPolicy,
1314
internalRateLimits,
@@ -148,6 +149,72 @@ describe('defineInternalJsonRoute', () => {
148149
expect(body).not.toHaveProperty('success')
149150
})
150151

152+
it('stamps the request id onto an authentication failure', async () => {
153+
mockGetRequestContext.mockReturnValue({ requestId: 'req-auth' })
154+
155+
const handler = defineInternalJsonRoute({
156+
contract,
157+
auth: {
158+
authenticate: vi.fn(async () => {
159+
throw new InternalUnauthenticatedError('Unauthorized')
160+
}),
161+
},
162+
operation,
163+
rateLimit: internalRateLimits.none({ reason: 'Unit test' }),
164+
errorPolicy: internalOrchestrationErrorPolicy,
165+
mapInput: () => undefined,
166+
useCase: {
167+
operation,
168+
async execute() {
169+
return { value: 'unreachable' }
170+
},
171+
},
172+
})
173+
174+
const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route'))
175+
176+
expect(response.status).toBe(401)
177+
await expect(response.json()).resolves.toEqual({
178+
error: 'Unauthorized',
179+
requestId: 'req-auth',
180+
})
181+
})
182+
183+
it('stamps the request id onto a request parsing failure', async () => {
184+
mockGetRequestContext.mockReturnValue({ requestId: 'req-parse' })
185+
186+
const queryContract = defineRouteContract({
187+
method: 'GET',
188+
path: '/api/test/internal-json-route',
189+
query: z.object({ widgetId: z.string().min(1, 'widgetId is required') }),
190+
response: {
191+
mode: 'json',
192+
schema: z.object({ value: z.string() }),
193+
},
194+
})
195+
196+
const handler = defineInternalJsonRoute({
197+
contract: queryContract,
198+
auth,
199+
operation,
200+
rateLimit: internalRateLimits.none({ reason: 'Unit test' }),
201+
errorPolicy: internalOrchestrationErrorPolicy,
202+
mapInput: () => undefined,
203+
useCase: {
204+
operation,
205+
async execute() {
206+
return { value: 'unreachable' }
207+
},
208+
},
209+
})
210+
211+
const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route'))
212+
const body = await response.json()
213+
214+
expect(response.status).toBe(400)
215+
expect(body.requestId).toBe('req-parse')
216+
})
217+
151218
it('orders auth, rate limiting, parsing, async mapping, and application execution', async () => {
152219
const events: string[] = []
153220
const orderedContract = defineRouteContract({

apps/sim/lib/api/server/routes/internal-json-route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { NextRequest } from 'next/server'
88
import { NextResponse } from 'next/server'
99
import type { ContractJsonResponse } from '@/lib/api/contracts'
1010
import { requireJsonRouteDefinition } from '@/lib/api/server/routes/definition'
11-
import { withRequestId } from '@/lib/api/server/routes/request-id'
11+
import { responseWithRequestId, withRequestId } from '@/lib/api/server/routes/request-id'
1212
import type {
1313
JsonApiRouteContract,
1414
JsonErrorResponseDescriptor,
@@ -304,7 +304,7 @@ export function defineInternalJsonRoute<
304304
principal = await options.auth.authenticate(request, rawParams)
305305
} catch (error) {
306306
if (error instanceof InternalUnauthenticatedError) {
307-
return NextResponse.json({ error: error.message }, { status: 401 })
307+
return createJsonErrorResponse(internalErrorResponse(401, { error: error.message }))
308308
}
309309
throw error
310310
}
@@ -325,7 +325,7 @@ export function defineInternalJsonRoute<
325325
context ?? {},
326326
options.parseOptions
327327
)
328-
if (!parsed.success) return parsed.response
328+
if (!parsed.success) return responseWithRequestId(parsed.response)
329329

330330
try {
331331
const input = await options.mapInput(parsed.data, { principal, request })

apps/sim/lib/api/server/routes/request-id.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
* @vitest-environment node
33
*/
44
import { getRequestContext } from '@sim/logger'
5+
import { NextResponse } from 'next/server'
56
import { beforeEach, describe, expect, it, vi } from 'vitest'
6-
import { withRequestId } from '@/lib/api/server/routes/request-id'
7+
import { responseWithRequestId, withRequestId } from '@/lib/api/server/routes/request-id'
78

89
const mockGetRequestContext = vi.mocked(getRequestContext)
910

@@ -43,3 +44,63 @@ describe('withRequestId', () => {
4344
expect(withRequestId([{ error: 'a' }])).toEqual([{ error: 'a' }])
4445
})
4546
})
47+
48+
describe('responseWithRequestId', () => {
49+
beforeEach(() => {
50+
vi.clearAllMocks()
51+
mockGetRequestContext.mockReturnValue(undefined)
52+
})
53+
54+
it('stamps the request id into an already-built JSON error response', async () => {
55+
mockGetRequestContext.mockReturnValue({ requestId: 'req-123' })
56+
57+
const stamped = await responseWithRequestId(
58+
NextResponse.json({ error: 'Validation error', details: [] }, { status: 400 })
59+
)
60+
61+
expect(stamped.status).toBe(400)
62+
await expect(stamped.json()).resolves.toEqual({
63+
error: 'Validation error',
64+
details: [],
65+
requestId: 'req-123',
66+
})
67+
})
68+
69+
it('preserves headers other than a stale content-length', async () => {
70+
mockGetRequestContext.mockReturnValue({ requestId: 'req-123' })
71+
72+
const original = NextResponse.json(
73+
{ error: 'Validation error' },
74+
{ status: 400, headers: { 'x-custom': 'kept', 'content-length': '29' } }
75+
)
76+
const stamped = await responseWithRequestId(original)
77+
78+
expect(stamped.headers.get('x-custom')).toBe('kept')
79+
expect(stamped.headers.get('content-length')).toBeNull()
80+
})
81+
82+
it('returns the original response when there is no active request scope', async () => {
83+
const original = NextResponse.json({ error: 'Validation error' }, { status: 400 })
84+
85+
expect(await responseWithRequestId(original)).toBe(original)
86+
})
87+
88+
it('returns the original response when the body is not JSON', async () => {
89+
mockGetRequestContext.mockReturnValue({ requestId: 'req-123' })
90+
91+
const original = new NextResponse('plain text', {
92+
status: 400,
93+
headers: { 'content-type': 'text/plain' },
94+
})
95+
96+
expect(await responseWithRequestId(original)).toBe(original)
97+
})
98+
99+
it('leaves a response that already carries a requestId untouched', async () => {
100+
mockGetRequestContext.mockReturnValue({ requestId: 'req-123' })
101+
102+
const original = NextResponse.json({ error: 'boom', requestId: 'explicit' }, { status: 400 })
103+
104+
expect(await responseWithRequestId(original)).toBe(original)
105+
})
106+
})

apps/sim/lib/api/server/routes/request-id.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getRequestContext } from '@sim/logger'
2+
import { NextResponse } from 'next/server'
23

34
/**
45
* Stamps the ambient request id onto an internal error body.
@@ -22,3 +23,37 @@ export function withRequestId(body: unknown): unknown {
2223

2324
return { ...body, requestId }
2425
}
26+
27+
/**
28+
* Rebuilds an already-constructed JSON error response with the ambient request
29+
* id stamped into its body.
30+
*
31+
* Request parsing failures arrive as a finished `NextResponse` from the shared
32+
* validation helpers, which v1 and v2 routes also use and whose envelopes must
33+
* not change. Stamping here — at the internal builders' call site rather than
34+
* inside those helpers — keeps the added field scoped to internal routes.
35+
*
36+
* Returns the original response when there is no active request scope, when the
37+
* body is not JSON, or when it cannot be re-read. The body is read from a clone
38+
* so the original stays usable on any of those paths.
39+
*/
40+
export async function responseWithRequestId(
41+
response: NextResponse<unknown>
42+
): Promise<NextResponse<unknown>> {
43+
if (!getRequestContext()?.requestId) return response
44+
if (!response.headers.get('content-type')?.includes('application/json')) return response
45+
46+
let body: unknown
47+
try {
48+
body = await response.clone().json()
49+
} catch {
50+
return response
51+
}
52+
53+
const stamped = withRequestId(body)
54+
if (stamped === body) return response
55+
56+
const headers = new Headers(response.headers)
57+
headers.delete('content-length')
58+
return NextResponse.json(stamped, { status: response.status, headers })
59+
}

0 commit comments

Comments
 (0)