diff --git a/.changeset/29236-meta-list-names-filter.md b/.changeset/29236-meta-list-names-filter.md new file mode 100644 index 00000000000..1849d95306a --- /dev/null +++ b/.changeset/29236-meta-list-names-filter.md @@ -0,0 +1,24 @@ +--- +'@objectstack/rest': patch +'@objectstack/runtime': patch +'@objectstack/metadata-protocol': patch +'@objectstack/client': patch +'@objectstack/spec': patch +--- + +feat(rest)!: the metadata list reads several items by name in one request (`?names=a,b,c`), and refuses a query parameter it does not read + +Clause-②: yes (narrowing) — `GET /api/v1/meta/:type` (and its environment-scoped twin, on both the REST server and the runtime dispatcher) declares its closed query-parameter set, `package`, `preview`, `object`, `include`, `id`, `names` and `locale`, and refuses any other name with `400 VALIDATION_ERROR`, where it ignored it and answered the whole list with `200`. The widening half: `?names=a,b,c` narrows every type's list but `package`'s to the items of those names, `@objectstack/client`'s `meta.getItems(type, { names })` sends it, and `@objectstack/rest` exports three new names, the list's query decision that both transports ask: `META_LIST_QUERY_PARAMS`, `metaListQueryRefusal` and `metaListNames`. Ruling 6580261740 assigns both halves to this one server change. +Metadata-protocol: none + + + +**The batch by-names read.** `GET /api/v1/meta/:type?names=a,b,c` answers only the items of those names, among the items the list would answer without it, on every metadata type except `package`, whose items are keyed by id and carry no `name`: the `package` list refuses `?names=` with `400 VALIDATION_ERROR`. One comma-separated value, as the data routes spell `fields`; each name is trimmed and repeats count once. A name that matches no item is simply absent from the answer (no `404`). The names are handed to the store read (`GetMetaItemsRequest.names`), so `@objectstack/metadata-protocol`'s `getMetaItems` filters before it computes locks, governance and diagnostics, and the list's per-caller gate, object field mask and translation run on the named items only. A value that names no item (`?names=`, `?names=,`) is refused with `400 VALIDATION_ERROR` rather than read as every item; leave the key out to list every item. A repeated `?names=` is refused like the list's other single-valued parameters. Before, `?names=` was ignored, so a client that needed several objects paid one by-name request for each. + +**BREAKING** accept-set narrowing at the metadata list (`@objectstack/rest`, `@objectstack/runtime`): a query parameter the list does not read is refused instead of ignored. + +- **Before.** `GET /meta/object?name=account`, `?ids=a,b` or `?filter=x` answered `200` with every object, which reads the same as a filter that matched everything. +- **Now.** A name outside `package`, `preview`, `object`, `include`, `id`, `names`, `locale` is refused with `400` and the nested `{ "error": { "code": "VALIDATION_ERROR", "message" } }` body; the message names the parameter and lists the supported ones, and nothing is read. A bracketed `?package[]=`, and `?id[]=` on the app list or `?object[]=` on the view list, keep their own located refusal; any other bracketed key (`?names[]=`) is refused as a name the list does not read. +- **Who moves.** The Console and the client SDK send only declared names. A script that sends another name gets the 400. + +**The remedy.** Drop the parameter, or spell it as one of the supported names: `?names=a,b` for several items by name, `?id=APP` for one app, `?object=OBJECT` for one object's views, `?package=ID` for one package. diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 2c6caf30e47..f62ebb212a6 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -967,6 +967,17 @@ A type listing returns `{ type, items }` — the requested metadata `type` plus } ``` +To read several items of one type in a single request, name them: +`GET /api/v1/meta/object?names=account,contact` answers only those items, among +the ones the caller may see. The value is one comma-separated list; a name that +matches no item is simply absent from `items`, and an empty value (`?names=`) is +refused with `400 VALIDATION_ERROR`. `?names=` works on every type except +`package`, whose items are keyed by id rather than name; the `package` list +refuses it with `400 VALIDATION_ERROR`. The list reads a closed set of query +parameters (`package`, `preview`, `object`, `include`, `id`, `names` and +`locale`) and refuses any other name with `400 VALIDATION_ERROR` instead of +ignoring it. + ### Get Object Schema **Request:** diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index c5f062da017..26966ab22cb 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1517,7 +1517,7 @@ One changed key path of the pending draft, and the last write that changed it. | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name (e.g., "object", "plugin") | | **packageId** | `string` | optional | Optional package ID to filter items by | -| **names** | `string[]` | optional | [PLANNED — not yet served] Item names to narrow the list to: only the named items of the type are answered, among those the list would answer without it. Sent over HTTP as one comma-separated value (`?names=a,b,c`). A name that matches no item is simply absent from the answer (no 404). An empty value is refused with 400 rather than read as every item; leave the key out to list every item. No list door reads it yet: until one does, a request carrying it is answered as if it were absent. | +| **names** | `string[]` | optional | Item names to narrow the list to: only the named items of the type are answered, among those the list would answer without it. Served on every type except `package`, whose items are keyed by id; the `package` list refuses it with 400. Sent over HTTP as one comma-separated value (`?names=a,b,c`). A name that matches no item is simply absent from the answer (no 404). An empty value is refused with 400 rather than read as every item; leave the key out to list every item. | | **previewDrafts** | `boolean` | optional | Draft-visibility switch (ADR-0033 draft-overlay preview): when true, pending `state='draft'` rows are overlaid on the active list — draft wins on name collision, draft-only items appear, and each overlaid item is tagged `_draft: true` so UIs can badge the preview. Absent/false = published world only. Declaration ≠ authorization: this member only switches which rows are read, and ADR-0106 masking is unaffected — callers without draft-preview authorization are refused upstream (admin-gated), not by this schema. | diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 2cea6d94707..af763700eb1 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -187,6 +187,28 @@ describe('ObjectStackClient', () => { expect(result.items).toHaveLength(2); }); + it('[#29236] sends `names` as ONE comma-separated `?names=` value, on the unscoped and the environment surface', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ type: 'object', items: [{ name: 'customer' }] }), + }); + const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock }); + + await client.meta.getItems('object', { names: ['customer', 'order'] }); + await client.meta.getItems('object', { packageId: 'com.acme', names: ['customer'] }); + await client.environment('env_1').meta.getItems('object', { names: ['customer', 'order'] }); + // An empty list is sent as the empty filter the server refuses, never dropped into "every item". + await client.meta.getItems('object', { names: [] }); + + const urls = fetchMock.mock.calls.map((c) => String(c[0])); + expect(urls).toEqual([ + 'http://localhost:3000/api/v1/meta/object?names=customer%2Corder', + 'http://localhost:3000/api/v1/meta/object?package=com.acme&names=customer', + 'http://localhost:3000/api/v1/environments/env_1/meta/object?names=customer%2Corder', + 'http://localhost:3000/api/v1/meta/object?names=', + ]); + }); + it('should get metadata item by type and name', async () => { // The server's body is the spec-declared `GetMetaItemResponseSchema` // envelope — `{ type, name, item }`, with the metadata document under diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index b67dd091396..da17777a3a4 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1939,12 +1939,17 @@ export class ObjectStackClient { /** * Get all items of a specific metadata type * @param type - Metadata type name (e.g., 'object', 'plugin') - * @param options - Optional filters (e.g., packageId to scope by package) + * @param options - Optional filters: `packageId` scopes the list to one + * package; `names` narrows it to the items of those names, in ONE request + * (sent as `?names=a,b,c`). A name that matches no item is simply absent + * from the answer. An empty `names` array is sent as an empty filter, + * which the server refuses with `400`; leave `names` out to list every item. */ - getItems: async (type: string, options?: { packageId?: string }): Promise => { + getItems: async (type: string, options?: { packageId?: string; names?: readonly string[] }): Promise => { const route = this.getRoute('metadata'); const params = new URLSearchParams(); if (options?.packageId) params.set('package', options.packageId); + if (options?.names !== undefined) params.set('names', options.names.join(',')); const qs = params.toString(); const url = `${this.baseUrl}${route}/${type}${qs ? `?${qs}` : ''}`; const res = await this.fetch(url); @@ -8194,9 +8199,11 @@ export class ScopedEnvironmentClient { const res = await this.parent._fetch(this.metaUrl('')); return this.parent._unwrap(res); }, - getItems: async (type: string, options?: { packageId?: string }): Promise => { + /** The unscoped surface's `getItems`, `names` included (one `?names=a,b,c` request). */ + getItems: async (type: string, options?: { packageId?: string; names?: readonly string[] }): Promise => { const params = new URLSearchParams(); if (options?.packageId) params.set('package', options.packageId); + if (options?.names !== undefined) params.set('names', options.names.join(',')); const qs = params.toString(); const res = await this.parent._fetch(this.metaUrl(`/${type}${qs ? `?${qs}` : ''}`)); return this.parent._unwrap(res); diff --git a/packages/metadata-protocol/src/protocol-meta-list-reads.ts b/packages/metadata-protocol/src/protocol-meta-list-reads.ts index 5bbf9f0ed6b..1fec60a2293 100644 --- a/packages/metadata-protocol/src/protocol-meta-list-reads.ts +++ b/packages/metadata-protocol/src/protocol-meta-list-reads.ts @@ -51,7 +51,7 @@ import { viewIdentityPatch } from './protocol-view-normalization.js'; import { isServedOverrideRowOf } from './served-override.js'; /** The body of `ObjectStackProtocolImplementation#getMetaItems`; the method's docblock there is its contract. */ -export async function getMetaItems(host: Pick, request: { type: string; packageId?: string; previewDrafts?: boolean }) { +export async function getMetaItems(host: Pick, request: { type: string; packageId?: string; names?: readonly string[]; previewDrafts?: boolean }) { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. This one // is load-bearing twice over: the SchemaRegistry indexes code-authored // items under the SINGULAR type, and the overlay-hydration branch below @@ -78,7 +78,7 @@ export async function getMetaItemsPerPackage(host: Pick, request: { type: string; packageId?: string; previewDrafts?: boolean }, +export async function readFlattenedMetaItems(host: Pick, request: { type: string; packageId?: string; names?: readonly string[]; previewDrafts?: boolean }, audience: 'served' | 'execution', layer: 'effective' | 'per-package' = 'effective',) { const { packageId } = request; @@ -690,6 +690,21 @@ export async function readFlattenedMetaItems(host: Pick !(it && typeof it === 'object' && unseated.has(it))); } + // [#29236, ruling 6580261740 letter A] `GetMetaItemsRequest.names` — the + // list door's `?names=a,b`, pushed down: only the items of those names, + // kept HERE, once every source is merged and before any step below, so the + // narrowing, the hiding, the lock selection, the governance and the + // serving decorations all run on the named items alone. Every step below + // judges an item by its own name and rows, so the named items are served + // exactly as the whole list would serve them. A name that matches nothing + // is simply absent. Absent, every item (the list as before); the door + // refuses an empty filter before it reads, so an empty one never names + // "every item" here either: it keeps none. + if (request.names !== undefined) { + const wanted = new Set(request.names); + items = (items as any[]).filter((it) => it && typeof it === 'object' && wanted.has((it as any).name)); + } + // [#28409, ADR-0146 D8 items 1–2, #28238's list half] THE ENV-WIDE // LIST JOINS THE ONE EFFECTIVE READ. Naming no package, a name exactly // one code package ships is listed once, as that package's item diff --git a/packages/metadata-protocol/src/protocol.meta-list-names.test.ts b/packages/metadata-protocol/src/protocol.meta-list-names.test.ts new file mode 100644 index 00000000000..54f005d248d --- /dev/null +++ b/packages/metadata-protocol/src/protocol.meta-list-names.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29236, ruling 6580261740 letter A] `getMetaItems({ type, names })` — the + * list door's `?names=a,b` pushed down to the store read + * (`GetMetaItemsRequestSchema.names`). + * + * The protocol keeps the items of those names once every source is merged, + * BEFORE the per-item work after the merge (the app nav contributions, the lock + * selection, the governance, the serving decorations), so that work runs on the + * named items alone. A name that matches nothing is simply absent. + * + * The engine is the shape `get-meta-items-package-writable.test.ts` drives: the + * registry surface the list read touches and a `find` that answers the overlay + * query with nothing. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const APPS = ['crm', 'helpdesk', 'payroll'].map((name) => ({ name, label: name.toUpperCase(), navigation: [] })); +const OBJECTS = ['account', 'contact', 'lead'].map((name) => ({ name, label: name, fields: { name: { type: 'text' } } })); + +function make() { + const contributedTo: string[] = []; + const engine: any = { + manifests: new Map(), + find: async () => [], + registry: { + listItems: (type: string) => structuredClone(type === 'app' ? APPS : type === 'object' ? OBJECTS : []), + getPackage: () => undefined, + getItem: () => undefined, + getObject: () => undefined, + getArtifactItem: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: any) => { + contributedTo.push(app?.name); + return app; + }, + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()); + return { protocol, contributedTo }; +} + +const namesOf = (res: any): string[] => (res.items as any[]).map((it) => it.name); + +describe('getMetaItems — `names` narrows the served list (#29236)', () => { + it('keeps only the named items, on `object` and on `app`', async () => { + const { protocol } = make(); + expect(namesOf(await protocol.getMetaItems({ type: 'object', names: ['lead', 'account'] }))).toEqual(['account', 'lead']); + expect(namesOf(await protocol.getMetaItems({ type: 'app', names: ['helpdesk'] }))).toEqual(['helpdesk']); + }); + + it('narrows BEFORE the per-item work after the merge: only the named apps get their nav contributions', async () => { + const { protocol, contributedTo } = make(); + await protocol.getMetaItems({ type: 'app', names: ['payroll', 'crm'] }); + expect(contributedTo.sort()).toEqual(['crm', 'payroll']); + }); + + it('a name that matches nothing is simply absent', async () => { + const { protocol } = make(); + expect(namesOf(await protocol.getMetaItems({ type: 'object', names: ['account', 'nope'] }))).toEqual(['account']); + expect(namesOf(await protocol.getMetaItems({ type: 'object', names: ['nope'] }))).toEqual([]); + }); + + it('serves each named item exactly as the whole list serves it', async () => { + const { protocol } = make(); + const whole = await protocol.getMetaItems({ type: 'object' }); + const named = await protocol.getMetaItems({ type: 'object', names: ['contact'] }); + expect(named.items).toEqual((whole.items as any[]).filter((it) => it.name === 'contact')); + }); + + it('control: absent `names` lists every item, and every app gets its contributions', async () => { + const { protocol, contributedTo } = make(); + expect(namesOf(await protocol.getMetaItems({ type: 'object' }))).toEqual(['account', 'contact', 'lead']); + await protocol.getMetaItems({ type: 'app' }); + expect(contributedTo.sort()).toEqual(['crm', 'helpdesk', 'payroll']); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index d9077708e58..4b7bfd0438f 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3814,7 +3814,13 @@ export class ObjectStackProtocolImplementation implements return throwMetadataServiceUnavailable(errors); } - async getMetaItems(request: { type: string; packageId?: string; previewDrafts?: boolean }) { + /** + * The served list of one metadata type. [#29236] `names` narrows it to the + * items of those names (`GetMetaItemsRequestSchema.names`): a name that + * matches nothing is absent, and the narrowing runs before the serving + * decorations, so they are computed for the named items only. + */ + async getMetaItems(request: { type: string; packageId?: string; names?: readonly string[]; previewDrafts?: boolean }) { // [#25467 E1] The body moved to `protocol-meta-list-reads.ts`; it reaches this class through `protocol-host.ts`. return getMetaItems(protocolHost(this), request); } @@ -3988,7 +3994,7 @@ export class ObjectStackProtocolImplementation implements * `request` arrives already folded by the calling face (#4432). */ private async readFlattenedMetaItems( - request: { type: string; packageId?: string; previewDrafts?: boolean }, + request: { type: string; packageId?: string; names?: readonly string[]; previewDrafts?: boolean }, audience: 'served' | 'execution', layer: 'effective' | 'per-package' = 'effective', ) { diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index 27da359d348..e5befccce8a 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -152,6 +152,11 @@ export { DATA_RECORD_READ_PARAMS } from './rest-server.js'; // list and the book tree): one list per door (`META_QUERY_ARITY_DOORS`), which // `RestServer` hands `refuseRepeatedQueryParams` and the dispatcher asks // through `metaQueryArityRefusal`. +// +// [#29236] …and the list's closed query set (`META_LIST_QUERY_PARAMS`), asked +// with its arity as one decision (`metaListQueryRefusal`), and the one reading +// of its batch by-names filter (`metaListNames`, `?names=a,b`), which both +// transports push down to the store read. export { createMetaBookTreeAnswer, createMetaItemAnswer, @@ -165,6 +170,9 @@ export { metaCallerOrganizationId, metaItemLayersDeprecationHeaders, metaItemQueryRefusal, + META_LIST_QUERY_PARAMS, + metaListNames, + metaListQueryRefusal, metaMemberSelector, META_QUERY_ARITY_DOORS, metaQueryArityRefusal, diff --git a/packages/rest/src/meta-app-publish-gate.test.ts b/packages/rest/src/meta-app-publish-gate.test.ts index 530a7290914..f2b8afc1664 100644 --- a/packages/rest/src/meta-app-publish-gate.test.ts +++ b/packages/rest/src/meta-app-publish-gate.test.ts @@ -364,14 +364,19 @@ describe('#7566 — `GET /meta/app?id=` narrows the list instead of being droppe } }); - it('a bracketed `id` key on another list type is not read, as no other `id` spelling is (control)', async () => { + // [#29236] The list declares its closed query set: a bracketed `id` on a + // type that reads no `id` is a name the list does not read, refused by + // recognition (not with the app list's `id` sentence), never the whole list. + it('a bracketed `id` key on another list type is refused as a name the list does not read', async () => { 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' }] : []); const res = await getList(rest, 'view', { 'id[]': 'nope' }); - expect(res.statusCode).toBe(200); - expect(namesFrom(res.body)).toEqual(['all_leads']); + expect(res.statusCode).toBe(400); + expect(res.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(res.body?.error?.message).toMatch(/^The "id\[\]" query parameter is not supported by this endpoint\./); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); }); // The same class one step down the chain: `?object[]=lead` or @@ -413,11 +418,13 @@ describe('#7566 — `GET /meta/app?id=` narrows the list instead of being droppe 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 () => { + // [#29236] …and so is a bracketed `object` on a type that reads no `object`. + it('a bracketed `object` key on another list type is refused as a name the list does not read', 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']); + expect(res.statusCode).toBe(400); + expect(res.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(res.body?.error?.message).toMatch(/^The "object\[\]" query parameter is not supported by this endpoint\./); }); it('a WHITESPACE id keeps its answer — a value no app is named, so an empty list (#28548 control)', async () => { diff --git a/packages/rest/src/meta-collection-routes.ts b/packages/rest/src/meta-collection-routes.ts index 824a9f54dc1..289e98a9328 100644 --- a/packages/rest/src/meta-collection-routes.ts +++ b/packages/rest/src/meta-collection-routes.ts @@ -655,6 +655,21 @@ export function registerMetaCollectionRoutes(this: MetaCollectionRouteHost, scop // against. Refused, not resolved — see // `query-multiplicity.ts` for why picking one of two // conflicting intents is worse than a 400. + // [#29236] …and before the arity gate, RECOGNITION: the list + // declares its closed query set (`META_LIST_QUERY_PARAMS`), so + // a name it does not read — a misspelt filter — is a located + // 400, never the whole list with a 200. The one decision the + // runtime dispatcher's twin asks too (`metaListQueryRefusal`), + // written in the nested envelope `refuseUnknownQueryParams` + // writes. + const listType = this.metaTypeSingular(req.params.type); + const unknownParam = metaReadGate.metaListUnknownParamRefusal(listType, req.query); + if (unknownParam) { + res.status(unknownParam.status).json({ + error: { code: unknownParam.code, message: unknownParam.message }, + }); + return; + } // [#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; @@ -706,9 +721,16 @@ export function registerMetaCollectionRoutes(this: MetaCollectionRouteHost, scop // transport-level `environmentId` — the `as any` this // literal used to carry is retired now that the spec // declares `previewDrafts`. + // [#29236] `?names=a,b`, read once (an empty one is refused + // here, before the store read) and pushed down to the store + // as `GetMetaItemsRequest.names`, so the protocol filters + // before it decorates. The chain narrows by the same + // reading after the gate. + const names = metaReadGate.metaListNames(req.query); const listRequest: TransportScopedMetaRequest = { type: req.params.type, packageId, + ...(names ? { names: [...names] } : {}), ...(previewDrafts ? { previewDrafts: true } : {}), ...(environmentId ? { environmentId } : {}), }; @@ -734,7 +756,7 @@ export function registerMetaCollectionRoutes(this: MetaCollectionRouteHost, scop // declaration above — and the chain never re-reads it. const answer = await metaReadGate.createMetaListAnswer( this.metaListAnswerSources(environmentId, req, p), - { metaType: this.metaTypeSingular(req.params.type), query: req.query, previewDrafts }, + { metaType: listType, query: req.query, previewDrafts }, )(items); if (!answer.ok) { sendFieldVisibilityFault(res, answer.object); diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index b873c930702..680c1ab1600 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -2433,7 +2433,7 @@ export interface MetaItemQueryRefusal { readonly status: 400; readonly code: 'VALIDATION_ERROR'; readonly message: string; - readonly rule: 'unknown-parameter' | 'repeated-parameter' | 'layered-member' | 'bracketed-parameter'; + readonly rule: 'unknown-parameter' | 'repeated-parameter' | 'layered-member' | 'bracketed-parameter' | 'names-on-package'; } /** @@ -2525,9 +2525,11 @@ function repeatedOnceRefusal( } /** - * [#23214] The `/meta` doors whose only query rule is ARITY: none of them - * declares a closed query set, so recognition (rule 1 of - * {@link metaItemQueryRefusal}) never applies to them. + * [#23214] The `/meta` doors that read their query under the ARITY rule. None + * of the last three declares a closed query set, so recognition (rule 1 of + * {@link metaItemQueryRefusal}) never applies to them; [#29236] the list + * declares one ({@link META_LIST_QUERY_PARAMS}), asked first, by + * {@link metaListQueryRefusal}. * - `list` — `GET /meta/:type`; * - `book-tree` — `GET /meta/book/:name/tree`; * - `drafts` — `GET /meta/_drafts`; @@ -2546,7 +2548,9 @@ export type MetaQueryArityDoor = 'list' | 'book-tree' | 'drafts' | 'state'; * one inclusion, and `?id=` the app list's one app. Each is compared or * joined as one value, so a repeat would become a name no item has (`'a,b'`) * or a filter that matches nothing, or would read as absent: for `package`, - * a list of every package. + * a list of every package. [#29236] `?names=` is the list's batch by-names + * filter, ONE comma-separated value ({@link metaListNames}): a repeat would + * be two lists of names, of which reading either one drops the other. * - `book-tree`: `?package=` scopes the book and doc reads. * - `drafts`: `?packageId=` and `?type=` each narrow the draft list to one * package and one type; a repeat reached `listDrafts` as an array. @@ -2556,7 +2560,7 @@ export type MetaQueryArityDoor = 'list' | 'book-tree' | 'drafts' | 'state'; * Declared in this order on purpose: two repeated names answer for the first. */ export const META_QUERY_ARITY_DOORS: Readonly> = Object.freeze({ - list: Object.freeze(['package', 'preview', 'object', 'include', 'id']), + list: Object.freeze(['package', 'preview', 'object', 'include', 'id', 'names']), 'book-tree': Object.freeze(['package']), drafts: Object.freeze(['packageId', 'type']), state: Object.freeze(['from']), @@ -2579,6 +2583,139 @@ export function metaQueryArityRefusal(door: MetaQueryArityDoor, query: unknown): ?? metaBracketedFilterRefusal(door, query); } +/** + * [#29236] The closed query-parameter set of `GET /meta/:type`, the list + * (AGENTS.md 〈Route & surface ownership〉 rule 5; the ingress policy in + * `./query-allowlist.ts`). It declared none, so every name it does not read + * was ignored: `?names=` before this card, a misspelt `?name=`, or `?ids=` + * each answered the whole list with `200`, which a caller cannot tell from a + * filter that matched everything. + * + * **Measured** from the read points of both transports' list and of the chain + * they share ({@link createMetaListAnswer}): `package` (the scope, + * `metaItemPackageBinding`), `preview` (the draft overlay), `object` (the view + * switcher), `include` (the doc bodies, the position grants), `id` (the app + * list's one app), `names` (the batch by-names filter, {@link metaListNames}), + * and `locale` — read a frame down, by {@link metaRequestLocale} behind the doc + * locale collapse and the translation step, never in a handler body. The + * maintainer's ruling named the first six; `locale` joins them because the list + * already reads it, so leaving it out would turn every working + * `?locale=zh-CN` list read into a `400` (the item read's closed set carries it + * for the same reason, {@link META_ITEM_READ_PARAMS}). + * + * `meta-list-projection-parity.test.ts` in `@objectstack/runtime` derives the + * list's read points from the source and holds this set equal to them, so a + * name the list starts reading reaches it, or the census goes red. + */ +export const META_LIST_QUERY_PARAMS: readonly string[] = Object.freeze([ + 'package', 'preview', 'object', 'include', 'id', 'names', 'locale', +]); + +/** + * The bracketed spellings (`param[`…) the list judges with its own located + * sentence, by the type whose step reads `param`: `package` on every type, at + * the door's arity step ({@link metaBracketedFilterRefusal}); `id` on `app` and + * `object` on `view`, in the chain ({@link refuseBracketedListFilter}). + * Recognition leaves exactly these to that sentence, which names the one + * spelling the filter takes; every other bracketed key — `names[]`, `id[]` on a + * type that reads no `id` — is a name the list does not read, refused here. + */ +function bracketedKeyJudgedByList(metaType: string, key: string): boolean { + if (key.startsWith('package[')) return true; + if (metaType === 'app' && key.startsWith('id[')) return true; + return metaType === 'view' && key.startsWith('object['); +} + +/** + * [#29236] THE query decision of the `/meta/:type` list's entry, for both + * transports, asked before any read of the query: + * + * 1. **recognition** — a name outside {@link META_LIST_QUERY_PARAMS}, answered + * with the located sentence `refuseUnknownQueryParams` answers + * (`unknownQueryParamRefusalMessage`, `./query-allowlist.ts`), so a + * misspelt filter is `400`, never the whole list with `200`. A bracketed + * spelling the list judges itself ({@link bracketedKeyJudgedByList}) is + * left to that sentence; + * 2. **arity** — {@link metaQueryArityRefusal} for the `list` door, which + * unwraps a one-element array IN PLACE. + * + * `metaType` is the FOLDED singular type. `RestServer`'s door writes the + * answer in the nested `{ error: { code, message } }` its recognition and + * arity gates write; the runtime dispatcher's twin writes it through + * `deps.error`, with the same `error.code` and message. + */ +export function metaListQueryRefusal(metaType: string, query: unknown): MetaItemQueryRefusal | undefined { + return metaListUnknownParamRefusal(metaType, query) ?? metaQueryArityRefusal('list', query); +} + +/** + * Rule 1 of {@link metaListQueryRefusal} alone, for `RestServer`'s door, which + * asks rule 2 through `refuseRepeatedQueryParams` (the gate it has always + * asked) right after it. + */ +export function metaListUnknownParamRefusal(metaType: string, query: unknown): MetaItemQueryRefusal | undefined { + if (!query || typeof query !== 'object') return undefined; + const judged: Record = {}; + for (const key of Object.keys(query)) { + if (!bracketedKeyJudgedByList(metaType, key)) judged[key] = true; + } + const message = unknownQueryParamRefusalMessage(judged, META_LIST_QUERY_PARAMS); + if (message !== undefined) return { status: 400, code: 'VALIDATION_ERROR', message, rule: 'unknown-parameter' }; + if (metaType === 'package' && Object.prototype.hasOwnProperty.call(query, 'names')) { + return { status: 400, code: 'VALIDATION_ERROR', message: NAMES_ON_PACKAGE_LIST_MESSAGE, rule: 'names-on-package' }; + } + return undefined; +} + +/** + * [#29236, contract review 6672872746] `?names=` on the `package` list. A + * `package` list item is keyed by its id (`manifest.id`) and carries no + * `name`, so a names filter there could never match and would answer `200 []` + * for every value: the silent zero the list's closed set exists to remove. It + * is refused instead, on both transports, before any read. Matching packages + * by id is a different behaviour and not served here. + */ +export const NAMES_ON_PACKAGE_LIST_MESSAGE = + "Query parameter 'names' is not served on the 'package' list: package items are keyed by id, not by name, " + + 'so no name could match. The list was not served. Leave the parameter out to list every package you can see.'; + +/** + * [#29236, ruling 6580261740 letter A] THE reading of `?names=` on the + * `/meta/:type` list, for both transports and the chain: ONE comma-separated + * value (the data routes' spelling of `fields`, `searchFields`, `orderby`), + * each name trimmed, blanks dropped, repeats folded. Absent, `undefined`: no + * filter. A value that names no item at all (`?names=`, `?names=,`) is + * REFUSED — `400 VALIDATION_ERROR`, never read as every item, the #28548 rule + * the list's other narrowing filters keep — and THROWN, so each transport's own + * `catch` shapes it, as {@link refuseEmptyListFilter} does. + * + * Ask it AFTER {@link metaListQueryRefusal}, which refuses a repeated `names` + * and unwraps a one-element array, so the value read here is one string. A + * value that is not a string is no filter this reading knows, so it is read as + * absent — the decision above never hands one over. + * + * A name that matches no item is simply absent from the answer (no `404`): the + * list answers the items it would answer without the filter, narrowed to these + * names (`GetMetaItemsRequestSchema.names` in `@objectstack/spec/api`). + */ +export function metaListNames(query: Readonly> | undefined): readonly string[] | undefined { + const raw = query?.names; + if (typeof raw !== 'string') return undefined; + const names = [...new Set(raw.split(',').map((s) => s.trim()).filter(Boolean))]; + if (names.length === 0) { + const err: any = new Error( + `Query parameter 'names' names no item; got ${JSON.stringify(raw)}. The list was not served: ` + + 'a list of no names is not a filter. Name at least one item, spelled ?names=A,B, or leave the ' + + 'parameter out to list every item you can see.', + ); + err.status = 400; + err.code = 'VALIDATION_ERROR'; + err.param = 'names'; + throw err; + } + return names; +} + /** * One filter an arity door judges for a BRACKETED spelling: the name as the * door reads it, what one value of it names, how one value is spelled, what the @@ -3622,7 +3759,7 @@ export interface MetaListAnswerSources extends MetaItemReadGateSources { export interface MetaListRequest { /** The SINGULAR type (the caller folds `/meta/docs` once, at its boundary). */ readonly metaType: string; - /** The list route's query. The chain reads `id`, `object` and `include` off it (and `locale`, through `requestLocale`). */ + /** The list route's query. The chain reads `id`, `object`, `include` and [#29236] `names` off it (and `locale`, through `requestLocale`). */ readonly query: Readonly> | undefined; /** * `?preview=draft`, ADMITTED — the transport's own declaration, made with @@ -3823,6 +3960,20 @@ 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). + * 2a. [#29236] Every type — `?names=a,b` keeps the items of those names + * ({@link metaListNames}: an EMPTY value is refused `400 + * VALIDATION_ERROR`; a name that matches nothing is simply absent, never a + * 404). After the gate, by the rule step 3 keeps, and before the mask and + * the translation, so those run on the named items only. Each transport + * also hands the names to the store read (`GetMetaItemsRequest.names`), so + * the protocol filters before it decorates; this step is what holds the + * answer for a store that does not filter. Narrowing the gate's INPUT that + * way moves no verdict: every list gate judges an item on its own facts and + * on per-request facts (the books, the registered services and objects), + * never on which sibling items are listed. The doc list's corpus IS the + * listed docs, but a book claims a doc by that doc's own placement keys + * (`resolveDocAudiences`), so a doc's audience is the same in any corpus + * that holds it. * 3. `app` — `?id=` 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, as is a bracketed key @@ -3885,6 +4036,19 @@ export function createMetaListAnswer( } } + // 2a. [#29236] `?names=a,b`, on every type — after the gate, before + // the mask and the translation. An EMPTY one is refused, never read as + // absent ({@link metaListNames}). + const names = metaListNames(query); + if (names) { + const list = listItemsOf(visible); + if (list) { + const wanted = new Set(names); + const named = list.filter((it: any) => it && typeof it === 'object' && wanted.has(it.name)); + if (named.length !== list.length) visible = withItems(visible, named); + } + } + // 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 refuseBracketedListFilter}). diff --git a/packages/rest/src/meta-list-names-filter.test.ts b/packages/rest/src/meta-list-names-filter.test.ts new file mode 100644 index 00000000000..e48c3f08141 --- /dev/null +++ b/packages/rest/src/meta-list-names-filter.test.ts @@ -0,0 +1,225 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29236, ruling 6580261740 letter A] `?names=a,b,c` on every `/meta/:type` + * list — the batch by-names read. + * + * Before it, the list read no names filter: `?names=` was ignored and answered + * the whole type list, so the Console's Home roster gate read each required + * object by name, one request per name (8 on app-crm). The ruling binds: + * + * - ONE comma-separated value; an empty one is refused `400`, like `?id=`; + * - a name that matches nothing is simply absent (no 404); + * - the narrowing runs after the per-caller gate and before the object mask, + * so masking and translation run only on the named items; + * - the filter is pushed down to the store read (`GetMetaItemsRequest.names`). + * + * The chain's step is pinned here directly (`createMetaListAnswer`), and the + * door through `RestServer`. The protocol's push-down is pinned in + * `packages/metadata-protocol/src/protocol.meta-list-names.test.ts`; the + * runtime dispatcher's twin in the parity census + * (`packages/runtime/src/domains/meta-list-projection-parity.test.ts`). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { createMetaListAnswer, metaListNames } from './meta-item-read-gate.js'; +import { RestServer } from './rest-server.js'; + +const OBJECTS = [ + { name: 'account', label: 'Account', fields: { name: { type: 'text' } } }, + { name: 'contact', label: 'Contact', fields: { name: { type: 'text' } } }, + { name: 'lead', label: 'Lead', fields: { name: { type: 'text' } } }, +]; +const APPS = [ + { name: 'crm', label: 'CRM', navigation: [] }, + // Real, active metadata only a builder may observe (ADR-0045 §3): the gate withholds it from anyone else. + { name: 'build_in_flight', label: 'Build', _unpublished: true, navigation: [] }, + { name: 'helpdesk', label: 'Helpdesk', navigation: [] }, +]; + +const EMPTY_NAMES_MESSAGE = (got: string) => + `Query parameter 'names' names no item; got ${JSON.stringify(got)}. The list was not served: ` + + 'a list of no names is not a filter. Name at least one item, spelled ?names=A,B, or leave the ' + + 'parameter out to list every item you can see.'; + +describe('metaListNames — the one reading of `?names=`', () => { + it('reads one comma-separated value: trimmed, blanks dropped, repeats folded', () => { + expect(metaListNames({ names: 'account,contact' })).toEqual(['account', 'contact']); + expect(metaListNames({ names: ' account , contact,,account ' })).toEqual(['account', 'contact']); + expect(metaListNames({ names: 'account' })).toEqual(['account']); + }); + + it('is no filter when the key is absent', () => { + expect(metaListNames({})).toBeUndefined(); + expect(metaListNames(undefined)).toBeUndefined(); + }); + + it.each(['', ',', ' , '])('refuses %j — a value naming no item — with a located 400, never every item', (raw) => { + let thrown: any; + try { metaListNames({ names: raw }); } catch (e) { thrown = e; } + expect({ status: thrown?.status, code: thrown?.code, param: thrown?.param, message: thrown?.message }) + .toEqual({ status: 400, code: 'VALIDATION_ERROR', param: 'names', message: EMPTY_NAMES_MESSAGE(raw) }); + }); +}); + +/** Chain sources for a caller the gate does not narrow, with a masker that records what it is asked. */ +function chainSources(opts: { caller?: unknown; perms?: string[] } = {}) { + const masked: string[] = []; + const translated: unknown[] = []; + const sources: any = { + resolveCaller: async () => opts.caller, + resolveExecCtx: async () => opts.caller, + resolveSecurityService: async () => undefined, + serviceProbe: () => null, + listMetaItems: () => Promise.resolve([]), + navPruneLogged: new Set(), + requestLocale: () => undefined, + translateList: async (_t: string, list: unknown) => { translated.push(list); return list; }, + resolveEndpointMatcher: async () => undefined, + notifyMissingEndpointMatcher: () => {}, + resolveObjectMasker: async () => async (name: string) => { + masked.push(name); + return { kind: 'passthrough', reason: 'exempt' }; + }, + }; + return { sources, masked, translated }; +} + +const listOf = (answer: any): string[] => { + const data = answer.ok ? answer.data : undefined; + return ((Array.isArray(data) ? data : data?.items) ?? []).map((it: any) => it.name); +}; + +describe('the list chain\'s `?names=` step', () => { + it('`object`: narrows to the named items, and the mask and the translation run on those alone', async () => { + const { sources, masked, translated } = chainSources(); + const answer = await createMetaListAnswer(sources, { metaType: 'object', query: { names: 'contact,account' }, previewDrafts: false })( + { type: 'object', items: structuredClone(OBJECTS) }, + ); + expect(listOf(answer)).toEqual(['account', 'contact']); + expect(masked).toEqual(['account', 'contact']); + expect(((translated[0] as any).items as any[]).map((it) => it.name)).toEqual(['account', 'contact']); + }); + + it('`object`, control: no `names` masks and lists every item', async () => { + const { sources, masked } = chainSources(); + const answer = await createMetaListAnswer(sources, { metaType: 'object', query: {}, previewDrafts: false })( + { type: 'object', items: structuredClone(OBJECTS) }, + ); + expect(listOf(answer)).toEqual(['account', 'contact', 'lead']); + expect(masked).toEqual(['account', 'contact', 'lead']); + }); + + it('a name that matches no item is simply absent — never a 404, never the whole list', async () => { + const { sources, masked } = chainSources(); + const some = await createMetaListAnswer(sources, { metaType: 'object', query: { names: 'account,nope' }, previewDrafts: false })( + structuredClone(OBJECTS), + ); + expect(listOf(some)).toEqual(['account']); + const none = await createMetaListAnswer(sources, { metaType: 'object', query: { names: 'nope' }, previewDrafts: false })( + structuredClone(OBJECTS), + ); + expect({ ok: none.ok, names: listOf(none) }).toEqual({ ok: true, names: [] }); + expect(masked).toEqual(['account']); + }); + + it('an EMPTY `names` rejects the answer with the 400, before the mask', async () => { + const { sources, masked } = chainSources(); + await expect(createMetaListAnswer(sources, { metaType: 'object', query: { names: '' }, previewDrafts: false })( + structuredClone(OBJECTS), + )).rejects.toMatchObject({ status: 400, code: 'VALIDATION_ERROR', param: 'names' }); + expect(masked).toEqual([]); + }); + + it('`app`: narrows AFTER the per-caller gate — a named item the caller may not see stays withheld', async () => { + // A member: a caller with system permissions, none of them the builder's. + const member = { userId: 'u1', systemPermissions: [] }; + const { sources } = chainSources({ caller: member }); + const answer = await createMetaListAnswer(sources, { + metaType: 'app', query: { names: 'build_in_flight,helpdesk' }, previewDrafts: false, + })(structuredClone(APPS)); + expect(listOf(answer)).toEqual(['helpdesk']); + // Control: the same gate, no filter — the withheld app is not listed either. + const all = await createMetaListAnswer(sources, { metaType: 'app', query: {}, previewDrafts: false })(structuredClone(APPS)); + expect(listOf(all)).toEqual(['crm', 'helpdesk']); + }); +}); + +function mockRes() { + const res: any = { statusCode: 200, headers: {} as Record }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }); + res.setHeader = vi.fn(); res.send = vi.fn(() => res); res.end = vi.fn(() => res); res.write = vi.fn(); + return res; +} + +/** `RestServer`'s list door over a protocol that answers `items` — and, unless `honoursNames`, ignores `names`. */ +function bootRest(items: Record, opts: { honoursNames?: boolean } = {}) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn().mockResolvedValue({ types: Object.keys(items) }), + getMetaItems: vi.fn(async (req: any) => { + const list = structuredClone(items[req.type] ?? []); + const kept = opts.honoursNames && req.names ? list.filter((it: any) => req.names.includes(it.name)) : list; + return { type: req.type, items: kept }; + }), + }; + const server = { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn() }; + const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ isSystem: true, userId: 'u1' }); + rest.registerRoutes(); + const route = (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type'); + const read = async (type: string, query: Record) => { + const res = mockRes(); + await route.handler({ method: 'GET', path: '/api/v1/meta/:type', params: { type }, query, headers: {}, body: {} }, res); + const body = res.body; + const data = body?.data ?? body; + return { status: res.statusCode, body, names: ((Array.isArray(data) ? data : data?.items) ?? []).map((it: any) => it?.name) }; + }; + return { protocol, read }; +} + +describe('RestServer `GET /meta/:type?names=` — the door', () => { + it('pushes the names down to the store read, as `GetMetaItemsRequest.names`', async () => { + const { protocol, read } = bootRest({ object: OBJECTS }, { honoursNames: true }); + const res = await read('object', { names: 'contact,account' }); + expect(res.status).toBe(200); + expect(res.names).toEqual(['account', 'contact']); + expect(protocol.getMetaItems.mock.calls[0][0]).toMatchObject({ type: 'object', names: ['contact', 'account'] }); + }); + + it('narrows the answer of a store that does not filter, on `object` and on `view`', async () => { + const views = [ + { name: 'lead_all', viewKind: 'list', object: 'lead' }, + { name: 'lead_open', viewKind: 'list', object: 'lead' }, + { name: 'account_all', viewKind: 'list', object: 'account' }, + ]; + const { read } = bootRest({ object: OBJECTS, view: views }); + expect((await read('object', { names: 'lead' })).names).toEqual(['lead']); + expect((await read('view', { names: 'lead_open,account_all' })).names).toEqual(['lead_open', 'account_all']); + // …and composes with the view switcher: the named views OF that object. + expect((await read('view', { names: 'lead_open,account_all', object: 'lead' })).names).toEqual(['lead_open']); + }); + + it('sends no `names` to the store when the key is absent (control)', async () => { + const { protocol, read } = bootRest({ object: OBJECTS }); + expect((await read('object', {})).names).toEqual(['account', 'contact', 'lead']); + expect(protocol.getMetaItems.mock.calls[0][0]).not.toHaveProperty('names'); + }); + + it('refuses an EMPTY `names` with 400 VALIDATION_ERROR before the store read', async () => { + const { protocol, read } = bootRest({ object: OBJECTS }); + const res = await read('object', { names: '' }); + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toContain('VALIDATION_ERROR'); + expect(JSON.stringify(res.body)).toContain(JSON.stringify(EMPTY_NAMES_MESSAGE('')).slice(1, -1)); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + }); + + it('answers an unknown name as absent, with 200', async () => { + const { read } = bootRest({ object: OBJECTS }); + const res = await read('object', { names: 'nope' }); + expect({ status: res.status, names: res.names }).toEqual({ status: 200, names: [] }); + }); +}); diff --git a/packages/rest/src/meta-query-arity-doors.test.ts b/packages/rest/src/meta-query-arity-doors.test.ts index 2e0387578e0..ff045aca276 100644 --- a/packages/rest/src/meta-query-arity-doors.test.ts +++ b/packages/rest/src/meta-query-arity-doors.test.ts @@ -20,7 +20,15 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { META_QUERY_ARITY_DOORS, metaQueryArityRefusal, type MetaQueryArityDoor } from './meta-item-read-gate.js'; +import { + META_LIST_QUERY_PARAMS, + META_QUERY_ARITY_DOORS, + NAMES_ON_PACKAGE_LIST_MESSAGE, + metaListQueryRefusal, + metaQueryArityRefusal, + type MetaQueryArityDoor, +} from './meta-item-read-gate.js'; +import { unknownQueryParamMessage } from './query-allowlist.js'; import { repeatedQueryParamMessage } from './query-multiplicity.js'; import { RestServer } from './rest-server.js'; @@ -259,3 +267,121 @@ describe('a BRACKETED `packageId` or `type` key on `_drafts`', () => { .toHaveBeenCalledWith({ packageId: undefined, type: undefined }); }); }); + +/** + * [#29236] The list's closed query set. `GET /meta/:type` declared none, so a + * name it does not read was dropped: `?names=` before it was served, a misspelt + * `?name=` or `?ids=`, each answered the whole list with `200`. Recognition now + * runs first at the list's entry, on both transports (`metaListQueryRefusal`; + * the dispatcher's side is pinned in `packages/runtime/src/domains/ + * dispatcher-query-arity.test.ts` and the parity census). + */ +const SUPPORTED_LIST_PARAMS = [...META_LIST_QUERY_PARAMS].sort(); +const unknownListParam = (...names: string[]) => unknownQueryParamMessage(names, SUPPORTED_LIST_PARAMS); + +async function driveList(type: string, query: Record) { + const { protocol, route } = bootRest(); + const path = '/api/v1/meta/:type'; + const res = mockRes(); + await route(path).handler({ method: 'GET', path, params: { type }, query, headers: {}, body: {} }, res); + return { protocol, status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] }; +} + +describe('[#29236] the list\'s closed query set — recognition before arity', () => { + it('declares the ruled six, and `locale`, which the list already reads', () => { + expect(SUPPORTED_LIST_PARAMS).toEqual(['id', 'include', 'locale', 'names', 'object', 'package', 'preview']); + }); + + it.each(['name', 'ids', 'filter', 'select', 'names_', 'Names'])( + 'an undeclared `%s` is refused as data with the located sentence', (name) => { + expect(metaListQueryRefusal('object', { [name]: 'a,b' })).toEqual({ + status: 400, code: 'VALIDATION_ERROR', message: unknownListParam(name), rule: 'unknown-parameter', + }); + }, + ); + + it('names every unknown key, sorted, and outranks a repeat of a declared one', () => { + expect(metaListQueryRefusal('object', { zeta: '1', alpha: '2', names: ['a', 'b'] })?.message) + .toBe(unknownListParam('alpha', 'zeta')); + expect(metaListQueryRefusal('object', { names: ['a', 'b'] })?.rule).toBe('repeated-parameter'); + }); + + it('leaves every declared name, once, to the rest of the list (controls)', () => { + const query = { package: 'crm', preview: 'draft', object: 'lead', include: 'content', id: 'crm', names: 'a,b', locale: 'zh-CN' }; + expect(metaListQueryRefusal('object', query)).toBeUndefined(); + expect(metaListQueryRefusal('object', {})).toBeUndefined(); + expect(metaListQueryRefusal('object', undefined)).toBeUndefined(); + }); + + it('leaves a bracketed spelling to its own located sentence only on the type whose step reads it', () => { + // `package[` on every type: the arity step's bracketed refusal answers it. + expect(metaListQueryRefusal('object', { 'package[]': 'crm' })?.rule).toBe('bracketed-parameter'); + // `id[` on `app` and `object[` on `view` are the chain's: recognition passes them on. + expect(metaListQueryRefusal('app', { 'id[]': 'crm' })).toBeUndefined(); + expect(metaListQueryRefusal('view', { 'object[0]': 'lead' })).toBeUndefined(); + // Anywhere else no step reads them, so they are names the list does not read. + expect(metaListQueryRefusal('object', { 'id[]': 'crm' })?.message).toBe(unknownListParam('id[]')); + expect(metaListQueryRefusal('app', { 'object[]': 'lead' })?.message).toBe(unknownListParam('object[]')); + expect(metaListQueryRefusal('object', { 'names[]': 'a' })?.message).toBe(unknownListParam('names[]')); + }); + + it('RestServer: an undeclared key answers the nested 400 before any protocol read', async () => { + for (const type of ['object', 'app', 'view']) { + const { protocol, status, body } = await driveList(type, { name: 'account' }); + expect({ type, status, body }).toEqual({ + type, status: 400, body: { error: { code: 'VALIDATION_ERROR', message: unknownListParam('name') } }, + }); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + expect(protocol.getMetaTypes).not.toHaveBeenCalled(); + } + }); + + it('RestServer: `?locale=zh-CN` and every other declared name still answer 200 (controls)', async () => { + for (const query of [{ locale: 'zh-CN' }, { names: 'account' }, { include: 'content' }, { preview: 'draft' }, {}]) { + const { protocol, status } = await driveList('object', query); + expect({ query, status }).toEqual({ query, status: 200 }); + expect(protocol.getMetaItems).toHaveBeenCalledTimes(1); + } + }); + + it('RestServer: `?names[]=` is refused by recognition; `?id[]=` keeps the app list\'s own sentence', async () => { + expect((await driveList('object', { 'names[]': 'account' })).body) + .toEqual({ error: { code: 'VALIDATION_ERROR', message: unknownListParam('names[]') } }); + const app = await driveList('app', { 'id[]': 'crm' }); + expect(app.status).toBe(400); + expect(JSON.stringify(app.body)).toContain("Query parameter 'id[]' names no app"); + }); + + // [contract review 6672872746] A `package` list item is keyed by id and carries no `name`, so + // `?names=` there could only ever answer `200 []`. It is refused instead, before any read. + it('refuses `?names=` on the `package` list with its own located sentence', () => { + expect(NAMES_ON_PACKAGE_LIST_MESSAGE).toBe( + "Query parameter 'names' is not served on the 'package' list: package items are keyed by id, not by name, " + + 'so no name could match. The list was not served. Leave the parameter out to list every package you can see.', + ); + for (const names of ['crm', 'crm,helpdesk', '', ['a', 'b']]) { + expect(metaListQueryRefusal('package', { names })).toEqual({ + status: 400, code: 'VALIDATION_ERROR', message: NAMES_ON_PACKAGE_LIST_MESSAGE, rule: 'names-on-package', + }); + } + // An undeclared key on the `package` list keeps the recognition sentence, which outranks it. + expect(metaListQueryRefusal('package', { names: 'crm', zeta: '1' })?.message).toBe(unknownListParam('zeta')); + // Controls: the `package` list without `names`, and `names` on every other type. + expect(metaListQueryRefusal('package', {})).toBeUndefined(); + expect(metaListQueryRefusal('package', { preview: 'draft' })).toBeUndefined(); + for (const type of ['object', 'app', 'view', 'flow']) expect(metaListQueryRefusal(type, { names: 'crm' })).toBeUndefined(); + }); + + it('RestServer: `?names=` on `/meta/package` answers the nested 400 before any protocol read', async () => { + const { protocol, status, body } = await driveList('package', { names: 'com.acme.crm' }); + expect({ status, body }).toEqual({ + status: 400, body: { error: { code: 'VALIDATION_ERROR', message: NAMES_ON_PACKAGE_LIST_MESSAGE } }, + }); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + // Control: the same door without `names` reaches the protocol read (this double declares no + // `package` type, so what answers then is the type check, not the names refusal). + const control = await driveList('package', {}); + expect(JSON.stringify(control.body)).not.toContain('keyed by id'); + expect(control.protocol.getMetaTypes).toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/domains/dispatcher-query-arity.test.ts b/packages/runtime/src/domains/dispatcher-query-arity.test.ts index 3c9fb74a396..c926b7a88d7 100644 --- a/packages/runtime/src/domains/dispatcher-query-arity.test.ts +++ b/packages/runtime/src/domains/dispatcher-query-arity.test.ts @@ -38,7 +38,13 @@ import { describe, it, expect, vi } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; -import { META_QUERY_ARITY_DOORS, assertFilterParamSuppliedOnce, repeatedQueryParamMessage } from '@objectstack/rest'; +import { + META_LIST_QUERY_PARAMS, + META_QUERY_ARITY_DOORS, + assertFilterParamSuppliedOnce, + repeatedQueryParamMessage, + unknownQueryParamRefusalMessage, +} from '@objectstack/rest'; import { HttpDispatcher } from '../http-dispatcher.js'; const SCHEMA = { @@ -123,7 +129,7 @@ function expectArityRefusal(answer: Answer, name: string, count = 2): void { describe('[#23214] /meta — the four arity doors, over the list both transports read', () => { it('declares exactly the doors and names RestServer refuses (the one list)', () => { expect(META_QUERY_ARITY_DOORS).toEqual({ - list: ['package', 'preview', 'object', 'include', 'id'], + list: ['package', 'preview', 'object', 'include', 'id', 'names'], 'book-tree': ['package'], drafts: ['packageId', 'type'], state: ['from'], @@ -194,6 +200,51 @@ describe('[#23214] /meta — the four arity doors, over the list both transports const { dispatcher } = boot(); expect((await get(dispatcher, '/meta/view', { locale: ['en', 'fr'] })).status).toBe(200); }); + + // [#29236] The list's closed query set, asked before arity, as + // `RestServer`'s list asks it (`metaListQueryRefusal`). + it('refuses a name outside the closed set with the located sentence, before any list read', async () => { + for (const name of ['name', 'ids', 'names[]']) { + const { dispatcher, protocol } = boot(); + const answer = await get(dispatcher, '/meta/view', { [name]: 'task_grid' }); + expect({ name, status: answer.status, code: answer.body?.error?.code, message: answer.body?.error?.message }).toEqual({ + name, status: 400, code: 'VALIDATION_ERROR', + message: unknownQueryParamRefusalMessage({ [name]: 'task_grid' }, META_LIST_QUERY_PARAMS), + }); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + } + }); + + it('`?names=` narrows the list and is pushed down to the protocol read; `?locale=` still answers 200', async () => { + const named = boot(); + const answer = await get(named.dispatcher, '/meta/view', { names: 'task_grid,nope' }); + expect(answer.status).toBe(200); + // This protocol double ignores `names`: the chain's step holds the answer. + expect(answer.body?.data.map((v: any) => v.name)).toEqual(['task_grid']); + expect(named.protocol.getMetaItems.mock.calls[0][0]).toMatchObject({ type: 'view', names: ['task_grid', 'nope'] }); + + const localised = boot(); + expect((await get(localised.dispatcher, '/meta/view', { locale: 'fr' })).status).toBe(200); + expect(localised.protocol.getMetaItems.mock.calls[0][0]).not.toHaveProperty('names'); + }); + + it('refuses `?names=` on the `package` list (its items are keyed by id) before the protocol read', async () => { + for (const path of ['/meta/package']) { + const { dispatcher, protocol } = boot(); + const answer = await get(dispatcher, path, { names: 'com.acme' }); + expect({ path, status: answer.status, code: answer.body?.error?.code }).toEqual({ path, status: 400, code: 'VALIDATION_ERROR' }); + expect(answer.body?.error?.message).toMatch(/^Query parameter 'names' is not served on the 'package' list: package items are keyed by id/); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + } + }); + + it('refuses an EMPTY `names` with 400 VALIDATION_ERROR before the protocol read', async () => { + const { dispatcher, protocol } = boot(); + const answer = await get(dispatcher, '/meta/view', { names: '' }); + expect({ status: answer.status, code: answer.body?.error?.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); + expect(answer.body?.error?.message).toMatch(/^Query parameter 'names' names no item; got ""\./); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + }); }); describe('GET /meta/book/:name/tree — one package scopes the book', () => { 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 4ac1b0e64ff..58124dd5d5d 100644 --- a/packages/runtime/src/domains/meta-list-projection-parity.test.ts +++ b/packages/runtime/src/domains/meta-list-projection-parity.test.ts @@ -54,7 +54,7 @@ import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; -import { RestServer } from '@objectstack/rest'; +import { META_LIST_QUERY_PARAMS, RestServer } from '@objectstack/rest'; import { TRANSLATABLE_METADATA_TYPES } from '@objectstack/spec/system'; import { pluralToSingular } from '@objectstack/spec/shared'; import { HttpDispatcher } from '../http-dispatcher.js'; @@ -548,6 +548,16 @@ const PARAM_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record { 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' } }, + // [#29236] The batch by-names filter, on every type: it narrows (apps, + // objects, views and docs each hold one of these names), an unknown name + // is absent, and an EMPTY one is refused (400 VALIDATION_ERROR). + { label: '?names=crm,invoice,lead_all,crm_intro', param: 'names', query: { names: 'crm,invoice,lead_all,crm_intro' } }, + { label: '?names=nope', param: 'names', query: { names: 'nope' } }, + { label: '?names= (empty)', param: 'names', query: { names: '' } }, + // …and the list's closed query set: a bracketed `names` key and an + // undeclared name are refused by recognition, never answered with the list. + { label: '?names[]=crm', param: 'names', query: { 'names[]': 'crm' } }, + { label: '?name=crm (undeclared)', query: { name: 'crm' } }, ]; const PARAM_AXIS = new Set(PARAM_PROBES.map((p) => p.param).filter((p): p is string => !!p)); @@ -635,7 +645,7 @@ function deriveListReads(): Derived { visit(rest); const chain = parse(SHARED_CHAIN_SOURCE); for (const s of chain.statements) { - if (ts.isFunctionDeclaration(s) && s.name && ['createMetaListAnswer', 'metaRequestLocale', 'translateMetaList'].includes(s.name.text)) { + if (ts.isFunctionDeclaration(s) && s.name && ['createMetaListAnswer', 'metaRequestLocale', 'translateMetaList', 'metaListNames'].includes(s.name.text)) { readsUnder(chain, s, derived); } } @@ -654,6 +664,10 @@ describe('[#20320] the census axes cover every read of RestServer\'s list handle expect(missing, 'a list read of a parameter this census never sends: add a probe that moves its answer').toEqual([]); }); + it('[#29236] the list\'s closed query set is exactly what it reads — no name refused that it reads, none admitted that it ignores', () => { + expect([...META_LIST_QUERY_PARAMS].sort()).toEqual([...derived.params].sort()); + }); + it('every type a projection keys on, and every translatable type, is a census cell', () => { const wanted = new Set([...derived.types, ...TRANSLATABLE_METADATA_TYPES]); const missing = [...wanted].filter((t) => !TYPE_AXIS.has(t)).sort(); @@ -705,6 +719,14 @@ describe('[#20320] row A: the reference moves — each probe changes RestServer\ 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 }); + // [#29236] `?names=` narrows every type's list; an unknown name is absent; empty is 400. + expect(names((await read('/meta/app', { names: 'helpdesk,crm' })).items)).toEqual(['crm', 'helpdesk']); + expect(names((await read('/meta/objects', { names: 'invoice' })).items)).toEqual(['invoice']); + expect((await read('/meta/view', { names: 'nope' })).items).toEqual([]); + const blankNames = await read('/meta/view', { names: '' }); + expect({ status: blankNames.status, code: blankNames.code, items: blankNames.items }).toEqual({ status: 400, code: 'VALIDATION_ERROR', items: undefined }); + const undeclared = await read('/meta/view', { name: 'lead_all' }); + expect({ status: undeclared.status, code: undeclared.code, items: undeclared.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'); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index b507d24bfcb..61defce263f 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -84,6 +84,8 @@ import { metaItemLayersDeprecationHeaders, metaItemPackageBinding, metaItemQueryRefusal, + metaListNames, + metaListQueryRefusal, metaMemberSelector, metaQueryArityRefusal, metaRequestLocale, @@ -2174,10 +2176,15 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 1) { const typeOrName = parts[0]; // [#23214] Before any read of the list's query, as `RestServer`'s list - // asks it: `package`, `preview`, `object`, `include` and `id` are each - // one value, and a repeat would scope nothing, select no view, or drop - // a filter and answer the whole list. - const arity = metaQueryArityRefusal('list', query); + // asks it: `package`, `preview`, `object`, `include`, `id` and + // [#29236] `names` are each one value, and a repeat would scope + // nothing, select no view, or drop a filter and answer the whole list. + // [#29236] …and, first, RECOGNITION over the list's closed set + // (`META_LIST_QUERY_PARAMS`): a name the list does not read is a + // located 400, never the whole list with a 200. One decision for both + // transports (`metaListQueryRefusal`). + const listType = pluralToSingular(typeOrName); + const arity = metaListQueryRefusal(listType, query); if (arity) return queryRefusalAnswer(deps, arity); // Extract optional package filter from query string. // [#22188] `RestServer`'s reading: `all`, the list's "show everything" @@ -2207,7 +2214,6 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // the store does not know, and a chain fault must be answered as itself // — never fall through to the next store as though the first had not // answered. - const listType = pluralToSingular(typeOrName); // [#9488 · #20408] A segment that names no metadata type is REFUSED — // `400 INVALID_REQUEST`, the one refusal `RestServer`'s list answers — @@ -2236,13 +2242,26 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin && mayReadPendingDrafts(_context.executionContext); const answerList = (data: any) => answerMetaList(deps, _context, protocol, listType, query, previewDrafts, data); + // [#29236] `?names=a,b`, read as `RestServer`'s list reads it — an + // empty one refused before any store read — and pushed down to the + // protocol's list read, so it filters before it decorates. The chain + // narrows by the same reading, which also holds the answer of the + // metadata service and registry exits below, which take no names. + let names: readonly string[] | undefined; + try { + names = metaListNames(query); + } catch (e: any) { + return { handled: true, response: deps.errorFromThrown(e, 400) }; + } let listed: any; if (protocol && typeof protocol.getMetaItems === 'function') { try { // [ADR-0131 D6] No organization: environment → code, the // partition `RestServer`'s list reads. - const data = await protocol.getMetaItems({ type: typeOrName, packageId, previewDrafts }); + const data = await protocol.getMetaItems({ + type: typeOrName, packageId, previewDrafts, ...(names ? { names: [...names] } : {}), + }); // Return any valid response from protocol (including empty items arrays) if (data && (data.items !== undefined || Array.isArray(data))) listed = data; } catch (e: any) { diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 0f17294d7a9..3f7753f329a 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -1930,14 +1930,6 @@ describe('GetMetaItemsRequestSchema declares `names` — the batch by-names list it('types names as an optional string list, input and output alike', () => { expectTypeOf().toEqualTypeOf(); }); - - it('is declared ahead of its producer: the describe opens with the planned marker, never a tracker id', () => { - // No list door reads `names` yet; the change that serves it drops the - // marker and this pin with it. - const description = GetMetaItemsRequestSchema.shape.names.description ?? ''; - expect(description).toMatch(/^\[PLANNED — not yet served\] /); - expect(description).not.toMatch(/#\d{3,}/); - }); }); describe('MetadataProtocol declares getMetaItemLayered', () => { diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index a49f085dd09..413576ab528 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -259,10 +259,10 @@ export const GetMetaTypesResponseSchema = lazySchema(() => z.object({ * empty list. A minimum, not a refinement, so the published JSON Schema * states it. * - * **Declared ahead of its producer.** No list door reads `names` yet, so its - * describe opens `[PLANNED — not yet served]`; the server PR on #29236 drops - * the marker in the change that serves the member (the pin is in - * `protocol.test.ts`). + * **Served.** Both list doors (the REST server's and the runtime + * dispatcher's `GET /meta/:type`) read `?names=` and push it down here (#29236), + * on every type except `package`: a `package` list item is keyed by its id and + * carries no `name`, so the `package` list refuses `?names=` with `400`. */ export const GetMetaItemsRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name (e.g., "object", "plugin")'), @@ -271,12 +271,12 @@ export const GetMetaItemsRequestSchema = lazySchema(() => z.object({ .min(1, 'Name at least one item, or leave `names` out to list every item') .optional() .describe( - '[PLANNED — not yet served] Item names to narrow the list to: only the named items of the type ' - + 'are answered, among those the list would answer without it. Sent over HTTP as one ' + 'Item names to narrow the list to: only the named items of the type ' + + 'are answered, among those the list would answer without it. Served on every type except ' + + '`package`, whose items are keyed by id; the `package` list refuses it with 400. Sent over HTTP as one ' + 'comma-separated value (`?names=a,b,c`). A name that matches no item is simply absent from the ' + 'answer (no 404). An empty value is refused with 400 rather than read as every item; leave the ' - + 'key out to list every item. No list door reads it yet: until one does, a request carrying it ' - + 'is answered as if it were absent.', + + 'key out to list every item.', ), previewDrafts: z.boolean().optional().describe( 'Draft-visibility switch (ADR-0033 draft-overlay preview): when true, '