Skip to content

Commit 77406dd

Browse files
committed
Hand flagged organizations off to the Platform from the legacy layout
The proxy passes the requested /o URL to the layout in a header only when SIM_PLATFORM_ORIGIN is set. The layout redirects mapped routes for flagged members with a same-origin path, so the desktop origin stays in-app, and a small client guard covers client navigation from routes that stay legacy. Flag-off renders do one flag read; with the env unset, none. Extracts the surface's runtime flags so the Platform read shares them.
1 parent 5bbcb83 commit 77406dd

5 files changed

Lines changed: 156 additions & 62 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use client'
2+
3+
import { useEffect } from 'react'
4+
import { usePathname, useSearchParams } from 'next/navigation'
5+
import { mapLegacyOrganizationUrl } from '@/lib/platform/routes'
6+
7+
interface PlatformRouteGuardProps {
8+
organizationId: string
9+
}
10+
11+
/**
12+
* For an organization on the Platform, the layout only renders routes that stayed legacy, and it
13+
* does not re-render on client navigation. This sends a link from one of those pages to a route the
14+
* Platform owns. A full navigation, because the Platform is a different application.
15+
*/
16+
export function PlatformRouteGuard({ organizationId }: PlatformRouteGuardProps) {
17+
const pathname = usePathname()
18+
const search = useSearchParams().toString()
19+
useEffect(() => {
20+
const url = search ? `${pathname}?${search}` : pathname
21+
const platformUrl = mapLegacyOrganizationUrl(url, organizationId)
22+
if (platformUrl) window.location.replace(platformUrl)
23+
}, [pathname, search, organizationId])
24+
return null
25+
}

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

Lines changed: 35 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,23 @@
1-
import { createLogger } from '@sim/logger'
1+
import { Suspense } from 'react'
22
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
3-
import { cookies } from 'next/headers'
3+
import { cookies, headers } from 'next/headers'
44
import { redirect } from 'next/navigation'
55
import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider'
66
import { getSession } from '@/lib/auth'
77
import { getActiveOrganizationId } from '@/lib/auth/session-response'
8-
import { canUseBenchmarks } from '@/lib/benchmarks/config'
9-
import { isChangelogEnabled } from '@/lib/changelog/feature-flag'
10-
import { isComputerUseAvailable } from '@/lib/computer-use/availability.server'
11-
import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
12-
import { isMothershipModelSelectorEnabled } from '@/lib/mothership/feature-flags'
138
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
149
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
10+
import { resolveOrganizationSurfaceFlags } from '@/lib/organizations/surface-flags'
1511
import { canBrowsePeople } from '@/lib/people/feature-flag'
16-
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
12+
import { isPlatformOrgUiEnabled } from '@/lib/platform/rollout'
13+
import { LEGACY_URL_HEADER, mapLegacyOrganizationUrl } from '@/lib/platform/routes'
1714
import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization'
1815
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
19-
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
2016
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
2117
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
2218
import { OrganizationAccessDenied } from '@/app/o/[organizationId]/components/organization-access-denied'
2319
import { OrganizationSidebar } from '@/app/o/[organizationId]/components/organization-sidebar'
20+
import { PlatformRouteGuard } from '@/app/o/[organizationId]/components/platform-route-guard'
2421
import { SimLinkOrganizationProvider } from '@/app/o/[organizationId]/links/sim-link-organization-provider'
2522
import { prefetchOrganizationSidebar } from '@/app/o/[organizationId]/prefetch'
2623
import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider'
@@ -33,8 +30,6 @@ import {
3330
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
3431
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
3532

36-
const logger = createLogger('OrganizationLayout')
37-
3833
/**
3934
* The organization surface: the viewer's own view of one organization, outside
4035
* any workspace. Requires membership and an available organization chat surface.
@@ -67,6 +62,12 @@ export default async function OrganizationLayout({
6762
if (!context) {
6863
return <OrganizationAccessDenied />
6964
}
65+
const platformOrgUi = await isPlatformOrgUiEnabled(organizationId)
66+
if (platformOrgUi) {
67+
const legacyUrl = (await headers()).get(LEGACY_URL_HEADER)
68+
const platformUrl = legacyUrl && mapLegacyOrganizationUrl(legacyUrl, organizationId)
69+
if (platformUrl) redirect(platformUrl)
70+
}
7071
const peopleEnabledLookup = canBrowsePeople(organizationId, context.viewer.role)
7172
const simLinksEnabledLookup = isSimLinksEnabled(organizationId)
7273
if (
@@ -77,60 +78,34 @@ export default async function OrganizationLayout({
7778
)
7879
redirect(WORKSPACE_SETTINGS_PATH)
7980

80-
const [
81-
,
82-
modelSelectorEnabled,
83-
dashboardsEnabled,
84-
benchmarkEnabled,
85-
computerUseEnabled,
86-
workflowTestsEnabled,
87-
changelogEnabled,
88-
projectsEnabled,
89-
simLinksEnabled,
90-
peopleEnabled,
91-
simLinkOrganizationId,
92-
] = await Promise.all([
93-
prefetchOrganizationSidebar(
94-
queryClient,
95-
organizationId,
96-
{ kind: 'session', userId: session.user.id, sessionId: session.session.id },
97-
getActiveOrganizationId(session)
98-
),
99-
isMothershipModelSelectorEnabled(),
100-
isDashboardsEnabled(organizationId),
101-
canUseBenchmarks(session.user.id).catch(() => {
102-
logger.warn('Could not resolve benchmark navigation access')
103-
return false
104-
}),
105-
isComputerUseAvailable(organizationId),
106-
isWorkflowTestsEnabled(organizationId),
107-
isChangelogEnabled(organizationId),
108-
isProjectsEnabled(),
109-
simLinksEnabledLookup,
110-
peopleEnabledLookup,
111-
resolveSimLinkOrganization({
112-
kind: 'session',
113-
userId: session.user.id,
114-
sessionId: session.session.id,
115-
}),
116-
])
81+
const [, { flags, benchmarkEnabled }, simLinksEnabled, peopleEnabled, simLinkOrganizationId] =
82+
await Promise.all([
83+
prefetchOrganizationSidebar(
84+
queryClient,
85+
organizationId,
86+
{ kind: 'session', userId: session.user.id, sessionId: session.session.id },
87+
getActiveOrganizationId(session)
88+
),
89+
resolveOrganizationSurfaceFlags(organizationId, session.user.id),
90+
simLinksEnabledLookup,
91+
peopleEnabledLookup,
92+
resolveSimLinkOrganization({
93+
kind: 'session',
94+
userId: session.user.id,
95+
sessionId: session.session.id,
96+
}),
97+
])
11798
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
11899

119100
return (
120101
<HydrationBoundary state={dehydrate(queryClient)}>
121-
<FeatureFlagsProvider
122-
flags={{
123-
dashboards: dashboardsEnabled,
124-
'workflow-tests': workflowTestsEnabled,
125-
changelog: changelogEnabled,
126-
'mothership-model-selector': modelSelectorEnabled,
127-
'mothership-computer-use': computerUseEnabled,
128-
'mothership-plan-mode': benchmarkEnabled,
129-
'mothership-memory-spaces': benchmarkEnabled,
130-
projects: projectsEnabled,
131-
}}
132-
>
102+
<FeatureFlagsProvider flags={flags}>
133103
<OrganizationProvider context={context}>
104+
{platformOrgUi && (
105+
<Suspense fallback={null}>
106+
<PlatformRouteGuard organizationId={organizationId} />
107+
</Suspense>
108+
)}
134109
<SimLinkOrganizationProvider organizationId={simLinkOrganizationId}>
135110
<GlobalCommandsProvider>
136111
<WorkspaceViewport className='workspace-root'>
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { createLogger } from '@sim/logger'
2+
import { canUseBenchmarks } from '@/lib/benchmarks/config'
3+
import { isChangelogEnabled } from '@/lib/changelog/feature-flag'
4+
import { isComputerUseAvailable } from '@/lib/computer-use/availability.server'
5+
import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
6+
import { isMothershipModelSelectorEnabled } from '@/lib/mothership/feature-flags'
7+
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
8+
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
9+
10+
const logger = createLogger('OrganizationSurfaceFlags')
11+
12+
/**
13+
* The runtime flags the organization surface hands its client code, shared by the legacy layout and
14+
* the Platform's surface read so the two cannot resolve them differently.
15+
*/
16+
export async function resolveOrganizationSurfaceFlags(organizationId: string, userId: string) {
17+
const [
18+
modelSelectorEnabled,
19+
dashboardsEnabled,
20+
benchmarkEnabled,
21+
computerUseEnabled,
22+
workflowTestsEnabled,
23+
changelogEnabled,
24+
projectsEnabled,
25+
] = await Promise.all([
26+
isMothershipModelSelectorEnabled(),
27+
isDashboardsEnabled(organizationId),
28+
canUseBenchmarks(userId).catch(() => {
29+
logger.warn('Could not resolve benchmark navigation access')
30+
return false
31+
}),
32+
isComputerUseAvailable(organizationId),
33+
isWorkflowTestsEnabled(organizationId),
34+
isChangelogEnabled(organizationId),
35+
isProjectsEnabled(),
36+
])
37+
return {
38+
benchmarkEnabled,
39+
flags: {
40+
dashboards: dashboardsEnabled,
41+
'workflow-tests': workflowTestsEnabled,
42+
changelog: changelogEnabled,
43+
'mothership-model-selector': modelSelectorEnabled,
44+
'mothership-computer-use': computerUseEnabled,
45+
'mothership-plan-mode': benchmarkEnabled,
46+
'mothership-memory-spaces': benchmarkEnabled,
47+
projects: projectsEnabled,
48+
},
49+
}
50+
}

‎apps/sim/lib/platform/rollout.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { env } from '@/lib/core/config/env'
4+
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
5+
6+
const logger = createLogger('PlatformRollout')
7+
8+
/**
9+
* Whether this organization's legacy routes hand off to the Platform. The flag is never read on a
10+
* deployment with no Platform zone. A failed read keeps the organization on the legacy surface.
11+
*/
12+
export async function isPlatformOrgUiEnabled(organizationId: string): Promise<boolean> {
13+
if (!env.SIM_PLATFORM_ORIGIN) return false
14+
try {
15+
return await isFeatureEnabled('platform-org-ui', { orgId: organizationId })
16+
} catch (error) {
17+
logger.warn('Could not resolve the Platform rollout; staying on the legacy surface', {
18+
organizationId,
19+
error: getErrorMessage(error),
20+
})
21+
return false
22+
}
23+
}

‎apps/sim/proxy.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ import { SIM_MCP_ROUTE_PATH } from '@/lib/api/mcp/urls'
1010
import { getBrowserExtensionOrigins } from '@/lib/core/security/extension'
1111
import { isMarketingPath } from '@/lib/landing/routes'
1212
import { isAuthSurfacePath } from '@/lib/navigation/auth-surfaces'
13-
import { APP_ENTRY_PATH, isAppSurfacePath, isNoindexPath } from '@/lib/navigation/paths'
13+
import {
14+
APP_ENTRY_PATH,
15+
isAppSurfacePath,
16+
isNoindexPath,
17+
isPathOrDescendant,
18+
} from '@/lib/navigation/paths'
19+
import { LEGACY_URL_HEADER } from '@/lib/platform/routes'
1420
import { isOAuthAuthorizationCallback, resolveAuthRedirect } from '@/app/(auth)/auth-redirect'
1521
import { getEnv } from './lib/core/config/env'
1622
import { isAuthDisabled, isDev, isHosted } from './lib/core/config/env-flags'
@@ -356,6 +362,21 @@ function handleIntegrationSlugRedirect(request: NextRequest): NextResponse | nul
356362
return NextResponse.redirect(url, 308)
357363
}
358364

365+
/**
366+
* Continues an app-surface request. With the Platform zone configured, an organization route also
367+
* tells its layout which URL it is rendering, which that layout cannot otherwise learn. Overwriting
368+
* the header here means the layout never reads a value the browser sent.
369+
*/
370+
function continueAppSurface(request: NextRequest): NextResponse {
371+
const { pathname, search } = request.nextUrl
372+
if (!getEnv('SIM_PLATFORM_ORIGIN') || !isPathOrDescendant(pathname, '/o')) {
373+
return NextResponse.next()
374+
}
375+
const headers = new Headers(request.headers)
376+
headers.set(LEGACY_URL_HEADER, `${pathname}${search}`)
377+
return NextResponse.next({ request: { headers } })
378+
}
379+
359380
export function proxy(request: NextRequest) {
360381
const url = request.nextUrl
361382

@@ -430,7 +451,7 @@ export function proxy(request: NextRequest) {
430451
if (!hasActiveSession) {
431452
return applyIndexingPolicy(request, NextResponse.redirect(new URL('/login', request.url)))
432453
}
433-
const response = NextResponse.next()
454+
const response = continueAppSurface(request)
434455
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
435456
response.headers.set('X-Content-Type-Options', 'nosniff')
436457
response.headers.set('X-Frame-Options', 'SAMEORIGIN')

0 commit comments

Comments
 (0)