mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
feat: enforce private issue ancillary reads
Add audited owner/admin break-glass access, viewer-scoped ancillary feeds, status cards, activity, tree control, and the P5 leak-test coverage. Default issue privacy to enforce after the shadow-mode review. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7bf6cc9d85
commit
23ea12c188
17 files changed
+521
-55
No files matched your search
@@ -37,6 +37,14 @@ vi.mock("../services/index.js", () => ({
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/authorization.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../services/authorization.js")>();
|
||||
return {
|
||||
...actual,
|
||||
issueReadSqlCondition: vi.fn(async () => ({}) as any),
|
||||
};
|
||||
});
|
||||
|
||||
async function createApp(
|
||||
actor: Record<string, unknown> = {
|
||||
type: "board",
|
||||
@@ -119,6 +127,7 @@ describe.sequential("activity routes", () => {
|
||||
entityType: undefined,
|
||||
entityId: undefined,
|
||||
limit: 100,
|
||||
readCondition: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,6 +146,7 @@ describe.sequential("activity routes", () => {
|
||||
entityType: "issue",
|
||||
entityId: undefined,
|
||||
limit: 500,
|
||||
readCondition: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { activityService } from "../services/activity.ts";
|
||||
import { issueReadSqlCondition } from "../services/authorization.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -117,6 +118,54 @@ describeEmbeddedPostgres("activity service", () => {
|
||||
expect(result.map((event) => event.action)).toEqual(["test.newest", "test.middle"]);
|
||||
});
|
||||
|
||||
it("filters company activity rows whose entity is a private issue", async () => {
|
||||
const companyId = randomUUID();
|
||||
const privateIssueId = randomUUID();
|
||||
const openIssueId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Private activity",
|
||||
issuePrefix: `A${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: privateIssueId,
|
||||
companyId,
|
||||
title: "Confidential activity",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: "private-owner",
|
||||
},
|
||||
{
|
||||
id: openIssueId,
|
||||
companyId,
|
||||
title: "Open activity",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
]);
|
||||
await db.insert(activityLog).values([
|
||||
{ companyId, actorType: "system", actorId: "system", action: "private.changed", entityType: "issue", entityId: privateIssueId },
|
||||
{ companyId, actorType: "system", actorId: "system", action: "open.changed", entityType: "issue", entityId: openIssueId },
|
||||
]);
|
||||
const actor = {
|
||||
type: "board" as const,
|
||||
userId: "non-member",
|
||||
companyIds: [companyId],
|
||||
source: "session" as const,
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
|
||||
const rows = await activityService(db).list({
|
||||
companyId,
|
||||
readCondition: await issueReadSqlCondition(db, actor),
|
||||
});
|
||||
expect(rows.map((row) => row.action)).toContain("open.changed");
|
||||
expect(rows.map((row) => row.action)).not.toContain("private.changed");
|
||||
});
|
||||
|
||||
it("returns compact usage and result summaries for issue runs", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
||||
@@ -156,6 +156,9 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
createdAt?: Date;
|
||||
unblockDescriptor?: { owner: { userId: string } | "board"; action: string } | null;
|
||||
blockedTransitionAt?: Date | null;
|
||||
visibility?: "open" | "private";
|
||||
privacyRootIssueId?: string | null;
|
||||
responsibleUserId?: string | null;
|
||||
}) {
|
||||
const id = input.id ?? randomUUID();
|
||||
await db.insert(issues).values({
|
||||
@@ -176,6 +179,9 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
executionState: input.executionState ?? null,
|
||||
unblockDescriptor: input.unblockDescriptor ?? null,
|
||||
blockedTransitionAt: input.blockedTransitionAt ?? null,
|
||||
visibility: input.visibility ?? "open",
|
||||
privacyRootIssueId: input.privacyRootIssueId ?? null,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.updatedAt,
|
||||
});
|
||||
@@ -1114,4 +1120,33 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
await request(app(board)).get(`/api/companies/${companyId}/attention`).expect(200);
|
||||
await request(app(agent)).get(`/api/companies/${companyId}/attention`).expect(403);
|
||||
});
|
||||
|
||||
it("omits private issue review items for a non-member board user", async () => {
|
||||
const { companyId } = await seedCompany("ATP");
|
||||
const privateIssueId = randomUUID();
|
||||
await insertIssue({
|
||||
id: privateIssueId,
|
||||
companyId,
|
||||
identifier: "ATP-1",
|
||||
title: "Confidential review",
|
||||
status: "in_review",
|
||||
assigneeUserId: "private-owner",
|
||||
executionState: pendingUserExecutionState("private-owner"),
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: "private-owner",
|
||||
});
|
||||
|
||||
const actor = {
|
||||
type: "board" as const,
|
||||
source: "session" as const,
|
||||
userId: "non-member",
|
||||
companyIds: [companyId],
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
const feed = await attentionService(db).list(companyId, { userId: actor.userId, actor });
|
||||
|
||||
expect(feed.items.map((item) => item.subject.title)).not.toContain("Confidential review");
|
||||
expect(JSON.stringify(feed)).not.toContain(privateIssueId);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
authorizationService,
|
||||
canActorReadIssuePrivacy,
|
||||
ISSUE_PRIVACY_GRANT_CACHE_MAX_ENTRIES,
|
||||
issuePrivacyMode,
|
||||
type AuthorizationActor,
|
||||
} from "../services/authorization.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
@@ -193,6 +194,21 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
it("defaults issue privacy to enforce while retaining explicit rollout overrides", () => {
|
||||
const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
try {
|
||||
delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
expect(issuePrivacyMode()).toBe("enforce");
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "shadow";
|
||||
expect(issuePrivacyMode()).toBe("shadow");
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "off";
|
||||
expect(issuePrivacyMode()).toBe("off");
|
||||
} finally {
|
||||
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
||||
}
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-authorization-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
documents,
|
||||
issueComments,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { companySearchExtractService } from "../services/company-search-extract.js";
|
||||
import { issueReadSqlCondition, type AuthorizationActor } from "../services/authorization.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -73,6 +75,7 @@ describeEmbeddedPostgres("companySearchExtractService", () => {
|
||||
await db.delete(documents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issues);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
@@ -266,4 +269,44 @@ describeEmbeddedPostgres("companySearchExtractService", () => {
|
||||
|
||||
expect(result.results).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not extract content from a private issue for a non-member", async () => {
|
||||
const companyId = await createCompany();
|
||||
const viewerId = randomUUID();
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: viewerId,
|
||||
membershipRole: "operator",
|
||||
status: "active",
|
||||
});
|
||||
const visibleIssueId = await createIssue(companyId, {
|
||||
identifier: "EXT-PUBLIC",
|
||||
description: "privacy-leak-needle",
|
||||
});
|
||||
const privateIssueId = randomUUID();
|
||||
await createIssue(companyId, {
|
||||
id: privateIssueId,
|
||||
identifier: "EXT-PRIVATE",
|
||||
description: "privacy-leak-needle private payload",
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: randomUUID(),
|
||||
});
|
||||
const actor: AuthorizationActor = {
|
||||
type: "board",
|
||||
userId: viewerId,
|
||||
source: "session",
|
||||
};
|
||||
|
||||
const result = await svc.extract(
|
||||
companyId,
|
||||
companySearchExtractQuerySchema.parse({ contains: "privacy-leak-needle" }),
|
||||
{ issueReadCondition: await issueReadSqlCondition(db, actor) },
|
||||
);
|
||||
|
||||
expect(result.results.map((row) => row.issueId)).toEqual([visibleIssueId]);
|
||||
expect(JSON.stringify(result)).not.toContain(privateIssueId);
|
||||
expect(JSON.stringify(result)).not.toContain("private payload");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueAccessGrants,
|
||||
issueComments,
|
||||
issueReferenceMentions,
|
||||
issueRelations,
|
||||
issues,
|
||||
@@ -74,6 +75,7 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes",
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueReferenceMentions);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issueAccessGrants);
|
||||
@@ -96,6 +98,7 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes",
|
||||
async function seedFixture() {
|
||||
const companyId = randomUUID();
|
||||
const ownerId = `owner-${randomUUID()}`;
|
||||
const adminId = `admin-${randomUUID()}`;
|
||||
const memberId = `member-${randomUUID()}`;
|
||||
const sharedUserId = `shared-${randomUUID()}`;
|
||||
const now = new Date();
|
||||
@@ -107,6 +110,7 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes",
|
||||
});
|
||||
await db.insert(authUsers).values([
|
||||
{ id: ownerId, name: "Olivia Owner", email: `${ownerId}@example.test`, createdAt: now, updatedAt: now },
|
||||
{ id: adminId, name: "Ada Admin", email: `${adminId}@example.test`, createdAt: now, updatedAt: now },
|
||||
{ id: memberId, name: "Nora Nonreader", email: `${memberId}@example.test`, createdAt: now, updatedAt: now },
|
||||
{
|
||||
id: sharedUserId,
|
||||
@@ -117,12 +121,12 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes",
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
await db.insert(companyMemberships).values([ownerId, memberId, sharedUserId].map((principalId) => ({
|
||||
await db.insert(companyMemberships).values([ownerId, adminId, memberId, sharedUserId].map((principalId) => ({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
membershipRole: principalId === ownerId ? "owner" : principalId === adminId ? "admin" : "operator",
|
||||
})));
|
||||
const [assignedAgent, sharedAgent] = await db.insert(agents).values([
|
||||
{
|
||||
@@ -194,6 +198,7 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes",
|
||||
return {
|
||||
companyId,
|
||||
ownerId,
|
||||
adminId,
|
||||
memberId,
|
||||
sharedUserId,
|
||||
assignedAgent: assignedAgent!,
|
||||
@@ -205,6 +210,43 @@ describeEmbeddedPostgres.sequential("issue access grant and locked edge routes",
|
||||
};
|
||||
}
|
||||
|
||||
it("requires explicit owner/admin break-glass and records both audit evidence and an owner-visible notice", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const adminApp = await createApp(db, boardActor(fixture.companyId, fixture.adminId));
|
||||
const memberApp = await createApp(db, boardActor(fixture.companyId, fixture.memberId));
|
||||
|
||||
await request(adminApp).get(`/api/issues/${fixture.privateIssueId}`).expect(404);
|
||||
await request(memberApp).get(`/api/issues/${fixture.privateIssueId}?breakGlass=true`).expect(404);
|
||||
|
||||
const accessed = await request(adminApp)
|
||||
.get(`/api/issues/${fixture.privateIssueId}?breakGlass=true`);
|
||||
expect(accessed.status, JSON.stringify(accessed.body)).toBe(200);
|
||||
expect(accessed.body.id).toBe(fixture.privateIssueId);
|
||||
|
||||
const auditRows = await db.select().from(activityLog);
|
||||
expect(auditRows).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
action: "issue.break_glass_read",
|
||||
actorType: "user",
|
||||
actorId: fixture.adminId,
|
||||
entityType: "issue",
|
||||
entityId: fixture.privateIssueId,
|
||||
}),
|
||||
]));
|
||||
const notices = await db.select().from(issueComments);
|
||||
expect(notices).toEqual([
|
||||
expect.objectContaining({
|
||||
issueId: fixture.privateIssueId,
|
||||
authorType: "system",
|
||||
presentation: expect.objectContaining({
|
||||
kind: "system_notice",
|
||||
title: "Break-glass access used",
|
||||
tone: "warning",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
}, 20_000);
|
||||
|
||||
it("auto-grants private-root access on assignment and keeps it sticky until revoke", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const ownerApp = await createApp(db, boardActor(fixture.companyId, fixture.ownerId));
|
||||
|
||||
@@ -21,8 +21,10 @@ const mockHeartbeatService = vi.hoisted(() => ({
|
||||
cancelRun: vi.fn(),
|
||||
wakeup: vi.fn(),
|
||||
}));
|
||||
const mockAccessService = vi.hoisted(() => ({ decide: vi.fn() }));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueService: () => mockIssueService,
|
||||
issueTreeControlService: () => mockTreeControlService,
|
||||
@@ -62,6 +64,25 @@ describe("issue tree control routes", () => {
|
||||
});
|
||||
mockHeartbeatService.cancelRun.mockResolvedValue(null);
|
||||
mockHeartbeatService.wakeup.mockResolvedValue(null);
|
||||
mockAccessService.decide.mockResolvedValue({ allowed: true });
|
||||
});
|
||||
|
||||
it("conceals private roots from non-members before exposing tree state", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-2"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
mockAccessService.decide.mockResolvedValue({ allowed: false, reason: "deny_issue_private" });
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/tree-control/preview")
|
||||
.send({ mode: "pause" });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(mockTreeControlService.preview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects cross-company preview requests with a uniform 404 before calling the preview service", async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
companyMemberships,
|
||||
costEvents,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
@@ -52,6 +53,7 @@ function unprivilegedBoardActor(companyId: string): Express.Request["actor"] {
|
||||
source: "session",
|
||||
sessionId: "session-1",
|
||||
companyIds: [companyId],
|
||||
memberships: [{ companyId, membershipRole: "operator", status: "active" }],
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
}
|
||||
@@ -93,6 +95,7 @@ describeEmbeddedPostgres("status card routes", () => {
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(agents);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
@@ -322,6 +325,62 @@ describeEmbeddedPostgres("status card routes", () => {
|
||||
expect(list.body[0]).not.toHaveProperty("watchedIssueCount");
|
||||
});
|
||||
|
||||
it("redacts hydrated summaries and watched issues when a card includes a private task", async () => {
|
||||
const company = await seedCompany();
|
||||
await enableStatusCards();
|
||||
const privateIssueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: privateIssueId,
|
||||
companyId: company.id,
|
||||
title: "Confidential acquisition",
|
||||
status: "blocked",
|
||||
priority: "high",
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: "private-owner",
|
||||
});
|
||||
const document = await db.insert(documents).values({
|
||||
companyId: company.id,
|
||||
title: "Private status summary",
|
||||
format: "markdown",
|
||||
latestBody: "Confidential acquisition is blocked.",
|
||||
latestRevisionNumber: 1,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const card = await db.insert(statusCards).values({
|
||||
companyId: company.id,
|
||||
createdByUserId: "private-owner",
|
||||
interestPrompt: "Acquisition status",
|
||||
documentId: document.id,
|
||||
queries: [{ scope: "issues", status: ["blocked"], updatedWithin: "7d", sort: "updated", limit: 20, offset: 0 }],
|
||||
queryVersion: 1,
|
||||
refreshPolicy: { mode: "manual" },
|
||||
state: "active",
|
||||
fingerprint: {},
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: "unprivileged-user",
|
||||
membershipRole: "operator",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const app = createApp(db, unprivilegedBoardActor(company.id));
|
||||
const detail = await request(app).get(`/api/status-cards/${card.id}`);
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body).toMatchObject({
|
||||
watchedIssueCount: 0,
|
||||
summaryBody: null,
|
||||
privacyRedacted: true,
|
||||
});
|
||||
expect(JSON.stringify(detail.body)).not.toContain("Confidential acquisition");
|
||||
|
||||
const dryRun = await request(app).get(`/api/status-cards/${card.id}/dry-run`);
|
||||
expect(dryRun.status).toBe(200);
|
||||
expect(JSON.stringify(dryRun.body)).not.toContain("Confidential acquisition");
|
||||
expect(JSON.stringify(dryRun.body)).not.toContain(privateIssueId);
|
||||
});
|
||||
|
||||
it("refreshes a card whose watched issues carry human comments", async () => {
|
||||
// Regression: the postgres-js driver returns `max(updated_at)` as a string,
|
||||
// so `latestHumanCommentAt.toISOString()` threw and refresh 500'd whenever a
|
||||
|
||||
@@ -11,6 +11,7 @@ import { badRequest, forbidden } from "../errors.js";
|
||||
import { agentActionAuditService } from "../services/agent-action-audit.js";
|
||||
import { logActivity } from "../services/activity-log.js";
|
||||
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
|
||||
import { issueReadSqlCondition } from "../services/authorization.js";
|
||||
|
||||
/** Max rows a single CSV export will stream (guards against runaway exports). */
|
||||
const AUDIT_CSV_EXPORT_MAX_ROWS = 10_000;
|
||||
@@ -188,6 +189,7 @@ export function activityRoutes(db: Db) {
|
||||
entityType: req.query.entityType as string | undefined,
|
||||
entityId: req.query.entityId as string | undefined,
|
||||
limit: normalizeActivityLimit(Number(req.query.limit)),
|
||||
readCondition: await issueReadSqlCondition(db, req.actor),
|
||||
};
|
||||
const result = await svc.list(filters);
|
||||
res.json(result);
|
||||
|
||||
@@ -20,6 +20,7 @@ export function attentionRoutes(db: Db) {
|
||||
const feed = await svc.list(companyId, {
|
||||
userId: req.actor.userId,
|
||||
includeDismissed,
|
||||
actor: req.actor,
|
||||
});
|
||||
res.json(feed);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router } from "express";
|
||||
import type { Request } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
createIssueTreeHoldSchema,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
releaseIssueTreeHoldSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { heartbeatService, issueService, issueTreeControlService, logActivity } from "../services/index.js";
|
||||
import { accessService, heartbeatService, issueService, issueTreeControlService, logActivity } from "../services/index.js";
|
||||
import { assertBoard, getAccessibleResource, getActorInfo } from "./authz.js";
|
||||
|
||||
const TREE_RUN_CANCELLATION_RESPONSE_WAIT_MS = 1_000;
|
||||
@@ -36,6 +36,35 @@ export function issueTreeControlRoutes(db: Db) {
|
||||
const issuesSvc = issueService(db);
|
||||
const treeControlSvc = issueTreeControlService(db);
|
||||
const heartbeat = heartbeatService(db);
|
||||
const access = accessService(db);
|
||||
|
||||
async function assertIssueReadAllowed(req: Request, res: Response, issue: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId?: string | null;
|
||||
parentId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
status?: string;
|
||||
}) {
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "issue:read",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId ?? null,
|
||||
parentIssueId: issue.parentId ?? null,
|
||||
assigneeAgentId: issue.assigneeAgentId ?? null,
|
||||
assigneeUserId: issue.assigneeUserId ?? null,
|
||||
status: issue.status ?? "backlog",
|
||||
},
|
||||
});
|
||||
if (decision.allowed) return true;
|
||||
res.status(404).json({ error: "Root issue not found" });
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveRootIssue(req: Request) {
|
||||
const rootIssueId = req.params.id as string;
|
||||
@@ -47,6 +76,7 @@ export function issueTreeControlRoutes(db: Db) {
|
||||
assertBoard(req);
|
||||
const root = await getAccessibleResource(req, res, resolveRootIssue(req), "Root issue not found");
|
||||
if (!root) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, root))) return;
|
||||
|
||||
const preview = await treeControlSvc.preview(root.companyId, root.id, req.body);
|
||||
const actor = getActorInfo(req);
|
||||
@@ -74,6 +104,7 @@ export function issueTreeControlRoutes(db: Db) {
|
||||
assertBoard(req);
|
||||
const root = await getAccessibleResource(req, res, resolveRootIssue(req), "Root issue not found");
|
||||
if (!root) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, root))) return;
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const actorInput = {
|
||||
@@ -303,6 +334,7 @@ export function issueTreeControlRoutes(db: Db) {
|
||||
const issueId = req.params.id as string;
|
||||
const issue = await getAccessibleResource(req, res, issuesSvc.getById(issueId), "Issue not found");
|
||||
if (!issue) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
||||
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id);
|
||||
res.json({ activePauseHold });
|
||||
});
|
||||
@@ -311,6 +343,7 @@ export function issueTreeControlRoutes(db: Db) {
|
||||
assertBoard(req);
|
||||
const root = await getAccessibleResource(req, res, resolveRootIssue(req), "Root issue not found");
|
||||
if (!root) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, root))) return;
|
||||
const statusParam = typeof req.query.status === "string" ? req.query.status : null;
|
||||
const modeParam = typeof req.query.mode === "string" ? req.query.mode : null;
|
||||
const includeMembers = req.query.includeMembers === "true";
|
||||
@@ -329,6 +362,7 @@ export function issueTreeControlRoutes(db: Db) {
|
||||
assertBoard(req);
|
||||
const root = await getAccessibleResource(req, res, resolveRootIssue(req), "Root issue not found");
|
||||
if (!root) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, root))) return;
|
||||
|
||||
const holdId = req.params.holdId as string;
|
||||
if (!isUuidLike(holdId)) {
|
||||
@@ -351,6 +385,7 @@ export function issueTreeControlRoutes(db: Db) {
|
||||
assertBoard(req);
|
||||
const root = await getAccessibleResource(req, res, resolveRootIssue(req), "Root issue not found");
|
||||
if (!root) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, root))) return;
|
||||
|
||||
const holdId = req.params.holdId as string;
|
||||
if (!isUuidLike(holdId)) {
|
||||
|
||||
@@ -3485,9 +3485,79 @@ export function issueRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
async function assertIssueReadAllowed(req: Request, res: Response, issue: Parameters<typeof decideIssueAccess>[1]) {
|
||||
async function canUseIssueBreakGlass(req: Request, issue: { companyId: string }) {
|
||||
if (req.actor.type !== "board" || !req.actor.userId) return false;
|
||||
return db
|
||||
.select({ role: companyMemberships.membershipRole })
|
||||
.from(companyMemberships)
|
||||
.where(and(
|
||||
eq(companyMemberships.companyId, issue.companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, req.actor.userId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
inArray(companyMemberships.membershipRole, ["owner", "admin"]),
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0);
|
||||
}
|
||||
|
||||
async function recordIssueBreakGlassRead(
|
||||
req: Request,
|
||||
issue: Parameters<typeof decideIssueAccess>[1] & { identifier?: string | null },
|
||||
) {
|
||||
const actor = getActorInfo(req);
|
||||
const occurredAt = new Date().toISOString();
|
||||
await svc.addComment(
|
||||
issue.id,
|
||||
`A company administrator used audited break-glass access to read this private task at ${occurredAt}.`,
|
||||
{},
|
||||
{
|
||||
authorType: "system",
|
||||
presentation: {
|
||||
kind: "system_notice",
|
||||
tone: "warning",
|
||||
title: "Break-glass access used",
|
||||
detailsDefaultOpen: false,
|
||||
density: "compact",
|
||||
},
|
||||
},
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: "user",
|
||||
actorId: actor.actorId,
|
||||
agentId: null,
|
||||
runId: actor.runId,
|
||||
agentApiKeyId: actor.agentApiKeyId,
|
||||
action: "issue.break_glass_read",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
issueId: issue.id,
|
||||
details: {
|
||||
issueId: issue.id,
|
||||
occurredAt,
|
||||
acknowledgement: "breakGlass=true",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function assertIssueReadAllowed(
|
||||
req: Request,
|
||||
res: Response,
|
||||
issue: Parameters<typeof decideIssueAccess>[1] & { identifier?: string | null },
|
||||
options: { allowBreakGlass?: boolean } = {},
|
||||
) {
|
||||
const decision = await decideIssueAccess(req, issue, "issue:read");
|
||||
if (decision.allowed) return true;
|
||||
if (
|
||||
options.allowBreakGlass === true
|
||||
&& req.query.breakGlass === "true"
|
||||
&& decision.reason === "deny_issue_private"
|
||||
&& await canUseIssueBreakGlass(req, issue)
|
||||
) {
|
||||
await recordIssueBreakGlassRead(req, issue);
|
||||
return true;
|
||||
}
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return false;
|
||||
}
|
||||
@@ -5840,7 +5910,7 @@ export function issueRoutes(
|
||||
const id = req.params.id as string;
|
||||
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
|
||||
if (!issue) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
||||
if (!(await assertIssueReadAllowed(req, res, issue, { allowBreakGlass: true }))) return;
|
||||
const inboxArchiveFieldsPromise = req.actor.type === "board" && req.actor.userId
|
||||
? svc.getActiveInboxArchiveFields(issue, req.actor.userId)
|
||||
: Promise.resolve({});
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@paperclipai/shared";
|
||||
import { forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { authorizationDeniedDetails } from "../services/authorization.js";
|
||||
import { authorizationDeniedDetails, issueReadSqlCondition } from "../services/authorization.js";
|
||||
import { accessService, heartbeatService, instanceSettingsService, issueService, logActivity, statusCardService } from "../services/index.js";
|
||||
import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "../services/issue-assignment-wakeup.js";
|
||||
import { assertCompanyAccess, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js";
|
||||
@@ -144,7 +144,9 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
|
||||
assertCompanyAccess(req, companyId);
|
||||
await assertStatusCardsEnabled();
|
||||
const query = listStatusCardsQuerySchema.parse(req.query);
|
||||
res.json(await service.list(companyId, query.archived));
|
||||
res.json(await service.list(companyId, query.archived, {
|
||||
issueReadCondition: await issueReadSqlCondition(db, req.actor),
|
||||
}));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/status-cards", validate(createStatusCardSchema), async (req, res) => {
|
||||
@@ -171,7 +173,9 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
|
||||
await assertStatusCardsEnabled();
|
||||
const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found");
|
||||
if (!card) return;
|
||||
res.json(await service.hydrate(card));
|
||||
res.json(await service.hydrate(card, {
|
||||
issueReadCondition: await issueReadSqlCondition(db, req.actor),
|
||||
}));
|
||||
});
|
||||
|
||||
router.patch("/status-cards/:id", validate(patchStatusCardSchema), async (req, res) => {
|
||||
@@ -260,11 +264,12 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
|
||||
if (!decision.allowed) {
|
||||
throw forbidden("Status-card dry-run is outside this actor's low-trust authorization boundary", authorizationDeniedDetails(decision));
|
||||
}
|
||||
const issueReadCondition = await issueReadSqlCondition(db, req.actor);
|
||||
res.json({
|
||||
cardId: card.id,
|
||||
queryVersion: card.queryVersion,
|
||||
queries: await service.dryRun(card),
|
||||
mentionedIssues: await service.listMentionedIssues(card),
|
||||
queries: await service.dryRun(card, { issueReadCondition }),
|
||||
mentionedIssues: await service.listMentionedIssues(card, { issueReadCondition }),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
@@ -25,6 +25,7 @@ export interface ActivityFilters {
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
limit?: number;
|
||||
readCondition?: SQL<boolean>;
|
||||
}
|
||||
|
||||
const DEFAULT_ACTIVITY_LIMIT = 100;
|
||||
@@ -355,7 +356,7 @@ export function activityService(db: Db) {
|
||||
...conditions,
|
||||
or(
|
||||
sql`${activityLog.entityType} != 'issue'`,
|
||||
visibleIssueCondition(),
|
||||
and(visibleIssueCondition(), filters.readCondition ?? sql<boolean>`true`),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, notInArray, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, notInArray, sql, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
@@ -42,6 +42,7 @@ import { budgetService } from "./budgets.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { parseIssueExecutionState } from "./issue-execution-policy.js";
|
||||
import { isProspectiveBlockedTransition } from "./routable-blocked.js";
|
||||
import { issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
||||
|
||||
const ATTENTION_SOURCE_KINDS: AttentionSourceKind[] = [
|
||||
"approval",
|
||||
@@ -125,6 +126,7 @@ type BlockingIssueSummary = {
|
||||
type AttentionListOptions = {
|
||||
userId?: string | null;
|
||||
includeDismissed?: boolean;
|
||||
actor?: AuthorizationActor;
|
||||
};
|
||||
|
||||
type AttentionServiceOptions = {
|
||||
@@ -454,7 +456,12 @@ async function dismissalByKey(db: Db, companyId: string, userId: string | null |
|
||||
}]));
|
||||
}
|
||||
|
||||
async function issueSummaryMap(db: Db, companyId: string, issueIds: Array<string | null | undefined>) {
|
||||
async function issueSummaryMap(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
issueIds: Array<string | null | undefined>,
|
||||
readCondition: SQL<boolean> = sql<boolean>`true`,
|
||||
) {
|
||||
const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))];
|
||||
if (ids.length === 0) return new Map<string, IssueSummaryRow>();
|
||||
const rows = await db
|
||||
@@ -482,7 +489,7 @@ async function issueSummaryMap(db: Db, companyId: string, issueIds: Array<string
|
||||
eq(issues.projectWorkspaceId, projectWorkspaces.id),
|
||||
eq(projectWorkspaces.companyId, companyId),
|
||||
))
|
||||
.where(and(eq(issues.companyId, companyId), inArray(issues.id, ids), isNull(issues.hiddenAt)));
|
||||
.where(and(eq(issues.companyId, companyId), inArray(issues.id, ids), isNull(issues.hiddenAt), readCondition));
|
||||
return new Map(rows.map((row) => [row.id, {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
@@ -508,7 +515,12 @@ async function issueSummaryMap(db: Db, companyId: string, issueIds: Array<string
|
||||
}]));
|
||||
}
|
||||
|
||||
async function issueImageMap(db: Db, companyId: string, issueIds: Array<string | null | undefined>) {
|
||||
async function issueImageMap(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
issueIds: Array<string | null | undefined>,
|
||||
readCondition: SQL<boolean> = sql<boolean>`true`,
|
||||
) {
|
||||
const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))];
|
||||
if (ids.length === 0) return new Map<string, AttentionDetailImage[]>();
|
||||
const rows = await db
|
||||
@@ -519,11 +531,13 @@ async function issueImageMap(db: Db, companyId: string, issueIds: Array<string |
|
||||
})
|
||||
.from(issueAttachments)
|
||||
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
||||
.innerJoin(issues, and(eq(issueAttachments.issueId, issues.id), eq(issues.companyId, companyId)))
|
||||
.where(and(
|
||||
eq(issueAttachments.companyId, companyId),
|
||||
eq(assets.companyId, companyId),
|
||||
inArray(issueAttachments.issueId, ids),
|
||||
sql`${assets.contentType} like 'image/%'`,
|
||||
readCondition,
|
||||
))
|
||||
.orderBy(asc(issueAttachments.issueId), asc(issueAttachments.createdAt), asc(issueAttachments.id));
|
||||
|
||||
@@ -537,7 +551,12 @@ async function issueImageMap(db: Db, companyId: string, issueIds: Array<string |
|
||||
return map;
|
||||
}
|
||||
|
||||
async function planDocumentMap(db: Db, companyId: string, issueIds: Array<string | null | undefined>) {
|
||||
async function planDocumentMap(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
issueIds: Array<string | null | undefined>,
|
||||
readCondition: SQL<boolean> = sql<boolean>`true`,
|
||||
) {
|
||||
const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))];
|
||||
if (ids.length === 0) return new Map<string, PlanDocumentSummary>();
|
||||
const rows = await db
|
||||
@@ -548,16 +567,23 @@ async function planDocumentMap(db: Db, companyId: string, issueIds: Array<string
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.innerJoin(issues, and(eq(issueDocuments.issueId, issues.id), eq(issues.companyId, companyId)))
|
||||
.where(and(
|
||||
eq(issueDocuments.companyId, companyId),
|
||||
eq(documents.companyId, companyId),
|
||||
eq(issueDocuments.key, "plan"),
|
||||
inArray(issueDocuments.issueId, ids),
|
||||
readCondition,
|
||||
));
|
||||
return new Map(rows.map((row) => [row.issueId, { title: row.title, body: row.body }]));
|
||||
}
|
||||
|
||||
async function blockingIssueMap(db: Db, companyId: string, blockedIssueIds: Array<string | null | undefined>) {
|
||||
async function blockingIssueMap(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
blockedIssueIds: Array<string | null | undefined>,
|
||||
readCondition: SQL<boolean> = sql<boolean>`true`,
|
||||
) {
|
||||
const ids = [...new Set(blockedIssueIds.filter((value): value is string => Boolean(value)))];
|
||||
if (ids.length === 0) return new Map<string, BlockingIssueSummary>();
|
||||
const rows = await db
|
||||
@@ -575,6 +601,7 @@ async function blockingIssueMap(db: Db, companyId: string, blockedIssueIds: Arra
|
||||
eq(issueRelations.type, "blocks"),
|
||||
inArray(issueRelations.relatedIssueId, ids),
|
||||
isNull(issues.hiddenAt),
|
||||
readCondition,
|
||||
))
|
||||
.orderBy(asc(issueRelations.relatedIssueId), asc(issueRelations.createdAt), asc(issueRelations.id));
|
||||
const map = new Map<string, BlockingIssueSummary>();
|
||||
@@ -623,6 +650,9 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
|
||||
);
|
||||
return {
|
||||
list: async (companyId: string, options: AttentionListOptions = {}): Promise<AttentionFeed> => {
|
||||
const readCondition = options.actor
|
||||
? await issueReadSqlCondition(db, options.actor)
|
||||
: sql<boolean>`true`;
|
||||
const prefix = await companyPrefix(db, companyId);
|
||||
const dismissals = await dismissalByKey(db, companyId, options.userId);
|
||||
const includeDismissed = options.includeDismissed === true;
|
||||
@@ -665,8 +695,16 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
|
||||
for (const row of approvalIssueRows) {
|
||||
if (!approvalIssueMap.has(row.approvalId)) approvalIssueMap.set(row.approvalId, row.issueId);
|
||||
}
|
||||
const readableApprovalIssues = await issueSummaryMap(
|
||||
db,
|
||||
companyId,
|
||||
approvalIssueRows.map((row) => row.issueId),
|
||||
readCondition,
|
||||
);
|
||||
|
||||
for (const approval of pendingApprovals) {
|
||||
const linkedIssueId = approvalIssueMap.get(approval.id) ?? null;
|
||||
if (linkedIssueId && !readableApprovalIssues.has(linkedIssueId)) continue;
|
||||
const dedupKey = `approval:${approval.id}`;
|
||||
const title = approvalTitle(approval.type, approval.payload);
|
||||
add(createItem({
|
||||
@@ -724,12 +762,13 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
|
||||
inArray(issueThreadInteractions.status, [...PENDING_INTERACTION_STATUSES]),
|
||||
))
|
||||
.orderBy(desc(issueThreadInteractions.updatedAt), desc(issueThreadInteractions.id));
|
||||
const interactionIssueMap = await issueSummaryMap(db, companyId, interactionRows.map((row) => row.issueId));
|
||||
const interactionImageMap = await issueImageMap(db, companyId, interactionRows.map((row) => row.issueId));
|
||||
const interactionPlanDocumentMap = await planDocumentMap(db, companyId, interactionRows.map((row) => row.issueId));
|
||||
const interactionIssueMap = await issueSummaryMap(db, companyId, interactionRows.map((row) => row.issueId), readCondition);
|
||||
const interactionImageMap = await issueImageMap(db, companyId, interactionRows.map((row) => row.issueId), readCondition);
|
||||
const interactionPlanDocumentMap = await planDocumentMap(db, companyId, interactionRows.map((row) => row.issueId), readCondition);
|
||||
|
||||
for (const interaction of interactionRows) {
|
||||
const issue = interactionIssueMap.get(interaction.issueId) ?? null;
|
||||
if (!issue) continue;
|
||||
const payload = readRecord(interaction.payload);
|
||||
const detail = interactionDetail({
|
||||
kind: interaction.kind,
|
||||
@@ -787,7 +826,7 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
|
||||
}).from(decisions).where(and(eq(decisions.companyId, companyId), eq(decisions.status, "open")))
|
||||
.orderBy(desc(decisions.updatedAt), desc(decisions.id))
|
||||
.limit(openDecisionLimit);
|
||||
const decisionIssueMap = await issueSummaryMap(db, companyId, openDecisions.map((decision) => decision.originIssueId));
|
||||
const decisionIssueMap = await issueSummaryMap(db, companyId, openDecisions.map((decision) => decision.originIssueId), readCondition);
|
||||
// Bundle titles let the feed render a single "Agent proposed N decisions"
|
||||
// group header over sibling decisions (v1 still decides each independently).
|
||||
const bundleIds = [...new Set(openDecisions.map((decision) => decision.bundleId).filter((value): value is string => Boolean(value)))];
|
||||
@@ -799,6 +838,7 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
|
||||
}
|
||||
for (const decision of openDecisions) {
|
||||
const issue = decisionIssueMap.get(decision.originIssueId) ?? null;
|
||||
if (decision.originIssueId && !issue) continue;
|
||||
add(createItem({
|
||||
companyId,
|
||||
sourceKind: "decision",
|
||||
@@ -896,11 +936,13 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
|
||||
db,
|
||||
companyId,
|
||||
recoveryRows.flatMap((row) => [row.sourceIssueId, row.recoveryIssueId]),
|
||||
readCondition,
|
||||
);
|
||||
const recoveryImageMap = await issueImageMap(db, companyId, recoveryRows.map((row) => row.sourceIssueId));
|
||||
const recoveryImageMap = await issueImageMap(db, companyId, recoveryRows.map((row) => row.sourceIssueId), readCondition);
|
||||
|
||||
for (const recovery of recoveryRows) {
|
||||
const sourceIssue = recoveryIssueMap.get(recovery.sourceIssueId) ?? null;
|
||||
if (!sourceIssue) continue;
|
||||
const recoveryIssue = recovery.recoveryIssueId ? recoveryIssueMap.get(recovery.recoveryIssueId) ?? null : null;
|
||||
const dedupKey = `recovery:${recovery.kind}:${recovery.sourceIssueId}:${recovery.cause}:${recovery.fingerprint}`;
|
||||
add(createItem({
|
||||
@@ -967,11 +1009,12 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
|
||||
isNull(issues.hiddenAt),
|
||||
isNotNull(issues.assigneeUserId),
|
||||
notInArray(issues.status, [...PRODUCTIVITY_REVIEW_TERMINAL_STATUSES]),
|
||||
readCondition,
|
||||
))
|
||||
.orderBy(desc(issues.updatedAt), desc(issues.id));
|
||||
const productivitySourceMap = await issueSummaryMap(db, companyId, productivityRows.map((row) => row.originId));
|
||||
const productivityReviewMap = await issueSummaryMap(db, companyId, productivityRows.map((row) => row.id));
|
||||
const productivityImageMap = await issueImageMap(db, companyId, productivityRows.map((row) => row.id));
|
||||
const productivitySourceMap = await issueSummaryMap(db, companyId, productivityRows.map((row) => row.originId), readCondition);
|
||||
const productivityReviewMap = await issueSummaryMap(db, companyId, productivityRows.map((row) => row.id), readCondition);
|
||||
const productivityImageMap = await issueImageMap(db, companyId, productivityRows.map((row) => row.id), readCondition);
|
||||
|
||||
for (const review of productivityRows) {
|
||||
const reviewIssue = productivityReviewMap.get(review.id);
|
||||
@@ -1002,10 +1045,14 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
|
||||
}));
|
||||
}
|
||||
|
||||
const blockedIssues = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true });
|
||||
const blockedIssueSummaries = await issueSummaryMap(db, companyId, blockedIssues.map((issue) => issue.id));
|
||||
const blockedImageMap = await issueImageMap(db, companyId, blockedIssues.map((issue) => issue.id));
|
||||
const blockingIssues = await blockingIssueMap(db, companyId, blockedIssues.map((issue) => issue.id));
|
||||
const blockedIssues = await issueService(db).list(companyId, {
|
||||
status: "blocked",
|
||||
includeBlockedBy: true,
|
||||
readCondition,
|
||||
});
|
||||
const blockedIssueSummaries = await issueSummaryMap(db, companyId, blockedIssues.map((issue) => issue.id), readCondition);
|
||||
const blockedImageMap = await issueImageMap(db, companyId, blockedIssues.map((issue) => issue.id), readCondition);
|
||||
const blockingIssues = await blockingIssueMap(db, companyId, blockedIssues.map((issue) => issue.id), readCondition);
|
||||
for (const issue of blockedIssues as Array<IssueSubjectRow & {
|
||||
blockerAttention?: { state?: string; sampleStalledBlockerIdentifier?: string | null; sampleBlockerIdentifier?: string | null } | null;
|
||||
unblockDescriptor?: { owner: { userId: string } | { agentId: string } | "board"; action: string } | null;
|
||||
@@ -1094,7 +1141,7 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.status, "in_review"), isNull(issues.hiddenAt)))
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.status, "in_review"), isNull(issues.hiddenAt), readCondition))
|
||||
.orderBy(desc(issues.updatedAt), desc(issues.id));
|
||||
const reviewIssueIds = reviewRows.map((row) => row.id);
|
||||
const pendingReviewApprovalRows = reviewIssueIds.length === 0
|
||||
@@ -1110,8 +1157,8 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
|
||||
eq(approvals.status, "pending"),
|
||||
));
|
||||
const pendingApprovalByIssueId = new Map(pendingReviewApprovalRows.map((row) => [row.issueId, row.approvalId]));
|
||||
const reviewIssueMap = await issueSummaryMap(db, companyId, reviewIssueIds);
|
||||
const reviewImageMap = await issueImageMap(db, companyId, reviewIssueIds);
|
||||
const reviewIssueMap = await issueSummaryMap(db, companyId, reviewIssueIds, readCondition);
|
||||
const reviewImageMap = await issueImageMap(db, companyId, reviewIssueIds, readCondition);
|
||||
|
||||
for (const review of reviewRows) {
|
||||
const state = parseIssueExecutionState(review.executionState);
|
||||
@@ -1188,8 +1235,9 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
|
||||
db,
|
||||
companyId,
|
||||
failedIssueIds,
|
||||
readCondition,
|
||||
);
|
||||
const failedImageMap = await issueImageMap(db, companyId, failedIssueIds);
|
||||
const failedImageMap = await issueImageMap(db, companyId, failedIssueIds, readCondition);
|
||||
const failedAgentIds = [...new Set(failedRows.map((row) => row.agentId))];
|
||||
const oldestFailedRunCreatedAt = failedRows.reduce<Date | null>((oldest, row) => {
|
||||
if (!oldest || row.createdAt < oldest) return row.createdAt;
|
||||
@@ -1224,6 +1272,7 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {})
|
||||
if (hasNewerRun) continue;
|
||||
|
||||
const issue = issueId ? failedIssueMap.get(issueId) ?? null : null;
|
||||
if (issueId && !issue) continue;
|
||||
const dedupKey = `run:${run.id}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
|
||||
@@ -527,8 +527,8 @@ export function invalidateIssuePrivacyGrantCache(input: {
|
||||
|
||||
export function issuePrivacyMode(): "off" | "shadow" | "enforce" {
|
||||
const mode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE?.trim().toLowerCase();
|
||||
if (mode === "off" || mode === "enforce") return mode;
|
||||
return "shadow";
|
||||
if (mode === "off" || mode === "shadow") return mode;
|
||||
return "enforce";
|
||||
}
|
||||
|
||||
function issuePrivacyCacheTtlMs() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, desc, eq, gte, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, gte, inArray, isNotNull, isNull, lte, ne, or, sql, type SQL } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
costEvents,
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
type StatusCardActor = { agentId: string | null; userId: string | null };
|
||||
type StatusCardWriter = { agentId: string | null; runId: string | null };
|
||||
type StatusCardRow = typeof statusCards.$inferSelect;
|
||||
type StatusCardReadOptions = { issueReadCondition?: SQL<boolean> };
|
||||
|
||||
const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
|
||||
|
||||
@@ -154,10 +155,15 @@ export function statusCardService(
|
||||
const issuesSvc = deps.issuesSvc ?? issueService(db);
|
||||
const searchSvc = companySearchService(db);
|
||||
|
||||
async function readWatchedIssueCount(card: StatusCardRow) {
|
||||
if (card.queries.length === 0 && (card.mentionedIssueIds?.length ?? 0) === 0) return 0;
|
||||
async function readWatchedIssueVisibility(card: StatusCardRow, options: StatusCardReadOptions = {}) {
|
||||
if (card.queries.length === 0 && (card.mentionedIssueIds?.length ?? 0) === 0) {
|
||||
return { count: 0, privacyRedacted: false };
|
||||
}
|
||||
try {
|
||||
return (await executeQueries(card)).length;
|
||||
const visible = await executeQueries(card, options);
|
||||
if (!options.issueReadCondition) return { count: visible.length, privacyRedacted: false };
|
||||
const total = await executeQueries(card);
|
||||
return { count: visible.length, privacyRedacted: total.length !== visible.length };
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, cardId: card.id, companyId: card.companyId },
|
||||
@@ -167,7 +173,7 @@ export function statusCardService(
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrate(card: StatusCardRow) {
|
||||
async function hydrate(card: StatusCardRow, options: StatusCardReadOptions = {}) {
|
||||
const dayStart = new Date();
|
||||
dayStart.setUTCHours(0, 0, 0, 0);
|
||||
const [document, today, watchedIssues] = await Promise.all([
|
||||
@@ -184,25 +190,28 @@ export function statusCardService(
|
||||
.from(statusCardUpdates)
|
||||
.where(and(eq(statusCardUpdates.cardId, card.id), gte(statusCardUpdates.startedAt, dayStart)))
|
||||
.then((rows) => rows[0] ?? { tokens: 0, costCents: 0 }),
|
||||
readWatchedIssueCount(card),
|
||||
readWatchedIssueVisibility(card, options),
|
||||
]);
|
||||
|
||||
return {
|
||||
...card,
|
||||
summaryBody: document?.latestBody ?? null,
|
||||
...(watchedIssues === undefined ? {} : { watchedIssueCount: watchedIssues }),
|
||||
summaryBody: watchedIssues?.privacyRedacted ? null : document?.latestBody ?? null,
|
||||
...(watchedIssues === undefined ? {} : {
|
||||
watchedIssueCount: watchedIssues.count,
|
||||
privacyRedacted: watchedIssues.privacyRedacted,
|
||||
}),
|
||||
todayTokens: today.tokens,
|
||||
todayCostCents: today.costCents,
|
||||
};
|
||||
}
|
||||
|
||||
async function list(companyId: string, archived: boolean) {
|
||||
async function list(companyId: string, archived: boolean, options: StatusCardReadOptions = {}) {
|
||||
const cards = await db
|
||||
.select()
|
||||
.from(statusCards)
|
||||
.where(and(eq(statusCards.companyId, companyId), archived ? isNotNull(statusCards.archivedAt) : isNull(statusCards.archivedAt)))
|
||||
.orderBy(desc(statusCards.updatedAt));
|
||||
return Promise.all(cards.map(hydrate));
|
||||
return Promise.all(cards.map((card) => hydrate(card, options)));
|
||||
}
|
||||
|
||||
async function getById(id: string) {
|
||||
@@ -515,7 +524,11 @@ export function statusCardService(
|
||||
});
|
||||
}
|
||||
|
||||
async function loadIssueSummaries(companyId: string, issueIds: string[]): Promise<CompanySearchIssueSummary[]> {
|
||||
async function loadIssueSummaries(
|
||||
companyId: string,
|
||||
issueIds: string[],
|
||||
options: StatusCardReadOptions = {},
|
||||
): Promise<CompanySearchIssueSummary[]> {
|
||||
if (issueIds.length === 0) return [];
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -530,7 +543,11 @@ export function statusCardService(
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
|
||||
.where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
inArray(issues.id, issueIds),
|
||||
options.issueReadCondition ?? sql<boolean>`true`,
|
||||
));
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
status: row.status as CompanySearchIssueSummary["status"],
|
||||
@@ -559,15 +576,17 @@ export function statusCardService(
|
||||
return rows.map((row) => row.id).sort();
|
||||
}
|
||||
|
||||
async function listMentionedIssues(card: StatusCardRow) {
|
||||
return loadIssueSummaries(card.companyId, card.mentionedIssueIds ?? []);
|
||||
async function listMentionedIssues(card: StatusCardRow, options: StatusCardReadOptions = {}) {
|
||||
return loadIssueSummaries(card.companyId, card.mentionedIssueIds ?? [], options);
|
||||
}
|
||||
|
||||
async function executeQueries(card: StatusCardRow) {
|
||||
async function executeQueries(card: StatusCardRow, options: StatusCardReadOptions = {}) {
|
||||
const issueMap = new Map<string, CompanySearchIssueSummary>();
|
||||
for (const storedQuery of card.queries) {
|
||||
const query = companySearchQuerySchema.parse(storedQuery);
|
||||
const response = await searchSvc.search(card.companyId, query);
|
||||
const response = await searchSvc.search(card.companyId, query, {
|
||||
issueReadCondition: options.issueReadCondition,
|
||||
});
|
||||
for (const result of response.results) {
|
||||
if (result.type === "issue" && result.issue) issueMap.set(result.issue.id, result.issue);
|
||||
}
|
||||
@@ -577,6 +596,7 @@ export function statusCardService(
|
||||
const mentioned = await loadIssueSummaries(
|
||||
card.companyId,
|
||||
(card.mentionedIssueIds ?? []).filter((issueId) => !issueMap.has(issueId)),
|
||||
options,
|
||||
);
|
||||
for (const issue of mentioned) issueMap.set(issue.id, issue);
|
||||
const snapshot = [...issueMap.values()];
|
||||
@@ -909,8 +929,16 @@ export function statusCardService(
|
||||
});
|
||||
}
|
||||
|
||||
async function dryRun(card: StatusCardRow) {
|
||||
return Promise.all(card.queries.map(async (query) => ({ query, result: await searchSvc.search(card.companyId, query) })));
|
||||
async function dryRun(card: StatusCardRow, options: StatusCardReadOptions = {}) {
|
||||
return Promise.all(card.queries.map(async (storedQuery) => {
|
||||
const query = companySearchQuerySchema.parse(storedQuery);
|
||||
return {
|
||||
query,
|
||||
result: await searchSvc.search(card.companyId, query, {
|
||||
issueReadCondition: options.issueReadCondition,
|
||||
}),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
return { list, getById, hydrate, create, update, remove, listUpdates, listSummaryRevisions, listMentionedIssues, requestCompile, requestRefresh, tickDueStatusCards, writeQuery, writeSummary, dryRun };
|
||||
|
||||
Reference in new issue
Block a user