mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
fix: validate work product execution workspace references (#14063)
Reject invalid and cross-company execution workspace references with a useful 422 before changing the work product. Hold the validated reference through the transaction so concurrent deletion cannot turn validation into a 500. Verified 13 focused tests, full typecheck/build, and green PR CI. Greptile 5/5 with no unresolved comments. The known UI copy-toast flake passed on an unchanged-commit retry and in a focused local run. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
96bf004a79
commit
c3ddb288b1
3 files changed
+150
-1
No files matched your search
@@ -1,7 +1,8 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { heartbeatRunEvents, issueWorkProducts, workspaceRuntimeServices } from "@paperclipai/db";
|
||||
import { executionWorkspaces, heartbeatRunEvents, issueWorkProducts, workspaceRuntimeServices } from "@paperclipai/db";
|
||||
import type { IssueWorkProduct } from "@paperclipai/shared";
|
||||
import { unprocessable } from "../errors.js";
|
||||
import { insertRowsInChunks } from "./batch-insert.js";
|
||||
import {
|
||||
createPullRequestMergeDetailsResolver,
|
||||
@@ -16,6 +17,30 @@ import {
|
||||
import type { ImportIssueWorkProductRow } from "./import-write-types.js";
|
||||
|
||||
type IssueWorkProductRow = typeof issueWorkProducts.$inferSelect;
|
||||
type WorkProductTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
async function validateExecutionWorkspace(
|
||||
tx: WorkProductTransaction,
|
||||
companyId: string,
|
||||
executionWorkspaceId: string | null | undefined,
|
||||
): Promise<void> {
|
||||
if (!executionWorkspaceId) return;
|
||||
const [workspace] = await tx
|
||||
.select({ id: executionWorkspaces.id })
|
||||
.from(executionWorkspaces)
|
||||
.where(and(
|
||||
eq(executionWorkspaces.id, executionWorkspaceId),
|
||||
eq(executionWorkspaces.companyId, companyId),
|
||||
))
|
||||
// Keep a concurrent delete from invalidating the reference before the write.
|
||||
.for("key share");
|
||||
if (!workspace) {
|
||||
throw unprocessable(
|
||||
"executionWorkspaceId must identify an execution workspace in this company. " +
|
||||
"Project workspace IDs are not execution workspace IDs. Omit this field when no execution workspace is available.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkProductDiffSummary {
|
||||
additions: number | null;
|
||||
@@ -245,6 +270,7 @@ export function workProductService(
|
||||
|
||||
createForIssue: async (issueId: string, companyId: string, data: Omit<typeof issueWorkProducts.$inferInsert, "issueId" | "companyId">) => {
|
||||
const row = await db.transaction(async (tx) => {
|
||||
await validateExecutionWorkspace(tx, companyId, data.executionWorkspaceId);
|
||||
if (data.isPrimary) {
|
||||
await tx
|
||||
.update(issueWorkProducts)
|
||||
@@ -279,6 +305,8 @@ export function workProductService(
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
await validateExecutionWorkspace(tx, existing.companyId, patch.executionWorkspaceId);
|
||||
|
||||
if (patch.isPrimary === true) {
|
||||
await tx
|
||||
.update(issueWorkProducts)
|
||||
|
||||
Reference in new issue
Block a user