d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (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 / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
200 lines
7.5 KiB
TypeScript
200 lines
7.5 KiB
TypeScript
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
|
|
import { isAppConfigEnabled, isBillingEnabled, isForkingEnabled } from '@/lib/core/config/env-flags'
|
|
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
|
|
import { HttpError } from '@/lib/core/utils/http-error'
|
|
import { checkWorkspaceAccess, type WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
|
|
import { getWorkspaceCreationPolicy, type WorkspaceCreationPolicy } from '@/lib/workspaces/policy'
|
|
import { type ForkEdge, resolveForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
|
|
|
|
/** Direction of a promote, relative to the workspace the caller is acting from. */
|
|
export type PromoteDirection = 'push' | 'pull'
|
|
|
|
/**
|
|
* Gate shared by every fork/promote route. The deployment/entitlement gate is
|
|
* unchanged: on Sim Cloud the gate is the Enterprise plan; on self-hosted it's
|
|
* `FORKING_ENABLED`, which 404s when unset so a newer image doesn't silently expose
|
|
* forking. Mirrors the data-drains gate - this repo gates EE features by plan + env
|
|
* flag, not by directory.
|
|
*
|
|
* Layered on top is the runtime `workspace-forking` flag, a rollout switch enforced
|
|
* ONLY where AppConfig is the source of truth (Sim Cloud). It lets us dark-launch
|
|
* forking to specific orgs/users/admins without a redeploy; self-hosted/local
|
|
* deployments have no AppConfig, so their behaviour is untouched by the flag.
|
|
*/
|
|
async function assertForkingEnabled(organizationId: string | null, userId: string): Promise<void> {
|
|
if (!isBillingEnabled && !isForkingEnabled) {
|
|
throw new ForkError('Workspace forking is not enabled on this deployment', 404)
|
|
}
|
|
if (isBillingEnabled) {
|
|
const hasEnterprise = organizationId
|
|
? await isOrganizationOnEnterprisePlan(organizationId)
|
|
: false
|
|
if (!hasEnterprise) {
|
|
throw new ForkError('Workspace forking is available on Enterprise plans only', 403)
|
|
}
|
|
}
|
|
if (
|
|
isAppConfigEnabled &&
|
|
!(await isFeatureEnabled('workspace-forking', { userId, orgId: organizationId }))
|
|
) {
|
|
throw new ForkError('Workspace forking is not enabled on this deployment', 404)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Non-throwing availability verdict of the exact {@link assertForkingEnabled} gate
|
|
* (env/plan + AppConfig rollout flag), for surfaces that show/hide fork UI. The
|
|
* availability route serves this to the client so tab visibility can never drift
|
|
* from what the fork routes actually enforce.
|
|
*/
|
|
export async function isForkingAvailableForWorkspace(
|
|
organizationId: string | null,
|
|
userId: string
|
|
): Promise<boolean> {
|
|
try {
|
|
await assertForkingEnabled(organizationId, userId)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Domain error for fork/promote operations. Carries a concrete `statusCode` so
|
|
* `withRouteHandler` maps it to the right HTTP status and forwards the
|
|
* client-safe `message`.
|
|
*/
|
|
export class ForkError extends HttpError {
|
|
readonly statusCode: number
|
|
|
|
constructor(message: string, statusCode = 400) {
|
|
super(message)
|
|
this.name = 'ForkError'
|
|
this.statusCode = statusCode
|
|
}
|
|
}
|
|
|
|
async function requireWorkspace(
|
|
workspaceId: string,
|
|
userId: string
|
|
): Promise<{ workspace: WorkspaceWithOwner; canAdmin: boolean }> {
|
|
const access = await checkWorkspaceAccess(workspaceId, userId)
|
|
if (!access.exists || !access.workspace) {
|
|
throw new ForkError('Workspace not found', 404)
|
|
}
|
|
await assertForkingEnabled(access.workspace.organizationId, userId)
|
|
return { workspace: access.workspace, canAdmin: access.canAdmin }
|
|
}
|
|
|
|
/** Require admin access; returns the (active) workspace. */
|
|
export async function assertWorkspaceAdminAccess(
|
|
workspaceId: string,
|
|
userId: string
|
|
): Promise<WorkspaceWithOwner> {
|
|
const { workspace, canAdmin } = await requireWorkspace(workspaceId, userId)
|
|
if (!canAdmin) {
|
|
throw new ForkError('Admin access is required for this workspace', 403)
|
|
}
|
|
return workspace
|
|
}
|
|
|
|
export interface ForkAuthorization {
|
|
source: WorkspaceWithOwner
|
|
policy: WorkspaceCreationPolicy
|
|
}
|
|
|
|
/**
|
|
* Authorize forking `sourceWorkspaceId`: the caller needs admin access to the
|
|
* source (a fork copies its deployed workflows and resources en masse) and must
|
|
* pass the workspace-creation policy for the parent's org (the child inherits the
|
|
* parent's org/mode; plan caps apply). Org owners/admins derive workspace admin.
|
|
*
|
|
* `pinOrganization` makes the child ALWAYS land in the source's org - including a
|
|
* personal source (org `null`) - rather than the acting user's membership org,
|
|
* which the policy would otherwise fall back to when the source is personal.
|
|
*/
|
|
export async function assertCanFork(
|
|
sourceWorkspaceId: string,
|
|
userId: string
|
|
): Promise<ForkAuthorization> {
|
|
const source = await assertWorkspaceAdminAccess(sourceWorkspaceId, userId)
|
|
const policy = await getWorkspaceCreationPolicy({
|
|
userId,
|
|
activeOrganizationId: source.organizationId,
|
|
pinOrganization: true,
|
|
})
|
|
if (!policy.canCreate) {
|
|
throw new ForkError(
|
|
policy.reason ?? 'You cannot create another workspace on your current plan',
|
|
policy.status >= 400 ? policy.status : 403
|
|
)
|
|
}
|
|
return { source, policy }
|
|
}
|
|
|
|
export interface PromoteAuthorization {
|
|
edge: ForkEdge
|
|
source: WorkspaceWithOwner
|
|
target: WorkspaceWithOwner
|
|
sourceWorkspaceId: string
|
|
targetWorkspaceId: string
|
|
}
|
|
|
|
/**
|
|
* Authorize a promote along the strict edge between `currentWorkspaceId` and
|
|
* `otherWorkspaceId`. Requires admin on BOTH the source and the target: a sync
|
|
* reads the source's deployed workflows/resources and force-replaces the target's,
|
|
* and the sync surface is only ever offered to workspace admins. `push` sends
|
|
* current -> other; `pull` brings other -> current.
|
|
*/
|
|
export async function assertCanPromote(
|
|
currentWorkspaceId: string,
|
|
otherWorkspaceId: string,
|
|
direction: PromoteDirection,
|
|
userId: string
|
|
): Promise<PromoteAuthorization> {
|
|
const edge = await resolveForkEdge(currentWorkspaceId, otherWorkspaceId)
|
|
if (!edge) {
|
|
throw new ForkError('These workspaces are not a direct fork edge', 400)
|
|
}
|
|
const sourceWorkspaceId = direction === 'push' ? currentWorkspaceId : otherWorkspaceId
|
|
const targetWorkspaceId = direction === 'push' ? otherWorkspaceId : currentWorkspaceId
|
|
const source = await assertWorkspaceAdminAccess(sourceWorkspaceId, userId)
|
|
const target = await assertWorkspaceAdminAccess(targetWorkspaceId, userId)
|
|
return { edge, source, target, sourceWorkspaceId, targetWorkspaceId }
|
|
}
|
|
|
|
/** Authorize rolling back the last promote into `targetWorkspaceId` (admin only). */
|
|
export async function assertCanRollback(
|
|
targetWorkspaceId: string,
|
|
userId: string
|
|
): Promise<WorkspaceWithOwner> {
|
|
return assertWorkspaceAdminAccess(targetWorkspaceId, userId)
|
|
}
|
|
|
|
export interface UnlinkAuthorization {
|
|
edge: ForkEdge
|
|
current: WorkspaceWithOwner
|
|
}
|
|
|
|
/**
|
|
* Authorize permanently dissolving the fork edge between `currentWorkspaceId` and
|
|
* `otherWorkspaceId`. Requires admin on the ACTING side only (mirrors rollback):
|
|
* either participant may sever the association about itself — unlinking removes
|
|
* shared edge metadata without reading or writing the other workspace's content,
|
|
* and requiring both-side admin would strand an edge whose other side lost its
|
|
* admins.
|
|
*/
|
|
export async function assertCanUnlink(
|
|
currentWorkspaceId: string,
|
|
otherWorkspaceId: string,
|
|
userId: string
|
|
): Promise<UnlinkAuthorization> {
|
|
const current = await assertWorkspaceAdminAccess(currentWorkspaceId, userId)
|
|
const edge = await resolveForkEdge(currentWorkspaceId, otherWorkspaceId)
|
|
if (!edge) {
|
|
throw new ForkError('These workspaces are not a direct fork edge', 400)
|
|
}
|
|
return { edge, current }
|
|
}
|