Files
WeHub Mirror 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
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

561 lines
19 KiB
TypeScript

import { db } from '@sim/db'
import { member, type WorkspaceMode, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { and, count, eq, isNull } from 'drizzle-orm'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import {
acquireOrganizationUserMutationLocks,
getUserOrganization,
} from '@/lib/billing/organizations/membership'
import type { PlanCategory } from '@/lib/billing/plan-helpers'
import { getPlanType, isEnterprise, isMaxTier, isPro, isTeam } from '@/lib/billing/plan-helpers'
import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import type { DbOrTx } from '@/lib/db/types'
import {
CONTACT_OWNER_TO_UPGRADE_REASON,
UPGRADE_TO_INVITE_REASON,
} from '@/lib/workspaces/policy-constants'
const logger = createLogger('WorkspacePolicy')
export const WORKSPACE_MODE = {
PERSONAL: 'personal',
ORGANIZATION: 'organization',
GRANDFATHERED_SHARED: 'grandfathered_shared',
} as const satisfies Record<string, WorkspaceMode>
interface WorkspaceOwnershipState {
organizationId: string | null
workspaceMode: WorkspaceMode
billedAccountUserId: string
ownerId: string
}
export {
CONTACT_OWNER_TO_UPGRADE_REASON,
UPGRADE_TO_INVITE_REASON,
} from '@/lib/workspaces/policy-constants'
export interface WorkspaceInvitePolicy {
allowed: boolean
reason: string | null
requiresSeat: boolean
organizationId: string | null
upgradeRequired: boolean
}
/** Caller-facing invite flags derived from an evaluated invite policy. */
export interface WorkspaceInviteFlags {
inviteMembersEnabled: boolean
inviteDisabledReason: string | null
inviteUpgradeRequired: boolean
}
/**
* Derives the caller-facing invite flags for a workspace response. Only the
* billed user can act on an upgrade, so everyone else gets the contact-owner
* message when invites are disabled.
*/
export function resolveInviteFlags(
invitePolicy: WorkspaceInvitePolicy,
callerIsBilledUser: boolean
): WorkspaceInviteFlags {
return {
inviteMembersEnabled: invitePolicy.allowed,
inviteDisabledReason: invitePolicy.allowed
? null
: callerIsBilledUser
? (invitePolicy.reason ?? UPGRADE_TO_INVITE_REASON)
: CONTACT_OWNER_TO_UPGRADE_REASON,
inviteUpgradeRequired: invitePolicy.upgradeRequired && callerIsBilledUser,
}
}
export interface WorkspaceCreationPolicy {
canCreate: boolean
workspaceMode: WorkspaceMode
organizationId: string | null
billedAccountUserId: string
maxWorkspaces: number | null
currentWorkspaceCount: number
reason: string | null
status: number
/**
* The organization the caller belonged to when this decision was made
* (`null` for none). A PERSONAL decision is legitimate for an existing
* member whose organization has no usable Team/Enterprise plan, so
* creation compares membership against this snapshot instead of treating
* any membership as a mid-create join.
*/
observedOrganizationId: string | null
/** Discriminant for blocked states the workspace mode cannot distinguish. */
blockedReasonCode?: 'organization-subscription-inactive'
}
export class WorkspaceCreationContextChangedError extends Error {
constructor() {
super('Workspace creation context changed before the workspace was inserted')
this.name = 'WorkspaceCreationContextChangedError'
}
}
/**
* Serializes the final creation-policy check with membership/ownership
* mutations and row-locks the paid entitlement used by organization mode.
* Returns the live billing owner. The caller must invoke this in the same
* transaction as the workspace insert.
*/
export async function lockWorkspaceCreationContext(
tx: DbOrTx,
{
userId,
organizationId,
observedOrganizationId,
}: {
userId: string
organizationId: string | null
observedOrganizationId: string | null
}
): Promise<{ billedAccountUserId: string }> {
await acquireOrganizationUserMutationLocks(tx, {
userId,
organizationIds: organizationId ? [organizationId] : [],
})
const currentMembership = await getUserOrganization(userId, tx)
if (
(currentMembership?.organizationId ?? null) !== observedOrganizationId ||
(organizationId !== null && currentMembership?.organizationId !== organizationId)
) {
throw new WorkspaceCreationContextChangedError()
}
if (!organizationId) return { billedAccountUserId: userId }
if (isBillingEnabled) {
if (!currentMembership || !isOrgAdminRole(currentMembership.role)) {
throw new WorkspaceCreationContextChangedError()
}
const currentSubscription = await getOrganizationSubscription(organizationId, {
executor: tx,
onError: 'throw',
forUpdate: true,
})
if (
!currentSubscription ||
!hasUsableSubscriptionStatus(currentSubscription.status) ||
(!isTeam(currentSubscription.plan) && !isEnterprise(currentSubscription.plan))
) {
throw new WorkspaceCreationContextChangedError()
}
}
const currentOwnerId = await getOrganizationOwnerId(organizationId, tx)
if (!currentOwnerId) throw new WorkspaceCreationContextChangedError()
return { billedAccountUserId: currentOwnerId }
}
interface GetWorkspaceCreationPolicyParams {
userId: string
activeOrganizationId?: string | null
/**
* When true, `activeOrganizationId` is authoritative: it is used exactly as given
* (including `null`, which means a personal workspace) and never falls back to the
* caller's membership org. Forks set this so the child always lands in the SOURCE's
* org, not whatever org the acting user happens to belong to.
*/
pinOrganization?: boolean
}
export function isOrganizationWorkspace(
workspaceState: Pick<WorkspaceOwnershipState, 'workspaceMode' | 'organizationId'>
): boolean {
return (
workspaceState.workspaceMode === WORKSPACE_MODE.ORGANIZATION &&
workspaceState.organizationId !== null &&
workspaceState.organizationId.length > 0
)
}
/**
* Computes whether new members can be invited to the given workspace
* under the active product policy.
*
* Any paid billed account (Pro, Team, or Enterprise) may invite — the
* seat, and any Pro→Team upgrade, is provisioned when the invitee
* accepts, so invites are no longer seat-gated at this layer. Free
* accounts are blocked with an upgrade tooltip because there is no
* payment method to charge at acceptance.
*
* - `organization`: allowed; the org already holds a Team/Enterprise
* subscription. Only Enterprise keeps an invite-time `requiresSeat`
* gate (fixed seats).
* - `personal` / `grandfathered_shared`: allowed when the billed user is
* Pro/Team/Enterprise. For Pro, acceptance creates the org and moves the
* subscription to the Team tier.
*
* Billing-disabled deployments always allow invites. Existing members
* keep their access — this policy only governs *new* invitations.
*/
export async function getWorkspaceInvitePolicy(
workspaceState: WorkspaceOwnershipState
): Promise<WorkspaceInvitePolicy> {
const billedPlanCategory = isBillingEnabled
? await resolveBilledPlanCategory(workspaceState)
: 'free'
return evaluateWorkspaceInvitePolicy(workspaceState, { billedPlanCategory })
}
/**
* Pure evaluator — given the billed account's resolved plan category,
* returns the policy synchronously. Exposed so bulk callers (e.g. listing
* every workspace a user can see) can batch the subscription lookups by
* unique billed account user rather than re-querying per workspace.
*/
export function evaluateWorkspaceInvitePolicy(
workspaceState: WorkspaceOwnershipState,
context: { billedPlanCategory: PlanCategory }
): WorkspaceInvitePolicy {
if (!isBillingEnabled) {
return {
allowed: true,
reason: null,
requiresSeat: false,
organizationId: workspaceState.organizationId,
upgradeRequired: false,
}
}
if (workspaceState.workspaceMode === WORKSPACE_MODE.ORGANIZATION) {
if (workspaceState.organizationId === null || context.billedPlanCategory === 'free') {
return blockInvite(workspaceState.organizationId)
}
return {
allowed: true,
reason: null,
requiresSeat: context.billedPlanCategory === 'enterprise',
organizationId: workspaceState.organizationId,
upgradeRequired: false,
}
}
switch (context.billedPlanCategory) {
case 'pro':
case 'team':
return {
allowed: true,
reason: null,
requiresSeat: false,
organizationId: workspaceState.organizationId,
upgradeRequired: false,
}
case 'enterprise':
return {
allowed: true,
reason: null,
requiresSeat: true,
organizationId: workspaceState.organizationId,
upgradeRequired: false,
}
default:
return blockInvite(workspaceState.organizationId)
}
}
function blockInvite(organizationId: string | null): WorkspaceInvitePolicy {
return {
allowed: false,
reason: UPGRADE_TO_INVITE_REASON,
requiresSeat: false,
organizationId,
upgradeRequired: true,
}
}
async function resolveBilledPlanCategory(
workspaceState: WorkspaceOwnershipState
): Promise<PlanCategory> {
if (
workspaceState.workspaceMode === WORKSPACE_MODE.ORGANIZATION &&
workspaceState.organizationId
) {
return getInvitePlanCategoryForOrganization(workspaceState.organizationId)
}
return getInvitePlanCategoryForUser(workspaceState.billedAccountUserId)
}
/**
* Resolve the invite-governing plan category for an organization from its
* subscription. Exposed so bulk callers can batch by unique organization id.
* Returns `'free'` when there is no usable subscription so lapsed orgs are
* blocked consistently with accept-time provisioning.
*/
export async function getInvitePlanCategoryForOrganization(
organizationId: string
): Promise<PlanCategory> {
try {
const orgSub = await getOrganizationSubscription(organizationId)
if (!orgSub || !hasUsableSubscriptionStatus(orgSub.status)) return 'free'
return getPlanType(orgSub.plan)
} catch (error) {
logger.error('Failed to resolve organization subscription for invite policy', {
organizationId,
error,
})
return 'free'
}
}
/**
* Resolve the invite-governing plan category for a single billed account
* user. Exposed so bulk callers can batch by unique user id. Returns
* `'free'` when there is no usable paid subscription.
*/
export async function getInvitePlanCategoryForUser(
userId: string,
executor: DbOrTx = db
): Promise<PlanCategory> {
try {
const sub = await getHighestPrioritySubscription(userId, { executor })
if (!sub || !hasUsableSubscriptionStatus(sub.status)) return 'free'
return getPlanType(sub.plan)
} catch (error) {
logger.error('Failed to resolve subscription for invite policy', { userId, error })
return 'free'
}
}
export async function getWorkspaceCreationPolicy({
userId,
activeOrganizationId,
pinOrganization = false,
}: GetWorkspaceCreationPolicyParams): Promise<WorkspaceCreationPolicy> {
const membership = await getUserOrganization(userId)
const organizationId = pinOrganization
? (activeOrganizationId ?? null)
: (activeOrganizationId ?? membership?.organizationId ?? null)
const orgRole =
organizationId == null
? undefined
: membership?.organizationId === organizationId
? membership.role
: (
await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
.limit(1)
)[0]?.role
if (activeOrganizationId && !orgRole) {
const billedAccountUserId = await requireOrganizationOwnerId(activeOrganizationId)
return {
canCreate: false,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId: activeOrganizationId,
billedAccountUserId,
maxWorkspaces: null,
currentWorkspaceCount: 0,
reason: 'Only organization owners and admins can create organization workspaces.',
status: 403,
observedOrganizationId: membership?.organizationId ?? null,
}
}
if (!isBillingEnabled) {
if (organizationId && orgRole) {
const billedAccountUserId = await requireOrganizationOwnerId(organizationId)
/**
* Members may create organization workspaces once billing is off.
*
* The admin-only rule exists because an organization workspace draws on
* the organization's paid seats and usage. Without billing there is
* nothing to draw on, and the rule instead produces a dead end: a user
* auto-joined as a plain member — by instance-organization mode, or by
* SSO organization provisioning — resolves to an organization context and
* is then refused any workspace at all, including the personal one they
* would have received before joining. Members could always create
* personal workspaces here, so this changes where a new workspace lands,
* not whether they may make one.
*/
return {
canCreate: true,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId,
billedAccountUserId,
maxWorkspaces: null,
currentWorkspaceCount: 0,
reason: null,
status: 200,
observedOrganizationId: membership?.organizationId ?? null,
}
}
const currentWorkspaceCount = await countNonOrganizationOwnedWorkspaces(userId)
return {
canCreate: true,
workspaceMode: WORKSPACE_MODE.PERSONAL,
organizationId: null,
billedAccountUserId: userId,
maxWorkspaces: null,
currentWorkspaceCount,
reason: null,
status: 200,
observedOrganizationId: membership?.organizationId ?? null,
}
}
if (organizationId && orgRole) {
const organizationSubscription = await getOrganizationSubscription(organizationId)
if (
organizationSubscription &&
hasUsableSubscriptionStatus(organizationSubscription.status) &&
(isTeam(organizationSubscription.plan) || isEnterprise(organizationSubscription.plan))
) {
const billedAccountUserId = await requireOrganizationOwnerId(organizationId)
if (!isOrgAdminRole(orgRole)) {
return {
canCreate: false,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId,
billedAccountUserId,
maxWorkspaces: null,
currentWorkspaceCount: 0,
reason: 'Only organization owners and admins can create organization workspaces.',
status: 403,
observedOrganizationId: membership?.organizationId ?? null,
}
}
return {
canCreate: true,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId,
billedAccountUserId,
maxWorkspaces: null,
currentWorkspaceCount: 0,
reason: null,
status: 200,
observedOrganizationId: membership?.organizationId ?? null,
}
}
/**
* Lapsed organization (no usable Team/Enterprise plan). A plain member
* gets NO personal fallback: letting them create workspaces here would
* hand them an estate outside every admin's view purely because billing
* lapsed — exactly the purview escape this regime closes. Owners and
* admins DO fall through to the personal regime below: they sit at the top
* of the hierarchy, so there is no purview to escape, and after a
* downgrade they are usually back on a personal plan they still pay for.
*/
if (!isOrgAdminRole(orgRole)) {
return {
canCreate: false,
workspaceMode: WORKSPACE_MODE.ORGANIZATION,
organizationId,
billedAccountUserId: (await getOrganizationOwnerId(organizationId)) ?? userId,
maxWorkspaces: null,
currentWorkspaceCount: 0,
reason:
"Your organization's subscription is inactive. Ask an organization owner to reactivate it before creating workspaces.",
status: 403,
observedOrganizationId: membership?.organizationId ?? null,
blockedReasonCode: 'organization-subscription-inactive',
}
}
}
const highestPrioritySubscription = await getHighestPrioritySubscription(userId)
const plan = highestPrioritySubscription?.plan
/**
* Personal (non-organization) workspace cap. Organization workspaces are
* uncapped and returned above, so this is only reached when the org branch does
* not apply — including when a Team/Enterprise org's subscription is `past_due`
* and therefore not `hasUsableSubscriptionStatus`.
*
* Deliberately tier-only: `getHighestPrioritySubscription` already admits
* `past_due`, and delinquency is enforced by the billing-blocked gates rather
* than by shrinking the cap, which would only obstruct recovery.
*/
const maxWorkspaces = isMaxTier(plan) ? 10 : isPro(plan) ? 3 : 1
const currentWorkspaceCount = await countNonOrganizationOwnedWorkspaces(userId)
if (currentWorkspaceCount >= maxWorkspaces) {
return {
canCreate: false,
workspaceMode: WORKSPACE_MODE.PERSONAL,
organizationId: null,
billedAccountUserId: userId,
maxWorkspaces,
currentWorkspaceCount,
reason: `This plan supports up to ${maxWorkspaces} personal workspace${maxWorkspaces === 1 ? '' : 's'}.`,
status: 403,
observedOrganizationId: membership?.organizationId ?? null,
}
}
return {
canCreate: true,
workspaceMode: WORKSPACE_MODE.PERSONAL,
organizationId: null,
billedAccountUserId: userId,
maxWorkspaces,
currentWorkspaceCount,
reason: null,
status: 200,
observedOrganizationId: membership?.organizationId ?? null,
}
}
async function countNonOrganizationOwnedWorkspaces(userId: string): Promise<number> {
const [result] = await db
.select({ value: count() })
.from(workspace)
.where(and(eq(workspace.ownerId, userId), isNull(workspace.organizationId)))
return result?.value ?? 0
}
/**
* Returns the userId of the organization owner, or `null` if the
* organization has no owner row. Unexpected DB errors propagate to the
* caller so data-integrity issues surface loudly rather than being
* silently fallen back to the caller's identity.
*/
export async function getOrganizationOwnerId(
organizationId: string,
executor: DbOrTx = db
): Promise<string | null> {
const [ownerMembership] = await executor
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
.limit(1)
return ownerMembership?.userId ?? null
}
/**
* Like `getOrganizationOwnerId` but throws when no owner row exists.
* Use when the caller needs a guaranteed billed-account userId — every
* Better Auth organization is expected to have exactly one owner, so a
* missing owner is a data-integrity issue that should surface loudly.
*/
async function requireOrganizationOwnerId(organizationId: string): Promise<string> {
const ownerId = await getOrganizationOwnerId(organizationId)
if (!ownerId) {
logger.error('Organization is missing its owner membership row', { organizationId })
throw new Error(`Organization ${organizationId} has no owner membership`)
}
return ownerId
}