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
7 changes: 7 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,13 @@ export class RestServer {
*/
private filterAppForUser(item: any, sysPerms: Set<string>): 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;
Expand Down
36 changes: 36 additions & 0 deletions packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>())).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<string>())?.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');
});
});
53 changes: 53 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

// ═══════════════════════════════════════════════════════════════
Expand Down
40 changes: 40 additions & 0 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
Expand Down