chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user