Skip to content

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).


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.

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 around schema.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 with superRefine network calls (e.g. FeedSourceCreateSchema).
  • parseSearchParams(schema, params) — URL params variant. Handles comma-separated arrays and numeric coercion before delegating to parseBody.
  • Direct Zod: Schema.parse(...) / Schema.safeParse(...) (only 2 routes — ingest/markdown, admin/taxonomy-sync/callback).
  • validateEditableField(value) — re-export from lib/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) (returns null on invalid composite-id format).
  • Sanitisers: escapePostgrestValue(...) (defensive — relies on Postgres type coercion downstream).

Total app/api/**/route.ts files: 193.

Pattern combinationCountShare
Both Zod-helper AND broad auth-helper (getAuthorisedClient / getAuthenticatedClient / verifyCronAuth)13469.4%
Zod-helper only (no auth helper recognised)42.1%
Auth helper only (no Zod-helper)5025.9%
Neither Zod nor auth helper52.6%
Total193100%

The 5 “neither” routes are not gaps in the sense the spike envisioned — they are:

  1. app/api/cron/intelligence-cleanup/route.ts — bearer-token check against CRON_SECRET.
  2. app/api/cron/intelligence-poll/route.ts — bearer-token check against CRON_SECRET.
  3. app/api/health/route.ts — env-probe; reads process.env only, consumes no untrusted input.
  4. app/api/mcp/[transport]/route.ts — custom OAuth flow using createMcpUserClient (validates Bearer token via auth.getUser()).
  5. 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 (or request.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, validateEditableField
  • parsePairId(, 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.


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:

  1. Match each ExportNamedDeclaration whose declaration is an HTTP-verb export (GET | POST | PUT | PATCH | DELETE | HEAD | OPTIONS) — handles both export async function and export const = async () => shapes.
  2. 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 Identifier named searchParams or any MemberExpression.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)$/ — covers UUID_RE, SLUG_RE, WORKSPACE_KEY_RE, uuidRegex, etc.
  3. 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.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:

#FileHandlerClassification
1app/api/guides/[slug]/route.tsGETFP — uses SLUG_RE.test(slug)
2app/api/guides/[slug]/route.tsDELETEFPSLUG_RE.test(slug)
3app/api/guides/[slug]/sections/route.tsGETFPSLUG_RE.test(slug)
4app/api/intelligence/workspaces/[id]/sources/[sourceId]/route.tsDELETETP
5app/api/items/[id]/files/route.tsPOSTTP
6app/api/items/[id]/files/route.tsDELETETP
7app/api/items/[id]/layers/route.tsGETTP
8app/api/layers/[id]/route.tsDELETETP
9app/api/pipeline-runs/[id]/route.tsGETTP

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:

#FileHandlerClassification
1app/api/intelligence/workspaces/[id]/sources/[sourceId]/route.tsDELETETP
2app/api/items/[id]/files/route.tsPOSTTP
3app/api/items/[id]/files/route.tsDELETETP
4app/api/items/[id]/layers/route.tsGETTP
5app/api/layers/[id]/route.tsDELETETP
6app/api/pipeline-runs/[id]/route.tsGETTP

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 imports parseBody and uses it in PATCH; DELETE handler reads params.id, params.sourceId, and searchParams.get('confirm') without validation. File-level grep declared this file “validated” but the rule correctly isolates the DELETE handler.
  • layers/[id] — same pattern: PATCH uses parseBody(LayerUpdateSchema, raw), DELETE reads params.id raw.

The rule is strictly more precise than file-level grep for per-handler gaps. No false negatives observed against the audit.

HandlerConsumesRisk
intelligence/.../sources/[sourceId] DELETEparams.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 POSTparams.idfrom('content_items').eq('id', id)malformed UUID → 500 instead of 400; file uploaded to Anthropic Files API before any DB check
items/[id]/files DELETEparams.idfrom('content_items').eq('id', id)malformed UUID → 500
items/[id]/layers GETparams.idfrom('content_items').eq('id', id)malformed UUID → 500
layers/[id] DELETEparams.idfrom('layer_vocabulary').eq('id', id)malformed UUID → 500
pipeline-runs/[id] GETparams.idfrom('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.


RunWall 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.


Per spike-plan §S13 the four gates are:

GateCriterionOutcome
Pass: add to quality-precheckTP ≥80%, FP ≤10%, overhead ≤10 sHit (100% / 0% / 63 ms) — but see caveat
Fail (FP flood): deferFP > 10% sustainedNot applicable (FP = 0%)
Pass with overhead concern: advisoryOverhead exceeds budgetNot applicable
Audit reveals coverage already adequate: RESOLVED-NOT-NEEDEDCoverage 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:

  1. Adoption at error would break CI today. Six existing route handlers would emit errors. The spike found no critical risk in any of the six — adopting at error forces emergency remediation work without commensurate risk reduction.
  2. warn makes 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.
  3. Path to error: fix the 6 gaps (each is a 2-3 line change — add if (!UUID_RE.test(id)) return 400), then promote local/no-unvalidated-route-input from warn to error in a follow-up commit. Best done as a focused mini-sweep.
  4. Quality-precheck inclusion: the rule should run inside the existing quality-precheck job — 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 warn in quality-precheck. Six handler-level gaps remain as warnings; promote to error after 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.


If DW.13 is ratified at warn:

  1. Open six small PRs (or one focused PR) addressing the six handler gaps. Each is ≤5 lines (if (!UUID_RE.test(id)) return 400).
  2. After all six gaps closed, promote rule from warn to error in eslint.config.mjs.
  3. Update CLAUDE.md Gotchas under “Data & Architecture”: add a one-line note pointing at the new rule.

Optional refinements (defer to second-pass):

  • Recognise request.nextUrl.searchParams explicitly. Current rule treats any searchParams member access as consumption — verified no FP in practice but worth tightening.
  • Recognise inline URL construction: new URL(request.url). None flagged so far.
  • Promote parsePairId from the helper allowlist to a Zod schema so it integrates with the rest of the validation surface uniformly. Minor.

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 },
});
}
},
};
},
};