From 83026157f57e43eafac1608cebb29b535c32f0ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 11 Jun 2026 15:31:54 +0200 Subject: [PATCH 1/3] Add implementation details to the readme --- packages/eslint-plugin/README.md | 64 ++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 3fc3721c034..d2614b6a38a 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -111,10 +111,64 @@ export default async function Page() { } ``` -Recognized checks include `!isAuthenticated`, `isAuthenticated === false`, `userId === null`, and `sessionId === null` (from `auth()` imported as `@clerk/nextjs/server`). Client components (`'use client'`) are skipped. - General protection must happen at the top of the function, but additional narrowing auth checks can happen further down. +## Implementation details + +This section describes the exact details of how the lint rule works. You normally do no need to read or understand this if you only want to use the rule. + +Within folders that are configured as protected (and that eslint covers), this rule checks: + +- No files with `'use client'` at the top - Early bailout for these +- The default export of `page`, `layout`, `template`, `default` files +- All http verb exports of `route` files (`GET`, `POST` etc - API endpoints) +- All exports of files with `'use server'` at the top (Server Functions) +- All inline functions that has `'use server'` at the top of the function (Inline Server Functions) + +Notably, it does not: + +- Check `loading` or `error` files + - These normally don't use privileged resources, but if yours do, make sure you protect them +- Check arbitrary Server Components + - Only the different page entrypoints listed above are checked + - If you are importing a Server Component doing privileged access into a non-protected page, like an admin panel on an otherwise public page, it should be guarded but the lint rule does not detect this + +At the top of the relevant async function, after any directives or TypeScript-only declarations, to count as protected the rule accepts these patterns: + +```tsx +// -- Using the default .protect() behavior -- +await auth.protect() +await (await auth()).protect() +// Any kind of variable declaration is okay +const { userId } = await auth.protect(); + +// -- Custom handling -- +const { isAuthenticated, userId, sessionId } = await auth(); +// Any of these checks are okay +// Note: For useAuth() on the client !userId can also mean +// "loading", but here it's fine +if ( + !userId || userId == null || userId === null || + !sessionId || sessionId == null || sessionId === null || + !isAuthenticated +) { + // It is fine to have arbitrary code here: + console.log('Unauthenticated'); + // To count as protected, the function needs to have an + // unconditional "exit" at the top level, these count: + return; + throw; + // The Next.js versions of these functions throw errors + // and counts as exits, note that we match these by name, + // we do not currently trace them back to the real imports + redirect(); + permanentRedirect(); + notFound(); + unauthorized(); + forbidden(); +} +``` + ## Support For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_eslint_plugin). @@ -125,9 +179,11 @@ We're open to all community contributions! Please read [our contribution guideli ## Security -`@clerk/eslint-plugin` is a static analysis aid, not a runtime guard. It's provided to help you catch missing protections and it does error on the side of caution, but there are no guarantees there might not be edge cases it fails to detect. +`@clerk/eslint-plugin` is a static analysis aid, not a runtime guard. It's provided to _help_ you catch missing protections and it does error on the side of caution, but there are no guarantees it will catch everything, there might be edge cases it does not catch. + +We aim to fix bugs leading to false negatives promptly, but they are not considered vulnerabilities and will not lead to us posting advisories. You are free to file lint rule bugs via normal [GitHub issues](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml). -_For more information and to report security issues, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._ +_For more information and to report what you think is a security issue, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._ ## License From 4f24a78e92af98e44fe6936fa0e0754124ec072e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 10 Jun 2026 19:02:33 +0200 Subject: [PATCH 2/3] Add code suggestions to lint rule --- .../eslint-plugin-auth-protect-suggestion.md | 5 + packages/eslint-plugin/README.md | 4 + ...equire-auth-protection.suggestions.test.ts | 429 ++++++++++++++++++ .../__tests__/require-auth-protection.test.ts | 57 ++- packages/eslint-plugin/src/lib/fixers.ts | 262 +++++++++++ .../src/rules/next/require-auth-protection.ts | 37 +- 6 files changed, 790 insertions(+), 4 deletions(-) create mode 100644 .changeset/eslint-plugin-auth-protect-suggestion.md create mode 100644 packages/eslint-plugin/src/__tests__/require-auth-protection.suggestions.test.ts create mode 100644 packages/eslint-plugin/src/lib/fixers.ts diff --git a/.changeset/eslint-plugin-auth-protect-suggestion.md b/.changeset/eslint-plugin-auth-protect-suggestion.md new file mode 100644 index 00000000000..27c198564e4 --- /dev/null +++ b/.changeset/eslint-plugin-auth-protect-suggestion.md @@ -0,0 +1,5 @@ +--- +'@clerk/eslint-plugin': minor +--- + +The `require-auth-protection` rule now offers an editor quick-fix suggestion for unprotected resources that inserts `await auth.protect()` at the top of the function. Suggestions are opt-in and are not applied by `eslint --fix`. diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index d2614b6a38a..f121a6eb0c3 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -113,6 +113,10 @@ export default async function Page() { General protection must happen at the top of the function, but additional narrowing auth checks can happen further down. +## Suggestions + +For each unprotected resource it flags, the rule offers an editor quick-fix suggestion that inserts `await auth.protect()` at the top of the function (making it `async` and adding the `import { auth } from '@clerk/nextjs/server'` import if needed). Suggestions are opt-in: they appear in your editor's quick-fix menu and are not applied by `eslint --fix`, since adding a protection check changes runtime behavior. + ## Implementation details This section describes the exact details of how the lint rule works. You normally do no need to read or understand this if you only want to use the rule. diff --git a/packages/eslint-plugin/src/__tests__/require-auth-protection.suggestions.test.ts b/packages/eslint-plugin/src/__tests__/require-auth-protection.suggestions.test.ts new file mode 100644 index 00000000000..11599130497 --- /dev/null +++ b/packages/eslint-plugin/src/__tests__/require-auth-protection.suggestions.test.ts @@ -0,0 +1,429 @@ +import path from 'node:path'; + +import * as tsParser from '@typescript-eslint/parser'; +import type { Linter as LinterTypes } from 'eslint'; +import { RuleTester } from 'eslint'; +import { describe, it } from 'vitest'; + +import rule from '../rules/next/require-auth-protection'; + +RuleTester.describe = describe; +RuleTester.it = it; + +// See require-auth-protection.test.ts: filenames are anchored under a synthetic +// project root whose own path contains no `/app/` segment. +const projectRoot = '/clerk/apps/dashboard'; +const abs = (p: string) => path.posix.join(projectRoot, p); + +const ruleTester = new RuleTester({ + languageOptions: { + parser: tsParser as unknown as LinterTypes.Parser, + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, +}); + +const config = { protected: ['app/**'] }; + +ruleTester.run('require-auth-protection (suggestions)', rule, { + valid: [], + invalid: [ + { + name: 'page: sync default export — flips async, inserts call and import', + code: `export default function Page() { + return
Hello
; +}`, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [ + { + messageId: 'missingProtect', + suggestions: [ + { + messageId: 'addAuthProtect', + output: `import { auth } from '@clerk/nextjs/server'; +export default async function Page() { + await auth.protect(); + return
Hello
; +}`, + }, + ], + }, + ], + }, + { + name: 'page: concise-body arrow default export — wraps in a block', + code: `export default () => null;`, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [ + { + messageId: 'missingProtect', + suggestions: [ + { + messageId: 'addAuthProtect', + output: `import { auth } from '@clerk/nextjs/server'; +export default async () => { + await auth.protect(); + return null; +};`, + }, + ], + }, + ], + }, + { + name: 'page: default-exported local identifier', + code: `function Page() { + return
Hello
; +} + +export default Page;`, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [ + { + messageId: 'missingProtect', + suggestions: [ + { + messageId: 'addAuthProtect', + output: `import { auth } from '@clerk/nextjs/server'; +async function Page() { + await auth.protect(); + return
Hello
; +} + +export default Page;`, + }, + ], + }, + ], + }, + { + name: 'route: GET function declaration and POST const arrow', + code: `export async function GET() { + return new Response('ok'); +} + +export const POST = () => new Response('ok');`, + filename: abs('app/api/widgets/route.ts'), + options: [config], + errors: [ + { + messageId: 'missingProtect', + suggestions: [ + { + messageId: 'addAuthProtect', + output: `import { auth } from '@clerk/nextjs/server'; +export async function GET() { + await auth.protect(); + return new Response('ok'); +} + +export const POST = () => new Response('ok');`, + }, + ], + }, + { + messageId: 'missingProtect', + suggestions: [ + { + messageId: 'addAuthProtect', + output: `import { auth } from '@clerk/nextjs/server'; +export async function GET() { + return new Response('ok'); +} + +export const POST = async () => { + await auth.protect(); + return new Response('ok'); +};`, + }, + ], + }, + ], + }, + { + name: 'use server module: inserts import after the directive', + code: `'use server'; + +export async function loadData() { + return []; +}`, + filename: abs('app/components/actions.ts'), + options: [config], + errors: [ + { + messageId: 'missingProtect', + suggestions: [ + { + messageId: 'addAuthProtect', + output: `'use server'; +import { auth } from '@clerk/nextjs/server'; + +export async function loadData() { + await auth.protect(); + return []; +}`, + }, + ], + }, + ], + }, + { + name: 'inline server function: inserts call after the inline directive', + code: `export async function action() { + 'use server'; + return doStuff(); +}`, + filename: abs('app/dashboard/actions.ts'), + options: [config], + errors: [ + { + messageId: 'missingProtect', + suggestions: [ + { + messageId: 'addAuthProtect', + output: `import { auth } from '@clerk/nextjs/server'; +export async function action() { + 'use server'; + await auth.protect(); + return doStuff(); +}`, + }, + ], + }, + ], + }, + { + name: 'inline server function: sync function expression also gets the async flip', + code: `const action = function () { + 'use server'; + return null; +};`, + filename: abs('app/dashboard/actions.ts'), + options: [config], + errors: [ + { + messageId: 'missingProtect', + suggestions: [ + { + messageId: 'addAuthProtect', + output: `import { auth } from '@clerk/nextjs/server'; +const action = async function () { + 'use server'; + await auth.protect(); + return null; +};`, + }, + ], + }, + ], + }, + { + name: 'inline server function: nested inline arrow keeps its indentation', + code: `export function getAction() { + const create = async () => { + 'use server'; + return null; + }; + return create; +}`, + filename: abs('app/dashboard/utils.ts'), + options: [config], + errors: [ + { + messageId: 'missingProtect', + suggestions: [ + { + messageId: 'addAuthProtect', + output: `import { auth } from '@clerk/nextjs/server'; +export function getAction() { + const create = async () => { + 'use server'; + await auth.protect(); + return null; + }; + return create; +}`, + }, + ], + }, + ], + }, + { + name: 'aliased auth import: reuses the local name, adds no import', + code: `import { auth as clerkAuth } from '@clerk/nextjs/server'; + +export default function Page() { + return null; +}`, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [ + { + messageId: 'missingProtect', + suggestions: [ + { + messageId: 'addAuthProtect', + output: `import { auth as clerkAuth } from '@clerk/nextjs/server'; + +export default async function Page() { + await clerkAuth.protect(); + return null; +}`, + }, + ], + }, + ], + }, + { + name: 'existing clerk import without auth: merges the specifier', + code: `import { currentUser } from '@clerk/nextjs/server'; + +export default function Page() { + return null; +}`, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [ + { + messageId: 'missingProtect', + suggestions: [ + { + messageId: 'addAuthProtect', + output: `import { currentUser, auth } from '@clerk/nextjs/server'; + +export default async function Page() { + await auth.protect(); + return null; +}`, + }, + ], + }, + ], + }, + { + name: 'existing await auth() destructure: merges .protect() into the call', + code: `import { auth } from '@clerk/nextjs/server'; + +export default async function Page() { + const { userId } = await auth(); + return
{userId}
; +}`, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [ + { + messageId: 'missingProtect', + suggestions: [ + { + messageId: 'addAuthProtect', + output: `import { auth } from '@clerk/nextjs/server'; + +export default async function Page() { + const { userId } = await auth.protect(); + return
{userId}
; +}`, + }, + ], + }, + ], + }, + { + name: 'existing bare await auth(): merges .protect() into the call', + code: `import { auth } from '@clerk/nextjs/server'; + +export async function GET() { + await auth(); + return new Response('ok'); +}`, + filename: abs('app/api/widgets/route.ts'), + options: [config], + errors: [ + { + messageId: 'missingProtect', + suggestions: [ + { + messageId: 'addAuthProtect', + output: `import { auth } from '@clerk/nextjs/server'; + +export async function GET() { + await auth.protect(); + return new Response('ok'); +}`, + }, + ], + }, + ], + }, + { + name: 'concise arrow awaiting auth(): merges .protect() into the call', + code: `import { auth } from '@clerk/nextjs/server'; + +export const POST = async () => await auth();`, + filename: abs('app/api/widgets/route.ts'), + options: [config], + errors: [ + { + messageId: 'missingProtect', + suggestions: [ + { + messageId: 'addAuthProtect', + output: `import { auth } from '@clerk/nextjs/server'; + +export const POST = async () => await auth.protect();`, + }, + ], + }, + ], + }, + { + name: 'aliased await auth() destructure: merges using the alias', + code: `import { auth as clerkAuth } from '@clerk/nextjs/server'; + +export default async function Page() { + const { userId } = await clerkAuth(); + return
{userId}
; +}`, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [ + { + messageId: 'missingProtect', + suggestions: [ + { + messageId: 'addAuthProtect', + output: `import { auth as clerkAuth } from '@clerk/nextjs/server'; + +export default async function Page() { + const { userId } = await clerkAuth.protect(); + return
{userId}
; +}`, + }, + ], + }, + ], + }, + { + name: 're-exported default: reported as imported, offers no suggestion', + code: `export { default } from './implementation';`, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'exportImported', suggestions: [] }], + }, + { + name: 'wrapped default export: unverifiable, offers no suggestion', + code: `import { withAuth } from '@/lib'; +import Impl from './impl'; + +export default withAuth(Impl);`, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'unverifiableExport', suggestions: [] }], + }, + ], +}); diff --git a/packages/eslint-plugin/src/__tests__/require-auth-protection.test.ts b/packages/eslint-plugin/src/__tests__/require-auth-protection.test.ts index d5a0d586356..465ab80cf34 100644 --- a/packages/eslint-plugin/src/__tests__/require-auth-protection.test.ts +++ b/packages/eslint-plugin/src/__tests__/require-auth-protection.test.ts @@ -33,6 +33,59 @@ const config = { public: ['app/(routes)/(unauthenticated)/**'], }; +// The rule offers an `addAuthProtect` suggestion for every `missingProtect` +// report, and ESLint's RuleTester requires each suggestion to declare its +// `output`. Rather than hand-maintaining a fixed snapshot on every detection +// case (this test file is about detection, not the fix), derive the expected +// suggestion output from the rule itself and attach it per error. Exact +// suggestion outputs are asserted in require-auth-protection.suggestions.test.ts. +// RuleTester still independently verifies each suggestion produces valid syntax. +const suggestionLinter = new Linter({ cwd: projectRoot }); + +function lintMessages(testCase: RuleTester.InvalidTestCase): LinterTypes.LintMessage[] { + return suggestionLinter.verify( + testCase.code, + { + files: ['**/*.{js,jsx,ts,tsx,mjs,cjs}'], + languageOptions: { + parser: tsParser as unknown as LinterTypes.Parser, + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + plugins: { '@clerk/next': { rules: { 'require-auth-protection': rule } } }, + rules: { '@clerk/next/require-auth-protection': ['error', ...((testCase.options ?? []) as unknown[])] }, + } as unknown as LinterTypes.Config, + typeof testCase.filename === 'string' ? testCase.filename : undefined, + ); +} + +function attachSuggestionOutputs(cases: RuleTester.InvalidTestCase[]): RuleTester.InvalidTestCase[] { + return cases.map(testCase => { + if (!Array.isArray(testCase.errors)) { + return testCase; + } + const messages = lintMessages(testCase).filter(m => m.ruleId === '@clerk/next/require-auth-protection'); + const errors = testCase.errors.map((error, index) => { + const suggestions = messages[index]?.suggestions; + if (typeof error === 'string' || !suggestions || suggestions.length === 0) { + return error; + } + return { + ...error, + suggestions: suggestions.map(suggestion => ({ + messageId: suggestion.messageId, + output: + testCase.code.slice(0, suggestion.fix.range[0]) + + suggestion.fix.text + + testCase.code.slice(suggestion.fix.range[1]), + })), + }; + }); + return { ...testCase, errors }; + }); +} + ruleTester.run('require-auth-protection', rule, { valid: [ { @@ -814,7 +867,7 @@ ruleTester.run('require-auth-protection', rule, { }, ], - invalid: [ + invalid: attachSuggestionOutputs([ { name: 'protected page missing protect call', code: ` @@ -1551,7 +1604,7 @@ ruleTester.run('require-auth-protection', rule, { ], errors: [{ messageId: 'missingProtect' }], }, - ], + ]), }); describe('require-auth-protection schema validation', () => { diff --git a/packages/eslint-plugin/src/lib/fixers.ts b/packages/eslint-plugin/src/lib/fixers.ts new file mode 100644 index 00000000000..fb441c0d2f7 --- /dev/null +++ b/packages/eslint-plugin/src/lib/fixers.ts @@ -0,0 +1,262 @@ +/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ +/** + * Reusable fixers that add `await auth.protect()` to a function. + * + * These are intentionally decoupled from the ESLint rule: they take a `fixer` + * and `sourceCode` plus the resolved function node and return plain + * `RuleFix[]`. The rule wires them into a suggestion today, but a later + * auto-apply script (and `@clerk/upgrade`) can reuse the exact same logic. + * + * Mirrors the operations of the original `transform-add-auth-protect` codemod: + * - ensure the function is `async` + * - insert `await auth.protect()` as the first executable statement + * - add `import { auth } from '@clerk/nextjs/server'` (or merge the `auth` + * specifier into an existing import from that source) + */ + +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; + +import type { FunctionNode } from './exports'; + +const CLERK_AUTH_SOURCE = '@clerk/nextjs/server'; + +export interface BuildAuthProtectFixesParams { + fixer: TSESLint.RuleFixer; + sourceCode: TSESLint.SourceCode; + fn: FunctionNode; + /** Local names `auth` is already imported as, from `findAuthLocalNames`. */ + authNames: Set; +} + +/** + * The local name to call `.protect()` on. Reuses an existing alias (e.g. + * `import { auth as clerkAuth }`) when present, otherwise defaults to `auth`. + */ +export function resolveAuthName(authNames: Set): string { + for (const name of authNames) { + return name; + } + return 'auth'; +} + +/** + * Build the ordered, non-overlapping set of edits that protect `fn`. All edits + * belong to a single suggestion and are applied atomically. + */ +export function buildAuthProtectFixes({ + fixer, + sourceCode, + fn, + authNames, +}: BuildAuthProtectFixesParams): TSESLint.RuleFix[] { + const program = sourceCode.ast; + const authName = resolveAuthName(authNames); + const fixes: TSESLint.RuleFix[] = []; + + // Order matters when insertions share a position. When the function is the + // first statement in the file (e.g. `function Page() {}; export default Page`), + // the import and the `async ` keyword both insert at the function's start; + // emitting the import first keeps it on its own line above the function. + const importFix = ensureAuthImportFix(fixer, sourceCode, program, authNames); + if (importFix) { + fixes.push(importFix); + } + + const asyncFix = ensureAsyncFix(fixer, sourceCode, fn); + if (asyncFix) { + fixes.push(asyncFix); + } + + fixes.push(insertProtectCallFix(fixer, sourceCode, fn, authName, authNames)); + + return fixes; +} + +/** + * If `node` is `await ()` (a zero-argument call to one of the imported + * `auth` names), return the callee identifier so it can be rewritten to + * `.protect`. Returns `null` otherwise. + * + * Used to merge protection into an existing call (`const { userId } = await + * auth()` -> `const { userId } = await auth.protect()`) instead of prepending a + * duplicate `await auth.protect();`. + */ +function mergeableAuthCallee( + node: TSESTree.Node | null | undefined, + authNames: Set, +): TSESTree.Identifier | null { + if (!node || node.type !== 'AwaitExpression') { + return null; + } + const arg = node.argument; + if (arg.type !== 'CallExpression' || arg.arguments.length > 0) { + return null; + } + if (arg.callee.type !== 'Identifier' || !authNames.has(arg.callee.name)) { + return null; + } + return arg.callee; +} + +/** + * Look for a mergeable `await ()` in the first executable statement of a + * block body — either a bare `await auth();` or the initializer of the first + * declarator (`const { userId } = await auth();`). + */ +function firstStatementAuthCallee(stmt: TSESTree.Statement, authNames: Set): TSESTree.Identifier | null { + if (stmt.type === 'ExpressionStatement') { + return mergeableAuthCallee(stmt.expression, authNames); + } + if (stmt.type === 'VariableDeclaration') { + const first = stmt.declarations[0]; + return first ? mergeableAuthCallee(first.init, authNames) : null; + } + return null; +} + +function getLineIndent(sourceCode: TSESLint.SourceCode, node: TSESTree.Node): string { + const line = sourceCode.lines[node.loc.start.line - 1] ?? ''; + const match = /^\s*/.exec(line); + return match ? match[0] : ''; +} + +function ensureAsyncFix( + fixer: TSESLint.RuleFixer, + sourceCode: TSESLint.SourceCode, + fn: FunctionNode, +): TSESLint.RuleFix | null { + if (fn.async) { + return null; + } + const firstToken = sourceCode.getFirstToken(fn); + if (!firstToken) { + return null; + } + return fixer.insertTextBefore(firstToken, 'async '); +} + +function insertProtectCallFix( + fixer: TSESLint.RuleFixer, + sourceCode: TSESLint.SourceCode, + fn: FunctionNode, + authName: string, + authNames: Set, +): TSESLint.RuleFix { + const call = `await ${authName}.protect();`; + const body = fn.body; + + // Concise-body arrow (`() => expr`) — merge into an existing `await auth()` + // body, otherwise wrap in a block so we can insert. + if (body.type !== 'BlockStatement') { + const conciseCallee = mergeableAuthCallee(body, authNames); + if (conciseCallee) { + return fixer.replaceText(conciseCallee, `${conciseCallee.name}.protect`); + } + const fnIndent = getLineIndent(sourceCode, fn); + const inner = `${fnIndent} `; + const exprText = sourceCode.getText(body); + return fixer.replaceText(body, `{\n${inner}${call}\n${inner}return ${exprText};\n${fnIndent}}`); + } + + const stmts = body.body; + + // Skip a leading directive prologue (e.g. an inline `'use server'`): inserting + // before it would demote the directive to an ordinary expression statement. + let lastDirective: TSESTree.Statement | null = null; + let firstExecIdx = 0; + for (const stmt of stmts) { + if (stmt.type === 'ExpressionStatement' && stmt.directive) { + lastDirective = stmt; + firstExecIdx++; + } else { + break; + } + } + + // If the first executable statement already awaits `auth()`, rewrite that call + // to `auth.protect()` rather than prepending a duplicate protection call. + const firstExec = stmts[firstExecIdx]; + if (firstExec) { + const mergeCallee = firstStatementAuthCallee(firstExec, authNames); + if (mergeCallee) { + return fixer.replaceText(mergeCallee, `${mergeCallee.name}.protect`); + } + } + + if (lastDirective) { + const indent = getLineIndent(sourceCode, lastDirective); + return fixer.insertTextAfter(lastDirective, `\n${indent}${call}`); + } + + const firstStmt = stmts[0]; + if (firstStmt) { + const indent = getLineIndent(sourceCode, firstStmt); + return fixer.insertTextBefore(firstStmt, `${call}\n${indent}`); + } + + // Empty block body. + const openBrace = sourceCode.getFirstToken(body); + const indent = `${getLineIndent(sourceCode, fn)} `; + if (openBrace) { + return fixer.insertTextAfter(openBrace, `\n${indent}${call}`); + } + return fixer.insertTextBefore(body, `${call}\n`); +} + +function ensureAuthImportFix( + fixer: TSESLint.RuleFixer, + sourceCode: TSESLint.SourceCode, + program: TSESTree.Program, + authNames: Set, +): TSESLint.RuleFix | null { + // `auth` is already imported (possibly aliased) — reuse it, no import change. + if (authNames.size > 0) { + return null; + } + + // Past the guard above, no `auth` import exists, so the specifier is always + // the unaliased `auth`. (Callers reuse an existing alias for the + // `.protect()` call, but a fresh import never introduces one.) + + // Merge into an existing value import from `@clerk/nextjs/server` when it has + // a named-specifier list we can extend. + for (const stmt of program.body) { + if (stmt.type !== 'ImportDeclaration') { + continue; + } + if (stmt.source.value !== CLERK_AUTH_SOURCE || stmt.importKind === 'type') { + continue; + } + const named = stmt.specifiers.filter((spec): spec is TSESTree.ImportSpecifier => spec.type === 'ImportSpecifier'); + const last = named[named.length - 1]; + if (last) { + return fixer.insertTextAfter(last, ', auth'); + } + break; + } + + const importText = `import { auth } from '${CLERK_AUTH_SOURCE}';`; + const stmts = program.body; + + // Insert after a leading directive prologue (module-level `'use server'` / + // `'use client'`), otherwise before the first statement. + let lastDirective: TSESTree.ExpressionStatement | null = null; + for (const stmt of stmts) { + if (stmt.type === 'ExpressionStatement' && stmt.directive) { + lastDirective = stmt; + } else { + break; + } + } + + if (lastDirective) { + return fixer.insertTextAfter(lastDirective, `\n${importText}`); + } + + const firstStmt = stmts[0]; + if (firstStmt) { + return fixer.insertTextBefore(firstStmt, `${importText}\n`); + } + + return fixer.insertTextAfterRange([0, 0], `${importText}\n`); +} diff --git a/packages/eslint-plugin/src/rules/next/require-auth-protection.ts b/packages/eslint-plugin/src/rules/next/require-auth-protection.ts index cf7ea2e2f87..0a4cd9ce65d 100644 --- a/packages/eslint-plugin/src/rules/next/require-auth-protection.ts +++ b/packages/eslint-plugin/src/rules/next/require-auth-protection.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ -import type { TSESTree } from '@typescript-eslint/utils'; +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; import type { Rule } from 'eslint'; import type { ExportTarget, FunctionNode } from '../../lib/exports'; @@ -11,11 +11,17 @@ import { isClientModule, isServerFunctionModule, } from '../../lib/file-info'; +import { buildAuthProtectFixes } from '../../lib/fixers'; import type { ClassifyOptions } from '../../lib/match-folders'; import { classifyFolder, hasDescendantsMatching } from '../../lib/match-folders'; import { findAuthLocalNames, hasProtectAtTop } from '../../lib/protection-checks'; -export type MessageId = 'missingProtect' | 'exportImported' | 'unverifiableExport' | 'unlistedMixedScopeLayout'; +export type MessageId = + | 'missingProtect' + | 'addAuthProtect' + | 'exportImported' + | 'unverifiableExport' + | 'unlistedMixedScopeLayout'; export interface RuleOptions { /** Glob patterns that mark folders as protected. */ @@ -31,6 +37,7 @@ const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS' const rule: Rule.RuleModule = { meta: { type: 'problem', + hasSuggestions: true, docs: { description: 'Require `await auth.protect()` in App Router resources under protected folders', }, @@ -58,6 +65,7 @@ const rule: Rule.RuleModule = { messages: { missingProtect: 'Expected `await auth.protect()` at the top of {{subject}} in a folder configured as protected. Add the call to the top of the function, move the file into a public folder, or configure this folder as public.', + addAuthProtect: 'Add `await auth.protect()` to the top of this {{subject}}.', exportImported: "This {{subject}} is exported from '{{source}}'. The rule cannot follow imports across files. Add a wrapper with `await auth.protect()`, or ensure the imported function calls it and add an eslint-disable comment with a reason.", unverifiableExport: @@ -209,6 +217,7 @@ function checkMissingProtect( node: reportNode, messageId: 'missingProtect', data: { subject }, + suggest: buildAddAuthProtectSuggestion(context, target.node, subject, authNames), }); } return; @@ -317,6 +326,30 @@ function checkInlineServerFunction( node: fn, messageId: 'missingProtect', data: { subject: 'Inline Server Function' }, + suggest: buildAddAuthProtectSuggestion(context, fn, 'Inline Server Function', authNames), }); } } + +function buildAddAuthProtectSuggestion( + context: Rule.RuleContext, + fn: FunctionNode, + subject: string, + authNames: Set, +): Rule.SuggestionReportDescriptor[] { + const sourceCode = context.sourceCode; + return [ + { + messageId: 'addAuthProtect', + data: { subject }, + fix(fixer) { + return buildAuthProtectFixes({ + fixer: fixer as unknown as TSESLint.RuleFixer, + sourceCode: sourceCode as unknown as TSESLint.SourceCode, + fn, + authNames, + }) as unknown as Rule.Fix[]; + }, + }, + ]; +} From 496edb56efc462f07b78f1f25b832837afac9e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 11 Jun 2026 12:54:25 +0200 Subject: [PATCH 3/3] Add fix function and cli command --- .../eslint-plugin-fix-auth-protect-command.md | 5 + packages/eslint-plugin/README.md | 28 +++ packages/eslint-plugin/package.json | 13 + .../src/__tests__/fix-auth-protection.test.ts | 178 ++++++++++++++ packages/eslint-plugin/src/cli.ts | 121 +++++++++ .../eslint-plugin/src/fix-auth-protection.ts | 230 ++++++++++++++++++ packages/eslint-plugin/tsdown.config.mts | 5 +- 7 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 .changeset/eslint-plugin-fix-auth-protect-command.md create mode 100644 packages/eslint-plugin/src/__tests__/fix-auth-protection.test.ts create mode 100644 packages/eslint-plugin/src/cli.ts create mode 100644 packages/eslint-plugin/src/fix-auth-protection.ts diff --git a/.changeset/eslint-plugin-fix-auth-protect-command.md b/.changeset/eslint-plugin-fix-auth-protect-command.md new file mode 100644 index 00000000000..f36f25c3e9b --- /dev/null +++ b/.changeset/eslint-plugin-fix-auth-protect-command.md @@ -0,0 +1,5 @@ +--- +'@clerk/eslint-plugin': minor +--- + +Add a bulk auto-fixer for the `require-auth-protection` rule, available both as the `clerk-fix-auth-protection` command and as a `fixAuthProtection()` function exported from `@clerk/eslint-plugin/fix-auth-protection`. It lints with your existing ESLint config and applies the `await auth.protect()` suggestion to every resource it can safely fix, reporting the rest as needing manual attention. Supports `--dry-run`. Additionally, when a flagged function already starts with `await auth()`, the fix now rewrites that call to `await auth.protect()` instead of inserting a duplicate call. diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index f121a6eb0c3..b7575171326 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -117,6 +117,34 @@ General protection must happen at the top of the function, but additional narrow For each unprotected resource it flags, the rule offers an editor quick-fix suggestion that inserts `await auth.protect()` at the top of the function (making it `async` and adding the `import { auth } from '@clerk/nextjs/server'` import if needed). Suggestions are opt-in: they appear in your editor's quick-fix menu and are not applied by `eslint --fix`, since adding a protection check changes runtime behavior. +## Bulk auto-fixing + +> [!WARNING] +> Applying these fixes changes the runtime behavior of your application — `await auth.protect()` enforces authentication where there potentially was none, or might override custom auth checks that were already in place. Always review the changes and test your application afterwards. + +Because the protection insertion is a suggestion rather than an autofix, `eslint --fix` deliberately won't apply it. To apply it across many files at once, use the bundled command, which lints with your existing ESLint config (so your protected/public globs are honored) and applies the suggestion to every resource it can safely fix: + +```sh +# Fix everything under the current directory +npx clerk-fix-auth-protection + +# Scope it, or preview without writing +npx clerk-fix-auth-protection "app/**" +npx clerk-fix-auth-protection --dry-run +``` + +Resources the rule can't safely fix on its own (imported/wrapped exports, unacknowledged mixed-scope layouts) are listed as needing manual attention, and the command exits non-zero when any remain (or when `--dry-run` would make changes), so it can gate CI. + +The same logic is available programmatically: + +```ts +import { fixAuthProtection } from '@clerk/eslint-plugin/fix-auth-protection'; + +const { fixed, unresolved } = await fixAuthProtection({ + patterns: ['app/**'], + dryRun: false, +}); + ## Implementation details This section describes the exact details of how the lint rule works. You normally do no need to read or understand this if you only want to use the rule. diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 8d5b1f8c51d..762a68cc8b7 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -35,9 +35,22 @@ "default": "./dist/index.js" } }, + "./fix-auth-protection": { + "import": { + "types": "./dist/fix-auth-protection.d.mts", + "default": "./dist/fix-auth-protection.mjs" + }, + "require": { + "types": "./dist/fix-auth-protection.d.ts", + "default": "./dist/fix-auth-protection.js" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.js", + "bin": { + "clerk-fix-auth-protection": "./dist/cli.js" + }, "files": [ "dist" ], diff --git a/packages/eslint-plugin/src/__tests__/fix-auth-protection.test.ts b/packages/eslint-plugin/src/__tests__/fix-auth-protection.test.ts new file mode 100644 index 00000000000..7080d3c98c7 --- /dev/null +++ b/packages/eslint-plugin/src/__tests__/fix-auth-protection.test.ts @@ -0,0 +1,178 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import * as tsParser from '@typescript-eslint/parser'; +import { ESLint, type Linter as LinterTypes } from 'eslint'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { fixAuthProtection } from '../fix-auth-protection'; +import rule from '../rules/next/require-auth-protection'; + +// Register the rule directly (rather than importing the `next` plugin barrel, +// which references the build-time `PACKAGE_VERSION` global) so the runner sees +// a `.../require-auth-protection` rule id. +function createESLint(cwd: string): ESLint { + return new ESLint({ + cwd, + overrideConfigFile: true, + overrideConfig: { + files: ['**/*.{ts,tsx}'], + plugins: { '@clerk/next': { rules: { 'require-auth-protection': rule } } }, + languageOptions: { + parser: tsParser as unknown as LinterTypes.Parser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + rules: { + '@clerk/next/require-auth-protection': ['error', { protected: ['app/**'], public: ['app/sign-in/**'] }], + }, + }, + }); +} + +let projectRoot: string; + +async function write(relPath: string, contents: string): Promise { + const abs = path.join(projectRoot, relPath); + await mkdir(path.dirname(abs), { recursive: true }); + await writeFile(abs, contents, 'utf8'); + return abs; +} + +beforeEach(async () => { + projectRoot = await mkdtemp(path.join(tmpdir(), 'clerk-fix-auth-')); +}); + +afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); +}); + +describe('fixAuthProtection', () => { + it('protects flagged resources and leaves public/protected ones alone', async () => { + const page = await write( + 'app/dashboard/page.tsx', + `export default function Page() { + return
Hello
; +} +`, + ); + const signIn = await write( + 'app/sign-in/page.tsx', + `export default function Page() { + return
Sign in
; +} +`, + ); + const alreadyProtected = await write( + 'app/settings/page.tsx', + `import { auth } from '@clerk/nextjs/server'; + +export default async function Page() { + await auth.protect(); + return
Settings
; +} +`, + ); + + const result = await fixAuthProtection({ cwd: projectRoot, patterns: ['.'], eslint: createESLint(projectRoot) }); + + expect(result.unresolved).toEqual([]); + expect(result.fixed).toEqual([{ filePath: page, protections: 1 }]); + + expect(await readFile(page, 'utf8')).toBe(`import { auth } from '@clerk/nextjs/server'; +export default async function Page() { + await auth.protect(); + return
Hello
; +} +`); + // Public + already-protected files are untouched. + expect(await readFile(signIn, 'utf8')).toContain('Sign in'); + expect(await readFile(signIn, 'utf8')).not.toContain('auth.protect'); + expect(await readFile(alreadyProtected, 'utf8')).toMatch(/await auth\.protect\(\);[\s\S]*Settings/); + }); + + it('protects multiple resources in a single file (shared import added once)', async () => { + const route = await write( + 'app/api/widgets/route.ts', + `export async function GET() { + return new Response('ok'); +} + +export async function POST() { + return new Response('ok'); +} +`, + ); + + const result = await fixAuthProtection({ cwd: projectRoot, patterns: ['.'], eslint: createESLint(projectRoot) }); + + expect(result.fixed).toEqual([{ filePath: route, protections: 2 }]); + + const output = await readFile(route, 'utf8'); + expect(output).toBe(`import { auth } from '@clerk/nextjs/server'; +export async function GET() { + await auth.protect(); + return new Response('ok'); +} + +export async function POST() { + await auth.protect(); + return new Response('ok'); +} +`); + // The import is added exactly once even though two resources were fixed. + expect(output.match(/@clerk\/nextjs\/server/g)).toHaveLength(1); + }); + + it('merges into an existing await auth() call instead of duplicating it', async () => { + const page = await write( + 'app/dashboard/page.tsx', + `import { auth } from '@clerk/nextjs/server'; + +export default async function Page() { + const { userId } = await auth(); + return
{userId}
; +} +`, + ); + + await fixAuthProtection({ cwd: projectRoot, patterns: ['.'], eslint: createESLint(projectRoot) }); + + expect(await readFile(page, 'utf8')).toBe(`import { auth } from '@clerk/nextjs/server'; + +export default async function Page() { + const { userId } = await auth.protect(); + return
{userId}
; +} +`); + }); + + it('does not write files in dryRun but still reports them', async () => { + const original = `export default function Page() { + return
Hello
; +} +`; + const page = await write('app/dashboard/page.tsx', original); + + const result = await fixAuthProtection({ + cwd: projectRoot, + patterns: ['.'], + dryRun: true, + eslint: createESLint(projectRoot), + }); + + expect(result.fixed).toEqual([{ filePath: page, protections: 1 }]); + expect(await readFile(page, 'utf8')).toBe(original); + }); + + it('reports resources that have no safe automatic fix as unresolved', async () => { + const reexport = await write('app/dashboard/page.tsx', `export { default } from './impl';\n`); + + const result = await fixAuthProtection({ cwd: projectRoot, patterns: ['.'], eslint: createESLint(projectRoot) }); + + expect(result.fixed).toEqual([]); + expect(result.unresolved).toHaveLength(1); + expect(result.unresolved[0].filePath).toBe(reexport); + expect(result.unresolved[0].issues[0].message).toContain("exported from './impl'"); + }); +}); diff --git a/packages/eslint-plugin/src/cli.ts b/packages/eslint-plugin/src/cli.ts new file mode 100644 index 00000000000..5753351fda0 --- /dev/null +++ b/packages/eslint-plugin/src/cli.ts @@ -0,0 +1,121 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { parseArgs } from 'node:util'; + +import { fixAuthProtection } from './fix-auth-protection'; + +function relative(filePath: string): string { + return path.relative(process.cwd(), filePath) || filePath; +} + +const HELP = `clerk-fix-auth-protection + +Apply the @clerk/eslint-plugin \`require-auth-protection\` rule's +\`await auth.protect()\` suggestions across your project. Uses your existing +ESLint config (so the protected/public folder globs are honored). + +Usage + clerk-fix-auth-protection [patterns...] [options] + +Arguments + patterns Files, directories, or globs to scan (default: ".") + +Options + --dry-run Report what would change without writing any files + -h, --help Show this help + +Examples + clerk-fix-auth-protection + clerk-fix-auth-protection "app/**" --dry-run +`; + +function pluralize(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? '' : 's'}`; +} + +async function main(): Promise { + const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + 'dry-run': { type: 'boolean' }, + help: { type: 'boolean', short: 'h' }, + }, + }); + + if (values.help) { + console.log(HELP); + return 0; + } + + const patterns = positionals.length > 0 ? positionals : ['.']; + const dryRun = Boolean(values['dry-run']); + const verb = dryRun ? 'Would protect' : 'Protected'; + + console.log(`Scanning: ${patterns.join(', ')}`); + + const { fixed, unresolved } = await fixAuthProtection({ + patterns, + dryRun, + onConfigResolved(configFile) { + console.log(`Config: ${configFile ? relative(configFile) : '(resolved by ESLint from the working directory)'}`); + console.log(''); + console.log('Scanning for unprotected resources…'); + console.log('This lints your whole project, so it can take a while on large codebases.'); + }, + onScanComplete(fileCount) { + if (fileCount === 0) { + return; + } + console.log(''); + console.log(`Found ${pluralize(fileCount, 'file')} to update. ${dryRun ? 'Previewing' : 'Applying'} fixes…`); + }, + onFileFixed(file) { + console.log(` ${verb} ${relative(file.filePath)} (${pluralize(file.protections, 'resource')})`); + }, + }); + + if (fixed.length === 0 && unresolved.length === 0) { + console.log(''); + console.log('No unprotected resources found. Nothing to do.'); + return 0; + } + + if (unresolved.length > 0) { + console.log(''); + console.log('Needs manual attention (no safe automatic fix):'); + for (const file of unresolved) { + for (const issue of file.issues) { + console.log(` ${relative(file.filePath)}:${issue.line}:${issue.column} ${issue.message}`); + } + } + } + + const totalProtections = fixed.reduce((sum, file) => sum + file.protections, 0); + console.log(''); + console.log( + `${verb} ${pluralize(totalProtections, 'resource')} across ${pluralize(fixed.length, 'file')}.` + + (dryRun ? ' Run without --dry-run to apply.' : ''), + ); + + if (fixed.length > 0) { + console.log(''); + console.log( + 'Warning: Adding `await auth.protect()` changes your application\u2019s runtime behavior \u2014 it enforces', + ); + console.log('authentication where there potentially was none, or might override custom auth checks that were'); + console.log('already in place. Always review the changes and test your application.'); + } + + // Non-zero when there is still work to do (manual fixes, or pending changes in + // a dry run) so CI can gate on it. + return unresolved.length > 0 || (dryRun && fixed.length > 0) ? 1 : 0; +} + +main() + .then(code => { + process.exitCode = code; + }) + .catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); diff --git a/packages/eslint-plugin/src/fix-auth-protection.ts b/packages/eslint-plugin/src/fix-auth-protection.ts new file mode 100644 index 00000000000..085ec2a59aa --- /dev/null +++ b/packages/eslint-plugin/src/fix-auth-protection.ts @@ -0,0 +1,230 @@ +/** + * Programmatic auto-fixer for the `require-auth-protection` rule. + * + * The rule exposes its `await auth.protect()` insertion as an opt-in + * *suggestion* rather than an autofix, so `eslint --fix` deliberately leaves it + * alone (adding a protection check changes runtime behavior). This runner lets + * you apply those suggestions in bulk on demand — from a script via + * `fixAuthProtection()` or from the terminal via the `clerk-fix-auth-protection` + * command. + * + * It works by linting with the consumer's own ESLint config (so the + * protected/public folder globs are honored) and applying the rule's + * `addAuthProtect` suggestion to each flagged resource. Resources the rule + * cannot safely fix (imported/wrapped exports, unacknowledged mixed-scope + * layouts) are reported back as `unresolved` for manual follow-up. + */ + +import { readFile, writeFile } from 'node:fs/promises'; + +import { ESLint, type Linter } from 'eslint'; + +const RULE_NAME = 'require-auth-protection'; +const SUGGESTION_MESSAGE_ID = 'addAuthProtect'; +const UNFIXABLE_MESSAGE_IDS = new Set(['exportImported', 'unverifiableExport', 'unlistedMixedScopeLayout']); + +export interface FixAuthProtectionOptions { + /** File, directory, or glob patterns to scan. Defaults to `['.']`. */ + patterns?: string[]; + /** Working directory ESLint resolves config and files against. Defaults to `process.cwd()`. */ + cwd?: string; + /** Compute the changes without writing them to disk. */ + dryRun?: boolean; + /** + * Advanced/escape hatch: a pre-configured ESLint instance to lint with. When + * omitted, a default `new ESLint({ cwd })` is used, which discovers the + * consumer's flat config. Mainly useful for tests. + */ + eslint?: ESLint; + /** + * Called before scanning with the path to the ESLint config file that will be + * used (or `undefined` when none is found / an instance was injected). + */ + onConfigResolved?: (configFilePath: string | undefined) => void; + /** + * Called once linting finishes and per-file fixing begins, with the number of + * files that have flagged resources. Useful for reporting progress, since the + * initial lint can be slow on large projects. + */ + onScanComplete?: (fileCount: number) => void; + /** Called after each file is fixed (or, in `dryRun`, would be fixed). */ + onFileFixed?: (file: FixedFile) => void; +} + +export interface FixedFile { + filePath: string; + /** Number of resources that had `await auth.protect()` applied. */ + protections: number; +} + +export interface UnresolvedIssue { + line: number; + column: number; + message: string; +} + +export interface UnresolvedFile { + filePath: string; + issues: UnresolvedIssue[]; +} + +export interface FixAuthProtectionResult { + /** Files that were (or, in `dryRun`, would be) modified. */ + fixed: FixedFile[]; + /** Files with flagged resources that have no safe automatic fix. */ + unresolved: UnresolvedFile[]; +} + +type Fix = { range: [number, number]; text: string }; + +function isAuthProtectionRule(ruleId: string | null): boolean { + // The plugin can be registered under any namespace (e.g. `@clerk/next/...`), + // so match on the rule name rather than a fixed, fully-qualified id. + return ruleId === RULE_NAME || (ruleId?.endsWith(`/${RULE_NAME}`) ?? false); +} + +function collectSuggestionFixes(messages: Linter.LintMessage[]): Fix[] { + const fixes: Fix[] = []; + for (const message of messages) { + if (!isAuthProtectionRule(message.ruleId)) { + continue; + } + const suggestion = message.suggestions?.find(s => s.messageId === SUGGESTION_MESSAGE_ID); + if (suggestion?.fix) { + fixes.push({ range: [suggestion.fix.range[0], suggestion.fix.range[1]], text: suggestion.fix.text }); + } + } + return fixes; +} + +/** + * Apply as many non-overlapping fixes as possible in a single pass, mirroring + * ESLint's own `SourceCodeFixer`: sort by position and skip any fix that starts + * before the previous one ended. Overlapping fixes are left for a later pass. + */ +function applyFixes(source: string, fixes: Fix[]): { output: string; applied: number } { + const sorted = [...fixes].sort((a, b) => a.range[0] - b.range[0] || a.range[1] - b.range[1]); + let output = ''; + let lastPos = 0; + let applied = 0; + for (const fix of sorted) { + const [start, end] = fix.range; + if (start < lastPos) { + continue; + } + output += source.slice(lastPos, start) + fix.text; + lastPos = end; + applied++; + } + output += source.slice(lastPos); + return { output, applied }; +} + +async function applyFileFixes( + eslint: ESLint, + filePath: string, + source: string, +): Promise<{ output: string; applied: number }> { + // Applying a file's suggestions should converge in at most two passes: the first pass + // fixes one resource and adds the shared top-of-file `auth` import, after which + // every remaining resource is independent and applied in the second pass. + // We allow up to 10 passes to allow for unaccounted for edge cases, or future + // changes to the fixer, but throw an error if it fails to converge. + const MAX_FIX_PASSES = 10; + + let current = source; + let total = 0; + let passes = 0; + // Run one extra time so we can throw an error if the fixes don't converge. + for (let i = 0; i < MAX_FIX_PASSES + 1; i += 1) { + const [result] = await eslint.lintText(current, { filePath }); + if (!result) { + break; + } + const fixes = collectSuggestionFixes(result.messages); + if (fixes.length === 0) { + break; + } + if (passes >= MAX_FIX_PASSES) { + throw new Error( + `Auth-protect fixes for ${filePath} did not converge after ${MAX_FIX_PASSES} passes. ` + + 'This is unexpected; please report it at https://github.com/clerk/javascript/issues.', + ); + } + const { output, applied } = applyFixes(current, fixes); + if (applied === 0) { + break; + } + current = output; + total += applied; + passes += 1; + } + return { output: current, applied: total }; +} + +/** + * Lint the given patterns with the consumer's ESLint config and apply the + * `require-auth-protection` rule's `await auth.protect()` suggestions to every + * resource it can safely fix. + */ +export async function fixAuthProtection(options: FixAuthProtectionOptions = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + const patterns = options.patterns && options.patterns.length > 0 ? options.patterns : ['.']; + const dryRun = options.dryRun ?? false; + + // Only run our rule. The consumer's config (and its protected/public globs) + // still applies, but skipping every other rule avoids the cost of linting the + // whole project with the full ruleset on each pass. + const eslint = options.eslint ?? new ESLint({ cwd, ruleFilter: ({ ruleId }) => isAuthProtectionRule(ruleId) }); + + if (options.onConfigResolved) { + let configFile: string | undefined; + try { + configFile = await eslint.findConfigFile(); + } catch { + configFile = undefined; + } + options.onConfigResolved(configFile); + } + + const results = await eslint.lintFiles(patterns); + + const fixed: FixedFile[] = []; + const unresolved: UnresolvedFile[] = []; + + const flaggedResults = results.filter(result => + result.messages.some(message => isAuthProtectionRule(message.ruleId)), + ); + options.onScanComplete?.(flaggedResults.length); + + for (const result of flaggedResults) { + const ruleMessages = result.messages.filter(message => isAuthProtectionRule(message.ruleId)); + + const hasFixable = ruleMessages.some( + message => message.suggestions?.some(s => s.messageId === SUGGESTION_MESSAGE_ID) ?? false, + ); + if (hasFixable) { + const source = await readFile(result.filePath, 'utf8'); + const { output, applied } = await applyFileFixes(eslint, result.filePath, source); + if (applied > 0) { + if (!dryRun) { + await writeFile(result.filePath, output, 'utf8'); + } + const fixedFile = { filePath: result.filePath, protections: applied }; + fixed.push(fixedFile); + options.onFileFixed?.(fixedFile); + } + } + + // Messages without a suggestion (imported/wrapped exports, mixed-scope + // layouts) need a human; surface them so they aren't silently skipped. + const issues = ruleMessages + .filter(message => UNFIXABLE_MESSAGE_IDS.has(message.messageId ?? '')) + .map(message => ({ line: message.line, column: message.column, message: message.message })); + if (issues.length > 0) { + unresolved.push({ filePath: result.filePath, issues }); + } + } + + return { fixed, unresolved }; +} diff --git a/packages/eslint-plugin/tsdown.config.mts b/packages/eslint-plugin/tsdown.config.mts index b3e4d4b0287..a897afffb61 100644 --- a/packages/eslint-plugin/tsdown.config.mts +++ b/packages/eslint-plugin/tsdown.config.mts @@ -3,13 +3,16 @@ import { defineConfig } from 'tsdown'; import pkgJson from './package.json' with { type: 'json' }; export default defineConfig({ - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/fix-auth-protection.ts', 'src/cli.ts'], format: ['cjs', 'esm'], fixedExtension: false, clean: true, minify: false, sourcemap: true, dts: true, + // `eslint` is a peer dependency resolved from the consumer's install; never + // bundle it into the fix runner / CLI. + external: ['eslint'], define: { PACKAGE_VERSION: `"${pkgJson.version}"`, },