Add AES-256-GCM local encrypted secrets provider with auto-generated master key, stub providers for AWS/GCP/Vault, and a secrets service that normalizes adapter configs (converting sensitive inline values to secret refs in strict mode) and resolves secret refs back to plain values at runtime. Extract redaction utilities from agent routes into shared module. Redact sensitive values in activity logs, config revisions, and approval payloads. Block rollback of revisions containing redacted secrets. Filter hidden issues from list queries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
175 lines
5.6 KiB
TypeScript
175 lines
5.6 KiB
TypeScript
import { and, desc, eq, inArray } from "drizzle-orm";
|
|
import type { Db } from "@paperclip/db";
|
|
import { approvals, issueApprovals, issues } from "@paperclip/db";
|
|
import { notFound, unprocessable } from "../errors.js";
|
|
import { redactEventPayload } from "../redaction.js";
|
|
|
|
interface LinkActor {
|
|
agentId?: string | null;
|
|
userId?: string | null;
|
|
}
|
|
|
|
export function issueApprovalService(db: Db) {
|
|
async function getIssue(issueId: string) {
|
|
return db
|
|
.select()
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getApproval(approvalId: string) {
|
|
return db
|
|
.select()
|
|
.from(approvals)
|
|
.where(eq(approvals.id, approvalId))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function assertIssueAndApprovalSameCompany(issueId: string, approvalId: string) {
|
|
const issue = await getIssue(issueId);
|
|
if (!issue) throw notFound("Issue not found");
|
|
|
|
const approval = await getApproval(approvalId);
|
|
if (!approval) throw notFound("Approval not found");
|
|
|
|
if (issue.companyId !== approval.companyId) {
|
|
throw unprocessable("Issue and approval must belong to the same company");
|
|
}
|
|
|
|
return { issue, approval };
|
|
}
|
|
|
|
return {
|
|
listApprovalsForIssue: async (issueId: string) => {
|
|
const issue = await getIssue(issueId);
|
|
if (!issue) throw notFound("Issue not found");
|
|
|
|
const result = await db
|
|
.select({
|
|
id: approvals.id,
|
|
companyId: approvals.companyId,
|
|
type: approvals.type,
|
|
requestedByAgentId: approvals.requestedByAgentId,
|
|
requestedByUserId: approvals.requestedByUserId,
|
|
status: approvals.status,
|
|
payload: approvals.payload,
|
|
decisionNote: approvals.decisionNote,
|
|
decidedByUserId: approvals.decidedByUserId,
|
|
decidedAt: approvals.decidedAt,
|
|
createdAt: approvals.createdAt,
|
|
updatedAt: approvals.updatedAt,
|
|
})
|
|
.from(issueApprovals)
|
|
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
|
.where(eq(issueApprovals.issueId, issueId))
|
|
.orderBy(desc(issueApprovals.createdAt));
|
|
return result.map((approval) => ({
|
|
...approval,
|
|
payload: redactEventPayload(approval.payload) ?? {},
|
|
}));
|
|
},
|
|
|
|
listIssuesForApproval: async (approvalId: string) => {
|
|
const approval = await getApproval(approvalId);
|
|
if (!approval) throw notFound("Approval not found");
|
|
|
|
return db
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
projectId: issues.projectId,
|
|
goalId: issues.goalId,
|
|
parentId: issues.parentId,
|
|
title: issues.title,
|
|
description: issues.description,
|
|
status: issues.status,
|
|
priority: issues.priority,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
createdByAgentId: issues.createdByAgentId,
|
|
createdByUserId: issues.createdByUserId,
|
|
issueNumber: issues.issueNumber,
|
|
identifier: issues.identifier,
|
|
requestDepth: issues.requestDepth,
|
|
billingCode: issues.billingCode,
|
|
startedAt: issues.startedAt,
|
|
completedAt: issues.completedAt,
|
|
cancelledAt: issues.cancelledAt,
|
|
createdAt: issues.createdAt,
|
|
updatedAt: issues.updatedAt,
|
|
})
|
|
.from(issueApprovals)
|
|
.innerJoin(issues, eq(issueApprovals.issueId, issues.id))
|
|
.where(eq(issueApprovals.approvalId, approvalId))
|
|
.orderBy(desc(issueApprovals.createdAt));
|
|
},
|
|
|
|
link: async (issueId: string, approvalId: string, actor?: LinkActor) => {
|
|
const { issue } = await assertIssueAndApprovalSameCompany(issueId, approvalId);
|
|
|
|
await db
|
|
.insert(issueApprovals)
|
|
.values({
|
|
companyId: issue.companyId,
|
|
issueId,
|
|
approvalId,
|
|
linkedByAgentId: actor?.agentId ?? null,
|
|
linkedByUserId: actor?.userId ?? null,
|
|
})
|
|
.onConflictDoNothing();
|
|
|
|
return db
|
|
.select()
|
|
.from(issueApprovals)
|
|
.where(and(eq(issueApprovals.issueId, issueId), eq(issueApprovals.approvalId, approvalId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
},
|
|
|
|
unlink: async (issueId: string, approvalId: string) => {
|
|
await assertIssueAndApprovalSameCompany(issueId, approvalId);
|
|
await db
|
|
.delete(issueApprovals)
|
|
.where(and(eq(issueApprovals.issueId, issueId), eq(issueApprovals.approvalId, approvalId)));
|
|
},
|
|
|
|
linkManyForApproval: async (approvalId: string, issueIds: string[], actor?: LinkActor) => {
|
|
if (issueIds.length === 0) return;
|
|
|
|
const approval = await getApproval(approvalId);
|
|
if (!approval) throw notFound("Approval not found");
|
|
|
|
const uniqueIssueIds = Array.from(new Set(issueIds));
|
|
const rows = await db
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
})
|
|
.from(issues)
|
|
.where(inArray(issues.id, uniqueIssueIds));
|
|
|
|
if (rows.length !== uniqueIssueIds.length) {
|
|
throw notFound("One or more issues not found");
|
|
}
|
|
|
|
for (const row of rows) {
|
|
if (row.companyId !== approval.companyId) {
|
|
throw unprocessable("Issue and approval must belong to the same company");
|
|
}
|
|
}
|
|
|
|
await db
|
|
.insert(issueApprovals)
|
|
.values(
|
|
uniqueIssueIds.map((issueId) => ({
|
|
companyId: approval.companyId,
|
|
issueId,
|
|
approvalId,
|
|
linkedByAgentId: actor?.agentId ?? null,
|
|
linkedByUserId: actor?.userId ?? null,
|
|
})),
|
|
)
|
|
.onConflictDoNothing();
|
|
},
|
|
};
|
|
}
|