From b24a71a7dcc5dd1097fa9fcf5bca99c55e1e7cf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:20:27 +0000 Subject: [PATCH 1/4] wip(app-crm): Pipeline dashboard audience gate Claude-Session: https://claude.ai/code/session_01Utp6CY2n1JmZZKrHgtp9jC Co-authored-by: Claude --- examples/app-crm/objectstack.config.ts | 4 + examples/app-crm/src/apps/crm.app.ts | 3 +- .../src/dashboards/pipeline.dashboard.ts | 3 + examples/app-crm/src/security/capabilities.ts | 34 ++++++++ examples/app-crm/src/security/index.ts | 2 +- .../app-crm/src/security/sales-positions.ts | 3 + .../app-crm/test/pipeline-audience.test.ts | 80 +++++++++++++++++++ 7 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 examples/app-crm/src/security/capabilities.ts create mode 100644 examples/app-crm/test/pipeline-audience.test.ts diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts index a8913736cc0..291ab6c59c5 100644 --- a/examples/app-crm/objectstack.config.ts +++ b/examples/app-crm/objectstack.config.ts @@ -17,6 +17,7 @@ import { FinanceApproverPosition, SalesUserPermissionSet, GuestPortalProfile, + allCapabilities, } from './src/security/index.js'; import { CrmSeedData } from './src/data/index.js'; import { CrmDatasource, CrmAnalyticsDatasource } from './src/datasources/crm.datasource.js'; @@ -98,6 +99,9 @@ export default defineStack({ // Security positions: [SalesRepPosition, SalesManagerPosition, FinanceApproverPosition], permissions: [SalesUserPermissionSet, GuestPortalProfile], + // [ADR-0066 D1] The capability `crm_sales_user` grants and the Pipeline + // dashboard requires (src/security/capabilities.ts). + capabilities: allCapabilities, // No `sharingRules`. The three this app used to declare // (`share_high_value_opps_with_managers`, `share_active_leads_with_manager`, // `share_won_deal_activities`) were anchored on `crm_opportunity`, diff --git a/examples/app-crm/src/apps/crm.app.ts b/examples/app-crm/src/apps/crm.app.ts index c13ef009271..ff91f418cba 100644 --- a/examples/app-crm/src/apps/crm.app.ts +++ b/examples/app-crm/src/apps/crm.app.ts @@ -30,7 +30,8 @@ export const CrmApp = App.create({ label: 'Analytics', icon: 'chart-bar', children: [ - { id: 'nav_pipeline_dashboard', type: 'dashboard', dashboardName: 'pipeline_dashboard', label: 'Pipeline', icon: 'layout-dashboard' }, + // Same audience as the dashboard itself (see security/capabilities.ts). + { id: 'nav_pipeline_dashboard', type: 'dashboard', dashboardName: 'pipeline_dashboard', label: 'Pipeline', icon: 'layout-dashboard', requiredPermissions: ['crm.view_pipeline'] }, ], }, ], diff --git a/examples/app-crm/src/dashboards/pipeline.dashboard.ts b/examples/app-crm/src/dashboards/pipeline.dashboard.ts index 15eecd8a3ac..ba40115e765 100644 --- a/examples/app-crm/src/dashboards/pipeline.dashboard.ts +++ b/examples/app-crm/src/dashboards/pipeline.dashboard.ts @@ -32,6 +32,9 @@ export const PipelineDashboard: Dashboard = { name: 'pipeline_dashboard', label: 'Pipeline Dashboard', description: 'Aggregate view of the sales pipeline with period-over-period comparisons.', + // Only the sales set reads the pipeline, so only its holders are offered + // the dashboard. The nav entry carries the same list (security/capabilities.ts). + requiredPermissions: ['crm.view_pipeline'], columns: 12, widgets: [ // --- Row 1: KPI tiles ------------------------------------------------- diff --git a/examples/app-crm/src/security/capabilities.ts b/examples/app-crm/src/security/capabilities.ts new file mode 100644 index 00000000000..7c601e27c73 --- /dev/null +++ b/examples/app-crm/src/security/capabilities.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineCapability } from '@objectstack/spec'; + +/** + * The Pipeline dashboard's audience (ADR-0066 three-way separation; the worked + * example is `examples/app-showcase/src/security/capabilities.ts`). + * + * • DEFINED here, and collected on the stack's `capabilities` array. + * • GRANTED by `crm_sales_user` (`systemPermissions`), the one CRM set that + * reads `crm_opportunity` — the object behind `opportunity_metrics`, which + * every Pipeline widget reads. + * • REQUIRED by `pipeline_dashboard` and by the `nav_pipeline_dashboard` + * entry, with the same list on both. + * + * Without the gate, a member with no sales set was offered Analytics → + * Pipeline and then refused every tile. The server enforces both + * declarations: the dashboard read by name answers `audience-permission`, and + * the app's navigation drops the entry. + * + * `scope: 'org'` because the grant is the set's only system permission: a + * capability no declaration carries, or one declared `platform`, would put + * `crm_sales_user` outside the organization boundary, so an organization + * administrator could no longer assign it. + */ +export const ViewPipelineCapability = defineCapability({ + name: 'crm.view_pipeline', + label: 'View Sales Pipeline', + description: 'Open the CRM Pipeline dashboard, whose widgets read the opportunity pipeline.', + scope: 'org', + packageId: 'com.example.crm', +}); + +export const allCapabilities = [ViewPipelineCapability]; diff --git a/examples/app-crm/src/security/index.ts b/examples/app-crm/src/security/index.ts index b14d6a05cc2..9a96f5138e6 100644 --- a/examples/app-crm/src/security/index.ts +++ b/examples/app-crm/src/security/index.ts @@ -7,4 +7,4 @@ export { SalesUserPermissionSet, GuestPortalProfile, } from './sales-positions.js'; - +export { ViewPipelineCapability, allCapabilities } from './capabilities.js'; diff --git a/examples/app-crm/src/security/sales-positions.ts b/examples/app-crm/src/security/sales-positions.ts index 8f228d43739..314586f6284 100644 --- a/examples/app-crm/src/security/sales-positions.ts +++ b/examples/app-crm/src/security/sales-positions.ts @@ -59,6 +59,9 @@ export const SalesUserPermissionSet = definePermissionSet({ crm_lead: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, crm_activity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, }, + // The Pipeline dashboard's audience: granted here, beside the + // `crm_opportunity` read its widgets need (see capabilities.ts). + systemPermissions: ['crm.view_pipeline'], }); /** diff --git a/examples/app-crm/test/pipeline-audience.test.ts b/examples/app-crm/test/pipeline-audience.test.ts new file mode 100644 index 00000000000..94d5348c896 --- /dev/null +++ b/examples/app-crm/test/pipeline-audience.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27735] The Pipeline dashboard's audience gate, as the stack ships it. + * + * The server judges these declarations: the dashboard read by name refuses a + * caller missing any `requiredPermissions` entry (`audience-permission`), and + * the app's navigation drops an entry whose `requiredPermissions` the caller + * does not hold. Both compare against the union of the caller's sets' + * `systemPermissions`. This example does not depend on the REST package, so + * the gate itself is not run here; what is pinned is every input it reads, + * read off the parsed stack rather than the source constants: + * + * - the dashboard and its nav entry require the same, non-empty list; + * - every required capability is declared on the stack (`scope: 'org'`) and + * granted by exactly the CRM sets that read `crm_opportunity`, the object + * behind `opportunity_metrics` — never a gate nobody holds; + * - those sets reach people through the CRM positions, and no other set + * (a plain member's) grants it. + */ + +import { describe, expect, it } from 'vitest'; +import stack from '../objectstack.config.js'; + +type NavEntry = { id?: string; type?: string; dashboardName?: string; requiredPermissions?: string[]; children?: NavEntry[] }; + +function findNav(entries: NavEntry[] | undefined, id: string): NavEntry | undefined { + for (const entry of entries ?? []) { + if (entry.id === id) return entry; + const hit = findNav(entry.children, id); + if (hit) return hit; + } + return undefined; +} + +const dashboard = (stack.dashboards ?? []).find((d) => d.name === 'pipeline_dashboard'); +const app = (stack.apps ?? []).find((a) => a.name === 'crm_app'); +const navEntry = findNav(app?.navigation as NavEntry[] | undefined, 'nav_pipeline_dashboard'); +const sets = (stack.permissions ?? []) as Array<{ name: string; systemPermissions?: string[]; objects?: Record }>; +const required = dashboard?.requiredPermissions ?? []; + +describe('Pipeline dashboard audience (#27735)', () => { + it('the dashboard and its nav entry require the same capability list', () => { + expect(dashboard).toBeDefined(); + expect(navEntry).toMatchObject({ type: 'dashboard', dashboardName: 'pipeline_dashboard' }); + expect(required).toEqual(['crm.view_pipeline']); + expect(navEntry!.requiredPermissions).toEqual(required); + }); + + it('every required capability is declared on the stack, scoped to the organization', () => { + const declared = new Map((stack.capabilities ?? []).map((c) => [c.name, c])); + for (const cap of required) { + expect(declared.get(cap)).toMatchObject({ name: cap, scope: 'org' }); + } + }); + + it('is granted by exactly the sets that read the opportunities its widgets aggregate', () => { + const readers = sets.filter((s) => s.objects?.crm_opportunity?.allowRead === true).map((s) => s.name).sort(); + expect(readers).toEqual(['crm_sales_user']); + for (const cap of required) { + const holders = sets.filter((s) => (s.systemPermissions ?? []).includes(cap)).map((s) => s.name).sort(); + expect(holders).toEqual(readers); + } + }); + + it('reaches sales users through the CRM positions and no other set', () => { + const distributed = (stack.positions ?? []) + .filter((p) => (p.permissionSets ?? []).includes('crm_sales_user')) + .map((p) => p.name) + .sort(); + expect(distributed).toEqual(['finance_approver', 'sales_manager', 'sales_rep']); + // A member without a sales position holds none of the stack's other sets' + // system permissions toward this gate. + const others = sets.filter((s) => s.name !== 'crm_sales_user'); + expect(others.map((s) => s.name)).toEqual(['guest_portal']); + for (const s of others) { + expect((s.systemPermissions ?? []).filter((cap) => required.includes(cap))).toEqual([]); + } + }); +}); From 87e106748e4427efb46a1193c2df5776a246527c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:29:22 +0000 Subject: [PATCH 2/4] test(app-crm): tighten the plain-member comment Claude-Session: https://claude.ai/code/session_01Utp6CY2n1JmZZKrHgtp9jC Co-authored-by: Claude --- examples/app-crm/test/pipeline-audience.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/app-crm/test/pipeline-audience.test.ts b/examples/app-crm/test/pipeline-audience.test.ts index 94d5348c896..9a7da09480b 100644 --- a/examples/app-crm/test/pipeline-audience.test.ts +++ b/examples/app-crm/test/pipeline-audience.test.ts @@ -69,8 +69,8 @@ describe('Pipeline dashboard audience (#27735)', () => { .map((p) => p.name) .sort(); expect(distributed).toEqual(['finance_approver', 'sales_manager', 'sales_rep']); - // A member without a sales position holds none of the stack's other sets' - // system permissions toward this gate. + // A member without a sales position is left with the stack's other sets, + // and none of them grants the gate. const others = sets.filter((s) => s.name !== 'crm_sales_user'); expect(others.map((s) => s.name)).toEqual(['guest_portal']); for (const s of others) { From b8d8df7ab14a20c60a2791c358b591f9b1d455e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:39:37 +0000 Subject: [PATCH 3/4] fix(app-crm): hide the Pipeline nav entry from a member who cannot read opportunities Per ruling 6512784198 (letter G): a visible predicate on the nav entry instead of a declared capability; the capability, its grant and the dashboard's requiredPermissions are reverted. Claude-Session: https://claude.ai/code/session_01Utp6CXo8iLQ8vn2fXj6wxp Co-authored-by: Claude --- examples/app-crm/objectstack.config.ts | 4 - examples/app-crm/src/apps/crm.app.ts | 8 +- .../src/dashboards/pipeline.dashboard.ts | 3 - examples/app-crm/src/security/capabilities.ts | 34 -------- examples/app-crm/src/security/index.ts | 2 +- .../app-crm/src/security/sales-positions.ts | 3 - .../app-crm/test/pipeline-audience.test.ts | 78 ++++++------------- 7 files changed, 32 insertions(+), 100 deletions(-) delete mode 100644 examples/app-crm/src/security/capabilities.ts diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts index 291ab6c59c5..a8913736cc0 100644 --- a/examples/app-crm/objectstack.config.ts +++ b/examples/app-crm/objectstack.config.ts @@ -17,7 +17,6 @@ import { FinanceApproverPosition, SalesUserPermissionSet, GuestPortalProfile, - allCapabilities, } from './src/security/index.js'; import { CrmSeedData } from './src/data/index.js'; import { CrmDatasource, CrmAnalyticsDatasource } from './src/datasources/crm.datasource.js'; @@ -99,9 +98,6 @@ export default defineStack({ // Security positions: [SalesRepPosition, SalesManagerPosition, FinanceApproverPosition], permissions: [SalesUserPermissionSet, GuestPortalProfile], - // [ADR-0066 D1] The capability `crm_sales_user` grants and the Pipeline - // dashboard requires (src/security/capabilities.ts). - capabilities: allCapabilities, // No `sharingRules`. The three this app used to declare // (`share_high_value_opps_with_managers`, `share_active_leads_with_manager`, // `share_won_deal_activities`) were anchored on `crm_opportunity`, diff --git a/examples/app-crm/src/apps/crm.app.ts b/examples/app-crm/src/apps/crm.app.ts index ff91f418cba..493eaf0fabc 100644 --- a/examples/app-crm/src/apps/crm.app.ts +++ b/examples/app-crm/src/apps/crm.app.ts @@ -30,8 +30,12 @@ export const CrmApp = App.create({ label: 'Analytics', icon: 'chart-bar', children: [ - // Same audience as the dashboard itself (see security/capabilities.ts). - { id: 'nav_pipeline_dashboard', type: 'dashboard', dashboardName: 'pipeline_dashboard', label: 'Pipeline', icon: 'layout-dashboard', requiredPermissions: ['crm.view_pipeline'] }, + // Offered only to a member who can read the opportunities every + // Pipeline widget aggregates (`opportunity_metrics` is bound to + // `crm_opportunity`); anyone else would open a page whose every tile + // the server refuses. `visible` hides; the dataset query's own 403 + // stays the enforcement (ADR-0066 D4). + { id: 'nav_pipeline_dashboard', type: 'dashboard', dashboardName: 'pipeline_dashboard', label: 'Pipeline', icon: 'layout-dashboard', visible: "current_user.can('crm_opportunity', 'read')" }, ], }, ], diff --git a/examples/app-crm/src/dashboards/pipeline.dashboard.ts b/examples/app-crm/src/dashboards/pipeline.dashboard.ts index ba40115e765..15eecd8a3ac 100644 --- a/examples/app-crm/src/dashboards/pipeline.dashboard.ts +++ b/examples/app-crm/src/dashboards/pipeline.dashboard.ts @@ -32,9 +32,6 @@ export const PipelineDashboard: Dashboard = { name: 'pipeline_dashboard', label: 'Pipeline Dashboard', description: 'Aggregate view of the sales pipeline with period-over-period comparisons.', - // Only the sales set reads the pipeline, so only its holders are offered - // the dashboard. The nav entry carries the same list (security/capabilities.ts). - requiredPermissions: ['crm.view_pipeline'], columns: 12, widgets: [ // --- Row 1: KPI tiles ------------------------------------------------- diff --git a/examples/app-crm/src/security/capabilities.ts b/examples/app-crm/src/security/capabilities.ts deleted file mode 100644 index 7c601e27c73..00000000000 --- a/examples/app-crm/src/security/capabilities.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineCapability } from '@objectstack/spec'; - -/** - * The Pipeline dashboard's audience (ADR-0066 three-way separation; the worked - * example is `examples/app-showcase/src/security/capabilities.ts`). - * - * • DEFINED here, and collected on the stack's `capabilities` array. - * • GRANTED by `crm_sales_user` (`systemPermissions`), the one CRM set that - * reads `crm_opportunity` — the object behind `opportunity_metrics`, which - * every Pipeline widget reads. - * • REQUIRED by `pipeline_dashboard` and by the `nav_pipeline_dashboard` - * entry, with the same list on both. - * - * Without the gate, a member with no sales set was offered Analytics → - * Pipeline and then refused every tile. The server enforces both - * declarations: the dashboard read by name answers `audience-permission`, and - * the app's navigation drops the entry. - * - * `scope: 'org'` because the grant is the set's only system permission: a - * capability no declaration carries, or one declared `platform`, would put - * `crm_sales_user` outside the organization boundary, so an organization - * administrator could no longer assign it. - */ -export const ViewPipelineCapability = defineCapability({ - name: 'crm.view_pipeline', - label: 'View Sales Pipeline', - description: 'Open the CRM Pipeline dashboard, whose widgets read the opportunity pipeline.', - scope: 'org', - packageId: 'com.example.crm', -}); - -export const allCapabilities = [ViewPipelineCapability]; diff --git a/examples/app-crm/src/security/index.ts b/examples/app-crm/src/security/index.ts index 9a96f5138e6..b14d6a05cc2 100644 --- a/examples/app-crm/src/security/index.ts +++ b/examples/app-crm/src/security/index.ts @@ -7,4 +7,4 @@ export { SalesUserPermissionSet, GuestPortalProfile, } from './sales-positions.js'; -export { ViewPipelineCapability, allCapabilities } from './capabilities.js'; + diff --git a/examples/app-crm/src/security/sales-positions.ts b/examples/app-crm/src/security/sales-positions.ts index 314586f6284..8f228d43739 100644 --- a/examples/app-crm/src/security/sales-positions.ts +++ b/examples/app-crm/src/security/sales-positions.ts @@ -59,9 +59,6 @@ export const SalesUserPermissionSet = definePermissionSet({ crm_lead: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, crm_activity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, }, - // The Pipeline dashboard's audience: granted here, beside the - // `crm_opportunity` read its widgets need (see capabilities.ts). - systemPermissions: ['crm.view_pipeline'], }); /** diff --git a/examples/app-crm/test/pipeline-audience.test.ts b/examples/app-crm/test/pipeline-audience.test.ts index 9a7da09480b..2a849249c89 100644 --- a/examples/app-crm/test/pipeline-audience.test.ts +++ b/examples/app-crm/test/pipeline-audience.test.ts @@ -1,28 +1,23 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#27735] The Pipeline dashboard's audience gate, as the stack ships it. + * [#27735] The Pipeline nav entry is offered only to a member who can read + * what the dashboard shows (ruling 6512784198, letter G). * - * The server judges these declarations: the dashboard read by name refuses a - * caller missing any `requiredPermissions` entry (`audience-permission`), and - * the app's navigation drops an entry whose `requiredPermissions` the caller - * does not hold. Both compare against the union of the caller's sets' - * `systemPermissions`. This example does not depend on the REST package, so - * the gate itself is not run here; what is pinned is every input it reads, - * read off the parsed stack rather than the source constants: - * - * - the dashboard and its nav entry require the same, non-empty list; - * - every required capability is declared on the stack (`scope: 'org'`) and - * granted by exactly the CRM sets that read `crm_opportunity`, the object - * behind `opportunity_metrics` — never a gate nobody holds; - * - those sets reach people through the CRM positions, and no other set - * (a plain member's) grants it. + * The entry carries a `visible` predicate, `current_user.can(OBJECT, 'read')`, + * which the console's nav guard answers from the member's permissions payload. + * It hides; it refuses nothing. The refusal stays where it already is: the + * dataset query of every widget answers 403 to a caller who cannot read the + * dataset's object (ADR-0066 D4: `visibleWhen` hides, `requiredPermissions` + * refuses). So the one thing that can go wrong here is the predicate naming + * an object other than the one the widgets read, and that is what is pinned, + * off the parsed stack rather than the source constants. */ import { describe, expect, it } from 'vitest'; import stack from '../objectstack.config.js'; -type NavEntry = { id?: string; type?: string; dashboardName?: string; requiredPermissions?: string[]; children?: NavEntry[] }; +type NavEntry = { id?: string; type?: string; dashboardName?: string; visible?: unknown; children?: NavEntry[] }; function findNav(entries: NavEntry[] | undefined, id: string): NavEntry | undefined { for (const entry of entries ?? []) { @@ -33,48 +28,25 @@ function findNav(entries: NavEntry[] | undefined, id: string): NavEntry | undefi return undefined; } -const dashboard = (stack.dashboards ?? []).find((d) => d.name === 'pipeline_dashboard'); +const READ_PREDICATE = /^current_user\.can\('([a-z0-9_]+)', 'read'\)$/; + const app = (stack.apps ?? []).find((a) => a.name === 'crm_app'); const navEntry = findNav(app?.navigation as NavEntry[] | undefined, 'nav_pipeline_dashboard'); -const sets = (stack.permissions ?? []) as Array<{ name: string; systemPermissions?: string[]; objects?: Record }>; -const required = dashboard?.requiredPermissions ?? []; +const dashboard = (stack.dashboards ?? []).find((d) => d.name === 'pipeline_dashboard'); -describe('Pipeline dashboard audience (#27735)', () => { - it('the dashboard and its nav entry require the same capability list', () => { - expect(dashboard).toBeDefined(); +describe('Pipeline nav entry audience (#27735)', () => { + it('carries a read predicate on one object', () => { expect(navEntry).toMatchObject({ type: 'dashboard', dashboardName: 'pipeline_dashboard' }); - expect(required).toEqual(['crm.view_pipeline']); - expect(navEntry!.requiredPermissions).toEqual(required); - }); - - it('every required capability is declared on the stack, scoped to the organization', () => { - const declared = new Map((stack.capabilities ?? []).map((c) => [c.name, c])); - for (const cap of required) { - expect(declared.get(cap)).toMatchObject({ name: cap, scope: 'org' }); - } - }); - - it('is granted by exactly the sets that read the opportunities its widgets aggregate', () => { - const readers = sets.filter((s) => s.objects?.crm_opportunity?.allowRead === true).map((s) => s.name).sort(); - expect(readers).toEqual(['crm_sales_user']); - for (const cap of required) { - const holders = sets.filter((s) => (s.systemPermissions ?? []).includes(cap)).map((s) => s.name).sort(); - expect(holders).toEqual(readers); - } + expect(navEntry!.visible).toMatch(READ_PREDICATE); }); - it('reaches sales users through the CRM positions and no other set', () => { - const distributed = (stack.positions ?? []) - .filter((p) => (p.permissionSets ?? []).includes('crm_sales_user')) - .map((p) => p.name) - .sort(); - expect(distributed).toEqual(['finance_approver', 'sales_manager', 'sales_rep']); - // A member without a sales position is left with the stack's other sets, - // and none of them grants the gate. - const others = sets.filter((s) => s.name !== 'crm_sales_user'); - expect(others.map((s) => s.name)).toEqual(['guest_portal']); - for (const s of others) { - expect((s.systemPermissions ?? []).filter((cap) => required.includes(cap))).toEqual([]); - } + it("names the object of the dataset every pipeline_dashboard widget reads", () => { + const named = String(navEntry?.visible).match(READ_PREDICATE)?.[1]; + const widgets = (dashboard?.widgets ?? []) as Array<{ dataset?: string }>; + expect(widgets.length).toBeGreaterThan(0); + const objects = new Set( + widgets.map((w) => (stack.datasets ?? []).find((d) => d.name === w.dataset)?.object), + ); + expect([...objects]).toEqual([named]); }); }); From 3e519b497c94115bc15060c833eb855da3a1da14 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:40:29 +0000 Subject: [PATCH 4/4] test(app-crm): read the parsed CEL predicate's source Claude-Session: https://claude.ai/code/session_01Utp6CXo8iLQ8vn2fXj6wxp Co-authored-by: Claude --- .../app-crm/test/pipeline-audience.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/examples/app-crm/test/pipeline-audience.test.ts b/examples/app-crm/test/pipeline-audience.test.ts index 2a849249c89..22868cb5bb4 100644 --- a/examples/app-crm/test/pipeline-audience.test.ts +++ b/examples/app-crm/test/pipeline-audience.test.ts @@ -17,7 +17,13 @@ import { describe, expect, it } from 'vitest'; import stack from '../objectstack.config.js'; -type NavEntry = { id?: string; type?: string; dashboardName?: string; visible?: unknown; children?: NavEntry[] }; +type NavEntry = { + id?: string; + type?: string; + dashboardName?: string; + visible?: { dialect?: string; source?: string }; + children?: NavEntry[]; +}; function findNav(entries: NavEntry[] | undefined, id: string): NavEntry | undefined { for (const entry of entries ?? []) { @@ -33,15 +39,19 @@ const READ_PREDICATE = /^current_user\.can\('([a-z0-9_]+)', 'read'\)$/; const app = (stack.apps ?? []).find((a) => a.name === 'crm_app'); const navEntry = findNav(app?.navigation as NavEntry[] | undefined, 'nav_pipeline_dashboard'); const dashboard = (stack.dashboards ?? []).find((d) => d.name === 'pipeline_dashboard'); +// The stack parse normalises an authored CEL string to `{ dialect, source }`. +const predicate = navEntry?.visible; describe('Pipeline nav entry audience (#27735)', () => { it('carries a read predicate on one object', () => { expect(navEntry).toMatchObject({ type: 'dashboard', dashboardName: 'pipeline_dashboard' }); - expect(navEntry!.visible).toMatch(READ_PREDICATE); + expect(predicate?.dialect).toBe('cel'); + expect(predicate?.source).toMatch(READ_PREDICATE); }); - it("names the object of the dataset every pipeline_dashboard widget reads", () => { - const named = String(navEntry?.visible).match(READ_PREDICATE)?.[1]; + it('names the object of the dataset every pipeline_dashboard widget reads', () => { + const named = predicate?.source?.match(READ_PREDICATE)?.[1]; + expect(named).toBeDefined(); const widgets = (dashboard?.widgets ?? []) as Array<{ dataset?: string }>; expect(widgets.length).toBeGreaterThan(0); const objects = new Set(