* public-gh/master: (51 commits) Use attachment-size limit for company logos Address Greptile company logo feedback Drop lockfile from PR branch Use asset-backed company logos fix: use appType "custom" for Vite dev server so worktree branding is applied docs: fix documentation drift — adapters, plugins, tech stack docs: update documentation for accuracy after plugin system launch chore: ignore superset artifacts Dark theme for CodeMirror code blocks in MDXEditor Remove duplicate @paperclipai/adapter-openclaw-gateway in server/package.json Fix code block styles with robust prose overrides Add Docker setup for untrusted PR review in isolated containers Fix org chart canvas height to fit viewport without scrolling Add doc-maintenance skill for periodic documentation accuracy audits Fix sidebar scrollbar: hide track background when not hovering Restyle markdown code blocks: dark background, smaller font, compact padding Add archive project button and filter archived projects from selectors fix: address review feedback — subscription cleanup, filter nullability, stale diagram fix: wire plugin event subscriptions from worker to host fix(ui): hide scrollbar track background when sidebar is not hovered ... # Conflicts: # packages/db/src/migrations/meta/0030_snapshot.json # packages/db/src/migrations/meta/_journal.json
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
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 { 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 { 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<BetterAuthSessionResult | null>;
|
|
},
|
|
) {
|
|
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));
|
|
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));
|
|
const hostServicesDisposers = new Map<string, () => 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;
|
|
}
|