From 2c37e1a9e828c403d851c69889e240317eefd327 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 30 Sep 2026 17:59:07 -0700 Subject: [PATCH 1/3] fix(mothership): order dashboards between chats and tables in resource menus --- .../components/resource-registry/resource-registry.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 417b84c4205..defae9e3444 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -297,9 +297,9 @@ export const MENTION_PREVIEW_DEFAULT_LIMIT = 5 * surface them lands in the right place. */ export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [ - 'dashboard', 'integration', 'task', + 'dashboard', 'table', 'file', 'filefolder', From e4da9841493512776e1253ce4e7b4ac954a03572 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 30 Sep 2026 18:07:04 -0700 Subject: [PATCH 2/3] chore(rules): drop the resource-menu sidebar-mirroring rule --- .claude/rules/sim-list-ordering.md | 13 +++++++------ .cursor/rules/sim-list-ordering.mdc | 13 +++++++------ CLAUDE.md | 2 +- .../resource-registry/resource-registry.tsx | 7 +++---- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.claude/rules/sim-list-ordering.md b/.claude/rules/sim-list-ordering.md index 9b6d6a87147..825a545e9d2 100644 --- a/.claude/rules/sim-list-ordering.md +++ b/.claude/rules/sim-list-ordering.md @@ -1,5 +1,5 @@ --- -description: List and menu ordering that mirrors the sidebar or toolbar, with one separator before the destructive action +description: List and menu ordering that mirrors the toolbar or settings nav, encoded once, with one separator before the destructive action paths: - "apps/sim/app/**/*.tsx" - "apps/sim/ee/**/*.tsx" @@ -8,7 +8,7 @@ paths: # List & Menu Ordering -**A list orders itself the way the user already reads the same things somewhere else.** Dropdowns, context menus, tab strips, command palettes, and settings navs are all *second* presentations of a set the user has already seen — in the sidebar, in a toolbar, in a column-header row. When the second presentation reorders that set, the user re-reads it from scratch every time. +**A list orders itself the way the user already reads the same things somewhere else.** Dropdowns, context menus, tab strips, command palettes, and settings navs are all *second* presentations of a set the user has already seen — in a toolbar, in the settings nav, in a column-header row. When the second presentation reorders that set, the user re-reads it from scratch every time. This is not a style preference. Order is the cheapest affordance a list has, and the only one that costs nothing to get right. @@ -18,13 +18,14 @@ Before writing a list of items, find where the user sees those same items *first | The list | Mirrors | | --- | --- | -| Resource menus (`+` attach, `@` mention, resource-tab `+`) | the workspace **sidebar**, top-down | | A row / root **context menu** | that surface's **toolbar**, left-to-right → top-to-bottom | | Settings tab strip, recently-deleted tabs | the **settings nav**, top-down | | A "New …" menu | the order those things appear once created | Left-to-right becomes top-to-bottom. A toolbar reading `Filter · Sort · Export · Delete` becomes a menu reading Filter, Sort, Export, Delete — never alphabetized, never grouped by implementation, never "destructive last" unless the toolbar already puts it last. +Resource menus (`+` attach, `@` mention, resource-tab `+`) do not mirror the sidebar. Their order is a product decision encoded in `RESOURCE_MENU_ORDER` (see below), and every resource menu shares it. + Platform-only entries (desktop **Browser** and **Terminal**) trail the shared set rather than interleaving, so the common prefix is identical on every platform. ## Grouping: a rule marks a change in what the action acts on @@ -107,10 +108,10 @@ grouping wants the standard grouping. An order duplicated across surfaces is an order that will drift. Export **one** constant and sort by it — do not hand-maintain a matching literal per menu. ```ts -/** Top-down order for every menu listing resource families, mirroring the sidebar. */ +/** Top-down order for every menu listing resource families. */ export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [ - 'integration', 'task', 'table', 'file', 'filefolder', - 'knowledgebase', 'log', 'workflow', 'folder', 'browser', 'terminal', 'generic', + 'integration', 'task', 'dashboard', 'table', 'file', 'filefolder', + 'knowledgebase', 'workflow', 'log', 'folder', 'browser', 'terminal', 'generic', ] export function byResourceMenuOrder(a: T, b: T) { diff --git a/.cursor/rules/sim-list-ordering.mdc b/.cursor/rules/sim-list-ordering.mdc index f85dc165a02..a1eb0b94af8 100644 --- a/.cursor/rules/sim-list-ordering.mdc +++ b/.cursor/rules/sim-list-ordering.mdc @@ -1,5 +1,5 @@ --- -description: "List and menu ordering that mirrors the sidebar or toolbar, with one separator before the destructive action" +description: "List and menu ordering that mirrors the toolbar or settings nav, encoded once, with one separator before the destructive action" globs: ["apps/sim/app/**/*.tsx","apps/sim/ee/**/*.tsx","apps/sim/components/**/*.tsx"] --- @@ -7,7 +7,7 @@ globs: ["apps/sim/app/**/*.tsx","apps/sim/ee/**/*.tsx","apps/sim/components/**/* # List & Menu Ordering -**A list orders itself the way the user already reads the same things somewhere else.** Dropdowns, context menus, tab strips, command palettes, and settings navs are all *second* presentations of a set the user has already seen — in the sidebar, in a toolbar, in a column-header row. When the second presentation reorders that set, the user re-reads it from scratch every time. +**A list orders itself the way the user already reads the same things somewhere else.** Dropdowns, context menus, tab strips, command palettes, and settings navs are all *second* presentations of a set the user has already seen — in a toolbar, in the settings nav, in a column-header row. When the second presentation reorders that set, the user re-reads it from scratch every time. This is not a style preference. Order is the cheapest affordance a list has, and the only one that costs nothing to get right. @@ -17,13 +17,14 @@ Before writing a list of items, find where the user sees those same items *first | The list | Mirrors | | --- | --- | -| Resource menus (`+` attach, `@` mention, resource-tab `+`) | the workspace **sidebar**, top-down | | A row / root **context menu** | that surface's **toolbar**, left-to-right → top-to-bottom | | Settings tab strip, recently-deleted tabs | the **settings nav**, top-down | | A "New …" menu | the order those things appear once created | Left-to-right becomes top-to-bottom. A toolbar reading `Filter · Sort · Export · Delete` becomes a menu reading Filter, Sort, Export, Delete — never alphabetized, never grouped by implementation, never "destructive last" unless the toolbar already puts it last. +Resource menus (`+` attach, `@` mention, resource-tab `+`) do not mirror the sidebar. Their order is a product decision encoded in `RESOURCE_MENU_ORDER` (see below), and every resource menu shares it. + Platform-only entries (desktop **Browser** and **Terminal**) trail the shared set rather than interleaving, so the common prefix is identical on every platform. ## Grouping: a rule marks a change in what the action acts on @@ -106,10 +107,10 @@ grouping wants the standard grouping. An order duplicated across surfaces is an order that will drift. Export **one** constant and sort by it — do not hand-maintain a matching literal per menu. ```ts -/** Top-down order for every menu listing resource families, mirroring the sidebar. */ +/** Top-down order for every menu listing resource families. */ export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [ - 'integration', 'task', 'table', 'file', 'filefolder', - 'knowledgebase', 'log', 'workflow', 'folder', 'browser', 'terminal', 'generic', + 'integration', 'task', 'dashboard', 'table', 'file', 'filefolder', + 'knowledgebase', 'workflow', 'log', 'folder', 'browser', 'terminal', 'generic', ] export function byResourceMenuOrder(a: T, b: T) { diff --git a/CLAUDE.md b/CLAUDE.md index be75f097df3..70309e5dfce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,7 @@ The `'use client'` server boundary, the app/worker runtime env split, and featur - **Components**: `'use client'` only for hooks or browser APIs. Structure order, extraction thresholds, and list-render rules: `.claude/rules/sim-components.md`. Render-performance idioms (lazy-init refs, hoisting, `Map` pre-indexing, `[...arr].sort()` never `toSorted()` on client paths): `.claude/rules/sim-react-performance.md`. For effect/state/memo/callback anti-patterns use the `/you-might-not-need-*` skills and verify against the running UI. - **State ownership**: React Query owns server data — never `useState` + `fetch`; shareable client view-state (tabs, filters, search, pagination, selected id) lives in the URL via `nuqs`; Zustand owns global client state; `useState` owns UI-only state. Hooks: `.claude/rules/sim-hooks.md`. Stores (`devtools`, `persist` only with an explicit `partialize` whitelist, workflow value invariants): `.claude/rules/sim-stores.md`. URL state: `.claude/rules/sim-url-state.md`. - **Utils**: inline a helper with one consumer; create `utils.ts` when 2+ files share it — in `lib/` (app-wide) or `feature/utils/` (feature-scoped). Check `lib/` before writing a new one. -- **Lists and menus** mirror the order the user already reads elsewhere (sidebar, toolbar), encoded in one exported order constant; a separator marks only a change in what the action acts on (typically one, before the destructive action): `.claude/rules/sim-list-ordering.md`. +- **Lists and menus** mirror the order the user already reads elsewhere (toolbar, settings nav), encoded in one exported order constant (resource menus share `RESOURCE_MENU_ORDER`, a product order that does not mirror the sidebar); a separator marks only a change in what the action acts on (typically one, before the destructive action): `.claude/rules/sim-list-ordering.md`. - **Caching**: `lru-cache` with a `max` ceiling, never a hand-rolled TTL `Map`; a lifecycle map is not a cache; cache the gate, never the credential: `.claude/rules/sim-caching.md`. ## API Contracts and Routes diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index defae9e3444..1a9de189f0d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -289,10 +289,9 @@ export const RESOURCE_REGISTRY: Record Date: Wed, 30 Sep 2026 18:26:16 -0700 Subject: [PATCH 3/3] fix(dashboards): scope entitlements, keep mention ids, and tighten contracts --- .../[workspaceId]/home/hooks/use-chat.ts | 1 + .../components/charts/time-series-chart.tsx | 1 + apps/sim/lib/api/contracts/dashboards.ts | 4 +- .../lib/dashboards/repository.integration.ts | 5 +- .../mothership/chat/display-message.test.ts | 14 ++++ .../lib/mothership/chat/display-message.ts | 29 +++----- apps/sim/lib/mothership/chat/payload.test.ts | 17 +++++ .../lib/mothership/chat/persisted-message.ts | 68 ++++++++----------- apps/sim/lib/mothership/entitlements.ts | 49 ++++++++++--- 9 files changed, 115 insertions(+), 73 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 0f506f151b8..805b8fb6604 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -3423,6 +3423,7 @@ export function useChat( ? { viewId: (c.currentView ? c.currentView.viewId : c.viewId) ?? undefined } : {}), ...('fileId' in c && c.fileId ? { fileId: c.fileId } : {}), + ...('dashboardId' in c && c.dashboardId ? { dashboardId: c.dashboardId } : {}), ...('folderId' in c && c.folderId ? { folderId: c.folderId } : {}), ...(c.kind === 'skill' && 'skillId' in c ? { skillId: c.skillId } : {}), ...(c.kind === 'integration' && 'blockType' in c ? { blockType: c.blockType } : {}), diff --git a/apps/sim/components/charts/time-series-chart.tsx b/apps/sim/components/charts/time-series-chart.tsx index 0417ba1677d..8967fa48e9c 100644 --- a/apps/sim/components/charts/time-series-chart.tsx +++ b/apps/sim/components/charts/time-series-chart.tsx @@ -23,6 +23,7 @@ export function TimeSeriesChart({ label, option, ...config }: TimeSeriesChartPro
{ }) it('updates only at the expected revision and advances it', async () => { - const current = (await getWorkspaceDashboard('ws-a'))! + const current = await insertWorkspaceDashboard('ws-c', 'original', 'user-1') + if (!current) throw new Error('ws-c dashboard was not created') const updated = await updateDashboardContent(current.id, 'edited', 'user-2', current.revision) expect(updated).toMatchObject({ content: 'edited', @@ -61,6 +62,6 @@ describe('dashboard repository in PostgreSQL', () => { updatedBy: 'user-2', }) expect(await updateDashboardContent(current.id, 'stale', 'user-3', current.revision)).toBeNull() - expect((await getWorkspaceDashboard('ws-a'))?.content).toBe('edited') + expect((await getWorkspaceDashboard('ws-c'))?.content).toBe('edited') }) }) diff --git a/apps/sim/lib/mothership/chat/display-message.test.ts b/apps/sim/lib/mothership/chat/display-message.test.ts index efd2f9d9ba9..29b7bd7c369 100644 --- a/apps/sim/lib/mothership/chat/display-message.test.ts +++ b/apps/sim/lib/mothership/chat/display-message.test.ts @@ -154,6 +154,20 @@ describe('display-message', () => { ]) }) + it('keeps the dashboard id on a reopened dashboard mention', () => { + const display = toDisplayMessage({ + id: 'msg-dashboard', + role: 'user', + content: '@Dashboard', + timestamp: '2024-01-01T00:00:00.000Z', + contexts: [{ kind: 'dashboard', label: 'Dashboard', dashboardId: 'dash-1' }], + }) + + expect(display.contexts).toEqual([ + { kind: 'dashboard', label: 'Dashboard', dashboardId: 'dash-1' }, + ]) + }) + it('preserves browser and terminal selection metadata for reopened messages', () => { const display = toDisplayMessage({ id: 'msg-selection', diff --git a/apps/sim/lib/mothership/chat/display-message.ts b/apps/sim/lib/mothership/chat/display-message.ts index c2710d16cdc..b97f5d97e3d 100644 --- a/apps/sim/lib/mothership/chat/display-message.ts +++ b/apps/sim/lib/mothership/chat/display-message.ts @@ -2,7 +2,11 @@ import type { PersistedContentBlock } from '@/lib/api/contracts/copilot-messages import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachment-preview' import { isLiveAssistantMessageId } from '@/lib/mothership/chat/live-message-id' import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message' -import { isUnsettledToolState, withBlockTiming } from '@/lib/mothership/chat/persisted-message' +import { + copyPersistedMessageContext, + isUnsettledToolState, + withBlockTiming, +} from '@/lib/mothership/chat/persisted-message' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -141,25 +145,10 @@ function toDisplayContexts( contexts: PersistedMessage['contexts'] ): ChatMessageContext[] | undefined { if (!contexts || contexts.length === 0) return undefined - return contexts.map((c) => ({ - kind: c.kind as ChatContextKind, - label: c.label, - ...(c.workflowId ? { workflowId: c.workflowId } : {}), - ...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}), - ...(c.tableId ? { tableId: c.tableId } : {}), - ...(c.viewId ? { viewId: c.viewId } : {}), - ...(c.fileId ? { fileId: c.fileId } : {}), - ...(c.folderId ? { folderId: c.folderId } : {}), - ...(c.chatId ? { chatId: c.chatId } : {}), - ...(c.blockType ? { blockType: c.blockType } : {}), - ...(c.skillId ? { skillId: c.skillId } : {}), - ...(c.serverId ? { serverId: c.serverId } : {}), - ...(c.fileName ? { fileName: c.fileName } : {}), - ...(c.tableName ? { tableName: c.tableName } : {}), - ...(c.tabId ? { tabId: c.tabId } : {}), - ...(c.terminalId ? { terminalId: c.terminalId } : {}), - ...(c.selection ? { selection: { ...c.selection } } : {}), - })) + return contexts.map((c) => { + const copy = copyPersistedMessageContext(c) + return { ...copy, kind: copy.kind as ChatContextKind } + }) } const WORKSPACE_FILE_TOOL = 'prepare_file_edit' diff --git a/apps/sim/lib/mothership/chat/payload.test.ts b/apps/sim/lib/mothership/chat/payload.test.ts index dcc293bfd71..33ac385ba36 100644 --- a/apps/sim/lib/mothership/chat/payload.test.ts +++ b/apps/sim/lib/mothership/chat/payload.test.ts @@ -372,6 +372,23 @@ describe('buildCopilotRequestPayload', () => { } ) + it('never grants the workspace-only dashboards entitlement to an organization chat', async () => { + mockDashboardAvailability.mockResolvedValue(true) + const payload = await buildCopilotRequestPayload( + { + message: 'Show my dashboard', + userId: 'actor', + userMessageId: 'message-1', + organizationId: 'org-1', + principal: { kind: 'session' as const, userId: 'actor' }, + mode: 'agent', + model: '', + }, + { selectedModel: '' } + ) + expect(payload.entitlements).toEqual([]) + }) + beforeEach(() => { mockTrackChatUpload.mockResolvedValue({ displayName: 'payroll.xlsx' }) mockSecretNames.mockResolvedValue({ names: [] }) diff --git a/apps/sim/lib/mothership/chat/persisted-message.ts b/apps/sim/lib/mothership/chat/persisted-message.ts index 079ec378f24..9771edb2973 100644 --- a/apps/sim/lib/mothership/chat/persisted-message.ts +++ b/apps/sim/lib/mothership/chat/persisted-message.ts @@ -38,7 +38,7 @@ export interface PersistedFileAttachment { size: number } -interface PersistedMessageContext { +export interface PersistedMessageContext { kind: string label: string workflowId?: string @@ -87,6 +87,30 @@ function copyTextSelection( } } +/** The one field-wise copy of a message context, shared by every write, read and display path. */ +export function copyPersistedMessageContext(c: PersistedMessageContext): PersistedMessageContext { + return { + kind: c.kind, + label: c.label, + ...(c.workflowId ? { workflowId: c.workflowId } : {}), + ...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}), + ...(c.tableId ? { tableId: c.tableId } : {}), + ...(c.viewId ? { viewId: c.viewId } : {}), + ...(c.fileId ? { fileId: c.fileId } : {}), + ...(c.dashboardId ? { dashboardId: c.dashboardId } : {}), + ...(c.folderId ? { folderId: c.folderId } : {}), + ...(c.chatId ? { chatId: c.chatId } : {}), + ...(c.blockType ? { blockType: c.blockType } : {}), + ...(c.skillId ? { skillId: c.skillId } : {}), + ...(c.serverId ? { serverId: c.serverId } : {}), + ...(c.fileName ? { fileName: c.fileName } : {}), + ...(c.tableName ? { tableName: c.tableName } : {}), + ...(c.tabId ? { tabId: c.tabId } : {}), + ...(c.terminalId ? { terminalId: c.terminalId } : {}), + ...(c.selection ? { selection: copyTextSelection(c.selection) } : {}), + } +} + export interface PersistedMessage { id: string role: 'user' | 'assistant' @@ -464,26 +488,7 @@ export function buildPersistedUserMessage(params: UserMessageParams): PersistedM } if (params.contexts && params.contexts.length > 0) { - message.contexts = params.contexts.map((c) => ({ - kind: c.kind, - label: c.label, - ...(c.workflowId ? { workflowId: c.workflowId } : {}), - ...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}), - ...(c.tableId ? { tableId: c.tableId } : {}), - ...(c.viewId ? { viewId: c.viewId } : {}), - ...(c.fileId ? { fileId: c.fileId } : {}), - ...(c.dashboardId ? { dashboardId: c.dashboardId } : {}), - ...(c.folderId ? { folderId: c.folderId } : {}), - ...(c.chatId ? { chatId: c.chatId } : {}), - ...(c.blockType ? { blockType: c.blockType } : {}), - ...(c.skillId ? { skillId: c.skillId } : {}), - ...(c.serverId ? { serverId: c.serverId } : {}), - ...(c.fileName ? { fileName: c.fileName } : {}), - ...(c.tableName ? { tableName: c.tableName } : {}), - ...(c.tabId ? { tabId: c.tabId } : {}), - ...(c.terminalId ? { terminalId: c.terminalId } : {}), - ...(c.selection ? { selection: copyTextSelection(c.selection) } : {}), - })) + message.contexts = params.contexts.map(copyPersistedMessageContext) } return message @@ -816,26 +821,7 @@ export function normalizeMessage(raw: Record): PersistedMessage const rawContexts = raw.contexts as PersistedMessageContext[] | undefined if (Array.isArray(rawContexts) && rawContexts.length > 0) { - msg.contexts = rawContexts.map((c) => ({ - kind: c.kind, - label: c.label, - ...(c.workflowId ? { workflowId: c.workflowId } : {}), - ...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}), - ...(c.tableId ? { tableId: c.tableId } : {}), - ...(c.viewId ? { viewId: c.viewId } : {}), - ...(c.fileId ? { fileId: c.fileId } : {}), - ...(c.dashboardId ? { dashboardId: c.dashboardId } : {}), - ...(c.folderId ? { folderId: c.folderId } : {}), - ...(c.chatId ? { chatId: c.chatId } : {}), - ...(c.blockType ? { blockType: c.blockType } : {}), - ...(c.skillId ? { skillId: c.skillId } : {}), - ...(c.serverId ? { serverId: c.serverId } : {}), - ...(c.fileName ? { fileName: c.fileName } : {}), - ...(c.tableName ? { tableName: c.tableName } : {}), - ...(c.tabId ? { tabId: c.tabId } : {}), - ...(c.terminalId ? { terminalId: c.terminalId } : {}), - ...(c.selection ? { selection: copyTextSelection(c.selection) } : {}), - })) + msg.contexts = rawContexts.map(copyPersistedMessageContext) } return msg diff --git a/apps/sim/lib/mothership/entitlements.ts b/apps/sim/lib/mothership/entitlements.ts index e9deaff1899..e97f98941e4 100644 --- a/apps/sim/lib/mothership/entitlements.ts +++ b/apps/sim/lib/mothership/entitlements.ts @@ -1,6 +1,5 @@ import type { Principal } from '@sim/auth/principal' import { readDashboardAvailability } from '@/lib/dashboards/application/availability' -import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag' import { ENTITLEMENTS, type Entitlement } from '@/lib/mothership/generated/protocol' /** The owner of one chat turn: exactly one of a workspace or an organization. */ @@ -10,6 +9,26 @@ export interface EntitlementOwner { organizationId?: string } +interface WorkspaceOwner { + principal: Principal + workspaceId: string +} + +interface OrganizationOwner { + principal?: Principal + organizationId: string +} + +/** + * Each entitlement declares the chat scopes it exists in. A scope it does not + * declare is never granted, so a workspace-only capability cannot leak into an + * organization chat that has no workspace to run it against. + */ +interface EntitlementEvaluator { + workspace?: (owner: WorkspaceOwner) => Promise + organization?: (owner: OrganizationOwner) => Promise +} + /** * Entitlements are gated capabilities sent to Mothership as the chat payload's * `entitlements` list. The worker hides the matching commands, skills and prompt @@ -19,21 +38,35 @@ export interface EntitlementOwner { * 1. Worker: add the name to `ENTITLEMENTS` in `packages/contracts/src/protocol.ts`, run * `bun run contracts:sync`, then declare it on the gated surfaces (`entitlement` on a * command spec, `entitlement:` frontmatter on a skill, or an `entitled()` prompt section). - * 2. Here: add an evaluator. Every payload site picks it up through `buildCopilotRequestPayload`. + * 2. Here: add an evaluator for each scope it exists in. Every payload site picks it up + * through `buildCopilotRequestPayload`. * 3. Keep enforcement in Sim. The payload is forgeable, so the operation behind the gated * surface must re-check the same predicate when it runs. */ -const EVALUATORS: Record Promise> = { - [ENTITLEMENTS.dashboards]: async ({ principal, workspaceId, organizationId }) => { - if (organizationId) return isDashboardsEnabled(organizationId) - if (!workspaceId || !principal) return false - return readDashboardAvailability.execute({ principal, input: { workspaceId } }) +const EVALUATORS: Record = { + [ENTITLEMENTS.dashboards]: { + workspace: ({ principal, workspaceId }) => + readDashboardAvailability.execute({ principal, input: { workspaceId } }), }, } +function evaluate(evaluator: EntitlementEvaluator, owner: EntitlementOwner): Promise { + const { principal, workspaceId, organizationId } = owner + if (workspaceId && organizationId) { + throw new Error('Entitlement owner must be a workspace or an organization, not both') + } + if (organizationId) { + return evaluator.organization?.({ principal, organizationId }) ?? Promise.resolve(false) + } + if (workspaceId && principal) { + return evaluator.workspace?.({ principal, workspaceId }) ?? Promise.resolve(false) + } + return Promise.resolve(false) +} + /** The entitlements Sim grants a turn's owner, evaluated fresh for every turn. */ export async function computeEntitlements(owner: EntitlementOwner): Promise { const names = Object.values(ENTITLEMENTS) - const granted = await Promise.all(names.map((name) => EVALUATORS[name](owner))) + const granted = await Promise.all(names.map((name) => evaluate(EVALUATORS[name], owner))) return names.filter((_, index) => granted[index]) }