From 34f2588dd529ba24db04ebbfbc1f101de9616ae7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:50:09 +0000 Subject: [PATCH 1/3] fix(runtime,rest): the dispatcher /meta list prunes what RestServer's list prunes, through one list gate RestServer's GET /meta/:type per-caller filters (the app nav filter, the dashboard widget gate, the book and doc audience prunes) move into createMetaListReadGate beside createMetaItemReadGate, and both the RestServer list route and the runtime dispatcher's one-segment list branch call it. Every dispatcher list exit (protocol, MetadataService fallback, registry fallbacks) now runs the gate and the ADR-0106 object mask, outside the lookups' swallowing try blocks. RestServer delegates left without a caller are deleted; their unit tests call the one implementation. filterAppForUser stays resolvable at rest-server.ts as a named re-export for ADR-0056 row 18's pointer. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/rest/src/index.ts | 7 +- .../src/meta-app-nav-doc-audience.test.ts | 4 +- .../src/meta-app-nav-servability-gate.test.ts | 33 +-- packages/rest/src/meta-item-read-gate.ts | 131 +++++++++- packages/rest/src/rest-server.ts | 233 ++++-------------- packages/rest/src/rest.test.ts | 177 +++++-------- packages/runtime/src/domains/meta.ts | 99 ++++++-- 7 files changed, 346 insertions(+), 338 deletions(-) diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index 0b6bb06cc87..5539475b006 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -81,7 +81,12 @@ export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-mu // 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'; +// +// [#20237] …and its LIST twin, for `GET /meta/:type` — the doc and book +// audience prunes, the app nav filter and the dashboard widget gate — over the +// same ports. The judge answers the pruned ITEMS; each caller rewraps them in +// its own list envelope. +export { createMetaItemReadGate, createMetaListReadGate } from './meta-item-read-gate.js'; export type { MetaItemReadGateSources, MetaItemReadRefusal, diff --git a/packages/rest/src/meta-app-nav-doc-audience.test.ts b/packages/rest/src/meta-app-nav-doc-audience.test.ts index 2bf5ddadef9..290594d7c60 100644 --- a/packages/rest/src/meta-app-nav-doc-audience.test.ts +++ b/packages/rest/src/meta-app-nav-doc-audience.test.ts @@ -24,6 +24,7 @@ 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'; +import { filterAppForUser } from './meta-item-read-gate.js'; // This file spies on `console.warn` (the fail-closed diagnostic), so it // declares the level it observes under — the SHIPPED default. @@ -300,8 +301,7 @@ describe('[#19790] fails CLOSED', () => { }); 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'])); + const out = filterAppForUser(clone(CRM_APP), new Set(['crm.reports'])); expect(navIds(out)).toEqual(['nav_leads', 'nav_reports']); }); }); diff --git a/packages/rest/src/meta-app-nav-servability-gate.test.ts b/packages/rest/src/meta-app-nav-servability-gate.test.ts index ca5e9453302..da94ae0b841 100644 --- a/packages/rest/src/meta-app-nav-servability-gate.test.ts +++ b/packages/rest/src/meta-app-nav-servability-gate.test.ts @@ -24,6 +24,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } // frozen in the TEST_DEBT ledger, which only ever shrinks. A new file must not // add the 68th. import { RestServer } from './rest-server.js'; +import { filterAppForUser, resolveNavServability } from './meta-item-read-gate.js'; // [#17865] This file observes the REST fault log, so it declares the level it // asserts against instead of inheriting the suite's quiet one. 'info' is the @@ -78,7 +79,7 @@ const areaIds = (a: any, i: number): string[] => (a?.areas?.[i]?.navigation ?? [ /** Resolve the gate the way the `/meta` routes do. */ async function gateOf(rest: any, items?: unknown[]) { const p = createMockProtocol(items); - return rest.resolveNavServability(p, undefined); + return resolveNavServability({ ...rest.metaListSource(p, undefined), navPruneLogged: rest.navPruneLogged }); } describe('[#7912] nav servability — the prune', () => { @@ -100,7 +101,7 @@ describe('[#7912] nav servability — the prune', () => { it('drops an entry whose object is `apiEnabled: false` (404 OBJECT_API_DISABLED)', async () => { const rest = make(); const gate = await gateOf(rest); - expect(ids(rest.filterAppForUser(app(), new Set(), undefined, gate))).not.toContain('nav_jwks'); + expect(ids(filterAppForUser(app(), new Set(), undefined, gate))).not.toContain('nav_jwks'); }); it('drops an entry whose `apiMethods` whitelist omits `list` (405), a SEPARATE condition', async () => { @@ -108,13 +109,13 @@ describe('[#7912] nav servability — the prune', () => { // and still cannot answer the list its nav entry navigates to. const rest = make(); const gate = await gateOf(rest); - expect(ids(rest.filterAppForUser(app(), new Set(), undefined, gate))).not.toContain('nav_verification'); + expect(ids(filterAppForUser(app(), new Set(), undefined, gate))).not.toContain('nav_verification'); }); it('⭐ CONTROL: `nav_api_keys` → `sys_api_key` SURVIVES — the prune must not over-reach', async () => { const rest = make(); const gate = await gateOf(rest); - const out = ids(rest.filterAppForUser(app(), new Set(), undefined, gate)); + const out = ids(filterAppForUser(app(), new Set(), undefined, gate)); expect(out).toContain('nav_api_keys'); // The card's own control, stated as the whole surviving set so an // over-pruning derivation cannot pass by keeping one entry alive. @@ -124,13 +125,13 @@ describe('[#7912] nav servability — the prune', () => { it('an object that declares no `enable` block is served (default-open)', async () => { const rest = make(); const gate = await gateOf(rest); - expect(ids(rest.filterAppForUser(app(), new Set(), undefined, gate))).toContain('nav_inbox'); + expect(ids(filterAppForUser(app(), new Set(), undefined, gate))).toContain('nav_inbox'); }); it('an `enable` block silent about the API is served (unrestricted)', async () => { const rest = make(); const gate = await gateOf(rest); - expect(ids(rest.filterAppForUser(app(), new Set(), undefined, gate))).toContain('nav_leads'); + expect(ids(filterAppForUser(app(), new Set(), undefined, gate))).toContain('nav_leads'); }); }); @@ -152,7 +153,7 @@ describe('[#7912] nav servability — what it deliberately does NOT judge', () = { id: 'nav_gated_ghost', type: 'object', objectName: 'also_absent', requiresObject: 'also_absent' }, ], }; - expect(ids(rest.filterAppForUser(app, new Set(), undefined, gate))) + expect(ids(filterAppForUser(app, new Set(), undefined, gate))) .toEqual(['nav_ghost', 'nav_gated_ghost']); }); @@ -167,14 +168,14 @@ describe('[#7912] nav servability — what it deliberately does NOT judge', () = { id: 'nav_url', type: 'url', url: 'https://example.com' }, ], }; - expect(ids(rest.filterAppForUser(app, new Set(), undefined, gate))) + expect(ids(filterAppForUser(app, new Set(), undefined, gate))) .toEqual(['nav_component', 'nav_page', 'nav_url']); }); it('fail-open: with no gate resolved, nothing is pruned (prior behaviour)', async () => { const rest = make(); const app = { name: 'setup', navigation: [{ id: 'nav_jwks', type: 'object', objectName: 'sys_jwks' }] }; - expect(ids(rest.filterAppForUser(app, new Set()))).toEqual(['nav_jwks']); + expect(ids(filterAppForUser(app, new Set()))).toEqual(['nav_jwks']); }); it('fail-open: unreadable/empty object metadata resolves NO gate at all', async () => { @@ -209,7 +210,7 @@ describe('[#7912] nav servability — reaches every tree the other gates reach', ], }], }; - const out = rest.filterAppForUser(app, new Set(), undefined, gate); + const out = filterAppForUser(app, new Set(), undefined, gate); expect(out.navigation[0].children.map((c: any) => c.id)).toEqual(['nav_api_keys']); expect(areaIds(out, 0)).toEqual(['nav_api_keys_area']); }); @@ -224,7 +225,7 @@ describe('[#7912] nav servability — reaches every tree the other gates reach', children: [{ id: 'nav_jwks', type: 'object', objectName: 'sys_jwks' }], }], }; - expect(ids(rest.filterAppForUser(app, new Set(), undefined, gate))).toEqual([]); + expect(ids(filterAppForUser(app, new Set(), undefined, gate))).toEqual([]); }); it('does not mutate the app it filters', async () => { @@ -238,7 +239,7 @@ describe('[#7912] nav servability — reaches every tree the other gates reach', ], }; const before = JSON.stringify(app); - rest.filterAppForUser(app, new Set(), undefined, gate); + filterAppForUser(app, new Set(), undefined, gate); expect(JSON.stringify(app)).toBe(before); }); }); @@ -254,7 +255,7 @@ describe('[#7912] the prune is never silent — the serving-side diagnostic', () it('names the app, the entry AND the condition that pruned it (apiEnabled)', async () => { const rest = make(); const gate = await gateOf(rest); - rest.filterAppForUser( + filterAppForUser( { name: 'setup', navigation: [{ id: 'nav_jwks', type: 'object', objectName: 'sys_jwks' }] }, new Set(), undefined, gate, ); @@ -270,7 +271,7 @@ describe('[#7912] the prune is never silent — the serving-side diagnostic', () it('names the OTHER condition distinctly (apiMethods without `list`)', async () => { const rest = make(); const gate = await gateOf(rest); - rest.filterAppForUser( + filterAppForUser( { name: 'setup', navigation: [{ id: 'nav_verification', type: 'object', objectName: 'sys_verification' }] }, new Set(), undefined, gate, ); @@ -287,7 +288,7 @@ describe('[#7912] the prune is never silent — the serving-side diagnostic', () const rest = make(); const gate = await gateOf(rest); const app = { name: 'setup', navigation: [{ id: 'nav_jwks', type: 'object', objectName: 'sys_jwks' }] }; - for (let i = 0; i < 5; i++) rest.filterAppForUser(app, new Set(), undefined, gate); + for (let i = 0; i < 5; i++) filterAppForUser(app, new Set(), undefined, gate); expect(lines(warn).filter((l) => l.includes('nav_jwks'))).toHaveLength(1); }); }); @@ -329,7 +330,7 @@ describe('[#7912] the served Account app is unaffected — the QA-sweep sibling' { id: 'nav_account_oauth_apps', type: 'object', objectName: 'sys_oauth_application', requiresObject: 'sys_oauth_application' }, ], }; - expect(ids(rest.filterAppForUser(app, new Set(), undefined, gate))).toEqual([ + expect(ids(filterAppForUser(app, new Set(), undefined, gate))).toEqual([ 'nav_account_notifications', 'nav_account_orgs', 'nav_account_linked', diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 817024027bb..2072606fe1a 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -34,8 +34,14 @@ * * `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. + * the functions below, so its other docs and app reads — the book tree, the + * alternate doors — ask this same implementation. + * + * [#20237] The LIST read had the same two transports and the same split: + * `RestServer`'s `GET /meta/:type` pruned inline, and the dispatcher's list + * branch pruned nothing. Its gate lives here too + * ({@link createMetaListReadGate}), built from the same functions over the + * same ports. */ import type { AudienceCaller, Book, ResolvedBook, ResolverDoc } from '@objectstack/spec/system'; @@ -1360,3 +1366,124 @@ export function createMetaItemReadGate( return async (document) => serve(document); } + +// ── THE list gate ───────────────────────────────────────────────────────────── + +/** + * [#20237] THE per-caller gate of one `/meta/:type` LIST answer — the list + * twin of {@link createMetaItemReadGate}, called by both transports that serve + * that read. + * + * ## Why one spelling + * + * `RestServer`'s `GET /meta/:type` ran these filters inline, and the runtime + * dispatcher's `/meta` list branch — the only answer on a host that mounts + * just the `${prefix}/*` catch-all — ran none of them. So a member `RestServer` + * prunes listed a `{ permissionSet }`-gated doc there WITH its body + * (`?include=content`), a set-gated book, an app whose `requiredPermissions` + * they lack, and an ungated app with its gated nav entries — the item gate's + * defect, one door over: the by-name reads refused what the list served. + * Each transport now hands its list to THIS function, so a list gate added + * here reaches both, and `meta-list-read-gate-parity.test.ts` in + * `@objectstack/runtime` drives the same fixtures through both and holds the + * answers equal. + * + * ## The gates, per type (unchanged from `RestServer`'s list route) + * + * - `app` — for an authenticated caller, {@link filterAppForUser} over every + * app, all of them judged with ONE set of per-request inputs: the ADR-0057 + * D10 service probe, [#7912] the nav-servability gate and [#19790] the + * docs-audience entry arm. An app withheld for ANY reason is left out — on + * a list, unpublished, `requiredPermissions` and an absent service all + * mean "not in your list" (the by-name read is where they differ, #8013). + * An anonymous caller's list is returned untouched, as it always was: the + * anonymous-deny floor answers it before this gate, on both transports. + * - `dashboard` — ADR-0057 D10 {@link filterDashboardForUser} over every + * dashboard, when the deployment can be probed (fail OPEN otherwise). A + * per-DEPLOYMENT gate, not a per-caller one; it rides here because the + * list answers what the by-name read answers. + * - `book` — ADR-0046 §6.7, each book's OWN audience, resolved over the + * listed books ({@link DocsAudience.admitsBook}). + * - `doc` — ADR-0046 §6.7, each doc's EFFECTIVE audience: the env's books + * ({@link fetchAudienceBooks} — a book-read fault THROWS, [#20129], so the + * list is not served rather than filtered against no books), with the + * listed docs as the corpus. The fast path (no set-gated book anywhere) + * serves an authenticated caller every doc. + * + * Every other type is returned as given. One audience resolution, the docs + * reads' own ({@link resolveDocsAudience}) — ⛔ no second resolver. + * + * NOT here, for the item gate's reasons: the ADR-0106 object mask, which each + * transport threads through its own `fetch → mask → send` exit; and the list + * route's PROJECTIONS, which withhold nothing from anyone — `?id=`, + * `?object=`, the doc locale collapse and content slim, translation. + * + * ## Shape + * + * `metaType` is the SINGULAR type (the caller folds `/meta/docs` once, at its + * boundary). The judge takes the list's ITEMS — each transport unwraps and + * rewraps its own list envelope — and answers the SAME array when nothing + * applies (an ungated type, an empty book or doc list, an anonymous caller's + * app list, a deployment that cannot be probed), a new one otherwise; the + * input is never mutated. A gate input that cannot be read REJECTS the judge: + * the transport answers that fault, ⛔ never the unfiltered list. + */ +export function createMetaListReadGate( + sources: MetaItemReadGateSources, + metaType: string, +): (items: any[]) => Promise { + if (metaType === 'app') { + return async (items) => { + const ctx = await sources.resolveCaller(); + if (!ctx?.userId) return items; + const sysPerms = new Set( + Array.isArray(ctx.systemPermissions) ? ctx.systemPermissions : [], + ); + const registered = await resolveRegisteredServices(sources.serviceProbe(ctx), items); + const serviceGate = registered ? (n: string) => registered.has(n) : undefined; + // [#7912] Resolved ONCE for the whole list — object metadata is a + // per-request fact, not a per-app one. + const servabilityGate = await resolveNavServability(sources) ?? undefined; + // [#19790] Likewise once for the whole list: books, holdings and + // the doc corpus are per-request facts about this caller. + const docAudienceGate = await resolveNavDocAudience(sources, items); + return items + .map((it: any) => filterAppForUser(it, sysPerms, serviceGate, servabilityGate, docAudienceGate)) + .filter((it: any) => it != null); + }; + } + + if (metaType === 'dashboard') { + return async (items) => { + const ctx = await sources.resolveCaller(); + const registered = await resolveRegisteredServices(sources.serviceProbe(ctx), items); + if (!registered) return items; + const serviceGate = (n: string) => registered.has(n); + return items.map((it: any) => filterDashboardForUser(it, serviceGate)); + }; + } + + if (metaType === 'book') { + return async (items) => { + if (items.length === 0) return items; + const audience = await resolveDocsAudience(sources, items); + return items.filter((b: any) => b && typeof b === 'object' && audience.admitsBook(b)); + }; + } + + if (metaType === 'doc') { + return async (items) => { + if (items.length === 0) return items; + const books = await fetchAudienceBooks(sources); + const audience = await resolveDocsAudience(sources, books); + // Fast path: with no gated book anywhere, every effective audience + // admits an authenticated caller. + if (audience.allReadable) return items; + // The corpus is the listed docs themselves. + const canRead = audience.docReader(docCorpusOf(items)); + return items.filter((d: any) => !!d && typeof d === 'object' && canRead(d.name)); + }; + } + + return async (items) => items; +} diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index f01eb85b5fc..f27986fa886 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -156,11 +156,19 @@ 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 { 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. +// private helpers below that still have a caller here keep their names as +// one-line delegates into it. +// +// [#20237] …and THE per-caller LIST gate of `GET /meta/:type` +// (`createMetaListReadGate`), which the list route below calls whole. The +// delegates only that route called (`filterAppForUser`, +// `filterDashboardForUser`, `resolveRegisteredServices`, +// `resolveNavServability`, `resolveNavDocAudience`, `fetchAudienceBooks`, +// `docCorpusOf`) went with it: a delegate with no caller is a second place to +// read a rule that nothing runs. import * as metaReadGate from './meta-item-read-gate.js'; import type { DocsAudience, @@ -170,9 +178,15 @@ import type { MetaReadGateCaller, MetaReadGateListSource, MetaReadGatePolicy, - NavDocAudienceGate, - NavServabilityGate, } from './meta-item-read-gate.js'; +// [#20237] The server-side app filter, by name, at the path ADR-0056's +// verification table cites for it (`rest-server.ts#filterAppForUser`, row 18): +// the one implementation is `meta-item-read-gate.ts`'s, and this re-export is +// where that pointer lands — one hop from it. It is NOT on the package barrel +// (`index.ts` names this file's exports one by one). Re-anchoring the ADR row +// to the implementation is a governed-surface edit, so it is left to the +// maintainer; this line goes when that row moves. +export { filterAppForUser } from './meta-item-read-gate.js'; import type { ISecurityService } from '@objectstack/spec/contracts'; import { resolveEffectiveApiMethods, @@ -2775,21 +2789,6 @@ export class RestServer { return metaReadGate.audienceBooksOf(raw); } - /** The doc header the audience resolver reads — `docCorpusOf` in `./meta-item-read-gate.ts`. */ - private static docCorpusOf(list: readonly any[]): ResolverDoc[] { - return metaReadGate.docCorpusOf(list); - } - - /** - * 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 { - return metaReadGate.fetchAudienceBooks(this.metaListSource(p, environmentId)); - } - /** * [ADR-0046 §6.7] Build THE {@link DocsAudience} for this request's caller * over `books` — `resolveDocsAudience` in `./meta-item-read-gate.ts`, the @@ -3197,34 +3196,6 @@ export class RestServer { } } - /** - * 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, - sysPerms: Set, - serviceGate?: (name: string) => boolean, - servabilityGate?: NavServabilityGate, - docAudienceGate?: NavDocAudienceGate, - ): any | null { - return metaReadGate.filterAppForUser(item, sysPerms, serviceGate, servabilityGate, docAudienceGate); - } - - /** - * ADR-0057 D10 (dashboards): strip dashboard widgets whose `requiresService` - * 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 { - return metaReadGate.filterDashboardForUser(item, serviceGate); - } - /** * [#20156 · #20193] THE read gate of one `/meta/:type/:name` document, for * the plain read and every door beside it — `createMetaItemReadGate` in @@ -3532,16 +3503,6 @@ export class RestServer { }); } - /** - * Probe which `requiresService` capability gates referenced anywhere in - * `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> { - 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 @@ -3560,40 +3521,6 @@ export class RestServer { return null; } - /** - * [#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 { - return metaReadGate.resolveNavServability({ - ...this.metaListSource(p, environmentId), - navPruneLogged: this.navPruneLogged, - }); - } - - /** - * [#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, - environmentId: string | undefined, - req: any, - apps: readonly any[], - ): Promise { - return metaReadGate.resolveNavDocAudience( - { ...this.metaReadAudienceSources(environmentId, req), ...this.metaListSource(p, environmentId) }, - apps, - ); - } - /** * Build a `TranslationBundle` (`Record`) from an * `II18nService` instance. Returns `undefined` when no locales are @@ -5950,14 +5877,6 @@ export class RestServer { }; const items = await p.getMetaItems(listRequest); - // RBAC-filter app metadata for authenticated users so - // privileged apps (Studio, Setup, etc.) and gated nav - // items are stripped before reaching the client. We - // intentionally leave anonymous responses untouched — - // the anonymous-deny gate blocks - // them upstream; when disabled, the demo / public - // surface keeps its prior behaviour. - // // `getMetaItems` is typed as `{type, items[]}` but the // objectql implementation actually returns the raw // array. Handle both shapes defensively. @@ -6021,36 +5940,37 @@ export class RestServer { } } - if (RestServer.metaTypeSingular(req.params.type) === 'app') { - const raw = items as unknown; + // [#20237] THE per-caller LIST gate — the app nav + // filter (privileged apps and gated nav entries + // stripped for an authenticated caller; an anonymous + // list left to the anonymous-deny floor upstream), the + // ADR-0057 D10 dashboard widget gate, and the ADR-0046 + // §6.7 book and doc audience prunes. + // `createMetaListReadGate` in `./meta-item-read-gate.ts` + // is the ONE implementation, which this route and the + // runtime dispatcher's `/meta` list branch both call; + // its docblock carries each type's rule, and ⛔ a list + // gate is added there, never here. + // + // It runs where the app filter always ran: BEFORE + // `?id=` narrows (the next block says why that order is + // a disclosure rule), and on the raw doc items, before + // the locale collapse, so `_packageId` provenance is + // still present for membership scoping. + { + const raw = visible as unknown; const list: any[] | null = Array.isArray(raw) ? (raw as any[]) : (raw && typeof raw === 'object' && Array.isArray((raw as any).items)) ? ((raw as any).items as any[]) : null; if (list) { - const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); - if (ctx?.userId) { - const sysPerms = new Set( - Array.isArray(ctx.systemPermissions) ? ctx.systemPermissions : [], - ); - const registered = await this.resolveRegisteredServices((ctx as any).__kernel, list); - const serviceGate = registered ? (n: string) => registered.has(n) : undefined; - // [#7912] Resolved ONCE for the whole list — - // 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, docAudienceGate)) - .filter((it: any) => it != null); - visible = Array.isArray(raw) - ? filtered - : { ...(raw as any), items: filtered }; + const judged = await metaReadGate.createMetaListReadGate( + this.metaItemReadGateSources(environmentId, req, p), + RestServer.metaTypeSingular(req.params.type), + )(list); + if (judged !== list) { + visible = Array.isArray(raw) ? judged : { ...(raw as any), items: judged }; } } } @@ -6131,28 +6051,6 @@ export class RestServer { } } - // ADR-0057 D10: gate dashboard widgets by `requiresService` - // the same way app nav entries are gated above. - if (RestServer.metaTypeSingular(req.params.type) === 'dashboard') { - const raw = visible as unknown; - const list: any[] | null = Array.isArray(raw) - ? (raw as any[]) - : (raw && typeof raw === 'object' && Array.isArray((raw as any).items)) - ? ((raw as any).items as any[]) - : null; - if (list) { - const ctx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); - const registered = await this.resolveRegisteredServices((ctx as any)?.__kernel, list); - const serviceGate = registered ? (n: string) => registered.has(n) : undefined; - if (serviceGate) { - const filtered = list.map((it: any) => this.filterDashboardForUser(it, serviceGate)); - visible = Array.isArray(raw) - ? filtered - : { ...(raw as any), items: filtered }; - } - } - } - // View switcher query: GET /meta/view?object= // returns ONLY the independent ViewItems bound to that // object (the `package` layer of "Object has-many @@ -6179,51 +6077,6 @@ export class RestServer { } } - // ADR-0046 §6.7 — book list is audience-filtered: anonymous - // callers see only `public` books; `{ permissionSet }`-gated - // books require the caller to hold the named set (resolved - // through the security service; unresolvable → fail closed). - if (RestServer.metaTypeSingular(req.params.type) === 'book') { - const raw = visible as unknown; - const list = RestServer.metaItemsArray(raw); - if (list.length > 0) { - const audience = await this.resolveDocsAudience(environmentId, req, list); - const filtered = list.filter((b: any) => - b && typeof b === 'object' && audience.admitsBook(b)); - visible = Array.isArray(raw) ? filtered : { ...(raw as any), items: filtered }; - } - } - - // ADR-0046 §6.7 — doc list is audience-filtered by each - // doc's EFFECTIVE audience (union over the books that - // claim it; unclaimed docs default to `org`). Runs on the - // raw items (before locale collapse) so `_packageId` - // provenance is still present for membership scoping. - if (RestServer.metaTypeSingular(req.params.type) === 'doc') { - const raw = visible as unknown; - const list = RestServer.metaItemsArray(raw); - if (list.length > 0) { - // [#20129] A book-read fault THROWS here and the - // list is not served — never filtered against an - // empty book list, which clears every doc for an - // authenticated caller ({@link fetchAudienceBooks}). - const books = await this.fetchAudienceBooks(p, environmentId); - const audience = await this.resolveDocsAudience(environmentId, req, books); - let filtered: any[]; - if (audience.allReadable) { - // Fast path: with no gated book anywhere, every - // effective audience admits an authenticated caller. - filtered = list; - } else { - // 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 }; - } - } - // ADR-0046 i18n: collapse each doc to the request // locale (localized label/description, `translations` // map dropped) before the content-strip step below. diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index fc9961f89d2..1e75696db84 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, vi, beforeAll, afterAll } from 'vitest'; import { RouteManager } from './route-manager'; import { RestServer, mapDataError } from './rest-server'; +import { filterAppForUser, filterDashboardForUser, resolveRegisteredServices } from './meta-item-read-gate.js'; import { createRestApiPlugin } from './rest-api-plugin'; import type { RestApiPluginConfig } from './rest-api-plugin'; import { loadXlsxWorkbook } from './xlsx-test-loader.js'; @@ -2998,65 +2999,56 @@ describe('RestServer metadata translation — page documents', () => { // --------------------------------------------------------------------------- describe('filterAppForUser — ADR-0045 publish gate', () => { - const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any, ANON_API as any); const unpublishedApp = { name: 'production_management', _unpublished: true, navigation: [] }; const visibleApp = { name: 'crm', navigation: [] }; // The #4829 repro, in the shape `platform-objects` actually authors it. const accountApp = { name: 'account', hidden: true, navigation: [] }; it('drops an UNPUBLISHED app for users without builder access', () => { - const rest: any = make(); - expect(rest.filterAppForUser(unpublishedApp, new Set())).toBeNull(); - expect(rest.filterAppForUser(unpublishedApp, new Set(['manage_users']))).toBeNull(); + expect(filterAppForUser(unpublishedApp, new Set())).toBeNull(); + expect(filterAppForUser(unpublishedApp, new Set(['manage_users']))).toBeNull(); }); it('returns an unpublished app to builders (studio.access or setup.access)', () => { - const rest: any = make(); - expect(rest.filterAppForUser(unpublishedApp, new Set(['studio.access']))?.name).toBe('production_management'); - expect(rest.filterAppForUser(unpublishedApp, new Set(['setup.access']))?.name).toBe('production_management'); + expect(filterAppForUser(unpublishedApp, new Set(['studio.access']))?.name).toBe('production_management'); + expect(filterAppForUser(unpublishedApp, new Set(['setup.access']))?.name).toBe('production_management'); }); it('leaves visible apps untouched for everyone', () => { - const rest: any = make(); - expect(rest.filterAppForUser(visibleApp, new Set())?.name).toBe('crm'); + expect(filterAppForUser(visibleApp, new Set())?.name).toBe('crm'); }); it('still applies requiredPermissions to unpublished apps builders can see', () => { - const rest: any = make(); const gated = { ...unpublishedApp, requiredPermissions: ['manage_platform_settings'] }; - expect(rest.filterAppForUser(gated, new Set(['studio.access']))).toBeNull(); + expect(filterAppForUser(gated, new Set(['studio.access']))).toBeNull(); expect( - rest.filterAppForUser(gated, new Set(['studio.access', 'manage_platform_settings']))?.name, + filterAppForUser(gated, new Set(['studio.access', 'manage_platform_settings']))?.name, ).toBe('production_management'); }); // ---- the other direction: `hidden` must NOT gate access (#4829) ---- it('#4829: a `hidden` app is served to a user with NO permissions at all', () => { - const rest: any = make(); - expect(rest.filterAppForUser(accountApp, new Set())?.name).toBe('account'); - expect(rest.filterAppForUser(accountApp, new Set(['manage_users']))?.name).toBe('account'); + expect(filterAppForUser(accountApp, new Set())?.name).toBe('account'); + expect(filterAppForUser(accountApp, new Set(['manage_users']))?.name).toBe('account'); }); it('#4829: `hidden` survives the filter untouched — the shell, not the server, acts on it', () => { - const rest: any = make(); // The server must keep serving the flag: nav placement is the CLIENT's // decision, and stripping it here would move the launcher bug one layer out. - expect(rest.filterAppForUser(accountApp, new Set())?.hidden).toBe(true); + expect(filterAppForUser(accountApp, new Set())?.hidden).toBe(true); }); it('the two keys are independent — `hidden` does not weaken the publish gate', () => { - const rest: any = make(); const both = { name: 'draft_settings', hidden: true, _unpublished: true, navigation: [] }; - expect(rest.filterAppForUser(both, new Set())).toBeNull(); - expect(rest.filterAppForUser(both, new Set(['studio.access']))?.name).toBe('draft_settings'); + expect(filterAppForUser(both, new Set())).toBeNull(); + expect(filterAppForUser(both, new Set(['studio.access']))?.name).toBe('draft_settings'); }); it('a hidden app still answers to `requiredPermissions` — nav-only never means ungated', () => { - const rest: any = make(); const gated = { ...accountApp, requiredPermissions: ['account.access'] }; - expect(rest.filterAppForUser(gated, new Set())).toBeNull(); - expect(rest.filterAppForUser(gated, new Set(['account.access']))?.name).toBe('account'); + expect(filterAppForUser(gated, new Set())).toBeNull(); + expect(filterAppForUser(gated, new Set(['account.access']))?.name).toBe('account'); }); }); @@ -3079,27 +3071,23 @@ describe('filterAppForUser — ADR-0045 publish gate', () => { // --------------------------------------------------------------------------- describe('filterAppForUser — the enforced permission layers (#4651, #4722)', () => { - const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any, ANON_API as any); const ids = (a: any): string[] => (a?.navigation ?? []).map((e: any) => e.id); const areaIds = (a: any, i: number): string[] => (a?.areas?.[i]?.navigation ?? []).map((e: any) => e.id); it('APP level: an app whose requiredPermissions the caller lacks is dropped entirely', () => { - const rest: any = make(); const app = { name: 'crm', requiredPermissions: ['crm.access'], navigation: [] }; - expect(rest.filterAppForUser(app, new Set())).toBeNull(); - expect(rest.filterAppForUser(app, new Set(['other.perm']))).toBeNull(); - expect(rest.filterAppForUser(app, new Set(['crm.access']))?.name).toBe('crm'); + expect(filterAppForUser(app, new Set())).toBeNull(); + expect(filterAppForUser(app, new Set(['other.perm']))).toBeNull(); + expect(filterAppForUser(app, new Set(['crm.access']))?.name).toBe('crm'); }); it('APP level: every declared permission is required, not any of them', () => { - const rest: any = make(); const app = { name: 'crm', requiredPermissions: ['crm.access', 'crm.admin'], navigation: [] }; - expect(rest.filterAppForUser(app, new Set(['crm.access']))).toBeNull(); - expect(rest.filterAppForUser(app, new Set(['crm.access', 'crm.admin']))?.name).toBe('crm'); + expect(filterAppForUser(app, new Set(['crm.access']))).toBeNull(); + expect(filterAppForUser(app, new Set(['crm.access', 'crm.admin']))?.name).toBe('crm'); }); it('ITEM level: nav entries the caller cannot satisfy are stripped from the served tree', () => { - const rest: any = make(); const app = () => ({ name: 'crm', navigation: [ @@ -3113,17 +3101,16 @@ describe('filterAppForUser — the enforced permission layers (#4651, #4722)', ( }, ], }); - const out = rest.filterAppForUser(app(), new Set()); + const out = filterAppForUser(app(), new Set()); expect(ids(out)).toEqual(['nav_leads', 'grp_admin']); expect(out.navigation[1].children.map((c: any) => c.id)).toEqual(['nav_about']); - const admin = rest.filterAppForUser(app(), new Set(['sales.admin', 'admin.access'])); + const admin = filterAppForUser(app(), new Set(['sales.admin', 'admin.access'])); expect(ids(admin)).toEqual(['nav_leads', 'nav_forecast', 'grp_admin']); expect(admin.navigation[2].children.map((c: any) => c.id)).toEqual(['nav_users', 'nav_about']); }); it('a group left empty by the item gate is dropped, not served as a bare label', () => { - const rest: any = make(); const app = { name: 'crm', navigation: [{ @@ -3131,8 +3118,8 @@ describe('filterAppForUser — the enforced permission layers (#4651, #4722)', ( children: [{ id: 'nav_users', type: 'object', requiredPermissions: ['admin.access'] }], }], }; - expect(ids(rest.filterAppForUser(app, new Set()))).toEqual([]); - expect(ids(rest.filterAppForUser(app, new Set(['admin.access'])))).toEqual(['grp_admin']); + expect(ids(filterAppForUser(app, new Set()))).toEqual([]); + expect(ids(filterAppForUser(app, new Set(['admin.access'])))).toEqual(['grp_admin']); }); it('AREA level: the item gate applies inside `areas[]` too, not just the top-level tree', () => { @@ -3143,7 +3130,6 @@ describe('filterAppForUser — the enforced permission layers (#4651, #4722)', ( // rewrite: an item gate nested under an area is now enforced by the SERVER, // so the gated entry — and its `objectName` / `pageName` / `componentRef` // target with it — never reaches the browser at all. - const rest: any = make(); const app = () => ({ name: 'crm', navigation: [{ id: 'nav_home', type: 'object' }], @@ -3159,7 +3145,7 @@ describe('filterAppForUser — the enforced permission layers (#4651, #4722)', ( }], }); - const out = rest.filterAppForUser(app(), new Set()); + const out = filterAppForUser(app(), new Set()); expect(ids(out)).toEqual(['nav_home']); // area_sales keeps only the ungated item; area_admin was emptied by the // gate and is dropped whole — see the group-collapse pin below. @@ -3169,14 +3155,13 @@ describe('filterAppForUser — the enforced permission layers (#4651, #4722)', ( expect(JSON.stringify(out)).not.toContain('forecast'); expect(JSON.stringify(out)).not.toContain('sys_user'); - const admin = rest.filterAppForUser(app(), new Set(['sales.admin', 'admin.access'])); + const admin = filterAppForUser(app(), new Set(['sales.admin', 'admin.access'])); expect(admin.areas.map((a: any) => a.id)).toEqual(['area_sales', 'area_admin']); expect(areaIds(admin, 0)).toEqual(['nav_leads', 'nav_forecast']); expect(areaIds(admin, 1)).toEqual(['nav_users']); }); it('AREA level: one implementation — nested groups inside an area collapse exactly as at top level', () => { - const rest: any = make(); const app = () => ({ name: 'crm', areas: [{ @@ -3197,19 +3182,18 @@ describe('filterAppForUser — the enforced permission layers (#4651, #4722)', ( ], }], }); - const out = rest.filterAppForUser(app(), new Set()); + const out = filterAppForUser(app(), new Set()); // grp_admin emptied → dropped; grp_mixed keeps its ungated child. expect(areaIds(out, 0)).toEqual(['nav_tasks', 'grp_mixed']); expect(out.areas[0].navigation[1].children.map((c: any) => c.id)).toEqual(['nav_about']); - const admin = rest.filterAppForUser(app(), new Set(['admin.access'])); + const admin = filterAppForUser(app(), new Set(['admin.access'])); expect(areaIds(admin, 0)).toEqual(['nav_tasks', 'grp_admin', 'grp_mixed']); }); it('AREA level: an area emptied BY the gate is dropped; an area authored empty is passed through', () => { // Same shape as the top-level group-collapse rule pinned above: collapse is // a consequence of filtering, never a tidy-up of what the author wrote. - const rest: any = make(); const gatedEmpty = { name: 'crm', areas: [{ @@ -3217,20 +3201,19 @@ describe('filterAppForUser — the enforced permission layers (#4651, #4722)', ( navigation: [{ id: 'nav_users', type: 'object', requiredPermissions: ['admin.access'] }], }], }; - expect(rest.filterAppForUser(gatedEmpty, new Set()).areas).toEqual([]); + expect(filterAppForUser(gatedEmpty, new Set()).areas).toEqual([]); expect( - rest.filterAppForUser(gatedEmpty, new Set(['admin.access'])).areas.map((a: any) => a.id), + filterAppForUser(gatedEmpty, new Set(['admin.access'])).areas.map((a: any) => a.id), ).toEqual(['area_admin']); const authoredEmpty = { name: 'crm', areas: [{ id: 'area_soon', label: 'Soon', navigation: [] }] }; - expect(rest.filterAppForUser(authoredEmpty, new Set()).areas.map((a: any) => a.id)) + expect(filterAppForUser(authoredEmpty, new Set()).areas.map((a: any) => a.id)) .toEqual(['area_soon']); }); it('AREA level: an app with areas but no top-level navigation is still filtered', () => { // The pre-#4722 early return (`if (!nav) return item`) handed this shape // back untouched — which is precisely the areas-only app the bug hurt most. - const rest: any = make(); const app = { name: 'crm', areas: [{ @@ -3241,13 +3224,12 @@ describe('filterAppForUser — the enforced permission layers (#4651, #4722)', ( ], }], }; - const out = rest.filterAppForUser(app, new Set()); + const out = filterAppForUser(app, new Set()); expect(out.navigation).toBeUndefined(); expect(areaIds(out, 0)).toEqual(['nav_leads']); }); it('does not mutate the app it filters (cached metadata stays whole)', () => { - const rest: any = make(); const app = { name: 'crm', navigation: [{ id: 'nav_home', type: 'object' }], @@ -3260,7 +3242,7 @@ describe('filterAppForUser — the enforced permission layers (#4651, #4722)', ( }], }; const before = JSON.stringify(app); - rest.filterAppForUser(app, new Set()); + filterAppForUser(app, new Set()); expect(JSON.stringify(app)).toBe(before); }); @@ -3273,7 +3255,6 @@ describe('filterAppForUser — the enforced permission layers (#4651, #4722)', ( // `requiredPermissions`, not `visible`. Whoever binds CEL server-side // should see this expectation fail and rewrite it, updating the // `navigation.visible` liveness note in the same change. - const rest: any = make(); const app = { name: 'crm', navigation: [{ id: 'nav_secret_top', type: 'object', visible: 'false' }], @@ -3282,7 +3263,7 @@ describe('filterAppForUser — the enforced permission layers (#4651, #4722)', ( navigation: [{ id: 'nav_secret_area', type: 'object', visible: 'false' }], }], }; - const out = rest.filterAppForUser(app, new Set()); + const out = filterAppForUser(app, new Set()); expect(ids(out)).toEqual(['nav_secret_top']); expect(areaIds(out, 0)).toEqual(['nav_secret_area']); }); @@ -3321,12 +3302,10 @@ describe('filterAppForUser — the enforced permission layers (#4651, #4722)', ( // --------------------------------------------------------------------------- describe('filterAppForUser — childless groups drop however they got that way (#7380)', () => { - const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any, ANON_API as any); const ids = (a: any): string[] => (a?.navigation ?? []).map((e: any) => e.id); const areaIds = (a: any, i: number): string[] => (a?.areas?.[i]?.navigation ?? []).map((e: any) => e.id); it('a group DECLARED `children: []` is dropped — the regression #7380 reports', () => { - const rest: any = make(); const app = { name: 'setup', navigation: [ @@ -3336,12 +3315,11 @@ describe('filterAppForUser — childless groups drop however they got that way ( }; // No permission is involved: the caller here holds everything there is to // hold, so a survival would be the authored shape leaking, not a gate. - expect(ids(rest.filterAppForUser(app, new Set(['setup.access'])))).toEqual(['nav_home']); - expect(ids(rest.filterAppForUser(app, new Set()))).toEqual(['nav_home']); + expect(ids(filterAppForUser(app, new Set(['setup.access'])))).toEqual(['nav_home']); + expect(ids(filterAppForUser(app, new Set()))).toEqual(['nav_home']); }); it('a group emptied BY the gate is still dropped — the half that already worked', () => { - const rest: any = make(); const app = { name: 'setup', navigation: [ @@ -3352,13 +3330,12 @@ describe('filterAppForUser — childless groups drop however they got that way ( }, ], }; - expect(ids(rest.filterAppForUser(app, new Set()))).toEqual(['nav_home']); - expect(ids(rest.filterAppForUser(app, new Set(['admin.access'])))) + expect(ids(filterAppForUser(app, new Set()))).toEqual(['nav_home']); + expect(ids(filterAppForUser(app, new Set(['admin.access'])))) .toEqual(['nav_home', 'group_admin']); }); it('a group with surviving children is kept, with only the gated ones stripped', () => { - const rest: any = make(); const app = { name: 'setup', navigation: [{ @@ -3369,7 +3346,7 @@ describe('filterAppForUser — childless groups drop however they got that way ( ], }], }; - const out = rest.filterAppForUser(app, new Set()); + const out = filterAppForUser(app, new Set()); expect(ids(out)).toEqual(['group_mixed']); expect(out.navigation[0].children.map((c: any) => c.id)).toEqual(['nav_public']); // The label survives intact — collapsing is about emptiness, not tidying. @@ -3379,7 +3356,6 @@ describe('filterAppForUser — childless groups drop however they got that way ( it('nesting: an outer group left holding only a dead inner group collapses too', () => { // The recursion has to see the inner drop before it judges the outer one, // which is only true because the rule lives inside `filterNav` itself. - const rest: any = make(); const app = { name: 'setup', navigation: [{ @@ -3387,7 +3363,7 @@ describe('filterAppForUser — childless groups drop however they got that way ( children: [{ id: 'group_inner', type: 'group', label: 'Inner', children: [] }], }], }; - expect(ids(rest.filterAppForUser(app, new Set()))).toEqual([]); + expect(ids(filterAppForUser(app, new Set()))).toEqual([]); }); it('a group with no `children` key at all is the same dead label, and drops', () => { @@ -3396,7 +3372,6 @@ describe('filterAppForUser — childless groups drop however they got that way ( // `app.nav-type-assertions.ts` — but this filter reads untyped documents // off the metadata store, so the shape is reachable at runtime and would // otherwise be the same bypass one keyword over. - const rest: any = make(); const app = { name: 'setup', navigation: [ @@ -3404,7 +3379,7 @@ describe('filterAppForUser — childless groups drop however they got that way ( { id: 'group_bare', type: 'group', label: 'Bare' }, ], }; - expect(ids(rest.filterAppForUser(app, new Set()))).toEqual(['nav_home']); + expect(ids(filterAppForUser(app, new Set()))).toEqual(['nav_home']); }); // -- the boundary: NON-group entries are untouched by this rule ------------- @@ -3414,7 +3389,6 @@ describe('filterAppForUser — childless groups drop however they got that way ( // the lead list that happens to nest nothing. Collapsing it would delete a // reachable destination, which is the opposite of what the docblock rule // says, and the rule says `group` for exactly this reason. - const rest: any = make(); const app = { name: 'crm', navigation: [ @@ -3424,14 +3398,13 @@ describe('filterAppForUser — childless groups drop however they got that way ( { id: 'nav_sep', type: 'separator' }, ], }; - const out = rest.filterAppForUser(app, new Set()); + const out = filterAppForUser(app, new Set()); expect(ids(out)).toEqual(['nav_leads', 'nav_page', 'nav_link', 'nav_sep']); // Served as authored — the empty array is not rewritten or removed either. expect(out.navigation[0]).toMatchObject({ objectName: 'lead', children: [] }); }); it('a non-group entry still keeps its gated children stripped', () => { - const rest: any = make(); const app = { name: 'crm', navigation: [{ @@ -3442,14 +3415,13 @@ describe('filterAppForUser — childless groups drop however they got that way ( ], }], }; - const out = rest.filterAppForUser(app, new Set()); + const out = filterAppForUser(app, new Set()); // Emptied to zero children it would STILL be served — it is a target. expect(ids(out)).toEqual(['nav_leads']); expect(out.navigation[0].children.map((c: any) => c.id)).toEqual(['nav_hot']); }); it('an object entry emptied to zero children by the gate is still served', () => { - const rest: any = make(); const app = { name: 'crm', navigation: [{ @@ -3457,7 +3429,7 @@ describe('filterAppForUser — childless groups drop however they got that way ( children: [{ id: 'nav_all', type: 'object', requiredPermissions: ['admin.access'] }], }], }; - const out = rest.filterAppForUser(app, new Set()); + const out = filterAppForUser(app, new Set()); expect(ids(out)).toEqual(['nav_leads']); expect(out.navigation[0].children).toEqual([]); }); @@ -3471,7 +3443,6 @@ describe('filterAppForUser — childless groups drop however they got that way ( // So the filter sees a filled slot as a normal group with children, and an // unfilled slot as `children: []` — the app-showcase case and the app-crm // case from #7380, one filter apart. - const rest: any = make(); const setupShell = () => ({ name: 'setup', navigation: [ @@ -3485,7 +3456,7 @@ describe('filterAppForUser — childless groups drop however they got that way ( { id: 'group_diagnostics', type: 'group', label: 'Diagnostics', children: [] }, ], }); - const out = rest.filterAppForUser(setupShell(), new Set(['setup.access'])); + const out = filterAppForUser(setupShell(), new Set(['setup.access'])); expect(ids(out)).toEqual(['group_integrations']); expect(out.navigation[0].children.map((c: any) => c.id)).toEqual(['nav_webhooks']); // The dead slot's label is gone from the body, not merely unrendered. @@ -3496,7 +3467,6 @@ describe('filterAppForUser — childless groups drop however they got that way ( // Being filled is not a licence to survive: the slot is judged on what the // CALLER may see, so a contribution the caller cannot satisfy leaves the // same dead label as no contribution at all. - const rest: any = make(); const app = () => ({ name: 'setup', navigation: [{ @@ -3504,13 +3474,12 @@ describe('filterAppForUser — childless groups drop however they got that way ( children: [{ id: 'nav_logs', type: 'object', requiredPermissions: ['manage_platform_settings'] }], }], }); - expect(ids(rest.filterAppForUser(app(), new Set(['setup.access'])))).toEqual([]); - expect(ids(rest.filterAppForUser(app(), new Set(['setup.access', 'manage_platform_settings'])))) + expect(ids(filterAppForUser(app(), new Set(['setup.access'])))).toEqual([]); + expect(ids(filterAppForUser(app(), new Set(['setup.access', 'manage_platform_settings'])))) .toEqual(['group_diagnostics']); }); it('the ADR-0057 D10 service gate empties a slot the same way, and it drops', () => { - const rest: any = make(); const app = () => ({ name: 'setup', navigation: [{ @@ -3518,14 +3487,13 @@ describe('filterAppForUser — childless groups drop however they got that way ( children: [{ id: 'nav_orgs', type: 'object', objectName: 'sys_organization', requiresService: 'org-scoping' }], }], }); - expect(ids(rest.filterAppForUser(app(), new Set(), (n: string) => n !== 'org-scoping'))).toEqual([]); - expect(ids(rest.filterAppForUser(app(), new Set(), () => true))).toEqual(['group_org']); + expect(ids(filterAppForUser(app(), new Set(), (n: string) => n !== 'org-scoping'))).toEqual([]); + expect(ids(filterAppForUser(app(), new Set(), () => true))).toEqual(['group_org']); }); // -- one implementation: the same rule inside `areas[]` --------------------- it('AREA level: the same rule runs inside `areas[]` — one `filterNav`, not two', () => { - const rest: any = make(); const app = { name: 'crm', areas: [{ @@ -3536,7 +3504,7 @@ describe('filterAppForUser — childless groups drop however they got that way ( ], }], }; - expect(areaIds(rest.filterAppForUser(app, new Set()), 0)).toEqual(['nav_tasks']); + expect(areaIds(filterAppForUser(app, new Set()), 0)).toEqual(['nav_tasks']); }); it('AREA level: an area holding ONLY childless groups empties and is dropped', () => { @@ -3545,7 +3513,6 @@ describe('filterAppForUser — childless groups drop however they got that way ( // is now empty for this shape. An area authored `navigation: []` is still // passed through (pinned in the #4651/#4722 block) — that divergence is // explained at `filterAreas`. - const rest: any = make(); const app = { name: 'crm', areas: [ @@ -3553,12 +3520,11 @@ describe('filterAppForUser — childless groups drop however they got that way ( { id: 'area_authored_empty', label: 'Soon', navigation: [] }, ], }; - const out = rest.filterAppForUser(app, new Set()); + const out = filterAppForUser(app, new Set()); expect(out.areas.map((a: any) => a.id)).toEqual(['area_authored_empty']); }); it('does not mutate the app it filters', () => { - const rest: any = make(); const app = { name: 'setup', navigation: [ @@ -3567,7 +3533,7 @@ describe('filterAppForUser — childless groups drop however they got that way ( ], }; const before = JSON.stringify(app); - rest.filterAppForUser(app, new Set()); + filterAppForUser(app, new Set()); expect(JSON.stringify(app)).toBe(before); }); }); @@ -3590,26 +3556,22 @@ describe('filterAppForUser — ADR-0057 D10 requiresService gate', () => { const ids = (a: any): string[] => (a?.navigation ?? []).map((e: any) => e.id); it('drops requiresService entries when the gate reports the service absent', () => { - const rest: any = make(); - const out = rest.filterAppForUser(app(), new Set(), (n: string) => n !== 'org-scoping'); + const out = filterAppForUser(app(), new Set(), (n: string) => n !== 'org-scoping'); expect(ids(out)).toEqual(['nav_users', 'nav_business_units']); }); it('keeps requiresService entries when the service is present', () => { - const rest: any = make(); - const out = rest.filterAppForUser(app(), new Set(), () => true); + const out = filterAppForUser(app(), new Set(), () => true); expect(ids(out)).toContain('nav_organizations'); expect(ids(out)).toContain('nav_invitations'); }); it('fail-open: with no service gate, requiresService entries are kept (prior behaviour)', () => { - const rest: any = make(); - expect(ids(rest.filterAppForUser(app(), new Set()))).toContain('nav_organizations'); + expect(ids(filterAppForUser(app(), new Set()))).toContain('nav_organizations'); }); it('the service gate does not touch requiresObject entries (client-side concern)', () => { - const rest: any = make(); - const out = rest.filterAppForUser(app(), new Set(), () => false); + const out = filterAppForUser(app(), new Set(), () => false); expect(ids(out)).toContain('nav_business_units'); expect(ids(out)).not.toContain('nav_organizations'); }); @@ -3617,13 +3579,12 @@ describe('filterAppForUser — ADR-0057 D10 requiresService gate', () => { it('resolveRegisteredServices probes only referenced services and reports presence', async () => { const rest: any = make(); const kernel = { getServiceAsync: async (n: string) => { if (n === 'org-scoping') return {}; throw new Error('not registered'); } }; - const reg = await rest.resolveRegisteredServices(kernel, [app()]); + const reg = await resolveRegisteredServices(rest.serviceProbeFor(kernel), [app()]); expect(reg.has('org-scoping')).toBe(true); expect(reg.size).toBe(1); }); it('[#4722] the gate strips requiresService entries inside `areas[]` as well', () => { - const rest: any = make(); const areaApp = () => ({ name: 'setup', areas: [{ @@ -3635,12 +3596,12 @@ describe('filterAppForUser — ADR-0057 D10 requiresService gate', () => { }], }); const nav = (a: any): string[] => (a?.areas?.[0]?.navigation ?? []).map((e: any) => e.id); - expect(nav(rest.filterAppForUser(areaApp(), new Set(), (n: string) => n !== 'org-scoping'))) + expect(nav(filterAppForUser(areaApp(), new Set(), (n: string) => n !== 'org-scoping'))) .toEqual(['nav_users']); - expect(nav(rest.filterAppForUser(areaApp(), new Set(), () => true))) + expect(nav(filterAppForUser(areaApp(), new Set(), () => true))) .toEqual(['nav_users', 'nav_organizations']); // Fail-open when the gate cannot be probed at all, as at top level. - expect(nav(rest.filterAppForUser(areaApp(), new Set()))) + expect(nav(filterAppForUser(areaApp(), new Set()))) .toEqual(['nav_users', 'nav_organizations']); }); @@ -3661,12 +3622,12 @@ describe('filterAppForUser — ADR-0057 D10 requiresService gate', () => { ], }], }; - const reg = await rest.resolveRegisteredServices(kernel, [areaApp]); + const reg = await resolveRegisteredServices(rest.serviceProbeFor(kernel), [areaApp]); expect(reg.has('org-scoping')).toBe(true); expect(reg.has('nope')).toBe(false); // End-to-end: probe + gate keeps the registered one, drops the absent one. - const gated = rest.filterAppForUser(areaApp, new Set(), (n: string) => reg.has(n)); + const gated = filterAppForUser(areaApp, new Set(), (n: string) => reg.has(n)); expect(gated.areas[0].navigation.map((e: any) => e.id)).toEqual(['nav_organizations']); }); @@ -3720,24 +3681,20 @@ describe('filterDashboardForUser — ADR-0057 D10 widget requiresService gate', const ids = (d: any): string[] => (d?.widgets ?? []).map((w: any) => w.id); it('drops widgets whose requiresService gate reports the service absent', () => { - const rest: any = make(); - const out = rest.filterDashboardForUser(dash(), (n: string) => n !== 'org-scoping'); + const out = filterDashboardForUser(dash(), (n: string) => n !== 'org-scoping'); expect(ids(out)).toEqual(['widget_total_users', 'widget_packages_installed']); }); it('keeps requiresService widgets when the service is present', () => { - const rest: any = make(); - expect(ids(rest.filterDashboardForUser(dash(), () => true))).toContain('widget_organizations'); + expect(ids(filterDashboardForUser(dash(), () => true))).toContain('widget_organizations'); }); it('fail-open: with no service gate, widgets are untouched', () => { - const rest: any = make(); - expect(ids(rest.filterDashboardForUser(dash(), undefined))).toContain('widget_organizations'); + expect(ids(filterDashboardForUser(dash(), undefined))).toContain('widget_organizations'); }); it('does not touch requiresObject widgets (client-side concern)', () => { - const rest: any = make(); - const out = rest.filterDashboardForUser(dash(), () => false); + const out = filterDashboardForUser(dash(), () => false); expect(ids(out)).toContain('widget_packages_installed'); expect(ids(out)).not.toContain('widget_organizations'); }); @@ -3890,7 +3847,7 @@ describe('filterDashboardForUser — ADR-0057 D10 widget requiresService gate', it('resolveRegisteredServices discovers requiresService declared on widgets', async () => { const rest: any = make(); const kernel = { getServiceAsync: async (n: string) => { if (n === 'org-scoping') return {}; throw new Error('absent'); } }; - const reg = await rest.resolveRegisteredServices(kernel, [dash()]); + const reg = await resolveRegisteredServices(rest.serviceProbeFor(kernel), [dash()]); expect(reg.has('org-scoping')).toBe(true); }); }); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index c47473b9dcd..e5cbe6ba50d 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -45,8 +45,10 @@ 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). +// [#20237] …and its LIST twin, for `/meta/:type`, on the same ports. import { createMetaItemReadGate, + createMetaListReadGate, type MetaItemReadGateSources, type MetaReadGateCaller, type MetaReadGatePolicy, @@ -304,7 +306,8 @@ const RENDERED_DOCUMENT_POLICY: MetaReadGatePolicy = Object.freeze({ arms: 'all' const NAV_PRUNE_LOGGED = new Set(); /** - * [#20193] This transport's I/O, as the shared per-caller read gate takes it. + * [#20193] This transport's I/O, as the shared per-caller read gate takes it + * — and, [#20237], the list gate beside it, which takes the same ports. * * Only I/O — ⛔ no decision lives here; every verdict is * `createMetaItemReadGate`'s, the one `RestServer` asks: @@ -389,6 +392,43 @@ async function gateMetaItemDocument( return { ok: false, response: deps.error(refusal.message, refusal.status, { code: refusal.code }) }; } +/** + * [#20237] The per-caller steps of ONE list answer this transport is about to + * serve, in `RestServer`'s order: the shared per-caller LIST gate + * (`createMetaListReadGate`, the one `RestServer`'s `GET /meta/:type` calls — + * ⛔ no decision lives here), then the ADR-0106 object mask. + * + * `data` is either list shape this branch hands around (a bare array or a + * `{ type, items }` envelope), and the answer keeps that shape; `listType` is + * the folded singular type the gate is asked about, `typeOrName` the raw + * segment the mask folds for itself. A gate input that could not be read (a + * doc list's books read threw) is answered as that fault — its own status, + * `500` for a shapeless one — ⛔ never as the unfiltered list; so is a mask + * fault that is not the D6 tier-3 one, which keeps its `503`. + */ +async function gateMetaListAnswer( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + protocol: MetaDomainProtocol | undefined, + typeOrName: string, + listType: string, + data: any, +): Promise<{ ok: true; data: any } | { ok: false; result: HttpDispatcherResult }> { + const list: any[] | null = Array.isArray(data) ? data : (data && Array.isArray(data.items) ? data.items : null); + try { + let gated = data; + if (list) { + const judged = await createMetaListReadGate(metaItemReadGateSources(deps, context, protocol), listType)(list); + if (judged !== list) gated = Array.isArray(data) ? judged : { ...data, items: judged }; + } + const projected = await maskObjectSchemaList(deps, context, typeOrName, gated); + if (!projected.ok) return { ok: false, result: fieldVisibilityFault(deps, projected.object) }; + return { ok: true, data: projected.data }; + } catch (e: any) { + return { ok: false, result: { handled: true, response: deps.errorFromThrown(e, 500) } }; + } +} + /** * Percent-decode the `:name` path segment. [#12195] * @@ -1202,6 +1242,25 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // Try protocol service first for any type const protocol = await resolveProtocol(deps, _context); + + // [#20237] This branch serves a LIST, the same answer `RestServer`'s + // `GET /meta/:type` serves — so it prunes what that route prunes: the + // shared per-caller LIST gate (the ADR-0046 §6.7 doc and book audience, + // the app nav filter, the ADR-0057 D10 dashboard widget gate), then the + // ADR-0106 object mask. It ran only the mask, and only on the protocol + // exit, so a member `RestServer` prunes listed a `{ permissionSet }`- + // gated doc here with its body (`?include=content`), a set-gated book, + // and every app with its `requiredPermissions`-gated entries — the list + // twin of the item reads' defect. Every exit below that serves a list + // goes through `gateList`, OUTSIDE the lookups' own `try`s: those + // classify a type the store does not know, and a gate fault must be + // answered as itself — never fall through to the next store as though + // the first had not answered. Each exit keeps its own projection (the + // doc slim), exactly as before. + const listType = pluralToSingular(typeOrName); + const gateList = (data: any) => gateMetaListAnswer(deps, _context, protocol, typeOrName, listType, data); + + let listed: any; if (protocol && typeof protocol.getMetaItems === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); @@ -1211,38 +1270,42 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin const previewDrafts = query?.preview === 'draft'; const data = await protocol.getMetaItems({ type: typeOrName, packageId, organizationId, previewDrafts }); // Return any valid response from protocol (including empty items arrays) - if (data && (data.items !== undefined || Array.isArray(data))) { - // [ADR-0106 D5(2)] The dispatcher's list read is the same - // outlet as REST's `GET /meta/object`, reached by a different - // door. - const projected = await maskObjectSchemaList(deps, _context, typeOrName, data); - if (!projected.ok) return fieldVisibilityFault(deps, projected.object); - return { handled: true, response: deps.success(slimDocList(typeOrName, projected.data, query)) }; - } + if (data && (data.items !== undefined || Array.isArray(data))) listed = data; } catch { // Protocol doesn't know this type, fall through } } + if (listed !== undefined) { + // [ADR-0106 D5(2)] The dispatcher's list read is the same outlet as + // REST's `GET /meta/object`, reached by a different door. + const gated = await gateList(listed); + if (!gated.ok) return gated.result; + return { handled: true, response: deps.success(slimDocList(typeOrName, gated.data, query)) }; + } // Try MetadataService directly for runtime-registered metadata (agents, tools, etc.) const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata); if (metadataService && typeof (metadataService as any).list === 'function') { + let items: any; try { - let items = await (metadataService as any).list(typeOrName); + items = await (metadataService as any).list(typeOrName); // Respect package filter: MetadataService.list() returns ALL items, // so filter by _packageId when a specific package is requested. if (packageId && items && items.length > 0) { items = items.filter((item: any) => item?._packageId === packageId); } - if (items && items.length > 0) { - return { handled: true, response: deps.success({ type: typeOrName, items: slimDocList(typeOrName, items, query) }) }; - } } catch (e: any) { + items = undefined; // MetadataService doesn't know this type or failed, continue to other fallbacks // Sanitize typeOrName to prevent log injection (CodeQL warning) const sanitizedType = String(typeOrName).replace(/[\r\n\t]/g, ''); console.debug(`[HttpDispatcher] MetadataService.list() failed for type:`, sanitizedType, 'error:', e.message); } + if (items && items.length > 0) { + const gated = await gateList({ type: typeOrName, items }); + if (!gated.ok) return gated.result; + return { handled: true, response: deps.success(slimDocList(typeOrName, gated.data, query)) }; + } } // Try ObjectQL registry directly for object/type lookups @@ -1250,14 +1313,16 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (qlService?.registry) { if (typeOrName === 'objects') { const objs = qlService.registry.getAllObjects(packageId); - const projected = await maskObjectSchemaList(deps, _context, 'object', { type: 'object', items: objs }); - if (!projected.ok) return fieldVisibilityFault(deps, projected.object); - return { handled: true, response: deps.success(projected.data) }; + const gated = await gateList({ type: 'object', items: objs }); + if (!gated.ok) return gated.result; + return { handled: true, response: deps.success(gated.data) }; } // Try listing items of the given type const items = qlService.registry.listItems?.(typeOrName, packageId); if (items && items.length > 0) { - return { handled: true, response: deps.success({ type: typeOrName, items }) }; + const gated = await gateList({ type: typeOrName, items }); + if (!gated.ok) return gated.result; + return { handled: true, response: deps.success(gated.data) }; } // Legacy: treat as object name. [ADR-0106 D5(4)] A schema-bearing // exit reached by a one-segment path — masked like every other, so From de03b067bf87283ae8ed524843143c5a41c5b25f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:56:08 +0000 Subject: [PATCH 2/3] test(runtime,rest): pin the dispatcher /meta list against RestServer's, through dispatch() and a composed createHonoApp host meta-list-read-gate-parity.test.ts drives the same fixtures through both transports for a holder and a non-holder (doc with and without bodies, book, app, dashboard, object, view, and the plural spellings), plus the anonymous control, a gate-input fault, and the MetadataService and ObjectQL registry fallback exits. hono-meta-list-read-gate.conformance.test.ts pins the card's three rows through a real LiteKernel behind the real createHonoApp, with a holder as the control. The exec-ctx census moves 68 to 66 sites and 92 to 90 mentions: two same-line caught sites went into the shared list gate. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- .changeset/20237-dispatcher-meta-list-gate.md | 67 +++ ...no-meta-list-read-gate.conformance.test.ts | 225 ++++++++++ .../rest/src/execctx-consumer-census.test.ts | 40 +- .../meta-list-read-gate-parity.test.ts | 417 ++++++++++++++++++ 4 files changed, 738 insertions(+), 11 deletions(-) create mode 100644 .changeset/20237-dispatcher-meta-list-gate.md create mode 100644 packages/qa/http-conformance/src/hono-meta-list-read-gate.conformance.test.ts create mode 100644 packages/runtime/src/domains/meta-list-read-gate-parity.test.ts diff --git a/.changeset/20237-dispatcher-meta-list-gate.md b/.changeset/20237-dispatcher-meta-list-gate.md new file mode 100644 index 00000000000..aef59c33a9a --- /dev/null +++ b/.changeset/20237-dispatcher-meta-list-gate.md @@ -0,0 +1,67 @@ +--- +'@objectstack/runtime': patch +'@objectstack/rest': minor +--- + +fix(runtime): the dispatcher's `/meta/:type` list prunes what `RestServer`'s list prunes, through one list gate that `@objectstack/rest` publishes (#20237) + +Clause-②: yes + +`GET /meta/:type` has 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 list branch applied **no** per-caller filter. An +authenticated member who does not hold `crm_admin` got these answers through +`dispatch()`: + +``` +request before after (= RestServer) +GET /meta/doc?include=content the set-gated doc listed WITH its body the doc left out +GET /meta/book the set-gated book listed the book left out +GET /meta/app an app whose requiredPermissions the that app left out; the other app + member lacks, and an ungated app with pruned of its gated entry + its requiredPermissions-gated entry +GET /meta/dashboard (anyone) every widget minus the widget whose service is off +``` + +The plural spellings (`/meta/docs`, `/meta/books`, `/meta/apps`) answer the +same. Holders are still listed everything in full. Object lists are masked as +before. An anonymous caller still gets `401 UNAUTHENTICATED`. + +**One gate, not two.** `RestServer`'s list filters moved unchanged into +`createMetaListReadGate`, beside the item gate in +`packages/rest/src/meta-item-read-gate.ts`. It covers the ADR-0046 §6.7 doc +and book audience prunes, the app nav filter (`requiredPermissions`, the +ADR-0045 §3 publish gate and the docs-audience entry arm) and the ADR-0057 +D10 dashboard widget gate. `RestServer`'s list route and the dispatcher's list +branch both call it, over the same ports the item gate takes, and each rewraps +the pruned items in its own list envelope. There is no second audience +resolver in `packages/runtime`. `RestServer`'s list answers are unchanged. + +Every exit of the dispatcher's list branch now runs the gate and the ADR-0106 +object mask: the protocol list and the two fallbacks, the runtime metadata +service's list and the ObjectQL registry. The fallbacks used to serve +unmasked object schemas as well as ungated docs, books and apps. A gate input +that cannot be read (a doc list's books read throws) is answered as that fault, +never as the unfiltered list. + +**`@objectstack/rest`'s published export surface widens**, and that is why this +changeset declares `Clause-②: yes`. Its only export subpath (`.`) gains one +value: + +- `createMetaListReadGate(sources, metaType)`: the list gate. It takes the + same `MetaItemReadGateSources` the item gate takes, and it answers a judge + from a list's items to the items this caller may be served. + +It is 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 `createMetaItemReadGate` does. A caller outside the platform does not +need it. + +Nothing is removed or renamed from the package's exports, and no authorable key +moves. The dispatcher's prunes are not the widening: they pull a second +transport back to the rules the contract already declares (ADR-0046 §6.7, +ADR-0045 §3, `apps.mdx`'s `requiredPermissions` row), which is why +`@objectstack/runtime` stays a `patch`. diff --git a/packages/qa/http-conformance/src/hono-meta-list-read-gate.conformance.test.ts b/packages/qa/http-conformance/src/hono-meta-list-read-gate.conformance.test.ts new file mode 100644 index 00000000000..a501f2391be --- /dev/null +++ b/packages/qa/http-conformance/src/hono-meta-list-read-gate.conformance.test.ts @@ -0,0 +1,225 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20237] Through a REAL `createHonoApp` host, the dispatcher's `/meta/:type` + * LIST prunes exactly as `RestServer`'s 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` goes catch-all → `HttpDispatcher.dispatch()` → the domain + * registry → `handleMetadataRequest`'s list branch. That branch applied no + * per-caller gate, so a member who does not hold `crm_admin` listed the + * `{ permissionSet }`-gated doc WITH its body (`?include=content`), the + * set-gated book, an app whose `requiredPermissions` they lack, and an ungated + * app with its `requiredPermissions`-gated nav entry. It now asks the one list + * gate `RestServer`'s `GET /meta/:type` asks (`createMetaListReadGate`, + * `@objectstack/rest`), and these rows hold the ANSWER ON THE WIRE — status, + * the listed names, 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 three rows are the card's: the doc list with bodies, the book list, and + * the app list. What `RestServer` answers the same caller is pinned beside the + * dispatcher's in `meta-list-read-gate-parity.test.ts` (`@objectstack/runtime`). + * + * ## Where this lives, and the one stubbed seam + * + * Beside `hono-meta-item-read-gate.conformance.test.ts`, for its reasons: + * `packages/adapters/hono/vitest.config.ts` aliases `@objectstack/runtime` to a + * stub for its whole suite, so no test there can compose the real dispatcher; + * here `@objectstack/hono` is 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`) — a runtime change reaches + * these rows only once `@objectstack/runtime` is rebuilt. WHO the caller is + * comes from the dispatcher's identity step (`timedResolveExecutionContext`), + * replaced on the prototype by a lookup keyed on a request HEADER read off the + * very Fetch `Request` the catch-all hands to `dispatch()`; 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 item pin's, as lists) ─────────────────────────────────────── + +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'] }, + ], + }, + { + name: 'payroll', + label: 'Payroll', + requiredPermissions: ['payroll.access'], + navigation: [{ id: 'nav_payroll_runs', type: 'page', label: 'Runs', pageName: 'payroll_runs' }], + }, + ], + object: [], +}; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); +const singular = (type: unknown): string => String(type ?? '').replace(/s$/, ''); + +// ── Principals ──────────────────────────────────────────────────────────────── + +interface Principal { ctx: Record; holdings: string[] } +const PRINCIPALS: Record<'holder' | 'non-holder', Principal> = { + holder: { + ctx: { userId: 'u_holder', isSystem: false, systemPermissions: ['finance.access', 'payroll.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` list consults + * (`protocol`, and `security` for permission-set holdings), and the REAL + * `createHonoApp` over it. + */ +async function boot() { + const getMetaItems = vi.fn(async ({ type }: any) => clone(STORE[singular(type)] ?? [])); + const protocol = { getMetaItems }; + 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-list-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(); + const body = JSON.parse(text); + const data = body?.data; + const items: any[] = Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : []; + return { status: res.status, text, body, items }; + }; + return { read, getMetaItems }; +} + +afterEach(() => { vi.restoreAllMocks(); }); + +const names = (items: any[]): string[] => items.map((i: any) => i?.name); +const navIds = (doc: any): string[] => (doc?.navigation ?? []).map((e: any) => e.id); + +// ── The three rows, non-holder ──────────────────────────────────────────────── + +describe('[#20237] a real createHonoApp host lists the non-holder what RestServer lists — the three rows', () => { + it('the doc list with bodies: 200, the gated doc left out, and its body nowhere on the wire', async () => { + const { read, getMetaItems } = await boot(); + const res = await read('non-holder', '/meta/doc?include=content'); + + expect(res.status).toBe(200); + expect(res.body?.success).toBe(true); + expect(names(res.items)).toEqual(['crm_intro']); + // The opt-in still serves the bodies the caller may read. + expect(res.items[0]?.content).toBe('Welcome aboard.'); + expect(res.text).not.toContain('crm_admin_runbook'); + expect(res.text).not.toContain(DOC_SECRET); + // Reached through the catch-all's `/meta` domain — the list read ran. + expect(getMetaItems).toHaveBeenCalledWith(expect.objectContaining({ type: 'doc' })); + }, 30_000); + + it('the book list: 200, the set-gated book left out', async () => { + const { read } = await boot(); + const res = await read('non-holder', '/meta/book'); + + expect(res.status).toBe(200); + expect(names(res.items)).toEqual(['help_center']); + expect(res.text).not.toContain('admin_guide'); + expect(res.text).not.toContain(BOOK_SECRET); + }, 30_000); + + it('the app list: 200, the requiredPermissions-gated app left out and the ungated app PRUNED of its gated entry', async () => { + const { read } = await boot(); + const res = await read('non-holder', '/meta/app'); + + expect(res.status).toBe(200); + expect(names(res.items)).toEqual(['crm']); + expect(navIds(res.items[0])).toEqual(['nav_leads']); + expect(res.text).not.toContain('payroll'); + expect(res.text).not.toContain('nav_finance_ledger'); + }, 30_000); +}); + +// ── The control: the same host still serves a holder ────────────────────────── + +describe('[#20237] control — the same host lists a holder every row in full', () => { + it('the doc list with bodies: both docs, the gated body included', async () => { + const { read } = await boot(); + const res = await read('holder', '/meta/doc?include=content'); + expect(res.status).toBe(200); + expect(names(res.items)).toEqual(['crm_intro', 'crm_admin_runbook']); + expect(res.text).toContain(DOC_SECRET); + }, 30_000); + + it('the book list: both books', async () => { + const { read } = await boot(); + const res = await read('holder', '/meta/book'); + expect(res.status).toBe(200); + expect(names(res.items)).toEqual(['admin_guide', 'help_center']); + expect(res.text).toContain(BOOK_SECRET); + }, 30_000); + + it('the app list: both apps, every entry', async () => { + const { read } = await boot(); + const res = await read('holder', '/meta/app'); + expect(res.status).toBe(200); + expect(names(res.items)).toEqual(['crm', 'payroll']); + expect(navIds(res.items[0])).toEqual(['nav_leads', 'nav_finance_ledger']); + }, 30_000); +}); diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index de682931ac4..1b4b665c62c 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -310,7 +310,19 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t // --------------------------------------------------------------------------- describe('[#13160] §2 the consumer surface, counted from the tree', () => { - it('68 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => { + it('66 invocation sites, 90 mentions — the thread\'s two control numbers hold', () => { + // [#20237] 68 → 66 sites / 92 → 90 mentions — a MOVE, not a lost + // consumer. `GET /meta/:type`'s app nav filter and dashboard widget + // gate each resolved the context inline (two same-line CAUGHT sites); + // both moved into `createMetaListReadGate` in `meta-item-read-gate.ts`, + // the one list gate the runtime dispatcher's `/meta` list branch now + // asks too, where they read the caller through the SAME port the item + // gate uses (`metaReadAudienceSources`'s `resolveCaller`, already + // counted, still same-line `.catch(rethrowAuthzStoreUnavailable)`). So + // no site replaced them: −2 sites, both from the CAUGHT half (23 → 21, + // 15 → 13 on the invocation line); the bare half (45) is untouched. No + // prose mention was added, so the two numbers move together (−2 / −2). + // // [#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 @@ -436,26 +448,27 @@ 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(68); - expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92); + expect(SITES.length).toBe(66); + expect(SOURCE.split('resolveExecCtx').length - 1).toBe(90); }); - 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. + it('the split is 21 locally caught / 45 bare — NOT 13 / 45, which does not add to 66', () => { + // 13 sites spell the catch on the invocation line; 8 more spell it on + // the continuation line. A single-line grep sees 13 and the arithmetic + // silently loses eight sites. [#20237] 15 → 13 and 23 → 21: the list + // route's app and dashboard sites moved into the shared list gate (§2). // // [#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(15); - expect(CAUGHT.length).toBe(23); + expect(sameLine.length).toBe(13); + expect(CAUGHT.length).toBe(21); 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 23 caught ones is', () => { + it('⭐ every one of the 45 bare sites is guarded on the VERY NEXT LINE, and none of the 21 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 @@ -727,7 +740,12 @@ describe('[#13279] §7 every caught resolveExecCtx site re-raises the outage', ( // `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); + // + // [#20237] 14 → 12, the same one site of slack: the inline population + // fell 15 → 13 when the list route's app and dashboard sites moved into + // `createMetaListReadGate`, and no port replaced them (§2) — again the + // population shrank, not the reader's sight. + expect(args.filter((a) => a.layout === 'inline').length).toBeGreaterThanOrEqual(12); expect(args.filter((a) => a.layout === 'continuation').length).toBeGreaterThanOrEqual(4); expect(args.length).toBe(CAUGHT.length); }); diff --git a/packages/runtime/src/domains/meta-list-read-gate-parity.test.ts b/packages/runtime/src/domains/meta-list-read-gate-parity.test.ts new file mode 100644 index 00000000000..16addbbc6e8 --- /dev/null +++ b/packages/runtime/src/domains/meta-list-read-gate-parity.test.ts @@ -0,0 +1,417 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20237] The dispatcher's `/meta/:type` LIST answers what `RestServer`'s + * `GET /meta/:type` answers the same caller — because both ask ONE per-caller + * list gate. + * + * ## The defect this pins + * + * `handleMetadataRequest`'s one-segment list branch read `getMetaItems` and + * served the result through the doc slim alone: no ADR-0046 §6.7 doc or book + * audience, no app nav filter (`requiredPermissions`, the unpublished gate, the + * docs-audience entry arm), no ADR-0057 D10 dashboard widget gate — and on its + * two fallback exits (the `MetadataService` list and the ObjectQL registry) not + * even the ADR-0106 object mask its protocol exit runs. A host that mounts only + * the `${prefix}/*` catch-all (`createHonoApp`, and any thin adapter on the + * public `HttpDispatcher` API) therefore listed, to a member `RestServer` + * prunes, a `{ permissionSet }`-gated doc WITH its body, a set-gated book, and + * every app with its gated entries. Measured before the fix, through + * `dispatch()` (the catch-all's delegate), for a member without `crm_admin`: + * + * row dispatcher RestServer + * doc?include=content crm_intro + crm_admin_runbook+body crm_intro + * book admin_guide + help_center help_center + * app crm (3 entries), payroll, launchpad crm [nav_leads] + * dashboard (any caller) ops [w_open_cases, w_org_kpi] ops [w_open_cases] + * + * ## The fix, and why it is not a second resolver + * + * `RestServer`'s list filters moved, unchanged, into `createMetaListReadGate` + * (`@objectstack/rest`'s `meta-item-read-gate.ts`, beside the item gate); + * `RestServer`'s list route and this dispatcher's list branch each hand it + * their own I/O and rewrap its answer in their own list envelope. 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. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from '@objectstack/rest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +// ── Fixtures (the #20193 parity pin's, as lists) ────────────────────────────── + +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$/, ''); + +// ── 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. `unknownTypes` + * makes `getMetaItems` throw for those types — the "protocol doesn't know this + * type" answer that sends the dispatcher on to its fallback stores — while it + * still answers every other type, the books the doc audience reads included. + */ +function protocolDouble(unknownTypes: string[] = []) { + return { + getMetaTypes: vi.fn(async () => ({ types: Object.keys(STORE) })), + getMetaItems: vi.fn(async ({ type }: any) => { + if (unknownTypes.includes(singular(type))) throw new Error(`unknown metadata type '${type}'`); + return clone(STORE[singular(type)] ?? []); + }), + }; +} + +const securityFor = (caller: Caller) => ({ + resolvePermissionSetNames: async () => caller.holdings, + getMetadataReadableFields: async () => caller.readableFields, +}); + +// ── The two transports ──────────────────────────────────────────────────────── + +interface Answer { status: number; code?: string; body: any; items?: any[] } + +const itemsOf = (data: any): any[] | undefined => + Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : undefined; + +/** The dispatcher, exactly as `createHonoApp` builds it: `new HttpDispatcher(kernel)`. */ +function bootDispatcher( + callerName: CallerName, + opts: { protocol?: any; extra?: Record } = {}, +) { + const caller = CALLERS[callerName]; + const protocol = opts.protocol ?? protocolDouble(); + const services: Record = { protocol, security: securityFor(caller), ...opts.extra }; + 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 list = async (type: string, query: Record = {}): Promise => { + const res = await dispatcher.dispatch('GET', `/meta/${type}`, undefined, query, { request: { headers: {} } } as any); + const status = res.response?.status ?? 0; + const body = res.response?.body; + return { status, code: body?.error?.code, body, items: status === 200 ? itemsOf(body?.data) : undefined }; + }; + return { list, 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`'s `GET /meta/:type` 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.registerRoutes(); + const META = '/api/v1/meta'; + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === `${META}/:type`); + if (!route) throw new Error(`GET ${META}/:type is not registered`); + const list = async (type: string, query: Record = {}): Promise => { + const res = makeRes(); + await route.handler({ method: 'GET', path: `${META}/${type}`, params: { type }, query, body: {}, headers: {} }, res); + const status = res.statusCode; + return { status, code: res.body?.code ?? res.body?.error?.code, body: res.body, items: status === 200 ? itemsOf(res.body) : undefined }; + }; + return { list }; +} + +const text = (a: Answer): string => JSON.stringify(a.body ?? null); +const names = (items: any[] | undefined): string[] => (items ?? []).map((i: any) => i?.name); +/** Per item, what the gates decide: nav entries, widgets, fields, and whether a body is served. */ +const shape = (items: any[] | undefined) => (items ?? []).map((i: any) => ({ + name: i?.name, + nav: Array.isArray(i?.navigation) ? i.navigation.map((e: any) => e.id) : undefined, + widgets: Array.isArray(i?.widgets) ? i.widgets.map((w: any) => w.id) : undefined, + fields: i?.fields ? Object.keys(i.fields).sort() : undefined, + body: i && typeof i === 'object' && 'content' in i, +})); + +// ── The rows ────────────────────────────────────────────────────────────────── + +interface Row { + /** The `:type` segment as a client spells it — the plural is the same read. */ + type: string; + query?: Record; + /** Strings a caller the gate withholds them from must never receive. */ + secrets: string[]; + /** What `RestServer` lists for the non-holder — asserted, so the reference cannot drift. */ + nonHolder: string[]; +} + +const ROWS: Row[] = [ + // The card's three rows. + { type: 'doc', query: { include: 'content' }, secrets: [DOC_SECRET, 'crm_admin_runbook'], nonHolder: ['crm_intro'] }, + { type: 'book', secrets: [BOOK_SECRET, 'admin_guide'], nonHolder: ['help_center'] }, + { type: 'app', secrets: ['nav_finance_ledger', 'nav_admin_runbook', 'payroll', 'launchpad'], nonHolder: ['crm'] }, + // The gate's other arms and spellings. + { type: 'doc', secrets: ['crm_admin_runbook'], nonHolder: ['crm_intro'] }, + { type: 'docs', query: { include: 'content' }, secrets: [DOC_SECRET, 'crm_admin_runbook'], nonHolder: ['crm_intro'] }, + { type: 'books', secrets: [BOOK_SECRET, 'admin_guide'], nonHolder: ['help_center'] }, + { type: 'apps', secrets: ['nav_finance_ledger', 'nav_admin_runbook', 'payroll', 'launchpad'], nonHolder: ['crm'] }, + { type: 'dashboard', secrets: ['w_org_kpi'], nonHolder: ['ops'] }, + // The ADR-0106 object mask — the dispatcher's protocol exit already ran it. + { type: 'object', secrets: ['secret_margin'], nonHolder: ['invoice'] }, + { type: 'objects', secrets: ['secret_margin'], nonHolder: ['invoice'] }, + // Control: an ungated type. + { type: 'view', secrets: [], nonHolder: ['all_leads'] }, +]; + +const label = (row: Row): string => + `GET /meta/${row.type}${row.query ? `?${new URLSearchParams(row.query)}` : ''}`; + +describe('[#20237] the reference: RestServer lists for the non-holder what each row declares', () => { + for (const row of ROWS) { + it(label(row), async () => { + const rest = await bootRest('non-holder').list(row.type, row.query); + expect(rest.status).toBe(200); + expect(names(rest.items)).toEqual(row.nonHolder); + for (const s of row.secrets) expect(text(rest)).not.toContain(s); + }); + } +}); + +describe('[#20237] the dispatcher lists for every caller what RestServer lists — the same status, the same items, pruned the same way', () => { + for (const row of ROWS) { + for (const callerName of ['holder', 'non-holder'] as const) { + it(`${label(row)} × ${callerName}`, async () => { + const dispatcher = await bootDispatcher(callerName).list(row.type, row.query); + const rest = await bootRest(callerName).list(row.type, row.query); + + // 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 }); + expect(dispatcher.status).toBe(200); + // The same items, each pruned / masked / projected the same way. + expect(shape(dispatcher.items)).toEqual(shape(rest.items)); + for (const s of row.secrets) { + if (text(rest).includes(s)) expect(text(dispatcher)).toContain(s); + else expect(text(dispatcher)).not.toContain(s); + } + }); + } + } +}); + +describe('[#20237] controls', () => { + it('a holder lists the gated doc WITH its body, the set-gated book and every app whole through the dispatcher', async () => { + const { list } = bootDispatcher('holder'); + const docs = await list('doc', { include: 'content' }); + const books = await list('book'); + const apps = await list('app'); + expect(names(docs.items)).toEqual(['crm_intro', 'crm_admin_runbook']); + expect(text(docs)).toContain(DOC_SECRET); + expect(names(books.items)).toEqual(['admin_guide', 'help_center']); + expect(text(books)).toContain(BOOK_SECRET); + expect(shape(apps.items).map((a) => [a.name, a.nav])).toEqual([ + ['crm', ['nav_leads', 'nav_finance_ledger', 'nav_admin_runbook']], + ['payroll', ['nav_payroll_runs']], + ['launchpad', ['nav_launchpad_home']], + ]); + }); + + it('an unauthenticated caller keeps its existing answer on every list: 401 UNAUTHENTICATED, before any read', async () => { + for (const row of ROWS) { + const { list, protocol } = bootDispatcher('anonymous'); + const res = await list(row.type, row.query); + expect({ status: res.status, code: res.code }, label(row)).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); + for (const s of row.secrets) expect(text(res), label(row)).not.toContain(s); + expect(protocol.getMetaItems, label(row)).not.toHaveBeenCalled(); + } + }); + + it('…which is STRICTER than RestServer for doc and book lists, where an anonymous caller reads the public ones (none here) — and neither transport serves gated content', async () => { + for (const type of ['doc', 'book']) { + const rest = await bootRest('anonymous').list(type, { include: 'content' }); + expect(rest.status, type).toBe(200); + expect(rest.items, type).toEqual([]); + expect(text(rest), type).not.toContain(DOC_SECRET); + expect(text(rest), type).not.toContain(BOOK_SECRET); + } + }); + + it('a doc list whose audience input cannot be read is answered as that fault — never the unfiltered list', async () => { + const protocol = protocolDouble(); + const listed = protocol.getMetaItems.getMockImplementation()!; + protocol.getMetaItems.mockImplementation(async (req: any) => { + if (singular(req?.type) === 'book') throw Object.assign(new Error('metadata store unavailable'), { status: 503 }); + return listed(req); + }); + const res = await bootDispatcher('non-holder', { protocol }).list('doc', { include: 'content' }); + expect(res.status).toBe(503); + expect(res.body?.success).toBe(false); + expect(text(res)).not.toContain(DOC_SECRET); + expect(text(res)).not.toContain('crm_intro'); + }); +}); + +// ── Every exit of the list branch, not just the first ───────────────────────── + +describe('[#20237] the fallback exits are gated too — the protocol exit is not the only door', () => { + /** The runtime metadata service's list, answering the same store. */ + const metadataService = () => ({ list: vi.fn(async (type: string) => clone(STORE[singular(type)] ?? [])) }); + + it('the MetadataService exit: the non-holder is served the pruned doc, book and app lists and the masked objects', async () => { + for (const [type, expected, secrets] of [ + ['doc', [{ name: 'crm_intro', body: true }], [DOC_SECRET, 'crm_admin_runbook']], + ['book', [{ name: 'help_center', body: false }], [BOOK_SECRET, 'admin_guide']], + ['app', [{ name: 'crm', body: false }], ['nav_finance_ledger', 'payroll', 'launchpad']], + ] as const) { + const metadata = metadataService(); + const { list } = bootDispatcher('non-holder', { + protocol: protocolDouble([type]), + extra: { metadata }, + }); + const res = await list(type, { include: 'content' }); + expect(metadata.list, type).toHaveBeenCalledWith(type); + expect(res.status, type).toBe(200); + expect(shape(res.items).map(({ name, body }) => ({ name, body })), type).toEqual(expected); + for (const s of secrets) expect(text(res), type).not.toContain(s); + } + const metadata = metadataService(); + const objects = await bootDispatcher('non-holder', { protocol: protocolDouble(['object']), extra: { metadata } }) + .list('object'); + expect(metadata.list).toHaveBeenCalledWith('object'); + expect(shape(objects.items).map((o) => o.fields)).toEqual([['amount']]); + expect(text(objects)).not.toContain('secret_margin'); + }); + + it('the ObjectQL registry exit: the object list is masked for the non-holder', async () => { + const registry = { + getAllObjects: vi.fn(() => clone(STORE.object)), + listItems: vi.fn((type: string) => clone(STORE[singular(type)] ?? [])), + getObject: vi.fn(() => undefined), + }; + const { list } = bootDispatcher('non-holder', { + protocol: protocolDouble(['object']), + extra: { objectql: { registry } }, + }); + const res = await list('object'); + expect(registry.listItems).toHaveBeenCalledWith('object', undefined); + expect(shape(res.items).map((o) => o.fields)).toEqual([['amount']]); + expect(text(res)).not.toContain('secret_margin'); + }); + + it('control: the holder is served every fallback list whole', async () => { + const metadata = metadataService(); + const { list } = bootDispatcher('holder', { protocol: protocolDouble(['doc']), extra: { metadata } }); + const res = await list('doc', { include: 'content' }); + expect(names(res.items)).toEqual(['crm_intro', 'crm_admin_runbook']); + expect(text(res)).toContain(DOC_SECRET); + }); +}); From a48f373a291779626fff06ab247c279f5719ff29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:24:02 +0000 Subject: [PATCH 3/3] test(rest): type the retargeted gate-helper calls the way the deleted delegates' any did The three resolveRegisteredServices reads take a non-null assertion (the delegate answered any), gateOf returns Promise as its delegate call did, and one orphaned `rest` local goes. Type-level only; assertions unchanged. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude --- packages/rest/src/meta-app-nav-servability-gate.test.ts | 5 ++--- packages/rest/src/rest.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/rest/src/meta-app-nav-servability-gate.test.ts b/packages/rest/src/meta-app-nav-servability-gate.test.ts index da94ae0b841..af16d7909df 100644 --- a/packages/rest/src/meta-app-nav-servability-gate.test.ts +++ b/packages/rest/src/meta-app-nav-servability-gate.test.ts @@ -77,7 +77,7 @@ const ids = (a: any): string[] => (a?.navigation ?? []).map((e: any) => e.id); const areaIds = (a: any, i: number): string[] => (a?.areas?.[i]?.navigation ?? []).map((e: any) => e.id); /** Resolve the gate the way the `/meta` routes do. */ -async function gateOf(rest: any, items?: unknown[]) { +async function gateOf(rest: any, items?: unknown[]): Promise { const p = createMockProtocol(items); return resolveNavServability({ ...rest.metaListSource(p, undefined), navPruneLogged: rest.navPruneLogged }); } @@ -173,8 +173,7 @@ describe('[#7912] nav servability — what it deliberately does NOT judge', () = }); it('fail-open: with no gate resolved, nothing is pruned (prior behaviour)', async () => { - const rest = make(); - const app = { name: 'setup', navigation: [{ id: 'nav_jwks', type: 'object', objectName: 'sys_jwks' }] }; + const app ={ name: 'setup', navigation: [{ id: 'nav_jwks', type: 'object', objectName: 'sys_jwks' }] }; expect(ids(filterAppForUser(app, new Set()))).toEqual(['nav_jwks']); }); diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 1e75696db84..f8e224dbb2b 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -3579,7 +3579,7 @@ describe('filterAppForUser — ADR-0057 D10 requiresService gate', () => { it('resolveRegisteredServices probes only referenced services and reports presence', async () => { const rest: any = make(); const kernel = { getServiceAsync: async (n: string) => { if (n === 'org-scoping') return {}; throw new Error('not registered'); } }; - const reg = await resolveRegisteredServices(rest.serviceProbeFor(kernel), [app()]); + const reg = (await resolveRegisteredServices(rest.serviceProbeFor(kernel), [app()]))!; expect(reg.has('org-scoping')).toBe(true); expect(reg.size).toBe(1); }); @@ -3622,7 +3622,7 @@ describe('filterAppForUser — ADR-0057 D10 requiresService gate', () => { ], }], }; - const reg = await resolveRegisteredServices(rest.serviceProbeFor(kernel), [areaApp]); + const reg = (await resolveRegisteredServices(rest.serviceProbeFor(kernel), [areaApp]))!; expect(reg.has('org-scoping')).toBe(true); expect(reg.has('nope')).toBe(false); @@ -3847,7 +3847,7 @@ describe('filterDashboardForUser — ADR-0057 D10 widget requiresService gate', it('resolveRegisteredServices discovers requiresService declared on widgets', async () => { const rest: any = make(); const kernel = { getServiceAsync: async (n: string) => { if (n === 'org-scoping') return {}; throw new Error('absent'); } }; - const reg = await resolveRegisteredServices(rest.serviceProbeFor(kernel), [dash()]); + const reg = (await resolveRegisteredServices(rest.serviceProbeFor(kernel), [dash()]))!; expect(reg.has('org-scoping')).toBe(true); }); });