From 57673eca10f723c6ff773487371fd1808f06edb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 17:14:30 +0000 Subject: [PATCH 1/8] wip(platform-objects,plugin-audit): nav entries for the console's audit-log, AI-approvals and integrations pages Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- .../src/apps/setup-nav.contributions.ts | 32 +++++++++++++++++-- .../platform-objects/src/apps/studio.app.ts | 20 ++++++++++-- .../src/apps/translations/en.ts | 6 ++++ .../apps/translations/es-ES.source-hashes.ts | 3 ++ .../src/apps/translations/es-ES.ts | 3 ++ .../apps/translations/ja-JP.source-hashes.ts | 3 ++ .../src/apps/translations/ja-JP.ts | 3 ++ .../apps/translations/zh-CN.source-hashes.ts | 3 ++ .../src/apps/translations/zh-CN.ts | 3 ++ .../src/platform-objects.test.ts | 29 ++++++++++++++--- .../plugins/plugin-audit/src/audit-plugin.ts | 19 +++++++++-- 11 files changed, 113 insertions(+), 11 deletions(-) diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index 1428168fd4d..697fdee0272 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -12,7 +12,8 @@ * Some entries/groups are intentionally contributed by the capability plugin * that owns the underlying objects rather than living here (ADR-0029 K2): * - `group_integrations` → `@objectstack/plugin-webhooks` (K2.a) - * - `group_approvals` → `@objectstack/plugin-approvals` (K2.b) + * - `group_approvals` → `@objectstack/plugin-approvals` (K2.b); the one + * exception is `nav_ai_approvals`, gated on the `ai` service (see below) * - `group_access_control` Positions / Permission Sets → `@objectstack/plugin-security` * - `group_access_control` Sharing Rules / Record Shares → `@objectstack/plugin-sharing` * As each remaining domain moves to its capability plugin, its entries move out @@ -103,7 +104,34 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ ], }, // group_approvals is contributed by @objectstack/plugin-approvals, which owns - // sys_approval_request / sys_approval_action (ADR-0029 K2.b). + // sys_approval_request / sys_approval_action (ADR-0029 K2.b). The approvals + // ENGINE's entries stay there. The one entry this package places in the + // slot is the AI pending-action queue below, whose capability no package + // of this repository provides — `platform-objects.test.ts` pins that it is + // the only one. + { + app: 'setup', + group: 'group_approvals', + // After plugin-approvals' own entries (priority 100): the engine's inbox + // stays the slot's first, working surface. + priority: 200, + items: [ + // #20142 — the console's AI Approvals queue: the whole AI pending-action + // queue (`/api/v1/ai/pending-actions`) with approve / reject. The console + // registers the key in `registerSystemComponents.tsx`; its only in-app + // link was the System Hub card wall, retired by objectui#10520. + // + // `requiresService: 'ai'` is load-bearing, not decoration. The `ai` slot + // has no implementation in the open framework (`CORE_SERVICE_PROVIDER.ai` + // is `null` — `@objectstack/service-ai` ships in Cloud/Enterprise), so on + // a Community Edition boot the page's only data source answers 501. + // The gate is stripped server-side by `filterAppForUser` (ADR-0057 D10), + // so the entry never reaches a browser whose runtime cannot serve it. + // Same shape as `nav_account_approvals`' `requiresService: 'approvals'`. + // `group_approvals` already requires `manage_platform_settings`. + { id: 'nav_ai_approvals', type: 'component', label: 'AI Approvals', componentRef: 'ai:approvals', icon: 'bot', requiresService: 'ai' }, + ], + }, { app: 'setup', group: 'group_configuration', diff --git a/packages/platform-objects/src/apps/studio.app.ts b/packages/platform-objects/src/apps/studio.app.ts index 042219fb3a9..4c842ed5bf2 100644 --- a/packages/platform-objects/src/apps/studio.app.ts +++ b/packages/platform-objects/src/apps/studio.app.ts @@ -298,9 +298,9 @@ export const STUDIO_APP: App = { }, { // Developer — first-party developer tooling surfaces hosted by the - // console (API console, flow run inspector, public forms registry). - // Registered as built-in components in the console's - // ComponentRegistry under the `developer:*` namespace. + // console (API console, flow run inspector, public forms registry, + // integrations & APIs). Registered as built-in components in the + // console's ComponentRegistry under the `developer:*` namespace. id: 'group_developer', type: 'group', label: 'Developer', @@ -327,6 +327,20 @@ export const STUDIO_APP: App = { componentRef: 'developer:public-forms', icon: 'file-text', }, + { + // #20142 — the console's Integrations & APIs page: the environment's + // REST base URL, per-object endpoints and an `x-api-key` cURL + // sample. Its only in-app link was a card on the console's Developer + // Hub, which objectui#10520 retired once all four of the hub's + // destinations were registry keys; the console registers this one + // in `registerDeveloperComponents.tsx`. No gate beyond Studio's own + // `studio.access`, like its neighbours. + id: 'nav_integrations', + type: 'component', + label: 'Integrations & APIs', + componentRef: 'developer:integrations', + icon: 'plug-zap', + }, ], }, { diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index da6690b9347..dfba22fd666 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -115,6 +115,9 @@ export const en: TranslationData = { nav_approval_requests: { label: 'Requests' }, nav_approval_actions: { label: 'Action History' }, nav_approval_delegations: { label: 'Delegations (OOO)' }, + // Contributed by this package into plugin-approvals' slot, gated on the + // `ai` service (#20142) — the console's AI pending-action queue. + nav_ai_approvals: { label: 'AI Approvals' }, // Configuration nav_settings_hub: { label: 'All Settings' }, @@ -135,6 +138,8 @@ export const en: TranslationData = { // Diagnostics nav_sessions: { label: 'Sessions' }, nav_audit_logs: { label: 'Audit Logs' }, + // The console's Audit Log page, beside the object view (#20142). + nav_audit_log_browser: { label: 'Audit Log Browser' }, nav_notifications: { label: 'Notifications' }, // Integrations — every entry here is contributed at RUNTIME by the @@ -191,6 +196,7 @@ export const en: TranslationData = { nav_api_console: { label: 'API Console' }, nav_flow_runs: { label: 'Flow Runs' }, nav_public_forms: { label: 'Public Forms' }, + nav_integrations: { label: 'Integrations & APIs' }, group_integration: { label: 'Integration' }, nav_email_templates: { label: 'Email Templates' }, }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts index 0fd32ead688..baa2603b0ef 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts @@ -102,6 +102,7 @@ export const esESSourceHashes: Readonly> = { 'apps.setup.navigation.nav_approval_requests.label': 'aafd0f7c6a376f55', 'apps.setup.navigation.nav_approval_actions.label': 'd2ebad7acfd50978', 'apps.setup.navigation.nav_approval_delegations.label': 'fc0d41741659c462', + 'apps.setup.navigation.nav_ai_approvals.label': '0de8ec9f51fc2dac', 'apps.setup.navigation.nav_settings_hub.label': 'a3ac65c7c7f461bf', 'apps.setup.navigation.nav_settings_localization.label': 'beedfaf00452c829', 'apps.setup.navigation.nav_settings_company.label': '9d7f685e11eaade9', @@ -117,6 +118,7 @@ export const esESSourceHashes: Readonly> = { 'apps.setup.navigation.nav_notification_templates.label': '9e270c87ef2578df', 'apps.setup.navigation.nav_sessions.label': '673bde6e0c8a3ef9', 'apps.setup.navigation.nav_audit_logs.label': '700dd03639b82a6a', + 'apps.setup.navigation.nav_audit_log_browser.label': 'f6932710da7ec071', 'apps.setup.navigation.nav_notifications.label': 'e0fdb90b1d31078d', 'apps.setup.navigation.nav_webhooks.label': '8fdd81bc76459a6a', 'apps.setup.navigation.nav_http_deliveries.label': '340f81dc6ea64987', @@ -152,6 +154,7 @@ export const esESSourceHashes: Readonly> = { 'apps.studio.navigation.nav_api_console.label': 'd90e508f1921e8d3', 'apps.studio.navigation.nav_flow_runs.label': '0bdee1504c505862', 'apps.studio.navigation.nav_public_forms.label': '4592e97959994e86', + 'apps.studio.navigation.nav_integrations.label': 'edbd97d7f401a5b8', 'apps.studio.navigation.group_integration.label': 'a1f2ddfb5c00c83a', 'apps.studio.navigation.nav_email_templates.label': '3463bce831c833d5', 'dashboards.system_overview.label': '574b2a4b7fb7f979', diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index c5826733294..e2a38386a9e 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -77,6 +77,7 @@ export const esES: TranslationData = { nav_approval_requests: { label: 'Solicitudes' }, nav_approval_actions: { label: 'Historial de Acciones' }, nav_approval_delegations: { label: 'Delegaciones (ausencia)' }, + nav_ai_approvals: { label: 'Aprobaciones de IA' }, nav_settings_hub: { label: 'Todos los Ajustes' }, nav_settings_localization: { label: 'Localización' }, @@ -94,6 +95,7 @@ export const esES: TranslationData = { nav_sessions: { label: 'Sesiones' }, nav_audit_logs: { label: 'Registros de Auditoría' }, + nav_audit_log_browser: { label: 'Explorador de registros de auditoría' }, nav_notifications: { label: 'Notificaciones' }, // Integrations — contributed at RUNTIME by the owning capability @@ -137,6 +139,7 @@ export const esES: TranslationData = { nav_api_console: { label: 'Consola de API' }, nav_flow_runs: { label: 'Ejecuciones de flujo' }, nav_public_forms: { label: 'Formularios públicos' }, + nav_integrations: { label: 'Integraciones y API' }, group_integration: { label: 'Integración' }, nav_email_templates: { label: 'Plantillas de correo' }, }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts index 5018e10a630..0863a435309 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts @@ -102,6 +102,7 @@ export const jaJPSourceHashes: Readonly> = { 'apps.setup.navigation.nav_approval_requests.label': 'aafd0f7c6a376f55', 'apps.setup.navigation.nav_approval_actions.label': 'd2ebad7acfd50978', 'apps.setup.navigation.nav_approval_delegations.label': 'fc0d41741659c462', + 'apps.setup.navigation.nav_ai_approvals.label': '0de8ec9f51fc2dac', 'apps.setup.navigation.nav_settings_hub.label': 'a3ac65c7c7f461bf', 'apps.setup.navigation.nav_settings_localization.label': 'beedfaf00452c829', 'apps.setup.navigation.nav_settings_company.label': '9d7f685e11eaade9', @@ -117,6 +118,7 @@ export const jaJPSourceHashes: Readonly> = { 'apps.setup.navigation.nav_notification_templates.label': '9e270c87ef2578df', 'apps.setup.navigation.nav_sessions.label': '673bde6e0c8a3ef9', 'apps.setup.navigation.nav_audit_logs.label': '700dd03639b82a6a', + 'apps.setup.navigation.nav_audit_log_browser.label': 'f6932710da7ec071', 'apps.setup.navigation.nav_notifications.label': 'e0fdb90b1d31078d', 'apps.setup.navigation.nav_webhooks.label': '8fdd81bc76459a6a', 'apps.setup.navigation.nav_http_deliveries.label': '340f81dc6ea64987', @@ -152,6 +154,7 @@ export const jaJPSourceHashes: Readonly> = { 'apps.studio.navigation.nav_api_console.label': 'd90e508f1921e8d3', 'apps.studio.navigation.nav_flow_runs.label': '0bdee1504c505862', 'apps.studio.navigation.nav_public_forms.label': '4592e97959994e86', + 'apps.studio.navigation.nav_integrations.label': 'edbd97d7f401a5b8', 'apps.studio.navigation.group_integration.label': 'a1f2ddfb5c00c83a', 'apps.studio.navigation.nav_email_templates.label': '3463bce831c833d5', 'dashboards.system_overview.label': '574b2a4b7fb7f979', diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 788e23be437..6f869a3e635 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -77,6 +77,7 @@ export const jaJP: TranslationData = { nav_approval_requests: { label: 'リクエスト' }, nav_approval_actions: { label: 'アクション履歴' }, nav_approval_delegations: { label: '委任 (不在時)' }, + nav_ai_approvals: { label: 'AI 承認' }, nav_settings_hub: { label: 'すべての設定' }, nav_settings_localization: { label: 'ローカリゼーション' }, @@ -94,6 +95,7 @@ export const jaJP: TranslationData = { nav_sessions: { label: 'セッション' }, nav_audit_logs: { label: '監査ログ' }, + nav_audit_log_browser: { label: '監査ログブラウザー' }, nav_notifications: { label: '通知' }, // Integrations — contributed at RUNTIME by the owning capability @@ -137,6 +139,7 @@ export const jaJP: TranslationData = { nav_api_console: { label: 'API コンソール' }, nav_flow_runs: { label: 'フロー実行履歴' }, nav_public_forms: { label: '公開フォーム' }, + nav_integrations: { label: '連携と API' }, group_integration: { label: '連携' }, nav_email_templates: { label: 'メールテンプレート' }, }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts index 330d0122220..ea3b407afd1 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts @@ -102,6 +102,7 @@ export const zhCNSourceHashes: Readonly> = { 'apps.setup.navigation.nav_approval_requests.label': 'aafd0f7c6a376f55', 'apps.setup.navigation.nav_approval_actions.label': 'd2ebad7acfd50978', 'apps.setup.navigation.nav_approval_delegations.label': 'fc0d41741659c462', + 'apps.setup.navigation.nav_ai_approvals.label': '0de8ec9f51fc2dac', 'apps.setup.navigation.nav_settings_hub.label': 'a3ac65c7c7f461bf', 'apps.setup.navigation.nav_settings_localization.label': 'beedfaf00452c829', 'apps.setup.navigation.nav_settings_company.label': '9d7f685e11eaade9', @@ -117,6 +118,7 @@ export const zhCNSourceHashes: Readonly> = { 'apps.setup.navigation.nav_notification_templates.label': '9e270c87ef2578df', 'apps.setup.navigation.nav_sessions.label': '673bde6e0c8a3ef9', 'apps.setup.navigation.nav_audit_logs.label': '700dd03639b82a6a', + 'apps.setup.navigation.nav_audit_log_browser.label': 'f6932710da7ec071', 'apps.setup.navigation.nav_notifications.label': 'e0fdb90b1d31078d', 'apps.setup.navigation.nav_webhooks.label': '8fdd81bc76459a6a', 'apps.setup.navigation.nav_http_deliveries.label': '340f81dc6ea64987', @@ -152,6 +154,7 @@ export const zhCNSourceHashes: Readonly> = { 'apps.studio.navigation.nav_api_console.label': 'd90e508f1921e8d3', 'apps.studio.navigation.nav_flow_runs.label': '0bdee1504c505862', 'apps.studio.navigation.nav_public_forms.label': '4592e97959994e86', + 'apps.studio.navigation.nav_integrations.label': 'edbd97d7f401a5b8', 'apps.studio.navigation.group_integration.label': 'a1f2ddfb5c00c83a', 'apps.studio.navigation.nav_email_templates.label': '3463bce831c833d5', 'dashboards.system_overview.label': '574b2a4b7fb7f979', diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 4ee68d9bb2f..68167919d5f 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -84,6 +84,7 @@ export const zhCN: TranslationData = { // `审批委派` matches sys_approval_delegation's object label; `(外出)` // renders the entry's own "(OOO)" qualifier. nav_approval_delegations: { label: '审批委派(外出)' }, + nav_ai_approvals: { label: 'AI 审批' }, nav_settings_hub: { label: '全部设置' }, nav_settings_localization: { label: '本地化' }, @@ -101,6 +102,7 @@ export const zhCN: TranslationData = { nav_sessions: { label: '会话' }, nav_audit_logs: { label: '审计日志' }, + nav_audit_log_browser: { label: '审计日志浏览器' }, nav_notifications: { label: '通知' }, // Integrations — contributed at RUNTIME by the owning capability @@ -147,6 +149,7 @@ export const zhCN: TranslationData = { nav_api_console: { label: 'API 控制台' }, nav_flow_runs: { label: '流程运行记录' }, nav_public_forms: { label: '公开表单' }, + nav_integrations: { label: '集成与 API' }, group_integration: { label: '集成' }, nav_email_templates: { label: '邮件模板' }, }, diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index 72709ae37dc..4f02c2d55c9 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -417,10 +417,31 @@ describe('@objectstack/platform-objects', () => { it('does not contribute slots owned by capability plugins', () => { // group_integrations → @objectstack/plugin-webhooks (K2.a) - // group_approvals → @objectstack/plugin-approvals (K2.b) - for (const ownedSlot of ['group_integrations', 'group_approvals']) { - const contrib = SETUP_NAV_CONTRIBUTIONS.find((c) => c.group === ownedSlot); - expect(contrib).toBeUndefined(); + const integrations = SETUP_NAV_CONTRIBUTIONS.find((c) => c.group === 'group_integrations'); + expect(integrations).toBeUndefined(); + }); + + it('contributes exactly one entry into group_approvals, and it is not the approvals engine\'s', () => { + // group_approvals → @objectstack/plugin-approvals (K2.b): the engine's + // entries (its inbox component and its `sys_approval_*` tables) are that + // plugin's to ship, and none of them may come back here. + // + // #20142 adds ONE entry to the slot from this package: the console's AI + // pending-action queue. Its capability (`ai`) is provided by no package + // of this repository, so there is no in-repo owner to contribute it; it + // is gated on that service instead, and stripped server-side wherever + // the service is absent. The allowance is the literal id list below, + // so any further entry here still turns this red. + const items = SETUP_NAV_CONTRIBUTIONS + .filter((c) => c.group === 'group_approvals') + .flatMap((c) => c.items as Array<{ + id?: string; objectName?: string; componentRef?: string; requiresService?: string; + }>); + expect(items.map((i) => i.id)).toEqual(['nav_ai_approvals']); + for (const item of items) { + expect(item.objectName ?? '').not.toMatch(/^sys_approval_/); + expect(item.componentRef ?? '').not.toMatch(/^approvals:/); + expect(item.requiresService).toBe('ai'); } }); diff --git a/packages/plugins/plugin-audit/src/audit-plugin.ts b/packages/plugins/plugin-audit/src/audit-plugin.ts index 170ca0a79a8..9750312e04b 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.ts @@ -95,8 +95,22 @@ export class AuditPlugin implements Plugin { defaultDatasource: 'cloud', namespace: 'sys', objects: [SysAuditLog, SysActivity, SysComment], - // ADR-0029 D7 — contribute the Audit Logs entry into the Setup app's - // `group_diagnostics` slot. The plugin owns sys_audit_log (K2). + // ADR-0029 D7 — contribute the Audit Logs entries into the Setup app's + // `group_diagnostics` slot. The plugin owns sys_audit_log (K2), so both + // doors onto it live and die with this plugin and need no item gate. + // + // #20142 — the two entries are two different surfaces, and neither is a + // superset of the other: + // - `nav_audit_logs` is the object view: `sys_audit_log`'s named list + // views (`record_views` lists the `read` rows, an action the page's + // filter did not offer at the console pin this entry was measured + // against), searchable, with the actor and tenant rendered as + // resolved lookups. + // - `nav_audit_log_browser` is the console's Audit Log page (the + // `audit:log` registry key, `registerSystemComponents.tsx`): one + // filterable table whose detail drawer pretty-prints a change's + // before and after JSON, where the record page shows `old_value` / + // `new_value` as raw textarea text. navigationContributions: [ { app: 'setup', @@ -104,6 +118,7 @@ export class AuditPlugin implements Plugin { priority: 100, items: [ { id: 'nav_audit_logs', type: 'object', label: 'Audit Logs', objectName: 'sys_audit_log', icon: 'scroll-text' }, + { id: 'nav_audit_log_browser', type: 'component', label: 'Audit Log Browser', componentRef: 'audit:log', icon: 'file-diff' }, ], }, ], From e1e6fd9b8aebf76fec1880f9fd16ec03c702f3ba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 17:24:10 +0000 Subject: [PATCH 2/8] wip: pins for the three console-page nav entries Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- .../src/apps/console-page-nav-entries.test.ts | 157 ++++++++++++++++++ .../src/audit-nav-contribution.test.ts | 92 ++++++++++ 2 files changed, 249 insertions(+) create mode 100644 packages/platform-objects/src/apps/console-page-nav-entries.test.ts create mode 100644 packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts diff --git a/packages/platform-objects/src/apps/console-page-nav-entries.test.ts b/packages/platform-objects/src/apps/console-page-nav-entries.test.ts new file mode 100644 index 00000000000..9fae156b187 --- /dev/null +++ b/packages/platform-objects/src/apps/console-page-nav-entries.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #20142 — the framework half of three console pages' navigation contract. +// +// objectui#10520 (PR objectui#10576) retired the console's System Hub card wall +// and its Developer Hub, which had been the only in-app links to three pages, +// and registered each page under a component-registry key instead. Framework +// navigation reaches a console page only through a `type: 'component'` item +// whose `componentRef` is such a key, so until an entry names the key the page +// is reachable by typed URL alone. This file pins the two entries this package +// declares; `audit:log` is contributed by `@objectstack/plugin-audit` (it lives +// and dies with that plugin) and is pinned beside it in +// `packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts`. +// +// Why a pin rather than a code comment: `componentRef` is a free string, and +// `validate-nav-target-refs` deliberately does not resolve component refs (the +// registry is not visible to the linter). A misspelt key parses, merges and +// ships, and the console renders "Component not registered" in its place. +// Each repo pins its own half of the seam: objectui pins the registration in +// `apps/console/src/__tests__/orphanedPageComponentRefs-10520.test.tsx`, this +// file pins the ref the entry names. +// +// The keys below were MEASURED, not recalled: read from objectui at the commit +// objectstack's `.objectui-sha` pins (dd3f7e1be3561d63267d7162f3fc0ac52e72834d), +// where `registerSystemComponents.tsx` registers `audit:log` and `ai:approvals` +// and `registerDeveloperComponents.tsx` registers `developer:integrations`, +// all three imported for their side effect by `apps/console/src/main.tsx`. +// A pin bump that renames one of them must change this list in the same PR. + +import { describe, it, expect } from 'vitest'; +import { AppSchema, NavigationContributionSchema } from '@objectstack/spec/ui'; +import { CORE_SERVICE_PROVIDER } from '@objectstack/spec/system'; + +import { SETUP_NAV_CONTRIBUTIONS } from './setup-nav.contributions.js'; +import { STUDIO_APP } from './studio.app.js'; + +/** The registry keys the pinned console registers for the three pages. */ +const MEASURED_CONSOLE_KEYS = ['audit:log', 'ai:approvals', 'developer:integrations'] as const; + +type NavItem = { + id?: string; + type?: string; + label?: string; + objectName?: string; + componentRef?: string; + requiresService?: string; + requiredPermissions?: string[]; + children?: NavItem[]; +}; + +/** Every contributed Setup nav item, with the group it is contributed into. */ +const setupItems = (): Array<{ group?: string; priority?: number; item: NavItem }> => + SETUP_NAV_CONTRIBUTIONS.flatMap((c) => + ((c.items ?? []) as NavItem[]).map((item) => ({ group: c.group, priority: c.priority, item })), + ); + +/** The children of one Studio group, by id. */ +const studioGroup = (id: string): NavItem[] => { + const group = ((STUDIO_APP.navigation ?? []) as NavItem[]).find((g) => g.id === id); + expect(group, `Studio lost its ${id} group`).toBeDefined(); + return group?.children ?? []; +}; + +describe('the AI Approvals entry targets the console page and is gated on the ai service (#20142)', () => { + const entry = () => { + const found = setupItems().find(({ item }) => item.id === 'nav_ai_approvals'); + expect(found, 'Setup lost its nav_ai_approvals entry').toBeDefined(); + return found!; + }; + + it('routes to the `ai:approvals` registry key, in group_approvals', () => { + expect(entry().item).toMatchObject({ + type: 'component', + componentRef: 'ai:approvals', + label: 'AI Approvals', + }); + expect(entry().group).toBe('group_approvals'); + // The component item has no object route, so an object gate would be a + // dead key that reads as one. + expect(entry().item.objectName).toBeUndefined(); + }); + + it('comes after plugin-approvals\' own entries (priority 100), so the inbox stays first', () => { + expect(entry().priority).toBeGreaterThan(100); + }); + + it('is gated on the `ai` service, which no open-framework package provides', () => { + expect(entry().item.requiresService).toBe('ai'); + // The premise that makes the gate load-bearing: the discovery table that + // names each core slot's installable provider records NONE for `ai` + // (`@objectstack/service-ai` is Cloud/Enterprise only). So a Community + // Edition boot registers no `ai` service and `filterAppForUser` strips the + // entry server-side (ADR-0057 D10). If an open provider ever ships, this + // premise changes and the gate's purpose must be re-read, not the entry + // silently left to render. + expect(CORE_SERVICE_PROVIDER.ai).toBeNull(); + }); + + it('parses as a NavigationContribution the runtime merge accepts', () => { + const contribution = SETUP_NAV_CONTRIBUTIONS.find((c) => + (c.items ?? []).some((i) => (i as NavItem).id === 'nav_ai_approvals'), + ); + expect(() => NavigationContributionSchema.parse(contribution)).not.toThrow(); + }); +}); + +describe('the Integrations & APIs entry targets the console page (#20142)', () => { + const entry = (): NavItem => { + const found = studioGroup('group_developer').find((i) => i.id === 'nav_integrations'); + expect(found, 'Studio lost its nav_integrations entry').toBeDefined(); + return found!; + }; + + it('routes to the `developer:integrations` registry key, last in group_developer', () => { + expect(entry()).toMatchObject({ + type: 'component', + componentRef: 'developer:integrations', + label: 'Integrations & APIs', + }); + expect(studioGroup('group_developer').map((i) => i.id)).toEqual([ + 'nav_api_console', + 'nav_flow_runs', + 'nav_public_forms', + 'nav_integrations', + ]); + }); + + it('carries no gate beyond Studio\'s own, like its neighbours', () => { + expect(entry().requiresService).toBeUndefined(); + expect(entry().requiredPermissions).toBeUndefined(); + }); + + it('the Studio app still satisfies AppSchema', () => { + expect(() => AppSchema.parse(STUDIO_APP)).not.toThrow(); + }); +}); + +describe('each ref names a key the pinned console registers (#20142)', () => { + it('the two refs this package declares are among the measured keys', () => { + const refs = [ + setupItems().find(({ item }) => item.id === 'nav_ai_approvals')?.item.componentRef, + studioGroup('group_developer').find((i) => i.id === 'nav_integrations')?.componentRef, + ]; + for (const ref of refs) { + expect(MEASURED_CONSOLE_KEYS as readonly (string | undefined)[]).toContain(ref); + } + }); + + it('every measured key is a registry KEY, never a console path', () => { + // objectui#2763's boundary: navigation names a key and the console owns the + // URL it resolves to (`ai:approvals` → `/apps/APP/component/ai/approvals`). + for (const key of MEASURED_CONSOLE_KEYS) { + expect(key).toMatch(/^[a-z0-9_-]+:[a-z0-9_-]+$/); + expect(key).not.toContain('/'); + } + }); +}); diff --git a/packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts b/packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts new file mode 100644 index 00000000000..f3efe2fac94 --- /dev/null +++ b/packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #20142 — the Audit plugin's Setup navigation contribution carries BOTH doors +// onto `sys_audit_log`: the object view and the console's Audit Log page. +// +// The page is registered by the console under the `audit:log` component key +// (objectui `apps/console/src/registerSystemComponents.tsx`, measured at the +// commit objectstack's `.objectui-sha` pins, dd3f7e1be356). Its only in-app +// link was the System Hub card wall, which objectui#10520 retired; without an +// entry naming the key the page is reachable by typed URL alone. objectui pins +// the registration half (`orphanedPageComponentRefs-10520.test.tsx`); this file +// pins the half this repo owns — the ref the entry names. +// +// Why here and not beside the other two entries of #20142 in +// `@objectstack/platform-objects`: the page reads `sys_audit_log`, which this +// plugin owns (ADR-0029 K2), so its entry lives and dies with this plugin the +// way `nav_audit_logs` does — and needs no item gate for the same reason. + +import { describe, it, expect } from 'vitest'; +import { NavigationContributionSchema } from '@objectstack/spec/ui'; +import { AuditPlugin } from './audit-plugin.js'; + +type NavItem = { + id?: string; + type?: string; + label?: string; + objectName?: string; + componentRef?: string; + requiresService?: string; + requiresObject?: string; +}; + +/** Run the plugin's init() and return the manifest it registered. */ +async function registeredManifest(): Promise { + const registered: any[] = []; + const ctx: any = { + logger: { info() {}, warn() {}, error() {}, debug() {} }, + getService: (name: string) => + name === 'manifest' ? { register: (m: unknown) => registered.push(m) } : undefined, + registerService() {}, + hook() {}, + }; + await new AuditPlugin().init(ctx); + expect(registered).toHaveLength(1); + return registered[0]; +} + +describe('AuditPlugin — Setup navigation contribution (#20142)', () => { + it('contributes the object view AND the console page into group_diagnostics, in that order', async () => { + const manifest = await registeredManifest(); + expect(manifest.navigationContributions).toHaveLength(1); + const [contribution] = manifest.navigationContributions; + expect(contribution).toMatchObject({ app: 'setup', group: 'group_diagnostics' }); + // Array order IS rendered order (`applyNavContributions` appends `items` + // verbatim), so the page sits directly under the object view it adds to. + expect((contribution.items as NavItem[]).map((i) => i.id)).toEqual([ + 'nav_audit_logs', + 'nav_audit_log_browser', + ]); + expect(() => NavigationContributionSchema.parse(contribution)).not.toThrow(); + }); + + it('the page entry routes to the `audit:log` registry key', async () => { + const manifest = await registeredManifest(); + const page = (manifest.navigationContributions[0].items as NavItem[]) + .find((i) => i.id === 'nav_audit_log_browser'); + expect(page).toMatchObject({ + type: 'component', + componentRef: 'audit:log', + label: 'Audit Log Browser', + }); + // A registry KEY, never a console path (objectui#2763). + expect(page?.componentRef).toMatch(/^[a-z0-9_-]+:[a-z0-9_-]+$/); + // A component item has no object route to gate, and a contribution exists + // only while this plugin is installed — which is exactly when the page's + // `sys_audit_log` reads can answer. No gate of either kind. + expect(page?.objectName).toBeUndefined(); + expect(page?.requiresObject).toBeUndefined(); + expect(page?.requiresService).toBeUndefined(); + }); + + it('keeps the object view entry unchanged beside it', async () => { + const manifest = await registeredManifest(); + const objectView = (manifest.navigationContributions[0].items as NavItem[]) + .find((i) => i.id === 'nav_audit_logs'); + expect(objectView).toMatchObject({ + type: 'object', + objectName: 'sys_audit_log', + label: 'Audit Logs', + }); + }); +}); From ade04361092afe58063e8336c9e2586311ab3d81 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 17:25:46 +0000 Subject: [PATCH 3/8] wip: composed wire pin for the console-page nav entries Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- ...connect-agent-both-halves-wire.pin.test.ts | 15 +- .../test/console-page-nav-entries.pin.test.ts | 248 ++++++++++++++++++ 2 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 packages/cli/test/console-page-nav-entries.pin.test.ts diff --git a/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts b/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts index 37458a9ba7e..b2b30156617 100644 --- a/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts +++ b/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts @@ -252,10 +252,15 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', ( expect(allowedIds).toContain('nav_connect_agent'); expect(groupChildren(setupAllowed.body?.item, 'group_integrations')) .toContain('nav_connect_agent'); - // The Setup tree the platform admin is served: 9 authored groups less - // `group_approvals`, which nothing contributes into and the filter - // collapses, plus 25 contributed entries plus the MCP one. - expect(allowedIds).toHaveLength(34); + // The Setup tree the platform admin is served: 9 authored groups, plus + // 26 contributed entries plus the MCP one. One of the 26 is + // `nav_ai_approvals` (#20142), the only entry this composition folds + // into `group_approvals` (plugin-approvals is not part of it). It is + // gated on the `ai` service, and it rides through HERE only because + // this harness has no service probe, so ADR-0057 D10 fails open (see + // the header); its absence without that service is pinned where a + // probe is wired, in `console-page-nav-entries.pin.test.ts`. + expect(allowedIds).toHaveLength(36); }); it('the Account half widened NOTHING on Setup — the admin tree is unchanged', async () => { @@ -273,7 +278,7 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', ( expect(withAccountHalf.statusCode).toBe(200); expect(withoutAccountHalf.statusCode).toBe(200); expect(navIds(withAccountHalf.body?.item)).toEqual(navIds(withoutAccountHalf.body?.item)); - expect(navIds(withAccountHalf.body?.item)).toHaveLength(34); + expect(navIds(withAccountHalf.body?.item)).toHaveLength(36); // The mechanism behind the invariance, asserted rather than assumed: // the bundle aims exactly one contribution at `setup` and one at diff --git a/packages/cli/test/console-page-nav-entries.pin.test.ts b/packages/cli/test/console-page-nav-entries.pin.test.ts new file mode 100644 index 00000000000..9759bd6be48 --- /dev/null +++ b/packages/cli/test/console-page-nav-entries.pin.test.ts @@ -0,0 +1,248 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #20142 — the three console pages that lost their only in-app link when +// objectui#10520 retired the System Hub card wall and the Developer Hub, read +// back OVER THE WIRE from the composed Setup and Studio apps. +// +// --------------------------------------------------------------------------- +// What this pins +// --------------------------------------------------------------------------- +// The card's acceptance, in its own words: "each entry resolves to its +// registered page (a `componentRef` that is registered), and the +// `ai:approvals` entry is absent when the `ai` service is not." Three facts: +// +// 1. `nav_audit_log_browser` → `audit:log`, contributed by the REAL +// `AuditPlugin` into `group_diagnostics` beside `nav_audit_logs`; +// 2. `nav_integrations` → `developer:integrations`, in Studio's +// `group_developer`; +// 3. `nav_ai_approvals` → `ai:approvals`, in `group_approvals` — ABSENT from +// the served body on a Community Edition composition, PRESENT once an +// `ai` service is registered. Both readings sit in one `it()`: a gated-off +// entry with no lit counterpart proves the gate held exactly as much as +// it proves the fold never reached the entry. +// +// "Registered" is judged against the keys MEASURED at the commit +// objectstack's `.objectui-sha` pins (dd3f7e1be3561d63267d7162f3fc0ac52e72834d): +// `registerSystemComponents.tsx` registers `audit:log` and `ai:approvals`, +// `registerDeveloperComponents.tsx` registers `developer:integrations`. +// objectui pins its registration half in +// `apps/console/src/__tests__/orphanedPageComponentRefs-10520.test.tsx`. +// +// --------------------------------------------------------------------------- +// The Community Edition composition, and why the service gate is WIRED here +// --------------------------------------------------------------------------- +// The sibling `connect-agent-both-halves-wire.pin.test.ts` deliberately leaves +// the ADR-0057 D10 capability probe unwired, so every `requiresService` gate +// there fails OPEN. This file is the opposite case: the probe is the subject. +// `serviceExistsProvider` answers the way the most generous Community Edition +// boot would — every service slot some open-framework package can fill is +// registered — derived from `CORE_SERVICE_PROVIDER`, the discovery table that +// names each core slot's installable provider. It records none for `ai` +// (`@objectstack/service-ai` is Cloud/Enterprise only), so `ai` is the one name +// this composition cannot answer for. The probe also records what it was +// asked, so a gate that never ran cannot pass as a gate that held. +// +// It lives in `packages/cli/test/` for the reason its sibling states: `cli` is +// the one workspace package that depends on every piece at once — the shells +// and contributions (`@objectstack/platform-objects`), the contributing plugins +// (`@objectstack/plugin-audit`, `@objectstack/plugin-approvals`), the fold +// (`@objectstack/objectql`) and the per-request filter (`@objectstack/rest`). + +import { describe, expect, it, vi } from 'vitest'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { SETUP_APP, SETUP_NAV_CONTRIBUTIONS, STUDIO_APP } from '@objectstack/platform-objects/apps'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals'; +import { RestServer } from '@objectstack/rest'; +import { CORE_SERVICE_PROVIDER } from '@objectstack/spec/system'; + +type AnyRec = Record; + +/** The registry keys the pinned console registers for the three pages. */ +const MEASURED_CONSOLE_KEYS = ['audit:log', 'ai:approvals', 'developer:integrations']; + +/** A plugin's `init()` against a manifest sink — the nav contributions it registers. */ +async function contributionsOf(plugin: { init(ctx: any): Promise | void }): Promise { + const manifests: AnyRec[] = []; + const ctx: AnyRec = { + logger: { info() {}, warn() {}, error() {}, debug() {}, child() { return ctx.logger; } }, + getService: (name: string) => + name === 'manifest' ? { register: (m: AnyRec) => manifests.push(m) } : undefined, + registerService() {}, + hook() {}, + }; + await plugin.init(ctx); + return manifests.flatMap((m) => (m.navigationContributions ?? []) as AnyRec[]); +} + +/** + * The real composition: the Setup and Studio shells, this package's own Setup + * contributions, and the two plugins that contribute into the slots under test. + * `structuredClone` because `registerItem` writes the `_lock` envelope onto + * what it is handed (ADR-0010 §3.7). + */ +async function composedRegistry(): Promise { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + (registry as AnyRec).logLevel = 'silent'; + registry.registerApp(structuredClone(SETUP_APP), '@objectstack/platform-objects'); + registry.registerApp(structuredClone(STUDIO_APP), '@objectstack/platform-objects'); + for (const c of SETUP_NAV_CONTRIBUTIONS) { + registry.registerAppNavContribution(c as any, '@objectstack/platform-objects'); + } + for (const c of await contributionsOf(new AuditPlugin())) { + registry.registerAppNavContribution(c as any, '@objectstack/plugin-audit'); + } + for (const c of await contributionsOf(new ApprovalsServicePlugin({ disableService: true }))) { + registry.registerAppNavContribution(c as any, '@objectstack/plugin-approvals'); + } + return registry; +} + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeRes() { + const res: AnyRec = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: unknown) => { res.body = b; return res; }); + res.header = vi.fn(); res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); + return res; +} + +/** + * A `RestServer` serving the composed apps to a platform admin, with the + * capability probe answering from `serviceExists` and recording every name + * it was asked about. + */ +async function serve(serviceExists: (name: string) => boolean) { + const registry = await composedRegistry(); + const probed: string[] = []; + const protocol: AnyRec = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async ({ type }: AnyRec) => { + const t = String(type ?? ''); + return t === 'app' || t === 'apps' ? registry.getAllApps() : []; + }), + getMetaItem: vi.fn(async ({ name }: AnyRec) => { + const item = registry.getApp(String(name)); + return item ? { type: 'app', name, item } : undefined; + }), + findData: vi.fn().mockResolvedValue([]), + }; + const rest: AnyRec = new RestServer( + createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any, + ); + rest.resolveExecCtx = async () => ({ + userId: 'u1', + systemPermissions: ['setup.access', 'studio.access', 'manage_platform_settings'], + }); + rest.serviceExistsProvider = (name: string) => { probed.push(name); return serviceExists(name); }; + rest.registerRoutes(); + return { rest, probed }; +} + +/** `GET /api/v1/meta/apps/:name`, answered as `{ statusCode, body }`. */ +async function getApp(rest: AnyRec, name: string) { + const route = rest.getRoutes().find( + (r: AnyRec) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name', + ); + if (!route) throw new Error('meta/:type/:name route not registered'); + const res = makeRes(); + await route.handler( + { method: 'GET', params: { type: 'apps', name }, query: {}, body: {}, headers: {} }, res, + ); + return res; +} + +/** The served children of one nav group, or `undefined` when the group is not served. */ +function group(app: AnyRec | undefined, groupId: string): AnyRec[] | undefined { + const found = ((app?.navigation ?? []) as AnyRec[]).find((g) => g?.id === groupId); + return found ? ((found.children ?? []) as AnyRec[]) : undefined; +} + +const ids = (items: AnyRec[] | undefined) => (items ?? []).map((i) => String(i?.id)); + +/** The most generous Community Edition: every slot an open package can fill is filled. */ +const communityEdition = (name: string) => CORE_SERVICE_PROVIDER[name] !== null; + +describe('#20142 — the console pages\' nav entries, served from the composed apps', () => { + it('ai:approvals is absent on a Community Edition composition and present once `ai` is registered', async () => { + // The premise the CE probe is built on, asserted rather than assumed. + expect(CORE_SERVICE_PROVIDER.ai).toBeNull(); + + // ── Community Edition: no `ai` service ───────────────────────────── + const ce = await serve(communityEdition); + const ceSetup = await getApp(ce.rest, 'setup'); + expect(ceSetup.statusCode).toBe(200); + // The gate RAN: the probe was asked about `ai`. Without this, an + // unwired probe (fail-open) and a held gate read the same way below. + expect(ce.probed).toContain('ai'); + // Absent from the served tree AND from the wire bytes. + expect(ids(group(ceSetup.body?.item, 'group_approvals'))).toEqual([ + 'nav_approvals_inbox', + 'nav_approval_requests', + 'nav_approval_actions', + 'nav_approval_delegations', + ]); + expect(JSON.stringify(ceSetup.body)).not.toContain('ai:approvals'); + + // ── Cloud / Enterprise: `ai` registered — the lit counterpart ────── + const cloud = await serve(() => true); + const cloudSetup = await getApp(cloud.rest, 'setup'); + expect(cloudSetup.statusCode).toBe(200); + const approvals = group(cloudSetup.body?.item, 'group_approvals'); + // Last in the slot: plugin-approvals' inbox stays the first entry. + expect(ids(approvals)).toEqual([ + 'nav_approvals_inbox', + 'nav_approval_requests', + 'nav_approval_actions', + 'nav_approval_delegations', + 'nav_ai_approvals', + ]); + expect(approvals?.at(-1)).toMatchObject({ type: 'component', componentRef: 'ai:approvals' }); + }); + + it('audit:log is served beside the Audit Logs object view, with no gate', async () => { + const { rest } = await serve(communityEdition); + const setup = await getApp(rest, 'setup'); + expect(setup.statusCode).toBe(200); + const diagnostics = group(setup.body?.item, 'group_diagnostics'); + const at = ids(diagnostics).indexOf('nav_audit_logs'); + expect(at, 'nav_audit_logs is served in group_diagnostics').toBeGreaterThanOrEqual(0); + expect(diagnostics?.[at + 1]).toMatchObject({ + id: 'nav_audit_log_browser', + type: 'component', + componentRef: 'audit:log', + }); + }); + + it('developer:integrations is served last in Studio\'s Developer group', async () => { + const { rest } = await serve(communityEdition); + const studio = await getApp(rest, 'studio'); + expect(studio.statusCode).toBe(200); + const developer = group(studio.body?.item, 'group_developer'); + expect(ids(developer)).toEqual([ + 'nav_api_console', 'nav_flow_runs', 'nav_public_forms', 'nav_integrations', + ]); + expect(developer?.at(-1)).toMatchObject({ type: 'component', componentRef: 'developer:integrations' }); + }); + + it('every served ref of the three entries is a key the pinned console registers', async () => { + const { rest } = await serve(() => true); + const served = [ + ...((await getApp(rest, 'setup')).body?.item?.navigation ?? []), + ...((await getApp(rest, 'studio')).body?.item?.navigation ?? []), + ].flatMap((g: AnyRec) => (g?.children ?? []) as AnyRec[]); + const refs = served + .filter((i) => ['nav_audit_log_browser', 'nav_ai_approvals', 'nav_integrations'].includes(i?.id)) + .map((i) => i.componentRef); + expect(refs.sort()).toEqual([...MEASURED_CONSOLE_KEYS].sort()); + }); +}); From 79f7e023ed102469d170ff8bed2ce8f4cf1064cb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 17:30:01 +0000 Subject: [PATCH 4/8] wip: changeset Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- .changeset/20142-console-page-nav-entries.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/20142-console-page-nav-entries.md diff --git a/.changeset/20142-console-page-nav-entries.md b/.changeset/20142-console-page-nav-entries.md new file mode 100644 index 00000000000..fbcfa1dd89e --- /dev/null +++ b/.changeset/20142-console-page-nav-entries.md @@ -0,0 +1,20 @@ +--- +'@objectstack/platform-objects': patch +'@objectstack/plugin-audit': patch +--- + +fix(platform-objects,plugin-audit): Setup and Studio navigation entries for the console's Audit Log, AI Approvals and Integrations & APIs pages (#20142) + +The console retired its System Hub card wall and its Developer Hub, which had been the only in-app links to three pages, and registered each page under a component-registry key instead. Framework navigation reaches a console page only through a `type: 'component'` item that names such a key, and no item named these three, so each page was reachable only by a typed URL. Three entries now name them: + +| Entry | App / group | `componentRef` | Contributed by | Gate | +| --- | --- | --- | --- | --- | +| `nav_audit_log_browser` ("Audit Log Browser") | Setup / Diagnostics, directly under Audit Logs | `audit:log` | `@objectstack/plugin-audit` | none: it lives and dies with the plugin that owns `sys_audit_log` | +| `nav_ai_approvals` ("AI Approvals") | Setup / Approvals, after the approvals engine's entries | `ai:approvals` | `@objectstack/platform-objects` | `requiresService: 'ai'` | +| `nav_integrations` ("Integrations & APIs") | Studio / Developer, after Public Forms | `developer:integrations` | `@objectstack/platform-objects` | none beyond Studio's own `studio.access` | + +**Two audit entries, on purpose.** The existing Audit Logs entry (the `sys_audit_log` object view) stays. It carries the named list views, search, and the actor and tenant rendered as resolved lookups. The new page adds one filterable table whose detail drawer pretty-prints a change's before and after JSON, where the record page shows `old_value` / `new_value` as raw text. Neither surface replaces the other. + +**The `ai` gate.** The `ai` service has no implementation in the open framework (it is provided by `@objectstack/service-ai` in Cloud/Enterprise), and the AI Approvals page reads only that service. So on a Community Edition runtime the entry is removed server-side, before the app metadata reaches the browser, and it appears once an `ai` service is registered. + +The keys are the ones the console registers at the objectui commit this release's console is built from. Labels ship in all four locales (en, zh-CN, ja-JP, es-ES), with their source hashes recorded. Nothing is removed or renamed, and there is nothing to migrate. From 1d5520a0764999517da14909a6be723889f47a98 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 17:42:33 +0000 Subject: [PATCH 5/8] wip: narrow the cli composed pin to the platform-objects entries Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- .../test/console-page-nav-entries.pin.test.ts | 115 +++++++----------- 1 file changed, 41 insertions(+), 74 deletions(-) diff --git a/packages/cli/test/console-page-nav-entries.pin.test.ts b/packages/cli/test/console-page-nav-entries.pin.test.ts index 9759bd6be48..1ad0d214ed3 100644 --- a/packages/cli/test/console-page-nav-entries.pin.test.ts +++ b/packages/cli/test/console-page-nav-entries.pin.test.ts @@ -9,17 +9,24 @@ // --------------------------------------------------------------------------- // The card's acceptance, in its own words: "each entry resolves to its // registered page (a `componentRef` that is registered), and the -// `ai:approvals` entry is absent when the `ai` service is not." Three facts: +// `ai:approvals` entry is absent when the `ai` service is not." For the two +// entries `@objectstack/platform-objects` declares: // -// 1. `nav_audit_log_browser` → `audit:log`, contributed by the REAL -// `AuditPlugin` into `group_diagnostics` beside `nav_audit_logs`; -// 2. `nav_integrations` → `developer:integrations`, in Studio's -// `group_developer`; -// 3. `nav_ai_approvals` → `ai:approvals`, in `group_approvals` — ABSENT from +// 1. `nav_ai_approvals` → `ai:approvals`, in `group_approvals` — ABSENT from // the served body on a Community Edition composition, PRESENT once an // `ai` service is registered. Both readings sit in one `it()`: a gated-off // entry with no lit counterpart proves the gate held exactly as much as // it proves the fold never reached the entry. +// 2. `nav_integrations` → `developer:integrations`, in Studio's +// `group_developer`. +// +// The third entry, `nav_audit_log_browser` → `audit:log`, carries no gate and +// is contributed by `@objectstack/plugin-audit`; its ref is pinned beside it in +// `packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts`. It is not +// folded in here because importing that plugin (or plugin-approvals) from this +// package's tests resolves to its `dist/`, which `check:test-source-alias` +// refuses for a new import — and nothing about an ungated entry needs the +// probe this file exists to wire. // // "Registered" is judged against the keys MEASURED at the commit // objectstack's `.objectui-sha` pins (dd3f7e1be3561d63267d7162f3fc0ac52e72834d): @@ -44,15 +51,12 @@ // // It lives in `packages/cli/test/` for the reason its sibling states: `cli` is // the one workspace package that depends on every piece at once — the shells -// and contributions (`@objectstack/platform-objects`), the contributing plugins -// (`@objectstack/plugin-audit`, `@objectstack/plugin-approvals`), the fold +// and contributions (`@objectstack/platform-objects`), the fold // (`@objectstack/objectql`) and the per-request filter (`@objectstack/rest`). import { describe, expect, it, vi } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; import { SETUP_APP, SETUP_NAV_CONTRIBUTIONS, STUDIO_APP } from '@objectstack/platform-objects/apps'; -import { AuditPlugin } from '@objectstack/plugin-audit'; -import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals'; import { RestServer } from '@objectstack/rest'; import { CORE_SERVICE_PROVIDER } from '@objectstack/spec/system'; @@ -61,27 +65,13 @@ type AnyRec = Record; /** The registry keys the pinned console registers for the three pages. */ const MEASURED_CONSOLE_KEYS = ['audit:log', 'ai:approvals', 'developer:integrations']; -/** A plugin's `init()` against a manifest sink — the nav contributions it registers. */ -async function contributionsOf(plugin: { init(ctx: any): Promise | void }): Promise { - const manifests: AnyRec[] = []; - const ctx: AnyRec = { - logger: { info() {}, warn() {}, error() {}, debug() {}, child() { return ctx.logger; } }, - getService: (name: string) => - name === 'manifest' ? { register: (m: AnyRec) => manifests.push(m) } : undefined, - registerService() {}, - hook() {}, - }; - await plugin.init(ctx); - return manifests.flatMap((m) => (m.navigationContributions ?? []) as AnyRec[]); -} - /** - * The real composition: the Setup and Studio shells, this package's own Setup - * contributions, and the two plugins that contribute into the slots under test. - * `structuredClone` because `registerItem` writes the `_lock` envelope onto - * what it is handed (ADR-0010 §3.7). + * The real composition: the Setup and Studio shells and the Setup + * contributions `@objectstack/platform-objects` ships. `structuredClone` + * because `registerItem` writes the `_lock` envelope onto what it is handed + * (ADR-0010 §3.7). */ -async function composedRegistry(): Promise { +function composedRegistry(): SchemaRegistry { const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); (registry as AnyRec).logLevel = 'silent'; registry.registerApp(structuredClone(SETUP_APP), '@objectstack/platform-objects'); @@ -89,12 +79,6 @@ async function composedRegistry(): Promise { for (const c of SETUP_NAV_CONTRIBUTIONS) { registry.registerAppNavContribution(c as any, '@objectstack/platform-objects'); } - for (const c of await contributionsOf(new AuditPlugin())) { - registry.registerAppNavContribution(c as any, '@objectstack/plugin-audit'); - } - for (const c of await contributionsOf(new ApprovalsServicePlugin({ disableService: true }))) { - registry.registerAppNavContribution(c as any, '@objectstack/plugin-approvals'); - } return registry; } @@ -118,8 +102,8 @@ function makeRes() { * capability probe answering from `serviceExists` and recording every name * it was asked about. */ -async function serve(serviceExists: (name: string) => boolean) { - const registry = await composedRegistry(); +function serve(serviceExists: (name: string) => boolean) { + const registry = composedRegistry(); const probed: string[] = []; const protocol: AnyRec = { getDiscovery: vi.fn().mockResolvedValue({ @@ -178,53 +162,35 @@ describe('#20142 — the console pages\' nav entries, served from the composed a expect(CORE_SERVICE_PROVIDER.ai).toBeNull(); // ── Community Edition: no `ai` service ───────────────────────────── - const ce = await serve(communityEdition); + const ce = serve(communityEdition); const ceSetup = await getApp(ce.rest, 'setup'); expect(ceSetup.statusCode).toBe(200); // The gate RAN: the probe was asked about `ai`. Without this, an // unwired probe (fail-open) and a held gate read the same way below. expect(ce.probed).toContain('ai'); - // Absent from the served tree AND from the wire bytes. - expect(ids(group(ceSetup.body?.item, 'group_approvals'))).toEqual([ - 'nav_approvals_inbox', - 'nav_approval_requests', - 'nav_approval_actions', - 'nav_approval_delegations', - ]); + // Absent from the served tree AND from the wire bytes. `nav_ai_approvals` + // is the only entry this composition folds into `group_approvals` + // (plugin-approvals is not part of it), so stripping it leaves the group + // with nothing to serve. + expect(ids(group(ceSetup.body?.item, 'group_approvals'))).toEqual([]); + expect(JSON.stringify(ceSetup.body)).not.toContain('nav_ai_approvals'); expect(JSON.stringify(ceSetup.body)).not.toContain('ai:approvals'); + // The rest of the served Setup tree is untouched by the gate: a + // neighbouring component entry from the same contributor still arrives. + expect(ids(group(ceSetup.body?.item, 'group_apps'))).toContain('nav_packaged_automation'); // ── Cloud / Enterprise: `ai` registered — the lit counterpart ────── - const cloud = await serve(() => true); + const cloud = serve(() => true); const cloudSetup = await getApp(cloud.rest, 'setup'); expect(cloudSetup.statusCode).toBe(200); + expect(cloud.probed).toContain('ai'); const approvals = group(cloudSetup.body?.item, 'group_approvals'); - // Last in the slot: plugin-approvals' inbox stays the first entry. - expect(ids(approvals)).toEqual([ - 'nav_approvals_inbox', - 'nav_approval_requests', - 'nav_approval_actions', - 'nav_approval_delegations', - 'nav_ai_approvals', - ]); - expect(approvals?.at(-1)).toMatchObject({ type: 'component', componentRef: 'ai:approvals' }); - }); - - it('audit:log is served beside the Audit Logs object view, with no gate', async () => { - const { rest } = await serve(communityEdition); - const setup = await getApp(rest, 'setup'); - expect(setup.statusCode).toBe(200); - const diagnostics = group(setup.body?.item, 'group_diagnostics'); - const at = ids(diagnostics).indexOf('nav_audit_logs'); - expect(at, 'nav_audit_logs is served in group_diagnostics').toBeGreaterThanOrEqual(0); - expect(diagnostics?.[at + 1]).toMatchObject({ - id: 'nav_audit_log_browser', - type: 'component', - componentRef: 'audit:log', - }); + expect(ids(approvals)).toEqual(['nav_ai_approvals']); + expect(approvals?.[0]).toMatchObject({ type: 'component', componentRef: 'ai:approvals' }); }); it('developer:integrations is served last in Studio\'s Developer group', async () => { - const { rest } = await serve(communityEdition); + const { rest } = serve(communityEdition); const studio = await getApp(rest, 'studio'); expect(studio.statusCode).toBe(200); const developer = group(studio.body?.item, 'group_developer'); @@ -234,15 +200,16 @@ describe('#20142 — the console pages\' nav entries, served from the composed a expect(developer?.at(-1)).toMatchObject({ type: 'component', componentRef: 'developer:integrations' }); }); - it('every served ref of the three entries is a key the pinned console registers', async () => { - const { rest } = await serve(() => true); + it('every served ref of the two entries is a key the pinned console registers', async () => { + const { rest } = serve(() => true); const served = [ ...((await getApp(rest, 'setup')).body?.item?.navigation ?? []), ...((await getApp(rest, 'studio')).body?.item?.navigation ?? []), ].flatMap((g: AnyRec) => (g?.children ?? []) as AnyRec[]); const refs = served - .filter((i) => ['nav_audit_log_browser', 'nav_ai_approvals', 'nav_integrations'].includes(i?.id)) + .filter((i) => ['nav_ai_approvals', 'nav_integrations'].includes(i?.id)) .map((i) => i.componentRef); - expect(refs.sort()).toEqual([...MEASURED_CONSOLE_KEYS].sort()); + expect(refs).toHaveLength(2); + for (const ref of refs) expect(MEASURED_CONSOLE_KEYS).toContain(ref); }); }); From 874c41989d6fc3eb06a1d6c27a609955c2dac278 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:05:00 +0000 Subject: [PATCH 6/8] fix(platform-objects): name the reading behind the ai gate's 501 note Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- packages/platform-objects/src/apps/setup-nav.contributions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index 697fdee0272..9f0764be55b 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -124,7 +124,8 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ // `requiresService: 'ai'` is load-bearing, not decoration. The `ai` slot // has no implementation in the open framework (`CORE_SERVICE_PROVIDER.ai` // is `null` — `@objectstack/service-ai` ships in Cloud/Enterprise), so on - // a Community Edition boot the page's only data source answers 501. + // a Community Edition boot the page's only data source answers 501 (the + // platform checklist's FOLLOW-UPS row K2 records that reading). // The gate is stripped server-side by `filterAppForUser` (ADR-0057 D10), // so the entry never reaches a browser whose runtime cannot serve it. // Same shape as `nav_account_approvals`' `requiresService: 'approvals'`. From 085c4ddb1db1e44315c5c07da0d4b5a77652feda Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:35:59 +0000 Subject: [PATCH 7/8] =?UTF-8?q?fix(platform-objects):=20drop=20the=20ai:ap?= =?UTF-8?q?provals=20entry=20=E2=80=94=20it=20is=20the=20AI=20capability's?= =?UTF-8?q?=20to=20contribute=20(ADR-0029=20D7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores platform-objects.test.ts's K2.b slot pin and the Connect-an-Agent Setup tree count to main, removes nav_ai_approvals with its label leaves and source hashes, and narrows the new pins to the two entries this repo ships. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- .changeset/20142-console-page-nav-entries.md | 7 +- ...connect-agent-both-halves-wire.pin.test.ts | 15 +- .../test/console-page-nav-entries.pin.test.ts | 161 +++++------------- .../src/apps/console-page-nav-entries.test.ts | 95 +++-------- .../src/apps/setup-nav.contributions.ts | 33 +--- .../src/apps/translations/en.ts | 3 - .../apps/translations/es-ES.source-hashes.ts | 1 - .../src/apps/translations/es-ES.ts | 1 - .../apps/translations/ja-JP.source-hashes.ts | 1 - .../src/apps/translations/ja-JP.ts | 1 - .../apps/translations/zh-CN.source-hashes.ts | 1 - .../src/apps/translations/zh-CN.ts | 1 - .../src/platform-objects.test.ts | 29 +--- .../src/audit-nav-contribution.test.ts | 2 +- 14 files changed, 82 insertions(+), 269 deletions(-) diff --git a/.changeset/20142-console-page-nav-entries.md b/.changeset/20142-console-page-nav-entries.md index fbcfa1dd89e..b7535988f7c 100644 --- a/.changeset/20142-console-page-nav-entries.md +++ b/.changeset/20142-console-page-nav-entries.md @@ -3,18 +3,17 @@ '@objectstack/plugin-audit': patch --- -fix(platform-objects,plugin-audit): Setup and Studio navigation entries for the console's Audit Log, AI Approvals and Integrations & APIs pages (#20142) +fix(platform-objects,plugin-audit): Setup and Studio navigation entries for the console's Audit Log and Integrations & APIs pages (#20142) -The console retired its System Hub card wall and its Developer Hub, which had been the only in-app links to three pages, and registered each page under a component-registry key instead. Framework navigation reaches a console page only through a `type: 'component'` item that names such a key, and no item named these three, so each page was reachable only by a typed URL. Three entries now name them: +The console retired its System Hub card wall and its Developer Hub, which had been the only in-app links to several pages, and registered each page under a component-registry key instead. Framework navigation reaches a console page only through a `type: 'component'` item that names such a key, and no item named them, so each page was reachable only by a typed URL. Two entries now name the pages whose capability ships in the open framework: | Entry | App / group | `componentRef` | Contributed by | Gate | | --- | --- | --- | --- | --- | | `nav_audit_log_browser` ("Audit Log Browser") | Setup / Diagnostics, directly under Audit Logs | `audit:log` | `@objectstack/plugin-audit` | none: it lives and dies with the plugin that owns `sys_audit_log` | -| `nav_ai_approvals` ("AI Approvals") | Setup / Approvals, after the approvals engine's entries | `ai:approvals` | `@objectstack/platform-objects` | `requiresService: 'ai'` | | `nav_integrations` ("Integrations & APIs") | Studio / Developer, after Public Forms | `developer:integrations` | `@objectstack/platform-objects` | none beyond Studio's own `studio.access` | **Two audit entries, on purpose.** The existing Audit Logs entry (the `sys_audit_log` object view) stays. It carries the named list views, search, and the actor and tenant rendered as resolved lookups. The new page adds one filterable table whose detail drawer pretty-prints a change's before and after JSON, where the record page shows `old_value` / `new_value` as raw text. Neither surface replaces the other. -**The `ai` gate.** The `ai` service has no implementation in the open framework (it is provided by `@objectstack/service-ai` in Cloud/Enterprise), and the AI Approvals page reads only that service. So on a Community Edition runtime the entry is removed server-side, before the app metadata reaches the browser, and it appears once an `ai` service is registered. +**No entry for the console's AI Approvals page (`ai:approvals`) here.** Under ADR-0029 D7, each capability plugin contributes its own navigation entries into a Setup slot, and the Setup shell does not enumerate capability objects. The AI pending-action queue belongs to the AI capability, whose provider (`@objectstack/service-ai`) ships in Cloud/Enterprise, not in the open framework. Its entry is therefore that capability's to contribute. The keys are the ones the console registers at the objectui commit this release's console is built from. Labels ship in all four locales (en, zh-CN, ja-JP, es-ES), with their source hashes recorded. Nothing is removed or renamed, and there is nothing to migrate. diff --git a/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts b/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts index b2b30156617..37458a9ba7e 100644 --- a/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts +++ b/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts @@ -252,15 +252,10 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', ( expect(allowedIds).toContain('nav_connect_agent'); expect(groupChildren(setupAllowed.body?.item, 'group_integrations')) .toContain('nav_connect_agent'); - // The Setup tree the platform admin is served: 9 authored groups, plus - // 26 contributed entries plus the MCP one. One of the 26 is - // `nav_ai_approvals` (#20142), the only entry this composition folds - // into `group_approvals` (plugin-approvals is not part of it). It is - // gated on the `ai` service, and it rides through HERE only because - // this harness has no service probe, so ADR-0057 D10 fails open (see - // the header); its absence without that service is pinned where a - // probe is wired, in `console-page-nav-entries.pin.test.ts`. - expect(allowedIds).toHaveLength(36); + // The Setup tree the platform admin is served: 9 authored groups less + // `group_approvals`, which nothing contributes into and the filter + // collapses, plus 25 contributed entries plus the MCP one. + expect(allowedIds).toHaveLength(34); }); it('the Account half widened NOTHING on Setup — the admin tree is unchanged', async () => { @@ -278,7 +273,7 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', ( expect(withAccountHalf.statusCode).toBe(200); expect(withoutAccountHalf.statusCode).toBe(200); expect(navIds(withAccountHalf.body?.item)).toEqual(navIds(withoutAccountHalf.body?.item)); - expect(navIds(withAccountHalf.body?.item)).toHaveLength(36); + expect(navIds(withAccountHalf.body?.item)).toHaveLength(34); // The mechanism behind the invariance, asserted rather than assumed: // the bundle aims exactly one contribution at `setup` and one at diff --git a/packages/cli/test/console-page-nav-entries.pin.test.ts b/packages/cli/test/console-page-nav-entries.pin.test.ts index 1ad0d214ed3..cf6c39eee3e 100644 --- a/packages/cli/test/console-page-nav-entries.pin.test.ts +++ b/packages/cli/test/console-page-nav-entries.pin.test.ts @@ -1,84 +1,62 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// #20142 — the three console pages that lost their only in-app link when -// objectui#10520 retired the System Hub card wall and the Developer Hub, read -// back OVER THE WIRE from the composed Setup and Studio apps. +// #20142 — the Integrations & APIs page, one of the console pages that lost +// their only in-app link when objectui#10520 retired the Developer Hub, read +// back OVER THE WIRE from the composed Studio app. // // --------------------------------------------------------------------------- // What this pins // --------------------------------------------------------------------------- -// The card's acceptance, in its own words: "each entry resolves to its -// registered page (a `componentRef` that is registered), and the -// `ai:approvals` entry is absent when the `ai` service is not." For the two -// entries `@objectstack/platform-objects` declares: +// The card's acceptance: "each entry resolves to its registered page (a +// `componentRef` that is registered)". For the entry +// `@objectstack/platform-objects` declares — `nav_integrations` → +// `developer:integrations`, last in Studio's `group_developer` — this file +// reads it from the served body of `GET /api/v1/meta/:type/:name`, after the +// real `SchemaRegistry` registration and the real per-request filter, so a +// regression anywhere between the declaration and the wire turns it red. // -// 1. `nav_ai_approvals` → `ai:approvals`, in `group_approvals` — ABSENT from -// the served body on a Community Edition composition, PRESENT once an -// `ai` service is registered. Both readings sit in one `it()`: a gated-off -// entry with no lit counterpart proves the gate held exactly as much as -// it proves the fold never reached the entry. -// 2. `nav_integrations` → `developer:integrations`, in Studio's -// `group_developer`. -// -// The third entry, `nav_audit_log_browser` → `audit:log`, carries no gate and -// is contributed by `@objectstack/plugin-audit`; its ref is pinned beside it in -// `packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts`. It is not -// folded in here because importing that plugin (or plugin-approvals) from this -// package's tests resolves to its `dist/`, which `check:test-source-alias` -// refuses for a new import — and nothing about an ungated entry needs the -// probe this file exists to wire. +// The other two pages are pinned where they belong: +// - `nav_audit_log_browser` → `audit:log` is contributed by +// `@objectstack/plugin-audit`; its ref is pinned beside it in +// `packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts`. +// Importing that plugin from this package's tests would resolve to its +// `dist/`, which `check:test-source-alias` refuses for a new import. +// - `ai:approvals` is not this repository's entry: ADR-0029 D7 has each +// capability plugin contribute its own navigation, and the `ai` +// capability's owner is `@objectstack/service-ai` in Cloud/Enterprise. // // "Registered" is judged against the keys MEASURED at the commit // objectstack's `.objectui-sha` pins (dd3f7e1be3561d63267d7162f3fc0ac52e72834d): -// `registerSystemComponents.tsx` registers `audit:log` and `ai:approvals`, -// `registerDeveloperComponents.tsx` registers `developer:integrations`. -// objectui pins its registration half in +// `registerDeveloperComponents.tsx` registers `developer:integrations` and +// `registerSystemComponents.tsx` registers `audit:log`. objectui pins its +// registration half in // `apps/console/src/__tests__/orphanedPageComponentRefs-10520.test.tsx`. // -// --------------------------------------------------------------------------- -// The Community Edition composition, and why the service gate is WIRED here -// --------------------------------------------------------------------------- -// The sibling `connect-agent-both-halves-wire.pin.test.ts` deliberately leaves -// the ADR-0057 D10 capability probe unwired, so every `requiresService` gate -// there fails OPEN. This file is the opposite case: the probe is the subject. -// `serviceExistsProvider` answers the way the most generous Community Edition -// boot would — every service slot some open-framework package can fill is -// registered — derived from `CORE_SERVICE_PROVIDER`, the discovery table that -// names each core slot's installable provider. It records none for `ai` -// (`@objectstack/service-ai` is Cloud/Enterprise only), so `ai` is the one name -// this composition cannot answer for. The probe also records what it was -// asked, so a gate that never ran cannot pass as a gate that held. -// -// It lives in `packages/cli/test/` for the reason its sibling states: `cli` is -// the one workspace package that depends on every piece at once — the shells -// and contributions (`@objectstack/platform-objects`), the fold -// (`@objectstack/objectql`) and the per-request filter (`@objectstack/rest`). +// It lives in `packages/cli/test/` for the reason its sibling +// `connect-agent-both-halves-wire.pin.test.ts` states: `cli` is the one +// workspace package that depends on every piece at once — the app shells +// (`@objectstack/platform-objects`), the registry (`@objectstack/objectql`) and +// the per-request filter (`@objectstack/rest`). import { describe, expect, it, vi } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; -import { SETUP_APP, SETUP_NAV_CONTRIBUTIONS, STUDIO_APP } from '@objectstack/platform-objects/apps'; +import { STUDIO_APP } from '@objectstack/platform-objects/apps'; import { RestServer } from '@objectstack/rest'; -import { CORE_SERVICE_PROVIDER } from '@objectstack/spec/system'; type AnyRec = Record; -/** The registry keys the pinned console registers for the three pages. */ -const MEASURED_CONSOLE_KEYS = ['audit:log', 'ai:approvals', 'developer:integrations']; +/** The registry keys the pinned console registers for the pages this repo links. */ +const MEASURED_CONSOLE_KEYS = ['audit:log', 'developer:integrations']; /** - * The real composition: the Setup and Studio shells and the Setup - * contributions `@objectstack/platform-objects` ships. `structuredClone` - * because `registerItem` writes the `_lock` envelope onto what it is handed + * The real registration of the Studio shell. `structuredClone` because + * `registerItem` writes the `_lock` envelope onto what it is handed * (ADR-0010 §3.7). */ function composedRegistry(): SchemaRegistry { const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); (registry as AnyRec).logLevel = 'silent'; - registry.registerApp(structuredClone(SETUP_APP), '@objectstack/platform-objects'); registry.registerApp(structuredClone(STUDIO_APP), '@objectstack/platform-objects'); - for (const c of SETUP_NAV_CONTRIBUTIONS) { - registry.registerAppNavContribution(c as any, '@objectstack/platform-objects'); - } return registry; } @@ -97,14 +75,9 @@ function makeRes() { return res; } -/** - * A `RestServer` serving the composed apps to a platform admin, with the - * capability probe answering from `serviceExists` and recording every name - * it was asked about. - */ -function serve(serviceExists: (name: string) => boolean) { +/** A `RestServer` serving the composed Studio app to a caller holding `studio.access`. */ +function serve() { const registry = composedRegistry(); - const probed: string[] = []; const protocol: AnyRec = { getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, @@ -123,13 +96,9 @@ function serve(serviceExists: (name: string) => boolean) { const rest: AnyRec = new RestServer( createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any, ); - rest.resolveExecCtx = async () => ({ - userId: 'u1', - systemPermissions: ['setup.access', 'studio.access', 'manage_platform_settings'], - }); - rest.serviceExistsProvider = (name: string) => { probed.push(name); return serviceExists(name); }; + rest.resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: ['studio.access'] }); rest.registerRoutes(); - return { rest, probed }; + return rest; } /** `GET /api/v1/meta/apps/:name`, answered as `{ statusCode, body }`. */ @@ -153,45 +122,9 @@ function group(app: AnyRec | undefined, groupId: string): AnyRec[] | undefined { const ids = (items: AnyRec[] | undefined) => (items ?? []).map((i) => String(i?.id)); -/** The most generous Community Edition: every slot an open package can fill is filled. */ -const communityEdition = (name: string) => CORE_SERVICE_PROVIDER[name] !== null; - -describe('#20142 — the console pages\' nav entries, served from the composed apps', () => { - it('ai:approvals is absent on a Community Edition composition and present once `ai` is registered', async () => { - // The premise the CE probe is built on, asserted rather than assumed. - expect(CORE_SERVICE_PROVIDER.ai).toBeNull(); - - // ── Community Edition: no `ai` service ───────────────────────────── - const ce = serve(communityEdition); - const ceSetup = await getApp(ce.rest, 'setup'); - expect(ceSetup.statusCode).toBe(200); - // The gate RAN: the probe was asked about `ai`. Without this, an - // unwired probe (fail-open) and a held gate read the same way below. - expect(ce.probed).toContain('ai'); - // Absent from the served tree AND from the wire bytes. `nav_ai_approvals` - // is the only entry this composition folds into `group_approvals` - // (plugin-approvals is not part of it), so stripping it leaves the group - // with nothing to serve. - expect(ids(group(ceSetup.body?.item, 'group_approvals'))).toEqual([]); - expect(JSON.stringify(ceSetup.body)).not.toContain('nav_ai_approvals'); - expect(JSON.stringify(ceSetup.body)).not.toContain('ai:approvals'); - // The rest of the served Setup tree is untouched by the gate: a - // neighbouring component entry from the same contributor still arrives. - expect(ids(group(ceSetup.body?.item, 'group_apps'))).toContain('nav_packaged_automation'); - - // ── Cloud / Enterprise: `ai` registered — the lit counterpart ────── - const cloud = serve(() => true); - const cloudSetup = await getApp(cloud.rest, 'setup'); - expect(cloudSetup.statusCode).toBe(200); - expect(cloud.probed).toContain('ai'); - const approvals = group(cloudSetup.body?.item, 'group_approvals'); - expect(ids(approvals)).toEqual(['nav_ai_approvals']); - expect(approvals?.[0]).toMatchObject({ type: 'component', componentRef: 'ai:approvals' }); - }); - +describe('#20142 — the Integrations & APIs nav entry, served from the composed Studio app', () => { it('developer:integrations is served last in Studio\'s Developer group', async () => { - const { rest } = serve(communityEdition); - const studio = await getApp(rest, 'studio'); + const studio = await getApp(serve(), 'studio'); expect(studio.statusCode).toBe(200); const developer = group(studio.body?.item, 'group_developer'); expect(ids(developer)).toEqual([ @@ -200,16 +133,12 @@ describe('#20142 — the console pages\' nav entries, served from the composed a expect(developer?.at(-1)).toMatchObject({ type: 'component', componentRef: 'developer:integrations' }); }); - it('every served ref of the two entries is a key the pinned console registers', async () => { - const { rest } = serve(() => true); - const served = [ - ...((await getApp(rest, 'setup')).body?.item?.navigation ?? []), - ...((await getApp(rest, 'studio')).body?.item?.navigation ?? []), - ].flatMap((g: AnyRec) => (g?.children ?? []) as AnyRec[]); - const refs = served - .filter((i) => ['nav_ai_approvals', 'nav_integrations'].includes(i?.id)) - .map((i) => i.componentRef); - expect(refs).toHaveLength(2); - for (const ref of refs) expect(MEASURED_CONSOLE_KEYS).toContain(ref); + it('the served ref is a key the pinned console registers', async () => { + const studio = await getApp(serve(), 'studio'); + const served = ((studio.body?.item?.navigation ?? []) as AnyRec[]) + .flatMap((g) => (g?.children ?? []) as AnyRec[]) + .filter((i) => i?.id === 'nav_integrations'); + expect(served).toHaveLength(1); + expect(MEASURED_CONSOLE_KEYS).toContain(served[0].componentRef); }); }); diff --git a/packages/platform-objects/src/apps/console-page-nav-entries.test.ts b/packages/platform-objects/src/apps/console-page-nav-entries.test.ts index 9fae156b187..b4014ed4116 100644 --- a/packages/platform-objects/src/apps/console-page-nav-entries.test.ts +++ b/packages/platform-objects/src/apps/console-page-nav-entries.test.ts @@ -1,16 +1,21 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// #20142 — the framework half of three console pages' navigation contract. +// #20142 — the framework half of the console pages' navigation contract, for +// the entry this package declares. // // objectui#10520 (PR objectui#10576) retired the console's System Hub card wall // and its Developer Hub, which had been the only in-app links to three pages, // and registered each page under a component-registry key instead. Framework // navigation reaches a console page only through a `type: 'component'` item // whose `componentRef` is such a key, so until an entry names the key the page -// is reachable by typed URL alone. This file pins the two entries this package -// declares; `audit:log` is contributed by `@objectstack/plugin-audit` (it lives -// and dies with that plugin) and is pinned beside it in -// `packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts`. +// is reachable by typed URL alone. Of the three: +// - `developer:integrations` is Studio's, declared here and pinned below; +// - `audit:log` is contributed by `@objectstack/plugin-audit` (it lives and +// dies with that plugin) and is pinned beside it in +// `packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts`; +// - `ai:approvals` is NOT this repository's to contribute: ADR-0029 D7 has +// each capability plugin contribute its own entries, and the `ai` +// capability's owner is `@objectstack/service-ai` in Cloud/Enterprise. // // Why a pin rather than a code comment: `componentRef` is a free string, and // `validate-nav-target-refs` deliberately does not resolve component refs (the @@ -22,38 +27,29 @@ // // The keys below were MEASURED, not recalled: read from objectui at the commit // objectstack's `.objectui-sha` pins (dd3f7e1be3561d63267d7162f3fc0ac52e72834d), -// where `registerSystemComponents.tsx` registers `audit:log` and `ai:approvals` -// and `registerDeveloperComponents.tsx` registers `developer:integrations`, -// all three imported for their side effect by `apps/console/src/main.tsx`. -// A pin bump that renames one of them must change this list in the same PR. +// where `registerSystemComponents.tsx` registers `audit:log` and +// `registerDeveloperComponents.tsx` registers `developer:integrations`, both +// imported for their side effect by `apps/console/src/main.tsx`. A pin bump +// that renames one of them must change this list in the same PR. import { describe, it, expect } from 'vitest'; -import { AppSchema, NavigationContributionSchema } from '@objectstack/spec/ui'; -import { CORE_SERVICE_PROVIDER } from '@objectstack/spec/system'; +import { AppSchema } from '@objectstack/spec/ui'; -import { SETUP_NAV_CONTRIBUTIONS } from './setup-nav.contributions.js'; import { STUDIO_APP } from './studio.app.js'; -/** The registry keys the pinned console registers for the three pages. */ -const MEASURED_CONSOLE_KEYS = ['audit:log', 'ai:approvals', 'developer:integrations'] as const; +/** The registry keys the pinned console registers for the pages this repo links. */ +const MEASURED_CONSOLE_KEYS = ['audit:log', 'developer:integrations'] as const; type NavItem = { id?: string; type?: string; label?: string; - objectName?: string; componentRef?: string; requiresService?: string; requiredPermissions?: string[]; children?: NavItem[]; }; -/** Every contributed Setup nav item, with the group it is contributed into. */ -const setupItems = (): Array<{ group?: string; priority?: number; item: NavItem }> => - SETUP_NAV_CONTRIBUTIONS.flatMap((c) => - ((c.items ?? []) as NavItem[]).map((item) => ({ group: c.group, priority: c.priority, item })), - ); - /** The children of one Studio group, by id. */ const studioGroup = (id: string): NavItem[] => { const group = ((STUDIO_APP.navigation ?? []) as NavItem[]).find((g) => g.id === id); @@ -61,49 +57,6 @@ const studioGroup = (id: string): NavItem[] => { return group?.children ?? []; }; -describe('the AI Approvals entry targets the console page and is gated on the ai service (#20142)', () => { - const entry = () => { - const found = setupItems().find(({ item }) => item.id === 'nav_ai_approvals'); - expect(found, 'Setup lost its nav_ai_approvals entry').toBeDefined(); - return found!; - }; - - it('routes to the `ai:approvals` registry key, in group_approvals', () => { - expect(entry().item).toMatchObject({ - type: 'component', - componentRef: 'ai:approvals', - label: 'AI Approvals', - }); - expect(entry().group).toBe('group_approvals'); - // The component item has no object route, so an object gate would be a - // dead key that reads as one. - expect(entry().item.objectName).toBeUndefined(); - }); - - it('comes after plugin-approvals\' own entries (priority 100), so the inbox stays first', () => { - expect(entry().priority).toBeGreaterThan(100); - }); - - it('is gated on the `ai` service, which no open-framework package provides', () => { - expect(entry().item.requiresService).toBe('ai'); - // The premise that makes the gate load-bearing: the discovery table that - // names each core slot's installable provider records NONE for `ai` - // (`@objectstack/service-ai` is Cloud/Enterprise only). So a Community - // Edition boot registers no `ai` service and `filterAppForUser` strips the - // entry server-side (ADR-0057 D10). If an open provider ever ships, this - // premise changes and the gate's purpose must be re-read, not the entry - // silently left to render. - expect(CORE_SERVICE_PROVIDER.ai).toBeNull(); - }); - - it('parses as a NavigationContribution the runtime merge accepts', () => { - const contribution = SETUP_NAV_CONTRIBUTIONS.find((c) => - (c.items ?? []).some((i) => (i as NavItem).id === 'nav_ai_approvals'), - ); - expect(() => NavigationContributionSchema.parse(contribution)).not.toThrow(); - }); -}); - describe('the Integrations & APIs entry targets the console page (#20142)', () => { const entry = (): NavItem => { const found = studioGroup('group_developer').find((i) => i.id === 'nav_integrations'); @@ -136,19 +89,15 @@ describe('the Integrations & APIs entry targets the console page (#20142)', () = }); describe('each ref names a key the pinned console registers (#20142)', () => { - it('the two refs this package declares are among the measured keys', () => { - const refs = [ - setupItems().find(({ item }) => item.id === 'nav_ai_approvals')?.item.componentRef, - studioGroup('group_developer').find((i) => i.id === 'nav_integrations')?.componentRef, - ]; - for (const ref of refs) { - expect(MEASURED_CONSOLE_KEYS as readonly (string | undefined)[]).toContain(ref); - } + it('the ref this package declares is among the measured keys', () => { + const ref = studioGroup('group_developer').find((i) => i.id === 'nav_integrations')?.componentRef; + expect(MEASURED_CONSOLE_KEYS as readonly (string | undefined)[]).toContain(ref); }); it('every measured key is a registry KEY, never a console path', () => { // objectui#2763's boundary: navigation names a key and the console owns the - // URL it resolves to (`ai:approvals` → `/apps/APP/component/ai/approvals`). + // URL it resolves to (`developer:integrations` → + // `/apps/APP/component/developer/integrations`). for (const key of MEASURED_CONSOLE_KEYS) { expect(key).toMatch(/^[a-z0-9_-]+:[a-z0-9_-]+$/); expect(key).not.toContain('/'); diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index 9f0764be55b..1428168fd4d 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -12,8 +12,7 @@ * Some entries/groups are intentionally contributed by the capability plugin * that owns the underlying objects rather than living here (ADR-0029 K2): * - `group_integrations` → `@objectstack/plugin-webhooks` (K2.a) - * - `group_approvals` → `@objectstack/plugin-approvals` (K2.b); the one - * exception is `nav_ai_approvals`, gated on the `ai` service (see below) + * - `group_approvals` → `@objectstack/plugin-approvals` (K2.b) * - `group_access_control` Positions / Permission Sets → `@objectstack/plugin-security` * - `group_access_control` Sharing Rules / Record Shares → `@objectstack/plugin-sharing` * As each remaining domain moves to its capability plugin, its entries move out @@ -104,35 +103,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ ], }, // group_approvals is contributed by @objectstack/plugin-approvals, which owns - // sys_approval_request / sys_approval_action (ADR-0029 K2.b). The approvals - // ENGINE's entries stay there. The one entry this package places in the - // slot is the AI pending-action queue below, whose capability no package - // of this repository provides — `platform-objects.test.ts` pins that it is - // the only one. - { - app: 'setup', - group: 'group_approvals', - // After plugin-approvals' own entries (priority 100): the engine's inbox - // stays the slot's first, working surface. - priority: 200, - items: [ - // #20142 — the console's AI Approvals queue: the whole AI pending-action - // queue (`/api/v1/ai/pending-actions`) with approve / reject. The console - // registers the key in `registerSystemComponents.tsx`; its only in-app - // link was the System Hub card wall, retired by objectui#10520. - // - // `requiresService: 'ai'` is load-bearing, not decoration. The `ai` slot - // has no implementation in the open framework (`CORE_SERVICE_PROVIDER.ai` - // is `null` — `@objectstack/service-ai` ships in Cloud/Enterprise), so on - // a Community Edition boot the page's only data source answers 501 (the - // platform checklist's FOLLOW-UPS row K2 records that reading). - // The gate is stripped server-side by `filterAppForUser` (ADR-0057 D10), - // so the entry never reaches a browser whose runtime cannot serve it. - // Same shape as `nav_account_approvals`' `requiresService: 'approvals'`. - // `group_approvals` already requires `manage_platform_settings`. - { id: 'nav_ai_approvals', type: 'component', label: 'AI Approvals', componentRef: 'ai:approvals', icon: 'bot', requiresService: 'ai' }, - ], - }, + // sys_approval_request / sys_approval_action (ADR-0029 K2.b). { app: 'setup', group: 'group_configuration', diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index dfba22fd666..8ee72b2ac65 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -115,9 +115,6 @@ export const en: TranslationData = { nav_approval_requests: { label: 'Requests' }, nav_approval_actions: { label: 'Action History' }, nav_approval_delegations: { label: 'Delegations (OOO)' }, - // Contributed by this package into plugin-approvals' slot, gated on the - // `ai` service (#20142) — the console's AI pending-action queue. - nav_ai_approvals: { label: 'AI Approvals' }, // Configuration nav_settings_hub: { label: 'All Settings' }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts index baa2603b0ef..bb48dc87b67 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts @@ -102,7 +102,6 @@ export const esESSourceHashes: Readonly> = { 'apps.setup.navigation.nav_approval_requests.label': 'aafd0f7c6a376f55', 'apps.setup.navigation.nav_approval_actions.label': 'd2ebad7acfd50978', 'apps.setup.navigation.nav_approval_delegations.label': 'fc0d41741659c462', - 'apps.setup.navigation.nav_ai_approvals.label': '0de8ec9f51fc2dac', 'apps.setup.navigation.nav_settings_hub.label': 'a3ac65c7c7f461bf', 'apps.setup.navigation.nav_settings_localization.label': 'beedfaf00452c829', 'apps.setup.navigation.nav_settings_company.label': '9d7f685e11eaade9', diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index e2a38386a9e..16de6d4e247 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -77,7 +77,6 @@ export const esES: TranslationData = { nav_approval_requests: { label: 'Solicitudes' }, nav_approval_actions: { label: 'Historial de Acciones' }, nav_approval_delegations: { label: 'Delegaciones (ausencia)' }, - nav_ai_approvals: { label: 'Aprobaciones de IA' }, nav_settings_hub: { label: 'Todos los Ajustes' }, nav_settings_localization: { label: 'Localización' }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts index 0863a435309..d3063192df3 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts @@ -102,7 +102,6 @@ export const jaJPSourceHashes: Readonly> = { 'apps.setup.navigation.nav_approval_requests.label': 'aafd0f7c6a376f55', 'apps.setup.navigation.nav_approval_actions.label': 'd2ebad7acfd50978', 'apps.setup.navigation.nav_approval_delegations.label': 'fc0d41741659c462', - 'apps.setup.navigation.nav_ai_approvals.label': '0de8ec9f51fc2dac', 'apps.setup.navigation.nav_settings_hub.label': 'a3ac65c7c7f461bf', 'apps.setup.navigation.nav_settings_localization.label': 'beedfaf00452c829', 'apps.setup.navigation.nav_settings_company.label': '9d7f685e11eaade9', diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 6f869a3e635..07eb8248660 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -77,7 +77,6 @@ export const jaJP: TranslationData = { nav_approval_requests: { label: 'リクエスト' }, nav_approval_actions: { label: 'アクション履歴' }, nav_approval_delegations: { label: '委任 (不在時)' }, - nav_ai_approvals: { label: 'AI 承認' }, nav_settings_hub: { label: 'すべての設定' }, nav_settings_localization: { label: 'ローカリゼーション' }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts index ea3b407afd1..5229f05412a 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts @@ -102,7 +102,6 @@ export const zhCNSourceHashes: Readonly> = { 'apps.setup.navigation.nav_approval_requests.label': 'aafd0f7c6a376f55', 'apps.setup.navigation.nav_approval_actions.label': 'd2ebad7acfd50978', 'apps.setup.navigation.nav_approval_delegations.label': 'fc0d41741659c462', - 'apps.setup.navigation.nav_ai_approvals.label': '0de8ec9f51fc2dac', 'apps.setup.navigation.nav_settings_hub.label': 'a3ac65c7c7f461bf', 'apps.setup.navigation.nav_settings_localization.label': 'beedfaf00452c829', 'apps.setup.navigation.nav_settings_company.label': '9d7f685e11eaade9', diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 68167919d5f..ba01a9eb04f 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -84,7 +84,6 @@ export const zhCN: TranslationData = { // `审批委派` matches sys_approval_delegation's object label; `(外出)` // renders the entry's own "(OOO)" qualifier. nav_approval_delegations: { label: '审批委派(外出)' }, - nav_ai_approvals: { label: 'AI 审批' }, nav_settings_hub: { label: '全部设置' }, nav_settings_localization: { label: '本地化' }, diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index 4f02c2d55c9..72709ae37dc 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -417,31 +417,10 @@ describe('@objectstack/platform-objects', () => { it('does not contribute slots owned by capability plugins', () => { // group_integrations → @objectstack/plugin-webhooks (K2.a) - const integrations = SETUP_NAV_CONTRIBUTIONS.find((c) => c.group === 'group_integrations'); - expect(integrations).toBeUndefined(); - }); - - it('contributes exactly one entry into group_approvals, and it is not the approvals engine\'s', () => { - // group_approvals → @objectstack/plugin-approvals (K2.b): the engine's - // entries (its inbox component and its `sys_approval_*` tables) are that - // plugin's to ship, and none of them may come back here. - // - // #20142 adds ONE entry to the slot from this package: the console's AI - // pending-action queue. Its capability (`ai`) is provided by no package - // of this repository, so there is no in-repo owner to contribute it; it - // is gated on that service instead, and stripped server-side wherever - // the service is absent. The allowance is the literal id list below, - // so any further entry here still turns this red. - const items = SETUP_NAV_CONTRIBUTIONS - .filter((c) => c.group === 'group_approvals') - .flatMap((c) => c.items as Array<{ - id?: string; objectName?: string; componentRef?: string; requiresService?: string; - }>); - expect(items.map((i) => i.id)).toEqual(['nav_ai_approvals']); - for (const item of items) { - expect(item.objectName ?? '').not.toMatch(/^sys_approval_/); - expect(item.componentRef ?? '').not.toMatch(/^approvals:/); - expect(item.requiresService).toBe('ai'); + // group_approvals → @objectstack/plugin-approvals (K2.b) + for (const ownedSlot of ['group_integrations', 'group_approvals']) { + const contrib = SETUP_NAV_CONTRIBUTIONS.find((c) => c.group === ownedSlot); + expect(contrib).toBeUndefined(); } }); diff --git a/packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts b/packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts index f3efe2fac94..d3b1ec9a3ab 100644 --- a/packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts +++ b/packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts @@ -11,7 +11,7 @@ // the registration half (`orphanedPageComponentRefs-10520.test.tsx`); this file // pins the half this repo owns — the ref the entry names. // -// Why here and not beside the other two entries of #20142 in +// Why here and not beside #20142's Studio entry in // `@objectstack/platform-objects`: the page reads `sys_audit_log`, which this // plugin owns (ADR-0029 K2), so its entry lives and dies with this plugin the // way `nav_audit_logs` does — and needs no item gate for the same reason. From d4744797b9c99714e475160eafd7afcc4d85552d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 19:09:30 +0000 Subject: [PATCH 8/8] docs(ui): the Setup Diagnostics row names both plugin-audit entries Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- content/docs/ui/setup-app.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/ui/setup-app.mdx b/content/docs/ui/setup-app.mdx index 7671f751b6e..9247cf072a3 100644 --- a/content/docs/ui/setup-app.mdx +++ b/content/docs/ui/setup-app.mdx @@ -53,7 +53,7 @@ anchors are: | **Access Control** (`group_access_control`) | Positions / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` | | **Approvals** (`group_approvals`) | Approvals Inbox (the `approvals:inbox` component) · Requests · Action History · Delegations (OOO) — `plugin-approvals` | | **Configuration** (`group_configuration`) | All Settings · Localization · Company · Branding · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects` | -| **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Logs — `plugin-audit` | +| **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Logs · Audit Log Browser — `plugin-audit` | | **Integrations** (`group_integrations`) | `plugin-webhooks` | | **Advanced** (`group_advanced`) | OAuth Applications · Identity Links · User Preferences — `platform-objects` |