feat(webapp): org-gated internal API origin in run env vars (#4366)
Adds an opt-in way for operators to route deployed runs' API traffic through a different origin than the public one, per organization. Set `INTERNAL_API_ORIGIN` on the webapp and enable the `internalApiOriginEnabled` feature flag (globally or per org, with the org override winning in both directions): deployed runs for enabled orgs then get `TRIGGER_API_URL` set to the internal origin instead of `API_ORIGIN`. Useful for gradually moving run traffic onto a private network path. ## Design The origin is resolved when an attempt starts, so flag changes take effect on the next attempt and roll back the same way, with no task redeploys. The org override is read fresh per attempt; the global default comes from the cached flags registry (a cold read fails safe to the public origin). When `INTERNAL_API_ORIGIN` is unset the flag is a no-op and no extra queries run, so existing deployments are unaffected. Dev runs always use the public origin, and `TRIGGER_STREAM_URL` remains unchanged.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: improvement
|
||||
---
|
||||
|
||||
Self-hosted instances can now serve deployed runs' API traffic from a different origin than the public one, per organization, via the `INTERNAL_API_ORIGIN` environment variable and a feature flag.
|
||||
@@ -233,6 +233,11 @@ 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(),
|
||||
// Global default for internalApiOriginEnabled when an org hasn't set it.
|
||||
INTERNAL_API_ORIGIN_ENABLED: z.string().default("0"),
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { resolveVariablesForEnvironment } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
|
||||
@@ -44,6 +45,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
},
|
||||
include: {
|
||||
parentEnvironment: true,
|
||||
// Feeds resolveProdApiOrigin; only loaded when internal-origin routing is possible.
|
||||
...(env.INTERNAL_API_ORIGIN ? { organization: { select: { featureFlags: true } } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -934,7 +936,7 @@ export type RuntimeEnvironmentForEnvRepo = Pick<
|
||||
| "organizationId"
|
||||
| "branchName"
|
||||
| "builtInEnvironmentVariableOverrides"
|
||||
>;
|
||||
> & { organization?: { featureFlags: unknown } | null };
|
||||
|
||||
export const environmentVariablesRepository = new EnvironmentVariablesRepository();
|
||||
|
||||
@@ -1146,10 +1148,35 @@ 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; INTERNAL_API_ORIGIN_ENABLED is the global default applied
|
||||
// only when the org has not set it), they get the internal origin instead. The
|
||||
// global default is the cached DB flag with INTERNAL_API_ORIGIN_ENABLED as the
|
||||
// fallback; org flags are read in-memory, so a flip applies on the next attempt.
|
||||
function resolveProdApiOrigin(runtimeEnvironment: RuntimeEnvironmentForEnvRepo): string {
|
||||
const publicOrigin = env.API_ORIGIN ?? env.APP_ORIGIN;
|
||||
|
||||
if (!env.INTERNAL_API_ORIGIN) {
|
||||
return publicOrigin;
|
||||
}
|
||||
|
||||
const enabled = resolveInternalApiOriginEnabled({
|
||||
orgFeatureFlags: runtimeEnvironment.organization?.featureFlags,
|
||||
globalDefault:
|
||||
globalFlagsRegistry.current()?.[FEATURE_FLAG.internalApiOriginEnabled] ??
|
||||
env.INTERNAL_API_ORIGIN_ENABLED === "1",
|
||||
});
|
||||
|
||||
return enabled ? env.INTERNAL_API_ORIGIN : publicOrigin;
|
||||
}
|
||||
|
||||
async function resolveBuiltInProdVariables(
|
||||
runtimeEnvironment: RuntimeEnvironmentForEnvRepo,
|
||||
parentEnvironment?: RuntimeEnvironmentForEnvRepo
|
||||
) {
|
||||
const apiOrigin = resolveProdApiOrigin(runtimeEnvironment);
|
||||
|
||||
let result: Array<EnvironmentVariable> = [
|
||||
{
|
||||
key: "TRIGGER_SECRET_KEY",
|
||||
@@ -1157,9 +1184,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,
|
||||
},
|
||||
|
||||
@@ -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,12 @@ 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. Per-org, with
|
||||
// INTERNAL_API_ORIGIN_ENABLED as the global default (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 +111,35 @@ export function validatePartialFeatureFlags(values: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
// 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<string, unknown>)[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[] }
|
||||
|
||||
@@ -544,6 +544,8 @@ export class AuthenticatedWorkerInstance extends WithRunEngine {
|
||||
},
|
||||
include: {
|
||||
parentEnvironment: true,
|
||||
// Feeds resolveProdApiOrigin; only loaded when internal-origin routing is possible.
|
||||
...(env.INTERNAL_API_ORIGIN ? { organization: { select: { featureFlags: true } } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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);
|
||||
|
||||
// The fallback must follow the global default, not hardcode false.
|
||||
expect(
|
||||
resolveInternalApiOriginEnabled({
|
||||
orgFeatureFlags: { internalApiOriginEnabled: "false" },
|
||||
globalDefault: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores non-object flag containers", () => {
|
||||
expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: [], globalDefault: true })).toBe(
|
||||
true
|
||||
);
|
||||
expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: "junk", globalDefault: false })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user