Skip to content

Commit ef9fe19

Browse files
committed
fix(chat): review round 1 — restore workspace authz, purge recheck, archived-list invalidation
1 parent 78976fc commit ef9fe19

4 files changed

Lines changed: 88 additions & 20 deletions

File tree

apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,25 @@ import { parseRequest } from '@/lib/api/server'
88
import { chatPubSub } from '@/lib/copilot/chat-status'
99
import {
1010
authenticateCopilotRequestSessionOnly,
11+
createForbiddenResponse,
1112
createInternalServerErrorResponse,
1213
createUnauthorizedResponse,
1314
} from '@/lib/copilot/request/http'
1415
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1516
import { captureServerEvent } from '@/lib/posthog/server'
17+
import {
18+
assertActiveWorkspaceAccess,
19+
isWorkspaceAccessDeniedError,
20+
} from '@/lib/workspaces/permissions/utils'
1621

1722
const logger = createLogger('RestoreMothershipChatAPI')
1823

1924
/**
2025
* POST /api/mothership/chats/[chatId]/restore
2126
* Restores a soft-deleted mothership chat back into the sidebar. Ownership is
22-
* enforced by scoping the update to the authenticated user's rows, matching
23-
* the delete path.
27+
* enforced by scoping the update to the authenticated user's rows, and the
28+
* caller must still have access to the chat's workspace, matching the delete
29+
* path.
2430
*/
2531
export const POST = withRouteHandler(
2632
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
@@ -34,6 +40,26 @@ export const POST = withRouteHandler(
3440
if (!parsed.success) return parsed.response
3541
const { chatId } = parsed.data.params
3642

43+
const [chat] = await db
44+
.select({ workspaceId: copilotChats.workspaceId })
45+
.from(copilotChats)
46+
.where(
47+
and(
48+
eq(copilotChats.id, chatId),
49+
eq(copilotChats.userId, userId),
50+
eq(copilotChats.type, 'mothership'),
51+
isNotNull(copilotChats.deletedAt)
52+
)
53+
)
54+
.limit(1)
55+
56+
if (!chat) {
57+
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
58+
}
59+
if (chat.workspaceId) {
60+
await assertActiveWorkspaceAccess(chat.workspaceId, userId)
61+
}
62+
3763
const [restoredChat] = await db
3864
.update(copilotChats)
3965
.set({ deletedAt: null })
@@ -71,6 +97,9 @@ export const POST = withRouteHandler(
7197

7298
return NextResponse.json({ success: true })
7399
} catch (error) {
100+
if (isWorkspaceAccessDeniedError(error)) {
101+
return createForbiddenResponse('Workspace access denied')
102+
}
74103
logger.error('Error restoring mothership chat:', error)
75104
return createInternalServerErrorResponse('Failed to restore chat')
76105
}

apps/sim/background/cleanup-soft-deletes.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -600,13 +600,26 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise
600600

601601
// Workflow-scoped chats above are removed by the workflow FK cascade;
602602
// soft-deleted mothership chats have no workflow and need their own delete.
603-
const chatResult = await deleteRowsById(
604-
copilotChats,
605-
copilotChats.id,
606-
softDeletedChatIds,
607-
`${label}/copilotChats`
608-
)
609-
totalDeleted += chatResult.deleted
603+
// Re-check the soft-delete cutoff in the DELETE itself so a chat restored
604+
// between selection and this point survives (chatCleanup.execute() below
605+
// also re-checks row existence before purging external data).
606+
for (const batch of chunkArray(softDeletedChatIds, DEFAULT_DELETE_CHUNK_SIZE)) {
607+
try {
608+
const deleted = await db
609+
.delete(copilotChats)
610+
.where(
611+
and(
612+
inArray(copilotChats.id, batch),
613+
isNotNull(copilotChats.deletedAt),
614+
lt(copilotChats.deletedAt, retentionDate)
615+
)
616+
)
617+
.returning({ id: copilotChats.id })
618+
totalDeleted += deleted.length
619+
} catch (error) {
620+
logger.error(`[${label}/copilotChats] Soft-deleted chat delete failed`, { error })
621+
}
622+
}
610623

611624
const legacyFileResult = await deleteExpiredLegacyWorkspaceFileRows(
612625
fileCleanup.legacyRows,

apps/sim/hooks/use-mothership-chat-events.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ export function handleMothershipChatStatusEvent(
101101
return
102102
}
103103

104-
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(workspaceId) })
104+
// workspaceLists covers both the active and archived (Recently Deleted)
105+
// lists: delete/restore events move chats between the two scopes.
106+
queryClient.invalidateQueries({ queryKey: mothershipChatKeys.workspaceLists(workspaceId) })
105107
if (!payload.chatId) return
106108
if (payload.type === 'deleted') {
107109
queryClient.removeQueries({ queryKey: mothershipChatKeys.detail(payload.chatId) })

apps/sim/lib/cleanup/chat-cleanup.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from '@sim/db'
2-
import { copilotMessages, workspaceFiles } from '@sim/db/schema'
2+
import { copilotChats, copilotMessages, workspaceFiles } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, inArray, isNull } from 'drizzle-orm'
55
import { chunkArray } from '@/lib/cleanup/batch-delete'
@@ -26,6 +26,7 @@ type ChatScopedContext = (typeof CHAT_SCOPED_CONTEXTS)[number]
2626
interface FileRef {
2727
key: string
2828
context: ChatScopedContext
29+
chatId: string
2930
}
3031

3132
/**
@@ -42,7 +43,11 @@ export async function collectChatFiles(chatIds: string[]): Promise<FileRef[]> {
4243
for (const chunk of chunkArray(chatIds, CHAT_FILE_COLLECT_CHUNK_SIZE)) {
4344
const [linkedFiles, messageRows] = await Promise.all([
4445
db
45-
.select({ key: workspaceFiles.key, context: workspaceFiles.context })
46+
.select({
47+
key: workspaceFiles.key,
48+
context: workspaceFiles.context,
49+
chatId: workspaceFiles.chatId,
50+
})
4651
.from(workspaceFiles)
4752
.where(
4853
and(
@@ -54,15 +59,15 @@ export async function collectChatFiles(chatIds: string[]): Promise<FileRef[]> {
5459
// Scan every message row for the chat (no deleted_at filter): this is a
5560
// deletion path collecting blob keys, so attachments on any row count.
5661
db
57-
.select({ content: copilotMessages.content })
62+
.select({ content: copilotMessages.content, chatId: copilotMessages.chatId })
5863
.from(copilotMessages)
5964
.where(inArray(copilotMessages.chatId, chunk)),
6065
])
6166

6267
for (const f of linkedFiles) {
63-
if (!seen.has(f.key)) {
68+
if (f.chatId && !seen.has(f.key)) {
6469
seen.add(f.key)
65-
files.push({ key: f.key, context: f.context as ChatScopedContext })
70+
files.push({ key: f.key, context: f.context as ChatScopedContext, chatId: f.chatId })
6671
}
6772
}
6873

@@ -80,7 +85,7 @@ export async function collectChatFiles(chatIds: string[]): Promise<FileRef[]> {
8085
const key = (attachment as Record<string, unknown>).key as string
8186
if (!seen.has(key)) {
8287
seen.add(key)
83-
files.push({ key, context: 'copilot' })
88+
files.push({ key, context: 'copilot', chatId: row.chatId })
8489
}
8590
}
8691
}
@@ -195,17 +200,36 @@ export async function prepareChatCleanup(
195200

196201
return {
197202
execute: async () => {
203+
// A chat can be restored (or its delete can fail) between selection and
204+
// the caller's row delete. Purge backend data and files only for chats
205+
// whose rows are actually gone, so a surviving row never loses its data.
206+
const survivors = new Set<string>()
207+
for (const chunk of chunkArray(chatIds, CHAT_FILE_COLLECT_CHUNK_SIZE)) {
208+
const rows = await db
209+
.select({ id: copilotChats.id })
210+
.from(copilotChats)
211+
.where(inArray(copilotChats.id, chunk))
212+
for (const row of rows) survivors.add(row.id)
213+
}
214+
if (survivors.size > 0) {
215+
logger.info(
216+
`[${label}] Skipping external cleanup for ${survivors.size} chats whose rows still exist`
217+
)
218+
}
219+
const confirmedChatIds = chatIds.filter((id) => !survivors.has(id))
220+
const confirmedFiles = files.filter((file) => !survivors.has(file.chatId))
221+
198222
// Call copilot backend
199-
if (chatIds.length > 0) {
200-
const copilotResult = await cleanupCopilotBackend(chatIds, label)
223+
if (confirmedChatIds.length > 0) {
224+
const copilotResult = await cleanupCopilotBackend(confirmedChatIds, label)
201225
logger.info(
202226
`[${label}] Copilot backend: ${copilotResult.deleted} deleted, ${copilotResult.failed} failed`
203227
)
204228
}
205229

206230
// Delete storage files with correct context per file
207-
if (files.length > 0) {
208-
const fileStats = await deleteStorageFiles(files, label)
231+
if (confirmedFiles.length > 0) {
232+
const fileStats = await deleteStorageFiles(confirmedFiles, label)
209233
logger.info(
210234
`[${label}] Storage cleanup: ${fileStats.filesDeleted} deleted, ${fileStats.filesFailed} failed`
211235
)

0 commit comments

Comments
 (0)