diff --git a/.server-changes/internal-api-origin-flag.md b/.server-changes/internal-api-origin-flag.md new file mode 100644 index 0000000000..dc398d2e3e --- /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 53b5edf0a4..313297e517 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 1a0b824734..5491ff43cb 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, resolveInternalApiOriginEnabled } from "../featureFlags"; +import { globalFlagsRegistry } from "../globalFlagsRegistry.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { type CreateEnvironmentVariables, @@ -1146,10 +1148,40 @@ 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 enabled = resolveInternalApiOriginEnabled({ + orgFeatureFlags: organization?.featureFlags, + // 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; +} + async function resolveBuiltInProdVariables( runtimeEnvironment: RuntimeEnvironmentForEnvRepo, parentEnvironment?: RuntimeEnvironmentForEnvRepo ) { + const apiOrigin = await resolveProdApiOrigin(runtimeEnvironment); + let result: Array = [ { key: "TRIGGER_SECRET_KEY", @@ -1157,9 +1189,11 @@ async function resolveBuiltInProdVariables( }, { key: "TRIGGER_API_URL", - value: env.API_ORIGIN ?? env.APP_ORIGIN, + 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, }, diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index 49a18eaf35..e2de27d5d3 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. @@ -104,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 0000000000..705fb92782 --- /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 + ); + }); +});