/** * `HttpErrors.BadRequestError` (HTTP 400) is reserved for genuine input * validation. Throwing it where the upstream caller's input was already * accepted — i.e. after the provider rejected the request — mislabels the * failure. Per repo policy: prefer `HttpErrors.UnprocessableEntityError` * (422); the frontend absorbs 400s and would mask the real cause. * * Fires only on high-confidence misuse: * - Inside a `catch` whose `try.block` provably contains an `await` (async * I/O — provider, service helper, db query, fetch). Catches wrapping pure * sync operations (JSON.parse, schema.validate, parseInt) are NOT flagged * — those are legitimate input-validation throw points. * - Within 3 statements after an `await provider.*` / `await Provider.*` in * any enclosing block (function-boundary stop). * * Allowlisted message substrings (`/^Missing/i`, `/^Invalid/i`, `/required/i`, * `/must be/i`) skip the rule — those phrases describe input validation, * `BadRequestError` is correct there. */ import type { Rule } from 'eslint'; import type * as ESTree from 'estree'; import { isHttpErrorsCallee } from '../utils.js'; const SKIP_FILE_PATTERNS = [/\/provider\.ts$/, /\/credentials\.ts$/, /\/helpers\/credentials\.ts$/]; const INPUT_VALIDATION_MESSAGE_PATTERNS = [/^Missing/i, /^Invalid/i, /required/i, /must be/i]; const PROVIDER_AWAIT_LOOKBACK = 3; const BAD_REQUEST_ERROR = 'BadRequestError'; const PROVIDER_IDENTIFIER_RE = /^provider$/i; const FUNCTION_BOUNDARY_TYPES = new Set([ 'FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', ]); type NodeWithParent = ESTree.Node & { parent?: NodeWithParent }; function isFileSkipped(filename: string): boolean { return SKIP_FILE_PATTERNS.some(re => re.test(filename)); } function getLiteralMessageText( arg: ESTree.Node | null | undefined, sourceCode: Rule.RuleContext['sourceCode'], ): string | null { if (!arg) { return null; } if (arg.type === 'Literal' && typeof arg.value === 'string') { return arg.value; } if (arg.type === 'TemplateLiteral') { const raw = sourceCode.getText(arg); return raw.replace(/^`|`$/g, '').replace(/\$\{[^}]*\}/g, ''); } return null; } function isInputValidationMessage( arg: ESTree.Node | null | undefined, sourceCode: Rule.RuleContext['sourceCode'], ): boolean { const text = getLiteralMessageText(arg, sourceCode); if (text === null) { return false; } return INPUT_VALIDATION_MESSAGE_PATTERNS.some(re => re.test(text)); } function isAwaitOnProvider(awaitExpression: ESTree.Node | null | undefined): boolean { if (!awaitExpression || awaitExpression.type !== 'AwaitExpression') { return false; } const inner = awaitExpression.argument; if (!inner || inner.type !== 'CallExpression') { return false; } let callee: ESTree.Node | null | undefined = inner.callee; while (callee && callee.type === 'MemberExpression') { callee = callee.object; } if (!callee || callee.type !== 'Identifier') { return false; } return PROVIDER_IDENTIFIER_RE.test(callee.name); } function findProviderAwait(statement: ESTree.Node | undefined): ESTree.Node | null { if (!statement) { return null; } if (statement.type === 'ExpressionStatement') { return isAwaitOnProvider(statement.expression) ? statement.expression : null; } if (statement.type === 'VariableDeclaration') { for (const decl of statement.declarations) { if (decl.init && isAwaitOnProvider(decl.init)) { return decl.init; } } return null; } if (statement.type === 'ReturnStatement' && statement.argument) { return isAwaitOnProvider(statement.argument) ? statement.argument : null; } return null; } // Walks parents up to a function boundary. At each block-level ancestor, scans // up to PROVIDER_AWAIT_LOOKBACK preceding siblings for a provider await. Lets // the rule fire on `await provider.x(); if (y) { throw ... }` — the throw's // direct parent has no awaits but the enclosing block does. function isWithinProviderAwaitWindow(throwStatement: Rule.Node): boolean { let current: NodeWithParent = throwStatement as unknown as NodeWithParent; let parent: NodeWithParent | undefined = current.parent; while (parent) { if (FUNCTION_BOUNDARY_TYPES.has(parent.type)) { return false; } const parentBody = (parent as { body?: unknown }).body; if (Array.isArray(parentBody)) { const idx = parentBody.indexOf(current); if (idx > 0) { const start = Math.max(0, idx - PROVIDER_AWAIT_LOOKBACK); for (let i = idx - 1; i >= start; i--) { if (findProviderAwait(parentBody[i] as ESTree.Node)) { return true; } } } } current = parent; parent = parent.parent; } return false; } // Walk subtree looking for ANY AwaitExpression. Skips nested function bodies — // an async function defined inside the try doesn't count as awaiting at the // try-block scope. function subtreeHasAwait(rootNode: ESTree.Node): boolean { let found = false; const walk = (node: unknown): void => { if (found || !node || typeof node !== 'object') { return; } const n = node as ESTree.Node; if (n.type === 'AwaitExpression') { found = true; return; } if (FUNCTION_BOUNDARY_TYPES.has(n.type)) { return; } for (const key in n) { if (key === 'parent' || key === 'loc' || key === 'range') { continue; } const child = (n as unknown as Record)[key]; if (Array.isArray(child)) { for (const c of child) { walk(c); } } else if (child && typeof child === 'object' && 'type' in (child as object)) { walk(child); } } }; walk(rootNode); return found; } // Returns true when `node` is inside a CatchClause whose corresponding // TryStatement.block contains any `await`. Catches wrapping pure sync // operations have no await and are skipped — those are legitimate // input-validation throw points. function isInsideAsyncCatch(node: Rule.Node): boolean { let parent: NodeWithParent | undefined = (node as unknown as NodeWithParent).parent; while (parent) { if (parent.type === 'CatchClause') { const tryStatement = parent.parent; if (tryStatement && tryStatement.type === 'TryStatement') { const ts = tryStatement as unknown as ESTree.TryStatement; if (ts.block) { return subtreeHasAwait(ts.block); } } return false; } if (FUNCTION_BOUNDARY_TYPES.has(parent.type)) { return false; } parent = parent.parent; } return false; } const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { description: 'Reserve `HttpErrors.BadRequestError` (HTTP 400) for input validation. Throwing it inside a `catch` or after a `await provider.*` call mislabels a provider rejection as a caller-input error — use `HttpErrors.UnprocessableEntityError` (422) instead.', recommended: true, }, schema: [], messages: { badRequestNotInputValidation: '`HttpErrors.BadRequestError` (HTTP 400) is reserved for input validation. Throwing it {{context}} treats a provider rejection as a caller-input error — the upstream caller did not send bad input. Throw `HttpErrors.UnprocessableEntityError` (422) instead, or, if the message truly describes a missing/invalid input field, rephrase it (e.g. `Missing X`, `Invalid Y`, `X is required`, `X must be ...`).', }, }, create(context) { const filename = context.filename ?? ''; if (isFileSkipped(filename)) { return {}; } const sourceCode = context.sourceCode; return { ThrowStatement(node) { const argument = node.argument; if (!argument || argument.type !== 'NewExpression') { return; } if (!isHttpErrorsCallee(argument.callee, BAD_REQUEST_ERROR)) { return; } const firstArg = argument.arguments[0] as ESTree.Node | undefined; if (isInputValidationMessage(firstArg, sourceCode)) { return; } if (isInsideAsyncCatch(node)) { context.report({ node, messageId: 'badRequestNotInputValidation', data: { context: 'inside a `catch` that wraps an `await` (async I/O)' }, }); return; } if (isWithinProviderAwaitWindow(node)) { context.report({ node, messageId: 'badRequestNotInputValidation', data: { context: 'after a `provider.*` API call' }, }); } }, }; }, }; export default rule;