Skip to content

Commit ae3a162

Browse files
authored
fix: include org slug in connection handoff URLs (#1085)
The agentic connection handoff (connections.createHandoff) emitted a bare /integrations/<id> path with no org slug, so a user in multiple orgs could land in whatever org the browser last canonicalized to rather than the one the executor is bound to. Thread the resolved org's slug from the identity through a request-scoped service (RequestOrgSlug) into the URL builder, emitting /<org-slug>/integrations/<id>. Falls back to the bare path when no slug is known (CLI, local, non-request callers).
1 parent b3f5f43 commit ae3a162

16 files changed

Lines changed: 118 additions & 14 deletions

File tree

apps/cloud/src/auth/workos-auth-provider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) =>
137137
accountId: verified.accountId,
138138
organizationId: org.id,
139139
organizationName: org.name,
140+
organizationSlug: org.slug,
140141
email: "",
141142
name: null,
142143
avatarUrl: null,
@@ -183,6 +184,7 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig |
183184
accountId: principal.accountId,
184185
organizationId: org.id,
185186
organizationName: org.name,
187+
organizationSlug: org.slug,
186188
email: "",
187189
name: null,
188190
avatarUrl: null,
@@ -211,6 +213,7 @@ export const resolveSessionPrincipal = (request: Request) =>
211213
accountId: session.userId,
212214
organizationId: org.id,
213215
organizationName: org.name,
216+
organizationSlug: org.slug,
214217
email: session.email,
215218
name: sealedSessionDisplayName(session),
216219
avatarUrl: session.avatarUrl ?? null,

apps/cloud/src/mcp/session-durable-object.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> {
170170
return {
171171
organizationId: org.id,
172172
organizationName: org.name,
173+
organizationSlug: org.slug,
173174
userId: token.userId,
174175
elicitationMode: token.elicitationMode,
175176
} satisfies SessionMeta;

apps/host-cloudflare/src/auth/cloudflare-access.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export const principalFromAccessClaims = (
4141
accountId: sub || email || commonName,
4242
organizationId: config.organizationId,
4343
organizationName: config.organizationName,
44+
organizationSlug: config.organizationSlug,
4445
email,
4546
name: typeof nameClaim === "string" ? nameClaim : commonName || null,
4647
avatarUrl: null,
@@ -68,6 +69,7 @@ export const makeAccessVerifier = (config: CloudflareConfig) => {
6869
accountId: "dev",
6970
organizationId: config.organizationId,
7071
organizationName: config.organizationName,
72+
organizationSlug: config.organizationSlug,
7173
email: config.adminEmails[0] ?? "dev@local",
7274
name: "Dev",
7375
avatarUrl: null,

apps/host-cloudflare/src/mcp/session-durable-object.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> {
5959
return Effect.succeed({
6060
organizationId: token.organizationId,
6161
organizationName: this.cfConfig.organizationName,
62+
organizationSlug: this.cfConfig.organizationSlug,
6263
userId: token.userId,
6364
elicitationMode: token.elicitationMode,
6465
} satisfies SessionMeta);

apps/host-selfhost/src/auth/identity.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const bearerToken = (headers: Headers): string | undefined => {
4242
export const betterAuthIdentityLayer: Layer.Layer<IdentityProvider, never, BetterAuth> =
4343
Layer.effect(IdentityProvider)(
4444
Effect.gen(function* () {
45-
const { auth, organizationId, organizationName } = yield* BetterAuth;
45+
const { auth, organizationId, organizationName, organizationSlug } = yield* BetterAuth;
4646
return IdentityProvider.of({
4747
authenticate: (request) =>
4848
Effect.gen(function* () {
@@ -70,6 +70,7 @@ export const betterAuthIdentityLayer: Layer.Layer<IdentityProvider, never, Bette
7070
accountId: resolved.user.id,
7171
organizationId: resolvedOrganizationId,
7272
organizationName,
73+
organizationSlug,
7374
email: resolved.user.email,
7475
name: resolved.user.name ?? null,
7576
avatarUrl: resolved.user.image ?? null,

apps/host-selfhost/src/mcp/auth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I
8080
Layer.effect(
8181
McpAuthProvider,
8282
Effect.gen(function* () {
83-
const { auth, organizationId, organizationName } = yield* BetterAuth;
83+
const { auth, organizationId, organizationName, organizationSlug } = yield* BetterAuth;
8484
const fallback = yield* IdentityProvider;
8585

8686
const asMetadata = oAuthDiscoveryMetadata(auth);
@@ -121,6 +121,7 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I
121121
// the seeded org (same default as the cookie/api-key path).
122122
organizationId,
123123
organizationName,
124+
organizationSlug,
124125
email: user.email ?? "",
125126
name: user.name ?? null,
126127
avatarUrl: user.image ?? null,

e2e/scenarios/connect-handoff.test.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
//
88
// 1. MCP `execute` → `openapi.addSpec` registers the emulated Resend API
99
// 2. MCP `execute` → `connections.createHandoff` returns the browser URL
10-
// 3. The URL's origin must be THIS deployment (not a hardcoded host)
10+
// 3. The URL's origin must be THIS deployment (not a hardcoded host) AND it
11+
// must carry the bound org's slug (`/<slug>/integrations/…`), so a user in
12+
// several orgs lands in the exact org the agent is scoped to, not whatever
13+
// org the browser happened to last canonicalize onto
1114
// 4. Playwright opens it: the Add connection modal must be open with a
1215
// credential field, the emulator-minted API key is pasted and submitted
1316
// 5. The saved connection is proven live: `execute` sends an email through
@@ -17,6 +20,7 @@ import { randomBytes } from "node:crypto";
1720

1821
import { expect } from "@effect/vitest";
1922
import { Effect } from "effect";
23+
import { AccountHttpApi } from "@executor-js/api";
2024
import { composePluginApi } from "@executor-js/api/server";
2125
import { connectEmulator } from "@executor-js/emulate";
2226
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
@@ -126,6 +130,13 @@ scenario(
126130
const session = mcp.session(identity);
127131
const client = yield* makeApiClient(api, identity);
128132

133+
// The bound org's slug, read from the same account surface the console
134+
// shell reads — the handoff URL must canonicalize onto exactly this.
135+
const accountClient = yield* makeApiClient(AccountHttpApi, identity);
136+
const me = yield* accountClient.account.me();
137+
const orgSlug = me.organization?.slug;
138+
expect(orgSlug, "the bound organization advertises a URL slug").toBeTruthy();
139+
129140
yield* runScenario({
130141
target,
131142
browser,
@@ -134,6 +145,7 @@ scenario(
134145
integration,
135146
emailSubject,
136147
apiKey,
148+
orgSlug: orgSlug!,
137149
}).pipe(
138150
// Best-effort cleanup even on failure: drop the created connection(s)
139151
// over MCP, then the integration over the API.
@@ -155,9 +167,11 @@ const runScenario = (input: {
155167
readonly integration: string;
156168
readonly emailSubject: string;
157169
readonly apiKey: string;
170+
readonly orgSlug: string;
158171
}) =>
159172
Effect.gen(function* () {
160-
const { target, browser, session, identity, integration, emailSubject, apiKey } = input;
173+
const { target, browser, session, identity, integration, emailSubject, apiKey, orgSlug } =
174+
input;
161175

162176
// 1. Agent registers the emulated provider over MCP.
163177
const added = yield* executeJson(session, addSpecCode(integration));
@@ -168,13 +182,16 @@ const runScenario = (input: {
168182
expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true);
169183
const handoffUrl = String(handoff.url);
170184

171-
// 3. The URL must target THIS deployment. (Production returned a URL the
172-
// user called "wrong/bad" — pin the contract here.)
185+
// 3. The URL must target THIS deployment AND carry the bound org's slug.
186+
// (Production returned a URL the user called "wrong/bad" — it had no slug,
187+
// so a multi-org user could land in the wrong workspace. Pin both here.)
173188
const parsed = new URL(handoffUrl);
174189
expect(parsed.origin, `handoff URL (${handoffUrl}) targets this deployment`).toBe(
175190
new URL(target.baseUrl).origin,
176191
);
177-
expect(parsed.pathname).toBe(`/integrations/${integration}`);
192+
expect(parsed.pathname, `handoff URL (${handoffUrl}) carries the bound org slug`).toBe(
193+
`/${orgSlug}/integrations/${integration}`,
194+
);
178195
expect(parsed.searchParams.get("addAccount")).toBe("1");
179196

180197
// 4. The user opens the handoff URL and pastes the credential.

packages/core/api/src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,11 @@ export {
5656
HostConfig,
5757
PluginsProvider,
5858
RequestWebOrigin,
59+
RequestOrgSlug,
5960
type HostConfigShape,
6061
type PluginsProviderShape,
6162
type RequestWebOriginShape,
63+
type RequestOrgSlugShape,
6264
} from "./server/scoped-executor";
6365
export { collectTables } from "@executor-js/sdk";
6466
export {

packages/core/api/src/server/execution-stack-middleware.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ import { Context, Effect, Layer } from "effect";
4040
import type { AnyPlugin } from "@executor-js/sdk";
4141

4242
import type { DbProvider } from "./executor-fuma-db";
43-
import { RequestWebOrigin, type HostConfig, type PluginsProvider } from "./scoped-executor";
43+
import {
44+
RequestOrgSlug,
45+
RequestWebOrigin,
46+
type HostConfig,
47+
type PluginsProvider,
48+
} from "./scoped-executor";
4449
import { ExecutionEngineService, ExecutorService } from "../services";
4550
import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes";
4651
import {
@@ -169,7 +174,7 @@ export const makeExecutionStackMiddleware = <
169174
// web base URL (a Worker) derives one zero-config. An explicit
170175
// `HostConfig.webBaseUrl` still wins; we deliberately read `request.url`
171176
// (not a spoofable `X-Forwarded-Host`).
172-
const { executor, engine } = yield* makeExecutionStack<TPlugins>(
177+
const stack = makeExecutionStack<TPlugins>(
173178
resolved.accountId,
174179
resolved.organizationId,
175180
resolved.organizationName,
@@ -179,6 +184,12 @@ export const makeExecutionStackMiddleware = <
179184
origin: requestWebOriginFromRequest(webRequest),
180185
}),
181186
);
187+
// Pin browser-handoff URLs to the resolved org's slug when the identity
188+
// provider carried one. Absent slug -> the service stays unprovided and
189+
// the URL falls back to its bare, client-canonicalized form.
190+
const { executor, engine } = yield* resolved.organizationSlug !== undefined
191+
? stack.pipe(Effect.provideService(RequestOrgSlug, { slug: resolved.organizationSlug }))
192+
: stack;
182193
return yield* httpEffect.pipe(
183194
Effect.provideService(AuthContext, auth),
184195
Effect.provideService(ExecutorService, executor),

packages/core/api/src/server/identity.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ export interface Principal {
3232
readonly accountId: string;
3333
readonly organizationId: string;
3434
readonly organizationName: string;
35+
/**
36+
* The org's URL slug (`acme`), when the resolver has it. Optional because not
37+
* every principal source carries it (api-key paths that never load the org
38+
* row). Threaded into browser-handoff URLs so they open the right org's
39+
* console; a missing slug just falls back to a bare, client-canonicalized URL.
40+
*/
41+
readonly organizationSlug?: string;
3542
readonly email: string;
3643
readonly name: string | null;
3744
readonly avatarUrl: string | null;

0 commit comments

Comments
 (0)