From 7675d9a0db17a94695fe58785d890823bc9e6515 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:32:30 +0000 Subject: [PATCH 1/4] feat(rest,runtime): one by-name /meta query decision for both transports metaItemQueryRefusal (meta-item-read-gate.ts) judges the item read, /layers, /published and the dispatcher's PUT: recognition over the door's closed set, the arity of its single-valued names, and the item read's refusal of a member on the layered view. RestServer's item GET is re-pointed at it with its answers unchanged; the runtime dispatcher asks it on all four doors and its item read honours ?member= through every protocol read, with the name-keyed fallbacks never answering a named member. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/rest/src/index.ts | 9 + packages/rest/src/meta-item-read-gate.ts | 247 +++++++++++++++++++++++ packages/rest/src/query-allowlist.ts | 35 +++- packages/rest/src/rest-server.ts | 141 +++++-------- packages/runtime/src/domains/meta.ts | 128 +++++++++++- 5 files changed, 451 insertions(+), 109 deletions(-) diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index fddb0d685dc..2e95097c630 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -123,6 +123,13 @@ export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-mu // `META_TYPE_WRITE_CAPABILITIES`), asked at the same two entries before any // store write: a datasource definition is written under the capability the // datasource admin door requires. +// +// [#23072] …and the by-name doors' QUERY decision (`metaItemQueryRefusal`, +// ruling 6138588561): each door's closed set, the arity of its single-valued +// names and the item read's refusal of a member on the layered view, answered +// 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. export { createMetaBookTreeAnswer, createMetaItemAnswer, @@ -135,6 +142,8 @@ export { META_TYPE_WRITE_CAPABILITIES, metaCallerOrganizationId, metaItemLayersDeprecationHeaders, + metaItemQueryRefusal, + metaMemberSelector, metaRequestLocale, metaTypeReadRefusal, metaTypeWriteRefusal, diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index c3c426abf84..41952c07b37 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -85,6 +85,10 @@ import { deriveViewContainerObject } from '@objectstack/metadata/view-container' import type { ExpandedViewItem } from '@objectstack/spec/ui'; import { isEndpointMatchAuthority, selectServedEndpoints } from './served-endpoints.js'; import { logError, logWarn } from './log.js'; +// [#23072] The recognition sentence and the arity rule's portable halves, for +// the by-name doors' query decision ({@link metaItemQueryRefusal}). +import { unknownQueryParamRefusalMessage } from './query-allowlist.js'; +import { readSingleQueryValue, repeatedQueryParamMessage } from './query-multiplicity.js'; // ── The ports each transport supplies ───────────────────────────────────────── @@ -2143,6 +2147,249 @@ export function metaTypeWriteRefusal( }; } +// ── The by-name doors' query: one decision, both transports ─────────────────── +// +// [#23072] Ruling 6138588561 (option B): the query a by-name `/meta` door +// accepts is judged by ONE function both transports call +// ({@link metaItemQueryRefusal}), with the member selector's ONE reading +// ({@link metaMemberSelector}) beside it. Until it existed the closed sets and +// their sentences lived in `RestServer` alone, so the runtime dispatcher's +// copies of these doors — the only answer on a host that mounts just the +// `${prefix}/*` catch-all (`@objectstack/hono`'s `createHonoApp`) — declared no +// set: `?member=zh-CN` was answered with the canonical member, and every other +// name was dropped. The sets below moved here, unchanged, out of +// `rest-server.ts`, which imports them for its own doors and re-exports them. + +/** + * [#22877] The bundle member selector's query parameter on the by-name `/meta` + * doors (ruling 6116165299, Q2 i): GET, DELETE, `/history`, `/publish` and + * `/rollback` on `/meta/:type/:name` each carry it in their closed set. + * Named after the discriminator's concept (`sys_metadata.bundle_member`) and + * type-neutral — ⛔ never `locale`, which on `/meta` already names the + * response translation locale (`extractLocale`). + */ +export const META_ITEM_MEMBER_PARAM = 'member'; + +/** + * [#22877] The closed query-parameter set of `GET {metaPath}/:type/:name`, + * which declared none before the selector joined it. + * + * **Measured** from the handler's read points and the helpers it calls: + * `layers` (the deprecated layered spelling, `wantsMetaItemLayers`), `state` + * and `preview` (the two admitted draft switches), `package` (the cache bypass, + * `metaItemPackageBinding`, and the layered read's `serveMetaItemLayered`), + * and `locale` — read a frame down, by `extractLocale` behind the cached arm's + * validator and every `translateMetaEnvelope` / `createMetaItemAnswer` + * translation, never in the handler body; omitting it would 400 every + * `?locale=zh-CN` read that works today. Plus {@link META_ITEM_MEMBER_PARAM}. + * + * [#23072] The runtime dispatcher's item read reads the same six, measured on + * `handleMetadataRequest`'s two-segment `GET`: `state` and `preview` in the + * branch, `package` through `metaItemPackageBinding`, `layers` through + * `wantsMetaItemLayers`, `locale` through `metaRequestLocale` behind + * `createMetaItemAnswer`, and the member through {@link metaMemberSelector}. + */ +export const META_ITEM_READ_PARAMS: readonly string[] = [ + 'layers', 'state', 'preview', 'package', 'locale', META_ITEM_MEMBER_PARAM, +]; + +/** + * [#23072] The closed set of `PUT {metaPath}/:type/:name` (the save). + * **Measured** from the handler and the helper it calls: `force` (the + * destructive-change acknowledgement), `package` (the saved row's binding, + * `metaItemPackageBinding`) and `mode` — read a frame down, by + * `metaSaveRequestOptions` (`./meta-save-request.ts`), beside the `If-Match` / + * `If-None-Match` headers it reads. The capability, walled-catalog and actor + * gates read the session, not the query. The same three names the door's + * multiplicity guard declares. + * + * ⛔ No member selector: the save is BODY-addressed (ruling 6116165299) — the + * member it writes is named by the body's own discriminator field + * (`email_template`'s `locale`), and `SaveMetaItemRequest` declares no + * selector. A `?member=zh-CN` beside an `en-US` body is refused, not read. + */ +export const META_ITEM_SAVE_PARAMS: readonly string[] = ['force', 'package', 'mode']; + +/** + * [#23072] The closed set of `GET {metaPath}/:type/:name/layers`. + * **Measured**: `package`, read a frame down by `serveMetaItemLayered` (the + * layered read's package scope, `metaItemPackageBinding`); the masker and + * the read gate read the session. The layered view shows the canonical + * member's layers, as the deprecated `?layers=` spelling's refusal of a member + * on the item read already says. The runtime dispatcher's `/layers` reads the + * same one name, through the same binding. + */ +export const META_ITEM_LAYERS_PARAMS: readonly string[] = ['package']; + +/** + * [#23072] The closed set of `GET {metaPath}/:type/:name/published`: EMPTY. + * **Measured**: the handler reads no query parameter, and neither do the + * helpers it calls (`resolveObjectMasker`, `resolveProtocol`, + * `resolveMetadataService`, the read gate); the published document is asked of + * the type and the name alone. Neither does the runtime dispatcher's + * `/published`. + */ +export const META_ITEM_PUBLISHED_PARAMS: readonly string[] = []; + +/** + * [#23072] The closed set of the runtime dispatcher's `PUT /meta/:type/:name`: + * {@link META_ITEM_SAVE_PARAMS} less `force`. **Measured** on that branch of + * `handleMetadataRequest`: `package` (`metaItemPackageBinding`) and `mode` + * (`metaSaveRequestOptions`) — the names `RestServer`'s save reads through the + * same two shared readers. `force` is the one name that door was ruled never to + * take (#11095, settled: its `409 DESTRUCTIVE_CHANGE` remedy already says "the + * dispatcher's `PUT /meta` accepts no `force`"), so a `?force=true` there is + * refused like every other name the door does not read — never dropped, which + * answered the save as if the caller had not asked. + */ +const META_ITEM_SAVE_UNFORCED_PARAMS: readonly string[] = Object.freeze( + META_ITEM_SAVE_PARAMS.filter((name) => name !== 'force'), +); + +/** + * [#23072] The by-name `/meta` doors {@link metaItemQueryRefusal} judges: + * + * - `item` — `GET /meta/:type/:name`, on both transports; + * - `layers` — `GET /meta/:type/:name/layers`; + * - `published` — `GET /meta/:type/:name/published`; + * - `save` — `PUT /meta/:type/:name` as the runtime dispatcher serves it, + * which takes no `force`. `RestServer`'s save door reads `force` too, and + * keeps its own gate over {@link META_ITEM_SAVE_PARAMS}. + */ +export type MetaItemQueryDoor = 'item' | 'layers' | 'published' | 'save'; + +/** + * [#23072] Each door's closed set (`accepted`) and the names of it the door + * reads ONCE (`once` — the list `RestServer`'s door declares to + * `refuseRepeatedQueryParams`). `locale` is outside the item read's: its one + * reader, `metaRequestLocale`, sends a repeated value to the default locale. + */ +const META_ITEM_QUERY_DOORS: Readonly> = Object.freeze({ + item: { + accepted: META_ITEM_READ_PARAMS, + once: ['layers', 'state', 'preview', 'package', META_ITEM_MEMBER_PARAM], + }, + layers: { accepted: META_ITEM_LAYERS_PARAMS, once: ['package'] }, + published: { accepted: META_ITEM_PUBLISHED_PARAMS, once: [] }, + save: { accepted: META_ITEM_SAVE_UNFORCED_PARAMS, once: META_ITEM_SAVE_UNFORCED_PARAMS }, +}); + +/** [#22877] The item read's refusal of a member named on the layered view — `RestServer`'s sentence, byte for byte. */ +const LAYERED_VIEW_MEMBER_MESSAGE = + `The "${META_ITEM_MEMBER_PARAM}" selector is not served by the layered view ` + + `(\`?layers=\`), which shows the canonical member's layers. Read one member with ` + + `\`?${META_ITEM_MEMBER_PARAM}=\` alone.`; + +/** + * [#23072] Why a by-name `/meta` door refuses its QUERY — DATA, written by each + * transport in its own envelope, like {@link MetaTypeReadRefusal}. Always `400 + * VALIDATION_ERROR`, the code the recognition and arity rules answer + * everywhere; `rule` names which of the three refused, because `RestServer`'s + * item read answers the third at a different point (see + * {@link metaItemQueryRefusal}). + */ +export interface MetaItemQueryRefusal { + readonly status: 400; + readonly code: 'VALIDATION_ERROR'; + readonly message: string; + readonly rule: 'unknown-parameter' | 'repeated-parameter' | 'layered-member'; +} + +/** + * [#23072] THE query decision of a by-name `/meta` door, for both transports — + * ruling 6138588561, option B: one judgment, so a door's set, its sentences and + * their order cannot drift between `RestServer` and the runtime dispatcher, and + * a name a door starts reading reaches both. + * + * The rules, in order; the first that holds is the answer: + * + * 1. **recognition** (AGENTS.md route rule 5) — a name outside the door's + * closed set, answered with the located sentence `refuseUnknownQueryParams` + * answers (`unknownQueryParamRefusalMessage`, `./query-allowlist.ts`): every + * name not understood, and every name accepted; + * 2. **arity** — a single-valued name supplied more than once, answered with + * the multiplicity rule's sentence (`repeatedQueryParamMessage`, its + * portable half). A name supplied ONCE as a one-element array is unwrapped + * IN PLACE, exactly as `refuseRepeatedQueryParams` unwraps it, so every read + * after this one sees the string — ⛔ ask this before any other read of the + * query; + * 3. **`item` only: a member on the layered view** — `?layers=` shows the + * canonical member's layers and reads no member, so one named beside it is + * refused rather than answered with another member's layers. + * + * `undefined` — go on, nothing sent — otherwise. A query that is not an object + * carries no name, so it is judged by rule 3 alone (and passes it). + * + * Where each transport answers: rules 1 and 2 at the door's entry, before any + * service is resolved; rule 3 where the item read chooses the layered view, + * after the protocol and the caller's mask posture are resolved — where + * `RestServer` has always answered it. `RestServer` writes rules 1 and 2 in + * the nested `{ error: { code, message } }` its recognition and arity gates + * write, and throws rule 3 to its route error door, so each of its answers is + * byte for byte the one it gave before this decision; the runtime dispatcher + * writes all three through `deps.error`, in its own envelope, with the same + * `error.code` and message. + * + * ⛔ The VALUE of `?member=` is not judged here: it is the protocol's + * (`resolveBundleMemberSelector` in `@objectstack/metadata-protocol`), once, + * for every transport — see {@link metaMemberSelector}. + */ +export function metaItemQueryRefusal(door: MetaItemQueryDoor, query: unknown): MetaItemQueryRefusal | undefined { + const { accepted, once } = META_ITEM_QUERY_DOORS[door]; + if (query && typeof query === 'object') { + const unknownMessage = unknownQueryParamRefusalMessage(query, accepted); + 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; + } + } + if (door === 'item' + && wantsMetaItemLayers(query as Readonly> | undefined) + && metaMemberSelector(query).member !== undefined) { + return { status: 400, code: 'VALIDATION_ERROR', message: LAYERED_VIEW_MEMBER_MESSAGE, rule: 'layered-member' }; + } + return undefined; +} + +/** + * [#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 + * the caller named one, `{}` when not, which the protocol reads as the + * canonical member. The VALUE is the protocol's to judge, once, for every + * transport (`resolveBundleMemberSelector` in `@objectstack/metadata-protocol`): + * a selector on a type with no members, a blank one or an overlong one is + * answered there with the located 400. A value that is not a string (an + * adapter's nested-object parse) is handed over as received, so that one + * reading refuses it — ⛔ never dropped here, which would answer the canonical + * member to a request that named something. Recognition of the NAME is each + * route's closed set; repetition, the multiplicity rule. + * + * [#23072] Moved here, unchanged, out of `rest-server.ts`, whose five member + * doors read it by the same name; the runtime dispatcher's item read reads it + * too, after {@link metaItemQueryRefusal} (which unwraps a one-element array). + */ +export function metaMemberSelector(query: unknown): { member?: string } { + const raw = (query as Record | null | undefined)?.[META_ITEM_MEMBER_PARAM]; + return raw === undefined ? {} : { member: raw as string }; +} + // ── The list route's unknown-type refusal ───────────────────────────────────── /** diff --git a/packages/rest/src/query-allowlist.ts b/packages/rest/src/query-allowlist.ts index 046412f5dd1..a4df93185a4 100644 --- a/packages/rest/src/query-allowlist.ts +++ b/packages/rest/src/query-allowlist.ts @@ -171,6 +171,30 @@ export function unknownQueryParamMessage( + accepted; } +/** + * The recognition DECISION, as a pure function of the query: the located + * sentence for a query carrying any name outside `allowed`, or `undefined` when + * every name is understood (or there is no query object at all). + * {@link refuseUnknownQueryParams} writes it; a door with its own envelope — + * the by-name `/meta` doors' `metaItemQueryRefusal`, which the runtime + * dispatcher asks — takes it and writes its own body (#23072). + * + * @param query the request's parsed query (any shape; a non-object has no names) + * @param allowed every parameter name the route accepts + */ +export function unknownQueryParamRefusalMessage( + query: unknown, + allowed: readonly string[], +): string | undefined { + if (!query || typeof query !== 'object') return undefined; + const permitted = new Set(allowed); + // Sorted so a request carrying several unknown names always produces the + // same message, whatever order the adapter happened to build the object in. + const unknown = Object.keys(query).filter(k => !permitted.has(k)).sort(); + if (unknown.length === 0) return undefined; + return unknownQueryParamMessage(unknown, [...allowed].sort()); +} + /** * Refuse a request carrying any query parameter outside this route's closed * set. Returns `true` when it answered the request — the caller `return`s @@ -192,17 +216,12 @@ export function refuseUnknownQueryParams( res: any, allowed: readonly string[], ): boolean { - const query = req?.query; - if (!query || typeof query !== 'object') return false; - const permitted = new Set(allowed); - // Sorted so a request carrying several unknown names always produces the - // same message, whatever order the adapter happened to build the object in. - const unknown = Object.keys(query).filter(k => !permitted.has(k)).sort(); - if (unknown.length === 0) return false; + const message = unknownQueryParamRefusalMessage(req?.query, allowed); + if (message === undefined) return false; res.status(400).json({ error: { code: 'VALIDATION_ERROR', - message: unknownQueryParamMessage(unknown, [...allowed].sort()), + message, }, }); return true; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 9a64fac24d3..fb7a8dda55f 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -211,6 +211,15 @@ import type { // to the implementation is a governed-surface edit, so it is left to the // maintainer; this line goes when that row moves. export { filterAppForUser } from './meta-item-read-gate.js'; +// [#23072] What the by-name `/meta` doors read beside the query decision both +// transports ask (`metaItemQueryRefusal`) — see the re-export above the sets. +import { + META_ITEM_LAYERS_PARAMS, + META_ITEM_MEMBER_PARAM, + META_ITEM_PUBLISHED_PARAMS, + META_ITEM_SAVE_PARAMS, + metaMemberSelector, +} from './meta-item-read-gate.js'; // [#22141] The `PUT /meta/:type/:name` door's precondition and lifecycle, read // through the ONE mapping the runtime dispatcher's `/meta` domain asks too. import { metaSaveRequestOptions } from './meta-save-request.js'; @@ -641,32 +650,21 @@ export const GLOBAL_SEARCH_PARAMS: readonly string[] = [ 'q', 'query', 'objects', 'limit', 'perObject', ]; -/** - * [#22877] The bundle member selector's query parameter on the by-name `/meta` - * doors (ruling 6116165299, Q2 i): GET, DELETE, `/history`, `/publish` and - * `/rollback` on `/meta/:type/:name` each carry it in their closed set below. - * Named after the discriminator's concept (`sys_metadata.bundle_member`) and - * type-neutral — ⛔ never `locale`, which on `/meta` already names the - * response translation locale (`extractLocale`). - */ -export const META_ITEM_MEMBER_PARAM = 'member'; - -/** - * [#22877] The closed query-parameter set of `GET {metaPath}/:type/:name`, - * which declared none before the selector joined it. - * - * **Measured** from the handler's read points and the helpers it calls: - * `layers` (the deprecated layered spelling, `wantsMetaItemLayers`), `state` - * and `preview` (the two admitted draft switches), `package` (the cache bypass, - * `metaItemPackageBinding`, and the layered read's `serveMetaItemLayered`), - * and `locale` — read a frame down, by `extractLocale` behind the cached arm's - * validator and every `translateMetaEnvelope` / `createMetaItemAnswer` - * translation, never in the handler body; omitting it would 400 every - * `?locale=zh-CN` read that works today. Plus {@link META_ITEM_MEMBER_PARAM}. - */ -export const META_ITEM_READ_PARAMS: readonly string[] = [ - 'layers', 'state', 'preview', 'package', 'locale', META_ITEM_MEMBER_PARAM, -]; +// [#22877 · #23072] The member selector's name (`META_ITEM_MEMBER_PARAM`), its +// one reading (`metaMemberSelector`), the item read's closed set +// (`META_ITEM_READ_PARAMS`) and the save, `/layers` and `/published` sets +// (`META_ITEM_SAVE_PARAMS`, `META_ITEM_LAYERS_PARAMS`, +// `META_ITEM_PUBLISHED_PARAMS`) live in `./meta-item-read-gate.ts`, beside the +// by-name doors' query decision both transports ask (`metaItemQueryRefusal`, +// ruling 6138588561). Imported for the doors below, which read them by the +// names they always did, and re-exported from here unchanged. +export { + META_ITEM_MEMBER_PARAM, + META_ITEM_READ_PARAMS, + META_ITEM_SAVE_PARAMS, + META_ITEM_LAYERS_PARAMS, + META_ITEM_PUBLISHED_PARAMS, +} from './meta-item-read-gate.js'; /** * [#22877] The closed set of `DELETE {metaPath}/:type/:name` (the reset). @@ -706,24 +704,8 @@ export const META_ITEM_ROLLBACK_PARAMS: readonly string[] = ['toVersion', META_I // each declares no member selector, so `?member=` is refused with the located // 400 like any other name the door does not read — never dropped, which // answered the canonical member (or, on `PUT`, wrote the body's) to a request -// that named another. - -/** - * [#23072] The closed set of `PUT {metaPath}/:type/:name` (the save). - * **Measured** from the handler and the helper it calls: `force` (the - * destructive-change acknowledgement), `package` (the saved row's binding, - * {@link metaItemPackageBinding}) and `mode` — read a frame down, by - * `metaSaveRequestOptions` (`./meta-save-request.ts`), beside the `If-Match` / - * `If-None-Match` headers it reads. The capability, walled-catalog and actor - * gates read the session, not the query. The same three names the door's - * multiplicity guard declares. - * - * ⛔ No member selector: the save is BODY-addressed (ruling 6116165299) — the - * member it writes is named by the body's own discriminator field - * (`email_template`'s `locale`), and `SaveMetaItemRequest` declares no - * selector. A `?member=zh-CN` beside an `en-US` body is refused, not read. - */ -export const META_ITEM_SAVE_PARAMS: readonly string[] = ['force', 'package', 'mode']; +// that named another. `PUT`'s, `/layers`' and `/published`' sets are re-exported +// above, from the module the runtime dispatcher's copies of those doors read. /** * [#23072] The closed set of `GET {metaPath}/:type/:name/references`: EMPTY. @@ -735,16 +717,6 @@ export const META_ITEM_SAVE_PARAMS: readonly string[] = ['force', 'package', 'mo */ export const META_ITEM_REFERENCES_PARAMS: readonly string[] = []; -/** - * [#23072] The closed set of `GET {metaPath}/:type/:name/layers`. - * **Measured**: `package`, read a frame down by `serveMetaItemLayered` (the - * layered read's package scope, {@link metaItemPackageBinding}); the masker and - * the read gate read the session. The layered view shows the canonical - * member's layers, as the deprecated `?layers=` spelling's refusal of a member - * on the item read already says. - */ -export const META_ITEM_LAYERS_PARAMS: readonly string[] = ['package']; - /** * [#23072] The closed set of `GET {metaPath}/:type/:name/audit`. * **Measured**: `limit`, read through its declaration @@ -763,33 +735,6 @@ export const META_ITEM_AUDIT_PARAMS: readonly string[] = ['limit']; */ export const META_ITEM_DIFF_PARAMS: readonly string[] = ['from', 'fromVersion', 'to', 'toVersion']; -/** - * [#23072] The closed set of `GET {metaPath}/:type/:name/published`: EMPTY. - * **Measured**: the handler reads no query parameter, and neither do the - * helpers it calls (`resolveObjectMasker`, `resolveProtocol`, - * `resolveMetadataService`, the read gate); the published document is asked of - * the type and the name alone. - */ -export const META_ITEM_PUBLISHED_PARAMS: readonly string[] = []; - -/** - * [#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 - * the caller named one, `{}` when not, which the protocol reads as the - * canonical member. The VALUE is the protocol's to judge, once, for every - * transport (`resolveBundleMemberSelector` in `@objectstack/metadata-protocol`): - * a selector on a type with no members, a blank one or an overlong one is - * answered there with the located 400. A value that is not a string (an - * adapter's nested-object parse) is handed over as received, so that one - * reading refuses it — ⛔ never dropped here, which would answer the canonical - * member to a request that named something. Recognition of the NAME is each - * route's closed set above; repetition, `refuseRepeatedQueryParams`. - */ -function metaMemberSelector(query: unknown): { member?: string } { - const raw = (query as Record | null | undefined)?.[META_ITEM_MEMBER_PARAM]; - return raw === undefined ? {} : { member: raw as string }; -} - /** * [#20062 / #20139] The reading of a numeric query parameter on a door whose * request has no declared schema: a whole number, and nothing about range. @@ -6395,8 +6340,22 @@ export class RestServer { // [#22877] …and RECOGNITION first, over the route's // measured closed set (AGENTS.md route rule 5), which // the bundle member selector joined. - if (refuseUnknownQueryParams(req, res, META_ITEM_READ_PARAMS)) return; - if (refuseRepeatedQueryParams(req, res, ['layers', 'state', 'preview', 'package', META_ITEM_MEMBER_PARAM])) return; + // [#23072] All three of the route's query rules — + // recognition, arity and a member on the layered view — + // are ONE decision, `metaItemQueryRefusal` in + // `./meta-item-read-gate.ts`, which the runtime + // dispatcher's item read asks too (ruling 6138588561). + // The first two are answered here, in the nested body + // their gates always wrote; the third where the layered + // view is chosen, below. ⛔ A rule is added there, never + // here. + const queryRefusal = metaReadGate.metaItemQueryRefusal('item', req.query); + if (queryRefusal && queryRefusal.rule !== 'layered-member') { + res.status(queryRefusal.status).json({ + error: { code: queryRefusal.code, message: queryRefusal.message }, + }); + return; + } const memberSelector = metaMemberSelector(req.query); const environmentId = isScoped ? req.params?.environmentId : undefined; const p = await this.resolveProtocol(environmentId, req); @@ -6497,13 +6456,15 @@ export class RestServer { const wantLayered = metaReadGate.wantsMetaItemLayers(req.query); // [#22877] The layered view reads no member, so a member // named on it is refused rather than answered with the - // canonical member's layers. - if (wantLayered && memberSelector.member !== undefined) { - throw Object.assign(new Error( - `The "${META_ITEM_MEMBER_PARAM}" selector is not served by the layered view ` - + `(\`?layers=\`), which shows the canonical member's layers. Read one member with ` - + `\`?${META_ITEM_MEMBER_PARAM}=\` alone.`, - ), { code: 'VALIDATION_ERROR', status: 400 }); + // canonical member's layers. [#23072] The decision's + // third rule (`layered-member`), the only one that can + // still be pending here; thrown to `handleRouteError` as + // it always was. + if (queryRefusal) { + throw Object.assign(new Error(queryRefusal.message), { + code: queryRefusal.code, + status: queryRefusal.status, + }); } if (wantLayered && typeof (p as any).getMetaItemLayered === 'function') { const requestPath: unknown = req.path; diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 84a24c33019..2fbc1e6b65f 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -63,6 +63,11 @@ import type { ObjectStackProtocolImplementation } from '@objectstack/metadata-pr // (`metaSaveRequestOptions`): `If-Match` / `If-None-Match: *` and `?mode=draft`. // [#22188] …and the one reading of `?package=` (`metaItemPackageBinding`), // under which `all` names no package, on every branch here that reads it. +// [#23072] …and the by-name doors' query decision (`metaItemQueryRefusal`, +// ruling 6138588561) with the member selector's one reading +// (`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=`. // Imported, never restated — AGENTS.md 〈Route & surface ownership〉 rule 1. import { createMetaBookTreeAnswer, @@ -73,6 +78,8 @@ import { isPublicAudienceRead, metaItemLayersDeprecationHeaders, metaItemPackageBinding, + metaItemQueryRefusal, + metaMemberSelector, metaRequestLocale, metaSaveRequestOptions, metaTypeReadRefusal, @@ -707,7 +714,7 @@ async function readPendingDraft( packageId: string | undefined, previewDrafts: boolean, saveVerdict: MetaSaveVerdict, - item: { maskPosture: ObjectSchemaMaskPosture; query: any }, + item: { maskPosture: ObjectSchemaMaskPosture; query: any; memberSelector: { member?: string } }, ): Promise { const singularType = pluralToSingular(type); const protocol = await resolveProtocol(deps, context); @@ -718,6 +725,9 @@ async function readPendingDraft( try { envelope = await protocol.getMetaItem({ type: singularType, name, packageId, state: 'draft', previewDrafts, + // [#23072] The member the item read's `?member=` named: its own + // draft row, as `RestServer`'s draft read asks for it. + ...item.memberSelector, }); } catch (e: any) { return { handled: true, response: deps.errorFromThrown(e, 404) }; @@ -860,6 +870,20 @@ function decodeMetaNameSegment(segment: string): string { } } +/** + * [#23072] A by-name door's QUERY refusal — `metaItemQueryRefusal` in + * `@objectstack/rest`, the decision `RestServer`'s doors answer too — written + * in this transport's envelope (`deps.error`, the ADR-0112 nested body), with + * the decision's own `error.code` and sentence, so the two transports refuse + * one request with one code and one message. + */ +function queryRefusalAnswer( + deps: DomainHandlerDeps, + refusal: NonNullable>, +): HttpDispatcherResult { + return { handled: true, response: deps.error(refusal.message, refusal.status, { code: refusal.code }) }; +} + /** * Handles Metadata requests * Standard: /metadata/:type/:name @@ -1052,6 +1076,13 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // segments, like `/published` beside it. The anonymous gate above keeps its // deny here (`metaReadRouteOf` names no route), as `RestServer`'s does. if (parts.length === 3 && parts[2] === 'layers' && (!method || method.toUpperCase() === 'GET')) { + // [#23072] The route's closed query set — `package`, the one name it + // reads — refused before the protocol, the masker and the read gate, + // where `RestServer`'s `/layers` refuses it: a `?member=` (the layered + // view shows the canonical member's layers) or any other name is a + // located 400, never dropped. + const queryRefusal = metaItemQueryRefusal('layers', query); + if (queryRefusal) return queryRefusalAnswer(deps, queryRefusal); const type = parts[0]; const name = decodeMetaNameSegment(parts[1]); const protocol = await resolveProtocol(deps, _context); @@ -1092,6 +1123,12 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // so that fold could only ever address a name that can no longer be // written. if (parts.length === 3 && parts[2] === 'published' && (!method || method === 'GET')) { + // [#23072] The route reads no query parameter (its set is EMPTY, as + // `RestServer`'s `/published`): any name — `?member=` included, which + // the published document cannot honour — is a located 400 before the + // protocol and the read gate, never dropped. + const queryRefusal = metaItemQueryRefusal('published', query); + if (queryRefusal) return queryRefusalAnswer(deps, queryRefusal); const type = parts[0]; const name = decodeMetaNameSegment(parts[1]); @@ -1236,12 +1273,14 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2) { const type = parts[0]; const name = decodeMetaNameSegment(parts[1]); - // Extract optional package filter from query string. + // Extract optional package filter from query string — per door below, + // AFTER that door's query decision ([#23072]: it unwraps a name supplied + // once as a one-element array, so the binding reads the string). // [#22188] `RestServer`'s reading, for the read and the save alike: // `all` names no package. It forwarded the literal, so a // `PUT ?package=all` stored the row bound to a package called `all` // where `RestServer` stores it env-local. - const packageId = metaItemPackageBinding(query?.package); + const packageBinding = (): string | undefined => metaItemPackageBinding(query?.package); // PUT /metadata/:type/:name (Save) // @@ -1327,6 +1366,18 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin } } + // [#23072] The save's closed query set, after the two gates that read + // the session and before any query read, as `RestServer`'s `PUT` + // places its own: `package` and `mode`, the two names this branch + // reads. ⛔ Not `force` — this door was ruled never to take one + // (#11095, its `409` remedy says so) — and not `member`: the save + // is BODY-addressed (ruling 6116165299), so a `?member=zh-CN` beside + // an `en-US` body is refused rather than dropped while the body's + // member is written. Nothing is read or written before the refusal. + const queryRefusal = metaItemQueryRefusal('save', query); + if (queryRefusal) return queryRefusalAnswer(deps, queryRefusal); + const packageId = packageBinding(); + // [#22141] The ADR-0008 precondition and the ADR-0033 lifecycle, // read off THIS request through the one mapping `RestServer`'s // `PUT` door reads them through (`metaSaveRequestOptions`, @@ -1529,6 +1580,30 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin }; } + // [#23072] THE item read's query decision — `metaItemQueryRefusal` in + // `@objectstack/rest`, the one `RestServer`'s item read asks (ruling + // 6138588561) — before any other read of the query, as there. Its first + // two rules are answered here: a name outside the route's closed set + // (`layers`, `state`, `preview`, `package`, `locale`, `member` — the + // names this branch reads) and a single-valued name supplied twice. The + // third (a member on the layered view) is answered where the layered + // view is chosen, below. This branch declared no set, so `?member=` and + // every misspelling were dropped and answered with the canonical item. + const queryRefusal = metaItemQueryRefusal('item', query); + if (queryRefusal && queryRefusal.rule !== 'layered-member') return queryRefusalAnswer(deps, queryRefusal); + const packageId = packageBinding(); + // [#23072] …and the member it names (`metaMemberSelector`, the one + // reading `RestServer`'s doors read), handed to EVERY protocol read + // below — the draft read, the `object` branch and the generic one — + // whose `resolveBundleMemberSelector` judges the value once, for both + // transports. A named member is the PROTOCOL's to answer: the registry + // and `MetadataService` fallbacks key by name and hold one body per + // name, so they would answer the canonical member for it, and a + // protocol refusal (a selector on a type with no members, a blank one) + // is answered as itself instead of falling through to them. + const memberSelector = metaMemberSelector(query); + const memberNamed = memberSelector.member !== undefined; + // [#20338 · #20320] The item read's two draft switches, each declared // WITH its admission ({@link mayReadPendingDrafts}), once, above every // branch below — so no branch can re-derive a switch past the gate. A @@ -1570,6 +1645,13 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin throw maskError; } + // [#23072] The decision's third rule: the layered view shows the + // canonical member's layers and reads no member, so a member named + // beside `?layers=` is refused — here, after the mask posture, where + // `RestServer` answers it — rather than served the wrong member's + // layers. Whatever the protocol can do, as there. + if (queryRefusal) return queryRefusalAnswer(deps, queryRefusal); + try { // [#5882 · #20478] The DEPRECATED spelling of the layered view, // `?layers=` — the parse `RestServer`'s item @@ -1595,7 +1677,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (isDraftRead) { return await readPendingDraft( - deps, _context, type, name, packageId, previewDrafts, saveVerdict, { maskPosture, query }, + deps, _context, type, name, packageId, previewDrafts, saveVerdict, { maskPosture, query, memberSelector }, ); } @@ -1636,11 +1718,15 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // scoped kernel. This branch never read the switch, so a builder // previewing an object was answered the ACTIVE schema where // `RestServer` answers the draft. - const protocolFirst = scoped || previewDrafts; + // [#23072] …and so does a NAMED member: the registry holds one + // schema per name and reads no member, so only the protocol + // can answer one (for `object`, a type with no members, its + // located 400). + const protocolFirst = scoped || previewDrafts || memberNamed; // The protocol read `RestServer`'s plain read makes: the same - // `?package=` scope (ADR-0048), the same admitted switch and no - // organization (ADR-0131 D6). + // `?package=` scope (ADR-0048), the same admitted switch, the + // same member ([#23072]) and no organization (ADR-0131 D6). const readFromProtocol = async (): Promise => { // [#15238] `protocol &&` spelled out: the `any` cast this // branch used to resolve through let two sibling guards drift @@ -1652,6 +1738,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin name, ...(packageId ? { packageId } : {}), ...(previewDrafts ? { previewDrafts: true } : {}), + ...memberSelector, }); // Protocol returns `{ type, name, item }` — only treat the // lookup as a hit when `item` is really there. [#5563] The @@ -1660,13 +1747,18 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // store outage resolves to) was served as a hit and the // registry fallback below never ran. return data?.item != null ? data : undefined; - } catch { + } catch (e) { + // [#23072] A read that named a member is the protocol's + // alone, so its refusal is the answer (the outer `catch` + // renders it), never a fall-through to a registry that + // would serve the canonical schema for it. + if (memberNamed) throw e; return undefined; // fall through to registry / 404 } }; let found: any = protocolFirst ? await readFromProtocol() : undefined; - if (found === undefined) { + if (found === undefined && !memberNamed) { const qlService = await deps.getObjectQL(_context); const data = qlService?.registry?.getObject(name); // [#5563] The registry hands back the bare ObjectSchema, so this @@ -1692,9 +1784,11 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (protocol && typeof protocol.getMetaItem === 'function') { try { // `previewDrafts` is the ADMITTED switch declared above - // this block's branches (#20338). + // this block's branches (#20338). [#23072] The member, as + // `RestServer`'s plain read hands it. const data = await protocol.getMetaItem({ type: singularType, name, packageId, previewDrafts, + ...memberSelector, }); // [#18401] The SAME hit test the `object` branch above runs, // asked here for the same reason. `getMetaItem` answers a @@ -1727,12 +1821,24 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin } } catch (e: any) { // Protocol might throw if not found or not supported + // [#23072] …but a read that named a member is the + // protocol's alone: its refusal (a selector on a type with + // no members, a blank one) is the answer, rendered by the + // outer `catch` as `RestServer` renders it — never a + // fall-through to the name-keyed `MetadataService` below. + if (memberNamed) throw e; } } if (found) return serveItem(protocol, found); // Try MetadataService for runtime-registered types - const metaSvc = await deps.resolveService(_context, 'metadata', _context.environmentId); + // [#23072] …never for a NAMED member: `getItem` keys by name and + // holds one body per name, so it would answer the canonical member + // to a request the protocol answered absent. That member is + // absent, as on `RestServer`. + const metaSvc = memberNamed + ? undefined + : await deps.resolveService(_context, 'metadata', _context.environmentId); if (metaSvc && typeof (metaSvc as any).getItem === 'function') { let data: any; try { From e6d9b3e1e0ab2778d844bc1b08724b96b0c90c9d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:50:34 +0000 Subject: [PATCH 2/4] test(rest,runtime): pin the by-name /meta query decision on both transports The decision's sets, arity, rule order and in-place unwrap; RestServer's item read writing exactly the decision; the dispatcher's item read honouring ?member= through every protocol read with the protocol's refusal handed on and the name-keyed fallbacks never answering a named member; /layers, /published and PUT refusing what they do not read. meta-list-projection-parity's ITEM_PROBES gains the member selector on both transports. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .../rest/src/meta-item-query-decision.test.ts | 185 +++++++++ .../domains/meta-item-query-decision.test.ts | 383 ++++++++++++++++++ .../meta-list-projection-parity.test.ts | 32 +- 3 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 packages/rest/src/meta-item-query-decision.test.ts create mode 100644 packages/runtime/src/domains/meta-item-query-decision.test.ts diff --git a/packages/rest/src/meta-item-query-decision.test.ts b/packages/rest/src/meta-item-query-decision.test.ts new file mode 100644 index 00000000000..28d7a7debf4 --- /dev/null +++ b/packages/rest/src/meta-item-query-decision.test.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23072] THE query decision of the by-name `/meta` doors — + * `metaItemQueryRefusal` in `./meta-item-read-gate.ts`, ruling 6138588561 + * (option B) — and `RestServer`'s item read re-pointed at it. + * + * The decision is DATA: the rule that refused, `400 VALIDATION_ERROR` and the + * sentence. Each transport writes it in its own envelope, so this file pins + * the decision itself (every door's set, the arity of its single-valued names, + * the item read's refusal of a member on the layered view, and their order) + * and that `RestServer`'s item read writes exactly the decision — the nested + * body for the first two rules, its route error door's flat body for the third, + * as before (`rest-server-meta-member-selector.test.ts`, unchanged, pins the + * same answers from the request side). + */ + +import { describe, it, expect, vi } from 'vitest'; +// `.js` on purpose — NodeNext resolution requires the extension. +import { + META_ITEM_LAYERS_PARAMS, + META_ITEM_PUBLISHED_PARAMS, + META_ITEM_READ_PARAMS, + META_ITEM_SAVE_PARAMS, + metaItemQueryRefusal, + metaMemberSelector, +} from './meta-item-read-gate.js'; +import { RestServer } from './rest-server.js'; +import { unknownQueryParamMessage } from './query-allowlist.js'; +import { repeatedQueryParamMessage } from './query-multiplicity.js'; + +const LAYERED_MEMBER = 'The "member" selector is not served by the layered view (`?layers=`), which shows the ' + + 'canonical member\'s layers. Read one member with `?member=` alone.'; + +const refusal = (rule: string, message: string) => ({ status: 400, code: 'VALIDATION_ERROR', message, rule }); + +describe('[#23072] metaItemQueryRefusal — each door\'s closed set', () => { + it('the measured sets: the item read\'s six, `/layers`\' one, `/published`\' none, and the dispatcher save\'s two (no `force`)', () => { + expect([...META_ITEM_READ_PARAMS].sort()).toEqual(['layers', 'locale', 'member', 'package', 'preview', 'state']); + expect([...META_ITEM_LAYERS_PARAMS]).toEqual(['package']); + expect([...META_ITEM_PUBLISHED_PARAMS]).toEqual([]); + // RestServer's own save door reads `force`; the dispatcher's does not. + expect([...META_ITEM_SAVE_PARAMS].sort()).toEqual(['force', 'mode', 'package']); + expect(metaItemQueryRefusal('save', { force: 'true' })).toEqual(refusal( + 'unknown-parameter', unknownQueryParamMessage(['force'], ['mode', 'package']), + )); + }); + + const DOORS = [ + { door: 'item', set: META_ITEM_READ_PARAMS }, + { door: 'layers', set: META_ITEM_LAYERS_PARAMS }, + { door: 'published', set: META_ITEM_PUBLISHED_PARAMS }, + { door: 'save', set: ['package', 'mode'] }, + ] as const; + + for (const { door, set } of DOORS) { + it(`${door}: an unread name is refused with the located sentence, every unknown name and every accepted one in it`, () => { + expect(metaItemQueryRefusal(door, { memeber: 'zh-CN' })).toEqual(refusal( + 'unknown-parameter', unknownQueryParamMessage(['memeber'], [...set].sort()), + )); + // Sorted, whatever order the adapter built the object in. + expect(metaItemQueryRefusal(door, { zeta: '1', alpha: '2' })?.message) + .toBe(unknownQueryParamMessage(['alpha', 'zeta'], [...set].sort())); + }); + + it(`${door}: every name it reads passes, and so does a query that is no object at all`, () => { + // `layers` empty: the plain read, so the member beside it is not rule 3's case. + expect(metaItemQueryRefusal(door, Object.fromEntries(set.map((n) => [n, n === 'layers' ? '' : 'x'])))) + .toBeUndefined(); + expect(metaItemQueryRefusal(door, {})).toBeUndefined(); + expect(metaItemQueryRefusal(door, undefined)).toBeUndefined(); + expect(metaItemQueryRefusal(door, 'member=zh-CN')).toBeUndefined(); + }); + } + + it('`member` is refused by every door that does not read it — the layered view, the published document and the body-addressed save', () => { + for (const door of ['layers', 'published', 'save'] as const) { + expect(metaItemQueryRefusal(door, { member: 'zh-CN' })?.rule, door).toBe('unknown-parameter'); + } + expect(metaItemQueryRefusal('item', { member: 'zh-CN' })).toBeUndefined(); + }); +}); + +describe('[#23072] metaItemQueryRefusal — arity, and the order of the three rules', () => { + it('a single-valued name supplied twice is the multiplicity rule\'s sentence', () => { + expect(metaItemQueryRefusal('item', { member: ['zh-CN', 'ja-JP'] })).toEqual(refusal( + 'repeated-parameter', repeatedQueryParamMessage('member', 2), + )); + expect(metaItemQueryRefusal('layers', { package: ['a', 'b', 'c'] })).toEqual(refusal( + 'repeated-parameter', repeatedQueryParamMessage('package', 3), + )); + expect(metaItemQueryRefusal('save', { mode: ['draft', 'active'] })?.message) + .toBe(repeatedQueryParamMessage('mode', 2)); + }); + + it('`locale` stays outside the item read\'s arity declaration, as on RestServer: its reader sends a repeat to the default', () => { + expect(metaItemQueryRefusal('item', { locale: ['zh-CN', 'ja-JP'] })).toBeUndefined(); + }); + + it('a name supplied once as a one-element array is unwrapped IN PLACE, so the reads after it see the string', () => { + const query: Record = { member: ['zh-CN'], package: ['com.acme.mail'] }; + expect(metaItemQueryRefusal('item', query)).toBeUndefined(); + expect(query).toEqual({ member: 'zh-CN', package: 'com.acme.mail' }); + expect(metaMemberSelector(query)).toEqual({ member: 'zh-CN' }); + }); + + it('item: a member on the layered view is refused with RestServer\'s sentence; the empty flag is the plain read', () => { + expect(metaItemQueryRefusal('item', { layers: 'true', member: 'zh-CN' })).toEqual(refusal('layered-member', LAYERED_MEMBER)); + expect(metaItemQueryRefusal('item', { layers: '', member: 'zh-CN' })).toBeUndefined(); + expect(metaItemQueryRefusal('item', { layers: 'true' })).toBeUndefined(); + }); + + it('recognition outranks arity, and arity outranks the layered rule', () => { + expect(metaItemQueryRefusal('item', { memeber: 'x', member: ['a', 'b'], layers: 'true' })?.rule).toBe('unknown-parameter'); + expect(metaItemQueryRefusal('item', { member: ['a', 'b'], layers: 'true' })?.rule).toBe('repeated-parameter'); + }); +}); + +describe('[#22877 · #23072] metaMemberSelector — the one reading of the selector', () => { + it('a named member, no member, and a non-string handed over as received for the protocol to refuse', () => { + expect(metaMemberSelector({ member: 'zh-CN' })).toEqual({ member: 'zh-CN' }); + expect(metaMemberSelector({})).toEqual({}); + expect(metaMemberSelector(undefined)).toEqual({}); + expect(metaMemberSelector({ member: { nested: 'x' } })).toEqual({ member: { nested: 'x' } }); + }); +}); + +// ── RestServer's item read writes exactly the decision ──────────────────────── + +function bootRest() { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue({ items: [] }), + getMetaItem: vi.fn().mockResolvedValue({ type: 'email_template', name: 'acme.welcome', item: { name: 'acme.welcome' } }), + getMetaItemLayered: vi.fn().mockResolvedValue({ type: 'email_template', name: 'acme.welcome', code: null, overlay: null, effective: null }), + }; + const server = { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; + const rest: any = new RestServer(server as any, protocol, { api: { requireAuth: false }, metadata: { enableCache: false } } as any); + rest.resolveExecCtx = async () => ({ isSystem: true, userId: 'u1' }); + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name'); + const read = async (query: Record) => { + const res: any = { statusCode: 200 }; + res.status = (c: number) => { res.statusCode = c; return res; }; + res.json = (b: unknown) => { res.body = b; return res; }; + res.header = () => res; res.send = () => res; res.setHeader = () => res; + await route.handler({ method: 'GET', path: '/api/v1/meta/email_template/acme.welcome', params: { type: 'email_template', name: 'acme.welcome' }, query, headers: {}, body: {} }, res); + return { status: res.statusCode, body: res.body }; + }; + return { protocol, read }; +} + +describe('[#23072] RestServer\'s item read is re-pointed at the decision, its answers unchanged', () => { + it('rules 1 and 2: the nested `{ error: { code, message } }` its recognition and arity gates wrote, byte for byte', async () => { + for (const query of [{ memeber: 'zh-CN' }, { member: ['zh-CN', 'ja-JP'] }, { state: ['draft', 'active'] }]) { + const decided = metaItemQueryRefusal('item', JSON.parse(JSON.stringify(query)))!; + const { protocol, read } = bootRest(); + const answer = await read(query); + expect(answer.status).toBe(400); + expect(JSON.stringify(answer.body)).toBe(JSON.stringify({ error: { code: decided.code, message: decided.message } })); + expect(protocol.getMetaItem).not.toHaveBeenCalled(); + } + }); + + it('rule 3: thrown to the route error door, which writes its flat body with the decision\'s code and sentence', async () => { + const { protocol, read } = bootRest(); + const answer = await read({ layers: 'true', member: 'zh-CN' }); + expect(answer.status).toBe(400); + expect(answer.body?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error).toBe(LAYERED_MEMBER); + expect(protocol.getMetaItemLayered).not.toHaveBeenCalled(); + expect(protocol.getMetaItem).not.toHaveBeenCalled(); + }); + + it('CONTROL: a query the decision passes reaches the protocol with the member it named', async () => { + const { protocol, read } = bootRest(); + const answer = await read({ member: ['zh-CN'], locale: 'zh-CN' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.getMetaItem.mock.calls[0][0]).toMatchObject({ member: 'zh-CN' }); + }); +}); diff --git a/packages/runtime/src/domains/meta-item-query-decision.test.ts b/packages/runtime/src/domains/meta-item-query-decision.test.ts new file mode 100644 index 00000000000..55d27b6dcfc --- /dev/null +++ b/packages/runtime/src/domains/meta-item-query-decision.test.ts @@ -0,0 +1,383 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23072] The runtime dispatcher's by-name `/meta` doors ask THE query + * decision `RestServer`'s doors ask (`metaItemQueryRefusal` in + * `@objectstack/rest`, ruling 6138588561), and the item read honours the + * bundle member selector. + * + * The dispatcher is the only `/meta` answer on a host that mounts just the + * `${prefix}/*` catch-all (`@objectstack/hono`'s `createHonoApp`). Its item + * read, `PUT`, `/layers` and `/published` declared no closed query set: a + * `GET …?member=zh-CN` was answered with the CANONICAL member, a misspelled + * `?memeber=` the same, and a `PUT ?member=zh-CN` beside an `en-US` body wrote + * `en-US` — each a plausible answer to a request it did not read. + * + * Both halves per door, as AGENTS.md route rule 5 asks: + * - REFUSAL — status, the nested `error.code`, the located sentence (the one + * `RestServer` answers, where the doors share a set), and the protocol + * never called; + * - PRESERVATION — the request the protocol actually received. + * + * The VALUE of `?member=` is the protocol's to judge: those pins drive a REAL + * `ObjectStackProtocolImplementation` over an engine that is never reached + * (`{}`), so a 400 there is the protocol's own refusal, before any store read, + * and it must come out of this transport as itself — never swallowed by a + * lookup's `catch` and answered by a name-keyed fallback. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from '@objectstack/rest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const NAME = 'acme.welcome'; +/** One `email_template` bundle: the canonical member (`en-US`) and one more. */ +const MEMBERS: Record> = { + 'en-US': { name: NAME, label: 'Welcome', locale: 'en-US', subject: 'Welcome', bodyHtml: '

en

' }, + 'zh-CN': { name: NAME, label: 'Welcome', locale: 'zh-CN', subject: '欢迎', bodyHtml: '

zh

' }, +}; + +/** A caller every gate before the query admits: the save door's `manage_metadata`, the draft doors' exemption. */ +const AUTHOR = { userId: 'u_author', isSystem: false, systemPermissions: ['manage_metadata'] }; + +/** + * The protocol, as `metadata-protocol` answers these reads for one bundle: a + * named member's body or, for a member the bundle does not hold, the + * item-less envelope; no member, the canonical one. + */ +function protocolDouble() { + const envelope = (item: unknown) => ({ + type: 'email_template', name: NAME, item, lock: 'none', editable: true, deletable: true, resettable: false, + }); + return { + getMetaTypes: vi.fn(async () => ({ types: ['email_template', 'view', 'object'] })), + getMetaItem: vi.fn(async ({ member }: any) => envelope(MEMBERS[member ?? 'en-US'])), + getMetaItemLayered: vi.fn(async ({ type, name }: any) => ({ + type, name, code: MEMBERS['en-US'], overlay: null, overlayScope: null, effective: MEMBERS['en-US'], + lock: 'none', editable: true, deletable: true, resettable: false, + })), + saveMetaItem: vi.fn(async () => ({ success: true, version: 'v1' })), + }; +} + +/** A `MetadataService` that answers every name — with the canonical member, the only one it keys. */ +function metadataServiceDouble() { + return { + getItem: vi.fn(async () => MEMBERS['en-US']), + getPublished: vi.fn(async () => MEMBERS['en-US']), + }; +} + +// ── The dispatcher ──────────────────────────────────────────────────────────── + +function bootDispatcher(services: Record, caller: Record = AUTHOR) { + 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); + const call = async (method: string, path: string, query: Record = {}, body?: unknown) => { + const res = await dispatcher.handleMetadata(path, { + request: { headers: {} }, + environmentId: 'platform', + executionContext: { ...caller }, + } as any, method, body, query); + if (!res.response) throw new Error(`the dispatcher did not handle ${method} ${path}`); + return { status: res.response.status, body: res.response.body }; + }; + return { call }; +} + +// ── RestServer, the reference refusals ──────────────────────────────────────── + +function bootRest(protocol: unknown) { + const server = { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; + const rest: any = new RestServer(server as any, protocol as any, { api: { requireAuth: false }, metadata: { enableCache: false } } as any); + rest.resolveExecCtx = async () => ({ ...AUTHOR }); + rest.registerRoutes(); + const drive = async (routePath: string, params: Record, query: Record) => { + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === routePath); + if (!route) throw new Error(`GET ${routePath} is not registered`); + const res: any = { statusCode: 200, headers: {} }; + res.status = (c: number) => { res.statusCode = c; return res; }; + res.json = (b: unknown) => { res.body = b; return res; }; + res.header = (k: string, v: string) => { res.headers[k] = v; return res; }; + res.send = () => res; res.setHeader = () => res; res.write = () => true; res.end = () => res; + await route.handler({ method: 'GET', path: routePath, params, query, headers: {}, body: {} }, res); + // The nested gates write `{ error: { code, message } }`; the route error door the flat `{ error, code }`. + const body = res.body; + return { + status: res.statusCode, + code: typeof body?.error === 'object' ? body.error.code : body?.code, + message: typeof body?.error === 'object' ? body.error.message : body?.error, + }; + }; + const META = '/api/v1/meta'; + return { + item: (query: Record) => drive(`${META}/:type/:name`, { type: 'email_template', name: NAME }, query), + layers: (query: Record) => drive(`${META}/:type/:name/layers`, { type: 'email_template', name: NAME }, query), + published: (query: Record) => drive(`${META}/:type/:name/published`, { type: 'email_template', name: NAME }, query), + }; +} + +const ITEM = `/email_template/${NAME}`; + +/** The two transports refuse one request with one code and one sentence, each in its own body. */ +async function sameRefusal( + door: 'item' | 'layers' | 'published', + query: Record, + dispatcherAnswer: { status: number; body: any }, +) { + const reference = await bootRest(protocolDouble())[door]({ ...query }); + expect(reference.status).toBe(400); + expect(dispatcherAnswer.status).toBe(400); + expect(dispatcherAnswer.body?.success).toBe(false); + expect(dispatcherAnswer.body?.error?.code).toBe(reference.code); + expect(dispatcherAnswer.body?.error?.message).toBe(reference.message); + return reference; +} + +// ── The item read ───────────────────────────────────────────────────────────── + +describe('[#23072] GET /meta/:type/:name on the dispatcher: the member selector is honoured', () => { + it('PRESERVATION — `?member=zh-CN` reaches `getMetaItem` and answers THAT member\'s body', async () => { + const protocol = protocolDouble(); + const { call } = bootDispatcher({ protocol }); + const answer = await call('GET', ITEM, { member: 'zh-CN', package: 'com.acme.mail' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(answer.body?.data?.item?.locale).toBe('zh-CN'); + expect(answer.body?.data?.item?.subject).toBe('欢迎'); + expect(protocol.getMetaItem).toHaveBeenCalledTimes(1); + expect(protocol.getMetaItem.mock.calls[0][0]).toMatchObject({ + type: 'email_template', name: NAME, packageId: 'com.acme.mail', member: 'zh-CN', + }); + }); + + it('CONTROL — no selector sends no `member` key and answers the canonical member', async () => { + const protocol = protocolDouble(); + const { call } = bootDispatcher({ protocol }); + const answer = await call('GET', ITEM, {}); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(answer.body?.data?.item?.locale).toBe('en-US'); + expect(Object.keys(protocol.getMetaItem.mock.calls[0][0])).not.toContain('member'); + }); + + it('PRESERVATION — the draft read (`?state=draft`) and the preview (`?preview=draft`) hand the member on too', async () => { + for (const query of [{ state: 'draft', member: 'zh-CN' }, { preview: 'draft', member: 'zh-CN' }]) { + const protocol = protocolDouble(); + const { call } = bootDispatcher({ protocol }); + const answer = await call('GET', ITEM, query); + expect(answer.status, `${JSON.stringify(query)}: ${JSON.stringify(answer.body)}`).toBe(200); + expect(protocol.getMetaItem.mock.calls[0][0], JSON.stringify(query)).toMatchObject({ member: 'zh-CN' }); + } + const protocol = protocolDouble(); + await bootDispatcher({ protocol }).call('GET', ITEM, { state: 'draft', member: 'zh-CN' }); + expect(protocol.getMetaItem.mock.calls[0][0]).toMatchObject({ state: 'draft', member: 'zh-CN' }); + }); + + it('a member the bundle does not hold is ABSENT — the name-keyed MetadataService never answers it with the canonical one', async () => { + const protocol = protocolDouble(); + const metadata = metadataServiceDouble(); + const { call } = bootDispatcher({ protocol, metadata }); + const answer = await call('GET', ITEM, { member: 'ja-JP' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(404); + expect(metadata.getItem, 'the fallback keys by name, so it can only answer the canonical member').not.toHaveBeenCalled(); + }); + + it('CONTROL — with no member named, an item the protocol misses still falls through to the MetadataService', async () => { + const protocol = { ...protocolDouble(), getMetaItem: vi.fn(async () => ({ type: 'email_template', name: NAME, item: undefined })) }; + const metadata = metadataServiceDouble(); + const answer = await bootDispatcher({ protocol, metadata }).call('GET', ITEM, {}); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(metadata.getItem).toHaveBeenCalledTimes(1); + }); +}); + +describe('[#23072] GET /meta/:type/:name on the dispatcher: the query decision refuses what RestServer refuses', () => { + it('REFUSAL — a misspelled selector is the located 400, with RestServer\'s code and sentence, and nothing is read', async () => { + const protocol = protocolDouble(); + const metadata = metadataServiceDouble(); + const answer = await bootDispatcher({ protocol, metadata }).call('GET', ITEM, { memeber: 'zh-CN' }); + const reference = await sameRefusal('item', { memeber: 'zh-CN' }, answer); + expect(reference.code).toBe('VALIDATION_ERROR'); + expect(reference.message).toContain('"memeber" query parameter is not supported'); + expect(reference.message).toContain('Supported parameters: layers, locale, member, package, preview, state.'); + expect(protocol.getMetaItem).not.toHaveBeenCalled(); + expect(metadata.getItem).not.toHaveBeenCalled(); + }); + + it('REFUSAL — a repeated selector is refused, never resolved', async () => { + const protocol = protocolDouble(); + const answer = await bootDispatcher({ protocol }).call('GET', ITEM, { member: ['zh-CN', 'ja-JP'] }); + const reference = await sameRefusal('item', { member: ['zh-CN', 'ja-JP'] }, answer); + expect(reference.message).toContain('"member" query parameter was supplied 2 times'); + expect(protocol.getMetaItem).not.toHaveBeenCalled(); + }); + + it('PRESERVATION — a selector supplied once as a one-element array is read as that one member', async () => { + const protocol = protocolDouble(); + const answer = await bootDispatcher({ protocol }).call('GET', ITEM, { member: ['zh-CN'] }); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.getMetaItem.mock.calls[0][0]).toMatchObject({ member: 'zh-CN' }); + }); + + it('REFUSAL — a member on the layered view (`?layers=`) is refused, not answered with the canonical member\'s layers', async () => { + const protocol = protocolDouble(); + const answer = await bootDispatcher({ protocol }).call('GET', ITEM, { layers: 'true', member: 'zh-CN' }); + const reference = await sameRefusal('item', { layers: 'true', member: 'zh-CN' }, answer); + expect(reference.message).toContain('"member" selector is not served by the layered view'); + expect(protocol.getMetaItemLayered).not.toHaveBeenCalled(); + expect(protocol.getMetaItem).not.toHaveBeenCalled(); + }); + + it('CONTROL — every name the route reads still passes the decision', async () => { + const protocol = protocolDouble(); + const answer = await bootDispatcher({ protocol }).call('GET', ITEM, { + state: 'active', preview: 'none', package: 'com.acme.mail', locale: 'zh-CN', layers: '', member: 'zh-CN', + }); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.getMetaItem.mock.calls[0][0]).toMatchObject({ packageId: 'com.acme.mail', member: 'zh-CN' }); + }); +}); + +describe('[#23072] the selector\'s VALUE is the protocol\'s refusal, and it comes out as itself', () => { + const real = () => new ObjectStackProtocolImplementation({} as never); + + it('a selector on a type with no members: the protocol\'s located 400 — never swallowed into the MetadataService fallback', async () => { + const protocol = real(); + const spy = vi.spyOn(protocol, 'getMetaItem'); + const metadata = metadataServiceDouble(); + const answer = await bootDispatcher({ protocol, metadata }).call('GET', '/view/task_grid', { member: 'zh-CN' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toMatch( + /^The "member" selector names a bundle member, and a 'view' item has none: its identity is its name alone\./, + ); + // Reached, and refused there: the engine (`{}`) was never read. + expect(spy).toHaveBeenCalledTimes(1); + expect(metadata.getItem).not.toHaveBeenCalled(); + }); + + it('the `object` branch: the protocol answers a named member first, and the registry never serves its schema for it', async () => { + const protocol = real(); + const getObject = vi.fn(() => ({ name: 'invoice', fields: {} })); + const objectql = { registry: { getObject } }; + const answer = await bootDispatcher({ protocol, objectql }).call('GET', '/object/invoice', { member: 'zh-CN' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toMatch(/^The "member" selector names a bundle member, and a 'object' item has none/); + expect(getObject).not.toHaveBeenCalled(); + }); + + it('a blank selector is refused, never read as the canonical member', async () => { + const answer = await bootDispatcher({ protocol: real(), metadata: metadataServiceDouble() }) + .call('GET', ITEM, { member: ' ' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toMatch(/^The "member" selector is blank\./); + }); +}); + +// ── /layers and /published ──────────────────────────────────────────────────── + +describe('[#23072] GET /meta/:type/:name/layers on the dispatcher: its closed set is `package`', () => { + it('REFUSAL — `?member=` is the located 400 RestServer\'s `/layers` answers, before the layered read', async () => { + const protocol = protocolDouble(); + const answer = await bootDispatcher({ protocol }).call('GET', `${ITEM}/layers`, { member: 'zh-CN' }); + const reference = await sameRefusal('layers', { member: 'zh-CN' }, answer); + expect(reference.message).toContain('Supported parameters: package.'); + expect(protocol.getMetaItemLayered).not.toHaveBeenCalled(); + }); + + it('REFUSAL — a repeated `package` is refused, never read as no package', async () => { + const protocol = protocolDouble(); + const answer = await bootDispatcher({ protocol }).call('GET', `${ITEM}/layers`, { package: ['a', 'b'] }); + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toContain('"package" query parameter was supplied 2 times'); + expect(protocol.getMetaItemLayered).not.toHaveBeenCalled(); + }); + + it('PRESERVATION — `?package=` still scopes the layered read', async () => { + const protocol = protocolDouble(); + const answer = await bootDispatcher({ protocol }).call('GET', `${ITEM}/layers`, { package: 'com.acme.mail' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.getMetaItemLayered.mock.calls[0][0]).toEqual({ type: 'email_template', name: NAME, packageId: 'com.acme.mail' }); + }); +}); + +describe('[#23072] GET /meta/:type/:name/published on the dispatcher: its closed set is EMPTY', () => { + it('REFUSAL — `?member=` is the located 400 RestServer\'s `/published` answers, and nothing is read', async () => { + const protocol = protocolDouble(); + const metadata = metadataServiceDouble(); + const answer = await bootDispatcher({ protocol, metadata }).call('GET', `${ITEM}/published`, { member: 'zh-CN' }); + const reference = await sameRefusal('published', { member: 'zh-CN' }, answer); + expect(reference.message).toContain('This endpoint takes no query parameters.'); + expect(protocol.getMetaItemLayered).not.toHaveBeenCalled(); + expect(metadata.getPublished).not.toHaveBeenCalled(); + }); + + it('PRESERVATION — no query still serves the published document', async () => { + const protocol = protocolDouble(); + const metadata = metadataServiceDouble(); + const answer = await bootDispatcher({ protocol, metadata }).call('GET', `${ITEM}/published`, {}); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(answer.body?.data?.locale).toBe('en-US'); + }); +}); + +// ── PUT ─────────────────────────────────────────────────────────────────────── + +describe('[#23072] PUT /meta/:type/:name on the dispatcher: its closed set is `package` and `mode`', () => { + const ZH_BODY = MEMBERS['zh-CN']; + const EN_BODY = MEMBERS['en-US']; + + it('REFUSAL — `?member=zh-CN` beside an `en-US` body is the located 400, and nothing is written', async () => { + const protocol = protocolDouble(); + const answer = await bootDispatcher({ protocol }).call('PUT', ITEM, { member: 'zh-CN' }, EN_BODY); + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toContain('"member" query parameter is not supported'); + expect(answer.body?.error?.message).toContain('Supported parameters: mode, package.'); + expect(protocol.saveMetaItem).not.toHaveBeenCalled(); + }); + + it('REFUSAL — `?force=true` is refused here: this door takes no `force`, and dropping it answered as if unasked', async () => { + const protocol = protocolDouble(); + const answer = await bootDispatcher({ protocol }).call('PUT', ITEM, { force: 'true' }, ZH_BODY); + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(answer.body?.error?.message).toContain('"force" query parameter is not supported'); + expect(protocol.saveMetaItem).not.toHaveBeenCalled(); + }); + + it('REFUSAL — a repeated `mode` is refused, never read as an ACTIVE save', async () => { + const protocol = protocolDouble(); + const answer = await bootDispatcher({ protocol }).call('PUT', ITEM, { mode: ['draft', 'active'] }, ZH_BODY); + expect(answer.status).toBe(400); + expect(answer.body?.error?.message).toContain('"mode" query parameter was supplied 2 times'); + expect(protocol.saveMetaItem).not.toHaveBeenCalled(); + }); + + it('REFUSAL — the capability gate still answers first: a caller who may not save learns nothing from the query', async () => { + const protocol = protocolDouble(); + const answer = await bootDispatcher({ protocol }, { userId: 'u_member', isSystem: false, systemPermissions: [] }) + .call('PUT', ITEM, { member: 'zh-CN' }, ZH_BODY); + expect(answer.status).toBe(403); + expect(protocol.saveMetaItem).not.toHaveBeenCalled(); + }); + + it('PRESERVATION — `package` and `mode` still reach `saveMetaItem`, the body\'s member written', async () => { + const protocol = protocolDouble(); + const answer = await bootDispatcher({ protocol }).call('PUT', ITEM, { package: 'com.acme.mail', mode: 'draft' }, ZH_BODY); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(protocol.saveMetaItem).toHaveBeenCalledTimes(1); + expect(protocol.saveMetaItem.mock.calls[0][0]).toMatchObject({ + type: 'email_template', name: NAME, item: ZH_BODY, packageId: 'com.acme.mail', mode: 'draft', + }); + }); +}); 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 40e9131c724..7f24f561f5a 100644 --- a/packages/runtime/src/domains/meta-list-projection-parity.test.ts +++ b/packages/runtime/src/domains/meta-list-projection-parity.test.ts @@ -274,8 +274,19 @@ function protocolDouble(opts: { withoutLayered?: boolean } = {}) { const listed = [...items.values()]; return packageId ? listed.filter((i) => i._packageId === packageId) : listed; }), - getMetaItem: vi.fn(async ({ type, name, state, previewDrafts }: any) => { + getMetaItem: vi.fn(async ({ type, name, state, previewDrafts, member }: any) => { const t = singular(type); + // [#23072] The protocol's first step on a named member + // (`resolveBundleMemberSelector`): no type in these fixtures is a + // bundle, so a member is its located 400, before any read — which + // each transport must hand on as itself, never answer from a + // name-keyed fallback. + if (member !== undefined) { + throw Object.assign( + new Error(`The "member" selector names a bundle member, and a '${t}' item has none: its identity is its name alone. Remove the selector.`), + { code: 'VALIDATION_ERROR', status: 400 }, + ); + } const draft = (DRAFTS[t] ?? []).find((i) => i.name === name); if (state === 'draft') { if (!draft) { @@ -776,6 +787,12 @@ const ITEM_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record< // and the empty value, which is the plain read on both transports. { label: '?layers=true', param: 'layers', query: { layers: 'true' } }, { label: '?layers=', param: 'layers', query: { layers: '' } }, + // [#23072] The bundle member selector: forwarded to the protocol by both + // transports (the dispatcher dropped it), which refuses it on every type + // here — none is a bundle — and refused beside the layered flag before any + // read, by the one decision both ask (`metaItemQueryRefusal`). + { label: '?member=zh-CN', param: 'member', query: { member: 'zh-CN' } }, + { label: '?layers=true&member=zh-CN', param: 'member', query: { layers: 'true', member: 'zh-CN' } }, ]; const ITEM_PARAM_AXIS = new Set(ITEM_PROBES.map((p) => p.param).filter((p): p is string => !!p)); @@ -943,6 +960,19 @@ describe('[#20408] the item read: the reference moves — each probe changes Res expect((await builder('/meta/object/invoice', { preview: 'draft' })).item?.label).toBe('Invoice (draft)'); }); + it('[#23072] ?member= reaches the protocol, whose refusal is the answer; beside ?layers= the decision refuses it first', async () => { + const { read, protocol } = bootRest('holder'); + const member = await read('/meta/view/lead_all', { member: 'zh-CN' }); + expect({ status: member.status, code: member.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); + expect(protocol.getMetaItem.mock.calls.at(-1)?.[0]).toMatchObject({ member: 'zh-CN' }); + const calls = protocol.getMetaItem.mock.calls.length; + const layered = await read('/meta/view/lead_all', { layers: 'true', member: 'zh-CN' }); + expect({ status: layered.status, code: layered.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); + expect(text(layered)).toContain('not served by the layered view'); + expect(protocol.getMetaItem.mock.calls.length).toBe(calls); + expect(protocol.getMetaItemLayered).not.toHaveBeenCalled(); + }); + it('the list: ?preview=DRAFT overlays the drafts, and a segment that names no type is refused', async () => { expect(names((await bootRest('builder').read('/meta/app', { preview: 'DRAFT' })).items)).toContain('beacon'); const invented = await bootRest('holder').read('/meta/totally_invented_type'); From af356c3a1399dd05a6a3f7cb89586e408d8abf04 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 06:51:23 +0000 Subject: [PATCH 3/4] chore(changeset): the dispatcher's by-name /meta doors ask RestServer's query decision Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .../23072-dispatcher-meta-query-decision.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/23072-dispatcher-meta-query-decision.md diff --git a/.changeset/23072-dispatcher-meta-query-decision.md b/.changeset/23072-dispatcher-meta-query-decision.md new file mode 100644 index 00000000000..c654826d898 --- /dev/null +++ b/.changeset/23072-dispatcher-meta-query-decision.md @@ -0,0 +1,23 @@ +--- +'@objectstack/rest': minor +'@objectstack/runtime': minor +--- + +fix(runtime)!: the runtime dispatcher's by-name `/meta` doors ask the query decision `RestServer` asks, so `?member=` is honoured on the item read and refused where it is not read, never dropped + +`@objectstack/rest` exports one portable decision for the query a by-name `/meta` door accepts, `metaItemQueryRefusal(door, query)`, and the bundle member selector's one reading, `metaMemberSelector(query)`. Both transports call them, so a door's closed set, its sentences and their order are one answer. On a host whose `/meta` is answered by the runtime dispatcher alone (the `@objectstack/hono` `createHonoApp` catch-all), the item read, `PUT`, `/layers` and `/published` declared no closed query set: `GET /meta/email_template/x?member=zh-CN` was answered with the canonical member, a misspelling such as `?memeber=` the same, and `PUT ?member=zh-CN` beside an `en-US` body wrote `en-US`. + +Clause-②: yes (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +What each runtime dispatcher door accepts now, and what to do when it refuses. The answers are `RestServer`'s, in the dispatcher's own envelope, `{ success: false, error: { code: 'VALIDATION_ERROR', message } }`, and the message names the refused parameter: + +- `GET /meta/:type/:name` accepts `layers`, `state`, `preview`, `package`, `locale` and `member`, the six names `RestServer`'s item read accepts. `?member=` now reads that member of a bundled type (an `email_template`'s `zh-CN`); a member the bundle does not hold is `404`, and a selector on a type with no members is the protocol's located `400`. A single-valued name supplied twice is refused, and `?member=` beside `?layers=` is refused (the layered view shows the canonical member's layers). Read one member with `?member=` alone. +- `PUT /meta/:type/:name` accepts `package` and `mode`. It takes no `force`: this door never honoured one, and its `409` remedy already says so. Drop `?force=`, and send a body that keeps what the stored item carries. A save is addressed by its body: to save one member of a bundled type, put that member's own discriminator in the body and drop `?member=`. +- `GET /meta/:type/:name/layers` accepts `package`. Read one member with `GET /meta/:type/:name?member=…`. +- `GET /meta/:type/:name/published` accepts no query parameter. Send none. + +A request that sends only names the door reads is answered as before, except that `?member=` on the item read is now honoured. `RestServer`'s item read asks the same decision, and its answers are byte for byte what they were. Every name the `@objectstack/client` SDK and the Studio metadata client send to these doors is accepted, except `force` on a save: the SDK sends it only when its caller passes `force: true`, and Studio only to retry a save after a `409 DESTRUCTIVE_CHANGE`. On a dispatcher-only host that retry already failed with the same `409`, and it now answers the located `400` that names `force`. From 6fe8e7462997a86173eedf17e0625062bdebfd76 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 07:52:47 +0000 Subject: [PATCH 4/4] test(runtime): type the save double's request so the test layer compiles Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/runtime/src/domains/meta-item-query-decision.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/domains/meta-item-query-decision.test.ts b/packages/runtime/src/domains/meta-item-query-decision.test.ts index 55d27b6dcfc..239e086884e 100644 --- a/packages/runtime/src/domains/meta-item-query-decision.test.ts +++ b/packages/runtime/src/domains/meta-item-query-decision.test.ts @@ -59,7 +59,7 @@ function protocolDouble() { type, name, code: MEMBERS['en-US'], overlay: null, overlayScope: null, effective: MEMBERS['en-US'], lock: 'none', editable: true, deletable: true, resettable: false, })), - saveMetaItem: vi.fn(async () => ({ success: true, version: 'v1' })), + saveMetaItem: vi.fn(async (_request: unknown) => ({ success: true, version: 'v1' })), }; }