diff --git a/.changeset/fix-d10-envelope-unwrap.md b/.changeset/fix-d10-envelope-unwrap.md new file mode 100644 index 0000000000..df2fa8d1da --- /dev/null +++ b/.changeset/fix-d10-envelope-unwrap.md @@ -0,0 +1,13 @@ +--- +"@objectstack/rest": patch +--- + +Fix: the Setup-nav capability gate (`requiresService`, ADR-0057 D10) was a no-op on the single-item app-meta path. + +`GET /meta/app/:name` returns a metadata envelope `{ type, name, item: , ... }`, but +`filterAppForUser` was applied to the envelope — whose `.navigation` is undefined — so it +returned it untouched, silently bypassing BOTH the `requiredPermissions` gate and the D10 +`requiresService` gate. Organizations/Invitations therefore still appeared in the Setup app +even in single-tenant deployments. `filterAppForUser` and `resolveRegisteredServices` now +unwrap the envelope (the list path already passed the raw app). Verified against a live +`os dev`: single-tenant hides Organizations/Invitations; multi-tenant shows them. diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 2a042114d3..dd2d9aecc0 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1107,6 +1107,15 @@ export class RestServer { */ private filterAppForUser(item: any, sysPerms: Set, serviceGate?: (name: string) => boolean): any | null { if (!item || typeof item !== 'object') return item; + // getMetaItem returns an envelope { type, name, item: , ... } while the + // list path passes the raw app. Unwrap + re-wrap so gating runs on both — + // filtering the envelope directly is a silent no-op (its .navigation is + // undefined), which would bypass BOTH requiredPermissions and the ADR-0057 + // D10 requiresService gate. + if (isMetaEnvelope(item)) { + const body = this.filterAppForUser((item as any).item, sysPerms, serviceGate); + return body == null ? null : { ...(item as any), item: body }; + } // ADR-0045: an unpublished app (`hidden: true`) is externally // unobservable — only builders (studio/setup access) receive it at all, // for direct-URL preview. The launcher's client-side hidden filter is a @@ -1172,6 +1181,7 @@ export class RestServer { const wanted = new Set(); const walk = (e: any): void => { if (!e || typeof e !== 'object') return; + if (isMetaEnvelope(e)) { walk((e as any).item); 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; diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index fdaaba2370..a66a73a32f 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1923,6 +1923,20 @@ describe('filterAppForUser — ADR-0057 D10 requiresService gate', () => { expect(reg.has('org-scoping')).toBe(true); expect(reg.size).toBe(1); }); + + it('unwraps the getMetaItem envelope and gates the inner app (regression)', () => { + const rest: any = make(); + const envelope = { + type: 'app', name: 'setup', lock: 'none', + item: { name: 'setup', navigation: [ + { id: 'nav_users', type: 'object' }, + { id: 'nav_organizations', type: 'object', requiresService: 'org-scoping' }, + ] }, + }; + const out = rest.filterAppForUser(envelope, new Set(), (n: string) => n !== 'org-scoping'); + expect(out.type).toBe('app'); + expect((out.item.navigation as any[]).map((e: any) => e.id)).toEqual(['nav_users']); + }); }); // ---------------------------------------------------------------------------