diff --git a/.changeset/23214-dispatcher-query-arity.md b/.changeset/23214-dispatcher-query-arity.md new file mode 100644 index 00000000000..2543af5a454 --- /dev/null +++ b/.changeset/23214-dispatcher-query-arity.md @@ -0,0 +1,30 @@ +--- +'@objectstack/rest': minor +'@objectstack/runtime': minor +--- + +fix(runtime)!: the runtime dispatcher's read points refuse a repeated single-valued query name as their `RestServer` twins do, instead of reading one value, a joined value or an array + +The runtime dispatcher's `/meta`, `/ui`, `/i18n`, `/security`, `/packages` and `/data` read points each read a query name as ONE value. None of them guarded against the name arriving twice, because the only door in front of most of them, the `@objectstack/hono` `createHonoApp` catch-all, has always flattened a repeat to its last value. Handed an array, they answered wrongly: `GET /meta/object/:name/state/:field?from=a&from=b` answered a dead end for the joined state `'a,b'`; `_drafts`, `/ui/view` and the `/data` filter slot passed the array into the protocol call; both `/i18n` query-locale arms threw, which surfaced as a `500`; and the `/meta` list, the book tree, `/security/suggested-bindings` and `/packages` answered an empty or unscoped `200`. Each now refuses the repeat before any protocol or service call. + +`@objectstack/rest` publishes the lists and rules both transports read, so the two cannot drift: `META_QUERY_ARITY_DOORS` (the single-valued names of the `/meta` list, book tree, `_drafts` and state machine doors, which `RestServer`'s twins of those doors now read from), the decision `metaQueryArityRefusal(door, query)`, and `assertFilterParamSuppliedOnce`, the `/data` list's filter-slot rule, on the package entry. + +Clause-②: yes (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +What changes for you: a request that repeats one of the names below to the runtime dispatcher is now refused `400` in the dispatcher's own envelope, `{ success: false, error: { code, message } }`, instead of being answered with one value, a joined value or an array. The `code` is `VALIDATION_ERROR` with the sentence `RestServer` answers ("The "NAME" query parameter was supplied N times…"), except on the `/data` filter slot, which answers `INVALID_FILTER` with `RestServer`'s repeated-filter sentence. Send each name once. + +- `GET /meta/:type`: `package`, `preview`, `object`, `include`, `id`. +- `GET /meta/book/:name/tree`: `package`. +- `GET /meta/_drafts`: `packageId`, `type`. +- `GET /meta/object/:name/state/:field`: `from`. +- `GET /ui/view/:object`: `type`, when the path names no type. +- `GET /i18n/translations` and `GET /i18n/labels/:object`: `locale`, when the path names no locale. +- `GET /security/suggested-bindings`: `status`, `packageId`. A repeated `status` was already refused `400`, as an unknown status; it now gets the multiplicity sentence. +- `GET /packages`: `status`, `type`, beside `enabled`, which was already refused. +- `GET /data/:object`: every spelling of the filter slot (`filter`, `where` and their aliases, `filters`, `$filter`). Genuinely multi-valued names such as `select` and `expand` are untouched. + +A name sent once is answered as before, and one sent once as a one-element array now reads as that one value everywhere. Where a caller meets this today: `GET /packages` through `@objectstack/plugin-hono-server`, whose adapter already keeps repeats as arrays. Every other point is reached on a shipped host only through `createHonoApp`, which still flattens a repeat to its last value, so until that adapter keeps repeats the refusals are visible only to a caller of `HttpDispatcher.dispatch()` that passes an array. The `@objectstack/client` SDK sends each of these names once. diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index 2e95097c630..3b1389e75f9 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -67,7 +67,13 @@ export type { ExportFieldMeta } from './export-format.js'; // `@objectstack/runtime`'s `buildApiError` adds (measured on #17672 — the gate's // body fails `BaseResponseSchema` with `success is missing, must be a boolean`). // ⛔ A dispatcher domain takes the message and builds its own body. -export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-multiplicity.js'; +// +// [#23214] `assertFilterParamSuppliedOnce` is the `/data` list's filter-slot +// arity rule (#7390), published so the runtime dispatcher's `/data` domain asks +// THIS rule rather than a copy. It throws instead of writing a body, so a +// dispatcher domain catches it and writes the `code` and message it carries in +// its own envelope. +export { assertFilterParamSuppliedOnce, refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-multiplicity.js'; // [#20193] THE per-caller read gate of one `/meta/:type/:name` document — the // ADR-0046 §6.7 docs audience, the app nav filter (`requiredPermissions`, the @@ -130,6 +136,11 @@ export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-mu // as DATA that each transport writes in its own envelope — with the member // selector's one reading beside it (`metaMemberSelector`). The sets and their // sentences travel inside the decision, never separately. +// +// [#23214] …and the arity of the two `/meta` doors that are not by-name (the +// list and the book tree): one list per door (`META_QUERY_ARITY_DOORS`), which +// `RestServer` hands `refuseRepeatedQueryParams` and the dispatcher asks +// through `metaQueryArityRefusal`. export { createMetaBookTreeAnswer, createMetaItemAnswer, @@ -144,6 +155,8 @@ export { metaItemLayersDeprecationHeaders, metaItemQueryRefusal, metaMemberSelector, + META_QUERY_ARITY_DOORS, + metaQueryArityRefusal, metaRequestLocale, metaTypeReadRefusal, metaTypeWriteRefusal, diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 41952c07b37..93e97830955 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -2344,21 +2344,8 @@ export function metaItemQueryRefusal(door: MetaItemQueryDoor, query: unknown): M if (unknownMessage !== undefined) { return { status: 400, code: 'VALIDATION_ERROR', message: unknownMessage, rule: 'unknown-parameter' }; } - const record = query as Record; - for (const name of once) { - const raw = record[name]; - if (!Array.isArray(raw)) continue; - const read = readSingleQueryValue(raw as string[]); - if (!read.ok) { - return { - status: 400, - code: 'VALIDATION_ERROR', - message: repeatedQueryParamMessage(name, read.count), - rule: 'repeated-parameter', - }; - } - record[name] = read.value; - } + const repeated = repeatedOnceRefusal(query as Record, once); + if (repeated) return repeated; } if (door === 'item' && wantsMetaItemLayers(query as Readonly> | undefined) @@ -2368,6 +2355,87 @@ export function metaItemQueryRefusal(door: MetaItemQueryDoor, query: unknown): M return undefined; } +/** + * The arity rule (`refuseRepeatedQueryParams`'s), as DATA: the first name of + * `once` supplied more than once is refused with the multiplicity sentence, and + * a name supplied once as a one-element array (or not at all, as an empty one) + * is unwrapped IN PLACE, so every read after this one sees the string. One + * implementation for {@link metaItemQueryRefusal}'s rule 2 and + * {@link metaQueryArityRefusal}. + */ +function repeatedOnceRefusal( + record: Record, + once: readonly string[], +): MetaItemQueryRefusal | undefined { + for (const name of once) { + const raw = record[name]; + if (!Array.isArray(raw)) continue; + const read = readSingleQueryValue(raw as string[]); + if (!read.ok) { + return { + status: 400, + code: 'VALIDATION_ERROR', + message: repeatedQueryParamMessage(name, read.count), + rule: 'repeated-parameter', + }; + } + record[name] = read.value; + } + return undefined; +} + +/** + * [#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. + * - `list` — `GET /meta/:type`; + * - `book-tree` — `GET /meta/book/:name/tree`; + * - `drafts` — `GET /meta/_drafts`; + * - `state` — `GET /meta/object/:name/state/:field`, the state machine read. + */ +export type MetaQueryArityDoor = 'list' | 'book-tree' | 'drafts' | 'state'; + +/** + * [#23214] Each door's single-valued names, as ONE spelling for both + * transports. `RestServer`'s door hands its list to `refuseRepeatedQueryParams`, + * and the runtime dispatcher's twin asks {@link metaQueryArityRefusal} over + * it, so a name one transport starts reading once reaches the other too. + * + * - `list`: `?package=` scopes the read, `?preview=` is the draft overlay, + * `?object=` is the view switcher's one object, `?include=` the doc list's + * 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. + * - `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. + * - `state`: `?from=` is ONE current state; a repeat became the joined name + * `'a,b'`, which matches no transition key, so the answer was a dead end. + * + * 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']), + 'book-tree': Object.freeze(['package']), + drafts: Object.freeze(['packageId', 'type']), + state: Object.freeze(['from']), +}); + +/** + * [#23214] The arity decision of a `/meta` {@link MetaQueryArityDoor}, as DATA + * each transport writes in its own envelope. It returns the first name of the + * door's {@link META_QUERY_ARITY_DOORS} entry that was supplied more than once, + * as `400 VALIDATION_ERROR` with `repeatedQueryParamMessage`'s sentence, which + * is the answer `RestServer`'s door gives through `refuseRepeatedQueryParams`. + * It returns `undefined` otherwise, and has then unwrapped a one-element array + * in place. ⛔ Ask it before any read of the door's query. + */ +export function metaQueryArityRefusal(door: MetaQueryArityDoor, query: unknown): MetaItemQueryRefusal | undefined { + if (!query || typeof query !== 'object') return undefined; + return repeatedOnceRefusal(query as Record, META_QUERY_ARITY_DOORS[door]); +} + /** * [#22877] THE reading of the bundle member selector on every by-name `/meta` * door, as the spread each door's protocol request takes: `{ member }` when diff --git a/packages/rest/src/meta-query-arity-doors.test.ts b/packages/rest/src/meta-query-arity-doors.test.ts new file mode 100644 index 00000000000..66538565529 --- /dev/null +++ b/packages/rest/src/meta-query-arity-doors.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23214 slice 1] ONE list per `/meta` arity door, read by both transports. + * + * `META_QUERY_ARITY_DOORS` holds the single-valued names of the four `/meta` + * doors whose only query rule is arity: the list, the book tree, `_drafts` and + * the state machine read. `RestServer` hands each list to + * `refuseRepeatedQueryParams`, and the runtime dispatcher's twin asks + * `metaQueryArityRefusal` over it (`packages/runtime/src/domains/ + * dispatcher-query-arity.test.ts` pins that side). Before this, each list was + * spelled inline in `rest-server.ts` and nowhere else, so the dispatcher had + * none: a repeat reached its read points as an array. + * + * This file pins the decision itself, and that `RestServer` refuses exactly the + * names the shared list declares, door by door. `RestServer`'s answers did not + * move when the lists did: a BASE/HEAD differential of every name on all four + * doors (two and three repeats, a one-element array, an empty array, one value, + * two repeated names, an undeclared repeat) was byte-identical. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { META_QUERY_ARITY_DOORS, metaQueryArityRefusal, type MetaQueryArityDoor } from './meta-item-read-gate.js'; +import { repeatedQueryParamMessage } from './query-multiplicity.js'; +import { RestServer } from './rest-server.js'; + +const DOORS = Object.keys(META_QUERY_ARITY_DOORS) as MetaQueryArityDoor[]; + +describe('metaQueryArityRefusal — the arity rule as data', () => { + it.each(DOORS.flatMap((door) => META_QUERY_ARITY_DOORS[door].map((name) => [door, name] as const)))( + '%s door: a repeated `%s` is refused with the multiplicity sentence', + (door, name) => { + expect(metaQueryArityRefusal(door, { [name]: ['a', 'b', 'c'] })).toEqual({ + status: 400, + code: 'VALIDATION_ERROR', + message: repeatedQueryParamMessage(name, 3), + rule: 'repeated-parameter', + }); + }, + ); + + it('unwraps one occurrence IN PLACE, reads an empty array as none, and leaves strings alone', () => { + const query: Record = { package: ['com.acme'], preview: [], object: 'task' }; + expect(metaQueryArityRefusal('list', query)).toBeUndefined(); + expect(query).toEqual({ package: 'com.acme', preview: undefined, object: 'task' }); + }); + + it('never touches a name its door does not declare', () => { + const query: Record = { locale: ['en', 'fr'], select: ['a', 'b'] }; + expect(metaQueryArityRefusal('list', query)).toBeUndefined(); + expect(query).toEqual({ locale: ['en', 'fr'], select: ['a', 'b'] }); + }); + + it('answers two repeated names for the first one the door declares', () => { + expect(metaQueryArityRefusal('list', { id: ['a', 'b'], package: ['c', 'd'] })?.message) + .toBe(repeatedQueryParamMessage('package', 2)); + }); + + it('carries no name in a query that is not an object', () => { + expect(metaQueryArityRefusal('state', undefined)).toBeUndefined(); + expect(metaQueryArityRefusal('state', 'from=a')).toBeUndefined(); + }); +}); + +function mockRes() { + const res: any = { + statusCode: 200, + json: vi.fn(function (this: any) { return this; }), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + header: vi.fn(function (this: any) { return this; }), + setHeader: vi.fn(function (this: any) { return this; }), + send: vi.fn(function (this: any) { return this; }), + end: vi.fn(function (this: any) { return this; }), + }; + return res; +} + +/** Each door's route on a `RestServer` whose protocol records every read. */ +function bootRest() { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn().mockResolvedValue({ types: ['view', 'book', 'doc'] }), + getMetaItems: vi.fn().mockResolvedValue({ items: [] }), + getMetaItem: vi.fn().mockResolvedValue({ type: 'book', name: 'handbook', item: { name: 'handbook' }, lock: 'none' }), + listDrafts: vi.fn().mockResolvedValue({ items: [] }), + }; + 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); + // `isSystem` clears the capability gates that run before the query is read. + (rest as any).resolveExecCtx = async () => ({ isSystem: true, userId: 'u1' }); + rest.registerRoutes(); + const route = (path: string) => (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === path); + return { protocol, route }; +} + +const DOOR_ROUTES: Record }> = { + list: { path: '/api/v1/meta/:type', params: { type: 'view' } }, + 'book-tree': { path: '/api/v1/meta/book/:name/tree', params: { name: 'handbook' } }, + drafts: { path: '/api/v1/meta/_drafts', params: {} }, + state: { path: '/api/v1/meta/object/:name/state/:field', params: { name: 'task', field: 'status' } }, +}; + +async function drive(door: MetaQueryArityDoor, query: Record) { + const { protocol, route } = bootRest(); + const { path, params } = DOOR_ROUTES[door]; + const res = mockRes(); + await route(path).handler({ method: 'GET', path, params, query, headers: {}, body: {} }, res); + return { protocol, status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] }; +} + +describe('RestServer refuses exactly the names the shared list declares, door by door', () => { + it.each(DOORS.flatMap((door) => META_QUERY_ARITY_DOORS[door].map((name) => [door, name] as const)))( + '%s door: a repeated `%s` answers the nested 400 before any protocol read', + async (door, name) => { + const { protocol, status, body } = await drive(door, { [name]: ['a', 'b'] }); + expect(status).toBe(400); + expect(body).toEqual({ error: { code: 'VALIDATION_ERROR', message: repeatedQueryParamMessage(name, 2) } }); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + expect(protocol.listDrafts).not.toHaveBeenCalled(); + }, + ); + + it('preserves one value on each door: it reaches the protocol as the string', async () => { + expect((await drive('list', { package: ['com.acme'] })).protocol.getMetaItems.mock.calls[0][0]) + .toMatchObject({ packageId: 'com.acme' }); + expect((await drive('book-tree', { package: 'com.acme' })).protocol.getMetaItems.mock.calls[0][0]) + .toMatchObject({ packageId: 'com.acme' }); + expect((await drive('drafts', { packageId: 'com.acme', type: ['view'] })).protocol.listDrafts) + .toHaveBeenCalledWith({ packageId: 'com.acme', type: 'view' }); + // The state read reaches its engine seam with one `from`; this RestServer + // wires no engine, so the honest answer is the not-found it always gave. + expect((await drive('state', { from: 'todo' })).status).not.toBe(400); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index fb7a8dda55f..9a625e8126c 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -5858,7 +5858,9 @@ export class RestServer { } // [#6877] Both narrow the draft list to one package / // one type; an array reached `listDrafts` untouched. - if (refuseRepeatedQueryParams(req, res, ['packageId', 'type'])) 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.drafts)) return; // [ADR-0131 D6] No organization: a draft is saved // environment-wide, so the list reads the environment // partition, symmetric with the save door. A legacy @@ -6010,7 +6012,9 @@ export class RestServer { // against. Refused, not resolved — see // `query-multiplicity.ts` for why picking one of two // conflicting intents is worse than a 400. - if (refuseRepeatedQueryParams(req, res, ['package', 'preview', 'object', 'include', 'id'])) 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; // [#22188] Read through {@link metaItemPackageBinding}: // `all`, this list's "show everything" scope, names no package. const packageId = metaItemPackageBinding(req.query?.package); @@ -6284,8 +6288,9 @@ export class RestServer { try { const environmentId = isScoped ? req.params?.environmentId : undefined; const prot = await this.resolveProtocol(environmentId, req); - // [#6877] One package scopes the book lookup. - if (refuseRepeatedQueryParams(req, res, ['package'])) return; + // [#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; // [#22188] Read through {@link metaItemPackageBinding}: `all` names no package. const packageId = metaItemPackageBinding(req.query?.package); // [#20408] The route's whole answer is @@ -8222,7 +8227,9 @@ export class RestServer { // [#6877 shape] `?from=` narrows to ONE current state; // an array would reach `legalNextStates` as a // stringified pair and match no transition key. - if (refuseRepeatedQueryParams(req, res, ['from'])) return; + // [#23214] The list lives beside the shared decision, so the + // runtime dispatcher's twin refuses the same name. + if (refuseRepeatedQueryParams(req, res, metaReadGate.META_QUERY_ARITY_DOORS.state)) return; const from = req.query?.from !== undefined ? String(req.query.from) : undefined; // [#15405] The engine seam, reached the way its SIBLING at // `computeExecCtx` already reaches it — `wiredEngineOrLoud` diff --git a/packages/runtime/src/domains/data.ts b/packages/runtime/src/domains/data.ts index e94e88b75de..a5af09c1c77 100644 --- a/packages/runtime/src/domains/data.ts +++ b/packages/runtime/src/domains/data.ts @@ -18,6 +18,9 @@ import * as actionExec from '../action-execution.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +// [#23214] The `/data` list's filter-slot arity rule (#7390), the one +// `RestServer`'s list asks, imported rather than restated. +import { assertFilterParamSuppliedOnce } from '@objectstack/rest'; export function createDataDomain(deps: DomainHandlerDeps): DomainRoute { return { @@ -139,8 +142,22 @@ export async function handleDataRequest(deps: DomainHandlerDeps, path: string, m // condition #3713 described. One fold, one answer; a second copy // here could only agree by inspection. // + // [#23214] …with ONE exception, which is not a fold: the FILTER + // slot's arity. A filter AST is an array too, so the normalizer + // cannot tell a repeated `?filter=` from one AST; only the + // querystring layer can, and `RestServer`'s list asks + // `assertFilterParamSuppliedOnce` for exactly that reason. It is + // asked here on the copy `findData` receives (it unwraps a + // one-element array in place), and its throw is answered in this + // transport's envelope with the code and sentence it carries. + const listQuery = { ...query }; + try { + assertFilterParamSuppliedOnce(listQuery); + } catch (e: any) { + return { handled: true, response: deps.error(e.message, 400, { code: e.code }) }; + } // Spec: returns FindDataResponse = { object, records, total?, hasMore? } - const result = await actionExec.callData(deps, _context, 'query', { object: objectName, query: { ...query } }, _context.dataDriver, _context.environmentId, _context.executionContext); + const result = await actionExec.callData(deps, _context, 'query', { object: objectName, query: listQuery }, _context.dataDriver, _context.environmentId, _context.executionContext); return { handled: true, response: deps.success(result) }; } diff --git a/packages/runtime/src/domains/dispatcher-query-arity.test.ts b/packages/runtime/src/domains/dispatcher-query-arity.test.ts new file mode 100644 index 00000000000..be53fa13fff --- /dev/null +++ b/packages/runtime/src/domains/dispatcher-query-arity.test.ts @@ -0,0 +1,321 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23214 slice 1] The runtime dispatcher's read points refuse a REPEATED + * single-valued query name with the answer their `RestServer` twins give, + * instead of reading an array. + * + * ## Why these points, and why now + * + * `@objectstack/hono`'s `createHonoApp` catch-all is the only door in front of + * these dispatcher read points, and it has always handed `dispatch()` a + * repeated name as its LAST value. So none of them ever met an array, and none + * guards against one. The adapter is about to keep repeats as arrays, as + * `plugin-hono-server`'s `readQuery` does (#23214 slice 2). Measured by driving + * `dispatch()` with that array shape before this change: + * + * | point | the answer to a repeat | + * |---|---| + * | `GET /meta/object/:name/state/:field?from=` | `200`, `from: 'done,todo'`, `next: []`: a dead end for a state that does not exist | + * | `GET /meta/_drafts?packageId=` / `?type=` | the array reached `listDrafts` | + * | `GET /meta/:type?object=` (the view switcher) | `200 []` | + * | `GET /meta/:type?package=` (and `preview`, `id`, `include`), `GET /meta/book/:name/tree?package=` | read as ABSENT: an unscoped list of every package | + * | `GET /ui/view/:object?type=` | the array reached `getUiView` | + * | `GET /i18n/translations?locale=`, `GET /i18n/labels/:object?locale=` | a `TypeError` in `resolveLocale`, so a `500` | + * | `GET /security/suggested-bindings?packageId=` / `?status=` | a joined package no row has (`200 []`), and an "unknown status" refusal | + * | `GET /packages?status=` / `?type=` | `===` against an array: `200` with no packages | + * | `GET /data/:object?filter=` | the array reached `findData`, where a filter AST is an array too | + * + * Each now answers `400` with its twin's code and sentence. AGENTS.md route + * rule 5: both halves per route, a REFUSAL pin (status, the nested + * `error.code`, the sentence, and the service never called) beside a + * PRESERVATION pin (the arguments the service received for one value). + * + * The four `/meta` doors' names are read from `META_QUERY_ARITY_DOORS`, the + * one list `RestServer` reads too, so a name added there is pinned here + * without a second spelling. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { META_QUERY_ARITY_DOORS, assertFilterParamSuppliedOnce, repeatedQueryParamMessage } from '@objectstack/rest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const SCHEMA = { + name: 'task', + label: 'Task', + fields: { id: { type: 'text' }, status: { type: 'text' } }, + validations: [{ + type: 'state_machine', name: 'task_status_fsm', field: 'status', + transitions: { todo: ['backlog', 'in_progress'], done: [] }, + }], +}; + +const VIEWS = [ + { name: 'task_grid', viewKind: 'grid', object: 'task' }, + { name: 'task_form', viewKind: 'form', object: 'task' }, + { name: 'account_grid', viewKind: 'grid', object: 'account' }, +]; + +/** Admitted everywhere these doors gate: the draft doors and `/packages` included. */ +const AUTHOR = { userId: 'u_author', isSystem: false, systemPermissions: ['manage_metadata', 'studio.access'] }; + +/** The dispatcher exactly as `createHonoApp` builds it, over recording service doubles. */ +function boot() { + const protocol = { + getMetaTypes: vi.fn(async () => ({ types: ['app', 'view', 'doc', 'book', 'object'] })), + getMetaItems: vi.fn(async (r: any) => (r?.type === 'view' ? VIEWS.map((v) => ({ ...v })) : [])), + listDrafts: vi.fn(async (_r: any) => ({ items: [] })), + getUiView: vi.fn(async (r: any) => ({ object: r.object, type: r.type })), + findData: vi.fn(async (r: any) => ({ object: r.object, records: [] })), + }; + const i18n = { + getLocales: vi.fn(() => ['en', 'fr']), + getDefaultLocale: vi.fn(() => 'en'), + getTranslations: vi.fn((_l: unknown) => ({ greeting: 'bonjour' })), + getFieldLabels: vi.fn((_o: string, _l: unknown) => ({})), + }; + const security = { + resolvePermissionSetNames: async () => [], + getMetadataReadableFields: async () => undefined, + listAudienceBindingSuggestions: vi.fn(async (_ec: unknown, _r: unknown) => []), + }; + const getObject = vi.fn((n: string) => (n === 'task' ? SCHEMA : undefined)); + const services: Record = { protocol, i18n, security, objectql: { registry: { getObject } } }; + const get = (n: string) => services[n] ?? null; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + // The seam `dispatch()` resolves identity through (as the sibling dispatch-level pins do). + (dispatcher as any).timedResolveExecutionContext = async () => ({ ...AUTHOR }); + return { dispatcher, protocol, i18n, security, getObject }; +} + +/** `/packages` over a REAL registry: two packages whose `status` and `type` differ. */ +function bootPackages() { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + (registry as any).logLevel = 'silent'; + registry.installPackage({ id: 'com.acme.app', name: 'com.acme.app', namespace: 'app', version: '1.0.0', type: 'app' } as any); + registry.installPackage({ id: 'com.acme.plugin', name: 'com.acme.plugin', namespace: 'plugin', version: '1.0.0', type: 'plugin' } as any); + (registry.getPackage('com.acme.plugin') as any).status = 'error'; + const getAllPackages = vi.spyOn(registry, 'getAllPackages'); + const get = (n: string) => (n === 'objectql' ? { registry } : null); + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + (dispatcher as any).timedResolveExecutionContext = async () => ({ ...AUTHOR }); + return { dispatcher, getAllPackages }; +} + +interface Answer { status: number; body: any } + +async function get(dispatcher: HttpDispatcher, path: string, query: Record = {}): Promise { + const res = await dispatcher.dispatch('GET', path, undefined, query, { request: { headers: {} } } as any); + return { status: res.response?.status ?? (res.handled ? 200 : 0), body: res.response?.body }; +} + +/** The multiplicity refusal, in the dispatcher's envelope, with the twin's code and sentence. */ +function expectArityRefusal(answer: Answer, name: string, count = 2): void { + expect(answer.status, `expected 400 for a repeated "${name}", got ${JSON.stringify(answer)}`).toBe(400); + expect(answer.body?.success).toBe(false); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toBe(repeatedQueryParamMessage(name, count)); +} + +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'], + 'book-tree': ['package'], + drafts: ['packageId', 'type'], + state: ['from'], + }); + }); + + describe('GET /meta/object/:name/state/:field — `from` is ONE current state', () => { + it('refuses a repeated `from` before the object is looked up', async () => { + const { dispatcher, getObject } = boot(); + const answer = await get(dispatcher, '/meta/object/task/state/status', { from: ['done', 'todo'] }); + expectArityRefusal(answer, 'from'); + expect(getObject).not.toHaveBeenCalled(); + }); + + it('preserves one `from` (and one encoded as a one-element array)', async () => { + for (const from of ['todo', ['todo']]) { + const { dispatcher } = boot(); + const answer = await get(dispatcher, '/meta/object/task/state/status', { from }); + expect(answer.status).toBe(200); + expect(answer.body?.data).toEqual({ object: 'task', field: 'status', from: 'todo', next: ['backlog', 'in_progress'] }); + } + }); + }); + + describe('GET /meta/_drafts — one package, one type', () => { + it.each(META_QUERY_ARITY_DOORS.drafts)('refuses a repeated `%s` before `listDrafts`', async (name) => { + const { dispatcher, protocol } = boot(); + const answer = await get(dispatcher, '/meta/_drafts', { [name]: ['a', 'b'] }); + expectArityRefusal(answer, name); + expect(protocol.listDrafts).not.toHaveBeenCalled(); + }); + + it('preserves one of each, handed to `listDrafts` as strings', async () => { + const { dispatcher, protocol } = boot(); + const answer = await get(dispatcher, '/meta/_drafts', { packageId: 'com.acme', type: ['view'] }); + expect(answer.status).toBe(200); + expect(protocol.listDrafts).toHaveBeenCalledWith({ packageId: 'com.acme', type: 'view' }); + }); + }); + + describe('GET /meta/:type — the list', () => { + it.each(META_QUERY_ARITY_DOORS.list)('refuses a repeated `%s` before any list read', async (name) => { + const { dispatcher, protocol } = boot(); + const answer = await get(dispatcher, '/meta/view', { [name]: ['a', 'b'] }); + expectArityRefusal(answer, name); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + }); + + it('names the count it saw, and answers two repeated names for the first declared', async () => { + const three = await get(boot().dispatcher, '/meta/view', { object: ['a', 'b', 'c'] }); + expectArityRefusal(three, 'object', 3); + const two = await get(boot().dispatcher, '/meta/view', { id: ['a', 'b'], package: ['c', 'd'] }); + expectArityRefusal(two, 'package'); + }); + + it('preserves one `package` (it scopes the read) and one `object` (the view switcher)', async () => { + const scoped = boot(); + expect((await get(scoped.dispatcher, '/meta/view', { package: ['com.acme'] })).status).toBe(200); + expect(scoped.protocol.getMetaItems.mock.calls[0][0]).toMatchObject({ type: 'view', packageId: 'com.acme' }); + + const switched = boot(); + const answer = await get(switched.dispatcher, '/meta/view', { object: 'task' }); + expect(answer.status).toBe(200); + expect(answer.body?.data.map((v: any) => v.name).sort()).toEqual(['task_form', 'task_grid']); + }); + + it('leaves a name it does not declare alone (`locale` falls back to the default locale)', async () => { + const { dispatcher } = boot(); + expect((await get(dispatcher, '/meta/view', { locale: ['en', 'fr'] })).status).toBe(200); + }); + }); + + describe('GET /meta/book/:name/tree — one package scopes the book', () => { + it.each(META_QUERY_ARITY_DOORS['book-tree'])('refuses a repeated `%s` before the book is read', async (name) => { + const { dispatcher, protocol } = boot(); + const answer = await get(dispatcher, '/meta/book/handbook/tree', { [name]: ['a', 'b'] }); + expectArityRefusal(answer, name); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + }); + + it('preserves one `package`, handed to the book read', async () => { + const { dispatcher, protocol } = boot(); + await get(dispatcher, '/meta/book/handbook/tree', { package: 'com.acme' }); + expect(protocol.getMetaItems).toHaveBeenCalled(); + expect(protocol.getMetaItems.mock.calls[0][0]).toMatchObject({ packageId: 'com.acme' }); + }); + }); +}); + +describe('[#23214] GET /ui/view/:object — `?type=` is one view type, read only when the path names none', () => { + it('refuses a repeated `type` before `getUiView`', async () => { + const { dispatcher, protocol } = boot(); + expectArityRefusal(await get(dispatcher, '/ui/view/task', { type: ['list', 'form'] }), 'type'); + expect(protocol.getUiView).not.toHaveBeenCalled(); + }); + + it('preserves one `type`, and never reads the query when the path names the type', async () => { + const { dispatcher, protocol } = boot(); + expect((await get(dispatcher, '/ui/view/task', { type: 'form' })).status).toBe(200); + expect(protocol.getUiView).toHaveBeenLastCalledWith({ object: 'task', type: 'form' }); + expect((await get(dispatcher, '/ui/view/task/grid', { type: ['list', 'form'] })).status).toBe(200); + expect(protocol.getUiView).toHaveBeenLastCalledWith({ object: 'task', type: 'grid' }); + }); +}); + +describe('[#23214] /i18n — `?locale=` on the two arms whose path names no locale', () => { + it('refuses a repeated `locale` on /translations before any i18n call', async () => { + const { dispatcher, i18n } = boot(); + expectArityRefusal(await get(dispatcher, '/i18n/translations', { locale: ['en', 'fr'] }), 'locale'); + expect(i18n.getTranslations).not.toHaveBeenCalled(); + }); + + it('refuses a repeated `locale` on /labels/:object before any i18n call', async () => { + const { dispatcher, i18n } = boot(); + expectArityRefusal(await get(dispatcher, '/i18n/labels/task', { locale: ['en', 'fr'] }), 'locale'); + expect(i18n.getFieldLabels).not.toHaveBeenCalled(); + }); + + it('preserves one `locale`, and never reads the query when the path names the locale', async () => { + const { dispatcher, i18n } = boot(); + expect((await get(dispatcher, '/i18n/translations', { locale: ['fr'] })).status).toBe(200); + expect(i18n.getTranslations).toHaveBeenLastCalledWith('fr'); + expect((await get(dispatcher, '/i18n/labels/task', { locale: 'fr' })).status).toBe(200); + expect(i18n.getFieldLabels).toHaveBeenLastCalledWith('task', 'fr'); + expect((await get(dispatcher, '/i18n/translations/fr', { locale: ['en', 'fr'] })).status).toBe(200); + expect(i18n.getTranslations).toHaveBeenLastCalledWith('fr'); + }); +}); + +describe('[#23214] GET /security/suggested-bindings — one status, one package', () => { + it.each(['status', 'packageId'])('refuses a repeated `%s` with the multiplicity sentence, before the service', async (name) => { + const { dispatcher, security } = boot(); + expectArityRefusal(await get(dispatcher, '/security/suggested-bindings', { [name]: ['pending', 'pending'] }), name); + expect(security.listAudienceBindingSuggestions).not.toHaveBeenCalled(); + }); + + it('preserves one of each, handed to the service as strings', async () => { + const { dispatcher, security } = boot(); + expect((await get(dispatcher, '/security/suggested-bindings', { status: 'pending', packageId: ['com.acme'] })).status).toBe(200); + expect(security.listAudienceBindingSuggestions.mock.calls[0][1]).toEqual({ status: 'pending', packageId: 'com.acme' }); + }); +}); + +describe('[#23214] GET /packages — `status` and `type` are each one value', () => { + it.each(['status', 'type'])('refuses a repeated `%s` before the registry is read', async (name) => { + const { dispatcher, getAllPackages } = bootPackages(); + expectArityRefusal(await get(dispatcher, '/packages', { [name]: ['a', 'b'] }), name); + expect(getAllPackages).not.toHaveBeenCalled(); + }); + + it('preserves one `status` and one `type` as filters', async () => { + const ids = (a: Answer) => (a.body?.data?.packages ?? []).map((p: any) => p.manifest.id).sort(); + const byStatus = await get(bootPackages().dispatcher, '/packages', { status: ['error'] }); + expect(byStatus.status).toBe(200); + expect(ids(byStatus)).toEqual(['com.acme.plugin']); + const byType = await get(bootPackages().dispatcher, '/packages', { type: 'app' }); + expect(ids(byType)).toEqual(['com.acme.app']); + }); +}); + +describe('[#23214] GET /data/:object — the filter slot, through the rule RestServer asks', () => { + /** REST's answer for this query: the code and sentence `assertFilterParamSuppliedOnce` throws. */ + function restAnswer(query: Record): { code: string; message: string } { + try { + assertFilterParamSuppliedOnce({ ...query }); + } catch (e: any) { + return { code: e.code, message: e.message }; + } + throw new Error(`assertFilterParamSuppliedOnce accepted ${JSON.stringify(query)}: not a filter-slot spelling`); + } + + it.each(['filter', 'where', '$filter', 'filters'])('refuses a repeated `%s` before `findData`', async (name) => { + const { dispatcher, protocol } = boot(); + const query = { [name]: ['{"status":"a"}', '{"status":"b"}'] }; + const answer = await get(dispatcher, '/data/task', query); + expect(answer.status).toBe(400); + expect(answer.body?.success).toBe(false); + expect(answer.body?.error).toMatchObject(restAnswer(query)); + expect(answer.body?.error?.code).toBe('INVALID_FILTER'); + expect(protocol.findData).not.toHaveBeenCalled(); + }); + + it('preserves one filter, and leaves a genuinely multi-valued `select` an array', async () => { + const { dispatcher, protocol } = boot(); + const answer = await get(dispatcher, '/data/task', { filter: '{"status":"open"}', select: ['id', 'status'] }); + expect(answer.status).toBe(200); + expect(protocol.findData.mock.calls[0][0].query).toEqual({ filter: '{"status":"open"}', select: ['id', 'status'] }); + }); + + it('reads a one-element filter array as the one filter it encodes, as RestServer does', async () => { + const { dispatcher, protocol } = boot(); + expect((await get(dispatcher, '/data/task', { filter: ['{"status":"open"}'] })).status).toBe(200); + expect(protocol.findData.mock.calls[0][0].query).toEqual({ filter: '{"status":"open"}' }); + }); +}); diff --git a/packages/runtime/src/domains/i18n.ts b/packages/runtime/src/domains/i18n.ts index d111be27d4d..48265ff9eac 100644 --- a/packages/runtime/src/domains/i18n.ts +++ b/packages/runtime/src/domains/i18n.ts @@ -33,6 +33,30 @@ import { isServiceServeable } from '../service-serveable.js'; import type { TranslationData } from '@objectstack/spec/system'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +// [#23214] The repo's one sentence for a repeated single-valued parameter. +import { repeatedQueryParamMessage } from '@objectstack/rest'; + +/** + * [#23214] `?locale=` on the two arms whose PATH names no locale + * (`/translations?locale=`, `/labels/:object?locale=`): ONE locale. A repeat + * reached `getTranslations` and `resolveLocale` as an array, and the latter's + * `toLowerCase()` threw, so the caller was answered a 500. Refused instead with + * the multiplicity sentence, before any i18n call; a one-element array is one + * occurrence and an empty one is none. + */ +function readQueryLocale(deps: DomainHandlerDeps, query: any): { locale: any } | { refused: HttpDispatcherResult } { + const raw = query?.locale; + if (!Array.isArray(raw)) return { locale: raw }; + if (raw.length > 1) { + return { + refused: { + handled: true, + response: deps.error(repeatedQueryParamMessage('locale', raw.length), 400, { code: 'VALIDATION_ERROR' }), + }, + }; + } + return { locale: raw[0] }; +} export function createI18nDomain(deps: DomainHandlerDeps): DomainRoute { return { @@ -112,7 +136,12 @@ export async function handleI18nRequest( // GET /i18n/translations/:locale OR /i18n/translations?locale=xx if (parts[0] === 'translations') { - const locale = parts[1] ? decodeURIComponent(parts[1]) : query?.locale; + let locale = parts[1] ? decodeURIComponent(parts[1]) : undefined; + if (!parts[1]) { + const read = readQueryLocale(deps, query); + if ('refused' in read) return read.refused; + locale = read.locale; + } if (!locale) return { handled: true, response: deps.error('Missing locale parameter', 400) }; let translations = i18nService.getTranslations(locale); @@ -135,7 +164,12 @@ export async function handleI18nRequest( // GET /i18n/labels/:object/:locale OR /i18n/labels/:object?locale=xx if (parts[0] === 'labels' && parts.length >= 2) { const objectName = decodeURIComponent(parts[1]); - let locale = parts[2] ? decodeURIComponent(parts[2]) : query?.locale; + let locale = parts[2] ? decodeURIComponent(parts[2]) : undefined; + if (!parts[2]) { + const read = readQueryLocale(deps, query); + if ('refused' in read) return read.refused; + locale = read.locale; + } if (!locale) return { handled: true, response: deps.error('Missing locale parameter', 400) }; // Locale fallback for labels endpoint diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 2fbc1e6b65f..efde560e4ee 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -68,6 +68,11 @@ import type { ObjectStackProtocolImplementation } from '@objectstack/metadata-pr // (`metaMemberSelector`): the item read, `PUT`, `/layers` and `/published` // each refuse a name they do not read, as `RestServer`'s doors do, and the // item read honours `?member=`. +// [#23214] …and the arity of the four `/meta` doors whose only query rule it +// is (`metaQueryArityRefusal` over `META_QUERY_ARITY_DOORS`): the list, the +// book tree, `_drafts` and the state machine read refuse a repeated +// single-valued name with the sentence `RestServer`'s twins answer, instead of +// reading an array this transport's adapters never used to hand them. // Imported, never restated — AGENTS.md 〈Route & surface ownership〉 rule 1. import { createMetaBookTreeAnswer, @@ -80,6 +85,7 @@ import { metaItemPackageBinding, metaItemQueryRefusal, metaMemberSelector, + metaQueryArityRefusal, metaRequestLocale, metaSaveRequestOptions, metaTypeReadRefusal, @@ -1012,6 +1018,10 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 4 && (parts[0] === 'objects' || parts[0] === 'object') && parts[2] === 'state' && (!method || method === 'GET')) { const name = parts[1]; const field = parts[3]; + // [#23214] One current state: a repeat would be joined into a state no + // transition names (`'a,b'`), answered as a dead end. + const arity = metaQueryArityRefusal('state', query); + if (arity) return queryRefusalAnswer(deps, arity); const from = query?.from !== undefined ? String(query.from) : undefined; const qlService = await deps.getObjectQL(_context); const schema = qlService?.registry?.getObject(name); @@ -1043,6 +1053,10 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (!protocol || typeof protocol.getMetaItems !== 'function') { return { handled: true, response: deps.error('Not found', 404) }; } + // [#23214] One package scopes the book and doc reads: a repeat would read + // as no package at all, so the tree would span every package. + const arity = metaQueryArityRefusal('book-tree', query); + if (arity) return queryRefusalAnswer(deps, arity); // [#22188] `RestServer`'s reading: `all` names no package. const packageId = metaItemPackageBinding(query?.package); try { @@ -1893,6 +1907,11 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin } const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.listDrafts === 'function') { + // [#23214] One package and one type narrow the list: a repeat would + // reach `listDrafts` as an array. Asked where `RestServer` asks it, + // after the capability gate and the capability probe. + const arity = metaQueryArityRefusal('drafts', query); + if (arity) return queryRefusalAnswer(deps, arity); try { // [ADR-0131 D6] No organization, as `RestServer`'s `_drafts`: // every draft is saved environment-wide. @@ -1975,6 +1994,12 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // GET /metadata/:type (List items of type) OR /metadata/:objectName (Legacy) 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); + if (arity) return queryRefusalAnswer(deps, arity); // Extract optional package filter from query string. // [#22188] `RestServer`'s reading: `all`, the list's "show everything" // scope, names no package. diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 4c9541ee976..e87589a5f34 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -1093,6 +1093,22 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin response: deps.error(repeatedQueryParamMessage('enabled', enabled.count), 400), }; } + // [#23214] `status` and `type` are each compared with `===` against + // ONE value below, so a repeat matched no package and the door + // answered an empty 200, the plausible-looking wrong answer + // `enabled` above is refused to prevent. Refused the same way, with + // the same sentence; a one-element array is one occurrence. + for (const name of ['status', 'type'] as const) { + const raw = query?.[name]; + if (!Array.isArray(raw)) continue; + if (raw.length > 1) { + return { + handled: true, + response: deps.error(repeatedQueryParamMessage(name, raw.length), 400), + }; + } + query[name] = raw[0]; + } let packages = registry.getAllPackages(); // Apply optional filters if (query?.status) { diff --git a/packages/runtime/src/domains/security.ts b/packages/runtime/src/domains/security.ts index c822361859b..6b34e00a868 100644 --- a/packages/runtime/src/domains/security.ts +++ b/packages/runtime/src/domains/security.ts @@ -33,6 +33,8 @@ import { isCatalogActivationWrite, handleCatalogActivationWrite } from './catalo import { isPermissionSetCloneWrite, handlePermissionSetCloneWrite } from './permission-set-clone.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +// [#23214] The repo's one sentence for a repeated single-valued parameter. +import { repeatedQueryParamMessage } from '@objectstack/rest'; export function createSecurityDomain(deps: DomainHandlerDeps): DomainRoute { return { @@ -143,6 +145,24 @@ export async function handleSecurityRequest( // seam onto this same service call and was still forwarding // `?status=` unvalidated, so the empty-list arm described above was // reachable there. One owner, two callers — not two copies. + // [#23214] `status` and `packageId` are each ONE value: the names, in + // their order, that `RestServer`'s twin of this route declares to + // `refuseRepeatedQueryParams`. A repeat was joined by `String()` + // below, so a repeated `packageId` filtered on a package no row has + // (an empty 200) and a repeated `status` was refused as an unknown + // status. Refused with the multiplicity sentence, before the + // service is asked; a one-element array is one occurrence. + for (const name of ['status', 'packageId'] as const) { + const raw = query?.[name]; + if (!Array.isArray(raw)) continue; + if (raw.length > 1) { + return { + handled: true, + response: deps.error(repeatedQueryParamMessage(name, raw.length), 400, { code: 'VALIDATION_ERROR' }), + }; + } + query[name] = raw[0]; + } const rawStatus = query?.status ? String(query.status) : undefined; if (rawStatus !== undefined && !isAudienceBindingSuggestionStatus(rawStatus)) { return { diff --git a/packages/runtime/src/domains/ui.ts b/packages/runtime/src/domains/ui.ts index 7ae39da7eea..2adac685ddd 100644 --- a/packages/runtime/src/domains/ui.ts +++ b/packages/runtime/src/domains/ui.ts @@ -18,6 +18,8 @@ import type { MetadataProtocol } from '@objectstack/spec/api'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; import { capabilityUnavailable } from './unavailable.js'; +// [#23214] The repo's one sentence for a repeated single-valued parameter. +import { repeatedQueryParamMessage } from '@objectstack/rest'; /** * [#15238] The `protocol` service slot **as this domain reaches it** — one @@ -98,7 +100,20 @@ export async function handleUiRequest( if (parts[0] === 'view' && parts[1]) { const objectName = parts[1]; // Support both path param /view/obj/list AND query param /view/obj?type=list - const type = parts[2] || query?.type || 'list'; + // [#23214] `?type=` is read only when the path names no type, and it is + // ONE view type: a repeat reached `getUiView` as an array. Refused with + // the multiplicity sentence; a one-element array is one occurrence. + let queryType = query?.type; + if (!parts[2] && Array.isArray(queryType)) { + if (queryType.length > 1) { + return { + handled: true, + response: deps.error(repeatedQueryParamMessage('type', queryType.length), 400, { code: 'VALIDATION_ERROR' }), + }; + } + queryType = queryType[0]; + } + const type = parts[2] || queryType || 'list'; const protocol = await resolveProtocol(deps, _context);