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
2323 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
2424 import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'
2525 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
26+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
2627 import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'
2728 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'
2829
@@ -107,6 +108,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 78 unmodified lines
107108 getParentRoute: () => rootRouteImport,
108109 } as any,
109110 )
111+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute =
112+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update(
113+ {
114+ id: '/{-$orgSlug}/connect/$integrationSlug',
115+ path: '/{-$orgSlug}/connect/$integrationSlug',
116+ getParentRoute: () => rootRouteImport,
117+ } as any,
118+ )
110119 const Billing_DotplansRoute = Billing_DotplansRouteImport.update({
111120 id: '/{-$orgSlug}/billing_/plans',
112121 path: '/{-$orgSlug}/billing/plans',
@@ -134,6 +143,7 @@ export interface FileRoutesByFullPath { · 21 unmodified lines
134143 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
135144 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
136145 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
146+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
137147 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
138148 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
139149 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -152,6 +162,7 @@ export interface FileRoutesByTo { · 12 unmodified lines
152162 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
153163 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
154164 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
165+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
155166 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
156167 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
157168 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -171,6 +182,7 @@ export interface FileRoutesById { · 13 unmodified lines
171182 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
172183 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
173184 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute
185+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
174186 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
175187 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
176188 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -191,6 +203,7 @@ export interface FileRouteTypes { · 14 unmodified lines
191203 | '/{-$orgSlug}/tools'
192204 | '/{-$orgSlug}/'
193205 | '/{-$orgSlug}/billing/plans'
206+ | '/{-$orgSlug}/connect/$integrationSlug'
194207 | '/{-$orgSlug}/integrations/$namespace'
195208 | '/{-$orgSlug}/resume/$executionId'
196209 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -209,6 +222,7 @@ export interface FileRouteTypes { · 12 unmodified lines
209222 | '/{-$orgSlug}/tools'
210223 | '/{-$orgSlug}'
211224 | '/{-$orgSlug}/billing/plans'
225+ | '/{-$orgSlug}/connect/$integrationSlug'
212226 | '/{-$orgSlug}/integrations/$namespace'
213227 | '/{-$orgSlug}/resume/$executionId'
214228 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -227,6 +241,7 @@ export interface FileRouteTypes { · 12 unmodified lines
227241 | '/{-$orgSlug}/tools'
228242 | '/{-$orgSlug}/'
229243 | '/{-$orgSlug}/billing_/plans'
244+ | '/{-$orgSlug}/connect/$integrationSlug'
230245 | '/{-$orgSlug}/integrations/$namespace'
231246 | '/{-$orgSlug}/resume/$executionId'
232247 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -246,6 +261,7 @@ export interface RootRouteChildren { · 13 unmodified lines
246261 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
247262 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
248263 Billing_DotplansRoute: typeof Billing_DotplansRoute
264+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
249265 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
250266 ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute
251267 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -351,6 +367,13 @@ declare module '@tanstack/react-router' { · 99 unmodified lines
351367 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport
352368 parentRoute: typeof rootRouteImport
353369 }
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+ }
354377 '/{-$orgSlug}/billing_/plans': {
355378 id: '/{-$orgSlug}/billing_/plans'
356379 path: '/{-$orgSlug}/billing/plans'
@@ -400,6 +423,8 @@ const rootRouteChildren: RootRouteChildren = { · 43 unmodified lines
400423 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
401424 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
402425 Billing_DotplansRoute: Billing_DotplansRoute,
426+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute:
427+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
403428 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
404429 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
405430 ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute,
apps/host-cloudflare/web/routeTree.gen.ts+25−0
@@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImp · 16 unmodified lines
1717 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
1818 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
1919 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
20+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
2021 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'
2122 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'
2223
@@ -75,6 +76,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 52 unmodified lines
7576 getParentRoute: () => rootRouteImport,
7677 } as any,
7778 )
79+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute =
80+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update(
81+ {
82+ id: '/{-$orgSlug}/connect/$integrationSlug',
83+ path: '/{-$orgSlug}/connect/$integrationSlug',
84+ getParentRoute: () => rootRouteImport,
85+ } as any,
86+ )
7887 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute =
7988 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update(
8089 {
@@ -98,6 +107,7 @@ export interface FileRoutesByFullPath { · 17 unmodified lines
98107 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
99108 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
100109 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
110+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
101111 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
102112 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
103113 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -110,6 +120,7 @@ export interface FileRoutesByTo { · 6 unmodified lines
110120 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
111121 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
112122 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
123+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
113124 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
114125 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
115126 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -123,6 +134,7 @@ export interface FileRoutesById { · 7 unmodified lines
123134 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
124135 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
125136 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
137+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
126138 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
127139 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
128140 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -137,6 +149,7 @@ export interface FileRouteTypes { · 8 unmodified lines
137149 | '/{-$orgSlug}/toolkits'
138150 | '/{-$orgSlug}/tools'
139151 | '/{-$orgSlug}/'
152+ | '/{-$orgSlug}/connect/$integrationSlug'
140153 | '/{-$orgSlug}/integrations/$namespace'
141154 | '/{-$orgSlug}/resume/$executionId'
142155 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -149,6 +162,7 @@ export interface FileRouteTypes { · 6 unmodified lines
149162 | '/{-$orgSlug}/toolkits'
150163 | '/{-$orgSlug}/tools'
151164 | '/{-$orgSlug}'
165+ | '/{-$orgSlug}/connect/$integrationSlug'
152166 | '/{-$orgSlug}/integrations/$namespace'
153167 | '/{-$orgSlug}/resume/$executionId'
154168 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -161,6 +175,7 @@ export interface FileRouteTypes { · 6 unmodified lines
161175 | '/{-$orgSlug}/toolkits'
162176 | '/{-$orgSlug}/tools'
163177 | '/{-$orgSlug}/'
178+ | '/{-$orgSlug}/connect/$integrationSlug'
164179 | '/{-$orgSlug}/integrations/$namespace'
165180 | '/{-$orgSlug}/resume/$executionId'
166181 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -174,6 +189,7 @@ export interface RootRouteChildren { · 7 unmodified lines
174189 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
175190 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
176191 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
192+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
177193 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
178194 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
179195 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -238,6 +254,13 @@ declare module '@tanstack/react-router' { · 58 unmodified lines
238254 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport
239255 parentRoute: typeof rootRouteImport
240256 }
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+ }
241264 '/{-$orgSlug}/plugins/$pluginId/$': {
242265 id: '/{-$orgSlug}/plugins/$pluginId/$'
243266 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -281,6 +304,8 @@ const rootRouteChildren: RootRouteChildren = { · 37 unmodified lines
281304 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
282305 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
283306 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
307+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute:
308+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
284309 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
285310 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
286311 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:
apps/host-selfhost/src/auth/return-to.test.ts+45−1
@@ -1,6 +1,12 @@
11 import { describe, expect, it } from "@effect/vitest";
22
3−import { isSafeReturnTo, loginPath, mcpAuthorizeResumeTarget, safeReturnTo } from "./return-to";
3+import {
4+ isSafeReturnTo,
5+ loginPath,
6+ mcpAuthorizeResumeTarget,
7+ postLoginTarget,
8+ safeReturnTo,
9+} from "./return-to";
410
511 describe("isSafeReturnTo", () => {
612 const safe = [
@@ -73,6 +79,44 @@ describe("mcpAuthorizeResumeTarget", () => { · 66 unmodified lines
7379 });
7480 });
7581
82+describe("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+
76120 describe("loginPath", () => {
77121 it("omits returnTo for the root", () => {
78122 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
3232 if (!params.get("client_id") || !params.get("redirect_uri")) return null;
3333 return `${MCP_AUTHORIZE_PATH}?${params.toString()}`;
3434 };
35+
36+const 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+ */
54+export 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
66
77 import { authClient } from "./auth-client";
88 import { AuthLayout } from "./auth-layout";
9−import { mcpAuthorizeResumeTarget, safeReturnTo } from "../src/auth/return-to";
9+import { postLoginTarget } from "../src/auth/return-to";
1010
1111 // Self-host login: email + password sign-in via Better Auth. On success we
1212 // 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
1717 // redeeming an invite — either the full /join/<code> link, or by entering the
1818 // code here ("Have an invite code?"), which forwards to the same join page.
1919 export const LoginPage = () => {
20− const search = window.location.search;
21− // Where to go after sign-in: resume an interrupted MCP OAuth authorize if we
22− // arrived from one (Better Auth redirects it here with the OAuth params),
23− // otherwise honor a safe returnTo (e.g. an integration OAuth callback), else
24− // land on the dashboard.
25− const postLogin =
26− mcpAuthorizeResumeTarget(search) ??
27− safeReturnTo(new URLSearchParams(search).get("returnTo")) ??
28− "/";
20+ // Where to go after sign-in. The gate renders this page IN PLACE of the
21+ // requested route without navigating, so the live location is what carries a
22+ // deep link (`/connect/linear`) across sign-in — see `postLoginTarget`.
23+ const postLogin = postLoginTarget(window.location);
2924 const [mode, setMode] = useState<"signin" | "code">("signin");
3025 const [email, setEmail] = useState("");
3126 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
2020 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
2121 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
2222 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
23+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
2324 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$'
2425 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey'
2526
@@ -93,6 +94,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout · 67 unmodified lines
9394 getParentRoute: () => rootRouteImport,
9495 } as any,
9596 )
97+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute =
98+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update(
99+ {
100+ id: '/{-$orgSlug}/connect/$integrationSlug',
101+ path: '/{-$orgSlug}/connect/$integrationSlug',
102+ getParentRoute: () => rootRouteImport,
103+ } as any,
104+ )
96105 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute =
97106 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update(
98107 {
@@ -119,6 +128,7 @@ export interface FileRoutesByFullPath { · 20 unmodified lines
119128 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
120129 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
121130 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
131+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
122132 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
123133 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
124134 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -134,6 +144,7 @@ export interface FileRoutesByTo { · 9 unmodified lines
134144 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
135145 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
136146 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
147+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
137148 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
138149 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
139150 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -150,6 +161,7 @@ export interface FileRoutesById { · 10 unmodified lines
150161 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
151162 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
152163 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
164+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
153165 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
154166 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
155167 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -167,6 +179,7 @@ export interface FileRouteTypes { · 11 unmodified lines
167179 | '/{-$orgSlug}/toolkits'
168180 | '/{-$orgSlug}/tools'
169181 | '/{-$orgSlug}/'
182+ | '/{-$orgSlug}/connect/$integrationSlug'
170183 | '/{-$orgSlug}/integrations/$namespace'
171184 | '/{-$orgSlug}/resume/$executionId'
172185 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -182,6 +195,7 @@ export interface FileRouteTypes { · 9 unmodified lines
182195 | '/{-$orgSlug}/toolkits'
183196 | '/{-$orgSlug}/tools'
184197 | '/{-$orgSlug}'
198+ | '/{-$orgSlug}/connect/$integrationSlug'
185199 | '/{-$orgSlug}/integrations/$namespace'
186200 | '/{-$orgSlug}/resume/$executionId'
187201 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -197,6 +211,7 @@ export interface FileRouteTypes { · 9 unmodified lines
197211 | '/{-$orgSlug}/toolkits'
198212 | '/{-$orgSlug}/tools'
199213 | '/{-$orgSlug}/'
214+ | '/{-$orgSlug}/connect/$integrationSlug'
200215 | '/{-$orgSlug}/integrations/$namespace'
201216 | '/{-$orgSlug}/resume/$executionId'
202217 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -213,6 +228,7 @@ export interface RootRouteChildren { · 10 unmodified lines
213228 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
214229 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
215230 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
231+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
216232 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
217233 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
218234 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -298,6 +314,13 @@ declare module '@tanstack/react-router' { · 79 unmodified lines
298314 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport
299315 parentRoute: typeof rootRouteImport
300316 }
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+ }
301324 '/{-$orgSlug}/plugins/$pluginId/$': {
302325 id: '/{-$orgSlug}/plugins/$pluginId/$'
303326 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -344,6 +367,8 @@ const rootRouteChildren: RootRouteChildren = { · 40 unmodified lines
344367 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
345368 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
346369 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
370+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute:
371+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
347372 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
348373 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
349374 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
17+import { randomBytes, randomUUID } from "node:crypto";
18+
19+import { expect } from "@effect/vitest";
20+import { Effect } from "effect";
21+import { AccountHttpApi } from "@executor-js/api";
22+import { composePluginApi } from "@executor-js/api/server";
23+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
24+import {
25+ AuthTemplateSlug,
26+ ConnectionName,
27+ IntegrationSlug,
28+ ProviderItemId,
29+} from "@executor-js/sdk/shared";
30+
31+import { scenario } from "../src/scenario";
32+import { Api, Browser, Target } from "../src/services";
33+
34+const api = composePluginApi([openApiHttpPlugin()] as const);
35+
36+/** OpenAPI 3 spec with a single operation — enough to be a real catalog entry. */
37+const 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+
53+scenario(
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
1717 import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug'
1818 import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId'
1919 import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace'
20+import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug'
2021 import { Route as DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../react/src/routes/plugins.$pluginId.$'
2122 import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../react/src/routes/integrations.add.$pluginKey'
2223
@@ -67,6 +68,12 @@ const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute = · 44 unmodified lines
6768 path: '/{-$orgSlug}/integrations/$namespace',
6869 getParentRoute: () => rootRouteImport,
6970 } as any)
71+const DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute =
72+ DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport.update({
73+ id: '/{-$orgSlug}/connect/$integrationSlug',
74+ path: '/{-$orgSlug}/connect/$integrationSlug',
75+ getParentRoute: () => rootRouteImport,
76+ } as any)
7077 const DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute =
7178 DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update({
7279 id: '/{-$orgSlug}/plugins/$pluginId/$',
@@ -88,6 +95,7 @@ export interface FileRoutesByFullPath { · 15 unmodified lines
8895 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
8996 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
9097 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
98+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
9199 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
92100 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
93101 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -100,6 +108,7 @@ export interface FileRoutesByTo { · 6 unmodified lines
100108 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
101109 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
102110 '/{-$orgSlug}': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
111+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
103112 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
104113 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
105114 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -113,6 +122,7 @@ export interface FileRoutesById { · 7 unmodified lines
113122 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
114123 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
115124 '/{-$orgSlug}/': typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
125+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
116126 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
117127 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
118128 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
@@ -127,6 +137,7 @@ export interface FileRouteTypes { · 8 unmodified lines
127137 | '/{-$orgSlug}/toolkits'
128138 | '/{-$orgSlug}/tools'
129139 | '/{-$orgSlug}/'
140+ | '/{-$orgSlug}/connect/$integrationSlug'
130141 | '/{-$orgSlug}/integrations/$namespace'
131142 | '/{-$orgSlug}/resume/$executionId'
132143 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -139,6 +150,7 @@ export interface FileRouteTypes { · 6 unmodified lines
139150 | '/{-$orgSlug}/toolkits'
140151 | '/{-$orgSlug}/tools'
141152 | '/{-$orgSlug}'
153+ | '/{-$orgSlug}/connect/$integrationSlug'
142154 | '/{-$orgSlug}/integrations/$namespace'
143155 | '/{-$orgSlug}/resume/$executionId'
144156 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -151,6 +163,7 @@ export interface FileRouteTypes { · 6 unmodified lines
151163 | '/{-$orgSlug}/toolkits'
152164 | '/{-$orgSlug}/tools'
153165 | '/{-$orgSlug}/'
166+ | '/{-$orgSlug}/connect/$integrationSlug'
154167 | '/{-$orgSlug}/integrations/$namespace'
155168 | '/{-$orgSlug}/resume/$executionId'
156169 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -164,6 +177,7 @@ export interface RootRouteChildren { · 7 unmodified lines
164177 DotDotDotDotDotDotReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolkitsRouteWithChildren
165178 DotDotDotDotDotDotReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotReactSrcRoutesToolsRoute
166179 DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
180+ DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
167181 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
168182 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
169183 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -228,6 +242,13 @@ declare module '@tanstack/react-router' { · 58 unmodified lines
228242 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport
229243 parentRoute: typeof rootRouteImport
230244 }
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+ }
231252 '/{-$orgSlug}/plugins/$pluginId/$': {
232253 id: '/{-$orgSlug}/plugins/$pluginId/$'
233254 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -270,6 +291,8 @@ const rootRouteChildren: RootRouteChildren = { · 36 unmodified lines
270291 DotDotDotDotDotDotReactSrcRoutesToolsRoute,
271292 DotDotDotDotDotDotReactSrcRoutesIndexRoute:
272293 DotDotDotDotDotDotReactSrcRoutesIndexRoute,
294+ DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute:
295+ DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute,
273296 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute:
274297 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute,
275298 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute:
packages/core/api/src/account/org-slug.test.ts+14−1
@@ -57,7 +57,20 @@ describe("isValidOrgSlug", () => { · 56 unmodified lines
5757 });
5858
5959 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+ ]) {
6174 expect(RESERVED_ORG_SLUGS.has(critical), critical).toBe(true);
6275 }
6376 });
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
1717 * predictably will):
1818 * - App planes: api, mcp, .well-known (cloud `app-paths.ts`, selfhost
1919 * envelope, cloudflare `run_worker_first`)
20− * - Console routes: integrations, policies, secrets, tools, resume,
21− * plugins (the shared contract), plus host extras:
20+ * - Console routes: connect, integrations, policies, secrets, tools,
21+ * resume, plugins (the shared contract), plus host extras:
2222 * api-keys, org, billing, create-org, setup-mcp (cloud),
2323 * admin, join, docs (selfhost), login (Better Auth
2424 * `mcp({ loginPage })` + cloud/selfhost login UX)
@@ -41,6 +41,7 @@ export const RESERVED_ORG_SLUGS: ReadonlySet<string> = new Set([ · 16 unmodified lines
4141 "mcp",
4242 ".well-known",
4343 // console + host routes
44+ "connect",
4445 "integrations",
4546 "policies",
4647 "secrets",
packages/react/src/console-routes.ts+5−0
@@ -34,6 +34,7 @@ export const ORG_SLUG_SEGMENT = "{-$orgSlug}"; · 33 unmodified lines
3434 * are these paths prefixed with `/{-$orgSlug}`. */
3535 export const CONSOLE_ROUTE_PATHS = [
3636 "/",
37+ "/connect/$integrationSlug",
3738 "/integrations/$namespace",
3839 "/integrations/add/$pluginKey",
3940 "/policies",
@@ -62,6 +63,10 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 22 unmodified lines
6263 const file = (name: string): string => `${options.dir}/${name}`;
6364 const entries: ReadonlyArray<readonly [ConsoleRoutePath, VirtualRouteNode]> = [
6465 ["/", index(file("index.tsx"))],
66+ [
67+ "/connect/$integrationSlug",
68+ route("/connect/$integrationSlug", file("connect.$integrationSlug.tsx")),
69+ ],
6570 [
6671 "/integrations/$namespace",
6772 route("/integrations/$namespace", file("integrations.$namespace.tsx")),
packages/react/src/pages/connect-integration.tsx+109−0
@@ -0,0 +1,109 @@
1+import { useEffect } from "react";
2+import { Link, useNavigate } from "@tanstack/react-router";
3+import { useAtomValue, useAtomRefresh } from "@effect/atom-react";
4+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5+import { IntegrationSlug } from "@executor-js/sdk/shared";
6+
7+import { integrationAtom, integrationsOptimisticAtom } from "../api/atoms";
8+import { ErrorState } from "../components/error-state";
9+import { Skeleton } from "../components/skeleton";
10+import { isAsyncResultLoading } from "../lib/async-result";
11+import { 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+
31+export 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+
103+function 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
6060 export function IntegrationDetailPage(props: {
6161 namespace: string;
6262 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;
6367 }) {
6468 const { namespace } = props;
6569 const slug = IntegrationSlug.make(namespace);
@@ -122,9 +126,11 @@ export function IntegrationDetailPage(props: { · 56 unmodified lines
122126 const canRefresh = integrationData?.canRefresh ?? false;
123127 const canRemove = integrationData?.canRemove ?? false;
124128 const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => {
125− if (locationSearch.length === 0) return null;
126129 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;
128134 const owner = search.get("owner");
129135 const template = search.get("template");
130136 const label = search.get("label");
@@ -149,13 +155,13 @@ export function IntegrationDetailPage(props: { · 18 unmodified lines
149155 };
150156 })();
151157 return {
152− key: locationSearch,
158+ key: `${String(props.addAccount)}:${locationSearch}`,
153159 ...(owner === "org" || owner === "user" ? { owner } : {}),
154160 ...(template != null && template.length > 0 ? { template } : {}),
155161 ...(label != null && label.length > 0 ? { label } : {}),
156162 ...(oauthClient !== undefined ? { oauthClient } : {}),
157163 };
158− }, [locationSearch]);
164+ }, [locationSearch, props.addAccount]);
159165 const accountHandoff = manualAccountHandoff ?? urlAccountHandoff;
160166
161167 useEffect(() => {
packages/react/src/routes/connect-deep-link.test.ts+121−0
@@ -0,0 +1,121 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import {
3+ createMemoryHistory,
4+ createRootRoute,
5+ createRoute,
6+ createRouter,
7+} from "@tanstack/react-router";
8+
9+import { CONSOLE_ROUTE_PATHS } from "../console-routes";
10+import { 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+
18+type RouteMatch = { readonly routeId: string; readonly params: Record<string, string> };
19+type SpikeRouter = {
20+ matchRoutes(pathname: string): ReadonlyArray<RouteMatch>;
21+ buildLocation(options: unknown): { readonly href: string };
22+};
23+
24+const CONNECT_ROUTE_ID = "/{-$orgSlug}/connect/$integrationSlug";
25+
26+const 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+
50+describe("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+
107+describe("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 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+
3+import { ConnectIntegrationPage } from "../pages/connect-integration";
4+
5+export 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
44 import { IntegrationDetailPage } from "../pages/integration-detail";
55 import type { IntegrationDetailSearchTab } from "../lib/integration-detail-tabs";
66
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.
720 const SearchParams = Schema.toStandardSchemaV1(
821 Schema.Struct({
922 tab: Schema.optional(Schema.Literals(["accounts", "source", "tools"])),
23+ addAccount: Schema.optional(Schema.Unknown),
1024 }),
1125 );
1226
27+/** The truthy spellings of the `addAccount` URL flag. */
28+export const isAddAccountRequested = (value: unknown): boolean =>
29+ value === 1 || value === "1" || value === true;
30+
1331 export const Route = createFileRoute("/{-$orgSlug}/integrations/$namespace")({
1432 validateSearch: SearchParams,
1533 component: () => {
1634 const { namespace } = Route.useParams();
17− const { tab } = Route.useSearch();
35+ const { tab, addAccount } = Route.useSearch();
1836 return (
1937 <IntegrationDetailPage
2038 namespace={namespace}
2139 tab={tab as IntegrationDetailSearchTab | undefined}
40+ addAccount={isAddAccountRequested(addAccount)}
2241 />
2342 );
2443 },
packages/react/src/routes/routeTree.gen.ts+22−0
@@ -17,6 +17,7 @@ import { Route as DotPoliciesRouteImport } from './policies' · 16 unmodified lines
1717 import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug'
1818 import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId'
1919 import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace'
20+import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug'
2021 import { Route as DotPluginsDotpluginIdDotsplatRouteImport } from './plugins.$pluginId.$'
2122 import { Route as DotIntegrationsDotaddDotpluginKeyRouteImport } from './integrations.add.$pluginKey'
2223
@@ -62,6 +63,12 @@ const DotIntegrationsDotnamespaceRoute = · 39 unmodified lines
6263 path: '/{-$orgSlug}/integrations/$namespace',
6364 getParentRoute: () => rootRouteImport,
6465 } as any)
66+const DotConnectDotintegrationSlugRoute =
67+ DotConnectDotintegrationSlugRouteImport.update({
68+ id: '/{-$orgSlug}/connect/$integrationSlug',
69+ path: '/{-$orgSlug}/connect/$integrationSlug',
70+ getParentRoute: () => rootRouteImport,
71+ } as any)
6572 const DotPluginsDotpluginIdDotsplatRoute =
6673 DotPluginsDotpluginIdDotsplatRouteImport.update({
6774 id: '/{-$orgSlug}/plugins/$pluginId/$',
@@ -81,6 +88,7 @@ export interface FileRoutesByFullPath { · 13 unmodified lines
8188 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
8289 '/{-$orgSlug}/tools': typeof DotToolsRoute
8390 '/{-$orgSlug}/': typeof DotIndexRoute
91+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
8492 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
8593 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
8694 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
@@ -93,6 +101,7 @@ export interface FileRoutesByTo { · 6 unmodified lines
93101 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
94102 '/{-$orgSlug}/tools': typeof DotToolsRoute
95103 '/{-$orgSlug}': typeof DotIndexRoute
104+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
96105 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
97106 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
98107 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
@@ -106,6 +115,7 @@ export interface FileRoutesById { · 7 unmodified lines
106115 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
107116 '/{-$orgSlug}/tools': typeof DotToolsRoute
108117 '/{-$orgSlug}/': typeof DotIndexRoute
118+ '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
109119 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
110120 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
111121 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
@@ -120,6 +130,7 @@ export interface FileRouteTypes { · 8 unmodified lines
120130 | '/{-$orgSlug}/toolkits'
121131 | '/{-$orgSlug}/tools'
122132 | '/{-$orgSlug}/'
133+ | '/{-$orgSlug}/connect/$integrationSlug'
123134 | '/{-$orgSlug}/integrations/$namespace'
124135 | '/{-$orgSlug}/resume/$executionId'
125136 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -132,6 +143,7 @@ export interface FileRouteTypes { · 6 unmodified lines
132143 | '/{-$orgSlug}/toolkits'
133144 | '/{-$orgSlug}/tools'
134145 | '/{-$orgSlug}'
146+ | '/{-$orgSlug}/connect/$integrationSlug'
135147 | '/{-$orgSlug}/integrations/$namespace'
136148 | '/{-$orgSlug}/resume/$executionId'
137149 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -144,6 +156,7 @@ export interface FileRouteTypes { · 6 unmodified lines
144156 | '/{-$orgSlug}/toolkits'
145157 | '/{-$orgSlug}/tools'
146158 | '/{-$orgSlug}/'
159+ | '/{-$orgSlug}/connect/$integrationSlug'
147160 | '/{-$orgSlug}/integrations/$namespace'
148161 | '/{-$orgSlug}/resume/$executionId'
149162 | '/{-$orgSlug}/toolkits/$toolkitSlug'
@@ -157,6 +170,7 @@ export interface RootRouteChildren { · 7 unmodified lines
157170 DotToolkitsRoute: typeof DotToolkitsRouteWithChildren
158171 DotToolsRoute: typeof DotToolsRoute
159172 DotIndexRoute: typeof DotIndexRoute
173+ DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute
160174 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute
161175 DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute
162176 DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute
@@ -221,6 +235,13 @@ declare module '@tanstack/react-router' { · 58 unmodified lines
221235 preLoaderRoute: typeof DotIntegrationsDotnamespaceRouteImport
222236 parentRoute: typeof rootRouteImport
223237 }
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+ }
224245 '/{-$orgSlug}/plugins/$pluginId/$': {
225246 id: '/{-$orgSlug}/plugins/$pluginId/$'
226247 path: '/{-$orgSlug}/plugins/$pluginId/$'
@@ -256,6 +277,7 @@ const rootRouteChildren: RootRouteChildren = { · 29 unmodified lines
256277 DotToolkitsRoute: DotToolkitsRouteWithChildren,
257278 DotToolsRoute: DotToolsRoute,
258279 DotIndexRoute: DotIndexRoute,
280+ DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute,
259281 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,
260282 DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute,
261283 DotIntegrationsDotaddDotpluginKeyRoute: