From 9669f52e607376931b4389c8f7764c5e766114e5 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:09:37 +0200 Subject: [PATCH 1/2] fix(blueprints): move visibility with the public checkbox on the edit page PUT /api/blueprints/[id] wrote the edit page's public checkbox to the deprecated isPublic flag and never touched visibility. Unpublishing a blueprint left visibility PUBLIC, so the federation endpoints, the user profile page and the blueprint page, which accept either field, kept showing it; publishing a private one left visibility PRIVATE. Checking the box now sets visibility PUBLIC and keeps teamId. Unchecking it on a PUBLIC blueprint returns it to TEAM when it has a team and to PRIVATE otherwise. A TEAM or PRIVATE blueprint saved unchecked stays where it is. --- src/app/api/blueprints/[id]/route.ts | 17 +++- .../api/blueprints/update-visibility.test.ts | 98 +++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 tests/api/blueprints/update-visibility.test.ts diff --git a/src/app/api/blueprints/[id]/route.ts b/src/app/api/blueprints/[id]/route.ts index ca64a435..f073dfe6 100644 --- a/src/app/api/blueprints/[id]/route.ts +++ b/src/app/api/blueprints/[id]/route.ts @@ -129,7 +129,14 @@ export async function PUT( // Check if user owns this blueprint const existingBlueprint = await prismaUsers.userTemplate.findUnique({ where: { id: realId }, - select: { userId: true, currentVersion: true, content: true, isPublic: true }, + select: { + userId: true, + currentVersion: true, + content: true, + isPublic: true, + visibility: true, + teamId: true, + }, }); if (!existingBlueprint) { @@ -218,6 +225,14 @@ export async function PUT( if (isPublic !== undefined) { updateData.isPublic = Boolean(isPublic); + // Keep visibility in step with the public flag. Publishing keeps teamId, + // so unpublishing a team blueprint returns it to its team, not to PRIVATE. + // Unchecking "public" on a TEAM or PRIVATE blueprint leaves it where it is. + if (updateData.isPublic) { + updateData.visibility = "PUBLIC"; + } else if (existingBlueprint.visibility === "PUBLIC") { + updateData.visibility = existingBlueprint.teamId ? "TEAM" : "PRIVATE"; + } } if (showcaseUrl !== undefined) { diff --git a/tests/api/blueprints/update-visibility.test.ts b/tests/api/blueprints/update-visibility.test.ts new file mode 100644 index 00000000..1bbe89d8 --- /dev/null +++ b/tests/api/blueprints/update-visibility.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockFindUnique = vi.fn(); +const mockUpdate = vi.fn(); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn().mockResolvedValue({ user: { id: "user_1" } }), +})); + +vi.mock("@/lib/auth", () => ({ authOptions: {} })); + +vi.mock("@/lib/db-users", () => ({ + prismaUsers: { + userTemplate: { findUnique: mockFindUnique, update: mockUpdate }, + userTemplateVersion: { create: vi.fn().mockResolvedValue({}) }, + }, +})); + +type Stored = { visibility: "PRIVATE" | "TEAM" | "PUBLIC"; isPublic: boolean; teamId: string | null }; + +function existing(row: Stored) { + mockFindUnique.mockResolvedValue({ + userId: "user_1", + currentVersion: 1, + content: "# AGENTS.md\n\nUse pnpm and keep functions small.", + ...row, + }); +} + +async function update(body: Record) { + const { PUT } = await import("@/app/api/blueprints/[id]/route"); + return PUT( + new Request("https://lynxprompt.com/api/blueprints/bp_tpl_1", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id: "bp_tpl_1" }) } + ); +} + +function written() { + expect(mockUpdate).toHaveBeenCalledTimes(1); + return mockUpdate.mock.calls[0][0].data; +} + +describe("PUT /api/blueprints/[id] visibility", () => { + beforeEach(() => { + mockFindUnique.mockReset(); + mockUpdate.mockReset(); + mockUpdate.mockImplementation(async ({ data }) => ({ id: "tpl_1", ...data })); + }); + + it("makes a private blueprint PUBLIC when the edit page publishes it", async () => { + existing({ visibility: "PRIVATE", isPublic: false, teamId: null }); + const response = await update({ isPublic: true, sensitiveDataAcknowledged: true }); + + expect(response.status).toBe(200); + expect(written().isPublic).toBe(true); + expect(written().visibility).toBe("PUBLIC"); + }); + + it("makes a public blueprint PRIVATE when the edit page unpublishes it", async () => { + existing({ visibility: "PUBLIC", isPublic: true, teamId: null }); + const response = await update({ isPublic: false }); + + expect(response.status).toBe(200); + expect(written().isPublic).toBe(false); + expect(written().visibility).toBe("PRIVATE"); + }); + + it("returns an unpublished team blueprint to its team", async () => { + existing({ visibility: "PUBLIC", isPublic: true, teamId: "team_1" }); + const response = await update({ isPublic: false }); + + expect(response.status).toBe(200); + expect(written().visibility).toBe("TEAM"); + expect(written().teamId).toBeUndefined(); + }); + + it("keeps a team blueprint TEAM when the edit page saves it unchecked", async () => { + existing({ visibility: "TEAM", isPublic: false, teamId: "team_1" }); + const response = await update({ name: "Renamed config", isPublic: false }); + + expect(response.status).toBe(200); + expect(written().isPublic).toBe(false); + expect(written().visibility).toBeUndefined(); + }); + + it("leaves visibility alone when isPublic is not sent", async () => { + existing({ visibility: "PUBLIC", isPublic: true, teamId: null }); + const response = await update({ name: "Renamed config" }); + + expect(response.status).toBe(200); + expect(written().isPublic).toBeUndefined(); + expect(written().visibility).toBeUndefined(); + }); +}); From b08865e417ea696ca23e1705864e4467295f1dfd Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:21:18 +0200 Subject: [PATCH 2/2] fix(blueprints): write visibility on every public-flag change so overlapping saves cannot split them The first version only changed visibility when the row it had read was PUBLIC. Two overlapping saves could both read PRIVATE; if the publish wrote first, the unpublish then wrote isPublic false and left visibility PUBLIC. visibility is now written with every isPublic change and derived from teamId, which this route never changes, instead of from the visibility it read. The last save always leaves a matching pair. --- src/app/api/blueprints/[id]/route.ts | 18 +++++++++--------- tests/api/blueprints/update-visibility.test.ts | 13 ++++++++++++- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/app/api/blueprints/[id]/route.ts b/src/app/api/blueprints/[id]/route.ts index f073dfe6..881a3288 100644 --- a/src/app/api/blueprints/[id]/route.ts +++ b/src/app/api/blueprints/[id]/route.ts @@ -134,7 +134,6 @@ export async function PUT( currentVersion: true, content: true, isPublic: true, - visibility: true, teamId: true, }, }); @@ -225,14 +224,15 @@ export async function PUT( if (isPublic !== undefined) { updateData.isPublic = Boolean(isPublic); - // Keep visibility in step with the public flag. Publishing keeps teamId, - // so unpublishing a team blueprint returns it to its team, not to PRIVATE. - // Unchecking "public" on a TEAM or PRIVATE blueprint leaves it where it is. - if (updateData.isPublic) { - updateData.visibility = "PUBLIC"; - } else if (existingBlueprint.visibility === "PUBLIC") { - updateData.visibility = existingBlueprint.teamId ? "TEAM" : "PRIVATE"; - } + // Write visibility together with the public flag so the two always + // match, even when two saves overlap. Publishing keeps teamId, and this + // route never changes it, so an unpublished blueprint with a team goes + // back to TEAM and one without goes to PRIVATE. + updateData.visibility = updateData.isPublic + ? "PUBLIC" + : existingBlueprint.teamId + ? "TEAM" + : "PRIVATE"; } if (showcaseUrl !== undefined) { diff --git a/tests/api/blueprints/update-visibility.test.ts b/tests/api/blueprints/update-visibility.test.ts index 1bbe89d8..170e851e 100644 --- a/tests/api/blueprints/update-visibility.test.ts +++ b/tests/api/blueprints/update-visibility.test.ts @@ -84,7 +84,18 @@ describe("PUT /api/blueprints/[id] visibility", () => { expect(response.status).toBe(200); expect(written().isPublic).toBe(false); - expect(written().visibility).toBeUndefined(); + expect(written().visibility).toBe("TEAM"); + }); + + // Two overlapping saves: this one read the row as PRIVATE before another + // request published it. Writing only isPublic would leave visibility PUBLIC. + it("writes visibility with every unpublish, whatever the row said when read", async () => { + existing({ visibility: "PRIVATE", isPublic: false, teamId: null }); + const response = await update({ isPublic: false }); + + expect(response.status).toBe(200); + expect(written().isPublic).toBe(false); + expect(written().visibility).toBe("PRIVATE"); }); it("leaves visibility alone when isPublic is not sent", async () => {