diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 97a23fb2880..c751c5b12b6 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -37,6 +37,14 @@ vi.mock("../services/index.js", () => ({ heartbeatService: () => mockHeartbeatService, })); +vi.mock("../services/authorization.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + issueReadSqlCondition: vi.fn(async () => ({}) as any), + }; +}); + async function createApp( actor: Record = { 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(), }); }); diff --git a/server/src/__tests__/activity-service.test.ts b/server/src/__tests__/activity-service.test.ts index 642a699c773..77624ee1651 100644 --- a/server/src/__tests__/activity-service.test.ts +++ b/server/src/__tests__/activity-service.test.ts @@ -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(); diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts index f98ac712989..881a4fbd790 100644 --- a/server/src/__tests__/attention-service.test.ts +++ b/server/src/__tests__/attention-service.test.ts @@ -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); + }); }); diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 11a2086a149..485b9124e11 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -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; let tempDb: Awaited> | 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); diff --git a/server/src/__tests__/company-search-extract-service.test.ts b/server/src/__tests__/company-search-extract-service.test.ts index d50c0e149af..0054e8d89e0 100644 --- a/server/src/__tests__/company-search-extract-service.test.ts +++ b/server/src/__tests__/company-search-extract-service.test.ts @@ -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"); + }); }); diff --git a/server/src/__tests__/issue-access-grants-routes.test.ts b/server/src/__tests__/issue-access-grants-routes.test.ts index a2846ed9708..8b0dcb89f14 100644 --- a/server/src/__tests__/issue-access-grants-routes.test.ts +++ b/server/src/__tests__/issue-access-grants-routes.test.ts @@ -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)); diff --git a/server/src/__tests__/issue-tree-control-routes.test.ts b/server/src/__tests__/issue-tree-control-routes.test.ts index 31df97bb7ed..0fe5650118d 100644 --- a/server/src/__tests__/issue-tree-control-routes.test.ts +++ b/server/src/__tests__/issue-tree-control-routes.test.ts @@ -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 () => { diff --git a/server/src/__tests__/status-cards.test.ts b/server/src/__tests__/status-cards.test.ts index 285060b754e..f0aef9e6da8 100644 --- a/server/src/__tests__/status-cards.test.ts +++ b/server/src/__tests__/status-cards.test.ts @@ -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 diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 1a8ff605666..c60425d4a0c 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -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); diff --git a/server/src/routes/attention.ts b/server/src/routes/attention.ts index a8ef20f0e09..3593bd4249a 100644 --- a/server/src/routes/attention.ts +++ b/server/src/routes/attention.ts @@ -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); }); diff --git a/server/src/routes/issue-tree-control.ts b/server/src/routes/issue-tree-control.ts index ccd4bc61da2..f2ac777888f 100644 --- a/server/src/routes/issue-tree-control.ts +++ b/server/src/routes/issue-tree-control.ts @@ -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)) { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index ac25f9ad365..2b4b824b7c0 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -3485,9 +3485,79 @@ export function issueRoutes( }); } - async function assertIssueReadAllowed(req: Request, res: Response, issue: Parameters[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[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[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({}); diff --git a/server/src/routes/status-cards.ts b/server/src/routes/status-cards.ts index a1553e4a8f5..da4f91680bb 100644 --- a/server/src/routes/status-cards.ts +++ b/server/src/routes/status-cards.ts @@ -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 }), }); }); diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index c7a0ef2dc2d..7ed9378deea 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -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; } 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`true`), ), ), ) diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts index 94d49791b27..6474431fcfc 100644 --- a/server/src/services/attention.ts +++ b/server/src/services/attention.ts @@ -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) { +async function issueSummaryMap( + db: Db, + companyId: string, + issueIds: Array, + readCondition: SQL = sql`true`, +) { const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; if (ids.length === 0) return new Map(); const rows = await db @@ -482,7 +489,7 @@ async function issueSummaryMap(db: Db, companyId: string, issueIds: Array [row.id, { id: row.id, companyId: row.companyId, @@ -508,7 +515,12 @@ async function issueSummaryMap(db: Db, companyId: string, issueIds: Array) { +async function issueImageMap( + db: Db, + companyId: string, + issueIds: Array, + readCondition: SQL = sql`true`, +) { const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; if (ids.length === 0) return new Map(); const rows = await db @@ -519,11 +531,13 @@ async function issueImageMap(db: Db, companyId: string, issueIds: Array) { +async function planDocumentMap( + db: Db, + companyId: string, + issueIds: Array, + readCondition: SQL = sql`true`, +) { const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; if (ids.length === 0) return new Map(); const rows = await db @@ -548,16 +567,23 @@ async function planDocumentMap(db: Db, companyId: string, issueIds: Array [row.issueId, { title: row.title, body: row.body }])); } -async function blockingIssueMap(db: Db, companyId: string, blockedIssueIds: Array) { +async function blockingIssueMap( + db: Db, + companyId: string, + blockedIssueIds: Array, + readCondition: SQL = sql`true`, +) { const ids = [...new Set(blockedIssueIds.filter((value): value is string => Boolean(value)))]; if (ids.length === 0) return new Map(); 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(); @@ -623,6 +650,9 @@ export function attentionService(db: Db, options: AttentionServiceOptions = {}) ); return { list: async (companyId: string, options: AttentionListOptions = {}): Promise => { + const readCondition = options.actor + ? await issueReadSqlCondition(db, options.actor) + : sql`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 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((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, diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index d296cb48d42..d2a2ea0cde2 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -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() { diff --git a/server/src/services/status-cards.ts b/server/src/services/status-cards.ts index 025fd0dd188..5be96d185c5 100644 --- a/server/src/services/status-cards.ts +++ b/server/src/services/status-cards.ts @@ -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 }; 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 { + async function loadIssueSummaries( + companyId: string, + issueIds: string[], + options: StatusCardReadOptions = {}, + ): Promise { 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`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(); 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 };