From 6500f69eade3d14f086b9abf785db36d7f4df4a6 Mon Sep 17 00:00:00 2001 From: isiliconx <219942559+isiliconx@users.noreply.github.com> Date: Fri, 22 May 2026 21:59:45 -0500 Subject: [PATCH 1/2] Guard invalid route return values --- src/create-with-winter-spec.ts | 2 + src/middleware/route-return-value-error.ts | 17 ++++++++ src/middleware/with-response-object-check.ts | 5 +-- tests/errors/do-not-allow-raw-json.test.ts | 46 ++++++++++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/middleware/route-return-value-error.ts diff --git a/src/create-with-winter-spec.ts b/src/create-with-winter-spec.ts index 229a28c..9e9e0e7 100644 --- a/src/create-with-winter-spec.ts +++ b/src/create-with-winter-spec.ts @@ -18,6 +18,7 @@ import { withInputValidation } from "./middleware/with-input-validation.js" import { withUnhandledExceptionHandling } from "./middleware/with-unhandled-exception-handling.js" import { ResponseValidationError } from "./middleware/http-exceptions.js" import { withResponseObjectCheck } from "./middleware/with-response-object-check.js" +import { assertValidRouteReturnValue } from "./middleware/route-return-value-error.js" const attachMetadataToRouteFn = < const GS extends GlobalSpec, @@ -126,6 +127,7 @@ function serializeResponse( ): Middleware { return async (req, ctx, next) => { const rawResponse = await next(req, ctx) + assertValidRouteReturnValue(rawResponse) const statusCode = rawResponse instanceof WinterSpecResponse diff --git a/src/middleware/route-return-value-error.ts b/src/middleware/route-return-value-error.ts new file mode 100644 index 0000000..ed3a950 --- /dev/null +++ b/src/middleware/route-return-value-error.ts @@ -0,0 +1,17 @@ +export const INVALID_ROUTE_RETURN_VALUE_MESSAGE = + "Use ctx.json({...}) instead of returning an object directly." + +export function isValidRouteReturnValue(value: unknown): boolean { + return ( + value instanceof Response || + (typeof value === "object" && + value !== null && + "serializeToResponse" in value) + ) +} + +export function assertValidRouteReturnValue(value: unknown) { + if (!isValidRouteReturnValue(value)) { + throw new Error(INVALID_ROUTE_RETURN_VALUE_MESSAGE) + } +} diff --git a/src/middleware/with-response-object-check.ts b/src/middleware/with-response-object-check.ts index 9f8cae0..91530fe 100644 --- a/src/middleware/with-response-object-check.ts +++ b/src/middleware/with-response-object-check.ts @@ -1,6 +1,7 @@ import { ResponseValidationError } from "./http-exceptions.js" import { Middleware } from "./types.js" import { RouteSpec } from "src/types/route-spec.js" +import { INVALID_ROUTE_RETURN_VALUE_MESSAGE } from "./route-return-value-error.js" export const withResponseObjectCheck: Middleware< { routeSpec: RouteSpec }, @@ -9,9 +10,7 @@ export const withResponseObjectCheck: Middleware< const rawResponse = await next(req, ctx) if (typeof rawResponse === "object" && !(rawResponse instanceof Response)) { - throw new Error( - "Use ctx.json({...}) instead of returning an object directly." - ) + throw new Error(INVALID_ROUTE_RETURN_VALUE_MESSAGE) } return rawResponse diff --git a/tests/errors/do-not-allow-raw-json.test.ts b/tests/errors/do-not-allow-raw-json.test.ts index 8682796..b63838d 100644 --- a/tests/errors/do-not-allow-raw-json.test.ts +++ b/tests/errors/do-not-allow-raw-json.test.ts @@ -37,3 +37,49 @@ test("should throw an error when responding with raw JSON", async (t) => { ) ) }) + +const invalidReturnCases = [ + { name: "undefined", value: undefined }, + { name: "null", value: null }, + { name: "string", value: "not a response" }, + { name: "number", value: 42 }, + { name: "boolean", value: true }, + { name: "bigint", value: BigInt(42) }, + { name: "symbol", value: Symbol("not-a-response") }, + { name: "function", value: () => "not a response" }, +] + +for (const { name, value } of invalidReturnCases) { + test(`should throw ctx.json guidance when route returns ${name}`, async (t) => { + const { axios } = await getTestRoute(t, { + globalSpec: { + authMiddleware: {}, + beforeAuthMiddleware: [ + async (req, ctx, next) => { + try { + return await next(req, ctx) + } catch (e: any) { + return Response.json({ error: e.message }, { status: 500 }) + } + }, + ], + }, + routeSpec: { + methods: ["GET"], + jsonBody: z.any(), + jsonResponse: z.any(), + }, + routePath: "/", + routeFn: () => value as any, + }) + + const { data } = await axios.get("/", { + validateStatus: () => true, + }) + t.true( + data.error.includes( + "Use ctx.json({...}) instead of returning an object directly" + ) + ) + }) +} From 8a831fff6e2d243a3ca2f50d9d01834d29160b8e Mon Sep 17 00:00:00 2001 From: isiliconx <219942559+isiliconx@users.noreply.github.com> Date: Fri, 22 May 2026 22:04:14 -0500 Subject: [PATCH 2/2] Retry CI