Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/fix-d10-envelope-unwrap.md
Original file line number Diff line number Diff line change
@@ -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: <app>, ... }`, 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.
10 changes: 10 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1107,6 +1107,15 @@ export class RestServer {
*/
private filterAppForUser(item: any, sysPerms: Set<string>, serviceGate?: (name: string) => boolean): any | null {
if (!item || typeof item !== 'object') return item;
// getMetaItem returns an envelope { type, name, item: <app>, ... } 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
Expand Down Expand Up @@ -1172,6 +1181,7 @@ export class RestServer {
const wanted = new Set<string>();
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;
Expand Down
14 changes: 14 additions & 0 deletions packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(), (n: string) => n !== 'org-scoping');
expect(out.type).toBe('app');
expect((out.item.navigation as any[]).map((e: any) => e.id)).toEqual(['nav_users']);
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading