Skip to content

Commit 002b73d

Browse files
icecrasher321claude
andcommitted
fix(invitations): allow External with billing off, mark cross-org org invites blocked
The External paid-plan requirement is seat economics — an external collaborator takes no seat, so somebody else must be paying for them. With billing disabled there are no seats and no subscription rows at all, so every account resolves as `free` and choosing External failed at send time and would have failed at accept. Member and Admin still worked, so a self-hosted deployment had no way to grant workspace-only access without an organization join and a workspace sweep. Both the invite-time and accept-time gates now short-circuit when billing is off. A route test asserted that rejection without setting `isBillingEnabled`, which the shared mock defaults to false — it passed only because the gate ignored the flag. It now opts in explicitly, since the rule it covers is billing-only. Separately, the preview reported `external` for any invitee already in a different organization, but acceptance only downgrades a workspace-kind invite with live grants; an organization-kind invite hard-fails with `already-in-organization`. Those now report `blocked`, so the screen stops promising external access that acceptance can never grant. Legacy organization-kind rows still exist and coalescing preserves that kind, so this is reachable. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent aed4106 commit 002b73d

4 files changed

Lines changed: 66 additions & 3 deletions

File tree

apps/sim/app/api/workspaces/invitations/route.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
posthogServerMock,
1111
queueTableRows,
1212
resetDbChainMock,
13+
resetEnvFlagsMock,
1314
schemaMock,
15+
setEnvFlags,
1416
} from '@sim/testing'
1517
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
1618

@@ -91,6 +93,8 @@ const mockGetWorkspaceWithOwner = permissionsMockFns.mockGetWorkspaceWithOwner
9193
import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants'
9294
import { POST } from '@/app/api/workspaces/invitations/batch/route'
9395

96+
afterAll(resetEnvFlagsMock)
97+
9498
describe('POST /api/workspaces/invitations/batch', () => {
9599
beforeEach(() => {
96100
vi.clearAllMocks()
@@ -404,6 +408,12 @@ describe('POST /api/workspaces/invitations/batch', () => {
404408
organizationId: 'org-1',
405409
upgradeRequired: false,
406410
})
411+
/**
412+
* The paid-plan requirement is a billing rule, so it only applies when
413+
* billing is on — with billing off there are no seats to protect and every
414+
* account reads as free.
415+
*/
416+
setEnvFlags({ isBillingEnabled: true })
407417
queueTableRows(schemaMock.user, [{ id: 'free-user', email: 'free@example.com' }])
408418
mockGetUserOrganization.mockResolvedValueOnce(null)
409419
mockGetInvitePlanCategoryForUser.mockResolvedValueOnce('free')

apps/sim/lib/invitations/core.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,14 @@ export async function getInvitationJoinPreview(
300300
*/
301301
const inTargetOrganization =
302302
!!workspaceOrganizationId && existingMembership.organizationId === workspaceOrganizationId
303-
return withOutcome(inTargetOrganization ? 'already-member' : 'external')
303+
if (inTargetOrganization) return withOutcome('already-member')
304+
/**
305+
* In a DIFFERENT organization. Acceptance only downgrades a workspace-kind
306+
* invite with live grants to external; an organization-kind invite (or one
307+
* with no grants) hard-fails with `already-in-organization`, so promising
308+
* external access there would be a disclosure the accept can never honour.
309+
*/
310+
return withOutcome(inv.kind === 'workspace' && inv.grants.length > 0 ? 'external' : 'blocked')
304311
}
305312

306313
if (!(await stampedOrganizationAllowsEscalation(inv, workspaceOrganizationId)))
@@ -742,6 +749,7 @@ async function acceptLockedInvitation(
742749
* workspaces because sharing a personal workspace has no seat economics.
743750
*/
744751
if (
752+
isBillingEnabled &&
745753
inv.membershipIntent === 'external' &&
746754
!inviteeAlreadyInDifferentOrg &&
747755
workspaceOrganizationId &&

apps/sim/lib/invitations/workspace-invitations.test.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { auditMock, createMockRequest, dbChainMockFns, resetDbChainMock } from '@sim/testing'
5-
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
import {
5+
auditMock,
6+
createMockRequest,
7+
dbChainMockFns,
8+
resetDbChainMock,
9+
resetEnvFlagsMock,
10+
setEnvFlags,
11+
} from '@sim/testing'
12+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
613

714
const {
815
mockGetUserOrganization,
@@ -125,10 +132,14 @@ const request = createMockRequest(
125132
'http://localhost/api/workspaces/invitations/batch'
126133
)
127134

135+
afterAll(resetEnvFlagsMock)
136+
128137
describe('createWorkspaceInvitation', () => {
129138
beforeEach(() => {
130139
vi.clearAllMocks()
131140
resetDbChainMock()
141+
/** Production default; the billing-disabled case opts out explicitly. */
142+
setEnvFlags({ isBillingEnabled: true })
132143
mockGrantWorkspaceAccessDirectly.mockResolvedValue({ outcome: 'added', permission: 'write' })
133144
mockCreatePendingInvitation.mockResolvedValue({
134145
invitationId: 'inv-1',
@@ -259,6 +270,31 @@ describe('createWorkspaceInvitation', () => {
259270
)
260271
})
261272

273+
it('allows an external invite with billing disabled, where nobody has a plan', async () => {
274+
/**
275+
* The paid-plan requirement is seat economics: an external collaborator takes
276+
* no seat, so somebody else must be paying for them. With billing off there
277+
* are no seats and no subscriptions, so every account reads as free —
278+
* enforcing it would leave a self-hosted deployment no way to grant
279+
* workspace-only access without an organization join and a workspace sweep.
280+
*/
281+
setEnvFlags({ isBillingEnabled: false })
282+
queueWhereResponses([[{ id: 'user-9', email: 'selfhost@example.com' }], []])
283+
mockGetUserOrganization.mockResolvedValueOnce(null)
284+
285+
const result = await createWorkspaceInvitation({
286+
context: makeContext(),
287+
email: 'selfhost@example.com',
288+
permission: 'write',
289+
membership: 'external',
290+
request,
291+
})
292+
293+
expect(result.membershipIntent).toBe('external')
294+
/** Short-circuits before the plan lookup — there is nothing to look up. */
295+
expect(mockGetInvitePlanCategoryForUser).not.toHaveBeenCalled()
296+
})
297+
262298
it('rejects an explicit external invite for an invitee with no paid plan', async () => {
263299
queueWhereResponses([[{ id: 'user-5', email: 'free@example.com' }], []])
264300
mockGetUserOrganization.mockResolvedValueOnce(null)

apps/sim/lib/invitations/workspace-invitations.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { and, eq, inArray, sql } from 'drizzle-orm'
66
import type { NextRequest } from 'next/server'
77
import { getUserOrganization } from '@/lib/billing/organizations/membership'
88
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
9+
import { isBillingEnabled } from '@/lib/core/config/env-flags'
910
import { PlatformEvents } from '@/lib/core/telemetry'
1011
import {
1112
type DirectGrantOutcome,
@@ -183,6 +184,14 @@ async function assertSeatAvailable(organizationId: string, email: string): Promi
183184
* they must be invited as a Member or Admin instead.
184185
*/
185186
async function inviteeCanBeExternal(userId: string | undefined): Promise<boolean> {
187+
/**
188+
* The requirement exists because an external collaborator consumes no seat, so
189+
* somebody else must be paying for them. With billing disabled there are no
190+
* seats and no subscriptions at all — every account resolves as `free` — so
191+
* enforcing it would leave a self-hosted deployment no way to grant
192+
* workspace-only access without an organization join and a workspace sweep.
193+
*/
194+
if (!isBillingEnabled) return true
186195
if (!userId) return false
187196
return (await getInvitePlanCategoryForUser(userId)) !== 'free'
188197
}

0 commit comments

Comments
 (0)