0.9 Spike S13 — ESLint input-required rule (DW.13)
0.9 Spike S13 — ESLint input-required rule (DW.13)
Section titled “0.9 Spike S13 — ESLint input-required rule (DW.13)”Status: Spike complete. Decision-gate: ADVISORY (warn) — recommended for
adoption at warn severity in quality-precheck, NOT error.
Session: S229 (continuation from S228).
Spike-plan reference: docs/plans/phase-0-investigation/0.9-spike-plan.md
§2 S13 (~lines 591–622).
Decision-graph reference: docs/plans/phase-0-investigation/0.9-decision-graph.md DW.13.
Budget: 0.5 day (≈4h actual).
1. Headline
Section titled “1. Headline”KH’s route-handler input-validation coverage is far better than the S228
V1 finding implied. The pattern is not “Zod absent, replaced by
getAuthorisedClient” but rather “Zod-via-parseBody-helper is the
canonical idiom, used in 138/193 routes (71.5%)”, with near-complete
coverage of body and query consumption. A prototype ESLint rule found
genuine gaps in 6 route handlers across 5 files — 6 true positives, 0
false positives, 100% precision. Precheck overhead is ~63 ms —
two orders of magnitude under the 10 s budget.
Recommendation: adopt the rule at warn severity in
quality-precheck. Treating the 6 existing violations as error would
block CI on legacy code; warn keeps the signal visible without forcing
emergency remediation work.
2. Audit: current input-validation pattern coverage (193 routes)
Section titled “2. Audit: current input-validation pattern coverage (193 routes)”Per the S228 caveat, the spike first audited what KH actually does today, rather than assuming a tRPC-shaped pattern.
2.1 Recognised validation patterns
Section titled “2.1 Recognised validation patterns”KH does NOT use the tRPC convention of a Zod schema attached to each
procedure. Instead, the canonical idiom is a thin Zod wrapper exposed
from lib/validation/index.ts:
parseBody(schema, raw)— sync wrapper aroundschema.parse(...), returning a{ success: true; data } | { success: false; response }discriminated union. The 400 NextResponse is pre-built with structured error details.parseBodyAsync(schema, raw)— async variant for schemas withsuperRefinenetwork calls (e.g.FeedSourceCreateSchema).parseSearchParams(schema, params)— URL params variant. Handles comma-separated arrays and numeric coercion before delegating toparseBody.- Direct Zod:
Schema.parse(...)/Schema.safeParse(...)(only 2 routes —ingest/markdown,admin/taxonomy-sync/callback). validateEditableField(value)— re-export fromlib/validation/schemas.ts.- UUID/regex guards:
UUID_RE.test(...),uuidRegex.test(...),SLUG_RE.test(...), etc. Several variants of naming convention coexist. - Structural validators:
parsePairId(id)(returnsnullon invalid composite-id format). - Sanitisers:
escapePostgrestValue(...)(defensive — relies on Postgres type coercion downstream).
2.2 Coverage figures
Section titled “2.2 Coverage figures”Total app/api/**/route.ts files: 193.
| Pattern combination | Count | Share |
|---|---|---|
| Both Zod-helper AND broad auth-helper (getAuthorisedClient / getAuthenticatedClient / verifyCronAuth) | 134 | 69.4% |
| Zod-helper only (no auth helper recognised) | 4 | 2.1% |
| Auth helper only (no Zod-helper) | 50 | 25.9% |
| Neither Zod nor auth helper | 5 | 2.6% |
| Total | 193 | 100% |
The 5 “neither” routes are not gaps in the sense the spike envisioned — they are:
app/api/cron/intelligence-cleanup/route.ts— bearer-token check againstCRON_SECRET.app/api/cron/intelligence-poll/route.ts— bearer-token check againstCRON_SECRET.app/api/health/route.ts— env-probe; readsprocess.envonly, consumes no untrusted input.app/api/mcp/[transport]/route.ts— custom OAuth flow usingcreateMcpUserClient(validates Bearer token viaauth.getUser()).app/api/plugin/download/route.ts— intentionally-public static bundle download; consumes no input.
2.3 Genuine gaps — routes that consume input without any validation
Section titled “2.3 Genuine gaps — routes that consume input without any validation”A more discriminating check — does the handler actually consume untrusted input? — yields a much smaller gap set.
Consumption signals checked:
request.json()/.formData()/.text()/.arrayBuffer()searchParams(orrequest.nextUrl.searchParams)await params(Next.js dynamic-route segments)
Validation signals checked (grep audit pass 1):
parseBody(,parseBodyAsync(,parseSearchParams(,.parse(,.safeParse(,.parseAsync(,.safeParseAsync(UUID_RE,UUID_REGEX,uuidRegex,isUuid(,isUUID(,z.string().uuid,validateEditableFieldparsePairId(,escapePostgrestValue(
Body consumption without Zod: 0/193 routes — 100% coverage. Query consumption without Zod: 0/193 routes — 100% coverage. Path consumption without any validator (file-level grep): 3 files.
The grep-level audit shows ≥98% file-level coverage. But the spike’s
prototype rule operates at handler granularity (per GET/POST/PUT/…
export), which reveals additional gaps where a file’s PUT/PATCH handler
uses parseBody but its DELETE handler reads params.id raw.
3. Prototype rule
Section titled “3. Prototype rule”File: eslint-rules/no-unvalidated-route-input.js (244 lines).
Detection model: pattern-based AST traversal, no type information —
mirrors the existing no-unchecked-supabase-error and
no-silent-promise-catch rules in eslint-rules/.
Algorithm:
- Match each
ExportNamedDeclarationwhose declaration is an HTTP-verb export (GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) — handles bothexport async functionandexport const = async () =>shapes. - Walk the handler body collecting two booleans:
consumesInput: any of(request|req|_request|_req).(json|formData|text|arrayBuffer)()await params(dynamic-route path consumption)- any
IdentifiernamedsearchParamsor anyMemberExpression.searchParams(URL query consumption).
hasValidation: any of- direct call:
parseBody(,parseBodyAsync(,parseSearchParams(,validateEditableField(,parsePairId(,escapePostgrestValue(,isUuid(,isUUID(. - member call:
.parse(,.safeParse(,.parseAsync(,.safeParseAsync(. - regex test:
<Identifier>.test(...)where the receiver matches the regex-naming convention/^([A-Z][A-Z0-9_]*_RE|[A-Z][A-Z0-9_]*_REGEX|[a-z][A-Za-z0-9]*Regex)$/— coversUUID_RE,SLUG_RE,WORKSPACE_KEY_RE,uuidRegex, etc.
- direct call:
- Report
consumesInput && !hasValidation.
Plugin registration: eslint-rules/index.js re-exports the new rule
under name local/no-unvalidated-route-input. The rule is not wired
into eslint.config.mjs — per spike constraint, the prototype measures
overhead and FP/TP but does not yet enforce. To enable, add a new block
to eslint.config.mjs (see §6 for the diff).
4. Results
Section titled “4. Results”4.1 v1 (UUID_RE / uuidRegex only) — 9 warnings
Section titled “4.1 v1 (UUID_RE / uuidRegex only) — 9 warnings”Initial implementation hard-coded a whitelist of three UUID-regex identifier names. Output:
| # | File | Handler | Classification |
|---|---|---|---|
| 1 | app/api/guides/[slug]/route.ts | GET | FP — uses SLUG_RE.test(slug) |
| 2 | app/api/guides/[slug]/route.ts | DELETE | FP — SLUG_RE.test(slug) |
| 3 | app/api/guides/[slug]/sections/route.ts | GET | FP — SLUG_RE.test(slug) |
| 4 | app/api/intelligence/workspaces/[id]/sources/[sourceId]/route.ts | DELETE | TP |
| 5 | app/api/items/[id]/files/route.ts | POST | TP |
| 6 | app/api/items/[id]/files/route.ts | DELETE | TP |
| 7 | app/api/items/[id]/layers/route.ts | GET | TP |
| 8 | app/api/layers/[id]/route.ts | DELETE | TP |
| 9 | app/api/pipeline-runs/[id]/route.ts | GET | TP |
v1 result: 6 TP, 3 FP. TP rate 66.7%, FP rate 33.3%.
4.2 v2 (regex-naming convention) — 6 warnings
Section titled “4.2 v2 (regex-naming convention) — 6 warnings”The v1 FP all shared one cause: the rule hard-coded a UUID-only regex
identifier whitelist, so legitimate non-UUID validators like SLUG_RE
were flagged. v2 replaces the whitelist with a naming-convention regex
(see §3 step 2(c)). Output:
| # | File | Handler | Classification |
|---|---|---|---|
| 1 | app/api/intelligence/workspaces/[id]/sources/[sourceId]/route.ts | DELETE | TP |
| 2 | app/api/items/[id]/files/route.ts | POST | TP |
| 3 | app/api/items/[id]/files/route.ts | DELETE | TP |
| 4 | app/api/items/[id]/layers/route.ts | GET | TP |
| 5 | app/api/layers/[id]/route.ts | DELETE | TP |
| 6 | app/api/pipeline-runs/[id]/route.ts | GET | TP |
v2 result: 6 TP, 0 FP. TP rate 100%, FP rate 0%.
Cross-check against the grep audit (§2.3): the grep audit identified 3 files with under-validated path consumption (pipeline-runs/[id], items/[id]/layers, items/[id]/files — counted once each). The rule identified 4 of those (separately counting POST + DELETE in items/[id]/files) AND 2 additional gaps the grep audit missed:
intelligence/workspaces/[id]/sources/[sourceId]— file importsparseBodyand uses it in PATCH; DELETE handler readsparams.id,params.sourceId, andsearchParams.get('confirm')without validation. File-level grep declared this file “validated” but the rule correctly isolates the DELETE handler.layers/[id]— same pattern: PATCH usesparseBody(LayerUpdateSchema, raw), DELETE readsparams.idraw.
The rule is strictly more precise than file-level grep for per-handler gaps. No false negatives observed against the audit.
4.3 Each true-positive in detail
Section titled “4.3 Each true-positive in detail”| Handler | Consumes | Risk |
|---|---|---|
intelligence/.../sources/[sourceId] DELETE | params.id, params.sourceId, searchParams.get('confirm') | malformed UUID reaches .from('feed_sources').delete().eq('id', sourceId); PG throws but error is wrapped to generic 500 |
items/[id]/files POST | params.id → from('content_items').eq('id', id) | malformed UUID → 500 instead of 400; file uploaded to Anthropic Files API before any DB check |
items/[id]/files DELETE | params.id → from('content_items').eq('id', id) | malformed UUID → 500 |
items/[id]/layers GET | params.id → from('content_items').eq('id', id) | malformed UUID → 500 |
layers/[id] DELETE | params.id → from('layer_vocabulary').eq('id', id) | malformed UUID → 500 |
pipeline-runs/[id] GET | params.id → from('pipeline_runs').eq('id', id) | malformed UUID → 500; created_by filter for non-admins reduces leak surface but doesn’t validate input format |
None are critical security holes — all six are protected by
getAuthorisedClient/getAuthenticatedClient (RLS-backed) and would
return { error: 'Failed to fetch …' } 500s rather than expose data
on bad input. The gap is diagnostic quality (400-vs-500) and
defence in depth, not authn/authz bypass.
5. Performance
Section titled “5. Performance”| Run | Wall time | Δ vs baseline |
|---|---|---|
Baseline (bun run lint, no spike rule) | 23.151 s | — |
With rule wired at warn (config block added) | 23.214 s | +63 ms |
The 10 s overhead budget is met with two orders of magnitude headroom.
This is unsurprising: the rule visits only top-level
ExportNamedDeclaration nodes whose declarations match the verb-set,
and only walks function bodies of matching exports. Most non-route files
short-circuit on the first node check.
6. Decision-gate
Section titled “6. Decision-gate”Per spike-plan §S13 the four gates are:
| Gate | Criterion | Outcome |
|---|---|---|
| Pass: add to quality-precheck | TP ≥80%, FP ≤10%, overhead ≤10 s | Hit (100% / 0% / 63 ms) — but see caveat |
| Fail (FP flood): defer | FP > 10% sustained | Not applicable (FP = 0%) |
| Pass with overhead concern: advisory | Overhead exceeds budget | Not applicable |
| Audit reveals coverage already adequate: RESOLVED-NOT-NEEDED | Coverage close to 100% | Partial match — file-level coverage 98%, handler-level coverage reveals 6 fixable handler-level gaps |
Recommendation: ADOPT AT warn SEVERITY (not error).
Reasoning:
- Adoption at
errorwould break CI today. Six existing route handlers would emit errors. The spike found no critical risk in any of the six — adopting aterrorforces emergency remediation work without commensurate risk reduction. warnmakes the signal visible without forcing immediate work. New routes that don’t validate input would surface in PR review; existing gaps remain as warnings until they get touched.- Path to
error: fix the 6 gaps (each is a 2-3 line change — addif (!UUID_RE.test(id)) return 400), then promotelocal/no-unvalidated-route-inputfromwarntoerrorin a follow-up commit. Best done as a focused mini-sweep. - Quality-precheck inclusion: the rule should run inside the
existing
quality-precheckjob — no new job, no new shard. The 63 ms overhead is negligible.
Decision-gate notation for 0.9-decision-graph.md:
DW.13 → PROVISIONAL → RESOLVED-ADVISORY. Adopt rule at
warninquality-precheck. Six handler-level gaps remain as warnings; promote toerrorafter a focused remediation sweep.[RATIFY-AT-REVIEW].
7. Wiring instructions (deferred to ratification)
Section titled “7. Wiring instructions (deferred to ratification)”To wire the rule, add this block to eslint.config.mjs immediately
before the existing D-9 console block:
{ // S229 DW.13 — Input-validation guard for Next.js route handlers // (spec: docs/plans/phase-0-investigation/0.9-spike-S13-eslint-input-required.md). // Flags route handlers that consume request input (body/query/path) without // any recognised KH validation pattern. Scoped narrowly to app/api/**. files: ['app/api/**/*.ts'], ignores: [ '**/*.test.ts', '**/*.spec.ts', '__tests__/**', ], plugins: { local: localRules, }, rules: { 'local/no-unvalidated-route-input': 'warn', }, },No other changes required — the rule is already registered in
eslint-rules/index.js.
8. Follow-up work (post-ratification)
Section titled “8. Follow-up work (post-ratification)”If DW.13 is ratified at warn:
- Open six small PRs (or one focused PR) addressing the six handler
gaps. Each is ≤5 lines (
if (!UUID_RE.test(id)) return 400). - After all six gaps closed, promote rule from
warntoerrorineslint.config.mjs. - Update
CLAUDE.mdGotchas under “Data & Architecture”: add a one-line note pointing at the new rule.
Optional refinements (defer to second-pass):
- Recognise
request.nextUrl.searchParamsexplicitly. Current rule treats anysearchParamsmember access as consumption — verified no FP in practice but worth tightening. - Recognise inline
URLconstruction:new URL(request.url). None flagged so far. - Promote
parsePairIdfrom the helper allowlist to a Zod schema so it integrates with the rest of the validation surface uniformly. Minor.
9. Appendix — rule source code
Section titled “9. Appendix — rule source code”File: eslint-rules/no-unvalidated-route-input.js.
'use strict';
/** * no-unvalidated-route-input * * Flags Next.js route-handler exports (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS) * whose body consumes untrusted input (request body, URL search params, or * dynamic path params) without any recognised KH input-validation pattern. * * Recognised validation patterns (per S228 audit, see * docs/plans/phase-0-investigation/0.9-spike-S13-eslint-input-required.md): * - Zod helpers: parseBody / parseBodyAsync / parseSearchParams from * @/lib/validation * - Direct Zod: `.parse(` / `.safeParse(` / `.parseAsync(` / * `.safeParseAsync(` on a schema-shaped identifier * - UUID guards: `UUID_RE.test(...)` / `uuidRegex.test(...)` / `isUuid(...)` * - Schema utils: validateEditableField, parsePairId (returns null on * invalid input — structural validator) * - Path sanitiser: escapePostgrestValue (defensive only, but considered * acceptable when combined with Postgres type coercion) * * Pattern-based — no type information. Mirrors the design of the existing * `no-unchecked-supabase-error` and `no-silent-promise-catch` rules. * * Spike output: * docs/plans/phase-0-investigation/0.9-spike-S13-eslint-input-required.md */
const HTTP_VERB_EXPORTS = new Set([ 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS',]);
const VALIDATION_CALL_NAMES = new Set([ 'parseBody', 'parseBodyAsync', 'parseSearchParams', 'validateEditableField', 'parsePairId', 'escapePostgrestValue', 'isUuid', 'isUUID',]);
/** * Identifiers that look like regex validators by naming convention. We treat * any `.test(...)` call whose receiver matches one of these patterns as a * validation step — this avoids the per-route-specific identifier whitelist * trap (e.g. `SLUG_RE`, `EMAIL_RE`, `WORKSPACE_KEY_RE` etc.) the rule would * otherwise force. */const REGEX_IDENTIFIER_PATTERN = /^([A-Z][A-Z0-9_]*_RE|[A-Z][A-Z0-9_]*_REGEX|[a-z][A-Za-z0-9]*Regex)$/;
const REQUEST_RECEIVER_NAMES = new Set([ 'request', 'req', '_request', '_req',]);
const BODY_CONSUMER_METHODS = new Set([ 'json', 'formData', 'text', 'arrayBuffer',]);
/** * Returns the function node if `node` is an HTTP-verb route export, else null. * * export async function GET(...) { ... } * export const GET = async (...) => { ... } */function isRouteHandlerExport(node) { if (node.type !== 'ExportNamedDeclaration' || !node.declaration) return null; const decl = node.declaration; if ( decl.type === 'FunctionDeclaration' && decl.id && HTTP_VERB_EXPORTS.has(decl.id.name) ) { return { fn: decl, name: decl.id.name }; } if (decl.type === 'VariableDeclaration') { for (const v of decl.declarations) { if ( v.id && v.id.type === 'Identifier' && HTTP_VERB_EXPORTS.has(v.id.name) && v.init && (v.init.type === 'ArrowFunctionExpression' || v.init.type === 'FunctionExpression') ) { return { fn: v.init, name: v.id.name }; } } } return null;}
/** * Walk the route-handler body looking for evidence of input consumption AND * any recognised validation pattern. */function inspectFunctionBody(body) { let consumesInput = false; let hasValidation = false;
function visit(node) { if (!node || typeof node.type !== 'string') return;
// --- Input consumption --- if ( node.type === 'CallExpression' && node.callee && node.callee.type === 'MemberExpression' && node.callee.property && node.callee.property.type === 'Identifier' && BODY_CONSUMER_METHODS.has(node.callee.property.name) && node.callee.object && node.callee.object.type === 'Identifier' && REQUEST_RECEIVER_NAMES.has(node.callee.object.name) ) { consumesInput = true; }
if ( node.type === 'AwaitExpression' && node.argument && node.argument.type === 'Identifier' && node.argument.name === 'params' ) { consumesInput = true; }
if (node.type === 'Identifier' && node.name === 'searchParams') { consumesInput = true; }
if ( node.type === 'MemberExpression' && node.property && node.property.type === 'Identifier' && node.property.name === 'searchParams' ) { consumesInput = true; }
// --- Validation patterns --- if (node.type === 'CallExpression' && node.callee) { if ( node.callee.type === 'Identifier' && VALIDATION_CALL_NAMES.has(node.callee.name) ) { hasValidation = true; } if ( node.callee.type === 'MemberExpression' && node.callee.property && node.callee.property.type === 'Identifier' ) { const methodName = node.callee.property.name; if ( methodName === 'parse' || methodName === 'safeParse' || methodName === 'parseAsync' || methodName === 'safeParseAsync' ) { hasValidation = true; } if ( methodName === 'test' && node.callee.object && node.callee.object.type === 'Identifier' && REGEX_IDENTIFIER_PATTERN.test(node.callee.object.name) ) { hasValidation = true; } } }
for (const key of Object.keys(node)) { if (key === 'parent') continue; const child = node[key]; if (!child) continue; if (Array.isArray(child)) { for (const c of child) { if (c && typeof c.type === 'string') visit(c); } } else if (typeof child.type === 'string') { visit(child); } } }
visit(body); return { consumesInput, hasValidation };}
module.exports = { meta: { type: 'problem', docs: { description: 'Disallow Next.js route handlers that consume request input (body, query, or path params) without a recognised KH input-validation pattern (Zod helpers, UUID guards, parse* helpers).', }, messages: { missingInputValidation: 'Route handler `{{ name }}` consumes request input but has no recognised input-validation pattern. Use `parseBody` / `parseSearchParams` from `@/lib/validation`, a Zod schema `.parse()/.safeParse()`, or a UUID guard (`UUID_RE.test(...)` / `parsePairId(...)`). See docs/plans/phase-0-investigation/0.9-spike-S13-eslint-input-required.md.', }, schema: [], },
create(context) { return { ExportNamedDeclaration(node) { const match = isRouteHandlerExport(node); if (!match) return; const { fn, name } = match; if (!fn.body) return;
const { consumesInput, hasValidation } = inspectFunctionBody(fn.body); if (consumesInput && !hasValidation) { context.report({ node: fn, messageId: 'missingInputValidation', data: { name }, }); } }, }; },};