import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, expect, test } from "@effect/vitest"; import { mintInviteCode } from "./testing/mint-invite"; // Point config at a throwaway data dir before importing the app graph. process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-apps-")); process.env.BETTER_AUTH_SECRET = "apps-node-secret-0123456789-abcdefghij-klmnop"; process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@apps-node.test"; process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; let handler!: (request: Request) => Promise; let dispose: () => Promise = async () => {}; let token = ""; // A JSON fetch that carries the bearer (the apps HTTP surface is authenticated). const authed = (path: string, init?: RequestInit) => handler( new Request(`http://localhost${path}`, { ...init, headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...(init?.headers ?? {}), }, }), ); beforeAll(async () => { // Boot the REAL self-host app handler (makeSelfHostApp), which mounts the apps // extension route under /api/apps/*. const { makeSelfHostApiHandler } = await import("./app"); const app = await makeSelfHostApiHandler(); handler = app.handler; dispose = app.dispose; // Sign up to get a bearer token (the apps surface is behind Better Auth). const inviteCode = await mintInviteCode(handler); const su = await handler( new Request("http://localhost/api/auth/sign-up/email", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "u@apps-node.test", password: "password-12345678", name: "U", inviteCode, }), }), ); token = su.headers.get("set-auth-token") ?? ""; expect(token).not.toBe(""); }); afterAll(() => dispose()); // Fix 1: the apps HTTP surface rejects unauthenticated requests with 401. test("apps HTTP surface requires auth (401 without a credential)", async () => { // publish / invoke / workflow-start / SSE all 401 unauthenticated. const publish = await handler( new Request("http://localhost/api/apps/default/publish", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ files: FILES }), }), ); expect(publish.status).toBe(401); const invoke = await handler( new Request("http://localhost/api/apps/default/tools/note", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ args: { text: "x" }, bindings: {} }), }), ); expect(invoke.status).toBe(401); const sse = await handler( new Request("http://localhost/api/apps/default/live", { method: "GET" }), ); expect(sse.status).toBe(401); }, 30_000); // A minimal published app: one tool that writes the scope db, and a ui view. const FILES = { "tools/note.ts": `import { z } from "zod";\nimport { defineTool } from "executor:app";\n` + `export default defineTool({ description: "Save a note", input: z.object({ text: z.string() }), ` + `async handler({ text }, { db }) { await db.sql\`CREATE TABLE IF NOT EXISTS notes (t TEXT)\`; ` + `await db.sql\`INSERT INTO notes (t) VALUES (\${text})\`; const rows = await db.sql\`SELECT COUNT(*) AS n FROM notes\`; return { count: Number(rows[0].n) }; } });`, "ui/board.tsx": `import { config } from "executor:ui";\nconfig({ title: "Board", maxHeight: 400 });\n` + `export default function App() { return null; }`, }; test("apps HTTP surface is mounted (authenticated): publish then serve the ui bundle", async () => { // Publish over the booted server's /api/apps/:scope/publish route (authed). const publishRes = await authed("/api/apps/default/publish", { method: "POST", body: JSON.stringify({ files: FILES }), }); expect(publishRes.status).toBe(200); const published = (await publishRes.json()) as { descriptor: { tools: { name: string }[]; ui: { name: string }[] }; }; expect(published.descriptor.tools.map((t) => t.name)).toEqual(["note"]); expect(published.descriptor.ui.map((u) => u.name)).toEqual(["board"]); // The ui bundle is served (compiled JS) with its title header. const uiRes = await authed("/api/apps/default/ui/board", { method: "GET" }); expect(uiRes.status).toBe(200); expect(uiRes.headers.get("content-type")).toContain("javascript"); expect(uiRes.headers.get("x-ui-title")).toBe("Board"); // The HTML document variant (Fix 10 fallback target) is served behind auth. const docRes = await authed("/api/apps/default/ui/board?document=html", { method: "GET" }); expect(docRes.status).toBe(200); expect(docRes.headers.get("content-type")).toContain("text/html"); // Invoke the tool (scope-db path is live in the running server). const invokeRes = await authed("/api/apps/default/tools/note", { method: "POST", body: JSON.stringify({ args: { text: "hello" }, bindings: {} }), }); expect(invokeRes.status).toBe(200); const invoked = (await invokeRes.json()) as { result: { count: number } }; expect(invoked.result.count).toBe(1); }, 30_000);