Skip to content

Commit 5bbcb83

Browse files
committed
Serve the Platform as a zone under /platform
Adds SIM_PLATFORM_BASE_PATH and a base-path-safe public asset helper to the Platform app, a standalone /api rewrite to SIM_PLATFORM_API_ORIGIN, and a /platform rewrite in the main app gated on SIM_PLATFORM_ORIGIN. Inert with both unset. Also registers the platform-org-ui flag and the pure legacy route mapper the main config reads its base path from.
1 parent 4c967de commit 5bbcb83

8 files changed

Lines changed: 175 additions & 6 deletions

File tree

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,8 @@ export const env = createEnv({
613613
CHANGELOG: z.boolean().optional(), // Fallback for the `changelog` feature flag off AppConfig
614614
SIM_LINKS: z.boolean().optional(), // Fallback for the `sim-links` feature flag off AppConfig
615615
PEOPLE: z.boolean().optional(), // Fallback for the `people` feature flag off AppConfig
616+
PLATFORM_ORG_UI: z.boolean().optional(), // Fallback for the `platform-org-ui` feature flag off AppConfig
617+
SIM_PLATFORM_ORIGIN: z.string().url().optional(), // Internal origin of the Platform zone. Set at build and runtime: adds the /platform rewrite and arms the organization redirect
616618
MSHIP_MODEL_SELECTOR: z.boolean().optional(),
617619
INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted (bypasses hosted requirements)
618620
SANDBOXES_ENABLED: z.boolean().optional(), // Enable custom sandboxes on self-hosted (bypasses hosted requirements)

‎apps/sim/lib/core/config/feature-flags.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ const FEATURE_FLAGS = {
7777
'Enable the organization People directory, org chart, profile imports, and directory sync. Supports organization rollout; disabled by default.',
7878
fallback: 'PEOPLE',
7979
},
80+
'platform-org-ui': {
81+
description:
82+
'Send an organization to the Platform UI: its legacy organization routes redirect to their Platform counterparts. Supports organization rollout; disabled by default. Also requires SIM_PLATFORM_ORIGIN.',
83+
fallback: 'PLATFORM_ORG_UI',
84+
},
8085
'mothership-model-selector': {
8186
description:
8287
'Show the Mothership model selector, model-specific effort levels, and Fast for supported ' +

‎apps/sim/lib/navigation/paths.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,13 @@ export function isAppSurfacePath(pathname: string): boolean {
8787
}
8888

8989
/**
90-
* Non-app routes that must never appear in search results: deployed chats,
91-
* paused-run resume links, invitations, unsubscribe links, shared files, the
92-
* legacy `/w` redirects, the design playground, and account, self-host, and
93-
* upgrade utility pages.
90+
* Non-app routes that must never appear in search results: the Platform zone,
91+
* deployed chats, paused-run resume links, invitations, unsubscribe links, shared
92+
* files, the legacy `/w` redirects, the design playground, and account, self-host,
93+
* and upgrade utility pages.
9494
*/
9595
const NOINDEX_PATH_ROOTS = [
96+
'/platform',
9697
'/sim',
9798
'/chat',
9899
'/extension',

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* How the legacy organization surface maps onto the Platform zone. Dependency-free: the edge proxy,
3+
* `next.config.ts` (loaded before the `@/` alias resolves), and client code all read it.
4+
*/
5+
6+
export const PLATFORM_BASE_PATH = '/platform'
7+
8+
/**
9+
* Carries the requested `/o/...` URL from the proxy to the organization layout, which Next gives no
10+
* way to learn its own sub-path. A forged value can only choose among the mapped routes of an
11+
* organization the viewer already belongs to.
12+
*/
13+
export const LEGACY_URL_HEADER = 'x-sim-legacy-url'
14+
15+
/** Legacy settings paths that keep their name on the Platform. */
16+
const SETTINGS_SECTIONS: ReadonlySet<string> = new Set([
17+
'knowledge-graphs',
18+
'recently-deleted',
19+
'members',
20+
'billing',
21+
'usage',
22+
'whitelabeling',
23+
'requests',
24+
'audit-logs',
25+
'access-control',
26+
'sso',
27+
'security',
28+
'data-retention',
29+
'data-drains',
30+
])
31+
32+
/** The legacy surface's own aliases, resolved here so a saved link lands in one hop. */
33+
const SETTINGS_ALIASES: Readonly<Record<string, string>> = {
34+
organization: 'members',
35+
team: 'members',
36+
subscription: 'billing',
37+
domains: 'sso',
38+
sessions: 'security',
39+
'authorized-apps': 'account',
40+
}
41+
42+
/** Legacy General sub-views, each its own section on the Platform. */
43+
const GENERAL_VIEWS: Readonly<Record<string, string>> = {
44+
privacy: 'privacy',
45+
'authorized-apps': 'account',
46+
'organization-accounts': 'account',
47+
}
48+
49+
function mapSettingsRoute(rest: readonly string[], query: URLSearchParams): string | null {
50+
const [section, detail] = rest
51+
if (section === undefined) return '/settings'
52+
if (section === 'usage' && detail === 'events' && rest.length === 2)
53+
return '/settings/usage/events'
54+
if (rest.length !== 1) return null
55+
if (section === 'general') {
56+
const view = GENERAL_VIEWS[query.get('view') ?? '']
57+
query.delete('view')
58+
return view ? `/settings/${view}` : '/settings'
59+
}
60+
const target = SETTINGS_ALIASES[section] ?? (SETTINGS_SECTIONS.has(section) ? section : null)
61+
return target && `/settings/${target}`
62+
}
63+
64+
function mapRoute(rest: readonly string[], query: URLSearchParams): string | null {
65+
const [head, detail] = rest
66+
if (head === undefined) return '/'
67+
if (head === 'settings') return mapSettingsRoute(rest.slice(1), query)
68+
if (rest.length === 1) {
69+
if (head === 'home' || head === 'people' || head === 'integrations') return `/${head}`
70+
if (head === 'links') return '/settings/links'
71+
return null
72+
}
73+
if (rest.length === 2 && (head === 'chat' || head === 'people')) return `/${head}/${detail}`
74+
return null
75+
}
76+
77+
/**
78+
* The Platform URL for a legacy URL under `/o/{organizationId}`, keeping its query, or `null` for a
79+
* route that stays legacy: Search, Benchmark, Skills, platform administration, and the Search and
80+
* credential pages the Integrations owner has not mapped. Null also covers another organization's
81+
* URL. The Platform must never link back to a mapped route, or the two surfaces would redirect to
82+
* each other.
83+
*/
84+
export function mapLegacyOrganizationUrl(url: string, organizationId: string): string | null {
85+
const queryStart = url.indexOf('?')
86+
const pathname = queryStart === -1 ? url : url.slice(0, queryStart)
87+
const query = new URLSearchParams(queryStart === -1 ? '' : url.slice(queryStart + 1))
88+
const [surface, organization, ...rest] = pathname.split('/').filter(Boolean)
89+
if (surface !== 'o' || organization !== organizationId) return null
90+
const route = mapRoute(rest, query)
91+
if (route === null) return null
92+
const search = query.toString()
93+
return `${PLATFORM_BASE_PATH}${route === '/' ? '' : route}${search ? `?${search}` : ''}`
94+
}

‎apps/sim/next.config.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from './lib/core/security/csp'
1212
import { LANDING_ROUTES } from './lib/landing/routes'
1313
import { LIBRARY_MERGED_SLUGS, LIBRARY_MOVED_BLOG_SLUGS } from './lib/library/retired-slugs'
14+
import { PLATFORM_BASE_PATH } from './lib/platform/routes'
1415

1516
/**
1617
* `Accept` header patterns for Markdown negotiation. `text/markdown` must be one of the listed
@@ -25,6 +26,12 @@ const MARKDOWN_REQUEST = {
2526
missing: [{ type: 'header' as const, key: 'accept', value: REFUSES_MARKDOWN }],
2627
}
2728

29+
/**
30+
* The Platform zone, proxied under one origin so its pages share the session cookie and `/api`.
31+
* Next bakes rewrites into the build, so the value must be present when the image is built.
32+
*/
33+
const PLATFORM_ORIGIN = env.SIM_PLATFORM_ORIGIN ? new URL(env.SIM_PLATFORM_ORIGIN).origin : null
34+
2835
const nextConfig: NextConfig = {
2936
devIndicators: false,
3037
poweredByHeader: false,
@@ -719,6 +726,14 @@ const nextConfig: NextConfig = {
719726
source: '/r/:shortCode',
720727
destination: 'https://go.trybeluga.ai/:shortCode',
721728
},
729+
...(PLATFORM_ORIGIN
730+
? [
731+
{
732+
source: `${PLATFORM_BASE_PATH}/:path*`,
733+
destination: `${PLATFORM_ORIGIN}${PLATFORM_BASE_PATH}/:path*`,
734+
},
735+
]
736+
: []),
722737
],
723738
/**
724739
* Fallback rewrites run only after every page and dynamic route has failed to match, so

‎apps/sim/platform/design-system/components/icon/provider-marks.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { IconName } from '@/platform/design-system/catalog/icon-geometry'
2+
import { publicAssetPath } from '@/platform/design-system/navigation'
23

34
const SHARED_MARKS = new Map<string, IconName>([
45
['gmail', 'I005'],
@@ -51,5 +52,5 @@ export function providerMark(providerId: string): IconName | { href: string } {
5152
const shared = SHARED_MARKS.get(providerId)
5253
if (shared) return shared
5354
if (!ASSET_MARKS.has(providerId)) return 'I070'
54-
return { href: `/provider-marks/${providerId.replaceAll('_', '-')}.svg#mark` }
55+
return { href: publicAssetPath(`/provider-marks/${providerId.replaceAll('_', '-')}.svg#mark`) }
5556
}

‎apps/sim/platform/design-system/navigation.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ export function platformHref(path: string, search: SearchValues) {
2020
return query.size ? `${path}?${query}` : path
2121
}
2222

23+
/** A file under `public/`: unlike `next/link` and the router, the browser adds no base path to it. */
24+
export function publicAssetPath(path: string) {
25+
return `${process.env.NEXT_PUBLIC_SIM_PLATFORM_BASE_PATH ?? ''}${path}`
26+
}
27+
2328
export function readScenario(value: string | string[] | null | undefined) {
2429
return value === 'empty' || value === 'stress' ? value : 'full'
2530
}

‎apps/sim/platform/next.config.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,51 @@
1+
import path from 'node:path'
12
import type { NextConfig } from 'next'
23

3-
const config: NextConfig = { devIndicators: false }
4+
/** Next bakes the base path into every route and asset URL, so it is fixed when the app is built. */
5+
function readBasePath(value: string | undefined): string | undefined {
6+
if (!value) return undefined
7+
if (!/^(?:\/[\w-]+)+$/.test(value)) {
8+
throw new Error(`SIM_PLATFORM_BASE_PATH must look like /platform, received "${value}"`)
9+
}
10+
return value
11+
}
12+
13+
const basePath = readBasePath(process.env.SIM_PLATFORM_BASE_PATH)
14+
const apiOrigin = process.env.SIM_PLATFORM_API_ORIGIN
15+
const emcnShim = path.join(import.meta.dirname, 'lib/data/emcn-shim.ts')
16+
17+
const config: NextConfig = {
18+
devIndicators: false,
19+
...(basePath && { basePath }),
20+
env: { NEXT_PUBLIC_SIM_PLATFORM_BASE_PATH: basePath ?? '' },
21+
/** Published as ESM that only compiles through this list, as in the main app's config. */
22+
transpilePackages: ['@t3-oss/env-nextjs', '@t3-oss/env-core'],
23+
/**
24+
* Without a base path nothing proxies this origin, so it has no same-origin API of its own.
25+
* Behind the zone rewrite the browser reaches `/api` on the main app directly.
26+
*/
27+
async rewrites() {
28+
return !basePath && apiOrigin
29+
? [
30+
{
31+
source: '/api/:path*',
32+
destination: `${apiOrigin}/api/:path*`,
33+
basePath: false as const,
34+
},
35+
]
36+
: []
37+
},
38+
webpack(webpackConfig) {
39+
/** No `$`, so `@sim/emcn/icons` resolves beneath the shim file and fails the build as missing. */
40+
webpackConfig.resolve.alias['@sim/emcn'] = emcnShim
41+
/** A named import the shim lacks is an error in our sources, not webpack's default warning. */
42+
webpackConfig.module.rules.push({
43+
test: /\.tsx?$/,
44+
exclude: /node_modules/,
45+
parser: { importExportsPresence: 'error' },
46+
})
47+
return webpackConfig
48+
},
49+
}
450

551
export default config

0 commit comments

Comments
 (0)