Files
PaperClipAI/server/src/routes/folders.ts
T
DottaandPaperclip b07b2994cc feat: stamp responsible users on activity logs (#9731)
## Thinking Path

> - Paperclip is the control plane people use to manage AI-agent
companies and their work
> - The activity log is the generic audit spine for mutations across the
control plane
> - Activity rows identify agents and runs, but they do not persist the
responsible human upstream
> - Relying only on run joins loses attribution after run pruning and
misses agent API-key actions outside a run
> - This pull request resolves responsible-user attribution when each
activity row is written and stores it directly
> - The benefit is durable, queryable agent audit feeds without
rewriting historical provenance

## Linked Issues or Issue Description

### Problem or motivation

Agent activity records do not persist the responsible user, so
attribution can disappear when runs are pruned and no-run API-key
mutations cannot be attributed correctly.

### Proposed solution

Resolve attribution for each new activity row from the run, related
issue, active agent API key, or company default, in that order, and
persist the result directly.

### Alternatives considered

Read-time joins alone were rejected because pruned runs lose durable
attribution and out-of-run agent-key actions have no run to join.
Historical backfill was rejected because it would invent provenance.

### Roadmap alignment

This strengthens the durable audit-trail direction described in
`ROADMAP.md` without adding a new product surface.

## What Changed

- Added nullable `activity_log.responsible_user_id` plus
company/agent/time and company/responsible-user/time indexes.
- Added an idempotent forward-only migration with no historical
backfill.
- Added centralized write-time resolution: heartbeat run → issue
attribution → active agent API key → company default.
- Propagated authenticated API-key IDs through existing request-backed
`logActivity()` calls.
- Added unit coverage for every fallback and an embedded-Postgres
assertion for the no-run API-key stamping path.

## Verification

- `pnpm --filter @paperclipai/db typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/activity-log-responsible-user.test.ts
src/__tests__/authz-company-access.test.ts`
- Initial focused verification: 25 tests passed; migration safety
passed.
- Follow-up regression verification: 54 focused
attribution/company-skill/environment/issue-tree/tool-gateway tests
passed; server typecheck passed.
- GitHub: full build, typecheck, server shards, serialized suites, e2e,
security, and policy checks passed.

## Risks

- Adding two indexes to an existing large table can hold a write lock
while the transactional migration runs. The migration safety
suppressions document why `CONCURRENTLY` is unavailable under the
current Drizzle migration runner.
- Historical rows remain nullable by design; this avoids inventing
provenance and keeps the migration forward-only.
- API-key attribution requires request-backed activity call sites to
pass the authenticated key ID; this PR mechanically updates the existing
actor-based activity calls and covers the no-run path with integration
testing.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, `gpt-5.4`; context-window size was not exposed by the
runtime. Medium reasoning with repository editing, terminal execution,
and test execution capabilities was used.

## 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 either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [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: Paperclip <noreply@paperclip.ing>
2026-07-16 20:54:55 -05:00

176 lines
6.1 KiB
TypeScript

import { Router } from "express";
import type { Db } from "@paperclipai/db";
import {
createFolderSchema,
ensureMySkillFolderSchema,
folderKindSchema,
moveFolderItemSchema,
moveFolderSchema,
updateFolderSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { badRequest, forbidden } from "../errors.js";
import { folderService, logActivity } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
export function folderRoutes(db: Db) {
const router = Router();
const svc = folderService(db);
function parseKind(value: unknown) {
const result = folderKindSchema.safeParse(value);
if (!result.success) throw badRequest("Folder kind query parameter is required");
return result.data;
}
router.get("/companies/:companyId/folders", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.list(companyId, parseKind(req.query.kind)));
});
router.post("/companies/:companyId/folders", validate(createFolderSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const created = await svc.create(companyId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "folder.created",
entityType: "folder",
entityId: created.id,
details: { kind: created.kind, name: created.name, path: created.path, parentId: created.parentId, position: created.position },
});
res.status(201).json(created);
});
router.post(
"/companies/:companyId/folders/ensure-my",
validate(ensureMySkillFolderSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board" || !req.actor.userId) {
throw forbidden("A signed-in board user is required to create a personal skill folder");
}
const folder = await svc.ensureMyFolder(companyId, req.actor.userId, req.actor.userName ?? null, req.body.slug);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "folder.personal_ensured",
entityType: "folder",
entityId: folder.id,
details: { path: folder.path, systemKey: folder.systemKey },
});
res.json(folder);
},
);
router.patch("/companies/:companyId/folders/:folderId", validate(updateFolderSchema), async (req, res) => {
const companyId = req.params.companyId as string;
const folderId = req.params.folderId as string;
assertCompanyAccess(req, companyId);
const updated = await svc.update(companyId, folderId, req.body);
if (!updated) {
res.status(404).json({ error: "Folder not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "folder.updated",
entityType: "folder",
entityId: updated.id,
details: { kind: updated.kind, name: updated.name, path: updated.path, position: updated.position },
});
res.json(updated);
});
router.post("/companies/:companyId/folders/items/move", validate(moveFolderItemSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const moved = await svc.moveItem(companyId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "folder.item_moved",
entityType: req.body.kind === "routine" ? "routine" : "company_skill",
entityId: moved.itemId,
details: { kind: moved.kind, folderId: moved.folderId },
});
res.json(moved);
});
router.post("/companies/:companyId/folders/:folderId/move", validate(moveFolderSchema), async (req, res) => {
const companyId = req.params.companyId as string;
const folderId = req.params.folderId as string;
assertCompanyAccess(req, companyId);
const updated = await svc.moveFolder(companyId, folderId, req.body);
if (!updated) {
res.status(404).json({ error: "Folder not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "folder.moved",
entityType: "folder",
entityId: updated.id,
details: { kind: updated.kind, parentId: updated.parentId, path: updated.path, position: updated.position },
});
res.json(updated);
});
router.delete("/companies/:companyId/folders/:folderId", async (req, res) => {
const companyId = req.params.companyId as string;
const folderId = req.params.folderId as string;
assertCompanyAccess(req, companyId);
const deleted = await svc.deleteFolder(companyId, folderId);
if (!deleted) {
res.status(404).json({ error: "Folder not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "folder.deleted",
entityType: "folder",
entityId: deleted.id,
details: { kind: deleted.kind, name: deleted.name },
});
res.json({ deleted });
});
return router;
}