Skip to content

Commit 44eca4d

Browse files
authored
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.
1 parent ec562c0 commit 44eca4d

7 files changed

Lines changed: 156 additions & 2 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
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.

apps/webapp/app/env.server.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,11 @@ const EnvironmentSchema = z
233233
LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true),
234234
APP_ORIGIN: z.string().default("http://localhost:3030"),
235235
API_ORIGIN: z.string().optional(),
236+
// Alternative API origin for deployed runs whose org has the
237+
// internalApiOriginEnabled feature flag on. Unset = flag is a no-op.
238+
INTERNAL_API_ORIGIN: z.string().optional(),
239+
// Global default for internalApiOriginEnabled when an org hasn't set it.
240+
INTERNAL_API_ORIGIN_ENABLED: z.string().default("0"),
236241
STREAM_ORIGIN: z.string().optional(),
237242
ELECTRIC_ORIGIN: z.string().default("http://localhost:3060"),
238243
// A comma separated list of electric origins to shard into different electric instances by environmentId

apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
22
import { z } from "zod";
33
import { prisma } from "~/db.server";
4+
import { env } from "~/env.server";
45
import { authenticateApiRequest } from "~/services/apiAuth.server";
56
import { resolveVariablesForEnvironment } from "~/v3/environmentVariables/environmentVariablesRepository.server";
67

@@ -44,6 +45,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
4445
},
4546
include: {
4647
parentEnvironment: true,
48+
// Feeds resolveProdApiOrigin; only loaded when internal-origin routing is possible.
49+
...(env.INTERNAL_API_ORIGIN ? { organization: { select: { featureFlags: true } } } : {}),
4750
},
4851
});
4952

apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { env } from "~/env.server";
77
import { getSecretStore } from "~/services/secrets/secretStore.server";
88
import { deduplicateVariableArray } from "../deduplicateVariableArray.server";
99
import { removeBlacklistedVariables } from "../environmentVariableRules.server";
10+
import { FEATURE_FLAG, resolveInternalApiOriginEnabled } from "../featureFlags";
11+
import { globalFlagsRegistry } from "../globalFlagsRegistry.server";
1012
import { generateFriendlyId } from "../friendlyIdentifiers";
1113
import {
1214
type CreateEnvironmentVariables,
@@ -934,7 +936,7 @@ export type RuntimeEnvironmentForEnvRepo = Pick<
934936
| "organizationId"
935937
| "branchName"
936938
| "builtInEnvironmentVariableOverrides"
937-
>;
939+
> & { organization?: { featureFlags: unknown } | null };
938940

939941
export const environmentVariablesRepository = new EnvironmentVariablesRepository();
940942

@@ -1146,20 +1148,47 @@ async function resolveOverridableOtelDevVariables(
11461148
return result;
11471149
}
11481150

1151+
// Deployed runs normally get the public API origin. When INTERNAL_API_ORIGIN is
1152+
// set and the org's internalApiOriginEnabled flag resolves on (org override wins
1153+
// in both directions; INTERNAL_API_ORIGIN_ENABLED is the global default applied
1154+
// only when the org has not set it), they get the internal origin instead. The
1155+
// global default is the cached DB flag with INTERNAL_API_ORIGIN_ENABLED as the
1156+
// fallback; org flags are read in-memory, so a flip applies on the next attempt.
1157+
function resolveProdApiOrigin(runtimeEnvironment: RuntimeEnvironmentForEnvRepo): string {
1158+
const publicOrigin = env.API_ORIGIN ?? env.APP_ORIGIN;
1159+
1160+
if (!env.INTERNAL_API_ORIGIN) {
1161+
return publicOrigin;
1162+
}
1163+
1164+
const enabled = resolveInternalApiOriginEnabled({
1165+
orgFeatureFlags: runtimeEnvironment.organization?.featureFlags,
1166+
globalDefault:
1167+
globalFlagsRegistry.current()?.[FEATURE_FLAG.internalApiOriginEnabled] ??
1168+
env.INTERNAL_API_ORIGIN_ENABLED === "1",
1169+
});
1170+
1171+
return enabled ? env.INTERNAL_API_ORIGIN : publicOrigin;
1172+
}
1173+
11491174
async function resolveBuiltInProdVariables(
11501175
runtimeEnvironment: RuntimeEnvironmentForEnvRepo,
11511176
parentEnvironment?: RuntimeEnvironmentForEnvRepo
11521177
) {
1178+
const apiOrigin = resolveProdApiOrigin(runtimeEnvironment);
1179+
11531180
let result: Array<EnvironmentVariable> = [
11541181
{
11551182
key: "TRIGGER_SECRET_KEY",
11561183
value: parentEnvironment?.apiKey ?? runtimeEnvironment.apiKey,
11571184
},
11581185
{
11591186
key: "TRIGGER_API_URL",
1160-
value: env.API_ORIGIN ?? env.APP_ORIGIN,
1187+
value: apiOrigin,
11611188
},
11621189
{
1190+
// Deliberately not switched by internalApiOriginEnabled: streams are
1191+
// long-lived connections served on their own path.
11631192
key: "TRIGGER_STREAM_URL",
11641193
value: env.STREAM_ORIGIN ?? env.API_ORIGIN ?? env.APP_ORIGIN,
11651194
},

apps/webapp/app/v3/featureFlags.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const FEATURE_FLAG = {
1212
hasSso: "hasSso",
1313
mollifierEnabled: "mollifierEnabled",
1414
workerQueueScheduledSplitEnabled: "workerQueueScheduledSplitEnabled",
15+
internalApiOriginEnabled: "internalApiOriginEnabled",
1516
realtimeBackend: "realtimeBackend",
1617
computeMigrationEnabled: "computeMigrationEnabled",
1718
computeMigrationFreePercentage: "computeMigrationFreePercentage",
@@ -38,6 +39,12 @@ export const FeatureFlagCatalog = {
3839
[FEATURE_FLAG.hasSso]: z.coerce.boolean(),
3940
[FEATURE_FLAG.mollifierEnabled]: z.coerce.boolean(),
4041
[FEATURE_FLAG.workerQueueScheduledSplitEnabled]: z.coerce.boolean(),
42+
// Routes deployed runs' TRIGGER_API_URL to INTERNAL_API_ORIGIN. Per-org, with
43+
// INTERNAL_API_ORIGIN_ENABLED as the global default (org wins). No-op unless
44+
// INTERNAL_API_ORIGIN is set.
45+
// Strict z.boolean(): coercion turns the string "false" into true, which
46+
// would silently enable the wrong orgs if written as a string.
47+
[FEATURE_FLAG.internalApiOriginEnabled]: z.boolean(),
4148
// Which backend serves the realtime run feed. Controllable
4249
// globally and per-org (org wins). Defaults to "electric" when unset.
4350
// "shadow" serves Electric but diffs the native path in the background.
@@ -104,6 +111,35 @@ export function validatePartialFeatureFlags(values: Record<string, unknown>) {
104111
}
105112

106113
// Utility types for catalog-driven UI rendering
114+
/**
115+
* Resolve whether deployed runs should use the internal API origin, from the
116+
* org's feature-flags JSON. Precedence: a per-org override wins in BOTH
117+
* directions; the global default applies only when the org has not set the
118+
* flag (or set it to something invalid).
119+
*/
120+
export function resolveInternalApiOriginEnabled({
121+
orgFeatureFlags,
122+
globalDefault,
123+
}: {
124+
orgFeatureFlags: unknown;
125+
globalDefault: boolean;
126+
}): boolean {
127+
const override =
128+
orgFeatureFlags && typeof orgFeatureFlags === "object" && !Array.isArray(orgFeatureFlags)
129+
? (orgFeatureFlags as Record<string, unknown>)[FEATURE_FLAG.internalApiOriginEnabled]
130+
: undefined;
131+
132+
if (override !== undefined) {
133+
const parsed = FeatureFlagCatalog[FEATURE_FLAG.internalApiOriginEnabled].safeParse(override);
134+
135+
if (parsed.success) {
136+
return parsed.data;
137+
}
138+
}
139+
140+
return globalDefault;
141+
}
142+
107143
export type FlagControlType =
108144
| { type: "boolean" }
109145
| { type: "enum"; options: string[] }

apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,8 @@ export class AuthenticatedWorkerInstance extends WithRunEngine {
544544
},
545545
include: {
546546
parentEnvironment: true,
547+
// Feeds resolveProdApiOrigin; only loaded when internal-origin routing is possible.
548+
...(env.INTERNAL_API_ORIGIN ? { organization: { select: { featureFlags: true } } } : {}),
547549
},
548550
});
549551

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveInternalApiOriginEnabled } from "~/v3/featureFlags";
3+
4+
describe("resolveInternalApiOriginEnabled", () => {
5+
it("returns the global default when the org has no flags", () => {
6+
expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: null, globalDefault: false })).toBe(
7+
false
8+
);
9+
expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: null, globalDefault: true })).toBe(
10+
true
11+
);
12+
expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: {}, globalDefault: true })).toBe(
13+
true
14+
);
15+
});
16+
17+
it("lets an org override win in both directions", () => {
18+
expect(
19+
resolveInternalApiOriginEnabled({
20+
orgFeatureFlags: { internalApiOriginEnabled: true },
21+
globalDefault: false,
22+
})
23+
).toBe(true);
24+
25+
expect(
26+
resolveInternalApiOriginEnabled({
27+
orgFeatureFlags: { internalApiOriginEnabled: false },
28+
globalDefault: true,
29+
})
30+
).toBe(false);
31+
});
32+
33+
it("ignores invalid overrides and falls back to the global default", () => {
34+
// Strict z.boolean(): the string "false" must not coerce to an enable.
35+
expect(
36+
resolveInternalApiOriginEnabled({
37+
orgFeatureFlags: { internalApiOriginEnabled: "false" },
38+
globalDefault: false,
39+
})
40+
).toBe(false);
41+
42+
expect(
43+
resolveInternalApiOriginEnabled({
44+
orgFeatureFlags: { internalApiOriginEnabled: "true" },
45+
globalDefault: false,
46+
})
47+
).toBe(false);
48+
49+
expect(
50+
resolveInternalApiOriginEnabled({
51+
orgFeatureFlags: { internalApiOriginEnabled: 1 },
52+
globalDefault: false,
53+
})
54+
).toBe(false);
55+
56+
// The fallback must follow the global default, not hardcode false.
57+
expect(
58+
resolveInternalApiOriginEnabled({
59+
orgFeatureFlags: { internalApiOriginEnabled: "false" },
60+
globalDefault: true,
61+
})
62+
).toBe(true);
63+
});
64+
65+
it("ignores non-object flag containers", () => {
66+
expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: [], globalDefault: true })).toBe(
67+
true
68+
);
69+
expect(resolveInternalApiOriginEnabled({ orgFeatureFlags: "junk", globalDefault: false })).toBe(
70+
false
71+
);
72+
});
73+
});

0 commit comments

Comments
 (0)