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. diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index d1dde028373..79513b4a1c1 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: 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, 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,8 @@ export type { MetaItemReadRefusal, MetaItemReadVerdict, MetaItemRequest, + MetaLayeredAnswer, + 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..3fb3f6b5a25 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. @@ -2448,6 +2451,180 @@ 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; + +/** The request facts the layered chain reads — no transport shape. */ +export interface MetaLayeredRequest { + /** The SINGULAR type (the caller folds `/meta/apps/:name` once, at its boundary). */ + readonly metaType: string; + readonly name: 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` — 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 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 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. [#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 + * (`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. A gate + * input that cannot be read REJECTS: the transport answers that fault, ⛔ never + * a layered view with a layer missing. + */ +export function createMetaLayeredAnswer( + sources: MetaItemReadGateSources, + request: MetaLayeredRequest, +): (layered: unknown) => Promise { + const { metaType, name, maskPosture } = request; + return async (raw) => { + const layered = raw as Record | null | undefined; + + // 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); + } + } + + // 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); + } + + 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 ───────────────────────────────────────────────────────────── /** 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..365f0a2c7d4 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3716,6 +3716,16 @@ 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: 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 * it from the published-value cache) would misreport the thing being @@ -3752,11 +3762,12 @@ export class RestServer { // 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, - ); + // [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. + 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 @@ -3770,61 +3781,26 @@ export class RestServer { ...(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'); + // [#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( + 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); + 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 +6232,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-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 3519d199a54..b4ddb362cc2 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 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, createMetaItemAnswer, createMetaItemReadGate, + createMetaLayeredAnswer, createMetaListAnswer, isPublicAudienceRead, metaCallerOrganizationId, + metaItemLayersDeprecationHeaders, metaReadOrganizationId, metaRequestLocale, projectMetaObjectSchema, @@ -67,9 +72,11 @@ import { STORED_VERSION_DOOR_POLICY, translateMetaEnvelope, translateMetaList, + wantsMetaItemLayers, type MetaItemAnswer, type MetaItemAnswerSources, type MetaItemReadGateSources, + type MetaLayeredAnswer, type MetaListAnswerSources, type MetaListTranslationSources, type MetaPublicReadRoute, @@ -219,8 +226,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 +256,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 } }; } /** @@ -278,13 +304,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, + ); } /** @@ -690,6 +718,103 @@ 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: read the protocol's + * `getMetaItemLayered` exactly as `RestServer`'s layered read reads it, hand the + * answer to 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 read is scoped to the caller's VETTED organization + * ({@link metaReadOrganizationId}, the partition the plain read reads — ⛔ never + * the session's claim as stored) and to `?package=` (ADR-0048). The chain + * judges every present layer under `STORED_VERSION_DOOR_POLICY` — whole for a + * caller this transport's save door admits (`saveVerdict`, the `PUT` branch's + * own admission, 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 }, + saveVerdict: MetaSaveVerdict, + headers: Readonly> = {}, +): Promise { + const { type, name, packageId, maskPosture } = request; + const caller = context.executionContext as MetaReadGateCaller | undefined; + const organizationId = metaReadOrganizationId(type, caller); + const mayWriteItem = saveVerdict(canonicalMetaUrlType(type), metaCallerOrganizationId(caller)).allowed; + let answer: MetaLayeredAnswer; + try { + 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); + } + switch (answer.kind) { + case 'serve': + return withHeaders(deps.success(answer.layered), { ...headers, 'Cache-Control': answer.cacheControl }); + case 'mask-fault': + return withHeaders(fieldVisibilityFaultResponse(deps, answer.object), 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] * @@ -732,6 +857,30 @@ 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 @@ -852,6 +1001,46 @@ 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. 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]); + 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 }, saveVerdict, + ); + } + // GET /metadata/:type/:name/published → get published version // // [#12195] EXACTLY three segments, and no fold. This used to be @@ -999,27 +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: - // 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', - }); - }; - // PUT /metadata/:type/:name (Save) // // [#8842] The condition is the METHOD alone. It used to be @@ -1337,6 +1505,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 }, saveVerdict, + metaItemLayersDeprecationHeaders(requestedItemPath(_context)), + ); + } + } + if (isDraftRead) { return await readPendingDraft( deps, _context, type, name, packageId, previewDrafts, saveVerdict, { maskPosture, query },