From a6726a2793acc5402374ebd937d575edb3542c42 Mon Sep 17 00:00:00 2001 From: "Lucas A. C. Lessa" Date: Tue, 19 May 2026 06:41:21 +0000 Subject: [PATCH 1/5] fix(app-connect): use request.clone() to preserve body for downstream handlers TanStack middleware was calling request.json() which consumed the body stream, preventing downstream handlers from reading it. Now uses request.clone().text() to read raw body for signature verification while preserving the original request stream for downstream use. This also ensures signature verification uses exact raw body bytes instead of re-stringified JSON, preventing signature mismatches from whitespace or key ordering differences. Co-Authored-By: Claude Sonnet 4.5 --- packages/app-connect/src/tanstack/index.ts | 28 +++++++++++++++++-- .../src/types/verifiable-request.ts | 7 ++++- .../app-connect/src/utils/verification.ts | 7 +++-- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/app-connect/src/tanstack/index.ts b/packages/app-connect/src/tanstack/index.ts index 240d659a..97767b4a 100644 --- a/packages/app-connect/src/tanstack/index.ts +++ b/packages/app-connect/src/tanstack/index.ts @@ -15,6 +15,16 @@ export function createActionMiddleware(options?: VerificationOptions) { async ({ next, request }) => { const url = new URL(request.url); + // Read raw body as text from cloned request + // This preserves the original request stream for downstream handlers + let rawBody: string | undefined; + + if (request.body) { + // Clone the request to read body without consuming original + const clonedRequest = request.clone(); + rawBody = await clonedRequest.text(); + } + const verifiableRequest: VerifiableRequest = { method: request.method, url: request.url, @@ -22,7 +32,8 @@ export function createActionMiddleware(options?: VerificationOptions) { query: Object.fromEntries(url.searchParams), // biome-ignore lint/suspicious/noExplicitAny: Headers type compatibility issue headers: Object.fromEntries(request.headers as any), - body: request.body ? await request.json() : undefined, + // Pass raw body string directly for signature verification + body: rawBody, }; const result = await verifyAction(verifiableRequest, options); @@ -34,6 +45,7 @@ export function createActionMiddleware(options?: VerificationOptions) { }); } + // Original request is passed to next(), allowing downstream to call .json() return next(); }, ); @@ -50,6 +62,16 @@ export function createWebhookMiddleware(options?: WebhookVerificationOptions) { async ({ next, request }) => { const url = new URL(request.url); + // Read raw body as text from cloned request + // This preserves the original request stream for downstream handlers + let rawBody: string | undefined; + + if (request.body) { + // Clone the request to read body without consuming original + const clonedRequest = request.clone(); + rawBody = await clonedRequest.text(); + } + const verifiableRequest: VerifiableRequest = { method: request.method, url: request.url, @@ -57,7 +79,8 @@ export function createWebhookMiddleware(options?: WebhookVerificationOptions) { query: Object.fromEntries(url.searchParams), // biome-ignore lint/suspicious/noExplicitAny: Headers type compatibility issue headers: Object.fromEntries(request.headers as any), - body: request.body ? await request.json() : undefined, + // Pass raw body string directly for signature verification + body: rawBody, }; const result = verifyWebhookSubscription(verifiableRequest, options); @@ -69,6 +92,7 @@ export function createWebhookMiddleware(options?: WebhookVerificationOptions) { }); } + // Original request is passed to next(), allowing downstream to call .json() return next(); }, ); diff --git a/packages/app-connect/src/types/verifiable-request.ts b/packages/app-connect/src/types/verifiable-request.ts index f62ce0ca..1c9d8146 100644 --- a/packages/app-connect/src/types/verifiable-request.ts +++ b/packages/app-connect/src/types/verifiable-request.ts @@ -18,6 +18,11 @@ export interface VerifiableRequest { /** The request headers as a record */ headers: Record; - /** The request body, if any */ + /** + * The request body, if any. + * - For Express middleware: pre-parsed object from express.json() + * - For TanStack middleware: raw string from request.text() + * - For Next.js: caller-provided (typically pre-parsed with request.json()) + */ body?: unknown; } diff --git a/packages/app-connect/src/utils/verification.ts b/packages/app-connect/src/utils/verification.ts index f2b65de2..af337d07 100644 --- a/packages/app-connect/src/utils/verification.ts +++ b/packages/app-connect/src/utils/verification.ts @@ -102,7 +102,10 @@ export function canonicalizeRequest(req: VerifiableRequest): string { canonicalString += '\n'; // Add the body to the canonical string - // Use original body exactly as received to match Go middleware + // Use raw body string if available (TanStack), otherwise stringify parsed body (Express) + // IMPORTANT: For signature verification to work, we need the exact bytes that were signed. + // TanStack middleware provides the raw body string directly for accurate verification. + // Express middleware provides parsed object from express.json(), which we re-stringify. const bodyContent = typeof req.body === 'string' ? req.body : JSON.stringify(req.body); canonicalString += bodyContent; @@ -450,7 +453,7 @@ export function verifyWebhookSubscription( } return ok(); - } catch (err) { + } catch (_err) { return error(new InvalidWebhookSignatureError().toJSON()); } } From f41165c7754c023f56bdb4a9500eef78b4e670ad Mon Sep 17 00:00:00 2001 From: "Lucas A. C. Lessa" Date: Tue, 19 May 2026 07:06:23 +0000 Subject: [PATCH 2/5] Add tanstack unit tests --- .../app-connect/src/tanstack/index.test.ts | 495 ++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 packages/app-connect/src/tanstack/index.test.ts diff --git a/packages/app-connect/src/tanstack/index.test.ts b/packages/app-connect/src/tanstack/index.test.ts new file mode 100644 index 00000000..01010f18 --- /dev/null +++ b/packages/app-connect/src/tanstack/index.test.ts @@ -0,0 +1,495 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Capture the server callback passed to createMiddleware().server() +let capturedServerFn: (args: { + next: (opts?: unknown) => unknown; + request: Request; +}) => Promise; + +vi.mock('@tanstack/react-start', () => ({ + createMiddleware: () => ({ + server: (fn: typeof capturedServerFn) => { + capturedServerFn = fn; + return 'middleware-instance'; + }, + }), +})); + +vi.mock('../utils/verification', () => ({ + verifyAction: vi.fn(), +})); + +vi.mock('../utils/webhook', () => ({ + verifyWebhookSubscription: vi.fn(), +})); + +import { verifyAction } from '../utils/verification'; +import { verifyWebhookSubscription } from '../utils/webhook'; +import { createActionMiddleware, createWebhookMiddleware } from './index'; + +const mockVerifyAction = vi.mocked(verifyAction); +const mockVerifyWebhookSubscription = vi.mocked(verifyWebhookSubscription); + +function createMockRequest( + body?: Record, + headers?: Record, +): Request { + const requestInit: RequestInit = { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(headers ?? {}), + }, + }; + + if (body !== undefined) { + requestInit.body = JSON.stringify(body); + } + + return new Request('https://example.com/api/test?foo=bar', requestInit); +} + +function createInvalidJsonRequest(): Request { + return new Request('https://example.com/api/test', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{invalid json', + }); +} + +describe('createActionMiddleware', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should parse body and pass it in context on success', async () => { + const body = { action: 'test', data: { key: 'value' } }; + const request = createMockRequest(body); + const next = vi.fn().mockResolvedValue('next-result'); + + mockVerifyAction.mockResolvedValue({ success: true }); + + createActionMiddleware(); + const result = await capturedServerFn({ next, request }); + + expect(next).toHaveBeenCalledWith({ context: { body } }); + expect(result).toBe('next-result'); + }); + + it('should pass undefined body in context when request has no body', async () => { + const request = createMockRequest(); + const next = vi.fn().mockResolvedValue('next-result'); + + mockVerifyAction.mockResolvedValue({ success: true }); + + createActionMiddleware(); + await capturedServerFn({ next, request }); + + expect(next).toHaveBeenCalledWith({ context: { body: undefined } }); + }); + + it('should read body as text from cloned request (not json)', async () => { + const body = { action: 'test' }; + const request = createMockRequest(body); + const jsonSpy = vi.spyOn(request, 'json'); + const textSpy = vi.spyOn(Request.prototype, 'text'); + const next = vi.fn().mockResolvedValue('next-result'); + + mockVerifyAction.mockResolvedValue({ success: true }); + + createActionMiddleware(); + await capturedServerFn({ next, request }); + + // Should read as text (from clone), not call json() on original + expect(jsonSpy).not.toHaveBeenCalled(); + expect(textSpy).toHaveBeenCalledTimes(1); + + textSpy.mockRestore(); + }); + + it('should build a correct VerifiableRequest and pass it to verifyAction', async () => { + const body = { action: 'test' }; + const request = createMockRequest(body, { + 'x-store-id': 'store-123', + }); + const next = vi.fn().mockResolvedValue('next-result'); + + mockVerifyAction.mockResolvedValue({ success: true }); + + const options = { maxTimestampAgeSeconds: 300 }; + createActionMiddleware(options); + await capturedServerFn({ next, request }); + + // Verify that body is passed as RAW STRING (not parsed object) for signature verification + expect(mockVerifyAction).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + url: 'https://example.com/api/test?foo=bar', + path: '/api/test', + body: JSON.stringify(body), // Raw string, not parsed object + headers: expect.objectContaining({ + 'content-type': 'application/json', + 'x-store-id': 'store-123', + }), + query: { foo: 'bar' }, + }), + options, + ); + }); + + it('should throw a 401 Response when verification fails', async () => { + const errorPayload = { + name: 'MISSING_HEADER', + message: 'Missing required header', + }; + const body = { action: 'test' }; + const request = createMockRequest(body); + const next = vi.fn(); + + mockVerifyAction.mockResolvedValue({ success: false, error: errorPayload }); + + createActionMiddleware(); + + try { + await capturedServerFn({ next, request }); + expect.unreachable('Should have thrown'); + } catch (thrown) { + expect(thrown).toBeInstanceOf(Response); + const response = thrown as Response; + expect(response.status).toBe(401); + expect(response.headers.get('Content-Type')).toBe('application/json'); + const responseBody = await response.json(); + expect(responseBody).toEqual(errorPayload); + } + + expect(next).not.toHaveBeenCalled(); + }); + + it('should not call next when verification fails', async () => { + const body = { action: 'test' }; + const request = createMockRequest(body); + const next = vi.fn(); + + mockVerifyAction.mockResolvedValue({ + success: false, + error: { name: 'ERROR', message: 'fail' }, + }); + + createActionMiddleware(); + + try { + await capturedServerFn({ next, request }); + } catch { + // expected + } + + expect(next).not.toHaveBeenCalled(); + }); + + it('should throw a 400 Response with InvalidBodyError when body is invalid JSON', async () => { + const request = createInvalidJsonRequest(); + const next = vi.fn(); + + createActionMiddleware(); + + try { + await capturedServerFn({ next, request }); + expect.unreachable('Should have thrown'); + } catch (thrown) { + expect(thrown).toBeInstanceOf(Response); + const response = thrown as Response; + expect(response.status).toBe(400); + expect(response.headers.get('Content-Type')).toBe('application/json'); + const responseBody = await response.json(); + expect(responseBody.name).toBe('INVALID_REQUEST_BODY'); + expect(responseBody.message).toBe( + 'The request body contains invalid JSON', + ); + expect(responseBody.correlationId).toBeDefined(); + expect(responseBody.details).toEqual([ + { + issue: 'INVALID_JSON', + description: 'The request body could not be parsed as JSON', + location: 'body', + }, + ]); + } + + expect(next).not.toHaveBeenCalled(); + expect(mockVerifyAction).not.toHaveBeenCalled(); + }); + + it('should preserve exact raw body string including whitespace', async () => { + // Test that formatting/whitespace is preserved exactly + const rawBodyString = '{\n "action": "test",\n "data": {\n "key": "value"\n }\n}'; + const request = new Request('https://example.com/api/test', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: rawBodyString, + }); + const next = vi.fn().mockResolvedValue('next-result'); + + mockVerifyAction.mockResolvedValue({ success: true }); + + createActionMiddleware(); + await capturedServerFn({ next, request }); + + // Verify raw string is passed exactly as-is to verification + expect(mockVerifyAction).toHaveBeenCalledWith( + expect.objectContaining({ + body: rawBodyString, // Exact match including whitespace + }), + undefined, + ); + + // But parsed body (without whitespace) is passed to next + expect(next).toHaveBeenCalledWith({ + context: { + body: { action: 'test', data: { key: 'value' } }, + }, + }); + }); + + it('should preserve unicode and special characters in raw body', async () => { + const rawBodyString = '{"emoji":"🎉","unicode":"\\u00e9","text":"café"}'; + const request = new Request('https://example.com/api/test', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: rawBodyString, + }); + const next = vi.fn().mockResolvedValue('next-result'); + + mockVerifyAction.mockResolvedValue({ success: true }); + + createActionMiddleware(); + await capturedServerFn({ next, request }); + + // Verify exact raw string preservation + expect(mockVerifyAction).toHaveBeenCalledWith( + expect.objectContaining({ + body: rawBodyString, + }), + undefined, + ); + }); +}); + +describe('createWebhookMiddleware', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should parse body and pass it in context on success', async () => { + const body = { event: 'order.created', payload: { id: '123' } }; + const request = createMockRequest(body); + const next = vi.fn().mockResolvedValue('next-result'); + + mockVerifyWebhookSubscription.mockReturnValue({ success: true }); + + createWebhookMiddleware(); + const result = await capturedServerFn({ next, request }); + + expect(next).toHaveBeenCalledWith({ context: { body } }); + expect(result).toBe('next-result'); + }); + + it('should pass undefined body in context when request has no body', async () => { + const request = createMockRequest(); + const next = vi.fn().mockResolvedValue('next-result'); + + mockVerifyWebhookSubscription.mockReturnValue({ success: true }); + + createWebhookMiddleware(); + await capturedServerFn({ next, request }); + + expect(next).toHaveBeenCalledWith({ context: { body: undefined } }); + }); + + it('should read body as text from cloned request (not json)', async () => { + const body = { event: 'order.created' }; + const request = createMockRequest(body); + const jsonSpy = vi.spyOn(request, 'json'); + const textSpy = vi.spyOn(Request.prototype, 'text'); + const next = vi.fn().mockResolvedValue('next-result'); + + mockVerifyWebhookSubscription.mockReturnValue({ success: true }); + + createWebhookMiddleware(); + await capturedServerFn({ next, request }); + + // Should read as text (from clone), not call json() on original + expect(jsonSpy).not.toHaveBeenCalled(); + expect(textSpy).toHaveBeenCalledTimes(1); + + textSpy.mockRestore(); + }); + + it('should build a correct VerifiableRequest and pass it to verifyWebhookSubscription', async () => { + const body = { event: 'order.created' }; + const request = createMockRequest(body, { + 'webhook-signature': 'sig-value', + }); + const next = vi.fn().mockResolvedValue('next-result'); + + mockVerifyWebhookSubscription.mockReturnValue({ success: true }); + + const options = { secret: 'test-secret' }; + createWebhookMiddleware(options); + await capturedServerFn({ next, request }); + + // Verify that body is passed as RAW STRING (not parsed object) for signature verification + expect(mockVerifyWebhookSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + url: 'https://example.com/api/test?foo=bar', + path: '/api/test', + body: JSON.stringify(body), // Raw string, not parsed object + headers: expect.objectContaining({ + 'content-type': 'application/json', + 'webhook-signature': 'sig-value', + }), + query: { foo: 'bar' }, + }), + options, + ); + }); + + it('should throw a 401 Response when verification fails', async () => { + const errorPayload = { + name: 'INVALID_WEBHOOK_SIGNATURE', + message: 'The webhook signature is invalid', + }; + const body = { event: 'order.created' }; + const request = createMockRequest(body); + const next = vi.fn(); + + mockVerifyWebhookSubscription.mockReturnValue({ + success: false, + error: errorPayload, + }); + + createWebhookMiddleware(); + + try { + await capturedServerFn({ next, request }); + expect.unreachable('Should have thrown'); + } catch (thrown) { + expect(thrown).toBeInstanceOf(Response); + const response = thrown as Response; + expect(response.status).toBe(401); + expect(response.headers.get('Content-Type')).toBe('application/json'); + const responseBody = await response.json(); + expect(responseBody).toEqual(errorPayload); + } + + expect(next).not.toHaveBeenCalled(); + }); + + it('should not call next when verification fails', async () => { + const body = { event: 'order.created' }; + const request = createMockRequest(body); + const next = vi.fn(); + + mockVerifyWebhookSubscription.mockReturnValue({ + success: false, + error: { name: 'ERROR', message: 'fail' }, + }); + + createWebhookMiddleware(); + + try { + await capturedServerFn({ next, request }); + } catch { + // expected + } + + expect(next).not.toHaveBeenCalled(); + }); + + it('should throw a 400 Response with InvalidBodyError when body is invalid JSON', async () => { + const request = createInvalidJsonRequest(); + const next = vi.fn(); + + createWebhookMiddleware(); + + try { + await capturedServerFn({ next, request }); + expect.unreachable('Should have thrown'); + } catch (thrown) { + expect(thrown).toBeInstanceOf(Response); + const response = thrown as Response; + expect(response.status).toBe(400); + expect(response.headers.get('Content-Type')).toBe('application/json'); + const responseBody = await response.json(); + expect(responseBody.name).toBe('INVALID_REQUEST_BODY'); + expect(responseBody.message).toBe( + 'The request body contains invalid JSON', + ); + expect(responseBody.correlationId).toBeDefined(); + expect(responseBody.details).toEqual([ + { + issue: 'INVALID_JSON', + description: 'The request body could not be parsed as JSON', + location: 'body', + }, + ]); + } + + expect(next).not.toHaveBeenCalled(); + expect(mockVerifyWebhookSubscription).not.toHaveBeenCalled(); + }); + + it('should preserve exact raw body string including whitespace', async () => { + // Test that formatting/whitespace is preserved exactly + const rawBodyString = '{\n "event": "order.created",\n "payload": {\n "id": "123"\n }\n}'; + const request = new Request('https://example.com/api/test', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: rawBodyString, + }); + const next = vi.fn().mockResolvedValue('next-result'); + + mockVerifyWebhookSubscription.mockReturnValue({ success: true }); + + createWebhookMiddleware(); + await capturedServerFn({ next, request }); + + // Verify raw string is passed exactly as-is to verification + expect(mockVerifyWebhookSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + body: rawBodyString, // Exact match including whitespace + }), + undefined, + ); + + // But parsed body (without whitespace) is passed to next + expect(next).toHaveBeenCalledWith({ + context: { + body: { event: 'order.created', payload: { id: '123' } }, + }, + }); + }); + + it('should preserve unicode and special characters in raw body', async () => { + const rawBodyString = '{"event":"user.created","data":"café","emoji":"🎉"}'; + const request = new Request('https://example.com/api/test', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: rawBodyString, + }); + const next = vi.fn().mockResolvedValue('next-result'); + + mockVerifyWebhookSubscription.mockReturnValue({ success: true }); + + createWebhookMiddleware(); + await capturedServerFn({ next, request }); + + // Verify exact raw string preservation + expect(mockVerifyWebhookSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + body: rawBodyString, + }), + undefined, + ); + }); +}); \ No newline at end of file From f71cafb0e43e1687fe1c0025cd29e8e40ff02bc8 Mon Sep 17 00:00:00 2001 From: "Lucas A. C. Lessa" Date: Tue, 19 May 2026 07:09:34 +0000 Subject: [PATCH 3/5] refactor(app-connect): extract createInvalidJsonResponse helper - Extract createInvalidJsonResponse() to eliminate duplication - Add JSON parsing and error handling for invalid JSON bodies - Pass parsed body to next() via context for downstream use Co-Authored-By: Claude Sonnet 4.5 --- packages/app-connect/src/tanstack/index.ts | 54 ++++++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/app-connect/src/tanstack/index.ts b/packages/app-connect/src/tanstack/index.ts index 97767b4a..4c422923 100644 --- a/packages/app-connect/src/tanstack/index.ts +++ b/packages/app-connect/src/tanstack/index.ts @@ -7,6 +7,30 @@ import type { import { verifyAction } from '../utils/verification'; import { verifyWebhookSubscription } from '../utils/webhook'; +/** + * Creates a 400 Response for invalid JSON in request body + */ +function createInvalidJsonResponse(): Response { + return new Response( + JSON.stringify({ + name: 'INVALID_REQUEST_BODY', + message: 'The request body contains invalid JSON', + correlationId: crypto.randomUUID(), + details: [ + { + issue: 'INVALID_JSON', + description: 'The request body could not be parsed as JSON', + location: 'body', + }, + ], + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, + ); +} + /** * Creates a middleware for TanStack Start that verifies signed action requests */ @@ -18,11 +42,22 @@ export function createActionMiddleware(options?: VerificationOptions) { // Read raw body as text from cloned request // This preserves the original request stream for downstream handlers let rawBody: string | undefined; + let parsedBody: unknown; if (request.body) { // Clone the request to read body without consuming original const clonedRequest = request.clone(); rawBody = await clonedRequest.text(); + + // Parse the raw body for downstream use + if (rawBody) { + try { + parsedBody = JSON.parse(rawBody); + } catch (error) { + // Return 400 for invalid JSON + throw createInvalidJsonResponse(); + } + } } const verifiableRequest: VerifiableRequest = { @@ -45,8 +80,8 @@ export function createActionMiddleware(options?: VerificationOptions) { }); } - // Original request is passed to next(), allowing downstream to call .json() - return next(); + // Pass parsed body to next middleware via context + return next({ context: { body: parsedBody } }); }, ); @@ -65,11 +100,22 @@ export function createWebhookMiddleware(options?: WebhookVerificationOptions) { // Read raw body as text from cloned request // This preserves the original request stream for downstream handlers let rawBody: string | undefined; + let parsedBody: unknown; if (request.body) { // Clone the request to read body without consuming original const clonedRequest = request.clone(); rawBody = await clonedRequest.text(); + + // Parse the raw body for downstream use + if (rawBody) { + try { + parsedBody = JSON.parse(rawBody); + } catch (error) { + // Return 400 for invalid JSON + throw createInvalidJsonResponse(); + } + } } const verifiableRequest: VerifiableRequest = { @@ -92,8 +138,8 @@ export function createWebhookMiddleware(options?: WebhookVerificationOptions) { }); } - // Original request is passed to next(), allowing downstream to call .json() - return next(); + // Pass parsed body to next middleware via context + return next({ context: { body: parsedBody } }); }, ); From 08d2f89f0dd5b4e74e57cd044decded4d35c9770 Mon Sep 17 00:00:00 2001 From: "Lucas A. C. Lessa" Date: Wed, 20 May 2026 01:08:20 +0000 Subject: [PATCH 4/5] refactor(app-connect): remove JSON parsing from tanstack middleware Remove JSON parsing and validation from middleware - downstream handlers can parse as needed using the preserved request body stream. Changes: - Remove createInvalidJsonResponse() helper function - Remove JSON.parse() and try/catch error handling - Remove parsedBody variable and context passing - Simplify middleware to only read raw body text for verification - Remove 4 tests related to JSON parsing and body context passing - Keep 14 tests covering raw body preservation and verification Tests: 14 tests passing (down from 18) Co-Authored-By: Claude Sonnet 4.5 --- .../app-connect/src/tanstack/index.test.ts | 120 +----------------- packages/app-connect/src/tanstack/index.ts | 52 +------- 2 files changed, 6 insertions(+), 166 deletions(-) diff --git a/packages/app-connect/src/tanstack/index.test.ts b/packages/app-connect/src/tanstack/index.test.ts index 01010f18..19be216d 100644 --- a/packages/app-connect/src/tanstack/index.test.ts +++ b/packages/app-connect/src/tanstack/index.test.ts @@ -49,20 +49,12 @@ function createMockRequest( return new Request('https://example.com/api/test?foo=bar', requestInit); } -function createInvalidJsonRequest(): Request { - return new Request('https://example.com/api/test', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: '{invalid json', - }); -} - describe('createActionMiddleware', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('should parse body and pass it in context on success', async () => { + it('should call next without arguments on success', async () => { const body = { action: 'test', data: { key: 'value' } }; const request = createMockRequest(body); const next = vi.fn().mockResolvedValue('next-result'); @@ -72,22 +64,10 @@ describe('createActionMiddleware', () => { createActionMiddleware(); const result = await capturedServerFn({ next, request }); - expect(next).toHaveBeenCalledWith({ context: { body } }); + expect(next).toHaveBeenCalledWith(); expect(result).toBe('next-result'); }); - it('should pass undefined body in context when request has no body', async () => { - const request = createMockRequest(); - const next = vi.fn().mockResolvedValue('next-result'); - - mockVerifyAction.mockResolvedValue({ success: true }); - - createActionMiddleware(); - await capturedServerFn({ next, request }); - - expect(next).toHaveBeenCalledWith({ context: { body: undefined } }); - }); - it('should read body as text from cloned request (not json)', async () => { const body = { action: 'test' }; const request = createMockRequest(body); @@ -186,39 +166,6 @@ describe('createActionMiddleware', () => { expect(next).not.toHaveBeenCalled(); }); - it('should throw a 400 Response with InvalidBodyError when body is invalid JSON', async () => { - const request = createInvalidJsonRequest(); - const next = vi.fn(); - - createActionMiddleware(); - - try { - await capturedServerFn({ next, request }); - expect.unreachable('Should have thrown'); - } catch (thrown) { - expect(thrown).toBeInstanceOf(Response); - const response = thrown as Response; - expect(response.status).toBe(400); - expect(response.headers.get('Content-Type')).toBe('application/json'); - const responseBody = await response.json(); - expect(responseBody.name).toBe('INVALID_REQUEST_BODY'); - expect(responseBody.message).toBe( - 'The request body contains invalid JSON', - ); - expect(responseBody.correlationId).toBeDefined(); - expect(responseBody.details).toEqual([ - { - issue: 'INVALID_JSON', - description: 'The request body could not be parsed as JSON', - location: 'body', - }, - ]); - } - - expect(next).not.toHaveBeenCalled(); - expect(mockVerifyAction).not.toHaveBeenCalled(); - }); - it('should preserve exact raw body string including whitespace', async () => { // Test that formatting/whitespace is preserved exactly const rawBodyString = '{\n "action": "test",\n "data": {\n "key": "value"\n }\n}'; @@ -241,13 +188,6 @@ describe('createActionMiddleware', () => { }), undefined, ); - - // But parsed body (without whitespace) is passed to next - expect(next).toHaveBeenCalledWith({ - context: { - body: { action: 'test', data: { key: 'value' } }, - }, - }); }); it('should preserve unicode and special characters in raw body', async () => { @@ -279,7 +219,7 @@ describe('createWebhookMiddleware', () => { vi.clearAllMocks(); }); - it('should parse body and pass it in context on success', async () => { + it('should call next without arguments on success', async () => { const body = { event: 'order.created', payload: { id: '123' } }; const request = createMockRequest(body); const next = vi.fn().mockResolvedValue('next-result'); @@ -289,22 +229,10 @@ describe('createWebhookMiddleware', () => { createWebhookMiddleware(); const result = await capturedServerFn({ next, request }); - expect(next).toHaveBeenCalledWith({ context: { body } }); + expect(next).toHaveBeenCalledWith(); expect(result).toBe('next-result'); }); - it('should pass undefined body in context when request has no body', async () => { - const request = createMockRequest(); - const next = vi.fn().mockResolvedValue('next-result'); - - mockVerifyWebhookSubscription.mockReturnValue({ success: true }); - - createWebhookMiddleware(); - await capturedServerFn({ next, request }); - - expect(next).toHaveBeenCalledWith({ context: { body: undefined } }); - }); - it('should read body as text from cloned request (not json)', async () => { const body = { event: 'order.created' }; const request = createMockRequest(body); @@ -406,39 +334,6 @@ describe('createWebhookMiddleware', () => { expect(next).not.toHaveBeenCalled(); }); - it('should throw a 400 Response with InvalidBodyError when body is invalid JSON', async () => { - const request = createInvalidJsonRequest(); - const next = vi.fn(); - - createWebhookMiddleware(); - - try { - await capturedServerFn({ next, request }); - expect.unreachable('Should have thrown'); - } catch (thrown) { - expect(thrown).toBeInstanceOf(Response); - const response = thrown as Response; - expect(response.status).toBe(400); - expect(response.headers.get('Content-Type')).toBe('application/json'); - const responseBody = await response.json(); - expect(responseBody.name).toBe('INVALID_REQUEST_BODY'); - expect(responseBody.message).toBe( - 'The request body contains invalid JSON', - ); - expect(responseBody.correlationId).toBeDefined(); - expect(responseBody.details).toEqual([ - { - issue: 'INVALID_JSON', - description: 'The request body could not be parsed as JSON', - location: 'body', - }, - ]); - } - - expect(next).not.toHaveBeenCalled(); - expect(mockVerifyWebhookSubscription).not.toHaveBeenCalled(); - }); - it('should preserve exact raw body string including whitespace', async () => { // Test that formatting/whitespace is preserved exactly const rawBodyString = '{\n "event": "order.created",\n "payload": {\n "id": "123"\n }\n}'; @@ -461,13 +356,6 @@ describe('createWebhookMiddleware', () => { }), undefined, ); - - // But parsed body (without whitespace) is passed to next - expect(next).toHaveBeenCalledWith({ - context: { - body: { event: 'order.created', payload: { id: '123' } }, - }, - }); }); it('should preserve unicode and special characters in raw body', async () => { diff --git a/packages/app-connect/src/tanstack/index.ts b/packages/app-connect/src/tanstack/index.ts index 4c422923..3aacd12c 100644 --- a/packages/app-connect/src/tanstack/index.ts +++ b/packages/app-connect/src/tanstack/index.ts @@ -7,30 +7,6 @@ import type { import { verifyAction } from '../utils/verification'; import { verifyWebhookSubscription } from '../utils/webhook'; -/** - * Creates a 400 Response for invalid JSON in request body - */ -function createInvalidJsonResponse(): Response { - return new Response( - JSON.stringify({ - name: 'INVALID_REQUEST_BODY', - message: 'The request body contains invalid JSON', - correlationId: crypto.randomUUID(), - details: [ - { - issue: 'INVALID_JSON', - description: 'The request body could not be parsed as JSON', - location: 'body', - }, - ], - }), - { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }, - ); -} - /** * Creates a middleware for TanStack Start that verifies signed action requests */ @@ -42,22 +18,11 @@ export function createActionMiddleware(options?: VerificationOptions) { // Read raw body as text from cloned request // This preserves the original request stream for downstream handlers let rawBody: string | undefined; - let parsedBody: unknown; if (request.body) { // Clone the request to read body without consuming original const clonedRequest = request.clone(); rawBody = await clonedRequest.text(); - - // Parse the raw body for downstream use - if (rawBody) { - try { - parsedBody = JSON.parse(rawBody); - } catch (error) { - // Return 400 for invalid JSON - throw createInvalidJsonResponse(); - } - } } const verifiableRequest: VerifiableRequest = { @@ -80,8 +45,7 @@ export function createActionMiddleware(options?: VerificationOptions) { }); } - // Pass parsed body to next middleware via context - return next({ context: { body: parsedBody } }); + return next(); }, ); @@ -100,22 +64,11 @@ export function createWebhookMiddleware(options?: WebhookVerificationOptions) { // Read raw body as text from cloned request // This preserves the original request stream for downstream handlers let rawBody: string | undefined; - let parsedBody: unknown; if (request.body) { // Clone the request to read body without consuming original const clonedRequest = request.clone(); rawBody = await clonedRequest.text(); - - // Parse the raw body for downstream use - if (rawBody) { - try { - parsedBody = JSON.parse(rawBody); - } catch (error) { - // Return 400 for invalid JSON - throw createInvalidJsonResponse(); - } - } } const verifiableRequest: VerifiableRequest = { @@ -138,8 +91,7 @@ export function createWebhookMiddleware(options?: WebhookVerificationOptions) { }); } - // Pass parsed body to next middleware via context - return next({ context: { body: parsedBody } }); + return next(); }, ); From 1870f0da065196f919aad5db4b795e7c42e0aad1 Mon Sep 17 00:00:00 2001 From: "Lucas A. C. Lessa" Date: Wed, 20 May 2026 01:44:23 +0000 Subject: [PATCH 5/5] changeset --- .changeset/upset-waves-rescue.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/upset-waves-rescue.md diff --git a/.changeset/upset-waves-rescue.md b/.changeset/upset-waves-rescue.md new file mode 100644 index 00000000..a15bc296 --- /dev/null +++ b/.changeset/upset-waves-rescue.md @@ -0,0 +1,5 @@ +--- +"@godaddy/app-connect": patch +--- + +Verification middlewares for tanstack now pass raw text of request body through to verification function.