From f170750908b73f233679cca4c0e01fdfadd5131e Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:33:52 +0800 Subject: [PATCH] fix(rest): unwrap getMetaItem envelope so the nav capability gate fires (ADR-0057 D10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /meta/app/:name returns an envelope { type, name, item: , ... }, but filterAppForUser was applied to the envelope (whose .navigation is undefined) → it returned untouched, silently bypassing BOTH the requiredPermissions gate and the D10 requiresService gate. So Organizations/Invitations still showed in the Setup app even single-tenant. filterAppForUser + resolveRegisteredServices now unwrap the envelope (the list path already passed the raw app). Verified live (os dev): single-tenant hides Organizations/Invitations, multi-tenant shows them; +regression unit test. Co-Authored-By: Claude Opus 4.8 --- .changeset/fix-d10-envelope-unwrap.md | 13 +++++++++++++ packages/rest/src/rest-server.ts | 10 ++++++++++ packages/rest/src/rest.test.ts | 14 ++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 .changeset/fix-d10-envelope-unwrap.md 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']); + }); }); // ---------------------------------------------------------------------------