diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 8b67aa8e5e..4c292cd251 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -975,6 +975,13 @@ export class RestServer { */ private filterAppForUser(item: any, sysPerms: Set): any | null { if (!item || typeof item !== 'object') return item; + // 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 + // listing courtesy; THIS is the visibility gate. + if (item.hidden === true && !sysPerms.has('studio.access') && !sysPerms.has('setup.access')) { + return null; + } const reqApp = Array.isArray(item.requiredPermissions) ? item.requiredPermissions : []; if (reqApp.length > 0 && !reqApp.every((p: string) => sysPerms.has(p))) { return null; diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index b02fb6f950..e5279e48bc 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1741,3 +1741,39 @@ describe('RestServer metadata translation — envelope unwrap', () => { expect(out[0].navigation[0].children[0].label).toBe('文件存储'); }); }); + +// --------------------------------------------------------------------------- +// ADR-0045 — hidden-app visibility gate (filterAppForUser) +// --------------------------------------------------------------------------- + +describe('filterAppForUser — ADR-0045 hidden-app gate', () => { + const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any); + const hiddenApp = { name: 'production_management', hidden: true, navigation: [] }; + const visibleApp = { name: 'crm', navigation: [] }; + + it('drops a hidden app for users without builder access', () => { + const rest: any = make(); + expect(rest.filterAppForUser(hiddenApp, new Set())).toBeNull(); + expect(rest.filterAppForUser(hiddenApp, new Set(['manage_users']))).toBeNull(); + }); + + it('returns a hidden app to builders (studio.access or setup.access)', () => { + const rest: any = make(); + expect(rest.filterAppForUser(hiddenApp, new Set(['studio.access']))?.name).toBe('production_management'); + expect(rest.filterAppForUser(hiddenApp, new Set(['setup.access']))?.name).toBe('production_management'); + }); + + it('leaves visible apps untouched for everyone', () => { + const rest: any = make(); + expect(rest.filterAppForUser(visibleApp, new Set())?.name).toBe('crm'); + }); + + it('still applies requiredPermissions to hidden apps builders can see', () => { + const rest: any = make(); + const gated = { ...hiddenApp, requiredPermissions: ['manage_platform_settings'] }; + expect(rest.filterAppForUser(gated, new Set(['studio.access']))).toBeNull(); + expect( + rest.filterAppForUser(gated, new Set(['studio.access', 'manage_platform_settings']))?.name, + ).toBe('production_management'); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index c9d7f58f5c..30819df9a8 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -1022,6 +1022,59 @@ describe('HttpDispatcher', () => { expect(insert).toHaveBeenCalledTimes(2); expect(insert).toHaveBeenCalledWith('project', expect.objectContaining({ name: 'Apollo' }), expect.anything()); }); + + // ADR-0045: "Publish" = live AND visible. A materialized (additive) + // build leaves its app at hidden:true; publish-drafts must flip it so + // one publish verb serves both the draft and the materialize regimes. + it('POST /packages/:id/publish-drafts unhides the package\'s hidden app', async () => { + const publishPackageDrafts = vi.fn().mockResolvedValue({ + success: true, publishedCount: 0, failedCount: 0, published: [], failed: [], seedApplied: { success: true }, + }); + const getMetaItems = vi.fn().mockResolvedValue([ + { name: 'production_management', label: '生产管理', hidden: true, navigation: [] }, + { name: 'already_visible', hidden: false, navigation: [] }, + ]); + const saveMetaItem = vi.fn().mockResolvedValue({ ok: true }); + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({ publishPackageDrafts, getMetaItems, saveMetaItem }); + if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); + return null; + }); + + const result = await dispatcher.handlePackages('/app.production_management/publish-drafts', 'POST', {}, {}, { request: {} }); + + expect(result.response?.status).toBe(200); + expect(getMetaItems).toHaveBeenCalledWith(expect.objectContaining({ type: 'app', packageId: 'app.production_management' })); + // Only the hidden app is re-saved, with hidden:false and everything else intact. + expect(saveMetaItem).toHaveBeenCalledTimes(1); + expect(saveMetaItem).toHaveBeenCalledWith(expect.objectContaining({ + type: 'app', + name: 'production_management', + item: expect.objectContaining({ hidden: false, label: '生产管理' }), + packageId: 'app.production_management', + })); + expect((result.response as any)?.body?.data?.unhiddenApps).toEqual(['production_management']); + }); + + it('POST /packages/:id/publish-drafts reports (not throws) when the visibility flip fails', async () => { + const publishPackageDrafts = vi.fn().mockResolvedValue({ + success: true, publishedCount: 1, failedCount: 0, published: [], failed: [], seedApplied: { success: true }, + }); + const getMetaItems = vi.fn().mockRejectedValue(new Error('meta backend down')); + const saveMetaItem = vi.fn(); + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({ publishPackageDrafts, getMetaItems, saveMetaItem }); + if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); + return null; + }); + + const result = await dispatcher.handlePackages('/app.edu/publish-drafts', 'POST', {}, {}, { request: {} }); + + // The draft publish itself succeeded — the flip failure is surfaced, not fatal. + expect(result.response?.status).toBe(200); + expect((result.response as any)?.body?.data?.unhideError).toBe('meta backend down'); + expect(saveMetaItem).not.toHaveBeenCalled(); + }); }); // ═══════════════════════════════════════════════════════════════ diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 71a7d17cbc..ea406c4380 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1707,6 +1707,46 @@ export class HttpDispatcher { (result as any).seedApplied = { success: false, error: e?.message ?? 'seed apply failed' }; } } + // ADR-0045: "Publish" makes the package live AND visible. + // A materialized (additive) build has no drafts left to + // promote — its app sits at `hidden: true` awaiting the + // visibility flip. Unhide every hidden app bound to this + // package so ONE publish verb serves both regimes (the + // caller never needs to know how the package was built). + // Best-effort: a custom protocol without the meta + // primitives keeps plain draft-publish semantics. + try { + if ( + typeof (protocol as any).getMetaItems === 'function' && + typeof (protocol as any).saveMetaItem === 'function' + ) { + const appsRes = await (protocol as any).getMetaItems({ + type: 'app', + packageId: id, + ...(organizationId ? { organizationId } : {}), + }); + const apps: any[] = Array.isArray(appsRes) + ? appsRes + : Array.isArray((appsRes as any)?.items) ? (appsRes as any).items : []; + const unhidden: string[] = []; + for (const app of apps) { + if (app && typeof app === 'object' && app.hidden === true && typeof app.name === 'string') { + await (protocol as any).saveMetaItem({ + type: 'app', + name: app.name, + item: { ...app, hidden: false }, + packageId: id, + ...(organizationId ? { organizationId } : {}), + ...(body?.actor ? { actor: body.actor } : {}), + }); + unhidden.push(app.name); + } + } + if (unhidden.length > 0) (result as any).unhiddenApps = unhidden; + } + } catch (e: any) { + (result as any).unhideError = e?.message ?? 'visibility flip failed'; + } return { handled: true, response: this.success(result) }; } catch (e: any) { return { handled: true, response: this.error(e.message, e.statusCode || 500) };