From 58f5e1c7708d01fc9c7c9354e16caf0467089872 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 18 May 2026 22:43:33 +0200 Subject: [PATCH 01/45] feat(config): Cookies input source and parser. --- cookie-support-plan.md | 273 +++++++++++++++++++ express-zod-api/src/config-type.ts | 24 +- express-zod-api/tests/common-helpers.spec.ts | 31 +++ express-zod-api/tests/config-type.spec.ts | 53 ++++ 4 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 cookie-support-plan.md diff --git a/cookie-support-plan.md b/cookie-support-plan.md new file mode 100644 index 000000000..999b3b7bc --- /dev/null +++ b/cookie-support-plan.md @@ -0,0 +1,273 @@ +# Cookie Support — Implementation Plan + +Integrate cookie parsing (`cookie-parser`), cookie input sources, a public cookie-setting middleware, and OpenAPI cookie parameter depiction into express-zod-api. + +--- + +## Phase 1: Configuration & Types + +### 1.1 Expand `InputSource` Union + +`@types/express-serve-static-core` already declares `cookies: any` and `signedCookies: any` on `Request`, so they're available for `keyof Pick` without any augmentation. + +```typescript +export type InputSource = keyof Pick< + Request, + | "query" + | "body" + | "files" + | "params" + | "headers" + | "cookies" + | "signedCookies" +>; +``` + +### 1.3 Add Cookie Parser Config Type + +`CookieOptions` already exists in `@types/express-serve-static-core` (for `res.cookie()`). For cookie-parser config, use a distinct name: + +```typescript +interface CookieParserOptions { + /** @desc The secret string or array used by cookie-parser for signed cookies */ + /** @default undefined (no signed cookies) */ + secret?: string | string[]; + /** @desc Custom decode function for cookie values */ + /** @default decodeURIComponent */ + decode?: (val: string) => string; +} +``` + +### 1.4 Add `cookies` to `ServerConfig` + +```typescript +export interface ServerConfig extends CommonConfig { + // ... existing properties ... + + /** + * @desc Enable cookie parsing via cookie-parser + * @requires cookie-parser + * @example true + * @example { secret: "my-secret" } + */ + cookies?: boolean | CookieParserOptions; +} +``` + +### 1.5 Tests + +- **`config-type.spec.ts`**: Verify `CookieOptions` shape, `ServerConfig.cookies` accepts `boolean | CookieOptions`, and `InputSource` includes `"cookies"` and `"signedCookies"`. +- **`common-helpers.spec.ts`**: Verify `getInputSources` recognizes the new sources and `getInput` merges `req.cookies`/`req.signedCookies` when present. + +--- + +## Phase 2: Cookie Parser Integration + +### 2.1 Create Cookie Parser in `server.ts` + +Following the `compression` pattern (line 73–80), load `cookie-parser` via `loadPeer` and attach it as global middleware. Place it after compression, before `beforeRouting`: + +```typescript +if (config.cookies) { + const cookieParser = await loadPeer("cookie-parser"); + const settings = typeof config.cookies === "object" ? config.cookies : {}; + const { secret, decode } = settings; + app.use(cookieParser(secret, decode ? { decode } : undefined)); +} +``` + +This means cookie-parser runs on every request when the feature is enabled. + +### 2.2 Tests + +- **`server.spec.ts`**: Verify cookie parser middleware is registered when `config.cookies` is truthy and absent when falsy. +- **`server-helpers.spec.ts`**: Verify `loadPeer` behavior for `cookie-parser`. + +--- + +## Phase 3: Input Source Plumbing + +### 3.1 Update `getInput` in `common-helpers.ts` + +No special filtering needed — unlike `"files"` (which checks `areFilesAvailable`), `req.cookies` and `req.signedCookies` are either populated by cookie-parser or `undefined`. `Object.assign` handles `undefined` gracefully. + +The existing code already handles this correctly since it does: + +```typescript +.reduce((agg, src) => Object.assign(agg, req[src]), {}); +``` + +The `"cookies"` and `"signedCookies"` sources need no guards added to the `.filter()` chain. + +### 3.2 Default Input Sources + +`defaultInputSources` stays unchanged — cookies are opt-in: + +```typescript +// User enables cookies per-method: +createConfig({ + inputSources: { get: ["query", "params", "cookies", "signedCookies"] }, + cookies: { secret: "my-secret" }, +}); +``` + +### 3.3 Tests + +- **`common-helpers.spec.ts`**: Verify merged input order (later source overrides earlier when keys collide between `cookies` and `signedCookies`). + +--- + +## Phase 4: Public Cookie-Setting Middleware + +### 4.1 New File: `express-zod-api/src/cookie-middleware.ts` + +A singleton `Middleware` instance that exposes `setCookie` and `clearCookie` directly into context — no wrapper class needed since middlewares already have access to `response`: + +```typescript +import { Middleware } from "./middleware"; +import type { CookieOptions } from "express"; // already available from @types/express-serve-static-core + +export const cookieMiddleware = new Middleware({ + handler: async ({ response }) => ({ + setCookie: (name: string, value: string, options?: CookieOptions) => { + response.cookie(name, value, options); + }, + clearCookie: ( + name: string, + options?: Pick, + ) => { + response.clearCookie(name, options); + }, + }), +}); +``` + +Usage: + +```typescript +import { cookieMiddleware } from "express-zod-api"; + +const factory = new EndpointsFactory(defaultResultHandler).addMiddleware( + cookieMiddleware, +); + +const setSession = factory.build({ + method: "post", + path: "/session", + output: z.object({ success: z.boolean() }), + handler: async ({ ctx: { setCookie, clearCookie } }) => { + setCookie("session", "abc123", { httpOnly: true, path: "/" }); + return { success: true }; + }, +}); +``` + +### 4.2 Export in `index.ts` + +```typescript +export { cookieMiddleware } from "./cookie-middleware"; +``` + +### 4.3 Tests + +- **`cookie-middleware.spec.ts`**: Verify `setCookie` delegates to `response.cookie()`, `clearCookie` delegates to `response.clearCookie()`, and `cookieMiddleware` provides both functions in context. + +--- + +## Phase 5: OpenAPI Documentation + +### 5.1 Add `"cookie"` Location in `depictRequestParams` + +Extract cookie security names from the security schemas: + +```typescript +const securityCookieNames = R.chain( + R.filter((entry: Security) => entry.type === "cookie"), + security ?? [], +).map(({ name }) => name); +``` + +Add `areCookiesEnabled` flag: + +```typescript +const areCookiesEnabled = + inputSources.includes("cookies") || inputSources.includes("signedCookies"); +``` + +Update `getLocation` priority: **path → cookie → header → query** + +```typescript +const getLocation = (name: string) => { + if (areParamsEnabled && pathParams.includes(name)) return "path"; + if (areCookiesEnabled && securityCookieNames.includes(name)) return "cookie"; + if ( + areHeadersEnabled && + (isHeader?.(name, method, path) ?? defaultIsHeader(name, securityHeaders)) + ) + return "header"; + if (isQueryEnabled) return "query"; +}; +``` + +Cookie is checked before header to avoid misclassification when a property name matches both a cookie and header security scheme (unlikely but safe). + +### 5.2 Tests + +- **`documentation-helpers.spec.ts`**: Test cookie parameter depiction — properties matching `CookieSecurity` names are placed `in: "cookie"`, others fall through to header/query. +- **`documentation.spec.ts`**: Full integration test — `Documentation` class produces correct OpenAPI output with cookie parameters and security schemes. + +--- + +## Phase 6: Polish & Documentation + +### 6.1 README Update + +Add a "Cookies" section covering: + +- Enabling cookie parsing via `config.cookies` +- Adding `"cookies"`/`"signedCookies"` to `inputSources` +- Using `cookieMiddleware` for setting cookies +- Cookie security schemas for OpenAPI + +### 6.2 CHANGELOG + +Note the new feature in upcoming version under `Added:`. + +### 6.3 Migration + +No migration rule needed — no breaking changes to existing public API types. + +--- + +## Implementation Order Summary + +``` +Phase 1 (Types & config) + ├── 1.1 Module augmentation + ├── 1.2 Expand InputSource + ├── 1.3 CookieOptions type + ├── 1.4 ServerConfig.cookies + └── 1.5 Tests + +Phase 2 (Parser integration) + ├── 2.1 Load & attach in server.ts + └── 2.2 Tests + +Phase 3 (Input plumbing) + ├── 3.1 getInput handles new sources + └── 3.2 Tests + +Phase 4 (Cookie middleware) + ├── 4.1 cookieMiddleware singleton + ├── 4.2 Export from index.ts + └── 4.3 Tests + +Phase 5 (OpenAPI docs) + ├── 5.1 Cookie location in depictRequestParams + └── 5.2 Tests + +Phase 6 (Polish) + ├── 6.1 README + ├── 6.2 CHANGELOG + └── 6.3 (no migration needed) +``` diff --git a/express-zod-api/src/config-type.ts b/express-zod-api/src/config-type.ts index f9c6bcbf2..08f64876e 100644 --- a/express-zod-api/src/config-type.ts +++ b/express-zod-api/src/config-type.ts @@ -12,7 +12,13 @@ import type { GetLogger } from "./server-helpers"; export type InputSource = keyof Pick< Request, - "query" | "body" | "files" | "params" | "headers" + | "query" + | "body" + | "files" + | "params" + | "headers" + | "cookies" + | "signedCookies" >; export type InputSources = Record; @@ -123,6 +129,15 @@ type UploadOptions = Pick< beforeUpload?: BeforeUpload; }; +interface CookieParserOptions { + /** @desc The secret string or array used by cookie-parser for signed cookies */ + /** @default undefined (no signed cookies) */ + secret?: string | string[]; + /** @desc Custom decode function for cookie values */ + /** @default decodeURIComponent */ + decode?: (val: string) => string; +} + type CompressionOptions = Pick< compression.CompressionOptions, "threshold" | "level" | "strategy" | "chunkSize" | "memLevel" @@ -181,6 +196,13 @@ export interface ServerConfig extends CommonConfig { * @requires compression */ compression?: boolean | CompressionOptions; + /** + * @desc Enable cookie parsing via cookie-parser. + * @requires cookie-parser + * @example true + * @example { secret: "my-secret" } + */ + cookies?: boolean | CookieParserOptions; /** * @desc Configure or customize the parser for request query string * @example "simple" // for "node:querystring" module, array elements must be repeated: ?a=1&a=2 diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index 98b401b08..b443b88d9 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -223,6 +223,37 @@ describe("Common Helpers", () => { "x-request-id": "test", }); }); + + test("should include cookies when enabled in user-defined sources", () => { + const req = makeRequestMock({ method: "GET" }); + req.cookies = { session: "abc", theme: "dark" }; + expect(getInput(req, { get: ["query", "cookies"] })).toEqual({ + session: "abc", + theme: "dark", + }); + }); + + test("should include signedCookies when enabled in user-defined sources", () => { + const req = makeRequestMock({ method: "GET" }); + req.signedCookies = { session: "signed-abc" }; + expect(getInput(req, { get: ["query", "signedCookies"] })).toEqual({ + session: "signed-abc", + }); + }); + + test("should merge cookies and signedCookies with signed overriding unsigned", () => { + const req = makeRequestMock({ method: "GET" }); + req.cookies = { session: "unsigned", theme: "dark" }; + req.signedCookies = { session: "signed" }; + expect( + getInput(req, { get: ["query", "cookies", "signedCookies"] }), + ).toEqual({ session: "signed", theme: "dark" }); + }); + + test("should handle cookies being undefined gracefully", () => { + const req = makeRequestMock({ method: "GET" }); + expect(getInput(req, { get: ["query", "cookies"] })).toEqual({}); + }); }); describe("getMessageFromError()", () => { diff --git a/express-zod-api/tests/config-type.spec.ts b/express-zod-api/tests/config-type.spec.ts index 377708920..d9dd8b7ee 100644 --- a/express-zod-api/tests/config-type.spec.ts +++ b/express-zod-api/tests/config-type.spec.ts @@ -1,5 +1,6 @@ import type { Express, IRouter } from "express"; import { createConfig } from "../src"; +import type { InputSource, ServerConfig } from "../src/config-type"; describe("ConfigType", () => { describe("createConfig()", () => { @@ -39,4 +40,56 @@ describe("ConfigType", () => { expect(config).toEqual(argument); }); }); + + describe("InputSource", () => { + test("should include cookies and signedCookies", () => { + expectTypeOf().toEqualTypeOf< + | "query" + | "body" + | "files" + | "params" + | "headers" + | "cookies" + | "signedCookies" + >(); + }); + }); + + describe("ServerConfig cookies", () => { + test.each([true, false, undefined])("should accept boolean %s", (value) => { + const config: ServerConfig = { + cors: true, + cookies: value, + } as ServerConfig; + expect(config.cookies).toBe(value); + }); + + test("should accept CookieParserOptions with secret", () => { + const config: ServerConfig = { + cors: true, + cookies: { secret: "my-secret" }, + } as ServerConfig; + expect(config.cookies).toEqual({ secret: "my-secret" }); + }); + + test("should accept CookieParserOptions with decode", () => { + const decode = () => "decoded"; + const config: ServerConfig = { + cors: true, + cookies: { decode }, + } as ServerConfig; + expect(config.cookies).toEqual({ decode }); + }); + + test("should accept CookieParserOptions with both options", () => { + const config: ServerConfig = { + cors: true, + cookies: { secret: "s", decode: (v: string) => v }, + } as ServerConfig; + expect(config.cookies).toEqual({ + secret: "s", + decode: expect.any(Function), + }); + }); + }); }); From ac6340385598a16d2300bd13322b1b5a2bf3a555 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 18 May 2026 22:48:49 +0200 Subject: [PATCH 02/45] feat(server): add cookie-parser dynamic intergration. --- express-zod-api/src/server-helpers.ts | 15 +++++++ express-zod-api/src/server.ts | 2 + express-zod-api/tests/express-mock.ts | 3 ++ express-zod-api/tests/server.spec.ts | 65 +++++++++++++++++++++++++++ 4 files changed, 85 insertions(+) diff --git a/express-zod-api/src/server-helpers.ts b/express-zod-api/src/server-helpers.ts index 7be3b0812..36ad0d401 100644 --- a/express-zod-api/src/server-helpers.ts +++ b/express-zod-api/src/server-helpers.ts @@ -81,6 +81,21 @@ export const createUploadFailureHandler = next(); }; +export const createCookieParser = async ({ + config, +}: { + config: ServerConfig; +}): Promise => { + type CookieParser = ( + secret?: string | string[], + options?: { decode?: (val: string) => string }, + ) => RequestHandler; + const cookieParser = await loadPeer("cookie-parser"); + const settings = typeof config.cookies === "object" ? config.cookies : {}; + const { secret, decode } = settings; + return cookieParser(secret, decode ? { decode } : undefined); +}; + export const createUploadLogger = ( logger: ActualLogger, ): Pick => ({ log: logger.debug.bind(logger) }); diff --git a/express-zod-api/src/server.ts b/express-zod-api/src/server.ts index c74ba07bf..100984bf0 100644 --- a/express-zod-api/src/server.ts +++ b/express-zod-api/src/server.ts @@ -14,6 +14,7 @@ import { loadPeer } from "./peer-helpers"; import { defaultResultHandler } from "./result-handler"; import { initRouting, type Parsers, type Routing } from "./routing"; import { + createCookieParser, createLoggingMiddleware, createNotFoundHandler, createCatcher, @@ -78,6 +79,7 @@ export const createServer = async (config: ServerConfig, routing: Routing) => { ), ); } + if (config.cookies) app.use(await createCookieParser({ config })); await config.beforeRouting?.({ app, getLogger }); const parsers: Parsers = { diff --git a/express-zod-api/tests/express-mock.ts b/express-zod-api/tests/express-mock.ts index 1c0925853..afb6f20a0 100644 --- a/express-zod-api/tests/express-mock.ts +++ b/express-zod-api/tests/express-mock.ts @@ -3,9 +3,11 @@ const expressRawMock = vi.fn(); const expressUrlencodedMock = vi.fn(); const compressionMock = vi.fn(); const fileUploadMock = vi.fn(); +const cookieParserMock = vi.fn(() => vi.fn()); vi.mock("compression", () => ({ default: compressionMock })); vi.mock("express-fileupload", () => ({ default: fileUploadMock })); +vi.mock("cookie-parser", () => ({ default: cookieParserMock })); const staticHandler = vi.fn(); const staticMock = vi.fn(() => staticHandler); @@ -35,6 +37,7 @@ vi.mock("express", () => ({ default: expressMock })); export { compressionMock, fileUploadMock, + cookieParserMock, expressMock, appMock, expressJsonMock, diff --git a/express-zod-api/tests/server.spec.ts b/express-zod-api/tests/server.spec.ts index 07b80bd0a..9785c845e 100644 --- a/express-zod-api/tests/server.spec.ts +++ b/express-zod-api/tests/server.spec.ts @@ -3,6 +3,7 @@ import { givePort } from "../../tools/ports"; import { appMock, compressionMock, + cookieParserMock, expressJsonMock, expressUrlencodedMock, expressMock, @@ -293,6 +294,70 @@ describe("Server", () => { expect(compressionMock).toHaveBeenCalledWith(undefined); }); + test("should enable cookie parser when cookies is true", async () => { + const configMock = { + http: { listen: givePort() }, + cookies: true, + cors: true, + startupLogo: false, + logger: { level: "warn" }, + } satisfies ServerConfig; + const routingMock = { + v1: { + test: new EndpointsFactory(defaultResultHandler).build({ + output: z.object({}), + handler: vi.fn(), + }), + }, + }; + await createServer(configMock, routingMock); + expect(appMock.use).toHaveBeenCalledTimes(3); + expect(cookieParserMock).toHaveBeenCalledTimes(1); + expect(cookieParserMock).toHaveBeenCalledWith(undefined, undefined); + }); + + test("should enable cookie parser with secret", async () => { + const configMock = { + http: { listen: givePort() }, + cookies: { secret: "my-secret" }, + cors: true, + startupLogo: false, + logger: { level: "warn" }, + } satisfies ServerConfig; + const routingMock = { + v1: { + test: new EndpointsFactory(defaultResultHandler).build({ + output: z.object({}), + handler: vi.fn(), + }), + }, + }; + await createServer(configMock, routingMock); + expect(cookieParserMock).toHaveBeenCalledTimes(1); + expect(cookieParserMock).toHaveBeenCalledWith("my-secret", undefined); + }); + + test("should not register cookie parser when cookies is falsy", async () => { + const configMock = { + http: { listen: givePort() }, + cookies: false, + cors: true, + startupLogo: false, + logger: { level: "warn" }, + } satisfies ServerConfig; + const routingMock = { + v1: { + test: new EndpointsFactory(defaultResultHandler).build({ + output: z.object({}), + handler: vi.fn(), + }), + }, + }; + await createServer(configMock, routingMock); + expect(appMock.use).toHaveBeenCalledTimes(2); + expect(cookieParserMock).toHaveBeenCalledTimes(0); + }); + test("should enable uploads on request", async () => { const configMock = { http: { listen: givePort() }, From e1adad4ec09340cc23cad0d21cba244db0f9ec7c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 18 May 2026 22:54:01 +0200 Subject: [PATCH 03/45] feat(mw): Add cookieMiddleware (public). --- express-zod-api/src/cookie-middleware.ts | 13 +++ express-zod-api/src/index.ts | 1 + .../tests/__snapshots__/index.spec.ts.snap | 3 + .../tests/cookie-middleware.spec.ts | 87 +++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 express-zod-api/src/cookie-middleware.ts create mode 100644 express-zod-api/tests/cookie-middleware.spec.ts diff --git a/express-zod-api/src/cookie-middleware.ts b/express-zod-api/src/cookie-middleware.ts new file mode 100644 index 000000000..90e05a6aa --- /dev/null +++ b/express-zod-api/src/cookie-middleware.ts @@ -0,0 +1,13 @@ +import { Middleware } from "./middleware"; +import type { CookieOptions } from "express"; + +export const cookieMiddleware = new Middleware({ + handler: async ({ response }) => ({ + setCookie: (name: string, value: string, options?: CookieOptions) => { + response.cookie(name, value, options ?? {}); + }, + clearCookie: (name: string, options?: CookieOptions) => { + response.clearCookie(name, options ?? {}); + }, + }), +}); diff --git a/express-zod-api/src/index.ts b/express-zod-api/src/index.ts index 986cecf0c..9d15b10b6 100644 --- a/express-zod-api/src/index.ts +++ b/express-zod-api/src/index.ts @@ -8,6 +8,7 @@ export { getMessageFromError } from "./common-helpers"; export { ensureHttpError } from "./result-helpers"; export { BuiltinLogger } from "./builtin-logger"; export { Middleware } from "./middleware"; +export { cookieMiddleware } from "./cookie-middleware"; export { ResultHandler, defaultResultHandler, diff --git a/express-zod-api/tests/__snapshots__/index.spec.ts.snap b/express-zod-api/tests/__snapshots__/index.spec.ts.snap index d4e754fd4..9a77cc654 100644 --- a/express-zod-api/tests/__snapshots__/index.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/index.spec.ts.snap @@ -39,6 +39,8 @@ exports[`Index Entrypoint > exports > arrayResultHandler should have certain val exports[`Index Entrypoint > exports > attachRouting should have certain value 1`] = `[Function]`; +exports[`Index Entrypoint > exports > cookieMiddleware should have certain value 1`] = `Middleware {}`; + exports[`Index Entrypoint > exports > createConfig should have certain value 1`] = `[Function]`; exports[`Index Entrypoint > exports > createServer should have certain value 1`] = `[Function]`; @@ -80,6 +82,7 @@ exports[`Index Entrypoint > exports > should have certain entities exposed 1`] = "ensureHttpError", "BuiltinLogger", "Middleware", + "cookieMiddleware", "ResultHandler", "defaultResultHandler", "arrayResultHandler", diff --git a/express-zod-api/tests/cookie-middleware.spec.ts b/express-zod-api/tests/cookie-middleware.spec.ts new file mode 100644 index 000000000..794ad4e7c --- /dev/null +++ b/express-zod-api/tests/cookie-middleware.spec.ts @@ -0,0 +1,87 @@ +import { cookieMiddleware } from "../src"; +import { + makeLoggerMock, + makeRequestMock, + makeResponseMock, +} from "../src/testing"; + +describe("Cookie middleware", () => { + describe("cookieMiddleware", () => { + test("should be an instance of Middleware", () => { + const { constructor } = Object.getPrototypeOf(cookieMiddleware); + expect(constructor.name).toBe("Middleware"); + }); + + test("should return setCookie and clearCookie in context", async () => { + const logger = makeLoggerMock(); + const request = makeRequestMock(); + const response = makeResponseMock(); + const ctx = await cookieMiddleware.execute({ + input: {}, + ctx: {}, + logger, + request, + response, + }); + expect(ctx).toHaveProperty("setCookie"); + expect(typeof ctx.setCookie).toBe("function"); + expect(ctx).toHaveProperty("clearCookie"); + expect(typeof ctx.clearCookie).toBe("function"); + }); + + test("setCookie should delegate to response.cookie", async () => { + const logger = makeLoggerMock(); + const request = makeRequestMock(); + const response = makeResponseMock(); + const spy = vi.spyOn(response, "cookie"); + const ctx = await cookieMiddleware.execute({ + input: {}, + ctx: {}, + logger, + request, + response, + }); + ctx.setCookie("session", "abc123", { httpOnly: true, path: "/" }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith("session", "abc123", { + httpOnly: true, + path: "/", + }); + }); + + test("clearCookie should delegate to response.clearCookie", async () => { + const logger = makeLoggerMock(); + const request = makeRequestMock(); + const response = makeResponseMock(); + const spy = vi.spyOn(response, "clearCookie"); + const ctx = await cookieMiddleware.execute({ + input: {}, + ctx: {}, + logger, + request, + response, + }); + ctx.clearCookie("session", { path: "/" }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toBe("session"); + expect(spy.mock.calls[0][1]).toMatchObject({ path: "/" }); + }); + + test("setCookie should work without options", async () => { + const logger = makeLoggerMock(); + const request = makeRequestMock(); + const response = makeResponseMock(); + const spy = vi.spyOn(response, "cookie"); + const ctx = await cookieMiddleware.execute({ + input: {}, + ctx: {}, + logger, + request, + response, + }); + ctx.setCookie("session", "abc123"); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith("session", "abc123", {}); + }); + }); +}); From ac3d1da919086b5dc103a2cf4dea0506c2d50d41 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 18 May 2026 22:58:11 +0200 Subject: [PATCH 04/45] feat(Documentation): add cookie request parameter depiction based on security schema. --- express-zod-api/src/documentation-helpers.ts | 8 ++ .../documentation-helpers.spec.ts.snap | 27 +++++ .../__snapshots__/documentation.spec.ts.snap | 113 ++++++++++++++++++ .../tests/documentation-helpers.spec.ts | 19 +++ express-zod-api/tests/documentation.spec.ts | 31 +++++ 5 files changed, 198 insertions(+) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index d334c6546..de7698acc 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -299,9 +299,17 @@ export const depictRequestParams = ({ R.filter((entry: Security) => entry.type === "header"), security ?? [], ).map(({ name }) => name); + const securityCookieNames = R.chain( + R.filter((entry: Security) => entry.type === "cookie"), + security ?? [], + ).map(({ name }) => name); + const areCookiesEnabled = + inputSources.includes("cookies") || inputSources.includes("signedCookies"); const getLocation = (name: string) => { if (areParamsEnabled && pathParams.includes(name)) return "path"; + if (areCookiesEnabled && securityCookieNames.includes(name)) + return "cookie"; if ( areHeadersEnabled && (isHeader?.(name, method, path) ?? defaultIsHeader(name, securityHeaders)) diff --git a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap index 621ca0865..f3d048d0d 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -315,6 +315,33 @@ exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: ] `; +exports[`Documentation helpers > depictRequestParams() > should depict cookie params when enabled via CookieSecurity 1`] = ` +[ + { + "deprecated": undefined, + "description": "GET /v1/user/:id Parameter", + "examples": undefined, + "in": "cookie", + "name": "session", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "deprecated": undefined, + "description": "GET /v1/user/:id Parameter", + "examples": undefined, + "in": "query", + "name": "page", + "required": true, + "schema": { + "type": "string", + }, + }, +] +`; + exports[`Documentation helpers > depictRequestParams() > should depict none if both query and params are disabled 1`] = `[]`; exports[`Documentation helpers > depictRequestParams() > should depict only path params if query is disabled 1`] = ` diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index fb1c9105f..4ae29d87a 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -1343,6 +1343,119 @@ servers: " `; +exports[`Documentation > Basic cases > should handle CookieSecurity in params and security section 1`] = ` +"openapi: 3.1.0 +info: + title: Testing CookieSecurity + version: 3.4.5 +paths: + /v1/getSomething: + get: + operationId: GetV1GetSomething + parameters: + - name: session + in: cookie + required: true + description: GET /v1/getSomething Parameter + schema: + type: string + - name: page + in: query + required: true + description: GET /v1/getSomething Parameter + schema: + type: number + security: + - APIKEY_1: [] + responses: + "200": + description: GET /v1/getSomething Positive response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: success + data: + type: object + properties: {} + additionalProperties: false + required: + - status + - data + additionalProperties: false + "400": + description: GET /v1/getSomething Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message + head: + operationId: HeadV1GetSomething + parameters: + - name: session + in: cookie + required: true + description: HEAD /v1/getSomething Parameter + schema: + type: string + - name: page + in: query + required: true + description: HEAD /v1/getSomething Parameter + schema: + type: number + security: + - APIKEY_1: [] + responses: + "200": + description: HEAD /v1/getSomething Positive response + "400": + description: HEAD /v1/getSomething Negative response +components: + schemas: {} + responses: {} + parameters: {} + examples: {} + requestBodies: {} + headers: {} + securitySchemes: + APIKEY_1: + type: apiKey + in: cookie + name: session + links: {} + callbacks: {} +tags: [] +servers: + - url: https://example.com +" +`; + exports[`Documentation > Basic cases > should handle bigint, boolean, date, null and readonly 1`] = ` "openapi: 3.1.0 info: diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 28d8c8862..79f9b26d4 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -469,6 +469,25 @@ describe("Documentation helpers", () => { }), ).toMatchSnapshot(); }); + + test("should depict cookie params when enabled via CookieSecurity", () => { + expect( + depictRequestParams({ + request: { + properties: { + session: { type: "string" }, + page: { type: "string" }, + }, + required: ["session", "page"], + type: "object", + }, + inputSources: ["query", "cookies", "params"], + composition: "inline", + security: [[{ type: "cookie", name: "session" }]], + ...requestCtx, + }), + ).toMatchSnapshot(); + }); }); describe("depictBody", () => { diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 2972721ad..10ce18192 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -581,6 +581,37 @@ describe("Documentation", () => { expect(spec).toMatchSnapshot(); }); + test("should handle CookieSecurity in params and security section", () => { + const mw = new Middleware({ + security: { type: "cookie", name: "session" }, + handler: vi.fn(), + }); + const spec = new Documentation({ + config: createConfig({ + cors: true, + logger: { level: "silent" }, + http: { listen: givePort() }, + inputSources: { get: ["query", "cookies"] }, + }), + routing: { + v1: { + getSomething: defaultEndpointsFactory.addMiddleware(mw).build({ + input: z.object({ + session: z.string(), + page: z.number(), + }), + output: z.object({}), + handler: async () => ({}), + }), + }, + }, + version: "3.4.5", + title: "Testing CookieSecurity", + serverUrl: "https://example.com", + }).getSpecAsYaml(); + expect(spec).toMatchSnapshot(); + }); + test("should ensure the uniq operation ids", () => { const spec = new Documentation({ config: sampleConfig, From 9493c14a5df44091ec9c1c216216dc59e2364f82 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 18 May 2026 23:04:13 +0200 Subject: [PATCH 05/45] feat(docs): Readme article draft and Changelog 28.1.0. --- CHANGELOG.md | 8 ++++++ README.md | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 826a45635..4df295ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Version 28 +### v28.1.0 + +- Added cookie support: + - Cookie parsing via `cookie-parser` can be enabled in `ServerConfig.cookies`; + - `"cookies"` and `"signedCookies"` can be used as input sources; + - `cookieMiddleware` exposes `setCookie` and `clearCookie` in middleware context; + - `CookieSecurity` schemas are depicted as `in: "cookie"` parameters in OpenAPI. + ### v28.0.1 - Adjusted the list of well-known headers, recognized by Documentation generator: diff --git a/README.md b/README.md index 23602bf46..ceb0f3314 100644 --- a/README.md +++ b/README.md @@ -818,6 +818,85 @@ factory.build({ }); ``` +## Cookies + +The framework supports cookie parsing and setting via an opt-in feature using `cookie-parser`. + +### Enabling cookie parsing + +Add `cookies` to your `ServerConfig`. This loads `cookie-parser` and attaches it as global middleware: + +```ts +import { createConfig } from "express-zod-api"; + +createConfig({ + // ... + cookies: { + secret: "my-secret", // optional, enables signed cookies + // decode: myDecode, // optional custom decode function + }, +}); +``` + +### Using cookies as input source + +Once parsing is enabled, add `"cookies"` and/or `"signedCookies"` to your input sources: + +```ts +createConfig({ + inputSources: { + get: ["query", "params", "cookies", "signedCookies"], + }, + cookies: { secret: "my-secret" }, +}); +``` + +For signed cookies to work, you must provide a `secret`. When both sources are enabled and a key exists in both, `signedCookies` takes priority. + +### Declaring cookie security schema + +Use `CookieSecurity` in your middleware to document cookie-based authentication: + +```ts +import { Middleware } from "express-zod-api"; +import { z } from "zod"; + +new Middleware({ + security: { type: "cookie", name: "session" }, + input: z.object({ session: z.string() }), + handler: async ({ input: { session } }) => { + // validate session + return { userId: "abc" }; + }, +}); +``` + +### Setting cookies via middleware + +The `cookieMiddleware` exposes `setCookie` and `clearCookie` in the middleware context: + +```ts +import { + cookieMiddleware, + EndpointsFactory, + defaultResultHandler, +} from "express-zod-api"; + +const factory = new EndpointsFactory(defaultResultHandler).addMiddleware( + cookieMiddleware, +); + +const setSession = factory.build({ + method: "post", + path: "/session", + output: z.object({ success: z.boolean() }), + handler: async ({ ctx: { setCookie } }) => { + setCookie("session", "abc123", { httpOnly: true, path: "/" }); + return { success: true }; + }, +}); +``` + ## Response customization `ResultHandler` is responsible for transmitting consistent responses containing the endpoint output or an error. From a229dcc25f1a04ebe7ded53b5256369b462e2b62 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 08:25:13 +0200 Subject: [PATCH 06/45] feat(deps): Add cookie parser and its types as optional peer dependencies. --- express-zod-api/package.json | 10 ++++++++++ pnpm-lock.yaml | 29 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/express-zod-api/package.json b/express-zod-api/package.json index 9f18210a4..9dd38dab0 100644 --- a/express-zod-api/package.json +++ b/express-zod-api/package.json @@ -49,10 +49,12 @@ "peerDependencies": { "@express-zod-api/zod-plugin": "workspace:^", "@types/compression": "^1.7.5", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/express-fileupload": "^1.5.0", "@types/http-errors": "^2.0.2", "compression": "^1.8.0", + "cookie-parser": "^1.4.7", "express": "^5.1.0", "express-fileupload": "^1.5.0", "http-errors": "^2.0.1", @@ -66,6 +68,9 @@ "@types/compression": { "optional": true }, + "@types/cookie-parser": { + "optional": true + }, "@types/express": { "optional": true }, @@ -78,6 +83,9 @@ "compression": { "optional": true }, + "cookie-parser": { + "optional": true + }, "express-fileupload": { "optional": true }, @@ -88,6 +96,7 @@ "devDependencies": { "@express-zod-api/zod-plugin": "workspace:^", "@types/compression": "catalog:dev", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/depd": "^1.1.37", "@types/express": "catalog:dev", @@ -97,6 +106,7 @@ "@types/ramda": "catalog:dev", "camelize-ts": "catalog:dev", "compression": "catalog:dev", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "depd": "^2.0.0", "express": "catalog:dev", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4367b994c..a9edadbf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,9 @@ importers: '@types/compression': specifier: catalog:dev version: 1.8.1 + '@types/cookie-parser': + specifier: ^1.4.10 + version: 1.4.10(@types/express@5.0.6) '@types/cors': specifier: ^2.8.19 version: 2.8.19 @@ -237,6 +240,9 @@ importers: compression: specifier: catalog:dev version: 1.8.1 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 cors: specifier: ^2.8.5 version: 2.8.6 @@ -590,6 +596,11 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie-parser@1.4.10': + resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==} + peerDependencies: + '@types/express': '*' + '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} @@ -915,6 +926,13 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -2145,6 +2163,10 @@ snapshots: dependencies: '@types/node': 25.7.0 + '@types/cookie-parser@1.4.10(@types/express@5.0.6)': + dependencies: + '@types/express': 5.0.6 + '@types/cors@2.8.19': dependencies: '@types/node': 25.7.0 @@ -2579,6 +2601,13 @@ snapshots: convert-source-map@2.0.0: {} + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} From e49fe00c836ae43e085beb9ade3da0be84074cd6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 08:31:06 +0200 Subject: [PATCH 07/45] fix(server): createCookieParser() to load the cookie-parser peer typed externally. --- express-zod-api/src/server-helpers.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/express-zod-api/src/server-helpers.ts b/express-zod-api/src/server-helpers.ts index 36ad0d401..6976e2e41 100644 --- a/express-zod-api/src/server-helpers.ts +++ b/express-zod-api/src/server-helpers.ts @@ -1,3 +1,4 @@ +import type cookieParser from "cookie-parser"; import type fileUpload from "express-fileupload"; import { loadPeer } from "./peer-helpers"; import type { AbstractResultHandler } from "./result-handler"; @@ -86,14 +87,10 @@ export const createCookieParser = async ({ }: { config: ServerConfig; }): Promise => { - type CookieParser = ( - secret?: string | string[], - options?: { decode?: (val: string) => string }, - ) => RequestHandler; - const cookieParser = await loadPeer("cookie-parser"); + const parser = await loadPeer("cookie-parser"); const settings = typeof config.cookies === "object" ? config.cookies : {}; - const { secret, decode } = settings; - return cookieParser(secret, decode ? { decode } : undefined); + const { secret, ...rest } = settings; + return parser(secret, Object.keys(rest).length ? rest : undefined); }; export const createUploadLogger = ( From 111807a89f80d2a709bafbbf197daf3b514aa6ed Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 09:01:42 +0200 Subject: [PATCH 08/45] fix(config): Reusing the cookie-parser types to describe options. --- express-zod-api/src/config-type.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/express-zod-api/src/config-type.ts b/express-zod-api/src/config-type.ts index 08f64876e..34141287f 100644 --- a/express-zod-api/src/config-type.ts +++ b/express-zod-api/src/config-type.ts @@ -1,6 +1,7 @@ import type compression from "compression"; import type { IRouter, Request, RequestHandler } from "express"; import type fileUpload from "express-fileupload"; +import type cookieParser from "cookie-parser"; import type { ServerOptions } from "node:https"; import type { BuiltinLoggerConfig } from "./builtin-logger"; import type { AbstractEndpoint } from "./endpoint"; @@ -129,13 +130,10 @@ type UploadOptions = Pick< beforeUpload?: BeforeUpload; }; -interface CookieParserOptions { +interface CookieParserOptions extends cookieParser.CookieParseOptions { /** @desc The secret string or array used by cookie-parser for signed cookies */ /** @default undefined (no signed cookies) */ - secret?: string | string[]; - /** @desc Custom decode function for cookie values */ - /** @default decodeURIComponent */ - decode?: (val: string) => string; + secret?: Parameters[0]; } type CompressionOptions = Pick< From eeeddc3fa1d4b656b8ae5df5929a2d7428196984 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 09:04:58 +0200 Subject: [PATCH 09/45] fix(mw): add jsdoc to cookieMiddleware. --- express-zod-api/src/cookie-middleware.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/express-zod-api/src/cookie-middleware.ts b/express-zod-api/src/cookie-middleware.ts index 90e05a6aa..cedf072ff 100644 --- a/express-zod-api/src/cookie-middleware.ts +++ b/express-zod-api/src/cookie-middleware.ts @@ -1,11 +1,14 @@ import { Middleware } from "./middleware"; import type { CookieOptions } from "express"; +/** @desc Middleware providing cookie-setting convenience methods. */ export const cookieMiddleware = new Middleware({ handler: async ({ response }) => ({ + /** @desc Sets a cookie on the response. */ setCookie: (name: string, value: string, options?: CookieOptions) => { response.cookie(name, value, options ?? {}); }, + /** @desc Clears a cookie on the response. */ clearCookie: (name: string, options?: CookieOptions) => { response.clearCookie(name, options ?? {}); }, From 4ca3be23deb13d555c49e322943d7500e0ce87e8 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 10:16:31 +0200 Subject: [PATCH 10/45] feat(mw): createCookieMiddleware. --- CHANGELOG.md | 2 +- README.md | 8 +- express-zod-api/src/cookie-middleware.ts | 33 ++++--- express-zod-api/src/index.ts | 2 +- .../tests/__snapshots__/index.spec.ts.snap | 6 +- .../tests/cookie-middleware.spec.ts | 96 ++++++++----------- 6 files changed, 68 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4df295ff2..730a0e0cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - Added cookie support: - Cookie parsing via `cookie-parser` can be enabled in `ServerConfig.cookies`; - `"cookies"` and `"signedCookies"` can be used as input sources; - - `cookieMiddleware` exposes `setCookie` and `clearCookie` in middleware context; + - `createCookieMiddleware(options)` creates a middleware that exposes `setCookie` and `clearCookie` with inherited base options; - `CookieSecurity` schemas are depicted as `in: "cookie"` parameters in OpenAPI. ### v28.0.1 diff --git a/README.md b/README.md index ceb0f3314..12e3204f0 100644 --- a/README.md +++ b/README.md @@ -873,17 +873,17 @@ new Middleware({ ### Setting cookies via middleware -The `cookieMiddleware` exposes `setCookie` and `clearCookie` in the middleware context: +The `createCookieMiddleware(options?)` creates a middleware that exposes `setCookie` and `clearCookie` in the context. Base options are applied to every call; per-call options override them: ```ts import { - cookieMiddleware, + createCookieMiddleware, EndpointsFactory, defaultResultHandler, } from "express-zod-api"; const factory = new EndpointsFactory(defaultResultHandler).addMiddleware( - cookieMiddleware, + createCookieMiddleware({ httpOnly: true, path: "/" }), ); const setSession = factory.build({ @@ -891,7 +891,7 @@ const setSession = factory.build({ path: "/session", output: z.object({ success: z.boolean() }), handler: async ({ ctx: { setCookie } }) => { - setCookie("session", "abc123", { httpOnly: true, path: "/" }); + setCookie("session", "abc123", { httpOnly: false }); // overrides base return { success: true }; }, }); diff --git a/express-zod-api/src/cookie-middleware.ts b/express-zod-api/src/cookie-middleware.ts index cedf072ff..5c2f44bfc 100644 --- a/express-zod-api/src/cookie-middleware.ts +++ b/express-zod-api/src/cookie-middleware.ts @@ -1,16 +1,23 @@ import { Middleware } from "./middleware"; import type { CookieOptions } from "express"; -/** @desc Middleware providing cookie-setting convenience methods. */ -export const cookieMiddleware = new Middleware({ - handler: async ({ response }) => ({ - /** @desc Sets a cookie on the response. */ - setCookie: (name: string, value: string, options?: CookieOptions) => { - response.cookie(name, value, options ?? {}); - }, - /** @desc Clears a cookie on the response. */ - clearCookie: (name: string, options?: CookieOptions) => { - response.clearCookie(name, options ?? {}); - }, - }), -}); +/** + * @desc Creates a middleware providing cookie-setting convenience methods. + * @param baseOptions — Default options applied to every setCookie / clearCookie call. + * @desc Per-call options are spread over base options, so you can override them individually. + * @example createCookieMiddleware() + * @example createCookieMiddleware({ httpOnly: true, secure: true, path: "/" }) + */ +export const createCookieMiddleware = (baseOptions?: CookieOptions) => + new Middleware({ + handler: async ({ response }) => ({ + /** @desc Sets a cookie on the response. */ + setCookie: (name: string, value: string, options?: CookieOptions) => { + response.cookie(name, value, { ...baseOptions, ...options }); + }, + /** @desc Clears a cookie on the response. */ + clearCookie: (name: string, options?: CookieOptions) => { + response.clearCookie(name, { ...baseOptions, ...options }); + }, + }), + }); diff --git a/express-zod-api/src/index.ts b/express-zod-api/src/index.ts index 9d15b10b6..21f5f627b 100644 --- a/express-zod-api/src/index.ts +++ b/express-zod-api/src/index.ts @@ -8,7 +8,7 @@ export { getMessageFromError } from "./common-helpers"; export { ensureHttpError } from "./result-helpers"; export { BuiltinLogger } from "./builtin-logger"; export { Middleware } from "./middleware"; -export { cookieMiddleware } from "./cookie-middleware"; +export { createCookieMiddleware } from "./cookie-middleware"; export { ResultHandler, defaultResultHandler, diff --git a/express-zod-api/tests/__snapshots__/index.spec.ts.snap b/express-zod-api/tests/__snapshots__/index.spec.ts.snap index 9a77cc654..a60e24351 100644 --- a/express-zod-api/tests/__snapshots__/index.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/index.spec.ts.snap @@ -39,10 +39,10 @@ exports[`Index Entrypoint > exports > arrayResultHandler should have certain val exports[`Index Entrypoint > exports > attachRouting should have certain value 1`] = `[Function]`; -exports[`Index Entrypoint > exports > cookieMiddleware should have certain value 1`] = `Middleware {}`; - exports[`Index Entrypoint > exports > createConfig should have certain value 1`] = `[Function]`; +exports[`Index Entrypoint > exports > createCookieMiddleware should have certain value 1`] = `[Function]`; + exports[`Index Entrypoint > exports > createServer should have certain value 1`] = `[Function]`; exports[`Index Entrypoint > exports > defaultEndpointsFactory should have certain value 1`] = ` @@ -82,7 +82,7 @@ exports[`Index Entrypoint > exports > should have certain entities exposed 1`] = "ensureHttpError", "BuiltinLogger", "Middleware", - "cookieMiddleware", + "createCookieMiddleware", "ResultHandler", "defaultResultHandler", "arrayResultHandler", diff --git a/express-zod-api/tests/cookie-middleware.spec.ts b/express-zod-api/tests/cookie-middleware.spec.ts index 794ad4e7c..4d75d13d9 100644 --- a/express-zod-api/tests/cookie-middleware.spec.ts +++ b/express-zod-api/tests/cookie-middleware.spec.ts @@ -1,87 +1,69 @@ -import { cookieMiddleware } from "../src"; +import { createCookieMiddleware } from "../src"; import { makeLoggerMock, makeRequestMock, makeResponseMock, } from "../src/testing"; +const execute = async (baseOptions?: import("express").CookieOptions) => { + const middleware = createCookieMiddleware(baseOptions); + const logger = makeLoggerMock(); + const request = makeRequestMock(); + const response = makeResponseMock(); + const ctx = await middleware.execute({ + input: {}, + ctx: {}, + logger, + request, + response, + }); + return { ctx, response }; +}; + describe("Cookie middleware", () => { - describe("cookieMiddleware", () => { - test("should be an instance of Middleware", () => { - const { constructor } = Object.getPrototypeOf(cookieMiddleware); + describe("createCookieMiddleware", () => { + test("should create a Middleware instance", () => { + const middleware = createCookieMiddleware(); + const { constructor } = Object.getPrototypeOf(middleware); expect(constructor.name).toBe("Middleware"); }); test("should return setCookie and clearCookie in context", async () => { - const logger = makeLoggerMock(); - const request = makeRequestMock(); - const response = makeResponseMock(); - const ctx = await cookieMiddleware.execute({ - input: {}, - ctx: {}, - logger, - request, - response, - }); + const { ctx } = await execute(); expect(ctx).toHaveProperty("setCookie"); expect(typeof ctx.setCookie).toBe("function"); expect(ctx).toHaveProperty("clearCookie"); expect(typeof ctx.clearCookie).toBe("function"); }); - test("setCookie should delegate to response.cookie", async () => { - const logger = makeLoggerMock(); - const request = makeRequestMock(); - const response = makeResponseMock(); + test("setCookie without base options should pass empty object fallback", async () => { + const { ctx, response } = await execute(); const spy = vi.spyOn(response, "cookie"); - const ctx = await cookieMiddleware.execute({ - input: {}, - ctx: {}, - logger, - request, - response, + ctx.setCookie("session", "abc123"); + expect(spy).toHaveBeenCalledWith("session", "abc123", {}); + }); + + test("setCookie should merge per-call options over base options", async () => { + const { ctx, response } = await execute({ + httpOnly: true, + secure: true, + path: "/", }); - ctx.setCookie("session", "abc123", { httpOnly: true, path: "/" }); - expect(spy).toHaveBeenCalledTimes(1); + const spy = vi.spyOn(response, "cookie"); + ctx.setCookie("session", "abc123", { httpOnly: false }); expect(spy).toHaveBeenCalledWith("session", "abc123", { - httpOnly: true, + httpOnly: false, + secure: true, path: "/", }); }); - test("clearCookie should delegate to response.clearCookie", async () => { - const logger = makeLoggerMock(); - const request = makeRequestMock(); - const response = makeResponseMock(); + test("clearCookie should use base options", async () => { + const { ctx, response } = await execute({ path: "/" }); const spy = vi.spyOn(response, "clearCookie"); - const ctx = await cookieMiddleware.execute({ - input: {}, - ctx: {}, - logger, - request, - response, - }); - ctx.clearCookie("session", { path: "/" }); - expect(spy).toHaveBeenCalledTimes(1); + ctx.clearCookie("session"); expect(spy.mock.calls[0][0]).toBe("session"); expect(spy.mock.calls[0][1]).toMatchObject({ path: "/" }); }); - - test("setCookie should work without options", async () => { - const logger = makeLoggerMock(); - const request = makeRequestMock(); - const response = makeResponseMock(); - const spy = vi.spyOn(response, "cookie"); - const ctx = await cookieMiddleware.execute({ - input: {}, - ctx: {}, - logger, - request, - response, - }); - ctx.setCookie("session", "abc123"); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith("session", "abc123", {}); - }); }); }); From 519dedefe2fd7a78e0900747543f218f72e0f731 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 10:19:13 +0200 Subject: [PATCH 11/45] fix(jsdoc): minor. --- express-zod-api/src/cookie-middleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/cookie-middleware.ts b/express-zod-api/src/cookie-middleware.ts index 5c2f44bfc..115a46c07 100644 --- a/express-zod-api/src/cookie-middleware.ts +++ b/express-zod-api/src/cookie-middleware.ts @@ -2,7 +2,7 @@ import { Middleware } from "./middleware"; import type { CookieOptions } from "express"; /** - * @desc Creates a middleware providing cookie-setting convenience methods. + * @desc Creates a Middleware providing cookie-setting convenience methods. * @param baseOptions — Default options applied to every setCookie / clearCookie call. * @desc Per-call options are spread over base options, so you can override them individually. * @example createCookieMiddleware() From 34d19bbb43c97c1cfac7607fa5868be348812ec2 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 10:35:13 +0200 Subject: [PATCH 12/45] ref(server): shortening createCookieParser. --- express-zod-api/src/server-helpers.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/express-zod-api/src/server-helpers.ts b/express-zod-api/src/server-helpers.ts index 6976e2e41..e2125c2b8 100644 --- a/express-zod-api/src/server-helpers.ts +++ b/express-zod-api/src/server-helpers.ts @@ -88,8 +88,9 @@ export const createCookieParser = async ({ config: ServerConfig; }): Promise => { const parser = await loadPeer("cookie-parser"); - const settings = typeof config.cookies === "object" ? config.cookies : {}; - const { secret, ...rest } = settings; + const { secret, ...rest } = { + ...(typeof config.cookies === "object" && config.cookies), + }; return parser(secret, Object.keys(rest).length ? rest : undefined); }; From 0eb0fb105e47bed9981ccc5ae4678f93ae81643f Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 10:39:40 +0200 Subject: [PATCH 13/45] fix(test): placing cookies into makeRequestMock. --- express-zod-api/tests/common-helpers.spec.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index b443b88d9..a12259f7f 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -225,8 +225,10 @@ describe("Common Helpers", () => { }); test("should include cookies when enabled in user-defined sources", () => { - const req = makeRequestMock({ method: "GET" }); - req.cookies = { session: "abc", theme: "dark" }; + const req = makeRequestMock({ + method: "GET", + cookies: { session: "abc", theme: "dark" }, + }); expect(getInput(req, { get: ["query", "cookies"] })).toEqual({ session: "abc", theme: "dark", @@ -234,17 +236,21 @@ describe("Common Helpers", () => { }); test("should include signedCookies when enabled in user-defined sources", () => { - const req = makeRequestMock({ method: "GET" }); - req.signedCookies = { session: "signed-abc" }; + const req = makeRequestMock({ + method: "GET", + signedCookies: { session: "signed-abc" }, + }); expect(getInput(req, { get: ["query", "signedCookies"] })).toEqual({ session: "signed-abc", }); }); test("should merge cookies and signedCookies with signed overriding unsigned", () => { - const req = makeRequestMock({ method: "GET" }); - req.cookies = { session: "unsigned", theme: "dark" }; - req.signedCookies = { session: "signed" }; + const req = makeRequestMock({ + method: "GET", + cookies: { session: "unsigned", theme: "dark" }, + signedCookies: { session: "signed" }, + }); expect( getInput(req, { get: ["query", "cookies", "signedCookies"] }), ).toEqual({ session: "signed", theme: "dark" }); From b61fb218ef301a68539b92e735016031378ae9af Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 10:45:05 +0200 Subject: [PATCH 14/45] fix(test): shortening for getInput(). --- express-zod-api/tests/common-helpers.spec.ts | 44 +++++++------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index a12259f7f..1e3f914ec 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -224,41 +224,29 @@ describe("Common Helpers", () => { }); }); - test("should include cookies when enabled in user-defined sources", () => { - const req = makeRequestMock({ - method: "GET", + test.each([ + { cookies: { session: "abc", theme: "dark" }, - }); - expect(getInput(req, { get: ["query", "cookies"] })).toEqual({ - session: "abc", - theme: "dark", - }); - }); - - test("should include signedCookies when enabled in user-defined sources", () => { - const req = makeRequestMock({ - method: "GET", + }, + { signedCookies: { session: "signed-abc" }, - }); - expect(getInput(req, { get: ["query", "signedCookies"] })).toEqual({ - session: "signed-abc", - }); - }); - - test("should merge cookies and signedCookies with signed overriding unsigned", () => { - const req = makeRequestMock({ - method: "GET", + }, + { cookies: { session: "unsigned", theme: "dark" }, signedCookies: { session: "signed" }, + }, + {}, // graceful handling expected + ])("should include cookies when enabled %#", (props) => { + const req = makeRequestMock({ + method: "GET", + ...props, }); expect( getInput(req, { get: ["query", "cookies", "signedCookies"] }), - ).toEqual({ session: "signed", theme: "dark" }); - }); - - test("should handle cookies being undefined gracefully", () => { - const req = makeRequestMock({ method: "GET" }); - expect(getInput(req, { get: ["query", "cookies"] })).toEqual({}); + ).toEqual({ + ...props.cookies, + ...props.signedCookies, + }); }); }); From 6e3bdb1619f43c97d6bc705174fa78bc39eb5f8b Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 10:50:52 +0200 Subject: [PATCH 15/45] fix(test): naming. --- express-zod-api/tests/config-type.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/tests/config-type.spec.ts b/express-zod-api/tests/config-type.spec.ts index d9dd8b7ee..c4674193f 100644 --- a/express-zod-api/tests/config-type.spec.ts +++ b/express-zod-api/tests/config-type.spec.ts @@ -42,7 +42,7 @@ describe("ConfigType", () => { }); describe("InputSource", () => { - test("should include cookies and signedCookies", () => { + test("should list the selected properties of Request", () => { expectTypeOf().toEqualTypeOf< | "query" | "body" From 9b910a9132ef1662707d9dd8b350fef29620dc42 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 10:53:21 +0200 Subject: [PATCH 16/45] rm(test): redundant in config type. --- express-zod-api/tests/config-type.spec.ts | 40 +---------------------- 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/express-zod-api/tests/config-type.spec.ts b/express-zod-api/tests/config-type.spec.ts index c4674193f..2bba30907 100644 --- a/express-zod-api/tests/config-type.spec.ts +++ b/express-zod-api/tests/config-type.spec.ts @@ -1,6 +1,6 @@ import type { Express, IRouter } from "express"; import { createConfig } from "../src"; -import type { InputSource, ServerConfig } from "../src/config-type"; +import type { InputSource } from "../src/config-type"; describe("ConfigType", () => { describe("createConfig()", () => { @@ -54,42 +54,4 @@ describe("ConfigType", () => { >(); }); }); - - describe("ServerConfig cookies", () => { - test.each([true, false, undefined])("should accept boolean %s", (value) => { - const config: ServerConfig = { - cors: true, - cookies: value, - } as ServerConfig; - expect(config.cookies).toBe(value); - }); - - test("should accept CookieParserOptions with secret", () => { - const config: ServerConfig = { - cors: true, - cookies: { secret: "my-secret" }, - } as ServerConfig; - expect(config.cookies).toEqual({ secret: "my-secret" }); - }); - - test("should accept CookieParserOptions with decode", () => { - const decode = () => "decoded"; - const config: ServerConfig = { - cors: true, - cookies: { decode }, - } as ServerConfig; - expect(config.cookies).toEqual({ decode }); - }); - - test("should accept CookieParserOptions with both options", () => { - const config: ServerConfig = { - cors: true, - cookies: { secret: "s", decode: (v: string) => v }, - } as ServerConfig; - expect(config.cookies).toEqual({ - secret: "s", - decode: expect.any(Function), - }); - }); - }); }); From fbb196ff4bc9337f31071fdd5cdfc7dda36446ca Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 11:43:32 +0200 Subject: [PATCH 17/45] fix(test): using testMiddleware for createCookieMiddleware. --- .../tests/cookie-middleware.spec.ts | 91 +++++++------------ 1 file changed, 33 insertions(+), 58 deletions(-) diff --git a/express-zod-api/tests/cookie-middleware.spec.ts b/express-zod-api/tests/cookie-middleware.spec.ts index 4d75d13d9..8d063f909 100644 --- a/express-zod-api/tests/cookie-middleware.spec.ts +++ b/express-zod-api/tests/cookie-middleware.spec.ts @@ -1,24 +1,5 @@ -import { createCookieMiddleware } from "../src"; -import { - makeLoggerMock, - makeRequestMock, - makeResponseMock, -} from "../src/testing"; - -const execute = async (baseOptions?: import("express").CookieOptions) => { - const middleware = createCookieMiddleware(baseOptions); - const logger = makeLoggerMock(); - const request = makeRequestMock(); - const response = makeResponseMock(); - const ctx = await middleware.execute({ - input: {}, - ctx: {}, - logger, - request, - response, - }); - return { ctx, response }; -}; +import { createCookieMiddleware, testMiddleware } from "../src"; +import { expect } from "vitest"; describe("Cookie middleware", () => { describe("createCookieMiddleware", () => { @@ -28,42 +9,36 @@ describe("Cookie middleware", () => { expect(constructor.name).toBe("Middleware"); }); - test("should return setCookie and clearCookie in context", async () => { - const { ctx } = await execute(); - expect(ctx).toHaveProperty("setCookie"); - expect(typeof ctx.setCookie).toBe("function"); - expect(ctx).toHaveProperty("clearCookie"); - expect(typeof ctx.clearCookie).toBe("function"); - }); - - test("setCookie without base options should pass empty object fallback", async () => { - const { ctx, response } = await execute(); - const spy = vi.spyOn(response, "cookie"); - ctx.setCookie("session", "abc123"); - expect(spy).toHaveBeenCalledWith("session", "abc123", {}); - }); - - test("setCookie should merge per-call options over base options", async () => { - const { ctx, response } = await execute({ - httpOnly: true, - secure: true, - path: "/", - }); - const spy = vi.spyOn(response, "cookie"); - ctx.setCookie("session", "abc123", { httpOnly: false }); - expect(spy).toHaveBeenCalledWith("session", "abc123", { - httpOnly: false, - secure: true, - path: "/", - }); - }); - - test("clearCookie should use base options", async () => { - const { ctx, response } = await execute({ path: "/" }); - const spy = vi.spyOn(response, "clearCookie"); - ctx.clearCookie("session"); - expect(spy.mock.calls[0][0]).toBe("session"); - expect(spy.mock.calls[0][1]).toMatchObject({ path: "/" }); - }); + test.each([undefined, { httpOnly: true, secure: true, path: "/" }])( + "should return setCookie and clearCookie helpers %#", + async (baseOptions) => { + const { output, responseMock } = await testMiddleware({ + middleware: createCookieMiddleware(baseOptions), + }); + const { setCookie, clearCookie } = output as { + setCookie: (typeof responseMock)["cookie"]; + clearCookie: (typeof responseMock)["clearCookie"]; + }; + expect(typeof setCookie).toBe("function"); + expect(typeof clearCookie).toBe("function"); + setCookie("session", "abc123", { httpOnly: false }); + expect(responseMock.cookies).toHaveProperty("session", { + options: { ...baseOptions, httpOnly: false }, + value: "abc123", + }); + clearCookie("session"); + expect(responseMock.cookies).toHaveProperty("session", { + options: { + ...baseOptions, + path: "/", + expires: expect.any(Date), // unstable + }, + value: "", + }); + expect( + Number(responseMock.cookies["session"].options.expires), + ).toBeLessThan(10); // usually 1 + }, + ); }); }); From f94a48d0d300c854eef1c03e4a2308d04a2c20a3 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 12:25:31 +0200 Subject: [PATCH 18/45] fix(test): simpler test for server parser. --- express-zod-api/tests/server.spec.ts | 90 +++++++++------------------- 1 file changed, 27 insertions(+), 63 deletions(-) diff --git a/express-zod-api/tests/server.spec.ts b/express-zod-api/tests/server.spec.ts index 9785c845e..9676e3cf1 100644 --- a/express-zod-api/tests/server.spec.ts +++ b/express-zod-api/tests/server.spec.ts @@ -294,69 +294,33 @@ describe("Server", () => { expect(compressionMock).toHaveBeenCalledWith(undefined); }); - test("should enable cookie parser when cookies is true", async () => { - const configMock = { - http: { listen: givePort() }, - cookies: true, - cors: true, - startupLogo: false, - logger: { level: "warn" }, - } satisfies ServerConfig; - const routingMock = { - v1: { - test: new EndpointsFactory(defaultResultHandler).build({ - output: z.object({}), - handler: vi.fn(), - }), - }, - }; - await createServer(configMock, routingMock); - expect(appMock.use).toHaveBeenCalledTimes(3); - expect(cookieParserMock).toHaveBeenCalledTimes(1); - expect(cookieParserMock).toHaveBeenCalledWith(undefined, undefined); - }); - - test("should enable cookie parser with secret", async () => { - const configMock = { - http: { listen: givePort() }, - cookies: { secret: "my-secret" }, - cors: true, - startupLogo: false, - logger: { level: "warn" }, - } satisfies ServerConfig; - const routingMock = { - v1: { - test: new EndpointsFactory(defaultResultHandler).build({ - output: z.object({}), - handler: vi.fn(), - }), - }, - }; - await createServer(configMock, routingMock); - expect(cookieParserMock).toHaveBeenCalledTimes(1); - expect(cookieParserMock).toHaveBeenCalledWith("my-secret", undefined); - }); - - test("should not register cookie parser when cookies is falsy", async () => { - const configMock = { - http: { listen: givePort() }, - cookies: false, - cors: true, - startupLogo: false, - logger: { level: "warn" }, - } satisfies ServerConfig; - const routingMock = { - v1: { - test: new EndpointsFactory(defaultResultHandler).build({ - output: z.object({}), - handler: vi.fn(), - }), - }, - }; - await createServer(configMock, routingMock); - expect(appMock.use).toHaveBeenCalledTimes(2); - expect(cookieParserMock).toHaveBeenCalledTimes(0); - }); + test.each([true, { secret: "my-secret" }])( + "should enable cookie parser on demand %#", + async (cookies) => { + const configMock = { + http: { listen: givePort() }, + cookies, + cors: true, + startupLogo: false, + logger: { level: "warn" }, + } satisfies ServerConfig; + const routingMock = { + v1: { + test: new EndpointsFactory(defaultResultHandler).build({ + output: z.object({}), + handler: vi.fn(), + }), + }, + }; + await createServer(configMock, routingMock); + expect(appMock.use).toHaveBeenCalledTimes(3); + expect(cookieParserMock).toHaveBeenCalledTimes(1); + expect(cookieParserMock).toHaveBeenCalledWith( + typeof cookies === "object" ? cookies.secret : undefined, + undefined, + ); + }, + ); test("should enable uploads on request", async () => { const configMock = { From d164e26af44fcc40eef0494b3ffd6d2493bd4351 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 12:28:13 +0200 Subject: [PATCH 19/45] fix(test): rm redundant endpoints from server test. --- express-zod-api/tests/server.spec.ts | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/express-zod-api/tests/server.spec.ts b/express-zod-api/tests/server.spec.ts index 9676e3cf1..81b35c89f 100644 --- a/express-zod-api/tests/server.spec.ts +++ b/express-zod-api/tests/server.spec.ts @@ -280,15 +280,7 @@ describe("Server", () => { startupLogo: false, logger: { level: "warn" }, } satisfies ServerConfig; - const routingMock = { - v1: { - test: new EndpointsFactory(defaultResultHandler).build({ - output: z.object({}), - handler: vi.fn(), - }), - }, - }; - await createServer(configMock, routingMock); + await createServer(configMock, {}); expect(appMock.use).toHaveBeenCalledTimes(3); expect(compressionMock).toHaveBeenCalledTimes(1); expect(compressionMock).toHaveBeenCalledWith(undefined); @@ -304,15 +296,7 @@ describe("Server", () => { startupLogo: false, logger: { level: "warn" }, } satisfies ServerConfig; - const routingMock = { - v1: { - test: new EndpointsFactory(defaultResultHandler).build({ - output: z.object({}), - handler: vi.fn(), - }), - }, - }; - await createServer(configMock, routingMock); + await createServer(configMock, {}); expect(appMock.use).toHaveBeenCalledTimes(3); expect(cookieParserMock).toHaveBeenCalledTimes(1); expect(cookieParserMock).toHaveBeenCalledWith( From 2f9fb536cc35aae64454314346fa413437038639 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 12:29:31 +0200 Subject: [PATCH 20/45] fix(test): simpler mock. --- express-zod-api/tests/express-mock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/tests/express-mock.ts b/express-zod-api/tests/express-mock.ts index afb6f20a0..9fa62774b 100644 --- a/express-zod-api/tests/express-mock.ts +++ b/express-zod-api/tests/express-mock.ts @@ -3,7 +3,7 @@ const expressRawMock = vi.fn(); const expressUrlencodedMock = vi.fn(); const compressionMock = vi.fn(); const fileUploadMock = vi.fn(); -const cookieParserMock = vi.fn(() => vi.fn()); +const cookieParserMock = vi.fn(); vi.mock("compression", () => ({ default: compressionMock })); vi.mock("express-fileupload", () => ({ default: fileUploadMock })); From 6b10f171c413c9f217919575bfdce5045fa21c14 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 12:31:25 +0200 Subject: [PATCH 21/45] fix(config): rm second jsdoc. --- express-zod-api/src/config-type.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/express-zod-api/src/config-type.ts b/express-zod-api/src/config-type.ts index 34141287f..e3c87bfa2 100644 --- a/express-zod-api/src/config-type.ts +++ b/express-zod-api/src/config-type.ts @@ -132,7 +132,6 @@ type UploadOptions = Pick< interface CookieParserOptions extends cookieParser.CookieParseOptions { /** @desc The secret string or array used by cookie-parser for signed cookies */ - /** @default undefined (no signed cookies) */ secret?: Parameters[0]; } From d43a7da3f407aea0eb9d257983bc765e85504e68 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 13:06:24 +0200 Subject: [PATCH 22/45] fix(test): shortening documentation test. --- express-zod-api/tests/documentation.spec.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 10ce18192..e96dcced9 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -589,19 +589,16 @@ describe("Documentation", () => { const spec = new Documentation({ config: createConfig({ cors: true, - logger: { level: "silent" }, - http: { listen: givePort() }, inputSources: { get: ["query", "cookies"] }, }), routing: { v1: { - getSomething: defaultEndpointsFactory.addMiddleware(mw).build({ + getSomething: defaultEndpointsFactory.addMiddleware(mw).buildVoid({ input: z.object({ session: z.string(), page: z.number(), }), - output: z.object({}), - handler: async () => ({}), + handler: vi.fn(), }), }, }, From 75715eec80d500197506a89b09e4fa556bf0ab48 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 15:18:12 +0200 Subject: [PATCH 23/45] feat: getSecurityNames() helper for extracting either cookies or headers from original schemas. --- express-zod-api/src/documentation-helpers.ts | 21 ++---- express-zod-api/src/documentation.ts | 7 +- express-zod-api/src/security.ts | 27 ++++++++ .../tests/documentation-helpers.spec.ts | 6 +- express-zod-api/tests/security.spec.ts | 64 +++++++++++++++++++ 5 files changed, 105 insertions(+), 20 deletions(-) create mode 100644 express-zod-api/tests/security.spec.ts diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index de7698acc..c2f9e0ce6 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -266,9 +266,9 @@ const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined => export const defaultIsHeader = ( name: string, - familiar?: string[], + familiar?: Set, ): name is `x-${string}` => - familiar?.includes(name) || + familiar?.has(name) || name.startsWith("x-") || getWellKnownHeaders().has(name); @@ -280,7 +280,8 @@ export const depictRequestParams = ({ makeRef, composition, isHeader, - security, + securityHeaders, + securityCookies, description = `${method.toUpperCase()} ${path} Parameter`, }: ReqResCommons & { composition: "inline" | "components"; @@ -288,28 +289,20 @@ export const depictRequestParams = ({ request: z.core.JSONSchema.BaseSchema; inputSources: InputSource[]; isHeader?: IsHeader; - security?: Alternatives; + securityHeaders?: Set; + securityCookies?: Set; }) => { const flat = flattenIO(request); const pathParams = getRoutePathParams(path); const isQueryEnabled = inputSources.includes("query"); const areParamsEnabled = inputSources.includes("params"); const areHeadersEnabled = inputSources.includes("headers"); - const securityHeaders = R.chain( - R.filter((entry: Security) => entry.type === "header"), - security ?? [], - ).map(({ name }) => name); - const securityCookieNames = R.chain( - R.filter((entry: Security) => entry.type === "cookie"), - security ?? [], - ).map(({ name }) => name); const areCookiesEnabled = inputSources.includes("cookies") || inputSources.includes("signedCookies"); const getLocation = (name: string) => { if (areParamsEnabled && pathParams.includes(name)) return "path"; - if (areCookiesEnabled && securityCookieNames.includes(name)) - return "cookie"; + if (areCookiesEnabled && securityCookies?.has(name)) return "cookie"; if ( areHeadersEnabled && (isHeader?.(name, method, path) ?? defaultIsHeader(name, securityHeaders)) diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index f1a8bb5a8..6e0fde9e4 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -15,6 +15,7 @@ import { getInputSources, makeCleanId } from "./common-helpers"; import type { CommonConfig } from "./config-type"; import { processContainers } from "./logical-container"; import type { ClientMethod } from "./method"; +import { getSecurityNames } from "./security"; import { depictBody, depictRequestParams, @@ -202,12 +203,12 @@ export class Documentation extends OpenApiBuilder { ); const request = depictRequest({ ...commons, schema: inputSchema }); - const security = processContainers(endpoint.security); const depictedParams = depictRequestParams({ ...commons, inputSources, isHeader, - security, + securityHeaders: getSecurityNames(endpoint.security, "header"), + securityCookies: getSecurityNames(endpoint.security, "cookie"), request, description: descriptions?.requestParameter?.({ method, @@ -256,7 +257,7 @@ export class Documentation extends OpenApiBuilder { : undefined; const securityRefs = depictSecurityRefs( - depictSecurity(security, inputSources), + depictSecurity(processContainers(endpoint.security), inputSources), scopes, (securitySchema) => { const name = this.#ensureUniqSecuritySchemaName(securitySchema); diff --git a/express-zod-api/src/security.ts b/express-zod-api/src/security.ts index 6bb64e549..ab150debb 100644 --- a/express-zod-api/src/security.ts +++ b/express-zod-api/src/security.ts @@ -1,3 +1,10 @@ +import * as R from "ramda"; +import { + isLogicalAnd, + isLogicalOr, + type LogicalContainer, +} from "./logical-container"; + export interface BasicSecurity { type: "basic"; } @@ -93,3 +100,23 @@ export type Security = | CookieSecurity | OpenIdSecurity | OAuth2Security; + +type NamedSecurityType = Extract["type"]; + +const pickNames = ( + container: LogicalContainer, + type: NamedSecurityType, +): string[] => { + if (isLogicalAnd(container)) + return R.chain((one) => pickNames(one, type), container.and); + if (isLogicalOr(container)) + return R.chain((one) => pickNames(one, type), container.or); + if (container.type === type) return [container.name]; + return []; +}; + +/** @desc Extract security names of a given type from logical containers without generating combinations */ +export const getSecurityNames = ( + containers: LogicalContainer[], + type: NamedSecurityType, +): Set => new Set(R.chain((c) => pickNames(c, type), containers)); diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 79f9b26d4..091b11090 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -368,7 +368,7 @@ describe("Documentation helpers", () => { { name: "authorization", expected: true }, { name: "secure", - familiar: ["secure"], + familiar: new Set(["secure"]), expected: true, }, { name: "unknown", expected: false }, @@ -464,7 +464,7 @@ describe("Documentation helpers", () => { }, inputSources: ["query", "headers", "params"], composition: "inline", - security: [[{ type: "header", name: "secure" }]], + securityHeaders: new Set(["secure"]), ...requestCtx, }), ).toMatchSnapshot(); @@ -483,7 +483,7 @@ describe("Documentation helpers", () => { }, inputSources: ["query", "cookies", "params"], composition: "inline", - security: [[{ type: "cookie", name: "session" }]], + securityCookies: new Set(["session"]), ...requestCtx, }), ).toMatchSnapshot(); diff --git a/express-zod-api/tests/security.spec.ts b/express-zod-api/tests/security.spec.ts new file mode 100644 index 000000000..9a7e7f291 --- /dev/null +++ b/express-zod-api/tests/security.spec.ts @@ -0,0 +1,64 @@ +import { getSecurityNames } from "../src/security"; + +describe("Security", () => { + describe("getSecurityNames", () => { + test("should return empty set for empty containers", () => { + expect(getSecurityNames([], "header")).toHaveProperty("size", 0); + }); + + test.each([ + { type: "basic" as const }, + { and: [{ type: "basic" as const }, { type: "bearer" as const }] }, + ])( + "should return empty set for containers without matching type %#", + (container) => { + expect(getSecurityNames([container], "header")).toHaveProperty( + "size", + 0, + ); + }, + ); + + test.each([ + { type: "header" as const, name: "test" }, + { type: "cookie" as const, name: "test" }, + { type: "input" as const, name: "test" }, + ])("should extract names from $type container", (container) => { + expect(Array.from(getSecurityNames([container], container.type))).toEqual( + ["test"], + ); + }); + + test.each([ + { + and: [ + { type: "header" as const, name: "x-auth" }, + { type: "header" as const, name: "x-token" }, + ], + }, + { + or: [ + { type: "header" as const, name: "x-auth" }, + { type: "header" as const, name: "x-token" }, + ], + }, + { + and: [ + { type: "header" as const, name: "x-auth" }, + { + or: [ + { type: "header" as const, name: "x-token" }, + { type: "basic" as const }, + { type: "cookie" as const, name: "session" }, + ], + }, + ], + }, + ])("should extract names from nested container %#", (container) => { + expect(Array.from(getSecurityNames([container], "header"))).toEqual([ + "x-auth", + "x-token", + ]); + }); + }); +}); From 76d776837162d5900f9172922fa0805e1254f8c7 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 15:21:50 +0200 Subject: [PATCH 24/45] minor: naming. --- express-zod-api/src/security.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/security.ts b/express-zod-api/src/security.ts index ab150debb..955891f56 100644 --- a/express-zod-api/src/security.ts +++ b/express-zod-api/src/security.ts @@ -119,4 +119,4 @@ const pickNames = ( export const getSecurityNames = ( containers: LogicalContainer[], type: NamedSecurityType, -): Set => new Set(R.chain((c) => pickNames(c, type), containers)); +): Set => new Set(R.chain((one) => pickNames(one, type), containers)); From 7075c9b230d02a3681fa5477764e88bb2e7321cb Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 15:42:09 +0200 Subject: [PATCH 25/45] fix(docs): Readme shortening. --- README.md | 72 ++++++++++++++----------------------------------------- 1 file changed, 18 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 12e3204f0..4d4649f94 100644 --- a/README.md +++ b/README.md @@ -818,82 +818,46 @@ factory.build({ }); ``` -## Cookies +## Cookies as an input source -The framework supports cookie parsing and setting via an opt-in feature using `cookie-parser`. - -### Enabling cookie parsing - -Add `cookies` to your `ServerConfig`. This loads `cookie-parser` and attaches it as global middleware: +Install `cookie-parser` as well as `@types/cookie-parser`, enable `cookies` in your config, add `"cookies"` and/or +`"signedCookies"` to your `inputSources`: ```ts import { createConfig } from "express-zod-api"; -createConfig({ - // ... - cookies: { - secret: "my-secret", // optional, enables signed cookies - // decode: myDecode, // optional custom decode function +const config = createConfig({ + cookies: { secret: "my-secret" }, // or true; the secret enables signedCookies + inputSources: { + get: ["query", "params", "cookies", "signedCookies"], // for methods of your choice }, }); ``` -### Using cookies as input source - -Once parsing is enabled, add `"cookies"` and/or `"signedCookies"` to your input sources: +Consider `createCookieMiddleware()` that makes a Middleware providing `setCookie()` and `clearCookie()` helpers: ```ts -createConfig({ - inputSources: { - get: ["query", "params", "cookies", "signedCookies"], +import { createCookieMiddleware } from "express-zod-api"; + +const cookieAssistingFactory = factory.addMiddleware( + createCookieMiddleware({ httpOnly: true, path: "/" }), // base options +); + +const sessionSettingEndpoint = cookieAssistingFactory.buildVoid({ + handler: async ({ ctx: { setCookie } }) => { + setCookie("session", "abc123", { httpOnly: false }); // overriden options }, - cookies: { secret: "my-secret" }, }); ``` -For signed cookies to work, you must provide a `secret`. When both sources are enabled and a key exists in both, `signedCookies` takes priority. - -### Declaring cookie security schema - -Use `CookieSecurity` in your middleware to document cookie-based authentication: +When handling cookies in a Middleware, declare its security to improve [Documentation](#creating-documentation): ```ts import { Middleware } from "express-zod-api"; -import { z } from "zod"; new Middleware({ security: { type: "cookie", name: "session" }, input: z.object({ session: z.string() }), - handler: async ({ input: { session } }) => { - // validate session - return { userId: "abc" }; - }, -}); -``` - -### Setting cookies via middleware - -The `createCookieMiddleware(options?)` creates a middleware that exposes `setCookie` and `clearCookie` in the context. Base options are applied to every call; per-call options override them: - -```ts -import { - createCookieMiddleware, - EndpointsFactory, - defaultResultHandler, -} from "express-zod-api"; - -const factory = new EndpointsFactory(defaultResultHandler).addMiddleware( - createCookieMiddleware({ httpOnly: true, path: "/" }), -); - -const setSession = factory.build({ - method: "post", - path: "/session", - output: z.object({ success: z.boolean() }), - handler: async ({ ctx: { setCookie } }) => { - setCookie("session", "abc123", { httpOnly: false }); // overrides base - return { success: true }; - }, }); ``` From 566032d2c2eb37c8a93f8dcb3667120e5c44f76b Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 15:42:59 +0200 Subject: [PATCH 26/45] fix(docs): upd index. --- README.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4d4649f94..9df6e426a 100644 --- a/README.md +++ b/README.md @@ -34,17 +34,18 @@ Start your API server with I/O schema validation and custom middlewares in minut 15. [Child logger](#child-logger) 5. [Advanced features](#advanced-features) 1. [Customizing input sources](#customizing-input-sources) - 2. [Headers as input source](#headers-as-an-input-source) - 3. [Response customization](#response-customization) - 4. [Empty response](#empty-response) - 5. [Non-JSON response](#non-json-response) including file downloads - 6. [Error handling](#error-handling) - 7. [Production mode](#production-mode) - 8. [HTML Forms (URL encoded)](#html-forms-url-encoded) - 9. [File uploads](#file-uploads) - 10. [Connect to your own express app](#connect-to-your-own-express-app) - 11. [Testing endpoints](#testing-endpoints) - 12. [Testing middlewares](#testing-middlewares) + 2. [Headers as an input source](#headers-as-an-input-source) + 3. [Cookies as an input source](#cookies-as-an-input-source) + 4. [Response customization](#response-customization) + 5. [Empty response](#empty-response) + 6. [Non-JSON response](#non-json-response) including file downloads + 7. [Error handling](#error-handling) + 8. [Production mode](#production-mode) + 9. [HTML Forms (URL encoded)](#html-forms-url-encoded) + 10. [File uploads](#file-uploads) + 11. [Connect to your own express app](#connect-to-your-own-express-app) + 12. [Testing endpoints](#testing-endpoints) + 13. [Testing middlewares](#testing-middlewares) 6. [Integration and Documentation](#integration-and-documentation) 1. [Zod Plugin](#zod-plugin) 2. [End-to-End Type Safety](#end-to-end-type-safety) From 326b419bf398449a6e0e731ee1d6ea29f662f657 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 15:49:44 +0200 Subject: [PATCH 27/45] Changelog: 28.1.0. --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 730a0e0cf..079d1e44a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ ### v28.1.0 -- Added cookie support: - - Cookie parsing via `cookie-parser` can be enabled in `ServerConfig.cookies`; - - `"cookies"` and `"signedCookies"` can be used as input sources; - - `createCookieMiddleware(options)` creates a middleware that exposes `setCookie` and `clearCookie` with inherited base options; - - `CookieSecurity` schemas are depicted as `in: "cookie"` parameters in OpenAPI. +- Added support for cookie handling: + - Cookie parsing can be enabled and configured in config (requires to install `cookie-parser`); + - `cookies` and `signedCookies` can be used as `inputSources` in config; + - `createCookieMiddleware()` creates a middleware that exposes `setCookie()` and `clearCookie()` helpers into context; + - Documentation depicts request parameters when Middleware has `security` schema with `type: cookie`. ### v28.0.1 From 3f8168fb48ac375845867b236aac92004b28e6c1 Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 19 May 2026 16:00:19 +0200 Subject: [PATCH 28/45] Add cookies to dataflow diagram --- dataflow.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dataflow.svg b/dataflow.svg index 72886fee4..cc9b506d0 100644 --- a/dataflow.svg +++ b/dataflow.svg @@ -1,4 +1,4 @@ -
Endpoint
Endpoint
context
context
input
schema
input...
output
schema
output...
handler
handler
Middleware N
Middleware N
context
context
handler
handler
input
schema
input...
   Middleware 1
   Middleware 1
handler
handler
input
schema
input...
Request
Request
.query
.query
.body
.body
ResultHandler
ResultHandler
Response
Response
error
error
GET & DELETE
GET & DELETE
PUT & PATCH
PUT & PATCH
.files
.files
POST
POST
.params
.params
.method
.method
.headers
.headers
 opt-in
 opt-in
Text is not SVG - cannot display
+
Endpoint
Endpoint
context
context
input
schema
input...
output
schema
output...
handler
handler
Middleware N
Middleware N
context
context
handler
handler
input
schema
input...
   Middleware 1
   Middleware 1
handler
handler
input
schema
input...
Request
Request
.query
.query
.body
.body
ResultHandler
ResultHandler
Response
Response
error
error
GET & DELETE
GET & DELETE
PUT & PATCH
PUT & PATCH
.files
.files
POST
POST
.params
.params
.method
.method
.headers
.headers
 opt-in
 opt-in
.cookies
.cookies
 opt-in
 opt-in
Text is not SVG - cannot display
\ No newline at end of file From cbfc6f97c97b5b4b214ce66dfa3dfeea49025ac0 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 16:05:15 +0200 Subject: [PATCH 29/45] fix(docs): transparent edges of the diagram. --- dataflow.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dataflow.svg b/dataflow.svg index cc9b506d0..cd1fb5042 100644 --- a/dataflow.svg +++ b/dataflow.svg @@ -1,4 +1,4 @@ -
Endpoint
Endpoint
context
context
input
schema
input...
output
schema
output...
handler
handler
Middleware N
Middleware N
context
context
handler
handler
input
schema
input...
   Middleware 1
   Middleware 1
handler
handler
input
schema
input...
Request
Request
.query
.query
.body
.body
ResultHandler
ResultHandler
Response
Response
error
error
GET & DELETE
GET & DELETE
PUT & PATCH
PUT & PATCH
.files
.files
POST
POST
.params
.params
.method
.method
.headers
.headers
 opt-in
 opt-in
.cookies
.cookies
 opt-in
 opt-in
Text is not SVG - cannot display
\ No newline at end of file +
Endpoint
Endpoint
context
context
input
schema
input...
output
schema
output...
handler
handler
Middleware N
Middleware N
context
context
handler
handler
input
schema
input...
   Middleware 1
   Middleware 1
handler
handler
input
schema
input...
Request
Request
.query
.query
.body
.body
ResultHandler
ResultHandler
Response
Response
error
error
GET & DELETE
GET & DELETE
PUT & PATCH
PUT & PATCH
.files
.files
POST
POST
.params
.params
.method
.method
.headers
.headers
 opt-in
 opt-in
.cookies
.cookies
 opt-in
 opt-in
Text is not SVG - cannot display
\ No newline at end of file From 3838f1ca2fa3a8352c69f88f18c95cd9350e42c6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 17:10:39 +0200 Subject: [PATCH 30/45] rm the plan. --- cookie-support-plan.md | 273 ----------------------------------------- 1 file changed, 273 deletions(-) delete mode 100644 cookie-support-plan.md diff --git a/cookie-support-plan.md b/cookie-support-plan.md deleted file mode 100644 index 999b3b7bc..000000000 --- a/cookie-support-plan.md +++ /dev/null @@ -1,273 +0,0 @@ -# Cookie Support — Implementation Plan - -Integrate cookie parsing (`cookie-parser`), cookie input sources, a public cookie-setting middleware, and OpenAPI cookie parameter depiction into express-zod-api. - ---- - -## Phase 1: Configuration & Types - -### 1.1 Expand `InputSource` Union - -`@types/express-serve-static-core` already declares `cookies: any` and `signedCookies: any` on `Request`, so they're available for `keyof Pick` without any augmentation. - -```typescript -export type InputSource = keyof Pick< - Request, - | "query" - | "body" - | "files" - | "params" - | "headers" - | "cookies" - | "signedCookies" ->; -``` - -### 1.3 Add Cookie Parser Config Type - -`CookieOptions` already exists in `@types/express-serve-static-core` (for `res.cookie()`). For cookie-parser config, use a distinct name: - -```typescript -interface CookieParserOptions { - /** @desc The secret string or array used by cookie-parser for signed cookies */ - /** @default undefined (no signed cookies) */ - secret?: string | string[]; - /** @desc Custom decode function for cookie values */ - /** @default decodeURIComponent */ - decode?: (val: string) => string; -} -``` - -### 1.4 Add `cookies` to `ServerConfig` - -```typescript -export interface ServerConfig extends CommonConfig { - // ... existing properties ... - - /** - * @desc Enable cookie parsing via cookie-parser - * @requires cookie-parser - * @example true - * @example { secret: "my-secret" } - */ - cookies?: boolean | CookieParserOptions; -} -``` - -### 1.5 Tests - -- **`config-type.spec.ts`**: Verify `CookieOptions` shape, `ServerConfig.cookies` accepts `boolean | CookieOptions`, and `InputSource` includes `"cookies"` and `"signedCookies"`. -- **`common-helpers.spec.ts`**: Verify `getInputSources` recognizes the new sources and `getInput` merges `req.cookies`/`req.signedCookies` when present. - ---- - -## Phase 2: Cookie Parser Integration - -### 2.1 Create Cookie Parser in `server.ts` - -Following the `compression` pattern (line 73–80), load `cookie-parser` via `loadPeer` and attach it as global middleware. Place it after compression, before `beforeRouting`: - -```typescript -if (config.cookies) { - const cookieParser = await loadPeer("cookie-parser"); - const settings = typeof config.cookies === "object" ? config.cookies : {}; - const { secret, decode } = settings; - app.use(cookieParser(secret, decode ? { decode } : undefined)); -} -``` - -This means cookie-parser runs on every request when the feature is enabled. - -### 2.2 Tests - -- **`server.spec.ts`**: Verify cookie parser middleware is registered when `config.cookies` is truthy and absent when falsy. -- **`server-helpers.spec.ts`**: Verify `loadPeer` behavior for `cookie-parser`. - ---- - -## Phase 3: Input Source Plumbing - -### 3.1 Update `getInput` in `common-helpers.ts` - -No special filtering needed — unlike `"files"` (which checks `areFilesAvailable`), `req.cookies` and `req.signedCookies` are either populated by cookie-parser or `undefined`. `Object.assign` handles `undefined` gracefully. - -The existing code already handles this correctly since it does: - -```typescript -.reduce((agg, src) => Object.assign(agg, req[src]), {}); -``` - -The `"cookies"` and `"signedCookies"` sources need no guards added to the `.filter()` chain. - -### 3.2 Default Input Sources - -`defaultInputSources` stays unchanged — cookies are opt-in: - -```typescript -// User enables cookies per-method: -createConfig({ - inputSources: { get: ["query", "params", "cookies", "signedCookies"] }, - cookies: { secret: "my-secret" }, -}); -``` - -### 3.3 Tests - -- **`common-helpers.spec.ts`**: Verify merged input order (later source overrides earlier when keys collide between `cookies` and `signedCookies`). - ---- - -## Phase 4: Public Cookie-Setting Middleware - -### 4.1 New File: `express-zod-api/src/cookie-middleware.ts` - -A singleton `Middleware` instance that exposes `setCookie` and `clearCookie` directly into context — no wrapper class needed since middlewares already have access to `response`: - -```typescript -import { Middleware } from "./middleware"; -import type { CookieOptions } from "express"; // already available from @types/express-serve-static-core - -export const cookieMiddleware = new Middleware({ - handler: async ({ response }) => ({ - setCookie: (name: string, value: string, options?: CookieOptions) => { - response.cookie(name, value, options); - }, - clearCookie: ( - name: string, - options?: Pick, - ) => { - response.clearCookie(name, options); - }, - }), -}); -``` - -Usage: - -```typescript -import { cookieMiddleware } from "express-zod-api"; - -const factory = new EndpointsFactory(defaultResultHandler).addMiddleware( - cookieMiddleware, -); - -const setSession = factory.build({ - method: "post", - path: "/session", - output: z.object({ success: z.boolean() }), - handler: async ({ ctx: { setCookie, clearCookie } }) => { - setCookie("session", "abc123", { httpOnly: true, path: "/" }); - return { success: true }; - }, -}); -``` - -### 4.2 Export in `index.ts` - -```typescript -export { cookieMiddleware } from "./cookie-middleware"; -``` - -### 4.3 Tests - -- **`cookie-middleware.spec.ts`**: Verify `setCookie` delegates to `response.cookie()`, `clearCookie` delegates to `response.clearCookie()`, and `cookieMiddleware` provides both functions in context. - ---- - -## Phase 5: OpenAPI Documentation - -### 5.1 Add `"cookie"` Location in `depictRequestParams` - -Extract cookie security names from the security schemas: - -```typescript -const securityCookieNames = R.chain( - R.filter((entry: Security) => entry.type === "cookie"), - security ?? [], -).map(({ name }) => name); -``` - -Add `areCookiesEnabled` flag: - -```typescript -const areCookiesEnabled = - inputSources.includes("cookies") || inputSources.includes("signedCookies"); -``` - -Update `getLocation` priority: **path → cookie → header → query** - -```typescript -const getLocation = (name: string) => { - if (areParamsEnabled && pathParams.includes(name)) return "path"; - if (areCookiesEnabled && securityCookieNames.includes(name)) return "cookie"; - if ( - areHeadersEnabled && - (isHeader?.(name, method, path) ?? defaultIsHeader(name, securityHeaders)) - ) - return "header"; - if (isQueryEnabled) return "query"; -}; -``` - -Cookie is checked before header to avoid misclassification when a property name matches both a cookie and header security scheme (unlikely but safe). - -### 5.2 Tests - -- **`documentation-helpers.spec.ts`**: Test cookie parameter depiction — properties matching `CookieSecurity` names are placed `in: "cookie"`, others fall through to header/query. -- **`documentation.spec.ts`**: Full integration test — `Documentation` class produces correct OpenAPI output with cookie parameters and security schemes. - ---- - -## Phase 6: Polish & Documentation - -### 6.1 README Update - -Add a "Cookies" section covering: - -- Enabling cookie parsing via `config.cookies` -- Adding `"cookies"`/`"signedCookies"` to `inputSources` -- Using `cookieMiddleware` for setting cookies -- Cookie security schemas for OpenAPI - -### 6.2 CHANGELOG - -Note the new feature in upcoming version under `Added:`. - -### 6.3 Migration - -No migration rule needed — no breaking changes to existing public API types. - ---- - -## Implementation Order Summary - -``` -Phase 1 (Types & config) - ├── 1.1 Module augmentation - ├── 1.2 Expand InputSource - ├── 1.3 CookieOptions type - ├── 1.4 ServerConfig.cookies - └── 1.5 Tests - -Phase 2 (Parser integration) - ├── 2.1 Load & attach in server.ts - └── 2.2 Tests - -Phase 3 (Input plumbing) - ├── 3.1 getInput handles new sources - └── 3.2 Tests - -Phase 4 (Cookie middleware) - ├── 4.1 cookieMiddleware singleton - ├── 4.2 Export from index.ts - └── 4.3 Tests - -Phase 5 (OpenAPI docs) - ├── 5.1 Cookie location in depictRequestParams - └── 5.2 Tests - -Phase 6 (Polish) - ├── 6.1 README - ├── 6.2 CHANGELOG - └── 6.3 (no migration needed) -``` From 68561e0d2913d720a73394536ddae72bd5d7ab66 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 18:37:56 +0200 Subject: [PATCH 31/45] fix(mw): rm double jsdoc and renaming overriding options more clearly. --- express-zod-api/src/cookie-middleware.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/express-zod-api/src/cookie-middleware.ts b/express-zod-api/src/cookie-middleware.ts index 115a46c07..9bed9dee9 100644 --- a/express-zod-api/src/cookie-middleware.ts +++ b/express-zod-api/src/cookie-middleware.ts @@ -4,7 +4,6 @@ import type { CookieOptions } from "express"; /** * @desc Creates a Middleware providing cookie-setting convenience methods. * @param baseOptions — Default options applied to every setCookie / clearCookie call. - * @desc Per-call options are spread over base options, so you can override them individually. * @example createCookieMiddleware() * @example createCookieMiddleware({ httpOnly: true, secure: true, path: "/" }) */ @@ -12,12 +11,12 @@ export const createCookieMiddleware = (baseOptions?: CookieOptions) => new Middleware({ handler: async ({ response }) => ({ /** @desc Sets a cookie on the response. */ - setCookie: (name: string, value: string, options?: CookieOptions) => { - response.cookie(name, value, { ...baseOptions, ...options }); + setCookie: (name: string, value: string, overrides?: CookieOptions) => { + response.cookie(name, value, { ...baseOptions, ...overrides }); }, /** @desc Clears a cookie on the response. */ - clearCookie: (name: string, options?: CookieOptions) => { - response.clearCookie(name, { ...baseOptions, ...options }); + clearCookie: (name: string, overrides?: CookieOptions) => { + response.clearCookie(name, { ...baseOptions, ...overrides }); }, }), }); From 9d00043a270832c99875c6635f9210cfb68d6658 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 19:03:06 +0200 Subject: [PATCH 32/45] feat(mw): support object-based values on setCookie. --- express-zod-api/src/cookie-middleware.ts | 9 +++++++-- express-zod-api/tests/cookie-middleware.spec.ts | 13 +++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/express-zod-api/src/cookie-middleware.ts b/express-zod-api/src/cookie-middleware.ts index 9bed9dee9..d037015cb 100644 --- a/express-zod-api/src/cookie-middleware.ts +++ b/express-zod-api/src/cookie-middleware.ts @@ -1,5 +1,6 @@ import { Middleware } from "./middleware"; import type { CookieOptions } from "express"; +import type { FlatObject } from "./common-helpers"; /** * @desc Creates a Middleware providing cookie-setting convenience methods. @@ -10,8 +11,12 @@ import type { CookieOptions } from "express"; export const createCookieMiddleware = (baseOptions?: CookieOptions) => new Middleware({ handler: async ({ response }) => ({ - /** @desc Sets a cookie on the response. */ - setCookie: (name: string, value: string, overrides?: CookieOptions) => { + /** @desc Sets a cookie on the response. Express converts object values to JSON. */ + setCookie: ( + name: string, + value: string | FlatObject, + overrides?: CookieOptions, + ) => { response.cookie(name, value, { ...baseOptions, ...overrides }); }, /** @desc Clears a cookie on the response. */ diff --git a/express-zod-api/tests/cookie-middleware.spec.ts b/express-zod-api/tests/cookie-middleware.spec.ts index 8d063f909..7f9c9f00d 100644 --- a/express-zod-api/tests/cookie-middleware.spec.ts +++ b/express-zod-api/tests/cookie-middleware.spec.ts @@ -16,8 +16,12 @@ describe("Cookie middleware", () => { middleware: createCookieMiddleware(baseOptions), }); const { setCookie, clearCookie } = output as { - setCookie: (typeof responseMock)["cookie"]; - clearCookie: (typeof responseMock)["clearCookie"]; + setCookie: Awaited< + ReturnType["execute"]> + >["setCookie"]; + clearCookie: Awaited< + ReturnType["execute"]> + >["clearCookie"]; }; expect(typeof setCookie).toBe("function"); expect(typeof clearCookie).toBe("function"); @@ -26,6 +30,11 @@ describe("Cookie middleware", () => { options: { ...baseOptions, httpOnly: false }, value: "abc123", }); + setCookie("prefs", { theme: "dark", lang: "en" }); + expect(responseMock.cookies).toHaveProperty("prefs", { + value: { theme: "dark", lang: "en" }, + options: baseOptions ?? {}, + }); clearCookie("session"); expect(responseMock.cookies).toHaveProperty("session", { options: { From baab13577dd81cbc7ad39b8dc27549b81e45629c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 19:09:33 +0200 Subject: [PATCH 33/45] fix(mw): resuing Zod's JSONType. --- express-zod-api/src/cookie-middleware.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/express-zod-api/src/cookie-middleware.ts b/express-zod-api/src/cookie-middleware.ts index d037015cb..cfe10e824 100644 --- a/express-zod-api/src/cookie-middleware.ts +++ b/express-zod-api/src/cookie-middleware.ts @@ -1,6 +1,6 @@ import { Middleware } from "./middleware"; import type { CookieOptions } from "express"; -import type { FlatObject } from "./common-helpers"; +import type { z } from "zod"; /** * @desc Creates a Middleware providing cookie-setting convenience methods. @@ -11,10 +11,10 @@ import type { FlatObject } from "./common-helpers"; export const createCookieMiddleware = (baseOptions?: CookieOptions) => new Middleware({ handler: async ({ response }) => ({ - /** @desc Sets a cookie on the response. Express converts object values to JSON. */ + /** @desc Sets a cookie on the response. Express converts non-string values to JSON. */ setCookie: ( name: string, - value: string | FlatObject, + value: string | z.core.util.JSONType, overrides?: CookieOptions, ) => { response.cookie(name, value, { ...baseOptions, ...overrides }); From 29a5367ff9a67e756f031c7475eca5761512e626 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 19 May 2026 19:11:58 +0200 Subject: [PATCH 34/45] rm redundant example. --- express-zod-api/src/cookie-middleware.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/express-zod-api/src/cookie-middleware.ts b/express-zod-api/src/cookie-middleware.ts index cfe10e824..cbff9e558 100644 --- a/express-zod-api/src/cookie-middleware.ts +++ b/express-zod-api/src/cookie-middleware.ts @@ -5,7 +5,6 @@ import type { z } from "zod"; /** * @desc Creates a Middleware providing cookie-setting convenience methods. * @param baseOptions — Default options applied to every setCookie / clearCookie call. - * @example createCookieMiddleware() * @example createCookieMiddleware({ httpOnly: true, secure: true, path: "/" }) */ export const createCookieMiddleware = (baseOptions?: CookieOptions) => From d7ef9e3fdc2bce5a65f215b016bf16c26a9f3ee0 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 20 May 2026 06:54:47 +0200 Subject: [PATCH 35/45] feat: getCookie, alternative flexible approach, versatility reflecting readme. --- CHANGELOG.md | 3 +- README.md | 48 +++++++++---------- express-zod-api/src/cookie-middleware.ts | 9 +++- .../tests/cookie-middleware.spec.ts | 18 +++---- 4 files changed, 44 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 079d1e44a..7063a718d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ - Added support for cookie handling: - Cookie parsing can be enabled and configured in config (requires to install `cookie-parser`); - `cookies` and `signedCookies` can be used as `inputSources` in config; - - `createCookieMiddleware()` creates a middleware that exposes `setCookie()` and `clearCookie()` helpers into context; + - `createCookieMiddleware()` creates a Middleware that exposes `setCookie()` and `clearCookie()` helpers into context + as well as the `getCookie()` one as an alternative to using cookies withint `inputSources`; - Documentation depicts request parameters when Middleware has `security` schema with `type: cookie`. ### v28.0.1 diff --git a/README.md b/README.md index 9df6e426a..9a4bb6ebe 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Start your API server with I/O schema validation and custom middlewares in minut 5. [Advanced features](#advanced-features) 1. [Customizing input sources](#customizing-input-sources) 2. [Headers as an input source](#headers-as-an-input-source) - 3. [Cookies as an input source](#cookies-as-an-input-source) + 3. [Cookies](#cookies) 4. [Response customization](#response-customization) 5. [Empty response](#empty-response) 6. [Non-JSON response](#non-json-response) including file downloads @@ -819,10 +819,10 @@ factory.build({ }); ``` -## Cookies as an input source +## Cookies -Install `cookie-parser` as well as `@types/cookie-parser`, enable `cookies` in your config, add `"cookies"` and/or -`"signedCookies"` to your `inputSources`: +Install `cookie-parser` as well as `@types/cookie-parser` and enable `cookies` in your config. To validate cookies add +`"cookies"` and/or `"signedCookies"` to your `inputSources`: ```ts import { createConfig } from "express-zod-api"; @@ -835,33 +835,33 @@ const config = createConfig({ }); ``` -Consider `createCookieMiddleware()` that makes a Middleware providing `setCookie()` and `clearCookie()` helpers: +Consider `createCookieMiddleware()` that makes a Middleware providing `setCookie()` and `clearCookie()` helpers, +as well as `getCookie()` — alternative to the cookies as an input source: ```ts -import { createCookieMiddleware } from "express-zod-api"; - -const cookieAssistingFactory = factory.addMiddleware( - createCookieMiddleware({ httpOnly: true, path: "/" }), // base options -); +import { createCookieMiddleware, Middleware } from "express-zod-api"; + +const cookieDrivenFactory = factory + .addMiddleware( + createCookieMiddleware({ httpOnly: true, path: "/" }), // base options + ) + .addMiddleware( + new Middleware({ + security: { type: "cookie", name: "session" }, // improves Documentation + input: z.object({ session: z.string() }), // alternatively, use getCookie + handler: async ({ input: { session }, ctx: { getCookie } }) => { + assert.equal(session, getCookie("session")); // getCookie reads from signedCookies first + }, + }), + ); -const sessionSettingEndpoint = cookieAssistingFactory.buildVoid({ - handler: async ({ ctx: { setCookie } }) => { - setCookie("session", "abc123", { httpOnly: false }); // overriden options +const sessionSettingEndpoint = cookieDrivenFactory.buildVoid({ + handler: async ({ ctx: { getCookie, setCookie } }) => { + setCookie("session", "abc123", { httpOnly: false }); // overridden cookie options }, }); ``` -When handling cookies in a Middleware, declare its security to improve [Documentation](#creating-documentation): - -```ts -import { Middleware } from "express-zod-api"; - -new Middleware({ - security: { type: "cookie", name: "session" }, - input: z.object({ session: z.string() }), -}); -``` - ## Response customization `ResultHandler` is responsible for transmitting consistent responses containing the endpoint output or an error. diff --git a/express-zod-api/src/cookie-middleware.ts b/express-zod-api/src/cookie-middleware.ts index cbff9e558..9b5dc65ce 100644 --- a/express-zod-api/src/cookie-middleware.ts +++ b/express-zod-api/src/cookie-middleware.ts @@ -9,7 +9,14 @@ import type { z } from "zod"; */ export const createCookieMiddleware = (baseOptions?: CookieOptions) => new Middleware({ - handler: async ({ response }) => ({ + handler: async ({ request, response }) => ({ + /** + * @desc Reads a cookie value. Checks signedCookies first, then falls back to cookies. + * @requires cookie-parser + * @see ServerConfig.cookies + * */ + getCookie: (name: string): z.core.util.JSONType | undefined => + request.signedCookies?.[name] ?? request.cookies?.[name], /** @desc Sets a cookie on the response. Express converts non-string values to JSON. */ setCookie: ( name: string, diff --git a/express-zod-api/tests/cookie-middleware.spec.ts b/express-zod-api/tests/cookie-middleware.spec.ts index 7f9c9f00d..7e326c673 100644 --- a/express-zod-api/tests/cookie-middleware.spec.ts +++ b/express-zod-api/tests/cookie-middleware.spec.ts @@ -14,17 +14,19 @@ describe("Cookie middleware", () => { async (baseOptions) => { const { output, responseMock } = await testMiddleware({ middleware: createCookieMiddleware(baseOptions), + requestProps: { + cookies: { session: "asdf" }, + signedCookies: { session: "qwerty" }, + }, }); - const { setCookie, clearCookie } = output as { - setCookie: Awaited< - ReturnType["execute"]> - >["setCookie"]; - clearCookie: Awaited< - ReturnType["execute"]> - >["clearCookie"]; - }; + const { getCookie, setCookie, clearCookie } = output as Awaited< + ReturnType["execute"]> + >; + expect(typeof getCookie).toBe("function"); expect(typeof setCookie).toBe("function"); expect(typeof clearCookie).toBe("function"); + expect(getCookie("missing")).toBeUndefined(); + expect(getCookie("session")).toBe("qwerty"); setCookie("session", "abc123", { httpOnly: false }); expect(responseMock.cookies).toHaveProperty("session", { options: { ...baseOptions, httpOnly: false }, From 3f5f9a26f1287a9cbd70a2082f45c0e7d427e10f Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Wed, 20 May 2026 07:30:47 +0200 Subject: [PATCH 36/45] Update CHANGELOG.md Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7063a718d..789a7a23d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - Cookie parsing can be enabled and configured in config (requires to install `cookie-parser`); - `cookies` and `signedCookies` can be used as `inputSources` in config; - `createCookieMiddleware()` creates a Middleware that exposes `setCookie()` and `clearCookie()` helpers into context - as well as the `getCookie()` one as an alternative to using cookies withint `inputSources`; + as well as the `getCookie()` one as an alternative to using cookies within `inputSources`; - Documentation depicts request parameters when Middleware has `security` schema with `type: cookie`. ### v28.0.1 From daed6505417a3fcfd656cd9a70a0899c0a13222c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 20 May 2026 08:18:57 +0200 Subject: [PATCH 37/45] feat(example): the cookie reading and writing tests. --- example/config.ts | 3 + example/endpoints/login.ts | 27 +++++++++ example/endpoints/upload-avatar.ts | 22 +++++--- example/example.client.ts | 46 +++++++++++++++ example/example.documentation.yaml | 89 +++++++++++++++++++++++++++++- example/factories.ts | 13 ++++- example/index.spec.ts | 29 ++++++++-- example/middlewares.ts | 14 ++++- example/routing.ts | 2 + 9 files changed, 229 insertions(+), 16 deletions(-) create mode 100644 example/endpoints/login.ts diff --git a/example/config.ts b/example/config.ts index 1d262d477..d49d77573 100644 --- a/example/config.ts +++ b/example/config.ts @@ -13,6 +13,7 @@ export const config = createConfig({ limitError: createHttpError(413, "The file is too large"), // affects uploadAvatarEndpoint }, compression: true, // affects sendAvatarEndpoint + cookies: true, // for uploadAvatarEndpoint // third-party middlewares serving their own routes or establishing their own routing besides the API beforeRouting: ({ app }) => { app.use( @@ -23,6 +24,7 @@ export const config = createConfig({ }, inputSources: { patch: ["headers", "body", "params"], // affects authMiddleware used by updateUserEndpoint + post: ["body", "params", "files", "cookies"], // cookies for uploadAvatarEndpoint }, cors: true, }); @@ -47,5 +49,6 @@ declare module "express-zod-api" { files: unknown; subscriptions: unknown; forms: unknown; + cookies: unknown; } } diff --git a/example/endpoints/login.ts b/example/endpoints/login.ts new file mode 100644 index 000000000..4eb70c0bb --- /dev/null +++ b/example/endpoints/login.ts @@ -0,0 +1,27 @@ +import { cookieAssistedFactory } from "../factories.ts"; +import { z } from "zod"; +import { randomUUID, hash } from "node:crypto"; + +export const loginEndpoint = cookieAssistedFactory.build({ + method: "post", + tag: "cookies", + input: z.object({ + username: z.string().optional(), + password: z.string().optional(), + }), + output: z.object({ success: z.boolean(), message: z.string() }), + handler: async ({ input: { username, password }, ctx: { setCookie } }) => { + if ( + username === "admin" && + password && + hash("sha1", password) === "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3" + ) { + setCookie("session", { token: randomUUID() }); + return { success: true, message: "Logged in" }; + } + return { + success: false, + message: "Invalid session/credentials", + }; + }, +}); diff --git a/example/endpoints/upload-avatar.ts b/example/endpoints/upload-avatar.ts index 3ce55af96..4efc3a935 100644 --- a/example/endpoints/upload-avatar.ts +++ b/example/endpoints/upload-avatar.ts @@ -1,8 +1,9 @@ import { z } from "zod"; -import { defaultEndpointsFactory, ez } from "express-zod-api"; +import { ez } from "express-zod-api"; import { createHash } from "node:crypto"; +import { cookieAuthenticatedFactory } from "../factories.ts"; -export const uploadAvatarEndpoint = defaultEndpointsFactory.build({ +export const uploadAvatarEndpoint = cookieAuthenticatedFactory.build({ method: "post", tag: "files", description: "Handles a file upload.", @@ -16,11 +17,14 @@ export const uploadAvatarEndpoint = defaultEndpointsFactory.build({ hash: z.string(), otherInputs: z.record(z.string(), z.any()), }), - handler: async ({ input: { avatar, ...rest } }) => ({ - name: avatar.name, - size: avatar.size, - mime: avatar.mimetype, - hash: createHash("sha1").update(avatar.data).digest("hex"), - otherInputs: rest, - }), + handler: async ({ input: { avatar, ...rest }, ctx: { session } }) => { + if (!session.token) throw new Error("Unauthorized"); + return { + name: avatar.name, + size: avatar.size, + mime: avatar.mimetype, + hash: createHash("sha1").update(avatar.data).digest("hex"), + otherInputs: rest, + }; + }, }); diff --git a/example/example.client.ts b/example/example.client.ts index aaf09a4df..6e7cbf8c5 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -205,6 +205,39 @@ interface HeadV1UserListNegativeResponseVariants { 400: HeadV1UserListNegativeVariant1; } +/** post /v1/login */ +type PostV1LoginInput = { + username?: string | undefined; + password?: string | undefined; +}; + +/** post /v1/login */ +type PostV1LoginPositiveVariant1 = { + status: "success"; + data: { + success: boolean; + message: string; + }; +}; + +/** post /v1/login */ +interface PostV1LoginPositiveResponseVariants { + 200: PostV1LoginPositiveVariant1; +} + +/** post /v1/login */ +type PostV1LoginNegativeVariant1 = { + status: "error"; + error: { + message: string; + }; +}; + +/** post /v1/login */ +interface PostV1LoginNegativeResponseVariants { + 400: PostV1LoginNegativeVariant1; +} + /** get /v1/avatar/send */ type GetV1AvatarSendInput = { userId: string; @@ -291,6 +324,9 @@ interface HeadV1AvatarStreamNegativeResponseVariants { /** post /v1/avatar/upload */ type PostV1AvatarUploadInput = { + session: { + token: string; + }; avatar: any; }; @@ -513,6 +549,7 @@ export type Path = | "/v1/user/:id" | "/v1/user/create" | "/v1/user/list" + | "/v1/login" | "/v1/avatar/send" | "/v1/avatar/stream" | "/v1/avatar/upload" @@ -531,6 +568,7 @@ export interface Input { "post /v1/user/create": PostV1UserCreateInput; "get /v1/user/list": GetV1UserListInput; "head /v1/user/list": HeadV1UserListInput; + "post /v1/login": PostV1LoginInput; /** @deprecated */ "get /v1/avatar/send": GetV1AvatarSendInput; /** @deprecated */ @@ -554,6 +592,7 @@ export interface PositiveResponse { "post /v1/user/create": SomeOf; "get /v1/user/list": SomeOf; "head /v1/user/list": SomeOf; + "post /v1/login": SomeOf; /** @deprecated */ "get /v1/avatar/send": SomeOf; /** @deprecated */ @@ -577,6 +616,7 @@ export interface NegativeResponse { "post /v1/user/create": SomeOf; "get /v1/user/list": SomeOf; "head /v1/user/list": SomeOf; + "post /v1/login": SomeOf; /** @deprecated */ "get /v1/avatar/send": SomeOf; /** @deprecated */ @@ -607,6 +647,8 @@ export interface EncodedResponse { GetV1UserListNegativeResponseVariants; "head /v1/user/list": HeadV1UserListPositiveResponseVariants & HeadV1UserListNegativeResponseVariants; + "post /v1/login": PostV1LoginPositiveResponseVariants & + PostV1LoginNegativeResponseVariants; /** @deprecated */ "get /v1/avatar/send": GetV1AvatarSendPositiveResponseVariants & GetV1AvatarSendNegativeResponseVariants; @@ -655,6 +697,9 @@ export interface Response { "head /v1/user/list": | PositiveResponse["head /v1/user/list"] | NegativeResponse["head /v1/user/list"]; + "post /v1/login": + | PositiveResponse["post /v1/login"] + | NegativeResponse["post /v1/login"]; /** @deprecated */ "get /v1/avatar/send": | PositiveResponse["get /v1/avatar/send"] @@ -702,6 +747,7 @@ export const endpointTags = { "post /v1/user/create": ["users"], "get /v1/user/list": ["users"], "head /v1/user/list": ["users"], + "post /v1/login": ["cookies"], "get /v1/avatar/send": ["files", "users"], "head /v1/avatar/send": ["files", "users"], "get /v1/avatar/stream": ["users", "files"], diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 014ebab98..417201939 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -466,6 +466,76 @@ paths: description: HEAD /v1/user/list Positive response "400": description: HEAD /v1/user/list Negative response + /v1/login: + post: + operationId: PostV1Login + tags: + - cookies + requestBody: + description: POST /v1/login Request body + content: + application/json: + schema: + type: object + properties: + username: + type: string + password: + type: string + responses: + "200": + description: POST /v1/login Positive response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: success + data: + type: object + properties: + success: + type: boolean + message: + type: string + required: + - success + - message + additionalProperties: false + required: + - status + - data + additionalProperties: false + "400": + description: POST /v1/login Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + additionalProperties: false + required: + - status + - error + additionalProperties: false + examples: + example1: + value: + status: error + error: + message: Sample error message /v1/avatar/send: get: operationId: GetV1AvatarSend @@ -571,6 +641,18 @@ paths: description: Handles a file upload. tags: - files + parameters: + - name: session + in: cookie + required: true + description: POST /v1/avatar/upload Parameter + schema: + type: object + properties: + token: + type: string + required: + - token requestBody: description: POST /v1/avatar/upload Request body content: @@ -583,8 +665,9 @@ paths: format: binary required: - avatar - additionalProperties: {} required: true + security: + - APIKEY_3: [] responses: "200": description: POST /v1/avatar/upload Positive response @@ -1068,6 +1151,10 @@ components: type: apiKey in: header name: token + APIKEY_3: + type: apiKey + in: cookie + name: session links: {} callbacks: {} tags: diff --git a/example/factories.ts b/example/factories.ts index e63782016..0263c4df5 100644 --- a/example/factories.ts +++ b/example/factories.ts @@ -7,7 +7,11 @@ import { EventStreamFactory, defaultEndpointsFactory, } from "express-zod-api"; -import { authMiddleware } from "./middlewares.ts"; +import { + authMiddleware, + cookieAssistingMiddleware, + sessionMiddleware, +} from "./middlewares.ts"; import { createReadStream } from "node:fs"; import { z } from "zod"; import { stat } from "node:fs/promises"; @@ -16,6 +20,13 @@ import { stat } from "node:fs/promises"; export const keyAndTokenAuthenticatedEndpointsFactory = defaultEndpointsFactory.addMiddleware(authMiddleware); +export const cookieAuthenticatedFactory = + defaultEndpointsFactory.addMiddleware(sessionMiddleware); + +export const cookieAssistedFactory = defaultEndpointsFactory.addMiddleware( + cookieAssistingMiddleware, +); + /** @desc This factory sends the file as string located in the "data" property of the endpoint's output */ export const fileSendingEndpointsFactory = new EndpointsFactory( new ResultHandler({ diff --git a/example/index.spec.ts b/example/index.spec.ts index 41a7d1c62..fd467f7ac 100644 --- a/example/index.spec.ts +++ b/example/index.spec.ts @@ -236,7 +236,14 @@ describe("Example", async () => { data.append("obj[some]", "thing"); const response = await fetch( `http://localhost:${port}/v1/avatar/upload`, - { method: "POST", body: data }, + { + method: "POST", + body: data, + headers: { + Cookie: + "session=j%3A%7B%22token%22%3A%2294f29246-fee7-4940-9a3e-7e1ac0387641%22%7D; Path=/", + }, + }, ); expect(response.headers.get("access-control-allow-methods")).toBe( "POST, OPTIONS", @@ -250,10 +257,10 @@ describe("Example", async () => { otherInputs: { arr: ["456", "789"], num: "123", - obj: { - some: "thing", - }, + obj: { some: "thing" }, str: "test string value", + Path: "/", // from cookie + session: { token: "94f29246-fee7-4940-9a3e-7e1ac0387641" }, }, size: 48687, }, @@ -314,6 +321,20 @@ describe("Example", async () => { await vi.waitFor(() => assert(stack.length > 2), { timeout: 5e3 }); subscription.source.close(); }); + + test("Should send readable cookies", async () => { + const response = await fetch(`http://localhost:${port}/v1/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "admin", password: "test" }), + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + data: { message: "Logged in", success: true }, + status: "success", + }); + expect(response.headers.get("set-cookie")).toMatch(/^session=j/); + }); }); describe("Protocol", () => { diff --git a/example/middlewares.ts b/example/middlewares.ts index c4b922b76..d623ed96e 100644 --- a/example/middlewares.ts +++ b/example/middlewares.ts @@ -1,7 +1,11 @@ import createHttpError from "http-errors"; import assert from "node:assert/strict"; import { z } from "zod"; -import { Middleware, type Method } from "express-zod-api"; +import { + createCookieMiddleware, + Middleware, + type Method, +} from "express-zod-api"; export const authMiddleware = new Middleware({ security: { @@ -22,6 +26,14 @@ export const authMiddleware = new Middleware({ }, }); +export const sessionMiddleware = new Middleware({ + security: { type: "cookie", name: "session" }, + input: z.object({ session: z.object({ token: z.string() }) }), + handler: async ({ input: { session } }) => ({ session }), +}); + +export const cookieAssistingMiddleware = createCookieMiddleware({ path: "/" }); + export const methodProviderMiddleware = new Middleware({ handler: async ({ request }) => ({ method: request.method.toLowerCase() as Method, diff --git a/example/routing.ts b/example/routing.ts index 20139e457..bac3f54ee 100644 --- a/example/routing.ts +++ b/example/routing.ts @@ -12,6 +12,7 @@ import { retrieveUserEndpoint } from "./endpoints/retrieve-user.ts"; import { sendAvatarEndpoint } from "./endpoints/send-avatar.ts"; import { updateUserEndpoint } from "./endpoints/update-user.ts"; import { streamAvatarEndpoint } from "./endpoints/stream-avatar.ts"; +import { loginEndpoint } from "./endpoints/login.ts"; export const routing: Routing = { v1: { @@ -29,6 +30,7 @@ export const routing: Routing = { // this one demonstrates the legacy array-based response list: listUsersEndpoint, }, + login: loginEndpoint, avatar: { // custom result handler examples with a file serving send: sendAvatarEndpoint.deprecated(), // demo for deprecated route From 14e6768db1de30f6c377c0d0d3ffa714911e176c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 20 May 2026 08:38:16 +0200 Subject: [PATCH 38/45] fix(example): more jsdoc. --- example/endpoints/login.ts | 1 + example/endpoints/upload-avatar.ts | 1 + example/factories.ts | 2 ++ example/middlewares.ts | 2 ++ example/routing.ts | 2 +- 5 files changed, 7 insertions(+), 1 deletion(-) diff --git a/example/endpoints/login.ts b/example/endpoints/login.ts index 4eb70c0bb..6a96dfa3a 100644 --- a/example/endpoints/login.ts +++ b/example/endpoints/login.ts @@ -2,6 +2,7 @@ import { cookieAssistedFactory } from "../factories.ts"; import { z } from "zod"; import { randomUUID, hash } from "node:crypto"; +/** @desc The endpoint demonstrates setting a cookie */ export const loginEndpoint = cookieAssistedFactory.build({ method: "post", tag: "cookies", diff --git a/example/endpoints/upload-avatar.ts b/example/endpoints/upload-avatar.ts index 4efc3a935..3bc2b8249 100644 --- a/example/endpoints/upload-avatar.ts +++ b/example/endpoints/upload-avatar.ts @@ -3,6 +3,7 @@ import { ez } from "express-zod-api"; import { createHash } from "node:crypto"; import { cookieAuthenticatedFactory } from "../factories.ts"; +/** @desc The endpoint demonstrates handling a file upload and cookie as an input source */ export const uploadAvatarEndpoint = cookieAuthenticatedFactory.build({ method: "post", tag: "files", diff --git a/example/factories.ts b/example/factories.ts index 0263c4df5..6c7d47f3e 100644 --- a/example/factories.ts +++ b/example/factories.ts @@ -20,9 +20,11 @@ import { stat } from "node:fs/promises"; export const keyAndTokenAuthenticatedEndpointsFactory = defaultEndpointsFactory.addMiddleware(authMiddleware); +/** @desc This factory adds session read from cookie into context */ export const cookieAuthenticatedFactory = defaultEndpointsFactory.addMiddleware(sessionMiddleware); +/** @desc This factory adds setCookie() helper to context */ export const cookieAssistedFactory = defaultEndpointsFactory.addMiddleware( cookieAssistingMiddleware, ); diff --git a/example/middlewares.ts b/example/middlewares.ts index d623ed96e..b74db7a70 100644 --- a/example/middlewares.ts +++ b/example/middlewares.ts @@ -26,12 +26,14 @@ export const authMiddleware = new Middleware({ }, }); +/** @desc This middleware uses cookie as an input source and reads session from it */ export const sessionMiddleware = new Middleware({ security: { type: "cookie", name: "session" }, input: z.object({ session: z.object({ token: z.string() }) }), handler: async ({ input: { session } }) => ({ session }), }); +/** @desc This middleware provides setCookie() helper to context */ export const cookieAssistingMiddleware = createCookieMiddleware({ path: "/" }); export const methodProviderMiddleware = new Middleware({ diff --git a/example/routing.ts b/example/routing.ts index bac3f54ee..339dc5132 100644 --- a/example/routing.ts +++ b/example/routing.ts @@ -30,7 +30,7 @@ export const routing: Routing = { // this one demonstrates the legacy array-based response list: listUsersEndpoint, }, - login: loginEndpoint, + login: loginEndpoint, // demonstrates cookie sending avatar: { // custom result handler examples with a file serving send: sendAvatarEndpoint.deprecated(), // demo for deprecated route From d50b262452e9503f7e259c9131fb8b19a8aea43e Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 20 May 2026 08:41:34 +0200 Subject: [PATCH 39/45] fix(example): simpler login. --- example/endpoints/login.ts | 22 ++++++++++------------ example/example.client.ts | 1 - example/example.documentation.yaml | 3 --- example/index.spec.ts | 2 +- 4 files changed, 11 insertions(+), 17 deletions(-) diff --git a/example/endpoints/login.ts b/example/endpoints/login.ts index 6a96dfa3a..8bd613625 100644 --- a/example/endpoints/login.ts +++ b/example/endpoints/login.ts @@ -1,6 +1,8 @@ import { cookieAssistedFactory } from "../factories.ts"; import { z } from "zod"; import { randomUUID, hash } from "node:crypto"; +import createHttpError from "http-errors"; +import assert from "node:assert/strict"; /** @desc The endpoint demonstrates setting a cookie */ export const loginEndpoint = cookieAssistedFactory.build({ @@ -10,19 +12,15 @@ export const loginEndpoint = cookieAssistedFactory.build({ username: z.string().optional(), password: z.string().optional(), }), - output: z.object({ success: z.boolean(), message: z.string() }), + output: z.object({ message: z.string() }), handler: async ({ input: { username, password }, ctx: { setCookie } }) => { - if ( + assert( username === "admin" && - password && - hash("sha1", password) === "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3" - ) { - setCookie("session", { token: randomUUID() }); - return { success: true, message: "Logged in" }; - } - return { - success: false, - message: "Invalid session/credentials", - }; + password && + hash("sha1", password) === "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + createHttpError(401, "Invalid credentials"), + ); + setCookie("session", { token: randomUUID() }); + return { message: "Logged in" }; }, }); diff --git a/example/example.client.ts b/example/example.client.ts index 6e7cbf8c5..0c17fe8f6 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -215,7 +215,6 @@ type PostV1LoginInput = { type PostV1LoginPositiveVariant1 = { status: "success"; data: { - success: boolean; message: string; }; }; diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index 417201939..c41925245 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -496,12 +496,9 @@ paths: data: type: object properties: - success: - type: boolean message: type: string required: - - success - message additionalProperties: false required: diff --git a/example/index.spec.ts b/example/index.spec.ts index fd467f7ac..da5da641f 100644 --- a/example/index.spec.ts +++ b/example/index.spec.ts @@ -330,7 +330,7 @@ describe("Example", async () => { }); expect(response.status).toBe(200); expect(await response.json()).toEqual({ - data: { message: "Logged in", success: true }, + data: { message: "Logged in" }, status: "success", }); expect(response.headers.get("set-cookie")).toMatch(/^session=j/); From 466de37013b2b7fd2a4fd1ab6876bcb0a286842c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 20 May 2026 09:13:00 +0200 Subject: [PATCH 40/45] fix(example): better auth assertion. --- example/endpoints/upload-avatar.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/example/endpoints/upload-avatar.ts b/example/endpoints/upload-avatar.ts index 3bc2b8249..7c5e77843 100644 --- a/example/endpoints/upload-avatar.ts +++ b/example/endpoints/upload-avatar.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { ez } from "express-zod-api"; import { createHash } from "node:crypto"; import { cookieAuthenticatedFactory } from "../factories.ts"; +import assert from "node:assert/strict"; +import createHttpError from "http-errors"; /** @desc The endpoint demonstrates handling a file upload and cookie as an input source */ export const uploadAvatarEndpoint = cookieAuthenticatedFactory.build({ @@ -19,7 +21,7 @@ export const uploadAvatarEndpoint = cookieAuthenticatedFactory.build({ otherInputs: z.record(z.string(), z.any()), }), handler: async ({ input: { avatar, ...rest }, ctx: { session } }) => { - if (!session.token) throw new Error("Unauthorized"); + assert(session.token, createHttpError(401, "Unauthorized")); return { name: avatar.name, size: avatar.size, From 9dd8e3717c7c41ed227953341d3aa7d8e2fe00ad Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 20 May 2026 09:39:27 +0200 Subject: [PATCH 41/45] fix(example): required params for login endpoint. --- example/endpoints/login.ts | 4 ++-- example/example.client.ts | 4 ++-- example/example.documentation.yaml | 6 ++++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/example/endpoints/login.ts b/example/endpoints/login.ts index 8bd613625..82e602172 100644 --- a/example/endpoints/login.ts +++ b/example/endpoints/login.ts @@ -9,8 +9,8 @@ export const loginEndpoint = cookieAssistedFactory.build({ method: "post", tag: "cookies", input: z.object({ - username: z.string().optional(), - password: z.string().optional(), + username: z.string().trim().nonempty(), + password: z.string().trim().nonempty(), }), output: z.object({ message: z.string() }), handler: async ({ input: { username, password }, ctx: { setCookie } }) => { diff --git a/example/example.client.ts b/example/example.client.ts index 0c17fe8f6..1e3469dc9 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -207,8 +207,8 @@ interface HeadV1UserListNegativeResponseVariants { /** post /v1/login */ type PostV1LoginInput = { - username?: string | undefined; - password?: string | undefined; + username: string; + password: string; }; /** post /v1/login */ diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index c41925245..5cb3a1df5 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -480,8 +480,14 @@ paths: properties: username: type: string + minLength: 1 password: type: string + minLength: 1 + required: + - username + - password + required: true responses: "200": description: POST /v1/login Positive response From a1aba6ebc7e0234ff43ba0bdee6a5f8c7ebe9633 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 20 May 2026 09:54:57 +0200 Subject: [PATCH 42/45] fix(mw): clearCookie omits 'expires' and 'maxAge' options, value 1 is stable. --- express-zod-api/src/cookie-middleware.ts | 6 +++++- express-zod-api/tests/cookie-middleware.spec.ts | 6 ++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/express-zod-api/src/cookie-middleware.ts b/express-zod-api/src/cookie-middleware.ts index 9b5dc65ce..debd4e59d 100644 --- a/express-zod-api/src/cookie-middleware.ts +++ b/express-zod-api/src/cookie-middleware.ts @@ -26,7 +26,11 @@ export const createCookieMiddleware = (baseOptions?: CookieOptions) => response.cookie(name, value, { ...baseOptions, ...overrides }); }, /** @desc Clears a cookie on the response. */ - clearCookie: (name: string, overrides?: CookieOptions) => { + clearCookie: ( + name: string, + /** Express ignores certain options: expires, maxAge */ + overrides?: Omit, + ) => { response.clearCookie(name, { ...baseOptions, ...overrides }); }, }), diff --git a/express-zod-api/tests/cookie-middleware.spec.ts b/express-zod-api/tests/cookie-middleware.spec.ts index 7e326c673..fcdc6b728 100644 --- a/express-zod-api/tests/cookie-middleware.spec.ts +++ b/express-zod-api/tests/cookie-middleware.spec.ts @@ -42,13 +42,11 @@ describe("Cookie middleware", () => { options: { ...baseOptions, path: "/", - expires: expect.any(Date), // unstable + expires: expect.any(Date), }, value: "", }); - expect( - Number(responseMock.cookies["session"].options.expires), - ).toBeLessThan(10); // usually 1 + expect(Number(responseMock.cookies["session"].options.expires)).toBe(1); }, ); }); From 4a0a1380cfcb8a6fab628165ce7d200110d4b872 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 20 May 2026 10:17:42 +0200 Subject: [PATCH 43/45] fix(docs): Reflecting recommended security measures in Readme. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9a4bb6ebe..66f97fcdf 100644 --- a/README.md +++ b/README.md @@ -822,7 +822,7 @@ factory.build({ ## Cookies Install `cookie-parser` as well as `@types/cookie-parser` and enable `cookies` in your config. To validate cookies add -`"cookies"` and/or `"signedCookies"` to your `inputSources`: +`"cookies"` and/or `"signedCookies"` to your `inputSources` (the order [matters](#customizing-input-sources)!): ```ts import { createConfig } from "express-zod-api"; @@ -843,7 +843,7 @@ import { createCookieMiddleware, Middleware } from "express-zod-api"; const cookieDrivenFactory = factory .addMiddleware( - createCookieMiddleware({ httpOnly: true, path: "/" }), // base options + createCookieMiddleware({ httpOnly: true, sameSite: "lax", path: "/" }), // recommended base options ) .addMiddleware( new Middleware({ From aebec21f1135ca6b014bec5fb19fa36690487a4f Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 20 May 2026 10:20:08 +0200 Subject: [PATCH 44/45] fix(example): applying best cookie practice on test. --- example/index.spec.ts | 7 +++++-- example/middlewares.ts | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/example/index.spec.ts b/example/index.spec.ts index da5da641f..9d4dc2e72 100644 --- a/example/index.spec.ts +++ b/example/index.spec.ts @@ -241,7 +241,8 @@ describe("Example", async () => { body: data, headers: { Cookie: - "session=j%3A%7B%22token%22%3A%2294f29246-fee7-4940-9a3e-7e1ac0387641%22%7D; Path=/", + "session=j%3A%7B%22token%22%3A%22553280ce-ab20-4481-a9dc-fd3fc4f6759c%22%7D; " + + "Path=/; HttpOnly; SameSite=Lax", }, }, ); @@ -260,7 +261,8 @@ describe("Example", async () => { obj: { some: "thing" }, str: "test string value", Path: "/", // from cookie - session: { token: "94f29246-fee7-4940-9a3e-7e1ac0387641" }, + SameSite: "Lax", + session: { token: "553280ce-ab20-4481-a9dc-fd3fc4f6759c" }, }, size: 48687, }, @@ -334,6 +336,7 @@ describe("Example", async () => { status: "success", }); expect(response.headers.get("set-cookie")).toMatch(/^session=j/); + console.log(response.headers.get("set-cookie")); }); }); diff --git a/example/middlewares.ts b/example/middlewares.ts index b74db7a70..bb432454e 100644 --- a/example/middlewares.ts +++ b/example/middlewares.ts @@ -34,7 +34,11 @@ export const sessionMiddleware = new Middleware({ }); /** @desc This middleware provides setCookie() helper to context */ -export const cookieAssistingMiddleware = createCookieMiddleware({ path: "/" }); +export const cookieAssistingMiddleware = createCookieMiddleware({ + httpOnly: true, + sameSite: "lax", + path: "/", +}); export const methodProviderMiddleware = new Middleware({ handler: async ({ request }) => ({ From bbde2f57b9bef097a298fb02ef3cc76dbd6416e6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 20 May 2026 10:56:32 +0200 Subject: [PATCH 45/45] fix(example): using scrypt instead of hash sha1. --- example/endpoints/login.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/example/endpoints/login.ts b/example/endpoints/login.ts index 82e602172..c967a44f4 100644 --- a/example/endpoints/login.ts +++ b/example/endpoints/login.ts @@ -1,8 +1,9 @@ import { cookieAssistedFactory } from "../factories.ts"; import { z } from "zod"; -import { randomUUID, hash } from "node:crypto"; +import { randomUUID, scrypt } from "node:crypto"; import createHttpError from "http-errors"; import assert from "node:assert/strict"; +import { promisify } from "node:util"; /** @desc The endpoint demonstrates setting a cookie */ export const loginEndpoint = cookieAssistedFactory.build({ @@ -14,10 +15,14 @@ export const loginEndpoint = cookieAssistedFactory.build({ }), output: z.object({ message: z.string() }), handler: async ({ input: { username, password }, ctx: { setCookie } }) => { + const key = await promisify(scrypt)( + password, + "kinda salt", + 16, + ); assert( username === "admin" && - password && - hash("sha1", password) === "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + key.toString("hex") === "79ad19b8c03bc92a2f25ed865400264e", createHttpError(401, "Invalid credentials"), ); setCookie("session", { token: randomUUID() });