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:
DottaandPaperclip committed 2026-08-01 22:54:02 +00:00
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 () => {
+59
View File
@@ -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
+2
View File
@@ -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);
+1
View File
@@ -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);
});
+37 -2
View File
@@ -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)) {
+72 -2
View File
@@ -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({});
+10 -5
View File
@@ -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 }),
});
});
+3 -2
View File
@@ -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`),
),
),
)
+71 -22
View File
@@ -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,
+2 -2
View File
@@ -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() {
+46 -18
View File
@@ -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 };