Skip to content

Commit 6482fd5

Browse files
os-zhuangclaude
andauthored
feat(adr-0045): hidden-app visibility gate + publish unhides materialized builds (#1768)
v1 framework slice of ADR-0045 (additive materialization): - rest-server filterAppForUser: hidden:true apps are dropped for users without studio.access/setup.access — the server-side visibility gate (the launcher's client filter is a listing courtesy, this is the law) - publish-drafts: after promoting drafts (and seeds), unhide every hidden app bound to the package — ONE publish verb serves both the draft and the materialize regimes; callers never need to know how a package was built. Best-effort with explicit unhideError reporting. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent fa65285 commit 6482fd5

4 files changed

Lines changed: 136 additions & 0 deletions

File tree

packages/rest/src/rest-server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,13 @@ export class RestServer {
975975
*/
976976
private filterAppForUser(item: any, sysPerms: Set<string>): any | null {
977977
if (!item || typeof item !== 'object') return item;
978+
// ADR-0045: an unpublished app (`hidden: true`) is externally
979+
// unobservable — only builders (studio/setup access) receive it at all,
980+
// for direct-URL preview. The launcher's client-side hidden filter is a
981+
// listing courtesy; THIS is the visibility gate.
982+
if (item.hidden === true && !sysPerms.has('studio.access') && !sysPerms.has('setup.access')) {
983+
return null;
984+
}
978985
const reqApp = Array.isArray(item.requiredPermissions) ? item.requiredPermissions : [];
979986
if (reqApp.length > 0 && !reqApp.every((p: string) => sysPerms.has(p))) {
980987
return null;

packages/rest/src/rest.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1741,3 +1741,39 @@ describe('RestServer metadata translation — envelope unwrap', () => {
17411741
expect(out[0].navigation[0].children[0].label).toBe('文件存储');
17421742
});
17431743
});
1744+
1745+
// ---------------------------------------------------------------------------
1746+
// ADR-0045 — hidden-app visibility gate (filterAppForUser)
1747+
// ---------------------------------------------------------------------------
1748+
1749+
describe('filterAppForUser — ADR-0045 hidden-app gate', () => {
1750+
const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any);
1751+
const hiddenApp = { name: 'production_management', hidden: true, navigation: [] };
1752+
const visibleApp = { name: 'crm', navigation: [] };
1753+
1754+
it('drops a hidden app for users without builder access', () => {
1755+
const rest: any = make();
1756+
expect(rest.filterAppForUser(hiddenApp, new Set<string>())).toBeNull();
1757+
expect(rest.filterAppForUser(hiddenApp, new Set(['manage_users']))).toBeNull();
1758+
});
1759+
1760+
it('returns a hidden app to builders (studio.access or setup.access)', () => {
1761+
const rest: any = make();
1762+
expect(rest.filterAppForUser(hiddenApp, new Set(['studio.access']))?.name).toBe('production_management');
1763+
expect(rest.filterAppForUser(hiddenApp, new Set(['setup.access']))?.name).toBe('production_management');
1764+
});
1765+
1766+
it('leaves visible apps untouched for everyone', () => {
1767+
const rest: any = make();
1768+
expect(rest.filterAppForUser(visibleApp, new Set<string>())?.name).toBe('crm');
1769+
});
1770+
1771+
it('still applies requiredPermissions to hidden apps builders can see', () => {
1772+
const rest: any = make();
1773+
const gated = { ...hiddenApp, requiredPermissions: ['manage_platform_settings'] };
1774+
expect(rest.filterAppForUser(gated, new Set(['studio.access']))).toBeNull();
1775+
expect(
1776+
rest.filterAppForUser(gated, new Set(['studio.access', 'manage_platform_settings']))?.name,
1777+
).toBe('production_management');
1778+
});
1779+
});

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,59 @@ describe('HttpDispatcher', () => {
10221022
expect(insert).toHaveBeenCalledTimes(2);
10231023
expect(insert).toHaveBeenCalledWith('project', expect.objectContaining({ name: 'Apollo' }), expect.anything());
10241024
});
1025+
1026+
// ADR-0045: "Publish" = live AND visible. A materialized (additive)
1027+
// build leaves its app at hidden:true; publish-drafts must flip it so
1028+
// one publish verb serves both the draft and the materialize regimes.
1029+
it('POST /packages/:id/publish-drafts unhides the package\'s hidden app', async () => {
1030+
const publishPackageDrafts = vi.fn().mockResolvedValue({
1031+
success: true, publishedCount: 0, failedCount: 0, published: [], failed: [], seedApplied: { success: true },
1032+
});
1033+
const getMetaItems = vi.fn().mockResolvedValue([
1034+
{ name: 'production_management', label: '生产管理', hidden: true, navigation: [] },
1035+
{ name: 'already_visible', hidden: false, navigation: [] },
1036+
]);
1037+
const saveMetaItem = vi.fn().mockResolvedValue({ ok: true });
1038+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1039+
if (name === 'protocol') return Promise.resolve({ publishPackageDrafts, getMetaItems, saveMetaItem });
1040+
if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } });
1041+
return null;
1042+
});
1043+
1044+
const result = await dispatcher.handlePackages('/app.production_management/publish-drafts', 'POST', {}, {}, { request: {} });
1045+
1046+
expect(result.response?.status).toBe(200);
1047+
expect(getMetaItems).toHaveBeenCalledWith(expect.objectContaining({ type: 'app', packageId: 'app.production_management' }));
1048+
// Only the hidden app is re-saved, with hidden:false and everything else intact.
1049+
expect(saveMetaItem).toHaveBeenCalledTimes(1);
1050+
expect(saveMetaItem).toHaveBeenCalledWith(expect.objectContaining({
1051+
type: 'app',
1052+
name: 'production_management',
1053+
item: expect.objectContaining({ hidden: false, label: '生产管理' }),
1054+
packageId: 'app.production_management',
1055+
}));
1056+
expect((result.response as any)?.body?.data?.unhiddenApps).toEqual(['production_management']);
1057+
});
1058+
1059+
it('POST /packages/:id/publish-drafts reports (not throws) when the visibility flip fails', async () => {
1060+
const publishPackageDrafts = vi.fn().mockResolvedValue({
1061+
success: true, publishedCount: 1, failedCount: 0, published: [], failed: [], seedApplied: { success: true },
1062+
});
1063+
const getMetaItems = vi.fn().mockRejectedValue(new Error('meta backend down'));
1064+
const saveMetaItem = vi.fn();
1065+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1066+
if (name === 'protocol') return Promise.resolve({ publishPackageDrafts, getMetaItems, saveMetaItem });
1067+
if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } });
1068+
return null;
1069+
});
1070+
1071+
const result = await dispatcher.handlePackages('/app.edu/publish-drafts', 'POST', {}, {}, { request: {} });
1072+
1073+
// The draft publish itself succeeded — the flip failure is surfaced, not fatal.
1074+
expect(result.response?.status).toBe(200);
1075+
expect((result.response as any)?.body?.data?.unhideError).toBe('meta backend down');
1076+
expect(saveMetaItem).not.toHaveBeenCalled();
1077+
});
10251078
});
10261079

10271080
// ═══════════════════════════════════════════════════════════════

packages/runtime/src/http-dispatcher.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1707,6 +1707,46 @@ export class HttpDispatcher {
17071707
(result as any).seedApplied = { success: false, error: e?.message ?? 'seed apply failed' };
17081708
}
17091709
}
1710+
// ADR-0045: "Publish" makes the package live AND visible.
1711+
// A materialized (additive) build has no drafts left to
1712+
// promote — its app sits at `hidden: true` awaiting the
1713+
// visibility flip. Unhide every hidden app bound to this
1714+
// package so ONE publish verb serves both regimes (the
1715+
// caller never needs to know how the package was built).
1716+
// Best-effort: a custom protocol without the meta
1717+
// primitives keeps plain draft-publish semantics.
1718+
try {
1719+
if (
1720+
typeof (protocol as any).getMetaItems === 'function' &&
1721+
typeof (protocol as any).saveMetaItem === 'function'
1722+
) {
1723+
const appsRes = await (protocol as any).getMetaItems({
1724+
type: 'app',
1725+
packageId: id,
1726+
...(organizationId ? { organizationId } : {}),
1727+
});
1728+
const apps: any[] = Array.isArray(appsRes)
1729+
? appsRes
1730+
: Array.isArray((appsRes as any)?.items) ? (appsRes as any).items : [];
1731+
const unhidden: string[] = [];
1732+
for (const app of apps) {
1733+
if (app && typeof app === 'object' && app.hidden === true && typeof app.name === 'string') {
1734+
await (protocol as any).saveMetaItem({
1735+
type: 'app',
1736+
name: app.name,
1737+
item: { ...app, hidden: false },
1738+
packageId: id,
1739+
...(organizationId ? { organizationId } : {}),
1740+
...(body?.actor ? { actor: body.actor } : {}),
1741+
});
1742+
unhidden.push(app.name);
1743+
}
1744+
}
1745+
if (unhidden.length > 0) (result as any).unhiddenApps = unhidden;
1746+
}
1747+
} catch (e: any) {
1748+
(result as any).unhideError = e?.message ?? 'visibility flip failed';
1749+
}
17101750
return { handled: true, response: this.success(result) };
17111751
} catch (e: any) {
17121752
return { handled: true, response: this.error(e.message, e.statusCode || 500) };

0 commit comments

Comments
 (0)