fix(rest): refuse a repeated single-valued query parameter instead of coercing it (#6877) - #7324
Conversation
… coercing it (#6877) `IHttpRequest.query` is `Record< string, string | string[] >` and the array arm is produced by a real first-party adapter (`NodeHttpServer`, measured over a socket on #6878). `rest-server.ts` read ~50 of its query parameters as if the union had one arm, so a repeated parameter became a DIFFERENT value and was served with a 200. `tsc` reported none of it: every site launders the array through `any`, `String()` or `Number()`, which is why this package's DEBT count reached 0 with the whole class still live. Two outcomes were inversions rather than degradations: ?force=false&force=false → `!!['false','false']` is true, so repeating an explicit OPT-OUT switched the destructive-change guard ON ?limit=1&limit=2 (export) → Number([...]) is NaN, NaN || 0 is 0, Math.max(1, 0) is 1 → a ONE-ROW export, 200 OK Each handler now declares which of its parameters are single-valued and refuses a repeat with 400 + the ADR-0112 nested `{ error: { code, message } }` envelope (#7035's shape, #6307's rule and message — now shared in `query-multiplicity.ts` rather than duplicated). The rule counts occurrences, not values. Genuinely multi-valued parameters are untouched and pinned: `select`/`expand` (whose consumer takes `string | string[]` by design), `objects`, `fields`, `searchFields`, `approverId`. `GET /data/:object` is deliberately ungated — its arity is the `findData` normalizer's contract, filed as #7321. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #6877
IHttpRequest.queryis declaredRecord< string, string | string[] >(
packages/spec/src/contracts/http-server.ts). The array arm is not hypothetical:NodeHttpServerhands?x=1&x=2through as['1','2'], measured over a real socketon #6878.
rest-server.tsread its query parameters as if the union had one arm, so arepeated parameter did not fail — it became a different value and was served with a
200.tscreported none of it, which is the card's sharpest observation: every site laundersthe array through
any,String()orNumber(), sopackages/rest's type-check DEBTcount reached 0 with this entire class still live.
Per the 2026-08-10 ruling on both threads, this lands before #6878's route 2. The
surface is dormant today only because the production Hono adapter collapses duplicates
before handlers run; route 2 removes that collapse. Landing this first makes route 2 a
safe two-point adapter change instead of switching ~50 read points live at once.
Re-derived inventory (merge base
f758cec17, my own count — not the card's)The card's map held up; the census drifted, as expected.
f758cec17)req.querytoken occurrences inpackages/rest/src, non-test, excl.package-routes.tsrest-server.tsexternal-datasource-routes.tstypeof … === 'string'-guardedrest-server.ts, 1 inexternal-datasource-routes.ts)Command:
grep -oP 'req\?\?\.query'per file (occurrences), read points counted by handfrom the contexts. Occurrences exceed read points because a guarded site spends two
(
typeof req.query?.x === 'string' && req.query.x.toLowerCase()).Result: 24 gate sites covering 63 declared single-valued parameter slots, plus 8
parameters deliberately classified multi-valued and 3 read points deliberately left
alone with the reason recorded in the code.
Per-parameter classification
Classified from what the consumer does with the value, never from the name — the
card explicitly flagged
select/expand/objectsas suspects, and measurementmoved two of them off the defect list.
Declared SINGLE-valued (gated)
['a','b']before this PRserveMetaItemLayered(both entry points)packagegetMetaItemLayered({ packageId })GET /meta/diagnosticsseverity,type,packageas stringcast intogetMetaDiagnosticsGET /meta/_draftspackageId,typelistDraftsGET /meta/:typepackage,preview,object,includeString(['a','b'])→ view named'a,b'→ switcher silently empty; repeatedincludestopped equalling'content'→ doc bodies stripped, 200GET /meta/book/:name/treepackagegetMetaItemsGET /meta/:type/:namelayers,state,preview,packagePUT /meta/:type/:nameforce,package,modeDELETE /meta/:type/:namestate,dropStorageGET /meta/:type/:name/historysinceSeq,limitNumber()→NaN→isFinitedrops the bound → the UNLIMITED historyGET /meta/:type/:name/auditlimitPOST /meta/:type/:name/rollbacktoVersionINVALID_REQUEST"'toVersion' … is required" — wrong diagnosisGET /meta/:type/:name/difffrom,fromVersion,to,toVersionGET /meta/:type/:section/:namepackagegetMetaItemPUT /meta/:type/:section/:namepackageundefined→ the save LANDED somewhere else (env-local overlay)DELETE /data/:object/:idexpectedVersionString()→'1,2'→ guaranteed OCC conflict on a destructive verbGET /data/import/jobsobject,status,limit,offsetGET /data/:object/exportformat,header,limit,page,filter,search,orderbylimit) — see below;filterarray passedtypeof … === 'object'and was handed tofindDataas the filterGET /searchq,query,limit,perObjectString()→ the literal term'a,b'GET /public-forms/**lookup pickerqPOST /analytics/dataset/querypreview'draft'→ Studio preview silently ran on PUBLISHED rowsGET /sharing/rulesobject,activeOnlylistRules;activeOnlystopped matching → inactive rules listed tooGET /security/suggested-bindingsstatus,packageIdString()join → filtered on a status nothing hasGET /reportsobject,ownerIdlistReportsGET /approvals/requestsobject,recordId,record_id,status,submitterId,submitter_id,q,limit,offsettypeofdrop,Number()→NaNThe two inversions — not degradations, wrong answers in the opposite direction:
Genuinely MULTI-valued (deliberately NOT gated) — measured, not assumed
GET /data/:object/:idselect,expandGetDataRequest.select/.expandarez.array(z.string())(spec/src/api/protocol.zod.ts), andmetadata-protocol'sgetDatasignature is{ …, expand?: string | string[], select?: string | string[] }— it splits the comma form itself and passes an array straight through.?select=a&select=bwas already correct end to end. The card listed this line under "array flows downstream"; measurement says the downstream was built for it.GET /searchobjectsArray.isArray(objectsParam) ? objectsParam : …GET /data/:object/exportfields,searchFieldsArray.isArray(q.fields); export columns are genuinely a listGET /approvals/requestsapproverId,approver_idFlattening or refusing any of these would have been the real regression. Each is pinned
by a test so a later "completion" of the pattern fails loudly.
Left alone, with the reason recorded in the code
extractLocale's?locale=typeof-guarded; the helper is shared by ~10 routes and has noresto refuse through, and the worst case is falling back to the default locale. Converted to a comment explaining the asymmetry rather than plumbingresthrough ten call sites.GET /security/explain'ssrc.*ExplainRequestSchema.safeParse, whose members arez.string(), so an array is refused400 VALIDATION_FAILEDby the schema itself. A schema at the boundary is what the gate imitates.GET /data/:object(query: req.query)findData, whose normalizer inpackages/metadata-protocolowns every parameter's arity — the$-alias table, the implicit field-equality bucket, and$select/$expand/$searchFields, which are legitimately multi-valued. Declaring an arity list here would be this package guessing at another's contract. One measured line in that normalizer:if (options.limit != null) options.limit = Number(options.limit);→?$top=1&$top=2reaches the driver aslimit: NaN.external-datasource-routes.ts's?schema=typeof-guarded, degrades toundefined, single read point in a modulecheck:route-envelopepins at zero hand-built responses.The fix shape
readSingleQueryValue/repeatedQueryParamMessagemoved out ofpackage-routes.ts(where #6307 landed them) into a new
packages/rest/src/query-multiplicity.ts, so thereis one rule and one message rather than a second implementation free to drift.
package-routes.tsimports them; its behaviour is unchanged.The new
refuseRepeatedQueryParams(req, res, names)is what handlers open with. It:400and the ADR-0112 nestedenvelope
{ error: { code: 'VALIDATION_ERROR', message } }— the position PR fix(rest): put the/meta501 refusals inside the ADR-0112 error envelope (#7035) #7293(finding:
rest-server.ts里三个相邻/metahandler 的错误信封是三种不同形状,其中两种不符合 ADR-0112 #7035) just converged this file's/meta501 refusals onto, matched deliberately;['a']is one supply an adapter chose to encode as an array. Accepting it without unwrapping
would leave the array for the very
String()/Number()/ truthy reads this existsto protect — the acceptance would be a hole, not a courtesy. A well-formed
single-value request carries a
stringand is not touched at all, which is whatmakes preservation byte-identical.
No new error code:
VALIDATION_ERRORis already the standard catalog's member for 400(
spec/src/api/errors.zod.ts,standardErrorCodeForHttpStatus(400)) and the code #6307chose for this same condition.
packages/specis untouched, as ruled. The Honoadapter and
packages/qa/http-conformanceare untouched — those are #6878's route 2.Tests — 33 cases, in four parts
packages/rest/src/rest-server-query-multiplicity.test.ts.Every refusal case asserts the ADR-0112 pair —
statusAND the nestedbody.error.code— plus the exact message. A baretoThrow()would be worthless hereand the file says why: these handlers send, they never throw, and on the unfixed code
every one of these requests answered 200, so a throw-shaped assertion would report
"the promise resolved" and could not separate "refused with the wrong envelope" from
"did not refuse at all".
Reverse verification — direction predicted first
Prediction: removing the gate calls from
rest-server.ts(keeping the helper module andthe tests) turns the §1 refusal cases red and leaves §2 preservation, §3
multi-valued and §4 helper-unit cases green. Removed with
git checkout origin/main -- packages/rest/src/rest-server.ts(nevergit stash—shared stack).
Measured: 18 failed | 15 passed, and the 18 are exactly the predicted set —
The last two are preservation-shaped but belong to the refusal side: they pin the
normalisation, which is part of the fix. Every case that pins genuinely unchanged
behaviour stayed green with the fix removed — that is the byte-identical evidence, and
it is stronger than the argument would have been.
Restored with
git applyof the saved patch; all 33 green again.Verification
TEST_DEBT['@objectstack/rest']re-measured the way the gate does it (a siblingextendsconfig with the test globs dropped fromexclude, afterturbo run build --filter '@objectstack/rest...'): 155 errors — exactly the recordedceiling, zero margin consumed, and zero of them in either new file. The
.jsextensions are on both new imports (TS2835 trap, #7248).
Wire-visible
Requests that used to receive a wrong
200now receive a400. No well-formedsingle-value request changes in any way. Changeset added
(
.changeset/rest-query-param-multiplicity.md, patch).Out of scope, filed
findData's list-query normalizer coerces repeated query parameters without checking arity (the half #6877 could not reach) #7321 —findData's list-query normalizer coerces repeated parameters withoutchecking arity (
packages/metadata-protocol).finding-labelled, unassigned; samedormancy and the same expiry date as this card's surface.
Generated by Claude Code