From 0f36c2e7bdcd013d69a425def8271c43b1e08514 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 22:21:16 -0700 Subject: [PATCH 1/2] feat(workspaces): gate workspace forking behind runtime workspace-forking feature flag --- .../sim/lib/core/config/feature-flags.test.ts | 21 ++++++++++++++++ apps/sim/lib/core/config/feature-flags.ts | 9 +++++++ apps/sim/lib/workspaces/fork/lineage/authz.ts | 24 ++++++++++++------- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index 5c366af6220..e7db9a30026 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -10,6 +10,7 @@ const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({ envRef: { APPCONFIG_APPLICATION: 'sim-staging' as string | undefined, APPCONFIG_ENVIRONMENT: 'staging' as string | undefined, + FORKING_ENABLED: undefined as boolean | undefined, }, flagRef: { isAppConfigEnabled: false }, })) @@ -106,6 +107,26 @@ describe('isFeatureEnabled', () => { beforeEach(() => { vi.clearAllMocks() flagRef.isAppConfigEnabled = false + envRef.FORKING_ENABLED = undefined + }) + + describe('workspace-forking flag', () => { + it('falls back to FORKING_ENABLED when AppConfig is disabled', async () => { + envRef.FORKING_ENABLED = undefined + expect(await isFeatureEnabled('workspace-forking', { userId: 'u1', orgId: 'o1' })).toBe(false) + + envRef.FORKING_ENABLED = true + expect(await isFeatureEnabled('workspace-forking', { userId: 'u1', orgId: 'o1' })).toBe(true) + }) + + it('targets specific orgs/users via AppConfig, ignoring the fallback secret', async () => { + envRef.FORKING_ENABLED = undefined + withAppConfig({ 'workspace-forking': { orgIds: ['o1'], userIds: ['u9'] } }) + + expect(await isFeatureEnabled('workspace-forking', { orgId: 'o1' })).toBe(true) + expect(await isFeatureEnabled('workspace-forking', { userId: 'u9' })).toBe(true) + expect(await isFeatureEnabled('workspace-forking', { orgId: 'o2', userId: 'u1' })).toBe(false) + }) }) it('returns false for an unknown flag', async () => { diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 9d0847a7152..50bed51b0b0 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -105,6 +105,15 @@ const FEATURE_FLAGS = { 'logging session (no user/org context) so an execution never mixes write paths.', fallback: 'REDIS_PROGRESS_MARKERS', }, + 'workspace-forking': { + description: + 'Runtime rollout gate for workspace forking (fork/promote/rollback). Resolved at the ' + + 'shared assertForkingEnabled choke point with org/user context, so operators can dark-' + + 'launch forking to specific orgs/users/admins via AppConfig before broad availability. ' + + 'Falls back to FORKING_ENABLED off-AppConfig, leaving self-hosted behaviour unchanged. ' + + 'On Sim Cloud the Enterprise-plan entitlement still applies on top of this gate.', + fallback: 'FORKING_ENABLED', + }, } satisfies Record /** diff --git a/apps/sim/lib/workspaces/fork/lineage/authz.ts b/apps/sim/lib/workspaces/fork/lineage/authz.ts index a7d4d84dcf7..8170d6b340d 100644 --- a/apps/sim/lib/workspaces/fork/lineage/authz.ts +++ b/apps/sim/lib/workspaces/fork/lineage/authz.ts @@ -1,5 +1,6 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' -import { isBillingEnabled, isForkingEnabled } from '@/lib/core/config/env-flags' +import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { HttpError } from '@/lib/core/utils/http-error' import { type ForkEdge, resolveForkEdge } from '@/lib/workspaces/fork/lineage/lineage' import { checkWorkspaceAccess, type WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' @@ -9,13 +10,20 @@ import { getWorkspaceCreationPolicy, type WorkspaceCreationPolicy } from '@/lib/ export type PromoteDirection = 'push' | 'pull' /** - * Enterprise-only gate shared by every fork/promote route. 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. + * Gate shared by every fork/promote route. A runtime `workspace-forking` flag is the + * master rollout switch: on Sim Cloud it's resolved from AppConfig with org/user + * context (dark-launchable to specific orgs/users/admins), and off-AppConfig it falls + * back to `FORKING_ENABLED`, leaving self-hosted behaviour unchanged. An off/absent + * flag 404s so a newer image never silently exposes forking. On Sim Cloud the + * Enterprise-plan entitlement still applies on top of the flag. Mirrors the + * data-drains gate - this repo gates EE features by plan + flag, not by directory. */ -async function assertForkingEnabled(organizationId: string | null): Promise { - if (!isBillingEnabled && !isForkingEnabled) { +async function assertForkingEnabled(organizationId: string | null, userId: string): Promise { + const flagEnabled = await isFeatureEnabled('workspace-forking', { + userId, + orgId: organizationId, + }) + if (!flagEnabled) { throw new ForkError('Workspace forking is not enabled on this deployment', 404) } if (isBillingEnabled) { @@ -51,7 +59,7 @@ async function requireWorkspace( if (!access.exists || !access.workspace) { throw new ForkError('Workspace not found', 404) } - await assertForkingEnabled(access.workspace.organizationId) + await assertForkingEnabled(access.workspace.organizationId, userId) return { workspace: access.workspace, canAdmin: access.canAdmin } } From 83231622d8515e0fef0dff924f2043fe3b8b925c Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 22:27:48 -0700 Subject: [PATCH 2/2] fix(workspaces): scope workspace-forking flag to AppConfig deployments to preserve self-hosted gate --- apps/sim/lib/core/config/feature-flags.ts | 10 +++--- apps/sim/lib/workspaces/fork/lineage/authz.ts | 31 +++++++++++-------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 50bed51b0b0..6f3a3b4847b 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -107,11 +107,11 @@ const FEATURE_FLAGS = { }, 'workspace-forking': { description: - 'Runtime rollout gate for workspace forking (fork/promote/rollback). Resolved at the ' + - 'shared assertForkingEnabled choke point with org/user context, so operators can dark-' + - 'launch forking to specific orgs/users/admins via AppConfig before broad availability. ' + - 'Falls back to FORKING_ENABLED off-AppConfig, leaving self-hosted behaviour unchanged. ' + - 'On Sim Cloud the Enterprise-plan entitlement still applies on top of this gate.', + 'Runtime rollout gate for workspace forking (fork/promote/rollback), layered on top of ' + + 'the existing FORKING_ENABLED / Enterprise-plan gate at the shared assertForkingEnabled ' + + 'choke point. Enforced ONLY where AppConfig is the source of truth (Sim Cloud), so ' + + 'operators can dark-launch forking to specific orgs/users/admins without touching ' + + 'self-hosted/local behaviour. Fallback mirrors FORKING_ENABLED for off-AppConfig reads.', fallback: 'FORKING_ENABLED', }, } satisfies Record diff --git a/apps/sim/lib/workspaces/fork/lineage/authz.ts b/apps/sim/lib/workspaces/fork/lineage/authz.ts index 8170d6b340d..ecbb2c6d8db 100644 --- a/apps/sim/lib/workspaces/fork/lineage/authz.ts +++ b/apps/sim/lib/workspaces/fork/lineage/authz.ts @@ -1,5 +1,5 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +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 { type ForkEdge, resolveForkEdge } from '@/lib/workspaces/fork/lineage/lineage' @@ -10,20 +10,19 @@ import { getWorkspaceCreationPolicy, type WorkspaceCreationPolicy } from '@/lib/ export type PromoteDirection = 'push' | 'pull' /** - * Gate shared by every fork/promote route. A runtime `workspace-forking` flag is the - * master rollout switch: on Sim Cloud it's resolved from AppConfig with org/user - * context (dark-launchable to specific orgs/users/admins), and off-AppConfig it falls - * back to `FORKING_ENABLED`, leaving self-hosted behaviour unchanged. An off/absent - * flag 404s so a newer image never silently exposes forking. On Sim Cloud the - * Enterprise-plan entitlement still applies on top of the flag. Mirrors the - * data-drains gate - this repo gates EE features by plan + flag, not by directory. + * 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 { - const flagEnabled = await isFeatureEnabled('workspace-forking', { - userId, - orgId: organizationId, - }) - if (!flagEnabled) { + if (!isBillingEnabled && !isForkingEnabled) { throw new ForkError('Workspace forking is not enabled on this deployment', 404) } if (isBillingEnabled) { @@ -34,6 +33,12 @@ async function assertForkingEnabled(organizationId: string | null, userId: strin 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) + } } /**