Skip to content

Commit 347b347

Browse files
committed
fix(runtime): a repeated ?version= on GET /packages/:id answers 400 VALIDATION_ERROR from the one shared rule (#17672)
The door refused a repeated `?version=` with `404` and a sentence of its own, so a request-shape error was indistinguishable from the two genuine not-founds the same door answers. The repo's one rule for this condition already answers `400 VALIDATION_ERROR` in the ADR-0112 nested body; what blocked #17668 from calling it was that `packages/rest/src/query-multiplicity.ts` is reachable from nowhere outside its package. - `@objectstack/rest`'s barrel publishes `repeatedQueryParamMessage` and `refuseRepeatedQueryParams`, with the entry recording which half is portable across a package boundary and which is not. - The dispatcher's `/packages` domain calls the message function and drops its local copy; `deps.error(msg, 400)` derives `VALIDATION_ERROR`. - The module header's "and it reads no `version`" parenthetical is corrected — false since #17668 landed, and load-bearing prose about why the rule has one home. - `packages-get-version-scope.test.ts` §4's pin is deliberately changed from the interim `404` to the end state, and §5 pins the distinction the card is about. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
1 parent 310760d commit 347b347

5 files changed

Lines changed: 270 additions & 32 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
'@objectstack/rest': minor
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(runtime): a repeated `?version=` on `GET /packages/:id` is refused `400 VALIDATION_ERROR` in the repo's one message, and `@objectstack/rest` publishes the rule that owns it (#17672)
7+
8+
`GET /api/v1/packages/:id?version=a&version=b` answered **`404`**, with a second
9+
sentence written at that door. This repo already had a landed answer for exactly
10+
that condition on exactly that route — `400 VALIDATION_ERROR` in the ADR-0112
11+
nested body (#6307) — and one implementation of it, `refuseRepeatedQueryParams`
12+
/ `repeatedQueryParamMessage` in `packages/rest/src/query-multiplicity.ts`,
13+
whose header is the authority on the rule.
14+
15+
Driven before the change, one host, three refusals:
16+
17+
```
18+
GET /packages/com.acme.crm?version=a&version=b -> 404 RESOURCE_NOT_FOUND
19+
GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND
20+
GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND
21+
```
22+
23+
A client branching on the answer could not tell "your request named the
24+
parameter twice" from the two genuine not-founds. After:
25+
26+
```
27+
GET /packages/com.acme.crm?version=a&version=b -> 400 VALIDATION_ERROR
28+
GET /packages/com.acme.crm?version=99.0.0 -> 404 RESOURCE_NOT_FOUND
29+
GET /packages/com.absent.pkg?version=99.0.0 -> 404 RESOURCE_NOT_FOUND
30+
```
31+
32+
The body is the dispatcher's declared envelope —
33+
`{ success: false, error: { code: 'VALIDATION_ERROR', message, httpStatus: 400 } }`
34+
— with `VALIDATION_ERROR` derived by `buildApiError` from
35+
`standardErrorCodeForHttpStatus(400)`, the standard catalog's member for 400.
36+
⛔ Nothing in `packages/spec` moves.
37+
38+
**What was actually blocking this was reachability, not judgement.**
39+
`@objectstack/rest` declares exactly one export subpath and that module was not
40+
on it, so #17668 could neither call the rule nor (correctly) copy it, and
41+
shipped the `404` with its own sentence instead. The barrel now publishes
42+
`repeatedQueryParamMessage` and `refuseRepeatedQueryParams`, and the dispatcher
43+
domain calls the message function — so the sentence a caller is told for a
44+
repeated parameter is the same one on every door that carries the rule, ⛔ never
45+
a second copy that drifts.
46+
47+
⚠️ The two published symbols are not interchangeable across a package boundary,
48+
and the barrel entry says so. `repeatedQueryParamMessage` is the portable half:
49+
a pure function of two primitives. `refuseRepeatedQueryParams` writes the bare
50+
ADR-0112 body onto a `res`, which suits handlers of that shape and ⛔ not a
51+
runtime dispatcher domain — measured, its body fails that surface's
52+
`BaseResponseSchema` with `success is missing, must be a boolean`.
53+
54+
**Not a breaking change, measured rather than assumed.** The `404` it replaces
55+
was introduced by #17668 (`1a25f4a8d`), which is not an ancestor of
56+
`@objectstack/runtime@17.4.0` (exit 1; two control commits from that tag's own
57+
history answer exit 0 on the same predicate, in a checkout
58+
`--is-shallow-repository` reports `false`). It has never been published, so no
59+
released consumer can have branched on it. Everything else about the door is
60+
unchanged: `?version=<installed>` and `?version=latest` still serve the
61+
installed row, an absent version and an unknown id still answer `404`, and a
62+
one-element array is still one occurrence.
63+
64+
Also corrected, on the module that owns the rule: its header said the
65+
dispatcher's `/packages` domain "reads no `version`" — load-bearing prose,
66+
since it is part of why the rule needs only one home. That stopped being true
67+
when #17668 landed. The paragraph now states what is true, which is that the one
68+
home did not move and now serves two doors.

packages/rest/src/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,24 @@ export { coerceRow } from './import-coerce.js';
4747
export type { CoerceContext, RefResolver } from './import-coerce.js';
4848
export { buildFieldMetaMap } from './export-format.js';
4949
export type { ExportFieldMeta } from './export-format.js';
50+
51+
// Query-parameter MULTIPLICITY — the repo's ONE rule for a single-valued
52+
// parameter supplied more than once (#6307 / #6877), published so the doors
53+
// OUTSIDE this package can answer it with that one implementation instead of a
54+
// second copy that drifts (#17672). `query-multiplicity.ts`'s header is the
55+
// authority on the rule; what belongs here is which half travels.
56+
//
57+
// `repeatedQueryParamMessage` is the portable half and the one the dispatcher's
58+
// `/packages` domain calls: it is a pure function of two primitives, so a
59+
// caller in any package gets the same sentence and no transport assumptions
60+
// ride along with it.
61+
//
62+
// ⚠️ `refuseRepeatedQueryParams` is the `res`-shaped gate, for a consumer that
63+
// has a response object to write — this package's own handlers, and any sibling
64+
// mounting handlers of that shape. It is NOT usable from a runtime dispatcher
65+
// domain: the body it writes is the bare ADR-0112 `{ error: { code, message } }`
66+
// and that surface's envelope needs the `success` / `httpStatus` siblings
67+
// `@objectstack/runtime`'s `buildApiError` adds (measured on #17672 — the gate's
68+
// body fails `BaseResponseSchema` with `success is missing, must be a boolean`).
69+
// ⛔ A dispatcher domain takes the message and builds its own body.
70+
export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-multiplicity.js';

packages/rest/src/query-multiplicity.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,28 @@ import { RPC_QUERY_ALIAS_SLOTS } from '@objectstack/spec/data';
5858
* #6307 landed the first copy of this rule in `package-routes.ts`, on the
5959
* `?version=` of that registrar's package read/delete routes. Those routes are
6060
* gone (#14503 — the dispatcher's `/packages` domain is their single
61-
* implementation, and it reads no `version`), so the rule now has one home:
62-
* here, for the `rest-server.ts` read points — ONE rule and one message, not
63-
* a second implementation that drifts.
61+
* implementation), so the rule has one home: here.
62+
*
63+
* That domain DOES read `?version=`: #17668 taught `GET /packages/:id` to
64+
* honour it. An earlier version of this paragraph said it read none, which
65+
* stopped being true the day that landed and left this module understating its
66+
* own scope (#17672). A repeated occurrence there is refused with
67+
* {@link repeatedQueryParamMessage} from here, so the home neither moved nor
68+
* split: the rule serves TWO doors — the `rest-server.ts` read points through
69+
* {@link refuseRepeatedQueryParams}, and that dispatcher domain through the
70+
* message function alone — ONE rule and one message, not a second
71+
* implementation that drifts.
72+
*
73+
* ⚠️ Why the dispatcher domain takes only the message: the two doors write
74+
* their bodies through different builders. {@link refuseRepeatedQueryParams}
75+
* puts the ADR-0112 body on `res` itself, which is right for the handlers in
76+
* this package; a dispatcher domain RETURNS `{ handled, response }` and every
77+
* error body on that surface is built by `@objectstack/runtime`'s
78+
* `buildApiError`, whose envelope carries the `success` / `httpStatus` siblings
79+
* this one does not — measured on #17672: the body written below fails that
80+
* surface's `BaseResponseSchema` with `success is missing, must be a boolean`.
81+
* So the message is the portable half and the gate is not: ⛔ a dispatcher
82+
* domain calls {@link repeatedQueryParamMessage}, never this gate.
6483
*/
6584

6685
/**

packages/runtime/src/domains/packages-get-version-scope.test.ts

Lines changed: 110 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,28 @@
3232
*
3333
* §3 pins the two requests that mean "the installed row" — no parameter and
3434
* `?version=latest` — as ONE request, which is the contract the deleted
35-
* handler published (`requested.value || 'latest'`). §4 pins the repeated
36-
* parameter as not-a-silent-success: `?version=a&version=b` carries two
37-
* conflicting intents, and the answer names what it saw rather than choosing.
38-
* ⚠️ §4 asserts the DEFECT CLASS is closed (no `200` with the installed row),
39-
* deliberately not the exact status, because the repo's one rule for a repeated
40-
* single-valued parameter answers `400 VALIDATION_ERROR` and is unreachable
41-
* from this package today — see `readRequestedVersion`'s header in
42-
* `packages.ts`. So this pin stays green when that rule lands here.
35+
* handler published (`requested.value || 'latest'`).
36+
*
37+
* §4 pins the repeated parameter. ⚠️ [#17672] **This section's pin was changed
38+
* deliberately.** As written for #17416 it asserted only that the DEFECT CLASS
39+
* was closed (`status` is not `200`, no installed row rides out) and explicitly
40+
* NOT the status, because the repo's one rule for a repeated single-valued
41+
* parameter answers `400 VALIDATION_ERROR` and was then reachable from nowhere
42+
* outside `@objectstack/rest` — so the door shipped a `404` and this pin was
43+
* written loose enough to survive the eventual fix. ⛔ That `404` was never
44+
* this door's contract: it was the interim answer of an unreachable rule, and
45+
* #17672 filed it because it made a request-shape error indistinguishable from
46+
* the two genuine not-founds §1 pins. The rule is reachable now
47+
* (`@objectstack/rest` publishes `repeatedQueryParamMessage`), so §4 pins the
48+
* END state — the status, the `VALIDATION_ERROR` code, the ADR-0112 nested
49+
* body, and the message BY DERIVATION from the shared function rather than as a
50+
* literal, so a caller is told the same sentence here as on every other door
51+
* that carries the rule.
52+
*
53+
* §5 is the card's actual acceptance criterion, which neither §1 nor §4 states
54+
* on its own: the three refusals this door can give are mutually
55+
* distinguishable by `status` + `error.code`, so a client branching on them can
56+
* finally tell "your request named the parameter twice" from "not found".
4357
*
4458
* ## The harness
4559
*
@@ -51,6 +65,13 @@
5165

5266
import { describe, it, expect } from 'vitest';
5367
import { SchemaRegistry } from '@objectstack/objectql';
68+
// [#17672] The SHARED rule's message, from the module that owns it. §4 asserts
69+
// the wire text by DERIVATION from this function — ⛔ never as a literal, which
70+
// would go on passing while the door answered a sentence of its own that
71+
// happened to match the day it was written. The control that makes the
72+
// derivation falsifiable is the ablation recorded in the PR: change the
73+
// sentence here and this door's answer moves with it.
74+
import { repeatedQueryParamMessage } from '@objectstack/rest';
5475
import { HttpDispatcher } from '../http-dispatcher.js';
5576

5677
const PKG = 'com.acme.crm';
@@ -121,6 +142,9 @@ describe('#17416 GET /packages/:id — ?version= scopes the read', () => {
121142

122143
// The discriminating field, not the status alone.
123144
expect(scoped.status).toBe(404);
145+
// [#17672] A GENUINE not-found, and it stays one: this is the half
146+
// of the card that must SURVIVE the repeated-parameter fix.
147+
expect(scoped.body?.error?.code).toBe('RESOURCE_NOT_FOUND');
124148
expect(scoped.body?.error?.message).toContain(ABSENT);
125149
expect(scoped.body?.error?.message).toContain(INSTALLED);
126150
// ⛔ No package row rode out on the refusal.
@@ -147,6 +171,20 @@ describe('#17416 GET /packages/:id — ?version= scopes the read', () => {
147171
// ⛔ The id 404 is NOT re-worded by the version scope: a package that
148172
// is not here cannot be "at the wrong version".
149173
expect(r.response?.body?.error?.message).toBe(`Package 'com.absent.pkg' not found`);
174+
// [#17672] The second genuine not-found, pinned on its code too.
175+
expect(r.response?.body?.error?.code).toBe('RESOURCE_NOT_FOUND');
176+
});
177+
178+
it('an unknown id wins over a repeated ?version= — the #17416 ordering, unchanged', async () => {
179+
// [#17672] The multiplicity check sits AFTER the id lookup, where
180+
// #17416 put the version scope. This card moved the STATUS of a
181+
// refusal, ⛔ not the order of two refusals — so an id this registry
182+
// does not hold keeps answering `not found` with a repeated
183+
// parameter riding along, and that is pinned rather than incidental.
184+
const dispatcher = make();
185+
const r = await dispatcher.handlePackages('/com.absent.pkg', 'GET', undefined, { version: ['a', 'b'] }, reader());
186+
expect(r.response?.status).toBe(404);
187+
expect(r.response?.body?.error?.message).toBe(`Package 'com.absent.pkg' not found`);
150188
});
151189
});
152190

@@ -180,14 +218,42 @@ describe('#17416 GET /packages/:id — ?version= scopes the read', () => {
180218
});
181219
});
182220

183-
describe('§4 a repeated ?version= is not resolved silently', () => {
221+
describe('§4 a repeated ?version= is refused 400 VALIDATION_ERROR, in the shared rule’s words', () => {
184222
it('two conflicting values are not answered 200 with the installed row', async () => {
185223
const r = await get({ version: [ABSENT, INSTALLED] });
186-
// The defect class: a success carrying a row the caller did not ask for.
224+
// The defect class #17416 closed: a success carrying a row the
225+
// caller did not ask for. Kept as its own assertion — the status
226+
// pin below is a stronger claim, and this one is the reason.
187227
expect(r.status).not.toBe(200);
188228
expect(r.body?.data).toBeUndefined();
189-
// It says what it saw rather than choosing one of the two.
190-
expect(r.body?.error?.message).toContain('supplied 2 times');
229+
});
230+
231+
it('[#17672] answers 400 VALIDATION_ERROR — a request-shape error, not a not-found', async () => {
232+
const r = await get({ version: [ABSENT, INSTALLED] });
233+
// ⚠️ The interim answer was `404`. See this file's header: that was
234+
// the answer of an unreachable rule, never this door's contract.
235+
expect(r.status).toBe(400);
236+
// ADR-0112 NESTED body, and the standard catalog's member for 400 —
237+
// derived by `buildApiError` from the status, so nothing in
238+
// `packages/spec` moved for it.
239+
expect(r.body?.error?.code).toBe('VALIDATION_ERROR');
240+
expect(r.body?.error?.httpStatus).toBe(400);
241+
expect(r.body?.success).toBe(false);
242+
expect(r.body?.data).toBeUndefined();
243+
});
244+
245+
it('[#17672] the sentence is the SHARED one, by derivation — not a local copy that matches', async () => {
246+
const r = await get({ version: [ABSENT, INSTALLED] });
247+
// ⛔ Not a literal. This is the whole point of the card: one rule,
248+
// one message. Computed from `@objectstack/rest`'s function, so the
249+
// day that sentence changes, this door's answer changes with it —
250+
// and a door that grew a second sentence of its own turns this red.
251+
expect(r.body?.error?.message).toBe(repeatedQueryParamMessage('version', 2));
252+
// The count is the door's own reading, not a constant in the
253+
// message: three occurrences say three.
254+
const three = await get({ version: ['a', 'b', 'c'] });
255+
expect(three.body?.error?.message).toBe(repeatedQueryParamMessage('version', 3));
256+
expect(three.status).toBe(400);
191257
});
192258

193259
it('ONE occurrence encoded as a one-element array is one occurrence', async () => {
@@ -202,6 +268,38 @@ describe('#17416 GET /packages/:id — ?version= scopes the read', () => {
202268
const r = await get({ version: [ABSENT] });
203269
expect(r.status).toBe(404);
204270
expect(r.body?.error?.message).toContain(ABSENT);
271+
// [#17672] Still a genuine not-found — the unwrapping rule means
272+
// one occurrence is one occurrence, so this is NOT a shape error.
273+
expect(r.body?.error?.code).toBe('RESOURCE_NOT_FOUND');
274+
});
275+
});
276+
277+
describe('§5 [#17672] the three refusals are mutually distinguishable', () => {
278+
it('a client branching on status + code can tell shape-error from not-found', async () => {
279+
// The card's acceptance criterion, stated as one reading. Before
280+
// this fix all three were `404` / `RESOURCE_NOT_FOUND` — the whole
281+
// defect, and the reason §1's two pins alone did not catch it.
282+
const host = make();
283+
const repeated = await read(host, { version: [ABSENT, INSTALLED] });
284+
const wrongVersion = await read(host, { version: ABSENT });
285+
const unknownId = await (async () => {
286+
const r = await host.handlePackages('/com.absent.pkg', 'GET', undefined, {}, reader());
287+
return { status: r.response?.status ?? 200, body: r.response?.body };
288+
})();
289+
290+
const seen = [repeated, wrongVersion, unknownId]
291+
.map((r) => `${r.status} ${r.body?.error?.code}`);
292+
expect(seen).toEqual([
293+
'400 VALIDATION_ERROR',
294+
'404 RESOURCE_NOT_FOUND',
295+
'404 RESOURCE_NOT_FOUND',
296+
]);
297+
// The request-shape error is separated from BOTH not-founds, which
298+
// is the distinction the card asked for. The two not-founds remain
299+
// one class on purpose — they differ by message, and §1 pins that.
300+
expect(seen[0]).not.toBe(seen[1]);
301+
expect(seen[0]).not.toBe(seen[2]);
302+
expect(wrongVersion.body?.error?.message).not.toBe(unknownId.body?.error?.message);
205303
});
206304
});
207305
});

0 commit comments

Comments
 (0)