The apps HTTP routes (publish/invoke/workflow/ui/SSE) were mounted as an extension route outside the protected API and never authenticated. Thread the host's Better Auth identity check into the apps handler (session cookie / bearer / bearer-as-api-key), 401 unauthenticated including SSE. The subsystem singleton reads a live auth ref per request and is fail-closed until it is set.
Unified · Split
| @@ -75,7 +75,32 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 74 unmodified lines | |||
| 75 | // closes over). Its HTTP surface mounts under /api/apps/*; published tools are | 75 | // closes over). Its HTTP surface mounts under /api/apps/*; published tools are |
| 76 | // catalog citizens through its source plugin; its extra MCP surface (publish | 76 | // catalog citizens through its source plugin; its extra MCP surface (publish |
| 77 | // door, skills, ui:// resources) is registered on the real MCP server below. | 77 | // door, skills, ui:// resources) is registered on the real MCP server below. |
| 78 | const apps = getSelfHostAppsSubsystem(); | 78 | // |
| 79 | // The HTTP surface (publish/invoke/workflows/ui/SSE) mutates per-scope state | ||
| 80 | // and reaches real integrations, so it is authenticated with the SAME Better | ||
| 81 | // Auth credential the rest of `/api` requires. We resolve a session from the | ||
| 82 | // same three credential shapes the identity seam accepts (session cookie, | ||
| 83 | // Bearer session token, Bearer value retried as an x-api-key), matching | ||
| 84 | // `betterAuthIdentityLayer` so a token that works on `/api` works here too. | ||
| 85 | const appsAuthenticate = async (request: Request): Promise<boolean> => { | ||
| 86 | const auth = betterAuth.auth; | ||
| 87 | const session = await auth.api.getSession({ headers: request.headers }).catch(() => null); | ||
| 88 | if (session) return true; | ||
| 89 | const authorization = request.headers.get("authorization"); | ||
| 90 | const token = | ||
| 91 | authorization && authorization.toLowerCase().startsWith("bearer ") | ||
| 92 | ? authorization.slice(7).trim() | ||
| 93 | : undefined; | ||
| 94 | if (!token) return false; | ||
| 95 | const apiKeySession = await auth.api | ||
| 96 | .getSession({ headers: new Headers({ "x-api-key": token }) }) | ||
| 97 | .catch(() => null); | ||
| 98 | return apiKeySession != null; | ||
| 99 | }; | ||
| 100 | const apps = getSelfHostAppsSubsystem({ | ||
| 101 | authenticate: appsAuthenticate, | ||
| 102 | webBaseUrl: config.webBaseUrl, | ||
| 103 | }); | ||
| 79 | 104 | ||
| 80 | // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- | 105 | // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- |
| 81 | // Pass the pinned public origin so browser-approval URLs are reachable behind | 106 | // Pass the pinned public origin so browser-approval URLs are reachable behind |
| @@ -4,11 +4,30 @@ import { join } from "node:path"; · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { afterAll, beforeAll, expect, test } from "@effect/vitest"; | 5 | import { afterAll, beforeAll, expect, test } from "@effect/vitest"; |
| 6 | 6 | ||
| 7 | import { mintInviteCode } from "./testing/mint-invite"; | ||
| 8 | |||
| 7 | // Point config at a throwaway data dir before importing the app graph. | 9 | // Point config at a throwaway data dir before importing the app graph. |
| 8 | process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-apps-")); | 10 | process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-apps-")); |
| 11 | process.env.BETTER_AUTH_SECRET = "apps-node-secret-0123456789-abcdefghij-klmnop"; | ||
| 12 | process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@apps-node.test"; | ||
| 13 | process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; | ||
| 9 | 14 | ||
| 10 | let handler!: (request: Request) => Promise<Response>; | 15 | let handler!: (request: Request) => Promise<Response>; |
| 11 | let dispose: () => Promise<void> = async () => {}; | 16 | let dispose: () => Promise<void> = async () => {}; |
| 17 | let token = ""; | ||
| 18 | |||
| 19 | // A JSON fetch that carries the bearer (the apps HTTP surface is authenticated). | ||
| 20 | const authed = (path: string, init?: RequestInit) => | ||
| 21 | handler( | ||
| 22 | new Request(`http://localhost${path}`, { | ||
| 23 | ...init, | ||
| 24 | headers: { | ||
| 25 | authorization: `Bearer ${token}`, | ||
| 26 | "content-type": "application/json", | ||
| 27 | ...(init?.headers ?? {}), | ||
| 28 | }, | ||
| 29 | }), | ||
| 30 | ); | ||
| 12 | 31 | ||
| 13 | beforeAll(async () => { | 32 | beforeAll(async () => { |
| 14 | // Boot the REAL self-host app handler (makeSelfHostApp), which mounts the apps | 33 | // Boot the REAL self-host app handler (makeSelfHostApp), which mounts the apps |
| @@ -17,9 +36,53 @@ beforeAll(async () => { · 2 unmodified lines | |||
| 17 | const app = await makeSelfHostApiHandler(); | 36 | const app = await makeSelfHostApiHandler(); |
| 18 | handler = app.handler; | 37 | handler = app.handler; |
| 19 | dispose = app.dispose; | 38 | dispose = app.dispose; |
| 39 | |||
| 40 | // Sign up to get a bearer token (the apps surface is behind Better Auth). | ||
| 41 | const inviteCode = await mintInviteCode(handler); | ||
| 42 | const su = await handler( | ||
| 43 | new Request("http://localhost/api/auth/sign-up/email", { | ||
| 44 | method: "POST", | ||
| 45 | headers: { "content-type": "application/json" }, | ||
| 46 | body: JSON.stringify({ | ||
| 47 | email: "u@apps-node.test", | ||
| 48 | password: "password-12345678", | ||
| 49 | name: "U", | ||
| 50 | inviteCode, | ||
| 51 | }), | ||
| 52 | }), | ||
| 53 | ); | ||
| 54 | token = su.headers.get("set-auth-token") ?? ""; | ||
| 55 | expect(token).not.toBe(""); | ||
| 20 | }); | 56 | }); |
| 21 | afterAll(() => dispose()); | 57 | afterAll(() => dispose()); |
| 22 | 58 | ||
| 59 | // Fix 1: the apps HTTP surface rejects unauthenticated requests with 401. | ||
| 60 | test("apps HTTP surface requires auth (401 without a credential)", async () => { | ||
| 61 | // publish / invoke / workflow-start / SSE all 401 unauthenticated. | ||
| 62 | const publish = await handler( | ||
| 63 | new Request("http://localhost/api/apps/default/publish", { | ||
| 64 | method: "POST", | ||
| 65 | headers: { "content-type": "application/json" }, | ||
| 66 | body: JSON.stringify({ files: FILES }), | ||
| 67 | }), | ||
| 68 | ); | ||
| 69 | expect(publish.status).toBe(401); | ||
| 70 | |||
| 71 | const invoke = await handler( | ||
| 72 | new Request("http://localhost/api/apps/default/tools/note", { | ||
| 73 | method: "POST", | ||
| 74 | headers: { "content-type": "application/json" }, | ||
| 75 | body: JSON.stringify({ args: { text: "x" }, bindings: {} }), | ||
| 76 | }), | ||
| 77 | ); | ||
| 78 | expect(invoke.status).toBe(401); | ||
| 79 | |||
| 80 | const sse = await handler( | ||
| 81 | new Request("http://localhost/api/apps/default/live", { method: "GET" }), | ||
| 82 | ); | ||
| 83 | expect(sse.status).toBe(401); | ||
| 84 | }, 30_000); | ||
| 85 | |||
| 23 | // A minimal published app: one tool that writes the scope db, and a ui view. | 86 | // A minimal published app: one tool that writes the scope db, and a ui view. |
| 24 | const FILES = { | 87 | const FILES = { |
| 25 | "tools/note.ts": | 88 | "tools/note.ts": |
| @@ -32,14 +95,12 @@ const FILES = { · 6 unmodified lines | |||
| 32 | `export default function App() { return null; }`, | 95 | `export default function App() { return null; }`, |
| 33 | }; | 96 | }; |
| 34 | 97 | ||
| 35 | test("apps HTTP surface is mounted: publish then serve the ui bundle", async () => { | 98 | test("apps HTTP surface is mounted (authenticated): publish then serve the ui bundle", async () => { |
| 36 | // Publish over the booted server's /api/apps/:scope/publish route. | 99 | // Publish over the booted server's /api/apps/:scope/publish route (authed). |
| 37 | const publishRes = await handler( | 100 | const publishRes = await authed("/api/apps/default/publish", { |
| 38 | new Request("http://localhost/api/apps/default/publish", { | 101 | method: "POST", |
| 39 | method: "POST", | 102 | body: JSON.stringify({ files: FILES }), |
| 40 | body: JSON.stringify({ files: FILES }), | 103 | }); |
| 41 | }), | ||
| 42 | ); | ||
| 43 | expect(publishRes.status).toBe(200); | 104 | expect(publishRes.status).toBe(200); |
| 44 | const published = (await publishRes.json()) as { | 105 | const published = (await publishRes.json()) as { |
| 45 | descriptor: { tools: { name: string }[]; ui: { name: string }[] }; | 106 | descriptor: { tools: { name: string }[]; ui: { name: string }[] }; |
| @@ -48,20 +109,21 @@ test("apps HTTP surface is mounted: publish then serve the ui bundle", async () · 2 unmodified lines | |||
| 48 | expect(published.descriptor.ui.map((u) => u.name)).toEqual(["board"]); | 109 | expect(published.descriptor.ui.map((u) => u.name)).toEqual(["board"]); |
| 49 | 110 | ||
| 50 | // The ui bundle is served (compiled JS) with its title header. | 111 | // The ui bundle is served (compiled JS) with its title header. |
| 51 | const uiRes = await handler( | 112 | const uiRes = await authed("/api/apps/default/ui/board", { method: "GET" }); |
| 52 | new Request("http://localhost/api/apps/default/ui/board", { method: "GET" }), | ||
| 53 | ); | ||
| 54 | expect(uiRes.status).toBe(200); | 113 | expect(uiRes.status).toBe(200); |
| 55 | expect(uiRes.headers.get("content-type")).toContain("javascript"); | 114 | expect(uiRes.headers.get("content-type")).toContain("javascript"); |
| 56 | expect(uiRes.headers.get("x-ui-title")).toBe("Board"); | 115 | expect(uiRes.headers.get("x-ui-title")).toBe("Board"); |
| 57 | 116 | ||
| 117 | // The HTML document variant (Fix 10 fallback target) is served behind auth. | ||
| 118 | const docRes = await authed("/api/apps/default/ui/board?document=html", { method: "GET" }); | ||
| 119 | expect(docRes.status).toBe(200); | ||
| 120 | expect(docRes.headers.get("content-type")).toContain("text/html"); | ||
| 121 | |||
| 58 | // Invoke the tool (scope-db path is live in the running server). | 122 | // Invoke the tool (scope-db path is live in the running server). |
| 59 | const invokeRes = await handler( | 123 | const invokeRes = await authed("/api/apps/default/tools/note", { |
| 60 | new Request("http://localhost/api/apps/default/tools/note", { | 124 | method: "POST", |
| 61 | method: "POST", | 125 | body: JSON.stringify({ args: { text: "hello" }, bindings: {} }), |
| 62 | body: JSON.stringify({ args: { text: "hello" }, bindings: {} }), | 126 | }); |
| 63 | }), | ||
| 64 | ); | ||
| 65 | expect(invokeRes.status).toBe(200); | 127 | expect(invokeRes.status).toBe(200); |
| 66 | const invoked = (await invokeRes.json()) as { result: { count: number } }; | 128 | const invoked = (await invokeRes.json()) as { result: { count: number } }; |
| 67 | expect(invoked.result.count).toBe(1); | 129 | expect(invoked.result.count).toBe(1); |
| @@ -54,11 +54,36 @@ const notImplementedResolver: ClientResolver = { · 53 unmodified lines | |||
| 54 | ), | 54 | ), |
| 55 | }; | 55 | }; |
| 56 | 56 | ||
| 57 | /** Options for the apps subsystem singleton. `authenticate` gates the HTTP | ||
| 58 | * surface; supplied by `app.ts` from the resolved Better Auth instance. | ||
| 59 | * `webBaseUrl` is the public origin used to build the `apps_open_ui` HTTP | ||
| 60 | * fallback URL. */ | ||
| 61 | export interface SelfHostAppsSubsystemOptions { | ||
| 62 | readonly authenticate?: (request: Request) => Promise<boolean>; | ||
| 63 | readonly webBaseUrl?: string; | ||
| 64 | } | ||
| 65 | |||
| 57 | // The boot-time singleton. Built lazily on first access so importing this module | 66 | // The boot-time singleton. Built lazily on first access so importing this module |
| 58 | // (which executor.config.ts does) does not do filesystem work at import time. | 67 | // (which executor.config.ts does) does not do filesystem work at import time. |
| 59 | let subsystem: SelfHostApps | undefined; | 68 | let subsystem: SelfHostApps | undefined; |
| 60 | 69 | ||
| 61 | export const getSelfHostAppsSubsystem = (): SelfHostApps => { | 70 | // The live authenticate + fallback-URL refs. The subsystem is a singleton and is |
| 71 | // FIRST built by executor.config.ts (which has no auth), then app.ts calls again | ||
| 72 | // WITH auth. So we can't bake auth into the http handler at build time — instead | ||
| 73 | // the handler reads these mutable refs per request, and app.ts sets them once the | ||
| 74 | // Better Auth instance is resolved. Until set, the surface DENIES all requests | ||
| 75 | // (fail-closed), so a misconfigured boot never exposes the surface unauthed. | ||
| 76 | let authenticateRef: ((request: Request) => Promise<boolean>) | undefined; | ||
| 77 | let webBaseUrlRef: string | undefined; | ||
| 78 | |||
| 79 | export const getSelfHostAppsSubsystem = ( | ||
| 80 | options: SelfHostAppsSubsystemOptions = {}, | ||
| 81 | ): SelfHostApps => { | ||
| 82 | // Apply any newly-supplied config to the live refs (app.ts's call carries the | ||
| 83 | // real auth even though config.ts built the singleton first). | ||
| 84 | if (options.authenticate) authenticateRef = options.authenticate; | ||
| 85 | if (options.webBaseUrl) webBaseUrlRef = options.webBaseUrl; | ||
| 86 | |||
| 62 | if (!subsystem) { | 87 | if (!subsystem) { |
| 63 | subsystem = makeSelfHostApps({ | 88 | subsystem = makeSelfHostApps({ |
| 64 | dataDir: resolveDataDir(), | 89 | dataDir: resolveDataDir(), |
| @@ -71,6 +96,13 @@ export const getSelfHostAppsSubsystem = (): SelfHostApps => { · 6 unmodified lines | |||
| 71 | // so external integration calls resolve the user's connection + credential | 96 | // so external integration calls resolve the user's connection + credential |
| 72 | // at the boundary and dispatch over the request's HttpClient. | 97 | // at the boundary and dispatch over the request's HttpClient. |
| 73 | makeResolver: ({ ctx }) => makeCtxResolver(ctx), | 98 | makeResolver: ({ ctx }) => makeCtxResolver(ctx), |
| 99 | // Read the live auth ref per request. Fail-closed: if app.ts hasn't wired | ||
| 100 | // the Better Auth check yet, deny (never expose the surface unauthed). | ||
| 101 | authenticate: (request) => | ||
| 102 | authenticateRef ? authenticateRef(request) : Promise.resolve(false), | ||
| 103 | // Read the live web base URL ref (used for the apps_open_ui fallback URL). | ||
| 104 | webBaseUrl: undefined, | ||
| 105 | webBaseUrlFn: () => webBaseUrlRef, | ||
| 74 | }); | 106 | }); |
| 75 | } | 107 | } |
| 76 | return subsystem; | 108 | return subsystem; |
| @@ -79,6 +111,8 @@ export const getSelfHostAppsSubsystem = (): SelfHostApps => { · 2 unmodified lines | |||
| 79 | /** Reset the singleton (tests build fresh instances per data dir). */ | 111 | /** Reset the singleton (tests build fresh instances per data dir). */ |
| 80 | export const resetSelfHostAppsSubsystem = (): void => { | 112 | export const resetSelfHostAppsSubsystem = (): void => { |
| 81 | subsystem = undefined; | 113 | subsystem = undefined; |
| 114 | authenticateRef = undefined; | ||
| 115 | webBaseUrlRef = undefined; | ||
| 82 | }; | 116 | }; |
| 83 | 117 | ||
| 84 | /** @deprecated use getSelfHostAppsSubsystem(); kept for existing call sites. */ | 118 | /** @deprecated use getSelfHostAppsSubsystem(); kept for existing call sites. */ |
| @@ -23,6 +23,18 @@ export interface AppsHttpDeps { · 22 unmodified lines | |||
| 23 | readonly runtime: AppsRuntime; | 23 | readonly runtime: AppsRuntime; |
| 24 | /** Mount prefix (default "/api/apps"). */ | 24 | /** Mount prefix (default "/api/apps"). */ |
| 25 | readonly prefix?: string; | 25 | readonly prefix?: string; |
| 26 | /** | ||
| 27 | * Authenticate an inbound request. Returns `true` when the caller is | ||
| 28 | * authorized and `false` otherwise; the handler answers `false` with a 401. | ||
| 29 | * | ||
| 30 | * The apps surface (publish / invoke / workflow lifecycle / SSE) mutates | ||
| 31 | * per-scope state and reaches real integrations, so it MUST be behind the same | ||
| 32 | * credential the rest of `/api` requires. This seam lets the host thread its | ||
| 33 | * own identity check (self-host's Better Auth session/bearer/api-key) in | ||
| 34 | * without this package importing the host's auth stack. When omitted (tests | ||
| 35 | * that drive the runtime directly) every request is allowed. | ||
| 36 | */ | ||
| 37 | readonly authenticate?: (request: Request) => Promise<boolean>; | ||
| 26 | } | 38 | } |
| 27 | 39 | ||
| 28 | const json = (body: unknown, status = 200): Response => | 40 | const json = (body: unknown, status = 200): Response => |
| @@ -42,6 +54,20 @@ export const makeAppsHttpRoutes = ( · 13 unmodified lines | |||
| 42 | const handler = async (request: Request): Promise<Response> => { | 54 | const handler = async (request: Request): Promise<Response> => { |
| 43 | const url = new URL(request.url); | 55 | const url = new URL(request.url); |
| 44 | if (!url.pathname.startsWith(prefix)) return new Response("not found", { status: 404 }); | 56 | if (!url.pathname.startsWith(prefix)) return new Response("not found", { status: 404 }); |
| 57 | |||
| 58 | // Auth gate: the whole apps surface (publish/invoke/workflows/ui/SSE) is | ||
| 59 | // behind the host's identity check. An unauthenticated caller gets a 401 | ||
| 60 | // BEFORE any route logic runs — including the SSE stream. | ||
| 61 | if (deps.authenticate) { | ||
| 62 | const ok = await deps.authenticate(request).catch(() => false); | ||
| 63 | if (!ok) { | ||
| 64 | return new Response(JSON.stringify({ error: "Unauthorized" }), { | ||
| 65 | status: 401, | ||
| 66 | headers: { "content-type": "application/json" }, | ||
| 67 | }); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 45 | const rest = url.pathname.slice(prefix.length).replace(/^\//, ""); | 71 | const rest = url.pathname.slice(prefix.length).replace(/^\//, ""); |
| 46 | const parts = rest.split("/").filter(Boolean); | 72 | const parts = rest.split("/").filter(Boolean); |
| 47 | // parts[0] = scope | 73 | // parts[0] = scope |
| @@ -134,8 +160,23 @@ export const makeAppsHttpRoutes = ( · 86 unmodified lines | |||
| 134 | return json({ runs }); | 160 | return json({ runs }); |
| 135 | } | 161 | } |
| 136 | 162 | ||
| 137 | // GET :scope/ui/:name -> compiled JS bundle | 163 | // GET :scope/ui/:name -> compiled JS bundle, OR the self-booting HTML |
| 164 | // document when `?document=html` (Fix 10: the fallback a non-UI MCP client | ||
| 165 | // opens in a browser). Both are behind the same auth gate as the rest of | ||
| 166 | // the surface. | ||
| 138 | if (parts[1] === "ui" && parts[2] && request.method === "GET") { | 167 | if (parts[1] === "ui" && parts[2] && request.method === "GET") { |
| 168 | if (url.searchParams.get("document") === "html") { | ||
| 169 | const doc = await run(runtime.getUiDocument(scope, parts[2])); | ||
| 170 | if (!doc) return new Response("ui not found", { status: 404 }); | ||
| 171 | return new Response(doc.html, { | ||
| 172 | status: 200, | ||
| 173 | headers: { | ||
| 174 | "content-type": "text/html; charset=utf-8", | ||
| 175 | "x-ui-title": doc.title ?? "", | ||
| 176 | "x-ui-max-height": String(doc.maxHeight ?? ""), | ||
| 177 | }, | ||
| 178 | }); | ||
| 179 | } | ||
| 139 | const bundle = await run(runtime.getUiBundle(scope, parts[2])); | 180 | const bundle = await run(runtime.getUiBundle(scope, parts[2])); |
| 140 | if (!bundle) return new Response("ui not found", { status: 404 }); | 181 | if (!bundle) return new Response("ui not found", { status: 404 }); |
| 141 | return new Response(bundle.code, { | 182 | return new Response(bundle.code, { |
| @@ -28,6 +28,18 @@ export interface SelfHostAppsOptions { · 27 unmodified lines | |||
| 28 | /** Optional per-request resolver factory for the catalog invoke path (built | 28 | /** Optional per-request resolver factory for the catalog invoke path (built |
| 29 | * from the invoking executor context). */ | 29 | * from the invoking executor context). */ |
| 30 | readonly makeResolver?: AppsPluginOptions["makeResolver"]; | 30 | readonly makeResolver?: AppsPluginOptions["makeResolver"]; |
| 31 | /** Authenticate the apps HTTP surface (publish/invoke/workflows/ui/SSE). The | ||
| 32 | * host threads its identity check here so this package doesn't import the | ||
| 33 | * host's auth stack. Omit to leave the surface open (tests). */ | ||
| 34 | readonly authenticate?: (request: Request) => Promise<boolean>; | ||
| 35 | /** Absolute base URL (e.g. `https://host`) used to build the HTTP fallback URL | ||
| 36 | * for `apps_open_ui` when the MCP client can't render UI inline. Omit to | ||
| 37 | * serve no fallback URL (the tool returns `fallback_unavailable`). */ | ||
| 38 | readonly webBaseUrl?: string; | ||
| 39 | /** Lazy variant of `webBaseUrl` for hosts whose base URL is resolved AFTER the | ||
| 40 | * subsystem singleton is built (self-host: config.ts builds it, app.ts sets | ||
| 41 | * the URL). Read per registration; takes precedence over `webBaseUrl`. */ | ||
| 42 | readonly webBaseUrlFn?: () => string | undefined; | ||
| 31 | } | 43 | } |
| 32 | 44 | ||
| 33 | export interface SelfHostApps { | 45 | export interface SelfHostApps { |
| @@ -48,7 +60,10 @@ export const makeSelfHostApps = (options: SelfHostAppsOptions): SelfHostApps => · 14 unmodified lines | |||
| 48 | store, | 60 | store, |
| 49 | resolver: options.resolver, | 61 | resolver: options.resolver, |
| 50 | }); | 62 | }); |
| 51 | const http = makeAppsHttpRoutes({ runtime: host.runtime }); | 63 | const http = makeAppsHttpRoutes({ |
| 64 | runtime: host.runtime, | ||
| 65 | authenticate: options.authenticate, | ||
| 66 | }); | ||
| 52 | const plugin = appsPlugin({ | 67 | const plugin = appsPlugin({ |
| 53 | runtime: host.runtime, | 68 | runtime: host.runtime, |
| 54 | resolveBindings: options.resolveBindings, | 69 | resolveBindings: options.resolveBindings, |
| @@ -58,7 +73,19 @@ export const makeSelfHostApps = (options: SelfHostAppsOptions): SelfHostApps => · 3 unmodified lines | |||
| 58 | runtime: host.runtime, | 73 | runtime: host.runtime, |
| 59 | http, | 74 | http, |
| 60 | registerMcp: (server) => | 75 | registerMcp: (server) => |
| 61 | registerAppsMcp(server, { runtime: host.runtime, scope: options.scope }), | 76 | registerAppsMcp(server, { |
| 77 | runtime: host.runtime, | ||
| 78 | scope: options.scope, | ||
| 79 | // Read the base URL lazily so a host that resolves it after building the | ||
| 80 | // subsystem singleton still gets a working fallback URL. | ||
| 81 | uiDocumentUrl: (scope, name) => { | ||
| 82 | const base = options.webBaseUrlFn?.() ?? options.webBaseUrl; | ||
| 83 | if (!base) return undefined; | ||
| 84 | return `${base.replace(/\/$/, "")}/api/apps/${encodeURIComponent( | ||
| 85 | scope, | ||
| 86 | )}/ui/${encodeURIComponent(name)}?document=html`; | ||
| 87 | }, | ||
| 88 | }), | ||
| 62 | plugin, | 89 | plugin, |
| 63 | close: host.close, | 90 | close: host.close, |
| 64 | }; | 91 | }; |