diff --git a/.changeset/tanstack-start-csrf.md b/.changeset/tanstack-start-csrf.md new file mode 100644 index 000000000..149b738d9 --- /dev/null +++ b/.changeset/tanstack-start-csrf.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +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 de2eb7cf3..04eb08488 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 | -| ------ | -------------------------- | ------------------------------------------- | -| 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` | 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, 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 d3c384ee3..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 @@ -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.10" }, envFile: ".env", ...overrides, }; @@ -64,7 +64,515 @@ 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('ctx.handlerType === "serverFn"'); + 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( + join(tempDir, "src/start.ts"), + `import { createStart, createCsrfMiddleware } from "@tanstack/react-start"; +import { ClerkProvider } from "@clerk/tanstack-react-start"; + +const csrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.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("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); +}); + +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( + join(tempDir, "src/start.ts"), + `import { createStart, createCsrfMiddleware } from "@tanstack/react-start"; +const csrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }); +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: [csrfMiddleware, clerkMiddleware()]"); + expect(action.content).toContain("createCsrfMiddleware"); + 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"); + 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 () => { + 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("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("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 { 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("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("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( + 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()); + 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 ea07eced1..ae73417a4 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,7 @@ -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { detectCodeFormat, parseModule } from "magicast"; +import type { ASTNode } from "magicast"; +import { gte, minVersion, valid, validRange } from "semver"; import { addBootstrapHeader, authComponentName, @@ -6,8 +9,8 @@ import { findFirstFile, findFirstDirMatch, hasTailwindStyles, - hasClerkImport, indentBlock, + insertAfterLastImport, jsxAuthComponentMarkup, jsxExt, safeAddImport, @@ -20,6 +23,27 @@ 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[]; +}; + +type StartFileUpdate = + | { status: "manual"; needsClerk: boolean } + | { status: "upgrade"; needsClerk: boolean } + | { status: "unverified"; needsClerk: boolean } + | { status: "unchanged"; content: string } + | { status: "modified"; content: string; addedCsrf: boolean }; + const START_FILE_CANDIDATES = [ "src/start.ts", "src/start.tsx", @@ -122,53 +146,403 @@ 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"; + +const csrfMiddleware = createCsrfMiddleware({ + filter: (ctx) => ctx.handlerType === "serverFn", +}); export const startInstance = createStart(() => { return { - requestMiddleware: [clerkMiddleware()], + requestMiddleware: [csrfMiddleware, clerkMiddleware()], }; }); `; } +/** 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 existing = [...code.matchAll(/^import \{([^}\n]*)\} from (["'])([^"'\n]+)\2/gm)].find( + (match) => match[3] === source, + ); + 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; + 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); + 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 { status: "manual", needsClerk: true }; + + const callback = calls[0]!.arguments[0]; + if (!callback || callback.type !== "ArrowFunctionExpression") { + return { status: "manual", needsClerk: true }; + } + 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 || config.properties.some((property) => property.type === "SpreadElement")) { + return { status: "manual", needsClerk: true }; + } + + 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 (properties.length > 1) return { status: "manual", needsClerk: true }; + const property = properties[0]; + if ( + property && + (property.type !== "ObjectProperty" || property.value.type !== "ArrayExpression") + ) { + return { status: "manual", needsClerk: true }; + } + const array = property?.value.type === "ArrayExpression" ? property.value : null; + const middleware = array?.elements ?? []; + 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) => { + 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) { + return declarations.length === 1 && isClerkCall(declarations[0]!.init); + } + } + return false; + }; + const hasClerk = middleware.some( + (element) => + isClerkCall(element) || (element?.type === "Identifier" && isClerkVariable(element.name)), + ); + + 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 (!hasCsrf) { + 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 ( + (middleware.length > 0 && !(middleware.length === 1 && hasClerk)) || + content.includes("csrfMiddleware") || + content.includes("createCsrfMiddleware") + ) { + 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 ? [] : [`${clerkName}()`]; + 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)}` }); + } + } 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} ` }, + ); + } + 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); + } + + const style = codeStyle(content); + if (clerkImports.length === 0) { + result = addNamedImport( + result, + "@clerk/tanstack-react-start/server", + "clerkMiddleware", + style, + ); + } + if (!hasCsrf) { + 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 { + return { status: "manual", needsClerk: true }; + } +} + async function scaffoldStartServer( ctx: ProjectContext, baseDir: TanstackBaseDir, -): Promise { +): 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 { - path: newPath, - type: "create", - content: newStartFileContent(), - description: "Create start.ts with clerkMiddleware", + action: { + path: newPath, + type: "create", + content: newStartFileContent(), + description: "Create start.ts with CSRF and Clerk middleware", + }, + postInstructions: [], }; } const content = await Bun.file(join(ctx.cwd, serverPath)).text(); + const update = updateStartFile(content, startVersion); + const postInstructions: string[] = []; - if (hasClerkImport(content)) { - return { type: "skip", path: serverPath, skipReason: "Already has Clerk middleware" }; - } - - 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 (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 !== "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`, ); + return { + action: { + type: "skip", + path: serverPath, + skipReason: update.needsClerk + ? "Could not safely add Clerk to requestMiddleware — add clerkMiddleware() manually" + : "Could not safely add CSRF middleware automatically", + }, + postInstructions, + }; + } + if (update.status === "unchanged") { + return { + action: { type: "skip", path: serverPath, skipReason: "Already has Clerk middleware" }, + postInstructions, + }; } return { - path: serverPath, - type: "modify", - content: newContent, - description: "Add clerkMiddleware to request middleware", + action: { + path: serverPath, + type: "modify", + content: update.content, + description: update.addedCsrf + ? "Add CSRF middleware before Clerk in requestMiddleware" + : "Add clerkMiddleware to request middleware", + }, + postInstructions, }; } @@ -215,16 +589,16 @@ 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[] = []; + const postInstructions = [...serverResult.postInstructions]; if (!rootAction) { postInstructions.push(