diff --git a/.changeset/20320-dispatcher-meta-read-parity.md b/.changeset/20320-dispatcher-meta-read-parity.md new file mode 100644 index 00000000000..d4203b09f2a --- /dev/null +++ b/.changeset/20320-dispatcher-meta-read-parity.md @@ -0,0 +1,54 @@ +--- +'@objectstack/rest': minor +'@objectstack/runtime': patch +--- + +fix(rest, runtime): the runtime dispatcher's `/meta` reads answer what `RestServer`'s answer — one list chain, one `public`-audience predicate, and the `?state=draft` read (#20320) + +Clause-②: yes (widening) — `@objectstack/rest`'s root entry gains five value exports (`createMetaListAnswer`, `translateMetaList`, `metaRequestLocale`, `isPublicAudienceRead`, `STORED_VERSION_DOOR_POLICY`) and six type exports (`MetaListAnswer`, `MetaListAnswerSources`, `MetaListRequest`, `MetaListTranslationSources`, `MetaPublicReadRoute`, `MetaRequestHttp`), so its published surface grows; nothing it exported before 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. Its reads now give the same answers as `RestServer`'s +`GET /meta/:type` and `GET /meta/:type/:name`. Until now a dispatcher-only host +answered: + +- **`GET /meta/app?id=crm`** — every app the caller may see, not `[crm]`. An + `?id=` that matches nothing listed every app instead of an empty list. +- **`GET /meta/view?object=lead`** — every view, not the lead views sorted for + the switcher. +- **`GET /meta/docs`** (the plural spelling) — every doc WITH its body. The + content slim compared the raw segment, so it ran only for `/meta/doc`. +- **any doc list** — each doc with its `translations` map and in no locale. + `RestServer` collapses each doc to the request's locale. +- **every translatable list** (`app`, `view`, `object`, `page`, `dashboard`, + `action`, `dataset`) — untranslated labels, whatever `Accept-Language` or + `?locale=` asked for, and no `Vary: Accept-Language` header. +- **`GET /meta/api`** — every stored `api` declaration, including ones the + endpoint matcher does not serve (their routes answer 404). +- **an anonymous `GET` of a `public` book or doc** (list or item) — + `401 UNAUTHENTICATED`. `RestServer` serves it (ADR-0046 §6.7). +- **`GET /meta/:type/:name?state=draft` from a caller who may read drafts** — + the ACTIVE item. It should be the pending draft, whole for a caller who may + save the app and pruned per caller for everyone else, or `404 NO_DRAFT` when + nothing is pending. A caller who may not read drafts is still answered the + plain read, byte for byte. + +**What changed.** The list route's whole post-read chain moved out of +`RestServer` into `createMetaListAnswer` in `@objectstack/rest`, unchanged. That +chain is the `api` served-set face, the per-caller list gate, `?id=`, +`?object=`, the doc locale collapse and content slim, the transport's own +object mask, and the translation. Every exit of the dispatcher's list branch +now hands its answer to that same function. The anonymous gates on both +transports ask one exported predicate, `isPublicAudienceRead`. It admits only +`GET` reads of book and doc, so every other type keeps the anonymous deny, and +the §6.7 audience gate still refuses `org` and `{ permissionSet }` audiences. +The dispatcher's `?state=draft` read runs the exported +`STORED_VERSION_DOOR_POLICY`, the constant `RestServer`'s draft branch runs. + +`RestServer`'s own answers are unchanged: the move is a refactor on that side, +and every existing REST test passes unedited. + +New exports from `@objectstack/rest`: `createMetaListAnswer`, +`translateMetaList`, `metaRequestLocale`, `isPublicAudienceRead`, +`STORED_VERSION_DOOR_POLICY` and their types. Nothing is removed or renamed. diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index 5539475b006..5423ce2dac0 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -86,11 +86,34 @@ export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-mu // audience prunes, the app nav filter and the dashboard widget gate — over the // same ports. The judge answers the pruned ITEMS; each caller rewraps them in // its own list envelope. -export { createMetaItemReadGate, createMetaListReadGate } from './meta-item-read-gate.js'; +// +// [#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 +// `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). +export { + createMetaItemReadGate, + createMetaListReadGate, + createMetaListAnswer, + isPublicAudienceRead, + metaRequestLocale, + STORED_VERSION_DOOR_POLICY, + translateMetaList, +} from './meta-item-read-gate.js'; export type { MetaItemReadGateSources, MetaItemReadRefusal, MetaItemReadVerdict, + MetaListAnswer, + MetaListAnswerSources, + MetaListRequest, + MetaListTranslationSources, + MetaPublicReadRoute, MetaReadGateCaller, MetaReadGatePolicy, + MetaRequestHttp, } from './meta-item-read-gate.js'; diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 6afc4acf750..4a2d2cb5cde 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -45,8 +45,11 @@ */ import type { AudienceCaller, Book, ResolvedBook, ResolverDoc } from '@objectstack/spec/system'; +import { preferredLocaleFromHeader } from '@objectstack/spec/system'; import { apiExposureDenialReason } from '@objectstack/spec/data'; -import { logWarn } from './log.js'; +import { pluralToSingular } from '@objectstack/spec/shared'; +import { isEndpointMatchAuthority, selectServedEndpoints } from './served-endpoints.js'; +import { logError, logWarn } from './log.js'; // ── The ports each transport supplies ───────────────────────────────────────── @@ -206,6 +209,29 @@ export interface MetaReadGatePolicy { app: 'gate' | 'author-exempt'; } +/** + * [#20156] The policy of the doors that serve STORED versions for authoring + * — the layered view (`/layers`, `?layers=`), `/diff` and [#20290] the + * plain read's `?state=draft` branch (the pending draft row, which Studio's + * designers merge over the layered view and save back). One constant, so + * they cannot come to disagree about the `app` row. + * + * `app: 'author-exempt'` — ruling 5856774816 (letter B, confirmed + * 5856866273): a caller who may write the app (the door's own save + * admission, carried on the caller as {@link MetaReadGateCaller.mayWriteItem}) + * reads the full stored version, and every other caller who may open the app + * reads exactly what the plain read gives them, pruned. See + * {@link MetaReadGatePolicy.app}. + * + * [#20320] Moved here from `RestServer`, unchanged, so the runtime + * dispatcher's `?state=draft` item read runs the SAME constant instead of a + * copy of it. + */ +export const STORED_VERSION_DOOR_POLICY: MetaReadGatePolicy = Object.freeze({ + arms: 'per-caller', + app: 'author-exempt', +}); + // ── The gates' types ────────────────────────────────────────────────────────── /** @@ -1491,3 +1517,458 @@ export function createMetaListReadGate( return async (items) => items; } + +// ── Anonymous reachability of the `public` audience ─────────────────────────── + +/** + * [#20320] The `/meta` READ route a transport is about to serve, named by its + * SHAPE in that transport's own route table — never by string-matching a + * request path, so a route added later cannot fall inside the exemption by + * accident, and a plural spelling cannot fall outside it (#3984). + * + * - `list` — `GET /meta/:type`; + * - `item` — `GET /meta/:type/:name`; + * - `book-tree` — `GET /meta/book/:name/tree` (the type segment is literal). + * + * A transport names only the shapes it serves: `RestServer` all three, the + * runtime dispatcher `list` and `item` (it has no book-tree route). + */ +export type MetaPublicReadRoute = 'list' | 'item' | 'book-tree'; + +/** + * [#3963 · #20320] Is this request a READ of the audience-gated book/doc + * surface — the one metadata surface whose own declaration (`book.audience`) + * can authorize an anonymous caller? + * + * Both transports' anonymous gates ask it, to grant an anonymous caller + * REACHABILITY of these reads, so `audience: 'public'` works on a + * secure-by-default deployment instead of only on one that opened its whole + * data plane (ADR-0046 §6.7). Authorization stays with the reads' own §6.7 + * gate ({@link createMetaItemReadGate}, {@link createMetaListReadGate}), which + * admits `'public'` only: `org` and `{ permissionSet }` audiences still refuse + * an anonymous caller. Reads only — never a write or a publish — and book and + * doc only: every other type (object, field, view, flow, …) keeps the + * anonymous deny. + * + * Moved here from `RestServer` (#20320): the runtime dispatcher's `/meta` + * domain opened with an unconditional anonymous deny, so a `public` book + * answered `401` there while `RestServer` served it. One predicate, two + * transports — each passes its own method, route shape and raw `:type` + * segment, and the type is folded HERE, once. + */ +export function isPublicAudienceRead( + method: unknown, + route: MetaPublicReadRoute | undefined, + type: unknown, +): boolean { + if (String(method ?? '').toUpperCase() !== 'GET') return false; + if (route === 'book-tree') return true; + if (route !== 'list' && route !== 'item') return false; + const folded = typeof type === 'string' ? pluralToSingular(type) : ''; + return folded === 'book' || folded === 'doc'; +} + +// ── The list route's locale and translation ─────────────────────────────────── + +/** The two request members a locale is read from — each transport hands in its own. */ +export interface MetaRequestHttp { + /** A `Headers`-like (`get`) or a plain header record. */ + readonly headers?: unknown; + readonly query?: Readonly>; +} + +/** + * [#20320] The request's locale, as every `/meta` answer reads it: the + * highest-priority `Accept-Language` tag, then a `?locale=` query parameter, + * then — when an i18n service is handed in — that service's default locale. + * `undefined` when no preference is expressed (the caller then serves + * untranslated metadata). + * + * Moved here, unchanged, from `RestServer.extractLocale` (which now + * delegates), so the runtime dispatcher reads the same locale from the same + * request instead of a second parse. + */ +export function metaRequestLocale(http: MetaRequestHttp | undefined, i18n?: unknown): string | undefined { + const headers: any = http?.headers; + let header: string | undefined; + if (headers) { + header = typeof headers.get === 'function' + ? headers.get('accept-language') ?? undefined + : headers['accept-language'] ?? headers['Accept-Language']; + } + // Shared parse — the dispatcher's execution context resolves the same + // header the same way, so a message and the labels around it can't + // disagree (#3957). + const preferred = preferredLocaleFromHeader(header); + if (preferred) return preferred; + // [#6877] The `typeof` guard sends a repeated `?locale=` to the i18n + // default rather than into the array arm. A guard, not the refusal gate: + // this helper is shared by ~10 routes and has no `res`, and the + // parameter's worst case is falling back to the default locale. + const queryLocale = http?.query?.locale; + if (typeof queryLocale === 'string' && queryLocale.length > 0) return queryLocale; + const service: any = i18n; + if (service && typeof service.getDefaultLocale === 'function') { + const def = service.getDefaultLocale(); + if (typeof def === 'string' && def.length > 0) return def; + } + return undefined; +} + +/** + * [#3786] Is `type` (the canonical singular) one `translateMetadataDocument` + * localizes? Derived from `TRANSLATABLE_METADATA_TYPES` in + * `@objectstack/spec/system`, the set that is itself derived from the + * translator table — so there is no second list to forget. + * + * Resolved lazily and memoised, so `@objectstack/spec/system`'s translators + * stay off the module-init path: the same `await import` the translate + * helpers perform, and a module-cache hit after the first call. + */ +let translatableMetaTypes: ReadonlySet | undefined; +export async function isTranslatableMetaType(type: string): Promise { + if (!translatableMetaTypes) { + ({ TRANSLATABLE_METADATA_TYPES: translatableMetaTypes } = await import('@objectstack/spec/system')); + } + return translatableMetaTypes.has(type); +} + +/** + * Build a `TranslationBundle` (`Record`) from an + * `II18nService` instance. `undefined` when no locales are registered, so + * callers can avoid translation work. (`RestServer.buildTranslationBundle` + * delegates here.) + */ +export function translationBundleOf(i18n: unknown): any | undefined { + const service: any = i18n; + if (!service || typeof service.getLocales !== 'function' || typeof service.getTranslations !== 'function') { + return undefined; + } + const locales: string[] = service.getLocales(); + if (!locales.length) return undefined; + const bundle: Record = {}; + for (const locale of locales) { + const data = service.getTranslations(locale); + if (data && typeof data === 'object') bundle[locale] = data; + } + return Object.keys(bundle).length ? bundle : undefined; +} + +/** + * [#14882 · #15711] The `ResolveOptions` every metadata-document translation + * hands `@objectstack/spec/system`: the request's locale, the deployment's + * DECLARED fallback chain (`getFallbackLocale()`) and its DEFAULT locale + * (`getDefaultLocale()`). Both are feature-detected (optional on + * `II18nService`): a service that declares neither gets neither — the + * serving layer threads a declaration, it never invents one. + * `RestServer.translateOptionsFor`, whose docblock carries the history, + * delegates here. + */ +export function metaTranslateOptions( + i18n: unknown, + locale: string, +): { locale: string; fallbackChain?: string[]; defaultLocale?: string } { + const service: any = i18n; + const fallback = service && typeof service.getFallbackLocale === 'function' ? service.getFallbackLocale() : undefined; + const def = service && typeof service.getDefaultLocale === 'function' ? service.getDefaultLocale() : undefined; + const opts: { locale: string; fallbackChain?: string[]; defaultLocale?: string } = { locale }; + if (typeof fallback === 'string' && fallback.length > 0) opts.fallbackChain = [fallback]; + if (typeof def === 'string' && def.length > 0) opts.defaultLocale = def; + return opts; +} + +/** + * [#8284] The packaged (code-layer) base declaration of an OBJECT, for + * `translateObject`'s `packagedBase` — `undefined` on every uncertainty, which + * the spec-side rule reads as "no baseline known". Feature-detected on the + * protocol (`getPackagedObjectBase` is a server-only extension). + * `RestServer.packagedObjectBase`, whose docblock carries the rule, delegates + * here. + */ +export function packagedObjectBaseOf(protocol: unknown, type: string, name: unknown): unknown { + if (type !== 'object') return undefined; + if (typeof name !== 'string' || name === '') return undefined; + const p: any = protocol; + if (!p || typeof p.getPackagedObjectBase !== 'function') return undefined; + try { + return p.getPackagedObjectBase(name); + } catch { + return undefined; + } +} + +/** How a transport reaches what the list translation reads. */ +export interface MetaListTranslationSources { + /** This request's i18n service, or `undefined` when the deployment has none. */ + resolveI18nService(): Promise; + /** This request's protocol, for the packaged object base; `undefined` when unreachable. */ + resolveProtocol(): Promise; + /** + * This request's locale ({@link metaRequestLocale} over the transport's own + * request) — with the i18n service handed in, its default locale is the + * last fallback; without it, only the request's own preference counts. + */ + requestLocale(i18n?: unknown): string | undefined; +} + +/** + * [#20320] Translate a metadata LIST — a bare array or the + * `{ type, items }` envelope, answered in the shape it came in — for the + * request's locale. `metaType` is the canonical singular (the caller folds its + * URL segment once): `TRANSLATABLE_METADATA_TYPES` is singular-only, so a + * plural spelling that reached it unfolded would skip the whole localization. + * + * A missing bundle is NOT a bail-out: `translateMetadataDocument` still + * applies built-in fallbacks (the injected system-field labels on custom + * objects). No locale is: nothing is translated then. + * + * `RestServer.translateMetaItems` delegates here, and the list chain + * ({@link createMetaListAnswer}) runs it as its last step on both transports. + */ +export async function translateMetaList( + sources: MetaListTranslationSources, + metaType: string, + items: unknown, +): Promise { + if (!(await isTranslatableMetaType(metaType))) return items; + const raw: any = items; + const arr: any[] | null = Array.isArray(raw) + ? raw + : (raw && typeof raw === 'object' && Array.isArray(raw.items) ? raw.items : null); + if (!arr) return items; + const i18n = await sources.resolveI18nService(); + const bundle = translationBundleOf(i18n); + const locale = sources.requestLocale(i18n); + if (!locale) return items; + const { translateMetadataDocument } = await import('@objectstack/spec/system'); + // [#8284] One protocol resolution for the whole page; the lookup itself is + // a synchronous in-memory registry read per element. + const p = metaType === 'object' ? await sources.resolveProtocol() : undefined; + // `getMetaItems` elements are metadata documents (the list envelope is the + // OUTER `{ type, items }`), so every element translates directly (#5563). + const translated = arr.map((item) => translateMetadataDocument(metaType, item, bundle, { + ...metaTranslateOptions(i18n, locale), + packagedBase: packagedObjectBaseOf(p, metaType, item?.name), + })); + return Array.isArray(raw) ? translated : { ...raw, items: translated }; +} + +// ── THE list answer ─────────────────────────────────────────────────────────── + +/** Everything {@link createMetaListAnswer} reads, supplied by the transport. */ +export interface MetaListAnswerSources extends MetaItemReadGateSources { + /** + * This request's locale, as {@link MetaListTranslationSources.requestLocale} + * reads it — 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 translation step: {@link translateMetaList} over this transport's + * I/O, handed the folded `metaType`. A port rather than a call so each + * transport keeps ONE entry into it (`RestServer.translateMetaItems`). + */ + translateList(metaType: string, items: unknown): Promise; + /** + * [#5224] The endpoint matcher this request's `api` face asks — the + * `metadata` service occupying the slot, or `undefined`. It is PROBED here + * (`matchEndpoint` is optional on `IMetadataService`), so a transport hands + * in the occupant, not a verdict. + */ + resolveEndpointMatcher(): Promise; + /** + * [#5224] Say — once, in the transport's own words and naming its own + * remedy — that no matcher is reachable, so the `api` face lists what is + * STORED (Route & surface ownership rule 3: absence must be loud). + */ + 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. + */ + maskObjects(items: any[]): Promise<{ ok: true; items: any[] } | { ok: false; object: string }>; +} + +/** The request facts the list chain reads — no transport shape. */ +export interface MetaListRequest { + /** The SINGULAR type (the caller folds `/meta/docs` once, at its boundary). */ + readonly metaType: string; + /** The list route's query. The chain reads `id`, `object` and `include` off it (and `locale`, through `requestLocale`). */ + readonly query: Readonly> | undefined; + /** + * `?preview=draft`, ADMITTED — the transport's own declaration, made with + * its admission at the list entry (#20338) and ⛔ never re-read here. The + * `api` face is exempt for it. + */ + readonly previewDrafts: boolean; +} + +/** + * 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. + */ +export type MetaListAnswer = + | { ok: true; data: unknown } + | { ok: false; object: string }; + +/** The items of a list shape this chain serves, or `null` for anything else (left alone, never replaced). */ +function listItemsOf(raw: unknown): any[] | null { + if (Array.isArray(raw)) return raw; + return raw && typeof raw === 'object' && Array.isArray((raw as any).items) ? (raw as any).items : null; +} + +/** `raw`'s shape around new items. */ +function withItems(raw: unknown, items: any[]): unknown { + return Array.isArray(raw) ? items : { ...(raw as any), items }; +} + +/** + * [#20320] THE answer of one `/meta/:type` LIST read, after the store read — + * one chain, called by both transports that serve that read. + * + * ## Why one chain + * + * `RestServer`'s `GET /meta/:type` ran every step below inline, and the runtime + * dispatcher's `/meta` list branch — the only answer on a host that mounts just + * the `${prefix}/*` catch-all — ran the per-caller gate (#20237), the object + * mask and the doc slim, the slim comparing the RAW segment. So the dispatcher + * listed every app for `?id=crm`, every view for `?object=lead`, doc bodies for + * `/meta/docs`, docs with their `translations` maps and in no locale, labels + * untranslated, and `api` declarations the runtime does not serve. Each + * transport now hands its store's answer to THIS function: a step added here + * reaches both, and `meta-list-projection-parity.test.ts` in + * `@objectstack/runtime` drives every type × query parameter × caller through + * both and holds the answers equal. ⛔ A list step is added HERE, never in a + * transport — one added in one of them is the defect this closed, reopened. + * + * ## The steps, in `RestServer`'s order (unchanged) + * + * 1. `api` — the served-set face (#5224): only the declarations the endpoint + * matcher will serve, asked of the matcher itself. Exempt for an admitted + * `?preview=draft` (that surface answers what is PENDING). A matcher throw + * propagates: an unreadable store is never "nothing declared". + * 2. THE per-caller list gate ({@link createMetaListReadGate}) — before `?id=` + * narrows (permission decides what exists for this caller; the filter + * narrows within it, never the reverse, ADR-0045 §3), and on the raw doc + * items, before the locale collapse (`_packageId` scopes membership). + * 3. `app` — `?id=` narrows to the app of that `name` (#7566); empty + * and absent spellings mean no filter; no match is an EMPTY list, never a + * 404. + * 4. `view` — `?object=` keeps the independent views bound to that + * object, sorted by `order` then `name` (the switcher). + * 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}). + * 8. The translation step ({@link translateMetaList}, reached through + * {@link MetaListAnswerSources.translateList}). + * + * Every step keys on the FOLDED `metaType`, and a step leaves a shape it does + * not serve untouched. The input is never mutated. A gate or matcher input + * that cannot be read REJECTS: the transport answers that fault, ⛔ never the + * unfiltered list. + */ +export function createMetaListAnswer( + sources: MetaListAnswerSources, + request: MetaListRequest, +): (raw: unknown) => Promise { + const { metaType, query } = request; + return async (raw) => { + let visible: unknown = raw; + + // 1. [#5224] The `api` served-set face. + if (metaType === 'api' && !request.previewDrafts) { + const list = metaItemsArray(visible); + if (list.length > 0) { + const candidate = await sources.resolveEndpointMatcher(); + if (!isEndpointMatchAuthority(candidate)) { + sources.notifyMissingEndpointMatcher('GET /meta/api'); + } else { + const served = await selectServedEndpoints(list, candidate, { + error: (message: string, meta?: unknown) => + meta === undefined ? logError(message) : logError(message, meta), + }); + // The STORED item is what this face answers with — its + // `_packageId` / `_provenance` / `_diagnostics` decorations + // are what the Studio list reads. + visible = withItems(visible, served.map((s) => s.item)); + } + } + } + + // 2. [#20237] THE per-caller list gate. + { + const list = listItemsOf(visible); + if (list) { + const judged = await createMetaListReadGate(sources, metaType)(list); + if (judged !== list) visible = withItems(visible, judged); + } + } + + // 3. [#7566] `?id=`. + const appIdFilter = metaType === 'app' ? query?.id : undefined; + if (typeof appIdFilter === 'string' && appIdFilter !== '') { + const list = listItemsOf(visible); + if (list) { + visible = withItems(visible, list.filter( + (a: any) => a && typeof a === 'object' && a.name === appIdFilter, + )); + } + } + + // 4. `?object=` — the view switcher. + if (metaType === 'view' && query?.object) { + const obj = String(query.object); + const list = listItemsOf(visible); + if (list) { + visible = withItems(visible, list + .filter((v: any) => v && typeof v === 'object' && v.viewKind && v.object === obj) + .sort((a: any, b: any) => + ((a.order ?? 0) as number) - ((b.order ?? 0) as number) || + String(a.name).localeCompare(String(b.name)))); + } + } + + // 5. ADR-0046 i18n: collapse each doc to the request locale. + if (metaType === 'doc') { + const locale = sources.requestLocale(); + const { resolveDocLocale } = await import('@objectstack/spec/system'); + const list = listItemsOf(visible); + if (list) { + visible = withItems(visible, list.map((it: any) => + it && typeof it === 'object' ? resolveDocLocale(it as any, locale) : it)); + } + } + + // 6. ADR-0046: the doc list omits `content` unless `?include=content`. + if (metaType === 'doc' && query?.include !== 'content') { + const list = listItemsOf(visible); + if (list) { + visible = withItems(visible, list.map((it: any) => { + if (!it || typeof it !== 'object') return it; + const { content: _content, ...rest } = it; + return rest; + })); + } + } + + // 7. [ADR-0106 D5(2)] The object mask — the transport's. + if (metaType === 'object') { + const list = metaItemsArray(visible); + if (list.length > 0) { + const masked = await sources.maskObjects(list); + if (!masked.ok) return masked; + visible = withItems(visible, masked.items); + } + } + + // 8. The translation step ({@link translateMetaList}, through the transport's entry). + return { ok: true, data: await sources.translateList(metaType, visible) }; + }; +} diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index b712ae5b863..61b0953957a 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -160,7 +160,6 @@ import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import { PLURAL_TO_SINGULAR, canonicalMetaUrlType, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared'; import { stripReadDecorations } from '@objectstack/spec/kernel'; import type { DroppedFieldsEvent } from '@objectstack/spec/data'; -import { preferredLocaleFromHeader } from '@objectstack/spec/system'; // [#20193] THE per-caller read gate of a `/meta/:type/:name` document, and the // docs-audience and app-nav gates it is built from — one implementation, which // this server and the runtime dispatcher's `/meta` domain both call. The @@ -401,13 +400,12 @@ export { mapDataError }; * 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. */ -let translatableMetaTypes: ReadonlySet | undefined; -async function isTranslatableMetaType(type: string): Promise { - if (!translatableMetaTypes) { - ({ TRANSLATABLE_METADATA_TYPES: translatableMetaTypes } = await import('@objectstack/spec/system')); - } - return translatableMetaTypes.has(type); +function isTranslatableMetaType(type: string): Promise { + return metaReadGate.isTranslatableMetaType(type); } @@ -2819,26 +2817,26 @@ export class RestServer { * `:type` param — not on `req.path` string-matching — so a route added later * cannot accidentally fall inside it, and the plural spelling cannot fall * outside it (#3984). + * + * [#20320] The decision is `isPublicAudienceRead` in + * `./meta-item-read-gate.ts`, the ONE predicate the runtime dispatcher's + * `/meta` anonymous gate asks too; this method only names which of those + * route shapes the registered path is. */ private static isPublicAudienceRead( entry: Readonly>, req: { method?: unknown; params?: Record }, ): boolean { - const method = String(req?.method ?? entry?.method ?? '').toUpperCase(); - if (method !== 'GET') return false; // reads only — never a write or a publish const path = typeof entry?.path === 'string' ? entry.path : ''; - // `GET /meta/book/:name/tree` — the type segment is literal here. - if (path.endsWith('/book/:name/tree')) return true; - // `GET /meta/:type` and `GET /meta/:type/:name` — book/doc only. Every - // other type (object, field, view, flow, …) keeps the anonymous deny. - if (!/\/:type(\/:name)?$/.test(path)) return false; - const type = RestServer.metaTypeSingular(req?.params?.type); - return type === 'book' || type === 'doc'; - } - - /** Coerce a getMetaItems result (array | {items}) into an array — `metaItemsArray` in `./meta-item-read-gate.ts`. */ - private static metaItemsArray(raw: unknown): any[] { - return metaReadGate.metaItemsArray(raw); + return metaReadGate.isPublicAudienceRead( + req?.method ?? entry?.method, + // `GET /meta/book/:name/tree` — the type segment is literal there. + path.endsWith('/book/:name/tree') ? 'book-tree' + : /\/:type\/:name$/.test(path) ? 'item' + : /\/:type$/.test(path) ? 'list' + : undefined, + req?.params?.type, + ); } /** Shape a book list for the audience resolver — `audienceBooksOf` in `./meta-item-read-gate.ts`. */ @@ -3528,11 +3526,12 @@ export class RestServer { * carried on the caller as `mayWriteItem`) reads the full stored version, * and every other caller who may open the app reads exactly what the plain * read gives them, pruned. See `MetaReadGatePolicy.app`. + * + * [#20320] The constant itself is `STORED_VERSION_DOOR_POLICY` in + * `./meta-item-read-gate.ts`, which the runtime dispatcher's `?state=draft` + * read runs too; this name is kept so every door here reads it unchanged. */ - private static readonly STORED_VERSION_DOOR_POLICY: MetaReadGatePolicy = Object.freeze({ - arms: 'per-caller', - app: 'author-exempt', - }); + private static readonly STORED_VERSION_DOOR_POLICY: MetaReadGatePolicy = metaReadGate.STORED_VERSION_DOOR_POLICY; /** * [#12702 · #20156] May this caller SAVE `:type/:name`? The admission of @@ -3583,20 +3582,12 @@ export class RestServer { /** * Build a `TranslationBundle` (`Record`) from an * `II18nService` instance. Returns `undefined` when no locales are - * registered so callers can avoid translation work. + * registered so callers can avoid translation work. [#20320] + * `translationBundleOf` in `./meta-item-read-gate.ts`, which the runtime + * dispatcher's list translation asks too. */ private buildTranslationBundle(i18n: any): any | undefined { - if (!i18n || typeof i18n.getLocales !== 'function' || typeof i18n.getTranslations !== 'function') { - return undefined; - } - const locales: string[] = i18n.getLocales(); - if (!locales.length) return undefined; - const bundle: Record = {}; - for (const locale of locales) { - const data = i18n.getTranslations(locale); - if (data && typeof data === 'object') bundle[locale] = data; - } - return Object.keys(bundle).length ? bundle : undefined; + return metaReadGate.translationBundleOf(i18n); } /** @@ -3624,14 +3615,9 @@ export class RestServer { * widening it — the same shape `getMetaItemLayered` is consumed with. */ private packagedObjectBase(p: any, type: string, name: unknown): unknown { - if (type !== 'object') return undefined; - if (typeof name !== 'string' || name === '') return undefined; - if (!p || typeof p.getPackagedObjectBase !== 'function') return undefined; - try { - return p.getPackagedObjectBase(name); - } catch { - return undefined; - } + // [#20320] `packagedObjectBaseOf` in `./meta-item-read-gate.ts`, which + // the runtime dispatcher's list translation asks too. + return metaReadGate.packagedObjectBaseOf(p, type, name); } /** @@ -3639,33 +3625,20 @@ export class RestServer { * Falls back to a `?locale=` query parameter, then to the i18n service's * default locale. Returns `undefined` when no preference is expressed * (callers will then return untranslated metadata). + * + * [#6877] One of the read points that was ALREADY safe: a repeated + * `?locale=` falls to the i18n default rather than into the array arm. + * Left as a guard rather than converted to the refusal gate because this + * helper is shared by ~10 routes and has no `res` — refusing here would + * need every caller to thread one through, for a parameter whose worst + * case is falling back to the default locale. Recorded so the asymmetry + * reads as a decision. + * + * [#20320] `metaRequestLocale` in `./meta-item-read-gate.ts`, the one parse + * the runtime dispatcher's `/meta` list reads its locale with too. */ private extractLocale(req: any, i18n?: any): string | undefined { - const headers = req?.headers; - let header: string | undefined; - if (headers) { - header = typeof headers.get === 'function' - ? headers.get('accept-language') ?? undefined - : headers['accept-language'] ?? headers['Accept-Language']; - } - // Shared parse — the runtime dispatcher resolves the same header the - // same way, so a message and the labels around it can't disagree (#3957). - const preferred = preferredLocaleFromHeader(header); - if (preferred) return preferred; - // [#6877] One of the read points that was ALREADY safe: the `typeof` - // guard sends a repeated `?locale=` to the i18n default rather than - // into the array arm. Left as a guard rather than converted to the - // refusal gate because this helper is shared by ~10 routes and has no - // `res` — refusing here would need every caller to thread one through, - // for a parameter whose worst case is falling back to the default - // locale. Recorded so the asymmetry reads as a decision. - const queryLocale = req?.query?.locale; - if (typeof queryLocale === 'string' && queryLocale.length > 0) return queryLocale; - if (i18n && typeof i18n.getDefaultLocale === 'function') { - const def = i18n.getDefaultLocale(); - if (typeof def === 'string' && def.length > 0) return def; - } - return undefined; + return metaReadGate.metaRequestLocale({ headers: req?.headers, query: req?.query }, i18n); } /** @@ -3706,17 +3679,15 @@ export class RestServer { * `填报单` to a `zh-CN` request and its `en` bundle to every other one — * the contract question this docblock once refused to answer on its own * (#14882 left it to #15711, which ruled it). + * + * [#20320] `metaTranslateOptions` in `./meta-item-read-gate.ts`, which the + * runtime dispatcher's list translation spreads too. */ private static translateOptionsFor( i18n: any, locale: string, ): { locale: string; fallbackChain?: string[]; defaultLocale?: string } { - const fallback = i18n && typeof i18n.getFallbackLocale === 'function' ? i18n.getFallbackLocale() : undefined; - const def = i18n && typeof i18n.getDefaultLocale === 'function' ? i18n.getDefaultLocale() : undefined; - const opts: { locale: string; fallbackChain?: string[]; defaultLocale?: string } = { locale }; - if (typeof fallback === 'string' && fallback.length > 0) opts.fallbackChain = [fallback]; - if (typeof def === 'string' && def.length > 0) opts.defaultLocale = def; - return opts; + return metaReadGate.metaTranslateOptions(i18n, locale); } /** @@ -4049,37 +4020,85 @@ export class RestServer { * splitting the fix (list normalized, single-item not) would trade one * missing translation for the far harder "the list is localized but the * detail page it links to is not". + * + * [#20320] The translation is `translateMetaList` in + * `./meta-item-read-gate.ts` — the last step of the list chain both + * transports run, reached here through {@link metaListAnswerSources}; this + * method folds the spelling and hands in this transport's I/O. */ private async translateMetaItems(req: any, type: string, environmentId: string | undefined, items: any): Promise { - const metaType = RestServer.metaTypeSingular(type); - if (!(await isTranslatableMetaType(metaType))) return items; - // `getMetaItems` may hand back a bare array or an `{ items: [...] }` - // envelope. Unwrap so list responses are localized the same way the - // single-item route is; a non-array, non-envelope value is returned - // untouched. - const arr: any[] | null = Array.isArray(items) - ? items - : (items && typeof items === 'object' && Array.isArray(items.items) ? items.items : null); - if (!arr) return items; - const i18n = await this.resolveI18nService(environmentId, req); - // Missing bundle ≠ bail-out — see `translateMetaItem`. - const bundle = this.buildTranslationBundle(i18n); - const locale = this.extractLocale(req, i18n); - if (!locale) return items; - const { translateMetadataDocument } = await import('@objectstack/spec/system'); - // [#8284] One protocol resolution for the whole page; the lookup - // itself is a synchronous in-memory registry read per element. - const p = metaType === 'object' - ? await this.resolveProtocol(environmentId, req).catch(() => undefined) - : undefined; - // `getMetaItems` elements are metadata documents (the list envelope is - // the OUTER `{ type, items }`), so every element translates directly — - // #5563 removed the per-element shape sniff that stood here. - const translated = arr.map((item) => translateMetadataDocument(metaType, item, bundle, { - ...RestServer.translateOptionsFor(i18n, locale), - packagedBase: this.packagedObjectBase(p, metaType, item?.name), - })); - return Array.isArray(items) ? translated : { ...items, items: translated }; + return metaReadGate.translateMetaList( + this.metaListTranslationSources(environmentId, req), + RestServer.metaTypeSingular(type), + items, + ); + } + + /** + * [#20320] This transport's I/O for the list translation: the request's + * i18n service, its protocol (for the #8284 packaged object base, resolved + * only for an `object` list) and {@link extractLocale} over this request. + */ + private metaListTranslationSources( + environmentId: string | undefined, + req: any, + ): metaReadGate.MetaListTranslationSources { + return { + resolveI18nService: () => this.resolveI18nService(environmentId, req), + resolveProtocol: () => this.resolveProtocol(environmentId, req).catch(() => undefined), + requestLocale: (i18n) => this.extractLocale(req, i18n), + }; + } + + /** + * [#20320] This transport's I/O for THE list chain + * (`createMetaListAnswer` in `./meta-item-read-gate.ts`): the per-caller + * 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. + */ + private metaListAnswerSources( + environmentId: string | undefined, + req: any, + p: RestProtocol, + res: any, + ): metaReadGate.MetaListAnswerSources { + return { + ...this.metaItemReadGateSources(environmentId, req, p), + requestLocale: (i18n) => this.extractLocale(req, i18n), + 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 }; + }, + }; } /** @@ -5960,292 +5979,34 @@ export class RestServer { }; const items = await p.getMetaItems(listRequest); - // `getMetaItems` is typed as `{type, items[]}` but the - // objectql implementation actually returns the raw - // array. Handle both shapes defensively. - let visible: any = items; - - // [#5224] `api` is a CONTRACT face, so it announces only - // the declarations the endpoint matcher will actually - // serve — the same set `/openapi.json` documents, asked - // of the same authority. - // - // The special case is `api`-only and stays that way on - // purpose: for every other type "listed" and "in effect" - // are the same fact, resolved by the one reader that - // enumerated them. For `api` they are not — a declared - // route is served by `IMetadataService.matchEndpoint`, - // whose index is a different reader with a different - // reach, and it is the SOLE holder of that verdict. So - // the special case is not "api is special", it is "api - // is the one type whose service verdict lives somewhere - // this route cannot see without asking". - // - // `?preview=draft` is exempt: that surface exists to - // answer "what is PENDING", which is by construction not - // the served set (a draft is not live and is not meant to - // look live). Filtering it would empty the drafts view of - // a type whose drafts are legitimately unserved. Codegen - // and SDK clients read the plain list, which is filtered. - if (RestServer.metaTypeSingular(req.params.type) === 'api' && !previewDrafts) { - const raw = visible as unknown; - const list = RestServer.metaItemsArray(raw); - if (list.length > 0) { - const authority = await this.resolveEndpointMatchAuthority(environmentId, req); - if (!authority) { - this.notifyMissingEndpointAuthority('GET /meta/api'); - } else { - // A `matchEndpoint` throw propagates: its - // contract distinguishes an unreadable store - // from a miss, so this route FAILS (through - // `handleRouteError`) rather than claiming - // the deployment declares nothing. Measured: - // that failure is currently reported as 400, - // because an unrecognised error lands on - // `mapDataError`'s terminal fallback — a - // pre-existing classification on every error - // this route reports, not something this - // narrowing chose. Filed separately; do not - // read the propagation here as a promise - // about which status arrives. - const servedList = await selectServedEndpoints(list, authority, { - error: (message: string, meta?: unknown) => - meta === undefined ? logError(message) : logError(message, meta), - }); - // The STORED item is what this face answers - // with — its `_packageId` / `_provenance` / - // `_diagnostics` decorations are read by the - // Studio list, and dropping them here would - // be a second, unannounced change. - const filtered = servedList.map((s) => s.item); - visible = Array.isArray(raw) ? filtered : { ...(raw as any), items: filtered }; - } - } - } - - // [#20237] THE per-caller LIST gate — the app nav - // filter (privileged apps and gated nav entries - // stripped for an authenticated caller; an anonymous - // list left to the anonymous-deny floor upstream), the - // ADR-0057 D10 dashboard widget gate, and the ADR-0046 - // §6.7 book and doc audience prunes. - // `createMetaListReadGate` in `./meta-item-read-gate.ts` - // is the ONE implementation, which this route and the - // runtime dispatcher's `/meta` list branch both call; - // its docblock carries each type's rule, and ⛔ a list - // gate is added there, never here. - // - // It runs where the app filter always ran: BEFORE - // `?id=` narrows (the next block says why that order is - // a disclosure rule), and on the raw doc items, before - // the locale collapse, so `_packageId` provenance is - // still present for membership scoping. - { - const raw = visible as unknown; - const list: any[] | null = Array.isArray(raw) - ? (raw as any[]) - : (raw && typeof raw === 'object' && Array.isArray((raw as any).items)) - ? ((raw as any).items as any[]) - : null; - if (list) { - const judged = await metaReadGate.createMetaListReadGate( - this.metaItemReadGateSources(environmentId, req, p), - RestServer.metaTypeSingular(req.params.type), - )(list); - if (judged !== list) { - visible = Array.isArray(raw) ? judged : { ...(raw as any), items: judged }; - } - } - } - - // [#7566] `GET /meta/app?id=` — the app-list filter, - // which until now was accepted and then dropped. - // - // Nothing on this route had ever read `id`: the block - // above narrows the list by PERMISSION and the branches - // around it by `?object=` / `?include=` / `?package=`, so - // `?id=crm` and `?id=not_an_app` produced the same three - // apps, byte for byte. A caller cannot tell a working - // filter from a dropped one — a client that asks for one - // app and renders `items[0]` gets a plausible, wrong - // answer, and a bogus id can never come back empty. - // - // ⚠️ Runs AFTER the RBAC filter above, on `visible` - // rather than on `items`. The two orders produce the same - // SET (both are pure filters), but not the same - // disclosure: narrowing first would hand `?id=` a one-element list to gate, and any - // future non-total gate — one that strips a field instead - // of dropping the document — would then be answering - // about an app the caller may not observe at all - // (ADR-0045 §3). Permission decides what exists for this - // caller; the filter narrows what they asked for within - // it, never the reverse. + // [#20320] Everything this route does to the list after + // the read — the #5224 `api` served-set face, THE + // per-caller list gate (#20237), `?id=` for apps (#7566), + // `?object=` for views, the ADR-0046 doc locale collapse + // and content slim, the ADR-0106 object mask and the + // translation step — is ONE chain, + // `createMetaListAnswer` in `./meta-item-read-gate.ts`, + // which the runtime dispatcher's `/meta` list branch + // calls too. Its docblock carries each step's rule and + // their order; ⛔ a list 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). `getMetaItems` + // answers a bare array or the `{ type, items }` envelope; + // the chain answers the same shape. // - // The match is on `name`, the App document's identity — - // `AppSchema.name`, "App unique machine name", the same - // key `GET /meta/app/:name` addresses and the same key - // the metadata store merges overlays on. `AppSchema` - // declares no `id` of its own (`id` appears on nav items - // and areas, never on the app), so there is no second - // identity to disagree with. - // - // A filter that matches nothing answers `200` with an - // EMPTY list, not a 404 — measured against this route's - // siblings, not chosen: `?package=` and - // `/meta/view?object=` both serve an - // empty list here, and the only meta 404 is the - // single-item address `GET /meta/:type/:name`. An empty - // list is the honest answer to "which apps have this id", - // and it is already observably different from the defect, - // which answered with all of them. - // - // Empty and absent spellings still mean "no filter", the - // same falsy gate `?package=` on this route has always - // used. The repeated spelling was refused at the top of - // the handler (#6877), so what arrives here is a string. - // - // Its own block rather than a line inside the branch - // above, because the two answer different questions: - // that branch is guarded on a resolved `ctx?.userId` and - // decides what this caller may observe, while narrowing - // to the app you named is not a privilege and must not - // acquire that guard's conditions. - const appIdFilter = RestServer.metaTypeSingular(req.params.type) === 'app' - ? req.query?.id - : undefined; - if (typeof appIdFilter === 'string' && appIdFilter !== '') { - const raw = visible as unknown; - // Only the two shapes this route serves are narrowed - // — a bare array or the `{ items: [] }` envelope. - // Anything else is left alone rather than replaced - // with an invented empty envelope: a filter must not - // be the thing that changes the response's shape. - const list: any[] | null = Array.isArray(raw) - ? (raw as any[]) - : (raw && typeof raw === 'object' && Array.isArray((raw as any).items)) - ? ((raw as any).items as any[]) - : null; - if (list) { - const matched = list.filter( - (a: any) => a && typeof a === 'object' && a.name === appIdFilter, - ); - visible = Array.isArray(raw) ? matched : { ...(raw as any), items: matched }; - } - } - - // View switcher query: GET /meta/view?object= - // returns ONLY the independent ViewItems bound to that - // object (the `package` layer of "Object has-many - // View"), sorted for the switcher / left rail. The - // aggregated container and other objects' views are - // excluded. Runtime `shared` / `personal` views - // (sys_view_definition) are merged client-side via the - // generic data API. - if (RestServer.metaTypeSingular(req.params.type) === 'view' && req.query?.object) { - const obj = String(req.query.object); - const raw = visible as unknown; - const list: any[] | null = Array.isArray(raw) - ? (raw as any[]) - : (raw && typeof raw === 'object' && Array.isArray((raw as any).items)) - ? ((raw as any).items as any[]) - : null; - if (list) { - const filtered = list - .filter((v: any) => v && typeof v === 'object' && v.viewKind && v.object === obj) - .sort((a: any, b: any) => - ((a.order ?? 0) as number) - ((b.order ?? 0) as number) || - String(a.name).localeCompare(String(b.name))); - visible = Array.isArray(raw) ? filtered : { ...(raw as any), items: filtered }; - } - } - - // ADR-0046 i18n: collapse each doc to the request - // locale (localized label/description, `translations` - // map dropped) before the content-strip step below. - if (RestServer.metaTypeSingular(req.params.type) === 'doc') { - const locale = this.extractLocale(req); - const { resolveDocLocale } = await import('@objectstack/spec/system'); - const raw = visible as unknown; - const list: any[] | null = Array.isArray(raw) - ? (raw as any[]) - : (raw && typeof raw === 'object' && Array.isArray((raw as any).items)) - ? ((raw as any).items as any[]) - : null; - if (list) { - const localized = list.map((it: any) => - it && typeof it === 'object' ? resolveDocLocale(it as any, locale) : it); - visible = Array.isArray(raw) ? localized : { ...(raw as any), items: localized }; - } - } - - // ADR-0046: `doc` list responses omit `content` by - // default — manuals are the one metadata payload that - // grows unbounded, and the list surface only needs - // name + label. `?include=content` opts back in; the - // single-item GET /meta/doc/:name always returns the - // full body. - if (RestServer.metaTypeSingular(req.params.type) === 'doc' && req.query?.include !== 'content') { - const raw = visible as unknown; - const list: any[] | null = Array.isArray(raw) - ? (raw as any[]) - : (raw && typeof raw === 'object' && Array.isArray((raw as any).items)) - ? ((raw as any).items as any[]) - : null; - if (list) { - const slim = list.map((it: any) => { - if (!it || typeof it !== 'object') return it; - const { content: _content, ...rest } = it; - return rest; - }); - visible = Array.isArray(raw) ? slim : { ...(raw as any), items: slim }; - } - } - - // [ADR-0106 D5(2)] The list read — each item projected - // the same way, through the same masker. The posture is - // per OBJECT (one caller may read every field of `lead` - // and half of `account`), so the masker is resolved once - // and asked per item. - { - const listMetaType = RestServer.metaTypeSingular(req.params.type); - if (listMetaType === 'object') { - const raw = visible as unknown; - const list = RestServer.metaItemsArray(raw); - if (list.length > 0) { - const masker = await this.resolveObjectMasker(environmentId, req, listMetaType); - 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) { - if (maskError instanceof ObjectSchemaMaskEvaluationError) { - // D6 tier 3 — one unevaluable - // object fails the whole list - // rather than serving it with a - // silent hole in the projection. - sendFieldVisibilityFault(res, objectName); - return; - } - throw maskError; - } - if (posture.kind === 'undetermined') undetermined = true; - const masked = this.maskObjectDocument(res, posture, objectName, item); - if (!masked) return; - projected.push(masked.document); - } - if (undetermined) res.header('Cache-Control', 'private, no-store'); - visible = Array.isArray(raw) ? projected : { ...(raw as any), items: projected }; - } - } + // `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), + { metaType: RestServer.metaTypeSingular(req.params.type), query: req.query, previewDrafts }, + )(items); + if (!answer.ok) { + sendFieldVisibilityFault(res, answer.object); + return; } - - const translated = await this.translateMetaItems(req, req.params.type, environmentId, visible); res.header('Vary', 'Accept-Language'); - res.json(translated); + res.json(answer.data); } catch (error: any) { handleRouteError(res, error); } @@ -6814,11 +6575,15 @@ export class RestServer { // [#9741] Typed request — `as any` retired. The // cached read carries NO draft-visibility members - // on purpose: this branch is unreachable when - // `previewDrafts` / `?state=draft` are set (the - // fork above bypasses the cache for both), and the - // implementation's `getMetaItemCached` signature - // declares neither. + // on purpose: this branch is unreachable when a + // draft switch is ADMITTED (`previewDrafts` / + // `isDraftRead` true — the fork above bypasses the + // cache for both), and the implementation's + // `getMetaItemCached` signature declares neither. + // [#20320] The query PARAMETER still reaches here: + // a caller `mayReadPendingDrafts` does not admit + // sends `?state=draft` / `?preview=draft` and is + // answered this arm, the plain read's (#20338). const cachedRequest: TransportScopedMetaRequest = { type: req.params.type, name: req.params.name, 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 c0127faf044..c5634e2b1ec 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 @@ -291,6 +291,8 @@ describe('[#20338] census: every draft switch in domains/meta.ts is ledgered, an 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' }, + // [#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. { site: 'protocol.listDrafts', count: 2, disposition: 'listing' }, ]; diff --git a/packages/runtime/src/domains/meta-item-read-gate-parity.test.ts b/packages/runtime/src/domains/meta-item-read-gate-parity.test.ts index f97823acfe3..c49450c09e2 100644 --- a/packages/runtime/src/domains/meta-item-read-gate-parity.test.ts +++ b/packages/runtime/src/domains/meta-item-read-gate-parity.test.ts @@ -343,14 +343,21 @@ describe('[#20193] controls', () => { expect(fieldNames(holder.served)).toEqual(['amount', 'secret_margin']); }); - it('an unauthenticated caller keeps its existing answer: 401 UNAUTHENTICATED, before any read', async () => { + it('an unauthenticated caller keeps its existing answer: 401 UNAUTHENTICATED — before any read for every type but doc and book', async () => { const { read, protocol } = bootDispatcher('anonymous'); - const res = await read('/meta/doc/crm_admin_runbook'); + const res = await read('/meta/app/crm'); expect({ status: res.status, code: res.code }).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); expect(protocol.getMetaItem).not.toHaveBeenCalled(); expect(protocol.getMetaItems).not.toHaveBeenCalled(); }); + it('[#20320] …and for a gated doc, from the ADR-0046 §6.7 audience gate, as on RestServer: reachability is not authorization', async () => { + const { read } = bootDispatcher('anonymous'); + const res = await read('/meta/doc/crm_admin_runbook'); + expect({ status: res.status, code: res.code }).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); + expect(text(res)).not.toContain(DOC_SECRET); + }); + it('the plural spelling is the same read, gated the same way', async () => { const res = await bootDispatcher('non-holder').read('/meta/docs/crm_admin_runbook'); expect({ status: res.status, code: res.code }).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); diff --git a/packages/runtime/src/domains/meta-list-projection-parity.test.ts b/packages/runtime/src/domains/meta-list-projection-parity.test.ts new file mode 100644 index 00000000000..6b76efb455e --- /dev/null +++ b/packages/runtime/src/domains/meta-list-projection-parity.test.ts @@ -0,0 +1,656 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20320] The dispatcher's `/meta` reads answer what `RestServer` answers, off + * the per-caller gate path too. This file is the family's closure census. + * + * ## The defects it pins + * + * #20193 and #20237 put the dispatcher's item and list reads through the + * per-caller gates `RestServer` asks. Three kinds of divergence were left: + * + * - **Row A, the list projections.** `RestServer`'s `GET /meta/:type` runs a + * fixed chain after the gate: the `api` served-set face, `?id=` for apps, + * `?object=` for views, the doc locale collapse, the doc content slim and + * the translation step. The dispatcher's list branch ran only the slim, and + * compared the raw segment for it, so `/meta/docs` served doc bodies. + * - **Row B, the `public` audience.** `handleMetadataRequest` opened with the + * anonymous deny, so an anonymous read of a `public` book or doc answered + * `401` where `RestServer` serves it (ADR-0046 §6.7). + * - **The `?state=draft` row.** The dispatcher's item read never read + * `?state=`, so an admitted builder was answered the active item, never the + * pending draft and never `404 NO_DRAFT`. + * + * Measured through `dispatch()` (the `createHonoApp` catch-all's delegate) + * against `RestServer` on this file's fixtures, before the fix: every row A + * projection cell, every anonymous `public` book and doc cell, and every + * admitted `?state=draft` cell was red. The numbers are in the PR. + * + * ## Why the census is derived, not written down + * + * Both transports now hand their list to ONE function + * (`createMetaListAnswer` in `@objectstack/rest`'s `meta-item-read-gate.ts`), + * so a projection added there reaches both. The drift left to catch is a + * projection added to `RestServer`'s list handler itself, or a parameter the + * shared chain starts reading that this census never sends. So the census axes + * are checked against the SOURCE: every query parameter and every type literal + * the list handler and the shared chain read must be a cell here, and every + * translatable type is a cell (`TRANSLATABLE_METADATA_TYPES`, the set the + * translation step asks). A new projection that reads a new parameter or keys + * on a new type reddens the derivation block until it is a cell, and the cell + * reddens until the dispatcher answers it. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { RestServer } from '@objectstack/rest'; +import { TRANSLATABLE_METADATA_TYPES } from '@objectstack/spec/system'; +import { pluralToSingular } from '@objectstack/spec/shared'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const DOC_SECRET = 'Rotate the tenant signing keys before every release.'; + +const ADMIN_GUIDE = { + name: 'admin_guide', + label: 'Admin Guide', + audience: { permissionSet: 'crm_admin' }, + _packageId: 'crm', + groups: [{ key: 'admin', label: 'Admin', include: 'crm_admin_*' }], +}; +const HELP_CENTER = { + name: 'help_center', + label: 'Help Centre', + audience: 'org', + _packageId: 'crm', + groups: [{ key: 'start', label: 'Start', include: 'crm_intro' }], +}; +const PUBLIC_GUIDE = { + name: 'public_guide', + label: 'Public Guide', + audience: 'public', + _packageId: 'crm', + groups: [{ key: 'faq', label: 'FAQ', include: 'public_*' }], +}; +const DOCS = [ + { + name: 'crm_intro', label: 'Getting started', description: 'Start here', content: 'Welcome aboard.', _packageId: 'crm', + translations: { 'zh-CN': { label: '入门', description: '从这里开始', content: '欢迎。' } }, + }, + { name: 'crm_admin_runbook', label: 'Admin runbook', content: DOC_SECRET, _packageId: 'crm' }, + { + name: 'public_faq', label: 'FAQ', content: 'Questions and answers.', _packageId: 'crm', + translations: { 'zh-CN': { label: '常见问题', content: '问答。' } }, + }, +]; +const CRM_APP = { + name: 'crm', + label: 'CRM', + _packageId: 'crm', + navigation: [ + { id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' }, + { id: 'nav_finance_ledger', type: 'page', label: 'Ledger', pageName: 'ledger', requiredPermissions: ['finance.access'] }, + { id: 'nav_admin_runbook', type: 'doc', label: 'Admin runbook', doc: 'crm_admin_runbook' }, + // Bound to an optional service this deployment does not register. + { id: 'nav_org_directory', type: 'page', label: 'Organizations', pageName: 'orgs', requiresService: 'tenancy' }, + ], +}; +const PAYROLL_APP = { + name: 'payroll', + label: 'Payroll', + requiredPermissions: ['payroll.access'], + navigation: [{ id: 'nav_payroll_runs', type: 'page', label: 'Runs', pageName: 'payroll_runs' }], +}; +const LAUNCHPAD_APP = { + name: 'launchpad', + label: 'Launchpad', + _unpublished: true, + navigation: [{ id: 'nav_launchpad_home', type: 'page', label: 'Home', pageName: 'launchpad_home' }], +}; +/** No pending draft: the `NO_DRAFT` row. */ +const HELPDESK_APP = { + name: 'helpdesk', + label: 'Helpdesk', + navigation: [{ id: 'nav_tickets', type: 'page', label: 'Tickets', pageName: 'tickets' }], +}; +const VIEWS = [ + { name: 'lead_all', label: 'All leads', object: 'lead', viewKind: 'list', order: 2 }, + { name: 'lead_mine', label: 'My leads', object: 'lead', viewKind: 'list', order: 1 }, + { name: 'account_all', label: 'All accounts', object: 'account', viewKind: 'list' }, + // The aggregated container: no `viewKind`, so the switcher leaves it out. + { name: 'lead', label: 'Lead views', object: 'lead' }, +]; +const API_ITEMS = [ + { name: 'crm_served', method: 'GET', path: '/crm/served' }, + // Declared and stored, never served: the matcher resolves it to nothing. + { name: 'crm_unserved', method: 'GET', path: '/crm/unserved' }, +]; +const INVOICE_OBJECT = { + name: 'invoice', + label: 'Invoice', + fields: { + amount: { type: 'number', label: 'Amount' }, + secret_margin: { type: 'number', label: 'Margin' }, + }, +}; +const OPS_DASHBOARD = { + name: 'ops', + label: 'Operations', + widgets: [ + { id: 'w_open_cases', type: 'metric', label: 'Open cases' }, + { id: 'w_org_kpi', type: 'metric', label: 'Organizations', requiresService: 'tenancy' }, + ], +}; + +const STORE: Record = { + book: [ADMIN_GUIDE, HELP_CENTER, PUBLIC_GUIDE], + doc: DOCS, + app: [CRM_APP, PAYROLL_APP, LAUNCHPAD_APP, HELPDESK_APP], + view: VIEWS, + api: API_ITEMS, + object: [INVOICE_OBJECT], + dashboard: [OPS_DASHBOARD], + page: [{ name: 'home', label: 'Home', type: 'app' }], + action: [{ name: 'close_case', label: 'Close case', objectName: 'case', type: 'script' }], + dataset: [{ name: 'pipeline', label: 'Pipeline', object: 'opportunity' }], + flow: [{ name: 'on_lead', label: 'On lead', type: 'autolaunched' }], +}; + +/** Pending drafts (ADR-0033): relabelled, plus an entry that exists only in the draft. */ +const DRAFTS: Record = { + app: [ + { + ...CRM_APP, + label: 'CRM (draft)', + navigation: [ + ...CRM_APP.navigation, + { id: 'nav_finance_forecast', type: 'page', label: 'Forecast', pageName: 'forecast', requiredPermissions: ['finance.access'] }, + ], + }, + // Never published. + { name: 'beacon', label: 'Beacon', navigation: [{ id: 'nav_beacon_home', type: 'page', label: 'Home', pageName: 'beacon_home' }] }, + ], + object: [{ + ...INVOICE_OBJECT, + label: 'Invoice (draft)', + fields: { ...INVOICE_OBJECT.fields, due_on: { type: 'date', label: 'Due on' } }, + }], + api: [{ name: 'crm_pending', method: 'GET', path: '/crm/pending' }], +}; + +const BUNDLE: Record = { + 'zh-CN': { + apps: { crm: { label: '客户管理' }, helpdesk: { label: '服务台' } }, + objects: { invoice: { label: '发票' } }, + pages: { home: { label: '首页' } }, + }, +}; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); +const singular = (type: unknown): string => pluralToSingular(String(type ?? '')); + +// ── Callers ─────────────────────────────────────────────────────────────────── + +interface Caller { + ctx: { userId?: string; isSystem: false; systemPermissions: string[] }; + holdings: string[]; + readableFields: string[]; +} +const CALLERS = { + holder: { + ctx: { userId: 'u_holder', isSystem: false, systemPermissions: ['finance.access', 'payroll.access'] }, + holdings: ['crm_admin'], + readableFields: ['amount', 'secret_margin'], + }, + 'non-holder': { + ctx: { userId: 'u_member', isSystem: false, systemPermissions: [] }, + holdings: [], + readableFields: ['amount'], + }, + /** May read drafts (`studio.access`), may not save an app. */ + builder: { + ctx: { userId: 'u_builder', isSystem: false, systemPermissions: ['studio.access'] }, + holdings: [], + readableFields: ['amount'], + }, + /** May save an app (`manage_metadata`), holds no entry permission. */ + author: { + ctx: { userId: 'u_author', isSystem: false, systemPermissions: ['manage_metadata'] }, + holdings: [], + readableFields: ['amount'], + }, + anonymous: { ctx: { isSystem: false, systemPermissions: [] }, holdings: [], readableFields: [] }, +} satisfies Record; +type CallerName = keyof typeof CALLERS; +const LIST_CALLERS = ['holder', 'non-holder', 'builder', 'anonymous'] as const satisfies readonly CallerName[]; + +// ── The services both transports read ───────────────────────────────────────── + +/** The protocol's read rules: `?package=` scopes, `previewDrafts` overlays, `state: 'draft'` reads the pending row or throws `NO_DRAFT`. */ +function protocolDouble() { + return { + getMetaTypes: vi.fn(async () => ({ types: Object.keys(STORE) })), + getMetaItems: vi.fn(async ({ type, packageId, previewDrafts }: any) => { + const t = singular(type); + const items = new Map((STORE[t] ?? []).map((i) => [i.name, clone(i)])); + if (previewDrafts) for (const d of DRAFTS[t] ?? []) items.set(d.name, { ...clone(d), _draft: true }); + const listed = [...items.values()]; + return packageId ? listed.filter((i) => i._packageId === packageId) : listed; + }), + getMetaItem: vi.fn(async ({ type, name, state, previewDrafts }: any) => { + const t = singular(type); + const draft = (DRAFTS[t] ?? []).find((i) => i.name === name); + if (state === 'draft') { + if (!draft) { + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + } + return { type: t, name, item: clone(draft) }; + } + const active = (STORE[t] ?? []).find((i) => i.name === name); + const item = previewDrafts && draft ? { ...clone(draft), _draft: true } : (active ? clone(active) : undefined); + return { type: t, name, item }; + }), + }; +} + +const securityFor = (caller: Caller) => ({ + resolvePermissionSetNames: async () => caller.holdings, + getMetadataReadableFields: async () => caller.readableFields, +}); + +const i18nDouble = { + getLocales: () => Object.keys(BUNDLE), + getTranslations: (locale: string) => BUNDLE[locale], + getDefaultLocale: () => 'en', +}; + +/** The endpoint matcher (#5224): it serves `crm_served` and nothing else. */ +const endpointMatcher = { + matchEndpoint: async ({ path, method }: { path: string; method: string }) => + (path === '/crm/served' && method === 'GET' ? { endpoint: { name: 'crm_served', method, path } } as any : undefined), +}; + +// ── The two transports ──────────────────────────────────────────────────────── + +interface Answer { status: number; code?: string; body: any; items?: any[]; item?: any; vary?: string } + +const itemsOf = (data: any): any[] | undefined => + Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : undefined; + +interface Boot { withI18n?: boolean } + +/** The dispatcher, exactly as `createHonoApp` builds it: `new HttpDispatcher(kernel)`. */ +function bootDispatcher(callerName: CallerName, opts: Boot = {}) { + const caller: Caller = CALLERS[callerName]; + const protocol = protocolDouble(); + const services: Record = { + protocol, + security: securityFor(caller), + metadata: endpointMatcher, + ...(opts.withI18n === false ? {} : { i18n: i18nDouble }), + }; + const get = (n: string) => services[n] ?? null; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + // The seam `dispatch()` resolves identity through (as the sibling dispatch-level pins do). + (dispatcher as any).timedResolveExecutionContext = async () => clone(caller.ctx); + const read = async (path: string, query: Record = {}, headers: Record = {}): Promise => { + const res = await dispatcher.dispatch('GET', path, undefined, query, { request: { headers } } as any); + const status = res.response?.status ?? 0; + const body = res.response?.body; + const vary = (res.response as any)?.headers?.Vary; + return { + status, code: body?.error?.code, body, vary, + items: status === 200 ? itemsOf(body?.data) : undefined, + item: status === 200 ? body?.data?.item : undefined, + }; + }; + return { read, protocol }; +} + +function createMockServer() { + return { + 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), + }; +} + +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.send = vi.fn(() => 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(); + return res; +} + +/** `RestServer` over the same services and the same caller — the reference answer. */ +function bootRest(callerName: CallerName, opts: Boot = {}) { + const caller: Caller = CALLERS[callerName]; + const protocol = protocolDouble(); + const rest: any = new RestServer(createMockServer() as any, protocol as any, {} as any); + if (caller.ctx.userId) { + const ctx = caller.ctx; + rest.resolveExecCtx = async () => clone(ctx); + } + rest.securityServiceProvider = async () => securityFor(caller); + rest.serviceExistsProvider = (name: string) => name !== 'tenancy'; + rest.metadataServiceProvider = async () => endpointMatcher; + if (opts.withI18n !== false) rest.i18nServiceProvider = async () => i18nDouble; + rest.registerRoutes(); + const META = '/api/v1/meta'; + const routeOf = (path: string) => { + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === path); + if (!route) throw new Error(`GET ${path} is not registered`); + return route; + }; + const drive = async (routePath: string, path: string, params: Record, query: Record, headers: Record): Promise => { + const res = makeRes(); + await routeOf(routePath).handler({ method: 'GET', path: `${META}${path}`, params, query, body: {}, headers }, res); + const status = res.statusCode; + return { + status, code: res.body?.code ?? res.body?.error?.code, body: res.body, vary: res.headers.Vary, + 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}`); + return name === undefined + ? drive(`${META}/:type`, path.replace(/^\/meta/, ''), { type }, query, headers) + : drive(`${META}/:type/:name`, path.replace(/^\/meta/, ''), { type, name }, query, headers); + }; + return { read, protocol }; +} + +const text = (a: Answer): string => JSON.stringify(a.body ?? null); +const names = (items: any[] | undefined): string[] => (items ?? []).map((i: any) => i?.name); +const brief = (a: Answer) => ({ + status: a.status, + code: a.code, + items: a.items && JSON.stringify(a.items).slice(0, 400), +}); + +// ── The census axes ─────────────────────────────────────────────────────────── + +/** + * One probe per query parameter the list reads (plus the `Accept-Language` + * header the locale is read from first). Each probe is chosen to MOVE the + * answer for at least one type: a parameter whose probe changes nothing proves + * nothing. + */ +const PARAM_PROBES: ReadonlyArray<{ label: string; param?: string; query: Record; headers?: Record }> = [ + { label: '(none)', query: {} }, + { label: '?id=crm', param: 'id', query: { id: 'crm' } }, + { label: '?id=nope', param: 'id', query: { id: 'nope' } }, + { label: '?object=lead', param: 'object', query: { object: 'lead' } }, + { label: '?object=nope', param: 'object', query: { object: 'nope' } }, + { label: '?include=content', param: 'include', query: { include: 'content' } }, + { label: '?locale=zh-CN', param: 'locale', query: { locale: 'zh-CN' } }, + { 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' } }, +]; +const PARAM_AXIS = new Set(PARAM_PROBES.map((p) => p.param).filter((p): p is string => !!p)); + +/** Every type a projection or a gate keys on, both spellings, plus every translatable type and an untouched control (`flow`). */ +const TYPE_CELLS: readonly string[] = [ + 'app', 'apps', 'view', 'views', 'doc', 'docs', 'book', 'books', 'api', 'apis', + 'object', 'objects', 'dashboard', 'dashboards', 'page', 'action', 'dataset', 'flow', +]; +const TYPE_AXIS = new Set(TYPE_CELLS.map(singular)); + +// ── The derivation: the axes are checked against the source ─────────────────── + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REST_SERVER_SOURCE = resolve(HERE, '../../../rest/src/rest-server.ts'); +const SHARED_CHAIN_SOURCE = resolve(HERE, '../../../rest/src/meta-item-read-gate.ts'); + +interface Derived { params: Set; types: Set; found: boolean } + +function parse(file: string): ts.SourceFile { + return ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); +} + +/** Query members read, and type literals compared, anywhere under `root`. */ +function readsUnder(sf: ts.SourceFile, root: ts.Node, into: Derived): void { + const walk = (n: ts.Node): void => { + // `req.query?.id`, `req.query.object`, `query?.include`, `http.query?.locale` … + if (ts.isPropertyAccessExpression(n) && /(^|\.)query$/.test(n.expression.getText(sf).replace(/\?/g, ''))) { + into.params.add(n.name.text); + } + // `refuseRepeatedQueryParams(req, res, ['package', …])` — the handler's declared set. + if (ts.isCallExpression(n) && n.expression.getText(sf) === 'refuseRepeatedQueryParams') { + for (const arg of n.arguments) { + if (ts.isArrayLiteralExpression(arg)) { + for (const el of arg.elements) if (ts.isStringLiteral(el)) into.params.add(el.text); + } + } + } + // `extractLocale(req …)` reads `?locale=` a frame down. + if (ts.isCallExpression(n) && /(^|\.)extractLocale$/.test(n.expression.getText(sf))) into.params.add('locale'); + // `metaTypeSingular(req.params.type) === 'api'`, `metaType === 'doc'` … + if (ts.isBinaryExpression(n) + && [ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken].includes(n.operatorToken.kind)) { + const [lit, other] = ts.isStringLiteral(n.right) ? [n.right, n.left] : ts.isStringLiteral(n.left) ? [n.left, n.right] : []; + if (lit && other && /metaType|MetaType/.test(other.getText(sf))) into.types.add(lit.text); + } + ts.forEachChild(n, walk); + }; + walk(root); +} + +function propertyInitializer(sf: ts.SourceFile, literal: ts.ObjectLiteralExpression, name: string): ts.Expression | undefined { + for (const p of literal.properties) if (ts.isPropertyAssignment(p) && p.name.getText(sf) === name) return p.initializer; + return undefined; +} + +/** `RestServer`'s `GET ${metaPath}/:type` handler, and the shared list chain it hands its answer to. */ +function deriveListReads(): 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) === '`${metaPath}/:type`' && 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 && ['createMetaListAnswer', 'metaRequestLocale', 'translateMetaList'].includes(s.name.text)) { + readsUnder(chain, s, derived); + } + } + return derived; +} + +describe('[#20320] the census axes cover every read of RestServer\'s list handler and the shared chain', () => { + const derived = deriveListReads(); + + it('the handler is found where the census looks for it', () => { + expect(derived.found, `GET \${metaPath}/:type in ${REST_SERVER_SOURCE}`).toBe(true); + }); + + it('every query parameter read is a census cell', () => { + const missing = [...derived.params].filter((p) => !PARAM_AXIS.has(p)).sort(); + expect(missing, 'a list read of a parameter this census never sends: add a probe that moves its answer').toEqual([]); + }); + + it('every type a projection keys on, and every translatable type, is a census cell', () => { + const wanted = new Set([...derived.types, ...TRANSLATABLE_METADATA_TYPES]); + const missing = [...wanted].filter((t) => !TYPE_AXIS.has(t)).sort(); + expect(missing, 'a type the list chain treats specially that this census never lists: add a type cell (and fixtures)').toEqual([]); + }); +}); + +// ── Row A: the list projections, every type × parameter × caller ────────────── + +describe('[#20320] row A: the dispatcher lists what RestServer lists — every type × query parameter × caller', () => { + for (const type of TYPE_CELLS) { + for (const probe of PARAM_PROBES) { + it(`GET /meta/${type} ${probe.label}`, async () => { + const mismatches: unknown[] = []; + for (const who of LIST_CALLERS) { + const dispatcher = await bootDispatcher(who).read(`/meta/${type}`, probe.query, probe.headers); + const rest = await bootRest(who).read(`/meta/${type}`, probe.query, probe.headers); + 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 } }); + } + expect(mismatches).toEqual([]); + }); + } + } +}); + +describe('[#20320] row A: the reference moves — each probe changes RestServer\'s answer for the type it targets', () => { + it('?id= narrows the app list; ?object= switches views; the doc slim, locale and translation all move', async () => { + const { read } = bootRest('holder'); + expect(names((await read('/meta/app', { id: 'crm' })).items)).toEqual(['crm']); + expect((await read('/meta/app', { id: 'nope' })).items).toEqual([]); + expect(names((await read('/meta/view', { object: 'lead' })).items)).toEqual(['lead_mine', 'lead_all']); + expect((await read('/meta/views', { object: 'nope' })).items).toEqual([]); + const docs = await read('/meta/docs'); + expect(text(docs)).not.toContain('Welcome aboard.'); + expect(text(await read('/meta/docs', { include: 'content' }))).toContain(DOC_SECRET); + const zh = await read('/meta/doc', { locale: 'zh-CN' }); + expect(zh.items?.find((d: any) => d.name === 'crm_intro')?.label).toBe('入门'); + expect(text(zh)).not.toContain('"translations"'); + const apps = await read('/meta/apps', {}, { 'accept-language': 'zh-CN' }); + expect(apps.items?.find((a: any) => a.name === 'crm')?.label).toBe('客户管理'); + expect(names((await read('/meta/api')).items)).toEqual(['crm_served']); + expect(names((await read('/meta/apis')).items)).toEqual(['crm_served']); + }); +}); + +// ── Row B: the `public` audience, anonymously ───────────────────────────────── + +describe('[#20320] row B: an anonymous read of a public book or doc is served on both transports', () => { + for (const path of ['/meta/book', '/meta/books', '/meta/doc', '/meta/docs']) { + it(`GET ${path}: the public items, and only those`, async () => { + const dispatcher = await bootDispatcher('anonymous').read(path); + const rest = await bootRest('anonymous').read(path); + expect({ status: rest.status, items: names(rest.items) }).toEqual({ + status: 200, items: path.includes('book') ? ['public_guide'] : ['public_faq'], + }); + expect({ status: dispatcher.status, code: dispatcher.code }).toEqual({ status: rest.status, code: rest.code }); + expect(dispatcher.items).toEqual(rest.items); + expect(text(dispatcher)).not.toContain(DOC_SECRET); + }); + } + + for (const [path, status] of [ + ['/meta/book/public_guide', 200], + ['/meta/books/public_guide', 200], + ['/meta/doc/public_faq', 200], + ['/meta/docs/public_faq', 200], + // Reachability is not authorization: the §6.7 gate still refuses these. + ['/meta/book/help_center', 401], + ['/meta/doc/crm_admin_runbook', 401], + ] as const) { + it(`GET ${path}: ${status} on both`, async () => { + const dispatcher = await bootDispatcher('anonymous', { withI18n: false }).read(path); + const rest = await bootRest('anonymous', { withI18n: false }).read(path); + expect({ status: rest.status, code: rest.status === 200 ? undefined : rest.code }) + .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.) + 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); + }); + } + + it('control: every other type keeps the anonymous deny on both transports, before any read', async () => { + for (const path of ['/meta/object', '/meta/objects', '/meta/app', '/meta/view', '/meta/object/invoice', '/meta/app/crm']) { + const { read, protocol } = bootDispatcher('anonymous'); + const dispatcher = await read(path); + const rest = await bootRest('anonymous').read(path); + expect({ status: rest.status, code: rest.code }, path).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); + expect({ status: dispatcher.status, code: dispatcher.code }, path).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); + expect(protocol.getMetaItems, path).not.toHaveBeenCalled(); + expect(protocol.getMetaItem, path).not.toHaveBeenCalled(); + } + }); + + 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']) { + const { read, protocol } = bootDispatcher('anonymous'); + const res = await read(path); + expect({ status: res.status, code: res.code }, path).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); + expect(protocol.getMetaItem, path).not.toHaveBeenCalled(); + } + }); +}); + +// ── The `?state=draft` row, on the item read ────────────────────────────────── + +describe('[#20320] the ?state=draft row: the dispatcher\'s item read answers what RestServer\'s answers', () => { + const CELLS = [ + // The pending draft: whole for the author, pruned for the builder. + '/meta/app/crm', + '/meta/apps/crm', + // A draft-only app: the draft for both admitted callers. + '/meta/app/beacon', + // Nothing pending: `404 NO_DRAFT` for both admitted callers. + '/meta/app/helpdesk', + // An object's pending draft (the object branch). + '/meta/object/invoice', + ]; + + for (const path of CELLS) { + for (const who of ['builder', 'author'] as const) { + it(`GET ${path}?state=draft × ${who}`, async () => { + const dispatcher = await bootDispatcher(who, { withI18n: false }).read(path, { state: 'draft' }); + const rest = await bootRest(who, { withI18n: false }).read(path, { state: 'draft' }); + expect({ status: dispatcher.status, code: dispatcher.code }).toEqual({ status: rest.status, code: rest.code }); + expect(dispatcher.item).toEqual(rest.item); + }); + } + } + + it('the reference: whole for the author, pruned per caller for the builder, NO_DRAFT where nothing is pending', async () => { + const nav = (a: Answer) => (a.item?.navigation ?? []).map((e: any) => e.id); + const author = await bootRest('author', { withI18n: false }).read('/meta/app/crm', { state: 'draft' }); + const builder = await bootRest('builder', { withI18n: false }).read('/meta/app/crm', { state: 'draft' }); + expect(author.item?.label).toBe('CRM (draft)'); + expect(nav(author)).toEqual(['nav_leads', 'nav_finance_ledger', 'nav_admin_runbook', 'nav_org_directory', 'nav_finance_forecast']); + expect(builder.item?.label).toBe('CRM (draft)'); + // Per caller only: the finance entries and the gated doc go; the service-bound entry stays (a stored version). + expect(nav(builder)).toEqual(['nav_leads', 'nav_org_directory']); + const none = await bootRest('builder', { withI18n: false }).read('/meta/app/helpdesk', { state: 'draft' }); + expect({ status: none.status, code: none.code }).toEqual({ status: 404, code: 'NO_DRAFT' }); + }); + + for (const path of ['/meta/app/crm', '/meta/app/beacon', '/meta/app/helpdesk', '/meta/object/invoice']) { + it(`a caller the draft doors do not admit is answered the plain read, byte for byte, on each transport: GET ${path}?state=draft × non-holder`, async () => { + for (const boot of [bootDispatcher, bootRest]) { + const { read, protocol } = boot('non-holder', { withI18n: false }); + const plain = await read(path); + const res = await read(path, { state: 'draft' }); + expect({ status: res.status, body: res.body }).toEqual({ status: plain.status, body: plain.body }); + expect(text(res)).not.toContain('(draft)'); + expect(protocol.getMetaItem.mock.calls.map(([r]: any[]) => r).filter((r: any) => r?.state === 'draft')).toEqual([]); + } + }); + } +}); diff --git a/packages/runtime/src/domains/meta-list-read-gate-parity.test.ts b/packages/runtime/src/domains/meta-list-read-gate-parity.test.ts index 16addbbc6e8..97923bcbf44 100644 --- a/packages/runtime/src/domains/meta-list-read-gate-parity.test.ts +++ b/packages/runtime/src/domains/meta-list-read-gate-parity.test.ts @@ -325,8 +325,8 @@ describe('[#20237] controls', () => { ]); }); - it('an unauthenticated caller keeps its existing answer on every list: 401 UNAUTHENTICATED, before any read', async () => { - for (const row of ROWS) { + it('an unauthenticated caller keeps its existing answer on every list but doc and book: 401 UNAUTHENTICATED, before any read', async () => { + for (const row of ROWS.filter((r) => !['doc', 'book'].includes(singular(r.type)))) { const { list, protocol } = bootDispatcher('anonymous'); const res = await list(row.type, row.query); expect({ status: res.status, code: res.code }, label(row)).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); @@ -335,13 +335,13 @@ describe('[#20237] controls', () => { } }); - it('…which is STRICTER than RestServer for doc and book lists, where an anonymous caller reads the public ones (none here) — and neither transport serves gated content', async () => { - for (const type of ['doc', 'book']) { - const rest = await bootRest('anonymous').list(type, { include: 'content' }); - expect(rest.status, type).toBe(200); - expect(rest.items, type).toEqual([]); - expect(text(rest), type).not.toContain(DOC_SECRET); - expect(text(rest), type).not.toContain(BOOK_SECRET); + it('[#20320] …and on doc and book lists answers what RestServer answers: the public ones (none here), never gated content', async () => { + for (const row of ROWS.filter((r) => ['doc', 'book'].includes(singular(r.type)))) { + const dispatcher = await bootDispatcher('anonymous').list(row.type, row.query); + const rest = await bootRest('anonymous').list(row.type, row.query); + expect({ status: rest.status, items: rest.items }, label(row)).toEqual({ status: 200, items: [] }); + expect({ status: dispatcher.status, items: dispatcher.items }, label(row)).toEqual({ status: 200, items: [] }); + for (const s of row.secrets) expect(text(dispatcher), label(row)).not.toContain(s); } }); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 14f515580af..c6336ff8d96 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -46,10 +46,20 @@ import type { MetadataProtocol } from '@objectstack/spec/api'; // `RestServer` asks, published by `@objectstack/rest` so this transport asks it // too instead of a second audience resolver (ruling `5793362670` item 1). // [#20237] …and its LIST twin, for `/meta/:type`, on the same ports. +// [#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. +// Imported, never restated — AGENTS.md 〈Route & surface ownership〉 rule 1. import { createMetaItemReadGate, - createMetaListReadGate, + createMetaListAnswer, + isPublicAudienceRead, + metaRequestLocale, + STORED_VERSION_DOOR_POLICY, + translateMetaList, type MetaItemReadGateSources, + type MetaListAnswerSources, + type MetaPublicReadRoute, type MetaReadGateCaller, type MetaReadGatePolicy, } from '@objectstack/rest'; @@ -150,8 +160,9 @@ async function resolveProtocol( /** * [#20338] May this caller read PENDING metadata (a `sys_metadata` row in - * `state: 'draft'`)? The one question this domain's three draft doors ask — - * `_drafts`, and the item and list reads' `?preview=draft` — delegated, never + * `state: 'draft'`)? The one question this domain's draft doors ask — + * `_drafts`, the item and list reads' `?preview=draft` and [#20320] the item + * read's `?state=draft` — delegated, never * restated, to the predicate `_drafts` has always asked * (`isObjectSchemaMaskExempt`: a system caller, `studio.access`, `setup.access` * or `manage_metadata`). `RestServer` asks the same predicate through its own @@ -188,22 +199,35 @@ export function createMetaDomain(deps: DomainHandlerDeps): DomainRoute { } /** - * ADR-0046: `doc` list responses omit `content` by default — manuals - * are the one metadata payload that grows unbounded, and the list - * surface only needs `name` + `label`. `?include=content` opts back in - * (single-item GET /metadata/doc/:name always returns the full body). + * [#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. */ -function slimDocList(type: string, data: any, query?: Record): any { - if (type !== 'doc' || query?.include === 'content') return data; - const strip = (items: any[]) => - items.map((i) => { - if (!i || typeof i !== 'object') return i; - const { content: _content, ...rest } = i as Record; - return rest; - }); - if (Array.isArray(data)) return strip(data); - if (data && Array.isArray(data.items)) return { ...data, items: strip(data.items) }; - return data; +function metaReadRouteOf(parts: readonly string[]): MetaPublicReadRoute | undefined { + if (parts.length === 1) return 'list'; + if (parts.length === 2) return 'item'; + return undefined; +} + +/** + * [#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 + * route that answers 404. `error`, as `RestServer`'s twin notice: the + * consequence is a contract face that may lie, and the remedy is wiring. + */ +let warnedMissingEndpointMatcher = false; +function notifyMissingEndpointMatcher(surface: string): void { + if (warnedMissingEndpointMatcher) return; + warnedMissingEndpointMatcher = true; + (globalThis as any).console?.error?.( + `[HttpDispatcher] no endpoint matcher is reachable (no \`metadata\` service with \`matchEndpoint\`), so ${surface} ` + + 'cannot narrow declared `api` items to the ones this runtime serves. It is listing what is STORED instead, ' + + 'which may advertise routes that answer 404. Register a metadata service that implements `matchEndpoint` ' + + 'in the kernel this dispatcher serves to restore the guarantee.', + ); } /** @@ -277,34 +301,26 @@ async function maskObjectSchema( } /** - * [ADR-0106 D5(2)] Project every object schema in a list answer. - * - * A no-op for any type but `object`/`objects`, and shape-preserving for both - * list shapes the dispatcher hands around (a bare array and an - * `{ type, items }` envelope). One unevaluable object fails the whole list — - * serving the rest would leave a hole in the projection that no client can see. + * [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 maskObjectSchemaList( +async function maskObjectItems( deps: DomainHandlerDeps, context: HttpProtocolContext, - typeOrName: string, - data: any, -): Promise<{ ok: true; data: any } | { ok: false; object: string }> { - if (pluralToSingular(typeOrName) !== 'object') return { ok: true, data }; - const list: any[] | null = Array.isArray(data) - ? data - : (data && Array.isArray(data.items) ? data.items : null); - if (!list || list.length === 0) return { ok: true, data }; - + items: any[], +): Promise<{ ok: true; items: any[] } | { ok: false; object: string }> { const masker = await resolveObjectMasker(deps, context); const projected: any[] = []; - for (const item of list) { + 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, data: Array.isArray(data) ? projected : { ...data, items: projected } }; + return { ok: true, items: projected }; } /** @@ -339,14 +355,23 @@ const NAV_PRUNE_LOGGED = new Set(); * (fail closed, ADR-0049), never as an empty one; * - the ADR-0057 D10 service probe is `resolveService`, the capability probe * whose collapsed `undefined` is exactly "not registered here". + * + * [#20320] `mayWriteItem` — handed in only by a door whose policy honours the + * author exemption (the `?state=draft` read): this transport's own save-door + * answer (`saveVerdict` in {@link handleMetadataRequest}), carried on a COPY of + * the context — the same object serves every other consumer of this request. */ function metaItemReadGateSources( deps: DomainHandlerDeps, context: HttpProtocolContext, protocol: MetaDomainProtocol | undefined, + mayWriteItem?: boolean, ): MetaItemReadGateSources { return { - resolveCaller: async () => context.executionContext as MetaReadGateCaller | undefined, + resolveCaller: async () => { + const caller = context.executionContext as MetaReadGateCaller | undefined; + return mayWriteItem === undefined || !caller ? caller : { ...caller, mayWriteItem }; + }, resolveSecurityService: () => deps.resolveService(context, 'security'), listMetaItems: (type) => (typeof protocol?.getMetaItems === 'function' ? protocol.getMetaItems({ type }) : undefined), serviceProbe: () => async (name: string) => { @@ -385,6 +410,10 @@ 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, @@ -393,11 +422,13 @@ 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), metaType, name, [document], RENDERED_DOCUMENT_POLICY, + metaItemReadGateSources(deps, context, protocol, mayWriteItem), metaType, name, [document], policy, ); verdict = await judge(document); } catch (e: any) { @@ -410,42 +441,140 @@ async function gateMetaItemDocument( } /** - * [#20237] The per-caller steps of ONE list answer this transport is about to - * serve, in `RestServer`'s order: the shared per-caller LIST gate - * (`createMetaListReadGate`, the one `RestServer`'s `GET /meta/:type` calls — - * ⛔ no decision lives here), then the ADR-0106 object mask. + * [#20237 · #20320] Answer ONE list this transport is about to serve: hand the + * store's answer to THE list chain (`createMetaListAnswer`, the one + * `RestServer`'s `GET /meta/:type` calls — ⛔ no step lives here) and write + * its answer on this transport's wire. + * + * 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. + * 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. * * `data` is either list shape this branch hands around (a bare array or a * `{ type, items }` envelope), and the answer keeps that shape; `listType` is - * the folded singular type the gate is asked about, `typeOrName` the raw - * segment the mask folds for itself. A gate 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`. + * 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 + * 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`. */ -async function gateMetaListAnswer( +async function answerMetaList( deps: DomainHandlerDeps, context: HttpProtocolContext, protocol: MetaDomainProtocol | undefined, - typeOrName: string, listType: string, + query: any, + previewDrafts: boolean, data: any, -): Promise<{ ok: true; data: any } | { ok: false; result: HttpDispatcherResult }> { - const list: any[] | null = Array.isArray(data) ? data : (data && Array.isArray(data.items) ? data.items : null); +): Promise { + // The locale parse `RestServer.extractLocale` runs, over this request. + const requestLocale = (i18n?: unknown) => metaRequestLocale({ headers: context.request?.headers, query }, i18n); + const sources: MetaListAnswerSources = { + ...metaItemReadGateSources(deps, context, protocol), + resolveEndpointMatcher: async () => { + try { + return await deps.resolveService(context, 'metadata'); + } catch { + return undefined; + } + }, + notifyMissingEndpointMatcher, + maskObjects: (items) => maskObjectItems(deps, context, items), + requestLocale, + translateList: (metaType, items) => translateMetaList({ + resolveI18nService: async () => { + try { + return await deps.resolveService(context, 'i18n'); + } catch { + return undefined; + } + }, + resolveProtocol: async () => protocol, + requestLocale, + }, metaType, items), + }; try { - let gated = data; - if (list) { - const judged = await createMetaListReadGate(metaItemReadGateSources(deps, context, protocol), listType)(list); - if (judged !== list) gated = Array.isArray(data) ? judged : { ...data, items: judged }; - } - const projected = await maskObjectSchemaList(deps, context, typeOrName, gated); - if (!projected.ok) return { ok: false, result: fieldVisibilityFault(deps, projected.object) }; - return { ok: true, data: projected.data }; + 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' } }, + }; } catch (e: any) { - return { ok: false, result: { handled: true, response: deps.errorFromThrown(e, 500) } }; + return { handled: true, response: deps.errorFromThrown(e, 500) }; } } +/** [#20320] This transport's save-door admission of `:type/:name` — see `saveVerdict` in {@link handleMetadataRequest}. */ +type MetaSaveVerdict = ( + canonicalType: string, + activeOrganizationId: string | undefined, +) => ReturnType; + +/** + * [#20320] `GET /meta/:type/:name?state=draft` for a caller who may read + * drafts — the pending draft ROW (ADR-0033), answered exactly as `RestServer`'s + * plain read answers that caller: + * + * - the protocol's draft read (`state: 'draft'`), whatever the type — the + * `object` branch below included, and ⛔ never the `MetadataService` or + * registry fallbacks, which know only published values; + * - 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 + * `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. + * + * Only an admitted caller arrives: the switch is declared with + * {@link mayReadPendingDrafts} at the item read's entry. + */ +async function readPendingDraft( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + type: string, + name: string, + packageId: string | undefined, + previewDrafts: boolean, + saveVerdict: MetaSaveVerdict, +): 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); + let envelope: any; + try { + envelope = await protocol.getMetaItem({ + type: singularType, name, packageId, organizationId: activeOrganizationId, 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, + ); + 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 }) }; +} + /** * Percent-decode the `:name` path segment. [#12195] * @@ -487,21 +616,30 @@ function decodeMetaNameSegment(segment: string): string { * Fallback for backward compat: /metadata (all objects), /metadata/:objectName (get object) */ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: string, _context: HttpProtocolContext, method?: string, body?: any, query?: any): Promise { + const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); // Defense-in-depth: the metadata catch-all must honour the same // anonymous-deny (#2567) as the REST `/meta` routes (which serve `/meta` on // the cloud runtime). Object/field schemas — SYSTEM-object schemas on a // tenant-less host — must not be readable by anonymous callers when the // an anonymous, non-system caller. Unconditional since #3963. - { - const ec: any = _context.executionContext; - if (shouldDenyAnonymous({ userId: ec?.userId, isSystem: ec?.isSystem })) { - return { - handled: true, - response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), - }; - } - } - const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); + // + // [#20320] …with `RestServer`'s ONE exception, asked of the same + // predicate: an anonymous GET of this domain's book or doc LIST or ITEM + // read skips the deny, and the ADR-0046 §6.7 audience gate inside that read + // decides instead — `'public'` only; `org` and `{ permissionSet }` still + // refuse (401). This granted reachability on `RestServer` alone, so a + // `public` book answered 401 here. Every other type, every other verb and + // every other route shape (`/published`, `/state/:field`, an unrouted + // path) keeps the deny. + const anonymousDeny = (): HttpDispatcherResult => ({ + handled: true, + response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), + }); + const anonymous = shouldDenyAnonymous({ + userId: (_context.executionContext as any)?.userId, + isSystem: (_context.executionContext as any)?.isSystem, + }); + if (anonymous && !isPublicAudienceRead(method, metaReadRouteOf(parts), parts[0])) return anonymousDeny(); // GET /metadata/types if (parts[0] === 'types') { @@ -697,6 +835,27 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // Extract optional package filter from query string const packageId = query?.package || undefined; + // [#12702 · #20320] May this caller SAVE `:type/:name` on THIS + // transport? The admission of the `PUT` branch below, spelled ONCE: + // that branch asks it, and so does the `?state=draft` read's author + // exemption (ruling 5856774816, item 1: 「whoever can save it must see + // it whole, or a save drops entries silently」), which + // `MetaReadGateCaller.mayWriteItem` says must be the transport's own + // save-door answer. A second spelling at the read could drift from the + // door it stands for. `canonicalType` is the folded segment and + // `activeOrganizationId` the one resolution each caller already made, + // so authorization and scope read one value. + const saveVerdict: MetaSaveVerdict = (canonicalType, activeOrganizationId) => { + const ec: any = _context.executionContext; + return metaWriteCapabilityVerdict({ + isSystem: ec?.isSystem === true, + systemPermissions: ec?.systemPermissions, + canonicalType, + activeOrganizationId, + operation: 'save', + }); + }; + // PUT /metadata/:type/:name (Save) // // [#8842] The condition is the METHOD alone. It used to be @@ -756,18 +915,13 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // single-resolution shape the REST doors carry, #8919). Resolving // it is a session read, not a protocol probe: the 403-vs-501 // discipline above is untouched. - const ec: any = _context.executionContext; // [#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); - const verdict = metaWriteCapabilityVerdict({ - isSystem: ec?.isSystem === true, - systemPermissions: ec?.systemPermissions, - canonicalType, - activeOrganizationId, - operation: 'save', - }); + // [#20320] Spelled once (`saveVerdict` above), because the + // `?state=draft` read's author exemption asks this same question. + const verdict = saveVerdict(canonicalType, activeOrganizationId); if (!verdict.allowed) { // `deps.error(msg, 403)` derives the code from the status — // `PERMISSION_DENIED`, this transport's pinned spelling. @@ -966,7 +1120,28 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin }; } + // [#20338 · #20320] The item read's two draft switches, each declared + // WITH its admission ({@link mayReadPendingDrafts}), once, above every + // branch below — so no branch can re-derive a switch past the gate. A + // caller who may not read drafts is answered this read as if neither + // switch were present: the published item, pruned as the plain read + // prunes it, or its absence. + // + // `?state=draft` is the pending draft ROW (ADR-0033) — a STORED version, + // parsed exactly as `RestServer`'s plain read parses it. It went unread + // here, so an admitted builder on a dispatcher-only host was answered + // the ACTIVE item, never the draft and never `404 NO_DRAFT`. + const isDraftRead = typeof query?.state === 'string' + && query.state.toLowerCase() === 'draft' + && 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' + && mayReadPendingDrafts(_context.executionContext); + try { + if (isDraftRead) return await readPendingDraft(deps, _context, type, name, packageId, previewDrafts, saveVerdict); + // Try specific calls based on type if (type === 'objects' || type === 'object') { // Check whether the kernel is project-scoped. When it is, @@ -1079,13 +1254,8 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (protocol && typeof protocol.getMetaItem === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); - // ADR-0033 draft-overlay preview: `?preview=draft` makes the - // detail read prefer a pending draft (falling back to active). - // [#20338] Admitted per caller ({@link mayReadPendingDrafts}), - // in this declaration: a caller who may not read drafts - // reads the published item — this read without the switch. - const previewDrafts = query?.preview === 'draft' - && mayReadPendingDrafts(_context.executionContext); + // `previewDrafts` is the ADMITTED switch declared above + // this block's branches (#20338). const data = await protocol.getMetaItem({ type: singularType, name, packageId, organizationId, previewDrafts }); // [#18401] The SAME hit test the `object` branch above runs, // asked here for the same reason. `getMetaItem` answers a @@ -1272,26 +1442,35 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // exit, so a member `RestServer` prunes listed a `{ permissionSet }`- // gated doc here with its body (`?include=content`), a set-gated book, // and every app with its `requiredPermissions`-gated entries — the list - // twin of the item reads' defect. Every exit below that serves a list - // goes through `gateList`, OUTSIDE the lookups' own `try`s: those - // classify a type the store does not know, and a gate fault must be - // answered as itself — never fall through to the next store as though - // the first had not answered. Each exit keeps its own projection (the - // doc slim), exactly as before. + // twin of the item reads' defect. + // + // [#20320] …and it PROJECTS what that route projects: every exit below + // hands its store's answer to `answerList` — THE list chain + // `RestServer`'s route runs (`createMetaListAnswer`: the `api` + // served-set face, the gate, `?id=`, `?object=`, the doc locale collapse + // and slim, the mask, the translation). This branch ran a doc slim of + // its own that compared the RAW segment, and none of the rest. Every + // exit calls it OUTSIDE the lookups' own `try`s: those classify a type + // the store does not know, and a chain fault must be answered as itself + // — never fall through to the next store as though the first had not + // answered. const listType = pluralToSingular(typeOrName); - const gateList = (data: any) => gateMetaListAnswer(deps, _context, protocol, typeOrName, listType, data); + // 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' + && mayReadPendingDrafts(_context.executionContext); + const answerList = (data: any) => + answerMetaList(deps, _context, protocol, listType, query, previewDrafts, data); let listed: any; if (protocol && typeof protocol.getMetaItems === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); - // 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. - const previewDrafts = query?.preview === 'draft' - && mayReadPendingDrafts(_context.executionContext); 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; @@ -1299,13 +1478,9 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // Protocol doesn't know this type, fall through } } - if (listed !== undefined) { - // [ADR-0106 D5(2)] The dispatcher's list read is the same outlet as - // REST's `GET /meta/object`, reached by a different door. - const gated = await gateList(listed); - if (!gated.ok) return gated.result; - return { handled: true, response: deps.success(slimDocList(typeOrName, gated.data, query)) }; - } + // [ADR-0106 D5(2)] The dispatcher's list read is the same outlet as + // REST's `GET /meta/object`, reached by a different door. + if (listed !== undefined) return answerList(listed); // Try MetadataService directly for runtime-registered metadata (agents, tools, etc.) const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata); @@ -1325,11 +1500,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin const sanitizedType = String(typeOrName).replace(/[\r\n\t]/g, ''); console.debug(`[HttpDispatcher] MetadataService.list() failed for type:`, sanitizedType, 'error:', e.message); } - if (items && items.length > 0) { - const gated = await gateList({ type: typeOrName, items }); - if (!gated.ok) return gated.result; - return { handled: true, response: deps.success(slimDocList(typeOrName, gated.data, query)) }; - } + if (items && items.length > 0) return answerList({ type: typeOrName, items }); } // Try ObjectQL registry directly for object/type lookups @@ -1337,17 +1508,17 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (qlService?.registry) { if (typeOrName === 'objects') { const objs = qlService.registry.getAllObjects(packageId); - const gated = await gateList({ type: 'object', items: objs }); - if (!gated.ok) return gated.result; - return { handled: true, response: deps.success(gated.data) }; + return answerList({ type: 'object', items: objs }); } // Try listing items of the given type const items = qlService.registry.listItems?.(typeOrName, packageId); - if (items && items.length > 0) { - const gated = await gateList({ type: typeOrName, items }); - if (!gated.ok) return gated.result; - return { handled: true, response: deps.success(gated.data) }; - } + if (items && items.length > 0) return answerList({ type: typeOrName, items }); + // [#20320] The anonymous exemption above grants reachability of the + // book and doc LISTS, and this exit is not one: a one-segment path + // that names an object (`/meta/book` where an object is called + // `book`) serves an object SCHEMA. An anonymous caller never reaches + // a schema — the deny it skipped is answered here instead. + if (anonymous) return anonymousDeny(); // Legacy: treat as object name. [ADR-0106 D5(4)] A schema-bearing // exit reached by a one-segment path — masked like every other, so // the legacy spelling is not a way around the projection. diff --git a/packages/runtime/vitest.repo-tests.json b/packages/runtime/vitest.repo-tests.json index 5924bb313c4..2dcd0892944 100644 --- a/packages/runtime/vitest.repo-tests.json +++ b/packages/runtime/vitest.repo-tests.json @@ -1,4 +1,5 @@ [ "src/action-owner-key-single-source.test.ts", + "src/domains/meta-list-projection-parity.test.ts", "src/error-envelope.conformance.test.ts" ] diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index ed816069414..6ba259c2431 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -493,9 +493,15 @@ const DISPATCHER_DOMAINS = { // document while nothing was deleted. The restored guard answers 405, and it // must carry `Allow:` to name what is allowed to a machine, which // `deps.error` cannot express — the same reason `mcp.ts` hand-rolls its 405. + // + // [#20320] Was 1. The `/meta/:type` list answer now varies by + // `Accept-Language` — the list chain it shares with `RestServer` translates + // it and collapses each doc to the request's locale — so it must carry + // `Vary: Accept-Language`, as `RestServer`'s list does, and `deps.success` + // takes no headers. Its body is still `deps.success`'s envelope, spread. 'meta.ts': { - handBuilt: 1, - note: 'one 405 on /metadata/:type/:name that must carry an `Allow:` header (`deps.error` takes none); the body is the declared envelope and its code is derived from the status', + handBuilt: 2, + note: 'one 405 on /metadata/:type/:name that must carry an `Allow:` header (`deps.error` takes none), and the /meta/:type list answer that must carry `Vary: Accept-Language` (`deps.success` takes none); both bodies are the declared envelope, the 405 code derived from the status', }, // Kinds 1 and 2 together.