diff --git a/server/src/__tests__/clips-routes.test.ts b/server/src/__tests__/clips-routes.test.ts index 6f34ed13ce6..865a978481a 100644 --- a/server/src/__tests__/clips-routes.test.ts +++ b/server/src/__tests__/clips-routes.test.ts @@ -265,7 +265,7 @@ describe("clip routes", () => { ); }); - it("rejects owner moderation, publish-status, and public-visibility patches", async () => { + it("rejects owner moderation, publish-status, public-visibility, and approved-revision patches", async () => { const clipId = "33333333-3333-4333-8333-333333333333"; const sourceCompanyId = "22222222-2222-4222-8222-222222222222"; mockClipService.getClipById.mockResolvedValue({ @@ -284,10 +284,14 @@ describe("clip routes", () => { const visibilityRes = await request(createApp()) .patch("/api/clips/" + clipId) .send({ visibility: "public" }); + const approvedRevisionRes = await request(createApp()) + .patch("/api/clips/" + clipId) + .send({ latestApprovedRevisionId: "44444444-4444-4444-8444-444444444444" }); expect(moderationRes.status).toBe(403); expect(publishRes.status).toBe(403); expect(visibilityRes.status).toBe(403); + expect(approvedRevisionRes.status).toBe(403); expect(mockClipService.updateClip).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/clips-service.test.ts b/server/src/__tests__/clips-service.test.ts index a966dc31da8..ecc4ae740d8 100644 --- a/server/src/__tests__/clips-service.test.ts +++ b/server/src/__tests__/clips-service.test.ts @@ -197,7 +197,7 @@ describeEmbeddedPostgres("clipService", () => { expect(rows).toHaveLength(0); }); - it("stores revisions append-only and serves exact revision manifests", async () => { + it("stores revisions append-only while keeping public reads on the last approved revision", async () => { const companyId = await seedCompany(); const { clip, revision } = await publishFixture(companyId); @@ -211,9 +211,13 @@ describeEmbeddedPostgres("clipService", () => { expect(update.revision.revisionNumber).toBe(2); const firstRevision = await svc.getPublicRevision(clip.slug, 1); const secondRevision = await svc.getPublicDetail(clip.slug); + const unapprovedRevision = await svc.getPublicRevision(clip.slug, update.revision.revisionNumber); + expect(update.clip.currentRevisionId).toBe(update.revision.id); + expect(update.clip.latestApprovedRevisionId).toBe(revision.id); expect(firstRevision?.currentRevision?.manifestChecksum).toBe("sha256:manifest-v1"); - expect(secondRevision?.currentRevision?.manifestChecksum).toBe("sha256:manifest-v2"); + expect(secondRevision?.currentRevision?.manifestChecksum).toBe("sha256:manifest-v1"); + expect(unapprovedRevision).toBeNull(); await expect( db.update(clipRevisions).set({ changeSummary: "mutated" }).where(eq(clipRevisions.id, revision.id)), ).rejects.toThrow(/Failed query/); diff --git a/server/src/routes/clips.ts b/server/src/routes/clips.ts index 39ecd99e2c5..b6ca3d49719 100644 --- a/server/src/routes/clips.ts +++ b/server/src/routes/clips.ts @@ -649,6 +649,9 @@ export function clipRoutes(db: Db, storage?: StorageService) { if (req.body.moderationState !== undefined || req.body.moderationReason !== undefined) { throw forbidden("Clip moderation fields require platform moderation access"); } + if (req.body.latestApprovedRevisionId !== undefined) { + throw forbidden("Approved revision promotion requires platform moderation access"); + } if (req.body.status === "published" && clip.status !== "published") { throw forbidden("Publishing clips requires platform moderation access"); } diff --git a/server/src/services/clips.ts b/server/src/services/clips.ts index ce4700cb03d..89664eaae92 100644 --- a/server/src/services/clips.ts +++ b/server/src/services/clips.ts @@ -235,7 +235,7 @@ export function clipService(db: Db) { const row = await getPublicClipBySlug(slug); if (!row) return null; const revision = await getRevisionByNumber(row.clip.id, revisionNumber); - if (!revision || !isPubliclyReadableRevision(revision)) return null; + if (!revision || revision.id !== row.clip.latestApprovedRevisionId || !isPubliclyReadableRevision(revision)) return null; const dependencies = await db.select().from(clipDependencies).where(eq(clipDependencies.revisionId, revision.id)); return serializePublicClip({ clip: row.clip, @@ -404,7 +404,7 @@ export function clipService(db: Db) { .update(clips) .set({ currentRevisionId: revision.id, - latestApprovedRevisionId: clip.status === "published" && clip.moderationState !== "blocked" ? revision.id : clip.latestApprovedRevisionId, + latestApprovedRevisionId: clip.latestApprovedRevisionId, updatedAt: new Date(), }) .where(eq(clips.id, clip.id))