|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Query-parameter MULTIPLICITY, for every REST handler in this package (#6877). |
| 5 | + * |
| 6 | + * `IHttpRequest.query` is declared `Record< string, string | string[] >` |
| 7 | + * (`packages/spec/src/contracts/http-server.ts`). A repeated parameter |
| 8 | + * (`?package=a&package=b`) is the ARRAY arm, and that arm is not hypothetical: |
| 9 | + * the `node:http` adapter (`@objectstack/http-conformance`'s `NodeHttpServer`) |
| 10 | + * hands it through as `['a','b']`, measured over a real socket on #6878. The |
| 11 | + * production Hono adapter happens to collapse repeats to the first value before |
| 12 | + * a handler runs — which is why this class is dormant today, and why it stops |
| 13 | + * being dormant the moment that collapse is removed (#6878's ruled route 2). |
| 14 | + * |
| 15 | + * ## Why the answer is a refusal and not a rule for picking |
| 16 | + * |
| 17 | + * `?version=1.0.0&version=2.0.0` is a well-formed request carrying two |
| 18 | + * conflicting intents. Picking one silently is a wrong answer delivered as a |
| 19 | + * success. The measured shapes in `rest-server.ts` were all of that kind, in |
| 20 | + * three flavours — none of which `tsc` can see, because each launders the array |
| 21 | + * through `any`, `String()` or `Number()`: |
| 22 | + * |
| 23 | + * - `?force=false&force=false` → `!!['false','false']` → **`force: true`**, |
| 24 | + * i.e. a repeated *opt-out* turned the destructive-change guard OFF. |
| 25 | + * - `?limit=1&limit=2` on the export route → `Number([...])` is `NaN`, |
| 26 | + * `NaN || 0` is `0`, `Math.max(1, 0)` is `1` → a **one-row export**, 200 OK. |
| 27 | + * - `?status=open&status=won` → `String([...])` → the single status |
| 28 | + * `'open,won'`, a name no record has → an empty list, 200 OK. |
| 29 | + * |
| 30 | + * So the rule is about the COUNT, not the shape: a parameter this API declares |
| 31 | + * single-valued may be supplied **at most once**. A one-element array is one |
| 32 | + * occurrence encoded differently by an adapter and is accepted (and unwrapped); |
| 33 | + * an empty array is no occurrence. Two identical values are still two |
| 34 | + * occurrences and are still refused — "at most one *distinct* value" would be a |
| 35 | + * de-duplication rule no caller can predict, while "supply it at most once" is |
| 36 | + * checkable client-side without knowing anything about our semantics. |
| 37 | + * |
| 38 | + * This is NOT tolerance for off-spec input: the contract already declares the |
| 39 | + * array. It is the consumer finally handling a declared shape. |
| 40 | + * |
| 41 | + * ## Single-valued is a per-parameter judgement, never a sweep |
| 42 | + * |
| 43 | + * Several parameters on this surface are genuinely multi-valued and their |
| 44 | + * consumers already read both arms — `select` / `expand` (`getData` accepts |
| 45 | + * `string | string[]` and splits the comma form itself), `objects` on |
| 46 | + * `/search`, `fields` / `searchFields` on the export route, `approverId` on |
| 47 | + * `/approvals/requests`. Those are deliberately absent from every declaration |
| 48 | + * below; flattening them would be a real regression, which is why each call |
| 49 | + * site names the parameters it declares single-valued instead of gating "every |
| 50 | + * key in `req.query`". |
| 51 | + * |
| 52 | + * #6307 landed the first copy of this rule in `package-routes.ts`. The two pure |
| 53 | + * helpers now live here so there is ONE rule and one message, not a second |
| 54 | + * implementation that drifts. |
| 55 | + */ |
| 56 | + |
| 57 | +/** |
| 58 | + * The outcome of reading a query parameter that this API declares as |
| 59 | + * single-valued. `ok: false` carries the multiplicity so the refusal can say |
| 60 | + * what it saw rather than only that it refused. |
| 61 | + */ |
| 62 | +export type SingleQueryRead = |
| 63 | + | { readonly ok: true; readonly value: string | undefined } |
| 64 | + | { readonly ok: false; readonly count: number }; |
| 65 | + |
| 66 | +/** |
| 67 | + * Read a query parameter the route declares single-valued out of the shape the |
| 68 | + * transport contract actually declares (#6307). |
| 69 | + * |
| 70 | + * See this module's header for why repetition is refused rather than resolved, |
| 71 | + * and why the rule counts occurrences instead of inspecting the value. |
| 72 | + */ |
| 73 | +export function readSingleQueryValue(raw: string | string[] | undefined): SingleQueryRead { |
| 74 | + if (Array.isArray(raw)) { |
| 75 | + // length 0 → the parameter was not supplied; length 1 → supplied once. |
| 76 | + return raw.length > 1 ? { ok: false, count: raw.length } : { ok: true, value: raw[0] }; |
| 77 | + } |
| 78 | + return { ok: true, value: raw }; |
| 79 | +} |
| 80 | + |
| 81 | +/** |
| 82 | + * The one refusal message for a repeated single-valued parameter, so every |
| 83 | + * route answers the SAME rule identically — two different answers for one |
| 84 | + * parameter would just be a new inconsistency. |
| 85 | + */ |
| 86 | +export function repeatedQueryParamMessage(name: string, count: number): string { |
| 87 | + return `The "${name}" query parameter was supplied ${count} times. Supply it at most once — ` |
| 88 | + + `this endpoint will not choose between conflicting values.`; |
| 89 | +} |
| 90 | + |
| 91 | +/** |
| 92 | + * The gate `rest-server.ts` handlers open with: refuse a repeated occurrence of |
| 93 | + * any parameter this route declares single-valued, and normalise the benign |
| 94 | + * one-element array away so nothing downstream has to know the union existed. |
| 95 | + * |
| 96 | + * Returns `true` when it answered the request — the caller `return`s |
| 97 | + * immediately, exactly like the capability gates it sits beside. |
| 98 | + * |
| 99 | + * ## The envelope |
| 100 | + * |
| 101 | + * `400` with the ADR-0112 **nested** body `{ error: { code, message } }` — the |
| 102 | + * position ADR-0112 declares and the one PR #7293 (#7035) just converged this |
| 103 | + * file's `/meta` 501 refusals onto. `VALIDATION_ERROR` is not a new code: it is |
| 104 | + * the standard catalog's member for 400 (`spec/src/api/errors.zod.ts`, |
| 105 | + * `standardErrorCodeForHttpStatus(400)`), and the same code #6307 chose for |
| 106 | + * this same condition on `/packages/:id`. Nothing in `packages/spec` moves. |
| 107 | + * |
| 108 | + * ## Why it also normalises |
| 109 | + * |
| 110 | + * The rule accepts a one-element array as one occurrence. Accepting it without |
| 111 | + * unwrapping would leave `['a']` for the very `String()` / `Number()` / truthy |
| 112 | + * reads this exists to protect, so the acceptance would be a hole rather than a |
| 113 | + * courtesy. `req.query` is a plain mutable `Record` per the contract, and the |
| 114 | + * assignment happens ONLY on the array arm — a well-formed single-valued |
| 115 | + * request carries a `string` and is not touched at all, which is what makes the |
| 116 | + * preservation half of this change byte-identical. |
| 117 | + * |
| 118 | + * @param req the handler's request (`IHttpRequest`-shaped; `any` because |
| 119 | + * `rest-server.ts` types its handlers that way) |
| 120 | + * @param res the handler's response |
| 121 | + * @param names the parameters THIS route declares single-valued, in the order |
| 122 | + * they should be reported; the first repeated one is refused, so |
| 123 | + * the answer is deterministic for a request that repeats two. |
| 124 | + */ |
| 125 | +export function refuseRepeatedQueryParams( |
| 126 | + req: any, |
| 127 | + res: any, |
| 128 | + names: readonly string[], |
| 129 | +): boolean { |
| 130 | + const query = req?.query; |
| 131 | + if (!query || typeof query !== 'object') return false; |
| 132 | + for (const name of names) { |
| 133 | + const raw = query[name]; |
| 134 | + if (!Array.isArray(raw)) continue; |
| 135 | + const read = readSingleQueryValue(raw); |
| 136 | + if (!read.ok) { |
| 137 | + res.status(400).json({ |
| 138 | + error: { code: 'VALIDATION_ERROR', message: repeatedQueryParamMessage(name, read.count) }, |
| 139 | + }); |
| 140 | + return true; |
| 141 | + } |
| 142 | + // One occurrence encoded as an array (or none): unwrap so every read |
| 143 | + // below this line sees the `string | undefined` it was written for. |
| 144 | + query[name] = read.value as string; |
| 145 | + } |
| 146 | + return false; |
| 147 | +} |
0 commit comments