From 790b35766654d9c736de4026ac319fec5a60351a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:04:53 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(rest,platform-objects):=20gate=20Setup?= =?UTF-8?q?=20nav=20on=20capability=20=E2=80=94=20Org/Invitations=20on=20m?= =?UTF-8?q?ulti-org=20(ADR-0057=20D10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforce NavigationItem.requiresService server-side in rest-server filterAppForUser: entries whose named kernel service is unregistered are dropped from the served app metadata (fail-open when the kernel can't be probed; previously requiresService was a frontend-only hint). Apply requiresService:'org-scoping' to the Setup app's Organizations + Invitations so they surface only in multi-org (multi-tenant) deployments and vanish in single-tenant. Business Units is intentionally left ungated — open per the open/paid seam + D12 (pick people by BU); only the paid hierarchy rollup is enterprise. Adds a dogfood proof + ADR D10 implementation note. Co-Authored-By: Claude Opus 4.8 --- .../adr-0057-d10-nav-capability-gate.md | 14 +++++ ...ion-core-business-units-and-scope-depth.md | 28 ++++++++++ .../setup-nav-capability-gate.dogfood.test.ts | 52 +++++++++++++++++++ .../src/apps/setup-nav.contributions.ts | 4 +- packages/rest/src/rest-server.ts | 47 +++++++++++++++-- 5 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 .changeset/adr-0057-d10-nav-capability-gate.md create mode 100644 packages/dogfood/test/setup-nav-capability-gate.dogfood.test.ts diff --git a/.changeset/adr-0057-d10-nav-capability-gate.md b/.changeset/adr-0057-d10-nav-capability-gate.md new file mode 100644 index 0000000000..2141f2c145 --- /dev/null +++ b/.changeset/adr-0057-d10-nav-capability-gate.md @@ -0,0 +1,14 @@ +--- +"@objectstack/rest": minor +"@objectstack/platform-objects": minor +--- + +Setup nav: gate Organizations/Invitations on multi-org; enforce `requiresService` server-side (ADR-0057 addendum D10). + +`rest-server`'s `filterAppForUser` now honours `NavigationItem.requiresService` — entries +whose named kernel service isn't registered are dropped from the served app metadata +(fail-open when the kernel can't be probed; previously the field was a frontend-only hint). +Applies `requiresService: 'org-scoping'` to the Setup app's Organizations and Invitations +entries, so they surface only in multi-org (multi-tenant) deployments and disappear in +single-tenant. Business Units is intentionally left ungated — it is open per the open/paid +seam + D12 ("pick people by BU"); only the hierarchy rollup capability is enterprise. diff --git a/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md b/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md index a66e97d391..08e486b029 100644 --- a/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md +++ b/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md @@ -512,3 +512,31 @@ One proof per surfacing decision, ratcheted with its PR: - **PS-2.** D10 — relocate `nav_business_units` to the hierarchy-security capability (ADR-0029 K2), retiring the inert `requiresObject` gate. - **PS-3.** D12 — `primary_business_unit_id` field + sync hook + backfill + picker proof. + +### PS-2 implementation note (2026-06-22) — D10 realized via server-enforced `requiresService` + +Implementing D10 refined the mechanism (no cross-repo relocation needed): + +- The Setup nav is filtered **server-side** (`rest-server` `filterAppForUser`), which + previously honoured only `requiredPermissions`. `requiresObject` is a **client-side** + (objectui) gate, not enforced in this repo. +- The spec already carries `NavigationItem.requiresService` (a kernel-service capability + gate). PS-2 gives it **server-side teeth**: gated entries are dropped from the served + payload (fail-open when the kernel can't be probed). Satisfies ADR-0049 (enforced, not + merely declared-for-frontend), in-repo and testable. +- **Organizations / Invitations** → `requiresService: 'org-scoping'`. `org-scoping` is the + canonical multi-org probe (SecurityPlugin already uses it), registered only in + multi-tenant mode → single-tenant hides both entries. +- **Business Units is deliberately NOT gated.** The only available signal would be + `hierarchy-security` (the **paid** rollup resolver), and gating on it would hide the + management UI for functionality that is **open**: BU as owning-unit, the explicit + `business_unit` sharing recipient, and the D12 "pick people by BU" projection. The + "ceiling" in D9 refers to the **hierarchy rollup capability**, not BU's data/management + surface. With Organizations hidden in single-tenant and the `kind='team'` collision + removed (D11), the residual community menu is Users + Teams + Business Units — two + distinct, legitimately co-present concepts, which was never the ambiguity this addendum + set out to remove. If hiding BU in vanilla deployments later proves desirable, gate it on + an explicit `business-units` opt-in, never on the paid resolver. + +Proof: a rest nav-serving test — single-tenant omits Organizations/Invitations; multi-org +shows them; Business Units present in both. diff --git a/packages/dogfood/test/setup-nav-capability-gate.dogfood.test.ts b/packages/dogfood/test/setup-nav-capability-gate.dogfood.test.ts new file mode 100644 index 0000000000..b679428060 --- /dev/null +++ b/packages/dogfood/test/setup-nav-capability-gate.dogfood.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0057 addendum D10 — Setup-nav surfacing follows capability. + * + * Organizations/Invitations are gated on the `org-scoping` (multi-org) kernel + * service via `NavigationItem.requiresService`, enforced server-side in + * rest-server's `filterAppForUser`. Business Units is intentionally NOT gated + * (open per the open/paid seam + D12). This proves the served Setup app + * metadata reflects that across single-tenant vs multi-org boots. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +async function servedAppMetaText(multiTenant: boolean): Promise { + const stack: VerifyStack = await bootStack(showcaseStack, { multiTenant }); + try { + const token = await stack.signIn(); + const res = await stack.apiAs(token, 'GET', '/meta/app'); + expect(res.status).toBe(200); + return JSON.stringify(await res.json()); + } finally { + await stack.stop(); + } +} + +describe('ADR-0057 D10: Setup nav surfacing follows capability', () => { + let single = ''; + let multi = ''; + + beforeAll(async () => { + single = await servedAppMetaText(false); + multi = await servedAppMetaText(true); + }, 180_000); + + it('hides Organizations/Invitations in single-tenant (no org-scoping service)', () => { + expect(single).not.toContain('nav_organizations'); + expect(single).not.toContain('nav_invitations'); + }); + + it('shows Organizations/Invitations when multi-org (org-scoping) is active', () => { + expect(multi).toContain('nav_organizations'); + expect(multi).toContain('nav_invitations'); + }); + + it('keeps Business Units visible in BOTH editions (open per seam + D12)', () => { + expect(single).toContain('nav_business_units'); + expect(multi).toContain('nav_business_units'); + }); +}); diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index 82e8e7b3d3..30366f4cfb 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -49,8 +49,8 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ { id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', icon: 'user' }, { id: 'nav_business_units', type: 'object', label: 'Business Units', objectName: 'sys_business_unit', icon: 'building', requiresObject: 'sys_business_unit' }, { id: 'nav_teams', type: 'object', label: 'Teams', objectName: 'sys_team', icon: 'users-round' }, - { id: 'nav_organizations', type: 'object', label: 'Organizations', objectName: 'sys_organization', icon: 'building-2' }, - { id: 'nav_invitations', type: 'object', label: 'Invitations', objectName: 'sys_invitation', icon: 'mail' }, + { id: 'nav_organizations', type: 'object', label: 'Organizations', objectName: 'sys_organization', icon: 'building-2', requiresService: 'org-scoping' }, + { id: 'nav_invitations', type: 'object', label: 'Invitations', objectName: 'sys_invitation', icon: 'mail', requiresService: 'org-scoping' }, ], }, { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index d448dc8240..71f0a732ed 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1076,6 +1076,10 @@ export class RestServer { ...(timezone ? { timezone } : {}), ...(locale ? { locale } : {}), ...(currency ? { currency } : {}), + // Internal: resolved kernel so the nav-serving path can probe + // requiresService capability gates (ADR-0057 D10). NOT an + // authorization input — never read by RLS/permission logic. + __kernel: kernel, } as any; } catch { return undefined; @@ -1095,7 +1099,7 @@ export class RestServer { * shallow copy with a filtered `navigation` tree otherwise — the original * is never mutated so cached metadata stays clean. */ - private filterAppForUser(item: any, sysPerms: Set): any | null { + private filterAppForUser(item: any, sysPerms: Set, serviceGate?: (name: string) => boolean): any | null { if (!item || typeof item !== 'object') return item; // ADR-0045: an unpublished app (`hidden: true`) is externally // unobservable — only builders (studio/setup access) receive it at all, @@ -1108,6 +1112,11 @@ export class RestServer { if (reqApp.length > 0 && !reqApp.every((p: string) => sysPerms.has(p))) { return null; } + // ADR-0057 D10 — capability gate: hide when the named kernel service is + // absent. Fail-open when the gate can't be probed (serviceGate undefined). + if (typeof item.requiresService === 'string' && serviceGate && serviceGate(item.requiresService) === false) { + return null; + } const nav = Array.isArray(item.navigation) ? item.navigation : null; if (!nav) return item; @@ -1117,6 +1126,7 @@ export class RestServer { if (!e || typeof e !== 'object') continue; const req = Array.isArray(e.requiredPermissions) ? e.requiredPermissions : []; if (req.length > 0 && !req.every((p: string) => sysPerms.has(p))) continue; + if (typeof e.requiresService === 'string' && serviceGate && serviceGate(e.requiresService) === false) continue; if (Array.isArray(e.children) && e.children.length > 0) { const kids = filterNav(e.children); // Drop empty groups so the sidebar doesn't render a label @@ -1133,6 +1143,33 @@ export class RestServer { return { ...item, navigation: filterNav(nav) }; } + /** + * Probe which `requiresService` capability gates referenced anywhere in + * `items` are actually registered in the runtime kernel. Returns `null` + * when the kernel can't be probed — callers then SKIP service gating + * (fail-open, matching the prior "send everything, let the client hide" + * behaviour). ADR-0057 addendum D10. + */ + private async resolveRegisteredServices(kernel: any, items: any[]): Promise | null> { + if (!kernel || typeof kernel.getServiceAsync !== 'function') return null; + const wanted = new Set(); + const walk = (e: any): void => { + if (!e || typeof e !== 'object') return; + if (typeof e.requiresService === 'string') wanted.add(e.requiresService); + const kids = Array.isArray(e.navigation) ? e.navigation + : Array.isArray(e.children) ? e.children : null; + if (kids) for (const k of kids) walk(k); + }; + for (const it of items) walk(it); + if (wanted.size === 0) return new Set(); + const registered = new Set(); + for (const name of wanted) { + try { if ((await kernel.getServiceAsync(name)) != null) registered.add(name); } + catch { /* service not registered -> leave out */ } + } + return registered; + } + /** * Build a `TranslationBundle` (`Record`) from an * `II18nService` instance. Returns `undefined` when no locales are @@ -1918,8 +1955,10 @@ export class RestServer { 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; const filtered = list - .map((it: any) => this.filterAppForUser(it, sysPerms)) + .map((it: any) => this.filterAppForUser(it, sysPerms, serviceGate)) .filter((it: any) => it != null); visible = Array.isArray(raw) ? filtered @@ -2239,7 +2278,9 @@ export class RestServer { const sysPerms = new Set( Array.isArray(ctx.systemPermissions) ? ctx.systemPermissions : [], ); - visible = this.filterAppForUser(item, sysPerms); + const registered = await this.resolveRegisteredServices((ctx as any).__kernel, [item]); + const serviceGate = registered ? (n: string) => registered.has(n) : undefined; + visible = this.filterAppForUser(item, sysPerms, serviceGate); if (visible == null) { res.status(404).json({ error: 'not_found', From e7247b7901d798942008210157200bce98585c18 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:23:27 +0800 Subject: [PATCH 2/3] fix(rest): make requiresService nav gate fire in single-kernel deployments (ADR-0057 D10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveExecCtx leaves kernel undefined on the single-kernel fallback path (no kernelManager), so ctx.__kernel was undefined → resolveRegisteredServices returned null → the gate failed OPEN (Organizations still shown in single-tenant). This is the primary OSS deployment mode (and the verify harness). Add a single-env serviceExistsProvider wired from the plugin's ctx.getService; resolveRegisteredServices now probes the per-request kernel (multi-env) OR that provider (single-env). Co-Authored-By: Claude Opus 4.8 --- packages/rest/src/rest-api-plugin.ts | 7 ++++++- packages/rest/src/rest-server.ts | 24 +++++++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index ac07b9942c..0b297d9d90 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -185,8 +185,13 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { ctx.logger.info('Hydrating REST API from Protocol...'); + // Single-env service-existence probe for nav capability gates + // (ADR-0057 D10). Multi-env uses the per-request kernel instead. + const serviceExistsProvider = (name: string): boolean => { + try { return ctx.getService(name) != null; } catch { return false; } + }; try { - const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider); + const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider); restServer.registerRoutes(); ctx.logger.info('REST API successfully registered'); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 71f0a732ed..2a042114d3 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -542,6 +542,10 @@ export class RestServer { private i18nServiceProvider?: (environmentId?: string) => Promise; private analyticsServiceProvider?: (environmentId?: string) => Promise; private settingsServiceProvider?: (environmentId?: string) => Promise; + /** Sync probe: is a kernel service registered? Single-env path for nav + * capability gates (ADR-0057 D10) — resolveExecCtx sets no kernel in + * single-kernel deployments, so this prevents the gate failing open. */ + private serviceExistsProvider?: (name: string) => boolean; constructor( server: IHttpServer, @@ -560,6 +564,7 @@ export class RestServer { i18nServiceProvider?: (environmentId?: string) => Promise, analyticsServiceProvider?: (environmentId?: string) => Promise, settingsServiceProvider?: (environmentId?: string) => Promise, + serviceExistsProvider?: (name: string) => boolean, ) { this.protocol = protocol; this.config = this.normalizeConfig(config); @@ -577,6 +582,7 @@ export class RestServer { this.i18nServiceProvider = i18nServiceProvider; this.analyticsServiceProvider = analyticsServiceProvider; this.settingsServiceProvider = settingsServiceProvider; + this.serviceExistsProvider = serviceExistsProvider; } /** @@ -1151,7 +1157,18 @@ export class RestServer { * behaviour). ADR-0057 addendum D10. */ private async resolveRegisteredServices(kernel: any, items: any[]): Promise | null> { - if (!kernel || typeof kernel.getServiceAsync !== 'function') return null; + // Prefer the per-request kernel (multi-env, resolved via kernelManager). + // Fall back to the single-env service-existence provider — in single-kernel + // deployments resolveExecCtx never sets a kernel, so without this the gate + // would fail open (ADR-0057 D10). + let probe: ((name: string) => Promise) | null = null; + if (kernel && typeof kernel.getServiceAsync === 'function') { + probe = async (name) => { try { return (await kernel.getServiceAsync(name)) != null; } catch { return false; } }; + } else if (this.serviceExistsProvider) { + const exists = this.serviceExistsProvider; + probe = async (name) => { try { return exists(name) === true; } catch { return false; } }; + } + if (!probe) return null; const wanted = new Set(); const walk = (e: any): void => { if (!e || typeof e !== 'object') return; @@ -1163,10 +1180,7 @@ export class RestServer { for (const it of items) walk(it); if (wanted.size === 0) return new Set(); const registered = new Set(); - for (const name of wanted) { - try { if ((await kernel.getServiceAsync(name)) != null) registered.add(name); } - catch { /* service not registered -> leave out */ } - } + for (const name of wanted) { if (await probe(name)) registered.add(name); } return registered; } From c243770e02443a433e36661b3455df65830ae26c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:37:15 +0800 Subject: [PATCH 3/3] test(rest): unit-test requiresService nav gate; drop infeasible e2e dogfood (ADR-0057 D10) The platform Setup app's navigation isn't materialized in the verify/showcase harness (GET /meta/app lists only the business app; /meta/app/setup returns a protection stub), so an end-to-end Setup-nav assertion can't see the gated entries. Replace it with direct filterAppForUser unit tests (mirroring the ADR-0045 block): requiresService entries drop when the gate reports the service absent, persist when present, fail-open with no gate, and requiresObject entries stay untouched; plus a resolveRegisteredServices probe test. Validated locally (5 passed). Update the ADR proof note accordingly. Co-Authored-By: Claude Opus 4.8 --- ...ion-core-business-units-and-scope-depth.md | 7 ++- .../setup-nav-capability-gate.dogfood.test.ts | 52 ------------------- packages/rest/src/rest.test.ts | 51 ++++++++++++++++++ 3 files changed, 56 insertions(+), 54 deletions(-) delete mode 100644 packages/dogfood/test/setup-nav-capability-gate.dogfood.test.ts diff --git a/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md b/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md index 08e486b029..8123a8f671 100644 --- a/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md +++ b/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md @@ -538,5 +538,8 @@ Implementing D10 refined the mechanism (no cross-repo relocation needed): set out to remove. If hiding BU in vanilla deployments later proves desirable, gate it on an explicit `business-units` opt-in, never on the paid resolver. -Proof: a rest nav-serving test — single-tenant omits Organizations/Invitations; multi-org -shows them; Business Units present in both. +Proof: `filterAppForUser` unit tests (rest.test.ts) — requiresService entries drop when the +gate reports the service absent, persist when present, fail-open with no gate, and +requiresObject entries are untouched. An end-to-end Setup-nav test is not feasible in the +verify harness: the platform Setup app's navigation is not materialized there (`/meta/app` +lists only the business app; `/meta/app/setup` returns a protection stub). diff --git a/packages/dogfood/test/setup-nav-capability-gate.dogfood.test.ts b/packages/dogfood/test/setup-nav-capability-gate.dogfood.test.ts deleted file mode 100644 index b679428060..0000000000 --- a/packages/dogfood/test/setup-nav-capability-gate.dogfood.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * ADR-0057 addendum D10 — Setup-nav surfacing follows capability. - * - * Organizations/Invitations are gated on the `org-scoping` (multi-org) kernel - * service via `NavigationItem.requiresService`, enforced server-side in - * rest-server's `filterAppForUser`. Business Units is intentionally NOT gated - * (open per the open/paid seam + D12). This proves the served Setup app - * metadata reflects that across single-tenant vs multi-org boots. - */ - -import { describe, it, expect, beforeAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; - -async function servedAppMetaText(multiTenant: boolean): Promise { - const stack: VerifyStack = await bootStack(showcaseStack, { multiTenant }); - try { - const token = await stack.signIn(); - const res = await stack.apiAs(token, 'GET', '/meta/app'); - expect(res.status).toBe(200); - return JSON.stringify(await res.json()); - } finally { - await stack.stop(); - } -} - -describe('ADR-0057 D10: Setup nav surfacing follows capability', () => { - let single = ''; - let multi = ''; - - beforeAll(async () => { - single = await servedAppMetaText(false); - multi = await servedAppMetaText(true); - }, 180_000); - - it('hides Organizations/Invitations in single-tenant (no org-scoping service)', () => { - expect(single).not.toContain('nav_organizations'); - expect(single).not.toContain('nav_invitations'); - }); - - it('shows Organizations/Invitations when multi-org (org-scoping) is active', () => { - expect(multi).toContain('nav_organizations'); - expect(multi).toContain('nav_invitations'); - }); - - it('keeps Business Units visible in BOTH editions (open per seam + D12)', () => { - expect(single).toContain('nav_business_units'); - expect(multi).toContain('nav_business_units'); - }); -}); diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 64ee22b61b..fdaaba2370 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1874,6 +1874,57 @@ describe('filterAppForUser — ADR-0045 hidden-app gate', () => { }); }); +// --------------------------------------------------------------------------- +// ADR-0057 D10 — requiresService capability gate (filterAppForUser) +// --------------------------------------------------------------------------- + +describe('filterAppForUser — ADR-0057 D10 requiresService gate', () => { + const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any); + const app = () => ({ + name: 'setup', + navigation: [ + { id: 'nav_users', type: 'object' }, + { id: 'nav_business_units', type: 'object', requiresObject: 'sys_business_unit' }, + { id: 'nav_organizations', type: 'object', requiresService: 'org-scoping' }, + { id: 'nav_invitations', type: 'object', requiresService: 'org-scoping' }, + ], + }); + 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'); + 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); + 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'); + }); + + it('the service gate does not touch requiresObject entries (client-side concern)', () => { + const rest: any = make(); + const out = rest.filterAppForUser(app(), new Set(), () => false); + expect(ids(out)).toContain('nav_business_units'); + expect(ids(out)).not.toContain('nav_organizations'); + }); + + 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()]); + expect(reg.has('org-scoping')).toBe(true); + expect(reg.size).toBe(1); + }); +}); + // --------------------------------------------------------------------------- // Object API exposure — enable.apiEnabled / enable.apiMethods (ADR-0049 #1889) // ---------------------------------------------------------------------------