Root cause
Authentication & access control introduced
Add API server with routes, services, and middleware
How AI contributed: Direct introductionPublished Apr 16, 2026 in
paperclipai/paperclipAll 8 findings in this repository
Four unauthenticated API endpoints in authenticated mode: (1) GET /api/heartbeat-runs/:runId/issues, (2) POST /api/cli-auth/challenges, (3) GET /api/skills/index|available|:skillName, (4) GET /api/health full deployment details.
Root cause
Add API server with routes, services, and middleware
Fix
[codex] harden authenticated routes and issue editor reliability (#3741)
AI-assisted fix: ChatGPT/Codex · Dotta
Case facts
@@ -0,0 +1,42 @@+import express, { Router } from "express";+import path from "node:path";+import { fileURLToPath } from "node:url";+import type { Db } from "@paperclip/db";+import { httpLogger, errorHandler } from "./middleware/index.js";+import { healthRoutes } from "./routes/health.js";+import { agentRoutes } from "./routes/agents.js";+import { projectRoutes } from "./routes/projects.js";+import { issueRoutes } from "./routes/issues.js";+import { goalRoutes } from "./routes/goals.js";+import { activityRoutes } from "./routes/activity.js";++export function createApp(db: Db, opts: { serveUi: boolean }) {+ const app = express();++ app.use(express.json());+ app.use(httpLogger);++ // Mount API routes+ const api = Router();+ api.use("/health", healthRoutes());+ api.use("/agents", agentRoutes(db));+ api.use("/projects", projectRoutes(db));+ api.use("/issues", issueRoutes(db));+ api.use("/goals", goalRoutes(db));+ api.use("/activity", activityRoutes(db));+ app.use("/api", api);++ // SPA fallback for serving the UI build+ if (opts.serveUi) {+ const __dirname = path.dirname(fileURLToPath(import.meta.url));+ const uiDist = path.resolve(__dirname, "../../ui/dist");+ app.use(express.static(uiDist));+ app.get("*", (_req, res) => {+ res.sendFile(path.join(uiDist, "index.html"));+ });+ }++ app.use(errorHandler);++ return app;+}@@ -0,0 +1,11 @@+import { Router } from "express";++export function healthRoutes() {+ const router = Router();++ router.get("/", (_req, res) => {+ res.json({ status: "ok" });+ });++ return router;+}@@ -3,7 +3,7 @@ import { z } from "zod"; import type { Db } from "@paperclipai/db"; import { validate } from "../middleware/validate.js"; import { activityService } from "../services/activity.js";-import { assertBoard, assertCompanyAccess } from "./authz.js";+import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js"; import { heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; @@ -81,6 +81,7 @@ export function activityRoutes(db: Db) { }); router.get("/heartbeat-runs/:runId/issues", async (req, res) => {+ assertAuthenticated(req); const runId = req.params.runId as string; const run = await heartbeat.getRun(runId); if (!run) {@@ -86,6 +86,16 @@ function shouldServeViteDevHtml(req: ExpressRequest): boolean { return req.accepts(["html"]) === "html"; } +export function shouldEnablePrivateHostnameGuard(opts: {+ deploymentMode: DeploymentMode;+ deploymentExposure: DeploymentExposure;+}): boolean {+ return (+ opts.deploymentExposure === "private" &&+ (opts.deploymentMode === "local_trusted" || opts.deploymentMode === "authenticated")+ );+}+ export async function createApp( db: Db, opts: {@@ -123,8 +133,10 @@ export async function createApp( }, })); app.use(httpLogger);- const privateHostnameGateEnabled =- opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private";+ const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({+ deploymentMode: opts.deploymentMode,+ deploymentExposure: opts.deploymentExposure,+ }); const privateHostnameAllowSet = resolvePrivateHostnameAllowSet({ allowedHostnames: opts.allowedHostnames, bindHost: opts.bindHost,@@ -7,6 +7,14 @@ import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-se import { instanceSettingsService } from "../services/instance-settings.js"; import { serverVersion } from "../version.js"; +function shouldExposeFullHealthDetails(+ actorType: "none" | "board" | "agent" | null | undefined,+ deploymentMode: DeploymentMode,+) {+ if (deploymentMode !== "authenticated") return true;+ return actorType === "board" || actorType === "agent";+}+ export function healthRoutes( db?: Db, opts: {@@ -23,9 +31,19 @@ export function healthRoutes( ) { const router = Router(); - router.get("/", async (_req, res) => {+ router.get("/", async (req, res) => {+ const actorType = "actor" in req ? req.actor?.type : null;+ const exposeFullDetails = shouldExposeFullHealthDetails(+ actorType,+ opts.deploymentMode,+ );+ if (!db) {- res.json({ status: "ok", version: serverVersion });+ res.json(+ exposeFullDetails+ ? { status: "ok", version: serverVersion }+ : { status: "ok", deploymentMode: opts.deploymentMode },+ ); return; } @@ -70,7 +88,7 @@ export function healthRoutes( const persistedDevServerStatus = readPersistedDevServerStatus(); let devServer: ReturnType<typeof toDevServerHealthStatus> | undefined;- if (persistedDevServerStatus) {+ if (persistedDevServerStatus && typeof (db as { select?: unknown }).select === "function") { const instanceSettings = instanceSettingsService(db); const experimentalSettings = await instanceSettings.getExperimental(); const activeRunCount = await db@@ -85,6 +103,16 @@ export function healthRoutes( }); } + if (!exposeFullDetails) {+ res.json({+ status: "ok",+ deploymentMode: opts.deploymentMode,+ bootstrapStatus,+ bootstrapInviteActive,+ });+ return;+ }+ res.json({ status: "ok", version: serverVersion,AI-assisted change 54402145515c48f412cd4f0668c1c0127dfb6d28ac535a0913b3e5ac9e4cb739 · Fix 3b606031fd3b414c7311c7e72535b040ae4930eca2a10f29ce4945a151fa1e81