From 7f139f40a7afa6992926c0cb7e97a7da741ed250 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:13:42 +0000 Subject: [PATCH 1/7] wip(rest,runtime): one per-caller /meta item read gate for both transports Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/rest/src/index.ts | 21 + packages/rest/src/meta-item-read-gate.ts | 1336 +++++++++++++++++ packages/rest/src/rest-server.ts | 1274 +++------------- .../meta-item-read-gate-parity.test.ts | 367 +++++ packages/runtime/src/domains/meta.ts | 181 ++- ...es__rest__src__meta-item-read-gate.ts.json | 8 + 6 files changed, 2070 insertions(+), 1117 deletions(-) create mode 100644 packages/rest/src/meta-item-read-gate.ts create mode 100644 packages/runtime/src/domains/meta-item-read-gate-parity.test.ts create mode 100644 scripts/adr-anchors/packages__rest__src__meta-item-read-gate.ts.json diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index 714ca8d902b..0b6bb06cc87 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -68,3 +68,24 @@ export type { ExportFieldMeta } from './export-format.js'; // body fails `BaseResponseSchema` with `success is missing, must be a boolean`). // ⛔ A dispatcher domain takes the message and builds its own body. export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-multiplicity.js'; + +// [#20193] THE per-caller read gate of one `/meta/:type/:name` document — the +// ADR-0046 §6.7 docs audience, the app nav filter (`requiredPermissions`, the +// ADR-0045 §3 publish gate, the docs-audience entry arm) and the ADR-0057 D10 +// service gates — published so the runtime dispatcher's `/meta` domain, the +// only answer on a host that mounts just the `${prefix}/*` catch-all, asks the +// SAME gate `RestServer` asks instead of a second resolver +// (`meta-item-read-gate.ts`'s header is the authority). +// +// What travels is the decision and nothing transport-shaped: each caller hands +// in its own I/O (`MetaItemReadGateSources`) and writes the DATA verdict +// (`MetaItemReadVerdict`) on its own wire, in its own envelope — the same split +// `repeatedQueryParamMessage` above makes. +export { createMetaItemReadGate } from './meta-item-read-gate.js'; +export type { + MetaItemReadGateSources, + MetaItemReadRefusal, + MetaItemReadVerdict, + MetaReadGateCaller, + MetaReadGatePolicy, +} 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 new file mode 100644 index 00000000000..2a968c896f1 --- /dev/null +++ b/packages/rest/src/meta-item-read-gate.ts @@ -0,0 +1,1336 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20193] THE per-caller read gate of one `/meta/:type/:name` document — one + * implementation, called by both transports that serve that read. + * + * ## Why this module exists + * + * Two transports answer `GET /meta/:type/:name` (and its `/published` twin): + * `RestServer`, and the runtime dispatcher's `/meta` domain, which is the ONLY + * answer on a host that mounts just the `${prefix}/*` catch-all — + * `@objectstack/hono`'s `createHonoApp`, the documented embed shape, and any + * adapter written on the public `HttpDispatcher` API. Neither can mount the + * other: `RestServer` needs an `IHttpServer`, and that catch-all is terminal + * by design (ADR-0076 OQ#9), so the dispatcher's `/meta` branches stay the + * fallback fabric ADR-0076 item 9 records. The per-caller gate lived inside + * `RestServer` (#20156), so the dispatcher applied none of it: a member + * `RestServer` refuses a `{ permissionSet }`-gated doc read its body there, and + * an app's `requiredPermissions` entries reached every member. + * + * Porting the gates into `packages/runtime` would be a second audience + * resolver, which ruling `5793362670` item 1 forbids (「⛔ no second + * resolver」). So the gate moved here, unchanged, out of `RestServer`: every + * decision below is the one `RestServer` made, and each transport hands in + * only its own I/O — how it knows the caller, how it reads a metadata list, + * where the security service is, how it probes a deployment's services + * ({@link MetaItemReadGateSources}). The verdict is DATA + * ({@link MetaItemReadVerdict}); each transport writes a refusal on its own + * wire, the way it writes every other refusal. + * + * ⛔ Do not add a decision to a transport's adapter. A gate added HERE reaches + * both transports; a gate added in one of them is the defect this module + * closed, reopened one layer over. + * + * `RestServer` keeps its private helper names (`filterAppForUser`, + * `resolveDocsAudience`, `resolveNavServability`, …) as one-line delegates to + * the functions below, so its other docs and app reads — the lists, the book + * tree — ask this same implementation. + */ + +import type { AudienceCaller, Book, ResolvedBook, ResolverDoc } from '@objectstack/spec/system'; +import { apiExposureDenialReason } from '@objectstack/spec/data'; +import { logWarn } from './log.js'; + +// ── The ports each transport supplies ───────────────────────────────────────── + +/** + * The slice of a request's execution context the gate reads: who the caller is + * (`userId`), what they may do (`systemPermissions`), and — internal to + * `RestServer`'s resolution — the per-request kernel a service probe prefers. + */ +export interface MetaReadGateCaller { + userId?: unknown; + systemPermissions?: unknown; + [key: string]: unknown; +} + +/** The one security-service verb the docs audience reads. */ +export interface MetaReadGatePermissionSetResolver { + resolvePermissionSetNames?(context: any): Promise | unknown; +} + +/** How a transport reads the caller (the audience half of the ports). */ +export interface MetaReadGateAudienceSources { + /** + * This request's caller as the transport resolved it, or `undefined` for an + * anonymous one. A REJECTION is an authorization-store outage and must + * propagate — ⛔ never answer `undefined` for a caller that could not be + * resolved, which would read the outage as "anonymous". + */ + resolveCaller(): Promise; + /** + * The security service holdings are resolved through, or `undefined` when + * the deployment has none. A throw is read as "holdings unresolvable", so + * gated audiences DENY (ADR-0049). + */ + resolveSecurityService(): Promise; +} + +/** How a transport reads a whole metadata type (the list half of the ports). */ +export interface MetaReadGateListSource { + /** + * One `getMetaItems` LIST read of `type`, env-wide — or `undefined` when + * this transport's protocol has no list read at all. A THROW is a fault, + * ⛔ never an empty list: an empty book list is "no gated book anywhere" and + * an empty doc corpus is "every doc unclaimed, so `org`" — both GRANT. + */ + listMetaItems(type: 'book' | 'doc' | 'object'): Promise | undefined; +} + +/** Everything {@link createMetaItemReadGate} reads, supplied by the transport. */ +export interface MetaItemReadGateSources extends MetaReadGateAudienceSources, MetaReadGateListSource { + /** + * The ADR-0057 D10 service probe for this caller's deployment, or `null` + * when nothing can be probed — the service gates then fail OPEN, as they + * always have. + */ + serviceProbe(caller: MetaReadGateCaller | undefined): ((name: string) => Promise) | null; + /** Where the nav-servability prune log records what it has already said (one line per key). */ + readonly navPruneLogged: Set; +} + +// ── The verdict ─────────────────────────────────────────────────────────────── + +/** + * Why a document is not served — DATA, written on each transport's own wire. + * + * - `absent` — the transport's own "nothing behind this name" answer, byte for + * byte (ADR-0045 §3: an unpublished app is externally unobservable, and an + * absent optional service is a deployment fact, not a denial). + * - `app-permission` — `403 PERMISSION_DENIED`, the one withheld reason the + * #8013 ruling lets an app report as itself. + * - `docs-audience` — ADR-0046 §6.7: `401 UNAUTHENTICATED` to an anonymous + * caller, `403 PERMISSION_DENIED` otherwise. + */ +export type MetaItemReadRefusal = + | { reason: 'absent' } + | { reason: 'app-permission'; status: 403; code: 'PERMISSION_DENIED'; message: string } + | { reason: 'docs-audience'; status: 401; code: 'UNAUTHENTICATED'; message: string } + | { reason: 'docs-audience'; status: 403; code: 'PERMISSION_DENIED'; message: string }; + +/** + * What the gate answers for ONE document. + * + * - `serve` — send `document`: the input, or the input minus what this + * caller may not read (the app nav filter, the dashboard widget gate). + * - `refuse` — send nothing of the document; `refusal` says what the + * transport writes instead. + */ +export type MetaItemReadVerdict = + | { kind: 'serve'; document: any } + | { kind: 'refuse'; refusal: MetaItemReadRefusal }; + +/** + * [#20156] How a door runs {@link createMetaItemReadGate}. + */ +export interface MetaReadGatePolicy { + /** + * `all` — every gate the plain read runs, the ones that answer per + * DEPLOYMENT included (ADR-0057 D10 `requiresService` on an app, its nav + * entries and a dashboard's widgets; #7912 object servability). The + * doors that serve the document a client RENDERS: the plain read and + * `/published`. + * + * `per-caller` — only the gates whose verdict depends on who asks. The + * doors that serve STORED versions — the layered view, `/diff`, + * `/history`, `/audit`. A per-deployment gate withholds nothing from the + * caller, and applied to a stored version it reports the store wrongly (a + * widget whose service is merely off here reads as never authored — and + * Studio's designer, which loads the layered view and saves what it + * loaded, would delete it). + */ + arms: 'all' | 'per-caller'; + /** + * The `app` arm (the unpublished gate, `requiredPermissions`, the + * docs-audience entry arm). + * + * `gate` — the plain read's app answer: its refusal, or the pruned app. + * + * `pending-decision` — an app the plain read refuses WHOLE (an app-level + * `requiredPermissions` the caller lacks → `403`; an unpublished app to a + * non-builder → the absence answer, ADR-0045 §3) is refused exactly as the + * plain read refuses it. An app it would serve only in PART (entries + * withheld) is served as STORED, unpruned, exactly as before this gate. + * ⚠️ That second half is a DECLARED EXEMPTION, not a verdict: what the + * layered view (`/layers`, `?layers=`) and `/diff` owe a caller who may see + * part of an app is a new permission boundary, and the maintainer's to + * decide (decision anchor #20156). Both ways of pulling those cells to the + * plain read's answer draw one: pruning a stored version the designer + * saves back deletes the withheld entries, and refusing it locks out an + * author — a platform admin included, whose capability list carries no + * wildcard and meets no admin exemption in + * {@link filterAppForUserWithReason} — who lacks one entry's permission. + * The census in `meta-alternate-door-read-gates.test.ts` pins exactly those + * partial cells to the pre-gate answer under that anchor, so they stay loud + * until ruled, and holds every whole-refusal cell to the plain read's. + */ + app: 'gate' | 'pending-decision'; +} + +// ── The gates' types ────────────────────────────────────────────────────────── + +/** + * [#7912] The nav-servability gate handed to `filterAppForUser`: given the + * `objectName` a `type: 'object'` entry targets (and the entry itself, for the + * diagnostic), answer whether the destination can serve a `list`. + * + * `true` = serve the entry. That includes every case this layer cannot judge — + * an object absent from metadata, or metadata that could not be read at all — + * because the gate is a SURFACE-AREA control, not an authorization boundary, + * and the same fail-open reasoning `loadObjectItems` records applies here. + * + * `appName` is passed in rather than captured because ONE gate serves the whole + * app list: the list route resolves object metadata once and gates every app + * with the same closure, so the app being filtered is a per-call fact. + */ +export type NavServabilityGate = (objectName: string, entry: any, appName: string) => boolean; + +/** + * [ADR-0046 §6.7] One request's docs-audience view of one caller — THE + * resolution behind every audience-gated docs answer this server gives: the + * `/meta/doc` list, the `/meta/doc/:name` read, the `/meta/book/:name/tree` + * read and the app-nav `doc` arm ({@link NavDocAudienceGate}). Built by + * `resolveDocsAudience` from the environment's books; every verdict below is a + * `@objectstack/spec/system` helper asked with this caller, so the four answers + * cannot drift apart — there is no second resolver to drift. + */ +export interface DocsAudience { + /** The caller as the audience helpers see it (holdings resolved only when a `{ permissionSet }` book exists). */ + readonly caller: AudienceCaller; + /** + * The fast path: an authenticated caller and no `{ permissionSet }` book + * anywhere, so every doc's effective audience (`org` / `public`) admits + * them and no doc corpus is needed to say so. + */ + readonly allReadable: boolean; + /** The book `name` names: a declared book, else the implicit per-package book (§6.4). */ + bookNamed(name: string): Book & { _packageId?: string }; + /** Whether the book's OWN audience admits this caller — the gate on the whole tree. */ + admitsBook(book: Book): boolean; + /** + * The per-doc predicate over `corpus`: the doc's effective audience (the + * union over the books claiming it; unclaimed or absent from the corpus → + * `org`) admits this caller. Build it once per corpus and ask it per doc — + * building it resolves every book against the whole corpus. + */ + docReader(corpus: ResolverDoc[]): (docName: unknown) => boolean; + /** + * `book` resolved over `docs`, narrowed to the entries this caller may read + * — exactly the body `GET /meta/book/:name/tree` serves once the book's own + * audience has admitted the caller. `canRead` is a {@link docReader} over + * the same `docs` when the caller already holds one. + */ + readableTree( + book: Book & { _packageId?: string }, + docs: ResolverDoc[], + canRead?: (docName: unknown) => boolean, + ): ResolvedBook; + /** + * The book's PAGES this caller may read: the docs `book` claims over + * `docs` (`resolveBookClaimedDocs` — the membership `resolveDocAudiences` + * itself uses) that pass the per-doc predicate. The tree's synthetic + * *Uncategorized* group is not among them: the spec defines those orphans + * as a rendering convenience, "not an authored membership claim". + */ + readablePages( + book: Book & { _packageId?: string }, + docs: ResolverDoc[], + canRead?: (docName: unknown) => boolean, + ): string[]; +} + +/** + * [#19790] The docs-audience gate handed to `filterAppForUser`: given a + * `type: 'doc'` nav entry, answer whether the caller may read what it opens. + * `true` = serve the entry. Built once per request from ONE + * {@link DocsAudience}, so it serves the whole app list the way + * {@link NavServabilityGate} does. + * + * Unlike that gate and the ADR-0057 D10 service gate, the arm it feeds FAILS + * CLOSED: this is an authorization boundary (the entry names a book or doc the + * caller may not read), so an absent gate prunes every `doc` entry rather than + * serving it. + */ +export type NavDocAudienceGate = (entry: any) => boolean; + +// ── The metadata reads the gates take as input ──────────────────────────────── + +/** + * Load the object metadata items for the current protocol/environment, + * coerced to a plain array. Returns `[]` when metadata is unavailable so + * callers fail OPEN (the data call itself needs the same metadata and will + * surface any real error). Shared by `enforceApiAccess` (one object), the + * cross-object batch route (all ops, fetched once) and the nav-servability + * gate. + */ +export async function loadObjectItems(source: MetaReadGateListSource): Promise { + try { + const r: any = await source.listMetaItems('object'); + return Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : []; + } catch (err) { + // [#3545] The API-exposure gate fails OPEN when object metadata can't + // be read: the exposure whitelist is a SURFACE-AREA control, not the + // authorization boundary (auth + CRUD/FLS/RLS still enforce on the + // data call, which needs the same metadata and surfaces the real + // error), and failing closed here would 405 every request during the + // normal cold-start window. But a THROWN read is a real fault + // (metadata store down / corrupt schema doc), NOT a legitimately-empty + // registry (a `[]` return, e.g. a fresh deployment) — so LOG it. Left + // silent, a persistent metadata outage, during which the gate allows + // every operation unchecked, is indistinguishable from healthy + // operation. Still returns `[]` (fail-open preserved). See #3545. + logWarn( + '[REST] api-exposure gate: object metadata read failed — failing open ' + + '(auth + CRUD/FLS/RLS still enforce on the data call)', + (err as Error)?.message ?? err, + ); + return []; + } +} + +/** Whether any of these books carries a `{ permissionSet }` audience. */ +export function anyPermissionSetAudience(books: readonly any[]): boolean { + return books.some( + (b) => b && typeof b === 'object' && b.audience && typeof b.audience === 'object' + && typeof b.audience.permissionSet === 'string', + ); +} + +/** Coerce a getMetaItems result (array | {items}) into an array. */ +export function metaItemsArray(raw: unknown): any[] { + if (Array.isArray(raw)) return raw; + if (raw && typeof raw === 'object' && Array.isArray((raw as any).items)) return (raw as any).items; + return []; +} + +/** Shape a book list for the audience resolver: `_packageId` provenance also as `packageId`. */ +export function audienceBooksOf(raw: unknown): any[] { + return metaItemsArray(raw).map((b: any) => + b && typeof b === 'object' ? { ...b, packageId: b._packageId } : b, + ); +} + +/** + * The doc header the audience resolver reads — name, the placement keys a + * book rule matches on, and provenance. Nothing rendered: a doc's label + * orders a group, but never decides which book claims it. + */ +export function docCorpusOf(list: readonly any[]): ResolverDoc[] { + return list + .filter((d: any) => d && typeof d === 'object') + .map((d: any) => ({ + name: d.name, + group: d.group, + tags: d.tags, + order: d.order, + packageId: d._packageId, + })); +} + +/** + * A `getMetaItems` list read that REPORTS a thrown read as `{ fault }` + * rather than swallowing it. The two docs-audience reads below go through + * here so each caller chooses HOW an unreadable gate input fails closed — + * never whether: the doc reads hand the fault to the caller + * ({@link fetchAudienceBooks}, the `/meta/doc/:name` corpus read), and the + * app-nav gate prunes every `doc` entry of one response + * ({@link resolveNavDocAudience}). ⛔ No caller reads a fault as an empty + * list: an empty book list is "no gated book anywhere" and an empty corpus + * is "every doc unclaimed, so `org`" — both GRANT. + * + * A transport whose protocol has no list read at all REJECTS here (it is + * `async`, so the throw below becomes the rejection) rather than answering an + * empty list — the same fail-closed direction as a thrown read. + */ +async function readMetaList( + source: MetaReadGateListSource, + type: 'book' | 'doc', +): Promise<{ items: any[] } | { fault: unknown }> { + const pending = source.listMetaItems(type); + if (pending === undefined) { + throw new TypeError(`No metadata list read is available to resolve the '${type}' audience input.`); + } + return pending.then( + (raw: unknown) => ({ items: metaItemsArray(raw) }), + (fault: unknown) => ({ fault }), + ); +} + +/** Every book of the environment, audience-shaped; `{ fault }` when the read throws. */ +export async function readAudienceBooks( + source: MetaReadGateListSource, +): Promise<{ items: any[] } | { fault: unknown }> { + const read = await readMetaList(source, 'book'); + return 'fault' in read ? read : { items: audienceBooksOf(read.items) }; +} + +/** Every doc of the environment as the resolver's corpus; `{ fault }` when the read throws. */ +export async function readDocCorpus( + source: MetaReadGateListSource, +): Promise<{ items: ResolverDoc[] } | { fault: unknown }> { + const read = await readMetaList(source, 'doc'); + return 'fault' in read ? read : { items: docCorpusOf(read.items) }; +} + +/** + * Fetch every book of the environment, shaped for the audience resolver, + * for the `/meta/doc` list and `/meta/doc/:name` reads. A read that throws + * THROWS its own fault (ADR-0046 §6.7, fail closed per ADR-0049). + * + * It used to answer `[]`, and `[]` is not "unknown" to the resolver — it + * is "no `{ permissionSet }` book anywhere", which puts an authenticated + * caller on the fast path where every doc is readable. So a store fault + * on this read served a set-gated doc, body and all, to a non-holder and + * listed it for them. The books are an input to the audience decision; a + * decision whose input could not be read is not made. + * + * Rethrown, not mapped to a deny: the fault reaches the route's + * `handleRouteError`, so these reads answer a book-read fault exactly as + * `GET /meta/book/:name/tree` — whose book read has always propagated — + * and as the `/meta/doc` list's own doc read do (`503 + * SERVICE_UNAVAILABLE` for `metadata-protocol`'s store fault). One fault, + * one answer across the docs doors, and an outage never reads as an + * authorization verdict (a 403 would tell a holder they hold nothing) nor + * as an empty list (every doc pruned is "this environment has no docs"). + * The app-nav gate reads {@link readAudienceBooks} directly because a nav + * response is a composite: it drops the `doc` entries and serves the rest. + */ +export async function fetchAudienceBooks(source: MetaReadGateListSource): Promise { + const read = await readAudienceBooks(source); + if ('fault' in read) throw read.fault; + return read.items; +} + +// ── The docs audience ───────────────────────────────────────────────────────── + +/** + * [ADR-0046 §6.7] The audience-evaluation view of the caller for book/doc + * gating. `permissionSets` resolves through the security service's + * `resolvePermissionSetNames` — the SAME resolution as data-plane + * enforcement (positions expanded, additive baseline), so the docs gate + * can never drift from it. `permissionSets` stays undefined when the + * service is absent or resolution fails; `audienceAllows` then DENIES + * permission-set-gated audiences (fail closed, ADR-0049). Resolution is + * skipped unless `needPermissionSets` — callers pass true only when a + * `{ permissionSet }` audience is actually in play. + */ +export async function resolveAudienceCaller( + sources: MetaReadGateAudienceSources, + opts: { needPermissionSets: boolean }, +): Promise<{ authenticated: boolean; permissionSets?: string[] }> { + const ctx = await sources.resolveCaller(); + const authenticated = !!ctx?.userId; + if (!authenticated || !opts.needPermissionSets) { + return { authenticated }; + } + try { + const svc = await sources.resolveSecurityService(); + if (!svc || typeof svc.resolvePermissionSetNames !== 'function') return { authenticated }; + const names = await svc.resolvePermissionSetNames(ctx); + return { authenticated, permissionSets: Array.isArray(names) ? names : [] }; + } catch { + return { authenticated }; // unresolved holdings → gated audiences deny + } +} + +/** + * [ADR-0046 §6.7] Build THE {@link DocsAudience} for this request's caller + * over `books` (audience-shaped, {@link audienceBooksOf}). + * + * Every audience-gated docs answer goes through here — the `/meta/doc` + * list, `/meta/doc/:name`, `/meta/book/:name/tree` and the app-nav `doc` + * arm, on both transports — so "may this caller read it" has one + * implementation, spelled in the spec's own helpers (`audienceAllows`, + * `resolveDocAudiences`, `docAudienceAllows`, `resolveBookTree`, + * `deriveImplicitPackageBook`). + * + * Holdings are resolved only when a `{ permissionSet }` book exists, and + * unresolvable holdings deny those audiences ({@link resolveAudienceCaller}, + * fail closed per ADR-0049). The fast path is the doc list's own: with no + * such book, an authenticated caller reads every doc, so `docReader` needs + * no corpus and `readableTree` filters nothing. + */ +export async function resolveDocsAudience( + sources: MetaReadGateAudienceSources, + books: readonly any[], +): Promise { + const { + audienceAllows, docAudienceAllows, resolveDocAudiences, resolveBookTree, resolveBookClaimedDocs, + deriveImplicitPackageBook, + } = await import('@objectstack/spec/system'); + const gated = anyPermissionSetAudience(books); + const caller = await resolveAudienceCaller(sources, { needPermissionSets: gated }); + const allReadable = caller.authenticated && !gated; + const docReader = (corpus: ResolverDoc[]): ((docName: unknown) => boolean) => { + if (allReadable) return () => true; + const audiences = resolveDocAudiences(books as any, corpus); + return (docName: unknown) => docAudienceAllows(audiences.get(docName as string), caller); + }; + return { + caller, + allReadable, + bookNamed: (name: string) => + books.find((b: any) => b && b.name === name) ?? deriveImplicitPackageBook(name, name), + admitsBook: (book: Book) => audienceAllows(book?.audience, caller), + docReader, + readableTree: (book, docs, canRead) => { + const tree = resolveBookTree(book, docs, book._packageId); + // The fast path serves the tree whole — no entry can fail an + // audience every doc passes, and the empty-group drop below is + // part of the narrowing, not of the tree. + if (allReadable) return tree; + const read = canRead ?? docReader(docs); + tree.groups = tree.groups + .map((g) => ({ + ...g, + entries: g.entries.filter((e) => !e.doc || read(e.doc)), + })) + .filter((g) => g.entries.some((e) => e.doc || e.href)); + return tree; + }, + readablePages: (book, docs, canRead) => { + const read = canRead ?? docReader(docs); + return [...resolveBookClaimedDocs(book, docs, book._packageId)].filter((name) => read(name)); + }, + }; +} + +// ── The app and dashboard filters ───────────────────────────────────────────── + +/** + * {@link filterAppForUser}, plus WHICH gate withheld the app. + * + * `filterAppForUser` filters an `App` metadata item by the current user's + * `systemPermissions`: + * + * - Drops the app entirely when it is UNPUBLISHED (`_unpublished: true`, + * ADR-0045 §3) and the caller is not a builder. Note the key: `hidden` is + * navigation presentation and is deliberately NOT consulted (#4829). + * - Drops the app entirely if its top-level `requiredPermissions` are not + * a subset of the user's system permissions. + * - Recursively strips child navigation entries (groups, items) whose + * `requiredPermissions` are not satisfied. Empty groups collapse so + * the sidebar doesn't render a label with no children — [#7380] a + * `type: 'group'` with no SURVIVING children is dropped whether it was + * emptied by the gate or authored `children: []`. Only `group` collapses; + * an `object` entry is its own target and is served however many children + * it has. See the rule at the `filterNav` branch for the measurement. + * - [#4722] Applies the SAME item gate to every `areas[].navigation` tree. + * Both trees are the same shape and the keys mean the same thing in both, + * so `filterNav` is reused — there is deliberately no second + * implementation to drift. Before this, an item gated inside an area was + * enforced by the shell alone: the entry (with its `objectName` / + * `pageName` / `componentRef` target) still shipped in the `/meta` body, + * so reading the JSON defeated it. + * - [#7912] SERVABILITY: drops a `type: 'object'` entry whose destination + * object could not answer a `list` for anyone — see `servabilityGate`. + * - [#19790] DOCS AUDIENCE (ADR-0046 §6.7): drops a `type: 'doc'` entry the + * caller may not read — see `docAudienceGate`. Fails CLOSED, unlike the + * two gates above: no gate means every `doc` entry is dropped. + * + * NOT gated here: `visible` (CEL) at any level, and `requiresObject` — both + * are still evaluated client-side only. That asymmetry is deliberate and + * pinned in `rest.test.ts`: server-side CEL needs a bound `user` context + * that this layer does not have, and is its own change. + * + * ⚠️ [#7912] `requiresObject` STAYS on that list, and the servability gate + * is not it wearing a new hat. `requiresObject` asks whether the named + * object is REGISTERED — a question about deployment composition, whose + * answer this filter deliberately leaves to the client (the maintainer + * ruling of 2026-08-12 rejected re-meaning the key server-side precisely + * because the docblock calls that asymmetry deliberate). The servability + * gate asks a different question of an object that IS registered: does its + * own `enable` block let the destination answer at all? An entry whose + * object this layer cannot find is therefore SERVED, not pruned — the + * `requiresObject` pin and #3770's "no declared policy ⇒ nothing to + * enforce" both survive unchanged. + * + * `app` is `null` when the app should be withheld from the user entirely, and + * a shallow copy with filtered `navigation` / `areas` otherwise — the original + * is never mutated so cached metadata stays clean. + * + * Takes the **app document itself**, never the `getMetaItem` envelope + * (#5563). Every caller hands it a document: the list path always did, and + * the single-item read unwraps `.item` once, gates the document, and rebuilds + * the envelope around the result. Filtering an envelope would be a silent + * no-op (its `.navigation` is undefined), bypassing BOTH + * `requiredPermissions` and the ADR-0057 D10 `requiresService` gate — which + * is why this used to sniff the shape. There is one shape now. + * + * ## Why the caller needs the reason + * + * The gate collapses three different refusals into one `null`, and for + * the LIST route that is exactly right — every one of them means "not in + * your list". The by-name route is where they stop being the same answer + * (#8013). + * + * `GET /meta/app/` answered a 404-equivalent for all three, so an + * app the session may never use and an app that does not exist were + * BYTE-IDENTICAL on the wire. The console has nothing to branch on, so it + * renders its only copy for an absent app — "it may still be publishing" — + * over a permanent authorization denial. Measured cost (objectui#4252): two + * acceptance-test batches spent chasing a "platform defect" that was a + * missing permission-set binding. + * + * ## Only ONE of the three converts, and that is the whole design + * + * The maintainer ruling (2026-08-12) licenses an explicit denial for + * `permission` alone. The other two keep answering absence, for reasons + * that are not stylistic: + * + * - `unpublished` — ADR-0045 §3 says an unpublished app is *externally + * unobservable*, not merely unlisted. A 403 confirms existence, which is + * precisely what that contract withholds; `meta-app-publish-gate.test.ts` + * has pinned the 404-over-403 choice since #4829 and it stands. + * - `service` — an absent optional kernel service (ADR-0057 D10) is a + * deployment fact about the platform, not a statement about this caller. + * Nothing is denied TO the session, so there is no denial to report. + * + * That partition is the security boundary of #8013, and it cuts one way + * only: a denial for `permission` makes an app the caller may not use + * observable BY NAME, which the ruling accepts because a by-name probe + * already implies the name. Widening it to a name that resolves to nothing + * would make every app name on the platform enumerable — a different and + * unruled change. Hence `withheld` is set from the branch that fired, never + * inferred from `app == null` at the call site. + * + * Ordering is load-bearing for the same reason: `unpublished` is judged + * FIRST, so an app that is both unpublished and permission-gated reports + * `unpublished` and stays absent. ADR-0045 §3 wins over the disclosure. + */ +export function filterAppForUserWithReason( + item: any, + sysPerms: Set, + serviceGate?: (name: string) => boolean, + servabilityGate?: NavServabilityGate, + docAudienceGate?: NavDocAudienceGate, +): { app: any | null; withheld?: 'unpublished' | 'permission' | 'service' } { + if (!item || typeof item !== 'object') return { app: item }; + // ADR-0045 §3 (as revised 2026-08, #4829) — the publish gate. An + // UNPUBLISHED app is externally unobservable, not merely unlisted: only + // builders (studio/setup access) receive it at all, for direct-URL + // preview. THIS is the visibility gate; the launcher's client-side + // filtering is a listing courtesy. + // + // ⛔ It judges `_unpublished`, the machine-managed key, and NOT `hidden`. + // `hidden` is navigation presentation — "not in the App Switcher, reach + // it from the avatar menu" — and reading it here made those two + // contracts one boolean. #4829 measured the cost: `account`, the + // platform's own personal-settings app, is authored `hidden: true` for + // exactly the reason its spec docblock gives, so this branch erased it + // from `GET /meta/app` for every user without builder access — password, + // avatar, sessions, inbox all 404 — while any admin saw a healthy + // system. A hidden app is fully routable and permission-checked here; + // only `_unpublished` withholds it. + if (item._unpublished === true && !sysPerms.has('studio.access') && !sysPerms.has('setup.access')) { + return { app: null, withheld: 'unpublished' }; + } + const reqApp = Array.isArray(item.requiredPermissions) ? item.requiredPermissions : []; + if (reqApp.length > 0 && !reqApp.every((p: string) => sysPerms.has(p))) { + return { app: null, withheld: 'permission' }; + } + // ADR-0057 D10 — capability gate: hide when the named kernel service is + // absent. Fail-open when the gate can't be probed (serviceGate undefined). + if (typeof item.requiresService === 'string' && serviceGate && serviceGate(item.requiresService) === false) { + return { app: null, withheld: 'service' }; + } + const nav = Array.isArray(item.navigation) ? item.navigation : null; + const areas = Array.isArray(item.areas) ? item.areas : null; + if (!nav && !areas) return { app: item }; + + const filterNav = (entries: any[]): any[] => { + const out: any[] = []; + for (const e of entries) { + if (!e || typeof e !== 'object') continue; + const req = Array.isArray(e.requiredPermissions) ? e.requiredPermissions : []; + if (req.length > 0 && !req.every((p: string) => sysPerms.has(p))) continue; + if (typeof e.requiresService === 'string' && serviceGate && serviceGate(e.requiresService) === false) continue; + // [#19790] DOCS AUDIENCE — the rule `DocNavItemSchema` declares + // and, until this arm, only a renderer honoured: a `doc` entry + // naming a doc the caller may not read, or a book with no page + // they may read, is not served. Left in the body, the entry's + // label and its book / doc names reached every member of the + // app however the book was gated, and reading the JSON + // defeated whatever the shell pruned (the #4722 lesson again). + // + // The verdict is the docs reads' own — `docAudienceGate` is one + // `DocsAudience` built by the caller, the same resolution + // `/meta/doc` and `/meta/book/:name/tree` answer from — and the + // arm FAILS CLOSED where its neighbours fail open: no gate, no + // `doc` entry. Like every entry-level arm here it is a bare + // `continue` with no reason attached; `withheld` reports only + // why a whole APP was withheld, and an app this arm empties is + // still served, exactly as one emptied by `requiredPermissions`. + if (e.type === 'doc' && (!docAudienceGate || !docAudienceGate(e))) continue; + // [#7912] SERVABILITY — the gate this filter had no vocabulary + // for. A `type: 'object'` entry names its destination in + // `objectName`; the object's own `enable` block decides whether + // a `list` can be answered there, and that decision takes no + // user, no permissions and no context. So an entry whose + // destination is API-disabled (404 `OBJECT_API_DISABLED`) or + // whose whitelist omits `list` (405 + // `OBJECT_API_METHOD_NOT_ALLOWED`) is dead for EVERY persona, + // platform admin included — which is why no combination of + // `requiredPermissions` on the entry could ever prune it + // (#7544 shipped exactly that combination for a year). + // + // The verdict comes from the same derivation the data route + // enforces (`apiExposureDenialReason`, #3391), reached through + // the gate the caller built — never a second reading of + // `enable` here. + if (servabilityGate && e.type === 'object' && typeof e.objectName === 'string') { + const appName = typeof item.name === 'string' ? item.name : '(unnamed)'; + if (servabilityGate(e.objectName, e, appName) === false) continue; + } + // [#7380] A `group` is judged on what SURVIVES, never on how it + // got there. Both childless shapes render the same dead sidebar + // label, so both are dropped: + // - BECAME empty — authored with children, all gated away; + // - STARTED empty — authored `children: []`. + // The old guard (`children.length > 0`) sent the second shape + // down the else branch, which never reaches the drop rule, so a + // declared-empty group shipped as a bare label the docblock + // above already promised it would not. That shape is not a + // corner case: `setup.app.ts` is authored entirely out of it — + // nine `children: []` contribution slots (ADR-0029 D7) that + // `Registry.applyNavContributions` fills on read, BEFORE this + // filter runs. So a slot a capability plugin filled arrives here + // with children and survives; a slot left empty because its + // capability is disabled arrives `[]` and is now dropped, which + // is exactly the "a disabled capability contributes nothing and + // its slot stays empty" case `setup.app.ts` documents. + // + // The rule is `type === 'group'` ONLY, and stays that way. The + // union nests on two branches (`NAV_VARIANTS_ACCEPTING_CHILDREN` + // = `object` | `group`), and an `object` entry is its own + // navigation target — `{ type: 'object', objectName: 'lead', + // children: [] }` is a live link to the lead list, not a label, + // so emptiness says nothing about whether to serve it. A group + // cannot be a target: `GroupNavItemSchema` is a `strictObject` + // over the base keys plus `expanded`/`children` and declares no + // `objectName` / `pageName` / `componentRef` / `url` — it + // REJECTS them — and its docblock reads "Does not perform + // navigation itself." Measured against that before the change + // (#7380): 41 `type: 'group'` entries across the shipped apps + // (`account`, `setup`, `studio`), the examples (`app-crm`, + // `app-showcase`, `app-todo`) and the spec's nav type-assertion + // fixtures. 16 are childless — the 9 `setup` slots and 7 spec + // fixtures; the three example apps have none — and ZERO of the + // 41 carry `objectName` / `pageName` / `componentRef` / `url` or + // any other target. So the drop is unconditional: there is no + // standalone childless-group shape in the tree to spare. + // + // A group with NO `children` key is covered by the same rule for + // the same reason — same dead label. It is unreachable through + // the spec (`children` is required on both the input and output + // group branches; `app.nav-type-assertions.ts` pins that with a + // `@ts-expect-error`), but this filter reads untyped documents + // off the metadata store, so leaving it out would just reopen + // the bypass one keyword over. + if (Array.isArray(e.children)) { + const kids = filterNav(e.children); + if (e.type === 'group' && kids.length === 0) continue; + out.push({ ...e, children: kids }); + } else { + if (e.type === 'group') continue; + out.push(e); + } + } + return out; + }; + + // [#4722] `areas[]` carries no gate of its own — the area-level `visible` + // / `requiredPermissions` keys were retired in 17.0.0 (#4651, ADR-0049) + // and are NOT revived here. What is enforced is the gate on the items + // INSIDE an area, through the very same `filterNav` the top-level tree + // uses, so the two trees can never disagree about what a key means. + // + // Collapse rule: an area whose authored tree is emptied BY the gate is + // dropped (a bare area label with nothing reachable under it is not a + // useful response), while an area authored `navigation: []` is passed + // through untouched — filtering reports what the caller may not see, it + // does not tidy the metadata. + // + // [#7380] That second half is where an area and a `group` now DIVERGE, + // deliberately: `filterNav` drops a childless group however it got that + // way, an area authored empty still ships. The reason is what the two + // shapes are. A group is a sidebar label and nothing else, so childless + // it renders dead — and the shipped `setup` app authors nine of them as + // contribution SLOTS, which makes "declared empty" the normal steady + // state of an unfilled one rather than an authoring slip. An area is a + // top-level workspace the shell can select and route to on its own; an + // author who ships `navigation: []` has declared an area that is not + // populated yet, and this filter is not the layer that judges that. + // What is NOT divergent is the walk: an area whose entries are all + // childless groups empties through the very same `filterNav` and is + // dropped by the rule above — one implementation, as everywhere else. + const filterAreas = (list: any[]): any[] => { + const out: any[] = []; + for (const a of list) { + if (!a || typeof a !== 'object') continue; + const anav = Array.isArray(a.navigation) ? a.navigation : null; + if (!anav || anav.length === 0) { out.push(a); continue; } + const kids = filterNav(anav); + if (kids.length === 0) continue; + out.push({ ...a, navigation: kids }); + } + return out; + }; + + return { + app: { + ...item, + ...(nav ? { navigation: filterNav(nav) } : {}), + ...(areas ? { areas: filterAreas(areas) } : {}), + }, + }; +} + +/** + * Filter an `App` metadata item by the current user's `systemPermissions` — + * {@link filterAppForUserWithReason} without the reason. `null` when the app + * is withheld from the user entirely. + */ +export function filterAppForUser( + item: any, + sysPerms: Set, + serviceGate?: (name: string) => boolean, + servabilityGate?: NavServabilityGate, + docAudienceGate?: NavDocAudienceGate, +): any | null { + return filterAppForUserWithReason(item, sysPerms, serviceGate, servabilityGate, docAudienceGate).app; +} + +/** + * ADR-0057 D10 (dashboards): strip dashboard widgets whose `requiresService` + * capability gate names a kernel service that isn't registered — the same + * "server is the authoritative visibility gate" rule already applied to app + * nav entries (see {@link filterAppForUser}). Without this, a widget bound to + * an optional service renders a dead tile in deployments where the service is + * off (e.g. the Organizations KPI under multi-tenant `org-scoping`, which is + * absent in a single-tenant runtime while its nav entry is correctly hidden). + * + * Fail-open when the gate can't be probed (serviceGate undefined). Never + * mutates the original — returns a shallow copy only when a widget is dropped. + * + * Takes the **dashboard document**, never the `getMetaItem` envelope — see + * {@link filterAppForUser} for why that distinction stopped being a runtime + * question in #5563. + */ +export function filterDashboardForUser(item: any, serviceGate?: (name: string) => boolean): any { + if (!item || typeof item !== 'object' || !serviceGate) return item; + if (!Array.isArray(item.widgets)) return item; + const widgets = item.widgets.filter( + (w: any) => !(w && typeof w.requiresService === 'string' && serviceGate(w.requiresService) === false), + ); + return widgets.length === item.widgets.length ? item : { ...item, widgets }; +} + +// ── The per-request gate inputs ─────────────────────────────────────────────── + +/** + * Probe which `requiresService` capability gates referenced anywhere in + * `items` are actually registered in the runtime kernel. Returns `null` + * when the kernel can't be probed (`probe` is `null`) — callers then SKIP + * service gating (fail-open, matching the prior "send everything, let the + * client hide" behaviour). ADR-0057 addendum D10. + * + * `items` are metadata **documents** (#5563) — the single-item route + * unwraps the envelope before probing, exactly as it does before gating. + */ +export async function resolveRegisteredServices( + probe: ((name: string) => Promise) | null, + items: any[], +): Promise | null> { + if (!probe) return null; + const wanted = new Set(); + const walk = (e: any): void => { + if (!e || typeof e !== 'object') return; + if (typeof e.requiresService === 'string') wanted.add(e.requiresService); + // [#4722] EVERY child list, not the first one that happens to be an + // array. An app may carry `navigation` AND `areas` at once, and now + // that `filterAppForUser` gates the trees under `areas[]` too, a + // service named only in there must be probed — an unprobed name is + // absent from `registered`, and the gate would read that as "service + // missing" and strip a live entry. Fail-closed by omission is still + // wrong; the probe set must cover exactly what the gate walks. + for (const key of ['navigation', 'areas', 'children', 'widgets'] as const) { + const kids = (e as any)[key]; + if (Array.isArray(kids)) for (const k of kids) walk(k); + } + }; + for (const it of items) walk(it); + if (wanted.size === 0) return new Set(); + const registered = new Set(); + for (const name of wanted) { if (await probe(name)) registered.add(name); } + return registered; +} + +/** + * [#7912] Build the nav-servability gate for one request: which objects can + * actually answer a `list` on the external REST surface. + * + * ## Shape, and why it mirrors `resolveRegisteredServices` + * + * Same contract as the ADR-0057 D10 service gate one function up: resolve the + * facts ONCE per request, hand `filterAppForUser` a closure, and return + * `null` when the facts cannot be established so the caller skips the gate + * entirely. Nav filtering already runs over a whole app list; re-reading + * object metadata per entry would turn one read into dozens. + * + * ## Fail-open, in three distinct cases — each deliberate + * + * 1. **Metadata unreadable** — `loadObjectItems` answers `[]` and logs. + * This function then answers `null` (no gate), so nothing is pruned. The + * alternative fails CLOSED during every cold start, emptying the + * sidebar of a healthy deployment; #3545 already settled that trade for + * the data-route twin and the same reasoning binds harder here, where + * the consequence is a user staring at an app with no navigation. + * 2. **Object not in metadata** — served. There is no declared exposure + * policy to enforce (#3770), and "is this object registered at all?" is + * `requiresObject`'s question, which this layer deliberately does not + * answer (see {@link filterAppForUser}). + * 3. **No `enable` block** — served, by `apiExposureDenialReason`'s own + * default-open contract. An object that declares nothing restricts + * nothing. + * + * Only case (3)'s opposite — a declared `enable` that refuses `list` — ever + * prunes. + * + * ## The prune is LOGGED, never silent + * + * The maintainer ruling of 2026-08-12 makes the author-visible diagnostic a + * mandatory companion, not an optional one: "a prune the author cannot see + * is the same failure one layer over — no silent dead rows, and no silent + * repairs." The authoring-time half of that is + * `validate-nav-object-servability` in `@objectstack/lint`, which refuses + * the stack at `os validate` / `os build` / `os lint` before it can ever be + * served. This log is the serving-side half, for an entry that reached a + * running deployment anyway (a `sys_metadata` overlay row, or a stack built + * before the lint existed): it names the app, the entry id, the object AND + * the condition, so the pruned row is discoverable from the server log + * rather than being an unexplained gap in a menu. + * + * One line per `app|entry|object|reason` per `navPruneLogged` set — a console + * session re-fetches `/meta/app` on every navigation, and an unthrottled log + * would bury the first occurrence under thousands of repeats. + */ +export async function resolveNavServability( + sources: MetaReadGateListSource & Pick, +): Promise { + const items = await loadObjectItems(sources); + // Case (1): nothing to judge with. `loadObjectItems` has already logged + // a THROWN read; a legitimately empty registry is silent and equally + // ungated, which is correct — an empty registry declares no policy. + if (items.length === 0) return null; + const enableByName = new Map(); + for (const o of items) { + if (o && typeof o.name === 'string') enableByName.set(o.name, o.enable); + } + return (objectName: string, entry: any, appName: string): boolean => { + // Case (2): unknown object → no declared policy to enforce here. + if (!enableByName.has(objectName)) return true; + const reason = apiExposureDenialReason(enableByName.get(objectName), 'list'); + if (!reason) return true; + const entryId = (entry && (entry.id ?? entry.label)) ?? '(unnamed)'; + const key = `${appName}|${entryId}|${objectName}|${reason}`; + if (!sources.navPruneLogged.has(key)) { + sources.navPruneLogged.add(key); + logWarn( + `[REST] [#7912] nav entry '${entryId}' pruned from app '${appName}': its destination ` + + `object '${objectName}' cannot serve a list — ` + + (reason === 'api-disabled' + ? `\`enable.apiEnabled: false\` (the list answers 404 OBJECT_API_DISABLED for every user).` + : `\`enable.apiMethods\` does not grant \`list\` (the list answers 405 ` + + `OBJECT_API_METHOD_NOT_ALLOWED for every user).`) + + ` Remove the entry, or expose the object — \`os validate\` refuses this stack ` + + `(nav-object-unservable).`, + ); + } + return false; + }; +} + +/** + * [#19790] Build the docs-audience nav gate for one request: may THIS + * caller read what a `type: 'doc'` nav entry opens (ADR-0046 §6.7, the rule + * `DocNavItemSchema` declares). + * + * ## One resolution, not a second one + * + * Every verdict comes from the {@link DocsAudience} that `/meta/doc`, + * `/meta/doc/:name` and `/meta/book/:name/tree` answer from, built over the + * same env-wide books the doc reads use and a doc corpus read the way + * `/meta/doc/:name` reads it. Per entry shape: + * + * - **`doc` alone** — served iff the doc's effective audience admits the + * caller: `docAudienceAllows` over `resolveDocAudiences`, the answer + * `/meta/doc/:name` gives. + * - **`book` alone** — the book the name names (a declared book, else the + * implicit per-package book, §6.4 — the tree read's own lookup) must + * admit the caller by its own audience (the tree read's 401/403), and at + * least one of its PAGES must be readable: a doc the book claims + * (`resolveBookClaimedDocs`, the membership `resolveDocAudiences` uses) + * whose effective audience admits the caller. Not "any entry of the + * tree": `resolveBookTree` appends every doc the book does NOT claim as + * a synthetic *Uncategorized* group, so over an env-wide corpus nearly + * every book's tree holds some readable doc, and the rule would never + * fire. The spec calls those orphans "not an authored membership + * claim"; external `href` links are not pages either. + * - **`book` + `doc`** — served iff BOTH hold: the book's own audience + * admits the caller AND the doc is readable. A doc can be readable while + * the book is not (its effective audience is the UNION over every book + * claiming it, `docAudienceAllows`), but the entry opens that page in + * that book's context, whose tree read answers 401/403 — and the entry + * itself names the gated book. So it is dropped; it does not fall back + * to the page alone. + * - **neither** — dropped. The spec refuses the shape; this filter reads + * untyped stored documents, and an entry with no target has nothing a + * caller could read. + * + * Existence is `docs/nav-target`'s question, answered at `os build`, and + * this gate asks only the resolver's. So a `doc` naming a doc absent from + * the corpus is SERVED — the resolver's own default for a doc it has no + * entry for is `org` (`docAudienceAllows`), so an authenticated caller may + * read it, and there is no gated audience behind a name that resolves to + * nothing. A `book` naming no declared book is judged as the implicit book + * of a package by that name — what the tree read serves for it — so when no + * doc resolves into it, it has no readable page and is NOT served: "no + * readable page" is the book rule's own wording. + * + * ## Fails CLOSED, and says so + * + * The arm this feeds treats an absent gate as "drop every `doc` entry", and + * so does this builder when a read it needs THROWS: the books read (an + * empty list there reads as "no gated book anywhere") or the doc corpus + * read (an empty corpus reads every doc as unclaimed, i.e. `org`). Either + * empty would serve a `{ permissionSet }`-gated entry to every member, so + * here a thrown read drops the `doc` entries of this one response and logs + * the fault — the rest of the navigation is served. The doc reads close + * the same two faults by handing them to their caller instead + * ({@link fetchAudienceBooks}): each serves one doc or one doc list, so + * there is no rest to serve. Unresolvable permission-set HOLDINGS + * already deny inside {@link resolveAudienceCaller} (ADR-0049). + * + * ## Cost, per `/meta/app` request (measured by this card's tests) + * + * Nothing, when no app in `apps` carries a `doc` entry: a walk over the nav + * trees, and no read at all. Otherwise, ONCE per request whatever the app + * count — the same shape as {@link resolveNavServability}: + * + * - one `book` list read; + * - one permission-set resolution, only when some book is set-gated + * (the execution context itself is memoised per request); + * - one `doc` list read, only when the fast path does not decide (a + * set-gated book exists) or some entry is `book` alone (its page count + * needs the corpus); + * - off the fast path, one `resolveDocAudiences` pass (a `resolveBookTree` + * per book over the whole corpus) shared by every entry. + * + * Per entry: a `doc` is one map lookup; a `book` alone is one + * `resolveBookClaimedDocs` of that book over the corpus (one + * `resolveBookTree`: every doc visited once per group rule) plus one + * lookup per claimed page. ⛔ No cache — nothing outlives the request. + */ +export async function resolveNavDocAudience( + sources: MetaReadGateAudienceSources & MetaReadGateListSource, + apps: readonly any[], +): Promise { + const entries = docNavEntries(apps); + // Nothing to judge — and the arm drops a `doc` entry this walk missed, + // so a walk that ever falls behind `filterNav` fails closed, not open. + if (entries.length === 0) return undefined; + + const failClosed = (what: string, fault: unknown): NavDocAudienceGate => { + logWarn( + `[REST] app-nav docs-audience gate: the ${what} read failed — failing CLOSED: every ` + + "`type: 'doc'` navigation entry is left out of this response, because whether the " + + 'caller may read what it names could not be established. The rest of the navigation ' + + 'is served.', + (fault as Error)?.message ?? fault, + ); + return () => false; + }; + + const books = await readAudienceBooks(sources); + if ('fault' in books) return failClosed('book', books.fault); + const audience = await resolveDocsAudience(sources, books.items); + + const bookAlone = (e: any): boolean => + navTarget(e.book) !== undefined && navTarget(e.doc) === undefined; + let corpus: ResolverDoc[] = []; + if (!audience.allReadable || entries.some(bookAlone)) { + const read = await readDocCorpus(sources); + if ('fault' in read) return failClosed('doc', read.fault); + corpus = read.items; + } + // Built ONCE for every entry of every app in this response. + const canRead = audience.docReader(corpus); + + return (entry: any): boolean => { + const bookName = navTarget(entry?.book); + const docName = navTarget(entry?.doc); + if (bookName === undefined && docName === undefined) return false; + if (bookName !== undefined) { + const book = audience.bookNamed(bookName); + if (!audience.admitsBook(book)) return false; + if (docName === undefined) return audience.readablePages(book, corpus, canRead).length > 0; + } + return canRead(docName); + }; +} + +/** A `doc` nav entry's `book` / `doc` target, when it names one. */ +export function navTarget(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** + * Every `type: 'doc'` entry in these apps, in every tree `filterNav` walks — + * top-level `navigation`, `areas[].navigation`, and `children` at any depth. + */ +export function docNavEntries(apps: readonly any[]): any[] { + const found: any[] = []; + const walk = (entries: unknown): void => { + if (!Array.isArray(entries)) return; + for (const e of entries) { + if (!e || typeof e !== 'object') continue; + if (e.type === 'doc') found.push(e); + walk(e.children); + } + }; + for (const app of apps) { + if (!app || typeof app !== 'object') continue; + walk(app.navigation); + if (Array.isArray(app.areas)) { + for (const area of app.areas) { + if (area && typeof area === 'object') walk(area.navigation); + } + } + } + return found; +} + +// ── THE gate ────────────────────────────────────────────────────────────────── + +const DOCS_SIGN_IN_MESSAGE = 'This documentation requires sign-in'; +const DOCS_HOLDER_MESSAGE = 'This documentation is limited to holders of a permission set you do not have'; + +/** + * [#20156 · #20193] THE read gate of one `/meta/:type/:name` document, for the + * plain read and every door beside it, on both transports. + * + * ## Why one spelling + * + * The plain read ran these gates inline, and the doors that serve the same + * document — the layered view (`/layers` and the deprecated `?layers=`), + * `/published`, `/history`, `/audit`, `/diff` — ran none of them. So a + * member the plain read refuses `403` read a `{ permissionSet }`-gated doc's + * body from three of those doors, an anonymous caller read any doc or book + * through `?layers=true`, and an app's `requiredPermissions` entries reached + * every member. The by-name app route's own rule — it "must not serve a nav + * entry the list route prunes, or reading the single-app JSON defeats the + * filter" — held for one door out of seven (the layered view and `/diff` + * still serve an app the caller may see only in PART as stored, pending a + * decision: `MetaReadGatePolicy.app`). Each door asks THIS function, so a + * gate added here reaches all of them, and the census in + * `meta-alternate-door-read-gates.test.ts` — its door list read off the + * route table — fails a door that does not ask. + * + * [#20193] And the SECOND TRANSPORT asks it too. The runtime dispatcher's + * `/meta` item read and its `/published` twin — the only answer on a host + * that mounts just the `${prefix}/*` catch-all — ran no gate at all; + * `meta-item-read-gate-parity.test.ts` in `@objectstack/runtime` drives the + * same fixtures through both transports and holds the answers equal. + * + * ## The gates, per type (unchanged from the plain read they came out of) + * + * - `app` — {@link filterAppForUserWithReason}, for an authenticated + * caller. `permission` → `403 PERMISSION_DENIED` (#8013, the one + * withheld reason the ruling lets report itself); `unpublished` and + * `service` → the absence answer (ADR-0045 §3: an unpublished app is + * externally unobservable). [#7912] The servability gate and [#19790] the + * docs-audience entry arm ride along. + * ⚠️ Under `app: 'pending-decision'` — the layered view and `/diff` — + * an app the plain read refuses WHOLE is refused the same way, and one + * it would serve only in PART is served as stored, unpruned: see + * `MetaReadGatePolicy.app` for the decision it waits on. + * - `dashboard` — ADR-0057 D10 {@link filterDashboardForUser}. A + * per-DEPLOYMENT gate (which optional services are registered), never + * per-caller, so `arms: 'per-caller'` skips it. + * - `book` — ADR-0046 §6.7, the book's own audience. + * - `doc` — ADR-0046 §6.7, the EFFECTIVE audience (union over the books + * that claim it, unclaimed → `org`). [#20129] Both gate inputs fail + * CLOSED by throwing: a book-read fault throws in + * {@link fetchAudienceBooks}, a corpus-read fault below — read as `[]` + * it made the doc unclaimed, i.e. `org`, and served it to every member. + * Refused `401 UNAUTHENTICATED` anonymous, `403 PERMISSION_DENIED` + * otherwise; holdings that cannot be resolved deny (ADR-0049). + * + * One audience resolution, the docs reads' own ({@link resolveDocsAudience}), + * for every door — ⛔ no second resolver. NOT here: the ADR-0106 object mask, + * which each exit already threads as a posture resolved before its fetch + * (D2/D3's `fetch → mask → send`), and which the doors apply too. + * + * ## Shape + * + * `metaType` is the SINGULAR type (`/meta/books/:name` is the canonical plural + * spelling, Prime Directive #3 — the caller folds it once, at its boundary). + * Returns a judge for this request, and resolves each type's inputs ONCE, + * on the first document judged — the layered view judges three, `/diff` + * up to three, and they share one books read, one corpus read, one + * holdings resolution. `documents` is every document the answer may judge, + * so the app arm's nav probes see all of them at once. `null`/`undefined` + * is served as given: absence is each door's own answer. A gate input that + * cannot be read REJECTS the judge: the transport answers that fault, ⛔ + * never the document. + */ +export function createMetaItemReadGate( + sources: MetaItemReadGateSources, + metaType: string, + name: string, + documents: readonly any[], + policy: MetaReadGatePolicy, +): (document: any) => Promise { + const serve = (document: any): MetaItemReadVerdict => ({ kind: 'serve', document }); + const refuse = (refusal: MetaItemReadRefusal): MetaItemReadVerdict => ({ kind: 'refuse', refusal }); + const docsAudienceRefusal = (caller: AudienceCaller): MetaItemReadRefusal => ( + caller.authenticated + ? { reason: 'docs-audience', status: 403, code: 'PERMISSION_DENIED', message: DOCS_HOLDER_MESSAGE } + : { reason: 'docs-audience', status: 401, code: 'UNAUTHENTICATED', message: DOCS_SIGN_IN_MESSAGE } + ); + + if (metaType === 'app') { + type AppGateInputs = { + sysPerms: Set; + serviceGate?: (n: string) => boolean; + servabilityGate?: NavServabilityGate; + docAudienceGate?: NavDocAudienceGate; + }; + let inputs: Promise | undefined; + const resolveInputs = (): Promise => (inputs ??= (async () => { + const ctx = await sources.resolveCaller(); + if (!ctx?.userId) return null; + const sysPerms = new Set( + Array.isArray(ctx.systemPermissions) ? ctx.systemPermissions : [], + ); + let serviceGate: ((n: string) => boolean) | undefined; + let servabilityGate: NavServabilityGate | undefined; + if (policy.arms === 'all') { + const registered = await resolveRegisteredServices(sources.serviceProbe(ctx), [...documents]); + serviceGate = registered ? (n: string) => registered.has(n) : undefined; + // [#7912] Same gate as the list route — the by-name route + // must not serve a nav entry the list route prunes, or + // reading the single-app JSON defeats the filter (the + // #4722 lesson, one gate over). + servabilityGate = await resolveNavServability(sources) ?? undefined; + } + // [#19790] And the same docs-audience gate, for the same reason: + // a `doc` entry the list route prunes must not come back here. + // Not resolved under `pending-decision`: it only prunes ENTRIES, + // and that arm serves the stored app unpruned (see below), so + // its reads would decide nothing. + const docAudienceGate = policy.app === 'gate' + ? await resolveNavDocAudience(sources, documents) + : undefined; + return { sysPerms, serviceGate, servabilityGate, docAudienceGate }; + })()); + return async (document) => { + if (document == null) return serve(document); + const gateInputs = await resolveInputs(); + if (!gateInputs) return serve(document); + const gated = filterAppForUserWithReason( + document, gateInputs.sysPerms, gateInputs.serviceGate, + gateInputs.servabilityGate, gateInputs.docAudienceGate); + if (gated.app == null) { + // [#8013] A PERMISSION denial is reported as one — + // everything else keeps answering absence. See + // {@link filterAppForUserWithReason} for why only this one + // of the three gates converts, and why the reason comes + // from the branch that fired rather than from `null`. + // + // The ADR-0112 STANDARD catalog code; the console reads + // `body.error.code` (objectui#4252 branches on exactly this + // `code`). + if (gated.withheld === 'permission') { + return refuse({ + reason: 'app-permission', status: 403, code: 'PERMISSION_DENIED', + message: `You do not have permission to open the '${name}' app.`, + }); + } + // [#18066] The transport's own absence answer, so this arm + // and the nothing-behind-the-name arm are byte-identical by + // construction (ADR-0045 §3). + return refuse({ reason: 'absent' }); + } + // ⚠️ A DECLARED EXEMPTION, pending the maintainer's decision + // (anchor #20156) — see `MetaReadGatePolicy.app`. An app the + // plain read refuses WHOLE was refused above, exactly as the + // plain read refuses it; one it would serve only in PART is + // served as STORED, unpruned, exactly as before this gate. + return serve(policy.app === 'pending-decision' ? document : gated.app); + }; + } + + if (metaType === 'dashboard') { + if (policy.arms !== 'all') return async (document) => serve(document); + // ADR-0057 D10: gate dashboard widgets by `requiresService` (mirrors + // the app-nav gate above) so the console never renders a tile bound + // to an absent optional service. [#5881] On the DEFAULT path since + // the plain read's cache exclusion — see the `isDashboardType` + // comment in `RestServer`'s plain read. + return async (document) => { + if (document == null) return serve(document); + const ctx = await sources.resolveCaller(); + const registered = await resolveRegisteredServices(sources.serviceProbe(ctx), [document]); + const serviceGate = registered ? (n: string) => registered.has(n) : undefined; + return serve(serviceGate ? filterDashboardForUser(document, serviceGate) : document); + }; + } + + if (metaType === 'book') { + // The book's own audience — holdings resolved only when THIS book + // is set-gated, so one resolution per document judged. + return async (document) => { + if (document == null) return serve(document); + const audience = await resolveDocsAudience(sources, [document]); + return audience.admitsBook(document) ? serve(document) : refuse(docsAudienceRefusal(audience.caller)); + }; + } + + if (metaType === 'doc') { + // A doc's effective audience reads its NAME against the books that + // claim it — never its body — so the verdict is the same for every + // version of it, and the inputs are resolved once. + let reader: Promise<{ caller: AudienceCaller; canRead: (docName: unknown) => boolean }> | undefined; + const resolveReader = () => (reader ??= (async () => { + const books = await fetchAudienceBooks(sources); + const audience = await resolveDocsAudience(sources, books); + // No gated book anywhere → org suffices. + if (audience.allReadable) return { caller: audience.caller, canRead: () => true }; + const read = await readDocCorpus(sources); + if ('fault' in read) throw read.fault; + return { caller: audience.caller, canRead: audience.docReader(read.items) }; + })()); + return async (document) => { + if (document == null) return serve(document); + // [#5563] `audience` is read off the DOCUMENT, never an + // envelope — an envelope's name-less shape would grant everyone. + const { caller, canRead } = await resolveReader(); + return canRead(document?.name) ? serve(document) : refuse(docsAudienceRefusal(caller)); + }; + } + + return async (document) => serve(document); +} diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 6783daa1600..90881f39332 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -155,7 +155,22 @@ import { PLURAL_TO_SINGULAR, canonicalMetaUrlType, unrecognisedMetaTypeRefusal } import { stripReadDecorations } from '@objectstack/spec/kernel'; import type { DroppedFieldsEvent } from '@objectstack/spec/data'; import { preferredLocaleFromHeader } from '@objectstack/spec/system'; -import type { AudienceCaller, Book, ResolvedBook, ResolverDoc } from '@objectstack/spec/system'; +import type { ResolverDoc } 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 +// private helpers below keep their names as one-line delegates into it. +import * as metaReadGate from './meta-item-read-gate.js'; +import type { + DocsAudience, + MetaItemReadGateSources, + MetaItemReadRefusal, + MetaReadGateAudienceSources, + MetaReadGateListSource, + MetaReadGatePolicy, + NavDocAudienceGate, + NavServabilityGate, +} from './meta-item-read-gate.js'; import type { ISecurityService } from '@objectstack/spec/contracts'; import { resolveEffectiveApiMethods, @@ -399,90 +414,6 @@ interface ApiAccessOpts { bulkChild?: string; } -/** - * [#7912] The nav-servability gate handed to `filterAppForUser`: given the - * `objectName` a `type: 'object'` entry targets (and the entry itself, for the - * diagnostic), answer whether the destination can serve a `list`. - * - * `true` = serve the entry. That includes every case this layer cannot judge — - * an object absent from metadata, or metadata that could not be read at all — - * because the gate is a SURFACE-AREA control, not an authorization boundary, - * and the same fail-open reasoning `loadObjectItems` records applies here. - * - * `appName` is passed in rather than captured because ONE gate serves the whole - * app list: the list route resolves object metadata once and gates every app - * with the same closure, so the app being filtered is a per-call fact. - */ -type NavServabilityGate = (objectName: string, entry: any, appName: string) => boolean; - -/** - * [ADR-0046 §6.7] One request's docs-audience view of one caller — THE - * resolution behind every audience-gated docs answer this server gives: the - * `/meta/doc` list, the `/meta/doc/:name` read, the `/meta/book/:name/tree` - * read and the app-nav `doc` arm ({@link NavDocAudienceGate}). Built by - * `resolveDocsAudience` from the environment's books; every verdict below is a - * `@objectstack/spec/system` helper asked with this caller, so the four answers - * cannot drift apart — there is no second resolver to drift. - */ -interface DocsAudience { - /** The caller as the audience helpers see it (holdings resolved only when a `{ permissionSet }` book exists). */ - readonly caller: AudienceCaller; - /** - * The fast path: an authenticated caller and no `{ permissionSet }` book - * anywhere, so every doc's effective audience (`org` / `public`) admits - * them and no doc corpus is needed to say so. - */ - readonly allReadable: boolean; - /** The book `name` names: a declared book, else the implicit per-package book (§6.4). */ - bookNamed(name: string): Book & { _packageId?: string }; - /** Whether the book's OWN audience admits this caller — the gate on the whole tree. */ - admitsBook(book: Book): boolean; - /** - * The per-doc predicate over `corpus`: the doc's effective audience (the - * union over the books claiming it; unclaimed or absent from the corpus → - * `org`) admits this caller. Build it once per corpus and ask it per doc — - * building it resolves every book against the whole corpus. - */ - docReader(corpus: ResolverDoc[]): (docName: unknown) => boolean; - /** - * `book` resolved over `docs`, narrowed to the entries this caller may read - * — exactly the body `GET /meta/book/:name/tree` serves once the book's own - * audience has admitted the caller. `canRead` is a {@link docReader} over - * the same `docs` when the caller already holds one. - */ - readableTree( - book: Book & { _packageId?: string }, - docs: ResolverDoc[], - canRead?: (docName: unknown) => boolean, - ): ResolvedBook; - /** - * The book's PAGES this caller may read: the docs `book` claims over - * `docs` (`resolveBookClaimedDocs` — the membership `resolveDocAudiences` - * itself uses) that pass the per-doc predicate. The tree's synthetic - * *Uncategorized* group is not among them: the spec defines those orphans - * as a rendering convenience, "not an authored membership claim". - */ - readablePages( - book: Book & { _packageId?: string }, - docs: ResolverDoc[], - canRead?: (docName: unknown) => boolean, - ): string[]; -} - -/** - * [#19790] The docs-audience gate handed to `filterAppForUser`: given a - * `type: 'doc'` nav entry, answer whether the caller may read what it opens. - * `true` = serve the entry. Built once per request from ONE - * {@link DocsAudience}, so it serves the whole app list the way - * {@link NavServabilityGate} does. - * - * Unlike that gate and the ADR-0057 D10 service gate, the arm it feeds FAILS - * CLOSED: this is an authorization boundary (the entry names a book or doc the - * caller may not read), so an absent gate prunes every `doc` entry rather than - * serving it. - */ -type NavDocAudienceGate = (entry: any) => boolean; - /** * [#15416] The operation to NAME in a `method-not-allowed` refusal. * @@ -1712,53 +1643,6 @@ type MetaReadVerdict = | { kind: 'serve'; document: any } | { kind: 'refuse'; send: (res: any) => void }; -/** - * [#20156] How a door runs {@link RestServer.metaItemReadGate}. - */ -interface MetaReadGatePolicy { - /** - * `all` — every gate the plain read runs, the ones that answer per - * DEPLOYMENT included (ADR-0057 D10 `requiresService` on an app, its nav - * entries and a dashboard's widgets; #7912 object servability). The - * doors that serve the document a client RENDERS: the plain read and - * `/published`. - * - * `per-caller` — only the gates whose verdict depends on who asks. The - * doors that serve STORED versions — the layered view, `/diff`, - * `/history`, `/audit`. A per-deployment gate withholds nothing from the - * caller, and applied to a stored version it reports the store wrongly (a - * widget whose service is merely off here reads as never authored — and - * Studio's designer, which loads the layered view and saves what it - * loaded, would delete it). - */ - arms: 'all' | 'per-caller'; - /** - * The `app` arm (the unpublished gate, `requiredPermissions`, the - * docs-audience entry arm). - * - * `gate` — the plain read's app answer: its refusal, or the pruned app. - * - * `pending-decision` — an app the plain read refuses WHOLE (an app-level - * `requiredPermissions` the caller lacks → `403`; an unpublished app to a - * non-builder → the absence answer, ADR-0045 §3) is refused exactly as the - * plain read refuses it. An app it would serve only in PART (entries - * withheld) is served as STORED, unpruned, exactly as before this gate. - * ⚠️ That second half is a DECLARED EXEMPTION, not a verdict: what the - * layered view (`/layers`, `?layers=`) and `/diff` owe a caller who may see - * part of an app is a new permission boundary, and the maintainer's to - * decide (decision anchor #20156). Both ways of pulling those cells to the - * plain read's answer draw one: pruning a stored version the designer - * saves back deletes the withheld entries, and refusing it locks out an - * author — a platform admin included, whose capability list carries no - * wildcard and meets no admin exemption in - * {@link filterAppForUserWithReason} — who lacks one entry's permission. - * The census in `meta-alternate-door-read-gates.test.ts` pins exactly those - * partial cells to the pre-gate answer under that anchor, so they stay loud - * until ruled, and holds every whole-refusal cell to the plain read's. - */ - app: 'gate' | 'pending-decision'; -} - /** * RestServer * @@ -2530,38 +2414,13 @@ export class RestServer { /** * Load the object metadata items for the current protocol/environment, - * coerced to a plain array. Returns `[]` when metadata is unavailable so - * callers fail OPEN (the data call itself needs the same metadata and will - * surface any real error). Shared by `enforceApiAccess` (one object) and the - * cross-object batch route (all ops, fetched once). + * coerced to a plain array — `loadObjectItems` in + * `./meta-item-read-gate.ts` (fail OPEN and logged, #3545). Shared by + * `enforceApiAccess` (one object), the cross-object batch route (all ops, + * fetched once) and the nav-servability gate. */ private async loadObjectItems(p: RestProtocol, environmentId: string | undefined): Promise { - try { - const objectsRequest: TransportScopedMetaRequest = { - type: 'object', - ...(environmentId ? { environmentId } : {}), - }; - const r: any = await p.getMetaItems?.(objectsRequest); - return Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : []; - } catch (err) { - // [#3545] The API-exposure gate fails OPEN when object metadata can't - // be read: the exposure whitelist is a SURFACE-AREA control, not the - // authorization boundary (auth + CRUD/FLS/RLS still enforce on the - // data call, which needs the same metadata and surfaces the real - // error), and failing closed here would 405 every request during the - // normal cold-start window. But a THROWN read is a real fault - // (metadata store down / corrupt schema doc), NOT a legitimately-empty - // registry (a `[]` return, e.g. a fresh deployment) — so LOG it. Left - // silent, a persistent metadata outage, during which the gate allows - // every operation unchecked, is indistinguishable from healthy - // operation. Still returns `[]` (fail-open preserved). See #3545. - logWarn( - '[REST] api-exposure gate: object metadata read failed — failing open ' + - '(auth + CRUD/FLS/RLS still enforce on the data call)', - (err as Error)?.message ?? err, - ); - return []; - } + return metaReadGate.loadObjectItems(this.metaListSource(p, environmentId)); } /** @@ -2716,37 +2575,6 @@ export class RestServer { return typeof userId === 'string' && userId ? userId : undefined; } - /** - * [ADR-0046 §6.7] The audience-evaluation view of the caller for book/doc - * gating. `permissionSets` resolves through the security service's - * `resolvePermissionSetNames` — the SAME resolution as data-plane - * enforcement (positions expanded, additive baseline), so the docs gate - * can never drift from it. `permissionSets` stays undefined when the - * service is absent or resolution fails; `audienceAllows` then DENIES - * permission-set-gated audiences (fail closed, ADR-0049). Resolution is - * skipped unless `needPermissionSets` — callers pass true only when a - * `{ permissionSet }` audience is actually in play. - */ - private async resolveAudienceCaller( - environmentId: string | undefined, - req: any, - opts: { needPermissionSets: boolean }, - ): Promise<{ authenticated: boolean; permissionSets?: string[] }> { - const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); - const authenticated = !!ctx?.userId; - if (!authenticated || !opts.needPermissionSets || !this.securityServiceProvider) { - return { authenticated }; - } - try { - const svc = await this.securityServiceProvider(environmentId); - if (!svc || typeof svc.resolvePermissionSetNames !== 'function') return { authenticated }; - const names = await svc.resolvePermissionSetNames(ctx); - return { authenticated, permissionSets: Array.isArray(names) ? names : [] }; - } catch { - return { authenticated }; // unresolved holdings → gated audiences deny - } - } - /** * Canonical SINGULAR form of the `:type` path segment. * @@ -2935,181 +2763,44 @@ export class RestServer { return type === 'book' || type === 'doc'; } - /** Whether any of these books carries a `{ permissionSet }` audience. */ - private static anyPermissionSetAudience(books: readonly any[]): boolean { - return books.some( - (b) => b && typeof b === 'object' && b.audience && typeof b.audience === 'object' - && typeof b.audience.permissionSet === 'string', - ); - } - - /** Coerce a getMetaItems result (array | {items}) into an array. */ + /** Coerce a getMetaItems result (array | {items}) into an array — `metaItemsArray` in `./meta-item-read-gate.ts`. */ private static metaItemsArray(raw: unknown): any[] { - if (Array.isArray(raw)) return raw; - if (raw && typeof raw === 'object' && Array.isArray((raw as any).items)) return (raw as any).items; - return []; + return metaReadGate.metaItemsArray(raw); } - /** Shape a book list for the audience resolver: `_packageId` provenance also as `packageId`. */ + /** Shape a book list for the audience resolver — `audienceBooksOf` in `./meta-item-read-gate.ts`. */ private static audienceBooksOf(raw: unknown): any[] { - return RestServer.metaItemsArray(raw).map((b: any) => - b && typeof b === 'object' ? { ...b, packageId: b._packageId } : b, - ); + return metaReadGate.audienceBooksOf(raw); } - /** - * The doc header the audience resolver reads — name, the placement keys a - * book rule matches on, and provenance. Nothing rendered: a doc's label - * orders a group, but never decides which book claims it. - */ + /** The doc header the audience resolver reads — `docCorpusOf` in `./meta-item-read-gate.ts`. */ private static docCorpusOf(list: readonly any[]): ResolverDoc[] { - return list - .filter((d: any) => d && typeof d === 'object') - .map((d: any) => ({ - name: d.name, - group: d.group, - tags: d.tags, - order: d.order, - packageId: d._packageId, - })); + return metaReadGate.docCorpusOf(list); } /** - * A `getMetaItems` list read that REPORTS a thrown read as `{ fault }` - * rather than swallowing it. The two docs-audience reads below go through - * here so each caller chooses HOW an unreadable gate input fails closed — - * never whether: the doc reads hand the fault to the caller - * ({@link fetchAudienceBooks}, the `/meta/doc/:name` corpus read), and the - * app-nav gate prunes every `doc` entry of one response - * ({@link resolveNavDocAudience}). ⛔ No caller reads a fault as an empty - * list: an empty book list is "no gated book anywhere" and an empty corpus - * is "every doc unclaimed, so `org`" — both GRANT. - */ - private static async readMetaList( - p: RestProtocol, - request: TransportScopedMetaRequest, - ): Promise<{ items: any[] } | { fault: unknown }> { - return p.getMetaItems(request).then( - (raw: unknown) => ({ items: RestServer.metaItemsArray(raw) }), - (fault: unknown) => ({ fault }), - ); - } - - /** Every book of the environment, audience-shaped; `{ fault }` when the read throws. */ - private async readAudienceBooks( - p: RestProtocol, - environmentId: string | undefined, - ): Promise<{ items: any[] } | { fault: unknown }> { - const booksRequest: TransportScopedMetaRequest = { - type: 'book', - ...(environmentId ? { environmentId } : {}), - }; - const read = await RestServer.readMetaList(p, booksRequest); - return 'fault' in read ? read : { items: RestServer.audienceBooksOf(read.items) }; - } - - /** Every doc of the environment as the resolver's corpus; `{ fault }` when the read throws. */ - private async readDocCorpus( - p: RestProtocol, - environmentId: string | undefined, - ): Promise<{ items: ResolverDoc[] } | { fault: unknown }> { - const docCorpusRequest: TransportScopedMetaRequest = { - type: 'doc', - ...(environmentId ? { environmentId } : {}), - }; - const read = await RestServer.readMetaList(p, docCorpusRequest); - return 'fault' in read ? read : { items: RestServer.docCorpusOf(read.items) }; - } - - /** - * Fetch every book of the environment, shaped for the audience resolver, - * for the `/meta/doc` list and `/meta/doc/:name` reads. A read that throws - * THROWS its own fault (ADR-0046 §6.7, fail closed per ADR-0049). - * - * It used to answer `[]`, and `[]` is not "unknown" to the resolver — it - * is "no `{ permissionSet }` book anywhere", which puts an authenticated - * caller on the fast path where every doc is readable. So a store fault - * on this read served a set-gated doc, body and all, to a non-holder and - * listed it for them. The books are an input to the audience decision; a - * decision whose input could not be read is not made. - * - * Rethrown, not mapped to a deny: the fault reaches the route's - * `handleRouteError`, so these reads answer a book-read fault exactly as - * `GET /meta/book/:name/tree` — whose book read has always propagated — - * and as the `/meta/doc` list's own doc read do (`503 - * SERVICE_UNAVAILABLE` for `metadata-protocol`'s store fault). One fault, - * one answer across the docs doors, and an outage never reads as an - * authorization verdict (a 403 would tell a holder they hold nothing) nor - * as an empty list (every doc pruned is "this environment has no docs"). - * The app-nav gate reads {@link readAudienceBooks} directly because a nav - * response is a composite: it drops the `doc` entries and serves the rest. + * Every book of the environment, audience-shaped — `fetchAudienceBooks` + * in `./meta-item-read-gate.ts`. A read that throws THROWS its own fault + * (ADR-0046 §6.7, fail closed per ADR-0049): `[]` would read as "no gated + * book anywhere" and grant. The fault reaches `handleRouteError`. */ private async fetchAudienceBooks(p: RestProtocol, environmentId: string | undefined): Promise { - const read = await this.readAudienceBooks(p, environmentId); - if ('fault' in read) throw read.fault; - return read.items; + return metaReadGate.fetchAudienceBooks(this.metaListSource(p, environmentId)); } /** * [ADR-0046 §6.7] Build THE {@link DocsAudience} for this request's caller - * over `books` (audience-shaped, {@link audienceBooksOf}). - * - * Every audience-gated docs answer goes through here — the `/meta/doc` - * list, `/meta/doc/:name`, `/meta/book/:name/tree` and the app-nav `doc` - * arm — so "may this caller read it" has one implementation, spelled in the - * spec's own helpers (`audienceAllows`, `resolveDocAudiences`, - * `docAudienceAllows`, `resolveBookTree`, `deriveImplicitPackageBook`). - * - * Holdings are resolved only when a `{ permissionSet }` book exists, and - * unresolvable holdings deny those audiences ({@link resolveAudienceCaller}, - * fail closed per ADR-0049). The fast path is the doc list's own: with no - * such book, an authenticated caller reads every doc, so `docReader` needs - * no corpus and `readableTree` filters nothing. + * over `books` — `resolveDocsAudience` in `./meta-item-read-gate.ts`, the + * one audience resolution every docs answer on both transports asks (the + * `/meta/doc` list, `/meta/doc/:name`, `/meta/book/:name/tree`, the app-nav + * `doc` arm). Holdings unresolvable → gated audiences deny (ADR-0049). */ private async resolveDocsAudience( environmentId: string | undefined, req: any, books: readonly any[], ): Promise { - const { - audienceAllows, docAudienceAllows, resolveDocAudiences, resolveBookTree, resolveBookClaimedDocs, - deriveImplicitPackageBook, - } = await import('@objectstack/spec/system'); - const gated = RestServer.anyPermissionSetAudience(books); - const caller = await this.resolveAudienceCaller(environmentId, req, { needPermissionSets: gated }); - const allReadable = caller.authenticated && !gated; - const docReader = (corpus: ResolverDoc[]): ((docName: unknown) => boolean) => { - if (allReadable) return () => true; - const audiences = resolveDocAudiences(books as any, corpus); - return (docName: unknown) => docAudienceAllows(audiences.get(docName as string), caller); - }; - return { - caller, - allReadable, - bookNamed: (name: string) => - books.find((b: any) => b && b.name === name) ?? deriveImplicitPackageBook(name, name), - admitsBook: (book: Book) => audienceAllows(book?.audience, caller), - docReader, - readableTree: (book, docs, canRead) => { - const tree = resolveBookTree(book, docs, book._packageId); - // The fast path serves the tree whole — no entry can fail an - // audience every doc passes, and the empty-group drop below is - // part of the narrowing, not of the tree. - if (allReadable) return tree; - const read = canRead ?? docReader(docs); - tree.groups = tree.groups - .map((g) => ({ - ...g, - entries: g.entries.filter((e) => !e.doc || read(e.doc)), - })) - .filter((g) => g.entries.some((e) => e.doc || e.href)); - return tree; - }, - readablePages: (book, docs, canRead) => { - const read = canRead ?? docReader(docs); - return [...resolveBookClaimedDocs(book, docs, book._packageId)].filter((name) => read(name)); - }, - }; + return metaReadGate.resolveDocsAudience(this.metaReadAudienceSources(environmentId, req), books); } /** Heavy path behind `resolveExecCtx` — resolve identity + RBAC/RLS + localization. */ @@ -3505,61 +3196,13 @@ export class RestServer { } /** - * Filter an `App` metadata item by the current user's `systemPermissions`. - * - * - Drops the app entirely when it is UNPUBLISHED (`_unpublished: true`, - * ADR-0045 §3) and the caller is not a builder. Note the key: `hidden` is - * navigation presentation and is deliberately NOT consulted (#4829). - * - Drops the app entirely if its top-level `requiredPermissions` are not - * a subset of the user's system permissions. - * - Recursively strips child navigation entries (groups, items) whose - * `requiredPermissions` are not satisfied. Empty groups collapse so - * the sidebar doesn't render a label with no children — [#7380] a - * `type: 'group'` with no SURVIVING children is dropped whether it was - * emptied by the gate or authored `children: []`. Only `group` collapses; - * an `object` entry is its own target and is served however many children - * it has. See the rule at the `filterNav` branch for the measurement. - * - [#4722] Applies the SAME item gate to every `areas[].navigation` tree. - * Both trees are the same shape and the keys mean the same thing in both, - * so `filterNav` is reused — there is deliberately no second - * implementation to drift. Before this, an item gated inside an area was - * enforced by the shell alone: the entry (with its `objectName` / - * `pageName` / `componentRef` target) still shipped in the `/meta` body, - * so reading the JSON defeated it. - * - [#7912] SERVABILITY: drops a `type: 'object'` entry whose destination - * object could not answer a `list` for anyone — see `servabilityGate`. - * - [#19790] DOCS AUDIENCE (ADR-0046 §6.7): drops a `type: 'doc'` entry the - * caller may not read — see `docAudienceGate`. Fails CLOSED, unlike the - * two gates above: no gate means every `doc` entry is dropped. - * - * NOT gated here: `visible` (CEL) at any level, and `requiresObject` — both - * are still evaluated client-side only. That asymmetry is deliberate and - * pinned in `rest.test.ts`: server-side CEL needs a bound `user` context - * that this layer does not have, and is its own change. - * - * ⚠️ [#7912] `requiresObject` STAYS on that list, and the servability gate - * is not it wearing a new hat. `requiresObject` asks whether the named - * object is REGISTERED — a question about deployment composition, whose - * answer this filter deliberately leaves to the client (the maintainer - * ruling of 2026-08-12 rejected re-meaning the key server-side precisely - * because the docblock calls that asymmetry deliberate). The servability - * gate asks a different question of an object that IS registered: does its - * own `enable` block let the destination answer at all? An entry whose - * object this layer cannot find is therefore SERVED, not pruned — the - * `requiresObject` pin and #3770's "no declared policy ⇒ nothing to - * enforce" both survive unchanged. - * - * Returns `null` when the app should be withheld from the user entirely. - * Returns a shallow copy with filtered `navigation` / `areas` otherwise — - * the original is never mutated so cached metadata stays clean. - * - * Takes the **app document itself**, never the `getMetaItem` envelope - * (#5563). Both callers now hand it a document: the list path always did, - * and the single-item route unwraps `.item` once, gates the document, and - * rebuilds the envelope around the result. Filtering an envelope would be a - * silent no-op (its `.navigation` is undefined), bypassing BOTH - * `requiredPermissions` and the ADR-0057 D10 `requiresService` gate — which - * is why this used to sniff the shape. There is one shape now. + * Filter an `App` metadata item by the current user's `systemPermissions` + * — the ADR-0045 §3 publish gate (`_unpublished`, never `hidden`), the + * `requiredPermissions` gate, the ADR-0057 D10 service gate, [#7912] the + * servability gate and [#19790] the docs-audience entry arm. + * `filterAppForUser` in `./meta-item-read-gate.ts` is the one + * implementation; its docblock (and `filterAppForUserWithReason`'s) carries + * every rule and its measurement. `null` when the app is withheld whole. */ private filterAppForUser( item: any, @@ -3568,325 +3211,29 @@ export class RestServer { servabilityGate?: NavServabilityGate, docAudienceGate?: NavDocAudienceGate, ): any | null { - return this.filterAppForUserWithReason(item, sysPerms, serviceGate, servabilityGate, docAudienceGate).app; - } - - /** - * {@link filterAppForUser}, plus WHICH gate withheld the app. - * - * The gate above collapses three different refusals into one `null`, and for - * the LIST route that is exactly right — every one of them means "not in - * your list". The by-name route is where they stop being the same answer - * (#8013). - * - * ## Why the caller needs the reason - * - * `GET /meta/app/` answered a 404-equivalent for all three, so an - * app the session may never use and an app that does not exist were - * BYTE-IDENTICAL on the wire. The console has nothing to branch on, so it - * renders its only copy for an absent app — "it may still be publishing" — - * over a permanent authorization denial. Measured cost (objectui#4252): two - * acceptance-test batches spent chasing a "platform defect" that was a - * missing permission-set binding. - * - * ## Only ONE of the three converts, and that is the whole design - * - * The maintainer ruling (2026-08-12) licenses an explicit denial for - * `permission` alone. The other two keep answering absence, for reasons - * that are not stylistic: - * - * - `unpublished` — ADR-0045 §3 says an unpublished app is *externally - * unobservable*, not merely unlisted. A 403 confirms existence, which is - * precisely what that contract withholds; `meta-app-publish-gate.test.ts` - * has pinned the 404-over-403 choice since #4829 and it stands. - * - `service` — an absent optional kernel service (ADR-0057 D10) is a - * deployment fact about the platform, not a statement about this caller. - * Nothing is denied TO the session, so there is no denial to report. - * - * That partition is the security boundary of #8013, and it cuts one way - * only: a denial for `permission` makes an app the caller may not use - * observable BY NAME, which the ruling accepts because a by-name probe - * already implies the name. Widening it to a name that resolves to nothing - * would make every app name on the platform enumerable — a different and - * unruled change. Hence `withheld` is set from the branch that fired, never - * inferred from `app == null` at the call site. - * - * Ordering is load-bearing for the same reason: `unpublished` is judged - * FIRST, so an app that is both unpublished and permission-gated reports - * `unpublished` and stays absent. ADR-0045 §3 wins over the disclosure. - */ - private filterAppForUserWithReason( - item: any, - sysPerms: Set, - serviceGate?: (name: string) => boolean, - servabilityGate?: NavServabilityGate, - docAudienceGate?: NavDocAudienceGate, - ): { app: any | null; withheld?: 'unpublished' | 'permission' | 'service' } { - if (!item || typeof item !== 'object') return { app: item }; - // ADR-0045 §3 (as revised 2026-08, #4829) — the publish gate. An - // UNPUBLISHED app is externally unobservable, not merely unlisted: only - // builders (studio/setup access) receive it at all, for direct-URL - // preview. THIS is the visibility gate; the launcher's client-side - // filtering is a listing courtesy. - // - // ⛔ It judges `_unpublished`, the machine-managed key, and NOT `hidden`. - // `hidden` is navigation presentation — "not in the App Switcher, reach - // it from the avatar menu" — and reading it here made those two - // contracts one boolean. #4829 measured the cost: `account`, the - // platform's own personal-settings app, is authored `hidden: true` for - // exactly the reason its spec docblock gives, so this branch erased it - // from `GET /meta/app` for every user without builder access — password, - // avatar, sessions, inbox all 404 — while any admin saw a healthy - // system. A hidden app is fully routable and permission-checked here; - // only `_unpublished` withholds it. - if (item._unpublished === true && !sysPerms.has('studio.access') && !sysPerms.has('setup.access')) { - return { app: null, withheld: 'unpublished' }; - } - const reqApp = Array.isArray(item.requiredPermissions) ? item.requiredPermissions : []; - if (reqApp.length > 0 && !reqApp.every((p: string) => sysPerms.has(p))) { - return { app: null, withheld: 'permission' }; - } - // ADR-0057 D10 — capability gate: hide when the named kernel service is - // absent. Fail-open when the gate can't be probed (serviceGate undefined). - if (typeof item.requiresService === 'string' && serviceGate && serviceGate(item.requiresService) === false) { - return { app: null, withheld: 'service' }; - } - const nav = Array.isArray(item.navigation) ? item.navigation : null; - const areas = Array.isArray(item.areas) ? item.areas : null; - if (!nav && !areas) return { app: item }; - - const filterNav = (entries: any[]): any[] => { - const out: any[] = []; - for (const e of entries) { - if (!e || typeof e !== 'object') continue; - const req = Array.isArray(e.requiredPermissions) ? e.requiredPermissions : []; - if (req.length > 0 && !req.every((p: string) => sysPerms.has(p))) continue; - if (typeof e.requiresService === 'string' && serviceGate && serviceGate(e.requiresService) === false) continue; - // [#19790] DOCS AUDIENCE — the rule `DocNavItemSchema` declares - // and, until this arm, only a renderer honoured: a `doc` entry - // naming a doc the caller may not read, or a book with no page - // they may read, is not served. Left in the body, the entry's - // label and its book / doc names reached every member of the - // app however the book was gated, and reading the JSON - // defeated whatever the shell pruned (the #4722 lesson again). - // - // The verdict is the docs reads' own — `docAudienceGate` is one - // `DocsAudience` built by the caller, the same resolution - // `/meta/doc` and `/meta/book/:name/tree` answer from — and the - // arm FAILS CLOSED where its neighbours fail open: no gate, no - // `doc` entry. Like every entry-level arm here it is a bare - // `continue` with no reason attached; `withheld` reports only - // why a whole APP was withheld, and an app this arm empties is - // still served, exactly as one emptied by `requiredPermissions`. - if (e.type === 'doc' && (!docAudienceGate || !docAudienceGate(e))) continue; - // [#7912] SERVABILITY — the gate this filter had no vocabulary - // for. A `type: 'object'` entry names its destination in - // `objectName`; the object's own `enable` block decides whether - // a `list` can be answered there, and that decision takes no - // user, no permissions and no context. So an entry whose - // destination is API-disabled (404 `OBJECT_API_DISABLED`) or - // whose whitelist omits `list` (405 - // `OBJECT_API_METHOD_NOT_ALLOWED`) is dead for EVERY persona, - // platform admin included — which is why no combination of - // `requiredPermissions` on the entry could ever prune it - // (#7544 shipped exactly that combination for a year). - // - // The verdict comes from the same derivation the data route - // enforces (`apiExposureDenialReason`, #3391), reached through - // the gate the caller built — never a second reading of - // `enable` here. - if (servabilityGate && e.type === 'object' && typeof e.objectName === 'string') { - const appName = typeof item.name === 'string' ? item.name : '(unnamed)'; - if (servabilityGate(e.objectName, e, appName) === false) continue; - } - // [#7380] A `group` is judged on what SURVIVES, never on how it - // got there. Both childless shapes render the same dead sidebar - // label, so both are dropped: - // - BECAME empty — authored with children, all gated away; - // - STARTED empty — authored `children: []`. - // The old guard (`children.length > 0`) sent the second shape - // down the else branch, which never reaches the drop rule, so a - // declared-empty group shipped as a bare label the docblock - // above already promised it would not. That shape is not a - // corner case: `setup.app.ts` is authored entirely out of it — - // nine `children: []` contribution slots (ADR-0029 D7) that - // `Registry.applyNavContributions` fills on read, BEFORE this - // filter runs. So a slot a capability plugin filled arrives here - // with children and survives; a slot left empty because its - // capability is disabled arrives `[]` and is now dropped, which - // is exactly the "a disabled capability contributes nothing and - // its slot stays empty" case `setup.app.ts` documents. - // - // The rule is `type === 'group'` ONLY, and stays that way. The - // union nests on two branches (`NAV_VARIANTS_ACCEPTING_CHILDREN` - // = `object` | `group`), and an `object` entry is its own - // navigation target — `{ type: 'object', objectName: 'lead', - // children: [] }` is a live link to the lead list, not a label, - // so emptiness says nothing about whether to serve it. A group - // cannot be a target: `GroupNavItemSchema` is a `strictObject` - // over the base keys plus `expanded`/`children` and declares no - // `objectName` / `pageName` / `componentRef` / `url` — it - // REJECTS them — and its docblock reads "Does not perform - // navigation itself." Measured against that before the change - // (#7380): 41 `type: 'group'` entries across the shipped apps - // (`account`, `setup`, `studio`), the examples (`app-crm`, - // `app-showcase`, `app-todo`) and the spec's nav type-assertion - // fixtures. 16 are childless — the 9 `setup` slots and 7 spec - // fixtures; the three example apps have none — and ZERO of the - // 41 carry `objectName` / `pageName` / `componentRef` / `url` or - // any other target. So the drop is unconditional: there is no - // standalone childless-group shape in the tree to spare. - // - // A group with NO `children` key is covered by the same rule for - // the same reason — same dead label. It is unreachable through - // the spec (`children` is required on both the input and output - // group branches; `app.nav-type-assertions.ts` pins that with a - // `@ts-expect-error`), but this filter reads untyped documents - // off the metadata store, so leaving it out would just reopen - // the bypass one keyword over. - if (Array.isArray(e.children)) { - const kids = filterNav(e.children); - if (e.type === 'group' && kids.length === 0) continue; - out.push({ ...e, children: kids }); - } else { - if (e.type === 'group') continue; - out.push(e); - } - } - return out; - }; - - // [#4722] `areas[]` carries no gate of its own — the area-level `visible` - // / `requiredPermissions` keys were retired in 17.0.0 (#4651, ADR-0049) - // and are NOT revived here. What is enforced is the gate on the items - // INSIDE an area, through the very same `filterNav` the top-level tree - // uses, so the two trees can never disagree about what a key means. - // - // Collapse rule: an area whose authored tree is emptied BY the gate is - // dropped (a bare area label with nothing reachable under it is not a - // useful response), while an area authored `navigation: []` is passed - // through untouched — filtering reports what the caller may not see, it - // does not tidy the metadata. - // - // [#7380] That second half is where an area and a `group` now DIVERGE, - // deliberately: `filterNav` drops a childless group however it got that - // way, an area authored empty still ships. The reason is what the two - // shapes are. A group is a sidebar label and nothing else, so childless - // it renders dead — and the shipped `setup` app authors nine of them as - // contribution SLOTS, which makes "declared empty" the normal steady - // state of an unfilled one rather than an authoring slip. An area is a - // top-level workspace the shell can select and route to on its own; an - // author who ships `navigation: []` has declared an area that is not - // populated yet, and this filter is not the layer that judges that. - // What is NOT divergent is the walk: an area whose entries are all - // childless groups empties through the very same `filterNav` and is - // dropped by the rule above — one implementation, as everywhere else. - const filterAreas = (list: any[]): any[] => { - const out: any[] = []; - for (const a of list) { - if (!a || typeof a !== 'object') continue; - const anav = Array.isArray(a.navigation) ? a.navigation : null; - if (!anav || anav.length === 0) { out.push(a); continue; } - const kids = filterNav(anav); - if (kids.length === 0) continue; - out.push({ ...a, navigation: kids }); - } - return out; - }; - - return { - app: { - ...item, - ...(nav ? { navigation: filterNav(nav) } : {}), - ...(areas ? { areas: filterAreas(areas) } : {}), - }, - }; + return metaReadGate.filterAppForUser(item, sysPerms, serviceGate, servabilityGate, docAudienceGate); } /** * ADR-0057 D10 (dashboards): strip dashboard widgets whose `requiresService` - * capability gate names a kernel service that isn't registered — the same - * "server is the authoritative visibility gate" rule already applied to app - * nav entries (see {@link filterAppForUser}). Without this, a widget bound to - * an optional service renders a dead tile in deployments where the service is - * off (e.g. the Organizations KPI under multi-tenant `org-scoping`, which is - * absent in a single-tenant runtime while its nav entry is correctly hidden). - * - * Fail-open when the gate can't be probed (serviceGate undefined). Never - * mutates the original — returns a shallow copy only when a widget is dropped. - * - * Takes the **dashboard document**, never the `getMetaItem` envelope — see - * {@link filterAppForUser} for why that distinction stopped being a runtime - * question in #5563. + * names a service that isn't registered — `filterDashboardForUser` in + * `./meta-item-read-gate.ts`, the one implementation. */ private filterDashboardForUser(item: any, serviceGate?: (name: string) => boolean): any { - if (!item || typeof item !== 'object' || !serviceGate) return item; - if (!Array.isArray(item.widgets)) return item; - const widgets = item.widgets.filter( - (w: any) => !(w && typeof w.requiresService === 'string' && serviceGate(w.requiresService) === false), - ); - return widgets.length === item.widgets.length ? item : { ...item, widgets }; + return metaReadGate.filterDashboardForUser(item, serviceGate); } /** - * [#20156] THE read gate of one `/meta/:type/:name` document, for the plain - * read and every door beside it. - * - * ## Why one spelling - * - * The plain read ran these gates inline, and the doors that serve the same - * document — the layered view (`/layers` and the deprecated `?layers=`), - * `/published`, `/history`, `/audit`, `/diff` — ran none of them. So a - * member the plain read refuses `403` read a `{ permissionSet }`-gated doc's - * body from three of those doors, an anonymous caller read any doc or book - * through `?layers=true`, and an app's `requiredPermissions` entries reached - * every member. The by-name app route's own rule — it "must not serve a nav - * entry the list route prunes, or reading the single-app JSON defeats the - * filter" — held for one door out of seven (the layered view and `/diff` - * still serve an app the caller may see only in PART as stored, pending a - * decision: `MetaReadGatePolicy.app`). Each - * door now asks THIS function, so a gate added here reaches all of them, - * and the census in - * `meta-alternate-door-read-gates.test.ts` — its door list read off the - * route table — fails a door that does not ask. - * - * ## The gates, per type (unchanged from the plain read they came out of) - * - * - `app` — {@link filterAppForUserWithReason}, for an authenticated - * caller. `permission` → `403 PERMISSION_DENIED` (#8013, the one - * withheld reason the ruling lets report itself); `unpublished` and - * `service` → the absence answer ({@link sendMetaItemAbsent}, ADR-0045 - * §3: an unpublished app is externally unobservable). [#7912] The - * servability gate and [#19790] the docs-audience entry arm ride along. - * ⚠️ Under `app: 'pending-decision'` — the layered view and `/diff` — - * an app the plain read refuses WHOLE is refused the same way, and one - * it would serve only in PART is served as stored, unpruned: see - * `MetaReadGatePolicy.app` for the decision it waits on. - * - `dashboard` — ADR-0057 D10 {@link filterDashboardForUser}. A - * per-DEPLOYMENT gate (which optional services are registered), never - * per-caller, so `arms: 'per-caller'` skips it. - * - `book` — ADR-0046 §6.7, the book's own audience. - * - `doc` — ADR-0046 §6.7, the EFFECTIVE audience (union over the books - * that claim it, unclaimed → `org`). [#20129] Both gate inputs fail - * CLOSED by throwing: a book-read fault throws in - * {@link fetchAudienceBooks}, a corpus-read fault below — read as `[]` - * it made the doc unclaimed, i.e. `org`, and served it to every member. - * Refused `401 UNAUTHENTICATED` anonymous, `403 PERMISSION_DENIED` - * otherwise; holdings that cannot be resolved deny (ADR-0049). - * - * One audience resolution, the docs reads' own ({@link resolveDocsAudience}), - * for every door — ⛔ no second resolver. NOT here: the ADR-0106 object mask, - * which each exit already threads as a posture resolved before its fetch - * (D2/D3's `fetch → mask → send`), and which the doors now apply too. - * - * ## Shape - * - * Returns a judge for this request, and resolves each type's inputs ONCE, - * on the first document judged — the layered view judges three, `/diff` - * up to three, and they share one books read, one corpus read, one - * holdings resolution. `documents` is every document the answer may judge, - * so the app arm's nav probes see all of them at once. `null`/`undefined` - * is served as given: absence is each door's own answer. + * [#20156 · #20193] THE read gate of one `/meta/:type/:name` document, for + * the plain read and every door beside it — `createMetaItemReadGate` in + * `./meta-item-read-gate.ts`, the ONE implementation both transports call + * (the runtime dispatcher's `/meta` item read asks it too). Its docblock + * carries the gates, per type, and their history; ⛔ a gate is added + * there, never here. + * + * This transport supplies its I/O ({@link metaItemReadGateSources}) and + * writes a refusal on its own wire ({@link sendMetaReadRefusal}) — the + * plain read's refusal, byte for byte, whichever door asked. */ private metaItemReadGate( environmentId: string | undefined, @@ -3897,140 +3244,87 @@ export class RestServer { documents: readonly any[], policy: MetaReadGatePolicy, ): (document: any) => Promise { - const serve = (document: any): MetaReadVerdict => ({ kind: 'serve', document }); - const refuse = (send: (res: any) => void): MetaReadVerdict => ({ kind: 'refuse', send }); - const docsAudienceRefusal = (caller: AudienceCaller) => (res: any): void => { - if (!caller.authenticated) { - sendDeclaredFault(res, { code: 'UNAUTHENTICATED', message: 'This documentation requires sign-in', status: 401 }); - } else { - sendDeclaredFault(res, { code: 'PERMISSION_DENIED', message: 'This documentation is limited to holders of a permission set you do not have', status: 403 }); - } + const judge = metaReadGate.createMetaItemReadGate( + this.metaItemReadGateSources(environmentId, req, p), metaType, name, documents, policy, + ); + return async (document) => { + const verdict = await judge(document); + if (verdict.kind === 'serve') return verdict; + const { refusal } = verdict; + return { kind: 'refuse', send: (res: any) => RestServer.sendMetaReadRefusal(res, refusal) }; }; + } - if (metaType === 'app') { - type AppGateInputs = { - sysPerms: Set; - serviceGate?: (n: string) => boolean; - servabilityGate?: NavServabilityGate; - docAudienceGate?: NavDocAudienceGate; - }; - let inputs: Promise | undefined; - const resolveInputs = (): Promise => (inputs ??= (async () => { - const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); - if (!ctx?.userId) return null; - const sysPerms = new Set( - Array.isArray(ctx.systemPermissions) ? ctx.systemPermissions : [], - ); - let serviceGate: ((n: string) => boolean) | undefined; - let servabilityGate: NavServabilityGate | undefined; - if (policy.arms === 'all') { - const registered = await this.resolveRegisteredServices((ctx as any).__kernel, [...documents]); - serviceGate = registered ? (n: string) => registered.has(n) : undefined; - // [#7912] Same gate as the list route — the by-name route - // must not serve a nav entry the list route prunes, or - // reading the single-app JSON defeats the filter (the - // #4722 lesson, one gate over). - servabilityGate = await this.resolveNavServability(p, environmentId) ?? undefined; - } - // [#19790] And the same docs-audience gate, for the same reason: - // a `doc` entry the list route prunes must not come back here. - // Not resolved under `pending-decision`: it only prunes ENTRIES, - // and that arm serves the stored app unpruned (see below), so - // its reads would decide nothing. - const docAudienceGate = policy.app === 'gate' - ? await this.resolveNavDocAudience(p, environmentId, req, documents) - : undefined; - return { sysPerms, serviceGate, servabilityGate, docAudienceGate }; - })()); - return async (document) => { - if (document == null) return serve(document); - const gateInputs = await resolveInputs(); - if (!gateInputs) return serve(document); - const gated = this.filterAppForUserWithReason( - document, gateInputs.sysPerms, gateInputs.serviceGate, - gateInputs.servabilityGate, gateInputs.docAudienceGate); - if (gated.app == null) { - // [#8013] A PERMISSION denial is reported as one — - // everything else keeps answering absence. See - // {@link filterAppForUserWithReason} for why only this one - // of the three gates converts, and why the reason comes - // from the branch that fired rather than from `null`. - // - // The ADR-0112 STANDARD catalog code, written through the - // shared `sendError` (`@objectstack/types`) — the declared - // envelope `{ success: false, error: { code, message } }`, - // so the console reads `body.error.code` (objectui#4252 - // branches on exactly this `code`). - if (gated.withheld === 'permission') { - return refuse((res) => sendEnvelopeError( - res, 403, 'PERMISSION_DENIED', - `You do not have permission to open the '${name}' app.`, - )); - } - // [#18066] Through the shared emitter, so this arm and the - // nothing-behind-the-name arm are byte-identical by - // construction (ADR-0045 §3). - return refuse(sendMetaItemAbsent); - } - // ⚠️ A DECLARED EXEMPTION, pending the maintainer's decision - // (anchor #20156) — see `MetaReadGatePolicy.app`. An app the - // plain read refuses WHOLE was refused above, exactly as the - // plain read refuses it; one it would serve only in PART is - // served as STORED, unpruned, exactly as before this gate. - return serve(policy.app === 'pending-decision' ? document : gated.app); - }; - } - - if (metaType === 'dashboard') { - if (policy.arms !== 'all') return async (document) => serve(document); - // ADR-0057 D10: gate dashboard widgets by `requiresService` (mirrors - // the app-nav gate above) so the console never renders a tile bound - // to an absent optional service. [#5881] On the DEFAULT path since - // the plain read's cache exclusion — see the `isDashboardType` - // comment there. - return async (document) => { - if (document == null) return serve(document); - const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); - const registered = await this.resolveRegisteredServices((ctx as any)?.__kernel, [document]); - const serviceGate = registered ? (n: string) => registered.has(n) : undefined; - return serve(serviceGate ? this.filterDashboardForUser(document, serviceGate) : document); - }; + /** + * [#20193] Write the shared gate's refusal on THIS transport's wire — the + * emitters the plain read has always used for each: + * + * - `absent` — {@link sendMetaItemAbsent}, byte-identical to the + * nothing-behind-the-name answer (#18066, ADR-0045 §3); + * - `app-permission` — the ADR-0112 standard envelope through the shared + * `sendError` (`@objectstack/types`), `{ success: false, error: { code, + * message } }`, the `body.error.code` objectui#4252 branches on (#8013); + * - `docs-audience` — {@link sendDeclaredFault}, `401 UNAUTHENTICATED` / + * `403 PERMISSION_DENIED` (ADR-0046 §6.7). + */ + private static sendMetaReadRefusal(res: any, refusal: MetaItemReadRefusal): void { + switch (refusal.reason) { + case 'absent': + sendMetaItemAbsent(res); + return; + case 'app-permission': + sendEnvelopeError(res, refusal.status, refusal.code, refusal.message); + return; + case 'docs-audience': + sendDeclaredFault(res, { code: refusal.code, message: refusal.message, status: refusal.status }); + return; } + } - if (metaType === 'book') { - // The book's own audience — holdings resolved only when THIS book - // is set-gated, so one resolution per document judged. - return async (document) => { - if (document == null) return serve(document); - const audience = await this.resolveDocsAudience(environmentId, req, [document]); - return audience.admitsBook(document) ? serve(document) : refuse(docsAudienceRefusal(audience.caller)); - }; - } + /** + * [#20193] This transport's I/O, as the shared read gate takes it: the + * caller from {@link resolveExecCtx} (an authz-store outage re-raised, + * never read as anonymous), the protocol's list read, the security service + * provider, the service probe, and this instance's prune-log dedupe. + */ + private metaItemReadGateSources( + environmentId: string | undefined, + req: any, + p: RestProtocol, + ): MetaItemReadGateSources { + return { + ...this.metaReadAudienceSources(environmentId, req), + ...this.metaListSource(p, environmentId), + serviceProbe: (caller) => this.serviceProbeFor((caller as any)?.__kernel), + navPruneLogged: this.navPruneLogged, + }; + } - if (metaType === 'doc') { - // A doc's effective audience reads its NAME against the books that - // claim it — never its body — so the verdict is the same for every - // version of it, and the inputs are resolved once. - let reader: Promise<{ caller: AudienceCaller; canRead: (docName: unknown) => boolean }> | undefined; - const resolveReader = () => (reader ??= (async () => { - const books = await this.fetchAudienceBooks(p, environmentId); - const audience = await this.resolveDocsAudience(environmentId, req, books); - // No gated book anywhere → org suffices. - if (audience.allReadable) return { caller: audience.caller, canRead: () => true }; - const read = await this.readDocCorpus(p, environmentId); - if ('fault' in read) throw read.fault; - return { caller: audience.caller, canRead: audience.docReader(read.items) }; - })()); - return async (document) => { - if (document == null) return serve(document); - // [#5563] `audience` is read off the DOCUMENT, never an - // envelope — an envelope's name-less shape would grant everyone. - const { caller, canRead } = await resolveReader(); - return canRead(document?.name) ? serve(document) : refuse(docsAudienceRefusal(caller)); - }; - } + /** [#20193] The caller half of {@link metaItemReadGateSources}. */ + private metaReadAudienceSources(environmentId: string | undefined, req: any): MetaReadGateAudienceSources { + return { + resolveCaller: () => this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable), + resolveSecurityService: async () => ( + this.securityServiceProvider ? this.securityServiceProvider(environmentId) : undefined + ), + }; + } - return async (document) => serve(document); + /** + * [#20193] The list half of {@link metaItemReadGateSources}: one + * `getMetaItems` read of a whole type, env-scoped, `undefined` when this + * protocol has no list read at all. + */ + private metaListSource(p: RestProtocol, environmentId: string | undefined): MetaReadGateListSource { + return { + listMetaItems: (type) => { + const listRequest: TransportScopedMetaRequest = { + type, + ...(environmentId ? { environmentId } : {}), + }; + return p.getMetaItems?.(listRequest); + }, + }; } /** @@ -4138,216 +3432,53 @@ export class RestServer { /** * Probe which `requiresService` capability gates referenced anywhere in - * `items` are actually registered in the runtime kernel. Returns `null` - * when the kernel can't be probed — callers then SKIP service gating - * (fail-open, matching the prior "send everything, let the client hide" - * behaviour). ADR-0057 addendum D10. - * - * `items` are metadata **documents** (#5563) — the single-item route - * unwraps the envelope before probing, exactly as it does before gating. + * `items` are actually registered — `resolveRegisteredServices` in + * `./meta-item-read-gate.ts`, over {@link serviceProbeFor}. `null` when + * nothing can be probed (the service gates then fail OPEN, ADR-0057 D10). */ private async resolveRegisteredServices(kernel: any, items: any[]): Promise | null> { - // Prefer the per-request kernel (multi-env, resolved via kernelManager). - // Fall back to the single-env service-existence provider — in single-kernel - // deployments resolveExecCtx never sets a kernel, so without this the gate - // would fail open (ADR-0057 D10). - let probe: ((name: string) => Promise) | null = null; + return metaReadGate.resolveRegisteredServices(this.serviceProbeFor(kernel), items); + } + + /** + * [ADR-0057 D10] This transport's service-existence probe. Prefer the + * per-request kernel (multi-env, resolved via kernelManager). Fall back to + * the single-env service-existence provider — in single-kernel deployments + * resolveExecCtx never sets a kernel, so without this the gate would fail + * open. `null` when neither can be asked. + */ + private serviceProbeFor(kernel: any): ((name: string) => Promise) | null { if (kernel && typeof kernel.getServiceAsync === 'function') { - probe = async (name) => { try { return (await kernel.getServiceAsync(name)) != null; } catch { return false; } }; - } else if (this.serviceExistsProvider) { + return async (name) => { try { return (await kernel.getServiceAsync(name)) != null; } catch { return false; } }; + } + if (this.serviceExistsProvider) { const exists = this.serviceExistsProvider; - probe = async (name) => { try { return exists(name) === true; } catch { return false; } }; + return async (name) => { try { return exists(name) === true; } catch { return false; } }; } - if (!probe) return null; - const wanted = new Set(); - const walk = (e: any): void => { - if (!e || typeof e !== 'object') return; - if (typeof e.requiresService === 'string') wanted.add(e.requiresService); - // [#4722] EVERY child list, not the first one that happens to be an - // array. An app may carry `navigation` AND `areas` at once, and now - // that `filterAppForUser` gates the trees under `areas[]` too, a - // service named only in there must be probed — an unprobed name is - // absent from `registered`, and the gate would read that as "service - // missing" and strip a live entry. Fail-closed by omission is still - // wrong; the probe set must cover exactly what the gate walks. - for (const key of ['navigation', 'areas', 'children', 'widgets'] as const) { - const kids = (e as any)[key]; - if (Array.isArray(kids)) for (const k of kids) walk(k); - } - }; - for (const it of items) walk(it); - if (wanted.size === 0) return new Set(); - const registered = new Set(); - for (const name of wanted) { if (await probe(name)) registered.add(name); } - return registered; + return null; } /** - * [#7912] Build the nav-servability gate for one request: which objects can - * actually answer a `list` on the external REST surface. - * - * ## Shape, and why it mirrors `resolveRegisteredServices` - * - * Same contract as the ADR-0057 D10 service gate one method up: resolve the - * facts ONCE per request, hand `filterAppForUser` a closure, and return - * `null` when the facts cannot be established so the caller skips the gate - * entirely. Nav filtering already runs over a whole app list; re-reading - * object metadata per entry would turn one read into dozens. - * - * ## Fail-open, in three distinct cases — each deliberate - * - * 1. **Metadata unreadable** — `loadObjectItems` answers `[]` and logs. - * This method then answers `null` (no gate), so nothing is pruned. The - * alternative fails CLOSED during every cold start, emptying the - * sidebar of a healthy deployment; #3545 already settled that trade for - * the data-route twin and the same reasoning binds harder here, where - * the consequence is a user staring at an app with no navigation. - * 2. **Object not in metadata** — served. There is no declared exposure - * policy to enforce (#3770), and "is this object registered at all?" is - * `requiresObject`'s question, which this layer deliberately does not - * answer (see {@link filterAppForUser}). - * 3. **No `enable` block** — served, by `apiExposureDenialReason`'s own - * default-open contract. An object that declares nothing restricts - * nothing. - * - * Only case (3)'s opposite — a declared `enable` that refuses `list` — ever - * prunes. - * - * ## The prune is LOGGED, never silent - * - * The maintainer ruling of 2026-08-12 makes the author-visible diagnostic a - * mandatory companion, not an optional one: "a prune the author cannot see - * is the same failure one layer over — no silent dead rows, and no silent - * repairs." The authoring-time half of that is - * `validate-nav-object-servability` in `@objectstack/lint`, which refuses - * the stack at `os validate` / `os build` / `os lint` before it can ever be - * served. This log is the serving-side half, for an entry that reached a - * running deployment anyway (a `sys_metadata` overlay row, or a stack built - * before the lint existed): it names the app, the entry id, the object AND - * the condition, so the pruned row is discoverable from the server log - * rather than being an unexplained gap in a menu. - * - * One line per `app|entry|object|reason` per process — a console session - * re-fetches `/meta/app` on every navigation, and an unthrottled log would - * bury the first occurrence under thousands of repeats. + * [#7912] The nav-servability gate for one request — + * `resolveNavServability` in `./meta-item-read-gate.ts` (its docblock + * carries the three fail-open cases and the logged prune), over this + * transport's object list read and this instance's prune-log dedupe. */ private async resolveNavServability( p: RestProtocol, environmentId: string | undefined, ): Promise { - const items = await this.loadObjectItems(p, environmentId); - // Case (1): nothing to judge with. `loadObjectItems` has already logged - // a THROWN read; a legitimately empty registry is silent and equally - // ungated, which is correct — an empty registry declares no policy. - if (items.length === 0) return null; - const enableByName = new Map(); - for (const o of items) { - if (o && typeof o.name === 'string') enableByName.set(o.name, o.enable); - } - return (objectName: string, entry: any, appName: string): boolean => { - // Case (2): unknown object → no declared policy to enforce here. - if (!enableByName.has(objectName)) return true; - const reason = apiExposureDenialReason(enableByName.get(objectName), 'list'); - if (!reason) return true; - const entryId = (entry && (entry.id ?? entry.label)) ?? '(unnamed)'; - const key = `${appName}|${entryId}|${objectName}|${reason}`; - if (!this.navPruneLogged.has(key)) { - this.navPruneLogged.add(key); - logWarn( - `[REST] [#7912] nav entry '${entryId}' pruned from app '${appName}': its destination ` + - `object '${objectName}' cannot serve a list — ` + - (reason === 'api-disabled' - ? `\`enable.apiEnabled: false\` (the list answers 404 OBJECT_API_DISABLED for every user).` - : `\`enable.apiMethods\` does not grant \`list\` (the list answers 405 ` + - `OBJECT_API_METHOD_NOT_ALLOWED for every user).`) + - ` Remove the entry, or expose the object — \`os validate\` refuses this stack ` + - `(nav-object-unservable).`, - ); - } - return false; - }; + return metaReadGate.resolveNavServability({ + ...this.metaListSource(p, environmentId), + navPruneLogged: this.navPruneLogged, + }); } /** - * [#19790] Build the docs-audience nav gate for one request: may THIS - * caller read what a `type: 'doc'` nav entry opens (ADR-0046 §6.7, the rule - * `DocNavItemSchema` declares). - * - * ## One resolution, not a second one - * - * Every verdict comes from the {@link DocsAudience} that `/meta/doc`, - * `/meta/doc/:name` and `/meta/book/:name/tree` answer from, built over the - * same env-wide books the doc reads use and a doc corpus read the way - * `/meta/doc/:name` reads it. Per entry shape: - * - * - **`doc` alone** — served iff the doc's effective audience admits the - * caller: `docAudienceAllows` over `resolveDocAudiences`, the answer - * `/meta/doc/:name` gives. - * - **`book` alone** — the book the name names (a declared book, else the - * implicit per-package book, §6.4 — the tree read's own lookup) must - * admit the caller by its own audience (the tree read's 401/403), and at - * least one of its PAGES must be readable: a doc the book claims - * (`resolveBookClaimedDocs`, the membership `resolveDocAudiences` uses) - * whose effective audience admits the caller. Not "any entry of the - * tree": `resolveBookTree` appends every doc the book does NOT claim as - * a synthetic *Uncategorized* group, so over an env-wide corpus nearly - * every book's tree holds some readable doc, and the rule would never - * fire. The spec calls those orphans "not an authored membership - * claim"; external `href` links are not pages either. - * - **`book` + `doc`** — served iff BOTH hold: the book's own audience - * admits the caller AND the doc is readable. A doc can be readable while - * the book is not (its effective audience is the UNION over every book - * claiming it, `docAudienceAllows`), but the entry opens that page in - * that book's context, whose tree read answers 401/403 — and the entry - * itself names the gated book. So it is dropped; it does not fall back - * to the page alone. - * - **neither** — dropped. The spec refuses the shape; this filter reads - * untyped stored documents, and an entry with no target has nothing a - * caller could read. - * - * Existence is `docs/nav-target`'s question, answered at `os build`, and - * this gate asks only the resolver's. So a `doc` naming a doc absent from - * the corpus is SERVED — the resolver's own default for a doc it has no - * entry for is `org` (`docAudienceAllows`), so an authenticated caller may - * read it, and there is no gated audience behind a name that resolves to - * nothing. A `book` naming no declared book is judged as the implicit book - * of a package by that name — what the tree read serves for it — so when no - * doc resolves into it, it has no readable page and is NOT served: "no - * readable page" is the book rule's own wording. - * - * ## Fails CLOSED, and says so - * - * The arm this feeds treats an absent gate as "drop every `doc` entry", and - * so does this builder when a read it needs THROWS: the books read (an - * empty list there reads as "no gated book anywhere") or the doc corpus - * read (an empty corpus reads every doc as unclaimed, i.e. `org`). Either - * empty would serve a `{ permissionSet }`-gated entry to every member, so - * here a thrown read drops the `doc` entries of this one response and logs - * the fault — the rest of the navigation is served. The doc reads close - * the same two faults by handing them to their caller instead - * ({@link fetchAudienceBooks}): each serves one doc or one doc list, so - * there is no rest to serve. Unresolvable permission-set HOLDINGS - * already deny inside {@link resolveAudienceCaller} (ADR-0049). - * - * ## Cost, per `/meta/app` request (measured by this card's tests) - * - * Nothing, when no app in `apps` carries a `doc` entry: a walk over the nav - * trees, and no read at all. Otherwise, ONCE per request whatever the app - * count — the same shape as {@link resolveNavServability}: - * - * - one `book` list read; - * - one permission-set resolution, only when some book is set-gated - * (the execution context itself is memoised per request); - * - one `doc` list read, only when the fast path does not decide (a - * set-gated book exists) or some entry is `book` alone (its page count - * needs the corpus); - * - off the fast path, one `resolveDocAudiences` pass (a `resolveBookTree` - * per book over the whole corpus) shared by every entry. - * - * Per entry: a `doc` is one map lookup; a `book` alone is one - * `resolveBookClaimedDocs` of that book over the corpus (one - * `resolveBookTree`: every doc visited once per group rule) plus one - * lookup per claimed page. ⛔ No cache — nothing outlives the request. + * [#19790] The docs-audience nav gate for one request — + * `resolveNavDocAudience` in `./meta-item-read-gate.ts` (its docblock + * carries the per-entry rules, the fail-closed reads and the cost), over + * this transport's caller and list read. */ private async resolveNavDocAudience( p: RestProtocol, @@ -4355,79 +3486,10 @@ export class RestServer { req: any, apps: readonly any[], ): Promise { - const entries = RestServer.docNavEntries(apps); - // Nothing to judge — and the arm drops a `doc` entry this walk missed, - // so a walk that ever falls behind `filterNav` fails closed, not open. - if (entries.length === 0) return undefined; - - const failClosed = (what: string, fault: unknown): NavDocAudienceGate => { - logWarn( - `[REST] app-nav docs-audience gate: the ${what} read failed — failing CLOSED: every ` + - "`type: 'doc'` navigation entry is left out of this response, because whether the " + - 'caller may read what it names could not be established. The rest of the navigation ' + - 'is served.', - (fault as Error)?.message ?? fault, - ); - return () => false; - }; - - const books = await this.readAudienceBooks(p, environmentId); - if ('fault' in books) return failClosed('book', books.fault); - const audience = await this.resolveDocsAudience(environmentId, req, books.items); - - const bookAlone = (e: any): boolean => - RestServer.navTarget(e.book) !== undefined && RestServer.navTarget(e.doc) === undefined; - let corpus: ResolverDoc[] = []; - if (!audience.allReadable || entries.some(bookAlone)) { - const read = await this.readDocCorpus(p, environmentId); - if ('fault' in read) return failClosed('doc', read.fault); - corpus = read.items; - } - // Built ONCE for every entry of every app in this response. - const canRead = audience.docReader(corpus); - - return (entry: any): boolean => { - const bookName = RestServer.navTarget(entry?.book); - const docName = RestServer.navTarget(entry?.doc); - if (bookName === undefined && docName === undefined) return false; - if (bookName !== undefined) { - const book = audience.bookNamed(bookName); - if (!audience.admitsBook(book)) return false; - if (docName === undefined) return audience.readablePages(book, corpus, canRead).length > 0; - } - return canRead(docName); - }; - } - - /** A `doc` nav entry's `book` / `doc` target, when it names one. */ - private static navTarget(value: unknown): string | undefined { - return typeof value === 'string' && value.length > 0 ? value : undefined; - } - - /** - * Every `type: 'doc'` entry in these apps, in every tree `filterNav` walks — - * top-level `navigation`, `areas[].navigation`, and `children` at any depth. - */ - private static docNavEntries(apps: readonly any[]): any[] { - const found: any[] = []; - const walk = (entries: unknown): void => { - if (!Array.isArray(entries)) return; - for (const e of entries) { - if (!e || typeof e !== 'object') continue; - if (e.type === 'doc') found.push(e); - walk(e.children); - } - }; - for (const app of apps) { - if (!app || typeof app !== 'object') continue; - walk(app.navigation); - if (Array.isArray(app.areas)) { - for (const area of app.areas) { - if (area && typeof area === 'object') walk(area.navigation); - } - } - } - return found; + return metaReadGate.resolveNavDocAudience( + { ...this.metaReadAudienceSources(environmentId, req), ...this.metaListSource(p, environmentId) }, + apps, + ); } /** 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 new file mode 100644 index 00000000000..f97823acfe3 --- /dev/null +++ b/packages/runtime/src/domains/meta-item-read-gate-parity.test.ts @@ -0,0 +1,367 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20193] The dispatcher's `/meta` item reads answer what `RestServer`'s + * answer the same caller — because both ask ONE per-caller read gate. + * + * ## The defect this pins + * + * `handleMetadataRequest`'s two-segment item branch and its `/published` + * branch applied no per-caller read gate: no ADR-0046 §6.7 docs audience, no + * app nav filter (`requiredPermissions`, the unpublished gate, the docs-audience + * entry arm), and on `/published` not even the ADR-0106 object mask its own + * plain read runs. A host that mounts only the `${prefix}/*` catch-all — + * `@objectstack/hono`'s `createHonoApp`, the documented embed shape, and any + * thin adapter written on the public `HttpDispatcher` API — therefore served a + * `{ permissionSet }`-gated doc body, a set-gated book, and an app's + * `requiredPermissions`-gated nav entries to a member `RestServer` refuses or + * prunes. Measured before the fix, through `dispatch()` (the catch-all's + * delegate: identity resolution, the domain registry, this handler): + * + * row dispatcher RestServer + * doc/crm_admin_runbook × non-holder 200 + body 403 PERMISSION_DENIED + * doc/…/published × non-holder 200 + body 403 PERMISSION_DENIED + * book/admin_guide × non-holder 200 + book 403 PERMISSION_DENIED + * app/crm × non-holder 200, unpruned 200, pruned + * + * ## The fix, and why it is not a second resolver + * + * `RestServer`'s gate moved, unchanged, into `@objectstack/rest`'s + * `meta-item-read-gate.ts`; `RestServer` and this dispatcher each hand it their + * own I/O (the caller, the protocol reads, the security service) and render its + * data verdict on their own wire. One gate, two callers — ruling `5793362670` + * item 1 forbids a second audience resolver, and there is none: the cases below + * drive the SAME fixtures through both transports and compare the answers. + * + * The four rows above are the card's; the rest are the gate's other arms + * (a whole-refused app, an unpublished app, the per-deployment dashboard gate), + * the object mask on both doors, and controls (a holder, an ungated type, an + * anonymous caller). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from '@objectstack/rest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +// ── Fixtures (the #20156 census's own) ──────────────────────────────────────── + +const DOC_SECRET = 'Rotate the tenant signing keys before every release.'; +const BOOK_SECRET = 'Restricted spine: the incident playbooks.'; + +const ADMIN_GUIDE = { + name: 'admin_guide', + label: 'Admin Guide', + description: BOOK_SECRET, + 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 DOCS = [ + { name: 'crm_intro', label: 'Getting started', content: 'Welcome aboard.', _packageId: 'crm' }, + { name: 'crm_admin_runbook', label: 'Admin runbook', content: DOC_SECRET, _packageId: 'crm' }, +]; +const CRM_APP = { + name: 'crm', + label: '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' }, + ], +}; +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' }], +}; +const OPS_DASHBOARD = { + name: 'ops', + label: 'Operations', + widgets: [ + { id: 'w_open_cases', type: 'metric', label: 'Open cases' }, + // Bound to an optional service this deployment does not register. + { id: 'w_org_kpi', type: 'metric', label: 'Organizations', requiresService: 'tenancy' }, + ], +}; +const INVOICE_OBJECT = { + name: 'invoice', + label: 'Invoice', + fields: { + amount: { type: 'number', label: 'Amount' }, + secret_margin: { type: 'number', label: 'Margin' }, + }, +}; +const LEADS_VIEW = { name: 'all_leads', label: 'All leads', object: 'lead', columns: ['name'] }; + +const STORE: Record = { + book: [ADMIN_GUIDE, HELP_CENTER], + doc: DOCS, + app: [CRM_APP, PAYROLL_APP, LAUNCHPAD_APP], + dashboard: [OPS_DASHBOARD], + object: [INVOICE_OBJECT], + view: [LEADS_VIEW], +}; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); +const singular = (type: unknown): string => String(type ?? '').replace(/s$/, ''); +const find = (type: unknown, name: unknown): any => + (STORE[singular(type)] ?? []).find((i: any) => i.name === name); + +// ── Callers ─────────────────────────────────────────────────────────────────── + +interface Caller { + ctx: { userId?: string; isSystem: false; systemPermissions: string[] }; + holdings: string[]; + readableFields: string[]; +} +const CALLERS: Record<'holder' | 'non-holder' | 'anonymous', Caller> = { + holder: { + ctx: { userId: 'u_holder', isSystem: false, systemPermissions: ['finance.access', 'payroll.access', 'studio.access'] }, + holdings: ['crm_admin'], + readableFields: ['amount', 'secret_margin'], + }, + 'non-holder': { + ctx: { userId: 'u_member', isSystem: false, systemPermissions: [] }, + holdings: [], + readableFields: ['amount'], + }, + anonymous: { ctx: { isSystem: false, systemPermissions: [] }, holdings: [], readableFields: [] }, +}; +type CallerName = keyof typeof CALLERS; + +/** One protocol double, the same shape both transports read. */ +function protocolDouble() { + return { + getMetaTypes: vi.fn(async () => ({ types: Object.keys(STORE) })), + getMetaItems: vi.fn(async ({ type }: any) => clone(STORE[singular(type)] ?? [])), + getMetaItem: vi.fn(async ({ type, name }: any) => { + const found = find(type, name); + return { type: singular(type), name, item: found ? clone(found) : undefined, lock: 'none', editable: true, deletable: true, resettable: false }; + }), + getMetaItemLayered: vi.fn(async ({ type, name }: any) => { + const found = find(type, name); + const layer = () => (found ? clone(found) : null); + return { type: singular(type), name, code: layer(), overlay: layer(), effective: layer() }; + }), + }; +} + +const securityFor = (caller: Caller) => ({ + resolvePermissionSetNames: async () => caller.holdings, + getMetadataReadableFields: async () => caller.readableFields, +}); + +// ── The two transports ──────────────────────────────────────────────────────── + +interface Answer { status: number; code?: string; body: any; served?: any } + +/** The dispatcher, exactly as `createHonoApp` builds it: `new HttpDispatcher(kernel)`. */ +function bootDispatcher(callerName: CallerName) { + const caller = CALLERS[callerName]; + const protocol = protocolDouble(); + const services: Record = { + protocol, + security: securityFor(caller), + metadata: { getPublished: async (type: string, name: string) => clone(find(type, name)) }, + }; + 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; a context handed in is + // overwritten by it, so the principal is supplied here (as the sibling + // dispatch-level pins do). + (dispatcher as any).timedResolveExecutionContext = async () => clone(caller.ctx); + const read = async (path: string): Promise => { + const res = await dispatcher.dispatch('GET', path, undefined, {}, { request: { headers: {} } } as any); + const status = res.response?.status ?? 0; + const body = res.response?.body; + const data = body?.data; + // The item read serves the `{ type, name, item }` envelope; `/published` + // serves the document itself. + const served = status === 200 ? (path.endsWith('/published') ? data : data?.item) : undefined; + return { status, code: body?.error?.code, body, served }; + }; + 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 protocol double and the same caller — the reference answer. */ +function bootRest(callerName: CallerName) { + const caller = CALLERS[callerName]; + const rest: any = new RestServer(createMockServer() as any, protocolDouble() as any, {} as any); + if (caller.ctx.userId) { + const ctx = caller.ctx; + rest.resolveExecCtx = async () => clone(ctx); + } + rest.securityServiceProvider = async () => securityFor(caller); + // ADR-0057 D10 — `tenancy` is an optional service this deployment lacks. + rest.serviceExistsProvider = (name: string) => name !== 'tenancy'; + rest.metadataServiceProvider = async () => ({ + getPublished: async (type: string, name: string) => clone(find(type, name)), + }); + rest.registerRoutes(); + const META = '/api/v1/meta'; + const read = async (type: string, name: string, suffix = ''): Promise => { + const routePath = suffix === '/published' ? `${META}/:type/:name/published` : `${META}/:type/:name`; + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === routePath); + if (!route) throw new Error(`GET ${routePath} is not registered`); + const res = makeRes(); + await route.handler({ method: 'GET', path: `${META}/${type}/${name}${suffix}`, params: { type, name }, query: {}, body: {}, headers: {} }, res); + const served = res.statusCode === 200 ? (suffix === '/published' ? res.body : res.body?.item) : undefined; + return { status: res.statusCode, code: res.body?.code ?? res.body?.error?.code, body: res.body, served }; + }; + return { read }; +} + +const text = (a: Answer): string => JSON.stringify(a.body ?? null); +const navIds = (doc: any): string[] => (doc?.navigation ?? []).map((e: any) => e.id); +const widgetIds = (doc: any): string[] => (doc?.widgets ?? []).map((w: any) => w.id); +const fieldNames = (doc: any): string[] => Object.keys(doc?.fields ?? {}).sort(); + +// ── The rows ────────────────────────────────────────────────────────────────── + +interface Row { + type: string; + name: string; + /** Strings a caller the gate withholds them from must never receive. */ + secrets: string[]; + /** What `RestServer` answers the non-holder — asserted, so the reference cannot drift. */ + nonHolder: { status: number; code?: string; shape?: 'pruned' | 'masked' | 'deployment-pruned' | 'whole' }; +} + +const ROWS: Row[] = [ + // The card's four rows (the fourth on both doors). + { type: 'doc', name: 'crm_admin_runbook', secrets: [DOC_SECRET], nonHolder: { status: 403, code: 'PERMISSION_DENIED' } }, + { type: 'book', name: 'admin_guide', secrets: [BOOK_SECRET], nonHolder: { status: 403, code: 'PERMISSION_DENIED' } }, + { type: 'app', name: 'crm', secrets: ['nav_finance_ledger', 'nav_admin_runbook'], nonHolder: { status: 200, shape: 'pruned' } }, + // The gate's other arms. + { type: 'app', name: 'payroll', secrets: ['nav_payroll_runs'], nonHolder: { status: 403, code: 'PERMISSION_DENIED' } }, + { type: 'app', name: 'launchpad', secrets: ['nav_launchpad_home'], nonHolder: { status: 404, code: 'RESOURCE_NOT_FOUND' } }, + { type: 'dashboard', name: 'ops', secrets: [], nonHolder: { status: 200, shape: 'deployment-pruned' } }, + // The ADR-0106 object mask — the dispatcher's plain read already ran it; + // its `/published` did not. + { type: 'object', name: 'invoice', secrets: ['secret_margin'], nonHolder: { status: 200, shape: 'masked' } }, + // Controls: an ungated doc and an ungated type. + { type: 'doc', name: 'crm_intro', secrets: [], nonHolder: { status: 200, shape: 'whole' } }, + { type: 'view', name: 'all_leads', secrets: [], nonHolder: { status: 200, shape: 'whole' } }, +]; + +const DOORS = ['', '/published'] as const; + +describe('[#20193] the reference: RestServer answers the non-holder as each row declares', () => { + for (const row of ROWS) { + for (const door of DOORS) { + it(`${row.type}/${row.name}${door}`, async () => { + const rest = await bootRest('non-holder').read(row.type, row.name, door); + expect(rest.status).toBe(row.nonHolder.status); + if (row.nonHolder.code) expect(rest.code).toBe(row.nonHolder.code); + }); + } + } +}); + +describe('[#20193] the dispatcher answers every caller what RestServer answers — the same status, the same code, the same document', () => { + for (const row of ROWS) { + for (const door of DOORS) { + for (const callerName of Object.keys(CALLERS) as CallerName[]) { + it(`GET /meta/${row.type}/${row.name}${door} × ${callerName}`, async () => { + const dispatcher = await bootDispatcher(callerName).read(`/meta/${row.type}/${row.name}${door}`); + const rest = await bootRest(callerName).read(row.type, row.name, door); + + // The ADR-0112 minimum, both halves: the status and the machine code. + expect({ status: dispatcher.status, code: dispatcher.code }) + .toEqual({ status: rest.status, code: rest.code }); + + if (rest.status !== 200) { + for (const s of row.secrets) expect(text(dispatcher)).not.toContain(s); + return; + } + // Served: the same document, pruned / masked the same way. + expect(navIds(dispatcher.served)).toEqual(navIds(rest.served)); + expect(widgetIds(dispatcher.served)).toEqual(widgetIds(rest.served)); + expect(fieldNames(dispatcher.served)).toEqual(fieldNames(rest.served)); + for (const s of row.secrets) { + if (text(rest).includes(s)) expect(text(dispatcher)).toContain(s); + else expect(text(dispatcher)).not.toContain(s); + } + }); + } + } + } +}); + +describe('[#20193] controls', () => { + it('a holder reads the gated doc body, the set-gated book and the whole app through the dispatcher', async () => { + const { read } = bootDispatcher('holder'); + const doc = await read('/meta/doc/crm_admin_runbook'); + const published = await read('/meta/doc/crm_admin_runbook/published'); + const book = await read('/meta/book/admin_guide'); + const app = await read('/meta/app/crm'); + expect(doc.status).toBe(200); + expect(doc.served?.content).toBe(DOC_SECRET); + expect(published.status).toBe(200); + expect(published.served?.content).toBe(DOC_SECRET); + expect(book.status).toBe(200); + expect(book.served?.description).toBe(BOOK_SECRET); + expect(navIds(app.served)).toEqual(navIds(CRM_APP)); + }); + + it('an object read is still masked for the non-holder, and served whole to the exempt holder', async () => { + const member = await bootDispatcher('non-holder').read('/meta/object/invoice'); + const holder = await bootDispatcher('holder').read('/meta/object/invoice'); + expect(fieldNames(member.served)).toEqual(['amount']); + expect(fieldNames(holder.served)).toEqual(['amount', 'secret_margin']); + }); + + it('an unauthenticated caller keeps its existing answer: 401 UNAUTHENTICATED, before any read', async () => { + const { read, protocol } = bootDispatcher('anonymous'); + const res = await read('/meta/doc/crm_admin_runbook'); + expect({ status: res.status, code: res.code }).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); + expect(protocol.getMetaItem).not.toHaveBeenCalled(); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + }); + + 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' }); + expect(text(res)).not.toContain(DOC_SECRET); + }); + + it('an unpublished app answers exactly what a name with nothing behind it answers on this transport (ADR-0045 §3)', async () => { + const { read } = bootDispatcher('non-holder'); + const unpublished = await read('/meta/app/launchpad'); + const missing = await read('/meta/app/no_such_app'); + expect(unpublished.status).toBe(404); + expect(unpublished.body).toEqual(missing.body); + }); +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 4e5e86dde30..c47473b9dcd 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -42,6 +42,15 @@ import { // which is the whole reason `MetaDomainProtocol` below is `Pick`ed rather than // written out. Same move `domains/packages.ts` and `domains/mcp.ts` make. import type { MetadataProtocol } from '@objectstack/spec/api'; +// [#20193] THE per-caller read gate of a `/meta/:type/:name` document — the one +// `RestServer` asks, published by `@objectstack/rest` so this transport asks it +// too instead of a second audience resolver (ruling `5793362670` item 1). +import { + createMetaItemReadGate, + type MetaItemReadGateSources, + type MetaReadGateCaller, + type MetaReadGatePolicy, +} from '@objectstack/rest'; import { buildApiError } from '../error-envelope.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -279,6 +288,107 @@ async function maskObjectSchemaList( return { ok: true, data: Array.isArray(data) ? projected : { ...data, items: projected } }; } +/** + * [#20193] The policy the plain item read and `/published` run the shared gate + * under — every arm, the per-deployment ones included, and the app PRUNED: the + * two doors that serve the document a client renders, exactly as `RestServer` + * runs them (`MetaReadGatePolicy` in `@objectstack/rest` says why). + */ +const RENDERED_DOCUMENT_POLICY: MetaReadGatePolicy = Object.freeze({ arms: 'all', app: 'gate' }); + +/** + * [#20193] Where the shared gate's nav-servability prune log records what it + * has already said — one line per `app|entry|object|reason` per process, the + * dedupe `RestServer` keeps per instance. + */ +const NAV_PRUNE_LOGGED = new Set(); + +/** + * [#20193] This transport's I/O, as the shared per-caller read gate takes it. + * + * Only I/O — ⛔ no decision lives here; every verdict is + * `createMetaItemReadGate`'s, the one `RestServer` asks: + * + * - the caller is the execution context `dispatch()` already resolved for + * this request (the shared `resolveAuthzContext`, the same resolution the + * REST transport runs); + * - the security service holdings are read through is this request's; + * - a metadata LIST read is the protocol's `getMetaItems`, whose environment + * is the kernel `dispatch()` resolved — `undefined` when this host's + * protocol has no list read, which the gate treats as an unreadable input + * (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". + */ +function metaItemReadGateSources( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + protocol: MetaDomainProtocol | undefined, +): MetaItemReadGateSources { + return { + resolveCaller: async () => context.executionContext as MetaReadGateCaller | undefined, + resolveSecurityService: () => deps.resolveService(context, 'security'), + listMetaItems: (type) => (typeof protocol?.getMetaItems === 'function' ? protocol.getMetaItems({ type }) : undefined), + serviceProbe: () => async (name: string) => { + try { + return (await deps.resolveService(context, name)) != null; + } catch { + return false; + } + }, + navPruneLogged: NAV_PRUNE_LOGGED, + }; +} + +/** + * [#20193] Ask the shared per-caller read gate about ONE document this + * transport is about to serve, and write its refusal on this transport's wire. + * + * The item read and `/published` used to serve whatever the store answered: + * no ADR-0046 §6.7 docs audience, no app nav filter, no ADR-0057 D10 gate. On a + * host that mounts just the `${prefix}/*` catch-all this handler is the only + * answer to those reads, so a member `RestServer` refuses a + * `{ permissionSet }`-gated doc read its body here, and an app's + * `requiredPermissions` entries reached every member. + * + * The refusals, in this transport's envelope (`deps.error`, the ADR-0112 nested + * one every other refusal in this file speaks), with `RestServer`'s status and + * code: + * + * - `absent` → the SAME `deps.error('Not found', 404)` this handler answers + * for a name with nothing behind it, so an unpublished app stays externally + * unobservable (ADR-0045 §3); + * - `app-permission` / `docs-audience` → `403 PERMISSION_DENIED` / + * `401 UNAUTHENTICATED` with the gate's message. + * + * A gate input that could not be read (the books or doc list read threw) is + * 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. + */ +async function gateMetaItemDocument( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + protocol: MetaDomainProtocol | undefined, + metaType: string, + name: string, + document: any, +): 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, + ); + verdict = await judge(document); + } catch (e: any) { + return { ok: false, response: deps.errorFromThrown(e, 500) }; + } + if (verdict.kind === 'serve') return { ok: true, document: verdict.document }; + const { refusal } = verdict; + if (refusal.reason === 'absent') return { ok: false, response: deps.error('Not found', 404) }; + return { ok: false, response: deps.error(refusal.message, refusal.status, { code: refusal.code }) }; +} + /** * Percent-decode the `:name` path segment. [#12195] * @@ -438,6 +548,33 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // broader `getMetaItem` would not do: it folds the code layer into its // own answer, so this route could no longer tell the two stores apart. const protocol = await resolveProtocol(deps, _context); + + // [#20193] This door serves ONE document, the same representation the + // plain read below serves — so it answers exactly what `RestServer`'s + // `/published` answers this caller: the shared per-caller read gate + // (every arm, a partly-withheld app PRUNED) and the ADR-0106 object + // mask, the one the plain read below already runs. It ran neither, so a + // member refused `crm_admin_runbook` read its body here, and an object's + // unreadable fields were served whole. Every exit that serves a + // document goes through `servePublished`, OUTSIDE the `try`s below: + // those classify a failed store READ, and a gate fault raised while + // serving must be answered as itself — never fall through to the + // snapshot as though no overlay existed. + const publishedType = pluralToSingular(type); + const publishedMasker = publishedType === 'object' ? await resolveObjectMasker(deps, _context) : undefined; + const servePublished = async (document: any): Promise => { + const gated = await gateMetaItemDocument(deps, _context, protocol, publishedType, name, document); + if (!gated.ok) return { handled: true, response: gated.response }; + let served = gated.document; + if (publishedMasker) { + const masked = await maskObjectSchema(publishedMasker, name, served); + if (!masked.ok) return fieldVisibilityFault(deps, name); + served = masked.document; + } + return { handled: true, response: deps.success(served) }; + }; + + let publishedOverlay: unknown; if (protocol && typeof protocol.getMetaItemLayered === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); @@ -447,10 +584,11 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin ...(organizationId ? { organizationId } : {}), }); if (layered?.overlay !== undefined && layered?.overlay !== null) { - return { handled: true, response: deps.success(layered.overlay) }; + publishedOverlay = layered.overlay; } } catch { /* fall through to the code/package snapshot below */ } } + if (publishedOverlay !== undefined) return servePublished(publishedOverlay); const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata); if (metadataService && typeof (metadataService as any).getPublished === 'function') { @@ -463,16 +601,17 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // code-published item. const data = await (metadataService as any).getPublished(canonicalMetaUrlType(type), name); if (data === undefined) return { handled: true, response: deps.error('Not found', 404) }; - return { handled: true, response: deps.success(data) }; + return servePublished(data); } // Fallback — try MetadataService via resolveService const metaSvc = await deps.resolveService(_context, 'metadata', _context.environmentId); if (metaSvc && typeof (metaSvc as any).getPublished === 'function') { + let fallbackData: unknown; try { // [#10503] Same fold — this slot reads the same canonical store. - const fallbackData = await (metaSvc as any).getPublished(canonicalMetaUrlType(type), name); - if (fallbackData !== undefined) return { handled: true, response: deps.success(fallbackData) }; + fallbackData = await (metaSvc as any).getPublished(canonicalMetaUrlType(type), name); } catch { /* fall through */ } + if (fallbackData !== undefined) return servePublished(fallbackData); } return { handled: true, response: deps.error('Not found', 404) }; } @@ -862,6 +1001,24 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // Try Protocol Service First (Preferred) const protocol = await resolveProtocol(deps, _context); + + // [#20193] THE per-caller read gate, on whichever lookup below + // answers — the one `RestServer`'s plain read asks, so this read + // refuses what that read refuses (a `{ permissionSet }`-gated doc + // or book to a non-holder, an app to a caller missing its + // `requiredPermissions`) and prunes what it prunes (the nav entries + // a caller may not open, a widget whose service is off here). It + // runs OUTSIDE the lookups' own `try`s — those classify a miss, and + // a gate fault must be answered as itself, never as "not found". + // `object` is not judged here: its per-caller gate is the ADR-0106 + // mask, which the branch above already runs. + const serveItem = async (envelope: any): Promise => { + const gated = await gateMetaItemDocument(deps, _context, protocol, singularType, name, envelope.item); + if (!gated.ok) return { handled: true, response: gated.response }; + return { handled: true, response: deps.success({ ...envelope, item: gated.document }) }; + }; + + let found: any; if (protocol && typeof protocol.getMetaItem === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); @@ -897,26 +1054,28 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // about this route is untouched here, neither answered nor // pre-empted. if (data?.item != null) { - return { handled: true, response: deps.success(data) }; + found = data; } } catch (e: any) { // Protocol might throw if not found or not supported } } + if (found) return serveItem(found); // Try MetadataService for runtime-registered types const metaSvc = await deps.resolveService(_context, 'metadata', _context.environmentId); if (metaSvc && typeof (metaSvc as any).getItem === 'function') { + let data: any; try { // ADR-0048 — thread `?package=` so single-item resolution is // package-scoped (prefer-local), matching list resolution. - const data = await (metaSvc as any).getItem(singularType, name, packageId); - // [#5563] Same convergence as the object branch above: the - // MetadataService hands back the bare document, so wrap it in - // the declared envelope rather than letting which service - // answered decide the caller's parse. - if (data) return { handled: true, response: deps.success({ type: singularType, name, item: data }) }; + data = await (metaSvc as any).getItem(singularType, name, packageId); } catch { /* not found */ } + // [#5563] Same convergence as the object branch above: the + // MetadataService hands back the bare document, so wrap it in + // the declared envelope rather than letting which service + // answered decide the caller's parse. + if (data) return serveItem({ type: singularType, name, item: data }); } return { handled: true, response: deps.error('Not found', 404) }; } catch (e: any) { diff --git a/scripts/adr-anchors/packages__rest__src__meta-item-read-gate.ts.json b/scripts/adr-anchors/packages__rest__src__meta-item-read-gate.ts.json new file mode 100644 index 00000000000..bb12a2b8c86 --- /dev/null +++ b/scripts/adr-anchors/packages__rest__src__meta-item-read-gate.ts.json @@ -0,0 +1,8 @@ +{ + "file": "packages/rest/src/meta-item-read-gate.ts", + "adrs": [ + "ADR-0045", + "ADR-0046" + ], + "invariant": "This module is THE per-caller read gate of one `/meta/:type/:name` document, and BOTH transports that serve that read ask it: `RestServer` and the runtime dispatcher's `/meta` domain (the only answer on a host that mounts just the `${prefix}/*` catch-all). ADR-0046 §6.7 — a doc or book is served only to a caller its audience admits (a doc's EFFECTIVE audience is the union over the books that claim it; unresolvable holdings DENY, and an unreadable books or doc-corpus read is a fault, never an empty list that grants). ADR-0045 §3 — `filterAppForUserWithReason` IS the publish gate: an unpublished app is externally unobservable (the transport's own absence answer, never a 403) and only a builder receives it; it judges `_unpublished`, never `hidden`. A gate is added HERE, never in one transport's adapter: a gate in one transport and not the other is the defect this module closed, and porting these rules into `packages/runtime` would be the second audience resolver ruling 5793362670 item 1 forbids." +} From 14ef63822a45736a993e4683f34059890ebf13e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:15:01 +0000 Subject: [PATCH 2/7] wip(rest): the execctx census follows the gate's move Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- .../rest/src/execctx-consumer-census.test.ts | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index 0c77ccaba26..f6313bac933 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -310,7 +310,20 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t // --------------------------------------------------------------------------- describe('[#13160] §2 the consumer surface, counted from the tree', () => { - it('70 invocation sites, 93 mentions — the thread\'s two control numbers hold', () => { + it('68 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => { + // [#20193] 70 → 68 sites / 93 → 92 mentions — a MOVE, not a new + // consumer. `metaItemReadGate`'s body and the docs-audience helpers it + // calls moved into `meta-item-read-gate.ts`, the one gate the runtime + // dispatcher's `/meta` domain now asks too. Three same-line CAUGHT sites + // left this file with them (the gate's app and dashboard arms, and + // `resolveAudienceCaller`); ONE took their place — the caller port + // `metaReadAudienceSources` hands the moved gate, still same-line + // `.catch(rethrowAuthzStoreUnavailable)`, still not behind the shared + // anonymous floor. Net −2 sites, all from the CAUGHT half (25 → 23, 17 + // → 15 on the invocation line); the bare half (45) is untouched. The + // new port's docblock names the seam once, so mentions fall by one + // fewer than sites (−2 invocations, +1 prose mention). + // // [#20156] 69 → 70 sites / 92 → 93 mentions — one net site, and it is // a MOVE plus one. The plain read's app and dashboard gates each // resolved the context inline (two same-line CAUGHT sites); both moved @@ -423,26 +436,26 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // naming the seam is the point of the sentence — and the sentence // moving only the mention count is this control working: a site was not // added, and the number that tracks sites did not move. - expect(SITES.length).toBe(70); - expect(SOURCE.split('resolveExecCtx').length - 1).toBe(93); + expect(SITES.length).toBe(68); + expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92); }); - it('the split is 25 locally caught / 45 bare — NOT 17 / 45, which does not add to 70', () => { - // 17 sites spell the catch on the invocation line; 8 more spell it on - // the continuation line. A single-line grep sees 17 and the arithmetic + it('the split is 23 locally caught / 45 bare — NOT 15 / 45, which does not add to 68', () => { + // 15 sites spell the catch on the invocation line; 8 more spell it on + // the continuation line. A single-line grep sees 15 and the arithmetic // silently loses eight sites. // // [#13214] The new site is BARE, and that is a decision the next case // enforces: a locally-caught site sitting behind the shared floor would // be the first of its kind and would break the structural claim below. const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch(')); - expect(sameLine.length).toBe(17); - expect(CAUGHT.length).toBe(25); + expect(sameLine.length).toBe(15); + expect(CAUGHT.length).toBe(23); expect(BARE.length).toBe(45); expect(CAUGHT.length + BARE.length).toBe(SITES.length); }); - it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 25 caught ones is', () => { + it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 23 caught ones is', () => { // This inverts the reason the thread gave for doing the bare sites // first ("no local signal that a fault becomes an anonymous subject"). // The bare sites are bare BECAUSE the shared anonymous floor is the From c280818b6c819e4ce829dd9d74f18d0d0ac76af6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:21:25 +0000 Subject: [PATCH 3/7] wip(rest): the caller port in the census reader's inline shape Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/rest/src/execctx-consumer-census.test.ts | 7 ++++++- packages/rest/src/rest-server.ts | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index f6313bac933..de682931ac4 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -722,7 +722,12 @@ describe('[#13279] §7 every caught resolveExecCtx site re-raises the outage', ( // form would report "0 survivors" and read exactly like a clean pass — // which is the precise failure that let the four sites through. const args = catchArguments(); - expect(args.filter((a) => a.layout === 'inline').length).toBeGreaterThanOrEqual(16); + // [#20193] 16 → 14, the floor keeping its one site of slack: the inline + // population fell 17 → 15 when three inline sites moved into + // `meta-item-read-gate.ts` with the gate and one inline caller port + // replaced them (§2 has the arithmetic) — the population shrank, the + // reader did not go blind. + expect(args.filter((a) => a.layout === 'inline').length).toBeGreaterThanOrEqual(14); expect(args.filter((a) => a.layout === 'continuation').length).toBeGreaterThanOrEqual(4); expect(args.length).toBe(CAUGHT.length); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 90881f39332..3fcdadce314 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3303,7 +3303,10 @@ export class RestServer { /** [#20193] The caller half of {@link metaItemReadGateSources}. */ private metaReadAudienceSources(environmentId: string | undefined, req: any): MetaReadGateAudienceSources { return { - resolveCaller: () => this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable), + resolveCaller: async () => { + const caller = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); + return caller; + }, resolveSecurityService: async () => ( this.securityServiceProvider ? this.securityServiceProvider(environmentId) : undefined ), From 1911b2e357e50e85f75fa8070a8cb82f4f8d44b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:39:37 +0000 Subject: [PATCH 4/7] wip: changeset for the shared /meta item read gate Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- .changeset/20193-dispatcher-meta-read-gate.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .changeset/20193-dispatcher-meta-read-gate.md diff --git a/.changeset/20193-dispatcher-meta-read-gate.md b/.changeset/20193-dispatcher-meta-read-gate.md new file mode 100644 index 00000000000..91b49d1a06c --- /dev/null +++ b/.changeset/20193-dispatcher-meta-read-gate.md @@ -0,0 +1,49 @@ +--- +'@objectstack/runtime': patch +'@objectstack/rest': minor +--- + +fix(runtime): the dispatcher's `/meta` item reads ask the same per-caller read gate `RestServer` asks, and `@objectstack/rest` publishes it (#20193) + +`GET /meta/:type/:name` and `GET /meta/:type/:name/published` have two +implementations: `RestServer`, and the runtime dispatcher's `/meta` domain. On a +host that mounts only the `${prefix}/*` catch-all (`@objectstack/hono`'s +`createHonoApp`, the documented embed shape, and any adapter built on the public +`HttpDispatcher` API), the dispatcher is the only one that answers. Its item read +and its `/published` read applied **no** per-caller read gate. An authenticated +member who does not hold `crm_admin` got these answers through `dispatch()`: + +``` +request before after (= RestServer) +GET /meta/doc/crm_admin_runbook 200 + the gated body 403 PERMISSION_DENIED +GET /meta/doc/crm_admin_runbook/published 200 + the gated body 403 PERMISSION_DENIED +GET /meta/book/admin_guide (set-gated) 200 + the book 403 PERMISSION_DENIED +GET /meta/app/crm (and /published) 200, every entry 200, pruned +GET /meta/app/payroll (app-level perms) 200 403 PERMISSION_DENIED +GET /meta/app/launchpad (unpublished) 200 404, the same body as a missing name +GET /meta/dashboard/ops 200, every widget 200, minus the widget whose service is off +GET /meta/object/invoice/published 200, every field 200, the ADR-0106 mask applied +``` + +Holders are still served in full. Object reads through the plain item read are +masked as before. An anonymous caller still gets `401 UNAUTHENTICATED`. + +**One gate, not two.** `RestServer`'s gate moved unchanged into +`packages/rest/src/meta-item-read-gate.ts`. That covers the ADR-0046 §6.7 docs +audience, the app nav filter (`requiredPermissions`, the ADR-0045 §3 publish gate +and the docs-audience entry arm), the ADR-0057 D10 service gates and the #7912 +servability gate. Both transports now call it. Each transport supplies only its +own I/O (the caller, the protocol's list read, the security service and a +service probe) and writes the gate's data verdict in its own envelope. There is +no second audience resolver in `packages/runtime`. `RestServer` keeps its +private helper names as delegates, so its own answers are byte-for-byte +unchanged. + +A gate input that cannot be read is answered as that fault, never as the +document. This covers a books or doc-list read that throws, and a host whose +protocol has no list read at all (fail closed, ADR-0049). + +`@objectstack/rest` adds these exports: `createMetaItemReadGate` and the types +`MetaItemReadGateSources`, `MetaItemReadVerdict`, `MetaItemReadRefusal`, +`MetaReadGateCaller` and `MetaReadGatePolicy`. Nothing is removed or renamed, +and no authorable key moves. From 0fcb064a27f5989c02c63085da85b844eefa4fb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:19:12 +0000 Subject: [PATCH 5/7] wip(rest): the moved prune log names no tracker id; the prose-id baseline shrinks Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/rest/src/meta-item-read-gate.ts | 4 +++- scripts/doc-authoring-prose-id.baseline.json | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 2a968c896f1..4c809812a34 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -947,8 +947,10 @@ export async function resolveNavServability( const key = `${appName}|${entryId}|${objectName}|${reason}`; if (!sources.navPruneLogged.has(key)) { sources.navPruneLogged.add(key); + // [#7912] The serving-side half of the prune diagnostic — the tracker + // id lives here, never in the logged text an operator reads. logWarn( - `[REST] [#7912] nav entry '${entryId}' pruned from app '${appName}': its destination ` + + `[REST] nav entry '${entryId}' pruned from app '${appName}': its destination ` + `object '${objectName}' cannot serve a list — ` + (reason === 'api-disabled' ? `\`enable.apiEnabled: false\` (the list answers 404 OBJECT_API_DISABLED for every user).` diff --git a/scripts/doc-authoring-prose-id.baseline.json b/scripts/doc-authoring-prose-id.baseline.json index a79c86bd3f8..33abd42d4c3 100644 --- a/scripts/doc-authoring-prose-id.baseline.json +++ b/scripts/doc-authoring-prose-id.baseline.json @@ -835,8 +835,7 @@ "#9180": 1 }, "packages/rest/src/rest-server.ts": { - "#1604": 1, - "#7912": 1 + "#1604": 1 }, "packages/runtime/src/action-execution.ts": { "#11519": 1, From 017082d66b6cc6c73053a6fe65b45aabe5ed709a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:30:06 +0000 Subject: [PATCH 6/7] =?UTF-8?q?fix(spec=20liveness,=20changeset):=20re-anc?= =?UTF-8?q?hor=20the=20moved=20read-gate=20pointers;=20declare=20Clause-?= =?UTF-8?q?=E2=91=A1=20yes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The move to packages/rest/src/meta-item-read-gate.ts took filterNav, filterAppForUserWithReason, filterDashboardForUser, deriveImplicitPackageBook, resolveDocAudiences, resolveBookTree and the admitsBook audienceAllows call with it, so the liveness evidence pointers follow, each re-measured against the site that now reads the key and stamped verifiedAt 2026-09-27. The changeset now declares Clause-② yes: @objectstack/rest's only export subpath gains createMetaItemReadGate and five types, which the runtime dispatcher consumes. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- .changeset/20193-dispatcher-meta-read-gate.md | 29 ++++++++++++++++--- packages/spec/liveness/app.json | 18 ++++++------ packages/spec/liveness/book.json | 8 ++--- packages/spec/liveness/dashboard.json | 4 +-- packages/spec/liveness/doc.json | 20 ++++++------- 5 files changed, 50 insertions(+), 29 deletions(-) diff --git a/.changeset/20193-dispatcher-meta-read-gate.md b/.changeset/20193-dispatcher-meta-read-gate.md index 91b49d1a06c..04e826281e6 100644 --- a/.changeset/20193-dispatcher-meta-read-gate.md +++ b/.changeset/20193-dispatcher-meta-read-gate.md @@ -5,6 +5,8 @@ fix(runtime): the dispatcher's `/meta` item reads ask the same per-caller read gate `RestServer` asks, and `@objectstack/rest` publishes it (#20193) +Clause-②: yes + `GET /meta/:type/:name` and `GET /meta/:type/:name/published` have two implementations: `RestServer`, and the runtime dispatcher's `/meta` domain. On a host that mounts only the `${prefix}/*` catch-all (`@objectstack/hono`'s @@ -43,7 +45,26 @@ A gate input that cannot be read is answered as that fault, never as the document. This covers a books or doc-list read that throws, and a host whose protocol has no list read at all (fail closed, ADR-0049). -`@objectstack/rest` adds these exports: `createMetaItemReadGate` and the types -`MetaItemReadGateSources`, `MetaItemReadVerdict`, `MetaItemReadRefusal`, -`MetaReadGateCaller` and `MetaReadGatePolicy`. Nothing is removed or renamed, -and no authorable key moves. +**`@objectstack/rest`'s published export surface widens**, and that is why this +changeset declares `Clause-②: yes`. Its only export subpath (`.`) gains one value +and five types: + +- `createMetaItemReadGate(sources, metaType, name, documents, policy)`: the gate + itself; +- `MetaItemReadGateSources`: the I/O a caller supplies (the caller, a metadata + list read, the security service, a service probe, a prune-log set); +- `MetaItemReadVerdict` and `MetaItemReadRefusal`: the data verdict (`serve`, or + `refuse` with `absent` / `app-permission` / `docs-audience`); +- `MetaReadGateCaller`: the slice of the execution context the gate reads; +- `MetaReadGatePolicy`: `arms` and `app`, how a door runs the gate. + +They are public because the runtime dispatcher's `/meta` domain in +`@objectstack/runtime` consumes this one gate. `@objectstack/rest` cannot import +the runtime, so the shared decision has to live here and travel as an export, +the way `repeatedQueryParamMessage` does. A caller outside the platform does not +need them. + +Nothing is removed or renamed, and no authorable key moves. The dispatcher's +refusals are not the widening: they pull a second transport back to the gate +the contract already declares (ADR-0046 §6.7, ADR-0045 §3, `apps.mdx`), which is +why `@objectstack/runtime` stays a `patch`. diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json index 53038a9cfb1..8580ace923e 100644 --- a/packages/spec/liveness/app.json +++ b/packages/spec/liveness/app.json @@ -1,6 +1,6 @@ { "type": "app", - "_note": "AppSchema — the navigation shell, the densest hand-authored surface on the platform. Consumers: the REST read layer's filterAppForUser (packages/rest/src/rest-server.ts:2651-2740 — the SERVER-side authority for app/nav permission + capability gating and the ADR-0045 publish gate, which judges `_unpublished` and NOT `hidden` since #4829), the spec i18n translateApp (i18n-resolver.ts:472), and objectui's shell (@940ba24: app-shell AppSidebar/ConsoleLayout/ContextSelectors, layout NavigationRenderer, console RootLandingRedirect). The #4001/#4142 app step already retired seven dead keys as retiredKey tombstones — they stay in the walked shape, so their rows stay here (tombstone rule, orphans.mts). WALK BOUNDARY (#3095 union rule): `navigation` drills into the union's FIRST member (the `object` variant + base keys); the other variants' payload keys sit outside the walk and were verified by hand — dashboardName (NavigationRenderer.tsx:433), pageName (:435-442), url/target (:462), reportName (:460), componentRef (:464,:644), group `expanded` (:856) all live. The one GAP found there is now CLOSED (#4509, objectui @e8bec83): an `action` item's click dispatches through a host-supplied `onAction` prop that no shipped shell passed, so `actionDef.actionName` reached no dispatcher and every such item dead-clicked. objectui's `useNavActionDispatch` (objectui: packages/app-shell/src/hooks/useNavActionDispatch.ts) resolves the name against `action` metadata and dispatches through the console action runtime, and UnifiedSidebar passes it (objectui: packages/app-shell/src/layout/UnifiedSidebar.tsx:473). A shell that still passes no handler now HIDES action items rather than rendering them dead (objectui: packages/layout/src/NavigationRenderer.tsx:971) — the renderer stops manufacturing the trap. Also note that filterAppForUser USED to walk only the top-level `navigation` tree — it never read `item.areas` at all (it returned early when `navigation` was absent), while the client area switcher renders every area. That made area-level `visible` / `requiredPermissions` FAIL-OPEN gates, not merely unread: a \"hidden\" or permission-gated area showed to everyone. Closed in #4722 for the layer that survived the retirement — the server now runs the same filterNav over every `areas[].navigation` — see the `areas.navigation` row below. AREA GATES, 17.0.0 (#4651): both keys REMOVED and their rows DELETED — NavigationAreaSchema is strict, so the keys left the walked shape and retained rows would report ORPHAN. Route B (remove) over route A (enforce) was the maintainer's call: enforcing needs semantics decided first (does filtering an area remove its items everywhere? does the server bind `user` for area CEL?), which the 17.0.0 window could not hold, and a gate that never gated is strictly safer removed than shipped for a whole major. The strict rejection carries the prescription (ui/app.zod.ts AREA_VISIBLE_RETIRED / AREA_REQUIRED_PERMISSIONS_RETIRED) and names the layers that DO enforce. The boundary those prescriptions pointed at — per-item gating inside an area being shell-side only — was the real gap #4651 left behind, and #4722 closed it: item-level `requiredPermissions` / `requiresService` are now stripped server-side inside `areas[]` too, `visible` (CEL) deliberately not. Recorded on `areas.navigation` below. The area-LEVEL keys remain retired; they were not revived. Seeded 2026-08-01 (#4488). CONTEXT SELECTORS, 17.0.0 (#4509): `includeAll` and `placement` rows DELETED — AppContextSelectorSchema is strict, so the keys left the walked shape and retained rows would report ORPHAN. Both were unwarnable (schema defaults materialize at parse, so the lint could not tell authored from supplied), which made removal the only channel that could reach an author. `includeAll` was the sharp one: not unread but deliberately DISOBEYED — selectors are mandatory-scope, and an All row would clear the scope, which on Studio's package selector means listing the platform's own system/cloud kernel packages. STUDIO_APP authored `includeAll: true` against a renderer that ignored it, and that authoring site went with the key. 2026-08-28 (#13003): every `path:NNN` citation in this file was re-anchored to its consuming symbol; nine of the ten were wrong, all of them IN RANGE. Two of this note's own pointers rotted the same way and are corrected here: `filterAppForUser` is a thin delegate today and the gate lives in `packages/rest/src/rest-server.ts#filterAppForUserWithReason`, and the spec i18n entry point is `packages/spec/src/system/i18n-resolver.ts#translateApp`.", + "_note": "AppSchema — the navigation shell, the densest hand-authored surface on the platform. Consumers: the REST read layer's filterAppForUser (packages/rest/src/rest-server.ts:2651-2740 — the SERVER-side authority for app/nav permission + capability gating and the ADR-0045 publish gate, which judges `_unpublished` and NOT `hidden` since #4829), the spec i18n translateApp (i18n-resolver.ts:472), and objectui's shell (@940ba24: app-shell AppSidebar/ConsoleLayout/ContextSelectors, layout NavigationRenderer, console RootLandingRedirect). The #4001/#4142 app step already retired seven dead keys as retiredKey tombstones — they stay in the walked shape, so their rows stay here (tombstone rule, orphans.mts). WALK BOUNDARY (#3095 union rule): `navigation` drills into the union's FIRST member (the `object` variant + base keys); the other variants' payload keys sit outside the walk and were verified by hand — dashboardName (NavigationRenderer.tsx:433), pageName (:435-442), url/target (:462), reportName (:460), componentRef (:464,:644), group `expanded` (:856) all live. The one GAP found there is now CLOSED (#4509, objectui @e8bec83): an `action` item's click dispatches through a host-supplied `onAction` prop that no shipped shell passed, so `actionDef.actionName` reached no dispatcher and every such item dead-clicked. objectui's `useNavActionDispatch` (objectui: packages/app-shell/src/hooks/useNavActionDispatch.ts) resolves the name against `action` metadata and dispatches through the console action runtime, and UnifiedSidebar passes it (objectui: packages/app-shell/src/layout/UnifiedSidebar.tsx:473). A shell that still passes no handler now HIDES action items rather than rendering them dead (objectui: packages/layout/src/NavigationRenderer.tsx:971) — the renderer stops manufacturing the trap. Also note that filterAppForUser USED to walk only the top-level `navigation` tree — it never read `item.areas` at all (it returned early when `navigation` was absent), while the client area switcher renders every area. That made area-level `visible` / `requiredPermissions` FAIL-OPEN gates, not merely unread: a \"hidden\" or permission-gated area showed to everyone. Closed in #4722 for the layer that survived the retirement — the server now runs the same filterNav over every `areas[].navigation` — see the `areas.navigation` row below. AREA GATES, 17.0.0 (#4651): both keys REMOVED and their rows DELETED — NavigationAreaSchema is strict, so the keys left the walked shape and retained rows would report ORPHAN. Route B (remove) over route A (enforce) was the maintainer's call: enforcing needs semantics decided first (does filtering an area remove its items everywhere? does the server bind `user` for area CEL?), which the 17.0.0 window could not hold, and a gate that never gated is strictly safer removed than shipped for a whole major. The strict rejection carries the prescription (ui/app.zod.ts AREA_VISIBLE_RETIRED / AREA_REQUIRED_PERMISSIONS_RETIRED) and names the layers that DO enforce. The boundary those prescriptions pointed at — per-item gating inside an area being shell-side only — was the real gap #4651 left behind, and #4722 closed it: item-level `requiredPermissions` / `requiresService` are now stripped server-side inside `areas[]` too, `visible` (CEL) deliberately not. Recorded on `areas.navigation` below. The area-LEVEL keys remain retired; they were not revived. Seeded 2026-08-01 (#4488). CONTEXT SELECTORS, 17.0.0 (#4509): `includeAll` and `placement` rows DELETED — AppContextSelectorSchema is strict, so the keys left the walked shape and retained rows would report ORPHAN. Both were unwarnable (schema defaults materialize at parse, so the lint could not tell authored from supplied), which made removal the only channel that could reach an author. `includeAll` was the sharp one: not unread but deliberately DISOBEYED — selectors are mandatory-scope, and an All row would clear the scope, which on Studio's package selector means listing the platform's own system/cloud kernel packages. STUDIO_APP authored `includeAll: true` against a renderer that ignored it, and that authoring site went with the key. 2026-08-28 (#13003): every `path:NNN` citation in this file was re-anchored to its consuming symbol; nine of the ten were wrong, all of them IN RANGE. Two of this note's own pointers rotted the same way and are corrected here: `filterAppForUser` is a thin delegate today and the gate lives in `packages/rest/src/meta-item-read-gate.ts#filterAppForUserWithReason`, and the spec i18n entry point is `packages/spec/src/system/i18n-resolver.ts#translateApp`.", "props": { "name": { "status": "live", @@ -95,8 +95,8 @@ }, "requiredPermissions": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/rest/src/rest-server.ts#filterNav (the server strips an entry whose `requiredPermissions` are not a subset of the caller's system permissions — from the top-level tree AND, since #4722, from every `areas[].navigation` tree through this same closure); objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:894", + "verifiedAt": "2026-09-27", + "evidence": "packages/rest/src/meta-item-read-gate.ts#filterNav (the server strips an entry whose `requiredPermissions` are not a subset of the caller's system permissions — from the top-level tree AND, since #4722, from every `areas[].navigation` tree through this same closure); objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:894", "note": "enforced in BOTH layers: the server strips unsatisfied entries before serving — from the top-level navigation tree AND, since #4722, from every `areas[].navigation` tree through the same filterNav — and the client re-gates per item. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — `:1844` had rotted onto a bare `try {` inside `computeExecCtx`'s auth-service lookup, ~320 lines above the gate. Re-closed by hand against 8cb96ec41." }, "requiresObject": { @@ -107,8 +107,8 @@ }, "requiresService": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/rest/src/rest-server.ts#filterNav (ADR-0057 D10 — an entry naming a service the kernel has not registered is dropped before the tree is served; fail-open when the kernel cannot be probed); objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:900-902", + "verifiedAt": "2026-09-27", + "evidence": "packages/rest/src/meta-item-read-gate.ts#filterNav (ADR-0057 D10 — an entry naming a service the kernel has not registered is dropped before the tree is served; fail-open when the kernel cannot be probed); objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:900-902", "note": "ADR-0057 D10 capability gate, server + client. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — `:1832` had rotted onto `environmentId = await this.resolveRequestEnvironmentId(environmentId, req)` in `computeExecCtx`, its sibling `requiredPermissions` twelve lines below it in the same unrelated block. Re-closed by hand against 8cb96ec41." }, "type": { @@ -241,8 +241,8 @@ }, "requiredPermissions": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/rest/src/rest-server.ts#filterAppForUserWithReason (`const reqApp = Array.isArray(item.requiredPermissions) ? item.requiredPermissions : []` — an app whose required permissions are not a subset of the caller's is dropped from /meta entirely, and the single-item GET re-checks through this same function)", + "verifiedAt": "2026-09-27", + "evidence": "packages/rest/src/meta-item-read-gate.ts#filterAppForUserWithReason (`const reqApp = Array.isArray(item.requiredPermissions) ? item.requiredPermissions : []` — an app whose required permissions are not a subset of the caller's is dropped from /meta entirely, and the single-item GET re-checks through this same function)", "note": "SERVER-enforced: an app whose required permissions are not a subset of the caller's system permissions is dropped from /meta entirely (and the single-item GET re-checks at rest-server.ts:3298). 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — `:1814` had rotted onto the closing `};` of a `booksRequest` literal in an unrelated metadata read, ~340 lines above the gate; the note's own \"single-item GET re-checks at rest-server.ts:3298\" had rotted with it, and that re-check now runs through `filterAppForUserWithReason` from the single-item branch ~2,000 lines further down. Re-closed by hand against 8cb96ec41." }, "defaultAgent": { @@ -288,8 +288,8 @@ }, "_unpublished": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/rest/src/rest-server.ts#filterAppForUserWithReason (`if (item._unpublished === true && !sysPerms.has('studio.access') && !sysPerms.has('setup.access'))` — the ADR-0045 publish gate, which judges THIS key and deliberately not `hidden`); packages/runtime/src/domains/packages.ts#handlePackagesRequest (POST /packages/:id/publish-drafts flips it to `false` — never deletes it, because ADR-0045 §3 keeps publish and unpublish symmetric)", + "verifiedAt": "2026-09-27", + "evidence": "packages/rest/src/meta-item-read-gate.ts#filterAppForUserWithReason (`if (item._unpublished === true && !sysPerms.has('studio.access') && !sysPerms.has('setup.access'))` — the ADR-0045 publish gate, which judges THIS key and deliberately not `hidden`); packages/runtime/src/domains/packages.ts#handlePackagesRequest (POST /packages/:id/publish-drafts flips it to `false` — never deletes it, because ADR-0045 §3 keeps publish and unpublish symmetric)", "note": "MACHINE-MANAGED publish gate (ADR-0045 §3, amended 2026-08-09 / #4829) — never authored: written by the AI additive-materialization path (cloud) and cleared by POST /packages/:id/publish-drafts. SERVER-enforced: filterAppForUser withholds an unpublished app from every metadata response except a builder's (studio/setup access), for direct-URL preview. Declared on AppSchema rather than omitted because the write path validates against that schema (saveMetaItem → 422; Registry.validate('app') → AppSchema.parse), so the flip itself would be unwritable otherwise. Stored pre-amendment rows carrying `hidden: true` are rewritten here by the ADR-0087 conversion `app-hidden-to-unpublished`. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED, both legs — `rest-server.ts:2669` had rotted onto a docblock about a diagnostic view being neither translated nor cached, ~480 lines below the gate, and `packages.ts:245` onto the domain-wide anonymous-deny floor, ~270 lines above the flip. Re-closed by hand against 8cb96ec41." } } diff --git a/packages/spec/liveness/book.json b/packages/spec/liveness/book.json index 632c929b078..0392560b392 100644 --- a/packages/spec/liveness/book.json +++ b/packages/spec/liveness/book.json @@ -4,8 +4,8 @@ "props": { "name": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/rest/src/rest-server.ts#deriveImplicitPackageBook (the tree route matches `b.name === req.params.name` and, failing that, synthesizes the implicit per-package book from the same segment — §6.4); packages/spec/src/system/book.zod.ts#resolveBookTree (`return { name: book.name, … }` — the resolved tree carries it back out)", + "verifiedAt": "2026-09-27", + "evidence": "packages/rest/src/meta-item-read-gate.ts#deriveImplicitPackageBook (`bookNamed`: `books.find((b: any) => b && b.name === name) ?? deriveImplicitPackageBook(name, name)` — the tree route's `audience.bookNamed(req.params.name)` matches the book by name and, failing that, synthesizes the implicit per-package book from the same segment — §6.4); packages/spec/src/system/book.zod.ts#resolveBookTree (`return { name: book.name, … }` — the resolved tree carries it back out)", "note": "tree-route identity; an unknown name is treated as a package id and resolved as the implicit per-package book (§6.4). 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — `rest-server.ts:3098` had rotted onto `patterns: crud.patterns` in the CRUD-endpoint config, ~1,800 lines from the book route. The `_note`'s file-level range (`:3078-3169`) had rotted with it and is replaced by a symbol too. Re-closed by hand against 93ea19bca." }, "label": { @@ -40,8 +40,8 @@ }, "audience": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/spec/src/system/book.zod.ts#audienceAllows (the gate itself: `public` always, `org`/unset any authenticated principal, `{ permissionSet }` only a holder, unknown shape → false); packages/spec/src/system/book.zod.ts#resolveDocAudiences (`const audience = book.audience ?? 'org'` — every doc's effective audience is the UNION over the books that claim it, unclaimed docs falling back to `org`); packages/rest/src/rest-server.ts#audienceAllows (the tree gate: `if (!audienceAllows(book.audience, caller))` → 401 anonymous / 403 non-holder, before any tree is built)", + "verifiedAt": "2026-09-27", + "evidence": "packages/spec/src/system/book.zod.ts#audienceAllows (the gate itself: `public` always, `org`/unset any authenticated principal, `{ permissionSet }` only a holder, unknown shape → false); packages/spec/src/system/book.zod.ts#resolveDocAudiences (`const audience = book.audience ?? 'org'` — every doc's effective audience is the UNION over the books that claim it, unclaimed docs falling back to `org`); packages/rest/src/meta-item-read-gate.ts#audienceAllows (`admitsBook: (book: Book) => audienceAllows(book?.audience, caller)` — the gate the tree route asks as `if (!audience.admitsBook(book))` → 401 anonymous / 403 non-holder, before any tree is built, and the book item read asks on both transports)", "note": "ENFORCED access gate (§6.7), fail-closed: gates the whole tree (401 anonymous / 403 non-holder), and every doc's effective audience is the union over the books that claim it (resolveDocAudiences) — applied to both doc lists and tree entries. The one security-shaped property on this type, and it is real. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — ALL THREE legs were wrong. `rest-server.ts:3113` lands on the comment `// config to read).` and `:2969` on a docblock sentence about `'v1/beta'` splicing a path segment; `book.zod.ts:351` lands on `entries.push(entryFromDoc(d))` inside `resolveBookTree`'s `...` rest-expansion — tree ASSEMBLY, not the gate, ~83 lines from `audienceAllows`. A security-shaped property whose every pointer had drifted, one of them onto a comment, is the case this whole worklist is justified by: the verdict was right, and nothing cited could have shown it. Re-closed by hand against 93ea19bca." }, "groups": { diff --git a/packages/spec/liveness/dashboard.json b/packages/spec/liveness/dashboard.json index 43bd4e467f5..c52cdd06127 100644 --- a/packages/spec/liveness/dashboard.json +++ b/packages/spec/liveness/dashboard.json @@ -162,8 +162,8 @@ }, "requiresService": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/rest/src/rest-server.ts#filterDashboardForUser (ADR-0057 D10 — the widget is stripped from the payload when the named kernel service is not registered; fail-open when the kernel cannot be probed); packages/rest/src/rest.test.ts#filterDashboardForUser (the pin: drops widgets whose gate reports the service absent, keeps them when it is present)", + "verifiedAt": "2026-09-27", + "evidence": "packages/rest/src/meta-item-read-gate.ts#filterDashboardForUser (ADR-0057 D10 — the widget is stripped from the payload when the named kernel service is not registered; fail-open when the kernel cannot be probed); packages/rest/src/rest.test.ts#filterDashboardForUser (the pin: drops widgets whose gate reports the service absent, keeps them when it is present)", "note": "server-side capability gate: the widget is stripped from the payload when the named kernel service is not registered (fail-open when the kernel cannot be probed). The authoritative half of the pair — read it as the counter-example to judging a widget key from the renderer repo alone. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED, both legs — `rest-server.ts:1921-1931` had rotted onto the auth-gate normalization comment in `computeExecCtx`, ~380 lines above the widget gate, and `rest.test.ts:3271-3305` onto a NAV-level case (\"ITEM level: nav entries the caller cannot satisfy are stripped\") ~600 lines above the widget suite — a different gate's tests, still in range and still plausible. Re-closed by hand against 8cb96ec41." }, "actionUrl": { diff --git a/packages/spec/liveness/doc.json b/packages/spec/liveness/doc.json index a89338c4e64..fdcc200d253 100644 --- a/packages/spec/liveness/doc.json +++ b/packages/spec/liveness/doc.json @@ -4,8 +4,8 @@ "props": { "name": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/spec/src/system/book.zod.ts#matchesInclude (`globToRegExp(include).test(doc.name)` — the glob `include` rule matches over names); packages/spec/src/system/book.zod.ts#entryFromDoc (`{ doc: doc.name, … }` — the name is what a rendered tree entry points at); packages/rest/src/rest-server.ts#resolveDocAudiences (`audiences.get(d.name)` — the effective-audience map is keyed by name, on both the list and the tree branch)", + "verifiedAt": "2026-09-27", + "evidence": "packages/spec/src/system/book.zod.ts#matchesInclude (`globToRegExp(include).test(doc.name)` — the glob `include` rule matches over names); packages/spec/src/system/book.zod.ts#entryFromDoc (`{ doc: doc.name, … }` — the name is what a rendered tree entry points at); packages/rest/src/meta-item-read-gate.ts#resolveDocAudiences (`docReader`: `audiences.get(docName as string)` over `resolveDocAudiences(books, corpus)` — the effective-audience map is keyed by name, asked with the doc's name on the list branch, the tree and the item read)", "note": "identity: the single-doc route key, the resolver's membership key (glob `include` matches over names), and the audience map key. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — BOTH legs were wrong. `book.zod.ts:225` is a BLANK LINE inside the `ResolvedBook` interface; `rest-server.ts:3129` lands on `upsertMany: batch.operations?.upsertMany ?? true` in the batch-endpoints config, ~1,480 lines from any doc read. Re-closed by hand against 93ea19bca." }, "label": { @@ -16,8 +16,8 @@ }, "description": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/spec/src/system/book.zod.ts#entryFromDoc (`description: doc.description` — carried onto the rendered tree entry); packages/spec/src/system/doc.zod.ts#resolveDocLocale (`description: variant.description ?? base.description`); packages/rest/src/rest-server.ts#resolveBookTree (the tree corpus projection `{ name, label, description, order, group, tags, packageId }` the route hands the resolver)", + "verifiedAt": "2026-09-27", + "evidence": "packages/spec/src/system/book.zod.ts#entryFromDoc (`description: doc.description` — carried onto the rendered tree entry); packages/spec/src/system/doc.zod.ts#resolveDocLocale (`description: variant.description ?? base.description`); packages/rest/src/rest-server.ts#readableTree (the tree route's corpus projection `{ name, label, description, order, group, tags, packageId }` — the transport half that reads the key — handed to `audience.readableTree(book, docs)`, whose `resolveBookTree` carries it onto the rendered entry)", "note": "carried into tree entries and kept on the list response (which strips `content`) so portals can show summaries without fetching bodies. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — `book.zod.ts:202` is the `tags` docblock (a DIFFERENT key's documentation) and `rest-server.ts:3131` is `defaultAtomic: batch.defaultAtomic ?? true` in the batch-endpoints config. Re-closed by hand against 93ea19bca." }, "content": { @@ -28,14 +28,14 @@ }, "order": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/spec/src/system/book.zod.ts#byOrderThenLabel (`(a.order ?? 0) - (b.order ?? 0)` — the primary sort, absent reads as 0); packages/rest/src/rest-server.ts#resolveDocAudiences (`order: d.order` in the list-branch corpus projection)", + "verifiedAt": "2026-09-27", + "evidence": "packages/spec/src/system/book.zod.ts#byOrderThenLabel (`(a.order ?? 0) - (b.order ?? 0)` — the primary sort, absent reads as 0); packages/rest/src/meta-item-read-gate.ts#docCorpusOf (`order: d.order` in the list-branch corpus projection the audience resolver reads)", "note": "sort key within a book group (0 when absent, then label). 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — `book.zod.ts:198` is `description?: string;` in the `ResolverDoc` interface: a citation for `order` landing on the DECLARATION of a different key. `order`'s own declaration is the next line but one, which is how close a wrong pointer can sit to a right one and still say nothing. Re-closed by hand against 93ea19bca." }, "group": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/spec/src/system/book.zod.ts#resolveBookTree (a doc joins the first group whose `include` matches it OR whose `key` equals `doc.group`); packages/rest/src/rest-server.ts#resolveDocAudiences (`group: d.group` in the list-branch corpus projection)", + "verifiedAt": "2026-09-27", + "evidence": "packages/spec/src/system/book.zod.ts#resolveBookTree (a doc joins the first group whose `include` matches it OR whose `key` equals `doc.group`); packages/rest/src/meta-item-read-gate.ts#docCorpusOf (`group: d.group` in the list-branch corpus projection the audience resolver reads)", "note": "explicit book-group membership; a doc joins the group whose `key` equals it when no `include` rule claims it first. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — `book.zod.ts:238` had rotted onto the docblock of `ResolvedEntrySchema` (a sentence about #12038's describe-only transcription), ~63 lines above `resolveBookTree`. Re-closed by hand against 93ea19bca." }, "translations": { @@ -46,8 +46,8 @@ }, "tags": { "status": "live", - "verifiedAt": "2026-08-28", - "evidence": "packages/spec/src/system/book.zod.ts#matchesInclude (`Array.isArray(doc.tags) && doc.tags.includes(include.tag)` — the tag branch of `include`); packages/spec/src/system/book.zod.ts#resolveBookTree (the caller that decides membership from it); packages/rest/src/rest-server.ts#resolveBookTree (`tags: d.tags` in the tree corpus the route hands the resolver — the transport half)", + "verifiedAt": "2026-09-27", + "evidence": "packages/spec/src/system/book.zod.ts#matchesInclude (`Array.isArray(doc.tags) && doc.tags.includes(include.tag)` — the tag branch of `include`); packages/spec/src/system/book.zod.ts#resolveBookTree (the caller that decides membership from it); packages/rest/src/rest-server.ts#readableTree (`tags: d.tags` in the tree corpus the route hands `audience.readableTree(book, docs)` — the transport half); packages/rest/src/meta-item-read-gate.ts#resolveBookTree (`resolveBookTree(book, docs, book._packageId)` — where that corpus reaches the resolver)", "note": "DECLARED 2026-08-02 (#4509, ADR-0049) — the enforce half of enforce-or-remove. The consumer, the transport and the resolver-side interface all predated this key: matchesInclude compared `doc.tags` against a group's `include: { tag }`, the book route already forwarded `tags: d.tags`, and ResolverDoc declared `tags?: string[]` marked '(P3d; absent today)'. What was missing was one line HERE — DocSchema is strict, so authoring `tags:` was a parse error, every doc reached the resolver with tags undefined, and the `{ tag }` include variant could never match. Removing the variant was the alternative and was rejected: a union member has no clean tombstone (retiredKey covers object keys), so authors would have gotten a bare union error, and it would have discarded working matcher code. Live on arrival — the branch it feeds is reachable the moment a doc carries a tag. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — `book.zod.ts:213` is `description?: string;` in the `ResolvedEntry` interface, and the parenthetical claiming it was \"matchesInclude tag branch\" named the right function ~61 lines away; the \"reached from :255 and :281\" hops were prose the ledger never bounded. `rest-server.ts:3218` had rotted onto `this.registerSecurityEndpoints(bp)` — a route-registration call, ~1,700 lines from the corpus that forwards the tags. Re-closed by hand against 93ea19bca." } } From b58d036ed52c53a6fc6dc109f0df39b921ccce26 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:45:32 +0000 Subject: [PATCH 7/7] test(http-conformance): the four /meta read-gate rows through a real createHonoApp host A non-holder of crm_admin is refused the gated doc, its /published twin and the set-gated book (403 PERMISSION_DENIED, nothing on the wire) and served the crm app pruned of its requiredPermissions-gated entry; a holder is the control. Lives here, not in packages/adapters/hono, because that suite aliases @objectstack/runtime to a stub for every test. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- ...no-meta-item-read-gate.conformance.test.ts | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 packages/qa/http-conformance/src/hono-meta-item-read-gate.conformance.test.ts diff --git a/packages/qa/http-conformance/src/hono-meta-item-read-gate.conformance.test.ts b/packages/qa/http-conformance/src/hono-meta-item-read-gate.conformance.test.ts new file mode 100644 index 00000000000..c357225ebb2 --- /dev/null +++ b/packages/qa/http-conformance/src/hono-meta-item-read-gate.conformance.test.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20193] Through a REAL `createHonoApp` host, the dispatcher's `/meta` item + * reads refuse and prune exactly as `RestServer` does — the composed-host pin. + * + * ## What this pins + * + * A host that mounts only `@objectstack/hono`'s `${prefix}/*` catch-all — the + * documented edge / serverless embed shape — has no `RestServer` at all: every + * `GET /meta/:type/:name` goes catch-all → `HttpDispatcher.dispatch()` → the + * domain registry → `handleMetadataRequest`. That handler applied no per-caller + * read gate, so a member who does not hold `crm_admin` read the + * `{ permissionSet }`-gated doc body, its `/published` twin and the set-gated + * book, and an app's `requiredPermissions`-gated nav entry reached every member. + * It now asks the one gate `RestServer` asks (`createMetaItemReadGate`, + * `@objectstack/rest`), and these rows hold the ANSWER ON THE WIRE — status, + * `error.code`, and whether the withheld content is in the body — for a + * non-holder, with a holder as the control that the same host still serves. + * + * The four rows are the card's: the doc, the doc's `/published`, the set-gated + * book, and the `requiredPermissions`-gated app entry. + * + * ## ⚠️ Why this lives HERE and not in `packages/adapters/hono` + * + * `packages/adapters/hono/vitest.config.ts` aliases `@objectstack/runtime` to a + * hand-written stub for its whole suite, and `createHonoApp` imports + * `HttpDispatcher` from that specifier — so no test in that package can reach + * the real dispatcher, and a "composed" host built there would compose the + * stub. This package is where the repo already boots the two for real (see + * `hono-dispatcher-result-response.conformance.test.ts`): `@objectstack/hono` + * is aliased to this checkout's adapter SOURCE, and `@objectstack/runtime` is + * the real package (through its `dist/`, a ledgered pair in + * `scripts/check-test-source-alias.mjs`). So a runtime change reaches these + * rows only once `@objectstack/runtime` is rebuilt — which is how CI builds + * the closure before it tests. + * + * ## The one seam that is stubbed, and why + * + * WHO the caller is comes from the dispatcher's identity step + * (`timedResolveExecutionContext`), whose real inputs are a better-auth session + * and the authorization tables — neither of which this boot has. It is replaced + * on the prototype (the instance `createHonoApp` constructs for itself is the + * one that runs) by a lookup keyed on a request HEADER, read off the very Fetch + * `Request` the catch-all hands to `dispatch()`. So the principal still arrives + * on the wire, and everything after identity — the catch-all, the registry, + * the handler, the gate, the adapter's rendering — is the real code. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { HttpDispatcher } from '@objectstack/runtime'; +import { createHonoApp } from '@objectstack/hono'; + +const PREFIX = '/api/v1'; +const PRINCIPAL_HEADER = 'x-probe-principal'; + +// ── Fixtures (the #20156 door census's own, trimmed to the four rows) ───────── + +const DOC_SECRET = 'Rotate the tenant signing keys before every release.'; +const BOOK_SECRET = 'Restricted spine: the incident playbooks.'; + +const STORE: Record = { + book: [ + { + name: 'admin_guide', label: 'Admin Guide', description: BOOK_SECRET, + audience: { permissionSet: 'crm_admin' }, _packageId: 'crm', + groups: [{ key: 'admin', label: 'Admin', include: 'crm_admin_*' }], + }, + { + name: 'help_center', label: 'Help Centre', audience: 'org', _packageId: 'crm', + groups: [{ key: 'start', label: 'Start', include: 'crm_intro' }], + }, + ], + doc: [ + { name: 'crm_intro', label: 'Getting started', content: 'Welcome aboard.', _packageId: 'crm' }, + { name: 'crm_admin_runbook', label: 'Admin runbook', content: DOC_SECRET, _packageId: 'crm' }, + ], + app: [{ + name: 'crm', + label: 'CRM', + navigation: [ + { id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' }, + { id: 'nav_finance_ledger', type: 'page', label: 'Ledger', pageName: 'ledger', requiredPermissions: ['finance.access'] }, + ], + }], + object: [], +}; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); +const singular = (type: unknown): string => String(type ?? '').replace(/s$/, ''); +const find = (type: unknown, name: unknown): any => + (STORE[singular(type)] ?? []).find((i: any) => i.name === name); + +// ── Principals ──────────────────────────────────────────────────────────────── + +interface Principal { ctx: Record; holdings: string[] } +const PRINCIPALS: Record<'holder' | 'non-holder', Principal> = { + holder: { + ctx: { userId: 'u_holder', isSystem: false, systemPermissions: ['finance.access'] }, + holdings: ['crm_admin'], + }, + 'non-holder': { + ctx: { userId: 'u_member', isSystem: false, systemPermissions: [] }, + holdings: [], + }, +}; +type PrincipalName = keyof typeof PRINCIPALS; +const principalOf = (request: any): Principal | undefined => { + const name = request?.headers?.get?.(PRINCIPAL_HEADER) as PrincipalName | null; + return name ? PRINCIPALS[name] : undefined; +}; + +// ── The composed host ───────────────────────────────────────────────────────── + +/** + * A real `LiteKernel` carrying the two services the `/meta` item read consults + * (`protocol`, and `security` for permission-set holdings), and the REAL + * `createHonoApp` over it. + */ +async function boot() { + const getMetaItem = vi.fn(async ({ type, name }: any) => { + const found = find(type, name); + return { type: singular(type), name, item: found ? clone(found) : undefined }; + }); + const protocol = { + getMetaItems: vi.fn(async ({ type }: any) => clone(STORE[singular(type)] ?? [])), + getMetaItem, + getMetaItemLayered: vi.fn(async ({ type, name }: any) => { + const found = find(type, name); + const layer = () => (found ? clone(found) : null); + return { type: singular(type), name, code: layer(), overlay: layer(), effective: layer() }; + }), + }; + const security = { + // Holdings are answered for the context the gate hands in — the + // principal the identity step resolved for THIS request. + resolvePermissionSetNames: async (ctx: any) => + Object.values(PRINCIPALS).find((p) => p.ctx.userId === ctx?.userId)?.holdings ?? [], + }; + + const kernel = new LiteKernel(); + kernel.use({ + metadata: { name: 'test-meta-read-gate-services', version: '1.0.0' }, + init: (c: any) => { + c.registerService('protocol', protocol); + c.registerService('security', security); + }, + } as any); + await kernel.bootstrap(); + + vi.spyOn(HttpDispatcher.prototype as any, 'timedResolveExecutionContext') + .mockImplementation(async (opts: any) => clone(principalOf(opts?.request)?.ctx ?? { isSystem: false })); + + const app = createHonoApp({ kernel: kernel as any, prefix: PREFIX, cors: false }); + const read = async (as: PrincipalName, path: string) => { + const res = await app.request(`http://localhost${PREFIX}${path}`, { headers: { [PRINCIPAL_HEADER]: as } }); + const text = await res.text(); + return { status: res.status, text, body: JSON.parse(text) }; + }; + return { read, getMetaItem }; +} + +afterEach(() => { vi.restoreAllMocks(); }); + +const navIds = (doc: any): string[] => (doc?.navigation ?? []).map((e: any) => e.id); + +// ── The four rows, non-holder ───────────────────────────────────────────────── + +describe('[#20193] a real createHonoApp host refuses the non-holder what RestServer refuses — the four rows', () => { + const REFUSED: Array<{ row: string; path: string; secret: string }> = [ + { row: 'the gated doc', path: '/meta/doc/crm_admin_runbook', secret: DOC_SECRET }, + { row: 'the gated doc, /published', path: '/meta/doc/crm_admin_runbook/published', secret: DOC_SECRET }, + { row: 'the set-gated book', path: '/meta/book/admin_guide', secret: BOOK_SECRET }, + ]; + + for (const { row, path, secret } of REFUSED) { + it(`${row}: 403 PERMISSION_DENIED, and none of it on the wire`, async () => { + const { read } = await boot(); + const res = await read('non-holder', path); + + // The ADR-0112 envelope — both halves, never a bare status. + expect(res.status).toBe(403); + expect(res.body?.success).toBe(false); + expect(res.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(res.text).not.toContain(secret); + }, 30_000); + } + + it('the requiredPermissions-gated app entry: 200, the app PRUNED of the entry', async () => { + const { read, getMetaItem } = await boot(); + const res = await read('non-holder', '/meta/app/crm'); + + expect(res.status).toBe(200); + expect(res.body?.success).toBe(true); + expect(navIds(res.body?.data?.item)).toEqual(['nav_leads']); + expect(res.text).not.toContain('nav_finance_ledger'); + // Reached through the catch-all's `/meta` domain — the item read ran. + expect(getMetaItem).toHaveBeenCalledTimes(1); + }, 30_000); +}); + +// ── The control: the same host still serves a holder ────────────────────────── + +describe('[#20193] control — the same host serves a holder every row in full', () => { + it('the gated doc and its /published twin: 200, the body', async () => { + const { read } = await boot(); + for (const path of ['/meta/doc/crm_admin_runbook', '/meta/doc/crm_admin_runbook/published']) { + const res = await read('holder', path); + expect(res.status, path).toBe(200); + expect(res.text, path).toContain(DOC_SECRET); + } + }, 30_000); + + it('the set-gated book: 200, the book', async () => { + const { read } = await boot(); + const res = await read('holder', '/meta/book/admin_guide'); + expect(res.status).toBe(200); + expect(res.body?.data?.item?.description).toBe(BOOK_SECRET); + }, 30_000); + + it('the app: 200, every entry', async () => { + const { read } = await boot(); + const res = await read('holder', '/meta/app/crm'); + expect(res.status).toBe(200); + expect(navIds(res.body?.data?.item)).toEqual(['nav_leads', 'nav_finance_ledger']); + }, 30_000); +});