Files
wehub-resource-sync d25d482dc2
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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 }
}