Files
PaperClipAI/server/src/routes/projects.ts
T
8818402e7a feat(inbox): route requests to people and record personal outcomes (#20959)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - The inbox collects requests that need a human decision.
> - Teams need to know who owns each request and who may act on it.
> - A shared close operation must differ from hiding a card for one
person.
> - This pull request adds personal routing and durable outcomes to the
existing attention service.
> - People can claim or transfer work and inspect what another person
did for them.
> - Domain permissions still decide who may resolve each request.

## Linked Issues or Issue Description

**Subsystem affected**

Server, shared contracts, and database. This is the routing foundation
for a stacked inbox UI change.

**Problem or motivation**

A company-wide attention feed does not give teams a clear personal
queue. Archive, withdrawal, transfer, and decisions by another person
need distinct scopes and records.

**Proposed solution**

Resolve a request audience from its addressee, task participants, and
configured owners. Provide personal, unassigned, company, all-assignee,
and archived views. Add claim, transfer, personal archive and snooze,
shared withdrawal, receipts, and personal history.

**Alternatives considered**

A single shared inbox leaves ownership and personal hiding ambiguous.
The service keeps one canonical request and projects its audience
instead of copying requests for each person.

**Roadmap alignment**

This extends the existing Multiple Human Users and Agent Reviews and
Approvals capabilities in ROADMAP.md.

**Additional context**

Refs #19363 and #19520 for the current inbox navigation and confirmation
replacement rules. Refs #20268 for related actionable-request work. That
PR filters actionability; this stack adds audience routing and personal
outcomes.

This is part 1 of a two-PR stack. The inbox UI PR #20961 targets this
branch.

## What Changed

- Add nullable company and project decision owners and a company budget
owner.
- Add audience views and personal badge counts to the canonical
attention projections.
- Add claim, transfer, withdrawal, personal archive, snooze, and restore
operations with permission checks and activity records. Lock addressed
transfers to the current addressee or routing administrators, and
validate the recipient against canonical resolution policies.
- Add receipts and scoped history for work handled by another person.
- Keep closed-task requests and replaced confirmations out of current
cards.
- Add an idempotent migration and a concurrent receipt-history index.
- Update typed fixtures so the existing UI remains compatible with the
new contracts.

## Verification

- Server layered typechecking passes, including all three test-tree
partitions.
- Seven routing and interaction-resolution suites pass all 249 tests
against a dedicated external PostgreSQL test database on `4caecc21d8`.
Six new regressions failed before the repair; they cover takeover
followed by approval, legitimate delegation, excluded creators/review
requesters, and unauthorized claims.
- Prior focused verification covers private approval history and
receipts, transfer visibility, audience escalation, withdrawal
governance, hidden-card counts, per-recipient confirmation replacement,
and SDK source contracts.
- A synthetic three-person company passed 37 routing and lifecycle
acceptance checks. The UI stack adds browser acceptance for the shared
controls.
- The complete [GitHub Actions CI
matrix](https://github.com/paperclipai/paperclip/actions/runs/43134997331)
passes on `4caecc21d891677323a7335f23b1a015cce24c13` (Linux/Node 24:
typecheck, build, general and serialized tests, Runner support, browser
shards, and aggregate gates). Greptile is 5/5 on that exact head with no
open findings. The fresh security scan passes after explicitly
rechecking the addressee-transfer repair.
- `pnpm verify` reports exit 2 because a CI-global script changed; full
CI supplies that broader coverage. Embedded PostgreSQL could not start
on this Mac because shared-memory slots were exhausted, so local
database checks use a separate external test database. Linux CI covers
the embedded setup.

## Risks

- Personal badge counts change to the viewer's assigned requests.
Accessible feed totals and task membership keep their existing scope.
- The migration adds nullable owner fields and a dismissal fingerprint.
Its index runs outside a transaction. The migration is classified and
rerunnable.
- Routing changes the addressee used by resolution authorization. The
current addressee or a routing administrator must authorize an addressed
transfer; the recipient must satisfy writer access, canonical resolver
exclusions, and task review policy under row locks.
- The shared validator export barrel crosses 1,000 lines because it
exports the new attention schemas. Schema implementation stays in the
dedicated attention validator module.

## Model Used

OpenAI GPT-6 through Codex. Used repository tools, code execution, and
browser checks. This session does not expose a more specific runtime
model ID or its context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [x] I have run focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
(OpenAPI contracts and docs/api/attention.md)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Scott Tong <scott@scottsmbpm5max.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-11 17:49:39 -08:00

1096 lines
50 KiB
TypeScript

import { projectEnvironmentOptions } from "../services/project-environments.js";
import { projectGitAuthorizationService } from "../services/project-git-authorization.js";
import { skillAccessRequestContext } from "../services/skill-access.js";
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 { museExternalAgents, withExternalExecutionAdmission } from "../modules/external-agents/index.js";
import { parseNativeExecutionInput } from "../vendor/paperclip-runner/index.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 { 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();
router.use(skillAccessRequestContext);
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";
/**
* 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.",
);
}
/**
* PAP-749 task 1d: the project's decision owner gets this project's ownerless
* Needs You cards, so only a board user may choose them, and they must be an
* active member of the company. `null` clears it; agents cannot change it.
*/
async function assertProjectDecisionOwner(
req: Request,
companyId: string,
decisionOwnerUserId: unknown,
current: string | null = null,
) {
if (decisionOwnerUserId === undefined || decisionOwnerUserId === current) return;
if (req.actor.type !== "board") throw forbidden("Only board users can set a project's decision owner");
if (typeof decisionOwnerUserId !== "string") return;
const membership = await access.getMembership(companyId, "user", decisionOwnerUserId);
if (membership?.status !== "active") {
throw unprocessable("Project decision owner must be an active member of this company");
}
}
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 assertProjectWorkspaceWriteAllowed(req: Request, res: Response, project: { id: string; companyId: string }) {
const assigneeAgentId = req.actor.type === "agent" ? req.actor.agentId ?? null : null;
const decision = await access.decide({ actor: req.actor, action: "project:write_workspace",
resource: { type: "issue", companyId: project.companyId, projectId: project.id, assigneeAgentId },
scope: { projectId: project.id, assigneeAgentId } });
if (decision.allowed) return true;
res.status(403).json({ error: decision.explanation });
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.get("/projects/:id/git-authorization", async (req, res) => {
assertBoard(req);
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
if (!req.actor.userId) throw forbidden("A signed-in user is required");
res.json(await projectGitAuthorizationService(db).read(project.companyId, project.id, req.actor.userId));
});
router.put("/projects/:id/git-authorization", validate(z.object({ grantId: z.string().uuid().nullable() }).strict()), async (req, res) => {
assertBoard(req);
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
if (!project || !(await assertProjectReadAllowed(req, res, project)) || !(await assertProjectWorkspaceWriteAllowed(req, res, project))) return;
if (!req.actor.userId) throw forbidden("A signed-in user is required");
res.json(await projectGitAuthorizationService(db).set(project.companyId, project.id, req.actor.userId, req.body.grantId));
});
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;
if (!(await assertProjectReadAllowed(req, res, project))) return;
if (!(await assertProjectWorkspaceWriteAllowed(req, res, 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.get("/companies/:companyId/project-environments", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await projectEnvironmentOptions(db, companyId, {
userId: req.actor.type === "board" ? req.actor.userId : req.actor.onBehalfOfUserId,
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
}));
});
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[];
environmentId?: string;
expectedStorageProviderPluginId?: string | null;
};
const { workspace, repositoryIds, repositoryUrls, idempotencyKey, environmentId, expectedStorageProviderPluginId, ...projectData } = req.body as CreateProjectPayload & { idempotencyKey?: string; repositoryUrls?: string[] };
const receiptOnly = req.get("X-Paperclip-Receipt-Only") === "true";
if (receiptOnly && !idempotencyKey) throw unprocessable("Project receipt recovery requires an idempotency key");
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 assertProjectDecisionOwner(req, companyId, projectData.decisionOwnerUserId);
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, environmentId, expectedStorageProviderPluginId })).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 museActor = runContext && req.actor.type === "agent" && req.actor.authorizingUserId ? req.actor : null;
if (museActor) {
const { companyId: actorCompanyId, agentId, runId, authorizingUserId } = museActor;
if (!actorCompanyId || !agentId || !runId) throw forbidden("Muse native tool authority is unavailable");
// The receipt lock can wait too. Keep current authority behind every
// admission wait and ahead of both receipt replay and project creation.
await withExternalExecutionAdmission(tx, actorCompanyId, agentId, runId, async () => {
const current = await projectToolContext(tx as unknown as Db, museActor, true, "Project", { includeExecutionInput: true });
try {
const execution = parseNativeExecutionInput(current.run.runnerProfileJson?.nativeExecutionInput);
const broker = museExternalAgents(db);
if (current.run.runtimeMode !== "native" || execution.schema !== "paperclip.native-execution-input.v8"
|| execution.binding.companyId !== actorCompanyId || execution.binding.agentId !== agentId
|| execution.binding.runId !== runId || execution.binding.issueId !== current.issue.id
|| await broker.authorizingUserIdForBinding(execution.provider.binding) !== authorizingUserId) {
throw new Error("Muse internal tool authority mismatch");
}
await broker.assertRunAuthority(execution, tx as unknown as Db);
} catch {
throw forbidden("Muse native tool authority is unavailable");
}
});
}
if (receiptOnly && runContext && !museActor) await projectToolContext(tx as unknown as Db, req.actor, true);
if (receiptKey) {
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 (receiptOnly) return null;
if (runContext && !museActor) await projectToolContext(tx as unknown as Db, req.actor, true);
const service = projectService(tx as unknown as Db);
const environment = {
environmentId,
expectedStorageProviderPluginId,
userId: req.actor.type === "board" ? req.actor.userId : req.actor.onBehalfOfUserId,
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
};
const project = repositories ? await service.createWithRepositories(companyId, projectData, repositories, environment) : await service.create(companyId, projectData, environment);
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) { res.status(204).end(); return; }
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 assertProjectDecisionOwner(req, existing.companyId, body.decisionOwnerUserId, existing.decisionOwnerUserId ?? null);
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;
if (!(await assertProjectWorkspaceWriteAllowed(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;
if (!(await assertProjectWorkspaceWriteAllowed(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;
if (!(await assertProjectWorkspaceWriteAllowed(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;
}