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
Hello
; + } + `, + filename: abs('src/app/dashboard/page.tsx'), + options: [ + { + protected: ['src/app/**'], + public: [], + rootDir: projectRoot, + }, + ], + }, + { + name: 'page.tsx outside App Router is ignored even with protected: ["**"]', + code: ` + export default function Page() { + return null; + } + `, + filename: abs('utils/page.tsx'), + options: [config], + }, + { + name: 'Server Function outside App Router is checked with protected: ["**"]', + code: ` + 'use server'; + import { auth } from '@clerk/nextjs/server'; + export async function deleteUser(id) { + await auth.protect(); + return id; + } + `, + filename: abs('shared/actions.ts'), + options: [config], + }, { name: 'intercepting route in public folder, no protect call (classified by source folder)', code: ` @@ -1736,10 +1779,23 @@ ruleTester.run('require-auth-protection', rule, { { protected: ['app/**'], public: [], + rootDir: projectRoot, }, ], errors: [missingProtectError()], }, + { + name: 'unprotected Server Function outside App Router is flagged with protected: ["**"]', + code: ` + 'use server'; + export async function deleteUser(id) { + return id; + } + `, + filename: abs('shared/actions.ts'), + options: [config], + errors: [missingProtectError({ subject: "Server Function 'deleteUser'" })], + }, ], }); @@ -1761,6 +1817,19 @@ describe('require-auth-protection schema validation', () => { }, }); }; + const lintWithCustomRuleId = (options: RuleOptions | Record) => { + const linter = new Linter(); + return linter.verify('export default function X() {}', { + plugins: { + clerk: { + rules: { 'require-auth-protection': rule }, + }, + }, + rules: { + 'clerk/require-auth-protection': ['warn', options], + }, + }); + }; it('rejects configs missing `protected`', () => { expect(() => lintWithOptions({ public: ['app/(unauthenticated)/**'] })).toThrow(/protected/); @@ -1774,6 +1843,67 @@ describe('require-auth-protection schema validation', () => { expect(() => lintWithOptions({ protected: ['app/**'] })).not.toThrow(); }); + it('rejects protected patterns with `..` segments', () => { + expect(() => lintWithOptions({ protected: ['../app/**'] })).toThrow(/protected.*cannot contain `\.\.` segments/s); + expect(() => lintWithOptions({ protected: ['app/../admin/**'] })).toThrow( + /protected.*cannot contain `\.\.` segments/s, + ); + }); + + it('uses the configured rule id in path pattern validation errors', () => { + expect(() => lintWithCustomRuleId({ protected: ['../app/**'] })).toThrow( + /clerk\/require-auth-protection: `protected` patterns/, + ); + }); + + it('rejects public patterns with `..` segments', () => { + expect(() => lintWithOptions({ protected: ['**'], public: ['../public/**'] })).toThrow( + /public.*cannot contain `\.\.` segments/s, + ); + }); + + it('rejects path patterns with absolute paths', () => { + expect(() => lintWithOptions({ protected: ['/app/**'] })).toThrow( + /protected.*relative to `rootDir`, not absolute/s, + ); + expect(() => lintWithOptions({ protected: ['C:/app/**'] })).toThrow( + /protected.*relative to `rootDir`, not absolute/s, + ); + }); + + it('rejects path patterns with backslashes', () => { + expect(() => lintWithOptions({ protected: ['src\\app\\**'] })).toThrow(/protected.*must use `\/` path separators/s); + }); + + it('rejects path patterns with empty segments', () => { + expect(() => lintWithOptions({ protected: [''] })).toThrow(/protected.*cannot be empty/s); + expect(() => lintWithOptions({ protected: ['app//admin/**'] })).toThrow(/protected.*empty path segments/s); + expect(() => lintWithOptions({ protected: ['app/**/'] })).toThrow(/protected.*empty path segments/s); + }); + + it('rejects path patterns with `.` segments', () => { + expect(() => lintWithOptions({ protected: ['./app/**'] })).toThrow(/protected.*cannot contain `\.` segments/s); + expect(() => lintWithOptions({ protected: ['app/./admin/**'] })).toThrow( + /protected.*cannot contain `\.` segments/s, + ); + }); + + it('rejects path patterns with brace expansion', () => { + expect(() => lintWithOptions({ protected: ['src/app/**/*.{ts,tsx}'] })).toThrow( + /protected.*cannot use brace expansion/s, + ); + }); + + it('validates array-form mixedScopeLayouts as path patterns', () => { + expect(() => lintWithOptions({ protected: ['**'], mixedScopeLayouts: ['src\\app'] })).toThrow( + /mixedScopeLayouts.*must use `\/` path separators/s, + ); + }); + + it('allows pattern segments that only start with dots', () => { + expect(() => lintWithOptions({ protected: ['.well-known/**', '..foo/**'] })).not.toThrow(); + }); + it('accepts optional resource configuration', () => { expect(() => lintWithOptions({ diff --git a/packages/eslint-plugin/src/next/lib/file-info.ts b/packages/eslint-plugin/src/next/lib/file-info.ts index 22b753336d9..74219d04d12 100644 --- a/packages/eslint-plugin/src/next/lib/file-info.ts +++ b/packages/eslint-plugin/src/next/lib/file-info.ts @@ -16,8 +16,7 @@ const RESOURCE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs)$/; /** * @param rootDir Project root to relativize against — typically from * `resolveProjectRoot()` (explicit option, nearest `eslint.config.*`, or ESLint - * `cwd`). When omitted or when the file lies outside `rootDir`, the absolute - * path is scanned for an `app` segment instead. + * `cwd`). Returns `null` when the file lies outside `rootDir`. */ export function getRelativeFolder(filename: string | undefined, rootDir: string | undefined): string | null { if (!filename) { @@ -25,39 +24,40 @@ export function getRelativeFolder(filename: string | undefined, rootDir: string } const normalizedFile = filename.replaceAll('\\', '/'); - // Prefer a project-relative path so that noise in the absolute prefix (a home - // directory like `/Users/app/...`, a monorepo root, etc.) can't be mistaken - // for the Next.js App Router root. When the file lives outside `rootDir` (e.g. - // in `RuleTester`, which lints in-memory code), fall back to the absolute path. - let candidate = normalizedFile; - if (rootDir) { - const normalizedRoot = rootDir.replaceAll('\\', '/'); - const rel = path.posix.relative(normalizedRoot, normalizedFile); - if (rel && !rel.startsWith('..')) { - candidate = rel; - } + if (!rootDir) { + return null; } - // The App Router root is the first path segment that is exactly `app` (this - // also covers the `src/app/` convention, where the leading `src` segment is - // simply skipped). Folder globs in config are rooted at `app/...`, so we - // re-root the returned folder there. Matching whole segments (rather than the - // `/app/` substring) avoids false positives like `myapp` or `app-utils`. - const segments = candidate.split('/'); - const appIdx = segments.findIndex(seg => seg === 'app'); - if (appIdx !== -1) { - return path.posix.dirname(segments.slice(appIdx).join('/')); + const normalizedRoot = rootDir.replaceAll('\\', '/'); + const rel = path.posix.relative(normalizedRoot, normalizedFile); + if (!rel || rel === '..' || rel.startsWith('../')) { + return null; } - // No `app` segment: only meaningful when we have a project-relative path. - if (candidate !== normalizedFile) { - return path.posix.dirname(candidate); - } - return null; + return path.posix.dirname(rel); } -export function getFileKind(filename: string | undefined): FileKind | null { - if (!filename) { +/** + * Whether `relativeFolder` lies under a Next.js App Router root, relative to + * the configured project root. Only Next.js' supported root layouts (`app/...` + * and `src/app/...`) count; monorepo apps should set `rootDir` per app. + */ +export function isUnderAppRouterRoot(relativeFolder: string): boolean { + return ( + relativeFolder === 'app' || + relativeFolder.startsWith('app/') || + relativeFolder === 'src/app' || + relativeFolder.startsWith('src/app/') + ); +} + +/** + * App Router resource kind (`page`, `layout`, etc.) when the file lives under an + * App Router root. Returns `null` for the same basename outside `app/` (e.g. + * `utils/page.tsx`). + */ +export function getAppRouterFileKind(filename: string | undefined, relativeFolder: string | null): FileKind | null { + if (!filename || !relativeFolder || !isUnderAppRouterRoot(relativeFolder)) { return null; } const base = path.basename(filename).replace(RESOURCE_EXTENSIONS, ''); diff --git a/packages/eslint-plugin/src/next/require-auth-protection.ts b/packages/eslint-plugin/src/next/require-auth-protection.ts index fae5a96ab50..3f2120a3253 100644 --- a/packages/eslint-plugin/src/next/require-auth-protection.ts +++ b/packages/eslint-plugin/src/next/require-auth-protection.ts @@ -4,7 +4,13 @@ import type { Rule } from 'eslint'; import type { ExportTarget, FunctionNode } from './lib/exports'; import { iterateExportAllDeclarations, iterateNamedExports, resolveDefaultExport } from './lib/exports'; -import { type FileKind, getFileKind, getRelativeFolder, isClientModule, isServerFunctionModule } from './lib/file-info'; +import { + type FileKind, + getAppRouterFileKind, + getRelativeFolder, + 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'; @@ -30,19 +36,19 @@ interface ResourceOptions { type NormalizedResourceOptions = Required; export interface RuleOptions { - /** Glob patterns that mark folders as protected. */ + /** Project-relative folder globs whose resources must be guarded. */ protected: string[]; - /** Glob patterns that exempt folders from protection. */ + /** Project-relative folder globs that are exempt. */ public?: string[]; /** Resource groups that should be checked. All resource groups are checked by default. */ resources?: ResourceOptions; /** Layouts that wrap both protected and public descendants. */ mixedScopeLayouts?: 'auto' | string[]; /** - * Directory paths are relativized against when resolving `app/...` folder - * globs. Defaults to the nearest ancestor `eslint.config.*` (same walk ESLint - * uses for config lookup), then ESLint `cwd`. Set to `import.meta.dirname` in - * your `eslint.config.mjs` when config discovery is unavailable. + * Project root used to resolve project-relative folder globs. Defaults to the + * nearest ancestor `eslint.config.*` (same walk ESLint uses for config + * lookup), then ESLint `cwd`. Set to `import.meta.dirname` in your + * `eslint.config.mjs` when config discovery is unavailable. */ rootDir?: string; } @@ -110,6 +116,12 @@ const rule: Rule.RuleModule = { const filename = context.physicalFilename ?? context.filename ?? context.getFilename?.(); const cwd = context.cwd || context.getCwd?.(); const options = (context.options[0] ?? {}) as Partial; + const ruleId = context.id ?? 'require-auth-protection'; + validatePathPatterns(ruleId, 'protected', options.protected); + validatePathPatterns(ruleId, 'public', options.public); + if (Array.isArray(options.mixedScopeLayouts)) { + validatePathPatterns(ruleId, 'mixedScopeLayouts', options.mixedScopeLayouts); + } const config: ClassifyOptions = { protected: options.protected, public: options.public ?? [], @@ -123,7 +135,7 @@ const rule: Rule.RuleModule = { return {}; } - const fileKind = getFileKind(filename); + const fileKind = getAppRouterFileKind(filename, folder); let authNames = new Set(); let shouldCheckInlineServerFunctions = false; @@ -215,6 +227,50 @@ function normalizeResources(resources: ResourceOptions | undefined): NormalizedR return { ...DEFAULT_RESOURCES, ...resources }; } +function validatePathPatterns( + ruleId: string, + optionName: 'protected' | 'public' | 'mixedScopeLayouts', + patterns: string[] | undefined, +): void { + if (!patterns) { + return; + } + for (const pattern of patterns) { + const error = getPathPatternError(pattern); + if (error) { + throw new Error(`${ruleId}: \`${optionName}\` ${error} Received "${pattern}".`); + } + } +} + +function getPathPatternError(pattern: string): string | null { + if (pattern === '') { + return 'patterns cannot be empty.'; + } + if (pattern.includes('\\')) { + return 'patterns must use `/` path separators, not `\\`.'; + } + if (pattern.startsWith('/') || /^[A-Za-z]:\//.test(pattern)) { + return 'patterns must be relative to `rootDir`, not absolute.'; + } + if (pattern.includes('{') || pattern.includes('}')) { + return 'patterns cannot use brace expansion.'; + } + + const segments = pattern.split('/'); + if (segments.includes('')) { + return 'patterns cannot contain empty path segments.'; + } + if (segments.includes('.')) { + return 'patterns cannot contain `.` segments.'; + } + if (segments.includes('..')) { + return 'patterns cannot contain `..` segments.'; + } + + return null; +} + function checkUnacknowledgedMixedScope( context: Rule.RuleContext, programNode: TSESTree.Program,