History

Self-host + cross-host: consistent public-origin resolution (platform detect, actionable errors, pinned outbound URLs) (#1021)

* Self-host: auto-detect public URL from platform env + actionable origin error Self-host on a PaaS failed sign-in with Better Auth's bare 'Invalid origin': config.webBaseUrl defaulted to http://localhost:PORT when EXECUTOR_WEB_BASE_URL was unset, so trustedOrigins never matched the real public origin. - config.ts resolves webBaseUrl from the platform's injected origin when EXECUTOR_WEB_BASE_URL is unset (Railway/Render/Fly/Vercel/Netlify/Heroku/ Azure/Cloudflare Pages; ordering from @t3-oss/env-core's preset, minus the CRA-ambiguous PUBLIC_URL), else localhost with a one-time production warning. Pinned, never derived from the request Host (matches Windmill/n8n; keeps host-header injection out of OAuth/links). - auth/invalid-origin-help.ts rewrites the 403 'Invalid origin' into a message naming the exact EXECUTOR_WEB_BASE_URL to set, keeping code:INVALID_ORIGIN. Verified end-to-end against the prod image: RAILWAY_PUBLIC_DOMAIN set -> sign-up 200; unset -> the actionable setup message. * Cross-host: shared public-origin resolver + consistency fixes Audit of every web-URL site (202 inventoried) found no host-header-injection holes, but origin resolution was split across hosts and had a few gaps. Extract one shared resolver and apply it consistently: - packages/core/sdk/src/public-origin.ts (new, @executor-js/sdk/public-origin): getPlatformOrigin (the t3-env-preset platform-detect chain) + resolvePublicOrigin (explicit env -> platform -> undefined) + the missing-origin warning helpers. selfhost and cloudflare configs now both use it instead of diverging. - selfhost: warn on the localhost fallback unless NODE_ENV is development/test (was gated on ===production, so staging/demo stayed silent). - cloudflare: pin VITE_PUBLIC_SITE_URL via the resolver + warn once on a real deploy when it's unset (mirrors selfhost; gated on enableDevAuth). - cloudflare MCP session DO: replace the silent 'http://localhost' approval-URL fallback with a logged, visibly-invalid origin so a misconfigured deploy fails loudly instead of handing clients an unreachable localhost link. - cloud: WorkOS Admin Portal returnUrl fell back to the bare relative '/org'; use the pinned origin like every other VITE_PUBLIC_SITE_URL site. - MCP in-process session store: thread the pinned webBaseUrl so the self-host browser-approval link uses the public URL, not the internal 127.0.0.1 bind behind a reverse proxy (local/desktop keep request.url — loopback is correct). All outbound URLs stay pinned (never the request Host); request origin remains for CSRF/display only. Typecheck + lint clean; selfhost 48, host-mcp 32, cloud 128, host-cloudflare 10, sdk public-origin 4 tests green.

Rhys Sullivan committed 5 months ago

Commit c8faad71 parent 498e4a617 files · +432 −19Browse files

Unified · Split

17 files
  1. .changeset/selfhost-zero-config-origin.md +16 −0
  2. apps/cloud/src/org/handlers.ts +4 −1
  3. apps/host-cloudflare/src/config.ts +22 −4
  4. apps/host-cloudflare/src/mcp/session-durable-object.ts +16 −4
  5. apps/host-selfhost/src/app.ts +3 −1
  6. apps/host-selfhost/src/auth/better-auth.ts +8 −2
  7. apps/host-selfhost/src/auth/index.ts +7 −0
  8. apps/host-selfhost/src/auth/invalid-origin-help.test.ts +50 −0
  9. apps/host-selfhost/src/auth/invalid-origin-help.ts +49 −0
  10. apps/host-selfhost/src/auth/origin-resolution.test.ts +62 −0
  11. apps/host-selfhost/src/config.ts +31 −1
  12. apps/host-selfhost/src/mcp/index.ts +2 −1
  13. apps/host-selfhost/src/mcp/session-store.ts +10 −2
  14. packages/core/sdk/package.json +8 −1
  15. packages/core/sdk/src/public-origin.test.ts +52 −0
  16. packages/core/sdk/src/public-origin.ts +77 −0
  17. packages/hosts/mcp/src/in-memory-session-store.ts +15 −2
.changeset/selfhost-zero-config-origin.md+16−0
@@ -0,0 +1,16 @@
1+---
2+"executor": patch
3+---
4+
5+Self-hosted instances now detect their public URL automatically on common
6+platforms, and origin handling is consistent across every host. When
7+`EXECUTOR_WEB_BASE_URL` is not set, the server reads the origin a host injects
8+(Railway, Render, Fly, Vercel, Netlify, Heroku, Azure, Cloudflare Pages) instead
9+of defaulting to localhost — so a platform deploy works with zero configuration
10+and no longer fails sign-in with "Invalid origin". When the origin still can't be
11+determined, that error is replaced with a clear message telling you exactly which
12+`EXECUTOR_WEB_BASE_URL` value to set, and a startup warning fires on any non-dev
13+deploy that falls back to localhost. The MCP browser-approval link a self-host
14+sends to clients now uses the pinned public URL (reachable behind a reverse
15+proxy) rather than the server's internal address. These resolution rules now live
16+in one shared helper used by every host.
apps/cloud/src/org/handlers.ts+4−1
@@ -90,9 +90,12 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 89 unmodified lines
9090 }
9191
9292 const workos = yield* WorkOSClient;
93+ // WorkOS requires an ABSOLUTE returnUrl — fall back to the pinned origin
94+ // (matching every other VITE_PUBLIC_SITE_URL site in this host), not a
95+ // bare relative path that WorkOS would reject.
9396 const { link } = yield* workos.generateDomainVerificationPortalLink(
9497 auth.organizationId,
95− env.VITE_PUBLIC_SITE_URL ? `${env.VITE_PUBLIC_SITE_URL}/org` : "/org",
98+ `${env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh"}/org`,
9699 );
97100 return { link };
98101 }),
apps/host-cloudflare/src/config.ts+22−4
@@ -1,6 +1,9 @@
11 import type { D1Database, DurableObjectNamespace, R2Bucket } from "@cloudflare/workers-types";
22
33 import { isValidOrgSlug } from "@executor-js/api";
4+import { missingPublicOriginWarning, resolvePublicOrigin } from "@executor-js/sdk/public-origin";
5+
6+let warnedNoCloudflareOrigin = false;
47
58 // ---------------------------------------------------------------------------
69 // Cloudflare host config. Unlike self-host (process.env + a data dir), a Worker
@@ -97,6 +100,17 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { · 90 unmodified lines
97100 "EXECUTOR_SECRET_KEY must be set (wrangler secret put EXECUTOR_SECRET_KEY) — it encrypts stored secrets at rest in D1",
98101 );
99102 }
103+ const enableDevAuth = env.ENABLE_DEV_AUTH === "true";
104+ const webBaseUrl = resolvePublicOrigin({ explicit: env.VITE_PUBLIC_SITE_URL, env: {} });
105+ if (!webBaseUrl && !enableDevAuth && !warnedNoCloudflareOrigin) {
106+ warnedNoCloudflareOrigin = true;
107+ console.warn(
108+ missingPublicOriginWarning({
109+ varName: "VITE_PUBLIC_SITE_URL",
110+ fallback: "the per-request origin",
111+ }),
112+ );
113+ }
100114 return {
101115 accessTeamDomain: env.ACCESS_TEAM_DOMAIN.replace(/^https?:\/\//, "").replace(/\/+$/, ""),
102116 accessAud: env.ACCESS_AUD,
@@ -108,9 +122,13 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { · 5 unmodified lines
108122 organizationSlug: resolveOrgSlug(env.SELF_HOSTED_ORG_SLUG),
109123 secretKey,
110124 allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
111− // No static URL on a Worker — leave unset when VITE_PUBLIC_SITE_URL is absent
112− // and let the request origin drive it (RequestWebOrigin). Explicit still wins.
113− webBaseUrl: env.VITE_PUBLIC_SITE_URL,
114− enableDevAuth: env.ENABLE_DEV_AUTH === "true",
125+ // Pinned origin via the shared resolver. A Worker receives no PaaS platform
126+ // vars (env: {} — there is nothing to detect), so only the explicit
127+ // VITE_PUBLIC_SITE_URL applies; when it's unset we leave webBaseUrl undefined
128+ // and let the per-request origin drive it (request.url — Cloudflare-set, not
129+ // spoofable via Host). Warn once on a real deployment so the operator pins it,
130+ // mirroring self-host (gated on enableDevAuth = local `wrangler dev`).
131+ webBaseUrl,
132+ enableDevAuth,
115133 };
116134 };
apps/host-cloudflare/src/mcp/session-durable-object.ts+16−4
@@ -91,12 +91,24 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 90 unmodified lines
9191 elicitationMode === "browser"
9292 ? {
9393 mode: "browser" as const,
94− approvalUrl: (executionId) =>
95− buildResumeApprovalUrl({
96− origin: sessionMeta.webOrigin ?? config.webBaseUrl ?? "http://localhost",
94+ approvalUrl: (executionId) => {
95+ // webOrigin is captured per-request at session create; for a
96+ // legacy session cold-restored without it, fall back to the
97+ // pinned site URL. If neither exists, the link would be
98+ // unreachable — fail VISIBLY (a logged, obviously-invalid host)
99+ // instead of silently pointing the human at http://localhost.
100+ const origin = sessionMeta.webOrigin ?? config.webBaseUrl;
101+ if (!origin) {
102+ console.error(
103+ "[executor-cloudflare] cannot build MCP approval URL: no session web origin and VITE_PUBLIC_SITE_URL is unset. Set VITE_PUBLIC_SITE_URL so approval links are reachable.",
104+ );
105+ }
106+ return buildResumeApprovalUrl({
107+ origin: origin ?? "https://unconfigured-origin.invalid",
97108 executionId,
98109 sessionId: self.sessionId,
99− }),
110+ });
111+ },
100112 }
101113 : { mode: elicitationMode },
102114 });
apps/host-selfhost/src/app.ts+3−1
@@ -68,7 +68,9 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 67 unmodified lines
6868 const { identityLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle);
6969
7070 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------
71− const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth);
71+ // Pass the pinned public origin so browser-approval URLs are reachable behind
72+ // a reverse proxy (not the internal 127.0.0.1 bind from the request URL).
73+ const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl);
7274
7375 const { appLayer, toWebHandler } = ExecutorApp.make({
7476 plugins: selfHostPlugins,
apps/host-selfhost/src/auth/better-auth.ts+8−2
@@ -88,9 +88,15 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 87 unmodified lines
8888 type: "sqlite" as const,
8989 },
9090 secret,
91− baseURL: config.webBaseUrl,
9291 // The browser Origin must match this exactly; CLI/MCP bearer requests carry
93− // no Origin and are unaffected.
92+ // no Origin and are unaffected. `config.webBaseUrl` resolves from an explicit
93+ // EXECUTOR_WEB_BASE_URL, else a platform-injected origin (Railway/Render/Fly/
94+ // …), else localhost — so a PaaS deploy is zero-config and any other host
95+ // sets the one variable (a loud warning fires on the localhost fallback).
96+ // See config.ts. We deliberately do NOT derive this from the request `Host`:
97+ // matching the ecosystem (Windmill `BASE_URL`, n8n `WEBHOOK_URL`), a pinned
98+ // origin keeps host-header injection out of OAuth redirects and links.
99+ baseURL: config.webBaseUrl,
94100 trustedOrigins: [config.webBaseUrl],
95101 emailAndPassword: { enabled: true },
96102 // `apiKey` issues long-lived personal keys (the API-keys page). With
apps/host-selfhost/src/auth/index.ts+7−0
@@ -2,10 +2,12 @@ import { Layer } from "effect"; · 1 unmodified line
22
33 import { IdentityProvider } from "@executor-js/api/server";
44
5+import { loadConfig } from "../config";
56 import type { SelfHostDbHandle } from "../db/self-host-db";
67 import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";
78 import { betterAuthIdentityLayer } from "./identity";
89 import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent";
10+import { rewriteInvalidOrigin } from "./invalid-origin-help";
911
1012 export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";
1113 export { betterAuthIdentityLayer } from "./identity";
@@ -56,8 +58,13 @@ export const resolveAuthProviders = async ( · 44 unmodified lines
5658 // authorize so a connecting client is gated on /mcp-consent rather than
5759 // silently granted a token (see ./force-mcp-consent), and enrich the
5860 // resulting consent redirect with the registered client name.
61+ const config = loadConfig();
5962 const authHandler = async (request: Request): Promise<Response> => {
6063 const response = await betterAuth.handler(withForcedMcpConsent(request));
64+ // Turn Better Auth's bare 403 "Invalid origin" into a setup instruction —
65+ // on a fresh deploy it almost always means the public URL needs configuring.
66+ const friendlier = await rewriteInvalidOrigin(request, response, config.webBaseUrl);
67+ if (friendlier) return friendlier;
6168 if (response.status !== 302) return response;
6269 const clientId = consentRedirectClientId(response.headers.get("location"));
6370 if (!clientId) return response;
apps/host-selfhost/src/auth/invalid-origin-help.test.ts+50−0
@@ -0,0 +1,50 @@
1+import { expect, test } from "@effect/vitest";
2+
3+import { invalidOriginHelp, originOf, rewriteInvalidOrigin } from "./invalid-origin-help";
4+
5+const req = (headers: Record<string, string>) =>
6+ new Request("https://svc.internal/api/auth/sign-up/email", { method: "POST", headers });
7+
8+test("originOf prefers Origin, then x-forwarded-host, then host", () => {
9+ expect(originOf(req({ origin: "https://app.example.com" }))).toBe("https://app.example.com");
10+ expect(
11+ originOf(req({ "x-forwarded-host": "app.example.com", "x-forwarded-proto": "https" })),
12+ ).toBe("https://app.example.com");
13+ expect(originOf(req({ host: "app.example.com" }))).toBe("https://app.example.com");
14+});
15+
16+test("the help message names the URL to set", () => {
17+ const msg = invalidOriginHelp("https://app.example.com", "http://localhost:4788");
18+ expect(msg).toContain("EXECUTOR_WEB_BASE_URL");
19+ expect(msg).toContain("https://app.example.com");
20+ expect(msg).toContain("http://localhost:4788");
21+});
22+
23+test("rewriteInvalidOrigin replaces a 403 'Invalid origin' with the setup message, keeping the code", async () => {
24+ const original = new Response(
25+ JSON.stringify({ code: "INVALID_ORIGIN", message: "Invalid origin" }),
26+ {
27+ status: 403,
28+ headers: { "content-type": "application/json" },
29+ },
30+ );
31+ const rewritten = await rewriteInvalidOrigin(
32+ req({ origin: "https://app.example.com" }),
33+ original,
34+ "http://localhost:4788",
35+ );
36+ expect(rewritten).not.toBeNull();
37+ expect(rewritten!.status).toBe(403);
38+ const body = (await rewritten!.json()) as { code: string; message: string };
39+ expect(body.code).toBe("INVALID_ORIGIN");
40+ expect(body.message).toContain("EXECUTOR_WEB_BASE_URL");
41+ expect(body.message).toContain("https://app.example.com");
42+});
43+
44+test("rewriteInvalidOrigin passes other responses through untouched", async () => {
45+ expect(await rewriteInvalidOrigin(req({}), new Response("ok", { status: 200 }), "x")).toBeNull();
46+ const otherErr = new Response(JSON.stringify({ message: "An invite code is required" }), {
47+ status: 403,
48+ });
49+ expect(await rewriteInvalidOrigin(req({}), otherErr, "x")).toBeNull();
50+});
apps/host-selfhost/src/auth/invalid-origin-help.ts+49−0
@@ -0,0 +1,49 @@
1+// Better Auth rejects any request whose Origin isn't the configured webBaseUrl
2+// with a bare 403 "Invalid origin". On a self-host deploy that almost always
3+// means the instance's public URL wasn't detected or configured — so we replace
4+// that dead-end with a message naming the exact fix (the URL to set). The error
5+// `code` is preserved so programmatic clients are unaffected; only the
6+// human-facing `message` changes.
7+
8+const INVALID_ORIGIN = /invalid origin/i;
9+
10+/** The origin a request came from: the browser `Origin`, else the proxy host. */
11+export const originOf = (request: Request): string | null => {
12+ const explicit = request.headers.get("origin");
13+ if (explicit) return explicit;
14+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
15+ if (!host) return null;
16+ const proto = request.headers.get("x-forwarded-proto") ?? "https";
17+ return `${proto}://${host}`;
18+};
19+
20+/** Actionable replacement for "Invalid origin". */
21+export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: string): string =>
22+ requestOrigin
23+ ? `This Executor instance is configured for ${webBaseUrl}, but you're connecting from ${requestOrigin}. ` +
24+ `Set the EXECUTOR_WEB_BASE_URL environment variable to ${requestOrigin} and restart the server, then try again. ` +
25+ `(Railway, Render, Fly, Vercel, and similar hosts are detected automatically; set it explicitly for a custom domain or other host.)`
26+ : `This Executor instance is configured for ${webBaseUrl}. If you're reaching it at a different address, ` +
27+ `set EXECUTOR_WEB_BASE_URL to that address and restart the server.`;
28+
29+/**
30+ * If `response` is Better Auth's 403 "Invalid origin", return a friendlier copy
31+ * with the same status + `code` but an actionable message. Otherwise null — the
32+ * caller passes the original response through untouched.
33+ */
34+export const rewriteInvalidOrigin = async (
35+ request: Request,
36+ response: Response,
37+ webBaseUrl: string,
38+): Promise<Response | null> => {
39+ if (response.status !== 403) return null;
40+ const body = await response.clone().text();
41+ if (!INVALID_ORIGIN.test(body)) return null;
42+ const message = invalidOriginHelp(originOf(request), webBaseUrl);
43+ const headers = new Headers(response.headers);
44+ headers.set("content-type", "application/json");
45+ return new Response(JSON.stringify({ code: "INVALID_ORIGIN", message }), {
46+ status: 403,
47+ headers,
48+ });
49+};
apps/host-selfhost/src/auth/origin-resolution.test.ts+62−0
@@ -0,0 +1,62 @@
1+import { mkdtempSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+
5+import { expect, test } from "@effect/vitest";
6+
7+import { loadConfig } from "../config";
8+
9+// Keep generated secret/key files out of the repo (loadConfig persists them).
10+process.env.EXECUTOR_DATA_DIR ??= mkdtempSync(join(tmpdir(), "origin-cfg-"));
11+
12+// The "Invalid origin" sign-up failure on a PaaS came from webBaseUrl defaulting
13+// to localhost: Better Auth's trustedOrigins is [config.webBaseUrl], so the real
14+// public origin never matched. The fix resolves webBaseUrl from the platform's
15+// injected origin (Railway/Render/Fly/…) when EXECUTOR_WEB_BASE_URL is unset, so
16+// a PaaS deploy is zero-config. These pin that resolution; the origin check that
17+// consumes it is Better Auth's and unchanged.
18+
19+// Clear every origin source so each test starts from a known state (no
20+// try/finally — these are the only env vars these cases read).
21+const PLATFORM_VARS = [
22+ "EXECUTOR_WEB_BASE_URL",
23+ "RAILWAY_PUBLIC_DOMAIN",
24+ "RENDER_EXTERNAL_URL",
25+ "RENDER_EXTERNAL_HOSTNAME",
26+ "FLY_APP_NAME",
27+ "VERCEL_URL",
28+];
29+const resetOriginEnv = (): void => {
30+ for (const key of PLATFORM_VARS) delete process.env[key];
31+ process.env.PORT = "4788";
32+};
33+
34+test("webBaseUrl falls back to localhost with no public origin", () => {
35+ resetOriginEnv();
36+ expect(loadConfig().webBaseUrl).toBe("http://localhost:4788");
37+});
38+
39+test("webBaseUrl auto-resolves from a platform host var (Railway, host only → https)", () => {
40+ resetOriginEnv();
41+ process.env.RAILWAY_PUBLIC_DOMAIN = "demo-production.up.railway.app";
42+ expect(loadConfig().webBaseUrl).toBe("https://demo-production.up.railway.app");
43+});
44+
45+test("webBaseUrl auto-resolves from a platform URL var (Render, full URL, trailing slash trimmed)", () => {
46+ resetOriginEnv();
47+ process.env.RENDER_EXTERNAL_URL = "https://demo.onrender.com/";
48+ expect(loadConfig().webBaseUrl).toBe("https://demo.onrender.com");
49+});
50+
51+test("Fly's app name becomes the .fly.dev origin", () => {
52+ resetOriginEnv();
53+ process.env.FLY_APP_NAME = "demo-app";
54+ expect(loadConfig().webBaseUrl).toBe("https://demo-app.fly.dev");
55+});
56+
57+test("an explicit EXECUTOR_WEB_BASE_URL always wins over a platform var", () => {
58+ resetOriginEnv();
59+ process.env.RAILWAY_PUBLIC_DOMAIN = "ignored.up.railway.app";
60+ process.env.EXECUTOR_WEB_BASE_URL = "https://pinned.example.com";
61+ expect(loadConfig().webBaseUrl).toBe("https://pinned.example.com");
62+});
apps/host-selfhost/src/config.ts+31−1
@@ -3,6 +3,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; · 2 unmodified lines
33 import { join } from "node:path";
44
55 import { isValidOrgSlug } from "@executor-js/api";
6+import {
7+ missingPublicOriginWarning,
8+ resolvePublicOrigin,
9+ shouldWarnMissingPublicOrigin,
10+} from "@executor-js/sdk/public-origin";
611
712 // ---------------------------------------------------------------------------
813 // Self-host server config — a single typed surface parsed from the
@@ -103,6 +108,31 @@ export const resolveAuthSecret = (): string => { · 94 unmodified lines
103108 return generated;
104109 };
105110
111+let warnedNoPublicUrl = false;
112+
113+// The public origin used to build absolute links (OAuth redirects, MCP OAuth
114+// metadata, the connect-card URL). Priority via the shared resolver: an explicit
115+// EXECUTOR_WEB_BASE_URL, then a platform-injected origin (zero-config on
116+// Railway/Render/Fly/…), then a localhost fallback for local dev. NEVER derived
117+// from the request `Host` — that's spoofable and would let host-header injection
118+// poison those links (the request origin is only trusted for the CSRF/
119+// `trustedOrigins` check, which is same-origin-safe; see better-auth.ts).
120+const resolveWebBaseUrl = (port: number): string => {
121+ const resolved = resolvePublicOrigin({
122+ explicit: process.env.EXECUTOR_WEB_BASE_URL,
123+ env: process.env,
124+ });
125+ if (resolved) return resolved;
126+ const fallback = `http://localhost:${port}`;
127+ // A deployed instance with no detectable origin mints localhost links — warn
128+ // once (unless local dev/test) so the operator sets the variable.
129+ if (!warnedNoPublicUrl && shouldWarnMissingPublicOrigin(process.env.NODE_ENV)) {
130+ warnedNoPublicUrl = true;
131+ console.warn(missingPublicOriginWarning({ varName: "EXECUTOR_WEB_BASE_URL", fallback }));
132+ }
133+ return fallback;
134+};
135+
106136 export const loadConfig = (): SelfHostConfig => {
107137 const port = Number.parseInt(process.env.PORT ?? "4788", 10);
108138 const dataDir = resolveDataDir();
@@ -110,7 +140,7 @@ export const loadConfig = (): SelfHostConfig => { · 1 unmodified line
110140 host: process.env.EXECUTOR_HOST ?? "127.0.0.1",
111141 port,
112142 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),
113− webBaseUrl: process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${port}`,
143+ webBaseUrl: resolveWebBaseUrl(port),
114144 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
115145 authSecret: resolveAuthSecret(),
116146 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,
apps/host-selfhost/src/mcp/index.ts+2−1
@@ -103,8 +103,9 @@ const makeApprovalHandler = · 102 unmodified lines
103103 export const makeSelfHostMcpSeams = (
104104 dbHandle: SelfHostDbHandle,
105105 betterAuth: BetterAuthHandle,
106+ webBaseUrl?: string,
106107 ): SelfHostMcpSeams => {
107− const sessionStore = makeSelfHostMcpSessionStore(dbHandle);
108+ const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl);
108109 const auth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = selfHostMcpAuth.pipe(
109110 Layer.provide(Layer.succeed(BetterAuth)(betterAuth)),
110111 );
apps/host-selfhost/src/mcp/session-store.ts+10−2
@@ -24,12 +24,20 @@ import { SelfHostExecutionStackLayer } from "../execution"; · 23 unmodified lines
2424
2525 export { McpEngineBuildError } from "@executor-js/host-mcp/in-memory-session-store";
2626
27−/** Build the in-process session store (plus its `close()` hook) over the DB handle. */
28−export const makeSelfHostMcpSessionStore = (db: SelfHostDbHandle): InMemoryMcpSessionStore =>
27+/**
28+ * Build the in-process session store (plus its `close()` hook) over the DB
29+ * handle. `webBaseUrl` is the pinned public origin so browser-approval URLs use
30+ * the reachable public address rather than the internal bind behind a proxy.
31+ */
32+export const makeSelfHostMcpSessionStore = (
33+ db: SelfHostDbHandle,
34+ webBaseUrl?: string,
35+): InMemoryMcpSessionStore =>
2936 makeInMemoryMcpSessionStore(
3037 makeMcpBuildServer(
3138 SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),
3239 ),
40+ { webBaseUrl },
3341 );
3442
3543 /** The `McpSessionStore` envelope seam over a freshly built in-process store. */
packages/core/sdk/package.json+8−1
@@ -24,7 +24,8 @@ · 23 unmodified lines
2424 "./client": "./src/client.ts",
2525 "./testing": "./src/testing.ts",
2626 "./migration": "./src/migration-spec.ts",
27− "./http-auth": "./src/http-auth/index.ts"
27+ "./http-auth": "./src/http-auth/index.ts",
28+ "./public-origin": "./src/public-origin.ts"
2829 },
2930 "publishConfig": {
3031 "access": "public",
@@ -76,6 +77,12 @@ · 45 unmodified lines
7677 "types": "./dist/http-auth.d.ts",
7778 "default": "./dist/http-auth.js"
7879 }
80+ },
81+ "./public-origin": {
82+ "import": {
83+ "types": "./dist/public-origin.d.ts",
84+ "default": "./dist/public-origin.js"
85+ }
7986 }
8087 }
8188 },
packages/core/sdk/src/public-origin.test.ts+52−0
@@ -0,0 +1,52 @@
1+import { expect, test } from "@effect/vitest";
2+
3+import {
4+ getPlatformOrigin,
5+ missingPublicOriginWarning,
6+ resolvePublicOrigin,
7+ shouldWarnMissingPublicOrigin,
8+} from "./public-origin";
9+
10+test("getPlatformOrigin reads host-only vars as https, trims trailing slash on URL vars", () => {
11+ expect(getPlatformOrigin({ RAILWAY_PUBLIC_DOMAIN: "demo.up.railway.app" })).toBe(
12+ "https://demo.up.railway.app",
13+ );
14+ expect(getPlatformOrigin({ RENDER_EXTERNAL_URL: "https://demo.onrender.com/" })).toBe(
15+ "https://demo.onrender.com",
16+ );
17+ expect(getPlatformOrigin({ FLY_APP_NAME: "demo" })).toBe("https://demo.fly.dev");
18+ expect(getPlatformOrigin({ SITE_NAME: "demo" })).toBe("https://demo.netlify.app");
19+ expect(getPlatformOrigin({})).toBeUndefined();
20+ // A Worker env (no platform vars) yields nothing — the caller falls back.
21+ expect(getPlatformOrigin({ SOME_OTHER: "x" })).toBeUndefined();
22+});
23+
24+test("resolvePublicOrigin: explicit wins over platform, else platform, else undefined", () => {
25+ expect(
26+ resolvePublicOrigin({
27+ explicit: "https://pinned.example.com",
28+ env: { RAILWAY_PUBLIC_DOMAIN: "x" },
29+ }),
30+ ).toBe("https://pinned.example.com");
31+ expect(
32+ resolvePublicOrigin({ explicit: " ", env: { RAILWAY_PUBLIC_DOMAIN: "demo.up.railway.app" } }),
33+ ).toBe("https://demo.up.railway.app");
34+ expect(resolvePublicOrigin({ explicit: undefined, env: {} })).toBeUndefined();
35+});
36+
37+test("shouldWarnMissingPublicOrigin warns unless dev/test", () => {
38+ expect(shouldWarnMissingPublicOrigin("production")).toBe(true);
39+ expect(shouldWarnMissingPublicOrigin("staging")).toBe(true);
40+ expect(shouldWarnMissingPublicOrigin(undefined)).toBe(true);
41+ expect(shouldWarnMissingPublicOrigin("development")).toBe(false);
42+ expect(shouldWarnMissingPublicOrigin("test")).toBe(false);
43+});
44+
45+test("missingPublicOriginWarning names the var and the fallback", () => {
46+ const msg = missingPublicOriginWarning({
47+ varName: "EXECUTOR_WEB_BASE_URL",
48+ fallback: "http://localhost:4788",
49+ });
50+ expect(msg).toContain("EXECUTOR_WEB_BASE_URL");
51+ expect(msg).toContain("http://localhost:4788");
52+});
packages/core/sdk/src/public-origin.ts+77−0
@@ -0,0 +1,77 @@
1+// Shared resolution of a host's PUBLIC ORIGIN — the pinned base URL every host
2+// uses to build absolute outbound links (OAuth redirect_uri, MCP OAuth metadata,
3+// connect/approval URLs, email links). One implementation so the hosts don't
4+// drift (selfhost had the platform-detect chain; cloud/cloudflare did not).
5+//
6+// SECURITY: this is for SERVER-SIDE OUTBOUND urls, so the origin must come from
7+// a TRUSTED source — an operator-set env var, or a platform-injected deploy-time
8+// var — NEVER from the request `Host`/`X-Forwarded-Host` (host-header injection
9+// would let an attacker poison those links). The request origin may still be
10+// used for CSRF/origin validation and client-side display; that is each host's
11+// concern, not this module's.
12+
13+/** A read-only environment record (process.env, or a Worker `env` binding). */
14+export type EnvRecord = Record<string, string | undefined>;
15+
16+/**
17+ * The public origin a platform-as-a-service injects for this deployment, e.g.
18+ * Railway's `RAILWAY_PUBLIC_DOMAIN`. Ordering mirrors `@t3-oss/env-core`'s
19+ * `getPlatformOrigin` preset (MIT) — all platform-SET (not client-set) values,
20+ * so building absolute URLs from them is safe. Returns an origin (`https://host`,
21+ * no trailing slash) or undefined. The generic `PUBLIC_URL`/`APP_URL` from that
22+ * preset are deliberately omitted: `PUBLIC_URL` is a *path* in some toolchains
23+ * (CRA), not an origin. Cloudflare Workers receive none of these, so it returns
24+ * undefined there — expected; the Worker falls back to its own request origin.
25+ */
26+export const getPlatformOrigin = (env: EnvRecord): string | undefined => {
27+ const host =
28+ env.RAILWAY_PUBLIC_DOMAIN ??
29+ env.RENDER_EXTERNAL_HOSTNAME ??
30+ env.VERCEL_PROJECT_PRODUCTION_URL ??
31+ env.VERCEL_URL ??
32+ env.HEROKU_APP_DEFAULT_DOMAIN_NAME ??
33+ env.WEBSITE_HOSTNAME ?? // Azure App Service
34+ env.WEBSITE_DEFAULT_HOSTNAME ??
35+ (env.FLY_APP_NAME ? `${env.FLY_APP_NAME}.fly.dev` : undefined) ??
36+ (env.SITE_NAME ? `${env.SITE_NAME}.netlify.app` : undefined);
37+ const url =
38+ env.RENDER_EXTERNAL_URL ??
39+ env.DEPLOY_PRIME_URL ?? // Netlify (deploy/branch previews)
40+ env.URL ?? // Netlify (primary site URL)
41+ env.CF_PAGES_URL ??
42+ (host ? `https://${host}` : undefined);
43+ return url?.replace(/\/+$/, "");
44+};
45+
46+/**
47+ * Resolve a deployment's pinned public origin: an explicit operator-set value
48+ * wins, else a platform-injected origin, else undefined (the caller supplies its
49+ * own fallback — a `localhost:PORT` for a Node host, a production constant or the
50+ * per-request origin for a Worker — and decides whether to warn).
51+ */
52+export const resolvePublicOrigin = (options: {
53+ readonly explicit?: string | undefined;
54+ readonly env: EnvRecord;
55+}): string | undefined => {
56+ const explicit = options.explicit?.trim();
57+ if (explicit) return explicit;
58+ return getPlatformOrigin(options.env);
59+};
60+
61+/**
62+ * True unless this is explicitly local dev or a test run. A staging/demo deploy
63+ * often leaves `NODE_ENV` unset, and silently using a localhost fallback there is
64+ * the footgun we want surfaced — so warn by default.
65+ */
66+export const shouldWarnMissingPublicOrigin = (nodeEnv: string | undefined): boolean =>
67+ nodeEnv !== "development" && nodeEnv !== "test";
68+
69+/** The one-time warning shown when a host falls back for its public origin. */
70+export const missingPublicOriginWarning = (options: {
71+ readonly varName: string;
72+ readonly fallback: string;
73+}): string =>
74+ `[executor] ${options.varName} is not set and no platform origin was detected; ` +
75+ `falling back to ${options.fallback}. OAuth redirects, MCP metadata, and connect ` +
76+ `links will use this — set ${options.varName} to your public origin ` +
77+ `(e.g. https://your-instance.example.com).`;
packages/hosts/mcp/src/in-memory-session-store.ts+15−2
@@ -5,7 +5,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ · 4 unmodified lines
55 import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution";
66
77 import {
8− approvalUrlForRequest,
8+ buildResumeApprovalUrl,
99 decodeResumeResponse,
1010 formatResumeAcknowledgement,
1111 readElicitationMode,
@@ -122,6 +122,12 @@ const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/res · 110 unmodified lines
122122 */
123123 export const makeInMemoryMcpSessionStore = (
124124 buildServer: McpBuildServer,
125+ // The host's pinned public origin, used to build browser-approval URLs the
126+ // human opens. When set (e.g. a public-internet self-host behind a reverse
127+ // proxy) it is preferred over the request URL — whose host would be the
128+ // internal bind address (127.0.0.1:PORT), unreachable for the user. Omit it on
129+ // loopback hosts (local/desktop), where the request URL is already correct.
130+ options: { readonly webBaseUrl?: string } = {},
125131 ): InMemoryMcpSessionStore => {
126132 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
127133 const servers = new Map<string, McpServer>();
@@ -192,7 +198,14 @@ export const makeInMemoryMcpSessionStore = ( · 64 unmodified lines
192198 return {
193199 elicitationMode: {
194200 mode: "browser",
195− approvalUrl: (executionId) => approvalUrlForRequest(request, executionId, sessionId()),
201+ // Prefer the pinned public origin; fall back to the request URL (correct
202+ // for loopback hosts, the internal bind address behind a proxy).
203+ approvalUrl: (executionId) =>
204+ buildResumeApprovalUrl({
205+ origin: options.webBaseUrl ?? request.url,
206+ executionId,
207+ sessionId: sessionId(),
208+ }),
196209 },
197210 browserApprovalStore: approvals.store,
198211 };