Skip to content

Commit 019d1bb

Browse files
committed
fix(projects): gate the Project list on the projects flag and answer 403 when it is off
1 parent 7589537 commit 019d1bb

10 files changed

Lines changed: 141 additions & 15 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { createMockRequest } from '@sim/testing'
2+
import { authMockFns } from '@sim/testing/mocks/auth.mock'
3+
import { featureFlagsMock } from '@sim/testing/mocks/feature-flags.mock'
4+
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock)
8+
9+
import { GET } from '@/app/api/projects/route'
10+
11+
describe('GET /api/projects with the projects flag off', () => {
12+
beforeEach(() => {
13+
authMockFns.mockGetSession.mockResolvedValue({
14+
user: { id: 'user-1', name: 'Member', email: 'member@example.com' },
15+
session: { id: 'session-1', token: 'token-1' },
16+
})
17+
})
18+
19+
it('answers as a disabled feature, not as a server fault', async () => {
20+
const response = await GET(
21+
createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/projects?limit=10')
22+
)
23+
24+
expect(response.status).toBe(403)
25+
expect(await response.json()).toMatchObject({ error: 'Projects are not enabled' })
26+
expect(getMockLogger('RouteHandler').error).not.toHaveBeenCalled()
27+
})
28+
})

‎apps/sim/app/o/[organizationId]/layout.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
1212
import { isMothershipModelSelectorEnabled } from '@/lib/mothership/feature-flags'
1313
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
1414
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
15+
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
1516
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
1617
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
1718
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
@@ -73,6 +74,7 @@ export default async function OrganizationLayout({
7374
computerUseEnabled,
7475
workflowTestsEnabled,
7576
changelogEnabled,
77+
projectsEnabled,
7678
] = await Promise.all([
7779
prefetchOrganizationSidebar(
7880
queryClient,
@@ -89,6 +91,7 @@ export default async function OrganizationLayout({
8991
isComputerUseAvailable(),
9092
isWorkflowTestsEnabled(organizationId),
9193
isChangelogEnabled(organizationId),
94+
isProjectsEnabled(),
9295
])
9396
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
9497

@@ -103,6 +106,7 @@ export default async function OrganizationLayout({
103106
'mothership-computer-use': computerUseEnabled,
104107
'mothership-plan-mode': benchmarkEnabled,
105108
'mothership-memory-spaces': benchmarkEnabled,
109+
projects: projectsEnabled,
106110
}}
107111
>
108112
<OrganizationProvider context={context}>

‎apps/sim/app/workspace/[workspaceId]/layout.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '@/lib/desktop/executor/availability'
1616
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
1717
import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry'
18+
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
1819
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
1920
import { ApplicationLoading } from '@/app/_shell/application-loading'
2021
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
@@ -131,6 +132,7 @@ async function WorkspaceContent({
131132
changelogEnabled,
132133
desktopExecutorRegistered,
133134
computerUseEnabled,
135+
projectsEnabled,
134136
] = await Promise.all([
135137
cookies(),
136138
orgSettings,
@@ -149,6 +151,7 @@ async function WorkspaceContent({
149151
isChangelogEnabled(hostContext.hostOrganizationId),
150152
hasDesktopBackgroundExecutor(session.user.id),
151153
isComputerUseAvailable(),
154+
isProjectsEnabled(),
152155
prefetchWorkspaceAccess(queryClient, workspaceId, principal),
153156
prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext),
154157
])
@@ -164,6 +167,7 @@ async function WorkspaceContent({
164167
'mothership-model-selector': modelSelectorEnabled,
165168
'mothership-computer-use': computerUseEnabled,
166169
'mothership-plan-mode': planModeEnabled,
170+
projects: projectsEnabled,
167171
}}
168172
>
169173
<WorkspaceHostProvider workspaceId={workspaceId} initialContext={hostContext}>

‎apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface WorkspaceFeatureFlags {
1010
'mothership-plan-mode': boolean
1111
'mothership-computer-use'?: boolean
1212
'mothership-memory-spaces'?: boolean
13+
projects?: boolean
1314
}
1415

1516
const FeatureFlagsContext = createContext<WorkspaceFeatureFlags | null>(null)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/** @vitest-environment jsdom */
2+
3+
import { act } from 'react'
4+
import { flushMacrotask } from '@sim/testing/helpers'
5+
import { jsonResponse } from '@sim/testing/helpers/http'
6+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
7+
import { createRoot, type Root } from 'react-dom/client'
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9+
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
10+
import { ProjectIssueRestrictions } from '@/ee/access-control/components/project-issue-restrictions'
11+
12+
let root: Root
13+
let container: HTMLDivElement
14+
let client: QueryClient
15+
let requests: string[]
16+
17+
beforeEach(() => {
18+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
19+
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
20+
requests = []
21+
vi.stubGlobal(
22+
'fetch',
23+
vi.fn(async (url: string) => {
24+
requests.push(url)
25+
return jsonResponse({
26+
projects: [
27+
{
28+
id: 'project-1',
29+
name: 'Payments',
30+
organizationId: 'org-1',
31+
ownerId: 'user-1',
32+
archivedAt: null,
33+
createdAt: '2026-01-01T00:00:00Z',
34+
updatedAt: '2026-01-01T00:00:00Z',
35+
environments: [],
36+
capabilities: { administer: true, issues: true },
37+
},
38+
],
39+
nextCursor: null,
40+
})
41+
})
42+
)
43+
container = document.createElement('div')
44+
document.body.appendChild(container)
45+
root = createRoot(container)
46+
})
47+
48+
afterEach(() => {
49+
act(() => root.unmount())
50+
client.clear()
51+
container.remove()
52+
vi.unstubAllGlobals()
53+
})
54+
55+
async function render(projects: boolean) {
56+
await act(async () => {
57+
root.render(
58+
<QueryClientProvider client={client}>
59+
<FeatureFlagsProvider
60+
flags={{
61+
dashboards: false,
62+
'workflow-tests': false,
63+
changelog: false,
64+
'mothership-model-selector': false,
65+
'mothership-plan-mode': false,
66+
projects,
67+
}}
68+
>
69+
<ProjectIssueRestrictions organizationId='org-1' value={[]} onChange={() => {}} />
70+
</FeatureFlagsProvider>
71+
</QueryClientProvider>
72+
)
73+
})
74+
await act(() => flushMacrotask())
75+
}
76+
77+
describe('ProjectIssueRestrictions', () => {
78+
it('never reads the Project APIs while the projects flag is off', async () => {
79+
await render(false)
80+
expect(requests).toEqual([])
81+
expect(container.querySelector('#project-issues-project-1')).toBeNull()
82+
})
83+
84+
it('offers each authorized Project once the projects flag is on', async () => {
85+
await render(true)
86+
expect(requests.some((url) => url.startsWith('/api/projects?'))).toBe(true)
87+
expect(container.querySelector('#project-issues-project-1')).not.toBeNull()
88+
})
89+
})

‎apps/sim/ee/access-control/components/project-issue-restrictions.tsx‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use client'
22

33
import { Checkbox, Chip, Info, OverflowText } from '@sim/emcn'
4-
import { isApiClientError } from '@/lib/api/client/errors'
54
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
65
import { useProjects } from '@/hooks/queries/projects'
76

@@ -21,9 +20,7 @@ export function ProjectIssueRestrictions({
2120
onChange,
2221
}: ProjectIssueRestrictionsProps) {
2322
const projects = useProjects(organizationId)
24-
if (projects.isPending || (isApiClientError(projects.error) && projects.error.status === 503)) {
25-
return null
26-
}
23+
if (projects.isPending) return null
2724
const choices = projects.data?.pages.flatMap((page) => page.projects) ?? []
2825
if (!projects.error && choices.length === 0) return null
2926
const selected = new Set(value)

‎apps/sim/hooks/queries/projects.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useInfiniteQuery } from '@tanstack/react-query'
22
import { isApiClientError } from '@/lib/api/client/errors'
33
import { requestJson } from '@/lib/api/client/request'
44
import { listProjectsContract } from '@/lib/api/contracts/projects'
5+
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
56

67
const PROJECT_LIST_STALE_TIME = 30_000
78
const projectKeys = {
@@ -10,7 +11,9 @@ const projectKeys = {
1011
list: (organizationId: string) => [...projectKeys.lists(), organizationId] as const,
1112
}
1213

14+
/** Lists the caller's Projects; never requests while the `projects` rollout flag is off. */
1315
export function useProjects(organizationId: string) {
16+
const projectsEnabled = useFeatureFlag('projects')
1417
return useInfiniteQuery({
1518
queryKey: projectKeys.list(organizationId),
1619
initialPageParam: null as string | null,
@@ -26,7 +29,7 @@ export function useProjects(organizationId: string) {
2629
(!isApiClientError(error) ||
2730
error.status === 408 ||
2831
error.status === 429 ||
29-
(error.status >= 500 && error.status !== 503)),
30-
enabled: Boolean(organizationId),
32+
error.status >= 500),
33+
enabled: projectsEnabled && Boolean(organizationId),
3134
})
3235
}

‎apps/sim/lib/projects/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A Project groups environments. An environment is an existing `workspace` record; there is no separate environment table. Every newly created Project starts with an environment.
44

5-
Project APIs return HTTP 503 until the `projects` feature flag is on (AppConfig on hosted deployments; the `PROJECTS` secret elsewhere). The flag defaults off and gates only the Project APIs: workspace creation always assigns a Project atomically, and assigned Projects keep their lifecycle protections (fork inheritance, disconnect) either way.
5+
Project APIs return HTTP 403 until the `projects` feature flag is on (AppConfig on hosted deployments; the `PROJECTS` secret elsewhere). The flag defaults off and gates only the Project APIs: workspace creation always assigns a Project atomically, and assigned Projects keep their lifecycle protections (fork inheritance, disconnect) either way.
66

77
## Storage and consistency
88

‎apps/sim/lib/projects/__integration__/foundation.integration.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ describe('Project foundation at the database and application boundary', () => {
439439
() => archiveProject.execute({ principal: f.owner, input, request }),
440440
() => getProjectIssueAccess.execute({ principal: f.owner, input, request }),
441441
]
442-
for (const call of calls) await expect(call()).rejects.toMatchObject({ statusCode: 503 })
442+
for (const call of calls) await expect(call()).rejects.toMatchObject({ code: 'forbidden' })
443443
expect(
444444
await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId))
445445
).toHaveLength(1)
Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
2-
import { HttpError } from '@/lib/core/utils/http-error'
2+
import { OrchestrationError } from '@/lib/core/orchestration/types'
33

4-
class ProjectUnavailableError extends HttpError {
5-
readonly statusCode = 503
6-
constructor() {
7-
super('Projects are not enabled on this deployment')
8-
}
4+
/** Project rollout is a global switch; it never varies by organization or user. */
5+
export function isProjectsEnabled(): Promise<boolean> {
6+
return isFeatureEnabled('projects')
97
}
108

119
export async function requireProjectApiEnabled(): Promise<void> {
12-
if (!(await isFeatureEnabled('projects'))) throw new ProjectUnavailableError()
10+
if (!(await isProjectsEnabled())) {
11+
throw new OrchestrationError('forbidden', 'Projects are not enabled')
12+
}
1313
}

0 commit comments

Comments
 (0)