|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Query-parameter RECOGNITION, for the routes that declare a closed parameter |
| 5 | + * set (#7527). |
| 6 | + * |
| 7 | + * The sibling rule in `query-multiplicity.ts` answers "how many times may a |
| 8 | + * parameter I understand be supplied". This one answers the question before |
| 9 | + * it: **do I understand this parameter at all**. |
| 10 | + * |
| 11 | + * ## The defect this exists for |
| 12 | + * |
| 13 | + * `GET /api/v1/approvals/requests?assignedToMe=true` answered **200 with every |
| 14 | + * request the caller can see**. The handler reads the keys it knows off the |
| 15 | + * query string and ignores the remainder, so a caller who believes they asked |
| 16 | + * for "the requests assigned to me" is handed the UNFILTERED list — and cannot |
| 17 | + * tell, because an unfiltered result is shaped exactly like a genuinely broad |
| 18 | + * one. There is no status code, no header and no field that distinguishes |
| 19 | + * "your filter matched everything" from "your filter was thrown away". |
| 20 | + * |
| 21 | + * That is the same anti-pattern as #7463's defect 2 (an unknown field inside |
| 22 | + * `where` answers 200/0 instead of a located `400`), pointed the other way: |
| 23 | + * there an unrecognised key silently NARROWS to zero, here it silently WIDENS |
| 24 | + * to everything. Both are the server accepting a request it does not |
| 25 | + * understand and returning a plausible-looking answer — the #5714 / #5931 / |
| 26 | + * #7463 family norm is to refuse instead of guessing. |
| 27 | + * |
| 28 | + * ## Why refusal, and not an alias |
| 29 | + * |
| 30 | + * The obvious-looking alternative — teach the endpoint what `assignedToMe` |
| 31 | + * means — is surface expansion with no pull behind it. The capability already |
| 32 | + * exists and is already reachable: the Console asks precisely this question as |
| 33 | + * `approverId=<id>,role:user`, which the `approverId` multi-identity arm was |
| 34 | + * built for. Adding a second spelling for a question that already has one buys |
| 35 | + * nothing and has to be carried forever. Refusing costs one error path and |
| 36 | + * makes every FUTURE typo self-reporting. |
| 37 | + * |
| 38 | + * ## Why a whitelist rather than a blacklist of known-bad names |
| 39 | + * |
| 40 | + * The bug is not `assignedToMe` specifically. `assigned_to_me`, `assignee`, |
| 41 | + * `mine`, `approver` and every other plausible-but-wrong spelling fails the |
| 42 | + * identical way, silently, and a blacklist can only ever name the ones someone |
| 43 | + * already tripped over. A closed set is the only shape with no escape valve |
| 44 | + * (#5794: one fix, no escape hatch). |
| 45 | + * |
| 46 | + * ⚠️ The closed set must be MEASURED from what the handler actually reads — |
| 47 | + * filters, paging, ordering, and any alias spelling it honours — not from the |
| 48 | + * filters alone. A whitelist that forgets `limit` / `offset` converts a |
| 49 | + * silent-widening bug into a loud paging outage, which is worse. |
| 50 | + * |
| 51 | + * ## The envelope |
| 52 | + * |
| 53 | + * `400` with the ADR-0112 **nested** body `{ error: { code, message } }` — |
| 54 | + * byte-identical in position and code to the multiplicity refusal that runs on |
| 55 | + * the same handler, so one route never answers two dialects for two flavours |
| 56 | + * of "this request is malformed". `VALIDATION_ERROR` is the standard catalog's |
| 57 | + * member for 400 (`spec/src/api/errors.zod.ts`); nothing in `packages/spec` |
| 58 | + * moves for this. |
| 59 | + */ |
| 60 | + |
| 61 | +/** |
| 62 | + * The one refusal message for unrecognised query parameters, so every route |
| 63 | + * adopting this rule answers it identically. |
| 64 | + * |
| 65 | + * The message is LOCATED in both directions a caller needs: it names the |
| 66 | + * parameters that were not understood, and it lists the ones that are — so a |
| 67 | + * caller who guessed wrong can fix the request from the response alone, |
| 68 | + * without reading our source or our docs. |
| 69 | + * |
| 70 | + * @param unknown the unrecognised names, in the order they should be |
| 71 | + * reported (the caller sorts them for determinism) |
| 72 | + * @param supported every name this route accepts, sorted |
| 73 | + */ |
| 74 | +export function unknownQueryParamMessage( |
| 75 | + unknown: readonly string[], |
| 76 | + supported: readonly string[], |
| 77 | +): string { |
| 78 | + const subject = unknown.length === 1 |
| 79 | + ? `The "${unknown[0]}" query parameter is not supported by this endpoint.` |
| 80 | + : `The query parameters ${unknown.map(n => `"${n}"`).join(', ')} are not supported by this endpoint.`; |
| 81 | + return `${subject} This endpoint will not silently ignore a parameter it does not ` |
| 82 | + + `understand — an ignored filter is indistinguishable from one that matched everything. ` |
| 83 | + + `Supported parameters: ${supported.join(', ')}.`; |
| 84 | +} |
| 85 | + |
| 86 | +/** |
| 87 | + * Refuse a request carrying any query parameter outside this route's closed |
| 88 | + * set. Returns `true` when it answered the request — the caller `return`s |
| 89 | + * immediately, exactly like {@link refuseRepeatedQueryParams} and the |
| 90 | + * capability gates it sits beside. |
| 91 | + * |
| 92 | + * Runs BEFORE the multiplicity rule on purpose: "I do not know this parameter" |
| 93 | + * outranks "this parameter I do know was supplied twice", so a request that |
| 94 | + * commits both errors gets the answer that explains the more fundamental one. |
| 95 | + * |
| 96 | + * @param req the handler's request (`IHttpRequest`-shaped; `any` because |
| 97 | + * `rest-server.ts` types its handlers that way) |
| 98 | + * @param res the handler's response |
| 99 | + * @param allowed every parameter name this route accepts — including paging, |
| 100 | + * ordering and alias spellings, not only filters |
| 101 | + */ |
| 102 | +export function refuseUnknownQueryParams( |
| 103 | + req: any, |
| 104 | + res: any, |
| 105 | + allowed: readonly string[], |
| 106 | +): boolean { |
| 107 | + const query = req?.query; |
| 108 | + if (!query || typeof query !== 'object') return false; |
| 109 | + const permitted = new Set(allowed); |
| 110 | + // Sorted so a request carrying several unknown names always produces the |
| 111 | + // same message, whatever order the adapter happened to build the object in. |
| 112 | + const unknown = Object.keys(query).filter(k => !permitted.has(k)).sort(); |
| 113 | + if (unknown.length === 0) return false; |
| 114 | + res.status(400).json({ |
| 115 | + error: { |
| 116 | + code: 'VALIDATION_ERROR', |
| 117 | + message: unknownQueryParamMessage(unknown, [...allowed].sort()), |
| 118 | + }, |
| 119 | + }); |
| 120 | + return true; |
| 121 | +} |
0 commit comments