Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/28548-empty-app-id-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/rest': patch
---

fix(rest)!: an empty `?id=` on the app list and an empty `?object=` on the view list are refused 400 naming the parameter instead of being read as absent

Clause-②: no (narrowing) — `GET /meta/app` / `GET /meta/apps` with an empty `?id=`, and `GET /meta/view` / `GET /meta/views` with an empty `?object=` (on `RestServer` and on the runtime dispatcher, which share the list chain), are refused with `400 VALIDATION_ERROR`; they answered 200 with the caller's whole visible list before. Nothing is widened: no export, answer member, error code or status is added.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: a list request whose filter names nothing is refused instead of served unfiltered. There is nothing for a migration to rewrite and no tombstone. -->

**BREAKING** accept-set narrowing on the app and view list reads (`@objectstack/rest`).

- **Before.** `?id=` (app list) and `?object=` (view list) with an empty value were read as no filter, so a caller who asked for one app, or for one object's views, by a name that came out blank got every app or every view it may see (the views unsorted, and including views bound to no object).
- **Now.** The list chain refuses either after the per-caller visibility gate: `400 VALIDATION_ERROR`, message `Query parameter 'id' names no app; …` or `Query parameter 'object' names no object; …` (the message names the parameter), and no list in the body. This is the answer #27484 gave a blank `$searchFields`.
- **Unchanged.** A non-empty value narrows as before (one app or one object's views, or an empty list when it names none, including `' '`); no value lists every item the caller may see; a repeated `id` or `object` is still the arity 400; `?id=` on any type but `app`, and `?object=` on any type but `view`, are still ignored.
- **Who sees it.** An API client that sends either parameter empty. `@objectstack/client` and the console send neither on these reads.

**The remedy.** Name the app or object, or leave the parameter out to list them all.
66 changes: 61 additions & 5 deletions packages/rest/src/meta-app-publish-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,11 +283,67 @@ describe('#7566 — `GET /meta/app?id=` narrows the list instead of being droppe
const { rest } = setup(['manage_users']);

expect(namesFrom((await getList(rest, 'app')).body).sort()).toEqual(['account', 'crm']);
// `?id=` (empty) is the "no filter" spelling an unset `<select>` submits
// — the same falsy gate `?package=` on this route has always used. It
// must not become a new 400 or an empty list.
expect(namesFrom((await getList(rest, 'app', { id: '' })).body).sort())
.toEqual(['account', 'crm']);
});

// [#28548] `?id=` (EMPTY) used to be read as absent and answered the whole
// list — the #27484 class (a blank `$searchFields` read as absent). A
// caller whose app name came out blank asked for ONE app and got every app
// it may see, a plausible wrong answer. It is refused now, exactly as the
// #27484 fix refuses a blank search-field override: `400 VALIDATION_ERROR`
// naming the parameter, and no list rides along. No producer sends it:
// `@objectstack/client`'s `meta.getItems` and the console send no `id`.
it('an EMPTY id is refused with 400 naming the parameter — not read as absent (#28548)', async () => {
for (const perms of [['studio.access'], ['manage_users'], []]) {
const { rest } = setup(perms);
const res = await getList(rest, 'app', { id: '' });

// Thrown to the route's error door, so it is the body that door
// writes (the flat `{ error, code }` the unknown-type refusal on
// this route answers too); the dispatcher's twin is pinned in
// `@objectstack/runtime`'s list parity census.
expect(res.statusCode, `caller holding [${perms}]`).toBe(400);
expect(res.body?.code).toBe('VALIDATION_ERROR');
expect(String(res.body?.error)).toMatch(/^Query parameter 'id' names no app/);
expect(res.body?.items).toBeUndefined();
expect(Array.isArray(res.body)).toBe(false);
}
// The plural spelling refuses identically.
const plural = await getList(setup(['manage_users']).rest, 'apps', { id: '' });
expect(plural.statusCode).toBe(400);
expect(plural.body?.code).toBe('VALIDATION_ERROR');
});

// [#28548] The same class one step down the chain: `?object=` (EMPTY) on
// the view list was read as absent and served every view, unsorted and
// including views bound to no object, instead of the switcher's list for
// one object. Refused the same way, after the same gate.
it('an EMPTY view `object` is refused with 400 naming the parameter — not read as absent (#28548)', async () => {
for (const type of ['view', 'views']) {
const { rest, protocol } = setup(['manage_users']);
protocol.getMetaItems = vi.fn(async ({ type: t }: any) =>
String(t ?? '').startsWith('view')
? [{ name: 'all_leads', viewKind: 'list', object: 'lead' }, { name: 'loose_view' }]
: []);
const res = await getList(rest, type, { object: '' });

expect(res.statusCode, type).toBe(400);
expect(res.body?.code).toBe('VALIDATION_ERROR');
expect(String(res.body?.error)).toMatch(/^Query parameter 'object' names no object/);
expect(res.body?.items).toBeUndefined();
expect(Array.isArray(res.body)).toBe(false);

// Controls: a named object still switches, an absent one lists all.
expect(namesFrom((await getList(rest, type, { object: 'lead' })).body)).toEqual(['all_leads']);
expect(namesFrom((await getList(rest, type)).body).sort()).toEqual(['all_leads', 'loose_view']);
}
});

it('a WHITESPACE id keeps its answer — a value no app is named, so an empty list (#28548 control)', async () => {
const { rest } = setup(['manage_users']);
const res = await getList(rest, 'app', { id: ' ' });

expect(res.statusCode).toBe(200);
expect(namesFrom(res.body)).toEqual([]);
});

it('a MALFORMED id — supplied twice — is refused with 400, not silently resolved', async () => {
Expand Down
46 changes: 39 additions & 7 deletions packages/rest/src/meta-item-read-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3388,6 +3388,32 @@ export type MetaListAnswer =
| { ok: true; data: unknown; cacheControl: string; etag?: string }
| { ok: false; object: string };

/**
* [#28548] The list chain's answer to a narrowing filter sent EMPTY — `?id=`
* on the app list, `?object=` on the view list. Each was read as absent, so a
* caller whose name came out blank — who asked for ONE app, or for the views
* of ONE object — was answered every item it may see: the #27484 class (a
* blank `$searchFields` read as absent), refused the way that fix refuses it —
* `400 VALIDATION_ERROR` naming the parameter, so the caller learns its request
* named nothing instead of reading a plausible wrong list.
*
* Asked by steps 3 and 4 of {@link createMetaListAnswer}, AFTER the per-caller
* gate (step 2), which keeps its place. Only the empty string: a value that
* names nothing real (`' '`, `nope`) still narrows to the empty list. THROWS,
* so each transport's own `catch` shapes it (`RestServer`'s `handleRouteError`,
* the dispatcher's `errorFromThrown`), as {@link refuseUnknownMetaListType} does.
*/
function refuseEmptyListFilter(param: 'id' | 'object', names: 'app' | 'object', lists: 'app' | 'view'): never {
const err: any = new Error(
`Query parameter '${param}' names no ${names}; got "". The list was not served: an empty ${names} `
+ `name is not a filter. Name one ${names}, or leave the parameter out to list every ${lists} you can see.`,
);
err.status = 400;
err.code = 'VALIDATION_ERROR';
err.param = param;
throw err;
}

/** The items of a list shape this chain serves, or `null` for anything else (left alone, never replaced). */
function listItemsOf(raw: unknown): any[] | null {
if (Array.isArray(raw)) return raw;
Expand Down Expand Up @@ -3474,11 +3500,13 @@ async function describeMetaPositionList(sources: MetaListAnswerSources, list: an
* narrows (permission decides what exists for this caller; the filter
* narrows within it, never the reverse, ADR-0045 §3), and on the raw doc
* items, before the locale collapse (`_packageId` scopes membership).
* 3. `app` — `?id=<app>` narrows to the app of that `name` (#7566); empty
* and absent spellings mean no filter; no match is an EMPTY list, never a
* 404.
* 3. `app` — `?id=<app>` narrows to the app of that `name` (#7566); absent
* means no filter, and [#28548] an EMPTY value is refused (`400
* VALIDATION_ERROR`), never read as absent; no match is an EMPTY list,
* never a 404.
* 4. `view` — `?object=<object>` keeps the independent views bound to that
* object, sorted by `order` then `name` (the switcher).
* object, sorted by `order` then `name` (the switcher); [#28548] an EMPTY
* value is refused (`400 VALIDATION_ERROR`), never read as absent.
* 5. `doc` — the ADR-0046 locale collapse (the request's own preference; the
* `translations` map is dropped whatever the locale).
* 6. `doc` — the ADR-0046 content slim, unless `?include=content`.
Expand Down Expand Up @@ -3532,9 +3560,11 @@ export function createMetaListAnswer(
}
}

// 3. [#7566] `?id=<app>`.
// 3. [#7566] `?id=<app>`. [#28548] An EMPTY one is refused, never
// read as absent (see {@link refuseEmptyListFilter}).
const appIdFilter = metaType === 'app' ? query?.id : undefined;
if (typeof appIdFilter === 'string' && appIdFilter !== '') {
if (appIdFilter === '') refuseEmptyListFilter('id', 'app', 'app');
if (typeof appIdFilter === 'string') {
const list = listItemsOf(visible);
if (list) {
visible = withItems(visible, list.filter(
Expand All @@ -3543,7 +3573,9 @@ export function createMetaListAnswer(
}
}

// 4. `?object=<object>` — the view switcher.
// 4. `?object=<object>` — the view switcher. [#28548] An EMPTY one is
// refused, never read as absent (see {@link refuseEmptyListFilter}).
if (metaType === 'view' && query?.object === '') refuseEmptyListFilter('object', 'object', 'view');
if (metaType === 'view' && query?.object) {
const obj = String(query.object);
const list = listItemsOf(visible);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -522,8 +522,13 @@ const PARAM_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record
{ label: '(none)', query: {} },
{ label: '?id=crm', param: 'id', query: { id: 'crm' } },
{ label: '?id=nope', param: 'id', query: { id: 'nope' } },
// [#28548] An EMPTY app name is refused (400 VALIDATION_ERROR) on both
// transports, never read as absent.
{ label: '?id= (empty)', param: 'id', query: { id: '' } },
{ label: '?object=lead', param: 'object', query: { object: 'lead' } },
{ label: '?object=nope', param: 'object', query: { object: 'nope' } },
// [#28548] …and so is an EMPTY view object.
{ label: '?object= (empty)', param: 'object', query: { object: '' } },
{ label: '?include=content', param: 'include', query: { include: 'content' } },
// [#25333] The position list's opt-in description.
{ label: '?include=grants', param: 'include', query: { include: 'grants' } },
Expand Down Expand Up @@ -680,8 +685,12 @@ describe('[#20320] row A: the reference moves — each probe changes RestServer\
const { read } = bootRest('holder');
expect(names((await read('/meta/app', { id: 'crm' })).items)).toEqual(['crm']);
expect((await read('/meta/app', { id: 'nope' })).items).toEqual([]);
const blank = await read('/meta/app', { id: '' });
expect({ status: blank.status, code: blank.code, items: blank.items }).toEqual({ status: 400, code: 'VALIDATION_ERROR', items: undefined });
expect(names((await read('/meta/view', { object: 'lead' })).items)).toEqual(['lead_mine', 'lead_all']);
expect((await read('/meta/views', { object: 'nope' })).items).toEqual([]);
const blankObject = await read('/meta/views', { object: '' });
expect({ status: blankObject.status, code: blankObject.code, items: blankObject.items }).toEqual({ status: 400, code: 'VALIDATION_ERROR', items: undefined });
const docs = await read('/meta/docs');
expect(text(docs)).not.toContain('Welcome aboard.');
expect(text(await read('/meta/docs', { include: 'content' }))).toContain(DOC_SECRET);
Expand Down
Loading