Skip to content

Commit 3754f80

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(rest): unwrap getMetaItem envelope so the nav capability gate fires (ADR-0057 D10) (#2166)
GET /meta/app/:name returns an envelope { type, name, item: <app>, ... }, 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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 163fbd0 commit 3754f80

3 files changed

Lines changed: 37 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
Fix: the Setup-nav capability gate (`requiresService`, ADR-0057 D10) was a no-op on the single-item app-meta path.
6+
7+
`GET /meta/app/:name` returns a metadata envelope `{ type, name, item: <app>, ... }`, but
8+
`filterAppForUser` was applied to the envelope — whose `.navigation` is undefined — so it
9+
returned it untouched, silently bypassing BOTH the `requiredPermissions` gate and the D10
10+
`requiresService` gate. Organizations/Invitations therefore still appeared in the Setup app
11+
even in single-tenant deployments. `filterAppForUser` and `resolveRegisteredServices` now
12+
unwrap the envelope (the list path already passed the raw app). Verified against a live
13+
`os dev`: single-tenant hides Organizations/Invitations; multi-tenant shows them.

packages/rest/src/rest-server.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,6 +1107,15 @@ export class RestServer {
11071107
*/
11081108
private filterAppForUser(item: any, sysPerms: Set<string>, serviceGate?: (name: string) => boolean): any | null {
11091109
if (!item || typeof item !== 'object') return item;
1110+
// getMetaItem returns an envelope { type, name, item: <app>, ... } while the
1111+
// list path passes the raw app. Unwrap + re-wrap so gating runs on both —
1112+
// filtering the envelope directly is a silent no-op (its .navigation is
1113+
// undefined), which would bypass BOTH requiredPermissions and the ADR-0057
1114+
// D10 requiresService gate.
1115+
if (isMetaEnvelope(item)) {
1116+
const body = this.filterAppForUser((item as any).item, sysPerms, serviceGate);
1117+
return body == null ? null : { ...(item as any), item: body };
1118+
}
11101119
// ADR-0045: an unpublished app (`hidden: true`) is externally
11111120
// unobservable — only builders (studio/setup access) receive it at all,
11121121
// for direct-URL preview. The launcher's client-side hidden filter is a
@@ -1172,6 +1181,7 @@ export class RestServer {
11721181
const wanted = new Set<string>();
11731182
const walk = (e: any): void => {
11741183
if (!e || typeof e !== 'object') return;
1184+
if (isMetaEnvelope(e)) { walk((e as any).item); return; }
11751185
if (typeof e.requiresService === 'string') wanted.add(e.requiresService);
11761186
const kids = Array.isArray(e.navigation) ? e.navigation
11771187
: Array.isArray(e.children) ? e.children : null;

packages/rest/src/rest.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1923,6 +1923,20 @@ describe('filterAppForUser — ADR-0057 D10 requiresService gate', () => {
19231923
expect(reg.has('org-scoping')).toBe(true);
19241924
expect(reg.size).toBe(1);
19251925
});
1926+
1927+
it('unwraps the getMetaItem envelope and gates the inner app (regression)', () => {
1928+
const rest: any = make();
1929+
const envelope = {
1930+
type: 'app', name: 'setup', lock: 'none',
1931+
item: { name: 'setup', navigation: [
1932+
{ id: 'nav_users', type: 'object' },
1933+
{ id: 'nav_organizations', type: 'object', requiresService: 'org-scoping' },
1934+
] },
1935+
};
1936+
const out = rest.filterAppForUser(envelope, new Set<string>(), (n: string) => n !== 'org-scoping');
1937+
expect(out.type).toBe('app');
1938+
expect((out.item.navigation as any[]).map((e: any) => e.id)).toEqual(['nav_users']);
1939+
});
19261940
});
19271941

19281942
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)