@prairielearn/eslint-plugin 4.3.0 → 4.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @prairielearn/eslint-plugin
2
2
 
3
+ ## 4.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 0f10d5b: Recognize course/course-instance permission OR-combination middleware in the tRPC permission lint rule.
8
+
3
9
  ## 4.3.0
4
10
 
5
11
  ### Minor Changes
@@ -1 +1 @@
1
- {"version":3,"file":"require-trpc-permission-middleware.d.ts","sourceRoot":"","sources":["../../src/rules/require-trpc-permission-middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,WAAW,EAAgC,MAAM,0BAA0B,CAAC","sourcesContent":["import { ASTUtils, ESLintUtils, type TSESLint, type TSESTree } from '@typescript-eslint/utils';\n\nconst { findVariable } = ASTUtils;\n\n/**\n * tRPC procedures must enforce their own authorization via a `require*`\n * permission middleware in the chain.\n * Individual procedures often need stricter permissions than the page they're\n * mounted under (e.g. an Edit-only mutation under a route that allows View).\n * Requiring an explicit permission middleware per procedure makes the\n * authz gate visible at the call site and helps prevent missing permission checks.\n *\n * Bad:\n * ```ts\n * const list = t.procedure.query(async ({ ctx }) => { ... });\n * ```\n *\n * Good:\n * ```ts\n * const list = t.procedure.use(requireCoursePermissionEdit).query(...);\n * ```\n *\n * Recognized permission middlewares:\n * - requireCoursePermissionPreview / View / Edit / Own\n * - requireCourseInstancePermissionPreview / View / Edit / Own\n * - requireAdministrator\n *\n * Feature gates like `requireEnhancedAccessControl` and `requireAiGradingFeature`\n * don't count — they must be paired with a permission middleware.\n *\n * The rule follows intermediate procedure-base variables so patterns like\n * `const protected = t.procedure.use(...); ...; protected.query(...)` are\n * checked end-to-end.\n */\n\n// TODO: This list requires manual maintenance. We should consider making feature gates\n// not use the `require` prefix, and then we can use a more generic pattern here.\nconst PERMISSION_MIDDLEWARE_PATTERN =\n /^require(Course|CourseInstance)Permission(Preview|View|Edit|Own)$|^requireAdministrator$/;\nconst TERMINAL_METHODS = new Set(['query', 'mutation', 'subscription']);\n\nfunction unwrapMemberObject(expr: TSESTree.Expression): TSESTree.Expression {\n return expr.type === 'TSNonNullExpression' || expr.type === 'TSAsExpression'\n ? unwrapMemberObject(expr.expression)\n : expr;\n}\n\nfunction isTProcedure(expr: TSESTree.Expression): boolean {\n return (\n expr.type === 'MemberExpression' &&\n !expr.computed &&\n expr.property.type === 'Identifier' &&\n expr.property.name === 'procedure' &&\n expr.object.type === 'Identifier' &&\n expr.object.name === 't'\n );\n}\n\ninterface ChainInfo {\n matchesTProcedure: boolean;\n middlewares: string[];\n}\n\n/**\n * Walks back through a chain like `expr.use(A).input(B).use(C)` collecting\n * `.use(...)` argument identifiers. If the base of the chain is `t.procedure`,\n * returns the collected middlewares. If the base is a variable, follows the\n * variable's initializer (e.g. `const protected = t.procedure.use(X)`).\n */\nfunction getProcedureChainInfo(\n expr: TSESTree.Expression,\n scope: TSESLint.Scope.Scope,\n visited: Set<TSESTree.Node>,\n): ChainInfo {\n if (visited.has(expr)) return { matchesTProcedure: false, middlewares: [] };\n visited.add(expr);\n\n const middlewares: string[] = [];\n let cursor: TSESTree.Expression = expr;\n\n while (cursor.type === 'CallExpression') {\n const callee = cursor.callee;\n if (\n callee.type !== 'MemberExpression' ||\n callee.computed ||\n callee.property.type !== 'Identifier'\n ) {\n return { matchesTProcedure: false, middlewares: [] };\n }\n if (callee.property.name === 'use' && cursor.arguments[0]?.type === 'Identifier') {\n middlewares.push(cursor.arguments[0].name);\n }\n cursor = callee.object;\n }\n\n cursor = unwrapMemberObject(cursor);\n\n if (isTProcedure(cursor)) {\n return { matchesTProcedure: true, middlewares };\n }\n\n // Follow an intermediate procedure-base variable defined in the same module:\n // const protectedProcedure = t.procedure.use(...);\n // protectedProcedure.query(...);\n if (cursor.type === 'Identifier') {\n const variable = findVariable(scope, cursor);\n if (variable && variable.defs.length === 1) {\n const def = variable.defs[0];\n if (\n def.node.type === 'VariableDeclarator' &&\n def.node.init &&\n // Conservative: only follow `const` bindings to avoid stale data\n // from let/var rebinding.\n def.parent?.type === 'VariableDeclaration' &&\n def.parent.kind === 'const'\n ) {\n const upstream = getProcedureChainInfo(def.node.init, scope, visited);\n if (upstream.matchesTProcedure) {\n return {\n matchesTProcedure: true,\n middlewares: [...middlewares, ...upstream.middlewares],\n };\n }\n }\n }\n }\n\n return { matchesTProcedure: false, middlewares: [] };\n}\n\nexport default ESLintUtils.RuleCreator.withoutDocs({\n meta: {\n type: 'problem',\n messages: {\n missingPermissionMiddleware:\n 'tRPC procedure must call .use() with a permission middleware (e.g. requireCoursePermissionEdit)',\n },\n schema: [],\n },\n defaultOptions: [],\n\n create(context) {\n return {\n CallExpression(node) {\n if (\n node.callee.type !== 'MemberExpression' ||\n node.callee.computed ||\n node.callee.property.type !== 'Identifier' ||\n !TERMINAL_METHODS.has(node.callee.property.name)\n ) {\n return;\n }\n\n const scope = context.sourceCode.getScope(node);\n const info = getProcedureChainInfo(node.callee.object, scope, new Set());\n\n if (!info.matchesTProcedure) return;\n\n const hasPermissionGate = info.middlewares.some((name) =>\n PERMISSION_MIDDLEWARE_PATTERN.test(name),\n );\n if (!hasPermissionGate) {\n context.report({ node, messageId: 'missingPermissionMiddleware' });\n }\n },\n };\n },\n});\n"]}
1
+ {"version":3,"file":"require-trpc-permission-middleware.d.ts","sourceRoot":"","sources":["../../src/rules/require-trpc-permission-middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,WAAW,EAAgC,MAAM,0BAA0B,CAAC","sourcesContent":["import { ASTUtils, ESLintUtils, type TSESLint, type TSESTree } from '@typescript-eslint/utils';\n\nconst { findVariable } = ASTUtils;\n\n/**\n * tRPC procedures must enforce their own authorization via a `require*`\n * permission middleware in the chain.\n * Individual procedures often need stricter permissions than the page they're\n * mounted under (e.g. an Edit-only mutation under a route that allows View).\n * Requiring an explicit permission middleware per procedure makes the\n * authz gate visible at the call site and helps prevent missing permission checks.\n *\n * Bad:\n * ```ts\n * const list = t.procedure.query(async ({ ctx }) => { ... });\n * ```\n *\n * Good:\n * ```ts\n * const list = t.procedure.use(requireCoursePermissionEdit).query(...);\n * ```\n *\n * Recognized permission middlewares:\n * - requireCoursePermissionPreview / View / Edit / Own\n * - requireCourseInstancePermissionPreview / View / Edit / Own\n * - Or-combinations of the above, e.g. requireCoursePermissionEditOrCourseInstancePermissionView\n * - requireAdministrator\n *\n * Feature gates like `requireEnhancedAccessControl` and `requireAiGradingFeature`\n * don't count — they must be paired with a permission middleware.\n *\n * The rule follows intermediate procedure-base variables so patterns like\n * `const protected = t.procedure.use(...); ...; protected.query(...)` are\n * checked end-to-end.\n */\n\n// TODO: This list requires manual maintenance. We should consider making feature gates\n// not use the `require` prefix, and then we can use a more generic pattern here.\nconst PERMISSION_MIDDLEWARE_PATTERN =\n /^require(Course|CourseInstance)Permission(Preview|View|Edit|Own)(Or(Course|CourseInstance)Permission(Preview|View|Edit|Own))*$|^requireAdministrator$/;\nconst TERMINAL_METHODS = new Set(['query', 'mutation', 'subscription']);\n\nfunction unwrapMemberObject(expr: TSESTree.Expression): TSESTree.Expression {\n return expr.type === 'TSNonNullExpression' || expr.type === 'TSAsExpression'\n ? unwrapMemberObject(expr.expression)\n : expr;\n}\n\nfunction isTProcedure(expr: TSESTree.Expression): boolean {\n return (\n expr.type === 'MemberExpression' &&\n !expr.computed &&\n expr.property.type === 'Identifier' &&\n expr.property.name === 'procedure' &&\n expr.object.type === 'Identifier' &&\n expr.object.name === 't'\n );\n}\n\ninterface ChainInfo {\n matchesTProcedure: boolean;\n middlewares: string[];\n}\n\n/**\n * Walks back through a chain like `expr.use(A).input(B).use(C)` collecting\n * `.use(...)` argument identifiers. If the base of the chain is `t.procedure`,\n * returns the collected middlewares. If the base is a variable, follows the\n * variable's initializer (e.g. `const protected = t.procedure.use(X)`).\n */\nfunction getProcedureChainInfo(\n expr: TSESTree.Expression,\n scope: TSESLint.Scope.Scope,\n visited: Set<TSESTree.Node>,\n): ChainInfo {\n if (visited.has(expr)) return { matchesTProcedure: false, middlewares: [] };\n visited.add(expr);\n\n const middlewares: string[] = [];\n let cursor: TSESTree.Expression = expr;\n\n while (cursor.type === 'CallExpression') {\n const callee = cursor.callee;\n if (\n callee.type !== 'MemberExpression' ||\n callee.computed ||\n callee.property.type !== 'Identifier'\n ) {\n return { matchesTProcedure: false, middlewares: [] };\n }\n if (callee.property.name === 'use' && cursor.arguments[0]?.type === 'Identifier') {\n middlewares.push(cursor.arguments[0].name);\n }\n cursor = callee.object;\n }\n\n cursor = unwrapMemberObject(cursor);\n\n if (isTProcedure(cursor)) {\n return { matchesTProcedure: true, middlewares };\n }\n\n // Follow an intermediate procedure-base variable defined in the same module:\n // const protectedProcedure = t.procedure.use(...);\n // protectedProcedure.query(...);\n if (cursor.type === 'Identifier') {\n const variable = findVariable(scope, cursor);\n if (variable && variable.defs.length === 1) {\n const def = variable.defs[0];\n if (\n def.node.type === 'VariableDeclarator' &&\n def.node.init &&\n // Conservative: only follow `const` bindings to avoid stale data\n // from let/var rebinding.\n def.parent?.type === 'VariableDeclaration' &&\n def.parent.kind === 'const'\n ) {\n const upstream = getProcedureChainInfo(def.node.init, scope, visited);\n if (upstream.matchesTProcedure) {\n return {\n matchesTProcedure: true,\n middlewares: [...middlewares, ...upstream.middlewares],\n };\n }\n }\n }\n }\n\n return { matchesTProcedure: false, middlewares: [] };\n}\n\nexport default ESLintUtils.RuleCreator.withoutDocs({\n meta: {\n type: 'problem',\n messages: {\n missingPermissionMiddleware:\n 'tRPC procedure must call .use() with a permission middleware (e.g. requireCoursePermissionEdit)',\n },\n schema: [],\n },\n defaultOptions: [],\n\n create(context) {\n return {\n CallExpression(node) {\n if (\n node.callee.type !== 'MemberExpression' ||\n node.callee.computed ||\n node.callee.property.type !== 'Identifier' ||\n !TERMINAL_METHODS.has(node.callee.property.name)\n ) {\n return;\n }\n\n const scope = context.sourceCode.getScope(node);\n const info = getProcedureChainInfo(node.callee.object, scope, new Set());\n\n if (!info.matchesTProcedure) return;\n\n const hasPermissionGate = info.middlewares.some((name) =>\n PERMISSION_MIDDLEWARE_PATTERN.test(name),\n );\n if (!hasPermissionGate) {\n context.report({ node, messageId: 'missingPermissionMiddleware' });\n }\n },\n };\n },\n});\n"]}
@@ -21,6 +21,7 @@ const { findVariable } = ASTUtils;
21
21
  * Recognized permission middlewares:
22
22
  * - requireCoursePermissionPreview / View / Edit / Own
23
23
  * - requireCourseInstancePermissionPreview / View / Edit / Own
24
+ * - Or-combinations of the above, e.g. requireCoursePermissionEditOrCourseInstancePermissionView
24
25
  * - requireAdministrator
25
26
  *
26
27
  * Feature gates like `requireEnhancedAccessControl` and `requireAiGradingFeature`
@@ -32,7 +33,7 @@ const { findVariable } = ASTUtils;
32
33
  */
33
34
  // TODO: This list requires manual maintenance. We should consider making feature gates
34
35
  // not use the `require` prefix, and then we can use a more generic pattern here.
35
- const PERMISSION_MIDDLEWARE_PATTERN = /^require(Course|CourseInstance)Permission(Preview|View|Edit|Own)$|^requireAdministrator$/;
36
+ const PERMISSION_MIDDLEWARE_PATTERN = /^require(Course|CourseInstance)Permission(Preview|View|Edit|Own)(Or(Course|CourseInstance)Permission(Preview|View|Edit|Own))*$|^requireAdministrator$/;
36
37
  const TERMINAL_METHODS = new Set(['query', 'mutation', 'subscription']);
37
38
  function unwrapMemberObject(expr) {
38
39
  return expr.type === 'TSNonNullExpression' || expr.type === 'TSAsExpression'
@@ -1 +1 @@
1
- {"version":3,"file":"require-trpc-permission-middleware.js","sourceRoot":"","sources":["../../src/rules/require-trpc-permission-middleware.ts"],"names":[],"mappings":"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","sourcesContent":["import { ASTUtils, ESLintUtils, type TSESLint, type TSESTree } from '@typescript-eslint/utils';\n\nconst { findVariable } = ASTUtils;\n\n/**\n * tRPC procedures must enforce their own authorization via a `require*`\n * permission middleware in the chain.\n * Individual procedures often need stricter permissions than the page they're\n * mounted under (e.g. an Edit-only mutation under a route that allows View).\n * Requiring an explicit permission middleware per procedure makes the\n * authz gate visible at the call site and helps prevent missing permission checks.\n *\n * Bad:\n * ```ts\n * const list = t.procedure.query(async ({ ctx }) => { ... });\n * ```\n *\n * Good:\n * ```ts\n * const list = t.procedure.use(requireCoursePermissionEdit).query(...);\n * ```\n *\n * Recognized permission middlewares:\n * - requireCoursePermissionPreview / View / Edit / Own\n * - requireCourseInstancePermissionPreview / View / Edit / Own\n * - requireAdministrator\n *\n * Feature gates like `requireEnhancedAccessControl` and `requireAiGradingFeature`\n * don't count — they must be paired with a permission middleware.\n *\n * The rule follows intermediate procedure-base variables so patterns like\n * `const protected = t.procedure.use(...); ...; protected.query(...)` are\n * checked end-to-end.\n */\n\n// TODO: This list requires manual maintenance. We should consider making feature gates\n// not use the `require` prefix, and then we can use a more generic pattern here.\nconst PERMISSION_MIDDLEWARE_PATTERN =\n /^require(Course|CourseInstance)Permission(Preview|View|Edit|Own)$|^requireAdministrator$/;\nconst TERMINAL_METHODS = new Set(['query', 'mutation', 'subscription']);\n\nfunction unwrapMemberObject(expr: TSESTree.Expression): TSESTree.Expression {\n return expr.type === 'TSNonNullExpression' || expr.type === 'TSAsExpression'\n ? unwrapMemberObject(expr.expression)\n : expr;\n}\n\nfunction isTProcedure(expr: TSESTree.Expression): boolean {\n return (\n expr.type === 'MemberExpression' &&\n !expr.computed &&\n expr.property.type === 'Identifier' &&\n expr.property.name === 'procedure' &&\n expr.object.type === 'Identifier' &&\n expr.object.name === 't'\n );\n}\n\ninterface ChainInfo {\n matchesTProcedure: boolean;\n middlewares: string[];\n}\n\n/**\n * Walks back through a chain like `expr.use(A).input(B).use(C)` collecting\n * `.use(...)` argument identifiers. If the base of the chain is `t.procedure`,\n * returns the collected middlewares. If the base is a variable, follows the\n * variable's initializer (e.g. `const protected = t.procedure.use(X)`).\n */\nfunction getProcedureChainInfo(\n expr: TSESTree.Expression,\n scope: TSESLint.Scope.Scope,\n visited: Set<TSESTree.Node>,\n): ChainInfo {\n if (visited.has(expr)) return { matchesTProcedure: false, middlewares: [] };\n visited.add(expr);\n\n const middlewares: string[] = [];\n let cursor: TSESTree.Expression = expr;\n\n while (cursor.type === 'CallExpression') {\n const callee = cursor.callee;\n if (\n callee.type !== 'MemberExpression' ||\n callee.computed ||\n callee.property.type !== 'Identifier'\n ) {\n return { matchesTProcedure: false, middlewares: [] };\n }\n if (callee.property.name === 'use' && cursor.arguments[0]?.type === 'Identifier') {\n middlewares.push(cursor.arguments[0].name);\n }\n cursor = callee.object;\n }\n\n cursor = unwrapMemberObject(cursor);\n\n if (isTProcedure(cursor)) {\n return { matchesTProcedure: true, middlewares };\n }\n\n // Follow an intermediate procedure-base variable defined in the same module:\n // const protectedProcedure = t.procedure.use(...);\n // protectedProcedure.query(...);\n if (cursor.type === 'Identifier') {\n const variable = findVariable(scope, cursor);\n if (variable && variable.defs.length === 1) {\n const def = variable.defs[0];\n if (\n def.node.type === 'VariableDeclarator' &&\n def.node.init &&\n // Conservative: only follow `const` bindings to avoid stale data\n // from let/var rebinding.\n def.parent?.type === 'VariableDeclaration' &&\n def.parent.kind === 'const'\n ) {\n const upstream = getProcedureChainInfo(def.node.init, scope, visited);\n if (upstream.matchesTProcedure) {\n return {\n matchesTProcedure: true,\n middlewares: [...middlewares, ...upstream.middlewares],\n };\n }\n }\n }\n }\n\n return { matchesTProcedure: false, middlewares: [] };\n}\n\nexport default ESLintUtils.RuleCreator.withoutDocs({\n meta: {\n type: 'problem',\n messages: {\n missingPermissionMiddleware:\n 'tRPC procedure must call .use() with a permission middleware (e.g. requireCoursePermissionEdit)',\n },\n schema: [],\n },\n defaultOptions: [],\n\n create(context) {\n return {\n CallExpression(node) {\n if (\n node.callee.type !== 'MemberExpression' ||\n node.callee.computed ||\n node.callee.property.type !== 'Identifier' ||\n !TERMINAL_METHODS.has(node.callee.property.name)\n ) {\n return;\n }\n\n const scope = context.sourceCode.getScope(node);\n const info = getProcedureChainInfo(node.callee.object, scope, new Set());\n\n if (!info.matchesTProcedure) return;\n\n const hasPermissionGate = info.middlewares.some((name) =>\n PERMISSION_MIDDLEWARE_PATTERN.test(name),\n );\n if (!hasPermissionGate) {\n context.report({ node, messageId: 'missingPermissionMiddleware' });\n }\n },\n };\n },\n});\n"]}
1
+ {"version":3,"file":"require-trpc-permission-middleware.js","sourceRoot":"","sources":["../../src/rules/require-trpc-permission-middleware.ts"],"names":[],"mappings":"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","sourcesContent":["import { ASTUtils, ESLintUtils, type TSESLint, type TSESTree } from '@typescript-eslint/utils';\n\nconst { findVariable } = ASTUtils;\n\n/**\n * tRPC procedures must enforce their own authorization via a `require*`\n * permission middleware in the chain.\n * Individual procedures often need stricter permissions than the page they're\n * mounted under (e.g. an Edit-only mutation under a route that allows View).\n * Requiring an explicit permission middleware per procedure makes the\n * authz gate visible at the call site and helps prevent missing permission checks.\n *\n * Bad:\n * ```ts\n * const list = t.procedure.query(async ({ ctx }) => { ... });\n * ```\n *\n * Good:\n * ```ts\n * const list = t.procedure.use(requireCoursePermissionEdit).query(...);\n * ```\n *\n * Recognized permission middlewares:\n * - requireCoursePermissionPreview / View / Edit / Own\n * - requireCourseInstancePermissionPreview / View / Edit / Own\n * - Or-combinations of the above, e.g. requireCoursePermissionEditOrCourseInstancePermissionView\n * - requireAdministrator\n *\n * Feature gates like `requireEnhancedAccessControl` and `requireAiGradingFeature`\n * don't count — they must be paired with a permission middleware.\n *\n * The rule follows intermediate procedure-base variables so patterns like\n * `const protected = t.procedure.use(...); ...; protected.query(...)` are\n * checked end-to-end.\n */\n\n// TODO: This list requires manual maintenance. We should consider making feature gates\n// not use the `require` prefix, and then we can use a more generic pattern here.\nconst PERMISSION_MIDDLEWARE_PATTERN =\n /^require(Course|CourseInstance)Permission(Preview|View|Edit|Own)(Or(Course|CourseInstance)Permission(Preview|View|Edit|Own))*$|^requireAdministrator$/;\nconst TERMINAL_METHODS = new Set(['query', 'mutation', 'subscription']);\n\nfunction unwrapMemberObject(expr: TSESTree.Expression): TSESTree.Expression {\n return expr.type === 'TSNonNullExpression' || expr.type === 'TSAsExpression'\n ? unwrapMemberObject(expr.expression)\n : expr;\n}\n\nfunction isTProcedure(expr: TSESTree.Expression): boolean {\n return (\n expr.type === 'MemberExpression' &&\n !expr.computed &&\n expr.property.type === 'Identifier' &&\n expr.property.name === 'procedure' &&\n expr.object.type === 'Identifier' &&\n expr.object.name === 't'\n );\n}\n\ninterface ChainInfo {\n matchesTProcedure: boolean;\n middlewares: string[];\n}\n\n/**\n * Walks back through a chain like `expr.use(A).input(B).use(C)` collecting\n * `.use(...)` argument identifiers. If the base of the chain is `t.procedure`,\n * returns the collected middlewares. If the base is a variable, follows the\n * variable's initializer (e.g. `const protected = t.procedure.use(X)`).\n */\nfunction getProcedureChainInfo(\n expr: TSESTree.Expression,\n scope: TSESLint.Scope.Scope,\n visited: Set<TSESTree.Node>,\n): ChainInfo {\n if (visited.has(expr)) return { matchesTProcedure: false, middlewares: [] };\n visited.add(expr);\n\n const middlewares: string[] = [];\n let cursor: TSESTree.Expression = expr;\n\n while (cursor.type === 'CallExpression') {\n const callee = cursor.callee;\n if (\n callee.type !== 'MemberExpression' ||\n callee.computed ||\n callee.property.type !== 'Identifier'\n ) {\n return { matchesTProcedure: false, middlewares: [] };\n }\n if (callee.property.name === 'use' && cursor.arguments[0]?.type === 'Identifier') {\n middlewares.push(cursor.arguments[0].name);\n }\n cursor = callee.object;\n }\n\n cursor = unwrapMemberObject(cursor);\n\n if (isTProcedure(cursor)) {\n return { matchesTProcedure: true, middlewares };\n }\n\n // Follow an intermediate procedure-base variable defined in the same module:\n // const protectedProcedure = t.procedure.use(...);\n // protectedProcedure.query(...);\n if (cursor.type === 'Identifier') {\n const variable = findVariable(scope, cursor);\n if (variable && variable.defs.length === 1) {\n const def = variable.defs[0];\n if (\n def.node.type === 'VariableDeclarator' &&\n def.node.init &&\n // Conservative: only follow `const` bindings to avoid stale data\n // from let/var rebinding.\n def.parent?.type === 'VariableDeclaration' &&\n def.parent.kind === 'const'\n ) {\n const upstream = getProcedureChainInfo(def.node.init, scope, visited);\n if (upstream.matchesTProcedure) {\n return {\n matchesTProcedure: true,\n middlewares: [...middlewares, ...upstream.middlewares],\n };\n }\n }\n }\n }\n\n return { matchesTProcedure: false, middlewares: [] };\n}\n\nexport default ESLintUtils.RuleCreator.withoutDocs({\n meta: {\n type: 'problem',\n messages: {\n missingPermissionMiddleware:\n 'tRPC procedure must call .use() with a permission middleware (e.g. requireCoursePermissionEdit)',\n },\n schema: [],\n },\n defaultOptions: [],\n\n create(context) {\n return {\n CallExpression(node) {\n if (\n node.callee.type !== 'MemberExpression' ||\n node.callee.computed ||\n node.callee.property.type !== 'Identifier' ||\n !TERMINAL_METHODS.has(node.callee.property.name)\n ) {\n return;\n }\n\n const scope = context.sourceCode.getScope(node);\n const info = getProcedureChainInfo(node.callee.object, scope, new Set());\n\n if (!info.matchesTProcedure) return;\n\n const hasPermissionGate = info.middlewares.some((name) =>\n PERMISSION_MIDDLEWARE_PATTERN.test(name),\n );\n if (!hasPermissionGate) {\n context.report({ node, messageId: 'missingPermissionMiddleware' });\n }\n },\n };\n },\n});\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"require-trpc-permission-middleware.test.d.ts","sourceRoot":"","sources":["../../src/tests/require-trpc-permission-middleware.test.ts"],"names":[],"mappings":"","sourcesContent":["import { RuleTester } from '@typescript-eslint/rule-tester';\nimport { afterAll, describe, it } from 'vitest';\n\nimport rule from '../rules/require-trpc-permission-middleware.js';\n\nRuleTester.afterAll = afterAll;\nRuleTester.describe = describe;\nRuleTester.it = it;\n\nconst ruleTester = new RuleTester();\n\nruleTester.run('require-trpc-permission-middleware', rule, {\n valid: [\n {\n code: 'const list = t.procedure.use(requireCoursePermissionEdit).query(async () => {});',\n },\n {\n code: 'const m = t.procedure.use(requireCoursePermissionPreview).mutation(async () => {});',\n },\n {\n code: `const m = t.procedure\n .use(requireEnhancedAccessControl)\n .use(requireCourseInstancePermissionView)\n .query(async () => {});`,\n },\n {\n code: 'const m = t.procedure.use(requireAdministrator).mutation(async () => {});',\n },\n {\n code: `const m = t.procedure\n .use(requireCourseInstancePermissionEdit)\n .input(z.object({}))\n .mutation(async () => {});`,\n },\n {\n code: 'const list = t.procedure.use(requireCoursePermissionOwn).query(async () => {});',\n },\n // Not a t.procedure chain — should be ignored\n {\n code: 'const fn = somethingElse.query(async () => {});',\n },\n {\n code: 'const fn = otherRouter.procedure.query(async () => {});',\n },\n // Intermediate procedure-base variable: permission middleware on the base\n {\n code: `\n const protectedProcedure = t.procedure.use(requireCoursePermissionOwn);\n const m = protectedProcedure.query(async () => {});\n `,\n },\n // Intermediate procedure-base variable: permission added downstream\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.use(requireAdministrator).mutation(async () => {});\n `,\n },\n // Imported procedure base — we can't follow across files, so don't fire\n {\n code: `\n import { protectedProcedure } from './foo.js';\n const m = protectedProcedure.query(async () => {});\n `,\n languageOptions: { parserOptions: { sourceType: 'module' } },\n },\n ],\n invalid: [\n {\n code: 'const list = t.procedure.query(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: 'const m = t.procedure.input(z.object({})).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n // Feature gate alone is not a permission gate\n {\n code: 'const m = t.procedure.use(requireEnhancedAccessControl).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: 'const m = t.procedure.use(requireAiGradingFeature).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: `const m = t.procedure\n .input(z.object({}))\n .output(z.object({}))\n .subscription(async () => {});`,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n // Intermediate procedure-base variable with only a feature gate is still a miss\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.query(async () => {});\n `,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.input(z.object({})).mutation(async () => {});\n `,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n ],\n});\n"]}
1
+ {"version":3,"file":"require-trpc-permission-middleware.test.d.ts","sourceRoot":"","sources":["../../src/tests/require-trpc-permission-middleware.test.ts"],"names":[],"mappings":"","sourcesContent":["import { RuleTester } from '@typescript-eslint/rule-tester';\nimport { afterAll, describe, it } from 'vitest';\n\nimport rule from '../rules/require-trpc-permission-middleware.js';\n\nRuleTester.afterAll = afterAll;\nRuleTester.describe = describe;\nRuleTester.it = it;\n\nconst ruleTester = new RuleTester();\n\nruleTester.run('require-trpc-permission-middleware', rule, {\n valid: [\n {\n code: 'const list = t.procedure.use(requireCoursePermissionEdit).query(async () => {});',\n },\n {\n code: 'const m = t.procedure.use(requireCoursePermissionPreview).mutation(async () => {});',\n },\n {\n code: `const m = t.procedure\n .use(requireEnhancedAccessControl)\n .use(requireCourseInstancePermissionView)\n .query(async () => {});`,\n },\n {\n code: 'const m = t.procedure.use(requireAdministrator).mutation(async () => {});',\n },\n {\n code: `const m = t.procedure\n .use(requireCourseInstancePermissionEdit)\n .input(z.object({}))\n .mutation(async () => {});`,\n },\n {\n code: 'const list = t.procedure.use(requireCoursePermissionOwn).query(async () => {});',\n },\n {\n code: `const list = t.procedure\n .use(requireCoursePermissionEditOrCourseInstancePermissionView)\n .query(async () => {});`,\n },\n // Not a t.procedure chain — should be ignored\n {\n code: 'const fn = somethingElse.query(async () => {});',\n },\n {\n code: 'const fn = otherRouter.procedure.query(async () => {});',\n },\n // Intermediate procedure-base variable: permission middleware on the base\n {\n code: `\n const protectedProcedure = t.procedure.use(requireCoursePermissionOwn);\n const m = protectedProcedure.query(async () => {});\n `,\n },\n // Intermediate procedure-base variable: permission added downstream\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.use(requireAdministrator).mutation(async () => {});\n `,\n },\n // Imported procedure base — we can't follow across files, so don't fire\n {\n code: `\n import { protectedProcedure } from './foo.js';\n const m = protectedProcedure.query(async () => {});\n `,\n languageOptions: { parserOptions: { sourceType: 'module' } },\n },\n ],\n invalid: [\n {\n code: 'const list = t.procedure.query(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: 'const m = t.procedure.input(z.object({})).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n // Feature gate alone is not a permission gate\n {\n code: 'const m = t.procedure.use(requireEnhancedAccessControl).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: 'const m = t.procedure.use(requireAiGradingFeature).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: `const m = t.procedure\n .input(z.object({}))\n .output(z.object({}))\n .subscription(async () => {});`,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n // Intermediate procedure-base variable with only a feature gate is still a miss\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.query(async () => {});\n `,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.input(z.object({})).mutation(async () => {});\n `,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n ],\n});\n"]}
@@ -31,6 +31,11 @@ ruleTester.run('require-trpc-permission-middleware', rule, {
31
31
  {
32
32
  code: 'const list = t.procedure.use(requireCoursePermissionOwn).query(async () => {});',
33
33
  },
34
+ {
35
+ code: `const list = t.procedure
36
+ .use(requireCoursePermissionEditOrCourseInstancePermissionView)
37
+ .query(async () => {});`,
38
+ },
34
39
  // Not a t.procedure chain — should be ignored
35
40
  {
36
41
  code: 'const fn = somethingElse.query(async () => {});',
@@ -1 +1 @@
1
- {"version":3,"file":"require-trpc-permission-middleware.test.js","sourceRoot":"","sources":["../../src/tests/require-trpc-permission-middleware.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AAC5D,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAEhD,OAAO,IAAI,MAAM,gDAAgD,CAAC;AAElE,UAAU,CAAC,QAAQ,GAAG,QAAQ,CAAC;AAC/B,UAAU,CAAC,QAAQ,GAAG,QAAQ,CAAC;AAC/B,UAAU,CAAC,EAAE,GAAG,EAAE,CAAC;AAEnB,MAAM,UAAU,GAAG,IAAI,UAAU,EAAE,CAAC;AAEpC,UAAU,CAAC,GAAG,CAAC,oCAAoC,EAAE,IAAI,EAAE;IACzD,KAAK,EAAE;QACL;YACE,IAAI,EAAE,kFAAkF;SACzF;QACD;YACE,IAAI,EAAE,qFAAqF;SAC5F;QACD;YACE,IAAI,EAAE;;;gCAGoB;SAC3B;QACD;YACE,IAAI,EAAE,2EAA2E;SAClF;QACD;YACE,IAAI,EAAE;;;mCAGuB;SAC9B;QACD;YACE,IAAI,EAAE,iFAAiF;SACxF;QACD,8CAA8C;QAC9C;YACE,IAAI,EAAE,iDAAiD;SACxD;QACD;YACE,IAAI,EAAE,yDAAyD;SAChE;QACD,0EAA0E;QAC1E;YACE,IAAI,EAAE;;;OAGL;SACF;QACD,oEAAoE;QACpE;YACE,IAAI,EAAE;;;OAGL;SACF;QACD,wEAAwE;QACxE;YACE,IAAI,EAAE;;;OAGL;YACD,eAAe,EAAE,EAAE,aAAa,EAAE,EAAE,UAAU,EAAE,QAAQ,EAAE,EAAE;SAC7D;KACF;IACD,OAAO,EAAE;QACP;YACE,IAAI,EAAE,iDAAiD;YACvD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD;YACE,IAAI,EAAE,qEAAqE;YAC3E,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD,8CAA8C;QAC9C;YACE,IAAI,EAAE,mFAAmF;YACzF,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD;YACE,IAAI,EAAE,8EAA8E;YACpF,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD;YACE,IAAI,EAAE;;;uCAG2B;YACjC,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD,gFAAgF;QAChF;YACE,IAAI,EAAE;;;OAGL;YACD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD;YACE,IAAI,EAAE;;;OAGL;YACD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;KACF;CACF,CAAC,CAAC","sourcesContent":["import { RuleTester } from '@typescript-eslint/rule-tester';\nimport { afterAll, describe, it } from 'vitest';\n\nimport rule from '../rules/require-trpc-permission-middleware.js';\n\nRuleTester.afterAll = afterAll;\nRuleTester.describe = describe;\nRuleTester.it = it;\n\nconst ruleTester = new RuleTester();\n\nruleTester.run('require-trpc-permission-middleware', rule, {\n valid: [\n {\n code: 'const list = t.procedure.use(requireCoursePermissionEdit).query(async () => {});',\n },\n {\n code: 'const m = t.procedure.use(requireCoursePermissionPreview).mutation(async () => {});',\n },\n {\n code: `const m = t.procedure\n .use(requireEnhancedAccessControl)\n .use(requireCourseInstancePermissionView)\n .query(async () => {});`,\n },\n {\n code: 'const m = t.procedure.use(requireAdministrator).mutation(async () => {});',\n },\n {\n code: `const m = t.procedure\n .use(requireCourseInstancePermissionEdit)\n .input(z.object({}))\n .mutation(async () => {});`,\n },\n {\n code: 'const list = t.procedure.use(requireCoursePermissionOwn).query(async () => {});',\n },\n // Not a t.procedure chain — should be ignored\n {\n code: 'const fn = somethingElse.query(async () => {});',\n },\n {\n code: 'const fn = otherRouter.procedure.query(async () => {});',\n },\n // Intermediate procedure-base variable: permission middleware on the base\n {\n code: `\n const protectedProcedure = t.procedure.use(requireCoursePermissionOwn);\n const m = protectedProcedure.query(async () => {});\n `,\n },\n // Intermediate procedure-base variable: permission added downstream\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.use(requireAdministrator).mutation(async () => {});\n `,\n },\n // Imported procedure base — we can't follow across files, so don't fire\n {\n code: `\n import { protectedProcedure } from './foo.js';\n const m = protectedProcedure.query(async () => {});\n `,\n languageOptions: { parserOptions: { sourceType: 'module' } },\n },\n ],\n invalid: [\n {\n code: 'const list = t.procedure.query(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: 'const m = t.procedure.input(z.object({})).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n // Feature gate alone is not a permission gate\n {\n code: 'const m = t.procedure.use(requireEnhancedAccessControl).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: 'const m = t.procedure.use(requireAiGradingFeature).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: `const m = t.procedure\n .input(z.object({}))\n .output(z.object({}))\n .subscription(async () => {});`,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n // Intermediate procedure-base variable with only a feature gate is still a miss\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.query(async () => {});\n `,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.input(z.object({})).mutation(async () => {});\n `,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n ],\n});\n"]}
1
+ {"version":3,"file":"require-trpc-permission-middleware.test.js","sourceRoot":"","sources":["../../src/tests/require-trpc-permission-middleware.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AAC5D,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAEhD,OAAO,IAAI,MAAM,gDAAgD,CAAC;AAElE,UAAU,CAAC,QAAQ,GAAG,QAAQ,CAAC;AAC/B,UAAU,CAAC,QAAQ,GAAG,QAAQ,CAAC;AAC/B,UAAU,CAAC,EAAE,GAAG,EAAE,CAAC;AAEnB,MAAM,UAAU,GAAG,IAAI,UAAU,EAAE,CAAC;AAEpC,UAAU,CAAC,GAAG,CAAC,oCAAoC,EAAE,IAAI,EAAE;IACzD,KAAK,EAAE;QACL;YACE,IAAI,EAAE,kFAAkF;SACzF;QACD;YACE,IAAI,EAAE,qFAAqF;SAC5F;QACD;YACE,IAAI,EAAE;;;gCAGoB;SAC3B;QACD;YACE,IAAI,EAAE,2EAA2E;SAClF;QACD;YACE,IAAI,EAAE;;;mCAGuB;SAC9B;QACD;YACE,IAAI,EAAE,iFAAiF;SACxF;QACD;YACE,IAAI,EAAE;;gCAEoB;SAC3B;QACD,8CAA8C;QAC9C;YACE,IAAI,EAAE,iDAAiD;SACxD;QACD;YACE,IAAI,EAAE,yDAAyD;SAChE;QACD,0EAA0E;QAC1E;YACE,IAAI,EAAE;;;OAGL;SACF;QACD,oEAAoE;QACpE;YACE,IAAI,EAAE;;;OAGL;SACF;QACD,wEAAwE;QACxE;YACE,IAAI,EAAE;;;OAGL;YACD,eAAe,EAAE,EAAE,aAAa,EAAE,EAAE,UAAU,EAAE,QAAQ,EAAE,EAAE;SAC7D;KACF;IACD,OAAO,EAAE;QACP;YACE,IAAI,EAAE,iDAAiD;YACvD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD;YACE,IAAI,EAAE,qEAAqE;YAC3E,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD,8CAA8C;QAC9C;YACE,IAAI,EAAE,mFAAmF;YACzF,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD;YACE,IAAI,EAAE,8EAA8E;YACpF,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD;YACE,IAAI,EAAE;;;uCAG2B;YACjC,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD,gFAAgF;QAChF;YACE,IAAI,EAAE;;;OAGL;YACD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD;YACE,IAAI,EAAE;;;OAGL;YACD,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;KACF;CACF,CAAC,CAAC","sourcesContent":["import { RuleTester } from '@typescript-eslint/rule-tester';\nimport { afterAll, describe, it } from 'vitest';\n\nimport rule from '../rules/require-trpc-permission-middleware.js';\n\nRuleTester.afterAll = afterAll;\nRuleTester.describe = describe;\nRuleTester.it = it;\n\nconst ruleTester = new RuleTester();\n\nruleTester.run('require-trpc-permission-middleware', rule, {\n valid: [\n {\n code: 'const list = t.procedure.use(requireCoursePermissionEdit).query(async () => {});',\n },\n {\n code: 'const m = t.procedure.use(requireCoursePermissionPreview).mutation(async () => {});',\n },\n {\n code: `const m = t.procedure\n .use(requireEnhancedAccessControl)\n .use(requireCourseInstancePermissionView)\n .query(async () => {});`,\n },\n {\n code: 'const m = t.procedure.use(requireAdministrator).mutation(async () => {});',\n },\n {\n code: `const m = t.procedure\n .use(requireCourseInstancePermissionEdit)\n .input(z.object({}))\n .mutation(async () => {});`,\n },\n {\n code: 'const list = t.procedure.use(requireCoursePermissionOwn).query(async () => {});',\n },\n {\n code: `const list = t.procedure\n .use(requireCoursePermissionEditOrCourseInstancePermissionView)\n .query(async () => {});`,\n },\n // Not a t.procedure chain — should be ignored\n {\n code: 'const fn = somethingElse.query(async () => {});',\n },\n {\n code: 'const fn = otherRouter.procedure.query(async () => {});',\n },\n // Intermediate procedure-base variable: permission middleware on the base\n {\n code: `\n const protectedProcedure = t.procedure.use(requireCoursePermissionOwn);\n const m = protectedProcedure.query(async () => {});\n `,\n },\n // Intermediate procedure-base variable: permission added downstream\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.use(requireAdministrator).mutation(async () => {});\n `,\n },\n // Imported procedure base — we can't follow across files, so don't fire\n {\n code: `\n import { protectedProcedure } from './foo.js';\n const m = protectedProcedure.query(async () => {});\n `,\n languageOptions: { parserOptions: { sourceType: 'module' } },\n },\n ],\n invalid: [\n {\n code: 'const list = t.procedure.query(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: 'const m = t.procedure.input(z.object({})).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n // Feature gate alone is not a permission gate\n {\n code: 'const m = t.procedure.use(requireEnhancedAccessControl).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: 'const m = t.procedure.use(requireAiGradingFeature).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: `const m = t.procedure\n .input(z.object({}))\n .output(z.object({}))\n .subscription(async () => {});`,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n // Intermediate procedure-base variable with only a feature gate is still a miss\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.query(async () => {});\n `,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: `\n const featureProcedure = t.procedure.use(requireAiGradingFeature);\n const m = featureProcedure.input(z.object({})).mutation(async () => {});\n `,\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n ],\n});\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prairielearn/eslint-plugin",
3
- "version": "4.3.0",
3
+ "version": "4.3.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,6 +23,7 @@ const { findVariable } = ASTUtils;
23
23
  * Recognized permission middlewares:
24
24
  * - requireCoursePermissionPreview / View / Edit / Own
25
25
  * - requireCourseInstancePermissionPreview / View / Edit / Own
26
+ * - Or-combinations of the above, e.g. requireCoursePermissionEditOrCourseInstancePermissionView
26
27
  * - requireAdministrator
27
28
  *
28
29
  * Feature gates like `requireEnhancedAccessControl` and `requireAiGradingFeature`
@@ -36,7 +37,7 @@ const { findVariable } = ASTUtils;
36
37
  // TODO: This list requires manual maintenance. We should consider making feature gates
37
38
  // not use the `require` prefix, and then we can use a more generic pattern here.
38
39
  const PERMISSION_MIDDLEWARE_PATTERN =
39
- /^require(Course|CourseInstance)Permission(Preview|View|Edit|Own)$|^requireAdministrator$/;
40
+ /^require(Course|CourseInstance)Permission(Preview|View|Edit|Own)(Or(Course|CourseInstance)Permission(Preview|View|Edit|Own))*$|^requireAdministrator$/;
40
41
  const TERMINAL_METHODS = new Set(['query', 'mutation', 'subscription']);
41
42
 
42
43
  function unwrapMemberObject(expr: TSESTree.Expression): TSESTree.Expression {
@@ -35,6 +35,11 @@ ruleTester.run('require-trpc-permission-middleware', rule, {
35
35
  {
36
36
  code: 'const list = t.procedure.use(requireCoursePermissionOwn).query(async () => {});',
37
37
  },
38
+ {
39
+ code: `const list = t.procedure
40
+ .use(requireCoursePermissionEditOrCourseInstancePermissionView)
41
+ .query(async () => {});`,
42
+ },
38
43
  // Not a t.procedure chain — should be ignored
39
44
  {
40
45
  code: 'const fn = somethingElse.query(async () => {});',