diff --git a/CHANGELOG.md b/CHANGELOG.md index 826a45635..789a7a23d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Version 28 +### v28.1.0 + +- 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 + 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 - Adjusted the list of well-known headers, recognized by Documentation generator: diff --git a/README.md b/README.md index 23602bf46..66f97fcdf 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](#cookies) + 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) @@ -818,6 +819,49 @@ 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` (the order [matters](#customizing-input-sources)!): + +```ts +import { createConfig } from "express-zod-api"; + +const config = createConfig({ + cookies: { secret: "my-secret" }, // or true; the secret enables signedCookies + inputSources: { + get: ["query", "params", "cookies", "signedCookies"], // for methods of your choice + }, +}); +``` + +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, Middleware } from "express-zod-api"; + +const cookieDrivenFactory = factory + .addMiddleware( + createCookieMiddleware({ httpOnly: true, sameSite: "lax", path: "/" }), // recommended 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 = cookieDrivenFactory.buildVoid({ + handler: async ({ ctx: { getCookie, setCookie } }) => { + setCookie("session", "abc123", { httpOnly: false }); // overridden cookie options + }, +}); +``` + ## Response customization `ResultHandler` is responsible for transmitting consistent responses containing the endpoint output or an error. diff --git a/dataflow.svg b/dataflow.svg index 72886fee4..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
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 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..c967a44f4 --- /dev/null +++ b/example/endpoints/login.ts @@ -0,0 +1,31 @@ +import { cookieAssistedFactory } from "../factories.ts"; +import { z } from "zod"; +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({ + method: "post", + tag: "cookies", + input: z.object({ + username: z.string().trim().nonempty(), + password: z.string().trim().nonempty(), + }), + 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" && + key.toString("hex") === "79ad19b8c03bc92a2f25ed865400264e", + createHttpError(401, "Invalid credentials"), + ); + setCookie("session", { token: randomUUID() }); + return { message: "Logged in" }; + }, +}); diff --git a/example/endpoints/upload-avatar.ts b/example/endpoints/upload-avatar.ts index 3ce55af96..7c5e77843 100644 --- a/example/endpoints/upload-avatar.ts +++ b/example/endpoints/upload-avatar.ts @@ -1,8 +1,12 @@ 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"; +import assert from "node:assert/strict"; +import createHttpError from "http-errors"; -export const uploadAvatarEndpoint = defaultEndpointsFactory.build({ +/** @desc The endpoint demonstrates handling a file upload and cookie as an input source */ +export const uploadAvatarEndpoint = cookieAuthenticatedFactory.build({ method: "post", tag: "files", description: "Handles a file upload.", @@ -16,11 +20,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 } }) => { + assert(session.token, createHttpError(401, "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..1e3469dc9 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -205,6 +205,38 @@ interface HeadV1UserListNegativeResponseVariants { 400: HeadV1UserListNegativeVariant1; } +/** post /v1/login */ +type PostV1LoginInput = { + username: string; + password: string; +}; + +/** post /v1/login */ +type PostV1LoginPositiveVariant1 = { + status: "success"; + data: { + 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 +323,9 @@ interface HeadV1AvatarStreamNegativeResponseVariants { /** post /v1/avatar/upload */ type PostV1AvatarUploadInput = { + session: { + token: string; + }; avatar: any; }; @@ -513,6 +548,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 +567,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 +591,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 +615,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 +646,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 +696,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 +746,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..5cb3a1df5 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -466,6 +466,79 @@ 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 + minLength: 1 + password: + type: string + minLength: 1 + required: + - username + - password + required: true + responses: + "200": + description: POST /v1/login Positive response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: success + data: + type: object + properties: + message: + type: string + required: + - 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 +644,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 +668,9 @@ paths: format: binary required: - avatar - additionalProperties: {} required: true + security: + - APIKEY_3: [] responses: "200": description: POST /v1/avatar/upload Positive response @@ -1068,6 +1154,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..6c7d47f3e 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,15 @@ 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, +); + /** @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..9d4dc2e72 100644 --- a/example/index.spec.ts +++ b/example/index.spec.ts @@ -236,7 +236,15 @@ 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%22553280ce-ab20-4481-a9dc-fd3fc4f6759c%22%7D; " + + "Path=/; HttpOnly; SameSite=Lax", + }, + }, ); expect(response.headers.get("access-control-allow-methods")).toBe( "POST, OPTIONS", @@ -250,10 +258,11 @@ describe("Example", async () => { otherInputs: { arr: ["456", "789"], num: "123", - obj: { - some: "thing", - }, + obj: { some: "thing" }, str: "test string value", + Path: "/", // from cookie + SameSite: "Lax", + session: { token: "553280ce-ab20-4481-a9dc-fd3fc4f6759c" }, }, size: 48687, }, @@ -314,6 +323,21 @@ 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" }, + status: "success", + }); + expect(response.headers.get("set-cookie")).toMatch(/^session=j/); + console.log(response.headers.get("set-cookie")); + }); }); describe("Protocol", () => { diff --git a/example/middlewares.ts b/example/middlewares.ts index c4b922b76..bb432454e 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,20 @@ 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({ + httpOnly: true, + sameSite: "lax", + 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..339dc5132 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, // demonstrates cookie sending avatar: { // custom result handler examples with a file serving send: sendAvatarEndpoint.deprecated(), // demo for deprecated route 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/express-zod-api/src/config-type.ts b/express-zod-api/src/config-type.ts index f9c6bcbf2..e3c87bfa2 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"; @@ -12,7 +13,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 +130,11 @@ type UploadOptions = Pick< beforeUpload?: BeforeUpload; }; +interface CookieParserOptions extends cookieParser.CookieParseOptions { + /** @desc The secret string or array used by cookie-parser for signed cookies */ + secret?: Parameters[0]; +} + type CompressionOptions = Pick< compression.CompressionOptions, "threshold" | "level" | "strategy" | "chunkSize" | "memLevel" @@ -181,6 +193,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/src/cookie-middleware.ts b/express-zod-api/src/cookie-middleware.ts new file mode 100644 index 000000000..debd4e59d --- /dev/null +++ b/express-zod-api/src/cookie-middleware.ts @@ -0,0 +1,37 @@ +import { Middleware } from "./middleware"; +import type { CookieOptions } from "express"; +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({ httpOnly: true, secure: true, path: "/" }) + */ +export const createCookieMiddleware = (baseOptions?: CookieOptions) => + new Middleware({ + 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, + value: string | z.core.util.JSONType, + overrides?: CookieOptions, + ) => { + response.cookie(name, value, { ...baseOptions, ...overrides }); + }, + /** @desc Clears a cookie on the response. */ + clearCookie: ( + name: string, + /** Express ignores certain options: expires, maxAge */ + overrides?: Omit, + ) => { + response.clearCookie(name, { ...baseOptions, ...overrides }); + }, + }), + }); diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index d334c6546..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,20 +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 areCookiesEnabled = + inputSources.includes("cookies") || inputSources.includes("signedCookies"); const getLocation = (name: string) => { if (areParamsEnabled && pathParams.includes(name)) return "path"; + 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/index.ts b/express-zod-api/src/index.ts index 986cecf0c..21f5f627b 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 { createCookieMiddleware } from "./cookie-middleware"; export { ResultHandler, defaultResultHandler, diff --git a/express-zod-api/src/security.ts b/express-zod-api/src/security.ts index 6bb64e549..955891f56 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((one) => pickNames(one, type), containers)); diff --git a/express-zod-api/src/server-helpers.ts b/express-zod-api/src/server-helpers.ts index 7be3b0812..e2125c2b8 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"; @@ -81,6 +82,18 @@ export const createUploadFailureHandler = next(); }; +export const createCookieParser = async ({ + config, +}: { + config: ServerConfig; +}): Promise => { + const parser = await loadPeer("cookie-parser"); + const { secret, ...rest } = { + ...(typeof config.cookies === "object" && config.cookies), + }; + return parser(secret, Object.keys(rest).length ? rest : 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/__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/__snapshots__/index.spec.ts.snap b/express-zod-api/tests/__snapshots__/index.spec.ts.snap index d4e754fd4..a60e24351 100644 --- a/express-zod-api/tests/__snapshots__/index.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/index.spec.ts.snap @@ -41,6 +41,8 @@ exports[`Index Entrypoint > exports > attachRouting should have certain value 1` 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`] = ` @@ -80,6 +82,7 @@ exports[`Index Entrypoint > exports > should have certain entities exposed 1`] = "ensureHttpError", "BuiltinLogger", "Middleware", + "createCookieMiddleware", "ResultHandler", "defaultResultHandler", "arrayResultHandler", diff --git a/express-zod-api/tests/common-helpers.spec.ts b/express-zod-api/tests/common-helpers.spec.ts index 98b401b08..1e3f914ec 100644 --- a/express-zod-api/tests/common-helpers.spec.ts +++ b/express-zod-api/tests/common-helpers.spec.ts @@ -223,6 +223,31 @@ describe("Common Helpers", () => { "x-request-id": "test", }); }); + + test.each([ + { + cookies: { session: "abc", theme: "dark" }, + }, + { + signedCookies: { session: "signed-abc" }, + }, + { + 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({ + ...props.cookies, + ...props.signedCookies, + }); + }); }); describe("getMessageFromError()", () => { diff --git a/express-zod-api/tests/config-type.spec.ts b/express-zod-api/tests/config-type.spec.ts index 377708920..2bba30907 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 } from "../src/config-type"; describe("ConfigType", () => { describe("createConfig()", () => { @@ -39,4 +40,18 @@ describe("ConfigType", () => { expect(config).toEqual(argument); }); }); + + describe("InputSource", () => { + test("should list the selected properties of Request", () => { + expectTypeOf().toEqualTypeOf< + | "query" + | "body" + | "files" + | "params" + | "headers" + | "cookies" + | "signedCookies" + >(); + }); + }); }); 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..fcdc6b728 --- /dev/null +++ b/express-zod-api/tests/cookie-middleware.spec.ts @@ -0,0 +1,53 @@ +import { createCookieMiddleware, testMiddleware } from "../src"; +import { expect } from "vitest"; + +describe("Cookie middleware", () => { + describe("createCookieMiddleware", () => { + test("should create a Middleware instance", () => { + const middleware = createCookieMiddleware(); + const { constructor } = Object.getPrototypeOf(middleware); + expect(constructor.name).toBe("Middleware"); + }); + + test.each([undefined, { httpOnly: true, secure: true, path: "/" }])( + "should return setCookie and clearCookie helpers %#", + async (baseOptions) => { + const { output, responseMock } = await testMiddleware({ + middleware: createCookieMiddleware(baseOptions), + requestProps: { + cookies: { session: "asdf" }, + signedCookies: { session: "qwerty" }, + }, + }); + 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 }, + 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: { + ...baseOptions, + path: "/", + expires: expect.any(Date), + }, + value: "", + }); + expect(Number(responseMock.cookies["session"].options.expires)).toBe(1); + }, + ); + }); +}); diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 28d8c8862..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,26 @@ describe("Documentation helpers", () => { }, inputSources: ["query", "headers", "params"], composition: "inline", - security: [[{ type: "header", name: "secure" }]], + securityHeaders: new Set(["secure"]), + ...requestCtx, + }), + ).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", + securityCookies: new Set(["session"]), ...requestCtx, }), ).toMatchSnapshot(); diff --git a/express-zod-api/tests/documentation.spec.ts b/express-zod-api/tests/documentation.spec.ts index 2972721ad..e96dcced9 100644 --- a/express-zod-api/tests/documentation.spec.ts +++ b/express-zod-api/tests/documentation.spec.ts @@ -581,6 +581,34 @@ 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, + inputSources: { get: ["query", "cookies"] }, + }), + routing: { + v1: { + getSomething: defaultEndpointsFactory.addMiddleware(mw).buildVoid({ + input: z.object({ + session: z.string(), + page: z.number(), + }), + handler: vi.fn(), + }), + }, + }, + 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, diff --git a/express-zod-api/tests/express-mock.ts b/express-zod-api/tests/express-mock.ts index 1c0925853..9fa62774b 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.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/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", + ]); + }); + }); +}); diff --git a/express-zod-api/tests/server.spec.ts b/express-zod-api/tests/server.spec.ts index 07b80bd0a..81b35c89f 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, @@ -279,20 +280,32 @@ 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); }); + 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; + await createServer(configMock, {}); + 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 = { http: { listen: givePort() }, 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: {}