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..7759cad50d 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 in this workspace 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..527b2e746c 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,63 @@ 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`,
+ {},
+ ),
+ onMutate: async () => {
+ await Promise.all([
+ queryClient.cancelQueries({
+ queryKey: ["dashboard", "conversations"],
+ }),
+ queryClient.cancelQueries({
+ exact: true,
+ queryKey: conversationDetailQueryKey(conversationId),
+ }),
+ ]);
+ },
+ 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..beab86ed58
--- /dev/null
+++ b/packages/junior/src/api/conversations/publish.ts
@@ -0,0 +1,110 @@
+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";
+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.
+ *
+ * Refuses destinations shared by other roots so one publish cannot expose
+ * unrelated private conversations on the same channel.
+ */
+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 destinationId = destination.destinationId;
+
+ 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, 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" };
+}
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..50fee06fa4
--- /dev/null
+++ b/packages/junior/tests/integration/api/conversations/publish.test.ts
@@ -0,0 +1,165 @@
+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.",
+ });
+ });
+
+ 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",
+ });
+ });
+});