From 306279a57d63d11570101d6f711057fb776c4b03 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 12 Oct 2026 20:15:40 -0400 Subject: [PATCH] fix(express): ship the Express.Request type augmentation The augmentation that types req.user lived in src/types/express.d.ts, an ambient file tsc never copies into dist, so the published package left req.user untyped and consumers had to declare it themselves. Move it into a module (src/types/express.ts) imported from the entry, and add req.cookiePayload, which createEnsureCookiesMiddleware sets. The FastifyRequest augmentation moves the same way so it also loads under NodeNext, where the entry's extensionless relative imports do not resolve. Add consumer type tests that typecheck a fixture against the built dist under bundler and NodeNext resolution. --- .changeset/express-request-types.md | 5 +++ .changeset/fastify-request-types.md | 5 +++ packages/express/src/index.ts | 3 ++ packages/express/src/types/express.d.ts | 5 --- packages/express/src/types/express.ts | 16 +++++++ packages/express/tests/consumerTypes.test.js | 46 ++++++++++++++++++++ packages/express/tests/types/consumer.ts | 18 ++++++++ packages/express/tsconfig.json | 1 - packages/fastify/src/index.ts | 3 ++ packages/fastify/src/options.ts | 10 ----- packages/fastify/src/types/fastify.ts | 12 +++++ packages/fastify/tests/consumerTypes.test.js | 46 ++++++++++++++++++++ packages/fastify/tests/types/consumer.ts | 22 ++++++++++ 13 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 .changeset/express-request-types.md create mode 100644 .changeset/fastify-request-types.md delete mode 100644 packages/express/src/types/express.d.ts create mode 100644 packages/express/src/types/express.ts create mode 100644 packages/express/tests/consumerTypes.test.js create mode 100644 packages/express/tests/types/consumer.ts create mode 100644 packages/fastify/src/types/fastify.ts create mode 100644 packages/fastify/tests/consumerTypes.test.js create mode 100644 packages/fastify/tests/types/consumer.ts diff --git a/.changeset/express-request-types.md b/.changeset/express-request-types.md new file mode 100644 index 0000000..d8b0bff --- /dev/null +++ b/.changeset/express-request-types.md @@ -0,0 +1,5 @@ +--- +"@seamless-auth/express": patch +--- + +Ship the `Express.Request` augmentation. `req.user` (set by `requireAuth`) and `req.cookiePayload` (set by `createEnsureCookiesMiddleware`) now typecheck in a consumer that imports the package, without declaring the augmentation itself. It previously lived in an ambient `.d.ts` that tsc never copied into `dist`. diff --git a/.changeset/fastify-request-types.md b/.changeset/fastify-request-types.md new file mode 100644 index 0000000..b79fcb0 --- /dev/null +++ b/.changeset/fastify-request-types.md @@ -0,0 +1,5 @@ +--- +"@seamless-auth/fastify": patch +--- + +Load the `FastifyRequest` augmentation (`request.user`, `request.cookiePayload`) from the package entry through an import that resolves under `moduleResolution: "NodeNext"`. Consumers on NodeNext previously lost it because the entry reached it only through an extensionless relative import. diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index aec4c36..4909086 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -1,3 +1,6 @@ +// Loads the request type augmentation for anyone importing the package. The +// .js extension keeps it resolvable under NodeNext as well as bundler. +import "./types/express.js"; import { createSeamlessAuthServer, SeamlessAuthServerOptions, diff --git a/packages/express/src/types/express.d.ts b/packages/express/src/types/express.d.ts deleted file mode 100644 index 84de5fa..0000000 --- a/packages/express/src/types/express.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare namespace Express { - interface Request { - user?: SeamlessAuthUser; - } -} diff --git a/packages/express/src/types/express.ts b/packages/express/src/types/express.ts new file mode 100644 index 0000000..01fe392 --- /dev/null +++ b/packages/express/src/types/express.ts @@ -0,0 +1,16 @@ +import type { SeamlessAuthUser } from "@seamless-auth/core"; + +// A module rather than an ambient .d.ts: tsc does not copy .d.ts sources into +// dist, so an ambient file never reached consumers. +declare global { + namespace Express { + interface Request { + /** Set by `requireAuth` once the access cookie or bearer token verifies. */ + user?: SeamlessAuthUser; + /** Set by `createEnsureCookiesMiddleware` once a session cookie verifies. */ + cookiePayload?: Record; + } + } +} + +export {}; diff --git a/packages/express/tests/consumerTypes.test.js b/packages/express/tests/consumerTypes.test.js new file mode 100644 index 0000000..fb1802f --- /dev/null +++ b/packages/express/tests/consumerTypes.test.js @@ -0,0 +1,46 @@ +// Type-checks a consumer against the built dist. Request augmentation that +// lives only in source (an ambient .d.ts tsc never emits) fails here. +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const tsc = require.resolve("typescript/bin/tsc"); +const fixture = fileURLToPath(new URL("./types/consumer.ts", import.meta.url)); + +const RESOLUTIONS = [ + { module: "ESNext", moduleResolution: "bundler" }, + { module: "NodeNext", moduleResolution: "NodeNext" }, +]; + +describe("@seamless-auth/express consumer types", () => { + it.each(RESOLUTIONS)( + "types req.user and req.cookiePayload under moduleResolution $moduleResolution", + ({ module, moduleResolution }) => { + let output = ""; + try { + execFileSync( + process.execPath, + [ + tsc, + "--noEmit", + "--strict", + "--skipLibCheck", + "--target", + "ES2022", + "--module", + module, + "--moduleResolution", + moduleResolution, + fixture, + ], + { encoding: "utf8", stdio: "pipe" }, + ); + } catch (err) { + output = `${err.stdout ?? ""}${err.stderr ?? ""}`; + } + expect(output).toBe(""); + }, + 60000, + ); +}); diff --git a/packages/express/tests/types/consumer.ts b/packages/express/tests/types/consumer.ts new file mode 100644 index 0000000..66355bf --- /dev/null +++ b/packages/express/tests/types/consumer.ts @@ -0,0 +1,18 @@ +// Resolves "@seamless-auth/express" by name through package.json `exports`, so +// it sees the built dist exactly as an installed consumer does. +import express from "express"; +import { requireAuth, requireRole } from "@seamless-auth/express"; + +const app = express(); + +app.get( + "/me", + requireAuth({ cookieSecret: "x".repeat(64) }), + requireRole("admin"), + (req, res) => { + const id: string | undefined = req.user?.id; + const roles: string[] | undefined = req.user?.roles; + const payload: Record | undefined = req.cookiePayload; + res.json({ id, roles, payload }); + }, +); diff --git a/packages/express/tsconfig.json b/packages/express/tsconfig.json index 7b4c8a6..1e68fb9 100644 --- a/packages/express/tsconfig.json +++ b/packages/express/tsconfig.json @@ -7,7 +7,6 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, - "typeRoots": ["src/types/express.d.types"], "declaration": true, "emitDeclarationOnly": false, "declarationMap": true diff --git a/packages/fastify/src/index.ts b/packages/fastify/src/index.ts index a6f15c9..e05496a 100644 --- a/packages/fastify/src/index.ts +++ b/packages/fastify/src/index.ts @@ -1,3 +1,6 @@ +// Loads the request type augmentation for anyone importing the package. The +// .js extension keeps it resolvable under NodeNext as well as bundler. +import "./types/fastify.js"; import { seamlessAuth } from "./plugin"; export { seamlessAuth }; diff --git a/packages/fastify/src/options.ts b/packages/fastify/src/options.ts index 9c110d9..f376ba9 100644 --- a/packages/fastify/src/options.ts +++ b/packages/fastify/src/options.ts @@ -1,7 +1,6 @@ import type { CookieSameSite, SeamlessAuthMessagingOptions, - SeamlessAuthUser, } from "@seamless-auth/core"; import type { ClientIpResolver } from "./internal/buildForwardedClientIp"; @@ -54,12 +53,3 @@ export type ResolvedOptions = SeamlessAuthServerOptions & { refreshCookieName: string; preAuthCookieName: string; }; - -declare module "fastify" { - interface FastifyRequest { - /** Set by the plugin's cookie hook once a session cookie has been verified. */ - cookiePayload?: Record; - /** Set by `requireAuth`. */ - user?: SeamlessAuthUser; - } -} diff --git a/packages/fastify/src/types/fastify.ts b/packages/fastify/src/types/fastify.ts new file mode 100644 index 0000000..fde86c9 --- /dev/null +++ b/packages/fastify/src/types/fastify.ts @@ -0,0 +1,12 @@ +import type { SeamlessAuthUser } from "@seamless-auth/core"; + +declare module "fastify" { + interface FastifyRequest { + /** Set by the plugin's cookie hook once a session cookie has been verified. */ + cookiePayload?: Record; + /** Set by `requireAuth`. */ + user?: SeamlessAuthUser; + } +} + +export {}; diff --git a/packages/fastify/tests/consumerTypes.test.js b/packages/fastify/tests/consumerTypes.test.js new file mode 100644 index 0000000..37bf74b --- /dev/null +++ b/packages/fastify/tests/consumerTypes.test.js @@ -0,0 +1,46 @@ +// Type-checks a consumer against the built dist. Request augmentation that +// lives only in source (an ambient .d.ts tsc never emits) fails here. +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const tsc = require.resolve("typescript/bin/tsc"); +const fixture = fileURLToPath(new URL("./types/consumer.ts", import.meta.url)); + +const RESOLUTIONS = [ + { module: "ESNext", moduleResolution: "bundler" }, + { module: "NodeNext", moduleResolution: "NodeNext" }, +]; + +describe("@seamless-auth/fastify consumer types", () => { + it.each(RESOLUTIONS)( + "types req.user and req.cookiePayload under moduleResolution $moduleResolution", + ({ module, moduleResolution }) => { + let output = ""; + try { + execFileSync( + process.execPath, + [ + tsc, + "--noEmit", + "--strict", + "--skipLibCheck", + "--target", + "ES2022", + "--module", + module, + "--moduleResolution", + moduleResolution, + fixture, + ], + { encoding: "utf8", stdio: "pipe" }, + ); + } catch (err) { + output = `${err.stdout ?? ""}${err.stderr ?? ""}`; + } + expect(output).toBe(""); + }, + 60000, + ); +}); diff --git a/packages/fastify/tests/types/consumer.ts b/packages/fastify/tests/types/consumer.ts new file mode 100644 index 0000000..1264795 --- /dev/null +++ b/packages/fastify/tests/types/consumer.ts @@ -0,0 +1,22 @@ +// Resolves "@seamless-auth/fastify" by name through package.json `exports`, so +// it sees the built dist exactly as an installed consumer does. +import Fastify from "fastify"; +import { requireAuth, requireRole } from "@seamless-auth/fastify"; + +const app = Fastify(); + +app.get( + "/me", + { + preHandler: [ + requireAuth({ cookieSecret: "x".repeat(64) }), + requireRole("admin"), + ], + }, + async (req) => { + const id: string | undefined = req.user?.id; + const roles: string[] | undefined = req.user?.roles; + const payload: Record | undefined = req.cookiePayload; + return { id, roles, payload }; + }, +);