Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/docs/content/docs/en/platform/costs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -317,9 +317,9 @@ By default, your usage is capped at the credits included in your plan. To allow
| **Free** | 1 | — |
| **Pro** | Up to 3 | — |
| **Max** | Up to 10 | — |
| **Team / Enterprise** | Unlimited | Unlimited |
| **Team / Enterprise** | | Unlimited (Owners and Admins) |

Team and Enterprise plans unlock shared workspaces that belong to your organization. Internal members invited to a shared workspace join the organization and count toward your seat total. Existing Sim users who already belong to another organization can be added as external workspace members; they get workspace access without joining your organization or using one of your seats. When a Team or Enterprise subscription is cancelled or downgraded, existing shared workspaces remain accessible to current members but new invites are disabled until the organization is upgraded again.
Team and Enterprise plans unlock shared workspaces that belong to your organization. Every workspace created under a Team or Enterprise plan is organization-owned: Owners and Admins can create unlimited shared workspaces, while organization Members cannot create workspaces (personal workspaces created before joining the organization remain accessible). Internal members invited to a shared workspace join the organization and count toward your seat total — Enterprise invites require an available seat at invite time, while Team plans add a seat automatically when the invitee accepts. Existing Sim users who already belong to another organization can be added as external workspace members; they get workspace access without joining your organization or using one of your seats. When a Team or Enterprise subscription is cancelled or downgraded, existing shared workspaces remain accessible to current members but new invites are disabled until the organization is upgraded again.

### Rate Limits

Expand Down
4 changes: 3 additions & 1 deletion apps/docs/content/docs/en/platform/permissions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ Sim has two kinds of workspaces:
| **Free** | 1 | — |
| **Pro** | Up to 3 | — |
| **Max** | Up to 10 | — |
| **Team / Enterprise** | Unlimited | Unlimited (seat-gated invites) |
| **Team / Enterprise** | — | Unlimited (Owners and Admins) |

On Team and Enterprise plans, every workspace you create belongs to the organization. Organization Owners and Admins can create unlimited shared workspaces; organization Members cannot create workspaces. Personal workspaces created before joining the organization remain accessible. Enterprise invites require an available seat at invite time; on Team plans, a seat is added automatically when the invitee accepts.

<Callout type="info">
When a Team or Enterprise subscription is cancelled or downgraded, existing shared workspaces stay accessible to current members. New invitations are blocked until the organization is upgraded again.
Expand Down
25 changes: 17 additions & 8 deletions apps/realtime/src/database/operations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import * as schema from '@sim/db'
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db'
import {
instrumentPoolClient,
workflow,
workflowBlocks,
workflowEdges,
workflowSubflows,
} from '@sim/db'
import { createLogger } from '@sim/logger'
import {
BLOCK_OPERATIONS,
Expand All @@ -27,13 +33,16 @@ const logger = createLogger('SocketDatabase')

const connectionString = env.DATABASE_URL
const socketDb = drizzle(
postgres(connectionString, {
prepare: false,
idle_timeout: 10,
connect_timeout: 20,
max: 15,
onnotice: () => {},
}),
instrumentPoolClient(
postgres(connectionString, {
prepare: false,
idle_timeout: 10,
connect_timeout: 20,
max: 15,
onnotice: () => {},
}),
'socketDb'
),
{ schema }
)

Expand Down
20 changes: 14 additions & 6 deletions apps/sim/app/api/credential-sets/[id]/members/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,16 +185,24 @@ export const DELETE = withRouteHandler(

const requestId = generateId().slice(0, 8)

// Use transaction to ensure member deletion + webhook sync are atomic
await db.transaction(async (tx) => {
await tx.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId))

const syncResult = await syncAllWebhooksForCredentialSet(id, requestId, tx)
await db.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId))

// Runs after the deletion commits: the sync performs external HTTP
// (OAuth refresh, provider unsubscribe) and must not hold a pooled
// connection. A sync failure must not fail the committed mutation —
// it self-heals on the next membership change/deploy.
try {
const syncResult = await syncAllWebhooksForCredentialSet(id, requestId)
logger.info('Synced webhooks after member removed', {
credentialSetId: id,
...syncResult,
})
})
} catch (syncError) {
logger.error('Webhook sync failed after member removal', {
credentialSetId: id,
error: syncError,
})
}

logger.info('Removed member from credential set', {
credentialSetId: id,
Expand Down
16 changes: 13 additions & 3 deletions apps/sim/app/api/credential-sets/invite/[token]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,17 +194,27 @@ export const POST = withRouteHandler(
)
)
}
})

// Runs after the membership commits: the sync performs external HTTP
// (OAuth refresh, provider unsubscribe) and must not hold a pooled
// connection. A sync failure must not fail the committed mutation —
// it self-heals on the next membership change/deploy.
try {
const syncResult = await syncAllWebhooksForCredentialSet(
invitation.credentialSetId,
requestId,
tx
requestId
)
logger.info('Synced webhooks after member joined', {
credentialSetId: invitation.credentialSetId,
...syncResult,
})
})
} catch (syncError) {
logger.error('Webhook sync failed after invitation accept', {
credentialSetId: invitation.credentialSetId,
error: syncError,
})
}

logger.info('Accepted credential set invitation', {
invitationId: invitation.id,
Expand Down
18 changes: 14 additions & 4 deletions apps/sim/app/api/credential-sets/memberships/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ export const DELETE = withRouteHandler(async (req: NextRequest) => {
try {
const requestId = generateId().slice(0, 8)

// Use transaction to ensure revocation + webhook sync are atomic
await db.transaction(async (tx) => {
// Find and verify membership
const [membership] = await tx
Expand Down Expand Up @@ -104,15 +103,26 @@ export const DELETE = withRouteHandler(async (req: NextRequest) => {
updatedAt: new Date(),
})
.where(eq(credentialSetMember.id, membership.id))
})

// Sync webhooks to remove this user's credential webhooks
const syncResult = await syncAllWebhooksForCredentialSet(credentialSetId, requestId, tx)
// Runs after the revocation commits: the sync performs external HTTP
// (OAuth refresh, provider unsubscribe) and must not hold a pooled
// connection. A sync failure must not fail the committed mutation —
// it self-heals on the next membership change/deploy.
try {
const syncResult = await syncAllWebhooksForCredentialSet(credentialSetId, requestId)
logger.info('Synced webhooks after member left', {
credentialSetId,
userId: session.user.id,
...syncResult,
})
})
} catch (syncError) {
logger.error('Webhook sync failed after member left', {
credentialSetId,
userId: session.user.id,
error: syncError,
})
}

logger.info('User left credential set', {
credentialSetId,
Expand Down
8 changes: 7 additions & 1 deletion apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
adminV1ListWorkspaceMembersContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
Expand Down Expand Up @@ -247,7 +248,12 @@ export const POST = withRouteHandler(
updatedAt: now,
})

await applyWorkspaceAutoAddGroup(db, workspaceId, userId)
await applyWorkspaceAutoAddGroup(
db,
workspaceId,
userId,
await isWorkspaceOnEnterprisePlan(workspaceId)
)

logger.info(`Admin API: Added user ${userId} to workspace ${workspaceId}`, {
permissions: permissionLevel,
Expand Down
9 changes: 8 additions & 1 deletion apps/sim/app/api/workspaces/[id]/permissions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { updateWorkspacePermissionsContract } from '@/lib/api/contracts/workspaces'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { applyWorkspaceAutoAddGroup } from '@/lib/permission-groups/auto-add'
Expand Down Expand Up @@ -159,6 +160,12 @@ export const PATCH = withRouteHandler(
existingPerms.map((p) => [p.userId, { permission: p.permissionType, email: p.email }])
)

// Resolved before the transaction: the entitlement check reads billing
// tables on the global pool and must not run while the tx holds a
// pooled connection.
const hasNewMembers = body.updates.some((update) => !permLookup.has(update.userId))
const autoAddEntitled = hasNewMembers ? await isWorkspaceOnEnterprisePlan(workspaceId) : false

await db.transaction(async (tx) => {
for (const update of body.updates) {
const isNew = !permLookup.has(update.userId)
Expand All @@ -184,7 +191,7 @@ export const PATCH = withRouteHandler(
})

if (isNew) {
await applyWorkspaceAutoAddGroup(tx, workspaceId, update.userId)
await applyWorkspaceAutoAddGroup(tx, workspaceId, update.userId, autoAddEntitled)
}
}
})
Expand Down
46 changes: 30 additions & 16 deletions apps/sim/lib/billing/core/usage-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,7 @@ interface UsageEntry {
metadata?: UsageLogMetadata
}

/**
* Parameters for the central recordUsage function.
* This is the single entry point for all billing mutations.
*/
export interface RecordUsageParams {
interface RecordUsageBaseParams {
/** The user being charged */
userId: string
/** One or more usage_log entries to record. Total cost is derived from these. */
Expand All @@ -92,19 +88,37 @@ export interface RecordUsageParams {
workflowId?: string
/** Execution context */
executionId?: string
/** Billing entity scope, resolved by caller when already known. */
billingEntity?: BillingEntity
/** Billing period bounds, resolved by caller when already known. */
billingPeriod?: { start: Date; end: Date }
/**
* Optional transaction to run the ledger INSERT in. Callers that reconcile a
* read-then-insert under a lock (e.g. the per-execution advisory lock in the
* workflow completion path) pass their tx so the insert participates in the
* same locked transaction. Defaults to the pooled db.
*/
tx?: DbOrTx
}

/**
* Parameters for the central recordUsage function.
* This is the single entry point for all billing mutations.
*
* Callers that pass `tx` (e.g. the per-execution advisory-lock reconciliation
* in the workflow completion path) must pre-resolve the billing context before
* opening the transaction: resolving it inside would run the subscription
* lookups on the global pool while the tx already holds a pooled connection,
* starving the pool under load (see recordCumulativeUsage for the history).
*/
export type RecordUsageParams = RecordUsageBaseParams &
(
| {
/** Transaction the ledger INSERT participates in. */
tx: DbOrTx
/** Billing entity scope, resolved before the transaction opened. */
billingEntity: BillingEntity
/** Billing period bounds, resolved before the transaction opened. */
billingPeriod: { start: Date; end: Date }
}
| {
tx?: undefined
/** Billing entity scope, resolved by caller when already known. */
billingEntity?: BillingEntity
/** Billing period bounds, resolved by caller when already known. */
billingPeriod?: { start: Date; end: Date }
}
)

export function stableEventKey(parts: Record<string, unknown>): string {
const payload = Object.keys(parts)
.sort()
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/lib/invitations/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const {
mockSyncUsageLimitsFromSubscription,
mockSyncWorkspaceEnvCredentials,
mockApplyWorkspaceAutoAddGroup,
mockIsWorkspaceOnEnterprisePlan,
mockFeatureFlags,
} = vi.hoisted(() => ({
mockEnsureUserInOrganization: vi.fn(),
Expand All @@ -27,6 +28,7 @@ const {
mockSyncUsageLimitsFromSubscription: vi.fn(),
mockSyncWorkspaceEnvCredentials: vi.fn(),
mockApplyWorkspaceAutoAddGroup: vi.fn(),
mockIsWorkspaceOnEnterprisePlan: vi.fn(async () => true),
mockFeatureFlags: { isBillingEnabled: true },
}))

Expand Down Expand Up @@ -60,6 +62,10 @@ vi.mock('@/lib/auth/active-organization', () => ({
setActiveOrganizationForCurrentSession: mockSetActiveOrganizationForCurrentSession,
}))

vi.mock('@/lib/billing/core/subscription', () => ({
isWorkspaceOnEnterprisePlan: mockIsWorkspaceOnEnterprisePlan,
}))

vi.mock('@/lib/billing/core/usage', () => ({
syncUsageLimitsFromSubscription: mockSyncUsageLimitsFromSubscription,
}))
Expand Down
16 changes: 15 additions & 1 deletion apps/sim/lib/invitations/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray, lte } from 'drizzle-orm'
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import {
acquireOrgMembershipLock,
Expand Down Expand Up @@ -433,6 +434,14 @@ export async function acceptInvitation(

const acceptedWorkspaceIds: string[] = []

// Resolved before the transaction: the entitlement check reads billing
// tables on the global pool, which must not run while the tx below holds a
// pooled connection and the org-membership advisory lock.
const autoAddEntitlementByWorkspace = new Map<string, boolean>()
Comment thread
icecrasher321 marked this conversation as resolved.
for (const workspaceId of new Set(inv.grants.map((grant) => grant.workspaceId))) {
autoAddEntitlementByWorkspace.set(workspaceId, await isWorkspaceOnEnterprisePlan(workspaceId))
}
Comment thread
icecrasher321 marked this conversation as resolved.

try {
await db.transaction(async (tx) => {
/**
Expand Down Expand Up @@ -502,7 +511,12 @@ export async function acceptInvitation(
})
}

await applyWorkspaceAutoAddGroup(tx, grant.workspaceId, input.userId)
await applyWorkspaceAutoAddGroup(
tx,
grant.workspaceId,
input.userId,
autoAddEntitlementByWorkspace.get(grant.workspaceId) ?? false
)

acceptedWorkspaceIds.push(grant.workspaceId)
}
Expand Down
Loading
Loading