From 71121853b99f54e62ab919a6dbe84d4bfff27248 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 03:57:27 +0000 Subject: [PATCH 1/2] feat(spec,objectql,platform-objects): ADR-0029 D7 setup app navigation contributions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the UI-layer analog of object own/extend so a shared admin app can be a thin shell while each capability plugin ships the menu for objects it owns. - spec: NavigationContributionSchema { app, group?, priority, items } + optional manifest.navigationContributions. - objectql: SchemaRegistry.registerAppNavContribution() + lazy merge in getApp/getAllApps (by group id + priority, clones the stored app so reads are idempotent); engine wires manifest.navigationContributions on app registration. - platform-objects: Setup app reduced to a shell of empty group anchors; its platform-objects-owned entries moved to SETUP_NAV_CONTRIBUTIONS. - plugin-auth: registers SETUP_NAV_CONTRIBUTIONS alongside the Setup app. - plugin-webhooks: contributes Webhooks/Webhook Deliveries into the Setup group_integrations slot (owns those objects per K2.a) — end-to-end cross-plugin contribution. Rendered Setup nav is unchanged; absent capabilities leave their slot empty. Tests: spec 128, objectql 61 (incl 7 new D7), platform-objects 78 (incl shell + contributions), plugin-webhooks 45, plugin-auth 85 — all green; turbo build (10 packages incl DTS type-check) green. https://claude.ai/code/session_01Tv6F1Ub6bhCedrx3r8sZM4 --- .../adr-0029-d7-setup-nav-contributions.md | 36 ++++ packages/objectql/src/engine.ts | 14 ++ .../src/registry-nav-contributions.test.ts | 106 ++++++++++ packages/objectql/src/registry.ts | 101 ++++++++- packages/platform-objects/src/apps/index.ts | 1 + .../src/apps/setup-nav.contributions.ts | 115 +++++++++++ .../platform-objects/src/apps/setup.app.ts | 193 +++--------------- .../src/platform-objects.test.ts | 53 +++-- .../plugins/plugin-auth/src/auth-plugin.ts | 5 + .../src/webhook-outbox-plugin.ts | 15 ++ packages/spec/src/kernel/manifest.zod.ts | 12 ++ packages/spec/src/ui/app.zod.ts | 34 +++ 12 files changed, 506 insertions(+), 179 deletions(-) create mode 100644 .changeset/adr-0029-d7-setup-nav-contributions.md create mode 100644 packages/objectql/src/registry-nav-contributions.test.ts create mode 100644 packages/platform-objects/src/apps/setup-nav.contributions.ts diff --git a/.changeset/adr-0029-d7-setup-nav-contributions.md b/.changeset/adr-0029-d7-setup-nav-contributions.md new file mode 100644 index 000000000..f54683475 --- /dev/null +++ b/.changeset/adr-0029-d7-setup-nav-contributions.md @@ -0,0 +1,36 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +"@objectstack/platform-objects": minor +"@objectstack/plugin-auth": minor +"@objectstack/plugin-webhooks": minor +--- + +ADR-0029 D7 — Setup app navigation contributions. + +Adds the UI-layer analog of object `own`/`extend`: a package can contribute +navigation items into an app it does not own, so a shared admin app can be a +thin shell while each capability plugin ships the menu for the objects it owns. + +- **`@objectstack/spec`** — new `NavigationContributionSchema` (`{ app, group?, + priority, items }`) and an optional `navigationContributions` field on the + manifest. +- **`@objectstack/objectql`** — `SchemaRegistry.registerAppNavContribution()` + plus lazy merge in `getApp` / `getAllApps` (by target group id + priority, + cloning so the stored app is never mutated); the engine wires + `manifest.navigationContributions` during app registration. +- **`@objectstack/platform-objects`** — the Setup app becomes a **shell** of + empty group anchors; its entries for platform-objects-owned objects move to + `SETUP_NAV_CONTRIBUTIONS`. +- **`@objectstack/plugin-auth`** — registers `SETUP_NAV_CONTRIBUTIONS` alongside + the Setup app it already registers. +- **`@objectstack/plugin-webhooks`** — contributes its `Webhooks` / + `Webhook Deliveries` entries into the Setup `group_integrations` slot (it owns + `sys_webhook` / `sys_webhook_delivery` per K2.a), demonstrating end-to-end + cross-plugin contribution. + +The rendered Setup nav is identical to the former static artifact — just +assembled from its owners. A disabled/absent capability contributes nothing and +its slot stays empty (in addition to the existing `requiresObject` gating). +This unblocks moving each remaining K2 domain's menu out of the monolith with +its objects. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index cff8b98a0..eb4a6f56e 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -802,6 +802,20 @@ export class ObjectQL implements IDataEngine { this.logger.debug('Registered manifest-as-app', { app: manifest.name, from: id }); } + // 4b. Register navigation contributions (ADR-0029 D7) — nav items this + // package injects into apps owned by other packages (e.g. a + // capability plugin adding its menu into the `setup` app). Merged + // into the target app's navigation on read by group id + priority. + if (Array.isArray((manifest as any).navigationContributions) && (manifest as any).navigationContributions.length > 0) { + for (const contribution of (manifest as any).navigationContributions) { + this._registry.registerAppNavContribution(contribution, id); + } + this.logger.debug('Registered navigation contributions', { + from: id, + count: (manifest as any).navigationContributions.length, + }); + } + // 5. Register all other metadata types generically const metadataArrayKeys = [ // UI Protocol diff --git a/packages/objectql/src/registry-nav-contributions.test.ts b/packages/objectql/src/registry-nav-contributions.test.ts new file mode 100644 index 000000000..e841b319a --- /dev/null +++ b/packages/objectql/src/registry-nav-contributions.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { SchemaRegistry } from './registry'; + +/** + * ADR-0029 D7 — app navigation contributions. + * + * A "shell" app exposes empty group anchors; packages contribute their nav + * entries into those groups, merged on read by group id + priority. This is + * the UI-layer analog of object `own`/`extend`. + */ +describe('SchemaRegistry navigation contributions (ADR-0029 D7)', () => { + let registry: SchemaRegistry; + + const shellApp = () => ({ + name: 'setup', + label: 'Setup', + navigation: [ + { id: 'group_people_org', type: 'group', label: 'People & Organization', children: [] }, + { id: 'group_integrations', type: 'group', label: 'Integrations', children: [] }, + ], + }); + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false }); + registry.registerApp(shellApp(), 'com.objectstack.platform-objects'); + }); + + it('merges a contribution into the targeted group', () => { + registry.registerAppNavContribution( + { + app: 'setup', + group: 'group_integrations', + priority: 100, + items: [ + { id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', requiresObject: 'sys_webhook' }, + ], + }, + 'com.objectstack.plugin-webhook-outbox.schema', + ); + + const app = registry.getApp('setup'); + const group = app.navigation.find((n: any) => n.id === 'group_integrations'); + expect(group.children).toHaveLength(1); + expect(group.children[0].objectName).toBe('sys_webhook'); + // Untargeted group stays empty. + const other = app.navigation.find((n: any) => n.id === 'group_people_org'); + expect(other.children).toHaveLength(0); + }); + + it('orders contributions to the same group by ascending priority', () => { + registry.registerAppNavContribution( + { app: 'setup', group: 'group_people_org', priority: 200, items: [{ id: 'nav_b', type: 'object', label: 'B', objectName: 'sys_b' }] }, + 'pkg.late', + ); + registry.registerAppNavContribution( + { app: 'setup', group: 'group_people_org', priority: 100, items: [{ id: 'nav_a', type: 'object', label: 'A', objectName: 'sys_a' }] }, + 'pkg.early', + ); + + const app = registry.getApp('setup'); + const group = app.navigation.find((n: any) => n.id === 'group_people_org'); + expect(group.children.map((c: any) => c.id)).toEqual(['nav_a', 'nav_b']); + }); + + it('appends at the top level when the target group is missing', () => { + registry.registerAppNavContribution( + { app: 'setup', group: 'group_does_not_exist', priority: 100, items: [{ id: 'nav_orphan', type: 'url', label: 'Orphan', url: '/x' }] }, + 'pkg.orphan', + ); + const app = registry.getApp('setup'); + expect(app.navigation.some((n: any) => n.id === 'nav_orphan')).toBe(true); + }); + + it('does not mutate the stored app — reads are idempotent', () => { + registry.registerAppNavContribution( + { app: 'setup', group: 'group_integrations', priority: 100, items: [{ id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook' }] }, + 'pkg.webhooks', + ); + const first = registry.getApp('setup'); + const second = registry.getApp('setup'); + const firstCount = first.navigation.find((n: any) => n.id === 'group_integrations').children.length; + const secondCount = second.navigation.find((n: any) => n.id === 'group_integrations').children.length; + expect(firstCount).toBe(1); + expect(secondCount).toBe(1); // not 2 — contributions are not appended cumulatively + }); + + it('returns the un-merged app when there are no contributions', () => { + const app = registry.getApp('setup'); + for (const group of app.navigation) { + expect(group.children).toHaveLength(0); + } + }); + + it('applies contributions through getAllApps too', () => { + registry.registerAppNavContribution( + { app: 'setup', group: 'group_integrations', priority: 100, items: [{ id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook' }] }, + 'pkg.webhooks', + ); + const apps = registry.getAllApps(); + const setup = apps.find((a: any) => a.name === 'setup'); + const group = setup.navigation.find((n: any) => n.id === 'group_integrations'); + expect(group.children).toHaveLength(1); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 98da27715..7c0db4cf0 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -317,6 +317,15 @@ export class SchemaRegistry { /** Type → Name/ID → MetadataItem */ private metadata = new Map>(); + /** + * App name → navigation contributions (ADR-0029 D7). + * + * Lets packages inject nav items into apps they do not own (the UI analog + * of object extenders). Merged into the owning app's `navigation` tree on + * read in {@link getApp} / {@link getAllApps} by group id + priority. + */ + private appNavContributions = new Map>(); + /** * Package ids that must be installed in a DISABLED state. Seeded once at * boot (from persisted state) BEFORE any package registration so that every @@ -952,11 +961,98 @@ export class SchemaRegistry { } getApp(name: string): any { - return this.getItem('app', name); + const app = this.getItem('app', name); + if (!app) return app; + return this.applyNavContributions(app); } getAllApps(): any[] { - return this.listItems('app'); + return this.listItems('app').map((app: any) => this.applyNavContributions(app)); + } + + // ========================================== + // App navigation contributions (ADR-0029 D7) + // ========================================== + + /** + * Register a navigation contribution — a package injecting nav items into + * an app it does not own (the UI-layer analog of object `extend`). + * + * Contributions are merged into the target app's `navigation` tree lazily + * on read ({@link getApp} / {@link getAllApps}) by group id + priority, so + * registration order does not matter and the owning app can be registered + * before or after its contributors. + */ + registerAppNavContribution( + contribution: { app: string; group?: string; priority?: number; items?: any[] }, + packageId?: string, + ): void { + if (!contribution || !contribution.app) return; + const list = this.appNavContributions.get(contribution.app) ?? []; + list.push({ + packageId, + group: contribution.group, + priority: contribution.priority ?? 200, + items: Array.isArray(contribution.items) ? contribution.items : [], + }); + this.appNavContributions.set(contribution.app, list); + this.log( + `[Registry] Navigation contribution: ${packageId ?? '(unknown)'} -> ${contribution.app}` + + (contribution.group ? `/${contribution.group}` : '') + + ` (${list[list.length - 1].items.length} items)`, + ); + } + + /** Contributions registered for an app (empty array when none). */ + getAppNavContributions(appName: string): Array<{ packageId?: string; group?: string; priority: number; items: any[] }> { + return this.appNavContributions.get(appName) ?? []; + } + + /** + * Return a copy of `app` with all registered navigation contributions + * merged into its `navigation` tree. The stored app is never mutated, so + * repeated reads stay idempotent. + */ + private applyNavContributions(app: any): any { + const contributions = this.appNavContributions.get(app?.name); + if (!contributions || contributions.length === 0) return app; + + const cloned = structuredClone(app); + const nav: any[] = Array.isArray(cloned.navigation) ? cloned.navigation : (cloned.navigation = []); + + // Lower priority applied first — mirrors object extender ordering. + const sorted = [...contributions].sort((a, b) => a.priority - b.priority); + for (const c of sorted) { + if (!c.items.length) continue; + if (c.group) { + const group = this.findNavGroup(nav, c.group); + if (group) { + if (!Array.isArray(group.children)) group.children = []; + group.children.push(...c.items); + } else { + this.log( + `[Registry] Navigation contribution from "${c.packageId ?? '(unknown)'}" targets ` + + `missing group "${c.group}" in app "${app.name}" — appending at top level.`, + ); + nav.push(...c.items); + } + } else { + nav.push(...c.items); + } + } + return cloned; + } + + /** Depth-first search for a `type: 'group'` nav item by id. */ + private findNavGroup(items: any[], groupId: string): any | undefined { + for (const item of items) { + if (item && item.id === groupId && item.type === 'group') return item; + if (item && Array.isArray(item.children)) { + const found = this.findNavGroup(item.children, groupId); + if (found) return found; + } + } + return undefined; } // ========================================== @@ -1026,6 +1122,7 @@ export class SchemaRegistry { this.mergedObjectCache.clear(); this.namespaceRegistry.clear(); this.metadata.clear(); + this.appNavContributions.clear(); this.log('[Registry] Reset complete'); } } diff --git a/packages/platform-objects/src/apps/index.ts b/packages/platform-objects/src/apps/index.ts index 35cbca62e..06f971cba 100644 --- a/packages/platform-objects/src/apps/index.ts +++ b/packages/platform-objects/src/apps/index.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export { SETUP_APP } from './setup.app.js'; +export { SETUP_NAV_CONTRIBUTIONS } from './setup-nav.contributions.js'; export { STUDIO_APP } from './studio.app.js'; export { ACCOUNT_APP } from './account.app.js'; export * from './dashboards/index.js'; diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts new file mode 100644 index 000000000..628e202c7 --- /dev/null +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup App navigation contributions owned by `@objectstack/platform-objects` + * (ADR-0029 D7). + * + * The Setup App (`setup.app.ts`) is a shell of empty group anchors; these + * contributions fill the groups with the entries for objects that still live + * in `@objectstack/platform-objects`. They are registered alongside + * `SETUP_APP` (via `plugin-auth`'s `manifest.register`). + * + * `group_integrations` is intentionally **absent** here — its Webhooks / + * Webhook Deliveries entries are contributed by `@objectstack/plugin-webhooks`, + * which owns `sys_webhook` / `sys_webhook_delivery` (ADR-0029 K2.a). As each + * remaining domain moves to its capability plugin (K2.b+), its entries move + * out of this file into that plugin the same way. + * + * Priority 100 keeps platform-objects base entries ahead of later + * contributions in the same group (mirrors object owner priority). + */ + +import type { NavigationContribution } from '@objectstack/spec/ui'; + +const BASE_PRIORITY = 100; + +export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ + { + app: 'setup', + group: 'group_overview', + priority: BASE_PRIORITY, + items: [ + { id: 'nav_system_overview', type: 'dashboard', label: 'System Overview', dashboardName: 'system_overview', icon: 'activity' }, + ], + }, + { + app: 'setup', + group: 'group_apps', + priority: BASE_PRIORITY, + items: [ + { id: 'nav_marketplace_browse', type: 'url', label: 'Browse Marketplace', url: '/apps/setup/system/marketplace', icon: 'store' }, + { id: 'nav_marketplace_installed', type: 'url', label: 'Installed Apps', url: '/apps/setup/system/marketplace/installed', icon: 'package-check', requiresObject: 'sys_package_installation' }, + ], + }, + { + app: 'setup', + group: 'group_people_org', + priority: BASE_PRIORITY, + items: [ + { id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', icon: 'user' }, + { id: 'nav_departments', type: 'object', label: 'Departments', objectName: 'sys_department', icon: 'building', requiresObject: 'sys_department' }, + { id: 'nav_teams', type: 'object', label: 'Teams', objectName: 'sys_team', icon: 'users-round' }, + { id: 'nav_organizations', type: 'object', label: 'Organizations', objectName: 'sys_organization', icon: 'building-2' }, + { id: 'nav_invitations', type: 'object', label: 'Invitations', objectName: 'sys_invitation', icon: 'mail' }, + ], + }, + { + app: 'setup', + group: 'group_access_control', + priority: BASE_PRIORITY, + items: [ + { id: 'nav_roles', type: 'object', label: 'Roles', objectName: 'sys_role', icon: 'shield-check' }, + { id: 'nav_permission_sets', type: 'object', label: 'Permission Sets', objectName: 'sys_permission_set', icon: 'lock' }, + { id: 'nav_sharing_rules', type: 'object', label: 'Sharing Rules', objectName: 'sys_sharing_rule', icon: 'share-2', requiresObject: 'sys_sharing_rule', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_record_shares', type: 'object', label: 'Record Shares', objectName: 'sys_record_share', icon: 'link', requiresObject: 'sys_record_share', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_api_keys', type: 'object', label: 'API Keys', objectName: 'sys_api_key', icon: 'key', requiredPermissions: ['manage_platform_settings'] }, + ], + }, + { + app: 'setup', + group: 'group_approvals', + priority: BASE_PRIORITY, + items: [ + { id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' }, + { id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' }, + ], + }, + { + app: 'setup', + group: 'group_configuration', + priority: BASE_PRIORITY, + items: [ + { id: 'nav_settings_hub', type: 'url', label: 'All Settings', url: '/apps/setup/system/settings', icon: 'settings-2', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_branding', type: 'url', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' }, + { id: 'nav_settings_mail', type: 'url', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_storage', type: 'url', label: 'File Storage', url: '/apps/setup/system/settings/storage', icon: 'hard-drive', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_ai', type: 'url', label: 'AI & Embedder', url: '/apps/setup/system/settings/ai', icon: 'sparkles', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_knowledge', type: 'url', label: 'Knowledge', url: '/apps/setup/system/settings/knowledge', icon: 'book-open', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_feature_flags', type: 'url', label: 'Feature Flags', url: '/apps/setup/system/settings/feature_flags', icon: 'flag' }, + ], + }, + { + app: 'setup', + group: 'group_diagnostics', + priority: BASE_PRIORITY, + items: [ + { id: 'nav_sessions', type: 'object', label: 'Sessions', objectName: 'sys_session', icon: 'monitor' }, + { id: 'nav_audit_logs', type: 'object', label: 'Audit Logs', objectName: 'sys_audit_log', icon: 'scroll-text' }, + { id: 'nav_notifications', type: 'object', label: 'Notifications', objectName: 'sys_notification', icon: 'bell', requiresObject: 'sys_notification' }, + ], + }, + { + app: 'setup', + group: 'group_advanced', + priority: BASE_PRIORITY, + items: [ + { id: 'nav_oauth_apps', type: 'object', label: 'OAuth Applications', objectName: 'sys_oauth_application', icon: 'app-window' }, + { id: 'nav_jwks', type: 'object', label: 'Signing Keys (JWKS)', objectName: 'sys_jwks', icon: 'key-round' }, + { id: 'nav_verifications', type: 'object', label: 'Verifications', objectName: 'sys_verification', icon: 'mail-check' }, + { id: 'nav_two_factor', type: 'object', label: 'Two-Factor', objectName: 'sys_two_factor', icon: 'smartphone' }, + { id: 'nav_device_codes', type: 'object', label: 'Device Codes', objectName: 'sys_device_code', icon: 'qr-code' }, + { id: 'nav_accounts', type: 'object', label: 'Identity Links', objectName: 'sys_account', icon: 'link-2' }, + { id: 'nav_user_preferences', type: 'object', label: 'User Preferences', objectName: 'sys_user_preference', icon: 'sliders' }, + ], + }, +]; diff --git a/packages/platform-objects/src/apps/setup.app.ts b/packages/platform-objects/src/apps/setup.app.ts index a16db6b75..5614a326a 100644 --- a/packages/platform-objects/src/apps/setup.app.ts +++ b/packages/platform-objects/src/apps/setup.app.ts @@ -1,26 +1,28 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * Platform Setup App — static definition. + * Platform Setup App — navigation **shell** (ADR-0029 D7). * - * Lists every `sys_*` administrative object as a left-hand navigation - * entry in ObjectUI's "Setup" area. Lives here (alongside the object - * schemas it references) instead of being assembled at runtime by - * `@objectstack/plugin-setup` — that plugin existed only because the - * referenced objects used to live in three different runtime plugins - * (auth/security/audit). Now that all `sys_*` objects are centralized - * in `@objectstack/platform-objects`, the Setup App is a fixed metadata - * artifact too and can be exported as plain data. + * The Setup App is now a thin shell: it defines the app envelope plus the + * stable left-nav **group anchors** ("slots"), but enumerates **no** objects. + * Each capability plugin contributes its own menu entries into a slot via + * `navigationContributions` (the UI-layer analog of object `extend`), so the + * menu for an object ships with the package that owns the object. * - * The runtime registration happens in `plugin-auth` (which is always - * loaded alongside security + audit and already calls - * `manifest.register({...})`). + * - Items owned by `@objectstack/platform-objects` are contributed by + * `SETUP_NAV_CONTRIBUTIONS` (see `setup-nav.contributions.ts`), registered + * alongside this app. + * - Items owned by a capability plugin are contributed by that plugin — e.g. + * `@objectstack/plugin-webhooks` fills `group_integrations` with its + * `sys_webhook` / `sys_webhook_delivery` entries (ADR-0029 K2.a). + * + * The runtime merges all contributions into this app's `navigation` tree by + * group id + priority on read, so the rendered Setup nav is identical to the + * former static artifact — just assembled from its owners. A disabled + * capability contributes nothing and its slot stays empty. * * Menu shape: flat `navigation[]` with `type: 'group'` category nodes, - * matching the convention used by the HotCRM reference app at - * https://github.com/objectstack-ai/hotcrm (see `src/apps/crm.app.ts`). - * The legacy `areas[]` shape was abandoned because it rendered poorly - * compared to the category style ObjectUI is built around. + * matching the convention used by the HotCRM reference app. */ import type { App } from '@objectstack/spec/ui'; @@ -43,176 +45,68 @@ export const SETUP_APP: App = { primaryColor: '#475569', // Slate-600 — neutral admin palette }, requiredPermissions: ['setup.access'], + // Shell only — the stable group anchors. Children are supplied by + // `navigationContributions` from the packages that own the objects. navigation: [ { id: 'group_overview', type: 'group', label: 'Overview', icon: 'layout-dashboard', - // Platform-wide metrics — aggregate counts across ALL tenants - // and are mislabeled for an org admin (RLS would filter to a - // single org but the dashboard still reads "Total Users" etc.). - // Hidden until a tenant-scoped `organization_overview` ships. requiredPermissions: ['manage_platform_settings'], - children: [ - { id: 'nav_system_overview', type: 'dashboard', label: 'System Overview', dashboardName: 'system_overview', icon: 'activity' }, - ], + children: [], }, { - // App Marketplace — browse + install packages. The browse page is - // always available: single-environment runtimes use the - // `MarketplaceProxy` plugin to browse the remote catalog; - // control-plane (cloud) deployments add `sys_package_installation` - // to track per-env installs. The "Installed Apps" entry is gated - // on that object so it only appears when the catalog backend - // exposes installations (i.e. control-plane / multi-tenant). id: 'group_apps', type: 'group', label: 'Apps', icon: 'package', - children: [ - { - id: 'nav_marketplace_browse', - type: 'url', - label: 'Browse Marketplace', - url: '/apps/setup/system/marketplace', - icon: 'store', - }, - { - id: 'nav_marketplace_installed', - type: 'url', - label: 'Installed Apps', - url: '/apps/setup/system/marketplace/installed', - icon: 'package-check', - requiresObject: 'sys_package_installation', - }, - ], + children: [], }, { id: 'group_people_org', type: 'group', label: 'People & Organization', icon: 'users', - children: [ - // HR-shaped grouping: who exists, where they sit in the org chart, - // and which tenants/teams they belong to. `sys_department` is the - // platform-owned org skeleton (M10.17.1); `sys_team` is better-auth's - // flat collaboration grouping. - // - // M10.30b: removed top-level Department Members / Team Members / - // Org Members entries — they are M:N join tables and the natural - // entry point is the parent record's detail page. - { id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', icon: 'user' }, - { id: 'nav_departments', type: 'object', label: 'Departments', objectName: 'sys_department', icon: 'building', requiresObject: 'sys_department' }, - { id: 'nav_teams', type: 'object', label: 'Teams', objectName: 'sys_team', icon: 'users-round' }, - { id: 'nav_organizations', type: 'object', label: 'Organizations', objectName: 'sys_organization', icon: 'building-2' }, - { id: 'nav_invitations', type: 'object', label: 'Invitations', objectName: 'sys_invitation', icon: 'mail' }, - ], + children: [], }, { id: 'group_access_control', type: 'group', label: 'Access Control', icon: 'shield', - children: [ - // M10.30b: removed top-level User Permission Sets / Role Permission - // Sets entries — same M:N → parent-detail-tab argument as the - // People & Org cleanup. - { id: 'nav_roles', type: 'object', label: 'Roles', objectName: 'sys_role', icon: 'shield-check' }, - { id: 'nav_permission_sets', type: 'object', label: 'Permission Sets', objectName: 'sys_permission_set', icon: 'lock' }, - // Sharing rules / record shares / API keys are platform-managed - // (shared across orgs or operate on the global identity surface). - // Org admins see Roles + Permission Sets read-only via RLS but - // these advanced entries are hidden behind manage_platform_settings. - { id: 'nav_sharing_rules', type: 'object', label: 'Sharing Rules', objectName: 'sys_sharing_rule', icon: 'share-2', requiresObject: 'sys_sharing_rule', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_record_shares', type: 'object', label: 'Record Shares', objectName: 'sys_record_share', icon: 'link', requiresObject: 'sys_record_share', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_api_keys', type: 'object', label: 'API Keys', objectName: 'sys_api_key', icon: 'key', requiredPermissions: ['manage_platform_settings'] }, - ], + children: [], }, { id: 'group_approvals', type: 'group', label: 'Approvals', icon: 'check-circle', - // Approval processes are configured at the platform level and - // reused across tenants. Hidden from org admins. requiredPermissions: ['manage_platform_settings'], - children: [ - { id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' }, - { id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' }, - ], + children: [], }, { id: 'group_configuration', type: 'group', label: 'Configuration', icon: 'sliders-horizontal', - children: [ - // Metadata-driven settings hub. Each entry maps to a SettingsManifest - // namespace exposed by @objectstack/service-settings. URL navigation - // is used (not `object`) because settings are stored in a generic - // K/V table (`sys_setting`) rather than per-namespace objects, and - // the renderer is a dedicated page in objectui. - // - // Order mirrors `builtinSettingsManifests` so left-nav order matches - // the All-Settings index. AI groups chat + embedder under one entry - // because operators reason about them together; Knowledge is its - // own entry because the adapter selection is independent. - // - // Permission gating: tenant-scoped manifests (branding, - // feature_flags) stay on `setup.access` so org admins can - // configure their own org. Global manifests (mail, storage, - // AI, knowledge) and the "All Settings" index sit behind - // `manage_platform_settings` because they affect every tenant. - { id: 'nav_settings_hub', type: 'url', label: 'All Settings', url: '/apps/setup/system/settings', icon: 'settings-2', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_branding', type: 'url', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' }, - { id: 'nav_settings_mail', type: 'url', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_storage', type: 'url', label: 'File Storage', url: '/apps/setup/system/settings/storage', icon: 'hard-drive', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_ai', type: 'url', label: 'AI & Embedder', url: '/apps/setup/system/settings/ai', icon: 'sparkles', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_knowledge', type: 'url', label: 'Knowledge', url: '/apps/setup/system/settings/knowledge', icon: 'book-open', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_feature_flags', type: 'url', label: 'Feature Flags', url: '/apps/setup/system/settings/feature_flags', icon: 'flag' }, - ], + children: [], }, { id: 'group_diagnostics', type: 'group', label: 'Diagnostics', icon: 'stethoscope', - // Sessions / audit logs / notifications expose cross-tenant - // telemetry and are platform-only. requiredPermissions: ['manage_platform_settings'], - children: [ - // Day-to-day observability surfaces. M10.30b removed `sys_activity` - // and `sys_comment` — both are CRM operational data authored from - // record pages, not platform admin surfaces. - { id: 'nav_sessions', type: 'object', label: 'Sessions', objectName: 'sys_session', icon: 'monitor' }, - { id: 'nav_audit_logs', type: 'object', label: 'Audit Logs', objectName: 'sys_audit_log', icon: 'scroll-text' }, - { id: 'nav_notifications', type: 'object', label: 'Notifications', objectName: 'sys_notification', icon: 'bell', requiresObject: 'sys_notification' }, - ], + children: [], }, { id: 'group_integrations', type: 'group', label: 'Integrations', icon: 'plug', - // Webhook configuration and delivery telemetry are platform-only. requiredPermissions: ['manage_platform_settings'], - children: [ - // Outbound HTTP integrations. `sys_webhook` always ships with - // platform-objects, so the Webhooks entry is always visible. - // `sys_webhook_delivery` is the durable outbox row from - // `@objectstack/plugin-webhooks/schema` — gated on `requiresObject` - // so the Deliveries entry only renders when the plugin has been - // wired into `defineStack({ objects: [SysWebhookDelivery, ...] })`. - // - // This is the canonical demonstration of "everything is an object": - // managing webhooks (configuration) and inspecting deliveries - // (operational telemetry) reuses the same generic ObjectView / - // ObjectListView UI as any business object — no bespoke webhook - // admin page. - { id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', icon: 'webhook', requiresObject: 'sys_webhook' }, - { id: 'nav_webhook_deliveries', type: 'object', label: 'Webhook Deliveries', objectName: 'sys_webhook_delivery', icon: 'send', requiresObject: 'sys_webhook_delivery' }, - ], + children: [], }, { id: 'group_advanced', @@ -220,37 +114,8 @@ export const SETUP_APP: App = { label: 'Advanced', icon: 'wrench', expanded: false, - // OAuth apps / JWKS / verifications / two-factor / device codes / - // identity links / user preferences are all platform support - // surfaces — hidden from org admins. requiredPermissions: ['manage_platform_settings'], - children: [ - // Better-auth internals — rarely useful for humans, but exposed - // so support engineers can inspect token state without dropping - // to SQL. The objectui sidebar collapses this group by default; - // edits should hit the read-only banner since these are all - // `managedBy: 'better-auth'`. - // - // M10.30b changes: - // - Removed the 3 OAuth satellite menus (access tokens / refresh - // tokens / consents). They live under their parent OAuth App. - // - Renamed "Linked Accounts" → "Identity Links" to distinguish - // from sys_user / org members. - // - Demoted "All Metadata" from the (now-deleted) Platform group - // to this Advanced/debug bucket. - // - The raw `sys_app` / `sys_package` / `sys_package_installation` - // object-list menus stay removed (they are control-plane admin - // surfaces and live in `cloud-control.app.ts`). End-user - // marketplace access lives in the top-level `Apps` group above, - // pointing at the React browse/installed pages. - { id: 'nav_oauth_apps', type: 'object', label: 'OAuth Applications', objectName: 'sys_oauth_application', icon: 'app-window' }, - { id: 'nav_jwks', type: 'object', label: 'Signing Keys (JWKS)', objectName: 'sys_jwks', icon: 'key-round' }, - { id: 'nav_verifications', type: 'object', label: 'Verifications', objectName: 'sys_verification', icon: 'mail-check' }, - { id: 'nav_two_factor', type: 'object', label: 'Two-Factor', objectName: 'sys_two_factor', icon: 'smartphone' }, - { id: 'nav_device_codes', type: 'object', label: 'Device Codes', objectName: 'sys_device_code', icon: 'qr-code' }, - { id: 'nav_accounts', type: 'object', label: 'Identity Links', objectName: 'sys_account', icon: 'link-2' }, - { id: 'nav_user_preferences', type: 'object', label: 'User Preferences', objectName: 'sys_user_preference', icon: 'sliders' }, - ], + children: [], }, ], }; diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index be5339af0..fc56b5619 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -30,7 +30,7 @@ import { SysMetadataHistoryObject, } from './metadata/index.js'; import { SysSetting } from './system/index.js'; -import { SETUP_APP } from './apps/index.js'; +import { SETUP_APP, SETUP_NAV_CONTRIBUTIONS } from './apps/index.js'; import { AppSchema } from '@objectstack/spec/ui'; const systemObjects = [ @@ -245,25 +245,52 @@ describe('@objectstack/platform-objects', () => { }); }); - describe('SETUP_APP', () => { + describe('SETUP_APP (ADR-0029 D7 shell)', () => { it('parses cleanly through AppSchema', () => { expect(() => AppSchema.parse(SETUP_APP)).not.toThrow(); }); - it('exposes an Integrations group with Webhooks + Webhook Deliveries', () => { + it('is a shell of group anchors with no enumerated objects', () => { + const nav = SETUP_APP.navigation ?? []; + expect(nav.length).toBeGreaterThan(0); + for (const item of nav) { + expect(item.type).toBe('group'); + // Shell groups carry no children — entries come from contributions. + expect((item as { children?: unknown[] }).children).toEqual([]); + } + }); + + it('keeps the group_integrations anchor (filled by plugin-webhooks contribution)', () => { const group = SETUP_APP.navigation?.find((n) => n.id === 'group_integrations'); expect(group).toBeDefined(); expect(group?.type).toBe('group'); - const children = (group as { children?: Array<{ id: string; objectName?: string; requiresObject?: string }> }).children ?? []; - const webhooks = children.find((c) => c.id === 'nav_webhooks'); - const deliveries = children.find((c) => c.id === 'nav_webhook_deliveries'); - expect(webhooks?.objectName).toBe('sys_webhook'); - expect(deliveries?.objectName).toBe('sys_webhook_delivery'); - // Both entries are plugin-owned (WebhookOutboxPlugin registers - // sys_webhook + sys_webhook_delivery), so they must gracefully - // hide when the plugin isn't installed in the stack. - expect(webhooks?.requiresObject).toBe('sys_webhook'); - expect(deliveries?.requiresObject).toBe('sys_webhook_delivery'); + // The webhooks entries are no longer static here — plugin-webhooks + // contributes them into this slot (ADR-0029 D7 / K2.a). + expect((group as { children?: unknown[] }).children).toEqual([]); + }); + }); + + describe('SETUP_NAV_CONTRIBUTIONS (ADR-0029 D7)', () => { + const shellGroupIds = new Set( + (SETUP_APP.navigation ?? []).map((n) => n.id), + ); + + it('all target the setup app and an existing shell group', () => { + expect(SETUP_NAV_CONTRIBUTIONS.length).toBeGreaterThan(0); + for (const c of SETUP_NAV_CONTRIBUTIONS) { + expect(c.app).toBe('setup'); + expect(c.group).toBeDefined(); + expect(shellGroupIds.has(c.group!)).toBe(true); + expect(Array.isArray(c.items)).toBe(true); + expect(c.items.length).toBeGreaterThan(0); + } + }); + + it('does not contribute the plugin-owned integrations slot', () => { + // group_integrations belongs to @objectstack/plugin-webhooks, not the + // platform-objects base contributions. + const integrations = SETUP_NAV_CONTRIBUTIONS.find((c) => c.group === 'group_integrations'); + expect(integrations).toBeUndefined(); }); }); }); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 61a2c2395..9a410c030 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -5,6 +5,7 @@ import type { BetterAuthOptions } from 'better-auth'; import { AuthConfig } from '@objectstack/spec/system'; import { SETUP_APP, + SETUP_NAV_CONTRIBUTIONS, STUDIO_APP, ACCOUNT_APP, SystemOverviewDashboard, @@ -143,6 +144,10 @@ export class AuthPlugin implements Plugin { // owner of its registration since it loads first among the trio // (auth + security + audit) that supplies the underlying objects. apps: [SETUP_APP, STUDIO_APP, ACCOUNT_APP], + // ADR-0029 D7 — the Setup App is a shell of group anchors; its entries + // for platform-objects-owned objects are contributed here. Capability + // plugins (e.g. plugin-webhooks) contribute their own slots' entries. + navigationContributions: SETUP_NAV_CONTRIBUTIONS, // Slotted record-detail pages for system objects — currently // sys_organization gets a Members / Invitations / Teams tab strip // (see SysOrganizationDetailPage for the rationale and the diff --git a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts index 0c24a9022..164be2162 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -136,6 +136,21 @@ export class WebhookOutboxPlugin implements Plugin { description: 'Registers sys_webhook (configuration) and sys_webhook_delivery (durable outbox telemetry).', objects: [SysWebhook, SysWebhookDelivery], + // ADR-0029 D7 — contribute the Webhooks entries into the + // Setup app's `group_integrations` slot. The plugin owns these + // objects (K2.a), so it ships their menu too; when the plugin + // isn't installed the slot stays empty. + navigationContributions: [ + { + app: 'setup', + group: 'group_integrations', + priority: 100, + items: [ + { id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', icon: 'webhook', requiresObject: 'sys_webhook' }, + { id: 'nav_webhook_deliveries', type: 'object', label: 'Webhook Deliveries', objectName: 'sys_webhook_delivery', icon: 'send', requiresObject: 'sys_webhook_delivery' }, + ], + }, + ], }); } else { ctx.logger.warn?.( diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 8a5a39bfc..40fbf337b 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -5,6 +5,7 @@ import { PluginCapabilityManifestSchema } from './plugin-capability.zod'; import { PluginLoadingConfigSchema } from './plugin-loading.zod'; import { CORE_PLUGIN_TYPES } from './plugin.zod'; import { DatasetSchema } from '../data/dataset.zod'; +import { NavigationContributionSchema } from '../ui/app.zod'; /** * Schema for the ObjectStack Manifest. @@ -381,6 +382,17 @@ export const ManifestSchema = z.object({ */ extensions: z.record(z.string(), z.unknown()).optional().describe('Extension points and contributions'), + /** + * Navigation contributions (ADR-0029 D7). + * + * Lets this package inject navigation items into apps it does not own + * (e.g. a capability plugin adding its menu entries into the `setup` app). + * The runtime merges these into the target app's `navigation` tree by + * group id + priority. See {@link NavigationContributionSchema}. + */ + navigationContributions: z.array(NavigationContributionSchema).optional() + .describe('Navigation items this package contributes into apps owned by other packages'), + /** * Plugin Loading Configuration. * Configures how the plugin is loaded, initialized, and managed at runtime. diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 51b2b0634..0a70329e1 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -235,6 +235,40 @@ export const NavigationItemSchema: z.ZodType = z.lazy(() => ]) ); +/** + * Navigation Contribution (ADR-0029 D7) + * + * Lets a package inject navigation items into an app it does **not** own — + * the UI-layer analog of object `objectExtensions`. A capability plugin + * contributes its menu entries into a shared admin app (e.g. `setup`) so the + * app can be a thin "shell + group anchors" while each plugin ships the menu + * for the objects it owns. + * + * The runtime merges all contributions into the owning app's `navigation` + * tree by **target group id + priority** (lower priority applied first, + * mirroring object extender ordering). When `group` is omitted the items are + * appended at the app's top level. Contributed items keep the normal nav + * gating fields (`requiresObject` / `requiredPermissions` / `visible`), so an + * uninstalled capability simply contributes nothing and its slot stays empty. + * + * @example + * { + * app: 'setup', + * group: 'group_integrations', + * priority: 100, + * items: [ + * { id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', requiresObject: 'sys_webhook' }, + * ], + * } + */ +export const NavigationContributionSchema = lazySchema(() => z.object({ + app: SnakeCaseIdentifierSchema.describe('Target app name to contribute navigation into (e.g. "setup")'), + group: SnakeCaseIdentifierSchema.optional().describe('Target group nav-item id to append into (e.g. "group_integrations"); omit to append at the app top level'), + priority: z.number().int().min(0).default(200).describe('Merge priority within the target group — lower applied first (matches object extender priority)'), + items: z.array(NavigationItemSchema).describe('Navigation items contributed into the target app/group'), +}).describe('A navigation contribution: a package injecting nav items into an app it does not own (ADR-0029 D7)')); +export type NavigationContribution = z.infer; + /** * App Branding Configuration * Allows configuring the look and feel of the specific app. From a634d84c30a5ea95428b9cea23a76de1f3d0a210 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 04:10:53 +0000 Subject: [PATCH 2/2] chore: re-trigger CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous Test Core run failed only in @objectstack/driver-mongodb, where mongodb-memory-server could not download the MongoDB binary from fastdl.mongodb.org (network/infra flake) — unrelated to this PR's changes (spec/objectql/platform-objects/plugin-auth/plugin-webhooks). Local full `turbo run test` is green except for that same environmental download error. https://claude.ai/code/session_01Tv6F1Ub6bhCedrx3r8sZM4