Server design-review finding. The query-parsing helpers in src/routes/records.ts validate almost nothing; malformed input either flows into the adapter as garbage or silently changes the query's meaning. (#33 decides what status malformed input gets — this issue is about the fact that today it isn't detected at all.)
1. limit becomes NaN
GET /records?limit=abc → Math.min(parseInt('abc', 10), MAX_QUERY_LIMIT) = Math.min(NaN, 1000) = NaN, assigned to query.limit (records.ts:141) and handed to the adapter's SQL LIMIT. #14's cap fix suggested parseInt(limit, 10) || MAX_LIMIT; the landed version dropped the fallback. The POST body path has the same hole via {"limit": null}? No — but {"limit": NaN} can't arrive via JSON; typeof body.limit === 'number' is safe there except for negatives and non-integers (limit: -5, limit: 2.7), which also pass through unchecked (records.ts:73). Validate: positive integer, else 400.
2. Date filters: the #15 fix missed this file
GET /records?createdBefore=garbage builds new Date('garbage') → Invalid Date → NaN comparisons in the adapter (records.ts:117-118, 125-127). This is the exact bug #15 fixed in tokens.ts/types.ts with parseDate — records.ts's GET path never got the treatment. The POST body path (parseQueryBody, records.ts:50-51, 57-58) uses parseDate but silently maps invalid → undefined, which drops the bound: a query with a malformed createdAt.before runs without it and returns a superset presented as the filtered result — the silent-widening shape haverstack/core#56 bans. Both paths: invalid date → 400.
3. Unchecked casts
parseQueryBody casts every filter field blind — f.tags as string[] accepts "tags": "not-an-array", sort.field accepts any string (records.ts:33-44, 65-71), and parseQueryParams casts ?sort= the same way (records.ts:136-138). Unknown sort fields and mistyped filters should 400, not depend on adapter mercy. Same for the version param: parseInt('1abc', 10) = 1, so GET /records/:id/versions/1abc quietly serves version 1 (records.ts:341, 352) — require an all-digits match.
Work items
Refs #33 (status + body contract), haverstack/core#56 (never silently widen), #32 (fixtures pin GET /records param behavior).
Server design-review finding. The query-parsing helpers in
src/routes/records.tsvalidate almost nothing; malformed input either flows into the adapter as garbage or silently changes the query's meaning. (#33 decides what status malformed input gets — this issue is about the fact that today it isn't detected at all.)1.
limitbecomesNaNGET /records?limit=abc→Math.min(parseInt('abc', 10), MAX_QUERY_LIMIT)=Math.min(NaN, 1000)=NaN, assigned toquery.limit(records.ts:141) and handed to the adapter's SQLLIMIT. #14's cap fix suggestedparseInt(limit, 10) || MAX_LIMIT; the landed version dropped the fallback. The POST body path has the same hole via{"limit": null}? No — but{"limit": NaN}can't arrive via JSON;typeof body.limit === 'number'is safe there except for negatives and non-integers (limit: -5,limit: 2.7), which also pass through unchecked (records.ts:73). Validate: positive integer, else 400.2. Date filters: the #15 fix missed this file
GET /records?createdBefore=garbagebuildsnew Date('garbage')→Invalid Date→ NaN comparisons in the adapter (records.ts:117-118, 125-127). This is the exact bug #15 fixed intokens.ts/types.tswithparseDate—records.ts's GET path never got the treatment. The POST body path (parseQueryBody,records.ts:50-51, 57-58) usesparseDatebut silently maps invalid →undefined, which drops the bound: a query with a malformedcreatedAt.beforeruns without it and returns a superset presented as the filtered result — the silent-widening shape haverstack/core#56 bans. Both paths: invalid date → 400.3. Unchecked casts
parseQueryBodycasts every filter field blind —f.tags as string[]accepts"tags": "not-an-array",sort.fieldaccepts any string (records.ts:33-44, 65-71), andparseQueryParamscasts?sort=the same way (records.ts:136-138). Unknown sort fields and mistyped filters should 400, not depend on adapter mercy. Same for the version param:parseInt('1abc', 10)= 1, soGET /records/:id/versions/1abcquietly serves version 1 (records.ts:341, 352) — require an all-digits match.Work items
limitvalidation, both paths (reject rather than clamp-to-default on garbage; clamping toMAX_QUERY_LIMITon large-but-valid stays)parseDate+ 400 on both date-filter paths; never silently drop a boundsort.field/directionagainst the allowed sets; type-check array/object filter fields; strict version-param parsecodevocabulary, structured bodies, 400/422 discipline (core #53) #33Refs #33 (status + body contract), haverstack/core#56 (never silently widen), #32 (fixtures pin
GET /recordsparam behavior).