From ec24ad5ed2c4626f6c411a4221b5b1e0b76a2d46 Mon Sep 17 00:00:00 2001 From: Sarah Soutoul Date: Tue, 6 Oct 2026 16:29:50 -0600 Subject: [PATCH 1/8] fix: protect TanStack Start init middleware setup --- .changeset/tanstack-start-csrf.md | 5 + packages/cli-core/src/commands/init/README.md | 12 +- .../init/frameworks/tanstack-start.test.ts | 119 +++++++++++++++ .../init/frameworks/tanstack-start.ts | 141 +++++++++++++++--- 4 files changed, 253 insertions(+), 24 deletions(-) create mode 100644 .changeset/tanstack-start-csrf.md diff --git a/.changeset/tanstack-start-csrf.md b/.changeset/tanstack-start-csrf.md new file mode 100644 index 000000000..ce13c56ca --- /dev/null +++ b/.changeset/tanstack-start-csrf.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Protect TanStack Start server functions with CSRF middleware when `clerk init` creates `start.ts`, and safely add Clerk to existing request middleware arrays. diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index de2eb7cf3..881d0297c 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -169,12 +169,12 @@ Nuxt's module system auto-configures middleware and auto-imports components. ### TanStack Start -| Action | File | Description | -| ------ | -------------------------- | ------------------------------------------- | -| MODIFY | `src/start.ts` | Add `clerkMiddleware` to request middleware | -| MODIFY | `src/routes/__root.tsx` | Add `ClerkProvider` and wrap body contents | -| CREATE | `src/routes/sign-in.$.tsx` | Sign-in route with `` component | -| CREATE | `src/routes/sign-up.$.tsx` | Sign-up route with `` component | +| Action | File | Description | +| ------------- | -------------------------- | ----------------------------------------------------------- | +| CREATE/MODIFY | `src/start.ts` | Add Clerk middleware; new files also enable CSRF protection | +| MODIFY | `src/routes/__root.tsx` | Add `ClerkProvider` and wrap body contents | +| CREATE | `src/routes/sign-in.$.tsx` | Sign-in route with `` component | +| CREATE | `src/routes/sign-up.$.tsx` | Sign-up route with `` component | ### Astro diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts index d3c384ee3..180a06213 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts @@ -64,9 +64,128 @@ test("creates src/start.ts with clerkMiddleware when no start file exists", asyn expect(serverAction.content).toContain("clerkMiddleware"); expect(serverAction.content).toContain("@clerk/tanstack-react-start/server"); expect(serverAction.content).toContain("requestMiddleware"); + expect(serverAction.content).toContain("createCsrfMiddleware"); + expect(serverAction.content).toContain('context.handlerType === "serverFn"'); + expect(serverAction.content).toContain( + "requestMiddleware: [csrfMiddleware, clerkMiddleware()]", + ); } }); +test("adds Clerk after existing CSRF middleware in an expression-body start callback", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { createStart, createCsrfMiddleware } from "@tanstack/react-start"; +import { ClerkProvider } from "@clerk/tanstack-react-start"; + +const csrfMiddleware = createCsrfMiddleware({ filter: (context) => context.handlerType === "serverFn" }); +export const startInstance = createStart(() => ({ + requestMiddleware: [csrfMiddleware], +})); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("modify"); + if (action?.type !== "modify") throw new Error("Expected modify action"); + expect(action.content).toContain("requestMiddleware: [csrfMiddleware, clerkMiddleware()]"); + expect(action.content.match(/requestMiddleware:/g)).toHaveLength(1); + expect(action.content).toContain( + 'import { clerkMiddleware } from "@clerk/tanstack-react-start/server"', + ); +}); + +test("preserves an existing requestMiddleware array in a block-body callback", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { createStart } from "@tanstack/react-start"; +export const startInstance = createStart(() => { + return { requestMiddleware: [csrfMiddleware, customMiddleware] }; +}); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("modify"); + if (action?.type !== "modify") throw new Error("Expected modify action"); + expect(action.content).toContain( + "requestMiddleware: [csrfMiddleware, customMiddleware, clerkMiddleware()]", + ); + expect(action.content.match(/requestMiddleware:/g)).toHaveLength(1); +}); + +test("adds a requestMiddleware property when the existing config has none", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { createStart } from "@tanstack/react-start"; +export const startInstance = createStart(() => ({ defaultSsr: false })); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("modify"); + if (action?.type !== "modify") throw new Error("Expected modify action"); + expect(action.content).toContain("requestMiddleware: [clerkMiddleware()]"); + expect(action.content).toContain("defaultSsr: false"); +}); + +test("reports unsupported start config without writing an unused import", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { createStart } from "@tanstack/react-start"; +export const startInstance = createStart(getConfig); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("skip"); + if (action?.type !== "skip") throw new Error("Expected skip action"); + expect(action.skipReason).toContain("add clerkMiddleware() manually"); +}); + +test("does not replace a requestMiddleware value it cannot safely extend", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { createStart } from "@tanstack/react-start"; +export const startInstance = createStart(() => ({ requestMiddleware: customMiddleware })); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("skip"); + if (action?.type !== "skip") throw new Error("Expected skip action"); + expect(action.skipReason).toContain("add clerkMiddleware() manually"); +}); + +test("skips a start config that already registers Clerk middleware", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { createStart } from "@tanstack/react-start"; +import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; +export const startInstance = createStart(() => ({ requestMiddleware: [clerkMiddleware()] })); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action).toEqual({ + type: "skip", + path: "src/start.ts", + skipReason: "Already has Clerk middleware", + }); +}); + test("creates app/start.ts when no start file exists and app base dir is detected", async () => { await mkdir(join(tempDir, "app/routes"), { recursive: true }); await Bun.write(join(tempDir, "app/routes/__root.tsx"), "export default function Root() {}"); diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts index ea07eced1..2e76a5c55 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts @@ -1,4 +1,6 @@ import { join } from "node:path"; +import { builders, parseModule } from "magicast"; +import type { ASTNode } from "magicast"; import { addBootstrapHeader, authComponentName, @@ -6,7 +8,6 @@ import { findFirstFile, findFirstDirMatch, hasTailwindStyles, - hasClerkImport, indentBlock, jsxAuthComponentMarkup, jsxExt, @@ -122,16 +123,121 @@ async function scaffoldAuthRoutes( function newStartFileContent(): string { return `import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; -import { createStart } from "@tanstack/react-start"; +import { createCsrfMiddleware, createStart } from "@tanstack/react-start"; -export const startInstance = createStart(() => { - return { - requestMiddleware: [clerkMiddleware()], - }; +const csrfMiddleware = createCsrfMiddleware({ + filter: (context) => context.handlerType === "serverFn", }); + +export const startInstance = createStart(() => ({ + requestMiddleware: [csrfMiddleware, clerkMiddleware()], +})); `; } +function addClerkToStart(content: string): string | null { + try { + const mod = parseModule(content); + const calls: Extract[] = []; + const visited = new WeakSet(); + + function visit(node: ASTNode): void { + if (visited.has(node)) return; + visited.add(node); + if ( + node.type === "CallExpression" && + node.callee.type === "Identifier" && + node.callee.name === "createStart" + ) { + calls.push(node); + } + for (const [key, value] of Object.entries(node)) { + if (key === "original" || key === "loc" || key === "comments") continue; + for (const child of Array.isArray(value) ? value : [value]) { + if (child && typeof child === "object" && "type" in child) visit(child as ASTNode); + } + } + } + + visit(mod.$ast); + if (calls.length !== 1) return null; + + const callback = calls[0]!.arguments[0]; + if (!callback || callback.type !== "ArrowFunctionExpression") return null; + + let config: Extract | null = null; + if (callback.body.type === "ObjectExpression") { + config = callback.body; + } else if (callback.body.type === "BlockStatement") { + const returns = callback.body.body.filter( + (statement) => statement.type === "ReturnStatement", + ); + if (returns.length === 1 && returns[0]!.argument?.type === "ObjectExpression") { + config = returns[0]!.argument; + } + } + if (!config) return null; + + // A spread could override requestMiddleware after our edit, and its contents are unknown. + if (config.properties.some((property) => property.type === "SpreadElement")) return null; + + const middlewareProperties = config.properties.filter( + (property) => + (property.type === "ObjectProperty" || property.type === "ObjectMethod") && + ((property.key.type === "Identifier" && property.key.name === "requestMiddleware") || + (property.key.type === "StringLiteral" && property.key.value === "requestMiddleware")), + ); + if (middlewareProperties.length > 1) return null; + + const middlewareProperty = middlewareProperties[0]; + const clerkCall = builders.raw("clerkMiddleware()").$ast; + if (clerkCall.type !== "CallExpression") return null; + + if (middlewareProperty) { + if ( + middlewareProperty.type !== "ObjectProperty" || + middlewareProperty.value.type !== "ArrayExpression" + ) { + return null; + } + const middleware = middlewareProperty.value.elements; + if ( + middleware.some( + (element) => + element?.type === "CallExpression" && + element.callee.type === "Identifier" && + element.callee.name === "clerkMiddleware", + ) + ) { + return content; + } + middleware.push(clerkCall); + } else { + const property = builders.raw("({ requestMiddleware: [clerkMiddleware()] })").$ast; + if (property.type !== "ObjectExpression") return null; + config.properties.unshift(property.properties[0]!); + } + + const result = mod.generate().code; + const hasImport = + mod.$ast.type === "Program" && + mod.$ast.body.some( + (statement) => + statement.type === "ImportDeclaration" && + statement.source.value === "@clerk/tanstack-react-start/server" && + statement.specifiers.some( + (specifier) => + specifier.type === "ImportSpecifier" && specifier.local.name === "clerkMiddleware", + ), + ); + return hasImport + ? result + : safeAddImport(result, "@clerk/tanstack-react-start/server", "clerkMiddleware"); + } catch { + return null; + } +} + async function scaffoldStartServer( ctx: ProjectContext, baseDir: TanstackBaseDir, @@ -144,24 +250,23 @@ async function scaffoldStartServer( path: newPath, type: "create", content: newStartFileContent(), - description: "Create start.ts with clerkMiddleware", + description: "Create start.ts with CSRF and Clerk middleware", }; } const content = await Bun.file(join(ctx.cwd, serverPath)).text(); - if (hasClerkImport(content)) { - return { type: "skip", path: serverPath, skipReason: "Already has Clerk middleware" }; + const newContent = addClerkToStart(content); + if (newContent === null) { + return { + type: "skip", + path: serverPath, + skipReason: + "Could not safely add Clerk to requestMiddleware — add clerkMiddleware() manually", + }; } - - let newContent = safeAddImport(content, "@clerk/tanstack-react-start/server", "clerkMiddleware"); - - // Insert requestMiddleware into createStart config - if (newContent.includes("createStart")) { - newContent = newContent.replace( - /(createStart\s*\(\s*\(\)\s*=>\s*\{[\s\S]*?return\s*\{)/, - "$1\n requestMiddleware: [clerkMiddleware()],", - ); + if (newContent === content) { + return { type: "skip", path: serverPath, skipReason: "Already has Clerk middleware" }; } return { From 581c8415386a9cb3c290c04a87718d4d3431b927 Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 7 Oct 2026 11:24:23 -0500 Subject: [PATCH 2/8] fix(init): list a manual step when start.ts can't take Clerk middleware When the TanStack Start scaffold can't safely add clerkMiddleware() to an existing start.ts, surface it as a next step instead of only a dimmed SKIP line. Without it, a re-run where everything else is in place reported "Clerk is already set up" while the middleware was missing. Co-Authored-By: Claude Opus 5.5 --- .../init/frameworks/tanstack-start.test.ts | 25 +++++++++ .../init/frameworks/tanstack-start.ts | 56 +++++++++++++------ 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts index 180a06213..6659dd0b0 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts @@ -149,6 +149,9 @@ export const startInstance = createStart(getConfig); expect(action?.type).toBe("skip"); if (action?.type !== "skip") throw new Error("Expected skip action"); expect(action.skipReason).toContain("add clerkMiddleware() manually"); + expect(plan.postInstructions).toContain( + "Add clerkMiddleware() from @clerk/tanstack-react-start/server to requestMiddleware in src/start.ts, after any CSRF middleware", + ); }); test("does not replace a requestMiddleware value it cannot safely extend", async () => { @@ -167,6 +170,27 @@ export const startInstance = createStart(() => ({ requestMiddleware: customMiddl expect(action.skipReason).toContain("add clerkMiddleware() manually"); }); +test("asks for manual setup when an existing config has duplicate requestMiddleware keys", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; +import { createStart } from "@tanstack/react-start"; +export const startInstance = createStart(() => { + return { + requestMiddleware: [clerkMiddleware()], + requestMiddleware: [csrfMiddleware], + }; +}); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("skip"); + expect(plan.postInstructions.some((msg) => msg.includes("clerkMiddleware()"))).toBe(true); +}); + test("skips a start config that already registers Clerk middleware", async () => { await mkdir(join(tempDir, "src"), { recursive: true }); await Bun.write( @@ -184,6 +208,7 @@ export const startInstance = createStart(() => ({ requestMiddleware: [clerkMiddl path: "src/start.ts", skipReason: "Already has Clerk middleware", }); + expect(plan.postInstructions.some((msg) => msg.includes("clerkMiddleware()"))).toBe(false); }); test("creates app/start.ts when no start file exists and app base dir is detected", async () => { diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts index 2e76a5c55..d5a39e499 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts @@ -21,6 +21,12 @@ import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from type TanstackBaseDir = "app" | "src"; +type StartScaffoldResult = { + action: FileAction; + /** True when the start file exists but Clerk couldn't be added — user must register it manually. */ + needsManualMiddleware: boolean; +}; + const START_FILE_CANDIDATES = [ "src/start.ts", "src/start.tsx", @@ -241,16 +247,19 @@ function addClerkToStart(content: string): string | null { async function scaffoldStartServer( ctx: ProjectContext, baseDir: TanstackBaseDir, -): Promise { +): Promise { const serverPath = await findStartFile(ctx, baseDir); if (!serverPath) { const newPath = `${baseDir}/start.ts`; return { - path: newPath, - type: "create", - content: newStartFileContent(), - description: "Create start.ts with CSRF and Clerk middleware", + action: { + path: newPath, + type: "create", + content: newStartFileContent(), + description: "Create start.ts with CSRF and Clerk middleware", + }, + needsManualMiddleware: false, }; } @@ -259,21 +268,30 @@ async function scaffoldStartServer( const newContent = addClerkToStart(content); if (newContent === null) { return { - type: "skip", - path: serverPath, - skipReason: - "Could not safely add Clerk to requestMiddleware — add clerkMiddleware() manually", + action: { + type: "skip", + path: serverPath, + skipReason: + "Could not safely add Clerk to requestMiddleware — add clerkMiddleware() manually", + }, + needsManualMiddleware: true, }; } if (newContent === content) { - return { type: "skip", path: serverPath, skipReason: "Already has Clerk middleware" }; + return { + action: { type: "skip", path: serverPath, skipReason: "Already has Clerk middleware" }, + needsManualMiddleware: false, + }; } return { - path: serverPath, - type: "modify", - content: newContent, - description: "Add clerkMiddleware to request middleware", + action: { + path: serverPath, + type: "modify", + content: newContent, + description: "Add clerkMiddleware to request middleware", + }, + needsManualMiddleware: false, }; } @@ -320,17 +338,23 @@ export const tanstackStart: FrameworkScaffold = { detectBaseDir(ctx), scaffoldEnvVars(ctx, SIGN_ROUTE_ENV_VARS.vite), ]); - const [serverAction, localeDir] = await Promise.all([ + const [serverResult, localeDir] = await Promise.all([ scaffoldStartServer(ctx, baseDir), detectLocaleDir(ctx.cwd, baseDir), ]); const authActions = await scaffoldAuthRoutes(ctx, baseDir, localeDir); - const actions = [serverAction, rootAction, ...authActions, envAction].filter( + const actions = [serverResult.action, rootAction, ...authActions, envAction].filter( (action): action is FileAction => action !== null, ); const postInstructions: string[] = []; + if (serverResult.needsManualMiddleware) { + postInstructions.push( + `Add clerkMiddleware() from @clerk/tanstack-react-start/server to requestMiddleware in ${serverResult.action.path}, after any CSRF middleware`, + ); + } + if (!rootAction) { postInstructions.push( "Wrap your root route with from @clerk/tanstack-react-start", From f60131db99f11d23c53e837d4e951e916db92839 Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 7 Oct 2026 11:55:34 -0500 Subject: [PATCH 3/8] fix(init): name the CSRF filter parameter ctx Matches Start's own dev warning text and the other TanStack Start 1.0 snippets across Clerk's docs, dashboard, skills, and quickstart. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/init/frameworks/tanstack-start.test.ts | 4 ++-- .../cli-core/src/commands/init/frameworks/tanstack-start.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts index 6659dd0b0..9cffa24f4 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts @@ -65,7 +65,7 @@ test("creates src/start.ts with clerkMiddleware when no start file exists", asyn expect(serverAction.content).toContain("@clerk/tanstack-react-start/server"); expect(serverAction.content).toContain("requestMiddleware"); expect(serverAction.content).toContain("createCsrfMiddleware"); - expect(serverAction.content).toContain('context.handlerType === "serverFn"'); + expect(serverAction.content).toContain('ctx.handlerType === "serverFn"'); expect(serverAction.content).toContain( "requestMiddleware: [csrfMiddleware, clerkMiddleware()]", ); @@ -79,7 +79,7 @@ test("adds Clerk after existing CSRF middleware in an expression-body start call `import { createStart, createCsrfMiddleware } from "@tanstack/react-start"; import { ClerkProvider } from "@clerk/tanstack-react-start"; -const csrfMiddleware = createCsrfMiddleware({ filter: (context) => context.handlerType === "serverFn" }); +const csrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }); export const startInstance = createStart(() => ({ requestMiddleware: [csrfMiddleware], })); diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts index d5a39e499..86776b578 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts @@ -132,7 +132,7 @@ function newStartFileContent(): string { import { createCsrfMiddleware, createStart } from "@tanstack/react-start"; const csrfMiddleware = createCsrfMiddleware({ - filter: (context) => context.handlerType === "serverFn", + filter: (ctx) => ctx.handlerType === "serverFn", }); export const startInstance = createStart(() => ({ From 0a85d997cf9c4bdb0e2cf4720b3505b85f140bc6 Mon Sep 17 00:00:00 2001 From: Sarah Soutoul Date: Wed, 7 Oct 2026 11:48:26 -0600 Subject: [PATCH 4/8] fix(init): add CSRF to existing TanStack Start setups --- .../init/frameworks/tanstack-start.test.ts | 110 +++++++++- .../init/frameworks/tanstack-start.ts | 206 ++++++++++++------ 2 files changed, 235 insertions(+), 81 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts index 9cffa24f4..51b77585f 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts @@ -21,7 +21,7 @@ function makeCtx(overrides?: Partial): ProjectContext { srcDir: true, packageManager: "npm", existingClerk: false, - deps: { "@tanstack/react-start": "1.0.0" }, + deps: { "@tanstack/react-start": "^1.168.0" }, envFile: ".env", ...overrides, }; @@ -101,7 +101,8 @@ test("preserves an existing requestMiddleware array in a block-body callback", a await mkdir(join(tempDir, "src"), { recursive: true }); await Bun.write( join(tempDir, "src/start.ts"), - `import { createStart } from "@tanstack/react-start"; + `import { createStart, createCsrfMiddleware } from "@tanstack/react-start"; +const csrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }); export const startInstance = createStart(() => { return { requestMiddleware: [csrfMiddleware, customMiddleware] }; }); @@ -131,7 +132,8 @@ export const startInstance = createStart(() => ({ defaultSsr: false })); const action = plan.actions.find((item) => item.path === "src/start.ts"); expect(action?.type).toBe("modify"); if (action?.type !== "modify") throw new Error("Expected modify action"); - expect(action.content).toContain("requestMiddleware: [clerkMiddleware()]"); + expect(action.content).toContain("requestMiddleware: [csrfMiddleware, clerkMiddleware()]"); + expect(action.content).toContain("createCsrfMiddleware"); expect(action.content).toContain("defaultSsr: false"); }); @@ -191,24 +193,108 @@ export const startInstance = createStart(() => { expect(plan.postInstructions.some((msg) => msg.includes("clerkMiddleware()"))).toBe(true); }); -test("skips a start config that already registers Clerk middleware", async () => { +test("adds CSRF before Clerk in an old Clerk-only start config", async () => { await mkdir(join(tempDir, "src"), { recursive: true }); await Bun.write( join(tempDir, "src/start.ts"), - `import { createStart } from "@tanstack/react-start"; + `import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; +import { createStart } from "@tanstack/react-start"; + +export const startInstance = createStart(() => { + return { + requestMiddleware: [clerkMiddleware()], + }; +}); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("modify"); + if (action?.type !== "modify") throw new Error("Expected modify action"); + expect(action.content).toContain("requestMiddleware: [csrfMiddleware, clerkMiddleware()]"); + expect(action.content).toMatch( + /import \{[^}]*createCsrfMiddleware[^}]*\} from "@tanstack\/react-start"/, + ); + expect(action.content).toContain('ctx.handlerType === "serverFn"'); + expect(action.content.match(/clerkMiddleware\(\)/g)).toHaveLength(1); + expect(plan.postInstructions.some((msg) => msg.includes("CSRF"))).toBe(false); + + await Bun.write(join(tempDir, "src/start.ts"), action.content); + const rerun = await tanstackStart.scaffold(makeCtx()); + expect(rerun.actions.find((item) => item.path === "src/start.ts")?.type).toBe("skip"); + expect(rerun.postInstructions.some((msg) => msg.includes("CSRF"))).toBe(false); +}); + +test("asks for an upgrade before adding CSRF to an older Start app", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + const original = `import { createStart } from "@tanstack/react-start"; import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; export const startInstance = createStart(() => ({ requestMiddleware: [clerkMiddleware()] })); +`; + await Bun.write(join(tempDir, "src/start.ts"), original); + + const plan = await tanstackStart.scaffold( + makeCtx({ deps: { "@tanstack/react-start": "^1.167.17" } }), + ); + expect(plan.actions.find((item) => item.path === "src/start.ts")?.type).toBe("skip"); + expect(plan.postInstructions.some((msg) => msg.includes("Upgrade @tanstack/react-start"))).toBe( + true, + ); + expect(plan.postInstructions.some((msg) => msg.includes("createCsrfMiddleware"))).toBe(true); + expect(await Bun.file(join(tempDir, "src/start.ts")).text()).toBe(original); +}); + +test("asks for manual CSRF setup when existing middleware is not Clerk-only", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { createStart } from "@tanstack/react-start"; +import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; +export const startInstance = createStart(() => ({ + requestMiddleware: [customMiddleware, clerkMiddleware()], +})); `, ); const plan = await tanstackStart.scaffold(makeCtx()); - const action = plan.actions.find((item) => item.path === "src/start.ts"); - expect(action).toEqual({ - type: "skip", - path: "src/start.ts", - skipReason: "Already has Clerk middleware", - }); - expect(plan.postInstructions.some((msg) => msg.includes("clerkMiddleware()"))).toBe(false); + expect(plan.actions.find((item) => item.path === "src/start.ts")?.type).toBe("skip"); + expect(plan.postInstructions.some((msg) => msg.includes("createCsrfMiddleware"))).toBe(true); +}); + +test("leaves custom middleware without known CSRF for manual setup", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { createStart } from "@tanstack/react-start"; +export const startInstance = createStart(() => ({ + requestMiddleware: [customMiddleware], +})); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + expect(plan.actions.find((item) => item.path === "src/start.ts")?.type).toBe("skip"); + expect(plan.postInstructions.some((msg) => msg.includes("Add clerkMiddleware()"))).toBe(true); + expect(plan.postInstructions.some((msg) => msg.includes("createCsrfMiddleware"))).toBe(true); +}); + +test("leaves an existing registered CSRF middleware in place", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { createStart, createCsrfMiddleware } from "@tanstack/react-start"; +import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; +const csrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }); +export const startInstance = createStart(() => ({ + requestMiddleware: [csrfMiddleware, clerkMiddleware()], +})); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + expect(plan.actions.find((item) => item.path === "src/start.ts")?.type).toBe("skip"); + expect(plan.postInstructions.some((msg) => msg.includes("CSRF"))).toBe(false); }); test("creates app/start.ts when no start file exists and app base dir is detected", async () => { diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts index 86776b578..9af6d996b 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts @@ -1,6 +1,7 @@ import { join } from "node:path"; import { builders, parseModule } from "magicast"; import type { ASTNode } from "magicast"; +import { gte, minVersion } from "semver"; import { addBootstrapHeader, authComponentName, @@ -23,10 +24,15 @@ type TanstackBaseDir = "app" | "src"; type StartScaffoldResult = { action: FileAction; - /** True when the start file exists but Clerk couldn't be added — user must register it manually. */ - needsManualMiddleware: boolean; + postInstructions: string[]; }; +type StartFileUpdate = + | { status: "manual"; needsClerk: boolean } + | { status: "upgrade"; needsClerk: boolean } + | { status: "unchanged"; content: string } + | { status: "modified"; content: string; addedCsrf: boolean }; + const START_FILE_CANDIDATES = [ "src/start.ts", "src/start.tsx", @@ -141,12 +147,14 @@ export const startInstance = createStart(() => ({ `; } -function addClerkToStart(content: string): string | null { +function updateStartFile(content: string, startVersion: string | undefined): StartFileUpdate { try { const mod = parseModule(content); + const program = mod.$ast; + if (program.type !== "Program") return { status: "manual", needsClerk: true }; + const calls: Extract[] = []; const visited = new WeakSet(); - function visit(node: ASTNode): void { if (visited.has(node)) return; visited.add(node); @@ -164,13 +172,13 @@ function addClerkToStart(content: string): string | null { } } } - visit(mod.$ast); - if (calls.length !== 1) return null; + if (calls.length !== 1) return { status: "manual", needsClerk: true }; const callback = calls[0]!.arguments[0]; - if (!callback || callback.type !== "ArrowFunctionExpression") return null; - + if (!callback || callback.type !== "ArrowFunctionExpression") { + return { status: "manual", needsClerk: true }; + } let config: Extract | null = null; if (callback.body.type === "ObjectExpression") { config = callback.body; @@ -182,65 +190,114 @@ function addClerkToStart(content: string): string | null { config = returns[0]!.argument; } } - if (!config) return null; - - // A spread could override requestMiddleware after our edit, and its contents are unknown. - if (config.properties.some((property) => property.type === "SpreadElement")) return null; + if (!config || config.properties.some((property) => property.type === "SpreadElement")) { + return { status: "manual", needsClerk: true }; + } - const middlewareProperties = config.properties.filter( + const properties = config.properties.filter( (property) => (property.type === "ObjectProperty" || property.type === "ObjectMethod") && ((property.key.type === "Identifier" && property.key.name === "requestMiddleware") || (property.key.type === "StringLiteral" && property.key.value === "requestMiddleware")), ); - if (middlewareProperties.length > 1) return null; + if (properties.length > 1) return { status: "manual", needsClerk: true }; + let property = properties[0]; + if (!property) { + const empty = builders.raw("({ requestMiddleware: [] })").$ast; + if (empty.type !== "ObjectExpression") return { status: "manual", needsClerk: true }; + property = empty.properties[0]; + config.properties.unshift(property!); + } + if (property?.type !== "ObjectProperty" || property.value.type !== "ArrayExpression") { + return { status: "manual", needsClerk: true }; + } + const middleware = property.value.elements; + const hasClerk = middleware.some( + (element) => + element?.type === "CallExpression" && + element.callee.type === "Identifier" && + element.callee.name === "clerkMiddleware", + ); - const middlewareProperty = middlewareProperties[0]; - const clerkCall = builders.raw("clerkMiddleware()").$ast; - if (clerkCall.type !== "CallExpression") return null; + const hasImport = (source: string, name: string) => + program.body.some( + (statement) => + statement.type === "ImportDeclaration" && + statement.source.value === source && + statement.specifiers.some( + (specifier) => + specifier.type === "ImportSpecifier" && + specifier.imported.type === "Identifier" && + specifier.imported.name === name && + specifier.local.name === name, + ), + ); + const csrfDeclared = program.body.some( + (statement) => + statement.type === "VariableDeclaration" && + statement.declarations.some( + (declaration) => + declaration.id.type === "Identifier" && + declaration.id.name === "csrfMiddleware" && + declaration.init?.type === "CallExpression" && + declaration.init.callee.type === "Identifier" && + declaration.init.callee.name === "createCsrfMiddleware", + ), + ); + const hasCsrf = + hasImport("@tanstack/react-start", "createCsrfMiddleware") && + middleware.some( + (element) => + (element?.type === "Identifier" && element.name === "csrfMiddleware" && csrfDeclared) || + (element?.type === "CallExpression" && + element.callee.type === "Identifier" && + element.callee.name === "createCsrfMiddleware"), + ); - if (middlewareProperty) { - if ( - middlewareProperty.type !== "ObjectProperty" || - middlewareProperty.value.type !== "ArrayExpression" - ) { - return null; + if (!hasCsrf) { + const minimum = startVersion ? minVersion(startVersion) : null; + if (!minimum || !gte(minimum, "1.168.0")) { + return { status: "upgrade", needsClerk: !hasClerk }; } - const middleware = middlewareProperty.value.elements; + // Only an empty array or the old Clerk-only array is safe to rewrite automatically. if ( - middleware.some( - (element) => - element?.type === "CallExpression" && - element.callee.type === "Identifier" && - element.callee.name === "clerkMiddleware", - ) + (middleware.length > 0 && !(middleware.length === 1 && hasClerk)) || + content.includes("csrfMiddleware") || + content.includes("createCsrfMiddleware") ) { - return content; + return { status: "manual", needsClerk: !hasClerk }; } + const declaration = parseModule( + 'const csrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" });', + ).$ast; + const csrfElement = builders.raw("csrfMiddleware").$ast; + if (declaration.type !== "Program" || csrfElement.type !== "Identifier") { + return { status: "manual", needsClerk: !hasClerk }; + } + const insertAt = program.body.findIndex( + (statement) => statement.type !== "ImportDeclaration", + ); + program.body.splice(insertAt < 0 ? program.body.length : insertAt, 0, declaration.body[0]!); + middleware.unshift(csrfElement); + } + + if (!hasClerk) { + const clerkCall = builders.raw("clerkMiddleware()").$ast; + if (clerkCall.type !== "CallExpression") return { status: "manual", needsClerk: true }; middleware.push(clerkCall); - } else { - const property = builders.raw("({ requestMiddleware: [clerkMiddleware()] })").$ast; - if (property.type !== "ObjectExpression") return null; - config.properties.unshift(property.properties[0]!); } + if (hasClerk && hasCsrf) return { status: "unchanged", content }; - const result = mod.generate().code; - const hasImport = - mod.$ast.type === "Program" && - mod.$ast.body.some( - (statement) => - statement.type === "ImportDeclaration" && - statement.source.value === "@clerk/tanstack-react-start/server" && - statement.specifiers.some( - (specifier) => - specifier.type === "ImportSpecifier" && specifier.local.name === "clerkMiddleware", - ), - ); - return hasImport - ? result - : safeAddImport(result, "@clerk/tanstack-react-start/server", "clerkMiddleware"); + let result = mod.generate().code; + if (!hasImport("@clerk/tanstack-react-start/server", "clerkMiddleware")) { + result = safeAddImport(result, "@clerk/tanstack-react-start/server", "clerkMiddleware"); + } + if (!hasCsrf) { + result = safeAddImport(result, "@tanstack/react-start", "createCsrfMiddleware"); + } + return { status: "modified", content: result, addedCsrf: !hasCsrf }; } catch { - return null; + return { status: "manual", needsClerk: true }; } } @@ -259,28 +316,43 @@ async function scaffoldStartServer( content: newStartFileContent(), description: "Create start.ts with CSRF and Clerk middleware", }, - needsManualMiddleware: false, + postInstructions: [], }; } const content = await Bun.file(join(ctx.cwd, serverPath)).text(); + const update = updateStartFile(content, ctx.deps["@tanstack/react-start"]); + const postInstructions: string[] = []; - const newContent = addClerkToStart(content); - if (newContent === null) { + if (update.status === "manual" || update.status === "upgrade") { + if (update.needsClerk) { + postInstructions.push( + `Add clerkMiddleware() from @clerk/tanstack-react-start/server to requestMiddleware in ${serverPath}, after any CSRF middleware`, + ); + } + if (update.status === "upgrade") { + postInstructions.push( + "Upgrade @tanstack/react-start to ^1.168.0 and @tanstack/react-router to ^1.170.0 before adding CSRF middleware", + ); + } + postInstructions.push( + `In ${serverPath}, register createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }) before clerkMiddleware() in requestMiddleware if equivalent CSRF protection is not already configured`, + ); return { action: { type: "skip", path: serverPath, - skipReason: - "Could not safely add Clerk to requestMiddleware — add clerkMiddleware() manually", + skipReason: update.needsClerk + ? "Could not safely add Clerk to requestMiddleware — add clerkMiddleware() manually" + : "Could not safely add CSRF middleware automatically", }, - needsManualMiddleware: true, + postInstructions, }; } - if (newContent === content) { + if (update.status === "unchanged") { return { action: { type: "skip", path: serverPath, skipReason: "Already has Clerk middleware" }, - needsManualMiddleware: false, + postInstructions, }; } @@ -288,10 +360,12 @@ async function scaffoldStartServer( action: { path: serverPath, type: "modify", - content: newContent, - description: "Add clerkMiddleware to request middleware", + content: update.content, + description: update.addedCsrf + ? "Add CSRF middleware before Clerk in requestMiddleware" + : "Add clerkMiddleware to request middleware", }, - needsManualMiddleware: false, + postInstructions, }; } @@ -347,13 +421,7 @@ export const tanstackStart: FrameworkScaffold = { const actions = [serverResult.action, rootAction, ...authActions, envAction].filter( (action): action is FileAction => action !== null, ); - const postInstructions: string[] = []; - - if (serverResult.needsManualMiddleware) { - postInstructions.push( - `Add clerkMiddleware() from @clerk/tanstack-react-start/server to requestMiddleware in ${serverResult.action.path}, after any CSRF middleware`, - ); - } + const postInstructions = [...serverResult.postInstructions]; if (!rootAction) { postInstructions.push( From 7c7fb628f5ccf73b93085c98f45506c8075c9fee Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 7 Oct 2026 13:53:43 -0500 Subject: [PATCH 5/8] fix(init): require Start 1.168.10 for CSRF and keep start.ts formatting - Raise the CSRF floor to @tanstack/react-start 1.168.10 (Router 1.170.7), matching @clerk/tanstack-react-start's peers, and apply the same check before creating start.ts - Prefer the installed Start version; treat specifiers without a lower bound (latest, catalog:, workspace:*) as unverified instead of throwing - Generate start.ts in the createStart(() => { return { ... } }) form - Edit existing files as text splices in the file's own quote and semicolon style instead of regenerating them - Update the init README and changeset for CSRF on existing files Co-Authored-By: Claude Opus 5.5 --- .changeset/tanstack-start-csrf.md | 2 +- packages/cli-core/src/commands/init/README.md | 14 +- .../init/frameworks/tanstack-start.test.ts | 141 ++++++++++- .../init/frameworks/tanstack-start.ts | 227 ++++++++++++++---- 4 files changed, 330 insertions(+), 54 deletions(-) diff --git a/.changeset/tanstack-start-csrf.md b/.changeset/tanstack-start-csrf.md index ce13c56ca..149b738d9 100644 --- a/.changeset/tanstack-start-csrf.md +++ b/.changeset/tanstack-start-csrf.md @@ -2,4 +2,4 @@ "clerk": patch --- -Protect TanStack Start server functions with CSRF middleware when `clerk init` creates `start.ts`, and safely add Clerk to existing request middleware arrays. +Register TanStack Start's CSRF middleware before Clerk's middleware in `src/start.ts`, both when `clerk init` creates the file and when it can safely edit an existing one. This needs `@tanstack/react-start` 1.168.10 or later; otherwise `clerk init` prints the manual steps instead. diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 881d0297c..d42330eff 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -169,12 +169,14 @@ Nuxt's module system auto-configures middleware and auto-imports components. ### TanStack Start -| Action | File | Description | -| ------------- | -------------------------- | ----------------------------------------------------------- | -| CREATE/MODIFY | `src/start.ts` | Add Clerk middleware; new files also enable CSRF protection | -| MODIFY | `src/routes/__root.tsx` | Add `ClerkProvider` and wrap body contents | -| CREATE | `src/routes/sign-in.$.tsx` | Sign-in route with `` component | -| CREATE | `src/routes/sign-up.$.tsx` | Sign-up route with `` component | +| Action | File | Description | +| ------------- | -------------------------- | ----------------------------------------------- | +| CREATE/MODIFY | `src/start.ts` | Register CSRF middleware, then Clerk middleware | +| MODIFY | `src/routes/__root.tsx` | Add `ClerkProvider` and wrap body contents | +| CREATE | `src/routes/sign-in.$.tsx` | Sign-in route with `` component | +| CREATE | `src/routes/sign-up.$.tsx` | Sign-up route with `` component | + +CSRF middleware needs `@tanstack/react-start` 1.168.10 or later. When the version is older or can't be confirmed, or an existing `start.ts` can't be edited safely, `clerk init` leaves the file unchanged and prints the manual steps. ### Astro diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts index 51b77585f..48712a55e 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts @@ -21,7 +21,7 @@ function makeCtx(overrides?: Partial): ProjectContext { srcDir: true, packageManager: "npm", existingClerk: false, - deps: { "@tanstack/react-start": "^1.168.0" }, + deps: { "@tanstack/react-start": "^1.168.10" }, envFile: ".env", ...overrides, }; @@ -66,12 +66,57 @@ test("creates src/start.ts with clerkMiddleware when no start file exists", asyn expect(serverAction.content).toContain("requestMiddleware"); expect(serverAction.content).toContain("createCsrfMiddleware"); expect(serverAction.content).toContain('ctx.handlerType === "serverFn"'); - expect(serverAction.content).toContain( - "requestMiddleware: [csrfMiddleware, clerkMiddleware()]", - ); + expect(serverAction.content).toContain(`createStart(() => { + return { + requestMiddleware: [csrfMiddleware, clerkMiddleware()], + }; +});`); } }); +test("asks for an upgrade instead of creating start.ts for an older Start app", async () => { + const plan = await tanstackStart.scaffold( + makeCtx({ deps: { "@tanstack/react-start": "^1.168.0" } }), + ); + + const serverAction = plan.actions.find((action) => action.path === "src/start.ts"); + expect(serverAction?.type).toBe("skip"); + expect(plan.postInstructions).toContain( + "Upgrade @tanstack/react-start to ^1.168.10 and @tanstack/react-router to ^1.170.7 before adding CSRF middleware", + ); + expect(plan.postInstructions.some((msg) => msg.includes("create src/start.ts"))).toBe(true); +}); + +test.each(["latest", "catalog:", "workspace:*", "*"])( + "asks to confirm the Start version instead of creating start.ts for %s", + async (specifier) => { + const plan = await tanstackStart.scaffold( + makeCtx({ deps: { "@tanstack/react-start": specifier } }), + ); + + expect(plan.actions.find((action) => action.path === "src/start.ts")?.type).toBe("skip"); + expect( + plan.postInstructions.some((msg) => + msg.startsWith(`Could not confirm @tanstack/react-start (${specifier})`), + ), + ).toBe(true); + }, +); + +test("uses the installed Start version over the declared specifier", async () => { + await mkdir(join(tempDir, "node_modules/@tanstack/react-start"), { recursive: true }); + await Bun.write( + join(tempDir, "node_modules/@tanstack/react-start/package.json"), + JSON.stringify({ name: "@tanstack/react-start", version: "1.170.0" }), + ); + + const plan = await tanstackStart.scaffold( + makeCtx({ deps: { "@tanstack/react-start": "catalog:" } }), + ); + + expect(plan.actions.find((action) => action.path === "src/start.ts")?.type).toBe("create"); +}); + test("adds Clerk after existing CSRF middleware in an expression-body start callback", async () => { await mkdir(join(tempDir, "src"), { recursive: true }); await Bun.write( @@ -245,6 +290,94 @@ export const startInstance = createStart(() => ({ requestMiddleware: [clerkMiddl expect(await Bun.file(join(tempDir, "src/start.ts")).text()).toBe(original); }); +test("keeps registered Clerk middleware when the Start version can't be confirmed", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + const original = `import { createStart } from "@tanstack/react-start"; +import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; +export const startInstance = createStart(() => ({ requestMiddleware: [clerkMiddleware()] })); +`; + await Bun.write(join(tempDir, "src/start.ts"), original); + + const plan = await tanstackStart.scaffold( + makeCtx({ deps: { "@tanstack/react-start": "workspace:*" } }), + ); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("skip"); + if (action?.type !== "skip") throw new Error("Expected skip action"); + expect(action.skipReason).toBe("Could not safely add CSRF middleware automatically"); + expect(plan.postInstructions.some((msg) => msg.startsWith("Add clerkMiddleware()"))).toBe(false); + expect(plan.postInstructions.some((msg) => msg.startsWith("Could not confirm"))).toBe(true); +}); + +test("matches the existing file's quotes and semicolons when adding CSRF", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { createStart } from '@tanstack/react-start' +import { clerkMiddleware } from '@clerk/tanstack-react-start/server' + +export const startInstance = createStart(() => { + return { + requestMiddleware: [clerkMiddleware()], + } +}) +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("modify"); + if (action?.type !== "modify") throw new Error("Expected modify action"); + expect(action.content) + .toBe(`import { createCsrfMiddleware, createStart } from '@tanstack/react-start' +import { clerkMiddleware } from '@clerk/tanstack-react-start/server' + +const csrfMiddleware = createCsrfMiddleware({ + filter: (ctx) => ctx.handlerType === 'serverFn', +}) + +export const startInstance = createStart(() => { + return { + requestMiddleware: [csrfMiddleware, clerkMiddleware()], + } +}) +`); +}); + +test("adds Clerk on its own line in a multi-line middleware array", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { createCsrfMiddleware, createStart } from '@tanstack/react-start' +import { authkitMiddleware } from '@workos/authkit-tanstack-react-start' + +const csrfMiddleware = createCsrfMiddleware({ + filter: (ctx) => ctx.handlerType === 'serverFn', +}) + +export const startInstance = createStart(() => ({ + requestMiddleware: [ + csrfMiddleware, + authkitMiddleware(), + ], +})) +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("modify"); + if (action?.type !== "modify") throw new Error("Expected modify action"); + expect(action.content).toContain(` requestMiddleware: [ + csrfMiddleware, + authkitMiddleware(), + clerkMiddleware(), + ],`); + expect(action.content).toContain( + "import { clerkMiddleware } from '@clerk/tanstack-react-start/server'\n", + ); +}); + test("asks for manual CSRF setup when existing middleware is not Clerk-only", async () => { await mkdir(join(tempDir, "src"), { recursive: true }); await Bun.write( diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts index 9af6d996b..ba26aca65 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts @@ -1,7 +1,7 @@ -import { join } from "node:path"; -import { builders, parseModule } from "magicast"; +import { dirname, join } from "node:path"; +import { detectCodeFormat, parseModule } from "magicast"; import type { ASTNode } from "magicast"; -import { gte, minVersion } from "semver"; +import { gte, minVersion, valid, validRange } from "semver"; import { addBootstrapHeader, authComponentName, @@ -10,6 +10,7 @@ import { findFirstDirMatch, hasTailwindStyles, indentBlock, + insertAfterLastImport, jsxAuthComponentMarkup, jsxExt, safeAddImport, @@ -22,6 +23,15 @@ import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from type TanstackBaseDir = "app" | "src"; +// 1.168.10 is the first release that names createCsrfMiddleware in its own +// exports (TanStack/router#7466); it pins @tanstack/react-router 1.170.7. +// Keep both in sync with @clerk/tanstack-react-start's peer dependencies. +const MIN_START_VERSION = "1.168.10"; +const MIN_START_RANGE = `^${MIN_START_VERSION}`; +const MIN_ROUTER_RANGE = "^1.170.7"; + +type StartVersionCheck = "supported" | "outdated" | "unknown"; + type StartScaffoldResult = { action: FileAction; postInstructions: string[]; @@ -30,6 +40,7 @@ type StartScaffoldResult = { type StartFileUpdate = | { status: "manual"; needsClerk: boolean } | { status: "upgrade"; needsClerk: boolean } + | { status: "unverified"; needsClerk: boolean } | { status: "unchanged"; content: string } | { status: "modified"; content: string; addedCsrf: boolean }; @@ -141,13 +152,93 @@ const csrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn", }); -export const startInstance = createStart(() => ({ - requestMiddleware: [csrfMiddleware, clerkMiddleware()], -})); +export const startInstance = createStart(() => { + return { + requestMiddleware: [csrfMiddleware, clerkMiddleware()], + }; +}); `; } -function updateStartFile(content: string, startVersion: string | undefined): StartFileUpdate { +/** Read the installed version, walking up so hoisted monorepo installs resolve too. */ +async function installedStartVersion(cwd: string): Promise { + let dir = cwd; + while (true) { + const file = Bun.file(join(dir, "node_modules/@tanstack/react-start/package.json")); + if (await file.exists()) { + try { + const { version } = (await file.json()) as { version?: unknown }; + return typeof version === "string" ? version : null; + } catch { + return null; + } + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * Check whether the app's Start version exports createCsrfMiddleware. Prefers + * the installed version; falls back to the lower bound of the declared range. + * Specifiers without a usable lower bound (`latest`, `catalog:`, `workspace:*`, + * `*`) can't be verified. + */ +function checkStartVersion( + installed: string | null, + specifier: string | undefined, +): StartVersionCheck { + if (installed && valid(installed)) { + return gte(installed, MIN_START_VERSION) ? "supported" : "outdated"; + } + if (!specifier || !validRange(specifier)) return "unknown"; + const minimum = minVersion(specifier); + if (!minimum || minimum.version === "0.0.0") return "unknown"; + return gte(minimum, MIN_START_VERSION) ? "supported" : "outdated"; +} + +function startVersionInstruction(check: StartVersionCheck, specifier: string | undefined): string { + return check === "outdated" + ? `Upgrade @tanstack/react-start to ${MIN_START_RANGE} and @tanstack/react-router to ${MIN_ROUTER_RANGE} before adding CSRF middleware` + : `Could not confirm @tanstack/react-start${specifier ? ` (${specifier})` : ""} is ${MIN_START_VERSION} or newer; make sure it's on ${MIN_START_RANGE} and @tanstack/react-router on ${MIN_ROUTER_RANGE} before adding CSRF middleware`; +} + +type CodeStyle = { quote: string; semi: string }; + +function codeStyle(content: string): CodeStyle { + const format = detectCodeFormat(content); + return { + quote: format.quote === "single" ? "'" : '"', + semi: format.useSemi === false ? "" : ";", + }; +} + +/** Add a named import in the file's style, merging into a one-line import from the same source. */ +function addNamedImport(code: string, source: string, name: string, style: CodeStyle): string { + const escaped = source.replace(/[/.]/g, "\\$&"); + const existing = new RegExp(`^import \\{([^}\\n]*)\\} from (["'])${escaped}\\2`, "m").exec(code); + if (existing) { + const names = existing[1]! + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + if (names.includes(name)) return code; + const index = names.findIndex((item) => item > name); + names.splice(index < 0 ? names.length : index, 0, name); + const quote = existing[2]!; + return code.replace( + existing[0], + () => `import { ${names.join(", ")} } from ${quote}${source}${quote}`, + ); + } + return insertAfterLastImport( + code, + `import { ${name} } from ${style.quote}${source}${style.quote}${style.semi}\n`, + ); +} + +function updateStartFile(content: string, startVersion: StartVersionCheck): StartFileUpdate { try { const mod = parseModule(content); const program = mod.$ast; @@ -201,17 +292,18 @@ function updateStartFile(content: string, startVersion: string | undefined): Sta (property.key.type === "StringLiteral" && property.key.value === "requestMiddleware")), ); if (properties.length > 1) return { status: "manual", needsClerk: true }; - let property = properties[0]; - if (!property) { - const empty = builders.raw("({ requestMiddleware: [] })").$ast; - if (empty.type !== "ObjectExpression") return { status: "manual", needsClerk: true }; - property = empty.properties[0]; - config.properties.unshift(property!); + const property = properties[0]; + if ( + property && + (property.type !== "ObjectProperty" || property.value.type !== "ArrayExpression") + ) { + return { status: "manual", needsClerk: true }; } - if (property?.type !== "ObjectProperty" || property.value.type !== "ArrayExpression") { + const array = property?.value.type === "ArrayExpression" ? property.value : null; + const middleware = array?.elements ?? []; + if (middleware.some((element) => element === null)) { return { status: "manual", needsClerk: true }; } - const middleware = property.value.elements; const hasClerk = middleware.some( (element) => element?.type === "CallExpression" && @@ -255,9 +347,11 @@ function updateStartFile(content: string, startVersion: string | undefined): Sta ); if (!hasCsrf) { - const minimum = startVersion ? minVersion(startVersion) : null; - if (!minimum || !gte(minimum, "1.168.0")) { - return { status: "upgrade", needsClerk: !hasClerk }; + if (startVersion !== "supported") { + return { + status: startVersion === "outdated" ? "upgrade" : "unverified", + needsClerk: !hasClerk, + }; } // Only an empty array or the old Clerk-only array is safe to rewrite automatically. if ( @@ -267,33 +361,67 @@ function updateStartFile(content: string, startVersion: string | undefined): Sta ) { return { status: "manual", needsClerk: !hasClerk }; } - const declaration = parseModule( - 'const csrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" });', - ).$ast; - const csrfElement = builders.raw("csrfMiddleware").$ast; - if (declaration.type !== "Program" || csrfElement.type !== "Identifier") { - return { status: "manual", needsClerk: !hasClerk }; + } + if (hasClerk && hasCsrf) return { status: "unchanged", content }; + + // Splice text at the AST positions instead of regenerating the module, so + // the rest of the file keeps its exact formatting. + const before = hasCsrf ? [] : ["csrfMiddleware"]; + const after = hasClerk ? [] : ["clerkMiddleware()"]; + const edits: { at: number; text: string }[] = []; + const first = middleware[0]; + const last = middleware.at(-1); + if (array && first && last) { + // Keep one element per line when the array is already split across lines. + const separator = + first.loc!.start.line === array.loc!.start.line + ? ", " + : `,\n${" ".repeat(first.loc!.start.column)}`; + if (before.length > 0) { + edits.push({ at: first.start!, text: `${before.join(separator)}${separator}` }); + } + if (after.length > 0) { + edits.push({ at: last.end!, text: `${separator}${after.join(separator)}` }); } - const insertAt = program.body.findIndex( - (statement) => statement.type !== "ImportDeclaration", + } else if (array) { + edits.push({ at: array.start! + 1, text: [...before, ...after].join(", ") }); + } else { + const entry = `requestMiddleware: [${[...before, ...after].join(", ")}]`; + const firstProperty = config.properties[0]; + edits.push( + firstProperty + ? { + at: firstProperty.start!, + text: `${entry},\n${" ".repeat(firstProperty.loc!.start.column)}`, + } + : { at: config.start! + 1, text: ` ${entry} ` }, ); - program.body.splice(insertAt < 0 ? program.body.length : insertAt, 0, declaration.body[0]!); - middleware.unshift(csrfElement); } - - if (!hasClerk) { - const clerkCall = builders.raw("clerkMiddleware()").$ast; - if (clerkCall.type !== "CallExpression") return { status: "manual", needsClerk: true }; - middleware.push(clerkCall); + if (edits.some((edit) => !Number.isInteger(edit.at))) { + return { status: "manual", needsClerk: !hasClerk }; + } + let result = content; + for (const edit of edits.sort((a, b) => b.at - a.at)) { + result = result.slice(0, edit.at) + edit.text + result.slice(edit.at); } - if (hasClerk && hasCsrf) return { status: "unchanged", content }; - let result = mod.generate().code; + const style = codeStyle(content); if (!hasImport("@clerk/tanstack-react-start/server", "clerkMiddleware")) { - result = safeAddImport(result, "@clerk/tanstack-react-start/server", "clerkMiddleware"); + result = addNamedImport( + result, + "@clerk/tanstack-react-start/server", + "clerkMiddleware", + style, + ); } if (!hasCsrf) { - result = safeAddImport(result, "@tanstack/react-start", "createCsrfMiddleware"); + result = addNamedImport(result, "@tanstack/react-start", "createCsrfMiddleware", style); + const declaration = `const csrfMiddleware = createCsrfMiddleware({\n filter: (ctx) => ctx.handlerType === ${style.quote}serverFn${style.quote},\n})${style.semi}\n`; + result = insertAfterLastImport(result, `\n${declaration}`); + // Keep a blank line between the new declaration and the code after it. + result = result.replace(declaration, (match, offset: number, code: string) => + code[offset + match.length] === "\n" ? match : `${match}\n`, + ); } return { status: "modified", content: result, addedCsrf: !hasCsrf }; } catch { @@ -306,9 +434,24 @@ async function scaffoldStartServer( baseDir: TanstackBaseDir, ): Promise { const serverPath = await findStartFile(ctx, baseDir); + const specifier = ctx.deps["@tanstack/react-start"]; + const startVersion = checkStartVersion(await installedStartVersion(ctx.cwd), specifier); if (!serverPath) { const newPath = `${baseDir}/start.ts`; + if (startVersion !== "supported") { + return { + action: { + type: "skip", + path: newPath, + skipReason: `Needs @tanstack/react-start ${MIN_START_RANGE} for CSRF middleware`, + }, + postInstructions: [ + startVersionInstruction(startVersion, specifier), + `Then create ${newPath} that registers createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }) and clerkMiddleware() in requestMiddleware, in that order`, + ], + }; + } return { action: { path: newPath, @@ -321,19 +464,17 @@ async function scaffoldStartServer( } const content = await Bun.file(join(ctx.cwd, serverPath)).text(); - const update = updateStartFile(content, ctx.deps["@tanstack/react-start"]); + const update = updateStartFile(content, startVersion); const postInstructions: string[] = []; - if (update.status === "manual" || update.status === "upgrade") { + if (update.status === "manual" || update.status === "upgrade" || update.status === "unverified") { if (update.needsClerk) { postInstructions.push( `Add clerkMiddleware() from @clerk/tanstack-react-start/server to requestMiddleware in ${serverPath}, after any CSRF middleware`, ); } - if (update.status === "upgrade") { - postInstructions.push( - "Upgrade @tanstack/react-start to ^1.168.0 and @tanstack/react-router to ^1.170.0 before adding CSRF middleware", - ); + if (update.status !== "manual") { + postInstructions.push(startVersionInstruction(startVersion, specifier)); } postInstructions.push( `In ${serverPath}, register createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }) before clerkMiddleware() in requestMiddleware if equivalent CSRF protection is not already configured`, From 9c7c9cbd0a759441d7bb2ef0a53e644dbddf4a93 Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 7 Oct 2026 14:16:17 -0500 Subject: [PATCH 6/8] fix(init): match the import source without building a regex Compare the captured module source directly instead of escaping a package name into a RegExp (CodeQL js/incomplete-sanitization), and document the Router 1.170.7 minimum next to Start 1.168.10 in the init README. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/init/README.md | 2 +- .../init/frameworks/tanstack-start.test.ts | 22 +++++++++++++++++++ .../init/frameworks/tanstack-start.ts | 5 +++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index d42330eff..04eb08488 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -176,7 +176,7 @@ Nuxt's module system auto-configures middleware and auto-imports components. | CREATE | `src/routes/sign-in.$.tsx` | Sign-in route with `` component | | CREATE | `src/routes/sign-up.$.tsx` | Sign-up route with `` component | -CSRF middleware needs `@tanstack/react-start` 1.168.10 or later. When the version is older or can't be confirmed, or an existing `start.ts` can't be edited safely, `clerk init` leaves the file unchanged and prints the manual steps. +CSRF middleware needs `@tanstack/react-start` 1.168.10 or later, with `@tanstack/react-router` 1.170.7 or later. When the version is older or can't be confirmed, or an existing `start.ts` can't be edited safely, `clerk init` leaves the file unchanged and prints the manual steps. ### Astro diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts index 48712a55e..7235f0703 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts @@ -378,6 +378,28 @@ export const startInstance = createStart(() => ({ ); }); +test("only merges the CSRF import into an import from @tanstack/react-start itself", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { getRequest } from "@tanstack/react-start/server"; +import { createStart } from "@tanstack/react-start"; +import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; + +export const startInstance = createStart(() => ({ requestMiddleware: [clerkMiddleware()] })); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("modify"); + if (action?.type !== "modify") throw new Error("Expected modify action"); + expect(action.content).toContain('import { getRequest } from "@tanstack/react-start/server";'); + expect(action.content).toContain( + 'import { createCsrfMiddleware, createStart } from "@tanstack/react-start";', + ); +}); + test("asks for manual CSRF setup when existing middleware is not Clerk-only", async () => { await mkdir(join(tempDir, "src"), { recursive: true }); await Bun.write( diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts index ba26aca65..100c6a863 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts @@ -216,8 +216,9 @@ function codeStyle(content: string): CodeStyle { /** Add a named import in the file's style, merging into a one-line import from the same source. */ function addNamedImport(code: string, source: string, name: string, style: CodeStyle): string { - const escaped = source.replace(/[/.]/g, "\\$&"); - const existing = new RegExp(`^import \\{([^}\\n]*)\\} from (["'])${escaped}\\2`, "m").exec(code); + const existing = [...code.matchAll(/^import \{([^}\n]*)\} from (["'])([^"'\n]+)\2/gm)].find( + (match) => match[3] === source, + ); if (existing) { const names = existing[1]! .split(",") From 19fa5ea8f374d300318f9a2e7e679a8d7be27c29 Mon Sep 17 00:00:00 2001 From: Sarah Soutoul Date: Wed, 7 Oct 2026 14:08:42 -0600 Subject: [PATCH 7/8] fix(init): recognize variable-held Clerk middleware --- .../init/frameworks/tanstack-start.test.ts | 42 +++++++++++++++++++ .../init/frameworks/tanstack-start.ts | 30 +++++++++++-- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts index 7235f0703..736a93625 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts @@ -142,6 +142,48 @@ export const startInstance = createStart(() => ({ ); }); +test("does not duplicate Clerk middleware held in a variable", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; +import { createCsrfMiddleware, createStart } from "@tanstack/react-start"; + +const csrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }); +const clerk = clerkMiddleware(); +export const startInstance = createStart(() => ({ requestMiddleware: [csrfMiddleware, clerk] })); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("skip"); + if (action?.type !== "skip") throw new Error("Expected skip action"); + expect(action.skipReason).toBe("Already has Clerk middleware"); +}); + +test("adds CSRF before a callback-local Clerk middleware variable", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; +import { createStart } from "@tanstack/react-start"; + +export const startInstance = createStart(() => { + const clerk = clerkMiddleware(); + return { requestMiddleware: [clerk] }; +}); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("modify"); + if (action?.type !== "modify") throw new Error("Expected modify action"); + expect(action.content).toContain("requestMiddleware: [csrfMiddleware, clerk]"); + expect(action.content.match(/clerkMiddleware\(\)/g)).toHaveLength(1); +}); + test("preserves an existing requestMiddleware array in a block-body callback", async () => { await mkdir(join(tempDir, "src"), { recursive: true }); await Bun.write( diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts index 100c6a863..bcadc2a6e 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts @@ -305,11 +305,35 @@ function updateStartFile(content: string, startVersion: StartVersionCheck): Star if (middleware.some((element) => element === null)) { return { status: "manual", needsClerk: true }; } + const callbackStatements = callback.body.type === "BlockStatement" ? callback.body.body : []; + const isClerkVariable = (name: string): boolean => { + for (const statements of [callbackStatements, program.body]) { + const declarations = statements.flatMap((statement) => + statement.type === "VariableDeclaration" && statement.kind === "const" + ? statement.declarations.filter( + (declaration) => + declaration.id.type === "Identifier" && declaration.id.name === name, + ) + : [], + ); + if (declarations.length > 0) { + const declaration = declarations[0]!; + return ( + declarations.length === 1 && + declaration.init?.type === "CallExpression" && + declaration.init.callee.type === "Identifier" && + declaration.init.callee.name === "clerkMiddleware" + ); + } + } + return false; + }; const hasClerk = middleware.some( (element) => - element?.type === "CallExpression" && - element.callee.type === "Identifier" && - element.callee.name === "clerkMiddleware", + (element?.type === "CallExpression" && + element.callee.type === "Identifier" && + element.callee.name === "clerkMiddleware") || + (element?.type === "Identifier" && isClerkVariable(element.name)), ); const hasImport = (source: string, name: string) => From 9fc43c2e0cfa28cb267f67804d9e0bf8bbb851bb Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 7 Oct 2026 16:36:37 -0500 Subject: [PATCH 8/8] fix(init): recognize aliased and shadowed Clerk middleware bindings Count clerkMiddleware imported under an alias as registered (and append it under that alias when missing), and resolve middleware variables to their nearest declaration of any kind, so a callback binding can't borrow a top-level Clerk const. Co-Authored-By: Claude Opus 5.5 --- .../init/frameworks/tanstack-start.test.ts | 81 +++++++++++++++++++ .../init/frameworks/tanstack-start.ts | 51 +++++++----- 2 files changed, 112 insertions(+), 20 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts index 736a93625..cb59a6374 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.test.ts @@ -184,6 +184,87 @@ export const startInstance = createStart(() => { expect(action.content.match(/clerkMiddleware\(\)/g)).toHaveLength(1); }); +const CSRF_SETUP = `import { createCsrfMiddleware, createStart } from "@tanstack/react-start"; +const csrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }); +`; + +test.each([ + [ + "an aliased import", + `import { clerkMiddleware as clerk } from "@clerk/tanstack-react-start/server"; +${CSRF_SETUP}export const startInstance = createStart(() => ({ requestMiddleware: [csrfMiddleware, clerk()] })); +`, + ], + [ + "a variable from an aliased import", + `import { clerkMiddleware as clerk } from "@clerk/tanstack-react-start/server"; +${CSRF_SETUP}const auth = clerk(); +export const startInstance = createStart(() => ({ requestMiddleware: [csrfMiddleware, auth] })); +`, + ], + [ + "a let binding", + `import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; +${CSRF_SETUP}let auth = clerkMiddleware(); +export const startInstance = createStart(() => ({ requestMiddleware: [csrfMiddleware, auth] })); +`, + ], + [ + "an exported binding", + `import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; +${CSRF_SETUP}export const auth = clerkMiddleware(); +export const startInstance = createStart(() => ({ requestMiddleware: [csrfMiddleware, auth] })); +`, + ], +])("does not duplicate Clerk middleware registered through %s", async (_, source) => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write(join(tempDir, "src/start.ts"), source); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("skip"); + if (action?.type !== "skip") throw new Error("Expected skip action"); + expect(action.skipReason).toBe("Already has Clerk middleware"); +}); + +test("registers Clerk under its aliased import name", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { clerkMiddleware as clerk } from "@clerk/tanstack-react-start/server"; +${CSRF_SETUP}export const startInstance = createStart(() => ({ requestMiddleware: [csrfMiddleware] })); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("modify"); + if (action?.type !== "modify") throw new Error("Expected modify action"); + expect(action.content).toContain("requestMiddleware: [csrfMiddleware, clerk()]"); + expect(action.content).not.toContain("clerkMiddleware()"); + expect(action.content.match(/@clerk\/tanstack-react-start\/server/g)).toHaveLength(1); +}); + +test("resolves a middleware variable to its nearest declaration", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/start.ts"), + `import { clerkMiddleware } from "@clerk/tanstack-react-start/server"; +${CSRF_SETUP}const auth = clerkMiddleware(); +export const startInstance = createStart(() => { + let auth = customMiddleware; + return { requestMiddleware: [csrfMiddleware, auth] }; +}); +`, + ); + + const plan = await tanstackStart.scaffold(makeCtx()); + const action = plan.actions.find((item) => item.path === "src/start.ts"); + expect(action?.type).toBe("modify"); + if (action?.type !== "modify") throw new Error("Expected modify action"); + expect(action.content).toContain("requestMiddleware: [csrfMiddleware, auth, clerkMiddleware()]"); +}); + test("preserves an existing requestMiddleware array in a block-body callback", async () => { await mkdir(join(tempDir, "src"), { recursive: true }); await Bun.write( diff --git a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts index bcadc2a6e..ae73417a4 100644 --- a/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts +++ b/packages/cli-core/src/commands/init/frameworks/tanstack-start.ts @@ -305,35 +305,46 @@ function updateStartFile(content: string, startVersion: StartVersionCheck): Star if (middleware.some((element) => element === null)) { return { status: "manual", needsClerk: true }; } + // Local names the Clerk middleware is imported under, so aliases count. + const clerkImports = program.body.flatMap((statement) => + statement.type === "ImportDeclaration" && + statement.source.value === "@clerk/tanstack-react-start/server" + ? statement.specifiers.flatMap((specifier) => + specifier.type === "ImportSpecifier" && + specifier.imported.type === "Identifier" && + specifier.imported.name === "clerkMiddleware" + ? [specifier.local.name] + : [], + ) + : [], + ); + const clerkName = clerkImports[0] ?? "clerkMiddleware"; + const isClerkCall = (node: ASTNode | null | undefined): boolean => + node?.type === "CallExpression" && + node.callee.type === "Identifier" && + (node.callee.name === "clerkMiddleware" || clerkImports.includes(node.callee.name)); const callbackStatements = callback.body.type === "BlockStatement" ? callback.body.body : []; + // Resolve a middleware variable to its nearest declaration, callback scope first. const isClerkVariable = (name: string): boolean => { for (const statements of [callbackStatements, program.body]) { - const declarations = statements.flatMap((statement) => - statement.type === "VariableDeclaration" && statement.kind === "const" - ? statement.declarations.filter( - (declaration) => - declaration.id.type === "Identifier" && declaration.id.name === name, + const declarations = statements.flatMap((statement) => { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + return declaration?.type === "VariableDeclaration" + ? declaration.declarations.filter( + (declarator) => declarator.id.type === "Identifier" && declarator.id.name === name, ) - : [], - ); + : []; + }); if (declarations.length > 0) { - const declaration = declarations[0]!; - return ( - declarations.length === 1 && - declaration.init?.type === "CallExpression" && - declaration.init.callee.type === "Identifier" && - declaration.init.callee.name === "clerkMiddleware" - ); + return declarations.length === 1 && isClerkCall(declarations[0]!.init); } } return false; }; const hasClerk = middleware.some( (element) => - (element?.type === "CallExpression" && - element.callee.type === "Identifier" && - element.callee.name === "clerkMiddleware") || - (element?.type === "Identifier" && isClerkVariable(element.name)), + isClerkCall(element) || (element?.type === "Identifier" && isClerkVariable(element.name)), ); const hasImport = (source: string, name: string) => @@ -392,7 +403,7 @@ function updateStartFile(content: string, startVersion: StartVersionCheck): Star // Splice text at the AST positions instead of regenerating the module, so // the rest of the file keeps its exact formatting. const before = hasCsrf ? [] : ["csrfMiddleware"]; - const after = hasClerk ? [] : ["clerkMiddleware()"]; + const after = hasClerk ? [] : [`${clerkName}()`]; const edits: { at: number; text: string }[] = []; const first = middleware[0]; const last = middleware.at(-1); @@ -431,7 +442,7 @@ function updateStartFile(content: string, startVersion: StartVersionCheck): Star } const style = codeStyle(content); - if (!hasImport("@clerk/tanstack-react-start/server", "clerkMiddleware")) { + if (clerkImports.length === 0) { result = addNamedImport( result, "@clerk/tanstack-react-start/server",