History

Add /connect/<slug> integration deep link

Resolves the slug against the tenant catalog and forwards into the integration detail page's existing add-connection flow. Unknown slugs get a not-found state. Makes the link survive sign-in: reserve connect as an org slug so cloud's callback stops reading it as an org selector, and return self-host's login to the requested path (its gate swaps the page in without ever writing a returnTo).

Rhys Sullivan committed 3 months ago

Commit 587b2cc1 parent 33f410b17 files · +659 −19Browse files

Unified · Split

17 files
  1. apps/cloud/src/routeTree.gen.ts +25 −0
  2. apps/host-cloudflare/web/routeTree.gen.ts +25 −0
  3. apps/host-selfhost/src/auth/return-to.test.ts +45 −1
  4. apps/host-selfhost/src/auth/return-to.ts +29 −0
  5. apps/host-selfhost/web/login.tsx +5 −10
  6. apps/host-selfhost/web/routeTree.gen.ts +25 −0
  7. e2e/scenarios/connect-deep-link.test.ts +168 −0
  8. packages/app/src/routeTree.gen.ts +23 −0
  9. packages/core/api/src/account/org-slug.test.ts +14 −1
  10. packages/core/api/src/account/org-slug.ts +3 −2
  11. packages/react/src/console-routes.ts +5 −0
  12. packages/react/src/pages/connect-integration.tsx +109 −0
  13. packages/react/src/pages/integration-detail.tsx +10 −4
  14. packages/react/src/routes/connect-deep-link.test.ts +121 −0
  15. packages/react/src/routes/connect.$integrationSlug.tsx +10 −0
  16. packages/react/src/routes/integrations.$namespace.tsx +20 −1
  17. packages/react/src/routes/routeTree.gen.ts +22 −0
apps/cloud/src/routeTree.gen.ts+25−0
@@ -23,6 +23,7 @@ import { Route as ApiKeysRouteImport } from './routes/app/api-keys' · 22 unmodified lines
23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
24import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'24import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'
25import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'25import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
26import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
26import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'27import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'
27import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'28import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'
28 29
@@ -107,6 +108,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 78 unmodified lines
107 getParentRoute: () => rootRouteImport,108 getParentRoute: () => rootRouteImport,
108 } as any,109 } as any,
109 )110 )
111const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute =
112 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update(
113 {
114 id: '/{-$orgSlug}/connect/$integrationSlug',
115 path: '/{-$orgSlug}/connect/$integrationSlug',
116 getParentRoute: () => rootRouteImport,
117 } as any,
118 )
110const Billing_DotplansRoute = Billing_DotplansRouteImport.update({119const Billing_DotplansRoute = Billing_DotplansRouteImport.update({
111 id: '/{-$orgSlug}/billing_/plans',120 id: '/{-$orgSlug}/billing_/plans',
112 path: '/{-$orgSlug}/billing/plans',121 path: '/{-$orgSlug}/billing/plans',
@@ -134,6 +143,7 @@ export interface FileRoutesByFullPath { · 21 unmodified lines
134 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute143 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
135 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute144 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
136 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute145 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
146 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
137 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute147 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
138 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute148 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
139 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute149 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -152,6 +162,7 @@ export interface FileRoutesByTo { · 12 unmodified lines
152 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute162 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
153 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute163 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
154 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute164 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
165 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
155 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute166 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
156 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute167 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
157 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute168 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -171,6 +182,7 @@ export interface FileRoutesById { · 13 unmodified lines
171 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute182 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
172 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute183 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
173 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute184 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute
185 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
174 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute186 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
175 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute187 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
176 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute188 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -191,6 +203,7 @@ export interface FileRouteTypes { · 14 unmodified lines
191 | '/{-$orgSlug}/tools'203 | '/{-$orgSlug}/tools'
192 | '/{-$orgSlug}/'204 | '/{-$orgSlug}/'
193 | '/{-$orgSlug}/billing/plans'205 | '/{-$orgSlug}/billing/plans'
206 | '/{-$orgSlug}/connect/$integrationSlug'
194 | '/{-$orgSlug}/integrations/$namespace'207 | '/{-$orgSlug}/integrations/$namespace'
195 | '/{-$orgSlug}/resume/$executionId'208 | '/{-$orgSlug}/resume/$executionId'
196 | '/{-$orgSlug}/toolkits/$toolkitSlug'209 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -209,6 +222,7 @@ export interface FileRouteTypes { · 12 unmodified lines
209 | '/{-$orgSlug}/tools'222 | '/{-$orgSlug}/tools'
210 | '/{-$orgSlug}'223 | '/{-$orgSlug}'
211 | '/{-$orgSlug}/billing/plans'224 | '/{-$orgSlug}/billing/plans'
225 | '/{-$orgSlug}/connect/$integrationSlug'
212 | '/{-$orgSlug}/integrations/$namespace'226 | '/{-$orgSlug}/integrations/$namespace'
213 | '/{-$orgSlug}/resume/$executionId'227 | '/{-$orgSlug}/resume/$executionId'
214 | '/{-$orgSlug}/toolkits/$toolkitSlug'228 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -227,6 +241,7 @@ export interface FileRouteTypes { · 12 unmodified lines
227 | '/{-$orgSlug}/tools'241 | '/{-$orgSlug}/tools'
228 | '/{-$orgSlug}/'242 | '/{-$orgSlug}/'
229 | '/{-$orgSlug}/billing_/plans'243 | '/{-$orgSlug}/billing_/plans'
244 | '/{-$orgSlug}/connect/$integrationSlug'
230 | '/{-$orgSlug}/integrations/$namespace'245 | '/{-$orgSlug}/integrations/$namespace'
231 | '/{-$orgSlug}/resume/$executionId'246 | '/{-$orgSlug}/resume/$executionId'
232 | '/{-$orgSlug}/toolkits/$toolkitSlug'247 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -246,6 +261,7 @@ export interface RootRouteChildren { · 13 unmodified lines
246 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute261 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
247 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute262 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
248 Billing_DotplansRoute: typeof Billing_DotplansRoute263 Billing_DotplansRoute: typeof Billing_DotplansRoute
264 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
249 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute265 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
250 ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute266 ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute
251 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute267 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -351,6 +367,13 @@ declare module '@tanstack/react-router' { · 99 unmodified lines
351 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport367 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport
352 parentRoute: typeof rootRouteImport368 parentRoute: typeof rootRouteImport
353 }369 }
370 '/{-$orgSlug}/connect/$integrationSlug': {
371 id: '/{-$orgSlug}/connect/$integrationSlug'
372 path: '/{-$orgSlug}/connect/$integrationSlug'
373 fullPath: '/{-$orgSlug}/connect/$integrationSlug'
374 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport
375 parentRoute: typeof rootRouteImport
376 }
354 '/{-$orgSlug}/billing_/plans': {377 '/{-$orgSlug}/billing_/plans': {
355 id: '/{-$orgSlug}/billing_/plans'378 id: '/{-$orgSlug}/billing_/plans'
356 path: '/{-$orgSlug}/billing/plans'379 path: '/{-$orgSlug}/billing/plans'
@@ -400,6 +423,8 @@ const rootRouteChildren: RootRouteChildren = { · 43 unmodified lines
400 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:423 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
401 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,424 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
402 Billing_DotplansRoute: Billing_DotplansRoute,425 Billing_DotplansRoute: Billing_DotplansRoute,
426 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute:
427 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
403 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:428 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
404 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,429 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
405 ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute,430 ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute,
apps/host-cloudflare/web/routeTree.gen.ts+25−0
@@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImp · 16 unmodified lines
17import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'17import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
18import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'18import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
19import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'19import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
20import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
20import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'21import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'
21import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'22import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'
22 23
@@ -75,6 +76,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 52 unmodified lines
75 getParentRoute: () => rootRouteImport,76 getParentRoute: () => rootRouteImport,
76 } as any,77 } as any,
77 )78 )
79const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute =
80 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update(
81 {
82 id: '/{-$orgSlug}/connect/$integrationSlug',
83 path: '/{-$orgSlug}/connect/$integrationSlug',
84 getParentRoute: () => rootRouteImport,
85 } as any,
86 )
78const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute =87const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute =
79 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update(88 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update(
80 {89 {
@@ -98,6 +107,7 @@ export interface FileRoutesByFullPath { · 17 unmodified lines
98 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren107 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
99 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute108 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
100 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute109 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
110 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
101 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute111 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
102 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute112 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
103 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute113 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -110,6 +120,7 @@ export interface FileRoutesByTo { · 6 unmodified lines
110 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren120 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
111 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute121 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
112 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute122 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
123 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
113 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute124 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
114 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute125 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
115 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute126 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -123,6 +134,7 @@ export interface FileRoutesById { · 7 unmodified lines
123 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren134 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
124 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute135 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
125 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute136 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
137 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
126 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute138 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
127 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute139 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
128 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute140 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -137,6 +149,7 @@ export interface FileRouteTypes { · 8 unmodified lines
137 | '/{-$orgSlug}/toolkits'149 | '/{-$orgSlug}/toolkits'
138 | '/{-$orgSlug}/tools'150 | '/{-$orgSlug}/tools'
139 | '/{-$orgSlug}/'151 | '/{-$orgSlug}/'
152 | '/{-$orgSlug}/connect/$integrationSlug'
140 | '/{-$orgSlug}/integrations/$namespace'153 | '/{-$orgSlug}/integrations/$namespace'
141 | '/{-$orgSlug}/resume/$executionId'154 | '/{-$orgSlug}/resume/$executionId'
142 | '/{-$orgSlug}/toolkits/$toolkitSlug'155 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -149,6 +162,7 @@ export interface FileRouteTypes { · 6 unmodified lines
149 | '/{-$orgSlug}/toolkits'162 | '/{-$orgSlug}/toolkits'
150 | '/{-$orgSlug}/tools'163 | '/{-$orgSlug}/tools'
151 | '/{-$orgSlug}'164 | '/{-$orgSlug}'
165 | '/{-$orgSlug}/connect/$integrationSlug'
152 | '/{-$orgSlug}/integrations/$namespace'166 | '/{-$orgSlug}/integrations/$namespace'
153 | '/{-$orgSlug}/resume/$executionId'167 | '/{-$orgSlug}/resume/$executionId'
154 | '/{-$orgSlug}/toolkits/$toolkitSlug'168 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -161,6 +175,7 @@ export interface FileRouteTypes { · 6 unmodified lines
161 | '/{-$orgSlug}/toolkits'175 | '/{-$orgSlug}/toolkits'
162 | '/{-$orgSlug}/tools'176 | '/{-$orgSlug}/tools'
163 | '/{-$orgSlug}/'177 | '/{-$orgSlug}/'
178 | '/{-$orgSlug}/connect/$integrationSlug'
164 | '/{-$orgSlug}/integrations/$namespace'179 | '/{-$orgSlug}/integrations/$namespace'
165 | '/{-$orgSlug}/resume/$executionId'180 | '/{-$orgSlug}/resume/$executionId'
166 | '/{-$orgSlug}/toolkits/$toolkitSlug'181 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -174,6 +189,7 @@ export interface RootRouteChildren { · 7 unmodified lines
174 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren189 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
175 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute190 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
176 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute191 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
192 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
177 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute193 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
178 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute194 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
179 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute195 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -238,6 +254,13 @@ declare module '@tanstack/react-router' { · 58 unmodified lines
238 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport254 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport
239 parentRoute: typeof rootRouteImport255 parentRoute: typeof rootRouteImport
240 }256 }
257 '/{-$orgSlug}/connect/$integrationSlug': {
258 id: '/{-$orgSlug}/connect/$integrationSlug'
259 path: '/{-$orgSlug}/connect/$integrationSlug'
260 fullPath: '/{-$orgSlug}/connect/$integrationSlug'
261 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport
262 parentRoute: typeof rootRouteImport
263 }
241 '/{-$orgSlug}/plugins/$pluginId/$': {264 '/{-$orgSlug}/plugins/$pluginId/$': {
242 id: '/{-$orgSlug}/plugins/$pluginId/$'265 id: '/{-$orgSlug}/plugins/$pluginId/$'
243 path: '/{-$orgSlug}/plugins/$pluginId/$'266 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -281,6 +304,8 @@ const rootRouteChildren: RootRouteChildren = { · 37 unmodified lines
281 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,304 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
282 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:305 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
283 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,306 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
307 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute:
308 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
284 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:309 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
285 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,310 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
286 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:311 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:
apps/host-selfhost/src/auth/return-to.test.ts+45−1
@@ -1,6 +1,12 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import { isSafeReturnTo, loginPath, mcpAuthorizeResumeTarget, safeReturnTo } from "./return-to";3import {
4 isSafeReturnTo,
5 loginPath,
6 mcpAuthorizeResumeTarget,
7 postLoginTarget,
8 safeReturnTo,
9} from "./return-to";
4 10
5describe("isSafeReturnTo", () => {11describe("isSafeReturnTo", () => {
6 const safe = [12 const safe = [
@@ -73,6 +79,44 @@ describe("mcpAuthorizeResumeTarget", () => { · 66 unmodified lines
73 });79 });
74});80});
75 81
82describe("postLoginTarget", () => {
83 // Self-host renders the login page IN PLACE of the requested route without
84 // navigating, so the live location is the only record of where the person was
85 // headed. A connect deep link must survive sign-in on that basis alone.
86 it("returns to the deep link the person actually opened", () => {
87 expect(postLoginTarget({ pathname: "/connect/linear", search: "" })).toBe("/connect/linear");
88 expect(postLoginTarget({ pathname: "/integrations/sentry", search: "?addAccount=1" })).toBe(
89 "/integrations/sentry?addAccount=1",
90 );
91 });
92
93 it("lands on the dashboard when signing in from the bare login page", () => {
94 // No deep link to resume, and echoing /login back would re-render the form.
95 expect(postLoginTarget({ pathname: "/login", search: "" })).toBe("/");
96 });
97
98 it("prefers an explicit returnTo over the current location", () => {
99 expect(postLoginTarget({ pathname: "/login", search: "?returnTo=%2Fconnect%2Flinear" })).toBe(
100 "/connect/linear",
101 );
102 });
103
104 it("prefers an MCP authorize resume over everything else", () => {
105 const target = postLoginTarget({
106 pathname: "/login",
107 search:
108 "?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback",
109 });
110 expect(target.startsWith("/api/auth/mcp/authorize?")).toBe(true);
111 });
112
113 it("never honors an unsafe location", () => {
114 // `safeReturnTo` vets the echoed path too — an app-plane path is not a
115 // place to land a freshly signed-in browser.
116 expect(postLoginTarget({ pathname: "/api/auth/logout", search: "" })).toBe("/");
117 });
118});
119
76describe("loginPath", () => {120describe("loginPath", () => {
77 it("omits returnTo for the root", () => {121 it("omits returnTo for the root", () => {
78 expect(loginPath("/")).toBe("/login");122 expect(loginPath("/")).toBe("/login");
apps/host-selfhost/src/auth/return-to.ts+29−0
@@ -32,3 +32,32 @@ export const mcpAuthorizeResumeTarget = (search: string): string | null => { · 31 unmodified lines
32 if (!params.get("client_id") || !params.get("redirect_uri")) return null;32 if (!params.get("client_id") || !params.get("redirect_uri")) return null;
33 return `${MCP_AUTHORIZE_PATH}?${params.toString()}`;33 return `${MCP_AUTHORIZE_PATH}?${params.toString()}`;
34};34};
35
36const LOGIN_PATH = "/login";
37
38/**
39 * Where the self-host login page sends someone after a successful sign-in, in
40 * priority order:
41 *
42 * 1. an interrupted MCP OAuth authorize (the params ARE the request),
43 * 2. an explicit safe `returnTo` (e.g. the integration OAuth callback),
44 * 3. the URL they actually opened, and
45 * 4. the dashboard.
46 *
47 * Step 3 is what makes a deep link like `/connect/linear` survive sign-in here.
48 * Unlike cloud — which redirects to `/login?returnTo=…` — self-host's gate
49 * swaps the login page in WITHOUT navigating, so the address bar still holds
50 * the requested URL and no `returnTo` was ever written. `/login` itself is
51 * excluded so signing in from the bare login page lands on the dashboard rather
52 * than looping back to the form.
53 */
54export const postLoginTarget = (location: {
55 readonly pathname: string;
56 readonly search: string;
57}): string =>
58 mcpAuthorizeResumeTarget(location.search) ??
59 safeReturnTo(new URLSearchParams(location.search).get("returnTo")) ??
60 (location.pathname === LOGIN_PATH
61 ? null
62 : safeReturnTo(`${location.pathname}${location.search}`)) ??
63 "/";
apps/host-selfhost/web/login.tsx+5−10
@@ -6,7 +6,7 @@ import { Label } from "@executor-js/react/components/label"; · 5 unmodified lines
6 6
7import { authClient } from "./auth-client";7import { authClient } from "./auth-client";
8import { AuthLayout } from "./auth-layout";8import { AuthLayout } from "./auth-layout";
9import { mcpAuthorizeResumeTarget, safeReturnTo } from "../src/auth/return-to";9import { postLoginTarget } from "../src/auth/return-to";
10 10
11// Self-host login: email + password sign-in via Better Auth. On success we11// Self-host login: email + password sign-in via Better Auth. On success we
12// reload so the shared AuthProvider re-reads /account/me and the AuthGate swaps12// reload so the shared AuthProvider re-reads /account/me and the AuthGate swaps
@@ -17,15 +17,10 @@ import { mcpAuthorizeResumeTarget, safeReturnTo } from "../src/auth/return-to"; · 4 unmodified lines
17// redeeming an invite — either the full /join/<code> link, or by entering the17// redeeming an invite — either the full /join/<code> link, or by entering the
18// code here ("Have an invite code?"), which forwards to the same join page.18// code here ("Have an invite code?"), which forwards to the same join page.
19export const LoginPage = () => {19export const LoginPage = () => {
20 const search = window.location.search;20 // Where to go after sign-in. The gate renders this page IN PLACE of the
21 // Where to go after sign-in: resume an interrupted MCP OAuth authorize if we21 // requested route without navigating, so the live location is what carries a
22 // arrived from one (Better Auth redirects it here with the OAuth params),22 // deep link (`/connect/linear`) across sign-in — see `postLoginTarget`.
23 // otherwise honor a safe returnTo (e.g. an integration OAuth callback), else23 const postLogin = postLoginTarget(window.location);
24 // land on the dashboard.
25 const postLogin =
26 mcpAuthorizeResumeTarget(search) ??
27 safeReturnTo(new URLSearchParams(search).get("returnTo")) ??
28 "/";
29 const [mode, setMode] = useState<"signin" | "code">("signin");24 const [mode, setMode] = useState<"signin" | "code">("signin");
30 const [email, setEmail] = useState("");25 const [email, setEmail] = useState("");
31 const [password, setPassword] = useState("");26 const [password, setPassword] = useState("");
apps/host-selfhost/web/routeTree.gen.ts+25−0
@@ -20,6 +20,7 @@ import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' · 19 unmodified lines
20import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'20import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
21import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'21import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
22import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'22import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'24import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'
24import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'25import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'
25 26
@@ -93,6 +94,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 67 unmodified lines
93 getParentRoute: () => rootRouteImport,94 getParentRoute: () => rootRouteImport,
94 } as any,95 } as any,
95 )96 )
97const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute =
98 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update(
99 {
100 id: '/{-$orgSlug}/connect/$integrationSlug',
101 path: '/{-$orgSlug}/connect/$integrationSlug',
102 getParentRoute: () => rootRouteImport,
103 } as any,
104 )
96const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute =105const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute =
97 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update(106 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update(
98 {107 {
@@ -119,6 +128,7 @@ export interface FileRoutesByFullPath { · 20 unmodified lines
119 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren128 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
120 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute129 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
121 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute130 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
131 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
122 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute132 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
123 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute133 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
124 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute134 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -134,6 +144,7 @@ export interface FileRoutesByTo { · 9 unmodified lines
134 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren144 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
135 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute145 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
136 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute146 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
147 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
137 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute148 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
138 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute149 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
139 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute150 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -150,6 +161,7 @@ export interface FileRoutesById { · 10 unmodified lines
150 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren161 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
151 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute162 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
152 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute163 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
164 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
153 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute165 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
154 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute166 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
155 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute167 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -167,6 +179,7 @@ export interface FileRouteTypes { · 11 unmodified lines
167 | '/{-$orgSlug}/toolkits'179 | '/{-$orgSlug}/toolkits'
168 | '/{-$orgSlug}/tools'180 | '/{-$orgSlug}/tools'
169 | '/{-$orgSlug}/'181 | '/{-$orgSlug}/'
182 | '/{-$orgSlug}/connect/$integrationSlug'
170 | '/{-$orgSlug}/integrations/$namespace'183 | '/{-$orgSlug}/integrations/$namespace'
171 | '/{-$orgSlug}/resume/$executionId'184 | '/{-$orgSlug}/resume/$executionId'
172 | '/{-$orgSlug}/toolkits/$toolkitSlug'185 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -182,6 +195,7 @@ export interface FileRouteTypes { · 9 unmodified lines
182 | '/{-$orgSlug}/toolkits'195 | '/{-$orgSlug}/toolkits'
183 | '/{-$orgSlug}/tools'196 | '/{-$orgSlug}/tools'
184 | '/{-$orgSlug}'197 | '/{-$orgSlug}'
198 | '/{-$orgSlug}/connect/$integrationSlug'
185 | '/{-$orgSlug}/integrations/$namespace'199 | '/{-$orgSlug}/integrations/$namespace'
186 | '/{-$orgSlug}/resume/$executionId'200 | '/{-$orgSlug}/resume/$executionId'
187 | '/{-$orgSlug}/toolkits/$toolkitSlug'201 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -197,6 +211,7 @@ export interface FileRouteTypes { · 9 unmodified lines
197 | '/{-$orgSlug}/toolkits'211 | '/{-$orgSlug}/toolkits'
198 | '/{-$orgSlug}/tools'212 | '/{-$orgSlug}/tools'
199 | '/{-$orgSlug}/'213 | '/{-$orgSlug}/'
214 | '/{-$orgSlug}/connect/$integrationSlug'
200 | '/{-$orgSlug}/integrations/$namespace'215 | '/{-$orgSlug}/integrations/$namespace'
201 | '/{-$orgSlug}/resume/$executionId'216 | '/{-$orgSlug}/resume/$executionId'
202 | '/{-$orgSlug}/toolkits/$toolkitSlug'217 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -213,6 +228,7 @@ export interface RootRouteChildren { · 10 unmodified lines
213 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren228 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
214 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute229 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
215 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute230 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
231 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
216 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute232 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
217 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute233 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
218 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute234 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -298,6 +314,13 @@ declare module '@tanstack/react-router' { · 79 unmodified lines
298 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport314 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport
299 parentRoute: typeof rootRouteImport315 parentRoute: typeof rootRouteImport
300 }316 }
317 '/{-$orgSlug}/connect/$integrationSlug': {
318 id: '/{-$orgSlug}/connect/$integrationSlug'
319 path: '/{-$orgSlug}/connect/$integrationSlug'
320 fullPath: '/{-$orgSlug}/connect/$integrationSlug'
321 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport
322 parentRoute: typeof rootRouteImport
323 }
301 '/{-$orgSlug}/plugins/$pluginId/$': {324 '/{-$orgSlug}/plugins/$pluginId/$': {
302 id: '/{-$orgSlug}/plugins/$pluginId/$'325 id: '/{-$orgSlug}/plugins/$pluginId/$'
303 path: '/{-$orgSlug}/plugins/$pluginId/$'326 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -344,6 +367,8 @@ const rootRouteChildren: RootRouteChildren = { · 40 unmodified lines
344 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,367 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
345 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:368 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
346 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,369 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
370 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute:
371 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
347 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:372 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
348 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,373 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
349 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:374 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:
e2e/scenarios/connect-deep-link.test.ts+168−0
@@ -0,0 +1,168 @@
1// The connect deep link: a customer sends an end user a direct link for ONE
2// integration ("connect your Linear") instead of pointing them at the dashboard
3// to hunt for it. `/connect/<integration slug>` resolves the slug against the
4// tenant's catalog and lands the person in the SAME connect flow the
5// integration detail page's own button opens.
6//
7// The slug — not a plugin id — is the stable identity here: it is what a
8// customer can write down and template into an email.
9//
10// Covered:
11// 1. Signed in, known slug → the Add connection flow is open on the right
12// integration, with the auth methods the spec declares
13// 2. Unknown slug → a clear not-found state, not a blank page or a
14// crash (an end user WILL mistype these, and links outlive integrations)
15// 3. Already connected → the deep link shows the existing connection and
16// still offers to add another account
17import { randomBytes, randomUUID } from "node:crypto";
18
19import { expect } from "@effect/vitest";
20import { Effect } from "effect";
21import { AccountHttpApi } from "@executor-js/api";
22import { composePluginApi } from "@executor-js/api/server";
23import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
24import {
25 AuthTemplateSlug,
26 ConnectionName,
27 IntegrationSlug,
28 ProviderItemId,
29} from "@executor-js/sdk/shared";
30
31import { scenario } from "../src/scenario";
32import { Api, Browser, Target } from "../src/services";
33
34const api = composePluginApi([openApiHttpPlugin()] as const);
35
36/** OpenAPI 3 spec with a single operation — enough to be a real catalog entry. */
37const greetSpec = (baseUrl: string): string =>
38 JSON.stringify({
39 openapi: "3.0.3",
40 info: { title: "Deep Link API", version: "1.0.0" },
41 servers: [{ url: baseUrl }],
42 paths: {
43 "/greet": {
44 get: {
45 operationId: "getGreeting",
46 summary: "Return a greeting message",
47 responses: { "200": { description: "A greeting" } },
48 },
49 },
50 },
51 });
52
53scenario(
54 "Connect · /connect/<slug> lands an end user in that integration's connect flow",
55 { timeout: 180_000 },
56 Effect.gen(function* () {
57 const target = yield* Target;
58 const browser = yield* Browser;
59 const { client } = yield* Api;
60
61 const identity = yield* target.newIdentity();
62 const apiClient = yield* client(api, identity);
63
64 // Selfhost shares one bootstrap-admin identity, so a unique slug keeps
65 // repeated and parallel runs out of each other's catalogs.
66 const slug = `connect-dl-${randomBytes(4).toString("hex")}`;
67 const specBaseUrl = "http://127.0.0.1:59999"; // never contacted here
68
69 // Org-scoped hosts canonicalize console URLs onto the active org's slug, so
70 // the deep link may legitimately arrive at either `/connect/x` or
71 // `/<org>/connect/x`. Read the slug the shell will canonicalize onto.
72 const accountClient = yield* client(AccountHttpApi, identity);
73 const me = yield* accountClient.account.me();
74 const orgSlug = me.organization?.slug ?? null;
75 const detailPath = orgSlug ? `/${orgSlug}/integrations/${slug}` : `/integrations/${slug}`;
76
77 yield* Effect.ensuring(
78 Effect.gen(function* () {
79 const added = yield* apiClient.openapi.addSpec({
80 payload: {
81 spec: { kind: "blob", value: greetSpec(specBaseUrl) },
82 slug,
83 name: "Deep Link API",
84 baseUrl: specBaseUrl,
85 authenticationTemplate: [
86 {
87 slug: "apiKey",
88 type: "apiKey",
89 headers: { "x-api-key": [{ type: "variable", name: "token" }] },
90 },
91 ],
92 },
93 });
94 expect(added.slug, "the integration keeps the requested slug").toBe(slug);
95
96 yield* browser.session(identity, async ({ page, step }) => {
97 await step("Open the connect deep link for the seeded integration", async () => {
98 await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" });
99 // It forwards into the integration's OWN detail route rather than
100 // rendering a parallel connect UI, carrying the add-account handoff.
101 await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 });
102 expect(new URL(page.url()).searchParams.get("addAccount")).toBe("1");
103 });
104
105 await step("The Add connection flow is open for that integration", async () => {
106 await page
107 .getByRole("heading", { name: /Add connection/ })
108 .waitFor({ timeout: 20_000 });
109 // The right integration, and the declared auth method is offered —
110 // proof it resolved the slug rather than opening a generic dialog.
111 await page
112 .getByRole("dialog")
113 .getByRole("textbox", { name: "x-api-key" })
114 .waitFor({ timeout: 20_000 });
115 });
116
117 await step("An unknown slug is a clear not-found, not a blank page", async () => {
118 await page.goto("/connect/zz-no-such-integration", { waitUntil: "networkidle" });
119 await page
120 .getByText("Unknown integration: zz-no-such-integration")
121 .waitFor({ timeout: 30_000 });
122 await page.getByRole("link", { name: "Back to integrations" }).waitFor();
123 });
124 });
125
126 // Already connected: the deep link must show the existing connection
127 // and still let the person add another account.
128 const providers = yield* apiClient.providers.list();
129 expect(providers.length, "a credential provider is available").toBeGreaterThan(0);
130 yield* apiClient.connections.create({
131 payload: {
132 owner: "org",
133 name: ConnectionName.make("existing"),
134 integration: IntegrationSlug.make(slug),
135 template: AuthTemplateSlug.make("apiKey"),
136 from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) },
137 },
138 });
139
140 yield* browser.session(identity, async ({ page, step }) => {
141 await step("The deep link surfaces the existing connection", async () => {
142 await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" });
143 await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 });
144 // The connect flow still opens (this link means "connect me"), and
145 // the already-saved account is visible behind it.
146 await page
147 .getByRole("heading", { name: /Add connection/ })
148 .waitFor({ timeout: 20_000 });
149 await page.keyboard.press("Escape");
150 await page.getByText("existing").first().waitFor({ timeout: 20_000 });
151 });
152 });
153 }),
154 Effect.gen(function* () {
155 yield* apiClient.connections
156 .remove({
157 params: {
158 owner: "org",
159 integration: IntegrationSlug.make(slug),
160 name: ConnectionName.make("existing"),
161 },
162 })
163 .pipe(Effect.ignore);
164 yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
165 }).pipe(Effect.ignore),
166 );
167 }),
168);
packages/app/src/routeTree.gen.ts+23−0
@@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './ · 16 unmodified lines
17import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug'17import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug'
18import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId'18import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId'
19import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace'19import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace'
20import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug'
20import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$'21import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$'
21import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../react/src/routes/integrations.add.$pluginKey'22import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../react/src/routes/integrations.add.$pluginKey'
22 23
@@ -67,6 +68,12 @@ const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute = · 44 unmodified lines
67 path: '/{-$orgSlug}/integrations/$namespace',68 path: '/{-$orgSlug}/integrations/$namespace',
68 getParentRoute: () => rootRouteImport,69 getParentRoute: () => rootRouteImport,
69 } as any)70 } as any)
71const DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute =
72 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport.update({
73 id: '/{-$orgSlug}/connect/$integrationSlug',
74 path: '/{-$orgSlug}/connect/$integrationSlug',
75 getParentRoute: () => rootRouteImport,
76 } as any)
70const DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute =77const DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute =
71 DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update({78 DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update({
72 id: '/{-$orgSlug}/plugins/$pluginId/$',79 id: '/{-$orgSlug}/plugins/$pluginId/$',
@@ -88,6 +95,7 @@ export interface FileRoutesByFullPath { · 15 unmodified lines
88 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren95 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
89 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute96 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
90 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute97 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
98 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
91 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute99 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
92 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute100 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
93 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute101 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -100,6 +108,7 @@ export interface FileRoutesByTo { · 6 unmodified lines
100 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren108 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
101 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute109 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
102 '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute110 '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
111 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
103 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute112 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
104 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute113 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
105 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute114 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -113,6 +122,7 @@ export interface FileRoutesById { · 7 unmodified lines
113 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren122 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
114 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute123 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
115 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute124 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
125 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
116 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute126 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
117 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute127 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
118 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute128 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -127,6 +137,7 @@ export interface FileRouteTypes { · 8 unmodified lines
127 | '/{-$orgSlug}/toolkits'137 | '/{-$orgSlug}/toolkits'
128 | '/{-$orgSlug}/tools'138 | '/{-$orgSlug}/tools'
129 | '/{-$orgSlug}/'139 | '/{-$orgSlug}/'
140 | '/{-$orgSlug}/connect/$integrationSlug'
130 | '/{-$orgSlug}/integrations/$namespace'141 | '/{-$orgSlug}/integrations/$namespace'
131 | '/{-$orgSlug}/resume/$executionId'142 | '/{-$orgSlug}/resume/$executionId'
132 | '/{-$orgSlug}/toolkits/$toolkitSlug'143 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -139,6 +150,7 @@ export interface FileRouteTypes { · 6 unmodified lines
139 | '/{-$orgSlug}/toolkits'150 | '/{-$orgSlug}/toolkits'
140 | '/{-$orgSlug}/tools'151 | '/{-$orgSlug}/tools'
141 | '/{-$orgSlug}'152 | '/{-$orgSlug}'
153 | '/{-$orgSlug}/connect/$integrationSlug'
142 | '/{-$orgSlug}/integrations/$namespace'154 | '/{-$orgSlug}/integrations/$namespace'
143 | '/{-$orgSlug}/resume/$executionId'155 | '/{-$orgSlug}/resume/$executionId'
144 | '/{-$orgSlug}/toolkits/$toolkitSlug'156 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -151,6 +163,7 @@ export interface FileRouteTypes { · 6 unmodified lines
151 | '/{-$orgSlug}/toolkits'163 | '/{-$orgSlug}/toolkits'
152 | '/{-$orgSlug}/tools'164 | '/{-$orgSlug}/tools'
153 | '/{-$orgSlug}/'165 | '/{-$orgSlug}/'
166 | '/{-$orgSlug}/connect/$integrationSlug'
154 | '/{-$orgSlug}/integrations/$namespace'167 | '/{-$orgSlug}/integrations/$namespace'
155 | '/{-$orgSlug}/resume/$executionId'168 | '/{-$orgSlug}/resume/$executionId'
156 | '/{-$orgSlug}/toolkits/$toolkitSlug'169 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -164,6 +177,7 @@ export interface RootRouteChildren { · 7 unmodified lines
164 DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren177 DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
165 DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute178 DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
166 DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute179 DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
180 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
167 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute181 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
168 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute182 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
169 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute183 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -228,6 +242,13 @@ declare module '@tanstack/react-router' { · 58 unmodified lines
228 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport242 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport
229 parentRoute: typeof rootRouteImport243 parentRoute: typeof rootRouteImport
230 }244 }
245 '/{-$orgSlug}/connect/$integrationSlug': {
246 id: '/{-$orgSlug}/connect/$integrationSlug'
247 path: '/{-$orgSlug}/connect/$integrationSlug'
248 fullPath: '/{-$orgSlug}/connect/$integrationSlug'
249 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport
250 parentRoute: typeof rootRouteImport
251 }
231 '/{-$orgSlug}/plugins/$pluginId/$': {252 '/{-$orgSlug}/plugins/$pluginId/$': {
232 id: '/{-$orgSlug}/plugins/$pluginId/$'253 id: '/{-$orgSlug}/plugins/$pluginId/$'
233 path: '/{-$orgSlug}/plugins/$pluginId/$'254 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -270,6 +291,8 @@ const rootRouteChildren: RootRouteChildren = { · 36 unmodified lines
270 DotDotDotDotDotDotReactSrcRoutesToolsRoute,291 DotDotDotDotDotDotReactSrcRoutesToolsRoute,
271 DotDotDotDotDotDotReactSrcRoutesIndexRoute:292 DotDotDotDotDotDotReactSrcRoutesIndexRoute:
272 DotDotDotDotDotDotReactSrcRoutesIndexRoute,293 DotDotDotDotDotDotReactSrcRoutesIndexRoute,
294 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute:
295 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute,
273 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute:296 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute:
274 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute,297 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute,
275 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute:298 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute:
packages/core/api/src/account/org-slug.test.ts+14−1
@@ -57,7 +57,20 @@ describe("isValidOrgSlug", () => { · 56 unmodified lines
57 });57 });
58 58
59 it("reserves the segments routing depends on", () => {59 it("reserves the segments routing depends on", () => {
60 for (const critical of ["api", "mcp", "integrations", "policies", "login", "cdn-cgi"]) {60 for (const critical of [
61 "api",
62 "mcp",
63 "integrations",
64 "policies",
65 "login",
66 "cdn-cgi",
67 // The connect deep link's root. Cloud's post-auth callback reads the
68 // first path segment as an org selector, so leaving `connect` claimable
69 // made `/connect/<slug>` look like a request for an org named "connect"
70 // — which suppressed the active-membership fallback and could drop the
71 // deep link into org onboarding after sign-in.
72 "connect",
73 ]) {
61 expect(RESERVED_ORG_SLUGS.has(critical), critical).toBe(true);74 expect(RESERVED_ORG_SLUGS.has(critical), critical).toBe(true);
62 }75 }
63 });76 });
packages/core/api/src/account/org-slug.ts+3−2
@@ -17,8 +17,8 @@ const ORG_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,47}$/; · 16 unmodified lines
17 * predictably will):17 * predictably will):
18 * - App planes: api, mcp, .well-known (cloud `app-paths.ts`, selfhost18 * - App planes: api, mcp, .well-known (cloud `app-paths.ts`, selfhost
19 * envelope, cloudflare `run_worker_first`)19 * envelope, cloudflare `run_worker_first`)
20 * - Console routes: integrations, policies, secrets, tools, resume,20 * - Console routes: connect, integrations, policies, secrets, tools,
21 * plugins (the shared contract), plus host extras:21 * resume, plugins (the shared contract), plus host extras:
22 * api-keys, org, billing, create-org, setup-mcp (cloud),22 * api-keys, org, billing, create-org, setup-mcp (cloud),
23 * admin, join, docs (selfhost), login (Better Auth23 * admin, join, docs (selfhost), login (Better Auth
24 * `mcp({ loginPage })` + cloud/selfhost login UX)24 * `mcp({ loginPage })` + cloud/selfhost login UX)
@@ -41,6 +41,7 @@ export const RESERVED_ORG_SLUGS: ReadonlySet<string> = new Set([ · 16 unmodified lines
41 "mcp",41 "mcp",
42 ".well-known",42 ".well-known",
43 // console + host routes43 // console + host routes
44 "connect",
44 "integrations",45 "integrations",
45 "policies",46 "policies",
46 "secrets",47 "secrets",
packages/react/src/console-routes.ts+5−0
@@ -34,6 +34,7 @@ export const ORG_SLUG_SEGMENT = "{-$orgSlug}"; · 33 unmodified lines
34 * are these paths prefixed with `/{-$orgSlug}`. */34 * are these paths prefixed with `/{-$orgSlug}`. */
35export const CONSOLE_ROUTE_PATHS = [35export const CONSOLE_ROUTE_PATHS = [
36 "/",36 "/",
37 "/connect/$integrationSlug",
37 "/integrations/$namespace",38 "/integrations/$namespace",
38 "/integrations/add/$pluginKey",39 "/integrations/add/$pluginKey",
39 "/policies",40 "/policies",
@@ -62,6 +63,10 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 22 unmodified lines
62 const file = (name: string): string => `${options.dir}/${name}`;63 const file = (name: string): string => `${options.dir}/${name}`;
63 const entries: ReadonlyArray<readonly [ConsoleRoutePath, VirtualRouteNode]> = [64 const entries: ReadonlyArray<readonly [ConsoleRoutePath, VirtualRouteNode]> = [
64 ["/", index(file("index.tsx"))],65 ["/", index(file("index.tsx"))],
66 [
67 "/connect/$integrationSlug",
68 route("/connect/$integrationSlug", file("connect.$integrationSlug.tsx")),
69 ],
65 [70 [
66 "/integrations/$namespace",71 "/integrations/$namespace",
67 route("/integrations/$namespace", file("integrations.$namespace.tsx")),72 route("/integrations/$namespace", file("integrations.$namespace.tsx")),
packages/react/src/pages/connect-integration.tsx+109−0
@@ -0,0 +1,109 @@
1import { useEffect } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";
3import { useAtomValue, useAtomRefresh } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import { IntegrationSlug } from "@executor-js/sdk/shared";
6
7import { integrationAtom, integrationsOptimisticAtom } from "../api/atoms";
8import { ErrorState } from "../components/error-state";
9import { Skeleton } from "../components/skeleton";
10import { isAsyncResultLoading } from "../lib/async-result";
11import { useExecutorDocumentTitle } from "../lib/document-title";
12
13// ---------------------------------------------------------------------------
14// `/connect/<integration slug>` — the stable, shareable deep link that drops a
15// person straight into the connect flow for ONE integration ("connect your
16// Linear") instead of the dashboard.
17//
18// This page owns no connect UI of its own. It resolves the slug against the
19// tenant's catalog and forwards into the integration detail page's existing
20// account handoff (`?addAccount=1`), which is the same surface the detail
21// page's own "Add connection" button opens — multi-method tab strips, OAuth
22// popups, existing-connection lists and "add another account" all come for
23// free, and stay correct as that flow evolves.
24//
25// Deliberately keyed on the INTEGRATION SLUG, not a plugin id: the slug is the
26// catalog identity a customer can write down, whereas plugin routes are on
27// their way out. The redirect is `replace: true` so the deep link does not
28// linger in history and Back leaves the connect flow rather than bouncing.
29// ---------------------------------------------------------------------------
30
31export function ConnectIntegrationPage(props: { readonly integrationSlug: string }) {
32 const { integrationSlug } = props;
33 const slug = IntegrationSlug.make(integrationSlug);
34 const integration = useAtomValue(integrationAtom(slug));
35 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
36 const navigate = useNavigate();
37
38 useExecutorDocumentTitle("Connect");
39
40 // The catalog read is a client-side find over the integrations list, so an
41 // unknown slug resolves to Success(null) rather than a failure — the missing
42 // case is a not-found state, and only a genuine fetch failure is an error.
43 const resolved = AsyncResult.isSuccess(integration) ? integration.value : null;
44 const loading = isAsyncResultLoading(integration);
45 const failed = AsyncResult.isFailure(integration);
46 const missing = !loading && !failed && resolved === null;
47
48 useEffect(() => {
49 if (resolved === null) return;
50 void navigate({
51 to: "/{-$orgSlug}/integrations/$namespace",
52 params: { namespace: String(resolved.slug) },
53 // The NUMBER 1, so the router serializes the canonical `?addAccount=1`
54 // that agent-generated handoff links already use (a string would be
55 // quoted as `%221%22`).
56 search: { tab: "accounts", addAccount: 1 },
57 replace: true,
58 });
59 }, [resolved, navigate]);
60
61 if (failed) {
62 return (
63 <ConnectStateFrame>
64 <ErrorState message="Failed to load integrations" onRetry={refreshIntegrations} />
65 </ConnectStateFrame>
66 );
67 }
68
69 if (missing) {
70 return (
71 <ConnectStateFrame>
72 <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20">
73 <p className="mb-1 text-sm font-medium text-foreground/70">
74 Unknown integration: {integrationSlug}
75 </p>
76 <p className="mb-5 text-xs text-muted-foreground">
77 This integration is not in your workspace.
78 </p>
79 <Link
80 to="/{-$orgSlug}"
81 className="inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
82 >
83 Back to integrations
84 </Link>
85 </div>
86 </ConnectStateFrame>
87 );
88 }
89
90 // Loading, or resolved and mid-redirect: the same quiet placeholder, so the
91 // hand-off never flashes an empty frame between catalog load and navigation.
92 return (
93 <ConnectStateFrame>
94 <div className="space-y-3">
95 <Skeleton className="h-8 w-56" />
96 <Skeleton className="h-4 w-80" />
97 <Skeleton className="h-32 w-full" />
98 </div>
99 </ConnectStateFrame>
100 );
101}
102
103function ConnectStateFrame(props: { readonly children: React.ReactNode }) {
104 return (
105 <div className="relative min-h-0 flex-1 overflow-y-auto">
106 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">{props.children}</div>
107 </div>
108 );
109}
packages/react/src/pages/integration-detail.tsx+10−4
@@ -60,6 +60,10 @@ type ToolRow = { · 59 unmodified lines
60export function IntegrationDetailPage(props: {60export function IntegrationDetailPage(props: {
61 namespace: string;61 namespace: string;
62 tab?: IntegrationDetailSearchTab;62 tab?: IntegrationDetailSearchTab;
63 /** Route-validated `addAccount=1`: open the add-connection flow on arrival.
64 * Set by the `/connect/<slug>` deep link, which navigates client-side and so
65 * cannot rely on the raw location search below. */
66 addAccount?: boolean;
63}) {67}) {
64 const { namespace } = props;68 const { namespace } = props;
65 const slug = IntegrationSlug.make(namespace);69 const slug = IntegrationSlug.make(namespace);
@@ -122,9 +126,11 @@ export function IntegrationDetailPage(props: { · 56 unmodified lines
122 const canRefresh = integrationData?.canRefresh ?? false;126 const canRefresh = integrationData?.canRefresh ?? false;
123 const canRemove = integrationData?.canRemove ?? false;127 const canRemove = integrationData?.canRemove ?? false;
124 const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => {128 const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => {
125 if (locationSearch.length === 0) return null;
126 const search = new URLSearchParams(locationSearch);129 const search = new URLSearchParams(locationSearch);
127 if (search.get("addAccount") !== "1") return null;130 // The route-validated flag and the raw `addAccount=1` are the same request;
131 // either one opens the flow. The extra prefill fields below are read from
132 // the raw search, which is populated on the full page loads that carry them.
133 if (!props.addAccount && search.get("addAccount") !== "1") return null;
128 const owner = search.get("owner");134 const owner = search.get("owner");
129 const template = search.get("template");135 const template = search.get("template");
130 const label = search.get("label");136 const label = search.get("label");
@@ -149,13 +155,13 @@ export function IntegrationDetailPage(props: { · 18 unmodified lines
149 };155 };
150 })();156 })();
151 return {157 return {
152 key: locationSearch,158 key: `${String(props.addAccount)}:${locationSearch}`,
153 ...(owner === "org" || owner === "user" ? { owner } : {}),159 ...(owner === "org" || owner === "user" ? { owner } : {}),
154 ...(template != null && template.length > 0 ? { template } : {}),160 ...(template != null && template.length > 0 ? { template } : {}),
155 ...(label != null && label.length > 0 ? { label } : {}),161 ...(label != null && label.length > 0 ? { label } : {}),
156 ...(oauthClient !== undefined ? { oauthClient } : {}),162 ...(oauthClient !== undefined ? { oauthClient } : {}),
157 };163 };
158 }, [locationSearch]);164 }, [locationSearch, props.addAccount]);
159 const accountHandoff = manualAccountHandoff ?? urlAccountHandoff;165 const accountHandoff = manualAccountHandoff ?? urlAccountHandoff;
160 166
161 useEffect(() => {167 useEffect(() => {
packages/react/src/routes/connect-deep-link.test.ts+121−0
@@ -0,0 +1,121 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 createMemoryHistory,
4 createRootRoute,
5 createRoute,
6 createRouter,
7} from "@tanstack/react-router";
8
9import { CONSOLE_ROUTE_PATHS } from "../console-routes";
10import { isAddAccountRequested } from "./integrations.$namespace";
11
12// Routing semantics of the `/connect/<integration slug>` deep link — the URL a
13// customer hands to an end user ("connect your Linear"). The page component is
14// exercised end-to-end in e2e/scenarios/connect-deep-link.test.ts; this locks
15// the URL contract itself: which shapes match, what the params resolve to, and
16// that the org-slug prefix round-trips.
17
18type RouteMatch = { readonly routeId: string; readonly params: Record<string, string> };
19type SpikeRouter = {
20 matchRoutes(pathname: string): ReadonlyArray<RouteMatch>;
21 buildLocation(options: unknown): { readonly href: string };
22};
23
24const CONNECT_ROUTE_ID = "/{-$orgSlug}/connect/$integrationSlug";
25
26const make = (): SpikeRouter => {
27 const rootRoute = createRootRoute();
28 const orgScope = createRoute({ getParentRoute: () => rootRoute, path: "/{-$orgSlug}" });
29 const indexRoute = createRoute({ getParentRoute: () => orgScope, path: "/" });
30 const connectRoute = createRoute({
31 getParentRoute: () => orgScope,
32 path: "/connect/$integrationSlug",
33 });
34 const nsRoute = createRoute({
35 getParentRoute: () => orgScope,
36 path: "/integrations/$namespace",
37 });
38 const routeTree = rootRoute.addChildren([
39 orgScope.addChildren([indexRoute, connectRoute, nsRoute]),
40 ]);
41 // A test-local tree is not the package's registered console tree, so widen
42 // away the global Register types — this asserts runtime semantics.
43 // oxlint-disable-next-line executor/no-double-cast -- boundary: a test-local route tree must escape the package-global router Register types
44 return createRouter({
45 routeTree,
46 history: createMemoryHistory({ initialEntries: ["/"] }),
47 }) as unknown as SpikeRouter;
48};
49
50describe("connect deep link", () => {
51 it("is part of the shared console route contract", () => {
52 // Every host mounts the contract, so the deep link exists on cloud,
53 // self-host, cloudflare and local alike.
54 expect(CONSOLE_ROUTE_PATHS).toContain("/connect/$integrationSlug");
55 });
56
57 it("matches the bare link owner.com would generate", () => {
58 const matches = make().matchRoutes("/connect/linear");
59 const match = matches.find((m) => m.routeId === CONNECT_ROUTE_ID);
60 expect(match?.params).toEqual({ integrationSlug: "linear" });
61 });
62
63 it("matches the org-prefixed form without eating the slug", () => {
64 const matches = make().matchRoutes("/acme/connect/linear");
65 const match = matches.find((m) => m.routeId === CONNECT_ROUTE_ID);
66 expect(match?.params).toEqual({ orgSlug: "acme", integrationSlug: "linear" });
67 });
68
69 it("keeps slugs that look like other console words intact", () => {
70 // The integration slug is a free-form catalog id; "tools" or "integrations"
71 // as a slug must still land on connect, not on those pages.
72 for (const slug of ["tools", "integrations", "connect"]) {
73 const match = make()
74 .matchRoutes(`/connect/${slug}`)
75 .find((m) => m.routeId === CONNECT_ROUTE_ID);
76 expect(match?.params).toEqual({ integrationSlug: slug });
77 }
78 });
79
80 it("builds the link with and without an org slug", () => {
81 const router = make();
82 expect(
83 router.buildLocation({ to: CONNECT_ROUTE_ID, params: { integrationSlug: "linear" } }).href,
84 ).toBe("/connect/linear");
85 expect(
86 router.buildLocation({
87 to: CONNECT_ROUTE_ID,
88 params: { orgSlug: "acme", integrationSlug: "linear" },
89 }).href,
90 ).toBe("/acme/connect/linear");
91 });
92
93 it("forwards into the integration's existing connect flow", () => {
94 // The page redirects here rather than rendering its own connect UI; the
95 // handoff is carried as validated search so it survives client navigation.
96 // The number 1 keeps the canonical `?addAccount=1` that agent-generated
97 // handoff links already use — a string would serialize as `%221%22`.
98 const href = make().buildLocation({
99 to: "/{-$orgSlug}/integrations/$namespace",
100 params: { namespace: "linear" },
101 search: { tab: "accounts", addAccount: 1 },
102 }).href;
103 expect(href).toBe("/integrations/linear?tab=accounts&addAccount=1");
104 });
105});
106
107describe("addAccount flag", () => {
108 it("accepts every spelling the two link sources produce", () => {
109 // A full page load parses `?addAccount=1` to the number; a client-side
110 // navigation may carry the string. Both mean "open the connect flow".
111 expect(isAddAccountRequested(1)).toBe(true);
112 expect(isAddAccountRequested("1")).toBe(true);
113 expect(isAddAccountRequested(true)).toBe(true);
114 });
115
116 it("ignores anything else instead of failing the page", () => {
117 for (const value of [undefined, null, 0, "0", "", "banana", {}]) {
118 expect(isAddAccountRequested(value)).toBe(false);
119 }
120 });
121});
packages/react/src/routes/connect.$integrationSlug.tsx+10−0
@@ -0,0 +1,10 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { ConnectIntegrationPage } from "../pages/connect-integration";
4
5export const Route = createFileRoute("/{-$orgSlug}/connect/$integrationSlug")({
6 component: () => {
7 const { integrationSlug } = Route.useParams();
8 return <ConnectIntegrationPage integrationSlug={integrationSlug} />;
9 },
10});
packages/react/src/routes/integrations.$namespace.tsx+20−1
@@ -4,21 +4,40 @@ import { createFileRoute } from "@tanstack/react-router"; · 3 unmodified lines
4import { IntegrationDetailPage } from "../pages/integration-detail";4import { IntegrationDetailPage } from "../pages/integration-detail";
5import type { IntegrationDetailSearchTab } from "../lib/integration-detail-tabs";5import type { IntegrationDetailSearchTab } from "../lib/integration-detail-tabs";
6 6
7// `addAccount=1` opens the add-connection flow on arrival. It is declared here
8// (rather than only read off `window.location`) because unlisted keys do not
9// survive validation: a CLIENT-SIDE navigation carrying the handoff — the
10// `/connect/<slug>` deep link — would otherwise have it stripped before the
11// page could see it. The richer handoff fields stay URL-only; they arrive on
12// full page loads from agent-generated links.
13//
14// Typed as unknown and coerced, not as a literal, for two reasons. A URL flag
15// must never hard-fail a page: a hand-edited `?addAccount=banana` should be
16// ignored, not rendered as a validation error. And the router's search
17// serializer quotes a string "1" (`addAccount=%221%22`) while leaving the
18// number 1 bare, so accepting both is what keeps ONE canonical URL —
19// `?addAccount=1` — shared by deep links and agent-generated handoff links.
7const SearchParams = Schema.toStandardSchemaV1(20const SearchParams = Schema.toStandardSchemaV1(
8 Schema.Struct({21 Schema.Struct({
9 tab: Schema.optional(Schema.Literals(["accounts", "source", "tools"])),22 tab: Schema.optional(Schema.Literals(["accounts", "source", "tools"])),
23 addAccount: Schema.optional(Schema.Unknown),
10 }),24 }),
11);25);
12 26
27/** The truthy spellings of the `addAccount` URL flag. */
28export const isAddAccountRequested = (value: unknown): boolean =>
29 value === 1 || value === "1" || value === true;
30
13export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({31export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({
14 validateSearch: SearchParams,32 validateSearch: SearchParams,
15 component: () => {33 component: () => {
16 const { namespace } = Route.useParams();34 const { namespace } = Route.useParams();
17 const { tab } = Route.useSearch();35 const { tab, addAccount } = Route.useSearch();
18 return (36 return (
19 <IntegrationDetailPage37 <IntegrationDetailPage
20 namespace={namespace}38 namespace={namespace}
21 tab={tab as IntegrationDetailSearchTab | undefined}39 tab={tab as IntegrationDetailSearchTab | undefined}
40 addAccount={isAddAccountRequested(addAccount)}
22 />41 />
23 );42 );
24 },43 },
packages/react/src/routes/routeTree.gen.ts+22−0
@@ -17,6 +17,7 @@ import { Route as DotPoliciesRouteImport } from './policies' · 16 unmodified lines
17import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug'17import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug'
18import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId'18import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId'
19import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace'19import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace'
20import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug'
20import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$'21import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$'
21import { Route as DotIntegrationsDotaddDotpluginKeyRouteImport } from './integrations.add.$pluginKey'22import { Route as DotIntegrationsDotaddDotpluginKeyRouteImport } from './integrations.add.$pluginKey'
22 23
@@ -62,6 +63,12 @@ const DotIntegrationsDotnamespaceRoute = · 39 unmodified lines
62 path: '/{-$orgSlug}/integrations/$namespace',63 path: '/{-$orgSlug}/integrations/$namespace',
63 getParentRoute: () => rootRouteImport,64 getParentRoute: () => rootRouteImport,
64 } as any)65 } as any)
66const DotConnectDotintegrationSlugRoute =
67 DotConnectDotintegrationSlugRouteImport.update({
68 id: '/{-$orgSlug}/connect/$integrationSlug',
69 path: '/{-$orgSlug}/connect/$integrationSlug',
70 getParentRoute: () => rootRouteImport,
71 } as any)
65const DotPluginsDotpluginIdDotsplatRoute =72const DotPluginsDotpluginIdDotsplatRoute =
66 DotPluginsDotpluginIdDotsplatRouteImport.update({73 DotPluginsDotpluginIdDotsplatRouteImport.update({
67 id: '/{-$orgSlug}/plugins/$pluginId/$',74 id: '/{-$orgSlug}/plugins/$pluginId/$',
@@ -81,6 +88,7 @@ export interface FileRoutesByFullPath { · 13 unmodified lines
81 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren88 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
82 '/{-$orgSlug}/tools': typeof DotToolsRoute89 '/{-$orgSlug}/tools': typeof DotToolsRoute
83 '/{-$orgSlug}/': typeof DotIndexRoute90 '/{-$orgSlug}/': typeof DotIndexRoute
91 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
84 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute92 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
85 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute93 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
86 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute94 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
@@ -93,6 +101,7 @@ export interface FileRoutesByTo { · 6 unmodified lines
93 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren101 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
94 '/{-$orgSlug}/tools': typeof DotToolsRoute102 '/{-$orgSlug}/tools': typeof DotToolsRoute
95 '/{-$orgSlug}': typeof DotIndexRoute103 '/{-$orgSlug}': typeof DotIndexRoute
104 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
96 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute105 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
97 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute106 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
98 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute107 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
@@ -106,6 +115,7 @@ export interface FileRoutesById { · 7 unmodified lines
106 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren115 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
107 '/{-$orgSlug}/tools': typeof DotToolsRoute116 '/{-$orgSlug}/tools': typeof DotToolsRoute
108 '/{-$orgSlug}/': typeof DotIndexRoute117 '/{-$orgSlug}/': typeof DotIndexRoute
118 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
109 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute119 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
110 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute120 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
111 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute121 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
@@ -120,6 +130,7 @@ export interface FileRouteTypes { · 8 unmodified lines
120 | '/{-$orgSlug}/toolkits'130 | '/{-$orgSlug}/toolkits'
121 | '/{-$orgSlug}/tools'131 | '/{-$orgSlug}/tools'
122 | '/{-$orgSlug}/'132 | '/{-$orgSlug}/'
133 | '/{-$orgSlug}/connect/$integrationSlug'
123 | '/{-$orgSlug}/integrations/$namespace'134 | '/{-$orgSlug}/integrations/$namespace'
124 | '/{-$orgSlug}/resume/$executionId'135 | '/{-$orgSlug}/resume/$executionId'
125 | '/{-$orgSlug}/toolkits/$toolkitSlug'136 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -132,6 +143,7 @@ export interface FileRouteTypes { · 6 unmodified lines
132 | '/{-$orgSlug}/toolkits'143 | '/{-$orgSlug}/toolkits'
133 | '/{-$orgSlug}/tools'144 | '/{-$orgSlug}/tools'
134 | '/{-$orgSlug}'145 | '/{-$orgSlug}'
146 | '/{-$orgSlug}/connect/$integrationSlug'
135 | '/{-$orgSlug}/integrations/$namespace'147 | '/{-$orgSlug}/integrations/$namespace'
136 | '/{-$orgSlug}/resume/$executionId'148 | '/{-$orgSlug}/resume/$executionId'
137 | '/{-$orgSlug}/toolkits/$toolkitSlug'149 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -144,6 +156,7 @@ export interface FileRouteTypes { · 6 unmodified lines
144 | '/{-$orgSlug}/toolkits'156 | '/{-$orgSlug}/toolkits'
145 | '/{-$orgSlug}/tools'157 | '/{-$orgSlug}/tools'
146 | '/{-$orgSlug}/'158 | '/{-$orgSlug}/'
159 | '/{-$orgSlug}/connect/$integrationSlug'
147 | '/{-$orgSlug}/integrations/$namespace'160 | '/{-$orgSlug}/integrations/$namespace'
148 | '/{-$orgSlug}/resume/$executionId'161 | '/{-$orgSlug}/resume/$executionId'
149 | '/{-$orgSlug}/toolkits/$toolkitSlug'162 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -157,6 +170,7 @@ export interface RootRouteChildren { · 7 unmodified lines
157 DotToolkitsRoute: typeof DotToolkitsRouteWithChildren170 DotToolkitsRoute: typeof DotToolkitsRouteWithChildren
158 DotToolsRoute: typeof DotToolsRoute171 DotToolsRoute: typeof DotToolsRoute
159 DotIndexRoute: typeof DotIndexRoute172 DotIndexRoute: typeof DotIndexRoute
173 DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute
160 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute174 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute
161 DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute175 DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute
162 DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute176 DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute
@@ -221,6 +235,13 @@ declare module '@tanstack/react-router' { · 58 unmodified lines
221 preLoaderRoute: typeof DotIntegrationsDotnamespaceRouteImport235 preLoaderRoute: typeof DotIntegrationsDotnamespaceRouteImport
222 parentRoute: typeof rootRouteImport236 parentRoute: typeof rootRouteImport
223 }237 }
238 '/{-$orgSlug}/connect/$integrationSlug': {
239 id: '/{-$orgSlug}/connect/$integrationSlug'
240 path: '/{-$orgSlug}/connect/$integrationSlug'
241 fullPath: '/{-$orgSlug}/connect/$integrationSlug'
242 preLoaderRoute: typeof DotConnectDotintegrationSlugRouteImport
243 parentRoute: typeof rootRouteImport
244 }
224 '/{-$orgSlug}/plugins/$pluginId/$': {245 '/{-$orgSlug}/plugins/$pluginId/$': {
225 id: '/{-$orgSlug}/plugins/$pluginId/$'246 id: '/{-$orgSlug}/plugins/$pluginId/$'
226 path: '/{-$orgSlug}/plugins/$pluginId/$'247 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -256,6 +277,7 @@ const rootRouteChildren: RootRouteChildren = { · 29 unmodified lines
256 DotToolkitsRoute: DotToolkitsRouteWithChildren,277 DotToolkitsRoute: DotToolkitsRouteWithChildren,
257 DotToolsRoute: DotToolsRoute,278 DotToolsRoute: DotToolsRoute,
258 DotIndexRoute: DotIndexRoute,279 DotIndexRoute: DotIndexRoute,
280 DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute,
259 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,281 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,
260 DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute,282 DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute,
261 DotIntegrationsDotaddDotpluginKeyRoute:283 DotIntegrationsDotaddDotpluginKeyRoute: