From 70c10923c9a4d5a461e0a1fd6f0f866a06a466f7 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 10 Aug 2026 18:52:55 +0000 Subject: [PATCH 1/3] fix(webapp): keep the stored chat pointer when opening a chat fails transiently --- .../app/components/dashboard-agent/DashboardAgentPanel.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx index acd59d282..a3d1c07fa 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx @@ -146,6 +146,9 @@ export function DashboardAgentPanel({ if (!res.ok && res.status !== 404) { console.error(`Dashboard agent: failed to open chat ${id} (${res.status})`); toast.error("We couldn't open that chat. Try again in a moment."); + // Transient failure: keep the stored pointer so the chat can be reopened. + if (seq === openChatRequestSeq.current) setActive(null); + return; } const data = res.ok ? ((await res.json()) as OpenedChatResponse) : undefined; if (seq !== openChatRequestSeq.current) return; From 3977c2dbebf21924a873c2c92342739433e14e99 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 10 Aug 2026 18:54:12 +0000 Subject: [PATCH 2/3] fix(dashboard-agent): finalize the turn's own message ids from newUIMessages --- internal-packages/dashboard-agent/src/dashboard-agent.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal-packages/dashboard-agent/src/dashboard-agent.ts b/internal-packages/dashboard-agent/src/dashboard-agent.ts index 779938b15..bd105037e 100644 --- a/internal-packages/dashboard-agent/src/dashboard-agent.ts +++ b/internal-packages/dashboard-agent/src/dashboard-agent.ts @@ -427,6 +427,7 @@ export const dashboardAgent = chat.agent({ turn, uiMessages, newMessages, + newUIMessages, responseMessage, clientData, chatAccessToken, @@ -453,7 +454,7 @@ export const dashboardAgent = chat.agent({ // operation is what could leave a terminal row whose card never arrived — and the // stale sweep only selects `in_progress`, so nothing would ever repair it. // Only what this turn produced may be finalised; the rest of the snapshot is history. - const produced = [...(newMessages ?? []), ...(responseMessage ? [responseMessage] : [])] + const produced = [...(newUIMessages ?? []), ...(responseMessage ? [responseMessage] : [])] .map((message) => (message as { id?: unknown }).id) .filter((id): id is string => typeof id === "string"); From bd5f8fd67fa779120ca5b3c64af5dcfce7c1904e Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 10 Aug 2026 18:54:21 +0000 Subject: [PATCH 3/3] fix(dashboard-agent-db): scope softDeleteChat by organizationId --- .../dashboard-agent-delete-chat-org-scope.md | 6 ++ ...jectParam.env.$envParam.dashboard-agent.ts | 16 +++- ...shboardAgentQueriesTenantIsolation.test.ts | 75 +++++++++++++++++++ .../dashboard-agent-db/src/queries.ts | 10 ++- 4 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 .server-changes/dashboard-agent-delete-chat-org-scope.md create mode 100644 apps/webapp/test/dashboardAgentQueriesTenantIsolation.test.ts diff --git a/.server-changes/dashboard-agent-delete-chat-org-scope.md b/.server-changes/dashboard-agent-delete-chat-org-scope.md new file mode 100644 index 000000000..3a651c0b8 --- /dev/null +++ b/.server-changes/dashboard-agent-delete-chat-org-scope.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Deleting a dashboard agent chat is now scoped to your organization, so a chat can only be removed from within the org it belongs to. diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts index d34519604..022e3dc79 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts @@ -286,7 +286,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { // handover was dispatched and no message was sent: a session the call did create in // spite of the error idles out having done nothing. The empty row is all there is to undo. // Swallowed so the start's own error is what surfaces and gets logged. - await softDeleteChat(dashboardAgentDb, { chatId, userId }).catch((cleanupError) => { + await softDeleteChat(dashboardAgentDb, { + chatId, + userId, + organizationId: project.organizationId, + }).catch((cleanupError) => { logger.error("Failed to remove a dashboard agent chat whose start failed", { chatId, error: cleanupError, @@ -453,8 +457,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } case "delete": { - // `softDeleteChat` is owner-scoped but takes no org, so the org scope has to be - // enforced here. + // Existence check gives a 404 for a chat this caller can't see; the delete itself + // is org- and owner-scoped too. if ( !(await chatExists(dashboardAgentDb, { chatId, @@ -464,7 +468,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { ) { return json({ error: "Chat not found" }, { status: 404 }); } - await softDeleteChat(dashboardAgentDb, { chatId, userId }); + await softDeleteChat(dashboardAgentDb, { + chatId, + userId, + organizationId: project.organizationId, + }); return json({ ok: true }); } } diff --git a/apps/webapp/test/dashboardAgentQueriesTenantIsolation.test.ts b/apps/webapp/test/dashboardAgentQueriesTenantIsolation.test.ts new file mode 100644 index 000000000..52b0af55a --- /dev/null +++ b/apps/webapp/test/dashboardAgentQueriesTenantIsolation.test.ts @@ -0,0 +1,75 @@ +import { + createChat, + createDashboardAgentDb, + listChats, + softDeleteChat, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect } from "vitest"; + +/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */ +async function applyAgentSchema(prisma: PrismaClient) { + const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + const migrations = readdirSync(folder) + .filter((file) => file.endsWith(".sql")) + .sort(); + for (const name of migrations) { + const sql = readFileSync(path.join(folder, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +let agentDbClient: DashboardAgentDbClient | undefined; + +async function boot(prisma: PrismaClient, connectionUri: string): Promise { + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(connectionUri, { max: 2 }); + return agentDbClient.db; +} + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +const ORG = "org_owner"; +const OTHER_ORG = "org_other"; +const USER = "user_owner"; + +describe("softDeleteChat tenant isolation", () => { + postgresTest( + "a soft-delete scoped to another org leaves the chat intact", + async ({ prisma, postgresContainer }) => { + const db = await boot(prisma, postgresContainer.getConnectionUri()); + + await createChat(db, { id: "chat_1", organizationId: ORG, userId: USER }); + + // Right user, wrong org: must not delete. + const wrongOrg = await softDeleteChat(db, { + chatId: "chat_1", + userId: USER, + organizationId: OTHER_ORG, + }); + expect(wrongOrg.deleted).toBe(false); + expect(await listChats(db, { organizationId: ORG, userId: USER })).toHaveLength(1); + + // Right org and user: deletes. + const rightOrg = await softDeleteChat(db, { + chatId: "chat_1", + userId: USER, + organizationId: ORG, + }); + expect(rightOrg.deleted).toBe(true); + expect(await listChats(db, { organizationId: ORG, userId: USER })).toHaveLength(0); + }, + 30_000 + ); +}); diff --git a/internal-packages/dashboard-agent-db/src/queries.ts b/internal-packages/dashboard-agent-db/src/queries.ts index d2cff6d5c..f8e1853f9 100644 --- a/internal-packages/dashboard-agent-db/src/queries.ts +++ b/internal-packages/dashboard-agent-db/src/queries.ts @@ -225,12 +225,18 @@ export async function setChatPinned( /** Owner-scoped: a client chatId can only delete the caller's own chat. */ export async function softDeleteChat( db: DashboardAgentDb, - params: { chatId: string; userId: string } + params: { chatId: string; userId: string; organizationId: string } ): Promise<{ deleted: boolean }> { const deleted = await db .update(chats) .set({ deletedAt: sql`now()`, updatedAt: sql`now()` }) - .where(and(eq(chats.id, params.chatId), eq(chats.userId, params.userId))) + .where( + and( + eq(chats.id, params.chatId), + eq(chats.userId, params.userId), + eq(chats.organizationId, params.organizationId) + ) + ) .returning({ id: chats.id }); return { deleted: deleted.length > 0 };