Skip to content

Commit 2934761

Browse files
fix(rest): refuse a repeated ?version= on GET/DELETE /packages/:id instead of handing the array to PackageService (#6307) (#6895)
* fix(rest): refuse a repeated ?version= on GET/DELETE /packages/:id (#6307) * chore(changeset): #6307 repeated version query param * style(rest): keep the PackageRoutesOptions docstring attached to its interface --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2672f85 commit 2934761

4 files changed

Lines changed: 396 additions & 3 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
fix(rest): a repeated `?version=` on `/packages/:id` is refused, not silently resolved (#6307)
6+
7+
`IHttpRequest.query` is declared `Record<string, string | string[]>` — a repeated
8+
query parameter arrives as an **array**. Both `/api/v1/packages/:id` handlers read
9+
it as a string and passed it straight to `PackageService.get/delete`, whose
10+
parameter is `version?: string`. Measured on `main` before the fix:
11+
12+
```
13+
GET /packages/com.acme.crm?version=1.0.0&version=2.0.0
14+
→ packageService.get('com.acme.crm', ['1.0.0','2.0.0'])
15+
DELETE /packages/com.acme.crm?version=1.0.0&version=2.0.0
16+
→ packageService.delete('com.acme.crm', ['1.0.0','2.0.0'])
17+
→ 200 { message: 'Deleted com.acme.crm@1.0.0,2.0.0' }
18+
```
19+
20+
The `DELETE` line is the sharp one. `if (!version && protocol.deletePackage)` is
21+
what gates the **full uninstall** (#2747: the package's metadata rows, the durable
22+
`sys_packages` record, and the registered data-plane cleanups — plugin-security
23+
revoking its permission sets and bindings). Any truthy `version` skips it, so a
24+
repeated parameter silently narrowed the *scope of the operation* on a destructive
25+
verb and still reported success.
26+
27+
**Both verbs now refuse the ambiguity** with `400 VALIDATION_ERROR`
28+
(`The "version" query parameter was supplied 2 times. Supply it at most once — this
29+
endpoint will not choose between conflicting values.`). `?version=a&version=b` is a
30+
well-formed request carrying two conflicting intents; picking one silently is a
31+
wrong answer delivered as a `200`. The rule is identical on both verbs — one
32+
parameter, one answer — and the code comes from ADR-0112's **standard** catalog
33+
rather than a newly registered synonym, because "this request contradicts itself"
34+
is a generic validation condition.
35+
36+
The rule is about **multiplicity, not shape**: the parameter may be supplied at
37+
most once. A one-element array is one occurrence encoded differently by an adapter
38+
and is accepted; an empty array is no occurrence. Two identical values are still
39+
two occurrences and are still refused — "at most one *distinct* value" would be a
40+
de-duplication rule no client can predict, while "supply it at most once" is
41+
checkable client-side.
42+
43+
**Not tolerance for off-spec input.** The contract already declared the array; the
44+
consumer simply never handled a shape it was told to expect.
45+
46+
**Nothing that works today changes.** A single `?version=1.0.0`, no `version` at
47+
all, and an empty `?version=` all behave exactly as before — including the full
48+
uninstall still being reached when no version is supplied. No in-repo caller,
49+
documented example or SDK path repeats the parameter (`client.packages.get` builds
50+
`?version=` from a single `version?: string`), so the new 400 is unreachable from
51+
any supported client. It is `minor` rather than `patch` only because a request
52+
shape that used to answer `200` now answers `400`.
53+
54+
Adapter note, measured over a real socket: the `node:http` adapter
55+
(`NodeHttpServer`) hands `['1.0.0','2.0.0']` to the handler as the contract
56+
declares, while the Hono adapter collapses a repeat to the first value before any
57+
handler sees it. Both are contract-legal (the union permits either), which is
58+
exactly why the consumer must handle the declared shape rather than depend on
59+
which server booted.

packages/rest/src/package-envelope.conformance.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,30 @@ describe('packages envelope (#3843) — error bodies', () => {
341341
expect(body.data.packages).toHaveLength(1);
342342
});
343343

344+
it('a repeated `?version=` is refused identically on both verbs (#6307)', async () => {
345+
// The rule is one rule, so the two verbs must answer the SAME code, status
346+
// and message — two answers for one parameter would be a new inconsistency.
347+
const get = await drive(
348+
mount({ get: async () => ({ id: 'com.acme.crm', manifest: MANIFEST }) }),
349+
'GET',
350+
`${PKGS}/:id`,
351+
{ params: { id: 'com.acme.crm' }, query: { version: ['1.0.0', '2.0.0'] } },
352+
);
353+
const del = await drive(
354+
mount({ delete: async () => ({ success: true }) }),
355+
'DELETE',
356+
`${PKGS}/:id`,
357+
{ params: { id: 'com.acme.crm' }, query: { version: ['1.0.0', '2.0.0'] } },
358+
);
359+
expect(get.status).toBe(400);
360+
expect(del.status).toBe(400);
361+
expect(get.body).toEqual(del.body);
362+
expect(get.body.error.code).toBe('VALIDATION_ERROR');
363+
expect(get.body.error.message).toContain('"version"');
364+
expect(envelopeViolations(get.body)).toEqual([]);
365+
expect(BaseResponseSchema.safeParse(get.body).success).toBe(true);
366+
});
367+
344368
it('a partial uninstall keeps its per-item detail under `error.details`', async () => {
345369
const { body } = await drive(
346370
mount({}, {
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `?version=` multiplicity on `/api/v1/packages/:id` (#6307).
5+
*
6+
* `IHttpRequest.query` is declared `Record<string, string | string[]>`, so a
7+
* repeated query parameter arrives as an ARRAY. Both handlers used it as a
8+
* string and handed the array straight to `PackageService`, whose parameter is
9+
* `version?: string`. Measured on `origin/main` before the fix:
10+
*
11+
* GET ?version=1.0.0&version=2.0.0 → packageService.get(id, ['1.0.0','2.0.0'])
12+
* DELETE ?version=1.0.0&version=2.0.0 → packageService.delete(id, ['1.0.0','2.0.0'])
13+
* …and `protocol.deletePackage` NOT called,
14+
* answering 200 "Deleted com.acme.crm@1.0.0,2.0.0"
15+
*
16+
* The DELETE line is the sharp one: `if (!version && protocol.deletePackage)`
17+
* gates the FULL uninstall (metadata rows + the durable `sys_packages` record +
18+
* the registered data-plane cleanups, #2747). A truthy `version` skips it, so a
19+
* repeated parameter silently narrowed the operation's SCOPE and still reported
20+
* success. That is a wrong answer on a destructive verb, so the route refuses
21+
* the ambiguity instead of resolving it — see `readSingleQueryValue`.
22+
*
23+
* Observation-class: no user hits this today, because it takes a client that
24+
* repeats the parameter, and the Hono adapter collapses repeats to the first
25+
* value before a handler sees them. The `node:http` adapter does not (measured:
26+
* `NodeHttpServer` hands `['1.0.0','2.0.0']` through over a real socket), which
27+
* is why the consumer has to handle the shape its contract declares rather than
28+
* depend on which server booted.
29+
*
30+
* What these cases pin, in order: the single-value paths behave EXACTLY as
31+
* before (the fix is not allowed to move them), repetition is refused
32+
* identically on both verbs, and the full-uninstall branch is still reached
33+
* when no version is supplied at all.
34+
*/
35+
36+
import { describe, it, expect } from 'vitest';
37+
import type { RouteHandler } from '@objectstack/spec/contracts';
38+
import { registerPackageRoutes } from './package-routes.js';
39+
40+
const PKGS = '/api/v1/packages';
41+
const ID = 'com.acme.crm';
42+
const MANIFEST = { id: ID, version: '1.0.0' };
43+
44+
interface Captured { status: number; body: any }
45+
46+
/** Records every argument the service/protocol layer is handed. */
47+
interface Spy {
48+
getVersions: unknown[];
49+
deleteVersions: unknown[];
50+
protocolCalls: number;
51+
}
52+
53+
function harness(options: { protocol?: boolean } = {}) {
54+
const spy: Spy = { getVersions: [], deleteVersions: [], protocolCalls: 0 };
55+
const svc = {
56+
get: async (_id: string, version?: string) => {
57+
spy.getVersions.push(version);
58+
return { id: ID, manifest: MANIFEST };
59+
},
60+
delete: async (_id: string, version?: string) => {
61+
spy.deleteVersions.push(version);
62+
return { success: true };
63+
},
64+
};
65+
const opts = options.protocol
66+
? {
67+
protocol: {
68+
deletePackage: async () => {
69+
spy.protocolCalls += 1;
70+
return { success: true, deletedCount: 3, failedCount: 0, failed: [], cleanups: [] };
71+
},
72+
},
73+
}
74+
: {};
75+
76+
const routes = new Map<string, RouteHandler>();
77+
const server = {
78+
get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); },
79+
post: (p: string, h: RouteHandler) => { routes.set(`POST:${p}`, h); },
80+
put: () => {},
81+
delete: (p: string, h: RouteHandler) => { routes.set(`DELETE:${p}`, h); },
82+
patch: () => {},
83+
use: () => {},
84+
listen: async () => {},
85+
close: async () => {},
86+
} as any;
87+
registerPackageRoutes(server, svc as any, '/api/v1', opts);
88+
89+
const drive = async (method: 'GET' | 'DELETE', query: Record<string, any>): Promise<Captured> => {
90+
const handler = routes.get(`${method}:${PKGS}/:id`);
91+
if (!handler) throw new Error(`no handler for ${method}`);
92+
const captured: Captured = { status: 200, body: undefined };
93+
const res: any = {
94+
json(d: any) { captured.body = d; },
95+
send() {},
96+
status(c: number) { captured.status = c; return res; },
97+
header() { return res; },
98+
};
99+
await handler(
100+
{ params: { id: ID }, query, body: undefined, headers: {}, method, path: `${PKGS}/:id` } as any,
101+
res,
102+
);
103+
return captured;
104+
};
105+
106+
return { spy, drive };
107+
}
108+
109+
describe('#6307 — a single `?version=` behaves exactly as before', () => {
110+
it('GET with one value passes that STRING through and answers the same body', async () => {
111+
const { spy, drive } = harness();
112+
const { status, body } = await drive('GET', { version: '1.0.0' });
113+
expect(spy.getVersions).toEqual(['1.0.0']);
114+
expect(status).toBe(200);
115+
expect(body).toEqual({
116+
success: true,
117+
data: { package: { id: ID, manifest: MANIFEST, source: 'database' } },
118+
});
119+
});
120+
121+
it('GET with no version still asks for `latest`', async () => {
122+
const { spy, drive } = harness();
123+
await drive('GET', {});
124+
expect(spy.getVersions).toEqual(['latest']);
125+
});
126+
127+
it('GET with an EMPTY `?version=` still asks for `latest` (falsy, as before)', async () => {
128+
const { spy, drive } = harness();
129+
await drive('GET', { version: '' });
130+
expect(spy.getVersions).toEqual(['latest']);
131+
});
132+
133+
it('DELETE with one value stays version-scoped and answers the same body', async () => {
134+
const { spy, drive } = harness({ protocol: true });
135+
const { status, body } = await drive('DELETE', { version: '1.0.0' });
136+
expect(spy.deleteVersions).toEqual(['1.0.0']);
137+
expect(spy.protocolCalls).toBe(0);
138+
expect(status).toBe(200);
139+
expect(body).toEqual({ success: true, data: { message: `Deleted ${ID}@1.0.0` } });
140+
});
141+
});
142+
143+
describe('#6307 — the full-uninstall branch is still reached without a version', () => {
144+
it('DELETE with NO version goes through protocol.deletePackage', async () => {
145+
const { spy, drive } = harness({ protocol: true });
146+
const { status, body } = await drive('DELETE', {});
147+
expect(spy.protocolCalls).toBe(1);
148+
expect(spy.deleteVersions).toEqual([]);
149+
expect(status).toBe(200);
150+
expect(body).toEqual({
151+
success: true,
152+
data: { message: `Deleted ${ID}`, deletedCount: 3, cleanups: [] },
153+
});
154+
});
155+
156+
it('DELETE with an EMPTY `?version=` still uninstalls fully (falsy, as before)', async () => {
157+
const { spy, drive } = harness({ protocol: true });
158+
await drive('DELETE', { version: '' });
159+
expect(spy.protocolCalls).toBe(1);
160+
});
161+
162+
it('DELETE with the parameter absent from an EMPTY array is no occurrence at all', async () => {
163+
// A contract-legal encoding of "not supplied". It must not be mistaken for
164+
// a version pin — that would silently narrow the uninstall again.
165+
const { spy, drive } = harness({ protocol: true });
166+
await drive('DELETE', { version: [] });
167+
expect(spy.protocolCalls).toBe(1);
168+
});
169+
});
170+
171+
describe('#6307 — one occurrence encoded as a one-element array is still one occurrence', () => {
172+
it('GET accepts `[\'1.0.0\']` and unwraps it', async () => {
173+
const { spy, drive } = harness();
174+
const { status } = await drive('GET', { version: ['1.0.0'] });
175+
expect(status).toBe(200);
176+
expect(spy.getVersions).toEqual(['1.0.0']);
177+
});
178+
179+
it('DELETE accepts `[\'1.0.0\']` and stays version-scoped', async () => {
180+
const { spy, drive } = harness({ protocol: true });
181+
const { status } = await drive('DELETE', { version: ['1.0.0'] });
182+
expect(status).toBe(200);
183+
expect(spy.deleteVersions).toEqual(['1.0.0']);
184+
expect(spy.protocolCalls).toBe(0);
185+
});
186+
});
187+
188+
describe('#6307 — a REPEATED `?version=` is refused, not resolved', () => {
189+
it('GET answers 400 VALIDATION_ERROR and never reaches the service', async () => {
190+
const { spy, drive } = harness();
191+
const { status, body } = await drive('GET', { version: ['1.0.0', '2.0.0'] });
192+
expect(status).toBe(400);
193+
expect(body.success).toBe(false);
194+
expect(body.error.code).toBe('VALIDATION_ERROR');
195+
expect(body.error.message).toContain('"version"');
196+
expect(body.error.message).toContain('2 times');
197+
// The array never reaches `version?: string`.
198+
expect(spy.getVersions).toEqual([]);
199+
});
200+
201+
it('DELETE answers 400 and performs NO deletion of either kind', async () => {
202+
// The defect answered 200 here, having quietly skipped the full uninstall
203+
// and asked the durable registry to delete "1.0.0,2.0.0".
204+
const { spy, drive } = harness({ protocol: true });
205+
const { status, body } = await drive('DELETE', { version: ['1.0.0', '2.0.0'] });
206+
expect(status).toBe(400);
207+
expect(body.error.code).toBe('VALIDATION_ERROR');
208+
expect(spy.deleteVersions).toEqual([]);
209+
expect(spy.protocolCalls).toBe(0);
210+
});
211+
212+
it('both verbs answer the identical body — one rule, one answer', async () => {
213+
const g = await harness().drive('GET', { version: ['a', 'b'] });
214+
const d = await harness({ protocol: true }).drive('DELETE', { version: ['a', 'b'] });
215+
expect(g.status).toBe(d.status);
216+
expect(g.body).toEqual(d.body);
217+
});
218+
219+
it('two IDENTICAL values are still two occurrences, and still refused', async () => {
220+
// Deliberate: the rule is "supply it at most once", which a client can check
221+
// without knowing our semantics. "at most one DISTINCT value" would be a
222+
// de-duplication rule nobody can predict.
223+
const { spy, drive } = harness({ protocol: true });
224+
const { status } = await drive('DELETE', { version: ['1.0.0', '1.0.0'] });
225+
expect(status).toBe(400);
226+
expect(spy.protocolCalls).toBe(0);
227+
});
228+
229+
it('three or more occurrences are reported by count', async () => {
230+
const { body } = await harness().drive('GET', { version: ['1', '2', '3'] });
231+
expect(body.error.message).toContain('3 times');
232+
});
233+
});

0 commit comments

Comments
 (0)