diff --git a/.changeset/20408-dispatcher-meta-item-parity.md b/.changeset/20408-dispatcher-meta-item-parity.md new file mode 100644 index 00000000000..6e7ec88297b --- /dev/null +++ b/.changeset/20408-dispatcher-meta-item-parity.md @@ -0,0 +1,55 @@ +--- +'@objectstack/rest': minor +'@objectstack/runtime': patch +--- + +fix(rest, runtime): the runtime dispatcher's `/meta` doors scope a caller to the organization `RestServer` scopes them to, and its item read, book tree and list answer what `RestServer`'s answer (#20408) + +Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains seven value exports (`createMetaItemAnswer`, `createMetaBookTreeAnswer`, `metaCallerOrganizationId`, `metaReadOrganizationId`, `projectMetaObjectSchema`, `refuseUnknownMetaListType`, `translateMetaEnvelope`) and five type exports (`MetaItemAnswer`, `MetaItemAnswerSources`, `MetaItemRequest`, `MetaBookTreeAnswer`, `MetaBookTreeSources`), and `MetaListAnswer` gains an optional `cacheControl`. `MetaListAnswerSources`, new in this same release with `createMetaListAnswer`, takes the transport's object-schema masker (`resolveObjectMasker`) instead of a whole-mask port, so the chain decides the cache posture for both transports. 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: + +- **A member removed from an organization kept its metadata partition.** The + dispatcher's `/meta` doors took the organization from the session's + `activeOrganizationId` as stored. Under a wall-enforcing tenancy posture the + identity resolver DROPS a claim naming an organization the caller no longer + belongs to, and `RestServer` reads that vetted value. The dispatcher did not, + so for the rest of the session the removed member was served that + organization's org-scoped overlays (`view`, `dashboard`, `report`, + `translation`, `email_template`) by the item read, the list, `/published` and + `?state=draft`, listed its pending drafts on `GET /meta/_drafts`, and had a + `PUT /meta/:type/:name` land in its partition. Every `/meta` door here now reads + the vetted organization on the execution context, the value `RestServer` reads. +- **`GET /meta/:type/:name` answered a different body.** Nothing was translated + whatever `Accept-Language` or `?locale=` asked for. A doc kept its whole + `translations` map, in no locale. An object schema came with no + `sortability`. The answer had no `Vary: Accept-Language`. +- **`?preview=DRAFT`** (any casing but lower) from a builder read the published + world on the item read and the list. `RestServer` compares it + case-insensitively. +- **`GET /meta/object/:name?preview=draft`** from a builder answered the ACTIVE + schema, never the pending draft. +- **`GET /meta/totally_invented_type`** answered `200 {"items": []}`. `RestServer` + refuses a segment that names no metadata type with `400 INVALID_REQUEST`. +- **`GET /meta/book/:name/tree`** was no route: `404 ROUTE_NOT_FOUND` to a signed-in + reader and `401` to an anonymous reader of a `public` book (ADR-0046 §6.7). +- **An object schema served under an undetermined field visibility** (ADR-0106 + D6 tier 2: served unmasked) carried no `Cache-Control`. `RestServer` answers + `private, no-store`. This was true of the list, the item read, `/published` and + the legacy one-segment object read. + +**What changed.** Everything `RestServer`'s `GET /meta/:type/:name` does after +the store read moved, unchanged, into `createMetaItemAnswer`: absence, the item +gate, the doc locale collapse, the object mask and its cache posture, and the +body (the translation and `sortability`, `translateMetaEnvelope`). The book-tree +route's whole answer moved into `createMetaBookTreeAnswer`, and the list's +unknown-type refusal into `refuseUnknownMetaListType`. The list chain now +applies the object mask itself (`projectMetaObjectSchema`) and reports the cache +posture. The dispatcher's `/meta` domain calls each of these, and takes its +organization from `metaCallerOrganizationId` / `metaReadOrganizationId`, which +`RestServer`'s list and item reads ask too. + +`RestServer`'s own answers are unchanged: the move is a refactor on that side, +and every existing REST test passes unedited. diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index 5423ce2dac0..d1dde028373 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -90,24 +90,47 @@ export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-mu // [#20320] …and everything else the two transports' `/meta` reads must answer // alike: the list route's whole post-read chain (`createMetaListAnswer` — the // `api` served-set face, the list gate, `?id=`, `?object=`, the doc locale -// collapse and slim, the transport's object mask, the translation +// collapse and slim, the object mask over the transport's masker, the translation // `translateMetaList`), the one locale parse it reads (`metaRequestLocale`), // the anonymous gates' // `public`-audience predicate (`isPublicAudienceRead`) and the stored-version // doors' policy (`STORED_VERSION_DOOR_POLICY`, which `?state=draft` runs). +// +// [#20408] …and the item read's and the book tree's: the item route's whole +// post-read chain (`createMetaItemAnswer` — absence, the item gate, the doc +// locale collapse, the object mask and its `private, no-store`, and the body +// `translateMetaEnvelope` builds: the translation and `sortability`), the one +// projection every object-schema exit applies (`projectMetaObjectSchema`), the +// `GET /meta/book/:name/tree` answer (`createMetaBookTreeAnswer`), the list's +// unknown-type refusal (`refuseUnknownMetaListType`) and the organization a +// caller's `/meta` request is scoped to — the VETTED one on its execution +// context (`metaCallerOrganizationId`, and `metaReadOrganizationId` for a read +// of one type). export { + createMetaBookTreeAnswer, + createMetaItemAnswer, createMetaItemReadGate, createMetaListReadGate, createMetaListAnswer, isPublicAudienceRead, + metaCallerOrganizationId, + metaReadOrganizationId, metaRequestLocale, + projectMetaObjectSchema, + refuseUnknownMetaListType, STORED_VERSION_DOOR_POLICY, + translateMetaEnvelope, translateMetaList, } from './meta-item-read-gate.js'; export type { + MetaBookTreeAnswer, + MetaBookTreeSources, + MetaItemAnswer, + MetaItemAnswerSources, MetaItemReadGateSources, MetaItemReadRefusal, MetaItemReadVerdict, + MetaItemRequest, MetaListAnswer, MetaListAnswerSources, MetaListRequest, diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 4a2d2cb5cde..a4d8daaabf8 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -42,12 +42,29 @@ * branch pruned nothing. Its gate lives here too * ({@link createMetaListReadGate}), built from the same functions over the * same ports. + * + * [#20320 · #20408] …and so does every other step those reads take after the + * store read: the list chain ({@link createMetaListAnswer}), the item read's + * ({@link createMetaItemAnswer}), the book tree ({@link createMetaBookTreeAnswer}), + * 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}). + * `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. */ import type { AudienceCaller, Book, ResolvedBook, ResolverDoc } from '@objectstack/spec/system'; import { preferredLocaleFromHeader } from '@objectstack/spec/system'; import { apiExposureDenialReason } from '@objectstack/spec/data'; -import { pluralToSingular } from '@objectstack/spec/shared'; +import { resolveObjectSortability } from '@objectstack/spec/api'; +import { canonicalMetaUrlType, pluralToSingular, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared'; +import { + ObjectSchemaMaskEvaluationError, + applyObjectSchemaMask, + organizationIdForMetaRead, + type ObjectSchemaMaskPosture, +} from '@objectstack/metadata-core'; import { isEndpointMatchAuthority, selectServedEndpoints } from './served-endpoints.js'; import { logError, logWarn } from './log.js'; @@ -120,6 +137,52 @@ export interface MetaItemReadGateSources extends MetaReadGateAudienceSources, Me readonly navPruneLogged: Set; } +// ── The caller's organization ───────────────────────────────────────────────── + +/** + * [#20408] The active organization a `/meta` request is scoped to: the + * `tenantId` the identity resolver VETTED onto the caller's execution context + * — ⛔ never the session's `activeOrganizationId` as stored. + * + * `resolveAuthzContext` (`@objectstack/core`) vets that claim: under a + * wall-enforcing tenancy posture, a claim naming an organization the caller no + * longer belongs to is DROPPED, and the request resolves with no active + * organization at all (the fail-closed state every other layer reads). The + * runtime dispatcher's `/meta` doors used to read the claim straight off the + * auth service, so a member removed from an organization kept its metadata + * partition there for the rest of the session: its org-scoped overlays were + * served to them by the item read, the list, `/published` and `?state=draft`, + * `GET /meta/_drafts` listed its pending drafts, and `PUT` wrote into it — + * while `RestServer`, which reads `ctx.tenantId`, answered the same caller the + * env-wide rows. One source, both transports. + * + * `undefined` for an anonymous caller, a caller with no active organization, + * and one whose claim was dropped — which are one fact to every consumer. + */ +export function metaCallerOrganizationId(caller: unknown): string | undefined { + const tenantId = caller && typeof caller === 'object' ? (caller as { tenantId?: unknown }).tenantId : undefined; + return typeof tenantId === 'string' ? tenantId : undefined; +} + +/** + * [#9454 · #20408] The organization a `/meta` READ of `type` carries: + * `organizationIdForMetaRead` over the FOLDED segment (folded-type commit + * 26f3588fb, whose card no longer resolves: the raw plural would miss the + * registry's override flag) and the caller's vetted + * organization ({@link metaCallerOrganizationId}). An organization reaches the + * read only for a type the registry declares `allowOrgOverride`, so a + * non-overridable type never resurrects a pre-#6190 phantom org row. + * + * `RestServer`'s list and item reads and the runtime dispatcher's ask this, so + * the partition a caller reads cannot differ by transport. + */ +export function metaReadOrganizationId(type: unknown, caller: unknown): string | undefined { + return organizationIdForMetaRead( + canonicalMetaUrlType(typeof type === 'string' ? type : ''), + metaCallerOrganizationId(caller), + ); +} + // ── The verdict ─────────────────────────────────────────────────────────────── /** @@ -1568,6 +1631,144 @@ export function isPublicAudienceRead( return folded === 'book' || folded === 'doc'; } +// ── The list route's unknown-type refusal ───────────────────────────────────── + +/** + * [#9488] Refuse a `GET /meta/:type` LIST whose `:type` segment names no + * metadata type — the read half of the verdict the WRITE door has enforced + * since #8421. + * + * ## The disagreement this closes + * + * `PUT /api/v1/meta/totally_invented_type/x` answers `400` / + * `INVALID_REQUEST` / *"'totally_invented_type' is not a metadata type"* + * (`refuseUnmintableMetaType` in `@objectstack/metadata-protocol`), while + * `GET /api/v1/meta/totally_invented_type` answered `200 + * {"items":[]}` — so the two doors disagreed about which type names exist. + * A 200-with-an-empty-collection is indistinguishable from "this type + * exists and holds nothing", which is the same trap + * `GET /meta/app?id=` was filed for: a typo'd or renamed type + * reads as an empty surface rather than as a mistake. + * + * ## Why the static verdict alone is NOT the rule here + * + * #8421 considered and REJECTED raising `unrecognisedMetaTypeRefusal` on + * the read entries, for a reason that is still true: the live type set + * legitimately holds keys the static contract does not. An ordinary + * `registerApp` puts `data`, `kind`, `package` and `policy` into + * `SchemaRegistry`, `GET /api/v1/meta/types` enumerates exactly that set, + * and a plugin's own type enters the live set as a side effect of + * registering items of it (`content/docs/plugins/adding-a-metadata-type.mdx`: + * *"A third-party package's type instead enters the live set as a side + * effect of registering items of that type"*). Refusing on the static + * verdict alone would answer `400` for types this same service advertises + * — trading one declared-≠-served gap for another, which is the objection + * verbatim. + * + * So the rule is the UNION of the two authorities the platform already + * has, and neither is restated here: the static spelling contract + * (`unrecognisedMetaTypeRefusal`, the predicate the write door consults) + * and the live listing (`getMetaTypes`, the one `GET /meta/types` serves). + * A name in neither is a name nothing can serve, which is exactly the + * population this card is about. ⛔ Do not hand-write a list of type names + * here — both halves are derived (Prime Directive #8). + * + * ## Order, cost, and the failure mode + * + * The static verdict runs FIRST and is silent for all 68 accepted + * spellings, so an ordinary list request pays nothing at all; the live + * probe is reached only by a request already headed for a refusal. That is + * the same shape the write door uses (static verdict, then + * `metaTypeNamespaceExists`), for the same reason. + * + * It fails OPEN. A host whose protocol carries no `getMetaTypes`, or whose + * listing cannot be read, keeps today's answer rather than earning a + * refusal — inventing "no such type" from an unreachable authority would + * be an existence claim stated while the authority was unreachable, the + * very thing `metaTypeNamespaceExists` refuses to do in the write door. + * The cost is that the defect survives an outage; the alternative is + * refusing a type that does exist. + * + * ## Scope — deliberately the LIST door only + * + * ⛔ Not the compound arity. `/meta/lead/views/all_leads` carries an OBJECT + * name in the `:type` segment, which no static contract can enumerate; + * that is exemption 1 of the write door and it is honoured here by simply + * not being this route. ⛔ Not the single-item doors: measured on this + * branch, `GET /meta//x` already answers `404 + * RESOURCE_NOT_FOUND` and the `/references`, `/layers`, `/history`, + * `/audit`, `/diff`, `/published` limbs already answer `501 + * NOT_IMPLEMENTED` — all distinguishable from a served answer, so none of + * them carries this defect. + * + * ## Both transports + * + * [#20408] Moved here, unchanged, out of `RestServer`: the runtime + * dispatcher's `/meta` list branch asks it too, at the same point — before any + * listing work — so a dispatcher-only host refuses the segments `RestServer` + * refuses instead of answering `200 {"items":[]}`. A dispatcher host whose + * protocol has no `getMetaTypes` keeps its answer (fail-open, above). + * + * @returns nothing; THROWS the refusal, so each transport's own `catch` + * shapes it (`RestServer`'s `handleRouteError`, the dispatcher's + * `errorFromThrown`) and the wire body is byte-identical to the + * write door's for the same condition. A hand-built body would + * author a second dialect for one condition and would tick + * `rest-server.ts`'s `check:route-envelope` dialect ratchets UP. + */ +export async function refuseUnknownMetaListType(p: unknown, urlType: unknown): Promise { + if (typeof urlType !== 'string' || urlType.length === 0) return; + const unrecognised = unrecognisedMetaTypeRefusal(urlType); + if (!unrecognised) return; + if (await metaTypeIsLive(p, urlType)) return; + const err: any = new Error( + `[invalid_request] '${unrecognised.type}' is not a metadata type. The platform declares ` + + `no such type and this deployment has registered no items under it, so an empty ` + + `collection here would be indistinguishable from a type that exists and holds ` + + `nothing. GET /api/v1/meta/types lists the types this deployment carries.`, + ); + err.code = 'INVALID_REQUEST'; + err.status = 400; + throw err; +} + +/** + * [#9488] Does this deployment's LIVE type set carry this `/meta/:type` + * segment? The second half of {@link refuseUnknownMetaListType}'s union. + * + * Reads the same accessor `GET /meta/types` serves, and tolerates both + * shapes it is known to arrive in — the protocol's `{ types, entries }` + * and the bare `string[]` older hosts and stubs return. Both sides of the + * comparison are folded through `canonicalMetaUrlType`, the platform's own + * spelling fold, so a registry storing the plural and a URL carrying the + * singular still meet; nothing about spelling is re-derived here. + * + * `getMetaTypes` is called optionally on purpose: `RestServer`'s own + * `getMetaItems` call sites are too, because a host may occupy the protocol + * slot with an object that does not implement the whole surface. + * + * Returns `true` — "cannot disprove, so do not refuse" — for every + * unreadable outcome: no accessor, a rejected call, or a listing in a + * shape this cannot read. See the caller's doc for why that direction. + */ +async function metaTypeIsLive(p: unknown, urlType: string): Promise { + let listing: unknown; + try { + listing = await (p as any)?.getMetaTypes?.(); + } catch { + return true; + } + const types: unknown = Array.isArray(listing) + ? listing + : (listing && typeof listing === 'object' && Array.isArray((listing as any).types)) + ? (listing as any).types + : null; + if (!Array.isArray(types)) return true; + const wanted = canonicalMetaUrlType(urlType); + return types.some((t: unknown) => typeof t === 'string' + && (t === urlType || canonicalMetaUrlType(t) === wanted)); +} + // ── The list route's locale and translation ─────────────────────────────────── /** The two request members a locale is read from — each transport hands in its own. */ @@ -1753,6 +1954,141 @@ export async function translateMetaList( return Array.isArray(raw) ? translated : { ...raw, items: translated }; } +/** + * [#20408] Translate ONE metadata document for the request's locale — the item + * twin of {@link translateMetaList}. `metaType` is the canonical singular (the + * caller folds its URL segment once — plural-spelling commit 2443bb4c4e, whose + * card no longer resolves: the translatable set is singular-only, so an + * unfolded plural would skip the whole localization). + * + * Takes the DOCUMENT, never the `getMetaItem` envelope (#5563): nav and field + * labels live on the document. A missing bundle is not a bail-out (the + * built-in fallbacks still apply); no locale is. The i18n service is resolved + * only for a translatable type, and the protocol only for an `object` (#8284, + * the packaged base). + * + * Moved here, unchanged, from `RestServer.translateMetaItem` (which now + * delegates), so the runtime dispatcher's item read translates with the same + * function instead of serving the authored labels. + */ +export async function translateMetaDocument( + sources: MetaListTranslationSources, + metaType: string, + document: unknown, +): Promise { + if (!document || typeof document !== 'object') return document; + if (!(await isTranslatableMetaType(metaType))) return document; + const i18n = await sources.resolveI18nService(); + const bundle = translationBundleOf(i18n); + const locale = sources.requestLocale(i18n); + if (!locale) return document; + const { translateMetadataDocument } = await import('@objectstack/spec/system'); + const packagedBase = metaType === 'object' + ? packagedObjectBaseOf(await sources.resolveProtocol(), metaType, (document as any)?.name) + : undefined; + return translateMetadataDocument(metaType, document as any, bundle, { + ...metaTranslateOptions(i18n, locale), + packagedBase, + }); +} + +/** + * [#5563 · #10235 · #20408] The one place a single-item read rebuilds its + * response body around the document it serves: `envelope` supplies the + * identity and the ADR-0008 OCC carriers (`lock`, `provenance`, …), `document` + * is the (gated, locale-collapsed, masked) document that belongs under `item`, + * translated ({@link translateMetaDocument}). + * + * Beside an OBJECT schema it serves the per-column `sortability` projection + * (#10235), computed from the FINAL document — post ADR-0106 mask — so its + * domain is exactly the field set this caller is served, and never inside + * `item` (`FieldSchema` is strict; the key must stay un-authorable). + * + * Moved here, unchanged, from `RestServer.translateMetaEnvelope` (which now + * delegates), so the runtime dispatcher's item read answers the same body — + * it served neither the translation nor `sortability`. + */ +export async function translateMetaEnvelope( + sources: MetaListTranslationSources, + metaType: string, + envelope: Readonly> | undefined, + document: unknown, +): Promise> { + const sortability = metaType === 'object' + && document && typeof document === 'object' && !Array.isArray(document) + ? { sortability: resolveObjectSortability(document) } + : {}; + return { + ...envelope, + ...sortability, + item: await translateMetaDocument(sources, metaType, document), + }; +} + +// ── The object mask's answer ────────────────────────────────────────────────── + +/** [ADR-0106 D6 tier 2] The cache posture a schema served under an UNDETERMINED field visibility carries. */ +export const META_UNDETERMINED_CACHE_CONTROL = 'private, no-store'; + +/** + * [#20408] Project ONE object schema through the caller's ADR-0106 posture — + * the decision every schema-serving `/meta` exit makes, on both transports: + * + * - `project` — the readable fields only (D1). A projection that leaves NO + * field is a fault (`ok: false`), never an empty-fields `200` (D6: "silently + * wrong UI **and** cacheable poison"); + * - `undetermined` (D6 tier 2) — the schema UNMASKED, and so served + * {@link META_UNDETERMINED_CACHE_CONTROL}: no shared cache may store or + * revalidate a body no mask touched; + * - `passthrough` — as given. + * + * The runtime dispatcher masked without the second half: an undetermined + * posture's schema went out with no `Cache-Control` at all, where `RestServer` + * answers `private, no-store` — the header the ADR (and the posture's own + * `warn` line) promises. + */ +export function projectMetaObjectSchema( + posture: ObjectSchemaMaskPosture, + document: unknown, +): { ok: true; document: any; cacheControl?: typeof META_UNDETERMINED_CACHE_CONTROL } | { ok: false } { + const masked = applyObjectSchemaMask(document, posture); + if (masked.emptied) return { ok: false }; + return posture.kind === 'undetermined' + ? { ok: true, document: masked.document, cacheControl: META_UNDETERMINED_CACHE_CONTROL } + : { ok: true, document: masked.document }; +} + +/** + * [ADR-0106 D5(2)] Project every object schema of a listed `object` page. One + * unevaluable object fails the whole list (`ok: false`, naming it) — serving the + * rest would leave a hole in the projection no client can see — whether its + * posture threw (D6 tier 3, {@link ObjectSchemaMaskEvaluationError}) or its + * projection emptied it; any other throw propagates. The page is served + * `private, no-store` when ANY object's posture was undetermined. + */ +async function maskMetaObjectList( + masker: (objectName: string) => Promise, + items: readonly any[], +): Promise<{ ok: true; items: any[]; cacheControl?: typeof META_UNDETERMINED_CACHE_CONTROL } | { ok: false; object: string }> { + const projected: any[] = []; + let cacheControl: typeof META_UNDETERMINED_CACHE_CONTROL | undefined; + for (const item of items) { + const objectName = String(item?.name ?? ''); + let posture: ObjectSchemaMaskPosture; + try { + posture = await masker(objectName); + } catch (maskError) { + if (maskError instanceof ObjectSchemaMaskEvaluationError) return { ok: false, object: objectName }; + throw maskError; + } + const masked = projectMetaObjectSchema(posture, item); + if (!masked.ok) return { ok: false, object: objectName }; + cacheControl ??= masked.cacheControl; + projected.push(masked.document); + } + return cacheControl ? { ok: true, items: projected, cacheControl } : { ok: true, items: projected }; +} + // ── THE list answer ─────────────────────────────────────────────────────────── /** Everything {@link createMetaListAnswer} reads, supplied by the transport. */ @@ -1783,15 +2119,19 @@ export interface MetaListAnswerSources extends MetaItemReadGateSources { */ notifyMissingEndpointMatcher(surface: string): void; /** - * [ADR-0106 D5(2)] The transport's own object mask over a non-empty - * `object` list: every item projected for this caller, or the object whose - * projection could not be evaluated (D6 tier 3 — the transport answers - * that fault, ⛔ never the list with a hole in it). A mask fault of any - * other kind is thrown. The mask stays the transport's for the gate's - * reason: each threads it through its own `fetch → mask → send` exit, and - * `RestServer` sets its own cache header from the posture. + * [ADR-0106 D2 · #20408] This request's object-schema masker — the + * transport's own posture resolver (its caller, its security service, its + * enablement switch), resolved ONCE per page and asked per object name. It + * REJECTS with `ObjectSchemaMaskEvaluationError` on D6 tier 3. Asked only + * for a non-empty `object` list. + * + * What the chain does with each posture — the projection, the tier-3 and + * emptied faults, and the `private, no-store` an undetermined posture owes + * — is the chain's ({@link projectMetaObjectSchema}), so both transports + * answer it alike. It used to be a whole-mask port, and the dispatcher's + * took the projection without the cache header. */ - maskObjects(items: any[]): Promise<{ ok: true; items: any[] } | { ok: false; object: string }>; + resolveObjectMasker(): Promise<(objectName: string) => Promise>; } /** The request facts the list chain reads — no transport shape. */ @@ -1812,9 +2152,14 @@ export interface MetaListRequest { * What the chain answers: the list to send (the input's shape — a bare array * or the `{ type, items }` envelope), or the object whose mask could not be * evaluated, which the transport answers as its D6 tier-3 fault. + * + * [#20408] `cacheControl` — the `Cache-Control` the list must be served under, + * when it owes one: {@link META_UNDETERMINED_CACHE_CONTROL} when any listed + * object schema was served under an undetermined field visibility (ADR-0106 + * D6 tier 2). Absent otherwise; the transport then sets none. */ export type MetaListAnswer = - | { ok: true; data: unknown } + | { ok: true; data: unknown; cacheControl?: typeof META_UNDETERMINED_CACHE_CONTROL } | { ok: false; object: string }; /** The items of a list shape this chain serves, or `null` for anything else (left alone, never replaced). */ @@ -1865,7 +2210,9 @@ function withItems(raw: unknown, items: any[]): unknown { * 5. `doc` — the ADR-0046 locale collapse (the request's own preference; the * `translations` map is dropped whatever the locale). * 6. `doc` — the ADR-0046 content slim, unless `?include=content`. - * 7. `object` — the transport's ADR-0106 mask ({@link MetaListAnswerSources.maskObjects}). + * 7. `object` — the ADR-0106 mask ({@link projectMetaObjectSchema} over the + * transport's {@link MetaListAnswerSources.resolveObjectMasker}), and the + * `private, no-store` an undetermined posture owes (D6 tier 2). * 8. The translation step ({@link translateMetaList}, reached through * {@link MetaListAnswerSources.translateList}). * @@ -1958,17 +2305,228 @@ export function createMetaListAnswer( } } - // 7. [ADR-0106 D5(2)] The object mask — the transport's. + // 7. [ADR-0106 D5(2)] The object mask, over the transport's posture resolver. + let cacheControl: typeof META_UNDETERMINED_CACHE_CONTROL | undefined; if (metaType === 'object') { const list = metaItemsArray(visible); if (list.length > 0) { - const masked = await sources.maskObjects(list); + const masked = await maskMetaObjectList(await sources.resolveObjectMasker(), list); if (!masked.ok) return masked; visible = withItems(visible, masked.items); + cacheControl = masked.cacheControl; } } // 8. The translation step ({@link translateMetaList}, through the transport's entry). - return { ok: true, data: await sources.translateList(metaType, visible) }; + const data = await sources.translateList(metaType, visible); + return cacheControl ? { ok: true, data, cacheControl } : { ok: true, data }; + }; +} + +// ── THE item answer ─────────────────────────────────────────────────────────── + +/** Everything {@link createMetaItemAnswer} reads, supplied by the transport. */ +export interface MetaItemAnswerSources extends MetaItemReadGateSources { + /** + * This request's locale ({@link metaRequestLocale} over the transport's own + * request). The doc locale collapse asks it with no i18n service, so only + * the request's own preference counts there. + */ + requestLocale(i18n?: unknown): string | undefined; + /** + * The body step: {@link translateMetaEnvelope} over this transport's I/O, + * handed the store's envelope and the served document. A port rather than a + * call so each transport keeps ONE entry into it + * (`RestServer.translateMetaEnvelope`). + */ + translateEnvelope(envelope: Readonly>, document: unknown): Promise; +} + +/** The request facts the item chain reads — no transport shape. */ +export interface MetaItemRequest { + /** The SINGULAR type (the caller folds `/meta/books/:name` once, at its boundary). */ + readonly metaType: string; + readonly name: string; + /** + * The door's gate policy: `{ arms: 'all', app: 'gate' }` for the rendered + * read (its `?preview=draft` included), {@link STORED_VERSION_DOOR_POLICY} + * for the ADMITTED `?state=draft` read — the transport's own declaration, + * made with its admission, ⛔ never re-read here. + */ + readonly policy: MetaReadGatePolicy; + /** + * [ADR-0106 D2/D3] The caller's field-visibility posture for this item, + * resolved by the transport BEFORE the fetch (`fetch → mask → send`), 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 item chain answers: + * + * - `serve` — send `envelope`, under `cacheControl` when it owes one + * ({@link META_UNDETERMINED_CACHE_CONTROL}, ADR-0106 D6 tier 2), and under + * `Vary: Accept-Language` always (the body is translated per request); + * - `refuse` — the gate's {@link MetaItemReadRefusal} (`absent` included: + * nothing behind the name is the transport's own absence answer); + * - `mask-fault` — the projection left the schema with no field (D6), which + * the transport answers as its field-visibility fault for `object`. + */ +export type MetaItemAnswer = + | { kind: 'serve'; envelope: unknown; cacheControl?: typeof META_UNDETERMINED_CACHE_CONTROL } + | { kind: 'refuse'; refusal: MetaItemReadRefusal } + | { kind: 'mask-fault'; object: string }; + +/** + * [#20408] THE answer of one `/meta/:type/:name` read, after the store read — + * one chain, called by both transports that serve that read. + * + * ## Why one chain + * + * `RestServer`'s plain read ran every step below inline, and the runtime + * dispatcher's `/meta` item read — the only answer on a host that mounts just + * the `${prefix}/*` catch-all — ran the per-caller gate (#20193) and the mask, + * and nothing else. So the dispatcher served an item untranslated whatever + * `Accept-Language` asked for, a doc with its whole `translations` map and in + * no locale, an object schema with no `sortability`, no `Vary` header, and an + * undetermined posture's schema with no `Cache-Control`. Each transport now + * hands its store's envelope to THIS function: a step added here reaches both, + * and `meta-list-projection-parity.test.ts` in `@objectstack/runtime` drives + * every cell × query parameter × caller through both and holds the answers + * equal. ⛔ An item step is added HERE, never in a transport. + * + * ## The steps, in `RestServer`'s order (unchanged) + * + * 1. Absence — an envelope with no `item` is `absent` (#18066), judged BEFORE + * the gate, so a name with nothing behind it can never be converted into + * the `403` #8013 reserves for an app the caller may not open. + * 2. THE per-caller gate ({@link createMetaItemReadGate}) under the door's + * policy. + * 3. `doc` — the ADR-0046 locale collapse (the request's own preference; the + * `translations` map is dropped whatever the locale). + * 4. `object` — the ADR-0106 mask ({@link projectMetaObjectSchema}) under the + * posture resolved before the fetch. + * 5. The body ({@link translateMetaEnvelope}, through the transport's entry): + * the translation, and `sortability` beside an object schema. + * + * The envelope is never mutated. A gate input that cannot be read REJECTS: + * the transport answers that fault, ⛔ never the document. + */ +export function createMetaItemAnswer( + sources: MetaItemAnswerSources, + request: MetaItemRequest, +): (envelope: Readonly> | null | undefined) => Promise { + const { metaType, name, policy, maskPosture } = request; + return async (envelope) => { + // 1. [#18066] Absence. + let visible: any = envelope?.item; + if (visible == null) return { kind: 'refuse', refusal: { reason: 'absent' } }; + + // 2. [#20156 · #20193] THE per-caller gate. + const verdict = await createMetaItemReadGate(sources, metaType, name, [visible], policy)(visible); + if (verdict.kind === 'refuse') return verdict; + visible = verdict.document; + + // 3. ADR-0046 i18n: collapse the doc to the request locale. + if (metaType === 'doc' && visible) { + const { resolveDocLocale } = await import('@objectstack/spec/system'); + visible = resolveDocLocale(visible as any, sources.requestLocale()); + } + + // 4. [ADR-0106 D1/D5(1)] The mask, under the posture resolved before the fetch. + const masked = projectMetaObjectSchema(maskPosture, visible); + if (!masked.ok) return { kind: 'mask-fault', object: name }; + visible = masked.document; + + // 5. The body — translation, and `sortability` beside an object schema. + const served = await sources.translateEnvelope(envelope ?? {}, visible); + return masked.cacheControl + ? { kind: 'serve', envelope: served, cacheControl: masked.cacheControl } + : { kind: 'serve', envelope: served }; }; } + +// ── THE book tree ───────────────────────────────────────────────────────────── + +/** Everything {@link createMetaBookTreeAnswer} reads, supplied by the transport. */ +export interface MetaBookTreeSources extends MetaReadGateAudienceSources { + /** + * One list read of `type` for the tree, env-wide, scoped to `packageId` when + * the request names one (`?package=`, ADR-0048). A THROW propagates: an + * unreadable book list is never "no book", nor an unreadable doc list "no + * page". + */ + listTreeInput(type: 'book' | 'doc', packageId: string | undefined): Promise; + /** This request's locale, as {@link MetaItemAnswerSources.requestLocale} reads it. */ + requestLocale(i18n?: unknown): string | undefined; +} + +/** + * What the tree route answers: the tree to send, or the ADR-0046 §6.7 refusal — + * `401 UNAUTHENTICATED` to an anonymous caller, `403 PERMISSION_DENIED` + * otherwise — each transport writes on its own wire. + */ +export type MetaBookTreeAnswer = + | { ok: true; tree: ResolvedBook } + | { ok: false; refusal: Extract }; + +/** + * [ADR-0046 §6 · #20408] THE answer of `GET /meta/book/:name/tree` — a book + * spine resolved against the docs that exist NOW into a rendered tree + * (membership is DERIVED, never stored — §6.2.1), on both transports. + * + * ## Why here + * + * `RestServer` served the route; the runtime dispatcher had no such route at + * all, so a dispatcher-only host answered `404 ROUTE_NOT_FOUND` to a signed-in + * caller and `401` to an anonymous reader of a `public` book — the reader the + * route exists for. It is a ROUTE, not a projection, but everything it does is + * a read and a pure resolution, so the whole handler moved here, unchanged, and + * each transport supplies only its I/O. + * + * ## The steps (unchanged from `RestServer`'s handler) + * + * 1. Every book (`?package=` scopes the list), audience-shaped, and THE + * {@link DocsAudience} over them — one resolution, the docs reads' own. + * 2. The book the name names: a declared book, else the implicit per-package + * book (§6.4). + * 3. §6.7 — the book's OWN audience gates the whole tree: anonymous → + * `public` only; `{ permissionSet }` → the caller must hold the named set + * (fail closed when holdings cannot be resolved, ADR-0049). + * 4. Every doc (the same package scope), collapsed to the request's locale and + * reduced to the header the tree renders. + * 5. The tree, its ENTRIES narrowed by each doc's effective audience, so a + * reader never sees an entry that would refuse them on fetch. + */ +export async function createMetaBookTreeAnswer( + sources: MetaBookTreeSources, + request: { readonly name: string; readonly packageId?: string }, +): Promise { + const { resolveDocLocale } = await import('@objectstack/spec/system'); + const locale = sources.requestLocale(); + const books = metaItemsArray(await sources.listTreeInput('book', request.packageId)); + const audience = await resolveDocsAudience(sources, audienceBooksOf(books)); + const book = audience.bookNamed(request.name); + if (!audience.admitsBook(book)) { + return { + ok: false, + refusal: audience.caller.authenticated + ? { reason: 'docs-audience', status: 403, code: 'PERMISSION_DENIED', message: DOCS_HOLDER_MESSAGE } + : { reason: 'docs-audience', status: 401, code: 'UNAUTHENTICATED', message: DOCS_SIGN_IN_MESSAGE }, + }; + } + const docs = metaItemsArray(await sources.listTreeInput('doc', request.packageId)) + .map((d: any) => (d && typeof d === 'object' ? resolveDocLocale(d, locale) : d)) + .map((d: any) => ({ + name: d.name, + label: d.label, + description: d.description, + order: d.order, + group: d.group, + tags: d.tags, + packageId: d._packageId, + })); + return { ok: true, tree: audience.readableTree(book, docs) }; +} diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 0ab6c5c1edb..01de5bf7829 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -155,9 +155,8 @@ import { IMPORT_JOB_MAX_ROWS } from '@objectstack/spec/api'; // single-item read path rebuilds its body through, from the spec's own storage // predicates — so the signal the grid reads cannot drift from what the runtime // doors (#6994/#7095) refuse. -import { resolveObjectSortability } from '@objectstack/spec/api'; import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; -import { PLURAL_TO_SINGULAR, canonicalMetaUrlType, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared'; +import { PLURAL_TO_SINGULAR, canonicalMetaUrlType } from '@objectstack/spec/shared'; import { stripReadDecorations } from '@objectstack/spec/kernel'; import type { DroppedFieldsEvent } from '@objectstack/spec/data'; // [#20193] THE per-caller read gate of a `/meta/:type/:name` document, and the @@ -173,9 +172,12 @@ import type { DroppedFieldsEvent } from '@objectstack/spec/data'; // `resolveNavServability`, `resolveNavDocAudience`, `fetchAudienceBooks`, // `docCorpusOf`) went with it: a delegate with no caller is a second place to // read a rule that nothing runs. +// +// [#20408] The book-tree route's own two (`audienceBooksOf`, +// `resolveDocsAudience`) went the same way when its whole answer moved to +// `createMetaBookTreeAnswer`, which the runtime dispatcher serves too. import * as metaReadGate from './meta-item-read-gate.js'; import type { - DocsAudience, MetaItemReadGateSources, MetaItemReadRefusal, MetaReadGateAudienceSources, @@ -385,28 +387,6 @@ import { } from './error-response.js'; export { mapDataError }; -/** - * Whether a metadata type's user-facing labels are localized at the REST - * boundary by `translateMetadataDocument`. - * - * DERIVED from the spec's translator dispatch. This used to be a hand-copied - * literal set under a "keep in sync with the type dispatch" comment — the - * shape #3786 was filed about: adding a translator in spec silently left the - * REST boundary serving that type untranslated, with no error anywhere. - * Reading the answer from `TRANSLATABLE_METADATA_TYPES` means there is no - * second list to forget. - * - * Resolved lazily and memoised, so `@objectstack/spec/system` stays off the - * module-init path exactly as it was before — the same `await import` the - * translate helpers below already perform, and a module-cache hit after the - * first call. - * - * [#20320] `isTranslatableMetaType` in `./meta-item-read-gate.ts`, the one - * answer the runtime dispatcher's list translation asks too. - */ -function isTranslatableMetaType(type: string): Promise { - return metaReadGate.isTranslatableMetaType(type); -} /** @@ -2709,130 +2689,15 @@ export class RestServer { /** * [#9488] Refuse a `GET /meta/:type` LIST whose `:type` segment names no - * metadata type — the read half of the verdict the WRITE door has enforced - * since #8421. - * - * ## The disagreement this closes - * - * `PUT /api/v1/meta/totally_invented_type/x` answers `400` / - * `INVALID_REQUEST` / *"'totally_invented_type' is not a metadata type"* - * (`refuseUnmintableMetaType` in `@objectstack/metadata-protocol`), while - * `GET /api/v1/meta/totally_invented_type` answered `200 - * {"items":[]}` — so the two doors disagreed about which type names exist. - * A 200-with-an-empty-collection is indistinguishable from "this type - * exists and holds nothing", which is the same trap - * `GET /meta/app?id=` was filed for: a typo'd or renamed type - * reads as an empty surface rather than as a mistake. - * - * ## Why the static verdict alone is NOT the rule here - * - * #8421 considered and REJECTED raising `unrecognisedMetaTypeRefusal` on - * the read entries, for a reason that is still true: the live type set - * legitimately holds keys the static contract does not. An ordinary - * `registerApp` puts `data`, `kind`, `package` and `policy` into - * `SchemaRegistry`, `GET /api/v1/meta/types` enumerates exactly that set, - * and a plugin's own type enters the live set as a side effect of - * registering items of it (`content/docs/plugins/adding-a-metadata-type.mdx`: - * *"A third-party package's type instead enters the live set as a side - * effect of registering items of that type"*). Refusing on the static - * verdict alone would answer `400` for types this same service advertises - * — trading one declared-≠-served gap for another, which is the objection - * verbatim. - * - * So the rule is the UNION of the two authorities the platform already - * has, and neither is restated here: the static spelling contract - * (`unrecognisedMetaTypeRefusal`, the predicate the write door consults) - * and the live listing (`getMetaTypes`, the one `GET /meta/types` serves). - * A name in neither is a name nothing can serve, which is exactly the - * population this card is about. ⛔ Do not hand-write a list of type names - * here — both halves are derived (Prime Directive #8). - * - * ## Order, cost, and the failure mode - * - * The static verdict runs FIRST and is silent for all 68 accepted - * spellings, so an ordinary list request pays nothing at all; the live - * probe is reached only by a request already headed for a refusal. That is - * the same shape the write door uses (static verdict, then - * `metaTypeNamespaceExists`), for the same reason. - * - * It fails OPEN. A host whose protocol carries no `getMetaTypes`, or whose - * listing cannot be read, keeps today's answer rather than earning a - * refusal — inventing "no such type" from an unreachable authority would - * be an existence claim stated while the authority was unreachable, the - * very thing `metaTypeNamespaceExists` refuses to do in the write door. - * The cost is that the defect survives an outage; the alternative is - * refusing a type that does exist. - * - * ## Scope — deliberately the LIST door only - * - * ⛔ Not the compound arity. `/meta/lead/views/all_leads` carries an OBJECT - * name in the `:type` segment, which no static contract can enumerate; - * that is exemption 1 of the write door and it is honoured here by simply - * not being this route. ⛔ Not the single-item doors: measured on this - * branch, `GET /meta//x` already answers `404 - * RESOURCE_NOT_FOUND` and the `/references`, `/layers`, `/history`, - * `/audit`, `/diff`, `/published` limbs already answer `501 - * NOT_IMPLEMENTED` — all distinguishable from a served answer, so none of - * them carries this defect. - * - * @returns nothing; THROWS the refusal, so the handler's own `catch` - * shapes it through `handleRouteError` and the wire body is - * byte-identical to the write door's for the same condition. A - * hand-built body here would author a second dialect for one - * condition and would tick this file's `check:route-envelope` - * dialect ratchets UP. + * metadata type — `refuseUnknownMetaListType` in `./meta-item-read-gate.ts`, + * whose docblock carries the rule: the union of the static spelling contract + * and the live type set, fail-open on an unreadable listing, and a THROWN + * refusal so the route's `handleRouteError` shapes it. [#20408] Moved there, + * unchanged, so the runtime dispatcher's `/meta` list refuses the same + * segments instead of listing an empty collection. */ private async refuseUnknownMetaListType(p: any, urlType: unknown): Promise { - if (typeof urlType !== 'string' || urlType.length === 0) return; - const unrecognised = unrecognisedMetaTypeRefusal(urlType); - if (!unrecognised) return; - if (await this.metaTypeIsLive(p, urlType)) return; - const err: any = new Error( - `[invalid_request] '${unrecognised.type}' is not a metadata type. The platform declares ` - + `no such type and this deployment has registered no items under it, so an empty ` - + `collection here would be indistinguishable from a type that exists and holds ` - + `nothing. GET /api/v1/meta/types lists the types this deployment carries.`, - ); - err.code = 'INVALID_REQUEST'; - err.status = 400; - throw err; - } - - /** - * [#9488] Does this deployment's LIVE type set carry this `/meta/:type` - * segment? The second half of {@link refuseUnknownMetaListType}'s union. - * - * Reads the same accessor `GET /meta/types` serves, and tolerates both - * shapes it is known to arrive in — the protocol's `{ types, entries }` - * and the bare `string[]` older hosts and stubs return. Both sides of the - * comparison are folded through `canonicalMetaUrlType`, the platform's own - * spelling fold, so a registry storing the plural and a URL carrying the - * singular still meet; nothing about spelling is re-derived here. - * - * `getMetaTypes` is called optionally on purpose: four `getMetaItems` - * call sites in this file already are, because a host may occupy the - * protocol slot with an object that does not implement the whole surface. - * - * Returns `true` — "cannot disprove, so do not refuse" — for every - * unreadable outcome: no accessor, a rejected call, or a listing in a - * shape this cannot read. See the caller's doc for why that direction. - */ - private async metaTypeIsLive(p: any, urlType: string): Promise { - let listing: unknown; - try { - listing = await (p as any)?.getMetaTypes?.(); - } catch { - return true; - } - const types: unknown = Array.isArray(listing) - ? listing - : (listing && typeof listing === 'object' && Array.isArray((listing as any).types)) - ? (listing as any).types - : null; - if (!Array.isArray(types)) return true; - const wanted = canonicalMetaUrlType(urlType); - return types.some((t: unknown) => typeof t === 'string' - && (t === urlType || canonicalMetaUrlType(t) === wanted)); + return metaReadGate.refuseUnknownMetaListType(p, urlType); } /** @@ -2872,26 +2737,6 @@ export class RestServer { ); } - /** Shape a book list for the audience resolver — `audienceBooksOf` in `./meta-item-read-gate.ts`. */ - private static audienceBooksOf(raw: unknown): any[] { - return metaReadGate.audienceBooksOf(raw); - } - - /** - * [ADR-0046 §6.7] Build THE {@link DocsAudience} for this request's caller - * over `books` — `resolveDocsAudience` in `./meta-item-read-gate.ts`, the - * one audience resolution every docs answer on both transports asks (the - * `/meta/doc` list, `/meta/doc/:name`, `/meta/book/:name/tree`, the app-nav - * `doc` arm). Holdings unresolvable → gated audiences deny (ADR-0049). - */ - private async resolveDocsAudience( - environmentId: string | undefined, - req: any, - books: readonly any[], - ): Promise { - return metaReadGate.resolveDocsAudience(this.metaReadAudienceSources(environmentId, req), books); - } - /** Heavy path behind `resolveExecCtx` — resolve identity + RBAC/RLS + localization. */ private async computeExecCtx(environmentId: string | undefined, req: any): Promise { try { @@ -3752,7 +3597,6 @@ export class RestServer { * they were handed. */ private async translateMetaItem(req: any, type: string, environmentId: string | undefined, item: any, i18nService?: any): Promise { - if (!item || typeof item !== 'object') return item; // [#6349] Normalize HERE, not at the call sites. `isTranslatableMetaType` // reads `TRANSLATABLE_METADATA_TYPES`, which is DERIVED from // `METADATA_DOCUMENT_TRANSLATORS`' keys — and those are singular-only, @@ -3774,34 +3618,17 @@ export class RestServer { // spelling that question is asked in. `metaTypeSingular` leaves an // unmapped type untouched, so nothing that was untranslatable becomes // translatable — the set is unchanged, only the spellings that reach it. - const metaType = RestServer.metaTypeSingular(type); - if (!(await isTranslatableMetaType(metaType))) return item; - // The cached read path resolves the i18n service up-front (to build a - // locale-aware ETag) and passes it here so we don't repeat the - // potentially registry-hitting lookup on every request. - const i18n = i18nService !== undefined ? i18nService : await this.resolveI18nService(environmentId, req); - // A missing bundle is NOT a bail-out: `translateMetadataDocument` - // still applies built-in fallbacks (e.g. the injected system-field - // labels `owner_id`/`created_*`/`updated_*` on custom objects, which - // ship no per-object translation entries). - const bundle = this.buildTranslationBundle(i18n); - const locale = this.extractLocale(req, i18n); - if (!locale) return item; - const { translateMetadataDocument } = await import('@objectstack/spec/system'); - // [#8284] The packaged baseline the catalog is a translation OF — see - // `packagedObjectBase`. Resolved through the request's own protocol so - // a multi-tenant read asks the kernel that actually serves it. - const packagedBase = metaType === 'object' - ? this.packagedObjectBase( - await this.resolveProtocol(environmentId, req).catch(() => undefined), - metaType, - (item as any)?.name, - ) - : undefined; - return translateMetadataDocument(metaType, item, bundle, { - ...RestServer.translateOptionsFor(i18n, locale), - packagedBase, - }); + // + // [#20408] The translation itself is `translateMetaDocument` in + // `./meta-item-read-gate.ts` — the runtime dispatcher's item read asks + // it too. The cached read path resolves the i18n service up-front (to + // build a locale-aware ETag) and passes it here, so the potentially + // registry-hitting lookup is not repeated. + return metaReadGate.translateMetaDocument( + this.metaItemTranslationSources(environmentId, req, i18nService), + RestServer.metaTypeSingular(type), + item, + ); } /** @@ -3824,22 +3651,57 @@ export class RestServer { i18nService?: any, ): Promise { // [#10235] The per-column sortability projection, served beside the - // document whenever the document IS an object schema. Computed here — - // the one seam every single-item exit passes through (#5563: cached, - // uncached, compound-name) — so the signal cannot be a property of - // which branch answered; and computed from the FINAL document (post - // ADR-0106 masking), so its domain is exactly the field set this - // caller is served. Never inside `item`: `FieldSchema` is strict and - // the key must stay un-authorable (see `sortability.zod.ts`). - const sortability = - RestServer.metaTypeSingular(type) === 'object' - && document && typeof document === 'object' && !Array.isArray(document) - ? { sortability: resolveObjectSortability(document) } - : {}; + // document whenever the document IS an object schema, computed from the + // FINAL document (post ADR-0106 masking) and never inside `item`. + // [#20408] Both halves are `translateMetaEnvelope` in + // `./meta-item-read-gate.ts` — the runtime dispatcher's item read answers + // the same body through it. + return metaReadGate.translateMetaEnvelope( + this.metaItemTranslationSources(environmentId, req, i18nService), + RestServer.metaTypeSingular(type), + envelope, + document, + ); + } + + /** + * [#20408] This transport's I/O for an item translation: the request's i18n + * service (or the one a caller already resolved — the cached arm's, which + * keyed its ETag on the locale), its protocol for the #8284 packaged object + * base, and {@link extractLocale} over this request. + */ + private metaItemTranslationSources( + environmentId: string | undefined, + req: any, + i18nService?: any, + ): metaReadGate.MetaListTranslationSources { return { - ...envelope, - ...sortability, - item: await this.translateMetaItem(req, type, environmentId, document, i18nService), + resolveI18nService: async () => (i18nService !== undefined ? i18nService : this.resolveI18nService(environmentId, req)), + resolveProtocol: () => this.resolveProtocol(environmentId, req).catch(() => undefined), + requestLocale: (i18n) => this.extractLocale(req, i18n), + }; + } + + /** + * [#20408] This transport's I/O for THE item chain + * (`createMetaItemAnswer` in `./meta-item-read-gate.ts`): the per-caller + * gate's ports for the door's policy ({@link metaItemReadGateSources} — + * with the caller's `mayWriteItem` when the policy honours the author + * exemption, as {@link metaItemReadGate} builds them), {@link extractLocale}, + * and the body through {@link translateMetaEnvelope}, handed the RAW + * segment exactly as the plain read always called it. + */ + private metaItemAnswerSources( + environmentId: string | undefined, + req: any, + p: RestProtocol, + policy: MetaReadGatePolicy, + ): metaReadGate.MetaItemAnswerSources { + return { + ...this.metaItemReadGateSources(environmentId, req, p, policy.app === 'author-exempt'), + requestLocale: (i18n) => this.extractLocale(req, i18n), + translateEnvelope: (envelope, document) => + this.translateMetaEnvelope(req, req.params.type, environmentId, envelope as Record, document), }; } @@ -4089,17 +3951,17 @@ export class RestServer { * gate's ports ({@link metaItemReadGateSources}), {@link extractLocale}, * the translation through {@link translateMetaItems} (handed the RAW * segment, exactly as the list route always called it), the #5224 - * endpoint matcher and its one-shot absence notice, and the ADR-0106 - * object mask — which stays this transport's, because it writes this - * transport's cache header: an undetermined posture serves the list - * `private, no-store`, exactly as the list route always has. A D6 tier-3 - * fault is handed back, never sent here: the route answers it. + * endpoint matcher and its one-shot absence notice, and [#20408] this + * transport's object-schema masker ({@link resolveObjectMasker}) — the + * chain applies it, and hands the route the `private, no-store` an + * undetermined posture owes (it used to be set here, inside the port, so + * the dispatcher's port could, and did, leave it out). A D6 tier-3 fault is + * handed back, never sent here: the route answers it. */ private metaListAnswerSources( environmentId: string | undefined, req: any, p: RestProtocol, - res: any, ): metaReadGate.MetaListAnswerSources { return { ...this.metaItemReadGateSources(environmentId, req, p), @@ -4107,30 +3969,7 @@ export class RestServer { translateList: (_metaType, items) => this.translateMetaItems(req, req.params.type, environmentId, items), resolveEndpointMatcher: () => this.resolveEndpointMatchAuthority(environmentId, req), notifyMissingEndpointMatcher: (surface) => this.notifyMissingEndpointAuthority(surface), - maskObjects: async (list) => { - const masker = await this.resolveObjectMasker(environmentId, req, 'object'); - const projected: any[] = []; - let undetermined = false; - for (const item of list) { - const objectName = String((item as any)?.name ?? ''); - let posture: ObjectSchemaMaskPosture; - try { - posture = await masker(objectName); - } catch (maskError: any) { - // D6 tier 3 — one unevaluable object fails the whole - // list rather than serving it with a silent hole in - // the projection. - if (maskError instanceof ObjectSchemaMaskEvaluationError) return { ok: false, object: objectName }; - throw maskError; - } - if (posture.kind === 'undetermined') undetermined = true; - const masked = applyObjectSchemaMask(item, posture); - if (masked.emptied) return { ok: false, object: objectName }; - projected.push(masked.document); - } - if (undetermined) res.header('Cache-Control', 'private, no-store'); - return { ok: true, items: projected }; - }, + resolveObjectMasker: () => this.resolveObjectMasker(environmentId, req, 'object'), }; } @@ -5979,11 +5818,12 @@ export class RestServer { // and same registry gate as every other read door here. const listCtx = await this.resolveExecCtx(environmentId, req) .catch(rethrowAuthzStoreUnavailable); - const listOrganizationId = organizationIdForMetaRead( - // [#10340] FOLDED, not raw — see the PUT door's - // org-scope comment for the measurement. - canonicalMetaUrlType(req.params.type), listCtx?.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. [#20408] Asked of + // `metaReadOrganizationId`, the one answer the runtime + // dispatcher's list asks too. + const listOrganizationId = metaReadGate.metaReadOrganizationId(req.params.type, listCtx); // ADR-0033/0037 draft-overlay preview: `?preview=draft` // overlays pending drafts on the active list, exactly as // the runtime dispatcher's /metadata/:type route does — @@ -6031,13 +5871,17 @@ export class RestServer { // `previewDrafts` is handed in ADMITTED — the one // declaration above — and the chain never re-reads it. const answer = await metaReadGate.createMetaListAnswer( - this.metaListAnswerSources(environmentId, req, p, res), + this.metaListAnswerSources(environmentId, req, p), { metaType: RestServer.metaTypeSingular(req.params.type), query: req.query, previewDrafts }, )(items); if (!answer.ok) { sendFieldVisibilityFault(res, answer.object); return; } + // [ADR-0106 D6 tier 2] The chain's cache posture — an + // undetermined field visibility serves the list + // `private, no-store`, exactly as this route always has. + if (answer.cacheControl) res.header('Cache-Control', answer.cacheControl); res.header('Vary', 'Accept-Language'); res.json(answer.data); } catch (error: any) { @@ -6284,67 +6128,37 @@ export class RestServer { try { const environmentId = isScoped ? req.params?.environmentId : undefined; const prot = await this.resolveProtocol(environmentId, req); - const locale = this.extractLocale(req); // [#6877] One package scopes the book lookup. if (refuseRepeatedQueryParams(req, res, ['package'])) return; const packageId = req.query?.package || undefined; - const { resolveDocLocale } = await import('@objectstack/spec/system'); - - const norm = (raw: any): any[] => - Array.isArray(raw) ? raw : (raw && Array.isArray(raw.items) ? raw.items : []); - - const booksRequest: TransportScopedMetaRequest = { - type: 'book', - ...(packageId ? { packageId } : {}), - ...(environmentId ? { environmentId } : {}), - }; - const books = norm(await prot.getMetaItems(booksRequest)); - // [#19790] The same `DocsAudience` the doc reads and the - // app-nav `doc` arm use — one resolution, four doors. - const audience = await this.resolveDocsAudience( - environmentId, req, RestServer.audienceBooksOf(books)); - const caller = audience.caller; - // Unknown name → the implicit per-package book (§6.4). - const book = audience.bookNamed(req.params.name); - - // §6.7 — the book's audience gates the whole tree: - // anonymous → `public` only; `{ permissionSet }` → - // the caller must hold the named set (fail closed - // when holdings cannot be resolved, ADR-0049). - if (!audience.admitsBook(book)) { - if (!caller.authenticated) { - sendDeclaredFault(res, { code: 'UNAUTHENTICATED', message: 'This documentation requires sign-in', status: 401 }); - } else { - sendDeclaredFault(res, { code: 'PERMISSION_DENIED', message: 'This documentation is limited to holders of a permission set you do not have', status: 403 }); - } + // [#20408] The route's whole answer is + // `createMetaBookTreeAnswer` in `./meta-item-read-gate.ts` + // — the book and doc reads, THE `DocsAudience` (#19790: one + // resolution, four doors), the §6.7 gate on the book's own + // audience, the doc locale collapse and the tree narrowed + // per caller — which the runtime dispatcher serves this + // route through too. ⛔ A step is added there, never here. + const answer = await metaReadGate.createMetaBookTreeAnswer( + { + ...this.metaReadAudienceSources(environmentId, req), + listTreeInput: (type, scopedPackageId) => { + const request: TransportScopedMetaRequest = { + type, + ...(scopedPackageId ? { packageId: scopedPackageId } : {}), + ...(environmentId ? { environmentId } : {}), + }; + return prot.getMetaItems(request); + }, + requestLocale: (i18n) => this.extractLocale(req, i18n), + }, + { name: req.params.name, packageId }, + ); + if (!answer.ok) { + const { code, message, status } = answer.refusal; + sendDeclaredFault(res, { code, message, status }); return; } - - const docsRequest: TransportScopedMetaRequest = { - type: 'doc', - ...(packageId ? { packageId } : {}), - ...(environmentId ? { environmentId } : {}), - }; - const docs = norm(await prot.getMetaItems(docsRequest)) - .map((d: any) => (d && typeof d === 'object' ? resolveDocLocale(d, locale) : d)) - .map((d: any) => ({ - name: d.name, - label: d.label, - description: d.description, - order: d.order, - group: d.group, - tags: d.tags, - packageId: d._packageId, - })); - - // §6.7 — the tree's ENTRIES are additionally filtered by - // each doc's effective audience (union over claiming - // books, unclaimed → org), so an anonymous reader of a - // public book never sees nav entries that would 401 on - // fetch, and gated-only docs stay out of non-holders' - // trees. The book gate above passed, so this only ever - // narrows further for anonymous / non-holder callers. - res.json(audience.readableTree(book, docs)); + res.json(answer.tree); } catch (error: any) { handleRouteError(res, error); } @@ -6577,11 +6391,12 @@ export class RestServer { // ⚠️ NOT a new seam: memoised per request, and this // handler resolves the same context again further down. // [#20338] `readCtx` is resolved above the draft switches. - const readOrganizationId = organizationIdForMetaRead( - // [#10340] FOLDED, not raw — see the PUT door's - // org-scope comment for the measurement. - canonicalMetaUrlType(req.params.type), readCtx?.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. [#20408] Asked of + // `metaReadOrganizationId`, the one answer the runtime + // dispatcher's item read asks too. + const readOrganizationId = metaReadGate.metaReadOrganizationId(req.params.type, readCtx); if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDashboardType && !isDraftRead && !previewDrafts && !packageScoped && !isAudienceGatedType) { // [ADR-0106 D3] When a projection applies, the // protocol is NOT allowed to judge the conditional @@ -6756,124 +6571,61 @@ export class RestServer { }; const envelope = await p.getMetaItem(itemRequest) as Record; - // [#5563] `getMetaItem` answers the envelope - // `{ type, name, item, lock, … }`. Unwrap ONCE here; - // every gate below operates on the document, and the - // envelope is rebuilt around the result at `res.json`. - // Nothing downstream asks which shape it holds. - let visible: any = envelope?.item; - - // [#18066] ABSENCE IS AN ERROR ON THIS ARM TOO. + // [#20408] Everything this read does to the envelope + // after the store read is ONE chain, + // `createMetaItemAnswer` in `./meta-item-read-gate.ts`, + // which the runtime dispatcher's `/meta` item read calls + // too. Its docblock carries each step's rule and their + // order — [#18066] absence judged BEFORE the gate (a name + // with nothing behind it can never become #8013's `403`), + // [#20156] THE per-caller read gate, the ADR-0046 doc + // locale collapse, the [ADR-0106 D1/D5(1)] mask under the + // posture resolved above (this uncached exit serves + // `?state=draft`, `?preview=draft`, `?package=` and every + // `enableCache: false` deployment, so a mask living only + // in the cached arm would be walked past by a query + // parameter), and the body: the translation and #10235's + // `sortability`. ⛔ An item step is added there, never + // here (`meta-list-projection-parity.test.ts` in + // `@objectstack/runtime` derives its census from this + // handler and fails on one added here). // - // Ordered BEFORE every gate below, and that ordering - // is the security half of this change rather than a - // style choice. The gate under it judges only a - // `visible` document, so it is reached only by a - // document that EXISTS; a name that resolves to - // nothing can never enter the app gate and can - // therefore never be converted into the `403 - // PERMISSION_DENIED` #8013 reserves for an app the - // caller may not open. The withheld-but-existing - // app keeps answering exactly what it answered - // before — 403 for a permission denial, absence for - // the other two arms — because nothing on its path - // changed. - // - // `== null` rather than falsiness: the miss this - // catches is `undefined` (no overlay row, no - // MetadataService copy, no registry entry — see - // `metadata-protocol`'s `getMetaItem`, whose three - // lookups all leave `item` undefined) or a protocol - // implementation that resolved nothing at all. A - // document that is legitimately falsy-but-present is - // not a miss, and a metadata store that could not be - // READ never arrives here as a value at all — it - // throws 503 (#5532), which is the distinction this - // condition must not flatten. - if (visible == null) { - sendMetaItemAbsent(res); - return; - } - // [#20156] THE per-caller read gate — the one every - // door beside this read asks too. The per-type gates - // this block used to spell inline (the app nav filter, - // the ADR-0057 D10 dashboard widget gate, the - // ADR-0046 §6.7 docs audience) now live in - // {@link metaItemReadGate}, with their history, so a - // door cannot serve this document past a gate this - // read applies. This read runs every arm and serves a - // partly-withheld app PRUNED, to every caller, its - // authors included (read-to-display is per user) — - // the answer the census in - // `meta-alternate-door-read-gates.test.ts` holds each - // door to, save the stored-version doors' author - // exemption (ruling 5856774816, see - // `MetaReadGatePolicy.app`). - // - // [#20290] Save its `?state=draft` branch, which serves - // a STORED version — the pending draft row, never the - // rendered world (that is `?preview=draft`, which keeps - // the policy above) — and so reads under the + // The gate's policy is this read's: every arm and the app + // PRUNED, to every caller, its authors included + // (read-to-display is per user) — save [#20290] the + // `?state=draft` branch, which serves a STORED version + // (the pending draft row) and so reads under the // stored-version doors' policy - // ({@link STORED_VERSION_DOOR_POLICY}). Studio's - // designers merge this answer over the layered view - // and save the result back, so a draft pruned for an - // author deleted what it withheld: whoever may save - // the app reads its draft whole, every other caller - // pruned per caller (ruling 5856774816's rule, the - // carrier triage decided in 5859504238). And no - // per-DEPLOYMENT gate: a nav entry or a widget whose - // service is merely off here is part of the stored - // draft, for every caller, as on `/layers`. - // [#20338] "Every other caller" is every other caller - // who may READ drafts: one who may not never reaches - // this branch (`isDraftRead` is false for them), and - // is served the published item under the policy above. - // - // [plural-spelling commit 83a3b1f2e] (the original - // card no longer resolves) Judged on the NORMALIZED - // `metaType`, like every gate here: `/meta/books/:name` - // is the canonical plural spelling (Prime Directive #3). + // ({@link STORED_VERSION_DOOR_POLICY}): Studio's designers + // merge this answer over the layered view and save it + // back, so whoever may save the app reads its draft + // whole, every other caller pruned per caller (ruling + // 5856774816), and no per-DEPLOYMENT gate. [#20338] "Every + // other caller" is every other caller who may READ + // drafts: one who may not never reaches this branch + // (`isDraftRead` is false for them). const readPolicy: MetaReadGatePolicy = isDraftRead ? RestServer.STORED_VERSION_DOOR_POLICY : { arms: 'all', app: 'gate' }; - const verdict = await this.metaItemReadGate( - environmentId, req, p, metaType, req.params.name, [visible], readPolicy, - )(visible); - if (verdict.kind === 'refuse') { - verdict.send(res); + const answer = await metaReadGate.createMetaItemAnswer( + this.metaItemAnswerSources(environmentId, req, p, readPolicy), + { metaType, name: req.params.name, policy: readPolicy, maskPosture }, + )(envelope); + if (answer.kind === 'refuse') { + // `absent` is {@link sendMetaItemAbsent}, byte for byte. + RestServer.sendMetaReadRefusal(res, answer.refusal); return; } - visible = verdict.document; - - // ADR-0046 i18n: collapse the doc to the request - // locale (label/description/content) and drop the - // `translations` map so consumers get one body. - if (metaType === 'doc' && visible) { - const locale = this.extractLocale(req); - const { resolveDocLocale } = await import('@objectstack/spec/system'); - visible = resolveDocLocale(visible as any, locale); - } - - // [ADR-0106 D1/D5(1)] The uncached exit. Same - // posture, same projection — this branch serves - // `?state=draft`, `?preview=draft`, `?package=` and - // any deployment with `enableCache: false`, so a - // mask that lived only in the cached branch would be - // walked past by a query parameter (#5881's shape, - // in reverse). - if (maskPosture.kind === 'project') { - const masked = this.maskObjectDocument(res, maskPosture, req.params.name, visible); - if (!masked) return; - visible = masked.document; - } else if (maskPosture.kind === 'undetermined') { - res.header('Cache-Control', 'private, no-store'); + if (answer.kind === 'mask-fault') { + sendFieldVisibilityFault(res, answer.object); + return; } - + // [ADR-0106 D6 tier 2] Visibility undetermined → the + // body is unmasked, so it must not be stored or + // revalidated under a shared validator. + if (answer.cacheControl) res.header('Cache-Control', answer.cacheControl); res.header('Vary', 'Accept-Language'); - res.json(await this.translateMetaEnvelope( - req, req.params.type, environmentId, envelope, visible, - )); + res.json(answer.envelope); } } catch (error: any) { // [#18402] THE one absence answer, whichever arm diff --git a/packages/runtime/src/domains/meta-draft-read-builder-gate.test.ts b/packages/runtime/src/domains/meta-draft-read-builder-gate.test.ts index c5634e2b1ec..1be988b870f 100644 --- a/packages/runtime/src/domains/meta-draft-read-builder-gate.test.ts +++ b/packages/runtime/src/domains/meta-draft-read-builder-gate.test.ts @@ -289,8 +289,9 @@ describe('[#20338] census: every draft switch in domains/meta.ts is ledgered, an const collapse = (s: string) => s.replace(/\s+/g, ' ').trim(); const LEDGER: ReadonlyArray<{ site: string; count: number; disposition: 'read' | 'listing' }> = [ - // The item read's and the list's `?preview=draft` — the switch declared with its admission. - { site: "query?.preview === 'draft'", count: 2, disposition: 'read' }, + // The item read's and the list's `?preview=draft` — the switch declared with its admission, + // [#20408] parsed case-insensitively as `RestServer` parses it. + { site: "query.preview.toLowerCase() === 'draft'", count: 2, disposition: 'read' }, // [#20320] The item read's `?state=draft` — the pending draft row, declared with its admission. { site: "query.state.toLowerCase() === 'draft'", count: 1, disposition: 'read' }, // `GET /meta/_drafts` — the probe and the call, behind the 403. 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 6b76efb455e..e67e5138575 100644 --- a/packages/runtime/src/domains/meta-list-projection-parity.test.ts +++ b/packages/runtime/src/domains/meta-list-projection-parity.test.ts @@ -198,7 +198,8 @@ const singular = (type: unknown): string => pluralToSingular(String(type ?? '')) interface Caller { ctx: { userId?: string; isSystem: false; systemPermissions: string[] }; holdings: string[]; - readableFields: string[]; + /** `undefined` — the field universe is unresolvable: the ADR-0106 D6 tier-2 `undetermined` posture. */ + readableFields: string[] | undefined; } const CALLERS = { holder: { @@ -224,6 +225,12 @@ const CALLERS = { readableFields: ['amount'], }, anonymous: { ctx: { isSystem: false, systemPermissions: [] }, holdings: [], readableFields: [] }, + /** [#20408] A member whose field visibility could not be determined (ADR-0106 D6 tier 2). */ + undetermined: { + ctx: { userId: 'u_undetermined', isSystem: false, systemPermissions: [] }, + holdings: [], + readableFields: undefined, + }, } satisfies Record; type CallerName = keyof typeof CALLERS; const LIST_CALLERS = ['holder', 'non-holder', 'builder', 'anonymous'] as const satisfies readonly CallerName[]; @@ -276,7 +283,7 @@ const endpointMatcher = { // ── The two transports ──────────────────────────────────────────────────────── -interface Answer { status: number; code?: string; body: any; items?: any[]; item?: any; vary?: string } +interface Answer { status: number; code?: string; body: any; items?: any[]; item?: any; vary?: string; cacheControl?: string } const itemsOf = (data: any): any[] | undefined => Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : undefined; @@ -303,8 +310,9 @@ function bootDispatcher(callerName: CallerName, opts: Boot = {}) { 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']; return { - status, code: body?.error?.code, body, vary, + status, code: body?.error?.code, body, vary, cacheControl, items: status === 200 ? itemsOf(body?.data) : undefined, item: status === 200 ? body?.data?.item : undefined, }; @@ -355,13 +363,17 @@ function bootRest(callerName: CallerName, opts: Boot = {}) { const status = res.statusCode; return { status, code: res.body?.code ?? res.body?.error?.code, body: res.body, vary: res.headers.Vary, + cacheControl: res.headers['Cache-Control'], items: status === 200 ? itemsOf(res.body) : undefined, item: status === 200 ? res.body?.item : undefined, }; }; const read = async (path: string, query: Record = {}, headers: Record = {}): Promise => { const [type, name, ...rest_] = path.replace(/^\/meta\//, '').split('/'); - if (rest_.length > 0) throw new Error(`bootRest.read drives list and item reads only: ${path}`); + 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}`); return name === undefined ? drive(`${META}/:type`, path.replace(/^\/meta/, ''), { type }, query, headers) : drive(`${META}/:type/:name`, path.replace(/^\/meta/, ''), { type, name }, query, headers); @@ -396,6 +408,8 @@ const PARAM_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record { label: 'Accept-Language: zh-CN', query: {}, headers: { 'accept-language': 'zh-CN' } }, { label: '?package=crm', param: 'package', query: { package: 'crm' } }, { label: '?preview=draft', param: 'preview', query: { preview: 'draft' } }, + // [#20408] The switch's value is compared case-insensitively on `RestServer`. + { label: '?preview=DRAFT', param: 'preview', query: { preview: 'DRAFT' } }, ]; const PARAM_AXIS = new Set(PARAM_PROBES.map((p) => p.param).filter((p): p is string => !!p)); @@ -403,6 +417,9 @@ const PARAM_AXIS = new Set(PARAM_PROBES.map((p) => p.param).filter((p): p is str const TYPE_CELLS: readonly string[] = [ 'app', 'apps', 'view', 'views', 'doc', 'docs', 'book', 'books', 'api', 'apis', 'object', 'objects', 'dashboard', 'dashboards', 'page', 'action', 'dataset', 'flow', + // [#20408] A segment that names no metadata type: `RestServer` refuses it + // (`refuseUnknownMetaListType`) rather than listing an empty collection. + 'totally_invented_type', ]; const TYPE_AXIS = new Set(TYPE_CELLS.map(singular)); @@ -509,8 +526,15 @@ describe('[#20320] row A: the dispatcher lists what RestServer lists — every t const same = dispatcher.status === rest.status && dispatcher.code === rest.code && JSON.stringify(dispatcher.items) === JSON.stringify(rest.items) - && (rest.status !== 200 || dispatcher.vary === rest.vary); - if (!same) mismatches.push({ who, dispatcher: { ...brief(dispatcher), vary: dispatcher.vary }, rest: { ...brief(rest), vary: rest.vary } }); + && (rest.status !== 200 || dispatcher.vary === rest.vary) + && dispatcher.cacheControl === rest.cacheControl; + if (!same) { + mismatches.push({ + who, + dispatcher: { ...brief(dispatcher), vary: dispatcher.vary, cacheControl: dispatcher.cacheControl }, + rest: { ...brief(rest), vary: rest.vary, cacheControl: rest.cacheControl }, + }); + } } expect(mismatches).toEqual([]); }); @@ -570,10 +594,8 @@ describe('[#20320] row B: an anonymous read of a public book or doc is served on .toEqual({ status, code: status === 200 ? undefined : 'UNAUTHENTICATED' }); expect({ status: dispatcher.status, code: dispatcher.code }).toEqual({ status: rest.status, code: rest.code }); // The document served: its identity, label and body. (The item read's - // doc LOCALE collapse — `RestServer` drops a doc's `translations` map - // on this read, the dispatcher does not — is a divergence of the item - // read, not of reachability; it is reported with this card's - // out-of-scope findings, not pinned here.) + // doc LOCALE collapse — the rest of the answer — is pinned by the + // [#20408] item census below, every caller × query parameter.) const served = (a: Answer) => a.item && { name: a.item.name, label: a.item.label, content: a.item.content }; if (status === 200) expect(served(dispatcher)).toEqual(served(rest)); expect(text(dispatcher)).not.toContain(DOC_SECRET); @@ -592,8 +614,11 @@ describe('[#20320] row B: an anonymous read of a public book or doc is served on } }); - it('control: the exemption reaches only the dispatcher\'s own list and item reads — /published and an unrouted book path stay 401', async () => { - for (const path of ['/meta/book/public_guide/published', '/meta/book/public_guide/tree', '/meta/doc/public_faq/published']) { + it('control: the exemption reaches only the dispatcher\'s own list, item and book-tree reads — /published and an unrouted book path stay 401', async () => { + // [#20408] `/meta/book/:name/tree` is a route here now, and exempt as on + // `RestServer` (its census is below); the plural spelling is no route on + // either transport, so it keeps the deny. + for (const path of ['/meta/book/public_guide/published', '/meta/books/public_guide/tree', '/meta/doc/public_faq/published']) { const { read, protocol } = bootDispatcher('anonymous'); const res = await read(path); expect({ status: res.status, code: res.code }, path).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); @@ -654,3 +679,235 @@ describe('[#20320] the ?state=draft row: the dispatcher\'s item read answers wha }); } }); + +// ── [#20408] The item read, the book-tree route, and the cache posture ──────── +// +// #20320 closed the LIST. The item read and the route beside it had the same two +// transports and the same split, measured on this file's fixtures before the +// fix: the dispatcher's item read did not translate, did not collapse a doc to +// the request's locale, served no `sortability` beside an object schema, read +// `?preview=` case-sensitively and ignored `?preview=draft` on the object +// branch; the list compared `?preview=` case-sensitively and served an empty +// collection for a segment that names no type; `GET /meta/book/:name/tree` +// was no route at all; and an object schema served under an UNDETERMINED field +// visibility (ADR-0106 D6 tier 2) went out with no `Cache-Control`, where +// `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' } }, + { label: '?state=DRAFT', param: 'state', query: { state: 'DRAFT' } }, + { label: '?preview=draft', param: 'preview', query: { preview: 'draft' } }, + { label: '?preview=DRAFT', param: 'preview', query: { preview: 'DRAFT' } }, + { 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' } }, +]; +const ITEM_PARAM_AXIS = new Set(ITEM_PROBES.map((p) => p.param).filter((p): p is string => !!p)); + +/** One cell per type the item read keys on (both spellings where a gate folds), per translatable type, plus absences and refusals. */ +const ITEM_CELLS: readonly string[] = [ + '/meta/app/crm', '/meta/apps/crm', + // `requiredPermissions` the non-holder lacks → 403; unpublished → absent to a non-builder. + '/meta/app/payroll', '/meta/app/launchpad', + // A draft-only app, an app with no draft, and a name with nothing behind it. + '/meta/app/beacon', '/meta/app/helpdesk', '/meta/app/no_such_app', + '/meta/view/lead_all', '/meta/views/lead_all', + '/meta/doc/crm_intro', '/meta/docs/crm_intro', '/meta/doc/crm_admin_runbook', '/meta/doc/public_faq', + '/meta/book/admin_guide', '/meta/books/help_center', '/meta/book/public_guide', + '/meta/object/invoice', '/meta/objects/invoice', + '/meta/dashboard/ops', '/meta/dashboards/ops', + '/meta/page/home', '/meta/action/close_case', '/meta/dataset/pipeline', '/meta/flow/on_lead', '/meta/api/crm_served', +]; +const ITEM_TYPE_AXIS = new Set(ITEM_CELLS.map((c) => singular(c.split('/')[2]))); +const ITEM_CALLERS = ['holder', 'non-holder', 'builder', 'author'] as const satisfies readonly CallerName[]; + +/** `GET /meta/book/:name/tree` — every declared book, the implicit per-package book, and a name nothing claims. */ +const TREE_CELLS: readonly string[] = ['admin_guide', 'help_center', 'public_guide', 'crm', 'no_such_book']; +const TREE_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record; headers?: Record }> = [ + { label: '(none)', query: {} }, + { 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' } }, +]; +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. */ +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 => + dispatcher.status === rest.status + && 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; +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) }, +}); + +/** + * The shared functions `RestServer`'s item and tree handlers hand their answer + * to — read as part of the handler, so a parameter or a type the SHARED step + * 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_TREE_FUNCTIONS = ['createMetaBookTreeAnswer', 'resolveDocsAudience', 'metaRequestLocale']; + +function deriveRouteReads(routePath: string, sharedFunctions: 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 => { + if (ts.isObjectLiteralExpression(n)) { + const method = propertyInitializer(rest, n, 'method'); + const path = propertyInitializer(rest, n, 'path'); + const handler = propertyInitializer(rest, n, 'handler'); + if (method?.getText(rest) === "'GET'" && path?.getText(rest) === routePath && handler) { + derived.found = true; + readsUnder(rest, handler, derived); + } + } + ts.forEachChild(n, visit); + }; + visit(rest); + const chain = parse(SHARED_CHAIN_SOURCE); + for (const s of chain.statements) { + if (ts.isFunctionDeclaration(s) && s.name && sharedFunctions.includes(s.name.text)) readsUnder(chain, s, derived); + } + return derived; +} + +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 tree = deriveRouteReads('`${metaPath}/book/:name/tree`', SHARED_TREE_FUNCTIONS); + + it('both handlers are found where the census looks for them', () => { + 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); + }); + + 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(); + 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([]); + }); + + it('every type the item read keys on, and every translatable type, is a census cell', () => { + const wanted = new Set([...item.types, ...TRANSLATABLE_METADATA_TYPES]); + expect([...wanted].filter((t) => !ITEM_TYPE_AXIS.has(t)).sort()).toEqual([]); + }); + + it('every query parameter the book tree reads is a census cell', () => { + expect([...tree.params].filter((p) => !TREE_PARAM_AXIS.has(p)).sort()).toEqual([]); + }); +}); + +describe('[#20408] the item read: the dispatcher answers what RestServer answers — every cell × query parameter × caller', () => { + for (const path of ITEM_CELLS) { + for (const probe of ITEM_PROBES) { + it(`GET ${path} ${probe.label}`, async () => { + const mismatches: unknown[] = []; + for (const who of ITEM_CALLERS) { + const dispatcher = await bootDispatcher(who).read(path, probe.query, probe.headers); + const rest = await bootRest(who).read(path, probe.query, probe.headers); + if (!sameAnswer(dispatcher, rest)) mismatches.push(mismatchOf(who, dispatcher, rest)); + } + expect(mismatches).toEqual([]); + }); + } + } +}); + +describe('[#20408] the item read: the reference moves — each probe changes RestServer\'s answer for the cell it targets', () => { + it('translation, the doc locale, sortability, both draft switches in any case, and the object preview all move', async () => { + const { read } = bootRest('holder'); + expect((await read('/meta/app/crm', {}, { 'accept-language': 'zh-CN' })).item?.label).toBe('客户管理'); + const zhDoc = await read('/meta/doc/crm_intro', { locale: 'zh-CN' }); + expect(zhDoc.item?.label).toBe('入门'); + expect(text(zhDoc)).not.toContain('"translations"'); + expect(Object.keys((await read('/meta/object/invoice')).body ?? {})).toContain('sortability'); + const builder = bootRest('builder').read; + expect((await builder('/meta/app/crm', { preview: 'DRAFT' })).item?.label).toBe('CRM (draft)'); + expect((await builder('/meta/app/crm', { state: 'DRAFT' })).item?.label).toBe('CRM (draft)'); + expect((await builder('/meta/object/invoice', { preview: 'draft' })).item?.label).toBe('Invoice (draft)'); + }); + + it('the list: ?preview=DRAFT overlays the drafts, and a segment that names no type is refused', async () => { + expect(names((await bootRest('builder').read('/meta/app', { preview: 'DRAFT' })).items)).toContain('beacon'); + const invented = await bootRest('holder').read('/meta/totally_invented_type'); + expect({ status: invented.status, code: invented.code }).toEqual({ status: 400, code: 'INVALID_REQUEST' }); + }); +}); + +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) { + it(`GET /meta/book/${book}/tree ${probe.label}`, async () => { + const mismatches: unknown[] = []; + for (const who of TREE_CALLERS) { + const dispatcher = await bootDispatcher(who).read(`/meta/book/${book}/tree`, probe.query, probe.headers); + const rest = await bootRest(who).read(`/meta/book/${book}/tree`, probe.query, probe.headers); + if (!sameAnswer(dispatcher, rest)) mismatches.push(mismatchOf(who, dispatcher, rest)); + } + expect(mismatches).toEqual([]); + }); + } + } + + it('the reference: the public book is served anonymously, a gated one refused, and the tree narrowed per caller', async () => { + const anonymous = await bootRest('anonymous').read('/meta/book/public_guide/tree'); + expect(anonymous.status).toBe(200); + expect(text(anonymous)).toContain('public_faq'); + const gated = await bootRest('anonymous').read('/meta/book/admin_guide/tree'); + expect({ status: gated.status, code: gated.code }).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); + const member = await bootRest('non-holder').read('/meta/book/admin_guide/tree'); + expect({ status: member.status, code: member.code }).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(text(await bootRest('holder').read('/meta/book/admin_guide/tree'))).toContain('crm_admin_runbook'); + }); +}); + +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']; + + 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) { + const undetermined = await bootRest('undetermined').read(path); + expect({ status: undetermined.status, cacheControl: undetermined.cacheControl }, path).toEqual({ status: 200, cacheControl: 'private, no-store' }); + expect((await bootRest('non-holder').read(path)).cacheControl, path).toBeUndefined(); + } + }); + + for (const path of OBJECT_PATHS) { + const probes = path.split('/').length === 3 ? PARAM_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); + const rest = await bootRest('undetermined').read(path, probe.query, probe.headers); + const list = path.split('/').length === 3; + const same = list + ? dispatcher.status === rest.status && dispatcher.code === rest.code + && JSON.stringify(dispatcher.items) === JSON.stringify(rest.items) + && dispatcher.cacheControl === rest.cacheControl + : sameAnswer(dispatcher, rest); + expect(same ? [] : [mismatchOf('undetermined', dispatcher, rest)]).toEqual([]); + }); + } + } +}); 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 new file mode 100644 index 00000000000..5dd4c0383ee --- /dev/null +++ b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts @@ -0,0 +1,358 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20408] The dispatcher's `/meta` doors scope a caller's metadata to the + * organization `RestServer` scopes it to — the VETTED active organization on + * the caller's execution context, never the raw session claim. + * + * ## The defect + * + * `handleMetadataRequest` took its organization from + * `deps.resolveActiveOrganizationId`, which reads `session.activeOrganizationId` + * off the auth service AS STORED. `RestServer` reads `ctx.tenantId` off the + * execution context — the value `resolveAuthzContext` VETS: under a + * wall-enforcing posture a session claim naming an organization its owner no + * longer belongs to is DROPPED there, and the request resolves with no active + * organization at all. The dispatcher never saw the drop, so a member removed + * from an organization kept that organization's metadata partition on this + * transport, for the rest of the session's lifetime: + * + * - its org-scoped overlays of an org-overridable type (`view`, `dashboard`, + * `report`, `translation`, `email_template`) were served to them by the item + * read, the list, `/published` and the `?state=draft` read; + * - `GET /meta/_drafts` listed that organization's pending drafts; + * - `PUT /meta/:type/:name` wrote INTO that organization's partition. + * + * `RestServer` answers the same caller the env-wide rows, and lands the write + * env-wide. Each row below drives `dispatch()` — the `createHonoApp` + * catch-all's delegate — and `RestServer` over the same services, with the REAL + * identity resolution on both (`resolveExecutionContext` → + * `resolveAuthzContext`, `RestServer.computeExecCtx` → the same resolver): only + * the caller's stored session claim and their `sys_member` rows differ between + * the arms, so only the organization source can separate the transports. + * + * ## The protocol double + * + * It applies the read gate the real `metadata-protocol` applies to + * `getMetaItem`, `getMetaItems` and `getMetaItemLayered` + * (`organizationIdForMetaRead` over the folded type): an organization reaches a + * read only for an org-overridable type. So a transport that hands down a raw + * organization for `object` is answered what one that pre-gates it is — the + * `object` control below — and the difference measured is the organization + * VALUE, not where the gate runs. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { organizationIdForMetaRead } from '@objectstack/metadata-core'; +import { canonicalMetaUrlType } from '@objectstack/spec/shared'; +import { RestServer } from '@objectstack/rest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +// ── The metadata store: env-wide rows and each organization's overlays ──────── + +const ENV_WIDE: Record> = { + view: { lead_all: { name: 'lead_all', label: 'All leads', object: 'lead', viewKind: 'list' } }, + object: { invoice: { name: 'invoice', label: 'Invoice', fields: { amount: { type: 'number', label: 'Amount' } } } }, +}; +const ORG_OVERLAYS: Record>> = { + org_alpha: { + view: { lead_all: { name: 'lead_all', label: 'Alpha pipeline', object: 'lead', viewKind: 'list' } }, + // A phantom row of a NON-overridable type: the protocol's gate never reads it. + object: { invoice: { name: 'invoice', label: 'Invoice (alpha phantom)', fields: { amount: { type: 'number', label: 'Amount' } } } }, + }, + org_beta: { + view: { lead_all: { name: 'lead_all', label: 'Beta pipeline', object: 'lead', viewKind: 'list' } }, + }, +}; +/** Each organization's pending drafts, as `listDrafts` answers them. */ +const ORG_DRAFTS: Record = { + org_alpha: [{ type: 'view', name: 'alpha_board', label: 'Alpha board (draft)' }], + org_beta: [{ type: 'view', name: 'beta_board', label: 'Beta board (draft)' }], +}; +const ENV_DRAFTS: any[] = [{ type: 'view', name: 'env_board', label: 'Env board (draft)' }]; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); + +function protocolDouble() { + const gate = (type: string, organizationId: unknown) => + organizationIdForMetaRead(canonicalMetaUrlType(type), typeof organizationId === 'string' ? organizationId : undefined); + const resolve = (type: string, name: string, organizationId: unknown) => { + const t = canonicalMetaUrlType(type); + const org = gate(type, organizationId); + const overlay = org ? ORG_OVERLAYS[org]?.[t]?.[name] : undefined; + return { overlay, env: ENV_WIDE[t]?.[name] }; + }; + return { + getMetaTypes: vi.fn(async () => ({ types: Object.keys(ENV_WIDE) })), + getMetaItem: vi.fn(async ({ type, name, organizationId, state }: any) => { + const { overlay, env } = resolve(type, name, organizationId); + if (state === 'draft') { + // The pending draft row is the overlay's, when this scope has one. + if (!overlay) throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + return { type: canonicalMetaUrlType(type), name, item: { ...clone(overlay), label: `${overlay.label} (draft)` } }; + } + const item = overlay ?? env; + return { type: canonicalMetaUrlType(type), name, item: item ? clone(item) : undefined }; + }), + getMetaItems: vi.fn(async ({ type, organizationId }: any) => { + const t = canonicalMetaUrlType(type); + const org = gate(type, organizationId); + const merged = new Map(Object.entries(ENV_WIDE[t] ?? {}).map(([n, v]) => [n, clone(v)])); + if (org) for (const [n, v] of Object.entries(ORG_OVERLAYS[org]?.[t] ?? {})) merged.set(n, clone(v)); + return { type: t, items: [...merged.values()] }; + }), + // The overlay layer is a strict `state: 'active'` lookup, org-scoped first, + // then env-wide — the env-wide rows here are env-wide overlay rows. + getMetaItemLayered: vi.fn(async ({ type, name, organizationId }: any) => { + const { overlay, env } = resolve(type, name, organizationId); + const active = overlay ?? env; + return { type: canonicalMetaUrlType(type), name, code: null, overlay: active ? clone(active) : null, effective: clone(active ?? null) }; + }), + listDrafts: vi.fn(async ({ organizationId }: any) => ({ + items: clone(typeof organizationId === 'string' ? ORG_DRAFTS[organizationId] ?? [] : ENV_DRAFTS), + })), + saveMetaItem: vi.fn(async ({ type, name, organizationId }: any) => ({ + success: true, type, name, organizationId: organizationId ?? null, + })), + }; +} + +// ── Identity: sessions and the permission store, in the shipped shapes ──────── + +/** + * `u_exmember`'s session is stamped `org_alpha` while its only current + * `sys_member` row is `org_beta` — the session outlived the membership that + * backed it. `u_member` is a current `org_alpha` member. Both hold ONE shared + * permission set, so RBAC cannot separate any two arms: only the organization + * claim can. + */ +function makeQl() { + const tables: Record = { + sys_api_key: [], + sys_member: [ + { user_id: 'u_member', organization_id: 'org_alpha', role: 'member' }, + { user_id: 'u_exmember', organization_id: 'org_beta', role: 'member' }, + ], + sys_user: [ + { id: 'u_member', email: 'u_member@example.com' }, + { id: 'u_exmember', email: 'u_exmember@example.com' }, + ], + sys_user_permission_set: [ + { user_id: 'u_member', permission_set_id: 'ps_shared' }, + { user_id: 'u_exmember', permission_set_id: 'ps_shared' }, + ], + sys_permission_set: [ + { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, + ], + }; + /** + * Equality plus `$in` — the two shapes the shared resolver issues — and a + * loud refusal of every other shape, so a combinator this double does not + * implement can never read as a field that happened not to match. + */ + const matches = (row: any, where: any): boolean => Object.entries(where ?? {}).every(([field, cond]) => { + if (field.startsWith('$')) throw new Error(`fixture where-matcher: unsupported combinator '${field}'`); + if (cond !== null && typeof cond === 'object') { + const ops = Object.keys(cond as object); + if (ops.length !== 1 || ops[0] !== '$in' || !Array.isArray((cond as any).$in)) { + throw new Error(`fixture where-matcher: unsupported operator shape on '${field}'`); + } + return (cond as any).$in.includes(row[field]); + } + return row[field] === cond; + }); + return { + find: async (object: string, q: any = {}) => { + const rows = (tables[object] ?? []).filter((row: any) => matches(row, q?.where)); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + }; +} + +interface SessionRow { id: string; token: string; userId: string; activeOrganizationId: string | null } + +function makeSessions(): Record { + return { + sid_member: { id: 'ses_member', token: 'tok_member', userId: 'u_member', activeOrganizationId: 'org_alpha' }, + // ⭐ THE SUBJECT: the claim outlived the membership. + sid_exmember: { id: 'ses_exmember', token: 'tok_exmember', userId: 'u_exmember', activeOrganizationId: 'org_alpha' }, + }; +} + +function authService(sessions: Record) { + return { + api: { + getSession: async ({ headers }: any) => { + const cookie: string | undefined = typeof headers?.get === 'function' ? headers.get('cookie') ?? undefined : headers?.cookie; + const sid = cookie?.split('os_session=')[1]?.split(';')[0]; + const row = sid ? sessions[sid] : undefined; + if (!row) return undefined; + return { user: { id: row.userId, email: `${row.userId}@example.com` }, session: { ...row } }; + }, + }, + }; +} + +const TENANCY = { posture: 'isolated' }; + +const COOKIE = { member: 'os_session=sid_member', exmember: 'os_session=sid_exmember' } as const; +type Who = keyof typeof COOKIE; + +// ── The two transports, over the same services ──────────────────────────────── + +interface Answer { status: number; code?: string; data: any } + +function bootDispatcher(sessions = makeSessions()) { + const protocol = protocolDouble(); + const ql = makeQl(); + const services: Record = { protocol, auth: authService(sessions), objectql: ql, tenancy: TENANCY }; + const get = (n: string) => services[n] ?? null; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + const call = async (method: string, who: Who, path: string, query: Record = {}, body?: unknown): Promise => { + const res = await dispatcher.dispatch(method, path, body, query, { request: { headers: { cookie: COOKIE[who] } } } as any); + const b: any = res.response?.body; + return { status: res.response?.status ?? 0, code: b?.error?.code, data: b?.data }; + }; + return { call, protocol }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(() => res); + return res; +} + +/** The single-kernel wiring (`rest-api-plugin.ts`), with the REAL `computeExecCtx`. */ +function bootRest(sessions = makeSessions()) { + const protocol = protocolDouble(); + const ql = makeQl(); + const server: any = { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; + const rest: any = new RestServer( + server, protocol as any, {} as any, + undefined, undefined, undefined, + async () => authService(sessions), + async () => ql, + undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, undefined, + async () => TENANCY, + ); + rest.registerRoutes(); + const META = '/api/v1/meta'; + const call = async (method: string, who: Who, path: string, query: Record = {}, body?: unknown): Promise => { + const segs = path.replace(/^\/meta\//, '').split('/'); + const [routePath, params] = segs[0] === '_drafts' + ? [`${META}/_drafts`, {}] + : segs.length === 1 + ? [`${META}/:type`, { type: segs[0] }] + : segs.length === 2 + ? [`${META}/:type/:name`, { type: segs[0], name: segs[1] }] + : [`${META}/:type/:name/${segs[2]}`, { type: segs[0], name: segs[1] }]; + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === routePath); + if (!route) throw new Error(`${method} ${routePath} is not registered`); + const res = makeRes(); + await route.handler({ method, path: `${META}${path.replace(/^\/meta/, '')}`, params, query, body: body ?? {}, headers: { cookie: COOKIE[who] } }, res); + const status = res.statusCode; + return { status, code: res.body?.code ?? res.body?.error?.code, data: res.body }; + }; + return { call, protocol }; +} + +const itemsOf = (data: any): any[] => (Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : []); +const labelOf = (data: any, name: string) => itemsOf(data).find((i: any) => i?.name === name)?.label; + +let warnSpy: ReturnType; +beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); +afterEach(() => { warnSpy.mockRestore(); }); + +// ── Controls: the rig separates the organizations, and the resolver drops the claim ── + +describe('[#20408] controls: the rig can tell the organizations apart', () => { + it('a CURRENT member reads its own organization\'s overlay on both transports', async () => { + for (const boot of [bootDispatcher, bootRest]) { + const { call } = boot(); + const item = await call('GET', 'member', '/meta/view/lead_all'); + expect({ status: item.status, label: item.data?.item?.label }).toEqual({ status: 200, label: 'Alpha pipeline' }); + expect(labelOf((await call('GET', 'member', '/meta/view')).data, 'lead_all')).toBe('Alpha pipeline'); + const published = await call('GET', 'member', '/meta/view/lead_all/published'); + expect({ status: published.status, label: published.data?.label }).toEqual({ status: 200, label: 'Alpha pipeline' }); + } + }); + + it('the protocol double gates a non-overridable type: the phantom org row of `object` is never read', async () => { + for (const boot of [bootDispatcher, bootRest]) { + const { call } = boot(); + expect((await call('GET', 'member', '/meta/object/invoice')).data?.item?.label).toBe('Invoice'); + } + }); + + it('the ex-member, switched to an organization they ARE in, reads that organization on both transports', async () => { + for (const boot of [bootDispatcher, bootRest]) { + const sessions = makeSessions(); + sessions.sid_exmember.activeOrganizationId = 'org_beta'; + const { call } = boot(sessions); + expect((await call('GET', 'exmember', '/meta/view/lead_all')).data?.item?.label).toBe('Beta pipeline'); + } + }); +}); + +// ── The subject: a claim the resolver dropped ───────────────────────────────── + +describe('[#20408] a session claim the resolver DROPPED scopes nothing, on either transport', () => { + it('RestServer (the reference) answers the ex-member the env-wide rows', async () => { + const { call } = bootRest(); + expect((await call('GET', 'exmember', '/meta/view/lead_all')).data?.item?.label).toBe('All leads'); + expect(labelOf((await call('GET', 'exmember', '/meta/view')).data, 'lead_all')).toBe('All leads'); + expect((await call('GET', 'exmember', '/meta/view/lead_all', { preview: 'draft' })).data?.item?.label).toBe('All leads'); + expect((await call('GET', 'exmember', '/meta/view/lead_all/published')).data?.label).toBe('All leads'); + }); + + for (const [label, path, query, read] of [ + ['the item read', '/meta/view/lead_all', {}, (a: Answer) => a.data?.item?.label], + ['the list', '/meta/view', {}, (a: Answer) => labelOf(a.data, 'lead_all')], + ['the ?preview=draft item read', '/meta/view/lead_all', { preview: 'draft' }, (a: Answer) => a.data?.item?.label], + ['/published', '/meta/view/lead_all/published', {}, (a: Answer) => a.data?.label], + ] as const) { + it(`${label}: the dispatcher serves the env-wide row, never the left organization's overlay`, async () => { + const { call } = bootDispatcher(); + const res = await call('GET', 'exmember', path, query as Record); + expect(res.status).toBe(200); + expect((read as (a: Answer) => unknown)(res)).toBe('All leads'); + expect(JSON.stringify(res.data)).not.toContain('Alpha'); + }); + } + + it('?state=draft: the dispatcher answers what RestServer answers — no pending draft in the env-wide scope, never the left organization\'s', async () => { + const dispatcher = await bootDispatcher().call('GET', 'exmember', '/meta/view/lead_all', { state: 'draft' }); + const rest = await bootRest().call('GET', 'exmember', '/meta/view/lead_all', { state: 'draft' }); + expect({ status: rest.status, code: rest.code }).toEqual({ status: 404, code: 'NO_DRAFT' }); + expect({ status: dispatcher.status, code: dispatcher.code }).toEqual({ status: rest.status, code: rest.code }); + expect(JSON.stringify(dispatcher.data ?? null)).not.toContain('Alpha'); + }); + + it('GET /meta/_drafts: the dispatcher lists what RestServer lists — never the left organization\'s drafts', async () => { + const dispatcher = await bootDispatcher().call('GET', 'exmember', '/meta/_drafts'); + const rest = await bootRest().call('GET', 'exmember', '/meta/_drafts'); + expect(itemsOf(rest.data).map((d: any) => d.name)).toEqual(['env_board']); + expect(itemsOf(dispatcher.data).map((d: any) => d.name)).toEqual(itemsOf(rest.data).map((d: any) => d.name)); + }); + + it('PUT /meta/view/lead_all: the dispatcher lands the write where RestServer lands it — env-wide, never in the left organization', async () => { + const body = { name: 'lead_all', label: 'Rewritten', object: 'lead', viewKind: 'list' }; + const d = bootDispatcher(); + const r = bootRest(); + const dispatcher = await d.call('PUT', 'exmember', '/meta/view/lead_all', {}, body); + const rest = await r.call('PUT', 'exmember', '/meta/view/lead_all', {}, body); + expect(rest.status).toBe(200); + expect(dispatcher.status).toBe(rest.status); + const landed = (p: ReturnType) => p.saveMetaItem.mock.calls.map(([req]: any[]) => req.organizationId); + expect(landed(r.protocol)).toEqual([undefined]); + expect(landed(d.protocol)).toEqual(landed(r.protocol)); + }); +}); diff --git a/packages/runtime/src/domains/meta-save-capability-gate.test.ts b/packages/runtime/src/domains/meta-save-capability-gate.test.ts index 7cde5b7b6bf..eab6e2552fd 100644 --- a/packages/runtime/src/domains/meta-save-capability-gate.test.ts +++ b/packages/runtime/src/domains/meta-save-capability-gate.test.ts @@ -276,8 +276,11 @@ describe('#7019 — dispatcher PUT /meta/:type/:name: the capability gate', () = describe('#12702 — dispatcher PUT: `manage_org_presentation`, org-scoped tier-A admission', () => { /** * The `boot()` double above, plus an auth service whose session carries an - * active organization — the value `deps.resolveActiveOrganizationId` reads, - * which the gate and the write threading now share. + * active organization — and [#20408] `caller()`, the execution context the + * identity resolver builds from that session: its VETTED active + * organization rides it as `tenantId` (no tenancy wall here, so the claim + * is kept). That is the value the gate and the write threading share — the + * one `RestServer`'s `PUT` door reads — never the session's claim as stored. */ function bootOrg(activeOrganizationId?: string) { const saveMetaItem = vi.fn(async ({ type, name }: any) => ({ success: true, type, name })); @@ -290,7 +293,9 @@ describe('#12702 — dispatcher PUT: `manage_org_presentation`, org-scoped tier- getService: (n: string) => (n === 'protocol' ? protocol : n === 'auth' ? auth : null), }, } as any; - return { dispatcher: new HttpDispatcher(kernel), saveMetaItem }; + const caller = (identity: Record) => + ctx({ ...identity, ...(activeOrganizationId ? { tenantId: activeOrganizationId } : {}) }); + return { dispatcher: new HttpDispatcher(kernel), saveMetaItem, caller }; } const HOLDER = { userId: 'u_orgadmin', systemPermissions: ['manage_org_presentation'] }; @@ -299,7 +304,7 @@ describe('#12702 — dispatcher PUT: `manage_org_presentation`, org-scoped tier- const stack = bootOrg('org_a'); const res = await stack.dispatcher.handleMetadata( - '/view/org_grid', ctx(HOLDER), 'PUT', { name: 'org_grid', label: 'Org Grid' }, + '/view/org_grid', stack.caller(HOLDER), 'PUT', { name: 'org_grid', label: 'Org Grid' }, ); expect(res.response?.status).toBe(200); @@ -315,7 +320,7 @@ describe('#12702 — dispatcher PUT: `manage_org_presentation`, org-scoped tier- const stack = bootOrg('org_a'); const res = await stack.dispatcher.handleMetadata( - '/email_templates/welcome', ctx(HOLDER), 'PUT', { name: 'welcome', subject: 'Hi' }, + '/email_templates/welcome', stack.caller(HOLDER), 'PUT', { name: 'welcome', subject: 'Hi' }, ); expect(res.response?.status).toBe(200); @@ -333,7 +338,7 @@ describe('#12702 — dispatcher PUT: `manage_org_presentation`, org-scoped tier- const stack = bootOrg('org_a'); const res = await stack.dispatcher.handleMetadata( - path, ctx(HOLDER), 'PUT', { label: 'x' }, + path, stack.caller(HOLDER), 'PUT', { label: 'x' }, ); expect(res.response?.status).toBe(403); @@ -345,7 +350,7 @@ describe('#12702 — dispatcher PUT: `manage_org_presentation`, org-scoped tier- const stack = bootOrg(undefined); const res = await stack.dispatcher.handleMetadata( - '/view/org_grid', ctx(HOLDER), 'PUT', { name: 'org_grid', label: 'Org Grid' }, + '/view/org_grid', stack.caller(HOLDER), 'PUT', { name: 'org_grid', label: 'Org Grid' }, ); expect(res.response?.status).toBe(403); @@ -360,7 +365,7 @@ describe('#12702 — dispatcher PUT: `manage_org_presentation`, org-scoped tier- const stack = bootOrg('org_a'); const res = await stack.dispatcher.handleMetadata( - '/view/org_grid', ctx(HOLDER), 'PUT', + '/view/org_grid', stack.caller(HOLDER), 'PUT', { name: 'org_grid', label: 'Org Grid', organization_id: 'org_b', organizationId: 'org_b' }, ); @@ -375,7 +380,7 @@ describe('#12702 — dispatcher PUT: `manage_org_presentation`, org-scoped tier- const res = await stack.dispatcher.handleMetadata( '/view/org_grid', - ctx({ userId: 'u_author', systemPermissions: ['manage_metadata'] }), + stack.caller({ userId: 'u_author', systemPermissions: ['manage_metadata'] }), 'PUT', { name: 'org_grid', label: 'Org Grid' }, ); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index c6336ff8d96..3519d199a54 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -20,8 +20,8 @@ import { CoreServiceName } from '@objectstack/spec/system'; // because D5's "every schema-serving outlet" is only true if a future exit // inherits the decision instead of re-deciding it. import { + OBJECT_SCHEMA_MASK_NOT_APPLICABLE, ObjectSchemaMaskEvaluationError, - applyObjectSchemaMask, isObjectSchemaMaskExempt, isObjectSchemaMaskingEnabled, resolveObjectSchemaMaskPosture, @@ -49,16 +49,29 @@ import type { MetadataProtocol } from '@objectstack/spec/api'; // [#20320] …and the rest of what `RestServer`'s `/meta` reads answer: the list // route's whole post-read chain, the locale parse it reads, the anonymous // gates' `public`-audience predicate and the stored-version doors' policy. +// [#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. // Imported, never restated — AGENTS.md 〈Route & surface ownership〉 rule 1. import { + createMetaBookTreeAnswer, + createMetaItemAnswer, createMetaItemReadGate, createMetaListAnswer, isPublicAudienceRead, + metaCallerOrganizationId, + metaReadOrganizationId, metaRequestLocale, + projectMetaObjectSchema, + refuseUnknownMetaListType, STORED_VERSION_DOOR_POLICY, + translateMetaEnvelope, translateMetaList, + type MetaItemAnswer, + type MetaItemAnswerSources, type MetaItemReadGateSources, type MetaListAnswerSources, + type MetaListTranslationSources, type MetaPublicReadRoute, type MetaReadGateCaller, type MetaReadGatePolicy, @@ -201,17 +214,46 @@ export function createMetaDomain(deps: DomainHandlerDeps): DomainRoute { /** * [#20320] The READ route shape this domain would serve `parts` with, as the * shared `isPublicAudienceRead` names it — or `undefined` for every shape that - * is not one of this domain's list or item reads. This domain has no - * `book/:name/tree` route (`RestServer` does), so it never names `book-tree`: - * a path of three or more segments is `/published`, the FSM `/state/:field` - * read or the located `ROUTE_NOT_FOUND`, and none of them is exempt. + * is not one of this domain's list, item or book-tree reads. + * + * [#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. */ function metaReadRouteOf(parts: readonly string[]): MetaPublicReadRoute | undefined { if (parts.length === 1) return 'list'; if (parts.length === 2) return 'item'; + if (isBookTreePath(parts)) return 'book-tree'; return undefined; } +/** [#20408] `GET /meta/book/:name/tree` — the ADR-0046 §6 book-tree route, matched exactly as `RestServer` registers it. */ +function isBookTreePath(parts: readonly string[]): boolean { + return parts.length === 3 && parts[0] === 'book' && parts[2] === 'tree'; +} + +/** + * [#20320 · #20408] A `deps.success` answer that carries the headers a `/meta` + * read owes — `Vary: Accept-Language` on a body translated per request, and + * `Cache-Control: private, no-store` on an object schema served under an + * undetermined field visibility (ADR-0106 D6 tier 2). `deps.success` takes no + * headers, so this is the ONE place this domain builds such an answer; its + * body is `deps.success`'s declared envelope, spread. A header whose value is + * `undefined` is not owed; with none owed the answer is `deps.success`'s own. + */ +function successWithHeaders( + deps: DomainHandlerDeps, + data: unknown, + 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 } }; +} + /** * [#5224 · #20320] Said once per process: this host's `api` list face has no * endpoint matcher to ask, so it lists what is STORED — which may advertise a @@ -251,11 +293,18 @@ function fieldVisibilityFault(deps: DomainHandlerDeps, objectName: string): Http * * Resolved once per request and asked per object name, so the `/metadata` list * read pays one context + service resolution for the whole page. + * + * [#20408] `metaType` is the FOLDED type the masker serves, as on `RestServer`: + * every type but `object` is answered the not-applicable passthrough without + * resolving anything, so an exit that serves every type (the item read) can + * resolve its posture unconditionally, before its fetch. */ async function resolveObjectMasker( deps: DomainHandlerDeps, context: HttpProtocolContext, + metaType: string, ): Promise<(objectName: string) => Promise> { + if (metaType !== 'object') return async () => OBJECT_SCHEMA_MASK_NOT_APPLICABLE; const enabled = isObjectSchemaMaskingEnabled(); if (!enabled) { const disabled: ObjectSchemaMaskPosture = { kind: 'passthrough', reason: 'disabled' }; @@ -282,12 +331,18 @@ async function resolveObjectMasker( * `'fault'` covers both the throw tier and the empty-projection case — see * `applyObjectSchemaMask`'s `emptied` for why an empty-fields 200 is not an * option the ADR leaves open. + * + * [#20408] The projection is the shared `projectMetaObjectSchema`, so the + * answer carries the `Cache-Control` an undetermined posture owes (ADR-0106 D6 + * tier 2: the schema goes out UNMASKED, `private, no-store`). This exit masked + * without it — the schema was served with no cache header at all, where + * `RestServer` answers `private, no-store`. */ async function maskObjectSchema( masker: (objectName: string) => Promise, objectName: string, document: any, -): Promise<{ ok: true; document: any } | { ok: false }> { +): Promise<{ ok: true; document: any; cacheControl?: string } | { ok: false }> { let posture: ObjectSchemaMaskPosture; try { posture = await masker(objectName); @@ -295,32 +350,7 @@ async function maskObjectSchema( if (error instanceof ObjectSchemaMaskEvaluationError) return { ok: false }; throw error; } - const masked = applyObjectSchemaMask(document, posture); - if (masked.emptied) return { ok: false }; - return { ok: true, document: masked.document }; -} - -/** - * [ADR-0106 D5(2)] Project every object schema of a listed `object` page — the - * mask step the shared list chain (`createMetaListAnswer`) asks this transport - * for, handed the page's items only when there are any. One unevaluable object - * fails the whole list — serving the rest would leave a hole in the projection - * that no client can see. - */ -async function maskObjectItems( - deps: DomainHandlerDeps, - context: HttpProtocolContext, - items: any[], -): Promise<{ ok: true; items: any[] } | { ok: false; object: string }> { - const masker = await resolveObjectMasker(deps, context); - const projected: any[] = []; - for (const item of items) { - const objectName = String(item?.name ?? ''); - const masked = await maskObjectSchema(masker, objectName, item); - if (!masked.ok) return { ok: false, object: objectName }; - projected.push(masked.document); - } - return { ok: true, items: projected }; + return projectMetaObjectSchema(posture, document); } /** @@ -386,8 +416,12 @@ function metaItemReadGateSources( } /** - * [#20193] Ask the shared per-caller read gate about ONE document this - * transport is about to serve, and write its refusal on this transport's wire. + * [#20193] Ask the shared per-caller read gate about ONE document `/published` + * is about to serve, and write its refusal on this transport's wire. + * + * [#20408] The plain item read and its `?state=draft` branch no longer come + * here: they hand their envelope to the whole item chain + * ({@link answerMetaItem}), whose first step is this same gate. * * The item read and `/published` used to serve whatever the store answered: * no ADR-0046 §6.7 docs audience, no app nav filter, no ADR-0057 D10 gate. On a @@ -410,10 +444,6 @@ function metaItemReadGateSources( * answered as that fault — its own status, `500` for a shapeless one — ⛔ never * as the document, and never as a 403 that would tell a holder they hold * nothing. - * - * [#20320] `policy` is the door's: the rendered-document policy by default, - * `STORED_VERSION_DOOR_POLICY` for the `?state=draft` read, which also hands in - * the caller's `mayWriteItem` (see {@link metaItemReadGateSources}). */ async function gateMetaItemDocument( deps: DomainHandlerDeps, @@ -422,13 +452,11 @@ async function gateMetaItemDocument( metaType: string, name: string, document: any, - policy: MetaReadGatePolicy = RENDERED_DOCUMENT_POLICY, - mayWriteItem?: boolean, ): Promise<{ ok: true; document: any } | { ok: false; response: { status: number; body: any } }> { let verdict; try { const judge = createMetaItemReadGate( - metaItemReadGateSources(deps, context, protocol, mayWriteItem), metaType, name, [document], policy, + metaItemReadGateSources(deps, context, protocol), metaType, name, [document], RENDERED_DOCUMENT_POLICY, ); verdict = await judge(document); } catch (e: any) { @@ -448,7 +476,8 @@ async function gateMetaItemDocument( * * The chain runs, in `RestServer`'s order, the `api` served-set face, the * per-caller list gate, `?id=`, `?object=`, the doc locale collapse and slim, - * this transport's object mask ({@link maskObjectItems}) and the translation. + * the object mask over this transport's masker ({@link resolveObjectMasker}) and + * the translation. * This branch used to run the gate, the mask and a doc slim that compared the * RAW segment — so `?id=`, `?object=`, `/meta/docs`, the locale, the * translation and the served-set face all answered differently here. @@ -457,7 +486,9 @@ async function gateMetaItemDocument( * `{ type, items }` envelope), and the answer keeps that shape; `listType` is * the folded singular type; `previewDrafts` is the ADMITTED switch the branch * declared. The answer carries `Vary: Accept-Language` because its body now - * varies by it, as `RestServer`'s does. A gate or matcher input that could not + * varies by it, as `RestServer`'s does, and [#20408] `Cache-Control: private, + * no-store` when the chain says an undetermined posture served a schema + * unmasked (ADR-0106 D6 tier 2). A gate or matcher input that could not * be read (a doc list's books read threw) is answered as that fault — its own * status, `500` for a shapeless one — ⛔ never as the unfiltered list; so is a * mask fault that is not the D6 tier-3 one, which keeps its `503`. @@ -483,32 +514,113 @@ async function answerMetaList( } }, notifyMissingEndpointMatcher, - maskObjects: (items) => maskObjectItems(deps, context, items), + // [#20408] The masker only: the chain projects each schema and says + // which cache posture the page owes. + resolveObjectMasker: () => resolveObjectMasker(deps, context, 'object'), requestLocale, - translateList: (metaType, items) => translateMetaList({ - resolveI18nService: async () => { - try { - return await deps.resolveService(context, 'i18n'); - } catch { - return undefined; - } - }, - resolveProtocol: async () => protocol, - requestLocale, - }, metaType, items), + translateList: (metaType, items) => translateMetaList( + metaTranslationSources(deps, context, protocol, requestLocale), metaType, items, + ), }; try { const answer = await createMetaListAnswer(sources, { metaType: listType, query, previewDrafts })(data); if (!answer.ok) return fieldVisibilityFault(deps, answer.object); - return { - handled: true, - response: { ...deps.success(answer.data), headers: { Vary: 'Accept-Language' } }, - }; + return successWithHeaders(deps, answer.data, { + 'Cache-Control': answer.cacheControl, + Vary: 'Accept-Language', + }); } catch (e: any) { return { handled: true, response: deps.errorFromThrown(e, 500) }; } } +/** + * [#20320 · #20408] This transport's I/O for a metadata translation — the + * request's i18n service, its protocol (for the #8284 packaged object base) + * and the locale parse `RestServer.extractLocale` runs, over this request. + */ +function metaTranslationSources( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + protocol: MetaDomainProtocol | undefined, + requestLocale: (i18n?: unknown) => string | undefined, +): MetaListTranslationSources { + return { + resolveI18nService: async () => { + try { + return await deps.resolveService(context, 'i18n'); + } catch { + return undefined; + } + }, + resolveProtocol: async () => protocol, + requestLocale, + }; +} + +/** + * [#20408] Answer ONE `/meta/:type/:name` document this transport is about to + * serve: hand the store's envelope to THE item chain (`createMetaItemAnswer`, + * the one `RestServer`'s plain read calls — ⛔ no step lives here) and write + * its answer on this transport's wire. + * + * The chain runs, in `RestServer`'s order, absence, the per-caller gate under + * the door's `policy`, the doc locale collapse, the ADR-0106 mask under the + * posture resolved before the fetch, and the body: the translation and + * `sortability` beside an object schema. This read used to run the gate and + * the mask alone, so it served every item untranslated, a doc with its whole + * `translations` map, an object schema with no `sortability`, no `Vary`, and + * no `Cache-Control` on an undetermined posture. + * + * The answers, in this transport's envelope, with `RestServer`'s status and + * code: `absent` → the SAME `deps.error('Not found', 404)` this handler answers + * for a name with nothing behind it (ADR-0045 §3: an unpublished app stays + * externally unobservable); `app-permission` / `docs-audience` → + * `403 PERMISSION_DENIED` / `401 UNAUTHENTICATED`; `mask-fault` → the D6 + * field-visibility fault. A gate input that could not be read is answered as + * that fault — its own status, `500` for a shapeless one — ⛔ never as the + * document. `mayWriteItem` is handed in only by a door whose policy honours the + * author exemption (see {@link metaItemReadGateSources}). + */ +async function answerMetaItem( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + protocol: MetaDomainProtocol | undefined, + request: { metaType: string; name: string; policy: MetaReadGatePolicy; maskPosture: ObjectSchemaMaskPosture }, + query: any, + envelope: any, + mayWriteItem?: boolean, +): Promise { + const requestLocale = (i18n?: unknown) => metaRequestLocale({ headers: context.request?.headers, query }, i18n); + const translation = metaTranslationSources(deps, context, protocol, requestLocale); + const sources: MetaItemAnswerSources = { + ...metaItemReadGateSources(deps, context, protocol, mayWriteItem), + requestLocale, + translateEnvelope: (itemEnvelope, document) => + translateMetaEnvelope(translation, request.metaType, itemEnvelope, document), + }; + let answer: MetaItemAnswer; + try { + answer = await createMetaItemAnswer(sources, request)(envelope); + } catch (e: any) { + return { handled: true, response: deps.errorFromThrown(e, 500) }; + } + switch (answer.kind) { + case 'serve': + return successWithHeaders(deps, answer.envelope, { + 'Cache-Control': answer.cacheControl, + Vary: 'Accept-Language', + }); + case 'mask-fault': + return fieldVisibilityFault(deps, answer.object); + case 'refuse': { + const { refusal } = answer; + if (refusal.reason === 'absent') return { handled: true, response: deps.error('Not found', 404) }; + return { handled: true, response: deps.error(refusal.message, refusal.status, { code: refusal.code }) }; + } + } +} + /** [#20320] This transport's save-door admission of `:type/:name` — see `saveVerdict` in {@link handleMetadataRequest}. */ type MetaSaveVerdict = ( canonicalType: string, @@ -526,15 +638,23 @@ type MetaSaveVerdict = ( * - nothing pending → the protocol's own `404 NO_DRAFT`, answered as ITSELF * (the item IS there, its draft is not) — never this read's plain 404, * never the published item; - * - the per-caller gate under `STORED_VERSION_DOOR_POLICY` — the constant + * - [#20408] the item chain (`createMetaItemAnswer`, through + * {@link answerMetaItem}) under `STORED_VERSION_DOOR_POLICY` — the constant * `RestServer`'s draft branch runs (#20290): per-caller arms only, and an * app WHOLE for a caller this transport's save door admits (`saveVerdict`, * the `PUT` branch's own admission, carried as `mayWriteItem`), pruned per - * caller for everyone else; - * - the ADR-0106 mask for an object, as every object exit here runs it. + * caller for everyone else; then the doc locale collapse, the ADR-0106 mask + * and the body, as the plain read answers them. * * Only an admitted caller arrives: the switch is declared with * {@link mayReadPendingDrafts} at the item read's entry. + * + * [#20408] Scoped to the caller's VETTED organization — the read to + * {@link metaReadOrganizationId}'s partition, the author exemption to the save + * door's verdict over {@link metaCallerOrganizationId} — exactly as + * `RestServer`'s plain read scopes both. It read the session's claim as stored, + * so a member removed from an organization read that organization's pending + * drafts here. */ async function readPendingDraft( deps: DomainHandlerDeps, @@ -544,35 +664,30 @@ async function readPendingDraft( packageId: string | undefined, previewDrafts: boolean, saveVerdict: MetaSaveVerdict, + item: { maskPosture: ObjectSchemaMaskPosture; query: any }, ): Promise { const singularType = pluralToSingular(type); const protocol = await resolveProtocol(deps, context); if (!protocol || typeof protocol.getMetaItem !== 'function') { return { handled: true, response: deps.error('Not found', 404) }; } - const activeOrganizationId = await deps.resolveActiveOrganizationId(context); + const caller = context.executionContext as MetaReadGateCaller | undefined; let envelope: any; try { envelope = await protocol.getMetaItem({ - type: singularType, name, packageId, organizationId: activeOrganizationId, state: 'draft', previewDrafts, + type: singularType, name, packageId, organizationId: metaReadOrganizationId(type, caller), state: 'draft', previewDrafts, }); } catch (e: any) { return { handled: true, response: deps.errorFromThrown(e, 404) }; } if (envelope?.item == null) return { handled: true, response: deps.error('Not found', 404) }; - const mayWriteItem = saveVerdict(canonicalMetaUrlType(type), activeOrganizationId).allowed; - const gated = await gateMetaItemDocument( - deps, context, protocol, singularType, name, envelope.item, STORED_VERSION_DOOR_POLICY, mayWriteItem, + const mayWriteItem = saveVerdict(canonicalMetaUrlType(type), metaCallerOrganizationId(caller)).allowed; + return answerMetaItem( + deps, context, protocol, + { metaType: singularType, name, policy: STORED_VERSION_DOOR_POLICY, maskPosture: item.maskPosture }, + item.query, envelope, mayWriteItem, ); - if (!gated.ok) return { handled: true, response: gated.response }; - let served = gated.document; - if (singularType === 'object') { - const masked = await maskObjectSchema(await resolveObjectMasker(deps, context), name, served); - if (!masked.ok) return fieldVisibilityFault(deps, name); - served = masked.document; - } - return { handled: true, response: deps.success({ ...envelope, item: served }) }; } /** @@ -696,6 +811,47 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin return { handled: true, response: deps.success({ object: name, field, from: from ?? null, next }) }; } + // GET /metadata/book/:name/tree — ADR-0046 §6: a book spine resolved against + // the docs that exist NOW into its rendered tree. + // + // [#20408] `RestServer` serves this route, and this domain had none: the + // path fell to the located `ROUTE_NOT_FOUND` tail below, so a + // dispatcher-only host answered `404` to a signed-in reader and `401` to an + // anonymous reader of a `public` book — the reader the route exists for. + // The whole answer is `createMetaBookTreeAnswer` in `@objectstack/rest`, the + // one `RestServer`'s handler calls: the book and doc reads (`?package=` + // scopes both, ADR-0048), THE `DocsAudience`, the §6.7 gate on the book's + // own audience, the doc locale collapse and the tree narrowed per caller. + // This branch supplies I/O and writes the answer on this transport's wire. + // The anonymous gate above lets an anonymous GET reach it + // (`metaReadRouteOf` names it `book-tree`), and the §6.7 gate refuses every + // audience but `public` — the one `RestServer` exempts, by the same predicate. + if (isBookTreePath(parts) && (!method || method.toUpperCase() === 'GET')) { + const protocol = await resolveProtocol(deps, _context); + if (!protocol || typeof protocol.getMetaItems !== 'function') { + return { handled: true, response: deps.error('Not found', 404) }; + } + const packageId = typeof query?.package === 'string' && query.package.length > 0 ? query.package : undefined; + try { + const answer = await createMetaBookTreeAnswer( + { + ...metaItemReadGateSources(deps, _context, protocol), + listTreeInput: (type, scopedPackageId) => + Promise.resolve(protocol.getMetaItems!({ type, ...(scopedPackageId ? { packageId: scopedPackageId } : {}) })), + requestLocale: (i18n) => metaRequestLocale({ headers: _context.request?.headers, query }, i18n), + }, + { name: decodeMetaNameSegment(parts[1]), packageId }, + ); + if (!answer.ok) { + const { refusal } = answer; + return { handled: true, response: deps.error(refusal.message, refusal.status, { code: refusal.code }) }; + } + return { handled: true, response: deps.success(answer.tree) }; + } catch (e: any) { + return { handled: true, response: deps.errorFromThrown(e, 500) }; + } + } + // GET /metadata/:type/:name/published → get published version // // [#12195] EXACTLY three segments, and no fold. This used to be @@ -756,23 +912,31 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // serving must be answered as itself — never fall through to the // snapshot as though no overlay existed. const publishedType = pluralToSingular(type); - const publishedMasker = publishedType === 'object' ? await resolveObjectMasker(deps, _context) : undefined; + const publishedMasker = publishedType === 'object' ? await resolveObjectMasker(deps, _context, publishedType) : undefined; const servePublished = async (document: any): Promise => { const gated = await gateMetaItemDocument(deps, _context, protocol, publishedType, name, document); if (!gated.ok) return { handled: true, response: gated.response }; let served = gated.document; + let cacheControl: string | undefined; if (publishedMasker) { const masked = await maskObjectSchema(publishedMasker, name, served); if (!masked.ok) return fieldVisibilityFault(deps, name); served = masked.document; + // [#20408] ADR-0106 D6 tier 2 — as `RestServer`'s `/published`. + cacheControl = masked.cacheControl; } - return { handled: true, response: deps.success(served) }; + return successWithHeaders(deps, served, { 'Cache-Control': cacheControl }); }; let publishedOverlay: unknown; if (protocol && typeof protocol.getMetaItemLayered === 'function') { try { - const organizationId = await deps.resolveActiveOrganizationId(_context); + // [#20408] The caller's VETTED organization, as `RestServer`'s + // `/published` reads it (`ctx.tenantId`, raw — the protocol's + // layered read gates it by type itself). The session's claim as + // stored served a removed member the overlay of the organization + // they had left. + const organizationId = metaCallerOrganizationId(_context.executionContext as MetaReadGateCaller | undefined); const layered = await protocol.getMetaItemLayered({ type, name, @@ -918,7 +1082,15 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // [#10503] Folded at the boundary, once — the verdict and the // scope decision below must read the same spelling. const canonicalType = canonicalMetaUrlType(type); - const activeOrganizationId = await deps.resolveActiveOrganizationId(_context); + // [#20408] The caller's VETTED organization — the `tenantId` + // `resolveAuthzContext` left on the execution context, the value + // `RestServer`'s `PUT` door reads — ⛔ never the session's claim as + // stored: under a walled posture a claim naming an organization the + // caller has LEFT is dropped there, and this door read it anyway, so + // a removed member's write landed in that organization's partition. + // No read at all now, so the 403-vs-501 discipline above is + // untouched. + const activeOrganizationId = metaCallerOrganizationId(_context.executionContext as MetaReadGateCaller | undefined); // [#20320] Spelled once (`saveVerdict` above), because the // `?state=draft` read's author exemption asks this same question. const verdict = saveVerdict(canonicalType, activeOrganizationId); @@ -1136,11 +1308,56 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin && mayReadPendingDrafts(_context.executionContext); // ADR-0033 draft-overlay preview: `?preview=draft` makes the detail // read prefer a pending draft (falling back to active). - const previewDrafts = query?.preview === 'draft' + // [#20408] Parsed exactly as `RestServer`'s plain read parses it — + // case-insensitively — so `?preview=DRAFT` from a builder is a preview + // on both transports, not a published read on this one. + const previewDrafts = typeof query?.preview === 'string' + && query.preview.toLowerCase() === 'draft' && mayReadPendingDrafts(_context.executionContext); + // [#20408] The caller's VETTED organization (`metaReadOrganizationId` + // gates it by the folded type): the partition `RestServer`'s plain read + // reads. The session's claim as stored served a member removed from an + // organization that organization's overlays here. + const caller = _context.executionContext as MetaReadGateCaller | undefined; + const singularType = pluralToSingular(type); + + // [ADR-0106 D2/D3 · #20408] ONE posture for this caller × this item, + // resolved BEFORE any lookup and applied by the item chain to whichever + // lookup answers — `RestServer`'s plain read resolves it at the same + // point, so an unevaluable posture (D6 tier 3) is answered as the + // field-visibility fault before any read, on both transports. Every + // type but `object` is the not-applicable passthrough. + let maskPosture: ObjectSchemaMaskPosture; try { - if (isDraftRead) return await readPendingDraft(deps, _context, type, name, packageId, previewDrafts, saveVerdict); + maskPosture = await (await resolveObjectMasker(deps, _context, singularType))(name); + } catch (maskError) { + if (maskError instanceof ObjectSchemaMaskEvaluationError) return fieldVisibilityFault(deps, name); + throw maskError; + } + + try { + if (isDraftRead) { + return await readPendingDraft( + deps, _context, type, name, packageId, previewDrafts, saveVerdict, { maskPosture, query }, + ); + } + + // [#20408] Every lookup below hands its envelope to THE item chain + // (`answerMetaItem` → `createMetaItemAnswer`, the one `RestServer`'s + // plain read runs): the per-caller gate, the doc locale collapse, + // the mask under the posture resolved above, the translation and + // `sortability`. It runs OUTSIDE the lookups' own `try`s — those + // classify a miss, and a gate or mask fault must be answered as + // itself, never as "not found" and never by falling through to the + // next lookup, which would answer with the very body the fault + // exists to withhold. + const serveItem = (protocol: MetaDomainProtocol | undefined, envelope: any): Promise => + answerMetaItem( + deps, _context, protocol, + { metaType: singularType, name, policy: RENDERED_DOCUMENT_POLICY, maskPosture }, + query, envelope, + ); // Try specific calls based on type if (type === 'objects' || type === 'object') { @@ -1157,106 +1374,74 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin : protocol?.environmentId; const scoped = scopedEnv !== undefined; - // [ADR-0106 D2/D5(3)] ONE posture for this caller × this object, - // resolved before any lookup, applied to whichever of the three - // lookups below answers. Resolving it per-lookup is how the - // registry-backed path ends up unmasked while the protocol-backed - // one is masked — two answers to one question, which is the - // shape #6562 already records for a different dimension of this - // very fan-out. - const objectMasker = await resolveObjectMasker(deps, _context); - - // [#15238] `protocol &&` spelled out, matching the `!scoped` twin - // below. Behaviour-identical: `scoped` is derived from - // `protocol?.getProjectId` / `protocol?.environmentId`, so it can only - // be true when the handle is there. The `any` cast this branch used to - // resolve through is what let the two sibling guards drift apart in - // spelling — typing the handle is what surfaced it (TS18048). - if (scoped && protocol && typeof protocol.getMetaItem === 'function') { + // [#20408] An ADMITTED `?preview=draft` reads a PENDING draft, + // which only the protocol knows — the registry holds published + // schemas — so the protocol answers first, as it always does on a + // scoped kernel. This branch never read the switch, so a builder + // previewing an object was answered the ACTIVE schema where + // `RestServer` answers the draft. + const protocolFirst = scoped || previewDrafts; + + // The protocol read `RestServer`'s plain read makes: the same + // `?package=` scope (ADR-0048), the same admitted switch and the + // same org partition — `organizationIdForMetaRead` never names an + // organization for `object`, so no phantom org row resurrects. + const readFromProtocol = async (): Promise => { + // [#15238] `protocol &&` spelled out: the `any` cast this + // branch used to resolve through let two sibling guards drift + // apart in spelling — typing the handle surfaced it (TS18048). + if (!protocol || typeof protocol.getMetaItem !== 'function') return undefined; try { - const organizationId = await deps.resolveActiveOrganizationId(_context); - const data = await protocol.getMetaItem({ type: 'object', name, organizationId }); + const data = await protocol.getMetaItem({ + type: 'object', + name, + organizationId: metaReadOrganizationId(type, caller), + ...(packageId ? { packageId } : {}), + ...(previewDrafts ? { previewDrafts: true } : {}), + }); // Protocol returns `{ type, name, item }` — only treat the // lookup as a hit when `item` is really there. [#5563] The // test used to be `data.item ?? data`, which is truthy for // ANY truthy `data`: an item-less answer (what a metadata // store outage resolves to) was served as a hit and the - // registry fallback below never ran. The guard now asks the - // question its own comment claims it asks. - if (data?.item != null) { - // The fault RETURNS from inside the try on purpose: - // a masking failure is not a lookup miss, and - // falling through to the registry below would answer - // with the very body the fault exists to withhold. - const masked = await maskObjectSchema(objectMasker, name, data.item); - if (!masked.ok) return fieldVisibilityFault(deps, name); - return { handled: true, response: deps.success({ ...data, item: masked.document }) }; - } - } catch { /* fall through to registry / 404 */ } - } + // registry fallback below never ran. + return data?.item != null ? data : undefined; + } catch { + return undefined; // fall through to registry / 404 + } + }; - const qlService = await deps.getObjectQL(_context); - if (qlService?.registry) { - const data = qlService.registry.getObject(name); + let found: any = protocolFirst ? await readFromProtocol() : undefined; + if (found === undefined) { + const qlService = await deps.getObjectQL(_context); + const data = qlService?.registry?.getObject(name); // [#5563] The registry hands back the bare ObjectSchema, so this // fallback used to answer a different body shape than the // protocol branch above for the very same request. Wrap it in // the declared `GetMetaItemResponseSchema` envelope — `type` and // `name` come from the request, the same values the protocol // would have echoed back. - if (data) { - const masked = await maskObjectSchema(objectMasker, name, data); - if (!masked.ok) return fieldVisibilityFault(deps, name); - return { handled: true, response: deps.success({ type: 'object', name, item: masked.document }) }; - } + if (data) found = { type: 'object', name, item: data }; } - // Last-ditch protocol attempt for unscoped kernels whose // registry missed (e.g. object persisted to DB but not // yet hydrated). Skip when we already tried above. - if (!scoped && protocol && typeof protocol.getMetaItem === 'function') { - try { - const organizationId = await deps.resolveActiveOrganizationId(_context); - const data = await protocol.getMetaItem({ type: 'object', name, organizationId }); - if (data?.item != null) { - const masked = await maskObjectSchema(objectMasker, name, data.item); - if (!masked.ok) return fieldVisibilityFault(deps, name); - return { handled: true, response: deps.success({ ...data, item: masked.document }) }; - } - } catch { /* fall through to 404 */ } - } - return { handled: true, response: deps.error('Not found', 404) }; + if (found === undefined && !protocolFirst) found = await readFromProtocol(); + if (found === undefined) return { handled: true, response: deps.error('Not found', 404) }; + return serveItem(protocol, found); } - // Normalize plural URL paths to singular registry type names - const singularType = pluralToSingular(type); - // Try Protocol Service First (Preferred) const protocol = await resolveProtocol(deps, _context); - // [#20193] THE per-caller read gate, on whichever lookup below - // answers — the one `RestServer`'s plain read asks, so this read - // refuses what that read refuses (a `{ permissionSet }`-gated doc - // or book to a non-holder, an app to a caller missing its - // `requiredPermissions`) and prunes what it prunes (the nav entries - // a caller may not open, a widget whose service is off here). It - // runs OUTSIDE the lookups' own `try`s — those classify a miss, and - // a gate fault must be answered as itself, never as "not found". - // `object` is not judged here: its per-caller gate is the ADR-0106 - // mask, which the branch above already runs. - const serveItem = async (envelope: any): Promise => { - const gated = await gateMetaItemDocument(deps, _context, protocol, singularType, name, envelope.item); - if (!gated.ok) return { handled: true, response: gated.response }; - return { handled: true, response: deps.success({ ...envelope, item: gated.document }) }; - }; - let found: any; if (protocol && typeof protocol.getMetaItem === 'function') { try { - const organizationId = await deps.resolveActiveOrganizationId(_context); // `previewDrafts` is the ADMITTED switch declared above // this block's branches (#20338). - const data = await protocol.getMetaItem({ type: singularType, name, packageId, organizationId, previewDrafts }); + const data = await protocol.getMetaItem({ + type: singularType, name, packageId, organizationId: metaReadOrganizationId(type, caller), previewDrafts, + }); // [#18401] The SAME hit test the `object` branch above runs, // asked here for the same reason. `getMetaItem` answers a // miss with the protection envelope around an absent item — @@ -1290,7 +1475,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // Protocol might throw if not found or not supported } } - if (found) return serveItem(found); + if (found) return serveItem(protocol, found); // Try MetadataService for runtime-registered types const metaSvc = await deps.resolveService(_context, 'metadata', _context.environmentId); @@ -1305,7 +1490,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // MetadataService hands back the bare document, so wrap it in // the declared envelope rather than letting which service // answered decide the caller's parse. - if (data) return serveItem({ type: singularType, name, item: data }); + if (data) return serveItem(protocol, { type: singularType, name, item: data }); } return { handled: true, response: deps.error('Not found', 404) }; } catch (e: any) { @@ -1349,7 +1534,11 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.listDrafts === 'function') { try { - const organizationId = await deps.resolveActiveOrganizationId(_context); + // [#20408] The caller's VETTED organization, raw — what + // `RestServer`'s `_drafts` hands down (`ctx.tenantId`). The + // session's claim as stored listed a removed member the pending + // drafts of the organization they had left. + const organizationId = metaCallerOrganizationId(ec); const data = await protocol.listDrafts({ packageId: query?.packageId || undefined, type: query?.type || undefined, @@ -1455,14 +1644,31 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // — never fall through to the next store as though the first had not // answered. const listType = pluralToSingular(typeOrName); + + // [#9488 · #20408] A segment that names no metadata type is REFUSED — + // `400 INVALID_REQUEST`, the one refusal `RestServer`'s list answers — + // before any listing work, rather than served as a real-but-empty + // collection (`200 {items: []}` here, until this card). The rule is the + // shared one: the static spelling contract UNION the live type set, + // fail-open when the live listing cannot be read — so a host whose + // protocol has no `getMetaTypes` keeps reaching the legacy + // one-segment object-name exit below. + try { + await refuseUnknownMetaListType(protocol, typeOrName); + } catch (e: any) { + return { handled: true, response: deps.errorFromThrown(e, 400) }; + } + // ADR-0033 draft-overlay preview: `?preview=draft` overlays pending // drafts on the active list so an (admin) reviewer can render the // console off drafts before publishing. // [#20338] Admitted per caller ({@link mayReadPendingDrafts}), in this // declaration: anyone else reads the published list. [#20320] Declared // once, at the branch's entry, because the chain reads it too (the `api` - // face is exempt for an admitted preview). - const previewDrafts = query?.preview === 'draft' + // face is exempt for an admitted preview). [#20408] Parsed exactly as + // `RestServer`'s list parses it — case-insensitively. + const previewDrafts = typeof query?.preview === 'string' + && query.preview.toLowerCase() === 'draft' && mayReadPendingDrafts(_context.executionContext); const answerList = (data: any) => answerMetaList(deps, _context, protocol, listType, query, previewDrafts, data); @@ -1470,7 +1676,11 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin let listed: any; if (protocol && typeof protocol.getMetaItems === 'function') { try { - const organizationId = await deps.resolveActiveOrganizationId(_context); + // [#20408] The caller's VETTED organization, gated by the folded + // type — the partition `RestServer`'s list reads. + const organizationId = metaReadOrganizationId( + typeOrName, _context.executionContext as MetaReadGateCaller | undefined, + ); const data = await protocol.getMetaItems({ type: typeOrName, packageId, organizationId, previewDrafts }); // Return any valid response from protocol (including empty items arrays) if (data && (data.items !== undefined || Array.isArray(data))) listed = data; @@ -1525,10 +1735,12 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin const obj = qlService.registry.getObject(typeOrName); if (obj) { const masked = await maskObjectSchema( - await resolveObjectMasker(deps, _context), typeOrName, obj, + await resolveObjectMasker(deps, _context, 'object'), typeOrName, obj, ); if (!masked.ok) return fieldVisibilityFault(deps, typeOrName); - return { handled: true, response: deps.success(masked.document) }; + // [#20408] ADR-0106 D6 tier 2's `private, no-store`, as every + // other object exit here serves it. + return successWithHeaders(deps, masked.document, { 'Cache-Control': masked.cacheControl }); } } return { handled: true, response: deps.error('Not found', 404) }; diff --git a/packages/runtime/src/meta-write-org-scope.test.ts b/packages/runtime/src/meta-write-org-scope.test.ts index 54c732a023a..ca373026a43 100644 --- a/packages/runtime/src/meta-write-org-scope.test.ts +++ b/packages/runtime/src/meta-write-org-scope.test.ts @@ -254,10 +254,20 @@ function makeDispatcher( * file order- and shard-independent, which is how the 403 reached CI at all: * the branch was cut before #7027 merged, so the gate did not exist locally. */ -const ctx = (): any => ({ +const ctx = (activeOrganizationId?: string): any => ({ request: { headers: {} }, environmentId: 'env_1', - executionContext: { userId: 'usr_1', systemPermissions: ['manage_metadata'] }, + // [#20408] The execution context the identity resolver builds from the + // session `makeDispatcher` answers: that session's active organization rides + // it as `tenantId` (there is no tenancy wall here, so the claim is kept, not + // dropped). The dispatcher's `/meta` doors scope by THIS value — the vetted + // one `RestServer`'s doors read — never the claim as stored, so a case whose + // session has an active organization hands the same one in here. + executionContext: { + userId: 'usr_1', + systemPermissions: ['manage_metadata'], + ...(activeOrganizationId ? { tenantId: activeOrganizationId } : {}), + }, }); function makeStack(activeOrganizationId: string | undefined, metadataService?: unknown) { @@ -361,7 +371,7 @@ describe('#7018 — the registry decides whether a metadata write carries the se it('a NON-overridable type lands env-wide even though the session has an active org', async () => { const { engine, dispatcher } = makeStack(ACTIVE_ORG); - const res = responseOf(await dispatcher.handleMetadata(`/flow/${FLOW.name}`, ctx(), 'PUT', FLOW)); + const res = responseOf(await dispatcher.handleMetadata(`/flow/${FLOW.name}`, ctx(ACTIVE_ORG), 'PUT', FLOW)); expect(res.status).toBe(200); const row = metaRow(engine, 'flow', FLOW.name); @@ -383,7 +393,7 @@ describe('#7018 — the registry decides whether a metadata write carries the se fields: { subject: { type: 'text', label: 'Subject' } }, }; - const res = responseOf(await dispatcher.handleMetadata(`/object/${OBJECT.name}`, ctx(), 'PUT', OBJECT)); + const res = responseOf(await dispatcher.handleMetadata(`/object/${OBJECT.name}`, ctx(ACTIVE_ORG), 'PUT', OBJECT)); expect(res.status).toBe(200); expect(metaRow(engine, 'object', OBJECT.name)!.organization_id).toBeNull(); @@ -398,7 +408,7 @@ describe('#7018 — the registry decides whether a metadata write carries the se const withOrg = makeStack(ACTIVE_ORG); const withoutOrg = makeStack(undefined); - const a = responseOf(await withOrg.dispatcher.handleMetadata(`/flow/${FLOW.name}`, ctx(), 'PUT', FLOW)); + const a = responseOf(await withOrg.dispatcher.handleMetadata(`/flow/${FLOW.name}`, ctx(ACTIVE_ORG), 'PUT', FLOW)); const b = responseOf(await withoutOrg.dispatcher.handleMetadata(`/flow/${FLOW.name}`, ctx(), 'PUT', FLOW)); expect(a.status).toBe(200); @@ -409,7 +419,7 @@ describe('#7018 — the registry decides whether a metadata write carries the se it('CONTROL — an `allowOrgOverride: true` type keeps its org scoping exactly as before', async () => { const { engine, dispatcher } = makeStack(ACTIVE_ORG); - const res = responseOf(await dispatcher.handleMetadata(`/view/${VIEW.name}`, ctx(), 'PUT', VIEW)); + const res = responseOf(await dispatcher.handleMetadata(`/view/${VIEW.name}`, ctx(ACTIVE_ORG), 'PUT', VIEW)); expect(res.status).toBe(200); // ADR-0005's per-org overlay is the point of the flag and must survive @@ -420,7 +430,7 @@ describe('#7018 — the registry decides whether a metadata write carries the se it('CONTROL — the plural URL spelling of an overridable type is scoped the same way', async () => { const { engine, dispatcher } = makeStack(ACTIVE_ORG); - const res = responseOf(await dispatcher.handleMetadata(`/views/${VIEW.name}`, ctx(), 'PUT', VIEW)); + const res = responseOf(await dispatcher.handleMetadata(`/views/${VIEW.name}`, ctx(ACTIVE_ORG), 'PUT', VIEW)); expect(res.status).toBe(200); expect(metaRow(engine, 'view', VIEW.name)!.organization_id).toBe(ACTIVE_ORG); @@ -449,7 +459,7 @@ describe('#7018 — the registry decides whether a metadata write carries the se success: true, publishedCount: 0, failedCount: 0, published: [], failed: [], }); - const res = responseOf(await dispatcher.handlePackages('/crm_pkg/publish-drafts', 'POST', {}, {}, ctx())); + const res = responseOf(await dispatcher.handlePackages('/crm_pkg/publish-drafts', 'POST', {}, {}, ctx(ACTIVE_ORG))); expect(res.status).toBe(200); expect(res.body.data.unhiddenApps).toEqual([APP.name]); @@ -484,7 +494,7 @@ describe('#7018 — the registry decides whether a metadata write carries the se }); error.mockClear(); - const res = responseOf(await dispatcher.handlePackages('/crm_pkg/publish-drafts', 'POST', {}, {}, ctx())); + const res = responseOf(await dispatcher.handlePackages('/crm_pkg/publish-drafts', 'POST', {}, {}, ctx(ACTIVE_ORG))); expect(res.body.data.unhiddenApps).toEqual([APP.name]); const flipComplaints = (error.mock.calls as unknown[][]) @@ -600,7 +610,7 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL // receipted 200, served by nothing. const viaPlural = makeStack(ACTIVE_ORG); const resPlural = responseOf( - await viaPlural.dispatcher.handleMetadata(`/${plural}/${item.name}`, ctx(), 'PUT', item), + await viaPlural.dispatcher.handleMetadata(`/${plural}/${item.name}`, ctx(ACTIVE_ORG), 'PUT', item), ); expect(resPlural.status).toBe(200); const pluralRow = metaRow(viaPlural.engine, singular, item.name); @@ -612,7 +622,7 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL // must not have moved. const viaSingular = makeStack(ACTIVE_ORG); const resSingular = responseOf( - await viaSingular.dispatcher.handleMetadata(`/${singular}/${item.name}`, ctx(), 'PUT', item), + await viaSingular.dispatcher.handleMetadata(`/${singular}/${item.name}`, ctx(ACTIVE_ORG), 'PUT', item), ); expect(resSingular.status).toBe(200); const singularRow = metaRow(viaSingular.engine, singular, item.name); @@ -628,10 +638,10 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL const { engine, dispatcher } = makeStack(ACTIVE_ORG); expect(responseOf( - await dispatcher.handleMetadata(`/${plural}/${item.name}`, ctx(), 'PUT', item), + await dispatcher.handleMetadata(`/${plural}/${item.name}`, ctx(ACTIVE_ORG), 'PUT', item), ).status).toBe(200); expect(responseOf( - await dispatcher.handleMetadata(`/${singular}/${item.name}`, ctx(), 'PUT', item), + await dispatcher.handleMetadata(`/${singular}/${item.name}`, ctx(ACTIVE_ORG), 'PUT', item), ).status).toBe(200); const rows = engine.metaRows().filter( @@ -669,7 +679,7 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL fields: { subject: { type: 'text', label: 'Subject' } }, }; - const res = responseOf(await dispatcher.handleMetadata(`/objects/${OBJECT.name}`, ctx(), 'PUT', OBJECT)); + const res = responseOf(await dispatcher.handleMetadata(`/objects/${OBJECT.name}`, ctx(ACTIVE_ORG), 'PUT', OBJECT)); expect(res.status).toBe(200); expect(metaRow(engine, 'object', OBJECT.name)!.organization_id).toBeNull(); @@ -695,7 +705,7 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL const saveMetaItem = vi.fn().mockResolvedValue({ success: true, version: 'v1', seq: 1 }); protocol.saveMetaItem = saveMetaItem; - await dispatcher.handleMetadata(`/${spelling}/specimen`, ctx(), 'PUT', { name: 'specimen' }); + await dispatcher.handleMetadata(`/${spelling}/specimen`, ctx(ACTIVE_ORG), 'PUT', { name: 'specimen' }); expect( saveMetaItem.mock.calls[0][0].organizationId, @@ -714,7 +724,7 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL const saveMetaItem = vi.fn().mockResolvedValue({ success: true, version: 'v1', seq: 1 }); protocol.saveMetaItem = saveMetaItem; - await dispatcher.handleMetadata('/translations/zh-CN', ctx(), 'PUT', { name: 'zh-CN' }); + await dispatcher.handleMetadata('/translations/zh-CN', ctx(ACTIVE_ORG), 'PUT', { name: 'zh-CN' }); const request = saveMetaItem.mock.calls[0][0]; expect(request.type).toBe('translations'); @@ -749,11 +759,11 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL it(`/${plural}/:name/published answers what /${singular}/:name/published answers`, async () => { const viaPlural = publishedStore(singular, item.name); const pluralRes = responseOf(await makeStack(ACTIVE_ORG, viaPlural.service) - .dispatcher.handleMetadata(`/${plural}/${item.name}/published`, ctx(), 'GET')); + .dispatcher.handleMetadata(`/${plural}/${item.name}/published`, ctx(ACTIVE_ORG), 'GET')); const viaSingular = publishedStore(singular, item.name); const singularRes = responseOf(await makeStack(ACTIVE_ORG, viaSingular.service) - .dispatcher.handleMetadata(`/${singular}/${item.name}/published`, ctx(), 'GET')); + .dispatcher.handleMetadata(`/${singular}/${item.name}/published`, ctx(ACTIVE_ORG), 'GET')); // Same status and same body — before the fold the plural was a // 404 for an item that IS published, the singular a 200. @@ -775,7 +785,7 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL expect(canonicalMetaUrlType('webhook')).toBe('webhook'); const store = publishedStore('webhook', 'stripe'); const res = responseOf(await makeStack(ACTIVE_ORG, store.service) - .dispatcher.handleMetadata('/webhook/stripe/published', ctx(), 'GET')); + .dispatcher.handleMetadata('/webhook/stripe/published', ctx(ACTIVE_ORG), 'GET')); expect(res.status).toBe(200); expect(store.asked).toEqual([['webhook', 'stripe']]); diff --git a/packages/spec/liveness/doc.json b/packages/spec/liveness/doc.json index fdcc200d253..a1f973f437f 100644 --- a/packages/spec/liveness/doc.json +++ b/packages/spec/liveness/doc.json @@ -17,7 +17,7 @@ "description": { "status": "live", "verifiedAt": "2026-09-27", - "evidence": "packages/spec/src/system/book.zod.ts#entryFromDoc (`description: doc.description` — carried onto the rendered tree entry); packages/spec/src/system/doc.zod.ts#resolveDocLocale (`description: variant.description ?? base.description`); packages/rest/src/rest-server.ts#readableTree (the tree route's corpus projection `{ name, label, description, order, group, tags, packageId }` — the transport half that reads the key — handed to `audience.readableTree(book, docs)`, whose `resolveBookTree` carries it onto the rendered entry)", + "evidence": "packages/spec/src/system/book.zod.ts#entryFromDoc (`description: doc.description` — carried onto the rendered tree entry); packages/spec/src/system/doc.zod.ts#resolveDocLocale (`description: variant.description ?? base.description`); packages/rest/src/meta-item-read-gate.ts#createMetaBookTreeAnswer (the tree route's corpus projection `{ name, label, description, order, group, tags, packageId }` — the transport half that reads the key — handed to `audience.readableTree(book, docs)`, whose `resolveBookTree` carries it onto the rendered entry)", "note": "carried into tree entries and kept on the list response (which strips `content`) so portals can show summaries without fetching bodies. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — `book.zod.ts:202` is the `tags` docblock (a DIFFERENT key's documentation) and `rest-server.ts:3131` is `defaultAtomic: batch.defaultAtomic ?? true` in the batch-endpoints config. Re-closed by hand against 93ea19bca." }, "content": { @@ -41,13 +41,13 @@ "translations": { "status": "live", "verifiedAt": "2026-08-28", - "evidence": "packages/spec/src/system/doc.zod.ts#resolveDocLocale (`const { translations, ...base } = doc` then `translations[want] ?? translations[want.split('-')[0]]` — exact locale, then primary subtag, then the base doc; the map itself never survives into the return value); packages/rest/src/rest-server.ts#resolveDocLocale (called on every doc read path — the list branch maps it over the items, the tree branch over the corpus, the single-item branch over the one doc)", + "evidence": "packages/spec/src/system/doc.zod.ts#resolveDocLocale (`const { translations, ...base } = doc` then `translations[want] ?? translations[want.split('-')[0]]` — exact locale, then primary subtag, then the base doc; the map itself never survives into the return value); packages/rest/src/meta-item-read-gate.ts#resolveDocLocale (called on every doc read path — the list branch maps it over the items, the tree branch over the corpus, the single-item branch over the one doc)", "note": "per-locale {label,description,content} variants collapsed by resolveDocLocale on every read path (list, tree corpus, single item); the map itself is stripped from responses. This is the doc's OWN i18n mechanism — the generic bundle translator does not cover `doc`. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — all three legs were wrong: `doc.zod.ts:110` is the `/**` that OPENS the `tags` docblock, and `rest-server.ts:2996` / `:3388` are comments about `os serve` config and `api.version` respectively. The rest-server leg is anchored to the imported symbol it calls rather than to the registrar, because that is what actually has to survive for this claim to hold: drop the import and the three call sites go with it, and the anchor reds. Re-closed by hand against 93ea19bca." }, "tags": { "status": "live", "verifiedAt": "2026-09-27", - "evidence": "packages/spec/src/system/book.zod.ts#matchesInclude (`Array.isArray(doc.tags) && doc.tags.includes(include.tag)` — the tag branch of `include`); packages/spec/src/system/book.zod.ts#resolveBookTree (the caller that decides membership from it); packages/rest/src/rest-server.ts#readableTree (`tags: d.tags` in the tree corpus the route hands `audience.readableTree(book, docs)` — the transport half); packages/rest/src/meta-item-read-gate.ts#resolveBookTree (`resolveBookTree(book, docs, book._packageId)` — where that corpus reaches the resolver)", + "evidence": "packages/spec/src/system/book.zod.ts#matchesInclude (`Array.isArray(doc.tags) && doc.tags.includes(include.tag)` — the tag branch of `include`); packages/spec/src/system/book.zod.ts#resolveBookTree (the caller that decides membership from it); packages/rest/src/meta-item-read-gate.ts#createMetaBookTreeAnswer (`tags: d.tags` in the tree corpus the route hands `audience.readableTree(book, docs)` — the transport half); packages/rest/src/meta-item-read-gate.ts#resolveBookTree (`resolveBookTree(book, docs, book._packageId)` — where that corpus reaches the resolver)", "note": "DECLARED 2026-08-02 (#4509, ADR-0049) — the enforce half of enforce-or-remove. The consumer, the transport and the resolver-side interface all predated this key: matchesInclude compared `doc.tags` against a group's `include: { tag }`, the book route already forwarded `tags: d.tags`, and ResolverDoc declared `tags?: string[]` marked '(P3d; absent today)'. What was missing was one line HERE — DocSchema is strict, so authoring `tags:` was a parse error, every doc reached the resolver with tags undefined, and the `{ tag }` include variant could never match. Removing the variant was the alternative and was rejected: a union member has no clean tombstone (retiredKey covers object keys), so authors would have gotten a bare union error, and it would have discarded working matcher code. Live on arrival — the branch it feeds is reachable the moment a doc carries a tag. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — `book.zod.ts:213` is `description?: string;` in the `ResolvedEntry` interface, and the parenthetical claiming it was \"matchesInclude tag branch\" named the right function ~61 lines away; the \"reached from :255 and :281\" hops were prose the ledger never bounded. `rest-server.ts:3218` had rotted onto `this.registerSecurityEndpoints(bp)` — a route-registration call, ~1,700 lines from the corpus that forwards the tags. Re-closed by hand against 93ea19bca." } }