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/29199-bracketed-object-package-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/rest': patch
---

fix(rest)!: a bracketed `object` key on the view list (`?object[]=NAME`, `?object[0]=NAME`) and a bracketed `package` key on the `/meta` list and book tree (`?package[]=ID`, `?package[0]=ID`) are refused 400 naming the key instead of being ignored and answered with every view or every package

Clause-②: no (narrowing) — `GET /meta/view` / `GET /meta/views` with any query key that opens `object[`, and `GET /meta/:type` / `GET /meta/book/:name/tree` with any query key that opens `package[` (on `RestServer` and on the runtime dispatcher, which share the list chain and the arity decision), are refused with `400 VALIDATION_ERROR`; they answered 200 with every view, or across every package, before. No package export, answer member, error code or status is added; the shared refusal record's `rule` tag names one more reason (`bracketed-parameter`), which no transport writes to the wire.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: a list or tree request that spells its one-object filter or its one-package scope as an array key is refused instead of served unfiltered. There is nothing for a migration to rewrite and no tombstone. -->

**BREAKING** accept-set narrowing on the `/meta` list and book tree reads (`@objectstack/rest`).

- **Before.** `?object[]=lead` and `?object[0]=lead` (the qs-style array spellings) reached the view list under the key as spelled, which the view switcher never read, so a caller who asked for one object's views got every view it may see. `?package[]=ID` and `?package[0]=ID` reached the list and the book tree the same way, which read no package, so a caller who named one package was answered across every package.
- **Now.** The list chain refuses a key that opens `object[` on the view list, after the per-caller visibility gate, as it refuses a bracketed `id` on the app list: `400 VALIDATION_ERROR`, message `Query parameter 'object[]' names no object; the 'object' filter takes exactly one object name, spelled ?object=NAME. …`, and no list in the body. The list and the book tree refuse a key that opens `package[` at their entry, before any read: `400 VALIDATION_ERROR`, message `Query parameter 'package[]' names no package; the 'package' filter takes exactly one package id, spelled ?package=ID. …`. A bracketed key beside the plain one is refused too.
- **Unchanged.** `?object=NAME` switches to that object's views; `?package=ID` scopes to that package (`?package=all` and no `package` read every package); an empty `?object=` and a repeated `object` or `package` keep their 400s; a bracketed `object` key on any type but `view` is still ignored, as `?object=` is there. Every by-name `/meta` door (the item read, `/layers`, the save, the reset, `/publish`) and the diagnostics sweep already refused a bracketed `package` key as an unknown name, and still do.
- **Who sees it.** An API client that serialises arrays qs-style. `@objectstack/client` builds `?package=` with `URLSearchParams` (no brackets), and the console sends neither key bracketed.

**The remedy.** FROM `?object[]=NAME` (or `?object[0]=NAME`) TO `?object=NAME`; FROM `?package[]=ID` (or `?package[0]=ID`) TO `?package=ID`; leave the key out to list every view, or every package.
46 changes: 46 additions & 0 deletions packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,4 +218,50 @@ describe('[#26572] the v18 /meta read doors, over HTTP on a booted showcase', ()
expect(memberApps, 'a member never sees setup').not.toContain('setup');
});
});

// The same class on the view switcher's `object` and on the list's
// `package` scope: a bracketed key (`?object[]=`, `?package[]=`) reached the
// door under the key as spelled, so the filter never read it, and a caller
// who asked for one object's views (or one package's items) got every view
// (or every package's items). Each is refused: 400 VALIDATION_ERROR naming
// the key, no list.
describe('GET /meta/view with a bracketed `object` key, and a list with a bracketed `package` key, are refused', () => {
const itemsOf = (body: any): any[] => (Array.isArray(body) ? body : body?.items) ?? [];
const OBJ = 'showcase_task';

for (const [path, key] of [
['/meta/view', 'object[]'], ['/meta/view', 'object[0]'],
['/meta/object', 'package[]'], ['/meta/object', 'package[0]'],
] as const) {
it(`${path}?${key}=…: 400 VALIDATION_ERROR naming the key, for the admin and for a member`, async () => {
const value = key.startsWith('object') ? OBJ : PKG;
const names = key.startsWith('object') ? 'object' : 'package';
for (const [who, token] of [['admin', admin], ['member', liaison]] as const) {
const res = await stack.apiAs(token, 'GET', `${path}?${key}=${encodeURIComponent(value)}`);
const body: any = await res.json();
expect({ who, status: res.status, code: codeOf(body) }, JSON.stringify(body).slice(0, 400))
.toEqual({ who, status: 400, code: 'VALIDATION_ERROR' });
expect(messageOf(body)).toMatch(new RegExp(`^Query parameter '${key.replace(/[[\]]/g, '\\$&')}' names no ${names}`));
expect(itemsOf(body)).toEqual([]);
}
});
}

it('CONTROLS — ?object=NAME is that object\'s views and ?package=ID that package\'s items, each narrower than no filter', async () => {
const all = itemsOf(await (await stack.apiAs(admin, 'GET', '/meta/view')).json());
const switched = await stack.apiAs(admin, 'GET', `/meta/view?object=${OBJ}`);
expect(switched.status).toBe(200);
const views = itemsOf(await switched.json());
expect(views.length, 'the showcase ships views of showcase_task').toBeGreaterThan(0);
expect(views.every((v: any) => v?.object === OBJ), JSON.stringify(views.map((v: any) => v?.object))).toBe(true);
expect(views.length).toBeLessThan(all.length);

const everyPackage = itemsOf(await (await stack.apiAs(admin, 'GET', '/meta/object')).json());
const scoped = await stack.apiAs(admin, 'GET', `/meta/object?package=${encodeURIComponent(PKG)}`);
expect(scoped.status).toBe(200);
const objects = itemsOf(await scoped.json());
expect(objects.length, 'the showcase package declares objects').toBeGreaterThan(0);
expect(objects.length).toBeLessThan(everyPackage.length);
});
});
});
46 changes: 46 additions & 0 deletions packages/rest/src/meta-app-publish-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,52 @@ describe('#7566 — `GET /meta/app?id=` narrows the list instead of being droppe
expect(namesFrom(res.body)).toEqual(['all_leads']);
});

// The same class one step down the chain: `?object[]=lead` or
// `?object[0]=lead` reached the view switcher under the key as spelled,
// so the filter, which reads `query.object`, never saw it and the caller
// who asked for one object's views got every view it may see. Refused with
// the bracketed `id`'s answer, naming the key as sent; the dispatcher's
// twin is pinned in `@objectstack/runtime`'s list parity census.
it.each(['object[]', 'object[0]', 'object[name]'])('a BRACKETED view `%s` key is refused with 400 naming it — never every view', async (key) => {
const views = async ({ type: t }: any) =>
String(t ?? '').startsWith('view')
? [{ name: 'all_leads', viewKind: 'list', object: 'lead' }, { name: 'acct_view', viewKind: 'list', object: 'account' }]
: [];
for (const perms of [['studio.access'], ['manage_users'], []]) {
for (const type of ['view', 'views']) {
const { rest, protocol } = setup(perms);
protocol.getMetaItems = vi.fn(views);
const res = await getList(rest, type, { [key]: 'lead' });

expect(res.statusCode, `${type}, caller holding [${perms}]`).toBe(400);
expect(res.body?.code).toBe('VALIDATION_ERROR');
expect(String(res.body?.error)).toBe(
`Query parameter '${key}' names no object; the 'object' filter takes exactly one object name, `
+ `spelled ?object=NAME. The list was not served: a bracketed 'object' key is not a filter. `
+ 'Name one object, or leave the parameter out to list every view you can see.',
);
expect(res.body?.items).toBeUndefined();
expect(Array.isArray(res.body)).toBe(false);
}
}
// A bracketed key beside a plain one is two spellings of one filter.
const { rest, protocol } = setup(['manage_users']);
protocol.getMetaItems = vi.fn(views);
const both = await getList(rest, 'view', { object: 'lead', [key]: 'account' });
expect(both.statusCode).toBe(400);
expect(both.body?.code).toBe('VALIDATION_ERROR');
// Controls: the plain spelling still switches, an absent one lists all.
expect(namesFrom((await getList(rest, 'view', { object: 'lead' })).body)).toEqual(['all_leads']);
expect(namesFrom((await getList(rest, 'view')).body).sort()).toEqual(['acct_view', 'all_leads']);
});

it('a bracketed `object` key on another list type is not read, as no other `object` spelling is (control)', async () => {
const res = await getList(setup(['manage_users']).rest, 'app', { 'object[]': 'lead' });

expect(res.statusCode).toBe(200);
expect(namesFrom(res.body).sort()).toEqual(['account', 'crm']);
});

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: ' ' });
Expand Down
10 changes: 10 additions & 0 deletions packages/rest/src/meta-collection-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,16 @@ export function registerMetaCollectionRoutes(this: MetaCollectionRouteHost, scop
// [#23214] The list lives beside the shared decision, so the
// runtime dispatcher's twin refuses the same names.
if (refuseRepeatedQueryParams(req, res, metaReadGate.META_QUERY_ARITY_DOORS.list)) return;
// A bracketed `package` key (`?package[]=`) is read by no
// binding below, so it scoped nothing: refused here, by the
// decision the dispatcher's twin asks too.
const bracketedPackage = metaReadGate.metaBracketedPackageRefusal('list', req.query);
if (bracketedPackage) {
res.status(bracketedPackage.status).json({
error: { code: bracketedPackage.code, message: bracketedPackage.message },
});
return;
}
// [#22188] Read through {@link metaItemPackageBinding}:
// `all`, this list's "show everything" scope, names no package.
const packageId = metaItemPackageBinding(req.query?.package);
Expand Down
Loading
Loading