From d89911d19ff9d6cccb7681994fadf133102f3453 Mon Sep 17 00:00:00 2001 From: asemabdallah Date: Thu, 1 Oct 2026 21:55:43 +0300 Subject: [PATCH 1/2] fix: return 400 for malformed JSON and invalid Space access on workspace routes Malformed request bodies on /api/spaces, /api/dots, /api/conversations and /api/voice/calls, and Dot create/update with an unknown Space, fell through to the generic 503 handler, which tells the caller to check server configuration. Return 400 with a specific message instead, matching the page routes. Signed-off-by: asemabdallah --- src/server/workspace-routes.ts | 4 ++++ tests/page-routes.test.ts | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/server/workspace-routes.ts b/src/server/workspace-routes.ts index 82ba5bb..50b135f 100644 --- a/src/server/workspace-routes.ts +++ b/src/server/workspace-routes.ts @@ -201,7 +201,11 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) { }); app.all('/copilotkit/*', (c) => platform.handle(c.req.raw)); app.onError((error, c) => { + if (error instanceof SyntaxError) + return c.json({ error: 'Invalid JSON request.' }, 400); const text = error.message; + if (text.startsWith('Space access must include')) + return c.json({ error: text }, 400); const known = /^(Setup|Voice setup|Dot |Space |Specialist |Conversation |Call |This call|End the current|Voice provider|An audio|Intelligence could not)/.test( text, diff --git a/tests/page-routes.test.ts b/tests/page-routes.test.ts index 9b0f8fe..a8b31ad 100644 --- a/tests/page-routes.test.ts +++ b/tests/page-routes.test.ts @@ -221,3 +221,39 @@ it('restores review receipts through the owner API with current thread and Space ws.updateDot(dot.id, { ...dot, spaceId: other.id, spaceIds: [other.id] }); expect((await app.request(`${base}/call`, { headers })).status).toBe(403); }); + +it('answers malformed JSON and invalid Space access with 400 on workspace routes', async () => { + const { ws, app } = fixture(); + const dot = ws.dots()[0]; + for (const path of ['/api/spaces', '/api/dots', '/api/conversations']) { + const response = await app.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{', + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'Invalid JSON request.' }); + } + const body = { + name: dot.name, + instructions: dot.instructions, + researchAllowed: true, + memoryAllowed: true, + }; + expect( + ( + await app.request( + '/api/dots', + request({ ...body, spaceId: 'missing-space' }), + ) + ).status, + ).toBe(400); + expect( + ( + await app.request( + `/api/dots/${dot.id}`, + request({ ...body, spaceIds: ['missing-space'] }, 'PUT'), + ) + ).status, + ).toBe(400); +}); From 6885ec3b77f1ecbb30ccb6c0743ecc54a6ba38e0 Mon Sep 17 00:00:00 2001 From: asemabdallah Date: Sat, 3 Oct 2026 10:07:05 +0300 Subject: [PATCH 2/2] fix: handle malformed request JSON at the body-parsing boundary Catch JSON parse failures where each workspace route reads its request body instead of mapping every SyntaxError to 400 in the global handler. Malformed caller JSON still gets a 400 from the route's schema check, while a SyntaxError raised by an upstream service call stays a 503. Add a regression test for the upstream case. Signed-off-by: asemabdallah --- src/server/workspace-routes.ts | 16 +++++++--------- tests/page-routes.test.ts | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/server/workspace-routes.ts b/src/server/workspace-routes.ts index 50b135f..9410d46 100644 --- a/src/server/workspace-routes.ts +++ b/src/server/workspace-routes.ts @@ -38,7 +38,7 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) { description: z.string().max(500).default(''), }) .strict() - .safeParse(await c.req.json()); + .safeParse(await c.req.json().catch(() => null)); if (!data.success) return c.json( { error: 'Enter a Space name (up to 60 characters).' }, @@ -52,7 +52,7 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) { app.post('/dots', async (c) => { const data = dotSchema .extend({ spaceId: z.string() }) - .safeParse(await c.req.json()); + .safeParse(await c.req.json().catch(() => null)); if (!data.success) return c.json( { @@ -92,7 +92,7 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) { ); }); app.put('/dots/:id', async (c) => { - const data = dotSchema.safeParse(await c.req.json()); + const data = dotSchema.safeParse(await c.req.json().catch(() => null)); if (!data.success) return c.json({ error: 'Invalid specialist settings.' }, 400); const current = platform.workspace.dot(c.req.param('id')); @@ -124,7 +124,7 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) { title: z.string().trim().min(1).max(120).default('A new thought'), }) .strict() - .safeParse(await c.req.json()); + .safeParse(await c.req.json().catch(() => null)); if (!data.success) return c.json({ error: 'Select a Dot and a conversation title.' }, 400); if (platform.setup().missing.length) @@ -144,7 +144,7 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) { const data = z .object({ threadId: z.string(), sdp: z.string().max(100000) }) .strict() - .safeParse(await c.req.json()); + .safeParse(await c.req.json().catch(() => null)); if (!data.success) return c.json( { error: 'A conversation and audio SDP offer are required.' }, @@ -169,7 +169,7 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) { transcript: z.string().max(12000).default(''), }) .strict() - .safeParse(await c.req.json()); + .safeParse(await c.req.json().catch(() => null)); if (!data.success) return c.json( { error: 'A bounded compute request and tool call ID are required.' }, @@ -190,7 +190,7 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) { anchorMessageId: z.string().max(200).optional(), }) .strict() - .safeParse(await c.req.json()); + .safeParse(await c.req.json().catch(() => null)); if (!data.success) return c.json( { error: 'Transcript exceeds the 20,000 character limit.' }, @@ -201,8 +201,6 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) { }); app.all('/copilotkit/*', (c) => platform.handle(c.req.raw)); app.onError((error, c) => { - if (error instanceof SyntaxError) - return c.json({ error: 'Invalid JSON request.' }, 400); const text = error.message; if (text.startsWith('Space access must include')) return c.json({ error: text }, 400); diff --git a/tests/page-routes.test.ts b/tests/page-routes.test.ts index a8b31ad..10d637a 100644 --- a/tests/page-routes.test.ts +++ b/tests/page-routes.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, it } from 'vitest'; +import { afterEach, expect, it, vi } from 'vitest'; import { Store } from '../src/server/store.js'; import { WorkspaceStore } from '../src/server/workspace.js'; import { Platform } from '../src/server/platform.js'; @@ -232,7 +232,6 @@ it('answers malformed JSON and invalid Space access with 400 on workspace routes body: '{', }); expect(response.status).toBe(400); - expect(await response.json()).toEqual({ error: 'Invalid JSON request.' }); } const body = { name: dot.name, @@ -257,3 +256,15 @@ it('answers malformed JSON and invalid Space access with 400 on workspace routes ).status, ).toBe(400); }); + +it('keeps upstream JSON parsing failures on workspace routes as 503', async () => { + const { ws, app } = fixture(); + vi.spyOn(ws, 'createSpace').mockImplementation(() => { + throw new SyntaxError('Unexpected token in upstream response'); + }); + const response = await app.request( + '/api/spaces', + request({ name: 'Valid', description: '' }), + ); + expect(response.status).toBe(503); +});