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 | 75 | // closes over). Its HTTP surface mounts under /api/apps/*; published tools are |
| 76 | 76 | // catalog citizens through its source plugin; its extra MCP surface (publish |
| 77 | 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 | 105 | // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- |
| 81 | 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 | 5 | import { afterAll, beforeAll, expect, test } from "@effect/vitest"; |
| 6 | 6 | |
| 7 | +import { mintInviteCode } from "./testing/mint-invite"; | |
| 8 | + | |
| 7 | 9 | // Point config at a throwaway data dir before importing the app graph. |
| 8 | 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 | 15 | let handler!: (request: Request) => Promise<Response>; |
| 11 | 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 | 32 | beforeAll(async () => { |
| 14 | 33 | // Boot the REAL self-host app handler (makeSelfHostApp), which mounts the apps |
| @@ -17,9 +36,53 @@ beforeAll(async () => { · 2 unmodified lines | ||
| 17 | 36 | const app = await makeSelfHostApiHandler(); |
| 18 | 37 | handler = app.handler; |
| 19 | 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 | 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 | 86 | // A minimal published app: one tool that writes the scope db, and a ui view. |
| 24 | 87 | const FILES = { |
| 25 | 88 | "tools/note.ts": |
| @@ -32,14 +95,12 @@ const FILES = { · 6 unmodified lines | ||
| 32 | 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 () => { | |
| 36 | − // Publish over the booted server's /api/apps/:scope/publish route. | |
| 37 | − const publishRes = await handler( | |
| 38 | − new Request("http://localhost/api/apps/default/publish", { | |
| 39 | − method: "POST", | |
| 40 | − body: JSON.stringify({ files: FILES }), | |
| 41 | − }), | |
| 42 | − ); | |
| 98 | +test("apps HTTP surface is mounted (authenticated): publish then serve the ui bundle", async () => { | |
| 99 | + // Publish over the booted server's /api/apps/:scope/publish route (authed). | |
| 100 | + const publishRes = await authed("/api/apps/default/publish", { | |
| 101 | + method: "POST", | |
| 102 | + body: JSON.stringify({ files: FILES }), | |
| 103 | + }); | |
| 43 | 104 | expect(publishRes.status).toBe(200); |
| 44 | 105 | const published = (await publishRes.json()) as { |
| 45 | 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 | 109 | expect(published.descriptor.ui.map((u) => u.name)).toEqual(["board"]); |
| 49 | 110 | |
| 50 | 111 | // The ui bundle is served (compiled JS) with its title header. |
| 51 | − const uiRes = await handler( | |
| 52 | − new Request("http://localhost/api/apps/default/ui/board", { method: "GET" }), | |
| 53 | − ); | |
| 112 | + const uiRes = await authed("/api/apps/default/ui/board", { method: "GET" }); | |
| 54 | 113 | expect(uiRes.status).toBe(200); |
| 55 | 114 | expect(uiRes.headers.get("content-type")).toContain("javascript"); |
| 56 | 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 | 122 | // Invoke the tool (scope-db path is live in the running server). |
| 59 | − const invokeRes = await handler( | |
| 60 | − new Request("http://localhost/api/apps/default/tools/note", { | |
| 61 | − method: "POST", | |
| 62 | − body: JSON.stringify({ args: { text: "hello" }, bindings: {} }), | |
| 63 | − }), | |
| 64 | − ); | |
| 123 | + const invokeRes = await authed("/api/apps/default/tools/note", { | |
| 124 | + method: "POST", | |
| 125 | + body: JSON.stringify({ args: { text: "hello" }, bindings: {} }), | |
| 126 | + }); | |
| 65 | 127 | expect(invokeRes.status).toBe(200); |
| 66 | 128 | const invoked = (await invokeRes.json()) as { result: { count: number } }; |
| 67 | 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 | 66 | // The boot-time singleton. Built lazily on first access so importing this module |
| 58 | 67 | // (which executor.config.ts does) does not do filesystem work at import time. |
| 59 | 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 | 87 | if (!subsystem) { |
| 63 | 88 | subsystem = makeSelfHostApps({ |
| 64 | 89 | dataDir: resolveDataDir(), |
| @@ -71,6 +96,13 @@ export const getSelfHostAppsSubsystem = (): SelfHostApps => { · 6 unmodified lines | ||
| 71 | 96 | // so external integration calls resolve the user's connection + credential |
| 72 | 97 | // at the boundary and dispatch over the request's HttpClient. |
| 73 | 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 | 108 | return subsystem; |
| @@ -79,6 +111,8 @@ export const getSelfHostAppsSubsystem = (): SelfHostApps => { · 2 unmodified lines | ||
| 79 | 111 | /** Reset the singleton (tests build fresh instances per data dir). */ |
| 80 | 112 | export const resetSelfHostAppsSubsystem = (): void => { |
| 81 | 113 | subsystem = undefined; |
| 114 | + authenticateRef = undefined; | |
| 115 | + webBaseUrlRef = undefined; | |
| 82 | 116 | }; |
| 83 | 117 | |
| 84 | 118 | /** @deprecated use getSelfHostAppsSubsystem(); kept for existing call sites. */ |
| @@ -23,6 +23,18 @@ export interface AppsHttpDeps { · 22 unmodified lines | ||
| 23 | 23 | readonly runtime: AppsRuntime; |
| 24 | 24 | /** Mount prefix (default "/api/apps"). */ |
| 25 | 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 | 40 | const json = (body: unknown, status = 200): Response => |
| @@ -42,6 +54,20 @@ export const makeAppsHttpRoutes = ( · 13 unmodified lines | ||
| 42 | 54 | const handler = async (request: Request): Promise<Response> => { |
| 43 | 55 | const url = new URL(request.url); |
| 44 | 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 | 71 | const rest = url.pathname.slice(prefix.length).replace(/^\//, ""); |
| 46 | 72 | const parts = rest.split("/").filter(Boolean); |
| 47 | 73 | // parts[0] = scope |
| @@ -134,8 +160,23 @@ export const makeAppsHttpRoutes = ( · 86 unmodified lines | ||
| 134 | 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 | 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 | 180 | const bundle = await run(runtime.getUiBundle(scope, parts[2])); |
| 140 | 181 | if (!bundle) return new Response("ui not found", { status: 404 }); |
| 141 | 182 | return new Response(bundle.code, { |
| @@ -28,6 +28,18 @@ export interface SelfHostAppsOptions { · 27 unmodified lines | ||
| 28 | 28 | /** Optional per-request resolver factory for the catalog invoke path (built |
| 29 | 29 | * from the invoking executor context). */ |
| 30 | 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 | 45 | export interface SelfHostApps { |
| @@ -48,7 +60,10 @@ export const makeSelfHostApps = (options: SelfHostAppsOptions): SelfHostApps => · 14 unmodified lines | ||
| 48 | 60 | store, |
| 49 | 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 | 67 | const plugin = appsPlugin({ |
| 53 | 68 | runtime: host.runtime, |
| 54 | 69 | resolveBindings: options.resolveBindings, |
| @@ -58,7 +73,19 @@ export const makeSelfHostApps = (options: SelfHostAppsOptions): SelfHostApps => · 3 unmodified lines | ||
| 58 | 73 | runtime: host.runtime, |
| 59 | 74 | http, |
| 60 | 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 | 89 | plugin, |
| 63 | 90 | close: host.close, |
| 64 | 91 | }; |