Skip to content

Commit 51e8300

Browse files
committed
fix(copilot): secure live platform context
1 parent f9f7fb8 commit 51e8300

30 files changed

Lines changed: 989 additions & 222 deletions

apps/sim/lib/api/contracts/organization.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from 'zod'
22
import {
3+
organizationRoleSchema,
34
type PiiRedactionSettings,
45
piiRedactionSettingsSchema,
56
retentionOverridesSchema,
@@ -15,9 +16,6 @@ const numericResponseSchema = z.preprocess((value) => {
1516
return Number.isFinite(parsed) ? parsed : value
1617
}, z.number())
1718

18-
export const organizationRoleSchema = z.enum(['owner', 'admin', 'member'], {
19-
error: 'Invalid role',
20-
})
2119
export const organizationParamsSchema = z.object({
2220
id: z.string().min(1),
2321
})

apps/sim/lib/api/contracts/primitives.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
customPatternSchema,
77
isCanonicalBase64,
88
organizationIdSchema,
9+
organizationRoleSchema,
910
piiStagePolicySchema,
1011
piiStagesSchema,
1112
privateSecretProvenanceBundleSchema,
@@ -16,6 +17,16 @@ import {
1617
workspaceIdSchema,
1718
} from '@/lib/api/contracts/primitives'
1819

20+
describe('organizationRoleSchema', () => {
21+
it.each(['owner', 'admin', 'member'] as const)('accepts canonical role %s', (role) => {
22+
expect(organizationRoleSchema.parse(role)).toBe(role)
23+
})
24+
25+
it.each(['billing-owner', 'viewer', '', null, undefined])('rejects invalid role %j', (role) => {
26+
expect(organizationRoleSchema.safeParse(role).success).toBe(false)
27+
})
28+
})
29+
1930
describe('workspaceFileNameSchema', () => {
2031
it('trims and accepts one bounded file name', () => {
2132
expect(workspaceFileNameSchema.parse(' report.pdf ')).toBe('report.pdf')

apps/sim/lib/api/contracts/primitives.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,12 @@ export const workspaceFileNameSchema = z
231231
/** Non-empty `organizationId` field with a stable, human-readable message. */
232232
export const organizationIdSchema = requiredFieldSchema('Organization ID is required')
233233

234+
/** Canonical organization membership role shared across API resource families. */
235+
export const organizationRoleSchema = z.enum(['owner', 'admin', 'member'], {
236+
error: 'Invalid role',
237+
})
238+
export type OrganizationRole = z.output<typeof organizationRoleSchema>
239+
234240
/** Non-empty `workflowId` field with a stable, human-readable message. */
235241
export const workflowIdSchema = requiredFieldSchema('Workflow ID is required')
236242

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { workspaceHostContextSchema } from '@/lib/api/contracts/workspaces'
6+
7+
const viewerSchema = workspaceHostContextSchema.shape.viewer
8+
const viewer = {
9+
permission: 'read' as const,
10+
isHostOrganizationMember: false,
11+
isHostOrganizationAdmin: false,
12+
}
13+
14+
describe('workspaceHostContextSchema organizationRole', () => {
15+
it.each(['owner', 'admin', 'member'] as const)(
16+
'accepts canonical role %s',
17+
(organizationRole) => {
18+
expect(viewerSchema.safeParse({ ...viewer, organizationRole }).success).toBe(true)
19+
}
20+
)
21+
22+
it('retains null and omission for rolling response compatibility', () => {
23+
expect(viewerSchema.safeParse({ ...viewer, organizationRole: null }).success).toBe(true)
24+
expect(viewerSchema.safeParse(viewer).success).toBe(true)
25+
})
26+
27+
it('rejects non-canonical organization roles', () => {
28+
expect(viewerSchema.safeParse({ ...viewer, organizationRole: 'billing-owner' }).success).toBe(
29+
false
30+
)
31+
})
32+
})

apps/sim/lib/api/contracts/workspaces.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { z } from 'zod'
2-
import { nonEmptyIdSchema, requiredFieldSchema } from '@/lib/api/contracts/primitives'
2+
import {
3+
nonEmptyIdSchema,
4+
organizationRoleSchema,
5+
requiredFieldSchema,
6+
} from '@/lib/api/contracts/primitives'
37
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
48

59
export const workspaceScopeSchema = z.enum(['active', 'archived', 'all'])
@@ -264,7 +268,7 @@ export const workspaceHostContextSchema = z.object({
264268
isHostOrganizationMember: z.boolean(),
265269
isHostOrganizationAdmin: z.boolean(),
266270
/** Optional for rolling compatibility with app versions that predate organization-role projection. */
267-
organizationRole: z.string().nullable().optional(),
271+
organizationRole: organizationRoleSchema.nullable().optional(),
268272
}),
269273
})
270274

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({
7+
events: [] as string[],
8+
getResolvedUserUsageData: vi.fn(),
9+
getCreditBalanceForEntity: vi.fn(),
10+
isOrgScopedSubscription: vi.fn(),
11+
}))
12+
13+
vi.mock('@/lib/billing/core/usage', () => ({
14+
getResolvedUserUsageData: mocks.getResolvedUserUsageData,
15+
}))
16+
17+
vi.mock('@/lib/billing/credits/balance', () => ({
18+
getCreditBalanceForEntity: mocks.getCreditBalanceForEntity,
19+
}))
20+
21+
vi.mock('@/lib/billing/subscriptions/utils', () => ({
22+
isOrgScopedSubscription: mocks.isOrgScopedSubscription,
23+
}))
24+
25+
import { getAccountBillingSnapshot } from '@/lib/billing/core/account-billing-snapshot'
26+
27+
const usage = {
28+
currentUsage: 18.5,
29+
limit: 40,
30+
percentUsed: 46.25,
31+
isWarning: false,
32+
isExceeded: false,
33+
billingPeriodStart: new Date('2026-08-01T00:00:00Z'),
34+
billingPeriodEnd: new Date('2026-09-01T00:00:00Z'),
35+
lastPeriodCost: 31,
36+
}
37+
38+
describe('getAccountBillingSnapshot', () => {
39+
beforeEach(() => {
40+
vi.clearAllMocks()
41+
mocks.events.length = 0
42+
})
43+
44+
it('reuses one resolved subscription for org scope, usage, limits, and credits', async () => {
45+
const subscription = {
46+
plan: 'team',
47+
referenceId: 'org-1',
48+
}
49+
mocks.getResolvedUserUsageData.mockImplementation(async () => {
50+
mocks.events.push('usage-and-subscription')
51+
return { usage, subscription, personalCreditBalance: 4 }
52+
})
53+
mocks.isOrgScopedSubscription.mockReturnValue(true)
54+
mocks.getCreditBalanceForEntity.mockImplementation(async () => {
55+
mocks.events.push('credits')
56+
return 25
57+
})
58+
59+
await expect(getAccountBillingSnapshot('user-1')).resolves.toEqual({
60+
plan: 'team',
61+
billingScope: 'organization',
62+
organizationId: 'org-1',
63+
usage: {
64+
currentPeriodCost: 18.5,
65+
limit: 40,
66+
remaining: 21.5,
67+
percentUsed: 46.25,
68+
isExceeded: false,
69+
billingPeriodEnd: new Date('2026-09-01T00:00:00Z'),
70+
},
71+
credits: { balance: 25, scope: 'organization' },
72+
})
73+
expect(mocks.getResolvedUserUsageData).toHaveBeenCalledOnce()
74+
expect(mocks.getCreditBalanceForEntity).toHaveBeenCalledWith(
75+
'organization',
76+
'org-1',
77+
expect.anything()
78+
)
79+
expect(mocks.events).toEqual(['usage-and-subscription', 'credits'])
80+
})
81+
82+
it('preserves personal scope and clamps negative remaining usage to zero', async () => {
83+
mocks.getResolvedUserUsageData.mockResolvedValue({
84+
usage: { ...usage, currentUsage: 45, isExceeded: true },
85+
subscription: { plan: 'pro', referenceId: 'user-1' },
86+
personalCreditBalance: 0,
87+
})
88+
mocks.isOrgScopedSubscription.mockReturnValue(false)
89+
mocks.getCreditBalanceForEntity.mockResolvedValue(0)
90+
91+
await expect(getAccountBillingSnapshot('user-1')).resolves.toMatchObject({
92+
plan: 'pro',
93+
billingScope: 'user',
94+
organizationId: null,
95+
usage: { remaining: 0, isExceeded: true },
96+
credits: { balance: 0, scope: 'user' },
97+
})
98+
expect(mocks.getCreditBalanceForEntity).not.toHaveBeenCalled()
99+
})
100+
})
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { db } from '@sim/db'
2+
import { getResolvedUserUsageData } from '@/lib/billing/core/usage'
3+
import { getCreditBalanceForEntity } from '@/lib/billing/credits/balance'
4+
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
5+
import type { DbClient } from '@/lib/db/types'
6+
7+
export interface AccountBillingSnapshot {
8+
plan: string
9+
billingScope: 'user' | 'organization'
10+
organizationId: string | null
11+
usage: {
12+
currentPeriodCost: number
13+
limit: number
14+
remaining: number
15+
percentUsed: number
16+
isExceeded: boolean
17+
billingPeriodEnd: Date | null
18+
}
19+
credits: {
20+
balance: number
21+
scope: 'user' | 'organization'
22+
}
23+
}
24+
25+
/** Resolves one coherent subscription, usage, limit, and credit snapshot for an account. */
26+
export async function getAccountBillingSnapshot(
27+
userId: string,
28+
executor: DbClient = db
29+
): Promise<AccountBillingSnapshot> {
30+
const { usage, subscription, personalCreditBalance } = await getResolvedUserUsageData(
31+
userId,
32+
executor
33+
)
34+
const organizationScoped = isOrgScopedSubscription(subscription, userId) && subscription !== null
35+
const billingScope = organizationScoped ? 'organization' : 'user'
36+
const billingEntityId = organizationScoped ? subscription.referenceId : userId
37+
const creditBalance = organizationScoped
38+
? await getCreditBalanceForEntity('organization', billingEntityId, executor)
39+
: personalCreditBalance
40+
41+
return {
42+
plan: subscription?.plan || 'free',
43+
billingScope,
44+
organizationId: organizationScoped ? subscription.referenceId : null,
45+
usage: {
46+
currentPeriodCost: usage.currentUsage,
47+
limit: usage.limit,
48+
remaining: Math.max(0, usage.limit - usage.currentUsage),
49+
percentUsed: usage.percentUsed,
50+
isExceeded: usage.isExceeded,
51+
billingPeriodEnd: usage.billingPeriodEnd,
52+
},
53+
credits: {
54+
balance: creditBalance,
55+
scope: billingScope,
56+
},
57+
}
58+
}

apps/sim/lib/billing/core/usage.ts

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import {
1414
} from '@/components/emails'
1515
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
1616
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
17-
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
17+
import {
18+
getHighestPrioritySubscription,
19+
type HighestPrioritySubscription,
20+
} from '@/lib/billing/core/plan'
1821
import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log'
1922
import {
2023
computeDailyRefreshConsumed,
@@ -190,13 +193,18 @@ export async function ensureUserStatsExists(userId: string): Promise<void> {
190193
.onConflictDoNothing({ target: userStats.userId })
191194
}
192195

193-
/**
194-
* Get comprehensive usage data for a user
195-
*/
196-
export async function getUserUsageData(
196+
export interface ResolvedUserUsageData {
197+
usage: UsageData
198+
subscription: HighestPrioritySubscription
199+
/** The personal balance from the same user-stats row used to calculate usage. */
200+
personalCreditBalance: number
201+
}
202+
203+
/** Resolves comprehensive usage and the subscription that determined its billing scope. */
204+
export async function getResolvedUserUsageData(
197205
userId: string,
198206
executor: DbClient = db
199-
): Promise<UsageData> {
207+
): Promise<ResolvedUserUsageData> {
200208
try {
201209
// Write — always on the primary regardless of executor routing.
202210
await ensureUserStatsExists(userId)
@@ -332,21 +340,33 @@ export async function getUserUsageData(
332340
const isExceeded = effectiveUsage >= limit
333341

334342
return {
335-
currentUsage: effectiveUsage,
336-
limit,
337-
percentUsed,
338-
isWarning,
339-
isExceeded,
340-
billingPeriodStart,
341-
billingPeriodEnd,
342-
lastPeriodCost,
343+
usage: {
344+
currentUsage: effectiveUsage,
345+
limit,
346+
percentUsed,
347+
isWarning,
348+
isExceeded,
349+
billingPeriodStart,
350+
billingPeriodEnd,
351+
lastPeriodCost,
352+
},
353+
subscription,
354+
personalCreditBalance: toNumber(toDecimal(stats.creditBalance)),
343355
}
344356
} catch (error) {
345357
logger.error('Failed to get user usage data', { userId, error })
346358
throw error
347359
}
348360
}
349361

362+
/** Get comprehensive usage data for a user. */
363+
export async function getUserUsageData(
364+
userId: string,
365+
executor: DbClient = db
366+
): Promise<UsageData> {
367+
return (await getResolvedUserUsageData(userId, executor)).usage
368+
}
369+
350370
/**
351371
* Get usage limit information for a user
352372
*/
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter'
2+
import { messageForCopilotApplicationError } from '@/lib/copilot/application/error'
3+
import {
4+
COPILOT_APPLICATION_DELEGATION_TTL_MS,
5+
type CopilotExecutionContext,
6+
InteractiveCopilotExecutionRequiredError,
7+
requireInteractiveCopilotExecutionContext,
8+
} from '@/lib/copilot/auth/application-delegation'
9+
import type { OperationUseCase } from '@/lib/core/application'
10+
import { platformContextDelegationPolicy } from '@/lib/platform-context/application/authorization'
11+
import {
12+
type PlatformContextOperation,
13+
platformContextOperations,
14+
} from '@/lib/platform-context/application/operations'
15+
16+
const executePlatformContextUseCase = createCopilotApplicationAdapter({
17+
domain: 'platform context',
18+
delegation: {
19+
audience: platformContextDelegationPolicy.audience,
20+
ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS,
21+
createDelegationId: (context) => `copilot-tool:${context.toolCallId}`,
22+
},
23+
operations: platformContextOperations,
24+
})
25+
26+
/** Enters a live platform-context operation only from a trusted interactive Copilot lifecycle. */
27+
export function executeCopilotPlatformContextUseCase<O extends PlatformContextOperation, I, R>(
28+
context: CopilotExecutionContext | undefined,
29+
useCase: OperationUseCase<O, I, R>,
30+
input: I
31+
): Promise<R> {
32+
const trustedContext = requireInteractiveCopilotExecutionContext(context)
33+
return executePlatformContextUseCase(trustedContext, useCase, input)
34+
}
35+
36+
/** Projects only actionable authorization failures into live platform-context tool output. */
37+
export function messageForCopilotPlatformContextError(error: unknown): string {
38+
if (error instanceof InteractiveCopilotExecutionRequiredError) return error.message
39+
return messageForCopilotApplicationError(error)
40+
}

0 commit comments

Comments
 (0)