Skip to content

Commit bc80e16

Browse files
fix(rest): app nav prunes doc entries the caller may not read (ADR-0046 §6.7) (#20128)
Fixes #19790 Clause-②: no The server's app-nav filter (`filterAppForUserWithReason`, `packages/rest/src/rest-server.ts`) gains the audience arm the maintainer ruled (ruling `5793362670`, letter A). A `type: 'doc'` entry naming a doc the caller may not read is pruned. A `book` entry with no page the caller may read is pruned too. Both callers apply it: the list route `GET /meta/app` and the by-name route `GET /meta/app/:name`. It covers the top-level `navigation`, `children` and `areas[].navigation`. The rule is the one `DocNavItemSchema` already declares. Before this change only the (unshipped) objectui renderer could honour it, so every member received the entry: its label, plus the gated book or doc name. ## One resolution, no second resolver A private `resolveDocsAudience(environmentId, req, books)` now builds a `DocsAudience`: the caller, the fast path, a book lookup (declared book, else the implicit per-package book), the book's own audience gate, a per-doc reader, the readable tree, and the readable pages. Every verdict is asked of the `@objectstack/spec/system` helpers (`audienceAllows`, `resolveDocAudiences`, `docAudienceAllows`, `resolveBookTree`, `resolveBookClaimedDocs`, `deriveImplicitPackageBook`). Five doors are built on it: `/meta/book`, `/meta/book/:name`, `/meta/doc`, `/meta/doc/:name` and `/meta/book/:name/tree`, which used to hold three hand-written copies of this logic, plus the new nav arm. - **The reads' responses are byte-identical.** A one-off probe (not committed) ran `BASE` `fa00ebf4`'s `rest-server.ts` against this branch's over 1584 requests. The matrix crossed 4 callers (anonymous, non-holder, holder, unresolvable holdings) × 3 book sets (set-gated, org and public only, none) × 3 failure modes (none, book read throws, doc read throws) × the book and doc list, item and tree routes, in both spellings, with and without `?package=` and `?include=content`. It found **0 diffs** across statuses `200:752 401:168 403:28 404:240 500:396`, with 55 distinct responses. A control (non-holder 403 vs holder 200 on the same doc) proves the probe distinguishes callers. The existing suites (`rest.test.ts` ADR-0046 §6.7 block, `meta-audience-plural`, `meta-public-book-grant`) stay green. ## Entry semantics, measured against the resolver - **`doc` alone**: served iff `docAudienceAllows(resolveDocAudiences(...).get(doc), caller)`. This is the answer `/meta/doc/:name` gives. - **`book` alone**: the named book (else the implicit per-package book) must admit the caller by its own audience. That is the tree read's 401/403. **And** at least one of its pages must be readable. A book's pages are the docs it *claims*, per `resolveBookClaimedDocs`, the membership `resolveDocAudiences` itself uses. Measured: `resolveBookTree` appends every doc the book does not claim as a synthetic *Uncategorized* group. Over an env-wide corpus, nearly every book's tree therefore holds some readable doc, and counting tree entries would never prune. The spec calls those orphans "not an authored membership claim". A pin shows the implicit `ops` book's tree serving `crm_intro` under Uncategorized while the entry is still pruned. - **`book` + `doc`**: pruned when either the book's own audience denies the caller or the doc is unreadable. A readable doc inside an unreadable book is possible, because a doc's effective audience is the union over every book claiming it (`docAudienceAllows`, `book.zod.ts`). But the entry opens the page in that book's context, whose tree read is refused (the `admitsBook` check in the tree route), and the entry itself names the gated book. So it is pruned. - **Existence** is left to `docs/nav-target`. A `doc` naming a doc absent from the corpus is served, because the resolver's own default for a doc it has no entry for is `org`. A `book` naming nothing resolves to an implicit book with no page, and is pruned: "no readable page" is the rule's own wording. Both are pinned. - **Anonymous**: `GET /meta/app` answers 401 before any read (pinned). The umbrella gate admits anonymous callers only to the book and doc reads, so there is no anonymous app nav for the arm to cover. - Entry-level arms carry no reason. `withheld` is app-level only, so the arm is a bare `continue`, like its neighbours. An app it empties is still served. ## Fails closed - **No gate means no `doc` entries.** Both callers must pass a gate, or the arm drops every `doc` entry. - **A thrown book read, or a thrown doc corpus read, also drops every `doc` entry** of that response and logs a `warn`. The rest of the navigation is served. - **Unresolvable holdings deny set-gated entries**, through `resolveAudienceCaller` (ADR-0049). - **Why the nav gate does not copy the reads' degradation.** Mechanism assumption: "degrades exactly as the reads do". Measured falsified for safety: the reads swallow a thrown book read to `[]` (`fetchAudienceBooks`), which reads as "no gated book anywhere". Copying that would serve set-gated entries to every member. The nav gate reads the same data through `readAudienceBooks` / `readDocCorpus`, which report the fault, and prunes. The reads keep their answers unchanged. That fail-open is reported below as a finding. ## Cost per `/meta/app` request (ruling point 4) - **No `doc` entry in the app list:** zero added reads, only a walk of the nav trees (pinned: no book read, no doc read, no holdings). - **Otherwise, once per request whatever the app count** (pinned with two apps: 1 book read, 1 doc read, 1 holdings resolution): - one `book` list read; - one permission-set resolution, only if some book is set-gated. The execution context is already memoised per request. - one `doc` list read, only off the fast path or when an entry names a book alone. - **Book traversal.** Off the fast path, `resolveDocAudiences` runs one `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`, which is one `resolveBookTree` of that book: every doc is visited once per group rule, plus one lookup per claimed page. - **No cache.** Nothing outlives the request. The fast path (authenticated, no set-gated book) is the doc list's own. ## Tests `packages/rest/src/meta-app-nav-doc-audience.test.ts` has 19 cases. They drive the real list, by-name and tree routes: - a non-reader receives no such entries, and the wire carries none of the names or labels; - a reader receives them; - a book with exactly one readable page stays, and agrees with the tree read; - a book whose own audience admits the member but whose every page is gated is pruned; - list and by-name parity, for a non-holder and a holder; - the `requiredPermissions` control; - the four fail-closed rows; - fast path and existence; - cost; - anonymous. Evidence: - `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 src/meta-app-nav-doc-audience.test.ts`: `Tests 19 passed (19)`. - `@objectstack/rest` full local project: `Test Files 195 passed (195)`, `Tests 3284 passed | 1 skipped (3285)`. - `pnpm --filter @objectstack/rest run typecheck`: exit 0, test layer `0 file(s) / 0 error(s)`. - Measured at `cb800e24`. The later commit `f2bc219b` touches only the docs page and the changeset. - **Ablation.** Committed first. The arm line was deleted through `scripts/ablation-replace.mjs` in wrap mode (anchor 1 to 0, blob `5c7dbf8f1015` to `f0b3c3cdd872`). Result: `Tests 12 failed | 7 passed (19)`. The 12 red are every non-reader, fail-closed, parity-non-holder, fast-path-existence and two-app-cost row. The 7 green are the holder, one-readable-page, control, no-doc-entry-cost, no-book-entry and anonymous rows. The restore was proven: blob equals `HEAD`, and `git diff HEAD` is empty. A re-run is green (19/19). - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` derived 90 commands at `f2bc219b`. All exit 0. Reconciled with `--ran`: `90 derived, 90 run, 0 NOT-MEASURED` (a derived zero, since every row recorded its exit code). Two gates first refused with PREREQUISITE NOT MET and were re-run green after building what they read: `check:skill-examples` after building client and client-react, and `check:dual-build-cjs-loads` after the workspace build. - **Lint.** `pnpm lint` (full union, `eslint . --no-inline-config`) exited 0 in 107s at `f2bc219b`. ## Docs and changeset - `content/docs/ui/apps.mdx`: the Audience bullet now says the server applies the rule to the entry itself, including the `book` + `doc` rule, and which pages count. - `.changeset/19790-nav-doc-audience-prune.md`: `@objectstack/rest` `patch`. ## Acceptance notes - **Conflict in the dispatch.** The dispatch asked the gate both to "degrade exactly as the reads do" and to "not fail open". The reads fail open on a thrown book read, so the gate follows the second and prunes (see "Fails closed"). - **Observation, not filed.** Without `?package=`, `/meta/book/:name/tree` resolves over the env-wide corpus. Every book's tree therefore lists every doc it does not claim under Uncategorized. That is the resolver's documented "nothing is ever dropped" rule, and it is why the nav arm counts claimed pages rather than tree entries. - **Out of scope, reported for the seat, not fixed here** (evidence from one-off probes, not committed): - (a) The docs reads fail open when the book list read throws. `fetchAudienceBooks` swallows the fault to `[]`, and `/meta/doc/:name` swallows a thrown corpus read the same way. Probe: a non-holder's `GET /meta/doc/crm_admin_runbook` goes from 403 to 200 with the body, and `GET /meta/doc` lists it, once the book read rejects. The shipped `getMetaItems` rethrows every `sys_metadata` read failure except "unprovisioned". - (b) `GET /meta/app/:name/layers`, the deprecated `?layers=true`, and `GET /meta/app/:name/published` serve the unfiltered app document. Probe: an authenticated member holding neither the audience nor the `requiredPermissions` receives both kinds of entry. `/history` and `/diff` serve stored app versions the same way; that was read from code, not driven. `packages/runtime/src/domains/meta.ts`'s dispatcher list and item reads apply no app-nav filter where that door answers. The MCP `list_metadata` / `describe_metadata` implementations are not in this tree, so they are NOT MEASURED here. - #19482 (the `doc` variant) is the parent card and is not addressed here. The objectui renderer pruning (objectui#10188) stays as defence in depth, per the ruling. --- _Generated by [Claude Code](https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6780e34 commit bc80e16

4 files changed

Lines changed: 853 additions & 95 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): `GET /meta/app` leaves out a `type: 'doc'` navigation entry the caller may not read (#19790)
6+
7+
`DocNavItemSchema` declares that a `doc` entry the member may not read is not
8+
rendered, and that a `book` entry is not rendered for a member with no readable
9+
page in it. Until now only a renderer could honour that. The server's app-nav
10+
filter pruned on `requiredPermissions`, `requiresService` and object servability,
11+
so every member of the app got the entry. That included its label and the gated
12+
book or doc name, however the book was gated.
13+
14+
The filter now applies the docs audience (ADR-0046 §6.7) on both the list route
15+
(`GET /meta/app`) and the by-name route (`GET /meta/app/:name`), in the top-level
16+
navigation, inside `children` and inside `areas[].navigation`:
17+
18+
- **`doc`**: the entry is dropped when the doc's effective audience does not
19+
admit the caller. This is the answer `GET /meta/doc/:name` gives.
20+
- **`book`**: the entry is dropped when the book's own audience does not admit
21+
the caller, or when none of its pages is readable. A book's pages are the docs
22+
its groups claim. The *Uncategorized* group that the book tree adds does not
23+
count.
24+
- **`book` + `doc`**: the entry is dropped when either of those checks fails.
25+
- An app emptied by the prune is still served, as it is today when
26+
`requiredPermissions` empties one. An emptied `group` or area collapses, as
27+
with every other gate.
28+
29+
The verdicts come from the same resolution that `/meta/doc`, `/meta/doc/:name`,
30+
`/meta/book` and `/meta/book/:name/tree` now share. What those reads return is
31+
unchanged.
32+
33+
**Fails closed.** If the book or doc list read throws while `/meta/app` is being
34+
answered, every `doc` entry is left out of that one response and a warning is
35+
logged. The rest of the navigation is still served. If the caller's
36+
permission-set holdings cannot be resolved, set-gated entries are dropped, as
37+
set-gated content already is.
38+
39+
**Cost.** An app list with no `doc` entry performs no extra read. Otherwise each
40+
request adds one `book` list read, one permission-set resolution when some book
41+
is set-gated, and one `doc` list read when a set-gated book exists or an entry
42+
names only a book. Each of these happens once per request, however many apps are
43+
listed. Nothing is cached across requests.

‎content/docs/ui/apps.mdx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,15 @@ and an item with neither is refused with a message naming both keys:
223223
- **Audience** — the entry has no gate of its own; it inherits the docs audience
224224
gate. A `book` entry shows the member only the pages they may read, and is not
225225
shown to a member with no readable page in it; a `doc` entry the member may not
226-
read is not shown. The page content itself is gated on the server by the
227-
`/meta/doc` and `/meta/book/:name/tree` reads. `visible` and
228-
`requiredPermissions` still apply and can only narrow further.
226+
read is not shown. The server applies this rule to the entry itself:
227+
`/meta/app` leaves such an entry out of the app it returns, so its label and
228+
its book or doc name never reach a member who cannot open it. An item with both
229+
`book` and `doc` is also left out when the member may not open that book, even
230+
if they may read the page. A book's pages are the docs its groups claim; the
231+
*Uncategorized* group the book tree adds does not count. The page content
232+
itself is gated on the server by the `/meta/doc` and `/meta/book/:name/tree`
233+
reads. `visible` and `requiredPermissions` still apply and can only narrow
234+
further.
229235

230236
`os build`, `os validate` and `os lint` refuse a `doc` item whose `book` or `doc`
231237
names nothing in the package (rule `docs/nav-target`) — checked in the docs step,

0 commit comments

Comments
 (0)