From 2ede951d33bc8310f19a6eba22d639c5cf88a2e0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:47:21 +0000 Subject: [PATCH 1/5] feat(conversations): allow one-way private to public publish Add a participant-only SQL mutation that flips the root destination visibility to public, and expose it in the conversation UI with an irreversible confirmation. Co-Authored-By: David Cramer --- .../conversations/ConversationHeader.tsx | 9 ++ .../ConversationHeaderActions.tsx | 30 ++++++ .../client/conversations/ConversationPage.tsx | 21 ++++ .../src/client/conversations/queries.ts | 47 ++++++++ .../junior/src/api/conversations/publish.ts | 84 +++++++++++++++ .../junior/src/api/conversations/routes.ts | 20 ++++ packages/junior/src/api/schema.ts | 2 + .../junior/src/api/schema/conversation.ts | 8 ++ .../api/conversations/publish.test.ts | 101 ++++++++++++++++++ 9 files changed, 322 insertions(+) create mode 100644 packages/junior/src/api/conversations/publish.ts create mode 100644 packages/junior/tests/integration/api/conversations/publish.test.ts diff --git a/packages/junior-dashboard/src/client/conversations/ConversationHeader.tsx b/packages/junior-dashboard/src/client/conversations/ConversationHeader.tsx index 38991aa59f..dad5617ab1 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationHeader.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationHeader.tsx @@ -4,6 +4,7 @@ import { SearchInput } from "../components/SearchInput"; import { ConversationHeaderActions, type ConversationArchiveAction, + type ConversationPublishAction, } from "./ConversationHeaderActions"; import { ConversationDetailsDrawer } from "./ConversationDetailsDrawer"; import type { TranscriptViewMode } from "./transcriptRenderModel"; @@ -20,6 +21,7 @@ export function ConversationHeader(props: { onSearchChange(value: string): void; onViewChange(value: TranscriptViewMode): void; privacy: ReactNode; + publish?: ConversationPublishAction; search: string; stats: ReactNode; title: string; @@ -70,6 +72,7 @@ export function ConversationHeader(props: { setSearchOpen(true); }} onViewChange={props.onViewChange} + publish={props.publish} searchOpen={searchOpenVisible} view={props.view} /> @@ -95,6 +98,12 @@ export function ConversationHeader(props: { ) : null} + {props.publish?.error ? ( +
+ Could not make this conversation public. +
+ ) : null} + {props.meta ? (
{props.meta} diff --git a/packages/junior-dashboard/src/client/conversations/ConversationHeaderActions.tsx b/packages/junior-dashboard/src/client/conversations/ConversationHeaderActions.tsx index 292a347840..fefbe062be 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationHeaderActions.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationHeaderActions.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { Archive, ArchiveRestore, + Globe2, Info, MessagesSquare, ScrollText, @@ -22,6 +23,14 @@ export type ConversationArchiveAction = { pending: boolean; }; +export type ConversationPublishAction = { + disabled: boolean; + error: boolean; + onClick(): void; + pending: boolean; + visible: boolean; +}; + /** Render the compact icon controls for one conversation header. */ export function ConversationHeaderActions(props: { archive: ConversationArchiveAction; @@ -30,6 +39,7 @@ export function ConversationHeaderActions(props: { onDetailsClick(): void; onSearchClick(): void; onViewChange(value: TranscriptViewMode): void; + publish?: ConversationPublishAction; searchOpen: boolean; view: TranscriptViewMode; }) { @@ -41,6 +51,9 @@ export function ConversationHeaderActions(props: { /> {props.copyAction} + {props.publish?.visible ? ( + + ) : null} ); } + +function PublishConversationButton(props: ConversationPublishAction) { + const label = props.pending ? "Making public" : "Make public"; + return ( + + + + ); +} diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index c2359b65be..60af75e0a9 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -10,6 +10,7 @@ import { useArchiveConversation, useCancelConversationPendingMessages, useConversationData, + usePublishConversation, type PendingArchiveConversationUpdate, } from "./queries"; import type { ConversationMailboxMessage } from "./conversationOutbox"; @@ -63,6 +64,7 @@ export function ConversationPage(props: { const conversations = buildConversations(summaries); const detail = useConversationData(conversationId); const archive = useArchiveConversation(conversationId); + const publish = usePublishConversation(conversationId); const feedConversation = conversations.find( (item) => item.id === conversationId, ); @@ -121,6 +123,25 @@ export function ConversationPage(props: { }), pending: archive.isPending, }} + publish={{ + disabled: publish.isPending, + error: Boolean(publish.error), + onClick: () => { + if ( + !window.confirm( + "Make this conversation public? Anyone with the link can read it. You cannot undo this.", + ) + ) { + return; + } + publish.mutate(); + }, + pending: publish.isPending, + visible: Boolean( + detail.data?.isParticipant && + conversation?.visibility === "private", + ), + }} identity={ hasConversationIdentity({ conversation, diff --git a/packages/junior-dashboard/src/client/conversations/queries.ts b/packages/junior-dashboard/src/client/conversations/queries.ts index ec87badc21..375417becd 100644 --- a/packages/junior-dashboard/src/client/conversations/queries.ts +++ b/packages/junior-dashboard/src/client/conversations/queries.ts @@ -21,6 +21,7 @@ import { conversationDetailReportSchema, conversationEventPageSchema, conversationPendingMessagesReportSchema, + publishConversationResponseSchema, } from "@sentry/junior/api/schema"; import { @@ -282,6 +283,52 @@ export function useCancelConversationPendingMessages(conversationId: string) { }); } +/** One-way private→public publish for a conversation the viewer participates in. */ +export function usePublishConversation(conversationId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["dashboard", "publish-conversation", conversationId], + mutationFn: () => + post( + publishConversationResponseSchema, + `/api/conversations/${encodeURIComponent(conversationId)}/publish`, + {}, + ), + onSuccess: () => { + const detailQueryKey = conversationDetailQueryKey(conversationId); + queryClient.setQueryData( + detailQueryKey, + (detail) => (detail ? { ...detail, visibility: "public" } : detail), + ); + const conversationQueries = { queryKey: ["dashboard", "conversations"] }; + const feeds = + queryClient.getQueriesData(conversationQueries); + for (const [queryKey, feed] of feeds) { + if (!feed) continue; + queryClient.setQueryData(queryKey, { + ...feed, + conversations: feed.conversations.map((conversation) => + conversation.conversationId === conversationId + ? { ...conversation, visibility: "public" } + : conversation, + ), + }); + } + }, + onSettled: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["dashboard", "conversations"], + }), + queryClient.invalidateQueries({ + exact: true, + queryKey: conversationDetailQueryKey(conversationId), + }), + ]); + }, + }); +} + /** Archive or restore one conversation with an immediate reversible cache update. */ export function useArchiveConversation( conversationId: string, diff --git a/packages/junior/src/api/conversations/publish.ts b/packages/junior/src/api/conversations/publish.ts new file mode 100644 index 0000000000..9bf9a47bbb --- /dev/null +++ b/packages/junior/src/api/conversations/publish.ts @@ -0,0 +1,84 @@ +import { and, eq, ne, sql } from "drizzle-orm"; +import type { User } from "@sentry/junior-plugin-api"; +import { getDb, getSqlExecutor } from "@/chat/db"; +import { resolveRootVisibility } from "@/chat/conversations/sql/privacy"; +import { juniorConversations, juniorDestinations } from "@/db/schema"; +import { throwApiError } from "../http"; +import type { PublishConversationResponse } from "../schema/conversation"; +import { readConversationAccessFromSql } from "./access"; + +/** + * Make one conversation public by flipping its root destination visibility. + * One-way only: non-public becomes public; already-public stays public. + */ +export async function publishConversationForViewer( + viewer: User, + conversationId: string, +): Promise { + const access = ( + await readConversationAccessFromSql(getDb(), [conversationId], viewer) + ).get(conversationId); + if (!access) { + throwApiError(404, "Conversation not found."); + } + if (!access.isParticipant) { + throwApiError(403, "Only conversation participants can make this public."); + } + + const executor = getSqlExecutor(); + const root = await resolveRootVisibility(executor, conversationId); + if (root.visibility === null) { + throwApiError(409, "Conversation has no destination to publish."); + } + + // Resolve the root destination id under the same privacy authority used by + // access and retention. Child conversations publish the parent root only. + const [destination] = await executor + .db() + .select({ destinationId: juniorConversations.destinationId }) + .from(juniorConversations) + .where( + and( + eq(juniorConversations.conversationId, root.rootConversationId), + eq( + juniorConversations.rootConversationId, + juniorConversations.conversationId, + ), + ), + ) + .limit(1); + + if (!destination?.destinationId) { + throwApiError(409, "Conversation has no destination to publish."); + } + + const updated = await executor + .db() + .update(juniorDestinations) + .set({ + updatedAt: sql`now()`, + visibility: "public", + }) + .where( + and( + eq(juniorDestinations.id, destination.destinationId), + ne(juniorDestinations.visibility, "public"), + ), + ) + .returning({ id: juniorDestinations.id }); + + // Already public is success: one-way publish is idempotent for participants. + if (updated.length === 0) { + const [existing] = await executor + .db() + .select({ visibility: juniorDestinations.visibility }) + .from(juniorDestinations) + .where(eq(juniorDestinations.id, destination.destinationId)) + .limit(1); + if (!existing) { + throwApiError(404, "Conversation not found."); + } + } + + return { visibility: "public" }; +} diff --git a/packages/junior/src/api/conversations/routes.ts b/packages/junior/src/api/conversations/routes.ts index a5ba27c92c..e851c58aaa 100644 --- a/packages/junior/src/api/conversations/routes.ts +++ b/packages/junior/src/api/conversations/routes.ts @@ -6,6 +6,7 @@ import { acceptedConversationMessageSchema, archiveConversationBodySchema, archiveConversationResponseSchema, + publishConversationResponseSchema, cancelConversationPendingMessagesBodySchema, cancelConversationPendingMessagesResponseSchema, conversationAttachmentParamsSchema, @@ -28,6 +29,7 @@ import { conversationAttachmentHeaders, requireConversationAttachment, } from "./attachments"; +import { publishConversationForViewer } from "./publish"; import { appendConversationMessageForViewer, createConversationForViewer, @@ -133,6 +135,24 @@ export function createConversationRoutes(options: { }, ); + app.post( + "/:conversationId/publish", + requireViewer, + validateRequest( + "param", + conversationParamsSchema, + "Invalid route parameters.", + ), + async (context) => { + const viewer = context.get("viewer"); + const { conversationId } = context.req.valid("param"); + return jsonResponse( + publishConversationResponseSchema, + await publishConversationForViewer(viewer, conversationId), + ); + }, + ); + app.get( "/:conversationId/events", validateRequest( diff --git a/packages/junior/src/api/schema.ts b/packages/junior/src/api/schema.ts index 02326405ca..76bce81707 100644 --- a/packages/junior/src/api/schema.ts +++ b/packages/junior/src/api/schema.ts @@ -4,6 +4,7 @@ export { acceptedConversationMessageSchema, archiveConversationBodySchema, archiveConversationResponseSchema, + publishConversationResponseSchema, cancelConversationPendingMessagesBodySchema, cancelConversationPendingMessagesResponseSchema, conversationAuxiliaryCostsSchema, @@ -30,6 +31,7 @@ export type { AcceptedConversationMessage, ArchiveConversationBody, ArchiveConversationResponse, + PublishConversationResponse, CancelConversationPendingMessagesBody, CancelConversationPendingMessagesResponse, ActorIdentity, diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index 24bbdb5293..98c3ce349e 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -65,6 +65,11 @@ export const archiveConversationResponseSchema = z .object({ archived: z.boolean() }) .strict(); +/** One-way private→public publish for a conversation root destination. */ +export const publishConversationResponseSchema = z + .object({ visibility: z.literal("public") }) + .strict(); + export const createConversationBodySchema = z .object({ idempotencyKey: z.string().trim().min(1).max(200), @@ -827,6 +832,9 @@ export type ArchiveConversationBody = z.infer< export type ArchiveConversationResponse = z.infer< typeof archiveConversationResponseSchema >; +export type PublishConversationResponse = z.infer< + typeof publishConversationResponseSchema +>; export type CreateConversationBody = z.infer< typeof createConversationBodySchema >; diff --git a/packages/junior/tests/integration/api/conversations/publish.test.ts b/packages/junior/tests/integration/api/conversations/publish.test.ts new file mode 100644 index 0000000000..259999ed59 --- /dev/null +++ b/packages/junior/tests/integration/api/conversations/publish.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Hono } from "hono"; +import { createJuniorApi, type JuniorApiVariables } from "@/api"; +import { + apiErrorSchema, + conversationDetailReportSchema, + publishConversationResponseSchema, +} from "@/api/schema"; +import { closeDb, getConversationStore } from "@/chat/db"; +import { testViewer } from "../../../fixtures/user"; + +function authenticatedApi(email: string) { + const app = new Hono<{ Variables: JuniorApiVariables }>(); + app.use("*", async (context, next) => { + context.set("viewer", testViewer(email)); + await next(); + }); + app.route("/", createJuniorApi()); + return app; +} + +describe("conversation publish API", () => { + afterEach(async () => { + await closeDb(); + }); + + it("lets a participant publish a private conversation", async () => { + const conversationId = "local:web:publish-private"; + await getConversationStore().recordActivity({ + actor: { + email: "owner@example.com", + fullName: "Owner Example", + }, + conversationId, + destination: { + conversationId, + platform: "local", + }, + nowMs: 1_000, + source: "web", + title: "Private web conversation", + visibility: "private", + }); + + const stranger = authenticatedApi("stranger@example.com"); + const denied = await stranger.request( + `http://localhost/api/conversations/${encodeURIComponent(conversationId)}/publish`, + { method: "POST" }, + ); + expect(denied.status).toBe(403); + expect(apiErrorSchema.parse(await denied.json())).toEqual({ + error: "Only conversation participants can make this public.", + }); + + const owner = authenticatedApi("owner@example.com"); + const published = await owner.request( + `http://localhost/api/conversations/${encodeURIComponent(conversationId)}/publish`, + { method: "POST" }, + ); + expect(published.status).toBe(200); + expect(publishConversationResponseSchema.parse(await published.json())).toEqual({ + visibility: "public", + }); + + const again = await owner.request( + `http://localhost/api/conversations/${encodeURIComponent(conversationId)}/publish`, + { method: "POST" }, + ); + expect(again.status).toBe(200); + expect(publishConversationResponseSchema.parse(await again.json())).toEqual({ + visibility: "public", + }); + + const detail = await stranger.request( + `http://localhost/api/conversations/${encodeURIComponent(conversationId)}`, + ); + expect(detail.status).toBe(200); + expect(conversationDetailReportSchema.parse(await detail.json())).toMatchObject({ + conversationId, + visibility: "public", + }); + }); + + it("requires authentication and a real conversation", async () => { + const app = createJuniorApi(); + const unauthenticated = await app.request( + "http://localhost/api/conversations/missing/publish", + { method: "POST" }, + ); + expect(unauthenticated.status).toBe(401); + + const missing = await authenticatedApi("owner@example.com").request( + "http://localhost/api/conversations/missing/publish", + { method: "POST" }, + ); + expect(missing.status).toBe(404); + expect(apiErrorSchema.parse(await missing.json())).toEqual({ + error: "Conversation not found.", + }); + }); +}); From ba43935089563e90753f01ea11b81059ed66fefe Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:07:31 +0000 Subject: [PATCH 2/5] fix(conversations): Prevent stale visibility after publish --- .../src/client/conversations/queries.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/junior-dashboard/src/client/conversations/queries.ts b/packages/junior-dashboard/src/client/conversations/queries.ts index 375417becd..527b2e746c 100644 --- a/packages/junior-dashboard/src/client/conversations/queries.ts +++ b/packages/junior-dashboard/src/client/conversations/queries.ts @@ -294,6 +294,17 @@ export function usePublishConversation(conversationId: string) { `/api/conversations/${encodeURIComponent(conversationId)}/publish`, {}, ), + onMutate: async () => { + await Promise.all([ + queryClient.cancelQueries({ + queryKey: ["dashboard", "conversations"], + }), + queryClient.cancelQueries({ + exact: true, + queryKey: conversationDetailQueryKey(conversationId), + }), + ]); + }, onSuccess: () => { const detailQueryKey = conversationDetailQueryKey(conversationId); queryClient.setQueryData( From c8955e1191fca6ab9e235e9e190693c9c417c0fd Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:27:28 +0000 Subject: [PATCH 3/5] fix(conversations): block publish on shared destinations Refuse one-way destination publish when other roots share the same destination so a single thread cannot expose unrelated private conversations. --- .../junior/src/api/conversations/publish.ts | 33 +++++++++- .../api/conversations/publish.test.ts | 64 +++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/packages/junior/src/api/conversations/publish.ts b/packages/junior/src/api/conversations/publish.ts index 9bf9a47bbb..4287881458 100644 --- a/packages/junior/src/api/conversations/publish.ts +++ b/packages/junior/src/api/conversations/publish.ts @@ -1,4 +1,4 @@ -import { and, eq, ne, sql } from "drizzle-orm"; +import { and, eq, isNull, ne, sql } from "drizzle-orm"; import type { User } from "@sentry/junior-plugin-api"; import { getDb, getSqlExecutor } from "@/chat/db"; import { resolveRootVisibility } from "@/chat/conversations/sql/privacy"; @@ -10,6 +10,9 @@ import { readConversationAccessFromSql } from "./access"; /** * Make one conversation public by flipping its root destination visibility. * One-way only: non-public becomes public; already-public stays public. + * + * Refuses destinations shared by other roots so one publish cannot expose + * unrelated private conversations on the same channel. */ export async function publishConversationForViewer( viewer: User, @@ -52,6 +55,32 @@ export async function publishConversationForViewer( throwApiError(409, "Conversation has no destination to publish."); } + // Already public is success without re-checking shared roots: the flip is + // one-way and the destination is already exposed. + if (root.visibility === "public") { + return { visibility: "public" }; + } + + const [shared] = await executor + .db() + .select({ + count: sql`count(*)::int`, + }) + .from(juniorConversations) + .where( + and( + eq(juniorConversations.destinationId, destination.destinationId), + isNull(juniorConversations.parentConversationId), + ne(juniorConversations.conversationId, root.rootConversationId), + ), + ); + if ((shared?.count ?? 0) > 0) { + throwApiError( + 409, + "This destination is shared by other conversations, so it cannot be made public from one conversation.", + ); + } + const updated = await executor .db() .update(juniorDestinations) @@ -67,7 +96,7 @@ export async function publishConversationForViewer( ) .returning({ id: juniorDestinations.id }); - // Already public is success: one-way publish is idempotent for participants. + // Concurrent publish can win the race; treat already-public as success. if (updated.length === 0) { const [existing] = await executor .db() diff --git a/packages/junior/tests/integration/api/conversations/publish.test.ts b/packages/junior/tests/integration/api/conversations/publish.test.ts index 259999ed59..50fee06fa4 100644 --- a/packages/junior/tests/integration/api/conversations/publish.test.ts +++ b/packages/junior/tests/integration/api/conversations/publish.test.ts @@ -98,4 +98,68 @@ describe("conversation publish API", () => { error: "Conversation not found.", }); }); + + it("refuses destinations shared by other private roots", async () => { + const store = getConversationStore(); + await store.recordActivity({ + actor: { + email: "owner@example.com", + fullName: "Owner Example", + platform: "slack", + slackUserId: "UOWNER", + teamId: "TSHARE", + }, + conversationId: "slack:CSHARE:1700000000.000100", + destination: { + channelId: "CSHARE", + platform: "slack", + teamId: "TSHARE", + }, + nowMs: 1_000, + source: "slack", + title: "First private thread", + visibility: "private", + }); + await store.recordActivity({ + actor: { + email: "owner@example.com", + fullName: "Owner Example", + platform: "slack", + slackUserId: "UOWNER", + teamId: "TSHARE", + }, + conversationId: "slack:CSHARE:1700000000.000200", + destination: { + channelId: "CSHARE", + platform: "slack", + teamId: "TSHARE", + }, + nowMs: 2_000, + source: "slack", + title: "Second private thread", + visibility: "private", + }); + + const owner = authenticatedApi("owner@example.com"); + const response = await owner.request( + "http://localhost/api/conversations/slack%3ACSHARE%3A1700000000.000100/publish", + { method: "POST" }, + ); + expect(response.status).toBe(409); + expect(apiErrorSchema.parse(await response.json())).toEqual({ + error: + "This destination is shared by other conversations, so it cannot be made public from one conversation.", + }); + + const detail = await owner.request( + "http://localhost/api/conversations/slack%3ACSHARE%3A1700000000.000100", + ); + expect(detail.status).toBe(200); + expect( + conversationDetailReportSchema.parse(await detail.json()), + ).toMatchObject({ + conversationId: "slack:CSHARE:1700000000.000100", + visibility: "private", + }); + }); }); From 6b56cf32f91712b559d71efb0df7694b012d2bf0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:45:57 +0000 Subject: [PATCH 4/5] fix(conversations): Serialize publish with root creation --- .../junior/src/api/conversations/publish.ts | 93 +++++++++---------- 1 file changed, 45 insertions(+), 48 deletions(-) diff --git a/packages/junior/src/api/conversations/publish.ts b/packages/junior/src/api/conversations/publish.ts index 4287881458..beab86ed58 100644 --- a/packages/junior/src/api/conversations/publish.ts +++ b/packages/junior/src/api/conversations/publish.ts @@ -54,60 +54,57 @@ export async function publishConversationForViewer( if (!destination?.destinationId) { throwApiError(409, "Conversation has no destination to publish."); } + const destinationId = destination.destinationId; - // Already public is success without re-checking shared roots: the flip is - // one-way and the destination is already exposed. - if (root.visibility === "public") { - return { visibility: "public" }; - } - - const [shared] = await executor - .db() - .select({ - count: sql`count(*)::int`, - }) - .from(juniorConversations) - .where( - and( - eq(juniorConversations.destinationId, destination.destinationId), - isNull(juniorConversations.parentConversationId), - ne(juniorConversations.conversationId, root.rootConversationId), - ), - ); - if ((shared?.count ?? 0) > 0) { - throwApiError( - 409, - "This destination is shared by other conversations, so it cannot be made public from one conversation.", - ); - } - - const updated = await executor - .db() - .update(juniorDestinations) - .set({ - updatedAt: sql`now()`, - visibility: "public", - }) - .where( - and( - eq(juniorDestinations.id, destination.destinationId), - ne(juniorDestinations.visibility, "public"), - ), - ) - .returning({ id: juniorDestinations.id }); - - // Concurrent publish can win the race; treat already-public as success. - if (updated.length === 0) { - const [existing] = await executor + await executor.transaction(async () => { + // Root creation upserts the destination before it inserts the conversation. + // Locking this row makes the shared-root check and visibility update atomic + // against a concurrent root that targets the same destination. + const [lockedDestination] = await executor .db() .select({ visibility: juniorDestinations.visibility }) .from(juniorDestinations) - .where(eq(juniorDestinations.id, destination.destinationId)) - .limit(1); - if (!existing) { + .where(eq(juniorDestinations.id, destinationId)) + .for("update"); + if (!lockedDestination) { throwApiError(404, "Conversation not found."); } - } + + // Already public is success without re-checking shared roots: the flip is + // one-way and the destination is already exposed. + if (lockedDestination.visibility === "public") { + return; + } + + const [shared] = await executor + .db() + .select({ + count: sql`count(*)::int`, + }) + .from(juniorConversations) + .where( + and( + eq(juniorConversations.destinationId, destinationId), + isNull(juniorConversations.parentConversationId), + ne(juniorConversations.conversationId, root.rootConversationId), + ), + ); + if ((shared?.count ?? 0) > 0) { + throwApiError( + 409, + "This destination is shared by other conversations, so it cannot be made public from one conversation.", + ); + } + + await executor + .db() + .update(juniorDestinations) + .set({ + updatedAt: sql`now()`, + visibility: "public", + }) + .where(eq(juniorDestinations.id, destinationId)); + }); return { visibility: "public" }; } From b0f91939d2cd6a1a68c7c2db79a4cd35717115a8 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:56:18 +0000 Subject: [PATCH 5/5] fix(conversations): correct public publish confirm copy Public conversations remain workspace-authenticated. Match the privacy chip wording so the confirm does not imply anonymous link access. --- .../src/client/conversations/ConversationPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index 60af75e0a9..7759cad50d 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -129,7 +129,7 @@ export function ConversationPage(props: { onClick: () => { if ( !window.confirm( - "Make this conversation public? Anyone with the link can read it. You cannot undo this.", + "Make this conversation public? Anyone in this workspace can read it. You cannot undo this.", ) ) { return;