From a87df0c351c1bea8576f36b664a79a5cda9d89d1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 1 Aug 2026 21:15:45 +0000 Subject: [PATCH] fix: redact private project from heartbeat context Co-Authored-By: Paperclip --- .../issue-access-grants-routes.test.ts | 67 +++++++++++++++++++ server/src/routes/issues.ts | 23 ++++--- 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/server/src/__tests__/issue-access-grants-routes.test.ts b/server/src/__tests__/issue-access-grants-routes.test.ts index aab10d0da73..a2846ed9708 100644 --- a/server/src/__tests__/issue-access-grants-routes.test.ts +++ b/server/src/__tests__/issue-access-grants-routes.test.ts @@ -14,6 +14,8 @@ import { issueReferenceMentions, issueRelations, issues, + projectAccessMembers, + projects, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -44,6 +46,7 @@ function agentActor(companyId: string, agentId: string): Express.Request["actor" async function createApp(db: Db, actor: Express.Request["actor"]) { const { issueRoutes } = await import("../routes/issues.js"); + const { projectRoutes } = await import("../routes/projects.js"); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -51,6 +54,7 @@ async function createApp(db: Db, actor: Express.Request["actor"]) { next(); }); app.use("/api", issueRoutes(db, {} as any)); + app.use("/api", projectRoutes(db)); app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" }); }); @@ -75,6 +79,8 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes", await db.delete(issueAccessGrants); await db.delete(heartbeatRuns); await db.delete(issues); + await db.delete(projectAccessMembers); + await db.delete(projects); await db.delete(agents); await db.delete(companyMemberships); await db.delete(authUsers); @@ -144,6 +150,12 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes", }, }, ]).returning(); + const privateProject = await db.insert(projects).values({ + companyId, + name: "Secret launch project", + visibility: "private", + status: "planned", + }).returning().then((rows) => rows[0]!); const privateIssueId = randomUUID(); await db.insert(issues).values({ id: privateIssueId, @@ -152,6 +164,7 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes", title: "Confidential root", status: "backlog", priority: "medium", + projectId: privateProject.id, visibility: "private", privacyRootIssueId: privateIssueId, responsibleUserId: ownerId, @@ -164,6 +177,7 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes", title: "Confidential child", status: "backlog", priority: "medium", + projectId: privateProject.id, visibility: "private", privacyRootIssueId: privateIssueId, responsibleUserId: ownerId, @@ -184,6 +198,7 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes", sharedUserId, assignedAgent: assignedAgent!, sharedAgent: sharedAgent!, + privateProject, privateIssueId, privateChild, openIssue, @@ -296,6 +311,58 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes", expect(createdActivities.filter((row) => row.action === "issue_access_grant.created")).toHaveLength(2); }, 20_000); + it("keeps private project metadata hidden from user and agent issue grantees", async () => { + const fixture = await seedFixture(); + await db.insert(issueAccessGrants).values([ + { + issueId: fixture.privateIssueId, + subjectType: "user", + subjectId: fixture.sharedUserId, + source: "explicit", + grantedByUserId: fixture.ownerId, + }, + { + issueId: fixture.privateIssueId, + subjectType: "agent", + subjectId: fixture.sharedAgent.id, + source: "explicit", + grantedByUserId: fixture.ownerId, + }, + ]); + + const granteeApps = [ + await createApp(db, boardActor(fixture.companyId, fixture.sharedUserId)), + await createApp(db, agentActor(fixture.companyId, fixture.sharedAgent.id)), + ]; + + for (const app of granteeApps) { + const detail = await request(app).get(`/api/issues/${fixture.privateIssueId}`); + expect(detail.status, JSON.stringify(detail.body)).toBe(200); + expect(detail.body.projectId).toBe(fixture.privateProject.id); + expect(detail.body.project).toBeNull(); + + const heartbeatContext = await request(app) + .get(`/api/issues/${fixture.privateIssueId}/heartbeat-context`); + expect(heartbeatContext.status, JSON.stringify(heartbeatContext.body)).toBe(200); + expect(heartbeatContext.body.issue.projectId).toBe(fixture.privateProject.id); + expect(heartbeatContext.body.project).toBeNull(); + + await request(app).get(`/api/projects/${fixture.privateProject.id}`).expect(404); + + const projectList = await request(app) + .get(`/api/companies/${fixture.companyId}/projects`); + expect(projectList.status, JSON.stringify(projectList.body)).toBe(200); + expect(projectList.body.map((project: { id: string }) => project.id)) + .not.toContain(fixture.privateProject.id); + + const projectSearch = await request(app) + .get(`/api/companies/${fixture.companyId}/search?q=Secret%20launch&scope=projects`); + expect(projectSearch.status, JSON.stringify(projectSearch.body)).toBe(200); + expect(projectSearch.body.results.map((project: { id: string }) => project.id)) + .not.toContain(fixture.privateProject.id); + } + }, 20_000); + it("returns exact locked stubs only through visible blocker and mention edges", async () => { const fixture = await seedFixture(); const visibleIssue = await db.insert(issues).values({ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 02a25cd26d3..ac25f9ad365 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -3500,6 +3500,14 @@ export function issueRoutes( }).then((decision) => decision.allowed); } + async function visibleIssueProject( + req: Request, + project: T | null, + ): Promise { + if (!project) return null; + return (await canActorReadProject(req, project)) ? project : null; + } + async function assertCanManageIssuePrivacy( req: Request, res: Response, @@ -5592,6 +5600,7 @@ export function issueRoutes( currentExecutionWorkspacePromise, recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id), ]); + const visibleProject = await visibleIssueProject(req, project); const recoveryActionsByRelationIssue = await relationRecoveryActionMap( recoveryActionsSvc, issue.companyId, @@ -5658,12 +5667,12 @@ export function issueRoutes( status: ancestor.status, priority: ancestor.priority, })), - project: project + project: visibleProject ? { - id: project.id, - name: project.name, - status: project.status, - targetDate: project.targetDate, + id: visibleProject.id, + name: visibleProject.name, + status: visibleProject.status, + targetDate: visibleProject.targetDate, } : null, goal: goal @@ -5887,9 +5896,7 @@ export function issueRoutes( ? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds) : []; const [visibleProject, mentionedProjectVisibility] = await Promise.all([ - project - ? canActorReadProject(req, project).then((allowed) => allowed ? project : null) - : Promise.resolve(null), + visibleIssueProject(req, project), Promise.all(mentionedProjects.map((mentionedProject) => canActorReadProject(req, mentionedProject))), ]); const visibleMentionedProjects = mentionedProjects.filter((_, index) => mentionedProjectVisibility[index]);