From 755eb409683d798a118592eeb90a35587e164212 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 23 Jul 2026 19:55:43 +0200 Subject: [PATCH 1/3] feat(webapp): org-gated internal API origin for deployed runs Deployed runs get TRIGGER_API_URL from INTERNAL_API_ORIGIN when the org's internalApiOriginEnabled feature flag resolves on (org override wins in both directions, global default otherwise). No-op unless INTERNAL_API_ORIGIN is set. Resolved at attempt start, so flag changes apply on the next attempt without redeploys. --- .server-changes/internal-api-origin-flag.md | 6 +++ apps/webapp/app/env.server.ts | 3 ++ .../environmentVariablesRepository.server.ts | 47 ++++++++++++++++++- apps/webapp/app/v3/featureFlags.ts | 6 +++ 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 .server-changes/internal-api-origin-flag.md diff --git a/.server-changes/internal-api-origin-flag.md b/.server-changes/internal-api-origin-flag.md new file mode 100644 index 00000000000..dc398d2e3e6 --- /dev/null +++ b/.server-changes/internal-api-origin-flag.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Operators can now route deployed runs' API traffic through an alternative origin on a per-organization basis, enabling gradual network migrations without redeploying tasks. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 53b5edf0a46..313297e5179 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -233,6 +233,9 @@ const EnvironmentSchema = z LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true), APP_ORIGIN: z.string().default("http://localhost:3030"), API_ORIGIN: z.string().optional(), + // Alternative API origin for deployed runs whose org has the + // internalApiOriginEnabled feature flag on. Unset = flag is a no-op. + INTERNAL_API_ORIGIN: z.string().optional(), STREAM_ORIGIN: z.string().optional(), ELECTRIC_ORIGIN: z.string().default("http://localhost:3060"), // A comma separated list of electric origins to shard into different electric instances by environmentId diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 1a0b8247347..f019ce28ef0 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -7,6 +7,8 @@ import { env } from "~/env.server"; import { getSecretStore } from "~/services/secrets/secretStore.server"; import { deduplicateVariableArray } from "../deduplicateVariableArray.server"; import { removeBlacklistedVariables } from "../environmentVariableRules.server"; +import { FEATURE_FLAG, FeatureFlagCatalog } from "../featureFlags"; +import { flag } from "../featureFlags.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { type CreateEnvironmentVariables, @@ -1146,10 +1148,53 @@ async function resolveOverridableOtelDevVariables( return result; } +// Deployed runs normally get the public API origin. When INTERNAL_API_ORIGIN is +// set and the org's internalApiOriginEnabled flag resolves on (org override wins +// in both directions; the global default applies only when the org has not set +// it), they get the internal origin instead. Resolved at attempt start, so flag +// changes take effect on the next attempt without redeploys. +async function resolveProdApiOrigin( + runtimeEnvironment: RuntimeEnvironmentForEnvRepo +): Promise { + const publicOrigin = env.API_ORIGIN ?? env.APP_ORIGIN; + + if (!env.INTERNAL_API_ORIGIN) { + return publicOrigin; + } + + const organization = await prisma.organization.findFirst({ + where: { id: runtimeEnvironment.organizationId }, + select: { featureFlags: true }, + }); + + const orgFeatureFlags = organization?.featureFlags; + const override = + orgFeatureFlags && typeof orgFeatureFlags === "object" && !Array.isArray(orgFeatureFlags) + ? (orgFeatureFlags as Record)[FEATURE_FLAG.internalApiOriginEnabled] + : undefined; + + if (override !== undefined) { + const parsed = FeatureFlagCatalog[FEATURE_FLAG.internalApiOriginEnabled].safeParse(override); + + if (parsed.success) { + return parsed.data ? env.INTERNAL_API_ORIGIN : publicOrigin; + } + } + + const globalEnabled = await flag({ + key: FEATURE_FLAG.internalApiOriginEnabled, + defaultValue: false, + }); + + return globalEnabled ? env.INTERNAL_API_ORIGIN : publicOrigin; +} + async function resolveBuiltInProdVariables( runtimeEnvironment: RuntimeEnvironmentForEnvRepo, parentEnvironment?: RuntimeEnvironmentForEnvRepo ) { + const apiOrigin = await resolveProdApiOrigin(runtimeEnvironment); + let result: Array = [ { key: "TRIGGER_SECRET_KEY", @@ -1157,7 +1202,7 @@ async function resolveBuiltInProdVariables( }, { key: "TRIGGER_API_URL", - value: env.API_ORIGIN ?? env.APP_ORIGIN, + value: apiOrigin, }, { key: "TRIGGER_STREAM_URL", diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index 49a18eaf35f..1a1f0be7f33 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -12,6 +12,7 @@ export const FEATURE_FLAG = { hasSso: "hasSso", mollifierEnabled: "mollifierEnabled", workerQueueScheduledSplitEnabled: "workerQueueScheduledSplitEnabled", + internalApiOriginEnabled: "internalApiOriginEnabled", realtimeBackend: "realtimeBackend", computeMigrationEnabled: "computeMigrationEnabled", computeMigrationFreePercentage: "computeMigrationFreePercentage", @@ -38,6 +39,11 @@ export const FeatureFlagCatalog = { [FEATURE_FLAG.hasSso]: z.coerce.boolean(), [FEATURE_FLAG.mollifierEnabled]: z.coerce.boolean(), [FEATURE_FLAG.workerQueueScheduledSplitEnabled]: z.coerce.boolean(), + // Routes deployed runs' TRIGGER_API_URL to INTERNAL_API_ORIGIN. Controllable + // globally and per-org (org wins). No-op unless INTERNAL_API_ORIGIN is set. + // Strict z.boolean(): coercion turns the string "false" into true, which + // would silently enable the wrong orgs if written as a string. + [FEATURE_FLAG.internalApiOriginEnabled]: z.boolean(), // Which backend serves the realtime run feed. Controllable // globally and per-org (org wins). Defaults to "electric" when unset. // "shadow" serves Electric but diffs the native path in the background. From ecc10520dbc68a13ab8dd5defb7f179f663f0d6c Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 23 Jul 2026 19:58:12 +0200 Subject: [PATCH 2/3] refactor(webapp): extract pure org-flag resolution for the internal API origin + unit tests --- .../environmentVariablesRepository.server.ts | 27 +++----- apps/webapp/app/v3/featureFlags.ts | 29 +++++++++ apps/webapp/test/internalApiOrigin.test.ts | 65 +++++++++++++++++++ 3 files changed, 102 insertions(+), 19 deletions(-) create mode 100644 apps/webapp/test/internalApiOrigin.test.ts diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index f019ce28ef0..f321f740293 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -7,7 +7,7 @@ import { env } from "~/env.server"; import { getSecretStore } from "~/services/secrets/secretStore.server"; import { deduplicateVariableArray } from "../deduplicateVariableArray.server"; import { removeBlacklistedVariables } from "../environmentVariableRules.server"; -import { FEATURE_FLAG, FeatureFlagCatalog } from "../featureFlags"; +import { FEATURE_FLAG, resolveInternalApiOriginEnabled } from "../featureFlags"; import { flag } from "../featureFlags.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { @@ -1167,26 +1167,15 @@ async function resolveProdApiOrigin( select: { featureFlags: true }, }); - const orgFeatureFlags = organization?.featureFlags; - const override = - orgFeatureFlags && typeof orgFeatureFlags === "object" && !Array.isArray(orgFeatureFlags) - ? (orgFeatureFlags as Record)[FEATURE_FLAG.internalApiOriginEnabled] - : undefined; - - if (override !== undefined) { - const parsed = FeatureFlagCatalog[FEATURE_FLAG.internalApiOriginEnabled].safeParse(override); - - if (parsed.success) { - return parsed.data ? env.INTERNAL_API_ORIGIN : publicOrigin; - } - } - - const globalEnabled = await flag({ - key: FEATURE_FLAG.internalApiOriginEnabled, - defaultValue: false, + const enabled = resolveInternalApiOriginEnabled({ + orgFeatureFlags: organization?.featureFlags, + globalDefault: await flag({ + key: FEATURE_FLAG.internalApiOriginEnabled, + defaultValue: false, + }), }); - return globalEnabled ? env.INTERNAL_API_ORIGIN : publicOrigin; + return enabled ? env.INTERNAL_API_ORIGIN : publicOrigin; } async function resolveBuiltInProdVariables( diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index 1a1f0be7f33..e2de27d5d3b 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -110,6 +110,35 @@ export function validatePartialFeatureFlags(values: Record) { } // Utility types for catalog-driven UI rendering +/** + * Resolve whether deployed runs should use the internal API origin, from the + * org's feature-flags JSON. Precedence: a per-org override wins in BOTH + * directions; the global default applies only when the org has not set the + * flag (or set it to something invalid). + */ +export function resolveInternalApiOriginEnabled({ + orgFeatureFlags, + globalDefault, +}: { + orgFeatureFlags: unknown; + globalDefault: boolean; +}): boolean { + const override = + orgFeatureFlags && typeof orgFeatureFlags === "object" && !Array.isArray(orgFeatureFlags) + ? (orgFeatureFlags as Record)[FEATURE_FLAG.internalApiOriginEnabled] + : undefined; + + if (override !== undefined) { + const parsed = FeatureFlagCatalog[FEATURE_FLAG.internalApiOriginEnabled].safeParse(override); + + if (parsed.success) { + return parsed.data; + } + } + + return globalDefault; +} + export type FlagControlType = | { type: "boolean" } | { type: "enum"; options: string[] } diff --git a/apps/webapp/test/internalApiOrigin.test.ts b/apps/webapp/test/internalApiOrigin.test.ts new file mode 100644 index 00000000000..705fb927827 --- /dev/null +++ b/apps/webapp/test/internalApiOrigin.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { resolveInternalApiOriginEnabled } from "~/v3/featureFlags"; + +describe("resolveInternalApiOriginEnabled", () => { + it("returns the global default when the org has no flags", () => { + expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: null, globalDefault: false })).toBe( + false + ); + expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: null, globalDefault: true })).toBe( + true + ); + expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: {}, globalDefault: true })).toBe( + true + ); + }); + + it("lets an org override win in both directions", () => { + expect( + resolveInternalApiOriginEnabled({ + orgFeatureFlags: { internalApiOriginEnabled: true }, + globalDefault: false, + }) + ).toBe(true); + + expect( + resolveInternalApiOriginEnabled({ + orgFeatureFlags: { internalApiOriginEnabled: false }, + globalDefault: true, + }) + ).toBe(false); + }); + + it("ignores invalid overrides and falls back to the global default", () => { + // Strict z.boolean(): the string "false" must not coerce to an enable. + expect( + resolveInternalApiOriginEnabled({ + orgFeatureFlags: { internalApiOriginEnabled: "false" }, + globalDefault: false, + }) + ).toBe(false); + + expect( + resolveInternalApiOriginEnabled({ + orgFeatureFlags: { internalApiOriginEnabled: "true" }, + globalDefault: false, + }) + ).toBe(false); + + expect( + resolveInternalApiOriginEnabled({ + orgFeatureFlags: { internalApiOriginEnabled: 1 }, + globalDefault: false, + }) + ).toBe(false); + }); + + it("ignores non-object flag containers", () => { + expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: [], globalDefault: true })).toBe( + true + ); + expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: "junk", globalDefault: false })).toBe( + false + ); + }); +}); From beaf9b72574f5ff6b909f76862ae25b80f7ef860 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 23 Jul 2026 20:05:24 +0200 Subject: [PATCH 3/3] perf(webapp): resolve the internal-api-origin global default from the cached flags registry Review follow-ups: the global default now comes from the in-memory globalFlagsRegistry snapshot (cold read fails safe to the public origin) instead of a per-attempt featureFlag query, and the deliberate non-switching of TRIGGER_STREAM_URL is documented. --- .../environmentVariablesRepository.server.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index f321f740293..5491ff43cbc 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -8,7 +8,7 @@ import { getSecretStore } from "~/services/secrets/secretStore.server"; import { deduplicateVariableArray } from "../deduplicateVariableArray.server"; import { removeBlacklistedVariables } from "../environmentVariableRules.server"; import { FEATURE_FLAG, resolveInternalApiOriginEnabled } from "../featureFlags"; -import { flag } from "../featureFlags.server"; +import { globalFlagsRegistry } from "../globalFlagsRegistry.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { type CreateEnvironmentVariables, @@ -1169,10 +1169,8 @@ async function resolveProdApiOrigin( const enabled = resolveInternalApiOriginEnabled({ orgFeatureFlags: organization?.featureFlags, - globalDefault: await flag({ - key: FEATURE_FLAG.internalApiOriginEnabled, - defaultValue: false, - }), + // Cached global snapshot; a cold read fails safe to the public origin. + globalDefault: globalFlagsRegistry.current()?.[FEATURE_FLAG.internalApiOriginEnabled] ?? false, }); return enabled ? env.INTERNAL_API_ORIGIN : publicOrigin; @@ -1194,6 +1192,8 @@ async function resolveBuiltInProdVariables( value: apiOrigin, }, { + // Deliberately not switched by internalApiOriginEnabled: streams are + // long-lived connections served on their own path. key: "TRIGGER_STREAM_URL", value: env.STREAM_ORIGIN ?? env.API_ORIGIN ?? env.APP_ORIGIN, },