Skip to content

Commit 667192b

Browse files
os-helpclaude
andauthored
fix(rest): honour ?id= on GET /api/v1/meta/app instead of dropping it (#7566) (#7662)
`GET /api/v1/meta/app?id=…` accepted the parameter and then ignored it — the same apps came back for every value, including one that names no app. Nothing on `GET /meta/:type` had ever read `id`: the list route narrows by permission (`filterAppForUser`, rest-server.ts:4706) and by `?package=` / `?object=` / `?include=`, and `id` was never among them. A caller could not tell a working filter from a dropped one. - The filter is honoured, matching on `name` — the App document's identity (`AppSchema.name`), the key `GET /meta/app/:name` addresses. Both spellings of the type segment are covered via `metaTypeSingular`. - A filter matching nothing answers 200 with an EMPTY list, not a 404 — measured off this route's siblings (`?package=<no such package>` and `/meta/view?object=<no such object>` both serve an empty list; the only meta 404 is the single-item address). - A repeated `?id=a&id=b` is refused 400 `VALIDATION_ERROR` through the same `refuseRepeatedQueryParams` gate this route already opens with (#6877). - Runs after the ADR-0045 §3 publish gate, so `?id=<unpublished app>` answers the same empty list to a non-builder as a nonexistent id. - Absent and empty spellings still mean "no filter"; other metadata types are untouched. Claude-Session: https://claude.ai/code/session_01BjEdc4MCajjEPDk4G33tga Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6a9dec6 commit 667192b

3 files changed

Lines changed: 290 additions & 4 deletions

File tree

.changeset/meta-app-id-filter.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): `GET /api/v1/meta/app?id=` narrows the app list instead of being dropped (#7566)
6+
7+
`GET /api/v1/meta/app?id=…` accepted the parameter and then ignored it. The
8+
same apps came back for **every** value, including one that names no app at all
9+
`?id=crm` and `?id=no_such_app` produced byte-identical responses. Nothing on
10+
`GET /meta/:type` had ever read `id`: the list route narrows by permission
11+
(`filterAppForUser`) and by `?package=` / `?object=` / `?include=`, and `id` was
12+
never among them.
13+
14+
Worse than an error, because the answer looks like the one that was asked for: a
15+
caller cannot tell a working filter from a dropped one. A client that asks for
16+
one app and renders `items[0]` gets a plausible, wrong answer, and a bogus id can
17+
never come back empty.
18+
19+
The filter is now honoured, matching on `name` — the App document's identity
20+
(`AppSchema.name`, "App unique machine name"), the key `GET /meta/app/:name`
21+
addresses and the key the metadata store merges overlays on. `AppSchema` declares
22+
no `id` of its own, so there is no second identity for the two to disagree about.
23+
Both spellings of the type segment are covered (`/meta/app` and `/meta/apps`),
24+
since every other per-type filter on this handler keys off `metaTypeSingular`.
25+
26+
**A filter that matches nothing answers `200` with an empty list, not a `404`.**
27+
Measured off this route's siblings rather than chosen: `?package=<no such
28+
package>` and `/meta/view?object=<no such object>` both serve an empty list here,
29+
and the only 404 on the meta surface is the single-item address `GET
30+
/meta/:type/:name`. An empty list is already observably different from the
31+
defect, which answered with all of them.
32+
33+
**A repeated `?id=a&id=b` is refused with `400`**, through the same
34+
`refuseRepeatedQueryParams` gate this route already opens with for `?package=` /
35+
`?preview=` / `?object=` / `?include=` (#6877) — one route, one dialect for "this
36+
request is malformed". Picking one of two conflicting intents is a wrong answer
37+
delivered as a success, and the alternative the other filters on this line were
38+
bitten by (`String(['crm','account'])` → the single app name `'crm,account'`)
39+
would just have emptied the list silently.
40+
41+
**The filter narrows within what the caller may observe, never around it.** It
42+
runs after the ADR-0045 §3 publish gate, so `?id=<an unpublished app>` answers the
43+
same empty list to a non-builder as `?id=<nonexistent>` — the two are
44+
indistinguishable by design. It is also not part of the permission branch's
45+
`ctx?.userId` guard, so an anonymous read of a public deployment gets the filter
46+
too.
47+
48+
**Nothing that worked before changes.** An absent `?id=` still returns the whole
49+
list, and so does the empty spelling `?id=` — the same falsy gate `?package=` on
50+
this route has always used, and what an unset `<select>` submits. Other metadata
51+
types are untouched: `?id=` on `/meta/view` and friends keeps being ignored
52+
exactly as before, since #7566 is filed on the app list and teaching every type an
53+
`id` filter in the same change would be surface expansion with nothing measured
54+
behind it.

packages/rest/src/meta-app-publish-gate.test.ts

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,11 @@ function setup(perms: string[]) {
9595
return { rest, protocol };
9696
}
9797

98-
async function getList(rest: any, type = 'app') {
98+
async function getList(rest: any, type = 'app', query: Record<string, unknown> = {}) {
9999
const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type');
100100
if (!route) throw new Error('meta/:type route not registered');
101101
const res = makeRes();
102-
await route.handler({ method: 'GET', params: { type }, query: {}, body: {}, headers: {} }, res);
102+
await route.handler({ method: 'GET', params: { type }, query, body: {}, headers: {} }, res);
103103
return res;
104104
}
105105

@@ -183,3 +183,148 @@ describe('#4829 — `GET /meta/app` gates on `_unpublished`, never on `hidden`',
183183
expect(allowed.body?.item?.name).toBe('production_management');
184184
});
185185
});
186+
187+
// ── #7566 ───────────────────────────────────────────────────────────────────
188+
//
189+
// `GET /api/v1/meta/app?id=…` accepted the parameter and then dropped it: the
190+
// SAME apps came back for every value, including one that names no app. The
191+
// acceptance criterion is therefore a BODY fact, not a status code — a route
192+
// that 200s either way is exactly what the reporter saw. Every case below
193+
// asserts the app names in the response.
194+
//
195+
// The defect's cost is that a caller cannot tell a working filter from a
196+
// dropped one: a client that asks for one app and renders `items[0]` gets a
197+
// plausible, wrong answer, and a bogus id can never come back empty.
198+
//
199+
// This file already owns `GET /meta/app`'s list body (the #4829 gate above),
200+
// and the filter has to COMPOSE with that gate rather than sit beside it — an
201+
// `?id=` naming an unpublished app must still be withheld from a non-builder —
202+
// so the cases live here with the fixture that has an unpublished app in it.
203+
204+
describe('#7566 — `GET /meta/app?id=` narrows the list instead of being dropped', () => {
205+
it('a MATCHING id returns exactly that app', async () => {
206+
const { rest } = setup(['manage_users']);
207+
const res = await getList(rest, 'app', { id: 'crm' });
208+
209+
expect(res.statusCode).toBe(200);
210+
expect(namesFrom(res.body)).toEqual(['crm']);
211+
// The stated failure mode: the unasked-for apps are gone. Before this
212+
// change the assertion below is what failed — `account` came back too.
213+
expect(namesFrom(res.body)).not.toContain('account');
214+
});
215+
216+
it('a NON-MATCHING id returns an empty list — a 200, not a 404, and not every app', async () => {
217+
const { rest } = setup(['manage_users']);
218+
const res = await getList(rest, 'app', { id: 'no_such_app' });
219+
220+
// Empty-vs-404 is measured off this route's siblings, not chosen: the
221+
// list route serves an empty list for `?package=<no such package>` and
222+
// for `/meta/view?object=<no such object>`, and the only 404 on the meta
223+
// surface is the single-item address `GET /meta/:type/:name` (pinned
224+
// above). A list filter that matched nothing is a list of nothing.
225+
expect(res.statusCode).toBe(200);
226+
expect(namesFrom(res.body)).toEqual([]);
227+
expect(res.body?.error).toBeUndefined();
228+
});
229+
230+
it('an ABSENT id still returns the whole published set (the preservation half)', async () => {
231+
const { rest } = setup(['manage_users']);
232+
233+
expect(namesFrom((await getList(rest, 'app')).body).sort()).toEqual(['account', 'crm']);
234+
// `?id=` (empty) is the "no filter" spelling an unset `<select>` submits
235+
// — the same falsy gate `?package=` on this route has always used. It
236+
// must not become a new 400 or an empty list.
237+
expect(namesFrom((await getList(rest, 'app', { id: '' })).body).sort())
238+
.toEqual(['account', 'crm']);
239+
});
240+
241+
it('a MALFORMED id — supplied twice — is refused with 400, not silently resolved', async () => {
242+
const { rest } = setup(['manage_users']);
243+
const res = await getList(rest, 'app', { id: ['crm', 'account'] });
244+
245+
// Two conflicting intents in one well-formed request. Picking one is a
246+
// wrong answer delivered as a success, and `String(['crm','account'])`
247+
// would have made it the single app name `'crm,account'` — a name no app
248+
// has, so the filter would silently empty. ADR-0112 nested envelope with
249+
// the standard catalog's 400 member, the same answer this route already
250+
// gives for a repeated `?package=` / `?object=` / `?include=` (#6877).
251+
expect(res.statusCode).toBe(400);
252+
expect(res.body?.error?.code).toBe('VALIDATION_ERROR');
253+
expect(res.body?.error?.message).toContain('"id"');
254+
// Refused means refused: no app list rode along with the error.
255+
expect(res.body?.items).toBeUndefined();
256+
expect(Array.isArray(res.body)).toBe(false);
257+
});
258+
259+
it('a one-element array is ONE occurrence and still filters', async () => {
260+
// `?id=crm` reaches some adapters as `['crm']`; that is one occurrence
261+
// encoded differently, not a repetition, so it must narrow rather than
262+
// 400 — and it must not survive as an array into the comparison, where
263+
// `['crm'] === 'crm'` is false and the filter would empty.
264+
const { rest } = setup(['manage_users']);
265+
const res = await getList(rest, 'app', { id: ['crm'] });
266+
267+
expect(res.statusCode).toBe(200);
268+
expect(namesFrom(res.body)).toEqual(['crm']);
269+
});
270+
271+
it('the PLURAL spelling filters identically — `/meta/apps?id=`', async () => {
272+
// Prime Directive #3 makes plural the canonical REST spelling, and every
273+
// other per-type filter on this handler keys off the singular through
274+
// `metaTypeSingular`. A filter that only ran on `/meta/app` would be the
275+
// #6238-class spelling hole one parameter over.
276+
const { rest } = setup(['manage_users']);
277+
278+
expect(namesFrom((await getList(rest, 'apps', { id: 'crm' })).body)).toEqual(['crm']);
279+
expect(namesFrom((await getList(rest, 'apps', { id: 'no_such_app' })).body)).toEqual([]);
280+
});
281+
282+
it('composes WITH the publish gate — `?id=<unpublished>` stays withheld from a non-builder', async () => {
283+
// The filter narrows within what the caller may observe, never around
284+
// it. ADR-0045 §3 says an unpublished app is externally unobservable, so
285+
// naming it must answer the same empty list as naming a nonexistent one
286+
// — the two are indistinguishable to a non-builder by design.
287+
const denied = await getList(setup(['manage_users']).rest, 'app', { id: 'production_management' });
288+
expect(denied.statusCode).toBe(200);
289+
expect(namesFrom(denied.body)).toEqual([]);
290+
expect(JSON.stringify(denied.body)).not.toContain('secret_production_line');
291+
292+
// …and a builder, who may observe it, gets it — narrowed to just it.
293+
const allowed = await getList(setup(['studio.access']).rest, 'app', { id: 'production_management' });
294+
expect(namesFrom(allowed.body)).toEqual(['production_management']);
295+
});
296+
297+
it('does not depend on what the caller holds — a caller with NO permissions filters too', async () => {
298+
// The permission filter above lives in a branch of its own, guarded by
299+
// a resolved `ctx?.userId`. The `id` filter is deliberately NOT inside
300+
// that branch: narrowing to the app you named is not a privilege, and a
301+
// caller holding nothing asked the same question as an admin.
302+
//
303+
// (An anonymous caller is not the case to state this with: the
304+
// anonymous-deny gate refuses `GET /meta/:type` with 401 before the
305+
// handler body runs at all, unconditionally since #3963 — measured, not
306+
// assumed. The least-privileged caller who reaches the filter is this
307+
// one.)
308+
const { rest } = setup([]);
309+
310+
expect(namesFrom((await getList(rest, 'app', { id: 'account' })).body)).toEqual(['account']);
311+
expect(namesFrom((await getList(rest, 'app', { id: 'no_such_app' })).body)).toEqual([]);
312+
// Unfiltered, the same caller still receives everything the gate lets
313+
// through — the filter is what changed, not the gate.
314+
expect(namesFrom((await getList(rest, 'app')).body).length).toBeGreaterThan(1);
315+
});
316+
317+
it('is scoped to `app` — another type\'s list is not narrowed by `?id=`', async () => {
318+
// Deliberately not generalised: #7566 is filed on the app list, and
319+
// teaching every meta type an `id` filter in the same change would be
320+
// surface expansion with nothing measured behind it. `?id=` on another
321+
// type keeps being ignored exactly as before.
322+
const { rest, protocol } = setup(['manage_users']);
323+
protocol.getMetaItems = vi.fn(async ({ type }: any) =>
324+
String(type ?? '') === 'view' ? [{ name: 'all_leads' }, { name: 'my_leads' }] : []);
325+
326+
const res = await getList(rest, 'view', { id: 'all_leads' });
327+
expect(res.statusCode).toBe(200);
328+
expect(namesFrom(res.body)).toEqual(['all_leads', 'my_leads']);
329+
});
330+
});

0 commit comments

Comments
 (0)