Skip to content

Commit d0b11f1

Browse files
feat(mothership): send entitlements instead of dashboardsEnabled
Restores the entitlement evaluator registry removed in v1.0.0 and sends the granted list with every Mothership turn; dashboards is the first entitlement. Sim still enforces every gated operation. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S6aTRnkiu7PxYZPNPXYEMV
1 parent 2e5d6dc commit d0b11f1

4 files changed

Lines changed: 82 additions & 33 deletions

File tree

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

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -351,23 +351,26 @@ describe('buildIntegrationToolSchemas', () => {
351351
})
352352

353353
describe('buildCopilotRequestPayload', () => {
354-
it.each([true, false])('passes server-authorized dashboard availability: %s', async (enabled) => {
355-
const principal = { kind: 'session' as const, userId: 'actor' }
356-
mockDashboardAvailability.mockResolvedValueOnce(enabled)
357-
const payload = await buildCopilotRequestPayload(
358-
{
359-
message: 'Show my dashboard',
360-
userId: 'actor',
361-
userMessageId: 'message-1',
362-
workspaceId: 'workspace-1',
363-
principal,
364-
mode: 'agent',
365-
model: '',
366-
},
367-
{ selectedModel: '' }
368-
)
369-
expect(payload.dashboardsEnabled).toBe(enabled)
370-
})
354+
it.each([true, false])(
355+
'grants the dashboards entitlement from server availability: %s',
356+
async (enabled) => {
357+
const principal = { kind: 'session' as const, userId: 'actor' }
358+
mockDashboardAvailability.mockResolvedValueOnce(enabled)
359+
const payload = await buildCopilotRequestPayload(
360+
{
361+
message: 'Show my dashboard',
362+
userId: 'actor',
363+
userMessageId: 'message-1',
364+
workspaceId: 'workspace-1',
365+
principal,
366+
mode: 'agent',
367+
model: '',
368+
},
369+
{ selectedModel: '' }
370+
)
371+
expect(payload.entitlements).toEqual(enabled ? ['dashboards'] : [])
372+
}
373+
)
371374

372375
beforeEach(() => {
373376
mockTrackChatUpload.mockResolvedValue({ displayName: 'payroll.xlsx' })
@@ -606,7 +609,6 @@ describe('buildCopilotRequestPayload', () => {
606609
for (const legacy of [
607610
'workspaceContext',
608611
'vfs',
609-
'entitlements',
610612
'userMetadata',
611613
'userPermission',
612614
'model',

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

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
88
import { isPaid } from '@/lib/billing/plan-helpers'
99
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
1010
import { isHosted, isLiveEnterpriseSearchEnabled } from '@/lib/core/config/env-flags'
11-
import { readDashboardAvailability } from '@/lib/dashboards/application/availability'
12-
import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
1311
import { isOAuthServiceDeploymentAvailable } from '@/lib/integrations/availability.server'
1412
import {
1513
type IntegrationGateConfig,
@@ -31,6 +29,7 @@ import {
3129
import type { AssistantImageContent } from '@/lib/mothership/chat/assistant-images'
3230
import { buildUploadedFileContext } from '@/lib/mothership/chat/upload-context'
3331
import { buildWorkspaceInventory } from '@/lib/mothership/chat/workspace-inventory'
32+
import { computeEntitlements } from '@/lib/mothership/entitlements'
3433
import type { AssistantSearchLevel } from '@/lib/mothership/generated/assistant'
3534
import type { ChatRequest, ModelSelection } from '@/lib/mothership/generated/protocol'
3635
import type { VfsSnapshotV1 } from '@/lib/mothership/generated/vfs-snapshot-v1'
@@ -444,18 +443,15 @@ export async function buildCopilotRequestPayload(
444443
!isAssistant && params.principal && params.workspaceId
445444
? await buildWorkspaceInventory(params.principal, params.workspaceId)
446445
: undefined
447-
const dashboardsEnabled =
448-
!isAssistant &&
449-
(params.organizationId
450-
? await isDashboardsEnabled(params.organizationId)
451-
: params.workspaceId && params.principal
452-
? await readDashboardAvailability.execute({
453-
principal: params.principal,
454-
input: { workspaceId: params.workspaceId },
455-
})
456-
: false)
446+
const entitlements = isAssistant
447+
? []
448+
: await computeEntitlements({
449+
principal: params.principal,
450+
workspaceId: params.workspaceId,
451+
organizationId: params.organizationId,
452+
})
457453
return {
458-
dashboardsEnabled,
454+
entitlements,
459455
message,
460456
...(!isAssistant && workflowId ? { workflowId } : {}),
461457
...(params.workspaceId ? { workspaceId: params.workspaceId } : {}),
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { Principal } from '@sim/auth/principal'
2+
import { readDashboardAvailability } from '@/lib/dashboards/application/availability'
3+
import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
4+
import { ENTITLEMENTS, type Entitlement } from '@/lib/mothership/generated/protocol'
5+
6+
/** The owner of one chat turn: exactly one of a workspace or an organization. */
7+
export interface EntitlementOwner {
8+
principal?: Principal
9+
workspaceId?: string
10+
organizationId?: string
11+
}
12+
13+
/**
14+
* Entitlements are gated capabilities sent to Mothership as the chat payload's
15+
* `entitlements` list. The worker hides the matching commands, skills and prompt
16+
* sections when one is absent, so an organization without the feature never hears of it.
17+
*
18+
* Adding an entitlement:
19+
* 1. Worker: add the name to `ENTITLEMENTS` in `packages/contracts/src/protocol.ts`, run
20+
* `bun run contracts:sync`, then declare it on the gated surfaces (`entitlement` on a
21+
* 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`.
23+
* 3. Keep enforcement in Sim. The payload is forgeable, so the operation behind the gated
24+
* surface must re-check the same predicate when it runs.
25+
*/
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 } })
31+
},
32+
}
33+
34+
/** The entitlements Sim grants a turn's owner, evaluated fresh for every turn. */
35+
export async function computeEntitlements(owner: EntitlementOwner): Promise<Entitlement[]> {
36+
const names = Object.values(ENTITLEMENTS)
37+
const granted = await Promise.all(names.map((name) => EVALUATORS[name](owner)))
38+
return names.filter((_, index) => granted[index])
39+
}

‎apps/sim/lib/mothership/generated/protocol.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,17 @@ export const DesktopContextSchema = z.object({
130130
});
131131
export type DesktopContext = z.infer<typeof DesktopContextSchema>;
132132

133+
/**
134+
* Capabilities Sim computes for a turn's owner from organization flags, plan and ownership.
135+
* The worker hides a gated command, skill or prompt section when its entitlement is absent.
136+
* This only controls what the agent is told about: Sim re-checks every operation, because
137+
* the list travels in a forgeable payload. The wire accepts any name, so Sim can add one
138+
* before the worker learns it.
139+
*/
140+
export const ENTITLEMENTS = { dashboards: "dashboards" } as const;
141+
export type Entitlement = (typeof ENTITLEMENTS)[keyof typeof ENTITLEMENTS];
142+
export const Entitlements = z.array(z.string().min(1).max(64)).max(32);
143+
133144
export const ChatPayloadSchema = z
134145
.strictObject({
135146
desktop: DesktopContextSchema.optional(),
@@ -152,7 +163,7 @@ export const ChatPayloadSchema = z
152163
/** Workflow-scoped chats (the workflow-page copilot): the agent anchors to this workflow. */
153164
workflowId: z.string().optional(),
154165
integrationCatalog: IntegrationCatalogContext.optional(),
155-
dashboardsEnabled: z.boolean().optional(),
166+
entitlements: Entitlements.default([]),
156167
/** Accepted for wire compatibility with current sim builds; unused — the CLI now
157168
* executes on the sim side under sim's own authentication, so no credential crosses. */
158169
delegationToken: z.string().optional(),
@@ -263,7 +274,8 @@ export interface ChatRequest extends StreamResponseReceipt {
263274
workflowId?: string | undefined;
264275
/** Authorized discovery selectors; schemas stay in Sim's catalog. */
265276
integrationCatalog?: IntegrationCatalogContext | undefined;
266-
dashboardsEnabled?: boolean | undefined;
277+
/** Capabilities Sim computed for this turn's owner; see {@link ENTITLEMENTS}. */
278+
entitlements?: string[] | undefined;
267279
/** Deprecated: unused since the CLI moved to sim-side in-process execution (no
268280
* credential crosses the wire); accepted so current senders keep validating. */
269281
delegationToken?: string | undefined;

0 commit comments

Comments
 (0)