@prairielearn/eslint-plugin 4.3.6 → 4.4.0

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.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 452f675: Allow applications to configure explicit permission middleware names for the tRPC authorization rule.
8
+
3
9
  ## 4.3.6
4
10
 
5
11
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -7,7 +7,9 @@ declare const _default: {
7
7
  'no-current-target-in-callback': import("@typescript-eslint/utils/ts-eslint").RuleModule<"noCurrentTargetInCallback", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
8
8
  'no-hydrate-reslocals': import("@typescript-eslint/utils/ts-eslint").RuleModule<"forbiddenProp" | "forbiddenSpread", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
9
9
  'no-unused-sql-blocks': import("@typescript-eslint/utils/ts-eslint").RuleModule<"unusedSqlBlock", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
10
- 'require-trpc-permission-middleware': import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingPermissionMiddleware", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
10
+ 'require-trpc-permission-middleware': import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingPermissionMiddleware", [({
11
+ additionalPermissionMiddlewareNames?: string[];
12
+ } | undefined)?], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
11
13
  'safe-db-types': import("@typescript-eslint/utils/ts-eslint").RuleModule<"spreadAttributes" | "unsafeTypes", [({
12
14
  allowDbTypes?: (string | RegExp)[];
13
15
  } | undefined)?], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
@@ -1,4 +1,6 @@
1
1
  import { ESLintUtils } from '@typescript-eslint/utils';
2
- declare const _default: ESLintUtils.RuleModule<"missingPermissionMiddleware", [], unknown, ESLintUtils.RuleListener>;
2
+ declare const _default: ESLintUtils.RuleModule<"missingPermissionMiddleware", [({
3
+ additionalPermissionMiddlewareNames?: string[];
4
+ } | undefined)?], unknown, ESLintUtils.RuleListener>;
3
5
  export default _default;
4
6
  //# sourceMappingURL=require-trpc-permission-middleware.d.ts.map
@@ -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 * - Or-combinations of the above, e.g. requireCoursePermissionEditOrCourseInstancePermissionView\n * - requireAdministrator\n *\n * Feature gates like `requireAiGradingFeature` don't count — they must be\n * 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
+ {"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;;0CAmIpD,MAAM,EAAE","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 * - Exact additional names configured by another application\n *\n * Feature gates like `requireAiGradingFeature` don't count — they must be\n * 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\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 [{ additionalPermissionMiddlewareNames?: string[] }?],\n 'missingPermissionMiddleware'\n>({\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 type: 'object',\n properties: {\n additionalPermissionMiddlewareNames: {\n type: 'array',\n items: { type: 'string' },\n uniqueItems: true,\n },\n },\n additionalProperties: false,\n },\n ],\n },\n defaultOptions: [{}],\n\n create(context) {\n const additionalPermissionMiddlewareNames = new Set(\n context.options[0]?.additionalPermissionMiddlewareNames,\n );\n\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(\n (name) =>\n PERMISSION_MIDDLEWARE_PATTERN.test(name) ||\n additionalPermissionMiddlewareNames.has(name),\n );\n if (!hasPermissionGate) {\n context.report({ node, messageId: 'missingPermissionMiddleware' });\n }\n },\n };\n },\n});\n"]}
@@ -23,6 +23,7 @@ const { findVariable } = ASTUtils;
23
23
  * - requireCourseInstancePermissionPreview / View / Edit / Own
24
24
  * - Or-combinations of the above, e.g. requireCoursePermissionEditOrCourseInstancePermissionView
25
25
  * - requireAdministrator
26
+ * - Exact additional names configured by another application
26
27
  *
27
28
  * Feature gates like `requireAiGradingFeature` don't count — they must be
28
29
  * paired with a permission middleware.
@@ -31,8 +32,6 @@ const { findVariable } = ASTUtils;
31
32
  * `const protected = t.procedure.use(...); ...; protected.query(...)` are
32
33
  * checked end-to-end.
33
34
  */
34
- // TODO: This list requires manual maintenance. We should consider making feature gates
35
- // not use the `require` prefix, and then we can use a more generic pattern here.
36
35
  const PERMISSION_MIDDLEWARE_PATTERN = /^require(Course|CourseInstance)Permission(Preview|View|Edit|Own)(Or(Course|CourseInstance)Permission(Preview|View|Edit|Own))*$|^requireAdministrator$/;
37
36
  const TERMINAL_METHODS = new Set(['query', 'mutation', 'subscription']);
38
37
  function unwrapMemberObject(expr) {
@@ -107,10 +106,23 @@ export default ESLintUtils.RuleCreator.withoutDocs({
107
106
  messages: {
108
107
  missingPermissionMiddleware: 'tRPC procedure must call .use() with a permission middleware (e.g. requireCoursePermissionEdit)',
109
108
  },
110
- schema: [],
109
+ schema: [
110
+ {
111
+ type: 'object',
112
+ properties: {
113
+ additionalPermissionMiddlewareNames: {
114
+ type: 'array',
115
+ items: { type: 'string' },
116
+ uniqueItems: true,
117
+ },
118
+ },
119
+ additionalProperties: false,
120
+ },
121
+ ],
111
122
  },
112
- defaultOptions: [],
123
+ defaultOptions: [{}],
113
124
  create(context) {
125
+ const additionalPermissionMiddlewareNames = new Set(context.options[0]?.additionalPermissionMiddlewareNames);
114
126
  return {
115
127
  CallExpression(node) {
116
128
  if (node.callee.type !== 'MemberExpression' ||
@@ -123,7 +135,8 @@ export default ESLintUtils.RuleCreator.withoutDocs({
123
135
  const info = getProcedureChainInfo(node.callee.object, scope, new Set());
124
136
  if (!info.matchesTProcedure)
125
137
  return;
126
- const hasPermissionGate = info.middlewares.some((name) => PERMISSION_MIDDLEWARE_PATTERN.test(name));
138
+ const hasPermissionGate = info.middlewares.some((name) => PERMISSION_MIDDLEWARE_PATTERN.test(name) ||
139
+ additionalPermissionMiddlewareNames.has(name));
127
140
  if (!hasPermissionGate) {
128
141
  context.report({ node, messageId: 'missingPermissionMiddleware' });
129
142
  }
@@ -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 * - Or-combinations of the above, e.g. requireCoursePermissionEditOrCourseInstancePermissionView\n * - requireAdministrator\n *\n * Feature gates like `requireAiGradingFeature` don't count — they must be\n * 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
+ {"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 * - Exact additional names configured by another application\n *\n * Feature gates like `requireAiGradingFeature` don't count — they must be\n * 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\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 [{ additionalPermissionMiddlewareNames?: string[] }?],\n 'missingPermissionMiddleware'\n>({\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 type: 'object',\n properties: {\n additionalPermissionMiddlewareNames: {\n type: 'array',\n items: { type: 'string' },\n uniqueItems: true,\n },\n },\n additionalProperties: false,\n },\n ],\n },\n defaultOptions: [{}],\n\n create(context) {\n const additionalPermissionMiddlewareNames = new Set(\n context.options[0]?.additionalPermissionMiddlewareNames,\n );\n\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(\n (name) =>\n PERMISSION_MIDDLEWARE_PATTERN.test(name) ||\n additionalPermissionMiddlewareNames.has(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(requireAiGradingFeature)\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(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(requireAiGradingFeature)\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.use(requireCenterPermissionManage).query(async () => {});',\n options: [\n {\n additionalPermissionMiddlewareNames: ['requireCenterPermissionManage'],\n },\n ],\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(requireAiGradingFeature).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: 'const m = t.procedure.use(requireCenterFeature).mutation(async () => {});',\n options: [\n {\n additionalPermissionMiddlewareNames: ['requireCenterPermissionManage'],\n },\n ],\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,14 @@ 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.use(requireCenterPermissionManage).query(async () => {});',
36
+ options: [
37
+ {
38
+ additionalPermissionMiddlewareNames: ['requireCenterPermissionManage'],
39
+ },
40
+ ],
41
+ },
34
42
  {
35
43
  code: `const list = t.procedure
36
44
  .use(requireCoursePermissionEditOrCourseInstancePermissionView)
@@ -80,6 +88,15 @@ ruleTester.run('require-trpc-permission-middleware', rule, {
80
88
  code: 'const m = t.procedure.use(requireAiGradingFeature).mutation(async () => {});',
81
89
  errors: [{ messageId: 'missingPermissionMiddleware' }],
82
90
  },
91
+ {
92
+ code: 'const m = t.procedure.use(requireCenterFeature).mutation(async () => {});',
93
+ options: [
94
+ {
95
+ additionalPermissionMiddlewareNames: ['requireCenterPermissionManage'],
96
+ },
97
+ ],
98
+ errors: [{ messageId: 'missingPermissionMiddleware' }],
99
+ },
83
100
  {
84
101
  code: `const m = t.procedure
85
102
  .input(z.object({}))
@@ -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;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,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(requireAiGradingFeature)\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(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,oFAAoF;YAC1F,OAAO,EAAE;gBACP;oBACE,mCAAmC,EAAE,CAAC,+BAA+B,CAAC;iBACvE;aACF;SACF;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,8EAA8E;YACpF,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,6BAA6B,EAAE,CAAC;SACvD;QACD;YACE,IAAI,EAAE,2EAA2E;YACjF,OAAO,EAAE;gBACP;oBACE,mCAAmC,EAAE,CAAC,+BAA+B,CAAC;iBACvE;aACF;YACD,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(requireAiGradingFeature)\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.use(requireCenterPermissionManage).query(async () => {});',\n options: [\n {\n additionalPermissionMiddlewareNames: ['requireCenterPermissionManage'],\n },\n ],\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(requireAiGradingFeature).mutation(async () => {});',\n errors: [{ messageId: 'missingPermissionMiddleware' }],\n },\n {\n code: 'const m = t.procedure.use(requireCenterFeature).mutation(async () => {});',\n options: [\n {\n additionalPermissionMiddlewareNames: ['requireCenterPermissionManage'],\n },\n ],\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.6",
3
+ "version": "4.4.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,14 +15,14 @@
15
15
  "eslint": "^10.0.0"
16
16
  },
17
17
  "dependencies": {
18
- "@typescript-eslint/utils": "^8.66.0",
18
+ "@typescript-eslint/utils": "^8.67.0",
19
19
  "typescript": "^6.0.3"
20
20
  },
21
21
  "devDependencies": {
22
- "@html-eslint/parser": "^0.61.0",
22
+ "@html-eslint/parser": "^0.64.0",
23
23
  "@types/node": "^24.13.3",
24
- "@typescript-eslint/rule-tester": "^8.66.0",
25
- "@typescript-eslint/types": "^8.66.0",
24
+ "@typescript-eslint/rule-tester": "^8.67.0",
25
+ "@typescript-eslint/types": "^8.67.0",
26
26
  "@typescript/native-preview": "beta",
27
27
  "@vitest/coverage-v8": "^4.1.10",
28
28
  "vitest": "^4.1.10",
@@ -25,6 +25,7 @@ const { findVariable } = ASTUtils;
25
25
  * - requireCourseInstancePermissionPreview / View / Edit / Own
26
26
  * - Or-combinations of the above, e.g. requireCoursePermissionEditOrCourseInstancePermissionView
27
27
  * - requireAdministrator
28
+ * - Exact additional names configured by another application
28
29
  *
29
30
  * Feature gates like `requireAiGradingFeature` don't count — they must be
30
31
  * paired with a permission middleware.
@@ -34,8 +35,6 @@ const { findVariable } = ASTUtils;
34
35
  * checked end-to-end.
35
36
  */
36
37
 
37
- // TODO: This list requires manual maintenance. We should consider making feature gates
38
- // not use the `require` prefix, and then we can use a more generic pattern here.
39
38
  const PERMISSION_MIDDLEWARE_PATTERN =
40
39
  /^require(Course|CourseInstance)Permission(Preview|View|Edit|Own)(Or(Course|CourseInstance)Permission(Preview|View|Edit|Own))*$|^requireAdministrator$/;
41
40
  const TERMINAL_METHODS = new Set(['query', 'mutation', 'subscription']);
@@ -129,18 +128,37 @@ function getProcedureChainInfo(
129
128
  return { matchesTProcedure: false, middlewares: [] };
130
129
  }
131
130
 
132
- export default ESLintUtils.RuleCreator.withoutDocs({
131
+ export default ESLintUtils.RuleCreator.withoutDocs<
132
+ [{ additionalPermissionMiddlewareNames?: string[] }?],
133
+ 'missingPermissionMiddleware'
134
+ >({
133
135
  meta: {
134
136
  type: 'problem',
135
137
  messages: {
136
138
  missingPermissionMiddleware:
137
139
  'tRPC procedure must call .use() with a permission middleware (e.g. requireCoursePermissionEdit)',
138
140
  },
139
- schema: [],
141
+ schema: [
142
+ {
143
+ type: 'object',
144
+ properties: {
145
+ additionalPermissionMiddlewareNames: {
146
+ type: 'array',
147
+ items: { type: 'string' },
148
+ uniqueItems: true,
149
+ },
150
+ },
151
+ additionalProperties: false,
152
+ },
153
+ ],
140
154
  },
141
- defaultOptions: [],
155
+ defaultOptions: [{}],
142
156
 
143
157
  create(context) {
158
+ const additionalPermissionMiddlewareNames = new Set(
159
+ context.options[0]?.additionalPermissionMiddlewareNames,
160
+ );
161
+
144
162
  return {
145
163
  CallExpression(node) {
146
164
  if (
@@ -157,8 +175,10 @@ export default ESLintUtils.RuleCreator.withoutDocs({
157
175
 
158
176
  if (!info.matchesTProcedure) return;
159
177
 
160
- const hasPermissionGate = info.middlewares.some((name) =>
161
- PERMISSION_MIDDLEWARE_PATTERN.test(name),
178
+ const hasPermissionGate = info.middlewares.some(
179
+ (name) =>
180
+ PERMISSION_MIDDLEWARE_PATTERN.test(name) ||
181
+ additionalPermissionMiddlewareNames.has(name),
162
182
  );
163
183
  if (!hasPermissionGate) {
164
184
  context.report({ node, messageId: 'missingPermissionMiddleware' });
@@ -35,6 +35,14 @@ 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.use(requireCenterPermissionManage).query(async () => {});',
40
+ options: [
41
+ {
42
+ additionalPermissionMiddlewareNames: ['requireCenterPermissionManage'],
43
+ },
44
+ ],
45
+ },
38
46
  {
39
47
  code: `const list = t.procedure
40
48
  .use(requireCoursePermissionEditOrCourseInstancePermissionView)
@@ -84,6 +92,15 @@ ruleTester.run('require-trpc-permission-middleware', rule, {
84
92
  code: 'const m = t.procedure.use(requireAiGradingFeature).mutation(async () => {});',
85
93
  errors: [{ messageId: 'missingPermissionMiddleware' }],
86
94
  },
95
+ {
96
+ code: 'const m = t.procedure.use(requireCenterFeature).mutation(async () => {});',
97
+ options: [
98
+ {
99
+ additionalPermissionMiddlewareNames: ['requireCenterPermissionManage'],
100
+ },
101
+ ],
102
+ errors: [{ messageId: 'missingPermissionMiddleware' }],
103
+ },
87
104
  {
88
105
  code: `const m = t.procedure
89
106
  .input(z.object({}))