import express, { Router, type Request as ExpressRequest } from "express"; import path from "node:path"; import fs from "node:fs"; import { fileURLToPath } from "node:url"; import type { Db } from "@paperclipai/db"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; import type { StorageService } from "./storage/types.js"; import { httpLogger, errorHandler } from "./middleware/index.js"; import { actorMiddleware } from "./middleware/auth.js"; import { boardMutationGuard } from "./middleware/board-mutation-guard.js"; import { privateHostnameGuard, resolvePrivateHostnameAllowSet } from "./middleware/private-hostname-guard.js"; import { healthRoutes } from "./routes/health.js"; import { companyRoutes } from "./routes/companies.js"; import { companySkillRoutes } from "./routes/company-skills.js"; import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; import { issueRoutes } from "./routes/issues.js"; import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js"; import { goalRoutes } from "./routes/goals.js"; import { approvalRoutes } from "./routes/approvals.js"; import { secretRoutes } from "./routes/secrets.js"; import { costRoutes } from "./routes/costs.js"; import { activityRoutes } from "./routes/activity.js"; import { dashboardRoutes } from "./routes/dashboard.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; import { llmRoutes } from "./routes/llms.js"; import { assetRoutes } from "./routes/assets.js"; import { accessRoutes } from "./routes/access.js"; import { pluginRoutes } from "./routes/plugins.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; import { applyUiBranding } from "./ui-branding.js"; import { logger } from "./middleware/logger.js"; import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js"; import { createPluginWorkerManager } from "./services/plugin-worker-manager.js"; import { createPluginJobScheduler } from "./services/plugin-job-scheduler.js"; import { pluginJobStore } from "./services/plugin-job-store.js"; import { createPluginToolDispatcher } from "./services/plugin-tool-dispatcher.js"; import { pluginLifecycleManager } from "./services/plugin-lifecycle.js"; import { createPluginJobCoordinator } from "./services/plugin-job-coordinator.js"; import { buildHostServices, flushPluginLogBuffer } from "./services/plugin-host-services.js"; import { createPluginEventBus } from "./services/plugin-event-bus.js"; import { setPluginEventBus } from "./services/activity-log.js"; import { createPluginDevWatcher } from "./services/plugin-dev-watcher.js"; import { createPluginHostServiceCleanup } from "./services/plugin-host-service-cleanup.js"; import { pluginRegistryService } from "./services/plugin-registry.js"; import { createHostClientHandlers } from "@paperclipai/plugin-sdk"; import type { BetterAuthSessionResult } from "./auth/better-auth.js"; type UiMode = "none" | "static" | "vite-dev"; export function resolveViteHmrPort(serverPort: number): number { if (serverPort <= 55_535) { return serverPort + 10_000; } return Math.max(1_024, serverPort - 10_000); } export async function createApp( db: Db, opts: { uiMode: UiMode; serverPort: number; storageService: StorageService; deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; allowedHostnames: string[]; bindHost: string; authReady: boolean; companyDeletionEnabled: boolean; instanceId?: string; hostVersion?: string; localPluginDir?: string; betterAuthHandler?: express.RequestHandler; resolveSession?: (req: ExpressRequest) => Promise; }, ) { const app = express(); app.use(express.json({ verify: (req, _res, buf) => { (req as unknown as { rawBody: Buffer }).rawBody = buf; }, })); app.use(httpLogger); const privateHostnameGateEnabled = opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"; const privateHostnameAllowSet = resolvePrivateHostnameAllowSet({ allowedHostnames: opts.allowedHostnames, bindHost: opts.bindHost, }); app.use( privateHostnameGuard({ enabled: privateHostnameGateEnabled, allowedHostnames: opts.allowedHostnames, bindHost: opts.bindHost, }), ); app.use( actorMiddleware(db, { deploymentMode: opts.deploymentMode, resolveSession: opts.resolveSession, }), ); app.get("/api/auth/get-session", (req, res) => { if (req.actor.type !== "board" || !req.actor.userId) { res.status(401).json({ error: "Unauthorized" }); return; } res.json({ session: { id: `paperclip:${req.actor.source}:${req.actor.userId}`, userId: req.actor.userId, }, user: { id: req.actor.userId, email: null, name: req.actor.source === "local_implicit" ? "Local Board" : null, }, }); }); if (opts.betterAuthHandler) { app.all("/api/auth/*authPath", opts.betterAuthHandler); } app.use(llmRoutes(db)); // Mount API routes const api = Router(); api.use(boardMutationGuard()); api.use( "/health", healthRoutes(db, { deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, authReady: opts.authReady, companyDeletionEnabled: opts.companyDeletionEnabled, }), ); api.use("/companies", companyRoutes(db, opts.storageService)); api.use(companySkillRoutes(db)); api.use(agentRoutes(db)); api.use(assetRoutes(db, opts.storageService)); api.use(projectRoutes(db)); api.use(issueRoutes(db, opts.storageService)); api.use(executionWorkspaceRoutes(db)); api.use(goalRoutes(db)); api.use(approvalRoutes(db)); api.use(secretRoutes(db)); api.use(costRoutes(db)); api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); api.use(sidebarBadgeRoutes(db)); api.use(instanceSettingsRoutes(db)); const hostServicesDisposers = new Map void>(); const workerManager = createPluginWorkerManager(); const pluginRegistry = pluginRegistryService(db); const eventBus = createPluginEventBus(); setPluginEventBus(eventBus); const jobStore = pluginJobStore(db); const lifecycle = pluginLifecycleManager(db, { workerManager }); const scheduler = createPluginJobScheduler({ db, jobStore, workerManager, }); const toolDispatcher = createPluginToolDispatcher({ workerManager, lifecycleManager: lifecycle, db, }); const jobCoordinator = createPluginJobCoordinator({ db, lifecycle, scheduler, jobStore, }); const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers); const loader = pluginLoader( db, { localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR }, { workerManager, eventBus, jobScheduler: scheduler, jobStore, toolDispatcher, lifecycleManager: lifecycle, instanceInfo: { instanceId: opts.instanceId ?? "default", hostVersion: opts.hostVersion ?? "0.0.0", }, buildHostHandlers: (pluginId, manifest) => { const notifyWorker = (method: string, params: unknown) => { const handle = workerManager.getWorker(pluginId); if (handle) handle.notify(method, params); }; const services = buildHostServices(db, pluginId, manifest.id, eventBus, notifyWorker); hostServicesDisposers.set(pluginId, () => services.dispose()); return createHostClientHandlers({ pluginId, capabilities: manifest.capabilities, services, }); }, }, ); api.use( pluginRoutes( db, loader, { scheduler, jobStore }, { workerManager }, { toolDispatcher }, { workerManager }, ), ); api.use( accessRoutes(db, { deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, bindHost: opts.bindHost, allowedHostnames: opts.allowedHostnames, }), ); app.use("/api", api); app.use("/api", (_req, res) => { res.status(404).json({ error: "API route not found" }); }); app.use(pluginUiStaticRoutes(db, { localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR, })); const __dirname = path.dirname(fileURLToPath(import.meta.url)); if (opts.uiMode === "static") { // Try published location first (server/ui-dist/), then monorepo dev location (../../ui/dist) const candidates = [ path.resolve(__dirname, "../ui-dist"), path.resolve(__dirname, "../../ui/dist"), ]; const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html"))); if (uiDist) { const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); app.use(express.static(uiDist)); app.get(/.*/, (_req, res) => { res.status(200).set("Content-Type", "text/html").end(indexHtml); }); } else { console.warn("[paperclip] UI dist not found; running in API-only mode"); } } if (opts.uiMode === "vite-dev") { const uiRoot = path.resolve(__dirname, "../../ui"); const hmrPort = resolveViteHmrPort(opts.serverPort); const { createServer: createViteServer } = await import("vite"); const vite = await createViteServer({ root: uiRoot, appType: "custom", server: { middlewareMode: true, hmr: { host: opts.bindHost, port: hmrPort, clientPort: hmrPort, }, allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined, }, }); app.use(vite.middlewares); app.get(/.*/, async (req, res, next) => { try { const templatePath = path.resolve(uiRoot, "index.html"); const template = fs.readFileSync(templatePath, "utf-8"); const html = applyUiBranding(await vite.transformIndexHtml(req.originalUrl, template)); res.status(200).set({ "Content-Type": "text/html" }).end(html); } catch (err) { next(err); } }); } app.use(errorHandler); jobCoordinator.start(); scheduler.start(); void toolDispatcher.initialize().catch((err) => { logger.error({ err }, "Failed to initialize plugin tool dispatcher"); }); const devWatcher = opts.uiMode === "vite-dev" ? createPluginDevWatcher( lifecycle, async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null, ) : null; void loader.loadAll().then((result) => { if (!result) return; for (const loaded of result.results) { if (devWatcher && loaded.success && loaded.plugin.packagePath) { devWatcher.watch(loaded.plugin.id, loaded.plugin.packagePath); } } }).catch((err) => { logger.error({ err }, "Failed to load ready plugins on startup"); }); process.once("exit", () => { devWatcher?.close(); hostServiceCleanup.disposeAll(); hostServiceCleanup.teardown(); }); process.once("beforeExit", () => { void flushPluginLogBuffer(); }); return app; }