mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 15:09:57 +02:00
Enforce private task and project access across direct reads, search, execution, files, plugins, live delivery, and sharing mutations. Preserve downward-only sharing, current responsible-user authorization, and audited emergency access. Bind historical draft assets with migration 0314. Co-Authored-By: Paperclip <noreply@paperclip.ing>
1016 lines
44 KiB
TypeScript
1016 lines
44 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { activityLog } from "@paperclipai/db";
|
|
import { projectToolContext } from "../services/project-tool-context.js";
|
|
import { persistActivity, publishActivity } from "../services/activity-log.js";
|
|
import { z } from "zod";
|
|
import { normalizeProjectRepositoryUrl, resolveProjectRepositorySelection } from "../services/project-repositories.js";
|
|
import { toolAccessService } from "../services/tool-access.js";
|
|
import { Router, type Request, type Response } from "express";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { agents, authUsers, companyMemberships, instanceUserRoles, projectAccessMembers } from "@paperclipai/db";
|
|
import { inArray } from "drizzle-orm";
|
|
import {
|
|
addProjectAccessMemberSchema,
|
|
createProjectSchema,
|
|
projectDiscoverySchema,
|
|
type ProjectDiscoveryPage,
|
|
createProjectWorkspaceSchema,
|
|
deriveProjectUrlKey,
|
|
findWorkspaceCommandDefinition,
|
|
isUuidLike,
|
|
matchWorkspaceRuntimeServiceToCommand,
|
|
normalizeProjectUrlKey,
|
|
updateProjectSchema,
|
|
updateProjectWorkspaceSchema,
|
|
workspaceRuntimeControlTargetSchema,
|
|
} from "@paperclipai/shared";
|
|
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
|
|
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
|
import { validate } from "../middleware/validate.js";
|
|
import { accessService, projectService, logActivity, workspaceOperationService } from "../services/index.js";
|
|
import { conflict, forbidden, unprocessable } from "../errors.js";
|
|
import { externalObjectService } from "../services/external-objects.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
|
|
import { ensureProjectAccessMember } from "../services/projects.js";
|
|
import {
|
|
buildWorkspaceRuntimeDesiredStatePatch,
|
|
listConfiguredRuntimeServiceEntries,
|
|
runWorkspaceJobForControl,
|
|
startRuntimeServicesForWorkspaceControl,
|
|
stopRuntimeServicesForProjectWorkspace,
|
|
} from "../services/workspace-runtime.js";
|
|
import {
|
|
assertNoAgentHostWorkspaceCommandMutation,
|
|
collectProjectExecutionWorkspaceCommandPaths,
|
|
collectProjectWorkspaceCommandPaths,
|
|
} from "./workspace-command-authz.js";
|
|
import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js";
|
|
import { getTelemetryClient } from "../telemetry.js";
|
|
import { appendWithCap } from "../adapters/utils.js";
|
|
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
|
|
import { environmentService } from "../services/environments.js";
|
|
import { secretService } from "../services/secrets.js";
|
|
|
|
const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024;
|
|
const SHARED_WORKSPACE_STOP_AND_RESTART_ACTIONS = new Set(["stop", "restart"]);
|
|
|
|
export function projectRoutes(db: Db) {
|
|
const router = Router();
|
|
const svc = projectService(db);
|
|
|
|
async function repositoryViewer(req: Request) {
|
|
if (req.actor.type === "board") return { userId: req.actor.userId ?? null, localTrusted: req.actor.source === "local_implicit" };
|
|
const context = await projectToolContext(db, req.actor);
|
|
if (!context.userId) throw forbidden("Repository access requires a responsible user");
|
|
return context;
|
|
}
|
|
|
|
async function selectedRepositories(req: Request, companyId: string, ids: string[], existing: import("@paperclipai/shared").ProjectWorkspace[] = []) {
|
|
const viewer = await repositoryViewer(req);
|
|
if (!ids.length) return [];
|
|
const available = await toolAccessService(db).listProjectRepositories(companyId, viewer.userId, viewer.localTrusted);
|
|
return resolveProjectRepositorySelection(ids, available.repositories, existing);
|
|
}
|
|
const access = accessService(db);
|
|
const secretsSvc = secretService(db);
|
|
const workspaceOperations = workspaceOperationService(db);
|
|
const instanceSettings = instanceSettingsService(db);
|
|
const externalObjectsSvc = externalObjectService(db, {
|
|
enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true,
|
|
});
|
|
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
|
const environmentsSvc = environmentService(db);
|
|
|
|
/**
|
|
* Managed-sandbox-only policy (`enableManagedSandboxOnly`): a project
|
|
* workspace `cwd` is an absolute path on the execution host. When the policy
|
|
* is on every agent runs in the platform-managed environment, so there is no
|
|
* host for a user to point at and a write that carries a path is refused
|
|
* rather than stored and silently ignored. This is the floor behind the
|
|
* hidden UI field, and it applies to every actor, mirroring how
|
|
* `assertNoAgentHostWorkspaceCommandMutation` floors host-executed commands
|
|
* on these same routes.
|
|
*
|
|
* `cwd: null` still passes: clearing a stale path is exactly what an instance
|
|
* that just turned the policy on needs to do. The settings read only happens
|
|
* when the payload actually carries a path.
|
|
*/
|
|
async function assertNoManagedSandboxWorkspacePath(workspacePatch: unknown) {
|
|
if (typeof workspacePatch !== "object" || workspacePatch === null || Array.isArray(workspacePatch)) return;
|
|
const patch = workspacePatch as Record<string, unknown>;
|
|
if (!Object.prototype.hasOwnProperty.call(patch, "cwd")) return;
|
|
if (patch.cwd === null || patch.cwd === undefined) return;
|
|
if ((await instanceSettings.getExperimental()).enableManagedSandboxOnly !== true) return;
|
|
throw unprocessable(
|
|
"This instance runs agents only in the platform-managed environment; local folders are not configurable.",
|
|
);
|
|
}
|
|
|
|
async function assertProjectEnvironmentSelection(companyId: string, environmentId: string | null | undefined) {
|
|
if (environmentId === undefined || environmentId === null) return;
|
|
await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, {
|
|
allowedDrivers: ["local", "ssh", "sandbox"],
|
|
});
|
|
}
|
|
|
|
function readProjectPolicyEnvironmentId(policy: unknown): string | null | undefined {
|
|
if (!policy || typeof policy !== "object" || !("environmentId" in policy)) {
|
|
return undefined;
|
|
}
|
|
const environmentId = (policy as { environmentId?: unknown }).environmentId;
|
|
return typeof environmentId === "string" || environmentId === null ? environmentId : undefined;
|
|
}
|
|
|
|
async function resolveCompanyIdForProjectReference(req: Request) {
|
|
const companyIdQuery = req.query.companyId;
|
|
const requestedCompanyId =
|
|
typeof companyIdQuery === "string" && companyIdQuery.trim().length > 0
|
|
? companyIdQuery.trim()
|
|
: null;
|
|
if (requestedCompanyId) {
|
|
assertCompanyAccess(req, requestedCompanyId);
|
|
return requestedCompanyId;
|
|
}
|
|
if (req.actor.type === "agent" && req.actor.companyId) {
|
|
return req.actor.companyId;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function normalizeProjectReference(req: Request, rawId: string) {
|
|
if (isUuidLike(rawId)) return rawId;
|
|
const companyId = await resolveCompanyIdForProjectReference(req);
|
|
if (!companyId) return rawId;
|
|
const urlKey = normalizeProjectUrlKey(rawId);
|
|
if (!urlKey) return rawId;
|
|
const visibleProjects = await filterProjectsForActor(
|
|
req,
|
|
await svc.list(companyId, { includeArchived: true }),
|
|
);
|
|
const matches = visibleProjects.filter((project) => deriveProjectUrlKey(project.name, project.id) === urlKey);
|
|
if (matches.length > 1) {
|
|
throw conflict("Project shortname is ambiguous in this company. Use the project ID.");
|
|
}
|
|
return matches[0]?.id ?? rawId;
|
|
}
|
|
|
|
async function assertProjectReadAllowed(req: Request, res: Response, project: { id: string; companyId: string }) {
|
|
const decision = await access.decide({
|
|
actor: req.actor,
|
|
action: "project:read",
|
|
resource: { type: "project", companyId: project.companyId, projectId: project.id },
|
|
});
|
|
if (decision.allowed) return true;
|
|
res.status(404).json({ error: "Project not found" });
|
|
return false;
|
|
}
|
|
|
|
async function assertCanManageProjectPrivacy(req: Request, res: Response, project: {
|
|
companyId: string; privacyOwnerUserId?: string | null; personalOwnerUserId?: string | null;
|
|
}) {
|
|
const actor = req.actor;
|
|
if (actor.type === "board" && actor.userId) {
|
|
if (actor.source === "local_implicit" || actor.isInstanceAdmin) return true;
|
|
if (actor.userId === project.privacyOwnerUserId || actor.userId === project.personalOwnerUserId) return true;
|
|
const instanceAdmin = actor.source !== "cloud_tenant" && await db.select({ id: instanceUserRoles.id }).from(instanceUserRoles)
|
|
.where(and(eq(instanceUserRoles.userId, actor.userId), eq(instanceUserRoles.role, "instance_admin"))).limit(1).then(rows => rows.length > 0);
|
|
if (instanceAdmin) return true;
|
|
const membership = await db.select({ role: companyMemberships.membershipRole }).from(companyMemberships)
|
|
.where(and(eq(companyMemberships.companyId, project.companyId), eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, actor.userId), eq(companyMemberships.status, "active"))).limit(1);
|
|
if (["owner", "admin"].includes(membership[0]?.role ?? "")) return true;
|
|
}
|
|
res.status(403).json({ error: "Only the project privacy owner or an administrator can change project access" });
|
|
return false;
|
|
}
|
|
|
|
async function addActorAsPrivateProjectPrincipal(req: Request, project: { id: string; companyId: string; privacyOwnerUserId?: string | null; personalOwnerUserId?: string | null }, dbOrTx: Db = db) {
|
|
const ownerUserId = project.privacyOwnerUserId ?? project.personalOwnerUserId;
|
|
if (ownerUserId) await ensureProjectAccessMember(dbOrTx, {
|
|
companyId: project.companyId, projectId: project.id, subjectType: "user", subjectId: ownerUserId,
|
|
});
|
|
if (req.actor.type === "board" && req.actor.userId) {
|
|
await ensureProjectAccessMember(dbOrTx, {
|
|
companyId: project.companyId,
|
|
projectId: project.id,
|
|
subjectType: "user",
|
|
subjectId: req.actor.userId,
|
|
});
|
|
return;
|
|
}
|
|
if (req.actor.type === "agent" && req.actor.agentId) {
|
|
if (req.actor.onBehalfOfUserId) {
|
|
await ensureProjectAccessMember(dbOrTx, {
|
|
companyId: project.companyId,
|
|
projectId: project.id,
|
|
subjectType: "user",
|
|
subjectId: req.actor.onBehalfOfUserId,
|
|
});
|
|
}
|
|
await ensureProjectAccessMember(dbOrTx, {
|
|
companyId: project.companyId,
|
|
projectId: project.id,
|
|
subjectType: "agent",
|
|
subjectId: req.actor.agentId,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function enrichProjectAccessMembers(rows: Array<typeof projectAccessMembers.$inferSelect>) {
|
|
const agentIds = rows.filter((row) => row.subjectType === "agent").map((row) => row.subjectId);
|
|
const userIds = rows.filter((row) => row.subjectType === "user").map((row) => row.subjectId);
|
|
const [agentRows, userRows] = await Promise.all([
|
|
agentIds.length === 0
|
|
? Promise.resolve([])
|
|
: db.select({ id: agents.id, name: agents.name }).from(agents).where(inArray(agents.id, agentIds)),
|
|
userIds.length === 0
|
|
? Promise.resolve([])
|
|
: db.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email, image: authUsers.image })
|
|
.from(authUsers)
|
|
.where(inArray(authUsers.id, userIds)),
|
|
]);
|
|
const agentsById = new Map(agentRows.map((row) => [row.id, row]));
|
|
const usersById = new Map(userRows.map((row) => [row.id, row]));
|
|
return rows.map((row) => {
|
|
const agent = row.subjectType === "agent" ? agentsById.get(row.subjectId) : null;
|
|
const user = row.subjectType === "user" ? usersById.get(row.subjectId) : null;
|
|
return {
|
|
...row,
|
|
subjectDisplayName: agent?.name ?? user?.name ?? user?.email ?? null,
|
|
subjectAvatarUrl: user?.image ?? null,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function assertRuntimeManageAllowed(req: Request, res: Response, companyId: string) {
|
|
const decision = await access.decide({
|
|
actor: req.actor,
|
|
action: "runtime:manage",
|
|
resource: { type: "company", companyId },
|
|
});
|
|
if (decision.allowed) return true;
|
|
res.status(403).json({ error: "Runtime service control is outside this actor's authorization boundary" });
|
|
return false;
|
|
}
|
|
|
|
async function filterProjectsForActor<T extends { id: string; companyId: string }>(req: Request, rows: T[]) {
|
|
const decisions = await Promise.all(rows.map((project) =>
|
|
access.decide({
|
|
actor: req.actor,
|
|
action: "project:read",
|
|
resource: { type: "project", companyId: project.companyId, projectId: project.id },
|
|
})
|
|
));
|
|
return rows.filter((_, index) => decisions[index]?.allowed);
|
|
}
|
|
|
|
router.param("id", async (req, _res, next, rawId) => {
|
|
try {
|
|
req.params.id = await normalizeProjectReference(req, rawId);
|
|
next();
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
router.get("/companies/:companyId/project-repositories", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const viewer = await repositoryViewer(req);
|
|
res.json(await toolAccessService(db).listProjectRepositories(companyId, viewer.userId, viewer.localTrusted));
|
|
});
|
|
|
|
router.put("/projects/:id/repositories", validate(z.object({ repositoryIds: z.array(z.string().regex(/^\d+$/)) })), async (req, res) => {
|
|
assertBoard(req);
|
|
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
|
|
if (!project) return;
|
|
const repositories = await selectedRepositories(req, project.companyId, req.body.repositoryIds, project.workspaces);
|
|
const updated = await svc.replaceRepositories(project.id, repositories);
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: project.companyId, actorType: actor.actorType, actorId: actor.actorId,
|
|
action: "project.repositories_updated", entityType: "project", entityId: project.id,
|
|
details: { repositoryIds: repositories.map((repo) => repo.id) },
|
|
});
|
|
res.json(updated);
|
|
});
|
|
|
|
router.get("/companies/:companyId/projects", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const includeArchived = req.query.includeArchived === "true";
|
|
if (req.query.view === "summary") {
|
|
const page = projectDiscoverySchema.extend({ limit: z.coerce.number().int().min(1).max(50).default(50) }).parse({
|
|
limit: req.query.limit, cursor: req.query.cursor,
|
|
});
|
|
const candidateIds = await access.projectDiscoveryCandidateIds(req.actor, companyId);
|
|
const visible: ProjectDiscoveryPage["projects"] = [];
|
|
let cursor = page.cursor;
|
|
// Scan bounded projections; authorization runs before selecting the public
|
|
// page/cursor so denied projects neither fill pages nor leak their IDs.
|
|
while (visible.length <= page.limit) {
|
|
const batch = await svc.listSummaries(companyId, { limit: 51, cursor, includeArchived, candidateIds });
|
|
const allowed = await filterProjectsForActor(req, batch.map(project => ({ ...project, companyId })));
|
|
visible.push(...allowed.map(({ companyId: _companyId, ...project }) => project));
|
|
if (batch.length < 51) break;
|
|
cursor = batch.at(-1)!.id;
|
|
}
|
|
const selected = visible.slice(0, page.limit);
|
|
res.json({ projects: selected, nextCursor: visible.length > page.limit ? selected.at(-1)!.id : null } satisfies ProjectDiscoveryPage);
|
|
return;
|
|
}
|
|
const result = await svc.list(companyId, { includeArchived });
|
|
res.json(await filterProjectsForActor(req, result));
|
|
});
|
|
|
|
router.get("/projects/:id", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!project) return;
|
|
if (!(await assertProjectReadAllowed(req, res, project))) return;
|
|
res.json(project);
|
|
});
|
|
|
|
router.get("/projects/:id/external-object-summary", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!project) return;
|
|
if (!(await assertProjectReadAllowed(req, res, project))) return;
|
|
const summary = await externalObjectsSvc.getProjectSummary(project.id);
|
|
res.json(summary);
|
|
});
|
|
|
|
router.post("/companies/:companyId/projects", validate(createProjectSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
type CreateProjectPayload = Parameters<typeof svc.create>[1] & {
|
|
workspace?: Parameters<typeof svc.createWorkspace>[1];
|
|
repositoryIds?: string[];
|
|
};
|
|
|
|
const { workspace, repositoryIds, repositoryUrls, idempotencyKey, ...projectData } = req.body as CreateProjectPayload & { idempotencyKey?: string; repositoryUrls?: string[] };
|
|
projectData.privacyOwnerUserId = req.actor.type === "board" ? req.actor.userId ?? null : req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : null;
|
|
const runContext = req.actor.type === "agent" && req.actor.source === "agent_jwt" && req.actor.runId
|
|
? await projectToolContext(db, req.actor, true) : null;
|
|
await assertProjectEnvironmentSelection(
|
|
companyId,
|
|
readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy),
|
|
);
|
|
assertNoAgentHostWorkspaceCommandMutation(
|
|
req,
|
|
[
|
|
...collectProjectExecutionWorkspaceCommandPaths(projectData.executionWorkspacePolicy),
|
|
...collectProjectWorkspaceCommandPaths(workspace, "workspace"),
|
|
],
|
|
);
|
|
await assertNoManagedSandboxWorkspacePath(workspace);
|
|
if (projectData.env !== undefined) {
|
|
projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence(
|
|
companyId,
|
|
projectData.env,
|
|
{ strictMode: strictSecretsMode, fieldPath: "env" },
|
|
);
|
|
}
|
|
if (workspace && (repositoryIds || repositoryUrls)) throw unprocessable("Use either workspace or repositoryIds/repositoryUrls when creating a project");
|
|
const urlRepositories = (repositoryUrls ?? []).map(normalizeProjectRepositoryUrl);
|
|
const repositories = repositoryIds ? await selectedRepositories(req, companyId, repositoryIds) : null;
|
|
const actor = getActorInfo(req);
|
|
const fingerprint = createHash("sha256").update(JSON.stringify({ projectData, workspace, repositoryIds, repositoryUrls })).digest("hex");
|
|
const receiptKey = idempotencyKey ? `project:${companyId}:${actor.actorId}:${runContext?.issue.id ?? "board"}:${idempotencyKey}` : null;
|
|
const result = await db.transaction(async (tx) => {
|
|
if (receiptKey) {
|
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${receiptKey}, 0))`);
|
|
const [prior] = await tx.select().from(activityLog).where(and(
|
|
eq(activityLog.companyId, companyId), eq(activityLog.action, "project.created"),
|
|
sql`${activityLog.details}->>'idempotencyKey' = ${receiptKey}`,
|
|
));
|
|
if (prior) {
|
|
if (prior.details?.fingerprint !== fingerprint) throw conflict("Project idempotency key was used with different inputs");
|
|
const project = await projectService(tx as unknown as Db).getById(prior.entityId);
|
|
if (!project) throw conflict("Previously created project is no longer available");
|
|
return { project, publication: null, duplicate: true };
|
|
}
|
|
}
|
|
if (runContext) await projectToolContext(tx as unknown as Db, req.actor, true);
|
|
const service = projectService(tx as unknown as Db);
|
|
const project = repositories ? await service.createWithRepositories(companyId, projectData, repositories) : await service.create(companyId, projectData);
|
|
if (project.visibility === "private") await addActorAsPrivateProjectPrincipal(req, project, tx as unknown as Db);
|
|
const attachedUrls = new Set((repositories ?? []).map(repo => repo.url.toLowerCase()));
|
|
const registeredUrls: typeof urlRepositories = [];
|
|
for (const repo of urlRepositories) {
|
|
if (attachedUrls.has(repo.url.toLowerCase())) continue;
|
|
attachedUrls.add(repo.url.toLowerCase());
|
|
await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url });
|
|
registeredUrls.push(repo);
|
|
}
|
|
const createdWorkspace = workspace ? await service.createWorkspace(project.id, workspace) : null;
|
|
if (workspace && !createdWorkspace) throw unprocessable("Invalid project workspace payload");
|
|
const hydrated = await service.getById(project.id);
|
|
const activity = await persistActivity(tx as unknown as Db, {
|
|
companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId,
|
|
runId: actor.runId, issueId: runContext?.issue.id,
|
|
action: "project.created", entityType: "project", entityId: project.id,
|
|
details: {
|
|
name: project.name, description: project.description, icon: project.icon,
|
|
sourceIssueId: runContext?.issue.id ?? null,
|
|
repositories: [...(repositories ?? []).map(repo => ({ id: repo.id, name: repo.fullName, url: repo.url })), ...registeredUrls.map(repo => ({ id: repo.url, name: repo.fullName, url: repo.url })),
|
|
...(createdWorkspace?.repoUrl ? [{ id: createdWorkspace.id, name: createdWorkspace.name, url: createdWorkspace.repoUrl }] : []),
|
|
],
|
|
workspaceId: createdWorkspace?.id ?? null,
|
|
envKeys: project.env ? Object.keys(project.env).sort() : [],
|
|
...(receiptKey ? { idempotencyKey: receiptKey, fingerprint } : {}),
|
|
},
|
|
});
|
|
return { project: hydrated ?? project, publication: activity.publication, duplicate: false };
|
|
});
|
|
if (result.publication) publishActivity(result.publication);
|
|
if (result.project.env) await secretsSvc.syncEnvBindingsForTarget?.(companyId, { targetType: "project", targetId: result.project.id }, result.project.env);
|
|
if (result.duplicate) { res.status(200).json(result.project); return; }
|
|
const telemetryClient = getTelemetryClient();
|
|
if (telemetryClient) {
|
|
trackProjectCreated(telemetryClient);
|
|
}
|
|
res.status(result.duplicate ? 200 : 201).json(result.project);
|
|
});
|
|
|
|
router.patch("/projects/:id", validate(updateProjectSchema), async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!existing) return;
|
|
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
|
const body = { ...req.body };
|
|
if (body.visibility !== undefined) {
|
|
if (!(await assertCanManageProjectPrivacy(req, res, existing))) return;
|
|
}
|
|
if (body.visibility === "private") await addActorAsPrivateProjectPrincipal(req, existing);
|
|
assertNoAgentHostWorkspaceCommandMutation(
|
|
req,
|
|
collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy),
|
|
);
|
|
await assertProjectEnvironmentSelection(
|
|
existing.companyId,
|
|
readProjectPolicyEnvironmentId(body.executionWorkspacePolicy),
|
|
);
|
|
if (typeof body.archivedAt === "string") {
|
|
body.archivedAt = new Date(body.archivedAt);
|
|
}
|
|
if (body.env !== undefined) {
|
|
body.env = await secretsSvc.normalizeEnvBindingsForPersistence(existing.companyId, body.env, {
|
|
strictMode: strictSecretsMode,
|
|
fieldPath: "env",
|
|
});
|
|
}
|
|
const project = await svc.update(id, body);
|
|
if (!project) {
|
|
res.status(404).json({ error: "Project not found" });
|
|
return;
|
|
}
|
|
if (body.env !== undefined) {
|
|
await secretsSvc.syncEnvBindingsForTarget?.(
|
|
project.companyId,
|
|
{ targetType: "project", targetId: project.id },
|
|
project.env,
|
|
);
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: project.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "project.updated",
|
|
entityType: "project",
|
|
entityId: project.id,
|
|
details: {
|
|
changedKeys: Object.keys(req.body).sort(),
|
|
envKeys:
|
|
body.env && typeof body.env === "object" && !Array.isArray(body.env)
|
|
? Object.keys(body.env as Record<string, unknown>).sort()
|
|
: undefined,
|
|
},
|
|
});
|
|
|
|
res.json(project);
|
|
});
|
|
|
|
router.get("/projects/:id/access-members", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
|
|
const rows = await db
|
|
.select()
|
|
.from(projectAccessMembers)
|
|
.where(and(eq(projectAccessMembers.companyId, project.companyId), eq(projectAccessMembers.projectId, project.id)));
|
|
res.json(await enrichProjectAccessMembers(rows));
|
|
});
|
|
|
|
router.post("/projects/:id/access-members", validate(addProjectAccessMemberSchema), async (req, res) => {
|
|
assertBoard(req);
|
|
const id = req.params.id as string;
|
|
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
|
|
if (!(await assertCanManageProjectPrivacy(req, res, project))) return;
|
|
const subjectType = req.body.subjectType as "user" | "agent";
|
|
const subjectId = req.body.subjectId as string;
|
|
const subjectExists = subjectType === "agent"
|
|
? await db.select({ id: agents.id }).from(agents)
|
|
.where(and(eq(agents.id, subjectId), eq(agents.companyId, project.companyId)))
|
|
.then((rows) => rows.length > 0)
|
|
: await db.select({ id: companyMemberships.id }).from(companyMemberships)
|
|
.where(and(
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, subjectId),
|
|
eq(companyMemberships.companyId, project.companyId),
|
|
eq(companyMemberships.status, "active"),
|
|
))
|
|
.then((rows) => rows.length > 0);
|
|
if (!subjectExists) {
|
|
res.status(422).json({ error: "Project access member must belong to this company" });
|
|
return;
|
|
}
|
|
await ensureProjectAccessMember(db, { companyId: project.companyId, projectId: project.id, subjectType, subjectId });
|
|
const rows = await db.select().from(projectAccessMembers).where(and(
|
|
eq(projectAccessMembers.projectId, project.id),
|
|
eq(projectAccessMembers.subjectType, subjectType),
|
|
eq(projectAccessMembers.subjectId, subjectId),
|
|
));
|
|
const [member] = await enrichProjectAccessMembers(rows);
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: project.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "project.access_member_added",
|
|
entityType: "project",
|
|
entityId: project.id,
|
|
details: { subjectType, subjectId },
|
|
});
|
|
res.status(201).json(member);
|
|
});
|
|
|
|
router.delete("/projects/:id/access-members/:memberId", async (req, res) => {
|
|
assertBoard(req);
|
|
const id = req.params.id as string;
|
|
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
|
|
if (!(await assertCanManageProjectPrivacy(req, res, project))) return;
|
|
const member = await db.select().from(projectAccessMembers).where(and(
|
|
eq(projectAccessMembers.id, req.params.memberId as string),
|
|
eq(projectAccessMembers.companyId, project.companyId),
|
|
eq(projectAccessMembers.projectId, project.id),
|
|
)).then((rows) => rows[0] ?? null);
|
|
if (!member) {
|
|
res.status(404).json({ error: "Project access member not found" });
|
|
return;
|
|
}
|
|
if (member.subjectType === "user" && [project.personalOwnerUserId, project.privacyOwnerUserId].includes(member.subjectId)) {
|
|
res.status(422).json({ error: "The project privacy owner cannot be removed" });
|
|
return;
|
|
}
|
|
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, member.id));
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: project.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "project.access_member_removed",
|
|
entityType: "project",
|
|
entityId: project.id,
|
|
details: { subjectType: member.subjectType, subjectId: member.subjectId },
|
|
});
|
|
res.json(member);
|
|
});
|
|
|
|
router.get("/projects/:id/workspaces", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!existing) return;
|
|
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
|
const workspaces = await svc.listWorkspaces(id);
|
|
res.json(workspaces);
|
|
});
|
|
|
|
router.post("/projects/:id/workspaces", validate(createProjectWorkspaceSchema), async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!existing) return;
|
|
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
|
assertNoAgentHostWorkspaceCommandMutation(
|
|
req,
|
|
collectProjectWorkspaceCommandPaths(req.body),
|
|
);
|
|
await assertNoManagedSandboxWorkspacePath(req.body);
|
|
const workspace = await svc.createWorkspace(id, req.body);
|
|
if (!workspace) {
|
|
res.status(422).json({ error: "Invalid project workspace payload" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: existing.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "project.workspace_created",
|
|
entityType: "project",
|
|
entityId: id,
|
|
details: {
|
|
workspaceId: workspace.id,
|
|
name: workspace.name,
|
|
cwd: workspace.cwd,
|
|
isPrimary: workspace.isPrimary,
|
|
},
|
|
});
|
|
|
|
res.status(201).json(workspace);
|
|
});
|
|
|
|
router.patch(
|
|
"/projects/:id/workspaces/:workspaceId",
|
|
validate(updateProjectWorkspaceSchema),
|
|
async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const workspaceId = req.params.workspaceId as string;
|
|
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!existing) return;
|
|
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
|
assertNoAgentHostWorkspaceCommandMutation(
|
|
req,
|
|
collectProjectWorkspaceCommandPaths(req.body),
|
|
);
|
|
await assertNoManagedSandboxWorkspacePath(req.body);
|
|
const workspaceExists = (await svc.listWorkspaces(id)).some((workspace) => workspace.id === workspaceId);
|
|
if (!workspaceExists) {
|
|
res.status(404).json({ error: "Project workspace not found" });
|
|
return;
|
|
}
|
|
const workspace = await svc.updateWorkspace(id, workspaceId, req.body);
|
|
if (!workspace) {
|
|
res.status(422).json({ error: "Invalid project workspace payload" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: existing.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "project.workspace_updated",
|
|
entityType: "project",
|
|
entityId: id,
|
|
details: {
|
|
workspaceId: workspace.id,
|
|
changedKeys: Object.keys(req.body).sort(),
|
|
},
|
|
});
|
|
|
|
res.json(workspace);
|
|
},
|
|
);
|
|
|
|
async function handleProjectWorkspaceRuntimeCommand(req: Request, res: Response) {
|
|
const id = req.params.id as string;
|
|
const workspaceId = req.params.workspaceId as string;
|
|
const action = String(req.params.action ?? "").trim().toLowerCase();
|
|
if (action !== "start" && action !== "stop" && action !== "restart" && action !== "run") {
|
|
res.status(404).json({ error: "Workspace command action not found" });
|
|
return;
|
|
}
|
|
|
|
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!project) return;
|
|
if (!(await assertProjectReadAllowed(req, res, project))) return;
|
|
|
|
const workspace = project.workspaces.find((entry) => entry.id === workspaceId) ?? null;
|
|
if (!workspace) {
|
|
res.status(404).json({ error: "Project workspace not found" });
|
|
return;
|
|
}
|
|
if (!(await assertRuntimeManageAllowed(req, res, project.companyId))) return;
|
|
|
|
const isSharedWorkspace = Boolean(workspace.sharedWorkspaceKey);
|
|
if (
|
|
req.actor.type === "agent"
|
|
&& isSharedWorkspace
|
|
&& SHARED_WORKSPACE_STOP_AND_RESTART_ACTIONS.has(action)
|
|
) {
|
|
throw forbidden("Missing permission to manage workspace runtime services");
|
|
}
|
|
|
|
await assertCanManageProjectWorkspaceRuntimeServices(db, req, {
|
|
companyId: project.companyId,
|
|
projectWorkspaceId: workspace.id,
|
|
});
|
|
|
|
const workspaceCwd = workspace.cwd;
|
|
if (!workspaceCwd) {
|
|
res.status(422).json({ error: "Project workspace needs a local path before Paperclip can run workspace commands" });
|
|
return;
|
|
}
|
|
|
|
const runtimeConfig = workspace.runtimeConfig?.workspaceRuntime ?? null;
|
|
const target = req.body as { workspaceCommandId?: string | null; runtimeServiceId?: string | null; serviceIndex?: number | null };
|
|
const configuredServices = runtimeConfig ? listConfiguredRuntimeServiceEntries({ workspaceRuntime: runtimeConfig }) : [];
|
|
const workspaceCommand = runtimeConfig
|
|
? findWorkspaceCommandDefinition(runtimeConfig, target.workspaceCommandId ?? null)
|
|
: null;
|
|
if (target.workspaceCommandId && !workspaceCommand) {
|
|
res.status(404).json({ error: "Workspace command not found for this project workspace" });
|
|
return;
|
|
}
|
|
if (target.runtimeServiceId && !(workspace.runtimeServices ?? []).some((service) => service.id === target.runtimeServiceId)) {
|
|
res.status(404).json({ error: "Runtime service not found for this project workspace" });
|
|
return;
|
|
}
|
|
const matchedRuntimeService =
|
|
workspaceCommand?.kind === "service" && !target.runtimeServiceId
|
|
? matchWorkspaceRuntimeServiceToCommand(workspaceCommand, workspace.runtimeServices ?? [])
|
|
: null;
|
|
const selectedRuntimeServiceId = target.runtimeServiceId ?? matchedRuntimeService?.id ?? null;
|
|
const selectedServiceIndex =
|
|
workspaceCommand?.kind === "service"
|
|
? workspaceCommand.serviceIndex
|
|
: target.serviceIndex ?? null;
|
|
if (
|
|
selectedServiceIndex !== undefined
|
|
&& selectedServiceIndex !== null
|
|
&& (selectedServiceIndex < 0 || selectedServiceIndex >= configuredServices.length)
|
|
) {
|
|
res.status(422).json({ error: "Selected runtime service is not defined in this project workspace runtime config" });
|
|
return;
|
|
}
|
|
if (workspaceCommand?.kind === "job" && action !== "run") {
|
|
res.status(422).json({ error: `Workspace job "${workspaceCommand.name}" can only be run` });
|
|
return;
|
|
}
|
|
if (workspaceCommand?.kind === "service" && action === "run") {
|
|
res.status(422).json({ error: `Workspace service "${workspaceCommand.name}" should be started or restarted, not run` });
|
|
return;
|
|
}
|
|
if (action === "run" && !workspaceCommand) {
|
|
res.status(422).json({ error: "Select a workspace job to run" });
|
|
return;
|
|
}
|
|
if ((action === "start" || action === "restart") && !runtimeConfig) {
|
|
res.status(422).json({ error: "Project workspace has no workspace command configuration" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const recorder = workspaceOperations.createRecorder({ companyId: project.companyId });
|
|
let runtimeServiceCount = workspace.runtimeServices?.length ?? 0;
|
|
let stdout = "";
|
|
let stderr = "";
|
|
|
|
const operation = await recorder.recordOperation({
|
|
phase: action === "stop" ? "workspace_teardown" : "workspace_provision",
|
|
command: workspaceCommand?.command ?? `workspace command ${action}`,
|
|
cwd: workspace.cwd,
|
|
metadata: {
|
|
action,
|
|
projectId: project.id,
|
|
projectWorkspaceId: workspace.id,
|
|
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
|
|
workspaceCommandKind: workspaceCommand?.kind ?? null,
|
|
workspaceCommandName: workspaceCommand?.name ?? null,
|
|
runtimeServiceId: selectedRuntimeServiceId,
|
|
serviceIndex: selectedServiceIndex,
|
|
},
|
|
run: async () => {
|
|
if (action === "run") {
|
|
if (!workspaceCommand || workspaceCommand.kind !== "job") {
|
|
throw new Error("Workspace job selection is required");
|
|
}
|
|
return await runWorkspaceJobForControl({
|
|
actor: {
|
|
id: actor.agentId ?? null,
|
|
name: actor.actorType === "user" ? "Board" : "Agent",
|
|
companyId: project.companyId,
|
|
},
|
|
issue: null,
|
|
workspace: {
|
|
baseCwd: workspaceCwd,
|
|
source: "project_primary",
|
|
projectId: project.id,
|
|
workspaceId: workspace.id,
|
|
repoUrl: workspace.repoUrl,
|
|
repoRef: workspace.repoRef,
|
|
strategy: "project_primary",
|
|
cwd: workspaceCwd,
|
|
branchName: workspace.defaultRef ?? workspace.repoRef ?? null,
|
|
worktreePath: null,
|
|
warnings: [],
|
|
created: false,
|
|
branchCreatedByRuntime: false,
|
|
},
|
|
command: workspaceCommand.rawConfig,
|
|
adapterEnv: {},
|
|
recorder,
|
|
metadata: {
|
|
action,
|
|
projectId: project.id,
|
|
projectWorkspaceId: workspace.id,
|
|
workspaceCommandId: workspaceCommand.id,
|
|
},
|
|
}).then((nestedOperation) => ({
|
|
status: "succeeded" as const,
|
|
exitCode: 0,
|
|
metadata: {
|
|
nestedOperationId: nestedOperation?.id ?? null,
|
|
runtimeServiceCount,
|
|
},
|
|
}));
|
|
}
|
|
|
|
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
|
if (stream === "stdout") stdout = appendWithCap(stdout, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS);
|
|
else stderr = appendWithCap(stderr, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS);
|
|
};
|
|
|
|
if (action === "stop" || action === "restart") {
|
|
await stopRuntimeServicesForProjectWorkspace({
|
|
db,
|
|
projectWorkspaceId: workspace.id,
|
|
runtimeServiceId: selectedRuntimeServiceId,
|
|
});
|
|
}
|
|
|
|
if (action === "start" || action === "restart") {
|
|
const startedServices = await startRuntimeServicesForWorkspaceControl({
|
|
db,
|
|
actor: {
|
|
id: actor.agentId ?? null,
|
|
name: actor.actorType === "user" ? "Board" : "Agent",
|
|
companyId: project.companyId,
|
|
},
|
|
issue: null,
|
|
workspace: {
|
|
baseCwd: workspaceCwd,
|
|
source: "project_primary",
|
|
projectId: project.id,
|
|
workspaceId: workspace.id,
|
|
repoUrl: workspace.repoUrl,
|
|
repoRef: workspace.repoRef,
|
|
strategy: "project_primary",
|
|
cwd: workspaceCwd,
|
|
branchName: workspace.defaultRef ?? workspace.repoRef ?? null,
|
|
worktreePath: null,
|
|
warnings: [],
|
|
created: false,
|
|
branchCreatedByRuntime: false,
|
|
},
|
|
config: { workspaceRuntime: runtimeConfig },
|
|
adapterEnv: {},
|
|
onLog,
|
|
serviceIndex: selectedServiceIndex,
|
|
runtimeServiceId: selectedRuntimeServiceId,
|
|
});
|
|
runtimeServiceCount = startedServices.length;
|
|
} else {
|
|
runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (workspace.runtimeServices?.length ?? 1) - 1) : 0;
|
|
}
|
|
|
|
const currentDesiredState: WorkspaceRuntimeDesiredState =
|
|
workspace.runtimeConfig?.desiredState
|
|
?? ((workspace.runtimeServices ?? []).some((service) =>
|
|
service.status === "provisioning" || service.status === "starting" || service.status === "running"
|
|
)
|
|
? "running"
|
|
: "stopped");
|
|
const nextRuntimeState: {
|
|
desiredState: WorkspaceRuntimeDesiredState;
|
|
serviceStates: WorkspaceRuntimeServiceStateMap | null | undefined;
|
|
} = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null)
|
|
? {
|
|
desiredState: currentDesiredState,
|
|
serviceStates: workspace.runtimeConfig?.serviceStates ?? null,
|
|
}
|
|
: buildWorkspaceRuntimeDesiredStatePatch({
|
|
config: { workspaceRuntime: runtimeConfig },
|
|
currentDesiredState,
|
|
currentServiceStates: workspace.runtimeConfig?.serviceStates ?? null,
|
|
action,
|
|
serviceIndex: selectedServiceIndex,
|
|
});
|
|
await svc.updateWorkspace(project.id, workspace.id, {
|
|
runtimeConfig: {
|
|
desiredState: nextRuntimeState.desiredState,
|
|
serviceStates: nextRuntimeState.serviceStates,
|
|
},
|
|
});
|
|
|
|
return {
|
|
status: "succeeded",
|
|
stdout,
|
|
stderr,
|
|
system:
|
|
action === "stop"
|
|
? "Stopped project workspace runtime services.\nThis does not pause issue work or held wake scheduling."
|
|
: action === "restart"
|
|
? "Restarted project workspace runtime services.\nThis does not pause issue work or held wake scheduling."
|
|
: "Started project workspace runtime services.\n",
|
|
metadata: {
|
|
runtimeServiceCount,
|
|
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
|
|
runtimeServiceId: selectedRuntimeServiceId,
|
|
serviceIndex: selectedServiceIndex,
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
const updatedWorkspace = (await svc.listWorkspaces(project.id)).find((entry) => entry.id === workspace.id) ?? workspace;
|
|
|
|
await logActivity(db, {
|
|
companyId: project.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: `project.workspace_runtime_${action}`,
|
|
entityType: "project",
|
|
entityId: project.id,
|
|
details: {
|
|
projectWorkspaceId: workspace.id,
|
|
runtimeServiceCount,
|
|
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
|
|
workspaceCommandKind: workspaceCommand?.kind ?? null,
|
|
workspaceCommandName: workspaceCommand?.name ?? null,
|
|
runtimeServiceId: selectedRuntimeServiceId,
|
|
serviceIndex: selectedServiceIndex,
|
|
},
|
|
});
|
|
|
|
res.json({
|
|
workspace: updatedWorkspace,
|
|
operation,
|
|
});
|
|
}
|
|
|
|
router.post("/projects/:id/workspaces/:workspaceId/runtime-services/:action", validate(workspaceRuntimeControlTargetSchema), handleProjectWorkspaceRuntimeCommand);
|
|
router.post("/projects/:id/workspaces/:workspaceId/runtime-commands/:action", validate(workspaceRuntimeControlTargetSchema), handleProjectWorkspaceRuntimeCommand);
|
|
|
|
router.delete("/projects/:id/workspaces/:workspaceId", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const workspaceId = req.params.workspaceId as string;
|
|
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!existing) return;
|
|
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
|
const workspace = await svc.removeWorkspace(id, workspaceId);
|
|
if (!workspace) {
|
|
res.status(404).json({ error: "Project workspace not found" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: existing.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "project.workspace_deleted",
|
|
entityType: "project",
|
|
entityId: id,
|
|
details: {
|
|
workspaceId: workspace.id,
|
|
name: workspace.name,
|
|
},
|
|
});
|
|
|
|
res.json(workspace);
|
|
});
|
|
|
|
router.delete("/projects/:id", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!existing) return;
|
|
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
|
const project = await svc.remove(id);
|
|
if (!project) {
|
|
res.status(404).json({ error: "Project not found" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: project.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "project.deleted",
|
|
entityType: "project",
|
|
entityId: project.id,
|
|
});
|
|
|
|
res.json(project);
|
|
});
|
|
|
|
return router;
|
|
}
|