diff --git a/.changeset/eslint-plugin-project-relative-paths.md b/.changeset/eslint-plugin-project-relative-paths.md new file mode 100644 index 00000000000..c388f0db969 --- /dev/null +++ b/.changeset/eslint-plugin-project-relative-paths.md @@ -0,0 +1,7 @@ +--- +'@clerk/eslint-plugin': minor +--- + +The `require-auth-protection` rule now matches `protected` and `public` globs relative to the project root, instead of relative to `app/`. You can specify `rootDir` to control the project root. + +**Breaking change:** If your project uses the `src/app/` folder structure, you need to rewrite your globs. For example, instead of `public: ['app/sign-in/**']`, use: `public: ['src/app/sign-in/**']`. diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index d2c6bcfde94..23970e7dd55 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -55,7 +55,7 @@ Requires ESLint `>=9` (flat config), and also works with Oxlint when configured ## Usage -Register the plugin and declare your protected/public folder globs in `eslint.config.mjs`: +Register the plugin and declare your protected/public folder globs in `eslint.config.mjs`, for example: ```js import clerkNext from '@clerk/eslint-plugin/next'; @@ -67,8 +67,8 @@ export default [ '@clerk/next/require-auth-protection': [ 'error', { - protected: ['app/**'], - public: ['app/sign-in/**', 'app/sign-up/**'], + protected: ['**'], + public: ['src/app/sign-in/**', 'src/app/sign-up/**'], }, ], }, @@ -76,6 +76,8 @@ export default [ ]; ``` +You need to adapt the exact paths to your application structure. + This rule also works with Oxlint, you can configure the `rules` just like above after adding the plugin as a `jsPlugin` in `.oxlintrc.json`: ```json @@ -91,21 +93,68 @@ Note that the bulk auto-fixer described further down does require `eslint` to be ## Options -| Option | Type | Default | Description | -| ------------------- | --------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `protected` | `string[]` (required) | — | Folder globs whose resources must be guarded. | -| `public` | `string[]` | `[]` | Folder globs that are exempt. | -| `resources` | `object` | all true | Resource groups to check. Supports `routeHandlers`, `serverFunctions`, and `serverComponentEntrypoints`, each as an optional boolean. | -| `mixedScopeLayouts` | `'auto' \| string[]` | `'auto'` | Layouts/templates that intentionally wrap both protected and public descendants. `'auto'` allows them silently; a list requires each such folder to be acknowledged explicitly. | -| `rootDir` | `string` | _(auto)_ | Directory folder globs are resolved against. Defaults to the nearest ancestor `eslint.config.*`, then ESLint `cwd`. Set to `import.meta.dirname` in your config file when auto-discovery is unavailable. | +| Option | Type | Default | Description | +| ------------------- | --------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `protected` | `string[]` (required) | — | Folder globs relative to the project root whose resources must be guarded. | +| `public` | `string[]` | `[]` | Folder globs relative to the project root that are exempt. | +| `resources` | `object` | all true | Resource groups to check. Supports `routeHandlers`, `serverFunctions`, and `serverComponentEntrypoints`, each as an optional boolean. | +| `mixedScopeLayouts` | `'auto' \| string[]` | `'auto'` | Layouts/templates that intentionally wrap both protected and public descendants. `'auto'` allows them silently; a list requires each such folder to be acknowledged explicitly. | +| `rootDir` | `string` | _(auto)_ | Project root used to resolve project-relative folder globs. Defaults to the nearest ancestor `eslint.config.*`, then ESLint `cwd`. Set to `import.meta.dirname` in your config file when auto-discovery is unavailable. | Globs use a minimal dialect — only `*` (single segment) and `**` (any depth). When a folder matches both `protected` and `public`, the most specific pattern wins, and `protected` wins ties. +### Path matching + +`protected` and `public` are project-relative folder globs. Use `app/**` for a root `app` directory, `src/app/**` for a `src/app` directory, and `src/**`, `shared/**`, etc. for other project folders. + +The project root is normally determined by the nearest `eslint.config.*` ancestor of the file being checked, and falls back to `cwd`. You can configure it manually using `rootDir`. + +We recommend starting with `protected: ['**']`. This protects the following resources by default: + +- Server Functions are checked wherever they live in the project when their folder is protected +- Regardless of configuration, App Router entrypoints like `page.jsx` or `route.js` are only checked if they live under `app/` or `src/app/` relative to the project root + +Use `public` for explicit exemptions: + +```js +{ + protected: ['**'], + public: [ + 'src/app/sign-in/**', + 'src/app/sign-up/**', + 'src/actions/public/**', + ], +} +``` + +### Public by default + +While we recommend protecting all resources by default and opting out for public ones, it is also possible to make the default public and opting in to what should be protected. + +If you do, we recommend `public: ['src/app/**']` over `public: ['**']`, so that Server Functions outside the `app/` folder are still considered protected by default: + +```js +{ + // Routes are considered public by default + public: ['src/app/**'], + protected: [ + // Protect everything outside of src/app/ + '**', + // Opt into protection for parts of src/app/ + 'src/app/(dashboard)/**' + ], +} +``` + +With `public: ['**']` it's easy to accidentally add a Server Function to a shared folder and forget adding protection since you'll get no lint error. + +### Opting out certain resources + Use `resources` to disable whole resource groups when a project only wants this rule to enforce protection for some App Router resources: ```js { - protected: ['app/**'], + protected: ['**'], resources: { routeHandlers: true, serverFunctions: true, @@ -116,6 +165,28 @@ Use `resources` to disable whole resource groups when a project only wants this We recommend leaving all as true, but switching some off can be useful during incremental migrations. This configuration also scopes suggestions and bulk-fix tooling: disabled resource groups are not reported by the rule, so they will not receive editor quick-fixes or bulk-applied fixes. +## Monorepo setups + +If you keep a separate `eslint.config.*` in each application, this rule will work with your monorepo setup out of the box. + +If you have a single top level `eslint.config.*`, you need to define the rule once for each application, and you need to configure `rootDir` to point to each application root (for example `apps/web`). This makes sense when you consider that each `protected` and `public` pattern is application specific. For example: + +```ts +{ + files: ['apps/web/src/**/*.{ts,tsx}'], + rules: { + '@clerk/next/require-auth-protection': [ + 'error', + { + rootDir: 'apps/web', + protected: ['**'], + public: ['src/app/sign-in/**'], + } + ] + } +} +``` + ## What counts as protected The rule is satisfied when the relevant function guards itself at the top, either by calling `auth.protect()`: @@ -167,7 +238,7 @@ npx clerk-next-fix-auth-protection --dry-run # Scope fixes to a specific pattern, this will still # use protected/public from your ESLint config, but # can be useful to only fix a subset of your application -npx clerk-next-fix-auth-protection "app/**" +npx clerk-next-fix-auth-protection "src/**" ``` 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). @@ -178,7 +249,7 @@ The same logic is available programmatically: import { fixAuthProtection } from '@clerk/eslint-plugin/next/fix-auth-protection'; const { fixed, unresolved } = await fixAuthProtection({ - patterns: ['app/**'], + patterns: ['src/**'], dryRun: false, }); ``` @@ -204,7 +275,7 @@ Notably, it does not: 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 +```jsx // -- Using the default .protect() behavior -- await auth.protect() await (await auth()).protect() diff --git a/packages/eslint-plugin/src/next/__tests__/file-info.test.ts b/packages/eslint-plugin/src/next/__tests__/file-info.test.ts index 3174ca87608..a7fd242f85e 100644 --- a/packages/eslint-plugin/src/next/__tests__/file-info.test.ts +++ b/packages/eslint-plugin/src/next/__tests__/file-info.test.ts @@ -1,35 +1,33 @@ import { describe, expect, it } from 'vitest'; -import { getRelativeFolder } from '../lib/file-info'; +import { getAppRouterFileKind, getRelativeFolder, isUnderAppRouterRoot } from '../lib/file-info'; describe('getRelativeFolder', () => { - it('roots at the `app` segment for a root-level App Router', () => { + it('returns the project-relative folder for a root-level App Router file', () => { expect(getRelativeFolder('/proj/app/dashboard/page.tsx', '/proj')).toBe('app/dashboard'); }); - it('supports the `src/app` convention (the `src` segment is skipped)', () => { - expect(getRelativeFolder('/proj/src/app/dashboard/page.tsx', '/proj')).toBe('app/dashboard'); + it('preserves the `src/app` prefix in project-relative paths', () => { + expect(getRelativeFolder('/proj/src/app/dashboard/page.tsx', '/proj')).toBe('src/app/dashboard'); }); - it('ignores a spurious `app` segment in the absolute prefix when rooted at the project', () => { - // Without project-root relativization, the leading `/Users/app/...` would anchor the - // folder at the wrong `app`. Relativizing against the project root fixes it. + it('relativizes against the project root, not arbitrary absolute prefixes', () => { expect(getRelativeFolder('/Users/app/work/myproj/app/dashboard/page.tsx', '/Users/app/work/myproj')).toBe( 'app/dashboard', ); }); - it('misclassifies when rooted at a parent directory that also contains an `app` segment', () => { + it('returns the full project-relative path when rooted at a parent directory', () => { expect(getRelativeFolder('/Users/app/work/myproj/app/sign-in/page.tsx', '/Users')).toBe( 'app/work/myproj/app/sign-in', ); }); - it('roots at the shallowest `app` when an inner route folder is also named `app`', () => { + it('preserves nested route folders also named `app`', () => { expect(getRelativeFolder('/proj/app/app/page.tsx', '/proj')).toBe('app/app'); }); - it('does not match segments that merely contain `app`', () => { + it('does not strip to an inner `app` segment for non-router folders', () => { expect(getRelativeFolder('/proj/myapp/dashboard/page.tsx', '/proj')).toBe('myapp/dashboard'); expect(getRelativeFolder('/proj/app-utils/foo.ts', '/proj')).toBe('app-utils'); }); @@ -38,18 +36,23 @@ describe('getRelativeFolder', () => { expect(getRelativeFolder('C:\\proj\\app\\dashboard\\page.tsx', 'C:\\proj')).toBe('app/dashboard'); }); - it('falls back to scanning the absolute path when the file is outside the project root', () => { - // Mirrors how RuleTester lints in-memory code: the filename is absolute and - // not under the project root, so the absolute path is scanned for `app`. - expect(getRelativeFolder('/elsewhere/app/dashboard/page.tsx', '/proj')).toBe('app/dashboard'); + it('returns null when the file is outside the project root', () => { + expect(getRelativeFolder('/elsewhere/app/dashboard/page.tsx', '/proj')).toBeNull(); + expect(getRelativeFolder('/elsewhere/utils/foo.ts', '/proj')).toBeNull(); + }); + + it('allows project folders whose names start with dots', () => { + expect(getRelativeFolder('/proj/..foo/actions.ts', '/proj')).toBe('..foo'); + expect(getRelativeFolder('/proj/.well-known/actions.ts', '/proj')).toBe('.well-known'); }); - it('returns the project-relative folder when there is no `app` segment but the file is under the project root', () => { + it('returns the project-relative folder for files outside App Router', () => { expect(getRelativeFolder('/proj/utils/foo.ts', '/proj')).toBe('utils'); + expect(getRelativeFolder('/proj/shared/actions.ts', '/proj')).toBe('shared'); }); - it('returns null when there is no `app` segment and the file is outside the project root', () => { - expect(getRelativeFolder('/elsewhere/utils/foo.ts', '/proj')).toBeNull(); + it('returns null when rootDir is omitted', () => { + expect(getRelativeFolder('/proj/app/dashboard/page.tsx', undefined)).toBeNull(); }); it('returns null for an empty filename', () => { @@ -57,3 +60,40 @@ describe('getRelativeFolder', () => { expect(getRelativeFolder('', '/proj')).toBeNull(); }); }); + +describe('isUnderAppRouterRoot', () => { + it('returns true for `app/` and `src/app/` folders', () => { + expect(isUnderAppRouterRoot('app')).toBe(true); + expect(isUnderAppRouterRoot('app/dashboard')).toBe(true); + expect(isUnderAppRouterRoot('src/app')).toBe(true); + expect(isUnderAppRouterRoot('src/app/dashboard')).toBe(true); + }); + + it('returns false for folders that are not rooted at `app/` or `src/app/`', () => { + expect(isUnderAppRouterRoot('apps/web/app/dashboard')).toBe(false); + expect(isUnderAppRouterRoot('src/pages-router/app')).toBe(false); + expect(isUnderAppRouterRoot('src/pages-router/app/dashboard')).toBe(false); + expect(isUnderAppRouterRoot('myapp/dashboard')).toBe(false); + expect(isUnderAppRouterRoot('app-utils')).toBe(false); + expect(isUnderAppRouterRoot('utils')).toBe(false); + expect(isUnderAppRouterRoot('shared')).toBe(false); + }); +}); + +describe('getAppRouterFileKind', () => { + it('returns the resource kind under an App Router root', () => { + expect(getAppRouterFileKind('/proj/app/dashboard/page.tsx', 'app/dashboard')).toBe('page'); + expect(getAppRouterFileKind('/proj/src/app/dashboard/route.ts', 'src/app/dashboard')).toBe('route'); + }); + + it('returns null for resource filenames outside App Router', () => { + expect(getAppRouterFileKind('/proj/utils/page.tsx', 'utils')).toBeNull(); + expect(getAppRouterFileKind('/proj/shared/route.ts', 'shared')).toBeNull(); + expect(getAppRouterFileKind('/proj/apps/web/app/page.tsx', 'apps/web/app')).toBeNull(); + expect(getAppRouterFileKind('/proj/src/pages-router/app/page.tsx', 'src/pages-router/app')).toBeNull(); + }); + + it('returns null for non-resource files even under App Router', () => { + expect(getAppRouterFileKind('/proj/app/dashboard/helpers.ts', 'app/dashboard')).toBeNull(); + }); +}); diff --git a/packages/eslint-plugin/src/next/__tests__/require-auth-protection.suggestions.test.ts b/packages/eslint-plugin/src/next/__tests__/require-auth-protection.suggestions.test.ts index 32a50529b5d..785bd4e9a94 100644 --- a/packages/eslint-plugin/src/next/__tests__/require-auth-protection.suggestions.test.ts +++ b/packages/eslint-plugin/src/next/__tests__/require-auth-protection.suggestions.test.ts @@ -26,7 +26,7 @@ const ruleTester = new RuleTester({ }, }); -const config = { protected: ['app/**'] }; +const config = { protected: ['**'], rootDir: projectRoot }; ruleTester.run('require-auth-protection (suggestions)', rule, { valid: [], diff --git a/packages/eslint-plugin/src/next/__tests__/require-auth-protection.test.ts b/packages/eslint-plugin/src/next/__tests__/require-auth-protection.test.ts index 8ef3be9c1b9..30a6151a59e 100644 --- a/packages/eslint-plugin/src/next/__tests__/require-auth-protection.test.ts +++ b/packages/eslint-plugin/src/next/__tests__/require-auth-protection.test.ts @@ -28,8 +28,9 @@ const ruleTester = new RuleTester({ }); const config = { - protected: ['app/**'], + protected: ['**'], public: ['app/(routes)/(unauthenticated)/**'], + rootDir: projectRoot, }; // suggestions.test.ts already tests the suggestions, so we override with @@ -870,9 +871,51 @@ ruleTester.run('require-auth-protection', rule, { { protected: ['app/**'], public: [], + rootDir: projectRoot, }, ], }, + { + name: 'src/app project uses project-relative globs', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + await auth.protect(); + return