6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
1849 lines
67 KiB
TypeScript
1849 lines
67 KiB
TypeScript
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
|
import { db } from '@sim/db'
|
|
import {
|
|
type InvitationKind,
|
|
type InvitationMembershipIntent,
|
|
type InvitationStatus,
|
|
invitation,
|
|
invitationWorkspaceGrant,
|
|
member,
|
|
organization,
|
|
permissions,
|
|
user,
|
|
workspace,
|
|
workspaceEnvironment,
|
|
} from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { isOrgAdminRole, PERMISSION_RANK, type PermissionType } from '@sim/platform-authz/workspace'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { normalizeEmail } from '@sim/utils/string'
|
|
import { and, asc, count, eq, inArray, lte, sql } from 'drizzle-orm'
|
|
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
|
|
import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy'
|
|
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
|
|
import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan'
|
|
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
|
import {
|
|
acquireOrganizationMutationLock,
|
|
acquireOrgMembershipLock,
|
|
ensureUserInOrganizationTx,
|
|
getUserOrganization,
|
|
} from '@/lib/billing/organizations/membership'
|
|
import {
|
|
type AcceptancePlanConversion,
|
|
ensureTeamOrganizationForAcceptance,
|
|
} from '@/lib/billing/organizations/provision-seat'
|
|
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
|
|
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
|
|
import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
|
|
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
|
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
|
import type { DbOrTx } from '@/lib/db/types'
|
|
import { acquireInvitationMutationLocks } from '@/lib/invitations/locks'
|
|
import { captureServerEvent } from '@/lib/posthog/server'
|
|
import {
|
|
attachOwnedWorkspacesToOrganizationTx,
|
|
ownedAttachableWorkspacesWhere,
|
|
} from '@/lib/workspaces/organization-workspaces'
|
|
import { getWorkspaceWithOwner, type WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
|
|
import { getInvitePlanCategoryForUser } from '@/lib/workspaces/policy'
|
|
|
|
const logger = createLogger('InvitationCore')
|
|
|
|
export const INVITATION_EXPIRY_DAYS = 7
|
|
|
|
export function computeInvitationExpiry(daysFromNow = INVITATION_EXPIRY_DAYS): Date {
|
|
return new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000)
|
|
}
|
|
|
|
export interface InvitationWithGrants {
|
|
id: string
|
|
kind: InvitationKind
|
|
email: string
|
|
organizationId: string | null
|
|
membershipIntent: InvitationMembershipIntent
|
|
inviterId: string
|
|
role: string
|
|
status: InvitationStatus
|
|
token: string
|
|
expiresAt: Date
|
|
createdAt: Date
|
|
updatedAt: Date
|
|
grants: Array<{
|
|
id: string
|
|
workspaceId: string
|
|
permission: 'admin' | 'write' | 'read'
|
|
workspaceName: string | null
|
|
}>
|
|
organizationName: string | null
|
|
inviterName: string | null
|
|
inviterEmail: string | null
|
|
}
|
|
|
|
export async function getInvitationById(
|
|
id: string,
|
|
executor: DbOrTx = db
|
|
): Promise<InvitationWithGrants | null> {
|
|
const [row] = await executor.select().from(invitation).where(eq(invitation.id, id)).limit(1)
|
|
if (!row) return null
|
|
return hydrateInvitation(row, executor)
|
|
}
|
|
|
|
/**
|
|
* Claims an invitation for a state-changing operation using the same advisory
|
|
* lock namespace and ordering as acceptance, sending, and workspace moves.
|
|
*
|
|
* The invitation advisory lock is taken first because its grants define the
|
|
* optional workspace lock set. Workspace advisory locks are then taken before
|
|
* the invitation row lock, preserving the platform's advisory-before-row lock
|
|
* order. Finally the invitation is hydrated again so authorization and mutation
|
|
* use the protected state.
|
|
*/
|
|
export async function lockInvitationForMutation(
|
|
tx: DbOrTx,
|
|
invitationId: string,
|
|
options?: {
|
|
lockCurrentGrantWorkspaces?: boolean
|
|
additionalWorkspaceIds?: string[]
|
|
}
|
|
): Promise<InvitationWithGrants | null> {
|
|
await acquireInvitationMutationLocks(tx, {
|
|
invitationIds: [invitationId],
|
|
workspaceIds: [],
|
|
})
|
|
const beforeWorkspaceLocks = await getInvitationById(invitationId, tx)
|
|
if (!beforeWorkspaceLocks) return null
|
|
|
|
const currentGrantWorkspaceIds = new Set(
|
|
beforeWorkspaceLocks.grants.map((grant) => grant.workspaceId)
|
|
)
|
|
const workspaceIds = [
|
|
...new Set([
|
|
...(options?.lockCurrentGrantWorkspaces ? currentGrantWorkspaceIds : []),
|
|
...(options?.additionalWorkspaceIds ?? []).filter((workspaceId) =>
|
|
currentGrantWorkspaceIds.has(workspaceId)
|
|
),
|
|
]),
|
|
]
|
|
if (workspaceIds.length > 0) {
|
|
await acquireInvitationMutationLocks(tx, {
|
|
invitationIds: [],
|
|
workspaceIds,
|
|
})
|
|
}
|
|
|
|
await tx.execute(sql`select id from invitation where id = ${invitationId} for update`)
|
|
|
|
return getInvitationById(invitationId, tx)
|
|
}
|
|
|
|
/**
|
|
* Locks the exact membership row that can authorize an organization mutation,
|
|
* then evaluates the role from that protected version. A concurrent demotion
|
|
* or removal either commits first and is observed here, or waits until this
|
|
* invitation transaction commits.
|
|
*/
|
|
async function lockOrganizationAdminAuthority(
|
|
tx: DbOrTx,
|
|
actorId: string,
|
|
organizationId: string
|
|
): Promise<boolean> {
|
|
const [authority] = await tx
|
|
.select({ id: member.id, role: member.role })
|
|
.from(member)
|
|
.where(and(eq(member.userId, actorId), eq(member.organizationId, organizationId)))
|
|
.for('update')
|
|
.limit(1)
|
|
return isOrgAdminRole(authority?.role)
|
|
}
|
|
|
|
/**
|
|
* Locks only the actor rows that can grant admin standing on this workspace:
|
|
* the explicit workspace permission first, then (when needed) the workspace
|
|
* organization's membership row. Callers visit workspace ids in sorted order,
|
|
* giving multi-workspace mutations a deterministic authority-row lock order.
|
|
*/
|
|
async function lockWorkspaceAdminAuthority(
|
|
tx: DbOrTx,
|
|
actorId: string,
|
|
workspaceId: string
|
|
): Promise<boolean> {
|
|
const ws = await getWorkspaceWithOwner(workspaceId, { executor: tx })
|
|
if (!ws) return false
|
|
|
|
const [explicitAuthority] = await tx
|
|
.select({ id: permissions.id, permissionType: permissions.permissionType })
|
|
.from(permissions)
|
|
.where(
|
|
and(
|
|
eq(permissions.userId, actorId),
|
|
eq(permissions.entityType, 'workspace'),
|
|
eq(permissions.entityId, workspaceId)
|
|
)
|
|
)
|
|
.for('update')
|
|
.limit(1)
|
|
if (explicitAuthority?.permissionType === 'admin') return true
|
|
if (!ws.organizationId) return false
|
|
|
|
return lockOrganizationAdminAuthority(tx, actorId, ws.organizationId)
|
|
}
|
|
|
|
async function hydrateInvitation(
|
|
row: typeof invitation.$inferSelect,
|
|
executor: DbOrTx = db
|
|
): Promise<InvitationWithGrants> {
|
|
const grantRows = await executor
|
|
.select({
|
|
id: invitationWorkspaceGrant.id,
|
|
workspaceId: invitationWorkspaceGrant.workspaceId,
|
|
permission: invitationWorkspaceGrant.permission,
|
|
workspaceName: workspace.name,
|
|
})
|
|
.from(invitationWorkspaceGrant)
|
|
.leftJoin(workspace, eq(workspace.id, invitationWorkspaceGrant.workspaceId))
|
|
.where(eq(invitationWorkspaceGrant.invitationId, row.id))
|
|
/**
|
|
* Oldest grant first, so `grants[0]` — the primary grant that decides the
|
|
* join target and the billed account — stays the workspace the invitation
|
|
* was originally sent for even after later invites merge grants into it.
|
|
*/
|
|
.orderBy(asc(invitationWorkspaceGrant.createdAt), asc(invitationWorkspaceGrant.id))
|
|
|
|
let organizationName: string | null = null
|
|
if (row.organizationId) {
|
|
const [orgRow] = await executor
|
|
.select({ name: organization.name })
|
|
.from(organization)
|
|
.where(eq(organization.id, row.organizationId))
|
|
.limit(1)
|
|
organizationName = orgRow?.name ?? null
|
|
}
|
|
|
|
const [inviterRow] = await executor
|
|
.select({ name: user.name, email: user.email })
|
|
.from(user)
|
|
.where(eq(user.id, row.inviterId))
|
|
.limit(1)
|
|
|
|
return {
|
|
id: row.id,
|
|
kind: row.kind,
|
|
email: row.email,
|
|
organizationId: row.organizationId,
|
|
membershipIntent: row.membershipIntent,
|
|
inviterId: row.inviterId,
|
|
role: row.role,
|
|
status: row.status,
|
|
token: row.token,
|
|
expiresAt: row.expiresAt,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
grants: grantRows.map((grant) => ({
|
|
id: grant.id,
|
|
workspaceId: grant.workspaceId,
|
|
permission: grant.permission,
|
|
workspaceName: grant.workspaceName,
|
|
})),
|
|
organizationName,
|
|
inviterName: inviterRow?.name ?? null,
|
|
inviterEmail: inviterRow?.email ?? null,
|
|
}
|
|
}
|
|
|
|
export function isInvitationExpired(inv: Pick<InvitationWithGrants, 'expiresAt'>): boolean {
|
|
return new Date() > new Date(inv.expiresAt)
|
|
}
|
|
|
|
/**
|
|
* A workspace invitation only escalates into an EXISTING organization when
|
|
* that organization matches what was stamped at send time — a workspace that
|
|
* entered an organization after the invite went out (a member's owned
|
|
* workspaces attaching on join, an admin move) never asked that org for a
|
|
* seat, so escalation requires the inviter to currently hold admin standing
|
|
* there. A workspace with no current organization is deliberately exempt:
|
|
* acceptance trusts the live workspace over stale stamped metadata and falls
|
|
* back to the standard personal-workspace regime (Pro→Team conversion of the
|
|
* current billed account), matching long-standing tested behavior.
|
|
* Organization-kind invitations always join their STAMPED organization (the
|
|
* join target is never re-derived from a granted workspace, whose org can
|
|
* change after send), so they pass trivially here. Acceptance and the
|
|
* accept-screen preview both consume this predicate so the disclosure can
|
|
* never contradict the accepted outcome.
|
|
*/
|
|
async function stampedOrganizationAllowsEscalation(
|
|
inv: InvitationWithGrants,
|
|
workspaceOrganizationId: string | null,
|
|
executor: DbOrTx = db
|
|
): Promise<boolean> {
|
|
if (inv.kind !== 'workspace') return true
|
|
if (!workspaceOrganizationId) return true
|
|
if (inv.organizationId === workspaceOrganizationId) return true
|
|
const inviterMembership = await getUserOrganization(inv.inviterId, executor)
|
|
return (
|
|
inviterMembership?.organizationId === workspaceOrganizationId &&
|
|
isOrgAdminRole(inviterMembership.role)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* True when a member-role organization invitation still has at least one
|
|
* granted workspace inside the organization it was stamped with. All grants
|
|
* leaving that organization would strand the new member with nowhere to land,
|
|
* so acceptance refuses and the preview must predict the same — both consume
|
|
* this single predicate so they cannot drift.
|
|
*/
|
|
async function hasLiveGrantInStampedOrganization(
|
|
inv: InvitationWithGrants,
|
|
executor: DbOrTx = db
|
|
): Promise<boolean> {
|
|
if (inv.kind !== 'organization' || !inv.organizationId) return true
|
|
if (isOrgAdminRole(inv.role)) return true
|
|
if (inv.grants.length === 0) return true
|
|
const [liveGrant] = await executor
|
|
.select({ id: workspace.id })
|
|
.from(workspace)
|
|
.where(
|
|
and(
|
|
inArray(
|
|
workspace.id,
|
|
inv.grants.map((grant) => grant.workspaceId)
|
|
),
|
|
eq(workspace.organizationId, inv.organizationId)
|
|
)
|
|
)
|
|
.limit(1)
|
|
return Boolean(liveGrant)
|
|
}
|
|
|
|
/** @see InvitationJoinPreviewResult.outcome */
|
|
export type InvitationJoinOutcome = 'will-join' | 'already-member' | 'external' | 'blocked'
|
|
|
|
export interface InvitationJoinPreviewResult {
|
|
/**
|
|
* What accepting will actually do, as one value rather than a set of booleans.
|
|
*
|
|
* These four outcomes need genuinely different disclosure, and collapsing any
|
|
* of them loses something the invitee must know:
|
|
* - `will-join` — a member row is created and a seat is taken.
|
|
* - `already-member` — they are in the organization already; only workspace
|
|
* access changes, so neither the join nor the external copy is true.
|
|
* - `external` — workspaces only, never a seat, nothing of theirs moves.
|
|
* - `blocked` — acceptance will fail (`upgrade-required`,
|
|
* `workspace-not-found`). Nothing is promised, because nothing happens.
|
|
*/
|
|
outcome: InvitationJoinOutcome
|
|
/**
|
|
* Name of the organization acceptance will actually join. For a workspace
|
|
* invite this is the granted workspace's LIVE organization, which can differ
|
|
* from the stamped `invitation.organizationName` — the disclosure must name
|
|
* the organization that will really gain control of the user's workspaces.
|
|
*/
|
|
organizationName: string | null
|
|
workspacesToMove: string[]
|
|
/**
|
|
* Stable ids behind `workspacesToMove`; the accept screen echoes them back
|
|
* as the disclosure token so acceptance can reject when the sweep set no
|
|
* longer matches what was disclosed.
|
|
*/
|
|
workspaceIdsToMove: string[]
|
|
}
|
|
|
|
/**
|
|
* Best-effort preview of what accepting will do for the invitee: whether a
|
|
* member row will be created and which of their owned personal workspaces
|
|
* (archived included) will follow them into the organization. Mirrors the
|
|
* acceptance decision flow without taking locks — races resolve at accept
|
|
* time; the preview only feeds disclosure copy.
|
|
*/
|
|
export async function getInvitationJoinPreview(
|
|
inviteeUserId: string,
|
|
inv: InvitationWithGrants
|
|
): Promise<InvitationJoinPreviewResult> {
|
|
const withOutcome = (outcome: InvitationJoinOutcome): InvitationJoinPreviewResult => ({
|
|
outcome,
|
|
organizationName: null,
|
|
workspacesToMove: [],
|
|
workspaceIdsToMove: [],
|
|
})
|
|
|
|
let workspaceOrganizationId = inv.organizationId
|
|
let billedAccountUserId: string | null = null
|
|
const primaryGrantWorkspaceId = inv.grants[0]?.workspaceId
|
|
if (primaryGrantWorkspaceId) {
|
|
const primaryWorkspace = await getWorkspaceWithOwner(primaryGrantWorkspaceId)
|
|
if (primaryWorkspace) {
|
|
billedAccountUserId = primaryWorkspace.billedAccountUserId
|
|
if (inv.kind === 'workspace') {
|
|
workspaceOrganizationId = primaryWorkspace.organizationId
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Personal-workspace invites only produce an organization through billing's
|
|
* Pro→Team provisioning; with billing disabled there is nothing to join.
|
|
*/
|
|
if (!workspaceOrganizationId && !isBillingEnabled) return withOutcome('external')
|
|
|
|
/**
|
|
* Already in the target organization (nothing changes) or in a different
|
|
* one (acceptance downgrades to external or rejects).
|
|
*/
|
|
const existingMembership = await getUserOrganization(inviteeUserId)
|
|
const inDifferentOrganization =
|
|
!!existingMembership &&
|
|
(workspaceOrganizationId ? existingMembership.organizationId !== workspaceOrganizationId : true)
|
|
|
|
if (inv.membershipIntent === 'external') {
|
|
/**
|
|
* Mirrors acceptance's `external-requires-paid-plan` gate, including its
|
|
* exemptions: it only applies with billing on, to an organization-owned
|
|
* workspace, and not when externality was imposed because the invitee already
|
|
* belongs to another organization. Without this the screen promised external
|
|
* access that acceptance would refuse.
|
|
*/
|
|
if (
|
|
isBillingEnabled &&
|
|
!inDifferentOrganization &&
|
|
workspaceOrganizationId &&
|
|
(await getInvitePlanCategoryForUser(inviteeUserId)) === 'free'
|
|
) {
|
|
return withOutcome('blocked')
|
|
}
|
|
return withOutcome('external')
|
|
}
|
|
|
|
if (existingMembership) {
|
|
/**
|
|
* Already in the organization acceptance lands in: nothing about their
|
|
* standing changes. A membership in a DIFFERENT organization is the
|
|
* external case — acceptance downgrades — so it keeps the plain shape.
|
|
*/
|
|
if (!inDifferentOrganization) return withOutcome('already-member')
|
|
/**
|
|
* In a DIFFERENT organization. Acceptance only downgrades a workspace-kind
|
|
* invite with live grants to external; an organization-kind invite (or one
|
|
* with no grants) hard-fails with `already-in-organization`, so promising
|
|
* external access there would be a disclosure the accept can never honour.
|
|
*/
|
|
return withOutcome(inv.kind === 'workspace' && inv.grants.length > 0 ? 'external' : 'blocked')
|
|
}
|
|
|
|
if (!(await stampedOrganizationAllowsEscalation(inv, workspaceOrganizationId)))
|
|
return withOutcome('external')
|
|
|
|
if (!(await hasLiveGrantInStampedOrganization(inv))) return withOutcome('blocked')
|
|
|
|
/**
|
|
* Mirror acceptance's billing gates: an unusable organization subscription
|
|
* (or, for personal-workspace invites, a billed owner without a convertible
|
|
* paid plan) makes acceptance fail with upgrade-required — the disclosure
|
|
* must not promise a migration that cannot happen.
|
|
*/
|
|
if (isBillingEnabled) {
|
|
if (workspaceOrganizationId) {
|
|
const orgSub = await getOrganizationSubscription(workspaceOrganizationId)
|
|
if (!orgSub || !hasUsableSubscriptionStatus(orgSub.status)) return withOutcome('blocked')
|
|
} else {
|
|
const payerUserId = billedAccountUserId ?? inv.inviterId
|
|
const personalSub = await getHighestPriorityPersonalSubscription(payerUserId)
|
|
if (
|
|
!personalSub ||
|
|
!hasUsableSubscriptionStatus(personalSub.status) ||
|
|
!(isPro(personalSub.plan) || isTeam(personalSub.plan))
|
|
) {
|
|
return withOutcome('blocked')
|
|
}
|
|
}
|
|
}
|
|
|
|
const ownedWorkspaces = await db
|
|
.select({ id: workspace.id, name: workspace.name })
|
|
.from(workspace)
|
|
.where(ownedAttachableWorkspacesWhere({ userId: inviteeUserId, includeArchived: true }))
|
|
.orderBy(asc(workspace.name))
|
|
|
|
let targetOrganizationName: string | null = null
|
|
if (workspaceOrganizationId) {
|
|
const [targetOrg] = await db
|
|
.select({ name: organization.name })
|
|
.from(organization)
|
|
.where(eq(organization.id, workspaceOrganizationId))
|
|
.limit(1)
|
|
targetOrganizationName = targetOrg?.name ?? null
|
|
}
|
|
|
|
return {
|
|
outcome: 'will-join',
|
|
organizationName: targetOrganizationName,
|
|
workspacesToMove: ownedWorkspaces.map((row) => row.name),
|
|
workspaceIdsToMove: ownedWorkspaces.map((row) => row.id),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flip any still-pending invitations for the given organization whose
|
|
* `expiresAt` has already passed to `expired`. Best-effort housekeeping
|
|
* — callers can rely on this for display freshness, but seat math also
|
|
* defensively filters by `expiresAt` at query time.
|
|
*/
|
|
export async function expireStalePendingInvitationsForOrganization(
|
|
organizationId: string
|
|
): Promise<void> {
|
|
try {
|
|
await db
|
|
.update(invitation)
|
|
.set({ status: 'expired', updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(invitation.organizationId, organizationId),
|
|
eq(invitation.status, 'pending'),
|
|
lte(invitation.expiresAt, new Date())
|
|
)
|
|
)
|
|
} catch (error) {
|
|
logger.error('Failed to expire stale pending invitations for organization', {
|
|
organizationId,
|
|
error,
|
|
})
|
|
}
|
|
}
|
|
|
|
export type AcceptInvitationFailure =
|
|
| { kind: 'not-found' }
|
|
| { kind: 'workspace-not-found' }
|
|
| { kind: 'disclosure-outdated' }
|
|
| { kind: 'already-processed' }
|
|
| { kind: 'expired' }
|
|
| { kind: 'email-mismatch' }
|
|
| { kind: 'invalid-token' }
|
|
| { kind: 'already-in-organization' }
|
|
| { kind: 'no-seats-available' }
|
|
| { kind: 'upgrade-required' }
|
|
| { kind: 'external-requires-paid-plan' }
|
|
| { kind: 'server-error'; message?: string }
|
|
|
|
export type AcceptInvitationSuccess = {
|
|
success: true
|
|
invitation: InvitationWithGrants
|
|
acceptedWorkspaceIds: string[]
|
|
redirectPath: string
|
|
membershipAlreadyExists: boolean
|
|
}
|
|
|
|
export type AcceptInvitationResult =
|
|
| AcceptInvitationSuccess
|
|
| ({ success: false } & AcceptInvitationFailure)
|
|
|
|
export interface AcceptInvitationInput {
|
|
userId: string
|
|
userEmail: string
|
|
actorName?: string | null
|
|
invitationId: string
|
|
token: string | null
|
|
/**
|
|
* Workspace ids the accept screen disclosed as moving. When provided,
|
|
* acceptance fails with `disclosure-outdated` if the set it would sweep
|
|
* differs — the user must see the refreshed notice before consenting.
|
|
*/
|
|
disclosedWorkspaceIds?: string[]
|
|
/**
|
|
* The outcome the accept screen disclosed. Verified against the resolved
|
|
* outcome so a membership the user was never shown can never be created, and
|
|
* a membership they were promised can never be silently downgraded.
|
|
*/
|
|
disclosedOutcome?: InvitationJoinOutcome
|
|
request?: { headers: { get(name: string): string | null } }
|
|
}
|
|
|
|
/**
|
|
* Thrown inside the grant transaction when the invitee's org membership was
|
|
* removed concurrently (between the join and the grant) — detected under the
|
|
* membership lock. Aborts the grant so we never write workspace access for a
|
|
* user who is no longer an org member (the "zombie" state).
|
|
*/
|
|
class MembershipRevokedDuringAcceptError extends Error {
|
|
constructor() {
|
|
super('Org membership was revoked during invite acceptance')
|
|
this.name = 'MembershipRevokedDuringAcceptError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Thrown after the member insert when the invitee's owned-workspace set no
|
|
* longer matches the pre-lock plan (a workspace was created concurrently and
|
|
* would escape the sweep). Rolls the whole acceptance back; safe to retry.
|
|
*/
|
|
class JoinerWorkspacesChangedDuringAcceptError extends Error {
|
|
constructor() {
|
|
super('Owned workspaces changed during invite acceptance')
|
|
this.name = 'JoinerWorkspacesChangedDuringAcceptError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Thrown after a personal subscription conversion when the billing owner
|
|
* created another attachable workspace after the pre-lock sweep plan was
|
|
* captured. The conversion now holds that owner's billing-identity lock, so
|
|
* this re-check is stable; rolling back lets the retry include the new
|
|
* workspace in the advisory-lock plan instead of leaving it personally billed
|
|
* after the subscription moved to the organization.
|
|
*/
|
|
class BillingOwnerWorkspacesChangedDuringAcceptError extends Error {
|
|
constructor() {
|
|
super('Billing owner workspaces changed during invite acceptance')
|
|
this.name = 'BillingOwnerWorkspacesChangedDuringAcceptError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Thrown when every grant on a member-role organization invite turned stale
|
|
* (the workspaces left the stamped organization), which would strand the new
|
|
* member with no workspace. Rolls the whole acceptance back.
|
|
*/
|
|
class AllGrantsStaleDuringAcceptError extends Error {
|
|
constructor() {
|
|
super('All organization-invite grants turned stale during acceptance')
|
|
this.name = 'AllGrantsStaleDuringAcceptError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Thrown when the workspace set acceptance would sweep no longer matches the
|
|
* set the accept screen disclosed. Rolls the acceptance back so the user
|
|
* consents to the refreshed notice instead of a silent migration.
|
|
*/
|
|
class DisclosureOutdatedDuringAcceptError extends Error {
|
|
constructor() {
|
|
super('Disclosed workspace set no longer matches the sweep set')
|
|
this.name = 'DisclosureOutdatedDuringAcceptError'
|
|
}
|
|
}
|
|
|
|
interface InvitationAcceptancePostCommitEffects {
|
|
organizationId: string | null
|
|
memberRole: string | null
|
|
reconcileSeats: boolean
|
|
acceptedWorkspaceIds: string[]
|
|
/** Owned personal workspaces that followed the invitee into the org. */
|
|
attachedWorkspaceIds: string[]
|
|
syncUsageLimitUserIds: string[]
|
|
planConversions: AcceptancePlanConversion[]
|
|
acceptedInvitation: InvitationWithGrants | null
|
|
membershipAlreadyExists: boolean
|
|
}
|
|
|
|
interface InvitationAcceptanceLockPlan {
|
|
/**
|
|
* Invitation grant workspaces plus the billing owner's attachable
|
|
* workspaces (a personal Pro→Team conversion attaches those in the same
|
|
* transaction). Passed through to acceptance provisioning unchanged.
|
|
*/
|
|
workspaceIds: string[]
|
|
/**
|
|
* Workspaces the invitee owns outside any organization. When acceptance
|
|
* joins them into an organization, these rows attach in the same
|
|
* transaction, so they participate in the same deterministic lock ordering.
|
|
*/
|
|
joinerAttachWorkspaceIds: string[]
|
|
primaryWorkspace: WorkspaceWithOwner | null
|
|
}
|
|
|
|
/** Compute the complete workspace lock set before taking any workspace lock. */
|
|
async function getInvitationAcceptanceWorkspaceLockIds(
|
|
tx: DbOrTx,
|
|
inv: InvitationWithGrants,
|
|
inviteeUserId: string
|
|
): Promise<InvitationAcceptanceLockPlan> {
|
|
const grantWorkspaceIds = inv.grants.map((grant) => grant.workspaceId)
|
|
const primaryWorkspace = grantWorkspaceIds[0]
|
|
? await getWorkspaceWithOwner(grantWorkspaceIds[0], { executor: tx })
|
|
: null
|
|
|
|
/**
|
|
* Computed for every non-external invite. The post-lock workspace re-read
|
|
* can reveal an organization this pre-lock snapshot does not have (a
|
|
* concurrent attach or move), and the join-attach sweep must already hold
|
|
* these locks in that case — so no billing/organization short-circuit is
|
|
* safe here. Only external intent (immutable: it is never upgraded
|
|
* in-flight) provably rules a join out.
|
|
*/
|
|
const joinerAttachWorkspaceIds =
|
|
inv.membershipIntent === 'external'
|
|
? []
|
|
: (
|
|
await tx
|
|
.select({ id: workspace.id })
|
|
.from(workspace)
|
|
.where(ownedAttachableWorkspacesWhere({ userId: inviteeUserId, includeArchived: true }))
|
|
).map((row) => row.id)
|
|
|
|
const billingOwnerCanAttach =
|
|
isBillingEnabled &&
|
|
inv.membershipIntent !== 'external' &&
|
|
primaryWorkspace !== null &&
|
|
!primaryWorkspace.organizationId
|
|
|
|
const billingOwnerWorkspaceIds = billingOwnerCanAttach
|
|
? (
|
|
await tx
|
|
.select({ id: workspace.id })
|
|
.from(workspace)
|
|
.where(
|
|
ownedAttachableWorkspacesWhere({
|
|
userId: primaryWorkspace.billedAccountUserId,
|
|
ownerMatch: 'billing-account',
|
|
includeArchived: true,
|
|
})
|
|
)
|
|
).map((row) => row.id)
|
|
: []
|
|
|
|
return {
|
|
workspaceIds: [...new Set([...grantWorkspaceIds, ...billingOwnerWorkspaceIds])].sort(),
|
|
joinerAttachWorkspaceIds: [...new Set(joinerAttachWorkspaceIds)].sort(),
|
|
primaryWorkspace,
|
|
}
|
|
}
|
|
|
|
export async function acceptInvitation(
|
|
input: AcceptInvitationInput
|
|
): Promise<AcceptInvitationResult> {
|
|
const effects: InvitationAcceptancePostCommitEffects = {
|
|
organizationId: null,
|
|
memberRole: null,
|
|
reconcileSeats: false,
|
|
acceptedWorkspaceIds: [],
|
|
attachedWorkspaceIds: [],
|
|
syncUsageLimitUserIds: [],
|
|
planConversions: [],
|
|
acceptedInvitation: null,
|
|
membershipAlreadyExists: false,
|
|
}
|
|
const result = await db
|
|
.transaction(async (tx): Promise<AcceptInvitationResult> => {
|
|
await acquireInvitationMutationLocks(tx, {
|
|
invitationIds: [input.invitationId],
|
|
workspaceIds: [],
|
|
})
|
|
|
|
await tx.execute(sql`select id from invitation where id = ${input.invitationId} for update`)
|
|
|
|
const inv = await getInvitationById(input.invitationId, tx)
|
|
if (!inv) {
|
|
return { success: false, kind: 'not-found' }
|
|
}
|
|
|
|
/**
|
|
* Cheap validity checks run before the workspace lock plan so replayed,
|
|
* expired, or mismatched accepts pay no workspace queries or advisory
|
|
* locks. The invitation row is already advisory- and row-locked above,
|
|
* so these reads cannot race a concurrent acceptance.
|
|
*/
|
|
if (input.token && inv.token !== input.token) {
|
|
return { success: false, kind: 'invalid-token' }
|
|
}
|
|
if (inv.status !== 'pending') {
|
|
return { success: false, kind: 'already-processed' }
|
|
}
|
|
if (isInvitationExpired(inv)) {
|
|
await tx
|
|
.update(invitation)
|
|
.set({ status: 'expired', updatedAt: new Date() })
|
|
.where(and(eq(invitation.id, inv.id), eq(invitation.status, 'pending')))
|
|
return { success: false, kind: 'expired' }
|
|
}
|
|
if (normalizeEmail(input.userEmail) !== normalizeEmail(inv.email)) {
|
|
return { success: false, kind: 'email-mismatch' }
|
|
}
|
|
|
|
const lockPlan = await getInvitationAcceptanceWorkspaceLockIds(tx, inv, input.userId)
|
|
await acquireInvitationMutationLocks(tx, {
|
|
invitationIds: [],
|
|
workspaceIds: [
|
|
...new Set([...lockPlan.workspaceIds, ...lockPlan.joinerAttachWorkspaceIds]),
|
|
],
|
|
})
|
|
|
|
// Re-read and row-lock the primary workspace only after the shared
|
|
// workspace advisory lock is held. If a move won the lock first, every
|
|
// billing and membership decision below now uses the committed post-move
|
|
// organization/billing identity rather than the pre-lock snapshot.
|
|
const lockedPrimaryWorkspace = inv.grants[0]
|
|
? await getWorkspaceWithOwner(inv.grants[0].workspaceId, {
|
|
executor: tx,
|
|
forUpdate: true,
|
|
})
|
|
: null
|
|
|
|
return acceptLockedInvitation(
|
|
input,
|
|
inv,
|
|
{ ...lockPlan, primaryWorkspace: lockedPrimaryWorkspace },
|
|
tx,
|
|
effects
|
|
)
|
|
})
|
|
.catch((error): AcceptInvitationResult => {
|
|
if (error instanceof JoinerWorkspacesChangedDuringAcceptError) {
|
|
logger.warn('Invite acceptance rolled back: owned workspaces changed concurrently', {
|
|
invitationId: input.invitationId,
|
|
userId: input.userId,
|
|
})
|
|
return {
|
|
success: false,
|
|
kind: 'server-error',
|
|
message: 'Your workspaces changed while accepting — please try again.',
|
|
}
|
|
}
|
|
if (error instanceof BillingOwnerWorkspacesChangedDuringAcceptError) {
|
|
logger.warn(
|
|
'Invite acceptance rolled back: billing owner workspaces changed concurrently',
|
|
{
|
|
invitationId: input.invitationId,
|
|
userId: input.userId,
|
|
}
|
|
)
|
|
return {
|
|
success: false,
|
|
kind: 'server-error',
|
|
message: "The workspace owner's workspaces changed while accepting — please try again.",
|
|
}
|
|
}
|
|
if (error instanceof AllGrantsStaleDuringAcceptError) {
|
|
logger.warn('Invite acceptance rolled back: every grant turned stale', {
|
|
invitationId: input.invitationId,
|
|
userId: input.userId,
|
|
})
|
|
return { success: false, kind: 'workspace-not-found' }
|
|
}
|
|
if (error instanceof DisclosureOutdatedDuringAcceptError) {
|
|
logger.warn('Invite acceptance rolled back: disclosed workspace set is outdated', {
|
|
invitationId: input.invitationId,
|
|
userId: input.userId,
|
|
})
|
|
return { success: false, kind: 'disclosure-outdated' }
|
|
}
|
|
/**
|
|
* This catch is outside `db.transaction`, so reaching it guarantees
|
|
* Postgres has rolled back every provisioning, membership, workspace,
|
|
* invitation, permission, and outbox write from the failed attempt.
|
|
*/
|
|
logger.error('Invitation acceptance transaction failed and was rolled back', {
|
|
invitationId: input.invitationId,
|
|
userId: input.userId,
|
|
error,
|
|
})
|
|
return { success: false, kind: 'server-error' }
|
|
})
|
|
if (result.success) {
|
|
await runInvitationAcceptancePostCommitEffects(input, effects)
|
|
}
|
|
return result
|
|
}
|
|
|
|
async function acceptLockedInvitation(
|
|
input: AcceptInvitationInput,
|
|
inv: InvitationWithGrants,
|
|
lockPlan: InvitationAcceptanceLockPlan,
|
|
tx: DbOrTx,
|
|
effects: InvitationAcceptancePostCommitEffects
|
|
): Promise<AcceptInvitationResult> {
|
|
let membershipAlreadyExists = false
|
|
let acceptedMembershipIntent = inv.membershipIntent
|
|
let shouldJoinOrganization = inv.membershipIntent !== 'external'
|
|
|
|
/**
|
|
* Workspace-kind invites derive their join target from the granted
|
|
* workspace's LIVE organization (the workspace is what was shared).
|
|
* Organization-kind invites always target their STAMPED organization: a
|
|
* granted workspace whose org changed after send must never redirect the
|
|
* membership into an organization the invitee was not invited to.
|
|
*/
|
|
const primaryGrant = inv.grants[0]
|
|
let billingOwnerUserId = inv.inviterId
|
|
let workspaceOrganizationId = inv.organizationId
|
|
if (primaryGrant && lockPlan.primaryWorkspace && inv.kind === 'workspace') {
|
|
billingOwnerUserId = lockPlan.primaryWorkspace.billedAccountUserId
|
|
workspaceOrganizationId = lockPlan.primaryWorkspace.organizationId
|
|
}
|
|
|
|
if (
|
|
shouldJoinOrganization &&
|
|
!(await stampedOrganizationAllowsEscalation(inv, workspaceOrganizationId, tx))
|
|
) {
|
|
acceptedMembershipIntent = 'external'
|
|
shouldJoinOrganization = false
|
|
}
|
|
|
|
const existingMembership = await getUserOrganization(input.userId, tx)
|
|
const inviteeAlreadyInDifferentOrg =
|
|
!!existingMembership &&
|
|
(workspaceOrganizationId ? existingMembership.organizationId !== workspaceOrganizationId : true)
|
|
|
|
if (shouldJoinOrganization && inviteeAlreadyInDifferentOrg) {
|
|
if (inv.kind !== 'workspace' || inv.grants.length === 0) {
|
|
return { success: false, kind: 'already-in-organization' }
|
|
}
|
|
acceptedMembershipIntent = 'external'
|
|
shouldJoinOrganization = false
|
|
}
|
|
|
|
/**
|
|
* External collaborators hold access inside a paid organization without
|
|
* taking one of its seats, so the invitee has to be paying Sim elsewhere.
|
|
* The invite-time gate can go stale across the invitation's 7-day life (a
|
|
* cancelled Pro), so the same predicate runs again here.
|
|
*
|
|
* Mirrors exactly when the invite-time gate applies, which is narrower than
|
|
* "the invitation is external". Externality is imposed, not chosen, whenever
|
|
* the invitee already belongs to another organization — an account can only
|
|
* be in one, so the inviter's Member/Admin choice is overridden and
|
|
* `inviteeCanBeExternal` never runs. The same holds for the downgrades above,
|
|
* where a workspace moved organizations after the invite went out. Those
|
|
* fallbacks preserve access the invitee was already legitimately granted;
|
|
* charging them a plan requirement nobody warned the inviter about would
|
|
* strand them over someone else's action. Scoped to organization-owned
|
|
* workspaces because sharing a personal workspace has no seat economics.
|
|
*/
|
|
if (
|
|
isBillingEnabled &&
|
|
inv.membershipIntent === 'external' &&
|
|
!inviteeAlreadyInDifferentOrg &&
|
|
workspaceOrganizationId &&
|
|
(await getInvitePlanCategoryForUser(input.userId, tx)) === 'free'
|
|
) {
|
|
return { success: false, kind: 'external-requires-paid-plan' }
|
|
}
|
|
|
|
/**
|
|
* Already in the organization the invitation lands in, so acceptance grants
|
|
* the workspaces without creating a membership or taking a seat. Shared with
|
|
* the consent guard and the join block below so they cannot disagree about
|
|
* whether this acceptance creates a member.
|
|
*/
|
|
const alreadyMemberOfTargetOrganization =
|
|
!!existingMembership &&
|
|
!!workspaceOrganizationId &&
|
|
existingMembership.organizationId === workspaceOrganizationId
|
|
|
|
/**
|
|
* A member-role organization invite whose grants ALL left the stamped
|
|
* organization can never land its member anywhere — fail before any
|
|
* mutation. This must precede the disclosure check: the preview mirrors
|
|
* this gate with an empty disclosure, and rejecting on disclosure first
|
|
* would loop the client on disclosure-outdated instead of surfacing the
|
|
* real cause. The grant rows are advisory-locked, so this read cannot
|
|
* change for the rest of the transaction.
|
|
*/
|
|
if (shouldJoinOrganization && !(await hasLiveGrantInStampedOrganization(inv, tx))) {
|
|
return { success: false, kind: 'workspace-not-found' }
|
|
}
|
|
|
|
/**
|
|
* Membership consent guard. The workspace-id token cannot distinguish "you
|
|
* will join, and nothing of yours moves" from "you will not join at all" —
|
|
* both disclose an empty set — so the disclosed outcome is compared directly.
|
|
*
|
|
* Compared against whether a NEW membership gets created, not against
|
|
* `shouldJoinOrganization`: the preview reports `already-member` for an
|
|
* invitee who already belongs to the target organization (nothing changes for
|
|
* them) while the invitation's intent is still internal, and comparing the raw
|
|
* flag would reject every such acceptance as `disclosure-outdated` with a
|
|
* retry that renders the same preview.
|
|
*
|
|
* A disclosed `blocked` is skipped deliberately: the screen already told the
|
|
* invitee acceptance would fail, so the gates below must surface the real
|
|
* cause (`upgrade-required`, `workspace-not-found`) instead of a consent
|
|
* mismatch. Placed after the dead-grant gate for the same reason.
|
|
*
|
|
* Runs before any write, so a plain failure return needs no rollback.
|
|
*/
|
|
/**
|
|
* Whether acceptance will actually create a member row, decided from the same
|
|
* conditions the join block below uses — `shouldJoinOrganization` alone is not
|
|
* enough here, because it is only cleared much later (after provisioning fails
|
|
* to yield a target organization), by which point a write has happened.
|
|
*
|
|
* The last term mirrors the preview: with no organization on the workspace and
|
|
* billing disabled there is nothing to provision and nothing to join, so a
|
|
* personal or grandfathered workspace invite creates no membership. Omitting it
|
|
* rejected every such acceptance as `disclosure-outdated` on billing-disabled
|
|
* deployments, with a retry that rendered the same preview.
|
|
*/
|
|
const willCreateMembership =
|
|
shouldJoinOrganization &&
|
|
!alreadyMemberOfTargetOrganization &&
|
|
(!!workspaceOrganizationId || isBillingEnabled)
|
|
if (input.disclosedOutcome !== undefined && input.disclosedOutcome !== 'blocked') {
|
|
if ((input.disclosedOutcome === 'will-join') !== willCreateMembership) {
|
|
return { success: false, kind: 'disclosure-outdated' }
|
|
}
|
|
}
|
|
|
|
let targetOrganizationId = workspaceOrganizationId
|
|
|
|
if (shouldJoinOrganization) {
|
|
const alreadyMemberOfTarget = alreadyMemberOfTargetOrganization
|
|
|
|
let fixedSeats = false
|
|
|
|
if (isBillingEnabled && !alreadyMemberOfTarget) {
|
|
if (workspaceOrganizationId) {
|
|
await acquireOrganizationMutationLock(tx, workspaceOrganizationId)
|
|
}
|
|
const orgResult = await ensureTeamOrganizationForAcceptance({
|
|
billingOwnerUserId,
|
|
workspaceOrganizationId,
|
|
executor: tx,
|
|
workspaceIdsToAttach: lockPlan.workspaceIds,
|
|
})
|
|
if (!orgResult.success) {
|
|
return { success: false, kind: orgResult.failureCode }
|
|
}
|
|
|
|
/**
|
|
* A personal Pro→Team conversion acquires the billing owner's
|
|
* billing-identity lock and attaches every workspace from the pre-lock
|
|
* plan inside this transaction. Re-read only after that conversion:
|
|
* anything still attachable was created between the plan read and the
|
|
* identity lock, so it never received a workspace advisory lock. Abort
|
|
* the whole conversion/acceptance and let the retry plan include it.
|
|
*
|
|
* Do not take the identity lock here before provisioning. Organization
|
|
* membership paths acquire organization → identity, and reversing that
|
|
* order would introduce a deadlock.
|
|
*/
|
|
if (!workspaceOrganizationId) {
|
|
const [unplannedBillingOwnerWorkspace] = await tx
|
|
.select({ id: workspace.id })
|
|
.from(workspace)
|
|
.where(
|
|
ownedAttachableWorkspacesWhere({
|
|
userId: billingOwnerUserId,
|
|
ownerMatch: 'billing-account',
|
|
includeArchived: true,
|
|
})
|
|
)
|
|
.limit(1)
|
|
if (unplannedBillingOwnerWorkspace) {
|
|
throw new BillingOwnerWorkspacesChangedDuringAcceptError()
|
|
}
|
|
}
|
|
|
|
targetOrganizationId = orgResult.organizationId
|
|
fixedSeats = orgResult.fixedSeats
|
|
if (orgResult.postCommitEffects) {
|
|
effects.planConversions.push(...orgResult.postCommitEffects.planConversions)
|
|
effects.syncUsageLimitUserIds.push(...orgResult.postCommitEffects.usageLimitUserIds)
|
|
}
|
|
}
|
|
|
|
// Team plans manage seats by reconciling to the member count after the
|
|
// join (and charging async), so the synchronous seat-cap validation is
|
|
// skipped. Enterprise keeps its fixed-seat validation, and when billing is
|
|
// disabled we leave validation in place unchanged.
|
|
const billingManagesSeats = isBillingEnabled && !fixedSeats
|
|
|
|
if (targetOrganizationId) {
|
|
const membershipResult = await ensureUserInOrganizationTx(tx, {
|
|
userId: input.userId,
|
|
organizationId: targetOrganizationId,
|
|
role: (inv.role || 'member') as 'admin' | 'member' | 'owner',
|
|
acceptingInvitationId: inv.id,
|
|
// If the pre-lock membership read said the user already belonged to
|
|
// this org but a concurrent removal won the org lock first, fall back
|
|
// to normal validation instead of accidentally bypassing Enterprise's
|
|
// fixed-seat cap with stale state.
|
|
skipSeatValidation: billingManagesSeats && !alreadyMemberOfTarget,
|
|
})
|
|
|
|
if (!membershipResult.success) {
|
|
if (membershipResult.existingOrgId) {
|
|
return { success: false, kind: 'already-in-organization' }
|
|
}
|
|
if (membershipResult.failureCode === 'no-seats-available') {
|
|
return { success: false, kind: 'no-seats-available' }
|
|
}
|
|
return { success: false, kind: 'server-error', message: membershipResult.error }
|
|
}
|
|
membershipAlreadyExists = membershipResult.alreadyMember
|
|
|
|
/**
|
|
* `membershipResult.alreadyMember` is true both for a genuinely
|
|
* pre-existing member AND for an invitee this very transaction just
|
|
* auto-joined (the Pro→Team conversion's `keep-external` attach joins
|
|
* org-less collaborators of the billing owner's workspaces before we
|
|
* reach here). Only the FORMER may skip the join side effects, so key
|
|
* them off the pre-acceptance membership snapshot instead — otherwise a
|
|
* collaborator-invitee silently keeps their workspaces personal, pays
|
|
* no seat, and loses their invited role.
|
|
*/
|
|
const joinedDuringThisAcceptance = !alreadyMemberOfTarget
|
|
|
|
if (joinedDuringThisAcceptance) {
|
|
effects.memberRole = inv.role || 'member'
|
|
}
|
|
|
|
/**
|
|
* An in-transaction auto-join lands everyone as `member`; restore the
|
|
* role the invitation actually granted when it is higher.
|
|
*/
|
|
if (
|
|
joinedDuringThisAcceptance &&
|
|
membershipResult.alreadyMember &&
|
|
isOrgAdminRole(inv.role)
|
|
) {
|
|
await tx
|
|
.update(member)
|
|
.set({ role: inv.role })
|
|
.where(
|
|
and(eq(member.userId, input.userId), eq(member.organizationId, targetOrganizationId))
|
|
)
|
|
}
|
|
|
|
// Grow the paid seat count to match the new member and push the charge
|
|
// to Stripe asynchronously (Team plans only; Enterprise seats are
|
|
// fixed). Best-effort: the member is already in, and a transient
|
|
// failure self-heals on the next join/removal reconcile, matching the
|
|
// removal path's seat accounting.
|
|
if (billingManagesSeats && joinedDuringThisAcceptance) {
|
|
effects.reconcileSeats = true
|
|
}
|
|
|
|
/**
|
|
* A new member's owned personal workspaces follow them into the
|
|
* organization so members never operate outside the org's purview.
|
|
* Collaborators on those workspaces stay external (`external-all`) —
|
|
* membership and seats never grow as a side effect of someone else's
|
|
* join. Fresh joins only: pre-existing members' estates are left
|
|
* untouched until an announced backfill.
|
|
*
|
|
* ensureUserInOrganizationTx holds the user's billing-identity lock,
|
|
* which personal workspace creation also takes — so the owned set is
|
|
* re-read here race-free. A set that changed since the pre-lock plan
|
|
* means a workspace escaped the advisory locks: the acceptance is
|
|
* rolled back (retry succeeds with the fresh set) instead of committing
|
|
* a member whose workspace dodged the sweep.
|
|
*/
|
|
if (joinedDuringThisAcceptance) {
|
|
const currentOwnedIds = (
|
|
await tx
|
|
.select({ id: workspace.id })
|
|
.from(workspace)
|
|
.where(ownedAttachableWorkspacesWhere({ userId: input.userId, includeArchived: true }))
|
|
).map((row) => row.id)
|
|
if (
|
|
[...currentOwnedIds].sort().join() !==
|
|
[...lockPlan.joinerAttachWorkspaceIds].sort().join()
|
|
) {
|
|
throw new JoinerWorkspacesChangedDuringAcceptError()
|
|
}
|
|
|
|
/**
|
|
* Consent is only valid for the workspace set the user saw: when the
|
|
* client supplies the disclosed ids from the join preview, a sweep
|
|
* set that differs (a workspace created or removed since the preview
|
|
* rendered) rolls the acceptance back so the refreshed notice is
|
|
* shown before any migration happens.
|
|
*/
|
|
if (
|
|
input.disclosedWorkspaceIds !== undefined &&
|
|
[...input.disclosedWorkspaceIds].sort().join() !==
|
|
[...lockPlan.joinerAttachWorkspaceIds].sort().join()
|
|
) {
|
|
throw new DisclosureOutdatedDuringAcceptError()
|
|
}
|
|
|
|
if (lockPlan.joinerAttachWorkspaceIds.length > 0) {
|
|
// No acquireOrganizationMutationLock here: ensureUserInOrganizationTx
|
|
// above already took it for this organization, and advisory locks are
|
|
// transaction-scoped, so re-taking it is two wasted round trips.
|
|
const attachResult = await attachOwnedWorkspacesToOrganizationTx(tx, {
|
|
ownerUserId: input.userId,
|
|
organizationId: targetOrganizationId,
|
|
workspaceIds: lockPlan.joinerAttachWorkspaceIds,
|
|
externalMemberPolicy: 'external-all',
|
|
ownerMatch: 'owner',
|
|
includeArchived: true,
|
|
})
|
|
effects.syncUsageLimitUserIds.push(...attachResult.usageLimitUserIds)
|
|
effects.attachedWorkspaceIds = attachResult.attachedWorkspaceIds
|
|
}
|
|
}
|
|
} else {
|
|
shouldJoinOrganization = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reverse disclosure guard: a will-join notice (non-empty disclosed set)
|
|
* whose acceptance resolved to no-join must not silently succeed as an
|
|
* external grant — the user consented to membership plus a migration that
|
|
* will not happen. Nothing has been written on the no-join path, so a
|
|
* plain failure return suffices; retry renders the refreshed preview.
|
|
*/
|
|
if (
|
|
!shouldJoinOrganization &&
|
|
input.disclosedWorkspaceIds !== undefined &&
|
|
input.disclosedWorkspaceIds.length > 0
|
|
) {
|
|
return { success: false, kind: 'disclosure-outdated' }
|
|
}
|
|
|
|
const acceptedWorkspaceIds: string[] = []
|
|
|
|
try {
|
|
/**
|
|
* The caller's transaction holds the invitation and workspace locks for
|
|
* this entire acceptance, including membership validation and grants.
|
|
*/
|
|
if (shouldJoinOrganization && targetOrganizationId) {
|
|
await acquireOrgMembershipLock(tx, input.userId, targetOrganizationId)
|
|
const [stillMember] = await tx
|
|
.select({ id: member.id })
|
|
.from(member)
|
|
.where(
|
|
and(eq(member.organizationId, targetOrganizationId), eq(member.userId, input.userId))
|
|
)
|
|
.limit(1)
|
|
if (!stillMember) {
|
|
throw new MembershipRevokedDuringAcceptError()
|
|
}
|
|
}
|
|
|
|
await tx
|
|
.update(invitation)
|
|
.set({
|
|
status: 'accepted',
|
|
membershipIntent: acceptedMembershipIntent,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(eq(invitation.id, inv.id), eq(invitation.status, 'pending')))
|
|
|
|
for (const grant of inv.grants) {
|
|
/**
|
|
* Organization-invite grants are only honored while the workspace still
|
|
* belongs to the stamped organization: a workspace that detached or
|
|
* moved after the invite went out is no longer the org's to share.
|
|
*/
|
|
if (inv.kind === 'organization' && inv.organizationId) {
|
|
const [grantWorkspace] = await tx
|
|
.select({ organizationId: workspace.organizationId })
|
|
.from(workspace)
|
|
.where(eq(workspace.id, grant.workspaceId))
|
|
.limit(1)
|
|
if (!grantWorkspace || grantWorkspace.organizationId !== inv.organizationId) {
|
|
logger.warn('Skipping stale organization-invite grant; workspace left the organization', {
|
|
invitationId: inv.id,
|
|
workspaceId: grant.workspaceId,
|
|
stampedOrganizationId: inv.organizationId,
|
|
currentOrganizationId: grantWorkspace?.organizationId ?? null,
|
|
})
|
|
continue
|
|
}
|
|
}
|
|
|
|
const [existingPermission] = await tx
|
|
.select({ id: permissions.id, permissionType: permissions.permissionType })
|
|
.from(permissions)
|
|
.where(
|
|
and(
|
|
eq(permissions.entityId, grant.workspaceId),
|
|
eq(permissions.entityType, 'workspace'),
|
|
eq(permissions.userId, input.userId)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
const newPermission = grant.permission as PermissionType
|
|
const newRank = PERMISSION_RANK[newPermission] ?? 0
|
|
|
|
if (existingPermission) {
|
|
const existingRank =
|
|
PERMISSION_RANK[existingPermission.permissionType as PermissionType] ?? 0
|
|
if (newRank > existingRank) {
|
|
await tx
|
|
.update(permissions)
|
|
.set({ permissionType: newPermission, updatedAt: new Date() })
|
|
.where(eq(permissions.id, existingPermission.id))
|
|
}
|
|
} else {
|
|
await tx.insert(permissions).values({
|
|
id: generateId(),
|
|
entityType: 'workspace',
|
|
entityId: grant.workspaceId,
|
|
userId: input.userId,
|
|
permissionType: newPermission,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
}
|
|
|
|
acceptedWorkspaceIds.push(grant.workspaceId)
|
|
}
|
|
|
|
/**
|
|
* A member-role organization invite whose grants ALL turned stale would
|
|
* create a member with no workspace to land in — the exact dead end the
|
|
* invite-time grant requirement exists to prevent. Roll the whole
|
|
* acceptance (including the member insert) back instead; admins are
|
|
* exempt since they derive access to every organization workspace.
|
|
*/
|
|
if (
|
|
inv.kind === 'organization' &&
|
|
shouldJoinOrganization &&
|
|
!membershipAlreadyExists &&
|
|
!isOrgAdminRole(inv.role) &&
|
|
inv.grants.length > 0 &&
|
|
acceptedWorkspaceIds.length === 0
|
|
) {
|
|
throw new AllGrantsStaleDuringAcceptError()
|
|
}
|
|
} catch (grantError) {
|
|
if (grantError instanceof MembershipRevokedDuringAcceptError) {
|
|
logger.warn('Aborted invite acceptance: org membership revoked concurrently', {
|
|
userId: input.userId,
|
|
organizationId: targetOrganizationId,
|
|
invitationId: inv.id,
|
|
})
|
|
return { success: false, kind: 'already-processed' }
|
|
}
|
|
throw grantError
|
|
}
|
|
|
|
effects.organizationId = shouldJoinOrganization ? targetOrganizationId : null
|
|
effects.acceptedWorkspaceIds = acceptedWorkspaceIds
|
|
if (shouldJoinOrganization && targetOrganizationId && !membershipAlreadyExists) {
|
|
effects.syncUsageLimitUserIds.push(input.userId)
|
|
}
|
|
const acceptedInvitation: InvitationWithGrants = {
|
|
...inv,
|
|
organizationId: targetOrganizationId,
|
|
status: 'accepted',
|
|
membershipIntent: acceptedMembershipIntent,
|
|
}
|
|
effects.acceptedInvitation = acceptedInvitation
|
|
effects.membershipAlreadyExists = membershipAlreadyExists
|
|
|
|
const redirectPath =
|
|
acceptedWorkspaceIds.length > 0 ? `/workspace/${acceptedWorkspaceIds[0]}` : '/workspace'
|
|
|
|
return {
|
|
success: true,
|
|
invitation: acceptedInvitation,
|
|
acceptedWorkspaceIds,
|
|
redirectPath,
|
|
membershipAlreadyExists,
|
|
}
|
|
}
|
|
|
|
async function runInvitationAcceptancePostCommitEffects(
|
|
input: AcceptInvitationInput,
|
|
effects: InvitationAcceptancePostCommitEffects
|
|
): Promise<void> {
|
|
if (effects.acceptedInvitation) {
|
|
const accepted = effects.acceptedInvitation
|
|
recordAudit({
|
|
workspaceId: effects.acceptedWorkspaceIds[0] ?? null,
|
|
actorId: input.userId,
|
|
actorName: input.actorName ?? undefined,
|
|
actorEmail: input.userEmail,
|
|
action:
|
|
accepted.kind === 'workspace'
|
|
? AuditAction.INVITATION_ACCEPTED
|
|
: AuditAction.ORG_INVITATION_ACCEPTED,
|
|
resourceType:
|
|
accepted.kind === 'workspace'
|
|
? AuditResourceType.WORKSPACE
|
|
: AuditResourceType.ORGANIZATION,
|
|
resourceId: accepted.organizationId ?? effects.acceptedWorkspaceIds[0] ?? accepted.id,
|
|
description: `Accepted ${accepted.kind} invitation for ${accepted.email}`,
|
|
metadata: {
|
|
invitationId: accepted.id,
|
|
targetEmail: accepted.email,
|
|
targetRole: accepted.role,
|
|
kind: accepted.kind,
|
|
membershipIntent: accepted.membershipIntent,
|
|
workspaceIds: effects.acceptedWorkspaceIds,
|
|
membershipAlreadyExists: effects.membershipAlreadyExists,
|
|
},
|
|
request: input.request,
|
|
})
|
|
}
|
|
|
|
if (effects.organizationId && effects.memberRole) {
|
|
// Pre-join sessions keep their old expiry until the next sliding refresh;
|
|
// apply the org's session policy to them now (best-effort, never throws).
|
|
await applySessionPolicyToNewMember(input.userId, effects.organizationId)
|
|
|
|
recordAudit({
|
|
workspaceId: null,
|
|
actorId: input.userId,
|
|
action: AuditAction.ORG_MEMBER_ADDED,
|
|
resourceType: AuditResourceType.ORGANIZATION,
|
|
resourceId: effects.organizationId,
|
|
description: `Joined organization as ${effects.memberRole} via invite acceptance`,
|
|
metadata: {
|
|
invitationId: input.invitationId,
|
|
memberRole: effects.memberRole,
|
|
attachedWorkspaceIds: effects.attachedWorkspaceIds,
|
|
},
|
|
})
|
|
captureServerEvent(
|
|
input.userId,
|
|
'org_member_added',
|
|
{ organization_id: effects.organizationId, member_role: effects.memberRole },
|
|
{ groups: { organization: effects.organizationId } }
|
|
)
|
|
}
|
|
|
|
for (const conversion of effects.planConversions) {
|
|
recordAudit({
|
|
workspaceId: null,
|
|
actorId: conversion.actorId,
|
|
action: AuditAction.ORG_PLAN_CONVERTED,
|
|
resourceType: AuditResourceType.ORGANIZATION,
|
|
resourceId: conversion.organizationId,
|
|
description: `Converted ${conversion.fromPlan} to ${conversion.toPlan}`,
|
|
metadata: {
|
|
fromPlan: conversion.fromPlan,
|
|
toPlan: conversion.toPlan,
|
|
trigger: 'invite-acceptance',
|
|
},
|
|
})
|
|
captureServerEvent(conversion.actorId, 'subscription_changed', {
|
|
from_plan: conversion.fromPlan,
|
|
to_plan: conversion.toPlan,
|
|
interval: 'unchanged',
|
|
})
|
|
}
|
|
|
|
if (effects.organizationId && effects.reconcileSeats) {
|
|
try {
|
|
await reconcileOrganizationSeats({
|
|
organizationId: effects.organizationId,
|
|
reason: 'member-accepted-invite',
|
|
actorId: input.userId,
|
|
})
|
|
} catch (seatError) {
|
|
logger.error('Failed to reconcile organization seats after invite acceptance', {
|
|
userId: input.userId,
|
|
organizationId: effects.organizationId,
|
|
invitationId: input.invitationId,
|
|
error: seatError,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (effects.organizationId) {
|
|
try {
|
|
await setActiveOrganizationForCurrentSession(effects.organizationId)
|
|
} catch (activeOrgError) {
|
|
logger.error('Failed to activate organization after accepting invitation', {
|
|
userId: input.userId,
|
|
organizationId: effects.organizationId,
|
|
invitationId: input.invitationId,
|
|
error: activeOrgError,
|
|
})
|
|
}
|
|
}
|
|
|
|
for (const workspaceId of effects.acceptedWorkspaceIds) {
|
|
try {
|
|
const [wsEnvRow] = await db
|
|
.select({ variables: workspaceEnvironment.variables })
|
|
.from(workspaceEnvironment)
|
|
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
|
.limit(1)
|
|
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
|
|
if (wsEnvKeys.length > 0) {
|
|
await syncWorkspaceEnvCredentials({
|
|
workspaceId,
|
|
envKeys: wsEnvKeys,
|
|
actingUserId: input.userId,
|
|
})
|
|
}
|
|
} catch (envError) {
|
|
logger.error('Failed to sync workspace env credentials after invitation accept', {
|
|
userId: input.userId,
|
|
workspaceId,
|
|
invitationId: input.invitationId,
|
|
error: envError,
|
|
})
|
|
}
|
|
}
|
|
|
|
for (const userId of new Set(effects.syncUsageLimitUserIds)) {
|
|
try {
|
|
await syncUsageLimitsFromSubscription(userId)
|
|
} catch (syncError) {
|
|
logger.error('Failed to sync usage limits after joining org', {
|
|
userId,
|
|
organizationId: effects.organizationId,
|
|
invitationId: input.invitationId,
|
|
error: syncError,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
export type RejectInvitationResult =
|
|
| { success: true; invitation: InvitationWithGrants }
|
|
| { success: false; kind: AcceptInvitationFailure['kind'] }
|
|
|
|
export type UpdateInvitationFailureKind =
|
|
| 'not-found'
|
|
| 'not-pending'
|
|
| 'external-role'
|
|
| 'role-not-organization-scoped'
|
|
| 'organization-forbidden'
|
|
| 'member-requires-workspace'
|
|
| 'grant-not-found'
|
|
| 'workspace-forbidden'
|
|
|
|
export type UpdateInvitationResult =
|
|
| { success: true; invitation: InvitationWithGrants }
|
|
| {
|
|
success: false
|
|
kind: UpdateInvitationFailureKind
|
|
workspaceId?: string
|
|
}
|
|
|
|
/**
|
|
* Updates a pending invitation only after claiming the invitation and all of
|
|
* its workspaces in the shared mutation lock namespace. Authorization is
|
|
* intentionally evaluated inside that transaction against the locked,
|
|
* re-hydrated invitation so a workspace move or acceptance cannot turn an
|
|
* authorized pre-lock snapshot into an unauthorized write.
|
|
*/
|
|
export async function updateInvitation(input: {
|
|
actorId: string
|
|
invitationId: string
|
|
role?: 'admin' | 'member'
|
|
grants?: Array<{ workspaceId: string; permission: PermissionType }>
|
|
}): Promise<UpdateInvitationResult> {
|
|
return db.transaction(async (tx): Promise<UpdateInvitationResult> => {
|
|
const inv = await lockInvitationForMutation(tx, input.invitationId, {
|
|
additionalWorkspaceIds: input.grants?.map((grant) => grant.workspaceId) ?? [],
|
|
})
|
|
if (!inv) return { success: false, kind: 'not-found' }
|
|
if (inv.status !== 'pending') return { success: false, kind: 'not-pending' }
|
|
|
|
if (input.role !== undefined) {
|
|
if (inv.membershipIntent === 'external') {
|
|
return { success: false, kind: 'external-role' }
|
|
}
|
|
if (!inv.organizationId) {
|
|
return { success: false, kind: 'role-not-organization-scoped' }
|
|
}
|
|
if (!(await lockOrganizationAdminAuthority(tx, input.actorId, inv.organizationId))) {
|
|
return { success: false, kind: 'organization-forbidden' }
|
|
}
|
|
if (!isOrgAdminRole(input.role) && inv.grants.length === 0) {
|
|
return { success: false, kind: 'member-requires-workspace' }
|
|
}
|
|
}
|
|
|
|
const grantsToApply = input.grants ?? []
|
|
const grantWorkspaceIds = [...new Set(grantsToApply.map((grant) => grant.workspaceId))].sort()
|
|
for (const workspaceId of grantWorkspaceIds) {
|
|
if (!inv.grants.some((grant) => grant.workspaceId === workspaceId)) {
|
|
return {
|
|
success: false,
|
|
kind: 'grant-not-found',
|
|
workspaceId,
|
|
}
|
|
}
|
|
if (!(await lockWorkspaceAdminAuthority(tx, input.actorId, workspaceId))) {
|
|
return {
|
|
success: false,
|
|
kind: 'workspace-forbidden',
|
|
workspaceId,
|
|
}
|
|
}
|
|
}
|
|
|
|
const now = new Date()
|
|
const [claimed] = await tx
|
|
.update(invitation)
|
|
.set({
|
|
...(input.role !== undefined && input.role !== inv.role ? { role: input.role } : {}),
|
|
updatedAt: now,
|
|
})
|
|
.where(and(eq(invitation.id, input.invitationId), eq(invitation.status, 'pending')))
|
|
.returning({ id: invitation.id })
|
|
if (!claimed) return { success: false, kind: 'not-pending' }
|
|
|
|
for (const update of grantsToApply) {
|
|
await tx
|
|
.update(invitationWorkspaceGrant)
|
|
.set({ permission: update.permission, updatedAt: now })
|
|
.where(
|
|
and(
|
|
eq(invitationWorkspaceGrant.invitationId, input.invitationId),
|
|
eq(invitationWorkspaceGrant.workspaceId, update.workspaceId)
|
|
)
|
|
)
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
invitation: {
|
|
...inv,
|
|
role: input.role ?? inv.role,
|
|
updatedAt: now,
|
|
grants: inv.grants.map((grant) => {
|
|
const update = grantsToApply.find(
|
|
(candidate) => candidate.workspaceId === grant.workspaceId
|
|
)
|
|
return update ? { ...grant, permission: update.permission } : grant
|
|
}),
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function rejectInvitation(
|
|
input: AcceptInvitationInput
|
|
): Promise<RejectInvitationResult> {
|
|
return db.transaction(async (tx): Promise<RejectInvitationResult> => {
|
|
const inv = await lockInvitationForMutation(tx, input.invitationId)
|
|
|
|
if (!inv) return { success: false, kind: 'not-found' }
|
|
if (input.token && inv.token !== input.token) return { success: false, kind: 'invalid-token' }
|
|
if (inv.status !== 'pending') return { success: false, kind: 'already-processed' }
|
|
if (isInvitationExpired(inv)) {
|
|
const expired = await tx
|
|
.update(invitation)
|
|
.set({ status: 'expired', updatedAt: new Date() })
|
|
.where(and(eq(invitation.id, inv.id), eq(invitation.status, 'pending')))
|
|
.returning({ id: invitation.id })
|
|
return {
|
|
success: false,
|
|
kind: expired.length > 0 ? 'expired' : 'already-processed',
|
|
}
|
|
}
|
|
if (normalizeEmail(input.userEmail) !== normalizeEmail(inv.email)) {
|
|
return { success: false, kind: 'email-mismatch' }
|
|
}
|
|
|
|
const now = new Date()
|
|
const rejected = await tx
|
|
.update(invitation)
|
|
.set({ status: 'rejected', updatedAt: now })
|
|
.where(and(eq(invitation.id, inv.id), eq(invitation.status, 'pending')))
|
|
.returning({ id: invitation.id })
|
|
if (rejected.length === 0) {
|
|
return { success: false, kind: 'already-processed' }
|
|
}
|
|
|
|
return { success: true, invitation: { ...inv, status: 'rejected', updatedAt: now } }
|
|
})
|
|
}
|
|
|
|
export type AuthorizedInvitationRevocationResult =
|
|
| {
|
|
success: true
|
|
invitation: InvitationWithGrants
|
|
invitationCancelled: boolean
|
|
}
|
|
| {
|
|
success: false
|
|
kind:
|
|
| 'not-found'
|
|
| 'not-pending'
|
|
| 'grant-not-found'
|
|
| 'scoped-forbidden'
|
|
| 'whole-forbidden'
|
|
| 'not-cancellable'
|
|
spansMultipleWorkspaces?: boolean
|
|
}
|
|
|
|
/**
|
|
* API-facing revocation path. Unlike generic internal cleanup helpers, this
|
|
* claims the invitation and relevant workspace scopes first, then evaluates
|
|
* the actor's organization/workspace authority against the protected live
|
|
* state in the same transaction as the conditional pending-only mutation.
|
|
*/
|
|
export async function revokeInvitationAsAdmin(input: {
|
|
actorId: string
|
|
invitationId: string
|
|
workspaceId?: string
|
|
}): Promise<AuthorizedInvitationRevocationResult> {
|
|
return db.transaction(async (tx): Promise<AuthorizedInvitationRevocationResult> => {
|
|
const inv = await lockInvitationForMutation(tx, input.invitationId, {
|
|
lockCurrentGrantWorkspaces: input.workspaceId === undefined,
|
|
additionalWorkspaceIds: input.workspaceId ? [input.workspaceId] : [],
|
|
})
|
|
if (!inv) return { success: false, kind: 'not-found' }
|
|
if (inv.status !== 'pending') return { success: false, kind: 'not-pending' }
|
|
|
|
const isOrganizationAdmin = inv.organizationId
|
|
? await lockOrganizationAdminAuthority(tx, input.actorId, inv.organizationId)
|
|
: false
|
|
|
|
if (input.workspaceId) {
|
|
if (!inv.grants.some((grant) => grant.workspaceId === input.workspaceId)) {
|
|
return { success: false, kind: 'grant-not-found' }
|
|
}
|
|
if (
|
|
!isOrganizationAdmin &&
|
|
!(await lockWorkspaceAdminAuthority(tx, input.actorId, input.workspaceId))
|
|
) {
|
|
return { success: false, kind: 'scoped-forbidden' }
|
|
}
|
|
|
|
const revoked = await revokeInvitationWorkspaceGrantTx(tx, {
|
|
invitationId: input.invitationId,
|
|
workspaceId: input.workspaceId,
|
|
})
|
|
if (!revoked.revoked) return { success: false, kind: 'not-cancellable' }
|
|
return {
|
|
success: true,
|
|
invitation: inv,
|
|
invitationCancelled: revoked.invitationCancelled,
|
|
}
|
|
}
|
|
|
|
let canCancel = isOrganizationAdmin
|
|
if (!canCancel && inv.grants.length > 0) {
|
|
canCancel = true
|
|
const workspaceIds = [...new Set(inv.grants.map((grant) => grant.workspaceId))].sort()
|
|
for (const workspaceId of workspaceIds) {
|
|
if (!(await lockWorkspaceAdminAuthority(tx, input.actorId, workspaceId))) {
|
|
canCancel = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (!canCancel) {
|
|
return {
|
|
success: false,
|
|
kind: 'whole-forbidden',
|
|
spansMultipleWorkspaces: inv.grants.length > 1,
|
|
}
|
|
}
|
|
|
|
const cancelled = await tx
|
|
.update(invitation)
|
|
.set({ status: 'cancelled', updatedAt: new Date() })
|
|
.where(and(eq(invitation.id, input.invitationId), eq(invitation.status, 'pending')))
|
|
.returning({ id: invitation.id })
|
|
if (cancelled.length === 0) return { success: false, kind: 'not-cancellable' }
|
|
|
|
return { success: true, invitation: inv, invitationCancelled: true }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Revokes one workspace's grant from a pending invitation, cancelling the whole
|
|
* invitation only when that was its last grant.
|
|
*
|
|
* One invitation can span several workspaces, so revoking from a single
|
|
* workspace's member list must not destroy the grants to its siblings — an
|
|
* admin of one workspace has no authority over the others. Removing the final
|
|
* grant would otherwise strand a pending invitation that grants nothing, so
|
|
* that case cancels it instead.
|
|
*
|
|
* Transaction-local: callers must already hold the canonical
|
|
* invitation/workspace advisory lock set. Keeping the grant deletion and
|
|
* final-grant cancellation in one implementation prevents direct grants,
|
|
* scoped revocation, and future callers from drifting on multi-workspace
|
|
* invitation semantics.
|
|
*/
|
|
export async function revokeInvitationWorkspaceGrantTx(
|
|
tx: DbOrTx,
|
|
{
|
|
invitationId,
|
|
workspaceId,
|
|
}: {
|
|
invitationId: string
|
|
workspaceId: string
|
|
}
|
|
): Promise<{ revoked: boolean; invitationCancelled: boolean }> {
|
|
const [pending] = await tx
|
|
.select({ id: invitation.id })
|
|
.from(invitation)
|
|
.where(and(eq(invitation.id, invitationId), eq(invitation.status, 'pending')))
|
|
.for('update')
|
|
.limit(1)
|
|
if (!pending) return { revoked: false, invitationCancelled: false }
|
|
|
|
const removed = await tx
|
|
.delete(invitationWorkspaceGrant)
|
|
.where(
|
|
and(
|
|
eq(invitationWorkspaceGrant.invitationId, invitationId),
|
|
eq(invitationWorkspaceGrant.workspaceId, workspaceId)
|
|
)
|
|
)
|
|
.returning({ id: invitationWorkspaceGrant.id })
|
|
if (removed.length === 0) return { revoked: false, invitationCancelled: false }
|
|
|
|
const [remaining] = await tx
|
|
.select({ value: count() })
|
|
.from(invitationWorkspaceGrant)
|
|
.where(eq(invitationWorkspaceGrant.invitationId, invitationId))
|
|
|
|
if ((remaining?.value ?? 0) > 0) {
|
|
await tx
|
|
.update(invitation)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(invitation.id, invitationId))
|
|
return { revoked: true, invitationCancelled: false }
|
|
}
|
|
|
|
await tx
|
|
.update(invitation)
|
|
.set({ status: 'cancelled', updatedAt: new Date() })
|
|
.where(eq(invitation.id, invitationId))
|
|
return { revoked: true, invitationCancelled: true }
|
|
}
|
|
|
|
/**
|
|
* Pending, unexpired invitations addressed to an email — the invitee-facing
|
|
* list (workspace-switcher Invitations section). Session-bound callers accept
|
|
* without a token, so the rows returned here must never need one.
|
|
*/
|
|
export async function listPendingInvitationsForEmail(
|
|
email: string
|
|
): Promise<InvitationWithGrants[]> {
|
|
const rows = await db
|
|
.select()
|
|
.from(invitation)
|
|
.where(
|
|
and(
|
|
sql`lower(${invitation.email}) = ${normalizeEmail(email)}`,
|
|
eq(invitation.status, 'pending'),
|
|
sql`${invitation.expiresAt} > now()`
|
|
)
|
|
)
|
|
.orderBy(invitation.createdAt)
|
|
return Promise.all(rows.map((row) => hydrateInvitation(row)))
|
|
}
|
|
|
|
export async function listInvitationsForWorkspaces(workspaceIds: string[]) {
|
|
if (workspaceIds.length === 0) return []
|
|
return db
|
|
.select({
|
|
id: invitation.id,
|
|
kind: invitation.kind,
|
|
email: invitation.email,
|
|
token: invitation.token,
|
|
status: invitation.status,
|
|
expiresAt: invitation.expiresAt,
|
|
createdAt: invitation.createdAt,
|
|
updatedAt: invitation.updatedAt,
|
|
organizationId: invitation.organizationId,
|
|
membershipIntent: invitation.membershipIntent,
|
|
inviterId: invitation.inviterId,
|
|
workspaceId: invitationWorkspaceGrant.workspaceId,
|
|
permission: invitationWorkspaceGrant.permission,
|
|
})
|
|
.from(invitationWorkspaceGrant)
|
|
.innerJoin(invitation, eq(invitation.id, invitationWorkspaceGrant.invitationId))
|
|
.where(inArray(invitationWorkspaceGrant.workspaceId, workspaceIds))
|
|
}
|