Merge public-gh/master into review/pr-162

This commit is contained in:
Dotta
2026-03-16 08:47:05 -05:00
536 changed files with 103660 additions and 9971 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,13 @@ export function activityRoutes(db: Db) {
const svc = activityService(db);
const issueSvc = issueService(db);
async function resolveIssueByRef(rawId: string) {
if (/^[A-Z]+-\d+$/i.test(rawId)) {
return issueSvc.getByIdentifier(rawId);
}
return issueSvc.getById(rawId);
}
router.get("/companies/:companyId/activity", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -47,42 +54,27 @@ export function activityRoutes(db: Db) {
res.status(201).json(event);
});
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs
router.param("id", async (req, res, next, rawId) => {
try {
if (/^[A-Z]+-\d+$/i.test(rawId)) {
const issue = await issueSvc.getByIdentifier(rawId);
if (issue) {
req.params.id = issue.id;
}
}
next();
} catch (err) {
next(err);
}
});
router.get("/issues/:id/activity", async (req, res) => {
const id = req.params.id as string;
const issue = await issueSvc.getById(id);
const rawId = req.params.id as string;
const issue = await resolveIssueByRef(rawId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const result = await svc.forIssue(id);
const result = await svc.forIssue(issue.id);
res.json(result);
});
router.get("/issues/:id/runs", async (req, res) => {
const id = req.params.id as string;
const issue = await issueSvc.getById(id);
const rawId = req.params.id as string;
const issue = await resolveIssueByRef(rawId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const result = await svc.runsForIssue(issue.companyId, id);
const result = await svc.runsForIssue(issue.companyId, issue.id);
res.json(result);
});

View File

@@ -1,5 +1,5 @@
import { Router, type Request } from "express";
import { randomUUID } from "node:crypto";
import { generateKeyPairSync, randomUUID } from "node:crypto";
import path from "node:path";
import type { Db } from "@paperclipai/db";
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db";
@@ -8,9 +8,11 @@ import {
createAgentKeySchema,
createAgentHireSchema,
createAgentSchema,
deriveAgentUrlKey,
isUuidLike,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
type InstanceSchedulerHeartbeatAgent,
updateAgentPermissionsSchema,
updateAgentInstructionsPathSchema,
wakeAgentSchema,
@@ -31,18 +33,21 @@ import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL,
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
export function agentRoutes(db: Db) {
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
claude_local: "instructionsFilePath",
codex_local: "instructionsFilePath",
gemini_local: "instructionsFilePath",
opencode_local: "instructionsFilePath",
cursor: "instructionsFilePath",
};
@@ -181,6 +186,55 @@ export function agentRoutes(db: Db) {
return trimmed.length > 0 ? trimmed : null;
}
function parseBooleanLike(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value === "number") {
if (value === 1) return true;
if (value === 0) return false;
return null;
}
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
return true;
}
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
return false;
}
return null;
}
function parseNumberLike(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value !== "string") return null;
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) {
const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {};
return {
enabled: parseBooleanLike(heartbeat.enabled) ?? true,
intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0),
};
}
function generateEd25519PrivateKeyPem(): string {
const { privateKey } = generateKeyPairSync("ed25519");
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
}
function ensureGatewayDeviceKey(
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
): Record<string, unknown> {
if (adapterType !== "openclaw_gateway") return adapterConfig;
const disableDeviceAuth = parseBooleanLike(adapterConfig.disableDeviceAuth) === true;
if (disableDeviceAuth) return adapterConfig;
if (asNonEmptyString(adapterConfig.devicePrivateKeyPem)) return adapterConfig;
return { ...adapterConfig, devicePrivateKeyPem: generateEd25519PrivateKeyPem() };
}
function applyCreateDefaultsByAdapterType(
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
@@ -196,13 +250,17 @@ export function agentRoutes(db: Db) {
if (!hasBypassFlag) {
next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
}
return next;
return ensureGatewayDeviceKey(adapterType, next);
}
if (adapterType === "gemini_local" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_GEMINI_LOCAL_MODEL;
return ensureGatewayDeviceKey(adapterType, next);
}
// OpenCode requires explicit model selection — no default
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
}
return next;
return ensureGatewayDeviceKey(adapterType, next);
}
async function assertAdapterConfigConstraints(
@@ -211,7 +269,7 @@ export function agentRoutes(db: Db) {
adapterConfig: Record<string, unknown>,
) {
if (adapterType !== "opencode_local") return;
const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig);
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig);
const runtimeEnv = asRecord(runtimeConfig.env) ?? {};
try {
await ensureOpenCodeModelConfiguredAndAvailable({
@@ -386,7 +444,7 @@ export function agentRoutes(db: Db) {
inputAdapterConfig,
{ strictMode: strictSecretsMode },
);
const runtimeAdapterConfig = await secretsSvc.resolveAdapterConfigForRuntime(
const { config: runtimeAdapterConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
companyId,
normalizedAdapterConfig,
);
@@ -413,6 +471,81 @@ export function agentRoutes(db: Db) {
res.json(result.map((agent) => redactForRestrictedAgentView(agent)));
});
router.get("/instance/scheduler-heartbeats", async (req, res) => {
assertBoard(req);
const accessConditions = [];
if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) {
const allowedCompanyIds = req.actor.companyIds ?? [];
if (allowedCompanyIds.length === 0) {
res.json([]);
return;
}
accessConditions.push(inArray(agentsTable.companyId, allowedCompanyIds));
}
const rows = await db
.select({
id: agentsTable.id,
companyId: agentsTable.companyId,
agentName: agentsTable.name,
role: agentsTable.role,
title: agentsTable.title,
status: agentsTable.status,
adapterType: agentsTable.adapterType,
runtimeConfig: agentsTable.runtimeConfig,
lastHeartbeatAt: agentsTable.lastHeartbeatAt,
companyName: companies.name,
companyIssuePrefix: companies.issuePrefix,
})
.from(agentsTable)
.innerJoin(companies, eq(agentsTable.companyId, companies.id))
.where(accessConditions.length > 0 ? and(...accessConditions) : undefined)
.orderBy(companies.name, agentsTable.name);
const items: InstanceSchedulerHeartbeatAgent[] = rows
.map((row) => {
const policy = parseSchedulerHeartbeatPolicy(row.runtimeConfig);
const statusEligible =
row.status !== "paused" &&
row.status !== "terminated" &&
row.status !== "pending_approval";
return {
id: row.id,
companyId: row.companyId,
companyName: row.companyName,
companyIssuePrefix: row.companyIssuePrefix,
agentName: row.agentName,
agentUrlKey: deriveAgentUrlKey(row.agentName, row.id),
role: row.role as InstanceSchedulerHeartbeatAgent["role"],
title: row.title,
status: row.status as InstanceSchedulerHeartbeatAgent["status"],
adapterType: row.adapterType,
intervalSec: policy.intervalSec,
heartbeatEnabled: policy.enabled,
schedulerActive: statusEligible && policy.enabled && policy.intervalSec > 0,
lastHeartbeatAt: row.lastHeartbeatAt,
};
})
.filter((item) =>
item.intervalSec > 0 &&
item.status !== "paused" &&
item.status !== "terminated" &&
item.status !== "pending_approval",
)
.sort((left, right) => {
if (left.schedulerActive !== right.schedulerActive) {
return left.schedulerActive ? -1 : 1;
}
const companyOrder = left.companyName.localeCompare(right.companyName);
if (companyOrder !== 0) return companyOrder;
return left.agentName.localeCompare(right.agentName);
});
res.json(items);
});
router.get("/companies/:companyId/org", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -442,6 +575,34 @@ export function agentRoutes(db: Db) {
res.json({ ...agent, chainOfCommand });
});
router.get("/agents/me/inbox-lite", async (req, res) => {
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) {
res.status(401).json({ error: "Agent authentication required" });
return;
}
const issuesSvc = issueService(db);
const rows = await issuesSvc.list(req.actor.companyId, {
assigneeAgentId: req.actor.agentId,
status: "todo,in_progress,blocked",
});
res.json(
rows.map((issue) => ({
id: issue.id,
identifier: issue.identifier,
title: issue.title,
status: issue.status,
priority: issue.priority,
projectId: issue.projectId,
goalId: issue.goalId,
parentId: issue.parentId,
updatedAt: issue.updatedAt,
activeRun: issue.activeRun,
})),
);
});
router.get("/agents/:id", async (req, res) => {
const id = req.params.id as string;
const agent = await svc.getById(id);
@@ -930,11 +1091,7 @@ export function agentRoutes(db: Db) {
if (changingInstructionsPath) {
await assertCanManageInstructionsPath(req, existing);
}
patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
adapterConfig,
{ strictMode: strictSecretsMode },
);
patchData.adapterConfig = adapterConfig;
}
const requestedAdapterType =
@@ -942,15 +1099,23 @@ export function agentRoutes(db: Db) {
const touchesAdapterConfiguration =
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
if (touchesAdapterConfiguration) {
const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
? (asRecord(patchData.adapterConfig) ?? {})
: (asRecord(existing.adapterConfig) ?? {});
const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
const effectiveAdapterConfig = applyCreateDefaultsByAdapterType(
requestedAdapterType,
rawEffectiveAdapterConfig,
);
const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
effectiveAdapterConfig,
{ strictMode: strictSecretsMode },
);
patchData.adapterConfig = normalizedEffectiveAdapterConfig;
}
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {};
await assertAdapterConfigConstraints(
existing.companyId,
requestedAdapterType,
@@ -1138,6 +1303,7 @@ export function agentRoutes(db: Db) {
contextSnapshot: {
triggeredBy: req.actor.type,
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
forceFreshSession: req.body.forceFreshSession === true,
},
});
@@ -1226,7 +1392,7 @@ export function agentRoutes(db: Db) {
}
const config = asRecord(agent.adapterConfig) ?? {};
const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config);
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config);
const result = await runClaudeLogin({
runId: `claude-login-${randomUUID()}`,
agent: {
@@ -1308,6 +1474,17 @@ export function agentRoutes(db: Db) {
res.json(liveRuns);
});
router.get("/heartbeat-runs/:runId", async (req, res) => {
const runId = req.params.runId as string;
const run = await heartbeat.getRun(runId);
if (!run) {
res.status(404).json({ error: "Heartbeat run not found" });
return;
}
assertCompanyAccess(req, run.companyId);
res.json(redactCurrentUserValue(run));
});
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
assertBoard(req);
const runId = req.params.runId as string;
@@ -1340,10 +1517,12 @@ export function agentRoutes(db: Db) {
const afterSeq = Number(req.query.afterSeq ?? 0);
const limit = Number(req.query.limit ?? 200);
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
const redactedEvents = events.map((event) => ({
...event,
payload: redactEventPayload(event.payload),
}));
const redactedEvents = events.map((event) =>
redactCurrentUserValue({
...event,
payload: redactEventPayload(event.payload),
}),
);
res.json(redactedEvents);
});
@@ -1440,7 +1619,7 @@ export function agentRoutes(db: Db) {
}
res.json({
...run,
...redactCurrentUserValue(run),
agentId: agent.id,
agentName: agent.name,
adapterType: agent.adapterType,

View File

@@ -121,85 +121,92 @@ export function approvalRoutes(db: Db) {
router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const approval = await svc.approve(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
const primaryIssueId = linkedIssueIds[0] ?? null;
const { approval, applied } = await svc.approve(
id,
req.body.decidedByUserId ?? "board",
req.body.decisionNote,
);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.approved",
entityType: "approval",
entityId: approval.id,
details: {
type: approval.type,
requestedByAgentId: approval.requestedByAgentId,
linkedIssueIds,
},
});
if (applied) {
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
const primaryIssueId = linkedIssueIds[0] ?? null;
if (approval.requestedByAgentId) {
try {
const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, {
source: "automation",
triggerDetail: "system",
reason: "approval_approved",
payload: {
approvalId: approval.id,
approvalStatus: approval.status,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
},
requestedByActorType: "user",
requestedByActorId: req.actor.userId ?? "board",
contextSnapshot: {
source: "approval.approved",
approvalId: approval.id,
approvalStatus: approval.status,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
taskId: primaryIssueId,
wakeReason: "approval_approved",
},
});
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.approved",
entityType: "approval",
entityId: approval.id,
details: {
type: approval.type,
requestedByAgentId: approval.requestedByAgentId,
linkedIssueIds,
},
});
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.requester_wakeup_queued",
entityType: "approval",
entityId: approval.id,
details: {
requesterAgentId: approval.requestedByAgentId,
wakeRunId: wakeRun?.id ?? null,
linkedIssueIds,
},
});
} catch (err) {
logger.warn(
{
err,
approvalId: approval.id,
requestedByAgentId: approval.requestedByAgentId,
},
"failed to queue requester wakeup after approval",
);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.requester_wakeup_failed",
entityType: "approval",
entityId: approval.id,
details: {
requesterAgentId: approval.requestedByAgentId,
linkedIssueIds,
error: err instanceof Error ? err.message : String(err),
},
});
if (approval.requestedByAgentId) {
try {
const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, {
source: "automation",
triggerDetail: "system",
reason: "approval_approved",
payload: {
approvalId: approval.id,
approvalStatus: approval.status,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
},
requestedByActorType: "user",
requestedByActorId: req.actor.userId ?? "board",
contextSnapshot: {
source: "approval.approved",
approvalId: approval.id,
approvalStatus: approval.status,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
taskId: primaryIssueId,
wakeReason: "approval_approved",
},
});
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.requester_wakeup_queued",
entityType: "approval",
entityId: approval.id,
details: {
requesterAgentId: approval.requestedByAgentId,
wakeRunId: wakeRun?.id ?? null,
linkedIssueIds,
},
});
} catch (err) {
logger.warn(
{
err,
approvalId: approval.id,
requestedByAgentId: approval.requestedByAgentId,
},
"failed to queue requester wakeup after approval",
);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.requester_wakeup_failed",
entityType: "approval",
entityId: approval.id,
details: {
requesterAgentId: approval.requestedByAgentId,
linkedIssueIds,
error: err instanceof Error ? err.message : String(err),
},
});
}
}
}
@@ -209,17 +216,23 @@ export function approvalRoutes(db: Db) {
router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const approval = await svc.reject(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
const { approval, applied } = await svc.reject(
id,
req.body.decidedByUserId ?? "board",
req.body.decisionNote,
);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.rejected",
entityType: "approval",
entityId: approval.id,
details: { type: approval.type },
});
if (applied) {
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.rejected",
entityType: "approval",
entityId: approval.id,
details: { type: approval.type },
});
}
res.json(redactApprovalPayload(approval));
});

View File

@@ -6,19 +6,10 @@ import type { Db } from "@paperclipai/db";
import { createAssetImageMetadataSchema } from "@paperclipai/shared";
import type { StorageService } from "../storage/types.js";
import { assetService, logActivity } from "../services/index.js";
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
const MAX_COMPANY_LOGO_BYTES = 100 * 1024;
const SVG_CONTENT_TYPE = "image/svg+xml";
const ALLOWED_IMAGE_CONTENT_TYPES = new Set([
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
SVG_CONTENT_TYPE,
]);
function sanitizeSvgBuffer(input: Buffer): Buffer | null {
const raw = input.toString("utf8").trim();
@@ -89,7 +80,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
const svc = assetService(db);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_ASSET_IMAGE_BYTES, files: 1 },
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
});
async function runSingleFileUpload(req: Request, res: Response) {
@@ -110,7 +101,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
} catch (err) {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
res.status(422).json({ error: `Image exceeds ${MAX_ASSET_IMAGE_BYTES} bytes` });
res.status(422).json({ error: `File exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
return;
}
res.status(400).json({ error: err.message });
@@ -125,9 +116,9 @@ export function assetRoutes(db: Db, storage: StorageService) {
return;
}
let contentType = (file.mimetype || "").toLowerCase();
if (!ALLOWED_IMAGE_CONTENT_TYPES.has(contentType)) {
res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` });
const contentType = (file.mimetype || "").toLowerCase();
if (contentType !== SVG_CONTENT_TYPE && !isAllowedContentType(contentType)) {
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
return;
}
let fileBody = file.buffer;

View File

@@ -1,7 +1,7 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { count, sql } from "drizzle-orm";
import { instanceUserRoles } from "@paperclipai/db";
import { and, count, eq, gt, isNull, sql } from "drizzle-orm";
import { instanceUserRoles, invites } from "@paperclipai/db";
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
export function healthRoutes(
@@ -27,6 +27,7 @@ export function healthRoutes(
}
let bootstrapStatus: "ready" | "bootstrap_pending" = "ready";
let bootstrapInviteActive = false;
if (opts.deploymentMode === "authenticated") {
const roleCount = await db
.select({ count: count() })
@@ -34,6 +35,23 @@ export function healthRoutes(
.where(sql`${instanceUserRoles.role} = 'instance_admin'`)
.then((rows) => Number(rows[0]?.count ?? 0));
bootstrapStatus = roleCount > 0 ? "ready" : "bootstrap_pending";
if (bootstrapStatus === "bootstrap_pending") {
const now = new Date();
const inviteCount = await db
.select({ count: count() })
.from(invites)
.where(
and(
eq(invites.inviteType, "bootstrap_ceo"),
isNull(invites.revokedAt),
isNull(invites.acceptedAt),
gt(invites.expiresAt, now),
),
)
.then((rows) => Number(rows[0]?.count ?? 0));
bootstrapInviteActive = inviteCount > 0;
}
}
res.json({
@@ -42,6 +60,7 @@ export function healthRoutes(
deploymentExposure: opts.deploymentExposure,
authReady: opts.authReady,
bootstrapStatus,
bootstrapInviteActive,
features: {
companyDeletionEnabled: opts.companyDeletionEnabled,
},

View File

@@ -8,6 +8,8 @@ import {
checkoutIssueSchema,
createIssueSchema,
linkIssueApprovalSchema,
issueDocumentKeySchema,
upsertIssueDocumentSchema,
updateIssueSchema,
} from "@paperclipai/shared";
import type { StorageService } from "../storage/types.js";
@@ -19,6 +21,7 @@ import {
heartbeatService,
issueApprovalService,
issueService,
documentService,
logActivity,
projectService,
} from "../services/index.js";
@@ -26,15 +29,9 @@ import { logger } from "../middleware/logger.js";
import { forbidden, HttpError, unauthorized } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
]);
const MAX_ISSUE_COMMENT_LIMIT = 500;
export function issueRoutes(db: Db, storage: StorageService) {
const router = Router();
@@ -45,6 +42,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
const projectsSvc = projectService(db);
const goalsSvc = goalService(db);
const issueApprovalsSvc = issueApprovalService(db);
const documentsSvc = documentService(db);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
@@ -184,6 +182,13 @@ export function issueRoutes(db: Db, storage: StorageService) {
}
});
// Common malformed path when companyId is empty in "/api/companies/{companyId}/issues".
router.get("/issues", (_req, res) => {
res.status(400).json({
error: "Missing companyId in path. Use /api/companies/{companyId}/issues.",
});
});
router.get("/companies/:companyId/issues", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -223,6 +228,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
touchedByUserId,
unreadForUserId,
projectId: req.query.projectId as string | undefined,
parentId: req.query.parentId as string | undefined,
labelId: req.query.labelId as string | undefined,
q: req.query.q as string | undefined,
});
@@ -291,16 +297,242 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
assertCompanyAccess(req, issue.companyId);
const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([
const [ancestors, project, goal, mentionedProjectIds, documentPayload] = await Promise.all([
svc.getAncestors(issue.id),
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
issue.goalId ? goalsSvc.getById(issue.goalId) : null,
issue.goalId
? goalsSvc.getById(issue.goalId)
: !issue.projectId
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
: null,
svc.findMentionedProjectIds(issue.id),
documentsSvc.getIssueDocumentPayload(issue),
]);
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
: [];
res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects });
res.json({
...issue,
goalId: goal?.id ?? issue.goalId,
ancestors,
...documentPayload,
project: project ?? null,
goal: goal ?? null,
mentionedProjects,
});
});
router.get("/issues/:id/heartbeat-context", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const wakeCommentId =
typeof req.query.wakeCommentId === "string" && req.query.wakeCommentId.trim().length > 0
? req.query.wakeCommentId.trim()
: null;
const [ancestors, project, goal, commentCursor, wakeComment] = await Promise.all([
svc.getAncestors(issue.id),
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
issue.goalId
? goalsSvc.getById(issue.goalId)
: !issue.projectId
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
: null,
svc.getCommentCursor(issue.id),
wakeCommentId ? svc.getComment(wakeCommentId) : null,
]);
res.json({
issue: {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
description: issue.description,
status: issue.status,
priority: issue.priority,
projectId: issue.projectId,
goalId: goal?.id ?? issue.goalId,
parentId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
updatedAt: issue.updatedAt,
},
ancestors: ancestors.map((ancestor) => ({
id: ancestor.id,
identifier: ancestor.identifier,
title: ancestor.title,
status: ancestor.status,
priority: ancestor.priority,
})),
project: project
? {
id: project.id,
name: project.name,
status: project.status,
targetDate: project.targetDate,
}
: null,
goal: goal
? {
id: goal.id,
title: goal.title,
status: goal.status,
level: goal.level,
parentId: goal.parentId,
}
: null,
commentCursor,
wakeComment:
wakeComment && wakeComment.issueId === issue.id
? wakeComment
: null,
});
});
router.get("/issues/:id/documents", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const docs = await documentsSvc.listIssueDocuments(issue.id);
res.json(docs);
});
router.get("/issues/:id/documents/:key", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const doc = await documentsSvc.getIssueDocumentByKey(issue.id, keyParsed.data);
if (!doc) {
res.status(404).json({ error: "Document not found" });
return;
}
res.json(doc);
});
router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const actor = getActorInfo(req);
const result = await documentsSvc.upsertIssueDocument({
issueId: issue.id,
key: keyParsed.data,
title: req.body.title ?? null,
format: req.body.format,
body: req.body.body,
changeSummary: req.body.changeSummary ?? null,
baseRevisionId: req.body.baseRevisionId ?? null,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
});
const doc = result.document;
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: result.created ? "issue.document_created" : "issue.document_updated",
entityType: "issue",
entityId: issue.id,
details: {
key: doc.key,
documentId: doc.id,
title: doc.title,
format: doc.format,
revisionNumber: doc.latestRevisionNumber,
},
});
res.status(result.created ? 201 : 200).json(doc);
});
router.get("/issues/:id/documents/:key/revisions", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const revisions = await documentsSvc.listIssueDocumentRevisions(issue.id, keyParsed.data);
res.json(revisions);
});
router.delete("/issues/:id/documents/:key", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const removed = await documentsSvc.deleteIssueDocument(issue.id, keyParsed.data);
if (!removed) {
res.status(404).json({ error: "Document not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.document_deleted",
entityType: "issue",
entityId: issue.id,
details: {
key: removed.key,
documentId: removed.id,
title: removed.title,
},
});
res.json({ ok: true });
});
router.post("/issues/:id/read", async (req, res) => {
@@ -522,6 +754,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
}
const actor = getActorInfo(req);
const hasFieldChanges = Object.keys(previous).length > 0;
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
@@ -531,7 +764,12 @@ export function issueRoutes(db: Db, storage: StorageService) {
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
details: { ...updateFields, identifier: issue.identifier, _previous: Object.keys(previous).length > 0 ? previous : undefined },
details: {
...updateFields,
identifier: issue.identifier,
...(commentBody ? { source: "comment" } : {}),
_previous: hasFieldChanges ? previous : undefined,
},
});
let comment = null;
@@ -555,12 +793,17 @@ export function issueRoutes(db: Db, storage: StorageService) {
bodySnippet: comment.body.slice(0, 120),
identifier: issue.identifier,
issueTitle: issue.title,
...(hasFieldChanges ? { updated: true } : {}),
},
});
}
const assigneeChanged = assigneeWillChange;
const statusChangedFromBacklog =
existing.status === "backlog" &&
issue.status !== "backlog" &&
req.body.status !== undefined;
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
void (async () => {
@@ -578,6 +821,18 @@ export function issueRoutes(db: Db, storage: StorageService) {
});
}
if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) {
wakeups.set(issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_status_changed",
payload: { issueId: issue.id, mutation: "update" },
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: { issueId: issue.id, source: "issue.status_change" },
});
}
if (commentBody && comment) {
let mentionedIds: string[] = [];
try {
@@ -757,7 +1012,29 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
assertCompanyAccess(req, issue.companyId);
const comments = await svc.listComments(id);
const afterCommentId =
typeof req.query.after === "string" && req.query.after.trim().length > 0
? req.query.after.trim()
: typeof req.query.afterCommentId === "string" && req.query.afterCommentId.trim().length > 0
? req.query.afterCommentId.trim()
: null;
const order =
typeof req.query.order === "string" && req.query.order.trim().toLowerCase() === "asc"
? "asc"
: "desc";
const limitRaw =
typeof req.query.limit === "string" && req.query.limit.trim().length > 0
? Number(req.query.limit)
: null;
const limit =
limitRaw && Number.isFinite(limitRaw) && limitRaw > 0
? Math.min(Math.floor(limitRaw), MAX_ISSUE_COMMENT_LIMIT)
: null;
const comments = await svc.listComments(id, {
afterCommentId,
order,
limit,
});
res.json(comments);
});
@@ -1037,7 +1314,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
const contentType = (file.mimetype || "").toLowerCase();
if (!ALLOWED_ATTACHMENT_CONTENT_TYPES.has(contentType)) {
if (!isAllowedContentType(contentType)) {
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
return;
}

View File

@@ -0,0 +1,496 @@
/**
* @fileoverview Plugin UI static file serving route
*
* Serves plugin UI bundles from the plugin's dist/ui/ directory under the
* `/_plugins/:pluginId/ui/*` namespace. This is specified in PLUGIN_SPEC.md
* §19.0.3 (Bundle Serving).
*
* Plugin UI bundles are pre-built ESM that the host serves as static assets.
* The host dynamically imports the plugin's UI entry module from this path,
* resolves the named export declared in `ui.slots[].exportName`, and mounts
* it into the extension slot.
*
* Security:
* - Path traversal is prevented by resolving the requested path and verifying
* it stays within the plugin's UI directory.
* - Only plugins in 'ready' status have their UI served.
* - Only plugins that declare `entrypoints.ui` serve UI bundles.
*
* Cache Headers:
* - Files with content-hash patterns in their name (e.g., `index-a1b2c3d4.js`)
* receive `Cache-Control: public, max-age=31536000, immutable`.
* - Other files receive `Cache-Control: public, max-age=0, must-revalidate`
* with ETag-based conditional request support.
*
* @module server/routes/plugin-ui-static
* @see doc/plugins/PLUGIN_SPEC.md §19.0.3 — Bundle Serving
* @see doc/plugins/PLUGIN_SPEC.md §25.4.5 — Frontend Cache Invalidation
*/
import { Router } from "express";
import path from "node:path";
import fs from "node:fs";
import crypto from "node:crypto";
import type { Db } from "@paperclipai/db";
import { pluginRegistryService } from "../services/plugin-registry.js";
import { logger } from "../middleware/logger.js";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/**
* Regex to detect content-hashed filenames.
*
* Matches patterns like:
* - `index-a1b2c3d4.js`
* - `styles.abc123def.css`
* - `chunk-ABCDEF01.mjs`
*
* The hash portion must be at least 8 hex characters to avoid false positives.
*/
const CONTENT_HASH_PATTERN = /[.-][a-fA-F0-9]{8,}\.\w+$/;
/**
* Cache-Control header for content-hashed files.
* These files are immutable by definition (the hash changes when content changes).
*/
/** 1 year in seconds — standard for content-hashed immutable resources. */
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60; // 31_536_000
const CACHE_CONTROL_IMMUTABLE = `public, max-age=${ONE_YEAR_SECONDS}, immutable`;
/**
* Cache-Control header for non-hashed files.
* These files must be revalidated on each request (ETag-based).
*/
const CACHE_CONTROL_REVALIDATE = "public, max-age=0, must-revalidate";
/**
* MIME types for common plugin UI bundle file extensions.
*/
const MIME_TYPES: Record<string, string> = {
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".html": "text/html; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
".ico": "image/x-icon",
".txt": "text/plain; charset=utf-8",
};
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
/**
* Resolve a plugin's UI directory from its package location.
*
* The plugin's `packageName` is stored in the DB. We resolve the package path
* from the local plugin directory (DEFAULT_LOCAL_PLUGIN_DIR) by looking in
* `node_modules`. If the plugin was installed from a local path, the manifest
* `entrypoints.ui` path is resolved relative to the package directory.
*
* @param localPluginDir - The plugin installation directory
* @param packageName - The npm package name
* @param entrypointsUi - The UI entrypoint path from the manifest (e.g., "./dist/ui/")
* @returns Absolute path to the UI directory, or null if not found
*/
export function resolvePluginUiDir(
localPluginDir: string,
packageName: string,
entrypointsUi: string,
packagePath?: string | null,
): string | null {
// For local-path installs, prefer the persisted package path.
if (packagePath) {
const resolvedPackagePath = path.resolve(packagePath);
if (fs.existsSync(resolvedPackagePath)) {
const uiDirFromPackagePath = path.resolve(resolvedPackagePath, entrypointsUi);
if (
uiDirFromPackagePath.startsWith(resolvedPackagePath)
&& fs.existsSync(uiDirFromPackagePath)
) {
return uiDirFromPackagePath;
}
}
}
// Resolve the package root within the local plugin directory's node_modules.
// npm installs go to <localPluginDir>/node_modules/<packageName>/
let packageRoot: string;
if (packageName.startsWith("@")) {
// Scoped package: @scope/name -> node_modules/@scope/name
packageRoot = path.join(localPluginDir, "node_modules", ...packageName.split("/"));
} else {
packageRoot = path.join(localPluginDir, "node_modules", packageName);
}
// If the standard location doesn't exist, the plugin may have been installed
// from a local path. Try to check if the package.json is accessible at the
// computed path or if the package is found elsewhere.
if (!fs.existsSync(packageRoot)) {
// For local-path installs, the packageName may be a directory that doesn't
// live inside node_modules. Check if the package exists directly at the
// localPluginDir level.
const directPath = path.join(localPluginDir, packageName);
if (fs.existsSync(directPath)) {
packageRoot = directPath;
} else {
return null;
}
}
// Resolve the UI directory relative to the package root
const uiDir = path.resolve(packageRoot, entrypointsUi);
// Verify the resolved UI directory exists and is actually inside the package
if (!fs.existsSync(uiDir)) {
return null;
}
return uiDir;
}
/**
* Compute an ETag from file stat (size + mtime).
* This is a lightweight approach that avoids reading the file content.
*/
function computeETag(size: number, mtimeMs: number): string {
const ETAG_VERSION = "v2";
const hash = crypto
.createHash("md5")
.update(`${ETAG_VERSION}:${size}-${mtimeMs}`)
.digest("hex")
.slice(0, 16);
return `"${hash}"`;
}
// ---------------------------------------------------------------------------
// Route factory
// ---------------------------------------------------------------------------
/**
* Options for the plugin UI static route.
*/
export interface PluginUiStaticRouteOptions {
/**
* The local plugin installation directory.
* This is where plugins are installed via `npm install --prefix`.
* Defaults to the standard `~/.paperclip/plugins/` location.
*/
localPluginDir: string;
}
/**
* Create an Express router that serves plugin UI static files.
*
* This route handles `GET /_plugins/:pluginId/ui/*` requests by:
* 1. Looking up the plugin in the registry by ID or key
* 2. Verifying the plugin is in 'ready' status with UI declared
* 3. Resolving the file path within the plugin's dist/ui/ directory
* 4. Serving the file with appropriate cache headers
*
* @param db - Database connection for plugin registry lookups
* @param options - Configuration options
* @returns Express router
*/
export function pluginUiStaticRoutes(db: Db, options: PluginUiStaticRouteOptions) {
const router = Router();
const registry = pluginRegistryService(db);
const log = logger.child({ service: "plugin-ui-static" });
/**
* GET /_plugins/:pluginId/ui/*
*
* Serve a static file from a plugin's UI bundle directory.
*
* The :pluginId parameter accepts either:
* - Database UUID
* - Plugin key (e.g., "acme.linear")
*
* The wildcard captures the relative file path within the UI directory.
*
* Cache strategy:
* - Content-hashed filenames → immutable, 1-year max-age
* - Other files → must-revalidate with ETag
*/
router.get("/_plugins/:pluginId/ui/*filePath", async (req, res) => {
const { pluginId } = req.params;
// Extract the relative file path from the named wildcard.
// In Express 5 with path-to-regexp v8, named wildcards may return
// an array of path segments or a single string.
const rawParam = req.params.filePath;
const rawFilePath = Array.isArray(rawParam)
? rawParam.join("/")
: rawParam as string | undefined;
if (!rawFilePath || rawFilePath.length === 0) {
res.status(400).json({ error: "File path is required" });
return;
}
// Step 1: Look up the plugin
let plugin = null;
try {
plugin = await registry.getById(pluginId);
} catch (error) {
const maybeCode =
typeof error === "object" && error !== null && "code" in error
? (error as { code?: unknown }).code
: undefined;
if (maybeCode !== "22P02") {
throw error;
}
}
if (!plugin) {
plugin = await registry.getByKey(pluginId);
}
if (!plugin) {
res.status(404).json({ error: "Plugin not found" });
return;
}
// Step 2: Verify the plugin is ready and has UI declared
if (plugin.status !== "ready") {
res.status(403).json({
error: `Plugin UI is not available (status: ${plugin.status})`,
});
return;
}
const manifest = plugin.manifestJson;
if (!manifest?.entrypoints?.ui) {
res.status(404).json({ error: "Plugin does not declare a UI bundle" });
return;
}
// Step 2b: Check for devUiUrl in plugin config — proxy to local dev server
// when a plugin author has configured a dev server URL for hot-reload.
// See PLUGIN_SPEC.md §27.2 — Local Development Workflow
try {
const configRow = await registry.getConfig(plugin.id);
const devUiUrl =
configRow &&
typeof configRow === "object" &&
"configJson" in configRow &&
(configRow as { configJson: Record<string, unknown> }).configJson?.devUiUrl;
if (typeof devUiUrl === "string" && devUiUrl.length > 0) {
// Dev proxy is only available in development mode
if (process.env.NODE_ENV === "production") {
log.warn(
{ pluginId: plugin.id },
"plugin-ui-static: devUiUrl ignored in production",
);
// Fall through to static file serving below
} else {
// Guard against rawFilePath overriding the base URL via protocol
// scheme (e.g. "https://evil.com/x") or protocol-relative paths
// (e.g. "//evil.com/x") which cause `new URL(path, base)` to
// ignore the base entirely.
// Normalize percent-encoding so encoded slashes (%2F) can't bypass
// the protocol/path checks below.
let decodedPath: string;
try {
decodedPath = decodeURIComponent(rawFilePath);
} catch {
res.status(400).json({ error: "Invalid file path" });
return;
}
if (
decodedPath.includes("://") ||
decodedPath.startsWith("//") ||
decodedPath.startsWith("\\\\")
) {
res.status(400).json({ error: "Invalid file path" });
return;
}
// Proxy the request to the dev server
const targetUrl = new URL(rawFilePath, devUiUrl.endsWith("/") ? devUiUrl : devUiUrl + "/");
// SSRF protection: only allow http/https and localhost targets for dev proxy
if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") {
res.status(400).json({ error: "devUiUrl must use http or https protocol" });
return;
}
// Dev proxy is restricted to loopback addresses only.
// Validate the *constructed* targetUrl hostname (not the base) to
// catch any path-based override that slipped past the checks above.
const devHost = targetUrl.hostname;
const isLoopback =
devHost === "localhost" ||
devHost === "127.0.0.1" ||
devHost === "::1" ||
devHost === "[::1]";
if (!isLoopback) {
log.warn(
{ pluginId: plugin.id, devUiUrl, host: devHost },
"plugin-ui-static: devUiUrl must target localhost, rejecting proxy",
);
res.status(400).json({ error: "devUiUrl must target localhost" });
return;
}
log.debug(
{ pluginId: plugin.id, devUiUrl, targetUrl: targetUrl.href },
"plugin-ui-static: proxying to devUiUrl",
);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const upstream = await fetch(targetUrl.href, { signal: controller.signal });
if (!upstream.ok) {
res.status(upstream.status).json({
error: `Dev server returned ${upstream.status}`,
});
return;
}
const contentType = upstream.headers.get("content-type");
if (contentType) res.set("Content-Type", contentType);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
const body = await upstream.arrayBuffer();
res.send(Buffer.from(body));
return;
} finally {
clearTimeout(timeout);
}
} catch (proxyErr) {
log.warn(
{
pluginId: plugin.id,
devUiUrl,
err: proxyErr instanceof Error ? proxyErr.message : String(proxyErr),
},
"plugin-ui-static: failed to proxy to devUiUrl, falling back to static",
);
// Fall through to static serving below
}
}
}
} catch {
// Config lookup failure is non-fatal — fall through to static serving
}
// Step 3: Resolve the plugin's UI directory
const uiDir = resolvePluginUiDir(
options.localPluginDir,
plugin.packageName,
manifest.entrypoints.ui,
plugin.packagePath,
);
if (!uiDir) {
log.warn(
{ pluginId: plugin.id, pluginKey: plugin.pluginKey, packageName: plugin.packageName },
"plugin-ui-static: UI directory not found on disk",
);
res.status(404).json({ error: "Plugin UI directory not found" });
return;
}
// Step 4: Resolve the requested file path and prevent traversal (including symlinks)
const resolvedFilePath = path.resolve(uiDir, rawFilePath);
// Step 5: Check that the file exists and is a regular file
let fileStat: fs.Stats;
try {
fileStat = fs.statSync(resolvedFilePath);
} catch {
res.status(404).json({ error: "File not found" });
return;
}
// Security: resolve symlinks via realpathSync and verify containment.
// This prevents symlink-based traversal that string-based startsWith misses.
let realFilePath: string;
let realUiDir: string;
try {
realFilePath = fs.realpathSync(resolvedFilePath);
realUiDir = fs.realpathSync(uiDir);
} catch {
res.status(404).json({ error: "File not found" });
return;
}
const relative = path.relative(realUiDir, realFilePath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
res.status(403).json({ error: "Access denied" });
return;
}
if (!fileStat.isFile()) {
res.status(404).json({ error: "File not found" });
return;
}
// Step 6: Determine cache strategy based on filename
const basename = path.basename(resolvedFilePath);
const isContentHashed = CONTENT_HASH_PATTERN.test(basename);
// Step 7: Set cache headers
if (isContentHashed) {
res.set("Cache-Control", CACHE_CONTROL_IMMUTABLE);
} else {
res.set("Cache-Control", CACHE_CONTROL_REVALIDATE);
// Compute and set ETag for conditional request support
const etag = computeETag(fileStat.size, fileStat.mtimeMs);
res.set("ETag", etag);
// Check If-None-Match for 304 Not Modified
const ifNoneMatch = req.headers["if-none-match"];
if (ifNoneMatch === etag) {
res.status(304).end();
return;
}
}
// Step 8: Set Content-Type
const ext = path.extname(resolvedFilePath).toLowerCase();
const contentType = MIME_TYPES[ext];
if (contentType) {
res.set("Content-Type", contentType);
}
// Step 9: Set CORS headers (plugin UI may be loaded from different origin in dev)
res.set("Access-Control-Allow-Origin", "*");
// Step 10: Send the file
// The plugin source can live in Git worktrees (e.g. ".worktrees/...").
// `send` defaults to dotfiles:"ignore", which treats dot-directories as
// not found. We already enforce traversal safety above, so allow dot paths.
res.sendFile(resolvedFilePath, { dotfiles: "allow" }, (err) => {
if (err) {
log.error(
{ err, pluginId: plugin.id, filePath: resolvedFilePath },
"plugin-ui-static: error sending file",
);
// Only send error if headers haven't been sent yet
if (!res.headersSent) {
res.status(500).json({ error: "Failed to serve file" });
}
}
});
});
return router;
}

2219
server/src/routes/plugins.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ import type { Db } from "@paperclipai/db";
import { and, eq, sql } from "drizzle-orm";
import { joinRequests } from "@paperclipai/db";
import { sidebarBadgeService } from "../services/sidebar-badges.js";
import { issueService } from "../services/issues.js";
import { accessService } from "../services/access.js";
import { dashboardService } from "../services/dashboard.js";
import { assertCompanyAccess } from "./authz.js";
@@ -11,7 +10,6 @@ import { assertCompanyAccess } from "./authz.js";
export function sidebarBadgeRoutes(db: Db) {
const router = Router();
const svc = sidebarBadgeService(db);
const issueSvc = issueService(db);
const access = accessService(db);
const dashboard = dashboardService(db);
@@ -40,12 +38,11 @@ export function sidebarBadgeRoutes(db: Db) {
joinRequests: joinRequestCount,
});
const summary = await dashboard.summary(companyId);
const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60);
const hasFailedRuns = badges.failedRuns > 0;
const alertsCount =
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount + badges.approvals;
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
res.json(badges);
});