From 919e71ba51d8d559b99c4863b7c98b8acfd49494 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:15:46 +0000 Subject: [PATCH 1/4] test(rest): red-first pins for bracketed object/package keys on the /meta list and book tree Claude-Session: https://claude.ai/code/session_01ADHkmPfJsJeWNxeaZkvyEM Co-authored-by: Claude --- .../rest/src/meta-app-publish-gate.test.ts | 46 +++++++++++++ .../rest/src/meta-query-arity-doors.test.ts | 66 +++++++++++++++++++ .../domains/dispatcher-query-arity.test.ts | 32 +++++++++ .../meta-list-projection-parity.test.ts | 16 +++++ 4 files changed, 160 insertions(+) diff --git a/packages/rest/src/meta-app-publish-gate.test.ts b/packages/rest/src/meta-app-publish-gate.test.ts index 94bf0d9dc7c..530a7290914 100644 --- a/packages/rest/src/meta-app-publish-gate.test.ts +++ b/packages/rest/src/meta-app-publish-gate.test.ts @@ -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: ' ' }); diff --git a/packages/rest/src/meta-query-arity-doors.test.ts b/packages/rest/src/meta-query-arity-doors.test.ts index 66538565529..d61f21c1151 100644 --- a/packages/rest/src/meta-query-arity-doors.test.ts +++ b/packages/rest/src/meta-query-arity-doors.test.ts @@ -132,3 +132,69 @@ describe('RestServer refuses exactly the names the shared list declares, door by expect((await drive('state', { from: 'todo' })).status).not.toBe(400); }); }); + +/** + * A client that serialises arrays qs-style sends `?package[]=com.acme` or + * `?package[0]=com.acme`. The adapters hand the key through as spelled, so + * `metaItemPackageBinding(query.package)` read no package and the list (and + * the book tree) answered across EVERY package for a request that named one. + * The two doors that read `package` with no closed set refuse it where they + * ask their arity, before any read; every by-name door already refuses the + * key as an unknown name (recognition). + */ +const bracketedPackageMessage = (key: string, served: 'list' | 'tree', unscoped: string): string => + `Query parameter '${key}' names no package; the 'package' filter takes exactly one package id, ` + + `spelled ?package=ID. The ${served} was not served: a bracketed 'package' key is not a filter. ` + + `Name one package, or leave the parameter out to ${unscoped}.`; +const PACKAGE_DOORS = [ + ['list', 'list', 'list across every package'], + ['book-tree', 'tree', 'read the tree across every package'], +] as const; + +describe('a BRACKETED `package` key on the two arity doors that read `package`', () => { + it.each(PACKAGE_DOORS.flatMap(([door, served, unscoped]) => + ['package[]', 'package[0]', 'package[id]'].map((key) => [door, key, served, unscoped] as const)))( + '%s door: `%s` is refused as data, naming the key', + (door, key, served, unscoped) => { + expect(metaQueryArityRefusal(door, { [key]: 'com.acme' })).toEqual({ + status: 400, + code: 'VALIDATION_ERROR', + message: bracketedPackageMessage(key, served, unscoped), + rule: 'bracketed-parameter', + }); + // Beside a plain `package` it is two spellings of one scope. + expect(metaQueryArityRefusal(door, { package: 'com.acme', [key]: 'com.other' })?.rule) + .toBe('bracketed-parameter'); + }, + ); + + it('a repeated plain `package` still answers the multiplicity sentence first', () => { + expect(metaQueryArityRefusal('list', { package: ['a', 'b'], 'package[]': 'c' })?.message) + .toBe(repeatedQueryParamMessage('package', 2)); + }); + + it('is judged only where `package` is read: not on `_drafts`, `state`, or a key that merely starts alike', () => { + expect(metaQueryArityRefusal('drafts', { 'package[]': 'com.acme' })).toBeUndefined(); + expect(metaQueryArityRefusal('state', { 'package[]': 'com.acme' })).toBeUndefined(); + expect(metaQueryArityRefusal('list', { 'packages[]': 'com.acme', packageId: 'x' })).toBeUndefined(); + }); + + it.each(PACKAGE_DOORS.flatMap(([door, served, unscoped]) => + ['package[]', 'package[0]'].map((key) => [door, key, served, unscoped] as const)))( + 'RestServer %s door: `%s` answers the nested 400 before any protocol read', + async (door, key, served, unscoped) => { + const { protocol, status, body } = await drive(door, { [key]: 'com.acme' }); + expect(status).toBe(400); + expect(body).toEqual({ error: { code: 'VALIDATION_ERROR', message: bracketedPackageMessage(key, served, unscoped) } }); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + }, + ); + + it('RestServer: the plain spelling still scopes, and an absent one reads every package (controls)', async () => { + for (const door of ['list', 'book-tree'] as const) { + expect((await drive(door, { package: 'com.acme' })).protocol.getMetaItems.mock.calls[0][0]) + .toMatchObject({ packageId: 'com.acme' }); + expect((await drive(door, {})).protocol.getMetaItems.mock.calls[0][0].packageId).toBeUndefined(); + } + }); +}); diff --git a/packages/runtime/src/domains/dispatcher-query-arity.test.ts b/packages/runtime/src/domains/dispatcher-query-arity.test.ts index 338f916c7e9..3c9729bed2a 100644 --- a/packages/runtime/src/domains/dispatcher-query-arity.test.ts +++ b/packages/runtime/src/domains/dispatcher-query-arity.test.ts @@ -211,6 +211,38 @@ describe('[#23214] /meta — the four arity doors, over the list both transports expect(protocol.getMetaItems.mock.calls[0][0]).toMatchObject({ packageId: 'com.acme' }); }); }); + + // A qs-style array spelling (`?package[]=`, `?package[0]=`) reached the + // binding under the key as sent, so it read no package and the list and + // the tree answered across every package. Refused at the same arity step, + // with `RestServer`'s code and sentence, before any read. + describe('a BRACKETED `package` key on the list and the book tree', () => { + it.each([ + ['/meta/view', 'package[]', 'list', 'list across every package'], + ['/meta/view', 'package[0]', 'list', 'list across every package'], + ['/meta/book/handbook/tree', 'package[]', 'tree', 'read the tree across every package'], + ['/meta/book/handbook/tree', 'package[0]', 'tree', 'read the tree across every package'], + ])('GET %s?%s=… is refused before any read', async (path, key, served, unscoped) => { + const { dispatcher, protocol } = boot(); + const answer = await get(dispatcher, path, { [key]: 'com.acme' }); + expect(answer.status, JSON.stringify(answer)).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toBe( + `Query parameter '${key}' names no package; the 'package' filter takes exactly one package id, ` + + `spelled ?package=ID. The ${served} was not served: a bracketed 'package' key is not a filter. ` + + `Name one package, or leave the parameter out to ${unscoped}.`, + ); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + }); + + it('a bracketed view `object` key is refused by the list chain, never every view', async () => { + const { dispatcher } = boot(); + const answer = await get(dispatcher, '/meta/view', { 'object[]': 'task' }); + expect(answer.status, JSON.stringify(answer)).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toMatch(/^Query parameter 'object\[\]' names no object/); + }); + }); }); describe('[#23214] GET /ui/view/:object — `?type=` is one view type, read only when the path names none', () => { diff --git a/packages/runtime/src/domains/meta-list-projection-parity.test.ts b/packages/runtime/src/domains/meta-list-projection-parity.test.ts index 269a7905eb6..4ac1b0e64ff 100644 --- a/packages/runtime/src/domains/meta-list-projection-parity.test.ts +++ b/packages/runtime/src/domains/meta-list-projection-parity.test.ts @@ -533,12 +533,18 @@ const PARAM_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record { label: '?object=nope', param: 'object', query: { object: 'nope' } }, // [#28548] …and so is an EMPTY view object. { label: '?object= (empty)', param: 'object', query: { object: '' } }, + // …and a BRACKETED view `object` key, never read as no filter. + { label: '?object[]=lead', param: 'object', query: { 'object[]': 'lead' } }, + { label: '?object[0]=lead', param: 'object', query: { 'object[0]': 'lead' } }, { label: '?include=content', param: 'include', query: { include: 'content' } }, // [#25333] The position list's opt-in description. { label: '?include=grants', param: 'include', query: { include: 'grants' } }, { label: '?locale=zh-CN', param: 'locale', query: { locale: 'zh-CN' } }, { label: 'Accept-Language: zh-CN', query: {}, headers: { 'accept-language': 'zh-CN' } }, { label: '?package=crm', param: 'package', query: { package: 'crm' } }, + // A BRACKETED `package` key is refused at the list's arity step, never + // read as every package. + { label: '?package[]=crm', param: 'package', query: { 'package[]': 'crm' } }, { label: '?preview=draft', param: 'preview', query: { preview: 'draft' } }, // [#20408] The switch's value is compared case-insensitively on `RestServer`. { label: '?preview=DRAFT', param: 'preview', query: { preview: 'DRAFT' } }, @@ -697,6 +703,10 @@ describe('[#20320] row A: the reference moves — each probe changes RestServer\ 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 bracketedObject = await read('/meta/view', { 'object[]': 'lead' }); + expect({ status: bracketedObject.status, code: bracketedObject.code, items: bracketedObject.items }).toEqual({ status: 400, code: 'VALIDATION_ERROR', items: undefined }); + const bracketedPackage = await read('/meta/view', { 'package[]': 'crm' }); + expect({ status: bracketedPackage.status, code: bracketedPackage.code, items: bracketedPackage.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); @@ -871,6 +881,8 @@ const ITEM_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record< { label: '?preview=draft', param: 'preview', query: { preview: 'draft' } }, { label: '?preview=DRAFT', param: 'preview', query: { preview: 'DRAFT' } }, { label: '?package=crm', param: 'package', query: { package: 'crm' } }, + // A BRACKETED `package` key: an unknown name to the item read's closed set. + { label: '?package[]=crm', param: 'package', query: { 'package[]': 'crm' } }, { label: '?locale=zh-CN', param: 'locale', query: { locale: 'zh-CN' } }, { label: 'Accept-Language: zh-CN', query: {}, headers: { 'accept-language': 'zh-CN' } }, // [#20478] The layered view's deprecated spelling — any non-empty value — @@ -909,6 +921,8 @@ const TREE_CELLS: readonly string[] = ['admin_guide', 'help_center', 'public_gui const TREE_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record; headers?: Record }> = [ { label: '(none)', query: {} }, { label: '?package=crm', param: 'package', query: { package: 'crm' } }, + // A BRACKETED `package` key is refused at the tree's arity step. + { label: '?package[]=crm', param: 'package', query: { 'package[]': 'crm' } }, { label: '?locale=zh-CN', param: 'locale', query: { locale: 'zh-CN' } }, { label: 'Accept-Language: zh-CN', query: {}, headers: { 'accept-language': 'zh-CN' } }, ]; @@ -923,6 +937,8 @@ const TREE_CALLERS = ['holder', 'non-holder', 'builder', 'anonymous'] as const s const LAYERS_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record; headers?: Record }> = [ { label: '(none)', query: {} }, { label: '?package=crm', param: 'package', query: { package: 'crm' } }, + // A BRACKETED `package` key: an unknown name to the layered view's closed set. + { label: '?package[]=crm', param: 'package', query: { 'package[]': 'crm' } }, { label: 'Accept-Language: zh-CN', query: {}, headers: { 'accept-language': 'zh-CN' } }, ]; const LAYERS_PARAM_AXIS = new Set(LAYERS_PROBES.map((p) => p.param).filter((p): p is string => !!p)); From 34997b8fdadb891d5bde733f8f6f2c9f09a7e10c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:17:08 +0000 Subject: [PATCH 2/4] fix(rest)!: /meta list refuses a bracketed object key on views and a bracketed package key on the list and book tree Claude-Session: https://claude.ai/code/session_01ADHkmPfJsJeWNxeaZkvyEM Co-authored-by: Claude --- packages/rest/src/meta-collection-routes.ts | 10 ++ packages/rest/src/meta-item-read-gate.ts | 128 +++++++++++++++----- packages/rest/src/meta-item-read-routes.ts | 10 ++ 3 files changed, 117 insertions(+), 31 deletions(-) diff --git a/packages/rest/src/meta-collection-routes.ts b/packages/rest/src/meta-collection-routes.ts index dd604608df3..04ec0eb993f 100644 --- a/packages/rest/src/meta-collection-routes.ts +++ b/packages/rest/src/meta-collection-routes.ts @@ -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); diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 9c8674a8195..894bc70c186 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -2416,13 +2416,14 @@ const LAYERED_VIEW_MEMBER_MESSAGE = * VALIDATION_ERROR`, the code the recognition and arity rules answer * everywhere; `rule` names which of the three refused, because `RestServer`'s * item read answers the third at a different point (see - * {@link metaItemQueryRefusal}). + * {@link metaItemQueryRefusal}). The arity doors answer a fourth, + * `bracketed-parameter` ({@link metaBracketedPackageRefusal}). */ export interface MetaItemQueryRefusal { readonly status: 400; readonly code: 'VALIDATION_ERROR'; readonly message: string; - readonly rule: 'unknown-parameter' | 'repeated-parameter' | 'layered-member'; + readonly rule: 'unknown-parameter' | 'repeated-parameter' | 'layered-member' | 'bracketed-parameter'; } /** @@ -2556,13 +2557,51 @@ export const META_QUERY_ARITY_DOORS: Readonly, META_QUERY_ARITY_DOORS[door]); + return repeatedOnceRefusal(query as Record, META_QUERY_ARITY_DOORS[door]) + ?? metaBracketedPackageRefusal(door, query); +} + +/** + * The answer of the two arity doors that read `?package=` — the list and the + * book tree — to a BRACKETED spelling of it: `?package[]=ID`, `?package[0]=ID`, + * any key that opens `package[`. A client that serialises arrays qs-style sends + * it; every adapter hands the key through as spelled, so the door's + * `metaItemPackageBinding(query.package)` read no package, and a caller who + * named ONE package was answered across every package. `400 + * VALIDATION_ERROR`, in the list chain's narrowing-filter frame + * ({@link listFilterRefusalMessage}), naming the key as sent; beside a plain + * `?package=` too (two spellings of one scope). Asked at the door's entry, + * before any read — where the binding is read — by both transports: + * {@link metaQueryArityRefusal} for the dispatcher, after + * `refuseRepeatedQueryParams` for `RestServer`. + * + * Every by-name `/meta` door (the item read, `/layers`, the save, the reset, + * `/publish`) and the diagnostics sweep already refuse the key: it is not in + * their closed set (route rule 5). `_drafts` and `state` read no `package`. + */ +export function metaBracketedPackageRefusal(door: MetaQueryArityDoor, query: unknown): MetaItemQueryRefusal | undefined { + if (!META_QUERY_ARITY_DOORS[door].includes('package')) return undefined; + const key = bracketedKeyOf(query, 'package'); + if (key === undefined) return undefined; + const tree = door === 'book-tree'; + return { + status: 400, + code: 'VALIDATION_ERROR', + message: listFilterRefusalMessage( + key, 'package', "the 'package' filter takes exactly one package id, spelled ?package=ID", + "a bracketed 'package' key", tree ? 'tree' : 'list', + tree ? 'read the tree across every package' : 'list across every package', + ), + rule: 'bracketed-parameter', + }; } /** @@ -3422,31 +3461,56 @@ function refuseEmptyListFilter(param: 'id' | 'object', names: 'app' | 'object', } /** - * The app list's answer to a BRACKETED spelling of its one-app filter — - * `?id[]=NAME`, `?id[0]=NAME`, any key that opens `id[`. A client that - * serialises arrays qs-style sends it; every adapter hands the key through as - * spelled (`readQuery` in `plugin-hono-server`, `readDispatchQuery` in - * `@objectstack/hono`), so step 3 of {@link createMetaListAnswer}, which reads - * `query.id`, never saw it, and the caller who asked for one app was answered - * every app it may see. Refused with {@link refuseEmptyListFilter}'s answer — - * one sentence frame, `400 VALIDATION_ERROR` — naming the key as sent: the - * filter takes exactly one name, spelled `?id=NAME`. A bracketed key beside a - * plain `?id=` is refused too: the request carries two spellings of one filter. - * - * Only the app list reads `id`, so only it judges the bracketed spelling; on - * every other type the key is not read, as no other `id` spelling is. + * The list chain's answer to a BRACKETED spelling of a one-name filter — + * `?id[]=NAME` or `?id[0]=NAME` on the app list (step 3), `?object[]=NAME` or + * `?object[0]=NAME` on the view list (step 4): any key that opens `param[`. A + * client that serialises arrays qs-style sends it; every adapter hands the key + * through as spelled (`readQuery` in `plugin-hono-server`, `readDispatchQuery` + * in `@objectstack/hono`), so the step, which reads `query[param]`, never saw + * it, and the caller who asked for one app, or for one object's views, was + * answered every item it may see. Refused with {@link refuseEmptyListFilter}'s + * answer — one sentence frame, `400 VALIDATION_ERROR` — naming the key as + * sent: the filter takes exactly one name, spelled `?param=NAME`. A bracketed + * key beside a plain one is refused too: the request carries two spellings of + * one filter. + * + * Only the type whose step reads `param` judges its bracketed spelling; on + * every other type the key is not read, as no other spelling of it is. */ -function refuseBracketedAppIdFilter(query: Readonly> | undefined): void { - if (!query || typeof query !== 'object') return; - const key = Object.keys(query).find((k) => k.startsWith('id[')); +function refuseBracketedListFilter( + query: Readonly> | undefined, + param: 'id' | 'object', + names: 'app' | 'object', + lists: 'app' | 'view', +): void { + const key = bracketedKeyOf(query, param); if (key === undefined) return; throwListFilterRefusal( - 'id', key, 'app', 'app', - "the 'id' filter takes exactly one app name, spelled ?id=NAME", "a bracketed 'id' key", + param, key, names, lists, + `the '${param}' filter takes exactly one ${names} name, spelled ?${param}=NAME`, `a bracketed '${param}' key`, ); } -/** The one sentence frame of the list chain's narrowing-filter refusals. */ +/** The first query key that opens `param[` (a qs-style array spelling of `param`), or `undefined`. */ +function bracketedKeyOf(query: unknown, param: string): string | undefined { + if (!query || typeof query !== 'object') return undefined; + return Object.keys(query).find((k) => k.startsWith(`${param}[`)); +} + +/** The one sentence frame of the `/meta` list doors' narrowing-filter refusals. */ +function listFilterRefusalMessage( + key: string, + names: string, + got: string, + notAFilter: string, + served: 'list' | 'tree', + unscoped: string, +): string { + return `Query parameter '${key}' names no ${names}; ${got}. The ${served} was not served: ${notAFilter} ` + + `is not a filter. Name one ${names}, or leave the parameter out to ${unscoped}.`; +} + +/** {@link listFilterRefusalMessage}, thrown, so each transport's own `catch` shapes it. */ function throwListFilterRefusal( param: 'id' | 'object', key: string, @@ -3456,8 +3520,7 @@ function throwListFilterRefusal( notAFilter: string, ): never { const err: any = new Error( - `Query parameter '${key}' names no ${names}; ${got}. The list was not served: ${notAFilter} ` - + `is not a filter. Name one ${names}, or leave the parameter out to list every ${lists} you can see.`, + listFilterRefusalMessage(key, names, got, notAFilter, 'list', `list every ${lists} you can see`), ); err.status = 400; err.code = 'VALIDATION_ERROR'; @@ -3558,7 +3621,8 @@ async function describeMetaPositionList(sources: MetaListAnswerSources, list: an * list, never a 404. * 4. `view` — `?object=` keeps the independent views bound to that * object, sorted by `order` then `name` (the switcher); [#28548] an EMPTY - * value is refused (`400 VALIDATION_ERROR`), never read as absent. + * value is refused (`400 VALIDATION_ERROR`), never read as absent, as is a + * bracketed key (`?object[]=`, `?object[0]=`), never read as no filter. * 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`. @@ -3614,8 +3678,8 @@ export function createMetaListAnswer( // 3. [#7566] `?id=`. [#28548] An EMPTY one is refused, never // read as absent (see {@link refuseEmptyListFilter}), and so is a - // bracketed spelling of the key (see {@link refuseBracketedAppIdFilter}). - if (metaType === 'app') refuseBracketedAppIdFilter(query); + // bracketed spelling of the key (see {@link refuseBracketedListFilter}). + if (metaType === 'app') refuseBracketedListFilter(query, 'id', 'app', 'app'); const appIdFilter = metaType === 'app' ? query?.id : undefined; if (appIdFilter === '') refuseEmptyListFilter('id', 'app', 'app'); if (typeof appIdFilter === 'string') { @@ -3628,7 +3692,9 @@ export function createMetaListAnswer( } // 4. `?object=` — the view switcher. [#28548] An EMPTY one is - // refused, never read as absent (see {@link refuseEmptyListFilter}). + // refused, never read as absent (see {@link refuseEmptyListFilter}), + // and so is a bracketed spelling of the key, as step 3's is. + if (metaType === 'view') refuseBracketedListFilter(query, 'object', 'object', 'view'); if (metaType === 'view' && query?.object === '') refuseEmptyListFilter('object', 'object', 'view'); if (metaType === 'view' && query?.object) { const obj = String(query.object); diff --git a/packages/rest/src/meta-item-read-routes.ts b/packages/rest/src/meta-item-read-routes.ts index b3f2d1dde21..55440b0b242 100644 --- a/packages/rest/src/meta-item-read-routes.ts +++ b/packages/rest/src/meta-item-read-routes.ts @@ -383,6 +383,16 @@ export function registerMetaItemReadRoutes(this: MetaItemReadRouteHost, scope: M // [#6877] One package scopes the book lookup. [#23214] The // list lives beside the shared decision, as the list door's. if (refuseRepeatedQueryParams(req, res, metaReadGate.META_QUERY_ARITY_DOORS['book-tree'])) 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('book-tree', req.query); + if (bracketedPackage) { + res.status(bracketedPackage.status).json({ + error: { code: bracketedPackage.code, message: bracketedPackage.message }, + }); + return; + } // [#22188] Read through {@link metaItemPackageBinding}: `all` names no package. const packageId = metaItemPackageBinding(req.query?.package); // [#20408] The route's whole answer is From 23bdc2eba1d019516e8f484a3589dba1fa86cf3a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:18:34 +0000 Subject: [PATCH 3/4] test(dogfood): bracketed object and package keys on the /meta list over HTTP Claude-Session: https://claude.ai/code/session_01ADHkmPfJsJeWNxeaZkvyEM Co-authored-by: Claude --- .../test/meta-read-doors-live.dogfood.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts b/packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts index 7f460d1f44c..f5605a0a3e3 100644 --- a/packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts +++ b/packages/qa/dogfood/test/meta-read-doors-live.dogfood.test.ts @@ -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); + }); + }); }); From 67663ae48b35e197d6a00430b961e6747fa53589 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:19:56 +0000 Subject: [PATCH 4/4] chore(changeset): 29199 bracketed object/package keys refused on the /meta list and book tree Claude-Session: https://claude.ai/code/session_01ADHkmPfJsJeWNxeaZkvyEM Co-authored-by: Claude --- .../29199-bracketed-object-package-refused.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/29199-bracketed-object-package-refused.md diff --git a/.changeset/29199-bracketed-object-package-refused.md b/.changeset/29199-bracketed-object-package-refused.md new file mode 100644 index 00000000000..459d81debaf --- /dev/null +++ b/.changeset/29199-bracketed-object-package-refused.md @@ -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 + + + +**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.