Skip to content

Commit ef278cc

Browse files
committed
fix(project-files): protect upload metadata and gate Project mentions
1 parent 1ae209e commit ef278cc

11 files changed

Lines changed: 365 additions & 85 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,11 @@ vi.mock('@/lib/browser-agent/transport', () => ({
108108
import { toast } from '@sim/emcn'
109109
import type { GenericSecretSource } from '@/lib/api/contracts/organization-secrets'
110110
import type { CredentialItemData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
111-
import { SpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
111+
import {
112+
SpecialTags,
113+
WorkspaceResourceDisplay,
114+
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
115+
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
112116
import { organizationSecretKeys } from '@/hooks/queries/organization-secrets'
113117

114118
const mockOrganizationContext = organizationProviderMockFns.mockUseOptionalOrganizationContext
@@ -459,3 +463,62 @@ describe('CredentialDisplay link tag', () => {
459463
act(() => root.unmount())
460464
})
461465
})
466+
467+
describe('restored Project file mentions', () => {
468+
it.each([
469+
[false, true],
470+
[true, false],
471+
[false, false],
472+
])('stops metadata reads after rollout flags become %s/%s', async (projects, projectFiles) => {
473+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
474+
vi.useFakeTimers()
475+
const requests: string[] = []
476+
vi.stubGlobal('fetch', async (input: string) => {
477+
requests.push(input)
478+
return Response.json({ error: 'Unavailable fixture' }, { status: 404 })
479+
})
480+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
481+
const container = document.createElement('div')
482+
const root = createRoot(container)
483+
const render = async (projects: boolean, projectFiles: boolean) => {
484+
await act(async () =>
485+
root.render(
486+
<QueryClientProvider client={client}>
487+
<FeatureFlagsProvider
488+
flags={{
489+
projects,
490+
'project-files': projectFiles,
491+
dashboards: false,
492+
'mothership-model-selector': false,
493+
'mothership-plan-mode': false,
494+
}}
495+
>
496+
<WorkspaceResourceDisplay
497+
data={{
498+
type: 'file',
499+
id: 'file',
500+
title: 'Fixture',
501+
owner: { entityType: 'project', entityId: 'project' },
502+
}}
503+
/>
504+
</FeatureFlagsProvider>
505+
</QueryClientProvider>
506+
)
507+
)
508+
await act(async () => vi.advanceTimersByTimeAsync(10))
509+
}
510+
try {
511+
await render(true, true)
512+
expect(requests).toHaveLength(1)
513+
requests.length = 0
514+
await render(projects, projectFiles)
515+
await act(async () => client.invalidateQueries())
516+
expect(requests).toEqual([])
517+
} finally {
518+
await act(async () => root.unmount())
519+
client.clear()
520+
vi.useRealTimers()
521+
vi.unstubAllGlobals()
522+
}
523+
})
524+
})

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import type {
9797
// ConnectServiceAccountModal, and that edge would pull the modal into this
9898
// chunk and defeat the lazy() split below.
9999
import { useServiceAccountConnectTarget } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect'
100+
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
100101
import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
101102
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
102103
import { BrandIcon } from '@/blocks/brand-icon'
@@ -2025,7 +2026,10 @@ function ProjectFileResourceDisplay({
20252026
title,
20262027
onSelect,
20272028
}: ProjectFileResourceDisplayProps) {
2028-
const { data, isError } = useProjectFile(projectId, fileId)
2029+
const projectsEnabled = useFeatureFlag('projects')
2030+
const projectFilesEnabled = useFeatureFlag('project-files')
2031+
const enabled = projectsEnabled && projectFilesEnabled
2032+
const { data, isError } = useProjectFile(enabled ? projectId : undefined, fileId)
20292033
const file = data?.file
20302034
const label = file?.name ?? title ?? 'File'
20312035
if (isError) return <span role='status'>File unavailable.</span>
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/** @vitest-environment jsdom */
2+
import { act, createRef } from 'react'
3+
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
4+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
7+
import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
8+
import { PlusMenuDropdown } from '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown'
9+
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
10+
import { workspaceKeys } from '@/hooks/queries/workspace'
11+
12+
vi.mock('@/blocks/integration-matcher', () => ({
13+
...integrationMatcherMock,
14+
listIntegrationsByPopularity: () => [],
15+
}))
16+
17+
vi.mock(
18+
'next/navigation',
19+
async () => (await import('@sim/testing/mocks/next-navigation.mock')).nextNavigationMock
20+
)
21+
22+
let client: QueryClient
23+
let root: Root
24+
let container: HTMLDivElement
25+
const handle = createRef<PlusMenuHandle>()
26+
const selected = vi.fn()
27+
const project = {
28+
id: 'project',
29+
name: 'Gate fixture',
30+
organizationId: 'organization',
31+
ownerId: 'user',
32+
archivedAt: null,
33+
createdAt: '2026-10-08T00:00:00Z',
34+
updatedAt: '2026-10-08T00:00:00Z',
35+
environments: [],
36+
capabilities: { administer: true, issues: true },
37+
}
38+
beforeEach(() => {
39+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
40+
vi.useFakeTimers()
41+
Element.prototype.scrollIntoView = vi.fn()
42+
selected.mockClear()
43+
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
44+
client.setQueryData(workspaceKeys.list(), { workspaces: [] })
45+
vi.stubGlobal('fetch', async (input: string) =>
46+
Response.json(
47+
input.startsWith('/api/projects?') ? { projects: [project], nextCursor: null } : {}
48+
)
49+
)
50+
container = document.createElement('div')
51+
document.body.append(container)
52+
root = createRoot(container)
53+
})
54+
afterEach(async () => {
55+
await act(async () => root.unmount())
56+
client.clear()
57+
container.remove()
58+
vi.useRealTimers()
59+
})
60+
async function render(projectFiles: boolean) {
61+
await act(async () =>
62+
root.render(
63+
<QueryClientProvider client={client}>
64+
<FeatureFlagsProvider
65+
flags={{
66+
projects: true,
67+
'project-files': projectFiles,
68+
dashboards: false,
69+
'mothership-model-selector': false,
70+
'mothership-plan-mode': false,
71+
}}
72+
>
73+
<PlusMenuDropdown
74+
ref={handle}
75+
workspaceId=''
76+
organizationId='organization'
77+
warm
78+
onResourceSelect={() => {}}
79+
onWorkspaceSelect={() => {}}
80+
onProjectSelect={selected}
81+
onClose={() => {}}
82+
textareaRef={{ current: null }}
83+
pendingCursorRef={{ current: null }}
84+
mentionQuery={project.name}
85+
/>
86+
</FeatureFlagsProvider>
87+
</QueryClientProvider>
88+
)
89+
)
90+
await act(async () => vi.advanceTimersByTimeAsync(10))
91+
}
92+
it('stops cached Project mention selection when Project files are disabled', async () => {
93+
await render(true)
94+
await act(async () => handle.current?.open({ left: 0, top: 0 }, { mention: true }))
95+
await act(async () => handle.current?.selectActive())
96+
expect(selected).toHaveBeenCalledWith(
97+
expect.objectContaining({ id: project.id, name: project.name })
98+
)
99+
selected.mockClear()
100+
await render(false)
101+
await act(async () => handle.current?.open({ left: 0, top: 0 }, { mention: true }))
102+
await act(async () => handle.current?.selectActive())
103+
expect(selected).not.toHaveBeenCalled()
104+
})
105+
it('stops Project tagging through the browse submenu when Project files are disabled', async () => {
106+
await render(true)
107+
await act(async () => handle.current?.open({ left: 0, top: 0 }))
108+
const selectProject = async () => {
109+
const trigger = Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(
110+
(item) => item.textContent === project.name
111+
)
112+
if (!trigger) throw new Error('Project fixture was not available for environment browsing')
113+
await act(async () => trigger.click())
114+
}
115+
await selectProject()
116+
expect(selected).toHaveBeenCalledWith(expect.objectContaining({ id: project.id }))
117+
selected.mockClear()
118+
await render(false)
119+
await act(async () => handle.current?.open({ left: 0, top: 0 }))
120+
await selectProject()
121+
expect(selected).not.toHaveBeenCalled()
122+
})

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ export const PlusMenuDropdown = React.memo(
169169
includeFolderMentions: true,
170170
})
171171
const projectsEnabled = useFeatureFlag('projects')
172+
const projectFilesEnabled = useFeatureFlag('project-files')
173+
const projectSelectionEnabled = projectsEnabled && projectFilesEnabled
172174
const projectQuery = useProjectInventory(organizationId, inventoryEnabled && projectsEnabled)
173175
const projects =
174176
projectsEnabled && !projectQuery.isError
@@ -259,7 +261,11 @@ export const PlusMenuDropdown = React.memo(
259261
const q = query.toLowerCase().trim()
260262
if (!isMention && !q) return null
261263
const projectItems: MentionCandidate[] = (
262-
q ? projects : projects.slice(0, MENTION_PREVIEW_DEFAULT_LIMIT)
264+
projectSelectionEnabled
265+
? q
266+
? projects
267+
: projects.slice(0, MENTION_PREVIEW_DEFAULT_LIMIT)
268+
: EMPTY_PROJECTS
263269
)
264270
.filter((project) => project.name.toLowerCase().includes(q))
265271
.map((item) => ({ type: 'project', item }))
@@ -277,7 +283,7 @@ export const PlusMenuDropdown = React.memo(
277283
(type) => getResourceConfig(type).mentionPreviewLimit ?? MENTION_PREVIEW_DEFAULT_LIMIT
278284
)
279285
return [...projectItems, ...workspaceItems, ...resourceItems]
280-
}, [isMention, query, visibleResources, workspaces, projects])
286+
}, [isMention, query, visibleResources, workspaces, projects, projectSelectionEnabled])
281287

282288
const activeIndex = Math.max(
283289
0,
@@ -494,7 +500,7 @@ export const PlusMenuDropdown = React.memo(
494500
key={project.id}
495501
project={project}
496502
workspaces={workspaces}
497-
onSelectProject={handleProjectSelect}
503+
onSelectProject={projectSelectionEnabled ? handleProjectSelect : undefined}
498504
excludeTypes={WORKSPACE_SUBMENU_EXCLUDED_TYPES}
499505
selectFolders
500506
onSelect={handleSelect}

‎apps/sim/hooks/queries/workspace-files.ts‎

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,11 @@ class StaleStorageKeyError extends Error {
180180
}
181181
}
182182

183+
interface UseStaleKeyRecoveryProps {
184+
workspaceId: string | undefined
185+
fileId?: string
186+
}
187+
183188
/**
184189
* Re-resolve a workspace's file records after a read found its storage key superseded.
185190
*
@@ -199,10 +204,10 @@ class StaleStorageKeyError extends Error {
199204
* on the dead key showing a failure until something unrelated (a window focus, another consumer)
200205
* happens to re-resolve the record.
201206
*/
202-
function useStaleKeyRecovery(
203-
workspaceId: string | undefined,
204-
fileId?: string
205-
): (error: unknown) => void {
207+
function useStaleKeyRecovery({
208+
workspaceId,
209+
fileId,
210+
}: UseStaleKeyRecoveryProps): (error: unknown) => void {
206211
const queryClient = useQueryClient()
207212
const source = useFileContentSource()
208213
const ownerQuery = resolveFileQueryOwner(source.owner, workspaceId)
@@ -269,7 +274,7 @@ export function useWorkspaceFileContent(
269274
): WorkspaceFileContentResult {
270275
const source = useFileContentSource()
271276
const ownerQuery = resolveFileQueryOwner(source.owner, workspaceId)
272-
const recoverStaleKey = useStaleKeyRecovery(workspaceId, fileId)
277+
const recoverStaleKey = useStaleKeyRecovery({ workspaceId, fileId })
273278
const query = useQuery({
274279
queryKey:
275280
ownerQuery?.adapter.contentKey(ownerQuery.id, fileId, raw ? 'raw' : 'text', key) ??
@@ -290,7 +295,12 @@ export function useWorkspaceFileContent(
290295
})
291296
return {
292297
data: query.data,
293-
...useStaleKeyRecoveryState(workspaceId, fileId, query.isLoading, query.error),
298+
...useStaleKeyRecoveryState({
299+
workspaceId,
300+
fileId,
301+
isLoading: query.isLoading,
302+
error: query.error,
303+
}),
294304
}
295305
}
296306

@@ -302,6 +312,13 @@ export interface WorkspaceFileContentResult {
302312
error: Error | null
303313
}
304314

315+
interface UseStaleKeyRecoveryStateProps {
316+
workspaceId: string | undefined
317+
fileId: string
318+
isLoading: boolean
319+
error: unknown
320+
}
321+
305322
/**
306323
* Present a superseded storage key as STILL LOADING rather than as a failure.
307324
*
@@ -315,12 +332,12 @@ export interface WorkspaceFileContentResult {
315332
* record hands back the same key — the object is genuinely gone, not moved — the recovery ends, the
316333
* error surfaces, and the reader sees a real failure.
317334
*/
318-
function useStaleKeyRecoveryState(
319-
workspaceId: string | undefined,
320-
fileId: string,
321-
isLoading: boolean,
322-
error: unknown
323-
): { isLoading: boolean; error: Error | null } {
335+
function useStaleKeyRecoveryState({
336+
workspaceId,
337+
fileId,
338+
isLoading,
339+
error,
340+
}: UseStaleKeyRecoveryStateProps): { isLoading: boolean; error: Error | null } {
324341
const source = useFileContentSource()
325342
const ownerQuery = resolveFileQueryOwner(source.owner, workspaceId)
326343
const resolvingRecord = useIsFetching({
@@ -393,7 +410,7 @@ export function useWorkspaceFileBinary(
393410
) {
394411
const source = useFileContentSource()
395412
const ownerQuery = resolveFileQueryOwner(source.owner, workspaceId)
396-
const recoverStaleKey = useStaleKeyRecovery(workspaceId, fileId)
413+
const recoverStaleKey = useStaleKeyRecovery({ workspaceId, fileId })
397414
return useQuery({
398415
queryKey:
399416
ownerQuery?.adapter.contentKey(ownerQuery.id, fileId, 'binary', key, options?.version) ??

‎apps/sim/lib/mothership/agent-cli/project-file-upload-transport.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '@/lib/api/server/routes/v2-json-route'
1313
import { parseRequest } from '@/lib/api/server/validation'
1414
import type { AgentCliExecutionContext } from '@/lib/mothership/agent-cli'
15+
import { requireCleanProjectFileMetadata } from '@/lib/mothership/agent-cli/project-file-write-provenance'
1516
import { executeCopilotProjectFileUseCase } from '@/lib/mothership/application/execute-project-file-use-case'
1617
import { requireTrustedCopilotResourceExecutionContext } from '@/lib/mothership/auth/application-delegation'
1718
import { toV2ProjectFileUpload } from '@/lib/projects/files/api/upload-presenter'
@@ -21,6 +22,7 @@ import {
2122
} from '@/lib/projects/files/application'
2223
import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
2324
import { v2Data, v2Error } from '@/app/api/v2/lib/response'
25+
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
2426

2527
const logger = createLogger('ProjectFileUploadTransport')
2628
const UPLOADS_ROUTE = '/api/v2/projects/{projectId}/files/uploads'
@@ -31,6 +33,7 @@ export function createProjectFileUploadTransport(options: {
3133
projectId: string
3234
context: AgentCliExecutionContext
3335
fallback: typeof fetch
36+
resolveSecretTraceRegistry?: () => Promise<ResolvedSecretTraceRegistry>
3437
uploadProvenance?: () => WorkspaceFileSecretProvenance | Promise<WorkspaceFileSecretProvenance>
3538
uploadBinding?: {
3639
record(uploadId: string): Promise<void>
@@ -74,6 +77,9 @@ export function createProjectFileUploadTransport(options: {
7477
)
7578
if (!parsed.success) return parsed.response
7679
const { body } = parsed.data
80+
await requireCleanProjectFileMetadata(body, options)
81+
context.signal?.throwIfAborted()
82+
request.signal.throwIfAborted()
7783
const session = await executeCopilotProjectFileUseCase(
7884
context,
7985
createProjectFileUploadSession,

0 commit comments

Comments
 (0)