diff --git a/.changeset/19790-nav-doc-audience-prune.md b/.changeset/19790-nav-doc-audience-prune.md new file mode 100644 index 00000000000..b57b41337af --- /dev/null +++ b/.changeset/19790-nav-doc-audience-prune.md @@ -0,0 +1,43 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest): `GET /meta/app` leaves out a `type: 'doc'` navigation entry the caller may not read (#19790) + +`DocNavItemSchema` declares that a `doc` entry the member may not read is not +rendered, and that a `book` entry is not rendered for a member with no readable +page in it. Until now only a renderer could honour that. The server's app-nav +filter pruned on `requiredPermissions`, `requiresService` and object servability, +so every member of the app got the entry. That included its label and the gated +book or doc name, however the book was gated. + +The filter now applies the docs audience (ADR-0046 §6.7) on both the list route +(`GET /meta/app`) and the by-name route (`GET /meta/app/:name`), in the top-level +navigation, inside `children` and inside `areas[].navigation`: + +- **`doc`**: the entry is dropped when the doc's effective audience does not + admit the caller. This is the answer `GET /meta/doc/:name` gives. +- **`book`**: the entry is dropped when the book's own audience does not admit + the caller, or when none of its pages is readable. A book's pages are the docs + its groups claim. The *Uncategorized* group that the book tree adds does not + count. +- **`book` + `doc`**: the entry is dropped when either of those checks fails. +- An app emptied by the prune is still served, as it is today when + `requiredPermissions` empties one. An emptied `group` or area collapses, as + with every other gate. + +The verdicts come from the same resolution that `/meta/doc`, `/meta/doc/:name`, +`/meta/book` and `/meta/book/:name/tree` now share. What those reads return is +unchanged. + +**Fails closed.** If the book or doc list read throws while `/meta/app` is being +answered, every `doc` entry is left out of that one response and a warning is +logged. The rest of the navigation is still served. If the caller's +permission-set holdings cannot be resolved, set-gated entries are dropped, as +set-gated content already is. + +**Cost.** An app list with no `doc` entry performs no extra read. Otherwise each +request adds one `book` list read, one permission-set resolution when some book +is set-gated, and one `doc` list read when a set-gated book exists or an entry +names only a book. Each of these happens once per request, however many apps are +listed. Nothing is cached across requests. diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index 755ac1b585f..a569a404d25 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -223,9 +223,15 @@ and an item with neither is refused with a message naming both keys: - **Audience** — the entry has no gate of its own; it inherits the docs audience gate. A `book` entry shows the member only the pages they may read, and is not shown to a member with no readable page in it; a `doc` entry the member may not - read is not shown. The page content itself is gated on the server by the - `/meta/doc` and `/meta/book/:name/tree` reads. `visible` and - `requiredPermissions` still apply and can only narrow further. + read is not shown. The server applies this rule to the entry itself: + `/meta/app` leaves such an entry out of the app it returns, so its label and + its book or doc name never reach a member who cannot open it. An item with both + `book` and `doc` is also left out when the member may not open that book, even + if they may read the page. A book's pages are the docs its groups claim; the + *Uncategorized* group the book tree adds does not count. The page content + itself is gated on the server by the `/meta/doc` and `/meta/book/:name/tree` + reads. `visible` and `requiredPermissions` still apply and can only narrow + further. `os build`, `os validate` and `os lint` refuse a `doc` item whose `book` or `doc` names nothing in the package (rule `docs/nav-target`) — checked in the docs step, diff --git a/packages/rest/src/meta-app-nav-doc-audience.test.ts b/packages/rest/src/meta-app-nav-doc-audience.test.ts new file mode 100644 index 00000000000..2bf5ddadef9 --- /dev/null +++ b/packages/rest/src/meta-app-nav-doc-audience.test.ts @@ -0,0 +1,373 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19790] `GET /meta/app` prunes a `type: 'doc'` nav entry by the docs + * audience (ADR-0046 §6.7) — the rule `DocNavItemSchema` declares: a `doc` + * entry the member may not read is not rendered, and a `book` entry is not + * rendered for a member with no readable page in it. + * + * Before this arm the server pruned such entries on `requiredPermissions`, + * `requiresService` and object servability only, so every member of the app + * received the entry — its label and the gated book / doc name — however the + * book was gated, and only a renderer could hide it. + * + * Driven through the real list and by-name routes, over one fixture whose + * audiences come from the spec's own resolver: + * + * admin_guide { permissionSet: crm_admin } claims crm_admin_runbook (crm), + * ops_keys + ops_rotation (ops) + * help_center 'org' claims crm_intro + * implicit `crm` book → crm_intro (org) + crm_admin_runbook (gated): ONE readable page + * implicit `ops` book → ops_keys + ops_rotation, both gated: NO readable page + */ + +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +// Explicit `.js` extension: NodeNext resolution (see the sibling nav-gate tests). +import { RestServer } from './rest-server.js'; + +// This file spies on `console.warn` (the fail-closed diagnostic), so it +// declares the level it observes under — the SHIPPED default. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + +let warn: ReturnType; +beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); +afterEach(() => { warn.mockRestore(); }); + +const ADMIN_GUIDE = { + name: 'admin_guide', + label: 'Admin Guide', + audience: { permissionSet: 'crm_admin' }, + _packageId: 'crm', + groups: [ + { key: 'admin', label: 'Admin', include: 'crm_admin_*' }, + { key: 'ops', label: 'Operations', include: 'ops_*', package: 'ops' }, + ], +}; +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', _packageId: 'crm' }, + { name: 'crm_admin_runbook', label: 'Admin runbook', _packageId: 'crm' }, + { name: 'ops_keys', label: 'Key handling', _packageId: 'ops' }, + { name: 'ops_rotation', label: 'Key rotation', _packageId: 'ops' }, +]; + +const CRM_APP = { + name: 'crm', + label: 'CRM', + navigation: [ + { id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' }, + // Control: a `requiredPermissions`-only entry the caller satisfies. + { id: 'nav_reports', type: 'page', label: 'Reports', pageName: 'crm_reports', requiredPermissions: ['crm.reports'] }, + { id: 'nav_admin_guide', type: 'doc', label: 'Admin Guide', book: 'admin_guide' }, + { id: 'nav_admin_runbook', type: 'doc', label: 'Admin Runbook', doc: 'crm_admin_runbook' }, + { id: 'nav_ops_book', type: 'doc', label: 'Ops Handbook', book: 'ops' }, + { id: 'nav_crm_book', type: 'doc', label: 'CRM Handbook', book: 'crm' }, + { id: 'nav_intro', type: 'doc', label: 'Getting started', doc: 'crm_intro' }, + // A readable page opened in a book the caller may not open. + { id: 'nav_intro_in_admin_guide', type: 'doc', label: 'Intro, admin edition', book: 'admin_guide', doc: 'crm_intro' }, + // Control: a readable doc that `requiredPermissions` still narrows away. + { id: 'nav_intro_locked', type: 'doc', label: 'Intro, locked', doc: 'crm_intro', requiredPermissions: ['crm.docs_admin'] }, + { + id: 'grp_admin_docs', type: 'group', label: 'Admin docs', + children: [{ id: 'nav_admin_runbook_nested', type: 'doc', doc: 'crm_admin_runbook' }], + }, + ], + areas: [{ id: 'area_ops', label: 'Operations', navigation: [{ id: 'nav_ops_book_area', type: 'doc', book: 'ops' }] }], +}; + +/** What a member who does NOT hold `crm_admin` is served. */ +const NON_HOLDER_NAV = ['nav_leads', 'nav_reports', 'nav_crm_book', 'nav_intro']; +/** What a `crm_admin` holder is served — every doc entry but the permission-locked one. */ +const HOLDER_NAV = [ + 'nav_leads', 'nav_reports', 'nav_admin_guide', 'nav_admin_runbook', 'nav_ops_book', + 'nav_crm_book', 'nav_intro', 'nav_intro_in_admin_guide', 'grp_admin_docs', +]; + +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 }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn(); res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); + return res; +} + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); + +interface SetupOpts { + /** Permission sets the caller holds; `'unresolvable'` makes the security service throw. */ + holdings?: string[] | 'unresolvable'; + books?: any[]; + docs?: any[]; + apps?: any[]; + /** Metadata types whose LIST read throws. */ + failing?: string[]; + /** No resolved session at all. */ + anonymous?: boolean; +} + +function setup(opts: SetupOpts = {}) { + const { holdings = [], books = [ADMIN_GUIDE, HELP_CENTER], docs = DOCS, apps = [CRM_APP], failing = [] } = opts; + const byType: Record = { app: apps, book: books, doc: docs }; + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async ({ type }: any) => { + const t = String(type ?? '').replace(/s$/, ''); + if (failing.includes(t)) throw new Error(`${t} store unavailable`); + return clone(byType[t] ?? []); + }), + getMetaItem: vi.fn(async ({ type, name }: any) => { + const t = String(type ?? '').replace(/s$/, ''); + const found = (byType[t] ?? []).find((i: any) => i.name === name); + return { type: t, name, item: found ? clone(found) : undefined, lock: 'none', editable: true, deletable: true, resettable: false }; + }), + findData: vi.fn().mockResolvedValue([]), + }; + const rest: any = new RestServer(createMockServer() as any, protocol, {} as any); + const resolvePermissionSetNames = vi.fn(async () => { + if (holdings === 'unresolvable') throw new Error('security service unavailable'); + return holdings; + }); + if (!opts.anonymous) { + rest.resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: ['crm.reports'] }); + rest.securityServiceProvider = async () => ({ resolvePermissionSetNames }); + } + rest.registerRoutes(); + return { rest, protocol, resolvePermissionSetNames }; +} + +async function call(rest: any, path: string, params: Record) { + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === `/api/v1/meta${path}`); + if (!route) throw new Error(`GET /meta${path} not registered`); + const res = makeRes(); + await route.handler({ method: 'GET', params, query: {}, body: {}, headers: {} }, res); + return res; +} +const listApps = (rest: any) => call(rest, '/:type', { type: 'app' }); +const getApp = (rest: any, name = 'crm') => call(rest, '/:type/:name', { type: 'app', name }); +const bookTree = (rest: any, name: string) => call(rest, '/book/:name/tree', { name }); + +const appsOf = (body: any): any[] => (Array.isArray(body) ? body : (body?.items ?? [])); +const navIds = (app: any): string[] => (app?.navigation ?? []).map((e: any) => e.id); +const areaIds = (app: any): string[] => (app?.areas ?? []).map((a: any) => a.id); +const listedApp = async (rest: any, name = 'crm') => appsOf((await listApps(rest)).body).find((a: any) => a?.name === name); +const namedApp = async (rest: any, name = 'crm') => (await getApp(rest, name)).body?.item; +const reads = (protocol: any, type: string): number => + protocol.getMetaItems.mock.calls.filter((c: any[]) => String(c[0]?.type ?? '').replace(/s$/, '') === type).length; +const treeDocs = (tree: any): string[] => + (tree?.groups ?? []).flatMap((g: any) => g.entries.map((e: any) => e.doc)).filter(Boolean).sort(); + +describe('[#19790] a member who may NOT read — the entries leave the server', () => { + it('LIST: no `doc` entry for a gated doc, a gated book, or a book whose every page is gated', async () => { + const { rest } = setup({ holdings: [] }); + const app = await listedApp(rest); + + expect(navIds(app)).toEqual(NON_HOLDER_NAV); + // The gate reaches the area tree too, and an area it empties is dropped. + expect(areaIds(app)).toEqual([]); + }); + + it('LIST: the wire carries none of the gated names or labels', async () => { + const { rest } = setup({ holdings: [] }); + const wire = JSON.stringify((await listApps(rest)).body); + + for (const leaked of ['admin_guide', 'crm_admin_runbook', 'Admin Guide', 'Ops Handbook', '"book":"ops"']) { + expect(wire).not.toContain(leaked); + } + }); + + it('a `book` + `doc` entry is dropped when the BOOK is gated, although the doc alone is readable', async () => { + // `crm_intro` is readable (help_center claims it, `org`) — `nav_intro` + // survives. The same page opened in `admin_guide`'s context names a + // book this member cannot open (its tree read answers 403), so it goes. + const { rest } = setup({ holdings: [] }); + const ids = navIds(await listedApp(rest)); + + expect(ids).toContain('nav_intro'); + expect(ids).not.toContain('nav_intro_in_admin_guide'); + }); + + it('a group left empty by the arm collapses, like any other gate (#7380)', async () => { + const { rest } = setup({ holdings: [] }); + expect(navIds(await listedApp(rest))).not.toContain('grp_admin_docs'); + }); +}); + +describe('[#19790] a member who MAY read — the same entries are served', () => { + it('LIST: every doc entry a `crm_admin` holder can read is present, area included', async () => { + const { rest } = setup({ holdings: ['crm_admin'] }); + const app = await listedApp(rest); + + expect(navIds(app)).toEqual(HOLDER_NAV); + expect(areaIds(app)).toEqual(['area_ops']); + expect(app.navigation.find((e: any) => e.id === 'grp_admin_docs').children.map((c: any) => c.id)) + .toEqual(['nav_admin_runbook_nested']); + }); +}); + +describe('[#19790] a `book` entry is judged on its readable PAGES', () => { + it('⭐ a book with exactly one readable page stays — and the tree read agrees on that page', async () => { + const { rest } = setup({ holdings: [] }); + + expect(navIds(await listedApp(rest))).toContain('nav_crm_book'); + // The implicit `crm` book holds crm_intro (readable) and + // crm_admin_runbook (gated): the tree this member is served has one page. + expect(treeDocs((await bookTree(rest, 'crm')).body)).toEqual(['crm_intro']); + }); + + it('a book whose OWN audience admits the member but whose every page is gated is dropped', async () => { + const { rest } = setup({ holdings: [] }); + + expect(navIds(await listedApp(rest))).not.toContain('nav_ops_book'); + // The implicit `ops` book is `org` — the tree read opens (200)… + const tree = await bookTree(rest, 'ops'); + expect(tree.statusCode).toBe(200); + // …and serves `crm_intro` in the synthetic Uncategorized group only. The + // spec calls those orphans "not an authored membership claim", so they + // are not the book's pages, and the entry is still dropped. + expect(tree.body.groups.map((g: any) => g.key)).toEqual(['uncategorized']); + }); +}); + +describe('[#19790] the by-name route prunes exactly what the list route prunes', () => { + for (const [who, holdings, expected] of [ + ['non-holder', [], NON_HOLDER_NAV], + ['holder', ['crm_admin'], HOLDER_NAV], + ] as const) { + it(`${who}: GET /meta/app/crm serves the list route's navigation and areas`, async () => { + const { rest } = setup({ holdings: [...holdings] }); + const listed = await listedApp(rest); + const named = await namedApp(rest); + + expect(navIds(named)).toEqual(expected); + expect(navIds(named)).toEqual(navIds(listed)); + expect(areaIds(named)).toEqual(areaIds(listed)); + }); + } +}); + +describe('[#19790] controls — the arm narrows `doc` entries and nothing else', () => { + it('a `requiredPermissions` entry is judged as before, whatever the caller holds', async () => { + for (const holdings of [[], ['crm_admin']]) { + const ids = navIds(await listedApp(setup({ holdings }).rest)); + expect(ids).toContain('nav_reports'); + // Readable doc, missing system permission: `requiredPermissions` + // still narrows further, exactly as the schema says. + expect(ids).not.toContain('nav_intro_locked'); + expect(ids).toContain('nav_leads'); + } + }); +}); + +describe('[#19790] fails CLOSED', () => { + const DOC_IDS = ['nav_admin_guide', 'nav_admin_runbook', 'nav_ops_book', 'nav_crm_book', 'nav_intro', 'nav_intro_in_admin_guide']; + + it('the books read throws: every `doc` entry is dropped, the rest is served, and the fault is logged', async () => { + const { rest } = setup({ holdings: ['crm_admin'], failing: ['book'] }); + + for (const app of [await listedApp(rest), await namedApp(rest)]) { + expect(navIds(app)).toEqual(['nav_leads', 'nav_reports']); + } + const lines = (warn.mock.calls as unknown[][]).map((c) => String(c[0])); + expect(lines.some((l) => l.includes('the book read failed') && l.includes('failing CLOSED'))).toBe(true); + }); + + it('the doc corpus read throws: every `doc` entry is dropped — never read as "unclaimed, so org"', async () => { + const { rest } = setup({ holdings: ['crm_admin'], failing: ['doc'] }); + const ids = navIds(await listedApp(rest)); + + for (const id of DOC_IDS) expect(ids).not.toContain(id); + expect(ids).toEqual(['nav_leads', 'nav_reports']); + }); + + it('unresolvable permission-set holdings deny the set-gated entries and keep the `org` ones', async () => { + const { rest } = setup({ holdings: 'unresolvable' }); + expect(navIds(await listedApp(rest))).toEqual(NON_HOLDER_NAV); + }); + + it('no gate handed to the filter at all: `doc` entries are dropped, not served', () => { + const rest: any = new RestServer(createMockServer() as any, {} as any, {} as any); + const out = rest.filterAppForUser(clone(CRM_APP), new Set(['crm.reports'])); + expect(navIds(out)).toEqual(['nav_leads', 'nav_reports']); + }); +}); + +describe('[#19790] the fast path and the existence question', () => { + const FAST_APP = { + name: 'crm', + navigation: [ + { id: 'nav_intro', type: 'doc', doc: 'crm_intro' }, + { id: 'nav_help', type: 'doc', book: 'help_center' }, + // Existence is `docs/nav-target`'s question. The resolver's own + // default for a doc it has no entry for is `org`: served. + { id: 'nav_unwritten_doc', type: 'doc', doc: 'not_written_yet' }, + // A name no book or package carries: its implicit book has no page. + { id: 'nav_no_such_book', type: 'doc', book: 'no_such_book' }, + ], + }; + + it('no set-gated book anywhere: readable entries stay, a book with no page goes, no holdings resolved', async () => { + const { rest, protocol, resolvePermissionSetNames } = setup({ books: [HELP_CENTER], apps: [FAST_APP] }); + + expect(navIds(await listedApp(rest))).toEqual(['nav_intro', 'nav_help', 'nav_unwritten_doc']); + expect(resolvePermissionSetNames).not.toHaveBeenCalled(); + // The corpus is read once, and only because a `book` entry needs its pages. + expect(reads(protocol, 'doc')).toBe(1); + }); + + it('no set-gated book and no `book` entry: no doc corpus read at all', async () => { + const app = { name: 'crm', navigation: [{ id: 'nav_intro', type: 'doc', doc: 'crm_intro' }] }; + const { rest, protocol } = setup({ books: [HELP_CENTER], apps: [app] }); + + expect(navIds(await listedApp(rest))).toEqual(['nav_intro']); + expect(reads(protocol, 'book')).toBe(1); + expect(reads(protocol, 'doc')).toBe(0); + }); +}); + +describe('[#19790] cost — resolved once per request, and not at all without a `doc` entry', () => { + it('an app list with no `doc` entry reads no book, no doc and no holdings', async () => { + const plain = { name: 'sales', navigation: [{ id: 'nav_leads', type: 'object', objectName: 'lead' }] }; + const { rest, protocol, resolvePermissionSetNames } = setup({ apps: [plain] }); + + expect(navIds(await listedApp(rest, 'sales'))).toEqual(['nav_leads']); + expect(reads(protocol, 'book')).toBe(0); + expect(reads(protocol, 'doc')).toBe(0); + expect(resolvePermissionSetNames).not.toHaveBeenCalled(); + }); + + it('two apps with `doc` entries: one book read, one doc read, one holdings resolution for the whole list', async () => { + const second = { ...clone(CRM_APP), name: 'support' }; + const { rest, protocol, resolvePermissionSetNames } = setup({ holdings: [], apps: [CRM_APP, second] }); + + const apps = appsOf((await listApps(rest)).body); + expect(apps.map((a: any) => navIds(a))).toEqual([NON_HOLDER_NAV, NON_HOLDER_NAV]); + expect(reads(protocol, 'book')).toBe(1); + expect(reads(protocol, 'doc')).toBe(1); + expect(resolvePermissionSetNames).toHaveBeenCalledTimes(1); + }); +}); + +describe('[#19790] the anonymous path', () => { + it('GET /meta/app is 401 before any read — there is no anonymous app nav for the arm to cover', async () => { + const { rest, protocol } = setup({ anonymous: true }); + const res = await listApps(rest); + + expect(res.statusCode).toBe(401); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 5004bb98b64..5c7dbf8f101 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -149,6 +149,7 @@ 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 { ISecurityService } from '@objectstack/spec/contracts'; import { resolveEffectiveApiMethods, @@ -408,6 +409,74 @@ interface ApiAccessOpts { */ 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. * @@ -2717,16 +2786,144 @@ export class RestServer { return []; } - /** Fetch every book of the environment, shaped for the audience resolver. */ - private async fetchAudienceBooks(p: RestProtocol, environmentId: string | undefined): Promise { + /** Shape a book list for the audience resolver: `_packageId` provenance also as `packageId`. */ + private static audienceBooksOf(raw: unknown): any[] { + return RestServer.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. + */ + 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, + })); + } + + /** + * 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 what an unreadable store means for it — the + * docs reads keep answering as they always have, while the app-nav gate + * fails closed ({@link resolveNavDocAudience}). + */ + 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 raw = await p.getMetaItems(booksRequest).catch(() => []); - return RestServer.metaItemsArray(raw).map((b: any) => - b && typeof b === 'object' ? { ...b, packageId: b._packageId } : b, - ); + 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. + * A read that throws answers `[]` — the docs reads' long-standing + * degradation; the app-nav gate reads {@link readAudienceBooks} instead + * because it must not fail open. + */ + private async fetchAudienceBooks(p: RestProtocol, environmentId: string | undefined): Promise { + const read = await this.readAudienceBooks(p, environmentId); + return 'fault' in read ? [] : read.items; + } + + /** + * [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. + */ + 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)); + }, + }; } /** Heavy path behind `resolveExecCtx` — resolve identity + RBAC/RLS + localization. */ @@ -3145,6 +3342,9 @@ export class RestServer { * 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 @@ -3180,8 +3380,9 @@ export class RestServer { sysPerms: Set, serviceGate?: (name: string) => boolean, servabilityGate?: NavServabilityGate, + docAudienceGate?: NavDocAudienceGate, ): any | null { - return this.filterAppForUserWithReason(item, sysPerms, serviceGate, servabilityGate).app; + return this.filterAppForUserWithReason(item, sysPerms, serviceGate, servabilityGate, docAudienceGate).app; } /** @@ -3233,6 +3434,7 @@ export class RestServer { 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 @@ -3274,6 +3476,23 @@ export class RestServer { 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 @@ -3555,6 +3774,166 @@ export class RestServer { }; } + /** + * [#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 (the + * doc reads swallow that to `[]`, which reads as "no gated book anywhere") + * or the doc corpus read (swallowed to `[]` there too, which reads every + * doc as unclaimed, i.e. `org`). Either swallow 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. 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. + */ + private async resolveNavDocAudience( + p: RestProtocol, + environmentId: string | undefined, + 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; + } + /** * Build a `TranslationBundle` (`Record`) from an * `II18nService` instance. Returns `undefined` when no locales are @@ -5962,8 +6341,13 @@ export class RestServer { // object metadata is a per-request fact, not // a per-app one. const servabilityGate = await this.resolveNavServability(p, environmentId) ?? undefined; + // [#19790] Likewise once for the whole list: + // books, holdings and the doc corpus are + // per-request facts about this caller. + const docAudienceGate = await this.resolveNavDocAudience(p, environmentId, req, list); const filtered = list - .map((it: any) => this.filterAppForUser(it, sysPerms, serviceGate, servabilityGate)) + .map((it: any) => this.filterAppForUser( + it, sysPerms, serviceGate, servabilityGate, docAudienceGate)) .filter((it: any) => it != null); visible = Array.isArray(raw) ? filtered @@ -6104,12 +6488,9 @@ export class RestServer { const raw = visible as unknown; const list = RestServer.metaItemsArray(raw); if (list.length > 0) { - const { audienceAllows } = await import('@objectstack/spec/system'); - const caller = await this.resolveAudienceCaller(environmentId, req, { - needPermissionSets: RestServer.anyPermissionSetAudience(list), - }); + const audience = await this.resolveDocsAudience(environmentId, req, list); const filtered = list.filter((b: any) => - b && typeof b === 'object' && audienceAllows((b as any).audience, caller)); + b && typeof b === 'object' && audience.admitsBook(b)); visible = Array.isArray(raw) ? filtered : { ...(raw as any), items: filtered }; } } @@ -6123,35 +6504,18 @@ export class RestServer { const raw = visible as unknown; const list = RestServer.metaItemsArray(raw); if (list.length > 0) { - const { audienceAllows, docAudienceAllows, resolveDocAudiences } = - await import('@objectstack/spec/system'); const books = await this.fetchAudienceBooks(p, environmentId); - const caller = await this.resolveAudienceCaller(environmentId, req, { - needPermissionSets: RestServer.anyPermissionSetAudience(books), - }); + const audience = await this.resolveDocsAudience(environmentId, req, books); let filtered: any[]; - if (caller.authenticated && !RestServer.anyPermissionSetAudience(books)) { + if (audience.allReadable) { // Fast path: with no gated book anywhere, every // effective audience admits an authenticated caller. filtered = list; } else { - const corpus = 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, - })); - const audiences = resolveDocAudiences(books as any, corpus); - filtered = list.filter((d: any) => { - if (!d || typeof d !== 'object') return false; - const eff = audiences.get(d.name); - return eff - ? docAudienceAllows(eff, caller) - : audienceAllows('org', caller); - }); + // The corpus is the listed docs themselves. + const canRead = audience.docReader(RestServer.docCorpusOf(list)); + filtered = list.filter((d: any) => + !!d && typeof d === 'object' && canRead(d.name)); } visible = Array.isArray(raw) ? filtered : { ...(raw as any), items: filtered }; } @@ -6491,8 +6855,7 @@ export class RestServer { // [#6877] One package scopes the book lookup. if (refuseRepeatedQueryParams(req, res, ['package'])) return; const packageId = req.query?.package || undefined; - const { resolveBookTree, deriveImplicitPackageBook, audienceAllows, resolveDocAudiences, docAudienceAllows, resolveDocLocale } = - await import('@objectstack/spec/system'); + const { resolveDocLocale } = await import('@objectstack/spec/system'); const norm = (raw: any): any[] => Array.isArray(raw) ? raw : (raw && Array.isArray(raw.items) ? raw.items : []); @@ -6503,22 +6866,19 @@ export class RestServer { ...(environmentId ? { environmentId } : {}), }; const books = norm(await prot.getMetaItems(booksRequest)); - let book = books.find((b: any) => b && b.name === req.params.name); - if (!book) { - // Unknown name → the implicit per-package book (§6.4). - book = deriveImplicitPackageBook(req.params.name, req.params.name); - } + // [#19790] The same `DocsAudience` the doc reads and the + // app-nav `doc` arm use — one resolution, four doors. + const audience = await this.resolveDocsAudience( + environmentId, req, RestServer.audienceBooksOf(books)); + const caller = audience.caller; + // Unknown name → the implicit per-package book (§6.4). + const book = audience.bookNamed(req.params.name); // §6.7 — the book's audience gates the whole tree: // anonymous → `public` only; `{ permissionSet }` → // the caller must hold the named set (fail closed // when holdings cannot be resolved, ADR-0049). - const audienceBooks = books.map((b: any) => - b && typeof b === 'object' ? { ...b, packageId: b._packageId } : b); - const caller = await this.resolveAudienceCaller(environmentId, req, { - needPermissionSets: RestServer.anyPermissionSetAudience([book, ...audienceBooks]), - }); - if (!audienceAllows((book as any).audience, caller)) { + if (!audience.admitsBook(book)) { if (!caller.authenticated) { sendDeclaredFault(res, { code: 'UNAUTHENTICATED', message: 'This documentation requires sign-in', status: 401 }); } else { @@ -6544,8 +6904,6 @@ export class RestServer { packageId: d._packageId, })); - const tree = resolveBookTree(book as any, docs, (book as any)._packageId); - // §6.7 — the tree's ENTRIES are additionally filtered by // each doc's effective audience (union over claiming // books, unclaimed → org), so an anonymous reader of a @@ -6553,19 +6911,7 @@ export class RestServer { // fetch, and gated-only docs stay out of non-holders' // trees. The book gate above passed, so this only ever // narrows further for anonymous / non-holder callers. - const gatedTreePossible = !caller.authenticated - || RestServer.anyPermissionSetAudience(audienceBooks); - if (gatedTreePossible) { - const audiences = resolveDocAudiences(audienceBooks as any, docs); - tree.groups = tree.groups - .map((g: any) => ({ - ...g, - entries: g.entries.filter((e: any) => - !e.doc || docAudienceAllows(audiences.get(e.doc), caller)), - })) - .filter((g: any) => g.entries.some((e: any) => e.doc || e.href)); - } - res.json(tree); + res.json(audience.readableTree(book, docs)); } catch (error: any) { handleRouteError(res, error); } @@ -7015,7 +7361,12 @@ export class RestServer { // single-app JSON defeats the filter (the // #4722 lesson, one gate over). const servabilityGate = await this.resolveNavServability(p, environmentId) ?? undefined; - const gated = this.filterAppForUserWithReason(visible, sysPerms, serviceGate, servabilityGate); + // [#19790] And the same docs-audience gate, + // for the same reason: a `doc` entry the + // list route prunes must not come back here. + const docAudienceGate = await this.resolveNavDocAudience(p, environmentId, req, [visible]); + const gated = this.filterAppForUserWithReason( + visible, sysPerms, serviceGate, servabilityGate, docAudienceGate); visible = gated.app; if (visible == null) { // [#8013] A PERMISSION denial is reported as @@ -7094,8 +7445,6 @@ export class RestServer { // authenticated non-holder; fail closed when holdings // cannot be resolved (ADR-0049). if (isAudienceGatedType && visible) { - const { audienceAllows, docAudienceAllows, resolveDocAudiences } = - await import('@objectstack/spec/system'); // The document under audience test. [#5563] This // used to unwrap an envelope-or-document here; // `visible` is always the document now, so the @@ -7104,37 +7453,24 @@ export class RestServer { // DOCUMENT and reading it off an envelope would // silently grant everyone (`undefined` audience). const target = visible; - let caller: { authenticated: boolean; permissionSets?: string[] }; + let caller: AudienceCaller; let allowed: boolean; if (metaType === 'book') { - caller = await this.resolveAudienceCaller(environmentId, req, { - needPermissionSets: RestServer.anyPermissionSetAudience([target]), - }); - allowed = audienceAllows(target?.audience, caller); + // The book's own audience — holdings resolved + // only when THIS book is set-gated. + const audience = await this.resolveDocsAudience(environmentId, req, [target]); + caller = audience.caller; + allowed = audience.admitsBook(target); } else { const books = await this.fetchAudienceBooks(p, environmentId); - caller = await this.resolveAudienceCaller(environmentId, req, { - needPermissionSets: RestServer.anyPermissionSetAudience(books), - }); - if (caller.authenticated && !RestServer.anyPermissionSetAudience(books)) { + const audience = await this.resolveDocsAudience(environmentId, req, books); + caller = audience.caller; + if (audience.allReadable) { allowed = true; // no gated book anywhere → org suffices } else { - const docCorpusRequest: TransportScopedMetaRequest = { - type: 'doc', - ...(environmentId ? { environmentId } : {}), - }; - const corpus = RestServer.metaItemsArray( - await p.getMetaItems(docCorpusRequest).catch(() => [])) - .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, - })); - const audiences = resolveDocAudiences(books as any, corpus); - allowed = docAudienceAllows(audiences.get(target?.name), caller); + const read = await this.readDocCorpus(p, environmentId); + const corpus = 'fault' in read ? [] : read.items; + allowed = audience.docReader(corpus)(target?.name); } } if (!allowed) {