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/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..af16d7909df 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 @@ -76,9 +77,9 @@ 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 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,13 @@ 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']); + const app ={ name: 'setup', navigation: [{ id: 'nav_jwks', type: 'object', objectName: 'sys_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 +209,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 +224,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 +238,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 +254,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 +270,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 +287,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 +329,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..f8e224dbb2b 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-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); + }); +}); 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