* 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 | 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 | }), |
| @@ -1,6 +1,9 @@ | |||
| 1 | import type { D1Database, DurableObjectNamespace, R2Bucket } from "@cloudflare/workers-types"; | 1 | import type { D1Database, DurableObjectNamespace, R2Bucket } from "@cloudflare/workers-types"; |
| 2 | 2 | ||
| 3 | import { isValidOrgSlug } from "@executor-js/api"; | 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 | // Cloudflare host config. Unlike self-host (process.env + a data dir), a Worker | 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 | "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 absent | 125 | // 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 | }; |
| @@ -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 | }); |
| @@ -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, |
| @@ -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 carry | 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 | 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). With | 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 | import { IdentityProvider } from "@executor-js/api/server"; | 3 | import { IdentityProvider } from "@executor-js/api/server"; |
| 4 | 4 | ||
| 5 | import { loadConfig } from "../config"; | ||
| 5 | import type { SelfHostDbHandle } from "../db/self-host-db"; | 6 | import type { SelfHostDbHandle } from "../db/self-host-db"; |
| 6 | import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; | 7 | import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; |
| 7 | import { betterAuthIdentityLayer } from "./identity"; | 8 | import { betterAuthIdentityLayer } from "./identity"; |
| 8 | import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent"; | 9 | import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent"; |
| 10 | import { rewriteInvalidOrigin } from "./invalid-origin-help"; | ||
| 9 | 11 | ||
| 10 | export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; | 12 | export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; |
| 11 | export { betterAuthIdentityLayer } from "./identity"; | 13 | export { 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 than | 58 | // authorize so a connecting client is gated on /mcp-consent rather than |
| 57 | // silently granted a token (see ./force-mcp-consent), and enrich the | 59 | // 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; |
| @@ -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 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { isValidOrgSlug } from "@executor-js/api"; | 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 | // Self-host server config — a single typed surface parsed from the | 13 | // 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 | ||
| 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 | export const loadConfig = (): SelfHostConfig => { | 136 | export 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, |
| @@ -103,8 +103,9 @@ const makeApprovalHandler = · 102 unmodified lines | |||
| 103 | export const makeSelfHostMcpSeams = ( | 103 | export 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 | ); |
| @@ -24,12 +24,20 @@ import { SelfHostExecutionStackLayer } from "../execution"; · 23 unmodified lines | |||
| 24 | 24 | ||
| 25 | export { McpEngineBuildError } from "@executor-js/host-mcp/in-memory-session-store"; | 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. */ | 27 | /** |
| 28 | export 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 | */ | ||
| 32 | export 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. */ |
| @@ -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 | }, |
| @@ -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 | import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution"; | 5 | import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution"; |
| 6 | 6 | ||
| 7 | import { | 7 | import { |
| 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 | */ |
| 123 | export const makeInMemoryMcpSessionStore = ( | 123 | export 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 | }; |