* 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.
Unified · Split
| @@ -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. | |
| @@ -90,9 +90,12 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 89 unmodified lines | ||
| 90 | 90 | } |
| 91 | 91 | |
| 92 | 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 | 96 | const { link } = yield* workos.generateDomainVerificationPortalLink( |
| 94 | 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 | 100 | return { link }; |
| 98 | 101 | }), |
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | import type { D1Database, DurableObjectNamespace, R2Bucket } from "@cloudflare/workers-types"; |
| 2 | 2 | |
| 3 | 3 | import { isValidOrgSlug } from "@executor-js/api"; |
| 4 | +import { missingPublicOriginWarning, resolvePublicOrigin } from "@executor-js/sdk/public-origin"; | |
| 5 | + | |
| 6 | +let warnedNoCloudflareOrigin = false; | |
| 4 | 7 | |
| 5 | 8 | // --------------------------------------------------------------------------- |
| 6 | 9 | // 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 | 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 | 114 | return { |
| 101 | 115 | accessTeamDomain: env.ACCESS_TEAM_DOMAIN.replace(/^https?:\/\//, "").replace(/\/+$/, ""), |
| 102 | 116 | accessAud: env.ACCESS_AUD, |
| @@ -108,9 +122,13 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { · 5 unmodified lines | ||
| 108 | 122 | organizationSlug: resolveOrgSlug(env.SELF_HOSTED_ORG_SLUG), |
| 109 | 123 | secretKey, |
| 110 | 124 | 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, | |
| 115 | 133 | }; |
| 116 | 134 | }; |
| @@ -91,12 +91,24 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 90 unmodified lines | ||
| 91 | 91 | elicitationMode === "browser" |
| 92 | 92 | ? { |
| 93 | 93 | 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", | |
| 97 | 108 | executionId, |
| 98 | 109 | sessionId: self.sessionId, |
| 99 | − }), | |
| 110 | + }); | |
| 111 | + }, | |
| 100 | 112 | } |
| 101 | 113 | : { mode: elicitationMode }, |
| 102 | 114 | }); |
| @@ -68,7 +68,9 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 67 unmodified lines | ||
| 68 | 68 | const { identityLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle); |
| 69 | 69 | |
| 70 | 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 | 75 | const { appLayer, toWebHandler } = ExecutorApp.make({ |
| 74 | 76 | plugins: selfHostPlugins, |
| @@ -88,9 +88,15 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 87 unmodified lines | ||
| 88 | 88 | type: "sqlite" as const, |
| 89 | 89 | }, |
| 90 | 90 | secret, |
| 91 | − baseURL: config.webBaseUrl, | |
| 92 | 91 | // 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 | 100 | trustedOrigins: [config.webBaseUrl], |
| 95 | 101 | emailAndPassword: { enabled: true }, |
| 96 | 102 | // `apiKey` issues long-lived personal keys (the API-keys page). With |
| @@ -2,10 +2,12 @@ import { Layer } from "effect"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { IdentityProvider } from "@executor-js/api/server"; |
| 4 | 4 | |
| 5 | +import { loadConfig } from "../config"; | |
| 5 | 6 | import type { SelfHostDbHandle } from "../db/self-host-db"; |
| 6 | 7 | import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; |
| 7 | 8 | import { betterAuthIdentityLayer } from "./identity"; |
| 8 | 9 | import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent"; |
| 10 | +import { rewriteInvalidOrigin } from "./invalid-origin-help"; | |
| 9 | 11 | |
| 10 | 12 | export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; |
| 11 | 13 | export { betterAuthIdentityLayer } from "./identity"; |
| @@ -56,8 +58,13 @@ export const resolveAuthProviders = async ( · 44 unmodified lines | ||
| 56 | 58 | // authorize so a connecting client is gated on /mcp-consent rather than |
| 57 | 59 | // silently granted a token (see ./force-mcp-consent), and enrich the |
| 58 | 60 | // resulting consent redirect with the registered client name. |
| 61 | + const config = loadConfig(); | |
| 59 | 62 | const authHandler = async (request: Request): Promise<Response> => { |
| 60 | 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 | 68 | if (response.status !== 302) return response; |
| 62 | 69 | const clientId = consentRedirectClientId(response.headers.get("location")); |
| 63 | 70 | if (!clientId) return response; |
| @@ -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 | +}); | |
| @@ -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 | +}; | |
| @@ -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 | +}); | |
| @@ -3,6 +3,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; · 2 unmodified lines | ||
| 3 | 3 | import { join } from "node:path"; |
| 4 | 4 | |
| 5 | 5 | import { isValidOrgSlug } from "@executor-js/api"; |
| 6 | +import { | |
| 7 | + missingPublicOriginWarning, | |
| 8 | + resolvePublicOrigin, | |
| 9 | + shouldWarnMissingPublicOrigin, | |
| 10 | +} from "@executor-js/sdk/public-origin"; | |
| 6 | 11 | |
| 7 | 12 | // --------------------------------------------------------------------------- |
| 8 | 13 | // Self-host server config — a single typed surface parsed from the |
| @@ -103,6 +108,31 @@ export const resolveAuthSecret = (): string => { · 94 unmodified lines | ||
| 103 | 108 | return generated; |
| 104 | 109 | }; |
| 105 | 110 | |
| 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 | + | |
| 106 | 136 | export const loadConfig = (): SelfHostConfig => { |
| 107 | 137 | const port = Number.parseInt(process.env.PORT ?? "4788", 10); |
| 108 | 138 | const dataDir = resolveDataDir(); |
| @@ -110,7 +140,7 @@ export const loadConfig = (): SelfHostConfig => { · 1 unmodified line | ||
| 110 | 140 | host: process.env.EXECUTOR_HOST ?? "127.0.0.1", |
| 111 | 141 | port, |
| 112 | 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 | 144 | allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", |
| 115 | 145 | authSecret: resolveAuthSecret(), |
| 116 | 146 | bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, |
| @@ -103,8 +103,9 @@ const makeApprovalHandler = · 102 unmodified lines | ||
| 103 | 103 | export const makeSelfHostMcpSeams = ( |
| 104 | 104 | dbHandle: SelfHostDbHandle, |
| 105 | 105 | betterAuth: BetterAuthHandle, |
| 106 | + webBaseUrl?: string, | |
| 106 | 107 | ): SelfHostMcpSeams => { |
| 107 | − const sessionStore = makeSelfHostMcpSessionStore(dbHandle); | |
| 108 | + const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl); | |
| 108 | 109 | const auth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = selfHostMcpAuth.pipe( |
| 109 | 110 | Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), |
| 110 | 111 | ); |
| @@ -24,12 +24,20 @@ import { SelfHostExecutionStackLayer } from "../execution"; · 23 unmodified lines | ||
| 24 | 24 | |
| 25 | 25 | export { 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. */ | |
| 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 => | |
| 29 | 36 | makeInMemoryMcpSessionStore( |
| 30 | 37 | makeMcpBuildServer( |
| 31 | 38 | SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), |
| 32 | 39 | ), |
| 40 | + { webBaseUrl }, | |
| 33 | 41 | ); |
| 34 | 42 | |
| 35 | 43 | /** The `McpSessionStore` envelope seam over a freshly built in-process store. */ |
| @@ -24,7 +24,8 @@ · 23 unmodified lines | ||
| 24 | 24 | "./client": "./src/client.ts", |
| 25 | 25 | "./testing": "./src/testing.ts", |
| 26 | 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 | 30 | "publishConfig": { |
| 30 | 31 | "access": "public", |
| @@ -76,6 +77,12 @@ · 45 unmodified lines | ||
| 76 | 77 | "types": "./dist/http-auth.d.ts", |
| 77 | 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 | }, |
| @@ -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 | +}); | |
| @@ -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).`; | |
| @@ -5,7 +5,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ · 4 unmodified lines | ||
| 5 | 5 | import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution"; |
| 6 | 6 | |
| 7 | 7 | import { |
| 8 | − approvalUrlForRequest, | |
| 8 | + buildResumeApprovalUrl, | |
| 9 | 9 | decodeResumeResponse, |
| 10 | 10 | formatResumeAcknowledgement, |
| 11 | 11 | readElicitationMode, |
| @@ -122,6 +122,12 @@ const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/res · 110 unmodified lines | ||
| 122 | 122 | */ |
| 123 | 123 | export const makeInMemoryMcpSessionStore = ( |
| 124 | 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 | 131 | ): InMemoryMcpSessionStore => { |
| 126 | 132 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); |
| 127 | 133 | const servers = new Map<string, McpServer>(); |
| @@ -192,7 +198,14 @@ export const makeInMemoryMcpSessionStore = ( · 64 unmodified lines | ||
| 192 | 198 | return { |
| 193 | 199 | elicitationMode: { |
| 194 | 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 | 210 | browserApprovalStore: approvals.store, |
| 198 | 211 | }; |