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
5Self-hosted instances now detect their public URL automatically on common
6platforms, 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
9of defaulting to localhost — so a platform deploy works with zero configuration
10and no longer fails sign-in with "Invalid origin". When the origin still can't be
11determined, 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
13deploy that falls back to localhost. The MCP browser-approval link a self-host
14sends to clients now uses the pinned public URL (reachable behind a reverse
15proxy) rather than the server's internal address. These resolution rules now live
16in 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
90 }90 }
91 91
92 const workos = yield* WorkOSClient;92 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.
93 const { link } = yield* workos.generateDomainVerificationPortalLink(96 const { link } = yield* workos.generateDomainVerificationPortalLink(
94 auth.organizationId,97 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`,
96 );99 );
97 return { link };100 return { link };
98 }),101 }),
apps/host-cloudflare/src/config.ts+22−4
@@ -1,6 +1,9 @@
1import type { D1Database, DurableObjectNamespace, R2Bucket } from "@cloudflare/workers-types";1import type { D1Database, DurableObjectNamespace, R2Bucket } from "@cloudflare/workers-types";
2 2
3import { isValidOrgSlug } from "@executor-js/api";3import { isValidOrgSlug } from "@executor-js/api";
4import { missingPublicOriginWarning, resolvePublicOrigin } from "@executor-js/sdk/public-origin";
5
6let warnedNoCloudflareOrigin = false;
4 7
5// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
6// Cloudflare host config. Unlike self-host (process.env + a data dir), a Worker9// 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
97 "EXECUTOR_SECRET_KEY must be set (wrangler secret put EXECUTOR_SECRET_KEY) — it encrypts stored secrets at rest in D1",100 "EXECUTOR_SECRET_KEY must be set (wrangler secret put EXECUTOR_SECRET_KEY) — it encrypts stored secrets at rest in D1",
98 );101 );
99 }102 }
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 }
100 return {114 return {
101 accessTeamDomain: env.ACCESS_TEAM_DOMAIN.replace(/^https?:\/\//, "").replace(/\/+$/, ""),115 accessTeamDomain: env.ACCESS_TEAM_DOMAIN.replace(/^https?:\/\//, "").replace(/\/+$/, ""),
102 accessAud: env.ACCESS_AUD,116 accessAud: env.ACCESS_AUD,
@@ -108,9 +122,13 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { · 5 unmodified lines
108 organizationSlug: resolveOrgSlug(env.SELF_HOSTED_ORG_SLUG),122 organizationSlug: resolveOrgSlug(env.SELF_HOSTED_ORG_SLUG),
109 secretKey,123 secretKey,
110 allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",124 allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
111 // No static URL on a Worker — leave unset when VITE_PUBLIC_SITE_URL is absent125 // Pinned origin via the shared resolver. A Worker receives no PaaS platform
112 // and let the request origin drive it (RequestWebOrigin). Explicit still wins.126 // vars (env: {} — there is nothing to detect), so only the explicit
113 webBaseUrl: env.VITE_PUBLIC_SITE_URL,127 // VITE_PUBLIC_SITE_URL applies; when it's unset we leave webBaseUrl undefined
114 enableDevAuth: env.ENABLE_DEV_AUTH === "true",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,
115 };133 };
116};134};
apps/host-cloudflare/src/mcp/session-durable-object.ts+16−4
@@ -91,12 +91,24 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 90 unmodified lines
91 elicitationMode === "browser"91 elicitationMode === "browser"
92 ? {92 ? {
93 mode: "browser" as const,93 mode: "browser" as const,
94 approvalUrl: (executionId) =>94 approvalUrl: (executionId) => {
95 buildResumeApprovalUrl({95 // webOrigin is captured per-request at session create; for a
96 origin: sessionMeta.webOrigin ?? config.webBaseUrl ?? "http://localhost",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",
97 executionId,108 executionId,
98 sessionId: self.sessionId,109 sessionId: self.sessionId,
99 }),110 });
111 },
100 }112 }
101 : { mode: elicitationMode },113 : { mode: elicitationMode },
102 });114 });
apps/host-selfhost/src/app.ts+3−1
@@ -68,7 +68,9 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 67 unmodified lines
68 const { identityLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle);68 const { identityLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle);
69 69
70 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------70 // ---- 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);
72 74
73 const { appLayer, toWebHandler } = ExecutorApp.make({75 const { appLayer, toWebHandler } = ExecutorApp.make({
74 plugins: selfHostPlugins,76 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
88 type: "sqlite" as const,88 type: "sqlite" as const,
89 },89 },
90 secret,90 secret,
91 baseURL: config.webBaseUrl,
92 // The browser Origin must match this exactly; CLI/MCP bearer requests carry91 // 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,
94 trustedOrigins: [config.webBaseUrl],100 trustedOrigins: [config.webBaseUrl],
95 emailAndPassword: { enabled: true },101 emailAndPassword: { enabled: true },
96 // `apiKey` issues long-lived personal keys (the API-keys page). With102 // `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
2 2
3import { IdentityProvider } from "@executor-js/api/server";3import { IdentityProvider } from "@executor-js/api/server";
4 4
5import { loadConfig } from "../config";
5import type { SelfHostDbHandle } from "../db/self-host-db";6import type { SelfHostDbHandle } from "../db/self-host-db";
6import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";7import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";
7import { betterAuthIdentityLayer } from "./identity";8import { betterAuthIdentityLayer } from "./identity";
8import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent";9import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent";
10import { rewriteInvalidOrigin } from "./invalid-origin-help";
9 11
10export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";12export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";
11export { betterAuthIdentityLayer } from "./identity";13export { betterAuthIdentityLayer } from "./identity";
@@ -56,8 +58,13 @@ export const resolveAuthProviders = async ( · 44 unmodified lines
56 // authorize so a connecting client is gated on /mcp-consent rather than58 // authorize so a connecting client is gated on /mcp-consent rather than
57 // silently granted a token (see ./force-mcp-consent), and enrich the59 // silently granted a token (see ./force-mcp-consent), and enrich the
58 // resulting consent redirect with the registered client name.60 // resulting consent redirect with the registered client name.
61 const config = loadConfig();
59 const authHandler = async (request: Request): Promise<Response> => {62 const authHandler = async (request: Request): Promise<Response> => {
60 const response = await betterAuth.handler(withForcedMcpConsent(request));63 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;
61 if (response.status !== 302) return response;68 if (response.status !== 302) return response;
62 const clientId = consentRedirectClientId(response.headers.get("location"));69 const clientId = consentRedirectClientId(response.headers.get("location"));
63 if (!clientId) return response;70 if (!clientId) return response;
apps/host-selfhost/src/auth/invalid-origin-help.test.ts+50−0
@@ -0,0 +1,50 @@
1import { expect, test } from "@effect/vitest";
2
3import { invalidOriginHelp, originOf, rewriteInvalidOrigin } from "./invalid-origin-help";
4
5const req = (headers: Record<string, string>) =>
6 new Request("https://svc.internal/api/auth/sign-up/email", { method: "POST", headers });
7
8test("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
16test("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
23test("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
44test("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
8const INVALID_ORIGIN = /invalid origin/i;
9
10/** The origin a request came from: the browser `Origin`, else the proxy host. */
11export 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". */
21export 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 */
34export 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 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
5import { expect, test } from "@effect/vitest";
6
7import { loadConfig } from "../config";
8
9// Keep generated secret/key files out of the repo (loadConfig persists them).
10process.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).
21const 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];
29const resetOriginEnv = (): void => {
30 for (const key of PLATFORM_VARS) delete process.env[key];
31 process.env.PORT = "4788";
32};
33
34test("webBaseUrl falls back to localhost with no public origin", () => {
35 resetOriginEnv();
36 expect(loadConfig().webBaseUrl).toBe("http://localhost:4788");
37});
38
39test("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
45test("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
51test("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
57test("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
3import { join } from "node:path";3import { join } from "node:path";
4 4
5import { isValidOrgSlug } from "@executor-js/api";5import { isValidOrgSlug } from "@executor-js/api";
6import {
7 missingPublicOriginWarning,
8 resolvePublicOrigin,
9 shouldWarnMissingPublicOrigin,
10} from "@executor-js/sdk/public-origin";
6 11
7// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
8// Self-host server config — a single typed surface parsed from the13// Self-host server config — a single typed surface parsed from the
@@ -103,6 +108,31 @@ export const resolveAuthSecret = (): string => { · 94 unmodified lines
103 return generated;108 return generated;
104};109};
105 110
111let 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).
120const 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
106export const loadConfig = (): SelfHostConfig => {136export const loadConfig = (): SelfHostConfig => {
107 const port = Number.parseInt(process.env.PORT ?? "4788", 10);137 const port = Number.parseInt(process.env.PORT ?? "4788", 10);
108 const dataDir = resolveDataDir();138 const dataDir = resolveDataDir();
@@ -110,7 +140,7 @@ export const loadConfig = (): SelfHostConfig => { · 1 unmodified line
110 host: process.env.EXECUTOR_HOST ?? "127.0.0.1",140 host: process.env.EXECUTOR_HOST ?? "127.0.0.1",
111 port,141 port,
112 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),142 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),
114 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",144 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
115 authSecret: resolveAuthSecret(),145 authSecret: resolveAuthSecret(),
116 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,146 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
103export const makeSelfHostMcpSeams = (103export const makeSelfHostMcpSeams = (
104 dbHandle: SelfHostDbHandle,104 dbHandle: SelfHostDbHandle,
105 betterAuth: BetterAuthHandle,105 betterAuth: BetterAuthHandle,
106 webBaseUrl?: string,
106): SelfHostMcpSeams => {107): SelfHostMcpSeams => {
107 const sessionStore = makeSelfHostMcpSessionStore(dbHandle);108 const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl);
108 const auth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = selfHostMcpAuth.pipe(109 const auth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = selfHostMcpAuth.pipe(
109 Layer.provide(Layer.succeed(BetterAuth)(betterAuth)),110 Layer.provide(Layer.succeed(BetterAuth)(betterAuth)),
110 );111 );
apps/host-selfhost/src/mcp/session-store.ts+10−2
@@ -24,12 +24,20 @@ import { SelfHostExecutionStackLayer } from "../execution"; · 23 unmodified lines
24 24
25export { McpEngineBuildError } from "@executor-js/host-mcp/in-memory-session-store";25export { McpEngineBuildError } from "@executor-js/host-mcp/in-memory-session-store";
26 26
27/** Build the in-process session store (plus its `close()` hook) over the DB handle. */27/**
28export const makeSelfHostMcpSessionStore = (db: SelfHostDbHandle): InMemoryMcpSessionStore =>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 */
32export const makeSelfHostMcpSessionStore = (
33 db: SelfHostDbHandle,
34 webBaseUrl?: string,
35): InMemoryMcpSessionStore =>
29 makeInMemoryMcpSessionStore(36 makeInMemoryMcpSessionStore(
30 makeMcpBuildServer(37 makeMcpBuildServer(
31 SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),38 SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),
32 ),39 ),
40 { webBaseUrl },
33 );41 );
34 42
35/** The `McpSessionStore` envelope seam over a freshly built in-process store. */43/** 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
24 "./client": "./src/client.ts",24 "./client": "./src/client.ts",
25 "./testing": "./src/testing.ts",25 "./testing": "./src/testing.ts",
26 "./migration": "./src/migration-spec.ts",26 "./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"
28 },29 },
29 "publishConfig": {30 "publishConfig": {
30 "access": "public",31 "access": "public",
@@ -76,6 +77,12 @@ · 45 unmodified lines
76 "types": "./dist/http-auth.d.ts",77 "types": "./dist/http-auth.d.ts",
77 "default": "./dist/http-auth.js"78 "default": "./dist/http-auth.js"
78 }79 }
80 },
81 "./public-origin": {
82 "import": {
83 "types": "./dist/public-origin.d.ts",
84 "default": "./dist/public-origin.js"
85 }
79 }86 }
80 }87 }
81 },88 },
packages/core/sdk/src/public-origin.test.ts+52−0
@@ -0,0 +1,52 @@
1import { expect, test } from "@effect/vitest";
2
3import {
4 getPlatformOrigin,
5 missingPublicOriginWarning,
6 resolvePublicOrigin,
7 shouldWarnMissingPublicOrigin,
8} from "./public-origin";
9
10test("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
24test("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
37test("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
45test("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). */
14export 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 */
26export 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 */
52export 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 */
66export 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. */
70export 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
5import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution";5import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution";
6 6
7import {7import {
8 approvalUrlForRequest,8 buildResumeApprovalUrl,
9 decodeResumeResponse,9 decodeResumeResponse,
10 formatResumeAcknowledgement,10 formatResumeAcknowledgement,
11 readElicitationMode,11 readElicitationMode,
@@ -122,6 +122,12 @@ const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/res · 110 unmodified lines
122 */122 */
123export const makeInMemoryMcpSessionStore = (123export const makeInMemoryMcpSessionStore = (
124 buildServer: McpBuildServer,124 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 } = {},
125): InMemoryMcpSessionStore => {131): InMemoryMcpSessionStore => {
126 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();132 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
127 const servers = new Map<string, McpServer>();133 const servers = new Map<string, McpServer>();
@@ -192,7 +198,14 @@ export const makeInMemoryMcpSessionStore = ( · 64 unmodified lines
192 return {198 return {
193 elicitationMode: {199 elicitationMode: {
194 mode: "browser",200 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 }),
196 },209 },
197 browserApprovalStore: approvals.store,210 browserApprovalStore: approvals.store,
198 };211 };