Skip to content

Commit e3af83d

Browse files
fix(dashboards): scope entitlements, keep mention ids, and tighten contracts
1 parent e4da984 commit e3af83d

9 files changed

Lines changed: 115 additions & 73 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3423,6 +3423,7 @@ export function useChat(
34233423
? { viewId: (c.currentView ? c.currentView.viewId : c.viewId) ?? undefined }
34243424
: {}),
34253425
...('fileId' in c && c.fileId ? { fileId: c.fileId } : {}),
3426+
...('dashboardId' in c && c.dashboardId ? { dashboardId: c.dashboardId } : {}),
34263427
...('folderId' in c && c.folderId ? { folderId: c.folderId } : {}),
34273428
...(c.kind === 'skill' && 'skillId' in c ? { skillId: c.skillId } : {}),
34283429
...(c.kind === 'integration' && 'blockType' in c ? { blockType: c.blockType } : {}),

‎apps/sim/components/charts/time-series-chart.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export function TimeSeriesChart({ label, option, ...config }: TimeSeriesChartPro
2323
<div className='h-full min-w-0'>
2424
<div
2525
className='mb-2 flex h-8 min-w-0 items-center justify-between gap-4 text-sm tabular-nums'
26+
role='group'
2627
aria-label={`${label} values`}
2728
>
2829
<div

‎apps/sim/lib/api/contracts/dashboards.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const dashboardRecordSchema = z.object({
1313
type: z.literal('dashboard'),
1414
name: z.string(),
1515
updatedAt: z.string(),
16-
revision: z.string(),
16+
revision: dashboardRevisionSchema,
1717
})
1818

1919
/** A workspace has at most one dashboard, which Sim builds; both fields are null until then. */
@@ -25,7 +25,7 @@ export const readWorkspaceDashboardContract = defineRouteContract({
2525
mode: 'json',
2626
schema: z.object({
2727
dashboard: dashboardRecordSchema.nullable(),
28-
content: z.string().nullable(),
28+
content: dashboardContentSchema.nullable(),
2929
}),
3030
},
3131
})

‎apps/sim/lib/dashboards/repository.integration.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,15 @@ describe('dashboard repository in PostgreSQL', () => {
5353
})
5454

5555
it('updates only at the expected revision and advances it', async () => {
56-
const current = (await getWorkspaceDashboard('ws-a'))!
56+
const current = await insertWorkspaceDashboard('ws-c', 'original', 'user-1')
57+
if (!current) throw new Error('ws-c dashboard was not created')
5758
const updated = await updateDashboardContent(current.id, 'edited', 'user-2', current.revision)
5859
expect(updated).toMatchObject({
5960
content: 'edited',
6061
revision: current.revision + 1,
6162
updatedBy: 'user-2',
6263
})
6364
expect(await updateDashboardContent(current.id, 'stale', 'user-3', current.revision)).toBeNull()
64-
expect((await getWorkspaceDashboard('ws-a'))?.content).toBe('edited')
65+
expect((await getWorkspaceDashboard('ws-c'))?.content).toBe('edited')
6566
})
6667
})

‎apps/sim/lib/mothership/chat/display-message.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,20 @@ describe('display-message', () => {
154154
])
155155
})
156156

157+
it('keeps the dashboard id on a reopened dashboard mention', () => {
158+
const display = toDisplayMessage({
159+
id: 'msg-dashboard',
160+
role: 'user',
161+
content: '@Dashboard',
162+
timestamp: '2024-01-01T00:00:00.000Z',
163+
contexts: [{ kind: 'dashboard', label: 'Dashboard', dashboardId: 'dash-1' }],
164+
})
165+
166+
expect(display.contexts).toEqual([
167+
{ kind: 'dashboard', label: 'Dashboard', dashboardId: 'dash-1' },
168+
])
169+
})
170+
157171
it('preserves browser and terminal selection metadata for reopened messages', () => {
158172
const display = toDisplayMessage({
159173
id: 'msg-selection',

‎apps/sim/lib/mothership/chat/display-message.ts‎

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import type { PersistedContentBlock } from '@/lib/api/contracts/copilot-messages
22
import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachment-preview'
33
import { isLiveAssistantMessageId } from '@/lib/mothership/chat/live-message-id'
44
import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message'
5-
import { isUnsettledToolState, withBlockTiming } from '@/lib/mothership/chat/persisted-message'
5+
import {
6+
copyPersistedMessageContext,
7+
isUnsettledToolState,
8+
withBlockTiming,
9+
} from '@/lib/mothership/chat/persisted-message'
610
import {
711
MothershipStreamV1CompletionStatus,
812
MothershipStreamV1EventType,
@@ -141,25 +145,10 @@ function toDisplayContexts(
141145
contexts: PersistedMessage['contexts']
142146
): ChatMessageContext[] | undefined {
143147
if (!contexts || contexts.length === 0) return undefined
144-
return contexts.map((c) => ({
145-
kind: c.kind as ChatContextKind,
146-
label: c.label,
147-
...(c.workflowId ? { workflowId: c.workflowId } : {}),
148-
...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}),
149-
...(c.tableId ? { tableId: c.tableId } : {}),
150-
...(c.viewId ? { viewId: c.viewId } : {}),
151-
...(c.fileId ? { fileId: c.fileId } : {}),
152-
...(c.folderId ? { folderId: c.folderId } : {}),
153-
...(c.chatId ? { chatId: c.chatId } : {}),
154-
...(c.blockType ? { blockType: c.blockType } : {}),
155-
...(c.skillId ? { skillId: c.skillId } : {}),
156-
...(c.serverId ? { serverId: c.serverId } : {}),
157-
...(c.fileName ? { fileName: c.fileName } : {}),
158-
...(c.tableName ? { tableName: c.tableName } : {}),
159-
...(c.tabId ? { tabId: c.tabId } : {}),
160-
...(c.terminalId ? { terminalId: c.terminalId } : {}),
161-
...(c.selection ? { selection: { ...c.selection } } : {}),
162-
}))
148+
return contexts.map((c) => {
149+
const copy = copyPersistedMessageContext(c)
150+
return { ...copy, kind: copy.kind as ChatContextKind }
151+
})
163152
}
164153

165154
const WORKSPACE_FILE_TOOL = 'prepare_file_edit'

‎apps/sim/lib/mothership/chat/payload.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,23 @@ describe('buildCopilotRequestPayload', () => {
372372
}
373373
)
374374

375+
it('never grants the workspace-only dashboards entitlement to an organization chat', async () => {
376+
mockDashboardAvailability.mockResolvedValue(true)
377+
const payload = await buildCopilotRequestPayload(
378+
{
379+
message: 'Show my dashboard',
380+
userId: 'actor',
381+
userMessageId: 'message-1',
382+
organizationId: 'org-1',
383+
principal: { kind: 'session' as const, userId: 'actor' },
384+
mode: 'agent',
385+
model: '',
386+
},
387+
{ selectedModel: '' }
388+
)
389+
expect(payload.entitlements).toEqual([])
390+
})
391+
375392
beforeEach(() => {
376393
mockTrackChatUpload.mockResolvedValue({ displayName: 'payroll.xlsx' })
377394
mockSecretNames.mockResolvedValue({ names: [] })

‎apps/sim/lib/mothership/chat/persisted-message.ts‎

Lines changed: 27 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export interface PersistedFileAttachment {
3838
size: number
3939
}
4040

41-
interface PersistedMessageContext {
41+
export interface PersistedMessageContext {
4242
kind: string
4343
label: string
4444
workflowId?: string
@@ -87,6 +87,30 @@ function copyTextSelection(
8787
}
8888
}
8989

90+
/** The one field-wise copy of a message context, shared by every write, read and display path. */
91+
export function copyPersistedMessageContext(c: PersistedMessageContext): PersistedMessageContext {
92+
return {
93+
kind: c.kind,
94+
label: c.label,
95+
...(c.workflowId ? { workflowId: c.workflowId } : {}),
96+
...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}),
97+
...(c.tableId ? { tableId: c.tableId } : {}),
98+
...(c.viewId ? { viewId: c.viewId } : {}),
99+
...(c.fileId ? { fileId: c.fileId } : {}),
100+
...(c.dashboardId ? { dashboardId: c.dashboardId } : {}),
101+
...(c.folderId ? { folderId: c.folderId } : {}),
102+
...(c.chatId ? { chatId: c.chatId } : {}),
103+
...(c.blockType ? { blockType: c.blockType } : {}),
104+
...(c.skillId ? { skillId: c.skillId } : {}),
105+
...(c.serverId ? { serverId: c.serverId } : {}),
106+
...(c.fileName ? { fileName: c.fileName } : {}),
107+
...(c.tableName ? { tableName: c.tableName } : {}),
108+
...(c.tabId ? { tabId: c.tabId } : {}),
109+
...(c.terminalId ? { terminalId: c.terminalId } : {}),
110+
...(c.selection ? { selection: copyTextSelection(c.selection) } : {}),
111+
}
112+
}
113+
90114
export interface PersistedMessage {
91115
id: string
92116
role: 'user' | 'assistant'
@@ -464,26 +488,7 @@ export function buildPersistedUserMessage(params: UserMessageParams): PersistedM
464488
}
465489

466490
if (params.contexts && params.contexts.length > 0) {
467-
message.contexts = params.contexts.map((c) => ({
468-
kind: c.kind,
469-
label: c.label,
470-
...(c.workflowId ? { workflowId: c.workflowId } : {}),
471-
...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}),
472-
...(c.tableId ? { tableId: c.tableId } : {}),
473-
...(c.viewId ? { viewId: c.viewId } : {}),
474-
...(c.fileId ? { fileId: c.fileId } : {}),
475-
...(c.dashboardId ? { dashboardId: c.dashboardId } : {}),
476-
...(c.folderId ? { folderId: c.folderId } : {}),
477-
...(c.chatId ? { chatId: c.chatId } : {}),
478-
...(c.blockType ? { blockType: c.blockType } : {}),
479-
...(c.skillId ? { skillId: c.skillId } : {}),
480-
...(c.serverId ? { serverId: c.serverId } : {}),
481-
...(c.fileName ? { fileName: c.fileName } : {}),
482-
...(c.tableName ? { tableName: c.tableName } : {}),
483-
...(c.tabId ? { tabId: c.tabId } : {}),
484-
...(c.terminalId ? { terminalId: c.terminalId } : {}),
485-
...(c.selection ? { selection: copyTextSelection(c.selection) } : {}),
486-
}))
491+
message.contexts = params.contexts.map(copyPersistedMessageContext)
487492
}
488493

489494
return message
@@ -816,26 +821,7 @@ export function normalizeMessage(raw: Record<string, unknown>): PersistedMessage
816821

817822
const rawContexts = raw.contexts as PersistedMessageContext[] | undefined
818823
if (Array.isArray(rawContexts) && rawContexts.length > 0) {
819-
msg.contexts = rawContexts.map((c) => ({
820-
kind: c.kind,
821-
label: c.label,
822-
...(c.workflowId ? { workflowId: c.workflowId } : {}),
823-
...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}),
824-
...(c.tableId ? { tableId: c.tableId } : {}),
825-
...(c.viewId ? { viewId: c.viewId } : {}),
826-
...(c.fileId ? { fileId: c.fileId } : {}),
827-
...(c.dashboardId ? { dashboardId: c.dashboardId } : {}),
828-
...(c.folderId ? { folderId: c.folderId } : {}),
829-
...(c.chatId ? { chatId: c.chatId } : {}),
830-
...(c.blockType ? { blockType: c.blockType } : {}),
831-
...(c.skillId ? { skillId: c.skillId } : {}),
832-
...(c.serverId ? { serverId: c.serverId } : {}),
833-
...(c.fileName ? { fileName: c.fileName } : {}),
834-
...(c.tableName ? { tableName: c.tableName } : {}),
835-
...(c.tabId ? { tabId: c.tabId } : {}),
836-
...(c.terminalId ? { terminalId: c.terminalId } : {}),
837-
...(c.selection ? { selection: copyTextSelection(c.selection) } : {}),
838-
}))
824+
msg.contexts = rawContexts.map(copyPersistedMessageContext)
839825
}
840826

841827
return msg
Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { Principal } from '@sim/auth/principal'
22
import { readDashboardAvailability } from '@/lib/dashboards/application/availability'
3-
import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
43
import { ENTITLEMENTS, type Entitlement } from '@/lib/mothership/generated/protocol'
54

65
/** The owner of one chat turn: exactly one of a workspace or an organization. */
@@ -10,6 +9,26 @@ export interface EntitlementOwner {
109
organizationId?: string
1110
}
1211

12+
interface WorkspaceOwner {
13+
principal: Principal
14+
workspaceId: string
15+
}
16+
17+
interface OrganizationOwner {
18+
principal?: Principal
19+
organizationId: string
20+
}
21+
22+
/**
23+
* Each entitlement declares the chat scopes it exists in. A scope it does not
24+
* declare is never granted, so a workspace-only capability cannot leak into an
25+
* organization chat that has no workspace to run it against.
26+
*/
27+
interface EntitlementEvaluator {
28+
workspace?: (owner: WorkspaceOwner) => Promise<boolean>
29+
organization?: (owner: OrganizationOwner) => Promise<boolean>
30+
}
31+
1332
/**
1433
* Entitlements are gated capabilities sent to Mothership as the chat payload's
1534
* `entitlements` list. The worker hides the matching commands, skills and prompt
@@ -19,21 +38,35 @@ export interface EntitlementOwner {
1938
* 1. Worker: add the name to `ENTITLEMENTS` in `packages/contracts/src/protocol.ts`, run
2039
* `bun run contracts:sync`, then declare it on the gated surfaces (`entitlement` on a
2140
* command spec, `entitlement:` frontmatter on a skill, or an `entitled()` prompt section).
22-
* 2. Here: add an evaluator. Every payload site picks it up through `buildCopilotRequestPayload`.
41+
* 2. Here: add an evaluator for each scope it exists in. Every payload site picks it up
42+
* through `buildCopilotRequestPayload`.
2343
* 3. Keep enforcement in Sim. The payload is forgeable, so the operation behind the gated
2444
* surface must re-check the same predicate when it runs.
2545
*/
26-
const EVALUATORS: Record<Entitlement, (owner: EntitlementOwner) => Promise<boolean>> = {
27-
[ENTITLEMENTS.dashboards]: async ({ principal, workspaceId, organizationId }) => {
28-
if (organizationId) return isDashboardsEnabled(organizationId)
29-
if (!workspaceId || !principal) return false
30-
return readDashboardAvailability.execute({ principal, input: { workspaceId } })
46+
const EVALUATORS: Record<Entitlement, EntitlementEvaluator> = {
47+
[ENTITLEMENTS.dashboards]: {
48+
workspace: ({ principal, workspaceId }) =>
49+
readDashboardAvailability.execute({ principal, input: { workspaceId } }),
3150
},
3251
}
3352

53+
function evaluate(evaluator: EntitlementEvaluator, owner: EntitlementOwner): Promise<boolean> {
54+
const { principal, workspaceId, organizationId } = owner
55+
if (workspaceId && organizationId) {
56+
throw new Error('Entitlement owner must be a workspace or an organization, not both')
57+
}
58+
if (organizationId) {
59+
return evaluator.organization?.({ principal, organizationId }) ?? Promise.resolve(false)
60+
}
61+
if (workspaceId && principal) {
62+
return evaluator.workspace?.({ principal, workspaceId }) ?? Promise.resolve(false)
63+
}
64+
return Promise.resolve(false)
65+
}
66+
3467
/** The entitlements Sim grants a turn's owner, evaluated fresh for every turn. */
3568
export async function computeEntitlements(owner: EntitlementOwner): Promise<Entitlement[]> {
3669
const names = Object.values(ENTITLEMENTS)
37-
const granted = await Promise.all(names.map((name) => EVALUATORS[name](owner)))
70+
const granted = await Promise.all(names.map((name) => evaluate(EVALUATORS[name], owner)))
3871
return names.filter((_, index) => granted[index])
3972
}

0 commit comments

Comments
 (0)