From 24d056623d55a93d1cee32ec6e26e4535da31f9d Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 9 Jul 2026 19:17:30 +0000 Subject: [PATCH] Constrain Clip trust state writes Co-Authored-By: Paperclip --- packages/shared/src/validators/clip.ts | 2 +- server/src/__tests__/clips-routes.test.ts | 55 +++++++++++++++++++++++ server/src/routes/clips.ts | 13 +++++- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/validators/clip.ts b/packages/shared/src/validators/clip.ts index 57eee63cec5..b81220fac16 100644 --- a/packages/shared/src/validators/clip.ts +++ b/packages/shared/src/validators/clip.ts @@ -42,7 +42,7 @@ export const createClipCreatorProfileSchema = z.object({ bio: z.string().trim().max(1_000).nullable().optional(), avatarUrl: z.string().url().nullable().optional(), websiteUrl: z.string().url().nullable().optional(), - verificationState: z.string().trim().min(1).max(48).optional(), + verificationState: z.literal("unverified").optional(), reputationSummary: recordSchema.optional(), }); diff --git a/server/src/__tests__/clips-routes.test.ts b/server/src/__tests__/clips-routes.test.ts index 366dc05b4a2..3cc26e4d1bb 100644 --- a/server/src/__tests__/clips-routes.test.ts +++ b/server/src/__tests__/clips-routes.test.ts @@ -179,6 +179,61 @@ describe("clip routes", () => { expect(mockCompanyPortabilityService.previewExport).not.toHaveBeenCalled(); }); + it("strips owner-supplied trust states when creating revisions", async () => { + const clipId = "33333333-3333-4333-8333-333333333333"; + const sourceCompanyId = "22222222-2222-4222-8222-222222222222"; + mockClipService.getClipById.mockResolvedValue({ + id: clipId, + slug: "support-triage", + sourceCompanyId, + }); + mockClipService.createRevision.mockResolvedValue({ + clip: { id: clipId, slug: "support-triage" }, + revision: { id: "44444444-4444-4444-8444-444444444444", revisionNumber: 2 }, + }); + + const res = await request(createApp()) + .post("/api/clips/" + clipId + "/revisions") + .send({ + manifestChecksum: "sha256:manifest-v2", + artifactChecksum: "sha256:artifact-v2", + manifestPayload: { schema: "paperclip.clip/v1" }, + securityReviewState: "security_reviewed", + verificationState: "passed", + }); + + expect(res.status).toBe(201); + expect(mockClipService.createRevision).toHaveBeenCalledWith( + clipId, + expect.objectContaining({ + securityReviewState: "unreviewed", + verificationState: "not_run", + }), + ); + }); + + it("rejects owner moderation and publish-status patches", async () => { + const clipId = "33333333-3333-4333-8333-333333333333"; + const sourceCompanyId = "22222222-2222-4222-8222-222222222222"; + mockClipService.getClipById.mockResolvedValue({ + id: clipId, + slug: "support-triage", + sourceCompanyId, + status: "pending_review", + }); + + const moderationRes = await request(createApp()) + .patch("/api/clips/" + clipId) + .send({ moderationState: "normal" }); + const publishRes = await request(createApp()) + .patch("/api/clips/" + clipId) + .send({ status: "published" }); + + expect(moderationRes.status).toBe(403); + expect(publishRes.status).toBe(403); + expect(mockClipService.updateClip).not.toHaveBeenCalled(); + }); + it("uses the clip source company when building import previews", async () => { const destinationCompanyId = "11111111-1111-4111-8111-111111111111"; const sourceCompanyId = "22222222-2222-4222-8222-222222222222"; diff --git a/server/src/routes/clips.ts b/server/src/routes/clips.ts index a0698609864..7f1ba2a3df4 100644 --- a/server/src/routes/clips.ts +++ b/server/src/routes/clips.ts @@ -605,7 +605,12 @@ export function clipRoutes(db: Db, storage?: StorageService) { } assertCompanyAccess(req, clip.sourceCompanyId); if (!consumeClipRateLimit(req, res, "update")) return; - const result = await svc.createRevision(clip.id, req.body); + const revisionInput = { + ...req.body, + securityReviewState: "unreviewed" as const, + verificationState: "not_run" as const, + }; + const result = await svc.createRevision(clip.id, revisionInput); const actor = getActorInfo(req); await logActivity(db, { companyId: clip.sourceCompanyId, @@ -632,6 +637,12 @@ export function clipRoutes(db: Db, storage?: StorageService) { return; } assertCompanyAccess(req, clip.sourceCompanyId); + if (req.body.moderationState !== undefined || req.body.moderationReason !== undefined) { + throw forbidden("Clip moderation fields require platform moderation access"); + } + if (req.body.status === "published" && clip.status !== "published") { + throw forbidden("Publishing clips requires platform moderation access"); + } const actorInfo = getActorInfo(req); const updated = await svc.updateClip(clip.id, req.body, { actorType: actorInfo.actorType,