From 932dcaa1c3e459c383bd9f55dd452e678ef4e46b Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:26:58 +0800 Subject: [PATCH 1/6] fix(rest, runtime): the dispatcher serves the layered view on both spellings through one seam The layered view (`GET /meta/:type/:name/layers`, and the deprecated `?layers=` flag on the item read) moves out of `RestServer` into `createMetaLayeredAnswer` in `meta-item-read-gate.ts`, unchanged: the read in the caller's vetted organization and `?package=` scope, the per-caller gate on every layer under the stored-version doors' policy, and the ADR-0106 mask with its cache posture. The flag's parse and its `Deprecation` / `Link` headers are shared too. The runtime dispatcher's `/meta` domain now serves both spellings through it. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- packages/rest/src/index.ts | 13 ++ packages/rest/src/meta-item-read-gate.ts | 200 ++++++++++++++++++- packages/rest/src/rest-server.ts | 157 ++++++--------- packages/runtime/src/domains/meta.ts | 236 ++++++++++++++++++++--- 4 files changed, 480 insertions(+), 126 deletions(-) diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index d1dde028373..a10be15b83c 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -106,14 +106,23 @@ export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-mu // caller's `/meta` request is scoped to — the VETTED one on its execution // context (`metaCallerOrganizationId`, and `metaReadOrganizationId` for a read // of one type). +// +// [#20478] …and the layered view's, on both of its spellings: the whole answer +// (`createMetaLayeredAnswer` — the read in the caller's vetted partition, the +// per-caller gate on every layer under the stored-version doors' policy, the +// object mask and its cache posture), the deprecated `?layers=` flag's parse +// (`wantsMetaItemLayers`) and the headers it is served under +// (`metaItemLayersDeprecationHeaders`). export { createMetaBookTreeAnswer, createMetaItemAnswer, createMetaItemReadGate, + createMetaLayeredAnswer, createMetaListReadGate, createMetaListAnswer, isPublicAudienceRead, metaCallerOrganizationId, + metaItemLayersDeprecationHeaders, metaReadOrganizationId, metaRequestLocale, projectMetaObjectSchema, @@ -121,6 +130,7 @@ export { STORED_VERSION_DOOR_POLICY, translateMetaEnvelope, translateMetaList, + wantsMetaItemLayers, } from './meta-item-read-gate.js'; export type { MetaBookTreeAnswer, @@ -131,6 +141,9 @@ export type { MetaItemReadRefusal, MetaItemReadVerdict, MetaItemRequest, + MetaLayeredAnswer, + MetaLayeredAnswerSources, + MetaLayeredRequest, MetaListAnswer, MetaListAnswerSources, MetaListRequest, diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index a4d8daaabf8..da89616de83 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -49,6 +49,9 @@ * the list's unknown-type refusal ({@link refuseUnknownMetaListType}), the * object mask's cache posture ({@link projectMetaObjectSchema}) and the * organization a caller's read is scoped to ({@link metaReadOrganizationId}). + * [#20478] So does the layered view, on both of its spellings + * ({@link createMetaLayeredAnswer}, {@link wantsMetaItemLayers}, + * {@link metaItemLayersDeprecationHeaders}). * `meta-list-projection-parity.test.ts` and `meta-read-org-scope-parity.test.ts` * in `@objectstack/runtime` drive both transports over the same fixtures and * hold the answers equal. @@ -57,7 +60,7 @@ import type { AudienceCaller, Book, ResolvedBook, ResolverDoc } from '@objectstack/spec/system'; import { preferredLocaleFromHeader } from '@objectstack/spec/system'; import { apiExposureDenialReason } from '@objectstack/spec/data'; -import { resolveObjectSortability } from '@objectstack/spec/api'; +import { resolveObjectSortability, type GetMetaItemLayeredRequest } from '@objectstack/spec/api'; import { canonicalMetaUrlType, pluralToSingular, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared'; import { ObjectSchemaMaskEvaluationError, @@ -2448,6 +2451,201 @@ export function createMetaItemAnswer( }; } +// ── THE layered answer ──────────────────────────────────────────────────────── + +/** + * [#5882 · #20478] Does this query ask for the layered view through its + * DEPRECATED spelling, `GET /meta/:type/:name?layers=`? Any value but + * the empty string does (`?layers=true`, `?layers=1`, `?layers=false` alike); + * `?layers=` alone, and no `layers` at all, are the plain read. + * + * The one parse both transports ask, so a value cannot be the layered view on + * one and the plain read on the other. A caller that asks is served the layered + * view only where the protocol has one (`getMetaItemLayered`); elsewhere the + * flag is the plain read, as it always was on `RestServer`. + */ +export function wantsMetaItemLayers(query: Readonly> | undefined): boolean { + return query?.layers !== undefined && query?.layers !== ''; +} + +/** + * [#5882 · #20478] The headers every answer of the deprecated + * `?layers=` spelling carries: RFC 9745 `Deprecation`, and RFC 8288 `Link` + * naming the successor `GET /meta/:type/:name/layers` — the pairing + * `versioning.zod.ts` describes for retiring API versions, applied to a + * retiring query flag. No `Sunset` date: the hard cut-off is a maintainer call, + * and an invented date is worse than none. + * + * `itemPath` is the path the transport serves this item read at — the successor + * is that path plus `/layers`. A transport that cannot say where it is mounted + * passes `undefined` and advertises the deprecation alone: a `Link` naming a + * path this host may not serve is a machine-readable surface that lies (AGENTS.md + * 〈Route & surface ownership〉 rule 4). + */ +export function metaItemLayersDeprecationHeaders( + itemPath: string | undefined, +): { Deprecation: 'true'; Link?: string } { + return itemPath === undefined + ? { Deprecation: 'true' } + : { Deprecation: 'true', Link: `<${itemPath}/layers>; rel="successor-version"` }; +} + +/** The layers a layered answer carries, in the order the gate judges them: `effective` first — it is what the plain read serves, so its refusal is the plain read's own. */ +const META_ITEM_LAYERS = ['effective', 'code', 'overlay'] as const; + +/** The layers in the order the ADR-0106 mask projects them. */ +const META_ITEM_MASKED_LAYERS = ['code', 'overlay', 'effective'] as const; + +/** Everything {@link createMetaLayeredAnswer} reads, supplied by the transport. */ +export interface MetaLayeredAnswerSources extends MetaItemReadGateSources { + /** + * The protocol's layered read (`getMetaItemLayered`), handed the request + * this chain builds — the item, its `?package=` scope (ADR-0048) and the + * caller's organization — plus whatever the transport scopes every read by + * itself (`RestServer`: its environment). A THROW propagates: the transport + * answers that fault, ⛔ never a layered view with a layer missing. + * + * ⚠️ {@link MetaReadGateAudienceSources.resolveCaller} MUST carry + * {@link MetaReadGateCaller.mayWriteItem} here — the transport's own save + * door's admission of the item — because the layered view is a door that + * honours the author exemption ({@link STORED_VERSION_DOOR_POLICY}). Absent + * reads as `false`: every caller is served the pruned layers. + */ + readLayered(request: GetMetaItemLayeredRequest): Promise; +} + +/** The request facts the layered chain reads — no transport shape. */ +export interface MetaLayeredRequest { + /** + * The `:type` segment AS RECEIVED. The protocol folds it at its own + * boundary, so it is handed down unfolded (a pre-fold here would hide a + * drift between the two from the protocol's tests); the gate and the + * organization are decided on the fold, here. + */ + readonly type: string; + readonly name: string; + /** `?package=` — ONE owning package (ADR-0048), or `undefined`. */ + readonly packageId?: string; + /** + * [ADR-0106 D2/D3] The caller's field-visibility posture for this item, + * resolved by the transport BEFORE the read, the not-applicable passthrough + * for every type but `object`. A tier-3 fault is the transport's to answer + * before it gets here. + */ + readonly maskPosture: ObjectSchemaMaskPosture; +} + +/** + * What the layered chain answers: + * + * - `serve` — send `layered`, under `cacheControl` when it owes one + * ({@link META_UNDETERMINED_CACHE_CONTROL}, ADR-0106 D6 tier 2). No `Vary`: + * the layered view is not translated; + * - `refuse` — the gate's {@link MetaItemReadRefusal} for a layer the caller + * may not read (`absent` included: an unpublished app to a non-builder); + * - `mask-fault` — a layer's projection left the schema with no field (D6), + * which the transport answers as its field-visibility fault. + */ +export type MetaLayeredAnswer = + | { kind: 'serve'; layered: unknown; cacheControl?: typeof META_UNDETERMINED_CACHE_CONTROL } + | { kind: 'refuse'; refusal: MetaItemReadRefusal } + | { kind: 'mask-fault'; object: string }; + +/** + * [#5882 · #20156 · #20478] THE answer of the layered view — the three-layer + * diagnostic projection (`code` / `overlay` / `effective`) declared by + * `GetMetaItemLayeredResponseSchema` — on both of its spellings + * (`GET /meta/:type/:name/layers`, and the deprecated `?layers=` flag on the item + * read), on both transports. + * + * ## Why here + * + * `RestServer` served both spellings through one private helper, and the + * runtime dispatcher — the only answer on a host that mounts just the + * `${prefix}/*` catch-all — served neither: the route answered a located + * `404 ROUTE_NOT_FOUND`, and the flag answered the PLAIN read's + * `{ type, name, item }` with a `200`, so a client reading `overlay` or + * `effective` there read `undefined`, and an author was served the item pruned + * where ruling 5856774816 serves it whole. The helper moved here, unchanged, and + * each transport supplies only its I/O. ⛔ A step is added HERE, never in a + * transport — one added in one of them is the defect this closed, reopened. + * `meta-list-projection-parity.test.ts` in `@objectstack/runtime` drives both + * spellings through both transports. + * + * Not translated and not cached, both deliberately: this is a diagnostic view of + * what is STORED at each layer, so locale-collapsing it (or serving it from the + * published-value cache) would misreport the thing being diagnosed. + * + * ## The steps, in `RestServer`'s order (unchanged) + * + * 1. The read, scoped to the caller's VETTED organization + * ({@link metaReadOrganizationId} over the folded type — the partition the + * plain read reads, [#9454] so an author who has just saved an org overlay + * is not shown `overlay: null`) and to `?package=` (ADR-0048). + * 2. [#20156] THE per-caller gate on EVERY present layer, `effective` first, + * under {@link STORED_VERSION_DOOR_POLICY}: per-caller arms only (these are + * STORED versions, which Studio's designer loads and saves back), and + * ruling 5856774816 — a caller who may write an app reads every layer + * whole, and any other caller who may open it reads each layer pruned, + * exactly as the plain read prunes it. Every layer is judged before any is + * served, so a refusal sends nothing of the others. + * 3. [ADR-0106 D5(4)] The mask on every layer — each is a full object schema — + * through {@link projectMetaObjectSchema} under the posture resolved before + * the read, and the `private, no-store` an undetermined posture owes. + * + * The protocol's answer is never mutated. A layer the gate or the mask leaves + * as it was is served as it was, and every other key of the answer + * (`overlayScope`, `_diagnostics`, the ADR-0010 protection envelope) rides + * through untouched. With nothing behind the name the protocol answers every + * layer `null`, and so does this chain: no layer is present to judge. + */ +export async function createMetaLayeredAnswer( + sources: MetaLayeredAnswerSources, + request: MetaLayeredRequest, +): Promise { + const { type, name, packageId, maskPosture } = request; + + // 1. [#9454 · #20408] The read, in the caller's vetted partition. + const organizationId = metaReadOrganizationId(type, await sources.resolveCaller()); + const layered: any = await sources.readLayered({ + type, + name, + ...(packageId ? { packageId } : {}), + ...(organizationId ? { organizationId } : {}), + }); + + // 2. [#20156] THE per-caller gate, on every present layer. + const served = new Map(); + { + const present = META_ITEM_LAYERS.filter((layer) => layered?.[layer] != null); + const judge = createMetaItemReadGate( + sources, pluralToSingular(type), name, present.map((layer) => layered[layer]), STORED_VERSION_DOOR_POLICY, + ); + for (const layer of present) { + const verdict = await judge(layered[layer]); + if (verdict.kind === 'refuse') return verdict; + served.set(layer, verdict.document); + } + } + + // 3. [ADR-0106 D5(4)] The mask, on every layer. + let cacheControl: typeof META_UNDETERMINED_CACHE_CONTROL | undefined; + for (const layer of META_ITEM_MASKED_LAYERS) { + const document = served.has(layer) ? served.get(layer) : layered?.[layer]; + const masked = projectMetaObjectSchema(maskPosture, document); + if (!masked.ok) return { kind: 'mask-fault', object: name }; + cacheControl ??= masked.cacheControl; + if (masked.document !== document) served.set(layer, masked.document); + } + + if (!layered || typeof layered !== 'object') { + return cacheControl ? { kind: 'serve', layered, cacheControl } : { kind: 'serve', layered }; + } + const answer: Record = { ...layered }; + for (const [layer, document] of served) answer[layer] = document; + return cacheControl ? { kind: 'serve', layered: answer, cacheControl } : { kind: 'serve', layered: answer }; +} + // ── THE book tree ───────────────────────────────────────────────────────────── /** Everything {@link createMetaBookTreeAnswer} reads, supplied by the transport. */ diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 01de5bf7829..d41967d3606 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3716,6 +3716,17 @@ export class RestServer { * old spelling answers *the same body* — two copies would let that stop * being true without anything failing. * + * [#20478] …and behind two TRANSPORTS: the answer is + * `createMetaLayeredAnswer` in `./meta-item-read-gate.ts` — the read in the + * caller's vetted organization (#9454) and `?package=` scope (ADR-0048), THE + * per-caller gate on every layer under the stored-version doors' policy + * (#20156, ruling 5856774816: whole for whoever may write the item, pruned + * as the plain read prunes it for everyone else), and the ADR-0106 mask on + * every layer with its cache posture — which the runtime dispatcher serves + * both spellings through too. ⛔ A step is added there, never here. This + * method keeps what is this transport's own: the ingress refusal of a + * repeated `?package=`, its environment, and its wire. + * * Not translated and not cached, both deliberately: this is a diagnostic * view of what is STORED at each layer, so locale-collapsing it (or serving * it from the published-value cache) would misreport the thing being @@ -3728,103 +3739,48 @@ export class RestServer { p: any, maskPosture: ObjectSchemaMaskPosture, ): Promise { - // ADR-0048 — thread `?package=` so the layered (Studio editor) view is - // package-scoped; the editor passes the edited item's owning package, - // not the studio app's. - // // [#6877] ONE owning package, so repetition is refused rather than // resolved: `?package=a&package=b` used to reach // `getMetaItemLayered({ packageId: ['a','b'] })`. Gated in the helper, // not in its two callers, so both entry points answer identically. if (refuseRepeatedQueryParams(req, res, ['package'])) return; - const layeredPackageId = req.query?.package || undefined; - // [#9454] State the ORG scope, exactly as the `/published` overlay read - // already does. Without it the layered view resolved the env-wide row - // only, so an author who had just saved an org overlay opened Studio to - // `overlay: null` and the code layer — the write receipted as live, the - // editor reporting it absent. This is the DIAGNOSTIC view of what is - // stored per layer, so an unstated scope does not merely miss a row: it - // misreports the very thing being diagnosed. - // ⚠️ NOT a new org-resolution seam — `resolveExecCtx` is memoised per - // request (WeakMap keyed by `req`), the same result 40+ handlers here - // already share. Registry-gated via `organizationIdForMetaRead` so a - // non-overridable type keeps reading env-wide (see that predicate for - // why naming the org unconditionally would resurrect #6190's phantoms). - const layeredCtx = await this.resolveExecCtx(environmentId, req) - .catch(rethrowAuthzStoreUnavailable); - const layeredOrganizationId = organizationIdForMetaRead( - // [#10340] FOLDED, not raw — see the PUT door's org-scope comment - // for the measurement. - canonicalMetaUrlType(req.params.type), layeredCtx?.tenantId, + const answer = await metaReadGate.createMetaLayeredAnswer( + { + // [#20156] The stored-version doors honour the author exemption, + // so the caller carries this transport's save-door admission. + ...this.metaItemReadGateSources(environmentId, req, p, true), + // [#9741] Typed: the spec shape plus the transport-level + // `environmentId` (see `TransportScopedMetaRequest`), so an + // undeclared key is a compile error here too. + readLayered: (request) => { + const layeredRequest: TransportScopedMetaRequest = { + ...request, + ...(environmentId ? { environmentId } : {}), + }; + return p.getMetaItemLayered(layeredRequest); + }, + }, + { + type: req.params.type, + name: req.params.name, + // ADR-0048 — the editor passes the edited item's owning + // package, not the studio app's. + packageId: req.query?.package || undefined, + maskPosture, + }, ); - // [#9741] This door never carried an `as any`, but `p: any` meant its - // request literal was never checked either — the same blind spot with - // a different spelling. Typing the literal (spec shape + the - // transport-level `environmentId`, see `TransportScopedMetaRequest`) - // makes an undeclared key a compile error here too. - const layeredRequest: TransportScopedMetaRequest = { - type: req.params.type, - name: req.params.name, - ...(layeredPackageId ? { packageId: layeredPackageId } : {}), - ...(environmentId ? { environmentId } : {}), - ...(layeredOrganizationId ? { organizationId: layeredOrganizationId } : {}), - }; - const layered = await p.getMetaItemLayered(layeredRequest); - // [#20156] The per-caller read gate, on EVERY layer. This view used to - // run none of the plain read's gates, so a member the plain read refuses - // `crm_admin_runbook` read its body here — and an anonymous caller read - // any doc or book through the deprecated `?layers=true`, which sits on - // the publicly-reachable book/doc route. Each present layer is judged, - // `effective` first (it is what the plain read serves, so its refusal is - // the plain read's own), then `code` and `overlay`: a layer the caller - // may not read is not served beside one they may. `per-caller` because - // these are STORED versions, loaded by Studio's designer and saved - // back. - // - // [#20156] Each layer is SERVED as the gate serves it, never as - // stored: ruling 5856774816 — a caller who may write an app reads - // every layer whole, and any other caller who may open it reads each - // layer pruned, exactly as the plain read prunes it (see - // `MetaReadGatePolicy.app`). Every layer is judged before any is - // replaced, so a refusal sends nothing of the others. - { - const metaType = RestServer.metaTypeSingular(req.params.type); - const present = (['effective', 'code', 'overlay'] as const) - .filter((layer) => (layered as any)?.[layer] != null); - const judge = this.metaItemReadGate( - environmentId, req, p, metaType, req.params.name, - present.map((layer) => (layered as any)[layer]), - RestServer.STORED_VERSION_DOOR_POLICY, - ); - const served = new Map<(typeof present)[number], unknown>(); - for (const layer of present) { - const verdict = await judge((layered as any)[layer]); - if (verdict.kind === 'refuse') { - verdict.send(res); - return; - } - served.set(layer, verdict.document); - } - for (const [layer, document] of served) (layered as any)[layer] = document; - } - // [ADR-0106 D5(4)] The layered view is a schema-bearing exit — - // `code`, `overlay` and `effective` are each a full object schema. - // Both entry points (the canonical `/layers` path and the deprecated - // `?layers=` flag) pass their request's resolved posture in, so the - // extraction cannot turn the mask into a one-entry-point detour. - if (maskPosture.kind === 'project') { - for (const layer of ['code', 'overlay', 'effective'] as const) { - const masked = this.maskObjectDocument( - res, maskPosture, req.params.name, (layered as any)?.[layer], - ); - if (!masked) return; - if (layered && typeof layered === 'object') (layered as any)[layer] = masked.document; - } - } - if (maskPosture.kind === 'undetermined') { - res.header('Cache-Control', 'private, no-store'); + switch (answer.kind) { + case 'refuse': + RestServer.sendMetaReadRefusal(res, answer.refusal); + return; + case 'mask-fault': + sendFieldVisibilityFault(res, answer.object); + return; + case 'serve': + if (answer.cacheControl) res.header('Cache-Control', answer.cacheControl); + res.json(answer.layered); + return; } - res.json(layered); } /** @@ -6256,19 +6212,18 @@ export class RestServer { // headers, so a client can discover the migration without // reading the changelog. Delete this branch (and the // headers with it) once the callers have moved. - const wantLayered = req.query?.layers !== undefined && req.query?.layers !== ''; + // + // [#20478] The flag's parse (`wantsMetaItemLayers`) and its + // headers (`metaItemLayersDeprecationHeaders`: RFC 9745 + // `Deprecation` + RFC 8288 `Link` to the successor) are the + // ones the runtime dispatcher's item read asks too, so the + // deprecated spelling is one answer on both transports. + const wantLayered = metaReadGate.wantsMetaItemLayers(req.query); if (wantLayered && typeof (p as any).getMetaItemLayered === 'function') { - // RFC 9745 `Deprecation` + RFC 8288 `Link` — the same - // machine-readable pairing `versioning.zod.ts` already - // describes for retiring API versions, applied to a - // retiring query flag. No `Sunset` date: choosing the - // hard cut-off is a maintainer call, and an invented - // date is worse than none. - res.header('Deprecation', 'true'); - res.header( - 'Link', - `<${metaPath}/${req.params.type}/${req.params.name}/layers>; rel="successor-version"`, + const deprecation = metaReadGate.metaItemLayersDeprecationHeaders( + `${metaPath}/${req.params.type}/${req.params.name}`, ); + for (const [header, value] of Object.entries(deprecation)) res.header(header, value); await this.serveMetaItemLayered(req, res, environmentId, p, maskPosture); return; } diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 3519d199a54..91fe544e19d 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -52,14 +52,19 @@ import type { MetadataProtocol } from '@objectstack/spec/api'; // [#20408] …and the item route's post-read chain, the book-tree route, the // list's unknown-type refusal, the object mask's cache posture and the // organization a caller's `/meta` request is scoped to. +// [#20478] …and the layered view, on both of its spellings: its whole answer, +// the deprecated `?layers=` flag's parse and the headers that flag is served +// under. // Imported, never restated — AGENTS.md 〈Route & surface ownership〉 rule 1. import { createMetaBookTreeAnswer, createMetaItemAnswer, createMetaItemReadGate, + createMetaLayeredAnswer, createMetaListAnswer, isPublicAudienceRead, metaCallerOrganizationId, + metaItemLayersDeprecationHeaders, metaReadOrganizationId, metaRequestLocale, projectMetaObjectSchema, @@ -67,9 +72,12 @@ import { STORED_VERSION_DOOR_POLICY, translateMetaEnvelope, translateMetaList, + wantsMetaItemLayers, type MetaItemAnswer, type MetaItemAnswerSources, type MetaItemReadGateSources, + type MetaLayeredAnswer, + type MetaLayeredAnswerSources, type MetaListAnswerSources, type MetaListTranslationSources, type MetaPublicReadRoute, @@ -219,8 +227,10 @@ export function createMetaDomain(deps: DomainHandlerDeps): DomainRoute { * [#20408] `book/:name/tree` is one of them now: this domain serves the route * `RestServer` serves ({@link isBookTreePath}), with its type segment LITERAL * as there — `/meta/books/:name/tree` is no route on either transport. Every - * other path of three or more segments is `/published`, the FSM - * `/state/:field` read or the located `ROUTE_NOT_FOUND`, and none is exempt. + * other path of three or more segments is `/published`, [#20478] `/layers`, the + * FSM `/state/:field` read or the located `ROUTE_NOT_FOUND`, and none is exempt + * — as on `RestServer`, where `/layers` keeps the anonymous deny and only the + * item read's `?layers=` flag reaches a `public` book's §6.7 gate. */ function metaReadRouteOf(parts: readonly string[]): MetaPublicReadRoute | undefined { if (parts.length === 1) return 'list'; @@ -247,11 +257,28 @@ function successWithHeaders( deps: DomainHandlerDeps, data: unknown, headers: Readonly>, +): HttpDispatcherResult { + return withHeaders(deps.success(data), headers); +} + +/** + * [#20478] ANY `deps.*` answer, carrying the headers a `/meta` read owes it — + * the ONE place this domain builds an answer with headers ({@link + * successWithHeaders} is its success case). The deprecated `?layers=` flag owes + * `Deprecation` and `Link` on EVERY answer it gets, refusals included, as on + * `RestServer`, which sets them before it reads; `deps.error` takes no headers + * either. The body is the `deps.*` helper's declared envelope, spread. A header + * whose value is `undefined` is not owed; with none owed the answer is the + * helper's own. + */ +function withHeaders( + response: { status: number; body: any }, + headers: Readonly>, ): HttpDispatcherResult { const set: Record = {}; for (const [name, value] of Object.entries(headers)) if (value !== undefined) set[name] = value; - if (Object.keys(set).length === 0) return { handled: true, response: deps.success(data) }; - return { handled: true, response: { ...deps.success(data), headers: set } }; + if (Object.keys(set).length === 0) return { handled: true, response }; + return { handled: true, response: { ...response, headers: set } }; } /** @@ -621,12 +648,37 @@ async function answerMetaItem( } } -/** [#20320] This transport's save-door admission of `:type/:name` — see `saveVerdict` in {@link handleMetadataRequest}. */ +/** [#20320] This transport's save-door admission of `:type/:name` — see {@link metaSaveVerdictOf}. */ type MetaSaveVerdict = ( canonicalType: string, activeOrganizationId: string | undefined, ) => ReturnType; +/** + * [#12702 · #20320 · #20478] May this caller SAVE `:type/:name` on THIS + * transport? The admission of the item branch's `PUT`, spelled ONCE: that door + * asks it, and so does every read door that honours the author exemption — + * the `?state=draft` read and the layered view, on both of its spellings + * (ruling 5856774816, item 1: 「whoever can save it must see it whole, or a + * save drops entries silently」), which `MetaReadGateCaller.mayWriteItem` says + * must be the transport's own save-door answer. A second spelling at a read + * could drift from the door it stands for. `canonicalType` is the folded + * segment and `activeOrganizationId` the one resolution each caller already + * made, so authorization and scope read one value. + */ +function metaSaveVerdictOf(context: HttpProtocolContext): MetaSaveVerdict { + return (canonicalType, activeOrganizationId) => { + const ec: any = context.executionContext; + return metaWriteCapabilityVerdict({ + isSystem: ec?.isSystem === true, + systemPermissions: ec?.systemPermissions, + canonicalType, + activeOrganizationId, + operation: 'save', + }); + }; +} + /** * [#20320] `GET /meta/:type/:name?state=draft` for a caller who may read * drafts — the pending draft ROW (ADR-0033), answered exactly as `RestServer`'s @@ -690,6 +742,93 @@ async function readPendingDraft( ); } +/** + * [#20478] The path THIS request's item read arrived on, as the client sent it + * — `/api/v1/meta/app/crm` for `GET /api/v1/meta/app/crm?layers=true` — read + * off the request's own URL (`createHonoApp` hands `dispatch()` the raw Fetch + * `Request`, whose `url` is absolute; a Node request's is path-only), without + * its query and trailing slash. `undefined` when the request carries no URL. + * + * It is the path the deprecated `?layers=` spelling names its successor under + * (`metaItemLayersDeprecationHeaders`). This domain is handed a path with the + * host's API prefix stripped, so the request's own URL is the only statement of + * where this host serves the item; with none there is no successor this + * transport can name, and the flag is answered with `Deprecation` alone. + */ +function requestedItemPath(context: HttpProtocolContext): string | undefined { + const url = (context.request as { url?: unknown } | undefined)?.url; + if (typeof url !== 'string' || url.length === 0) return undefined; + try { + const path = new URL(url, 'http://dispatcher.invalid').pathname.replace(/\/+$/, ''); + return path.length > 0 ? path : undefined; + } catch { + return undefined; + } +} + +/** [#20478] A protocol whose `getMetaItemLayered` the caller has PROBED — the handle {@link answerMetaLayered} reads through. */ +type MetaLayeredProtocol = MetaDomainProtocol & Required>; + +/** + * [#20478] Answer the layered view — `GET /meta/:type/:name/layers`, and the + * deprecated `?layers=` flag on the item read — through THE layered chain + * (`createMetaLayeredAnswer`, the one `RestServer` serves both spellings + * through; ⛔ no step lives here) and write its answer on this transport's wire. + * + * The chain reads the protocol's `getMetaItemLayered` in the caller's VETTED + * organization ({@link metaReadOrganizationId}, the partition the plain read + * reads) and `?package=` scope, judges every present layer under + * `STORED_VERSION_DOOR_POLICY` — whole for a caller this transport's save door + * admits ({@link metaSaveVerdictOf}, carried as `mayWriteItem`), pruned as the + * plain read prunes it for everyone else (ruling 5856774816) — and projects + * every layer through the ADR-0106 mask under the posture resolved before the + * read. The dispatcher served neither spelling: the route answered a located + * `404 ROUTE_NOT_FOUND`, and the flag answered the PLAIN read's + * `{ type, name, item }` with a `200`. + * + * The answers, in this transport's envelope, with `RestServer`'s status and + * code: the layered answer (no `Vary`: it is not translated; `private, + * no-store` when an undetermined posture served a schema unmasked); + * `absent` → the SAME `deps.error('Not found', 404)` the item read answers for + * a name with nothing behind it (ADR-0045 §3); `app-permission` / + * `docs-audience` → `403 PERMISSION_DENIED` / `401 UNAUTHENTICATED`; + * `mask-fault` → the D6 field-visibility fault; a read or gate input that + * could not be read → that fault, `500` for a shapeless one — ⛔ never a layered + * view with a layer missing. `headers` (the flag's `Deprecation` and `Link`) + * ride EVERY one of those answers. + */ +async function answerMetaLayered( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + protocol: MetaLayeredProtocol, + request: { type: string; name: string; packageId: string | undefined; maskPosture: ObjectSchemaMaskPosture }, + headers: Readonly> = {}, +): Promise { + const caller = context.executionContext as MetaReadGateCaller | undefined; + const mayWriteItem = metaSaveVerdictOf(context)(canonicalMetaUrlType(request.type), metaCallerOrganizationId(caller)).allowed; + const sources: MetaLayeredAnswerSources = { + ...metaItemReadGateSources(deps, context, protocol, mayWriteItem), + readLayered: (layeredRequest) => protocol.getMetaItemLayered(layeredRequest), + }; + let answer: MetaLayeredAnswer; + try { + answer = await createMetaLayeredAnswer(sources, request); + } catch (e: any) { + return withHeaders(deps.errorFromThrown(e, 500), headers); + } + switch (answer.kind) { + case 'serve': + return withHeaders(deps.success(answer.layered), { ...headers, 'Cache-Control': answer.cacheControl }); + case 'mask-fault': + return withHeaders(fieldVisibilityFault(deps, answer.object).response!, headers); + case 'refuse': { + const { refusal } = answer; + if (refusal.reason === 'absent') return withHeaders(deps.error('Not found', 404), headers); + return withHeaders(deps.error(refusal.message, refusal.status, { code: refusal.code }), headers); + } + } +} + /** * Percent-decode the `:name` path segment. [#12195] * @@ -852,6 +991,47 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin } } + // GET /metadata/:type/:name/layers — the three-layer diagnostic projection + // (`code` / `overlay` / `effective`, `GetMetaItemLayeredResponseSchema`) as + // its own resource (#5882). + // + // [#20478] `RestServer` serves this route, and this domain had none: the + // path fell to the located `ROUTE_NOT_FOUND` tail below. The whole answer is + // `createMetaLayeredAnswer` in `@objectstack/rest` ({@link + // answerMetaLayered}), the one `RestServer`'s handler calls. EXACTLY three + // segments, like `/published` beside it (#12195). 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')) { + const type = parts[0]; + const name = decodeMetaNameSegment(parts[1]); + const protocol = await resolveProtocol(deps, _context); + // [ADR-0106 D2/D5] Its own schema-serving outlet: the caller's posture, + // resolved before the read with the FOLDED type, as `RestServer`'s route + // resolves it — and before the capability probe, in the same order. + let maskPosture: ObjectSchemaMaskPosture; + try { + maskPosture = await (await resolveObjectMasker(deps, _context, pluralToSingular(type)))(name); + } catch (maskError) { + if (maskError instanceof ObjectSchemaMaskEvaluationError) return fieldVisibilityFault(deps, name); + throw maskError; + } + if (!protocol || typeof protocol.getMetaItemLayered !== 'function') { + // A dedicated path cannot fall through to the plain read the way the + // `?layers=` flag does — `RestServer`'s `501 NOT_IMPLEMENTED`, in this + // transport's envelope. + return { + handled: true, + response: deps.error('Layered metadata view not supported by protocol implementation', 501), + }; + } + const packageId = query?.package || undefined; + return answerMetaLayered( + deps, _context, protocol as MetaLayeredProtocol, + { type, name, packageId, maskPosture }, + ); + } + // GET /metadata/:type/:name/published → get published version // // [#12195] EXACTLY three segments, and no fold. This used to be @@ -1000,25 +1180,11 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin const packageId = query?.package || undefined; // [#12702 · #20320] May this caller SAVE `:type/:name` on THIS - // transport? The admission of the `PUT` branch below, spelled ONCE: - // that branch asks it, and so does the `?state=draft` read's author - // exemption (ruling 5856774816, item 1: 「whoever can save it must see - // it whole, or a save drops entries silently」), which - // `MetaReadGateCaller.mayWriteItem` says must be the transport's own - // save-door answer. A second spelling at the read could drift from the - // door it stands for. `canonicalType` is the folded segment and - // `activeOrganizationId` the one resolution each caller already made, - // so authorization and scope read one value. - const saveVerdict: MetaSaveVerdict = (canonicalType, activeOrganizationId) => { - const ec: any = _context.executionContext; - return metaWriteCapabilityVerdict({ - isSystem: ec?.isSystem === true, - systemPermissions: ec?.systemPermissions, - canonicalType, - activeOrganizationId, - operation: 'save', - }); - }; + // transport? The admission of the `PUT` branch below, spelled ONCE + // ({@link metaSaveVerdictOf}): that branch asks it, and so do the read + // doors that honour the author exemption — the `?state=draft` read + // below and [#20478] the layered view. + const saveVerdict: MetaSaveVerdict = metaSaveVerdictOf(_context); // PUT /metadata/:type/:name (Save) // @@ -1337,6 +1503,28 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin } try { + // [#5882 · #20478] The DEPRECATED spelling of the layered view, + // `?layers=` — the parse `RestServer`'s item + // read asks (`wantsMetaItemLayers`), answered FIRST, before either + // draft switch, as there. Where the protocol has a layered read the + // flag is the layered view ({@link answerMetaLayered}, the route's + // own answer), with `Deprecation` and a `Link` to the successor on + // every answer; where it has none the flag is the plain read, as it + // always was on `RestServer`. It answered the plain read's + // `{ type, name, item }` here whatever the protocol could do — a + // `200` whose `overlay` and `effective` read `undefined`, and an + // app pruned for its author where ruling 5856774816 serves it whole. + if (wantsMetaItemLayers(query)) { + const protocol = await resolveProtocol(deps, _context); + if (protocol && typeof protocol.getMetaItemLayered === 'function') { + return await answerMetaLayered( + deps, _context, protocol as MetaLayeredProtocol, + { type, name, packageId, maskPosture }, + metaItemLayersDeprecationHeaders(requestedItemPath(_context)), + ); + } + } + if (isDraftRead) { return await readPendingDraft( deps, _context, type, name, packageId, previewDrafts, saveVerdict, { maskPosture, query }, From 59810b743641e6fd5ae96f6a51c1e8246594616a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:32:34 +0800 Subject: [PATCH 2/6] test(runtime): the layered view joins the /meta census on both spellings, and its org scope is pinned The item census's `?layers=` probe replaces its one declared exclusion, `GET /meta/:type/:name/layers` gets its own route census derived from `RestServer`'s handler and helper, and the answers compare `Deprecation` and `Link` too. The org-scope pins drive both spellings for a current member and for a member whose session claim the resolver dropped. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- .../meta-list-projection-parity.test.ts | 278 +++++++++++++++--- .../meta-read-org-scope-parity.test.ts | 41 +++ packages/runtime/src/domains/meta.ts | 18 +- 3 files changed, 293 insertions(+), 44 deletions(-) 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 e67e5138575..6b39804f0a2 100644 --- a/packages/runtime/src/domains/meta-list-projection-parity.test.ts +++ b/packages/runtime/src/domains/meta-list-projection-parity.test.ts @@ -39,6 +39,14 @@ * translation step asks). A new projection that reads a new parameter or keys * on a new type reddens the derivation block until it is a cell, and the cell * reddens until the dispatcher answers it. + * + * [#20478] The layered view joined the census on both of its spellings: the + * item read's deprecated `?layers=` flag is an item probe like every other + * parameter, and `GET /meta/:type/:name/layers` has its own route census. The + * dispatcher answered the flag with the plain read's `{ type, name, item }` and + * the route with a located `404 ROUTE_NOT_FOUND`; both transports now hand it to + * `createMetaLayeredAnswer`, and the item census no longer excludes any + * parameter `RestServer`'s item read reads. */ import { describe, it, expect, vi } from 'vitest'; @@ -160,6 +168,23 @@ const STORE: Record = { flow: [{ name: 'on_lead', label: 'On lead', type: 'autolaunched' }], }; +/** + * [#20478] Env-wide overlay rows (a `sys_metadata` `state: 'active'` row over a + * packaged baseline), the `overlay` layer of the layered view: `crm` relabelled + * with one more gated entry, and `invoice` relabelled. + */ +const OVERLAYS: Record = { + app: [{ + ...CRM_APP, + label: 'CRM (overlay)', + navigation: [ + ...CRM_APP.navigation, + { id: 'nav_finance_reports', type: 'page', label: 'Reports', pageName: 'reports', requiredPermissions: ['finance.access'] }, + ], + }], + object: [{ ...INVOICE_OBJECT, label: 'Invoice (overlay)' }], +}; + /** Pending drafts (ADR-0033): relabelled, plus an entry that exists only in the draft. */ const DRAFTS: Record = { app: [ @@ -238,8 +263,8 @@ const LIST_CALLERS = ['holder', 'non-holder', 'builder', 'anonymous'] as const s // ── The services both transports read ───────────────────────────────────────── /** The protocol's read rules: `?package=` scopes, `previewDrafts` overlays, `state: 'draft'` reads the pending row or throws `NO_DRAFT`. */ -function protocolDouble() { - return { +function protocolDouble(opts: { withoutLayered?: boolean } = {}) { + const double = { getMetaTypes: vi.fn(async () => ({ types: Object.keys(STORE) })), getMetaItems: vi.fn(async ({ type, packageId, previewDrafts }: any) => { const t = singular(type); @@ -261,7 +286,28 @@ function protocolDouble() { const item = previewDrafts && draft ? { ...clone(draft), _draft: true } : (active ? clone(active) : undefined); return { type: t, name, item }; }), + /** + * [#20478] The layered read, in the shape `metadata-protocol` answers it: + * `code` the packaged baseline (scoped by `?package=`, ADR-0048), + * `overlay` the active overlay row, `effective` the overlay over the code + * layer — every layer `null` with nothing behind the name. + */ + getMetaItemLayered: vi.fn(async ({ type, name, packageId }: any) => { + const t = singular(type); + const code = (STORE[t] ?? []).find((i) => i.name === name && (!packageId || i._packageId === packageId)); + const overlay = (OVERLAYS[t] ?? []).find((i) => i.name === name); + const effective = overlay ?? code; + return { + type: t, name, + code: code ? clone(code) : null, + overlay: overlay ? clone(overlay) : null, + overlayScope: overlay ? 'env' : null, + effective: effective ? clone(effective) : null, + lock: 'none', editable: true, deletable: true, resettable: overlay !== undefined, + }; + }), }; + return opts.withoutLayered ? { ...double, getMetaItemLayered: undefined } : double; } const securityFor = (caller: Caller) => ({ @@ -283,17 +329,25 @@ const endpointMatcher = { // ── The two transports ──────────────────────────────────────────────────────── -interface Answer { status: number; code?: string; body: any; items?: any[]; item?: any; vary?: string; cacheControl?: string } +interface Answer { + status: number; code?: string; body: any; items?: any[]; item?: any; vary?: string; cacheControl?: string; + /** [#20478] The deprecated `?layers=` flag's RFC 9745 / RFC 8288 pair. */ + deprecation?: string; link?: string; +} const itemsOf = (data: any): any[] | undefined => Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : undefined; -interface Boot { withI18n?: boolean } +interface Boot { + withI18n?: boolean; + /** [#20478] A protocol with no layered read (`getMetaItemLayered` absent). */ + withoutLayered?: boolean; +} /** The dispatcher, exactly as `createHonoApp` builds it: `new HttpDispatcher(kernel)`. */ function bootDispatcher(callerName: CallerName, opts: Boot = {}) { const caller: Caller = CALLERS[callerName]; - const protocol = protocolDouble(); + const protocol = protocolDouble(opts); const services: Record = { protocol, security: securityFor(caller), @@ -306,13 +360,20 @@ function bootDispatcher(callerName: CallerName, opts: Boot = {}) { // The seam `dispatch()` resolves identity through (as the sibling dispatch-level pins do). (dispatcher as any).timedResolveExecutionContext = async () => clone(caller.ctx); const read = async (path: string, query: Record = {}, headers: Record = {}): Promise => { - const res = await dispatcher.dispatch('GET', path, undefined, query, { request: { headers } } as any); + // [#20478] The request carries its own URL, as `createHonoApp` hands + // `dispatch()` the raw Fetch `Request` (`c.req.raw`) — the only + // statement of where this host serves the item, which the `?layers=` + // flag names its successor under. + const request = { headers, url: `http://localhost/api/v1${path}` }; + const res = await dispatcher.dispatch('GET', path, undefined, query, { request } as any); const status = res.response?.status ?? 0; const body = res.response?.body; const vary = (res.response as any)?.headers?.Vary; const cacheControl = (res.response as any)?.headers?.['Cache-Control']; + const deprecation = (res.response as any)?.headers?.Deprecation; + const link = (res.response as any)?.headers?.Link; return { - status, code: body?.error?.code, body, vary, cacheControl, + status, code: body?.error?.code, body, vary, cacheControl, deprecation, link, items: status === 200 ? itemsOf(body?.data) : undefined, item: status === 200 ? body?.data?.item : undefined, }; @@ -340,7 +401,7 @@ function makeRes() { /** `RestServer` over the same services and the same caller — the reference answer. */ function bootRest(callerName: CallerName, opts: Boot = {}) { const caller: Caller = CALLERS[callerName]; - const protocol = protocolDouble(); + const protocol = protocolDouble(opts); const rest: any = new RestServer(createMockServer() as any, protocol as any, {} as any); if (caller.ctx.userId) { const ctx = caller.ctx; @@ -362,8 +423,11 @@ function bootRest(callerName: CallerName, opts: Boot = {}) { await routeOf(routePath).handler({ method: 'GET', path: `${META}${path}`, params, query, body: {}, headers }, res); const status = res.statusCode; return { - status, code: res.body?.code ?? res.body?.error?.code, body: res.body, vary: res.headers.Vary, + // [#20478] A refusal's code: a served layered body carries its own + // `code` — the packaged layer — which is not one. + status, code: status === 200 ? undefined : res.body?.code ?? res.body?.error?.code, body: res.body, vary: res.headers.Vary, cacheControl: res.headers['Cache-Control'], + deprecation: res.headers.Deprecation, link: res.headers.Link, items: status === 200 ? itemsOf(res.body) : undefined, item: status === 200 ? res.body?.item : undefined, }; @@ -373,7 +437,11 @@ function bootRest(callerName: CallerName, opts: Boot = {}) { if (type === 'book' && rest_.length === 1 && rest_[0] === 'tree') { return drive(`${META}/book/:name/tree`, path.replace(/^\/meta/, ''), { name }, query, headers); } - if (rest_.length > 0) throw new Error(`bootRest.read drives list, item and book-tree reads only: ${path}`); + // [#20478] The layered view's own route. + if (rest_.length === 1 && rest_[0] === 'layers') { + return drive(`${META}/:type/:name/layers`, path.replace(/^\/meta/, ''), { type, name }, query, headers); + } + if (rest_.length > 0) throw new Error(`bootRest.read drives list, item, layers and book-tree reads only: ${path}`); return name === undefined ? drive(`${META}/:type`, path.replace(/^\/meta/, ''), { type }, query, headers) : drive(`${META}/:type/:name`, path.replace(/^\/meta/, ''), { type, name }, query, headers); @@ -694,18 +762,6 @@ describe('[#20320] the ?state=draft row: the dispatcher\'s item read answers wha // `RestServer` answers `private, no-store`. Each is now a census cell, and each // census is derived from `RestServer`'s handler like the list's above. -/** - * The query parameters `RestServer`'s item handler reads that the dispatcher - * deliberately does not answer — each one a ROUTE this transport does not - * serve, named here so the derivation below still fails on any OTHER new one. - */ -const ITEM_PARAMS_NOT_SERVED_HERE: Readonly> = { - // The deprecated spelling of `GET /meta/:type/:name/layers` — the layered - // view, a route the dispatcher does not serve (ADR-0076 item 9: the - // catch-all is the fallback fabric, not a second `RestServer`). - layers: 'the deprecated `?layers=` spelling of the /layers route, which the dispatcher does not serve', -}; - const ITEM_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record; headers?: Record }> = [ { label: '(none)', query: {} }, { label: '?state=draft', param: 'state', query: { state: 'draft' } }, @@ -715,6 +771,10 @@ const ITEM_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record< { label: '?package=crm', param: 'package', query: { package: 'crm' } }, { label: '?locale=zh-CN', param: 'locale', query: { locale: 'zh-CN' } }, { label: 'Accept-Language: zh-CN', query: {}, headers: { 'accept-language': 'zh-CN' } }, + // [#20478] The layered view's deprecated spelling — any non-empty value — + // 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: '' } }, ]; const ITEM_PARAM_AXIS = new Set(ITEM_PROBES.map((p) => p.param).filter((p): p is string => !!p)); @@ -746,7 +806,19 @@ const TREE_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record< const TREE_PARAM_AXIS = new Set(TREE_PROBES.map((p) => p.param).filter((p): p is string => !!p)); const TREE_CALLERS = ['holder', 'non-holder', 'builder', 'anonymous'] as const satisfies readonly CallerName[]; -/** The item and tree answers compared whole: the envelope (or tree) served, and the two headers either transport sets. */ +/** + * [#20478] `GET /meta/:type/:name/layers` — the layered view's own route: one + * probe per parameter it reads, and the locale a control (the view is not + * translated, so `Accept-Language` must move nothing on either transport). + */ +const LAYERS_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record; headers?: Record }> = [ + { label: '(none)', query: {} }, + { label: '?package=crm', param: 'package', query: { package: 'crm' } }, + { label: 'Accept-Language: zh-CN', query: {}, headers: { 'accept-language': 'zh-CN' } }, +]; +const LAYERS_PARAM_AXIS = new Set(LAYERS_PROBES.map((p) => p.param).filter((p): p is string => !!p)); + +/** The item and tree answers compared whole: the envelope (or tree) served, and the headers either transport sets. */ const served = (a: Answer, transport: 'dispatcher' | 'rest'): unknown => (a.status === 200 ? (transport === 'dispatcher' ? a.body?.data : a.body) : undefined); const sameAnswer = (dispatcher: Answer, rest: Answer): boolean => @@ -754,11 +826,15 @@ const sameAnswer = (dispatcher: Answer, rest: Answer): boolean => && dispatcher.code === rest.code && JSON.stringify(served(dispatcher, 'dispatcher')) === JSON.stringify(served(rest, 'rest')) && (rest.status !== 200 || dispatcher.vary === rest.vary) - && dispatcher.cacheControl === rest.cacheControl; + && dispatcher.cacheControl === rest.cacheControl + // [#20478] On EVERY answer of the deprecated flag, refusals included. + && dispatcher.deprecation === rest.deprecation + && dispatcher.link === rest.link; +const headersOf = (a: Answer) => ({ vary: a.vary, cacheControl: a.cacheControl, deprecation: a.deprecation, link: a.link }); const mismatchOf = (who: string, dispatcher: Answer, rest: Answer) => ({ who, - dispatcher: { status: dispatcher.status, code: dispatcher.code, vary: dispatcher.vary, cacheControl: dispatcher.cacheControl, body: JSON.stringify(served(dispatcher, 'dispatcher'))?.slice(0, 400) }, - rest: { status: rest.status, code: rest.code, vary: rest.vary, cacheControl: rest.cacheControl, body: JSON.stringify(served(rest, 'rest'))?.slice(0, 400) }, + dispatcher: { status: dispatcher.status, code: dispatcher.code, ...headersOf(dispatcher), body: JSON.stringify(served(dispatcher, 'dispatcher'))?.slice(0, 400) }, + rest: { status: rest.status, code: rest.code, ...headersOf(rest), body: JSON.stringify(served(rest, 'rest'))?.slice(0, 400) }, }); /** @@ -767,10 +843,17 @@ const mismatchOf = (who: string, dispatcher: Answer, rest: Answer) => ({ * starts reading is a derived cell too. A name absent from the module derives * nothing (the handler's own reads still do). */ -const SHARED_ITEM_FUNCTIONS = ['createMetaItemAnswer', 'createMetaItemReadGate', 'metaRequestLocale', 'translateMetaDocument', 'translateMetaEnvelope']; +const SHARED_ITEM_FUNCTIONS = [ + 'createMetaItemAnswer', 'createMetaItemReadGate', 'metaRequestLocale', 'translateMetaDocument', 'translateMetaEnvelope', + // [#20478] The `?layers=` flag's parse, and the layered answer it serves. + 'wantsMetaItemLayers', 'createMetaLayeredAnswer', +]; const SHARED_TREE_FUNCTIONS = ['createMetaBookTreeAnswer', 'resolveDocsAudience', 'metaRequestLocale']; +const SHARED_LAYERS_FUNCTIONS = ['createMetaLayeredAnswer', 'createMetaItemReadGate']; +/** [#20478] `RestServer`'s own helper both layered spellings call — read as part of each handler. */ +const LAYERED_METHODS = ['serveMetaItemLayered']; -function deriveRouteReads(routePath: string, sharedFunctions: readonly string[]): Derived { +function deriveRouteReads(routePath: string, sharedFunctions: readonly string[], methods: readonly string[] = []): Derived { const derived: Derived = { params: new Set(), types: new Set(), found: false }; const rest = parse(REST_SERVER_SOURCE); const visit = (n: ts.Node): void => { @@ -783,6 +866,9 @@ function deriveRouteReads(routePath: string, sharedFunctions: readonly string[]) readsUnder(rest, handler, derived); } } + // [#20478] A private helper the handler delegates to reads its own + // parameters a frame down (`serveMetaItemLayered` reads `?package=`). + if (ts.isMethodDeclaration(n) && ts.isIdentifier(n.name) && methods.includes(n.name.text)) readsUnder(rest, n, derived); ts.forEachChild(n, visit); }; visit(rest); @@ -794,19 +880,26 @@ function deriveRouteReads(routePath: string, sharedFunctions: readonly string[]) } describe('[#20408] the item and book-tree census axes cover every read of RestServer\'s handlers and the shared steps', () => { - const item = deriveRouteReads('`${metaPath}/:type/:name`', SHARED_ITEM_FUNCTIONS); + const item = deriveRouteReads('`${metaPath}/:type/:name`', SHARED_ITEM_FUNCTIONS, LAYERED_METHODS); const tree = deriveRouteReads('`${metaPath}/book/:name/tree`', SHARED_TREE_FUNCTIONS); + const layers = deriveRouteReads('`${metaPath}/:type/:name/layers`', SHARED_LAYERS_FUNCTIONS, LAYERED_METHODS); - it('both handlers are found where the census looks for them', () => { + it('every handler is found where the census looks for it', () => { expect(item.found, `GET \${metaPath}/:type/:name in ${REST_SERVER_SOURCE}`).toBe(true); expect(tree.found, `GET \${metaPath}/book/:name/tree in ${REST_SERVER_SOURCE}`).toBe(true); + expect(layers.found, `GET \${metaPath}/:type/:name/layers in ${REST_SERVER_SOURCE}`).toBe(true); }); - it('every query parameter the item read reads is a census cell, or a route this transport does not serve, by name', () => { - const missing = [...item.params].filter((p) => !ITEM_PARAM_AXIS.has(p) && !(p in ITEM_PARAMS_NOT_SERVED_HERE)).sort(); + it('every query parameter the item read reads is a census cell — [#20478] `?layers=` included, with no exclusion left', () => { + const missing = [...item.params].filter((p) => !ITEM_PARAM_AXIS.has(p)).sort(); expect(missing, 'an item read of a parameter this census never sends: add a probe that moves its answer').toEqual([]); - // The exclusions stay honest: each is still read by the handler. - expect(Object.keys(ITEM_PARAMS_NOT_SERVED_HERE).filter((p) => !item.params.has(p))).toEqual([]); + // The flag is read, so its probe is not a dead cell. + expect(item.params.has('layers')).toBe(true); + }); + + it('[#20478] every query parameter the layered route reads is a census cell', () => { + expect([...layers.params].filter((p) => !LAYERS_PARAM_AXIS.has(p)).sort()).toEqual([]); + expect(layers.params.has('package')).toBe(true); }); it('every type the item read keys on, and every translatable type, is a census cell', () => { @@ -856,6 +949,115 @@ describe('[#20408] the item read: the reference moves — each probe changes Res }); }); +// ── [#20478] The layered view, on both of its spellings ────────────────────── +// +// Measured on this file's fixtures before the fix: every `?layers=true` item +// cell was red (the dispatcher answered the plain read's `{ type, name, item }`, +// with no `Deprecation` and, for the author, the app PRUNED where ruling +// 5856774816 serves it whole), and every `/layers` cell answered a located +// `404 ROUTE_NOT_FOUND`. The item census above carries the flag as a probe; +// the route has its own census here. + +describe('[#20478] GET /meta/:type/:name/layers: the dispatcher serves the route RestServer serves — every cell × query parameter × caller', () => { + for (const path of ITEM_CELLS) { + for (const probe of LAYERS_PROBES) { + it(`GET ${path}/layers ${probe.label}`, async () => { + const mismatches: unknown[] = []; + for (const who of ITEM_CALLERS) { + const dispatcher = await bootDispatcher(who).read(`${path}/layers`, probe.query, probe.headers); + const rest = await bootRest(who).read(`${path}/layers`, probe.query, probe.headers); + if (!sameAnswer(dispatcher, rest)) mismatches.push(mismatchOf(who, dispatcher, rest)); + } + expect(mismatches).toEqual([]); + }); + } + } +}); + +describe('[#20478] the layered view: the reference moves — each spelling and probe changes RestServer\'s answer', () => { + const nav = (doc: any): string[] => (doc?.navigation ?? []).map((e: any) => e.id); + const layerNav = (a: Answer) => ({ code: nav(a.body?.code), overlay: nav(a.body?.overlay), effective: nav(a.body?.effective) }); + const WHOLE = ['nav_leads', 'nav_finance_ledger', 'nav_admin_runbook', 'nav_org_directory']; + const PRUNED = ['nav_leads', 'nav_org_directory']; + + it('?layers=true answers the three layers, not the plain read, under Deprecation and a Link to the successor', async () => { + const { read } = bootRest('author'); + const flagged = await read('/meta/app/crm', { layers: 'true' }); + expect(Object.keys(flagged.body ?? {})).toEqual(expect.arrayContaining(['code', 'overlay', 'effective'])); + expect(flagged.body?.item).toBeUndefined(); + expect(flagged.body?.effective?.label).toBe('CRM (overlay)'); + expect({ deprecation: flagged.deprecation, link: flagged.link }) + .toEqual({ deprecation: 'true', link: '; rel="successor-version"' }); + const plain = await read('/meta/app/crm', { layers: '' }); + expect(plain.body?.item?.label).toBe('CRM'); + expect(plain.deprecation).toBeUndefined(); + }); + + it('the route answers the flag\'s body, with no Deprecation', async () => { + const { read } = bootRest('author'); + const route = await read('/meta/app/crm/layers'); + const flagged = await read('/meta/app/crm', { layers: 'true' }); + expect(route.body).toEqual(flagged.body); + expect({ deprecation: route.deprecation, link: route.link }).toEqual({ deprecation: undefined, link: undefined }); + }); + + it('ruling 5856774816: every layer whole for the author, pruned per caller for everyone else — the plain read prunes the author too', async () => { + const author = await bootRest('author').read('/meta/app/crm/layers'); + expect(layerNav(author)).toEqual({ code: WHOLE, overlay: [...WHOLE, 'nav_finance_reports'], effective: [...WHOLE, 'nav_finance_reports'] }); + const member = await bootRest('non-holder').read('/meta/app/crm/layers'); + expect(layerNav(member)).toEqual({ code: PRUNED, overlay: PRUNED, effective: PRUNED }); + // Per caller only: the service-bound entry and widget stay on a stored version. + expect(nav((await bootRest('author').read('/meta/app/crm')).item)).toEqual(['nav_leads']); + const ops = await bootRest('holder').read('/meta/dashboard/ops/layers'); + expect((ops.body?.effective?.widgets ?? []).map((w: any) => w.id)).toEqual(['w_open_cases', 'w_org_kpi']); + expect(((await bootRest('holder').read('/meta/dashboard/ops')).item?.widgets ?? []).map((w: any) => w.id)).toEqual(['w_open_cases']); + }); + + it('a refusal is the plain read\'s, and the flag still carries its Deprecation on it', async () => { + const payroll = await bootRest('non-holder').read('/meta/app/payroll', { layers: 'true' }); + expect({ status: payroll.status, code: payroll.code, deprecation: payroll.deprecation }) + .toEqual({ status: 403, code: 'PERMISSION_DENIED', deprecation: 'true' }); + const launchpad = await bootRest('non-holder').read('/meta/app/launchpad/layers'); + expect({ status: launchpad.status, code: launchpad.code }).toEqual({ status: 404, code: 'RESOURCE_NOT_FOUND' }); + }); + + it('?package= scopes the code layer, and the object mask projects every layer', async () => { + const scoped = await bootRest('non-holder').read('/meta/app/payroll/layers', { package: 'crm' }); + expect({ status: scoped.status, code: scoped.body?.code, effective: scoped.body?.effective }).toEqual({ status: 200, code: null, effective: null }); + const invoice = await bootRest('non-holder').read('/meta/object/invoice/layers'); + for (const layer of ['code', 'overlay', 'effective']) expect(Object.keys(invoice.body?.[layer]?.fields ?? {}), layer).toEqual(['amount']); + }); +}); + +describe('[#20478] the layered view: the controls', () => { + it('an anonymous ?layers=true on a public book reaches its §6.7 gate on both transports; a gated one is refused; /layers keeps the deny', async () => { + for (const [path, query, status] of [ + ['/meta/book/public_guide', { layers: 'true' }, 200], + ['/meta/doc/public_faq', { layers: 'true' }, 200], + ['/meta/doc/crm_admin_runbook', { layers: 'true' }, 401], + ['/meta/book/public_guide/layers', {}, 401], + ] as const) { + const { read, protocol } = bootDispatcher('anonymous'); + const dispatcher = await read(path, query); + const rest = await bootRest('anonymous').read(path, query); + expect({ status: rest.status }, path).toEqual({ status }); + expect(sameAnswer(dispatcher, rest) ? [] : [mismatchOf('anonymous', dispatcher, rest)], path).toEqual([]); + expect(text(dispatcher), path).not.toContain(DOC_SECRET); + if (path.endsWith('/layers')) expect(protocol.getMetaItemLayered, path).not.toHaveBeenCalled(); + } + }); + + it('a protocol with no layered read: /layers is 501 NOT_IMPLEMENTED and ?layers=true the plain read, on both transports', async () => { + for (const [path, query] of [['/meta/app/crm/layers', {}], ['/meta/app/crm', { layers: 'true' }]] as const) { + const dispatcher = await bootDispatcher('author', { withoutLayered: true }).read(path, query); + const rest = await bootRest('author', { withoutLayered: true }).read(path, query); + expect(sameAnswer(dispatcher, rest) ? [] : [mismatchOf('author', dispatcher, rest)], path).toEqual([]); + if (path.endsWith('/layers')) expect({ status: rest.status, code: rest.code }).toEqual({ status: 501, code: 'NOT_IMPLEMENTED' }); + else expect({ status: rest.status, label: rest.item?.label, deprecation: rest.deprecation }).toEqual({ status: 200, label: 'CRM', deprecation: undefined }); + } + }); +}); + describe('[#20408] GET /meta/book/:name/tree: the dispatcher serves the route RestServer serves — every book × query parameter × caller', () => { for (const book of TREE_CELLS) { for (const probe of TREE_PROBES) { @@ -884,7 +1086,11 @@ describe('[#20408] GET /meta/book/:name/tree: the dispatcher serves the route Re }); describe('[#20408] an undetermined field visibility (ADR-0106 D6 tier 2) serves the object schema `private, no-store` on both transports', () => { - const OBJECT_PATHS = ['/meta/object', '/meta/objects', '/meta/object/invoice', '/meta/objects/invoice']; + const OBJECT_PATHS = [ + '/meta/object', '/meta/objects', '/meta/object/invoice', '/meta/objects/invoice', + // [#20478] The layered view's own route. + '/meta/object/invoice/layers', '/meta/objects/invoice/layers', + ]; it('the reference: RestServer serves the unmasked schema under `private, no-store`, and a determined caller\'s answer carries no such header', async () => { for (const path of OBJECT_PATHS) { @@ -895,7 +1101,7 @@ describe('[#20408] an undetermined field visibility (ADR-0106 D6 tier 2) serves }); for (const path of OBJECT_PATHS) { - const probes = path.split('/').length === 3 ? PARAM_PROBES : ITEM_PROBES; + const probes = path.split('/').length === 3 ? PARAM_PROBES : path.endsWith('/layers') ? LAYERS_PROBES : ITEM_PROBES; for (const probe of probes) { it(`GET ${path} ${probe.label} × undetermined`, async () => { const dispatcher = await bootDispatcher('undetermined').read(path, probe.query, probe.headers); diff --git a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts index 5dd4c0383ee..29901154093 100644 --- a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts +++ b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts @@ -356,3 +356,44 @@ describe('[#20408] a session claim the resolver DROPPED scopes nothing, on eithe expect(landed(d.protocol)).toEqual(landed(r.protocol)); }); }); + +// ── [#20478] The layered view, on both of its spellings ─────────────────────── + +/** + * The layered view reads `getMetaItemLayered` in the caller's partition, as the + * plain read does (#9454) — and, on both transports now, through the one chain + * (`createMetaLayeredAnswer`), which takes that partition from the VETTED + * organization (`metaReadOrganizationId`). The dispatcher served neither + * spelling before: the route answered `404 ROUTE_NOT_FOUND` and the flag the + * plain read, so these rows had no layered answer there to scope at all. + */ +describe('[#20478] the layered view scopes a caller to the organization RestServer scopes them to', () => { + const SPELLINGS = [ + ['/layers', '/meta/view/lead_all/layers', {}], + ['?layers=true', '/meta/view/lead_all', { layers: 'true' }], + ] as const; + const layers = (a: Answer) => ({ status: a.status, overlay: a.data?.overlay?.label, effective: a.data?.effective?.label }); + + for (const [spelling, path, query] of SPELLINGS) { + it(`${spelling}: a CURRENT member reads its own organization's overlay on both transports (control)`, async () => { + for (const boot of [bootDispatcher, bootRest]) { + const { call } = boot(); + expect(layers(await call('GET', 'member', path, query))).toEqual({ status: 200, overlay: 'Alpha pipeline', effective: 'Alpha pipeline' }); + } + }); + + it(`${spelling}: the ex-member reads the env-wide row on both transports — never the left organization's overlay`, async () => { + const d = bootDispatcher(); + const r = bootRest(); + const dispatcher = await d.call('GET', 'exmember', path, query); + const rest = await r.call('GET', 'exmember', path, query); + expect(layers(rest)).toEqual({ status: 200, overlay: 'All leads', effective: 'All leads' }); + expect(layers(dispatcher)).toEqual(layers(rest)); + expect(JSON.stringify(dispatcher.data)).not.toContain('Alpha'); + // The partition each transport asked for: none — the claim was dropped. + const asked = (p: ReturnType) => p.getMetaItemLayered.mock.calls.map(([req]: any[]) => req.organizationId); + expect(asked(r.protocol)).toEqual([undefined]); + expect(asked(d.protocol)).toEqual(asked(r.protocol)); + }); + } +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 91fe544e19d..faf365d6765 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -305,13 +305,15 @@ function notifyMissingEndpointMatcher(surface: string): void { * an empty-fields 200. Mirrors the REST layer's `sendFieldVisibilityFault`. */ function fieldVisibilityFault(deps: DomainHandlerDeps, objectName: string): HttpDispatcherResult { - return { - handled: true, - response: deps.error( - `Field visibility for object '${objectName}' could not be evaluated; the object schema is not being served.`, - 503, - ), - }; + return { handled: true, response: fieldVisibilityFaultResponse(deps, objectName) }; +} + +/** [#20478] {@link fieldVisibilityFault}'s response alone, for an answer that owes it headers ({@link withHeaders}). */ +function fieldVisibilityFaultResponse(deps: DomainHandlerDeps, objectName: string): { status: number; body: any } { + return deps.error( + `Field visibility for object '${objectName}' could not be evaluated; the object schema is not being served.`, + 503, + ); } /** @@ -820,7 +822,7 @@ async function answerMetaLayered( case 'serve': return withHeaders(deps.success(answer.layered), { ...headers, 'Cache-Control': answer.cacheControl }); case 'mask-fault': - return withHeaders(fieldVisibilityFault(deps, answer.object).response!, headers); + return withHeaders(fieldVisibilityFaultResponse(deps, answer.object), headers); case 'refuse': { const { refusal } = answer; if (refusal.reason === 'absent') return withHeaders(deps.error('Not found', 404), headers); From 494a5198703a0122a2890d8524ea4b8ec91abbbc Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:39:20 +0800 Subject: [PATCH 3/6] refactor(rest, runtime): the layered chain starts after the store read, as the item chain does `createMetaLayeredAnswer` now takes each transport's read answer, like `createMetaItemAnswer`: the read stays in `RestServer`'s helper and in the dispatcher, each scoped by `metaReadOrganizationId`. `RestServer` keeps its own execution-context site there, so the exec-ctx consumer census holds at 66 sites and 90 mentions, unedited. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- packages/rest/src/index.ts | 13 +- packages/rest/src/meta-item-read-gate.ts | 161 ++++++++++------------- packages/rest/src/rest-server.ts | 87 +++++++----- packages/runtime/src/domains/meta.ts | 48 ++++--- 4 files changed, 157 insertions(+), 152 deletions(-) diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index a10be15b83c..79513b4a1c1 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -107,12 +107,12 @@ export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-mu // context (`metaCallerOrganizationId`, and `metaReadOrganizationId` for a read // of one type). // -// [#20478] …and the layered view's, on both of its spellings: the whole answer -// (`createMetaLayeredAnswer` — the read in the caller's vetted partition, the -// per-caller gate on every layer under the stored-version doors' policy, the -// object mask and its cache posture), the deprecated `?layers=` flag's parse -// (`wantsMetaItemLayers`) and the headers it is served under -// (`metaItemLayersDeprecationHeaders`). +// [#20478] …and the layered view's, on both of its spellings: its post-read +// chain (`createMetaLayeredAnswer` — the per-caller gate on every layer under +// the stored-version doors' policy, the object mask and its cache posture), the +// deprecated `?layers=` flag's parse (`wantsMetaItemLayers`) and the headers it +// is served under (`metaItemLayersDeprecationHeaders`). The read itself is each +// transport's, scoped by `metaReadOrganizationId`. export { createMetaBookTreeAnswer, createMetaItemAnswer, @@ -142,7 +142,6 @@ export type { MetaItemReadVerdict, MetaItemRequest, MetaLayeredAnswer, - MetaLayeredAnswerSources, MetaLayeredRequest, MetaListAnswer, MetaListAnswerSources, diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index da89616de83..3fb3f6b5a25 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -60,7 +60,7 @@ import type { AudienceCaller, Book, ResolvedBook, ResolverDoc } from '@objectstack/spec/system'; import { preferredLocaleFromHeader } from '@objectstack/spec/system'; import { apiExposureDenialReason } from '@objectstack/spec/data'; -import { resolveObjectSortability, type GetMetaItemLayeredRequest } from '@objectstack/spec/api'; +import { resolveObjectSortability } from '@objectstack/spec/api'; import { canonicalMetaUrlType, pluralToSingular, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared'; import { ObjectSchemaMaskEvaluationError, @@ -2496,36 +2496,11 @@ const META_ITEM_LAYERS = ['effective', 'code', 'overlay'] as const; /** The layers in the order the ADR-0106 mask projects them. */ const META_ITEM_MASKED_LAYERS = ['code', 'overlay', 'effective'] as const; -/** Everything {@link createMetaLayeredAnswer} reads, supplied by the transport. */ -export interface MetaLayeredAnswerSources extends MetaItemReadGateSources { - /** - * The protocol's layered read (`getMetaItemLayered`), handed the request - * this chain builds — the item, its `?package=` scope (ADR-0048) and the - * caller's organization — plus whatever the transport scopes every read by - * itself (`RestServer`: its environment). A THROW propagates: the transport - * answers that fault, ⛔ never a layered view with a layer missing. - * - * ⚠️ {@link MetaReadGateAudienceSources.resolveCaller} MUST carry - * {@link MetaReadGateCaller.mayWriteItem} here — the transport's own save - * door's admission of the item — because the layered view is a door that - * honours the author exemption ({@link STORED_VERSION_DOOR_POLICY}). Absent - * reads as `false`: every caller is served the pruned layers. - */ - readLayered(request: GetMetaItemLayeredRequest): Promise; -} - /** The request facts the layered chain reads — no transport shape. */ export interface MetaLayeredRequest { - /** - * The `:type` segment AS RECEIVED. The protocol folds it at its own - * boundary, so it is handed down unfolded (a pre-fold here would hide a - * drift between the two from the protocol's tests); the gate and the - * organization are decided on the fold, here. - */ - readonly type: string; + /** The SINGULAR type (the caller folds `/meta/apps/:name` once, at its boundary). */ + readonly metaType: string; readonly name: string; - /** `?package=` — ONE owning package (ADR-0048), or `undefined`. */ - readonly packageId?: string; /** * [ADR-0106 D2/D3] The caller's field-visibility posture for this item, * resolved by the transport BEFORE the read, the not-applicable passthrough @@ -2554,96 +2529,100 @@ export type MetaLayeredAnswer = /** * [#5882 · #20156 · #20478] THE answer of the layered view — the three-layer * diagnostic projection (`code` / `overlay` / `effective`) declared by - * `GetMetaItemLayeredResponseSchema` — on both of its spellings - * (`GET /meta/:type/:name/layers`, and the deprecated `?layers=` flag on the item - * read), on both transports. + * `GetMetaItemLayeredResponseSchema` — after the store read, on both of its + * spellings (`GET /meta/:type/:name/layers`, and the deprecated `?layers=` flag + * on the item read), on both transports. * - * ## Why here + * ## Why one chain * * `RestServer` served both spellings through one private helper, and the * runtime dispatcher — the only answer on a host that mounts just the * `${prefix}/*` catch-all — served neither: the route answered a located * `404 ROUTE_NOT_FOUND`, and the flag answered the PLAIN read's * `{ type, name, item }` with a `200`, so a client reading `overlay` or - * `effective` there read `undefined`, and an author was served the item pruned - * where ruling 5856774816 serves it whole. The helper moved here, unchanged, and - * each transport supplies only its I/O. ⛔ A step is added HERE, never in a - * transport — one added in one of them is the defect this closed, reopened. + * `effective` there read `undefined`, and an author was served the app pruned + * where ruling 5856774816 serves it whole. Everything the helper did after the + * read moved here, unchanged, and each transport hands its read's answer to + * THIS function. ⛔ A step is added HERE, never in a transport — one added in + * one of them is the defect this closed, reopened. * `meta-list-projection-parity.test.ts` in `@objectstack/runtime` drives both * spellings through both transports. * + * The read stays each transport's, in the caller's VETTED partition + * ({@link metaReadOrganizationId} over the folded type — the partition the plain + * read reads, [#9454] so an author who has just saved an org overlay is not + * shown `overlay: null`) and its `?package=` scope (ADR-0048), exactly as the + * item read's does ({@link createMetaItemAnswer}). + * * Not translated and not cached, both deliberately: this is a diagnostic view of * what is STORED at each layer, so locale-collapsing it (or serving it from the * published-value cache) would misreport the thing being diagnosed. * * ## The steps, in `RestServer`'s order (unchanged) * - * 1. The read, scoped to the caller's VETTED organization - * ({@link metaReadOrganizationId} over the folded type — the partition the - * plain read reads, [#9454] so an author who has just saved an org overlay - * is not shown `overlay: null`) and to `?package=` (ADR-0048). - * 2. [#20156] THE per-caller gate on EVERY present layer, `effective` first, - * under {@link STORED_VERSION_DOOR_POLICY}: per-caller arms only (these are - * STORED versions, which Studio's designer loads and saves back), and - * ruling 5856774816 — a caller who may write an app reads every layer - * whole, and any other caller who may open it reads each layer pruned, - * exactly as the plain read prunes it. Every layer is judged before any is - * served, so a refusal sends nothing of the others. - * 3. [ADR-0106 D5(4)] The mask on every layer — each is a full object schema — + * 1. [#20156] THE per-caller gate on EVERY present layer, `effective` first + * (it is what the plain read serves, so its refusal is the plain read's + * own), under {@link STORED_VERSION_DOOR_POLICY}: per-caller arms only + * (these are STORED versions, which Studio's designer loads and saves + * back), and ruling 5856774816 — a caller who may write the item reads + * every layer whole, and any other caller who may open it reads each layer + * pruned, exactly as the plain read prunes it. ⚠️ So the transport's + * {@link MetaReadGateAudienceSources.resolveCaller} MUST carry + * {@link MetaReadGateCaller.mayWriteItem} — its own save door's admission; + * absent reads as `false` (every caller pruned). Every layer is judged + * before any is served, so a refusal sends nothing of the others. + * 2. [ADR-0106 D5(4)] The mask on every layer — each is a full object schema — * through {@link projectMetaObjectSchema} under the posture resolved before * the read, and the `private, no-store` an undetermined posture owes. * - * The protocol's answer is never mutated. A layer the gate or the mask leaves - * as it was is served as it was, and every other key of the answer + * The protocol's answer is never mutated. A layer the gate or the mask leaves as + * it was is served as it was, and every other key of the answer * (`overlayScope`, `_diagnostics`, the ADR-0010 protection envelope) rides * through untouched. With nothing behind the name the protocol answers every - * layer `null`, and so does this chain: no layer is present to judge. + * layer `null`, and so does this chain: no layer is present to judge. A gate + * input that cannot be read REJECTS: the transport answers that fault, ⛔ never + * a layered view with a layer missing. */ -export async function createMetaLayeredAnswer( - sources: MetaLayeredAnswerSources, +export function createMetaLayeredAnswer( + sources: MetaItemReadGateSources, request: MetaLayeredRequest, -): Promise { - const { type, name, packageId, maskPosture } = request; - - // 1. [#9454 · #20408] The read, in the caller's vetted partition. - const organizationId = metaReadOrganizationId(type, await sources.resolveCaller()); - const layered: any = await sources.readLayered({ - type, - name, - ...(packageId ? { packageId } : {}), - ...(organizationId ? { organizationId } : {}), - }); +): (layered: unknown) => Promise { + const { metaType, name, maskPosture } = request; + return async (raw) => { + const layered = raw as Record | null | undefined; - // 2. [#20156] THE per-caller gate, on every present layer. - const served = new Map(); - { - const present = META_ITEM_LAYERS.filter((layer) => layered?.[layer] != null); - const judge = createMetaItemReadGate( - sources, pluralToSingular(type), name, present.map((layer) => layered[layer]), STORED_VERSION_DOOR_POLICY, - ); - for (const layer of present) { - const verdict = await judge(layered[layer]); - if (verdict.kind === 'refuse') return verdict; - served.set(layer, verdict.document); + // 1. [#20156] THE per-caller gate, on every present layer. + const served = new Map(); + { + const present = META_ITEM_LAYERS.filter((layer) => layered?.[layer] != null); + const judge = createMetaItemReadGate( + sources, metaType, name, present.map((layer) => layered![layer]), STORED_VERSION_DOOR_POLICY, + ); + for (const layer of present) { + const verdict = await judge(layered![layer]); + if (verdict.kind === 'refuse') return verdict; + served.set(layer, verdict.document); + } } - } - // 3. [ADR-0106 D5(4)] The mask, on every layer. - let cacheControl: typeof META_UNDETERMINED_CACHE_CONTROL | undefined; - for (const layer of META_ITEM_MASKED_LAYERS) { - const document = served.has(layer) ? served.get(layer) : layered?.[layer]; - const masked = projectMetaObjectSchema(maskPosture, document); - if (!masked.ok) return { kind: 'mask-fault', object: name }; - cacheControl ??= masked.cacheControl; - if (masked.document !== document) served.set(layer, masked.document); - } + // 2. [ADR-0106 D5(4)] The mask, on every layer. + let cacheControl: typeof META_UNDETERMINED_CACHE_CONTROL | undefined; + for (const layer of META_ITEM_MASKED_LAYERS) { + const document = served.has(layer) ? served.get(layer) : layered?.[layer]; + const masked = projectMetaObjectSchema(maskPosture, document); + if (!masked.ok) return { kind: 'mask-fault', object: name }; + cacheControl ??= masked.cacheControl; + if (masked.document !== document) served.set(layer, masked.document); + } - if (!layered || typeof layered !== 'object') { - return cacheControl ? { kind: 'serve', layered, cacheControl } : { kind: 'serve', layered }; - } - const answer: Record = { ...layered }; - for (const [layer, document] of served) answer[layer] = document; - return cacheControl ? { kind: 'serve', layered: answer, cacheControl } : { kind: 'serve', layered: answer }; + let answer: unknown = raw; + if (layered && typeof layered === 'object') { + const replaced: Record = { ...layered }; + for (const [layer, document] of served) replaced[layer] = document; + answer = replaced; + } + return cacheControl ? { kind: 'serve', layered: answer, cacheControl } : { kind: 'serve', layered: answer }; + }; } // ── THE book tree ───────────────────────────────────────────────────────────── diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index d41967d3606..419f5cfde14 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3716,16 +3716,15 @@ export class RestServer { * old spelling answers *the same body* — two copies would let that stop * being true without anything failing. * - * [#20478] …and behind two TRANSPORTS: the answer is - * `createMetaLayeredAnswer` in `./meta-item-read-gate.ts` — the read in the - * caller's vetted organization (#9454) and `?package=` scope (ADR-0048), THE - * per-caller gate on every layer under the stored-version doors' policy - * (#20156, ruling 5856774816: whole for whoever may write the item, pruned - * as the plain read prunes it for everyone else), and the ADR-0106 mask on - * every layer with its cache posture — which the runtime dispatcher serves - * both spellings through too. ⛔ A step is added there, never here. This - * method keeps what is this transport's own: the ingress refusal of a - * repeated `?package=`, its environment, and its wire. + * [#20478] …and behind two TRANSPORTS: everything after the store read is + * `createMetaLayeredAnswer` in `./meta-item-read-gate.ts` — THE per-caller + * gate on every layer under the stored-version doors' policy (#20156, + * ruling 5856774816: whole for whoever may write the item, pruned as the + * plain read prunes it for everyone else) and the ADR-0106 mask on every + * layer with its cache posture — which the runtime dispatcher serves both + * spellings through too. ⛔ A step is added there, never here. The read + * stays this transport's, scoped by `metaReadOrganizationId`, the one + * answer the dispatcher's layered read asks. * * Not translated and not cached, both deliberately: this is a diagnostic * view of what is STORED at each layer, so locale-collapsing it (or serving @@ -3739,36 +3738,56 @@ export class RestServer { p: any, maskPosture: ObjectSchemaMaskPosture, ): Promise { + // ADR-0048 — thread `?package=` so the layered (Studio editor) view is + // package-scoped; the editor passes the edited item's owning package, + // not the studio app's. + // // [#6877] ONE owning package, so repetition is refused rather than // resolved: `?package=a&package=b` used to reach // `getMetaItemLayered({ packageId: ['a','b'] })`. Gated in the helper, // not in its two callers, so both entry points answer identically. if (refuseRepeatedQueryParams(req, res, ['package'])) return; + const layeredPackageId = req.query?.package || undefined; + // [#9454] State the ORG scope, exactly as the `/published` overlay read + // already does. Without it the layered view resolved the env-wide row + // only, so an author who had just saved an org overlay opened Studio to + // `overlay: null` and the code layer — the write receipted as live, the + // editor reporting it absent. This is the DIAGNOSTIC view of what is + // stored per layer, so an unstated scope does not merely miss a row: it + // misreports the very thing being diagnosed. + // ⚠️ NOT a new org-resolution seam — `resolveExecCtx` is memoised per + // request (WeakMap keyed by `req`), the same result 40+ handlers here + // already share. Registry-gated via `organizationIdForMetaRead` so a + // non-overridable type keeps reading env-wide (see that predicate for + // why naming the org unconditionally would resurrect #6190's phantoms). + const layeredCtx = await this.resolveExecCtx(environmentId, req) + .catch(rethrowAuthzStoreUnavailable); + // [#10340] FOLDED, not raw — see the PUT door's org-scope comment for + // the measurement. [#20478] Asked of `metaReadOrganizationId` (the + // same fold over the vetted `tenantId`), the one answer the runtime + // dispatcher's layered read asks too. + const layeredOrganizationId = metaReadGate.metaReadOrganizationId(req.params.type, layeredCtx); + // [#9741] This door never carried an `as any`, but `p: any` meant its + // request literal was never checked either — the same blind spot with + // a different spelling. Typing the literal (spec shape + the + // transport-level `environmentId`, see `TransportScopedMetaRequest`) + // makes an undeclared key a compile error here too. + const layeredRequest: TransportScopedMetaRequest = { + type: req.params.type, + name: req.params.name, + ...(layeredPackageId ? { packageId: layeredPackageId } : {}), + ...(environmentId ? { environmentId } : {}), + ...(layeredOrganizationId ? { organizationId: layeredOrganizationId } : {}), + }; + const layered = await p.getMetaItemLayered(layeredRequest); + // [#20156 · #20478] THE per-caller gate on every layer, then the mask — + // the shared chain. The stored-version doors honour the author + // exemption, so the caller carries this transport's save-door + // admission (`metaItemReadGateSources(…, true)`). const answer = await metaReadGate.createMetaLayeredAnswer( - { - // [#20156] The stored-version doors honour the author exemption, - // so the caller carries this transport's save-door admission. - ...this.metaItemReadGateSources(environmentId, req, p, true), - // [#9741] Typed: the spec shape plus the transport-level - // `environmentId` (see `TransportScopedMetaRequest`), so an - // undeclared key is a compile error here too. - readLayered: (request) => { - const layeredRequest: TransportScopedMetaRequest = { - ...request, - ...(environmentId ? { environmentId } : {}), - }; - return p.getMetaItemLayered(layeredRequest); - }, - }, - { - type: req.params.type, - name: req.params.name, - // ADR-0048 — the editor passes the edited item's owning - // package, not the studio app's. - packageId: req.query?.package || undefined, - maskPosture, - }, - ); + this.metaItemReadGateSources(environmentId, req, p, true), + { metaType: RestServer.metaTypeSingular(req.params.type), name: req.params.name, maskPosture }, + )(layered); switch (answer.kind) { case 'refuse': RestServer.sendMetaReadRefusal(res, answer.refusal); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index faf365d6765..7b9eaadf435 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -77,7 +77,6 @@ import { type MetaItemAnswerSources, type MetaItemReadGateSources, type MetaLayeredAnswer, - type MetaLayeredAnswerSources, type MetaListAnswerSources, type MetaListTranslationSources, type MetaPublicReadRoute, @@ -773,20 +772,22 @@ type MetaLayeredProtocol = MetaDomainProtocol & Required> = {}, ): Promise { + const { type, name, packageId, maskPosture } = request; const caller = context.executionContext as MetaReadGateCaller | undefined; - const mayWriteItem = metaSaveVerdictOf(context)(canonicalMetaUrlType(request.type), metaCallerOrganizationId(caller)).allowed; - const sources: MetaLayeredAnswerSources = { - ...metaItemReadGateSources(deps, context, protocol, mayWriteItem), - readLayered: (layeredRequest) => protocol.getMetaItemLayered(layeredRequest), - }; + const organizationId = metaReadOrganizationId(type, caller); + const mayWriteItem = metaSaveVerdictOf(context)(canonicalMetaUrlType(type), metaCallerOrganizationId(caller)).allowed; let answer: MetaLayeredAnswer; try { - answer = await createMetaLayeredAnswer(sources, request); + const layered = await protocol.getMetaItemLayered({ + type, + name, + ...(packageId ? { packageId } : {}), + ...(organizationId ? { organizationId } : {}), + }); + answer = await createMetaLayeredAnswer( + metaItemReadGateSources(deps, context, protocol, mayWriteItem), + { metaType: pluralToSingular(type), name, maskPosture }, + )(layered); } catch (e: any) { return withHeaders(deps.errorFromThrown(e, 500), headers); } From 148af0aa9dfad6d5efc3d62973bef887e191ebf1 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:44:05 +0800 Subject: [PATCH 4/6] =?UTF-8?q?chore(changeset):=20the=20dispatcher's=20la?= =?UTF-8?q?yered=20view=20=E2=80=94=20rest=20minor,=20runtime=20patch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- .changeset/20478-dispatcher-meta-layers.md | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .changeset/20478-dispatcher-meta-layers.md diff --git a/.changeset/20478-dispatcher-meta-layers.md b/.changeset/20478-dispatcher-meta-layers.md new file mode 100644 index 00000000000..0d93acc7038 --- /dev/null +++ b/.changeset/20478-dispatcher-meta-layers.md @@ -0,0 +1,37 @@ +--- +'@objectstack/rest': minor +'@objectstack/runtime': patch +--- + +fix(rest, runtime): the runtime dispatcher serves the layered view, `GET /meta/:type/:name/layers` and the deprecated `?layers=` flag, as `RestServer` serves it (#20478) + +Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains three value exports (`createMetaLayeredAnswer`, `wantsMetaItemLayers`, `metaItemLayersDeprecationHeaders`) and two type exports (`MetaLayeredAnswer`, `MetaLayeredRequest`). Nothing any published version exported is removed, renamed or narrowed. `@objectstack/runtime` publishes no new surface and stays a `patch`. + +A host that mounts only the `${prefix}/*` catch-all (`createHonoApp`, and any +adapter written on the public `HttpDispatcher` API) serves `/meta` through the +runtime dispatcher. Until now, on such a host: + +- **`GET /meta/:type/:name?layers=true` answered the plain read.** The body was + `{ type, name, item }` with a `200`, so a client reading `code`, `overlay` or + `effective` read `undefined`. There was no `Deprecation` header and no `Link` + to the successor. An author (a caller the item's save door admits) was served + the app pruned, where the layered view serves them every layer whole. +- **`GET /meta/:type/:name/layers` was no route.** It answered a located + `404 ROUTE_NOT_FOUND`. + +Both spellings now answer what `RestServer` answers: the three layers, each +judged by the per-caller read gate under the stored-version doors' policy +(whole for a caller who may save the item, pruned as the plain read prunes it +for everyone else), each projected through the object-schema field mask, and +`private, no-store` when the caller's field visibility could not be determined. +The read is scoped to the caller's vetted organization and to `?package=`. The +flag's answers, refusals included, carry `Deprecation: true`, and a `Link` to +`/layers` built from the request's own URL (every `createHonoApp` request +carries one; a host that hands `dispatch()` no URL gets `Deprecation` alone). The route answers `501 NOT_IMPLEMENTED` where the protocol has no +layered read, and the flag is then the plain read, on both transports. + +**What changed.** Everything `RestServer`'s layered helper does after the store +read moved, unchanged, into `createMetaLayeredAnswer`, and the flag's parse and +headers into `wantsMetaItemLayers` and `metaItemLayersDeprecationHeaders`. The +dispatcher's `/meta` domain calls all three. `RestServer`'s own answers are +unchanged: every existing REST test passes unedited. From 79c967f58657edb0922510c7ed20097f8aab7e95 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:50:48 +0800 Subject: [PATCH 5/6] refactor(runtime): the save-door verdict stays inside handleMetadataRequest, hoisted above every branch The layered read asks the same `saveVerdict` the `PUT` branch and the `?state=draft` read ask; it is now declared once at the top of the handler instead of in a new module-level function, so the elevation-read census anchors it where it already did. Two moved comments now cite a record that resolves. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- packages/rest/src/rest-server.ts | 3 +- packages/runtime/src/domains/meta.ts | 77 +++++++++++++--------------- 2 files changed, 37 insertions(+), 43 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 419f5cfde14..365f0a2c7d4 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3762,7 +3762,8 @@ export class RestServer { // why naming the org unconditionally would resurrect #6190's phantoms). const layeredCtx = await this.resolveExecCtx(environmentId, req) .catch(rethrowAuthzStoreUnavailable); - // [#10340] FOLDED, not raw — see the PUT door's org-scope comment for + // [folded-type commit 26f3588fb] (the original card no longer + // resolves) FOLDED, not raw — see the PUT door's org-scope comment for // the measurement. [#20478] Asked of `metaReadOrganizationId` (the // same fold over the vetted `tenantId`), the one answer the runtime // dispatcher's layered read asks too. diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 7b9eaadf435..6bafe6c4c86 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -649,37 +649,12 @@ async function answerMetaItem( } } -/** [#20320] This transport's save-door admission of `:type/:name` — see {@link metaSaveVerdictOf}. */ +/** [#20320] This transport's save-door admission of `:type/:name` — see `saveVerdict` in {@link handleMetadataRequest}. */ type MetaSaveVerdict = ( canonicalType: string, activeOrganizationId: string | undefined, ) => ReturnType; -/** - * [#12702 · #20320 · #20478] May this caller SAVE `:type/:name` on THIS - * transport? The admission of the item branch's `PUT`, spelled ONCE: that door - * asks it, and so does every read door that honours the author exemption — - * the `?state=draft` read and the layered view, on both of its spellings - * (ruling 5856774816, item 1: 「whoever can save it must see it whole, or a - * save drops entries silently」), which `MetaReadGateCaller.mayWriteItem` says - * must be the transport's own save-door answer. A second spelling at a read - * could drift from the door it stands for. `canonicalType` is the folded - * segment and `activeOrganizationId` the one resolution each caller already - * made, so authorization and scope read one value. - */ -function metaSaveVerdictOf(context: HttpProtocolContext): MetaSaveVerdict { - return (canonicalType, activeOrganizationId) => { - const ec: any = context.executionContext; - return metaWriteCapabilityVerdict({ - isSystem: ec?.isSystem === true, - systemPermissions: ec?.systemPermissions, - canonicalType, - activeOrganizationId, - operation: 'save', - }); - }; -} - /** * [#20320] `GET /meta/:type/:name?state=draft` for a caller who may read * drafts — the pending draft ROW (ADR-0033), answered exactly as `RestServer`'s @@ -782,12 +757,12 @@ type MetaLayeredProtocol = MetaDomainProtocol & Required> = {}, ): Promise { const { type, name, packageId, maskPosture } = request; const caller = context.executionContext as MetaReadGateCaller | undefined; const organizationId = metaReadOrganizationId(type, caller); - const mayWriteItem = metaSaveVerdictOf(context)(canonicalMetaUrlType(type), metaCallerOrganizationId(caller)).allowed; + const mayWriteItem = saveVerdict(canonicalMetaUrlType(type), metaCallerOrganizationId(caller)).allowed; let answer: MetaLayeredAnswer; try { const layered = await protocol.getMetaItemLayered({ @@ -881,6 +857,29 @@ function decodeMetaNameSegment(segment: string): string { */ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: string, _context: HttpProtocolContext, method?: string, body?: any, query?: any): Promise { const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); + + // [#12702 · #20320] May this caller SAVE `:type/:name` on THIS transport? + // The admission of the item branch's `PUT`, spelled ONCE: that door asks + // it, and so does every read door that honours the author exemption — the + // `?state=draft` read and [#20478] the layered view, on both of its + // spellings (ruling 5856774816, item 1: 「whoever can save it must see it + // whole, or a save drops entries silently」), which + // `MetaReadGateCaller.mayWriteItem` says must be the transport's own + // save-door answer. A second spelling at a read could drift from the door + // it stands for. `canonicalType` is the folded segment and + // `activeOrganizationId` the one resolution each caller already made, so + // authorization and scope read one value. [#20478] Declared here, above + // every branch, so the `/layers` branch asks the same one. + const saveVerdict: MetaSaveVerdict = (canonicalType, activeOrganizationId) => { + const ec: any = _context.executionContext; + return metaWriteCapabilityVerdict({ + isSystem: ec?.isSystem === true, + systemPermissions: ec?.systemPermissions, + canonicalType, + activeOrganizationId, + operation: 'save', + }); + }; // Defense-in-depth: the metadata catch-all must honour the same // anonymous-deny (#2567) as the REST `/meta` routes (which serve `/meta` on // the cloud runtime). Object/field schemas — SYSTEM-object schemas on a @@ -1009,7 +1008,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // path fell to the located `ROUTE_NOT_FOUND` tail below. The whole answer is // `createMetaLayeredAnswer` in `@objectstack/rest` ({@link // answerMetaLayered}), the one `RestServer`'s handler calls. EXACTLY three - // segments, like `/published` beside it (#12195). The anonymous gate above + // 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')) { @@ -1038,7 +1037,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin const packageId = query?.package || undefined; return answerMetaLayered( deps, _context, protocol as MetaLayeredProtocol, - { type, name, packageId, maskPosture }, + { type, name, packageId, maskPosture }, saveVerdict, ); } @@ -1189,12 +1188,6 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // Extract optional package filter from query string const packageId = query?.package || undefined; - // [#12702 · #20320] May this caller SAVE `:type/:name` on THIS - // transport? The admission of the `PUT` branch below, spelled ONCE - // ({@link metaSaveVerdictOf}): that branch asks it, and so do the read - // doors that honour the author exemption — the `?state=draft` read - // below and [#20478] the layered view. - const saveVerdict: MetaSaveVerdict = metaSaveVerdictOf(_context); // PUT /metadata/:type/:name (Save) // @@ -1529,7 +1522,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (protocol && typeof protocol.getMetaItemLayered === 'function') { return await answerMetaLayered( deps, _context, protocol as MetaLayeredProtocol, - { type, name, packageId, maskPosture }, + { type, name, packageId, maskPosture }, saveVerdict, metaItemLayersDeprecationHeaders(requestedItemPath(_context)), ); } From fef1c8e6607cd4826b995ef13f4f93f3f67de0e6 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:07:22 +0800 Subject: [PATCH 6/6] docs(runtime): the dispatcher's layered-view comments name the post-read chain, not the whole answer Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- packages/runtime/src/domains/meta.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 6bafe6c4c86..b4ddb362cc2 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -52,9 +52,9 @@ import type { MetadataProtocol } from '@objectstack/spec/api'; // [#20408] …and the item route's post-read chain, the book-tree route, the // list's unknown-type refusal, the object mask's cache posture and the // organization a caller's `/meta` request is scoped to. -// [#20478] …and the layered view, on both of its spellings: its whole answer, -// the deprecated `?layers=` flag's parse and the headers that flag is served -// under. +// [#20478] …and the layered view, on both of its spellings: its post-read +// chain, the deprecated `?layers=` flag's parse and the headers that flag is +// served under. // Imported, never restated — AGENTS.md 〈Route & surface ownership〉 rule 1. import { createMetaBookTreeAnswer, @@ -880,6 +880,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin operation: 'save', }); }; + // Defense-in-depth: the metadata catch-all must honour the same // anonymous-deny (#2567) as the REST `/meta` routes (which serve `/meta` on // the cloud runtime). Object/field schemas — SYSTEM-object schemas on a @@ -1005,12 +1006,11 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // its own resource (#5882). // // [#20478] `RestServer` serves this route, and this domain had none: the - // path fell to the located `ROUTE_NOT_FOUND` tail below. The whole answer is - // `createMetaLayeredAnswer` in `@objectstack/rest` ({@link - // answerMetaLayered}), the one `RestServer`'s handler calls. EXACTLY three - // segments, like `/published` beside it. The anonymous gate above - // keeps its deny here (`metaReadRouteOf` names no route), as `RestServer`'s - // does. + // path fell to the located `ROUTE_NOT_FOUND` tail below. Everything after + // the read is `createMetaLayeredAnswer` in `@objectstack/rest` ({@link + // answerMetaLayered}), the chain `RestServer`'s handler calls. EXACTLY three + // 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')) { const type = parts[0]; const name = decodeMetaNameSegment(parts[1]); @@ -1188,7 +1188,6 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // Extract optional package filter from query string const packageId = query?.package || undefined; - // PUT /metadata/:type/:name (Save) // // [#8842] The condition is the METHOD alone. It used to be