diff --git a/.changeset/tresponse-json-constraint.md b/.changeset/tresponse-json-constraint.md new file mode 100644 index 00000000..092627fb --- /dev/null +++ b/.changeset/tresponse-json-constraint.md @@ -0,0 +1,7 @@ +--- +"@toapi/common": patch +"@toapi/server": patch +"@toapi/client": patch +--- + +Constrain `TResponse.json` to only accept JSON-serializable values (`JSONValue`). This prevents non-JSON types such as `Date`, `Map`, `Set`, `bigint`, `undefined`, functions, or symbols from being passed as structured response data, which would otherwise be silently coerced to strings by `JSON.stringify` and break the type contract the client relies on. Form-data mocks that echoed `Object.fromEntries(formData)` (which can contain `File` values) were updated to return only string entries. diff --git a/packages/toapi-client/src/api.mock.ts b/packages/toapi-client/src/api.mock.ts index 571a07f8..116324ce 100644 --- a/packages/toapi-client/src/api.mock.ts +++ b/packages/toapi-client/src/api.mock.ts @@ -80,10 +80,15 @@ export const api = defineApi({ { authorize: () => true, }, - async (req) => - TResponse.json(Object.fromEntries(await req.formData()), { - cache: { tags: ["movies"] }, - }), + async (req) => { + const data: Record = {}; + for (const [key, value] of await req.formData()) { + if (typeof value === "string") { + data[key] = value; + } + } + return TResponse.json(data, { cache: { tags: ["movies"] } }); + }, ), }) .route("/authorized", { @@ -192,7 +197,10 @@ export const api = defineApi({ authorize: () => true, }, async (req) => { - return TResponse.json(req.query()); + const query = req.query(); + return TResponse.json( + query.optional === undefined ? {} : query, + ); }, ), }); diff --git a/packages/toapi-common/package.json b/packages/toapi-common/package.json index 60e61bca..2255ed15 100644 --- a/packages/toapi-common/package.json +++ b/packages/toapi-common/package.json @@ -20,7 +20,7 @@ } }, "scripts": { - "build": "tsc --noEmit false", + "build": "tsc -p tsconfig.build.json", "release": "pnpm build && pnpm publish --no-git-checks", "test": "vitest" }, diff --git a/packages/toapi-common/src/t-response.test.ts b/packages/toapi-common/src/t-response.test.ts index 90457b6f..d0d79c2c 100644 --- a/packages/toapi-common/src/t-response.test.ts +++ b/packages/toapi-common/src/t-response.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, test } from "vitest"; -import { TResponse } from "./t-response.js"; +import { describe, expect, expectTypeOf, test } from "vitest"; +import { TResponse, type JSONValue } from "./t-response.js"; describe("TResponse", () => { test("correctly sets tags-header", () => { @@ -22,4 +22,64 @@ describe("TResponse", () => { const text = await res.text(); expect(text).toBe('{"id":1}\n{"id":2}\n'); }); + + test("json accepts JSON-serializable values", () => { + // These calls must compile (proving the JSONValue constraint accepts them) + // and are safe to execute at runtime. + TResponse.json("hello"); + TResponse.json(42); + TResponse.json(true); + TResponse.json(null); + TResponse.json([1, "two", false, null]); + TResponse.json({ now: "2024-01-01" }); + TResponse.json({ nested: { a: 1, list: [true, null, "x"] } }); + }); + + test("json infers the response type from the JSON data", () => { + const objectRes = TResponse.json({ now: "2024-01-01" }); + expectTypeOf(objectRes.data).toEqualTypeOf<{ now: string } | undefined>(); + + const arrayRes = TResponse.json([1, 2, 3]); + expectTypeOf(arrayRes.data).toEqualTypeOf(); + }); + + test("json rejects non-JSON values at compile time", () => { + // The assignments below are expected to fail type-checking because the + // values are not representable as JSON. `@ts-expect-error` consumes the + // error; if a value ever became JSON-compatible the directive would itself + // error, keeping the contract honest. None of these call `JSON.stringify`, + // so they are safe to execute at runtime (bigint/symbol would otherwise + // throw when serialized). + + // @ts-expect-error Date serializes to a string, breaking the type boundary + const dateValue: JSONValue = new Date(); + // @ts-expect-error bigint throws when serialized and is not JSON + const bigintValue: JSONValue = 1n; + // @ts-expect-error symbol is not JSON-serializable + const symbolValue: JSONValue = Symbol("x"); + // @ts-expect-error undefined is not representable in JSON + const undefinedValue: JSONValue = undefined; + // @ts-expect-error Map is not JSON-serializable + const mapValue: JSONValue = new Map(); + // @ts-expect-error Set is not JSON-serializable + const setValue: JSONValue = new Set(); + // @ts-expect-error functions are not JSON-serializable + const functionValue: JSONValue = () => {}; + + // End-to-end: the original issue scenario must be rejected. + // Safe at runtime: `JSON.stringify({ now: date })` does not throw. + // @ts-expect-error TResponse.json must not accept Date values + TResponse.json({ now: new Date() }); + + // reference the locals so they are not dropped by tooling + void [ + dateValue, + bigintValue, + symbolValue, + undefinedValue, + mapValue, + setValue, + functionValue, + ]; + }); }); diff --git a/packages/toapi-common/src/t-response.ts b/packages/toapi-common/src/t-response.ts index a279197c..9488ae70 100644 --- a/packages/toapi-common/src/t-response.ts +++ b/packages/toapi-common/src/t-response.ts @@ -1,6 +1,24 @@ import { EXPIRES_AT_HEADER, TAGS_HEADER } from "./constants.js"; import type { CookieStore } from "./cookie-store.js"; +/** + * A value that can be represented as JSON without coercion. + * + * This is the set of values `JSON.stringify` can round-trip into a structured + * JSON value (rather than coercing it into a string or throwing). Constraining + * {@link TResponse.json} to it prevents non-JSON values such as `Date`, `Map`, + * `Set`, `bigint`, `undefined`, functions or symbols from being advertised as + * structured response data while actually being serialized to (or rejected as) + * strings — which would break the type contract the client relies on. + */ +export type JSONValue = + | null + | boolean + | number + | string + | JSONValue[] + | { [key: string]: JSONValue }; + interface TResponseInit extends ResponseInit { cache?: { tags?: string[]; @@ -39,7 +57,10 @@ export class TResponse extends Response { this.cache = cache; } - static override json(data: T, init: TResponseInit = {}): TResponse { + static override json( + data: T, + init: TResponseInit = {}, + ): TResponse { setHeader(init, "Content-Type", "application/json"); const res = new TResponse(JSON.stringify(data), init); res.data = data; diff --git a/packages/toapi-common/tsconfig.build.json b/packages/toapi-common/tsconfig.build.json new file mode 100644 index 00000000..ef05bcae --- /dev/null +++ b/packages/toapi-common/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + }, + "exclude": ["**/*.test.ts"] +} diff --git a/packages/toapi-common/tsconfig.json b/packages/toapi-common/tsconfig.json index 48390c62..dbae97d0 100644 --- a/packages/toapi-common/tsconfig.json +++ b/packages/toapi-common/tsconfig.json @@ -28,8 +28,7 @@ "outDir": "./dist", "declarationDir": "./dist", "declaration": true, - "declarationMap": true, + "declarationMap": true }, - "include": ["src"], - "exclude": ["**/*.test.ts"], + "include": ["src"] } diff --git a/packages/toapi-server/src/api.mock.ts b/packages/toapi-server/src/api.mock.ts index 9cfa8376..351a37dc 100644 --- a/packages/toapi-server/src/api.mock.ts +++ b/packages/toapi-server/src/api.mock.ts @@ -77,10 +77,15 @@ export const api = defineApi({ { authorize: () => true, }, - async (req) => - TResponse.json(Object.fromEntries(await req.formData()), { - cache: { tags: ["movies"] }, - }), + async (req) => { + const data: Record = {}; + for (const [key, value] of await req.formData()) { + if (typeof value === "string") { + data[key] = value; + } + } + return TResponse.json(data, { cache: { tags: ["movies"] } }); + }, ), }) .route("/authorized", { @@ -189,7 +194,10 @@ export const api = defineApi({ authorize: () => true, }, async (req) => { - return TResponse.json(req.query()); + const query = req.query(); + return TResponse.json( + query.optional === undefined ? {} : query, + ); }, ), });