From b1cb75cd0516399e78a9cf67139a48c6c5c22a4b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 4 May 2026 01:30:42 -0700 Subject: [PATCH] Add /:org/mcp and /:org/:workspace/mcp context routes The plan moves cloud's "active org/workspace" off hidden state and onto the URL. This wires that into the MCP edge: - `/:org/mcp` and `/:org/:workspace/mcp` are first-class MCP routes. `/mcp` stays as a compatibility fallback that resolves to the user's first org membership (oldest by created_at). No "last active workspace" fallback, per the plan. - The MCP session DO is now keyed off the URL-resolved (org, workspace?) pair rather than the JWT's `org_id` claim. The JWT proves identity; the URL is the truth. `validateSessionOwner` checks workspace id too, so a session opened against one workspace can't be reused against another (or against no workspace). - `/.well-known/oauth-protected-resource/:org/mcp` and the workspace variant are published, with the `resource` field pointing at the matching public MCP URL. - `oauthCallbackUrl` mirrors the page's org/workspace prefix into the redirect URI so OAuth callbacks land in the same scope-stack context the source/connection lives in. - Test worker gets a `/__test__/seed-workspace` endpoint and a test-only `McpUrlContextResolver` that auto-mirrors org rows from the bearer for the fallback path (we can't reach WorkOS from the test isolate). --- apps/cloud/src/mcp-flow.test.ts | 200 ++++++++- apps/cloud/src/mcp-miniflare.e2e.node.test.ts | 2 +- apps/cloud/src/mcp-session.ts | 69 ++- apps/cloud/src/mcp.ts | 417 +++++++++++++++--- apps/cloud/src/start.ts | 16 +- apps/cloud/src/test-worker.ts | 167 ++++++- packages/react/src/plugins/oauth-sign-in.tsx | 89 +++- 7 files changed, 880 insertions(+), 80 deletions(-) diff --git a/apps/cloud/src/mcp-flow.test.ts b/apps/cloud/src/mcp-flow.test.ts index 73b72aa95..2b3b57d5a 100644 --- a/apps/cloud/src/mcp-flow.test.ts +++ b/apps/cloud/src/mcp-flow.test.ts @@ -121,13 +121,31 @@ const mcpGet = (init: { readonly bearer: string; readonly sessionId: string }): }, }); -const seedOrg = async (id: string, name = "MCP Flow Org"): Promise => { +const seedOrg = async ( + id: string, + name = "MCP Flow Org", +): Promise<{ handle: string }> => { const response = await SELF.fetch(`${BASE}/__test__/seed-org`, { method: "POST", headers: { "content-type": CONTENT_TYPE_JSON }, body: JSON.stringify({ id, name }), }); - expect(response.status).toBe(204); + expect(response.status).toBe(200); + return (await response.json()) as { handle: string }; +}; + +const seedWorkspace = async (input: { + organizationId: string; + name: string; + slug?: string; +}): Promise<{ id: string; slug: string }> => { + const response = await SELF.fetch(`${BASE}/__test__/seed-workspace`, { + method: "POST", + headers: { "content-type": CONTENT_TYPE_JSON }, + body: JSON.stringify(input), + }); + expect(response.status).toBe(200); + return (await response.json()) as { id: string; slug: string }; }; // --------------------------------------------------------------------------- @@ -320,7 +338,7 @@ describe("/mcp notification responses", () => { expect(notificationResponse.status).toBe(202); expect(notificationResponse.headers.get("content-type")).toBeNull(); expect(await notificationResponse.text()).toBe(""); - }); + }, 15_000); }); describe("/mcp session restore", () => { @@ -549,3 +567,179 @@ describe("McpSessionDO alarm lifecycle", () => { expect(stored.alarm).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// /:org/mcp + /:org/:workspace/mcp — context-addressed MCP routes +// --------------------------------------------------------------------------- +// +// Sister coverage to the legacy `/mcp` fallback above. The plan moves the +// "active org/workspace" off hidden state and onto the URL — these tests +// confirm the worker: +// 1. classifies the new path shapes, +// 2. resolves URL `:org` (and optional `:workspace`) to org/workspace rows, +// 3. seeds session-meta from the URL (NOT from the JWT's org_id claim), +// 4. publishes a per-context `oauth-protected-resource/:org(/:workspace)/mcp` +// metadata document. + +describe("/:org/mcp context routes", () => { + it("publishes per-org protected-resource metadata pointing at /:org/mcp", async () => { + const orgId = nextOrgId(); + const { handle } = await seedOrg(orgId, `MCP Org ${orgId}`); + + const response = await SELF.fetch( + `${BASE}/.well-known/oauth-protected-resource/${handle}/mcp`, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as Record; + expect(body.resource).toBe(`${BASE}/${handle}/mcp`); + expect(body.authorization_servers).toEqual(["https://test-authkit.example.com"]); + }); + + it("creates a session bound to the URL-resolved org (not the JWT claim)", async () => { + const urlOrgId = nextOrgId(); + const jwtOrgId = nextOrgId(); + const accountId = nextAccountId(); + const { handle } = await seedOrg(urlOrgId, `URL Org ${urlOrgId}`); + // The JWT carries a different org id. The URL is the source of truth, + // so the session's stored organizationId should be the URL one. + await seedOrg(jwtOrgId, `JWT Org ${jwtOrgId}`); + + const response = await SELF.fetch(`${BASE}/${handle}/mcp`, { + method: "POST", + headers: { + "content-type": CONTENT_TYPE_JSON, + accept: JSON_AND_SSE, + authorization: `Bearer ${makeTestBearer(accountId, jwtOrgId)}`, + }, + body: JSON.stringify(INITIALIZE_REQUEST), + }); + + expect(response.status).toBe(200); + const sessionId = response.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + + const stub = env.MCP_SESSION.get(env.MCP_SESSION.idFromString(sessionId!)); + const stored = await runInDurableObject(stub, async (_instance, state) => ({ + sessionMeta: await state.storage.get<{ + readonly organizationId: string; + readonly userId: string; + readonly workspaceId?: string; + }>(SESSION_META_KEY), + })); + expect(stored.sessionMeta?.organizationId).toBe(urlOrgId); + expect(stored.sessionMeta?.workspaceId).toBeUndefined(); + expect(stored.sessionMeta?.userId).toBe(accountId); + }, 15_000); + + it("returns 404 for an unknown org handle", async () => { + const response = await SELF.fetch(`${BASE}/no-such-org-here/mcp`, { + method: "POST", + headers: { + "content-type": CONTENT_TYPE_JSON, + accept: JSON_AND_SSE, + authorization: `Bearer ${makeTestBearer(nextAccountId(), nextOrgId())}`, + }, + body: JSON.stringify(INITIALIZE_REQUEST), + }); + expect(response.status).toBe(404); + const body = (await response.json()) as { + error?: { message?: string }; + }; + expect(body.error?.message ?? "").toMatch(/not found/i); + }); +}); + +describe("/:org/:workspace/mcp context routes", () => { + it("creates a session bound to the URL-resolved workspace", async () => { + const orgId = nextOrgId(); + const accountId = nextAccountId(); + const { handle } = await seedOrg(orgId, `WS Org ${orgId}`); + const workspace = await seedWorkspace({ + organizationId: orgId, + name: "Production", + }); + + const response = await SELF.fetch( + `${BASE}/${handle}/${workspace.slug}/mcp`, + { + method: "POST", + headers: { + "content-type": CONTENT_TYPE_JSON, + accept: JSON_AND_SSE, + authorization: `Bearer ${makeTestBearer(accountId, orgId)}`, + }, + body: JSON.stringify(INITIALIZE_REQUEST), + }, + ); + expect(response.status).toBe(200); + const sessionId = response.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + + const stub = env.MCP_SESSION.get(env.MCP_SESSION.idFromString(sessionId!)); + const stored = await runInDurableObject(stub, async (_instance, state) => ({ + sessionMeta: await state.storage.get<{ + readonly organizationId: string; + readonly workspaceId?: string; + readonly workspaceName?: string; + }>(SESSION_META_KEY), + })); + expect(stored.sessionMeta?.organizationId).toBe(orgId); + expect(stored.sessionMeta?.workspaceId).toBe(workspace.id); + expect(stored.sessionMeta?.workspaceName).toBe("Production"); + }, 15_000); + + it("rejects a session-id that was bound to a different workspace", async () => { + const orgId = nextOrgId(); + const accountId = nextAccountId(); + const { handle } = await seedOrg(orgId, `Cross WS ${orgId}`); + const wsA = await seedWorkspace({ organizationId: orgId, name: "Alpha" }); + const wsB = await seedWorkspace({ organizationId: orgId, name: "Beta" }); + + const initA = await SELF.fetch(`${BASE}/${handle}/${wsA.slug}/mcp`, { + method: "POST", + headers: { + "content-type": CONTENT_TYPE_JSON, + accept: JSON_AND_SSE, + authorization: `Bearer ${makeTestBearer(accountId, orgId)}`, + }, + body: JSON.stringify(INITIALIZE_REQUEST), + }); + expect(initA.status).toBe(200); + const sessionId = initA.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + + const stolen = await SELF.fetch(`${BASE}/${handle}/${wsB.slug}/mcp`, { + method: "POST", + headers: { + "content-type": CONTENT_TYPE_JSON, + accept: JSON_AND_SSE, + authorization: `Bearer ${makeTestBearer(accountId, orgId)}`, + "mcp-session-id": sessionId!, + }, + body: JSON.stringify(TOOLS_LIST_REQUEST), + }); + expect(stolen.status).toBe(403); + const body = (await stolen.json()) as { + readonly error?: { readonly code: number }; + }; + expect(body.error?.code).toBe(-32003); + }, 15_000); + + it("returns 404 for an unknown workspace slug", async () => { + const orgId = nextOrgId(); + const { handle } = await seedOrg(orgId, `WS 404 ${orgId}`); + const response = await SELF.fetch( + `${BASE}/${handle}/nope/mcp`, + { + method: "POST", + headers: { + "content-type": CONTENT_TYPE_JSON, + accept: JSON_AND_SSE, + authorization: `Bearer ${makeTestBearer(nextAccountId(), orgId)}`, + }, + body: JSON.stringify(INITIALIZE_REQUEST), + }, + ); + expect(response.status).toBe(404); + }); +}); diff --git a/apps/cloud/src/mcp-miniflare.e2e.node.test.ts b/apps/cloud/src/mcp-miniflare.e2e.node.test.ts index 63b0e33d4..6db695716 100644 --- a/apps/cloud/src/mcp-miniflare.e2e.node.test.ts +++ b/apps/cloud/src/mcp-miniflare.e2e.node.test.ts @@ -266,7 +266,7 @@ const WorkerLive = Layer.effect(Worker)(Effect.gen(function* () { headers: { "content-type": "application/json" }, body: JSON.stringify({ id, name }), }); - if (res.status !== 204) { + if (res.status !== 200 && res.status !== 204) { throw new Error(`seed-org failed: ${res.status} ${await res.text()}`); } }, diff --git a/apps/cloud/src/mcp-session.ts b/apps/cloud/src/mcp-session.ts index 338b489e7..8dbc5a792 100644 --- a/apps/cloud/src/mcp-session.ts +++ b/apps/cloud/src/mcp-session.ts @@ -36,7 +36,10 @@ import { DoTelemetryLive } from "./services/telemetry"; export type McpSessionInit = { organizationId: string; + organizationName: string; userId: string; + workspaceId?: string; + workspaceName?: string; }; export type IncomingTraceHeaders = { @@ -54,6 +57,7 @@ const SESSION_META_KEY = "session-meta"; const LAST_ACTIVITY_KEY = "last-activity-ms"; const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id"; const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id"; +const INTERNAL_WORKSPACE_ID_HEADER = "x-executor-mcp-workspace-id"; // --------------------------------------------------------------------------- // Errors @@ -119,6 +123,8 @@ type SessionMeta = { readonly organizationId: string; readonly organizationName: string; readonly userId: string; + readonly workspaceId?: string; + readonly workspaceName?: string; }; /** @@ -170,18 +176,34 @@ const makeResolveOrganizationServices = (dbHandle: DbHandle) => { // at the DO method boundary. const makeSessionServices = (dbHandle: DbHandle) => makeResolveOrganizationServices(dbHandle); +// The worker resolves the URL `:org` (and optional `:workspace`) before +// calling `init`, so we get the org row's id+name directly. We still fall +// back to a local lookup if the caller passed an id without a name — +// preserves the old single-arg call shape used by older test paths. const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function* ( - organizationId: string, - userId: string, + init: McpSessionInit, ) { - const org = yield* resolveOrganization(organizationId); + if (init.organizationName) { + return { + organizationId: init.organizationId, + organizationName: init.organizationName, + userId: init.userId, + ...(init.workspaceId + ? { workspaceId: init.workspaceId, workspaceName: init.workspaceName ?? "" } + : {}), + } satisfies SessionMeta; + } + const org = yield* resolveOrganization(init.organizationId); if (!org) { - return yield* new OrganizationNotFoundError({ organizationId }); + return yield* new OrganizationNotFoundError({ organizationId: init.organizationId }); } return { organizationId: org.id, organizationName: org.name, - userId, + userId: init.userId, + ...(init.workspaceId + ? { workspaceId: init.workspaceId, workspaceName: init.workspaceName ?? "" } + : {}), } satisfies SessionMeta; }); @@ -282,11 +304,21 @@ export class McpSessionDO extends DurableObject { ) { const self = this; return Effect.gen(function* () { - const { executor, engine } = yield* makeExecutionStack({ - userId: sessionMeta.userId, - organizationId: sessionMeta.organizationId, - organizationName: sessionMeta.organizationName, - }); + const { executor, engine } = yield* makeExecutionStack( + sessionMeta.workspaceId + ? { + userId: sessionMeta.userId, + organizationId: sessionMeta.organizationId, + organizationName: sessionMeta.organizationName, + workspaceId: sessionMeta.workspaceId, + workspaceName: sessionMeta.workspaceName ?? "", + } + : { + userId: sessionMeta.userId, + organizationId: sessionMeta.organizationId, + organizationName: sessionMeta.organizationName, + }, + ); // Build the description here so the postgres query it runs // (`executor.sources.list`) lands as a child of // `McpSessionDO.createRuntime`. host-mcp would otherwise call @@ -420,11 +452,19 @@ export class McpSessionDO extends DurableObject { const accountId = request.headers.get(INTERNAL_ACCOUNT_ID_HEADER); const organizationId = request.headers.get(INTERNAL_ORGANIZATION_ID_HEADER); + // The header carries an empty string when the request is hitting an + // org-only context. Treat "" identically to undefined. + const headerWorkspaceId = + request.headers.get(INTERNAL_WORKSPACE_ID_HEADER) || null; + const sessionWorkspaceId = sessionMeta.workspaceId ?? null; const matches = - accountId === sessionMeta.userId && organizationId === sessionMeta.organizationId; + accountId === sessionMeta.userId && + organizationId === sessionMeta.organizationId && + headerWorkspaceId === sessionWorkspaceId; yield* Effect.annotateCurrentSpan({ "mcp.session.owner_match": matches, + "mcp.session.workspace_id": sessionWorkspaceId ?? "", }); return matches ? null : sessionOwnerMismatch(); @@ -436,7 +476,7 @@ export class McpSessionDO extends DurableObject { return Effect.gen(function* () { const dbHandle = makeEphemeralDb(); try { - const sessionMeta = yield* resolveSessionMeta(token.organizationId, token.userId).pipe( + const sessionMeta = yield* resolveSessionMeta(token).pipe( Effect.provide(makeResolveOrganizationServices(dbHandle)), ); yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe( @@ -459,7 +499,10 @@ export class McpSessionDO extends DurableObject { yield* self.doInit(token); }).pipe( Effect.withSpan("McpSessionDO.init", { - attributes: { "mcp.auth.organization_id": token.organizationId }, + attributes: { + "mcp.auth.organization_id": token.organizationId, + "mcp.auth.workspace_id": token.workspaceId ?? "", + }, }), (eff) => withIncomingParent(incoming, eff), Effect.provide(DoTelemetryLive), diff --git a/apps/cloud/src/mcp.ts b/apps/cloud/src/mcp.ts index 958fed84f..c9cca18a2 100644 --- a/apps/cloud/src/mcp.ts +++ b/apps/cloud/src/mcp.ts @@ -38,6 +38,14 @@ import { jsonRpcError, unauthorized, } from "./mcp/responses"; +import { + resolveOrgContext, + resolveWorkspaceContext, + type ResolvedOrgContext, + type ResolvedWorkspaceContext, +} from "./services/url-context"; +import { WorkOSAuth } from "./auth/workos"; +import { resolveOrganization } from "./auth/resolve-organization"; // --------------------------------------------------------------------------- // Constants @@ -59,6 +67,7 @@ const jwks = createCachedRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`)); const BEARER_PREFIX = "Bearer "; const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id"; const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id"; +const INTERNAL_WORKSPACE_ID_HEADER = "x-executor-mcp-workspace-id"; const CORS_PREFLIGHT_HEADERS = { ...CORS_ALLOW_ORIGIN, @@ -68,10 +77,24 @@ const CORS_PREFLIGHT_HEADERS = { "access-control-expose-headers": "mcp-session-id", } as const; -const MCP_PATH = "/mcp"; -const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/mcp"; -const PROTECTED_RESOURCE_METADATA_URL = `${RESOURCE_ORIGIN}${PROTECTED_RESOURCE_METADATA_PATH}`; -const RESOURCE_URL = `${RESOURCE_ORIGIN}${MCP_PATH}`; +const LEGACY_MCP_PATH = "/mcp"; +const LEGACY_PROTECTED_RESOURCE_METADATA_PATH = + "/.well-known/oauth-protected-resource/mcp"; + +// --------------------------------------------------------------------------- +// URL context — `:org` / `:org/:workspace` carved out of the request path. +// `null` means "no URL context" — the legacy `/mcp` fallback that resolves +// to the signed-in user's first org membership. +// --------------------------------------------------------------------------- + +type UrlContextSegments = + | { readonly kind: "global"; readonly orgHandle: string } + | { + readonly kind: "workspace"; + readonly orgHandle: string; + readonly workspaceSlug: string; + } + | { readonly kind: "fallback" }; type McpUnauthorizedReason = "missing_bearer" | "invalid_token"; @@ -130,6 +153,100 @@ export class McpOrganizationAuth extends Context.Service< } >()("@executor-js/cloud/McpOrganizationAuth") {} +// --------------------------------------------------------------------------- +// URL context resolver — translates URL `:org` (and optional `:workspace`) to +// the org/workspace records we feed into the session DO. The fallback path +// (`/mcp`) calls `resolveFirstOrgForUser` to use the signed-in user's first +// org membership; the plan explicitly forbids "last active workspace". +// --------------------------------------------------------------------------- + +export type ResolvedMcpContext = + | { readonly _tag: "global"; readonly resolved: ResolvedOrgContext } + | { readonly _tag: "workspace"; readonly resolved: ResolvedWorkspaceContext }; + +export type McpUrlContextError = + | { readonly _tag: "OrgNotFound"; readonly handle: string } + | { readonly _tag: "WorkspaceNotFound"; readonly orgHandle: string; readonly slug: string } + | { readonly _tag: "NoFallbackOrg"; readonly userId: string }; + +export class McpUrlContextResolver extends Context.Service< + McpUrlContextResolver, + { + readonly resolve: ( + segments: UrlContextSegments, + token: VerifiedToken, + ) => Effect.Effect; + } +>()("@executor-js/cloud/McpUrlContextResolver") {} + +export const McpUrlContextResolverLive = Layer.succeed(McpUrlContextResolver)({ + resolve: (segments, token) => + Effect.gen(function* () { + const userId = token.accountId; + if (segments.kind === "global") { + const resolved = yield* resolveOrgContext(segments.orgHandle).pipe( + Effect.catchTag("OrganizationHandleNotFound", () => + Effect.succeed(null), + ), + ); + if (!resolved) { + return { _tag: "OrgNotFound", handle: segments.orgHandle } as const; + } + return { _tag: "global", resolved } as const; + } + if (segments.kind === "workspace") { + const resolved = yield* resolveWorkspaceContext( + segments.orgHandle, + segments.workspaceSlug, + ).pipe( + Effect.catchTags({ + OrganizationHandleNotFound: () => Effect.succeed(null), + WorkspaceSlugNotFound: () => Effect.succeed(null), + }), + ); + if (!resolved) { + // Need to disambiguate which lookup failed for diagnostics. + const orgOnly = yield* resolveOrgContext(segments.orgHandle).pipe( + Effect.catchTag("OrganizationHandleNotFound", () => + Effect.succeed(null), + ), + ); + if (!orgOnly) { + return { _tag: "OrgNotFound", handle: segments.orgHandle } as const; + } + return { + _tag: "WorkspaceNotFound", + orgHandle: segments.orgHandle, + slug: segments.workspaceSlug, + } as const; + } + return { _tag: "workspace", resolved } as const; + } + // Fallback: pick the user's first org membership (oldest by created_at + // — `listUserMemberships` is stable enough for v1; a reorderable + // "default org" lives in a later slice). + const workos = yield* WorkOSAuth; + const memberships = yield* workos.listUserMemberships(userId); + const active = memberships.data + .filter((m: { readonly status: string }) => m.status === "active") + .sort((a: { readonly createdAt?: string }, b: { readonly createdAt?: string }) => + (a.createdAt ?? "").localeCompare(b.createdAt ?? ""), + ); + const first = active[0]; + if (!first) { + return { _tag: "NoFallbackOrg", userId } as const; + } + const org = yield* resolveOrganization(first.organizationId); + if (!org) { + return { _tag: "NoFallbackOrg", userId } as const; + } + return { + _tag: "global", + resolved: { organization: org } satisfies ResolvedOrgContext, + } as const; + }).pipe(Effect.provide(McpUrlContextServices)), +}); + const verifyJwt = (token: string) => verifyWorkOSMcpAccessToken(token, jwks, { issuer: AUTHKIT_DOMAIN, @@ -139,6 +256,7 @@ const verifyJwt = (token: string) => const DbLive = DbService.Live; const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); const McpOrganizationAuthServices = Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices); +const McpUrlContextServices = Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices); export const McpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ authorize: (accountId, organizationId) => @@ -400,14 +518,15 @@ const annotateMcpRequest = ( // OAuth metadata endpoints // --------------------------------------------------------------------------- -const protectedResourceMetadata = Effect.sync(() => - jsonResponse({ - resource: RESOURCE_URL, - authorization_servers: [AUTHKIT_DOMAIN], - bearer_methods_supported: ["header"], - scopes_supported: [], - }), -); +const protectedResourceMetadataFor = (context: UrlContextSegments) => + Effect.sync(() => + jsonResponse({ + resource: `${RESOURCE_ORIGIN}${mcpPathForContext(context)}`, + authorization_servers: [AUTHKIT_DOMAIN], + bearer_methods_supported: ["header"], + scopes_supported: [], + }), + ); const authorizationServerMetadata = Effect.promise(async () => { try { @@ -465,10 +584,23 @@ const withPropagationHeaders = ( return new Request(request, { headers }); }; -const withVerifiedIdentityHeaders = (request: Request, token: VerifiedToken): Request => { +const withVerifiedIdentityHeaders = ( + request: Request, + token: VerifiedToken, + context: ResolvedMcpContext, +): Request => { const headers = new Headers(request.headers); headers.set(INTERNAL_ACCOUNT_ID_HEADER, token.accountId); - headers.set(INTERNAL_ORGANIZATION_ID_HEADER, token.organizationId ?? ""); + // The header carries the URL-resolved org id, NOT the JWT's `org_id` + // claim — the URL is the truth (the JWT only proves identity). + headers.set( + INTERNAL_ORGANIZATION_ID_HEADER, + context.resolved.organization.id, + ); + headers.set( + INTERNAL_WORKSPACE_ID_HEADER, + context._tag === "workspace" ? context.resolved.workspace.id : "", + ); return new Request(request, { headers }); }; @@ -493,13 +625,14 @@ const forwardToExistingSession = ( sessionId: string, peek: boolean, token: VerifiedToken, + context: ResolvedMcpContext, ) => Effect.gen(function* () { const ns = env.MCP_SESSION; const stub = ns.get(ns.idFromString(sessionId)); const propagation = yield* currentPropagationHeaders(request); const propagated = withPropagationHeaders( - withVerifiedIdentityHeaders(request, token), + withVerifiedIdentityHeaders(request, token, context), propagation, ); const raw = yield* Effect.promise( @@ -533,12 +666,10 @@ const authorizeMcpOrganization = ( request: Request, token: VerifiedToken, sessionId: string | null, + context: ResolvedMcpContext, ) => Effect.gen(function* () { - const organizationId = token.organizationId; - if (!organizationId) { - return jsonRpcError(403, -32001, "No organization in session — log in via the web app first"); - } + const organizationId = context.resolved.organization.id; const auth = yield* McpOrganizationAuth; const allowed = yield* auth.authorize(token.accountId, organizationId).pipe( @@ -562,27 +693,48 @@ const authorizeMcpOrganization = ( return jsonRpcError(403, -32001, "No organization in session — log in via the web app first"); }); -const dispatchPost = (request: Request, token: VerifiedToken) => +const dispatchPost = ( + request: Request, + token: VerifiedToken, + context: ResolvedMcpContext, +) => Effect.gen(function* () { const sessionId = request.headers.get("mcp-session-id"); - const authError = yield* authorizeMcpOrganization(request, token, sessionId); + const authError = yield* authorizeMcpOrganization(request, token, sessionId, context); if (authError) return authError; - const organizationId = token.organizationId!; - if (sessionId) return yield* forwardToExistingSession(request, sessionId, true, token); + if (sessionId) + return yield* forwardToExistingSession(request, sessionId, true, token, context); const ns = env.MCP_SESSION; const stub = ns.get(ns.newUniqueId()); const propagation = yield* currentPropagationHeaders(request); - yield* Effect.promise(() => - stub.init({ organizationId, userId: token.accountId }, propagation), - ).pipe( + const init = + context._tag === "workspace" + ? { + organizationId: context.resolved.organization.id, + organizationName: context.resolved.organization.name, + userId: token.accountId, + workspaceId: context.resolved.workspace.id, + workspaceName: context.resolved.workspace.name, + } + : { + organizationId: context.resolved.organization.id, + organizationName: context.resolved.organization.name, + userId: token.accountId, + }; + yield* Effect.promise(() => stub.init(init, propagation)).pipe( Effect.withSpan("mcp.do.init", { - attributes: { "mcp.request.session_id_present": false }, + attributes: { + "mcp.request.session_id_present": false, + "mcp.auth.organization_id": context.resolved.organization.id, + "mcp.auth.workspace_id": + context._tag === "workspace" ? context.resolved.workspace.id : "", + }, }), ); const propagated = withPropagationHeaders( - withVerifiedIdentityHeaders(request, token), + withVerifiedIdentityHeaders(request, token, context), propagation, ); const raw = yield* Effect.promise( @@ -599,24 +751,32 @@ const dispatchPost = (request: Request, token: VerifiedToken) => return HttpServerResponse.raw(withMcpResponseHeaders(annotated)); }); -const dispatchGet = (request: Request, token: VerifiedToken) => { +const dispatchGet = ( + request: Request, + token: VerifiedToken, + context: ResolvedMcpContext, +) => { const sessionId = request.headers.get("mcp-session-id"); if (!sessionId) return Effect.succeed(jsonRpcError(400, -32000, "mcp-session-id header required for SSE")); return Effect.gen(function* () { - const authError = yield* authorizeMcpOrganization(request, token, sessionId); + const authError = yield* authorizeMcpOrganization(request, token, sessionId, context); if (authError) return authError; - return yield* forwardToExistingSession(request, sessionId, false, token); + return yield* forwardToExistingSession(request, sessionId, false, token, context); }); }; -const dispatchDelete = (request: Request, token: VerifiedToken) => { +const dispatchDelete = ( + request: Request, + token: VerifiedToken, + context: ResolvedMcpContext, +) => { const sessionId = request.headers.get("mcp-session-id"); if (!sessionId) return Effect.succeed(HttpServerResponse.empty({ status: 204 })); return Effect.gen(function* () { - const authError = yield* authorizeMcpOrganization(request, token, sessionId); + const authError = yield* authorizeMcpOrganization(request, token, sessionId, context); if (authError) return authError; - return yield* forwardToExistingSession(request, sessionId, true, token); + return yield* forwardToExistingSession(request, sessionId, true, token, context); }); }; @@ -624,23 +784,111 @@ const dispatchDelete = (request: Request, token: VerifiedToken) => { // App // --------------------------------------------------------------------------- -type McpRoute = "mcp" | "oauth-protected-resource" | "oauth-authorization-server" | null; +type McpRouteKind = "mcp" | "oauth-protected-resource" | "oauth-authorization-server"; + +export type McpRoute = { + readonly kind: McpRouteKind; + readonly context: UrlContextSegments; +}; + +const isReservedHandleSegment = (segment: string): boolean => + segment.length === 0 || segment === "-" || segment === "api" || segment === "ingest"; /** - * Returns the MCP route type for a pathname, or `null` if the path isn't owned - * by the MCP handler. + * Classify a pathname into one of the MCP routes we own + the URL context + * (org / org+workspace / fallback) it carries. + * + * Returns `null` if the path isn't an MCP route. * - * Exported so the test worker can share the exact same predicate the middleware - * uses — we avoid duplicating the "is this an MCP path?" logic across entry - * points. + * Supported shapes: + * /mcp → fallback + * /:org/mcp → global + * /:org/:workspace/mcp → workspace + * /.well-known/oauth-protected-resource/mcp → fallback + * /.well-known/oauth-protected-resource/:org/mcp → global + * /.well-known/oauth-protected-resource/:org/:workspace/mcp → workspace + * /.well-known/oauth-authorization-server → fallback */ -export const classifyMcpPath = (pathname: string): McpRoute => { - if (pathname === MCP_PATH) return "mcp"; - if (pathname === PROTECTED_RESOURCE_METADATA_PATH) return "oauth-protected-resource"; - if (pathname === "/.well-known/oauth-authorization-server") return "oauth-authorization-server"; +export const classifyMcpPath = (pathname: string): McpRoute | null => { + if (pathname === LEGACY_MCP_PATH) { + return { kind: "mcp", context: { kind: "fallback" } }; + } + if (pathname === LEGACY_PROTECTED_RESOURCE_METADATA_PATH) { + return { kind: "oauth-protected-resource", context: { kind: "fallback" } }; + } + if (pathname === "/.well-known/oauth-authorization-server") { + return { + kind: "oauth-authorization-server", + context: { kind: "fallback" }, + }; + } + + // /:org/mcp /:org/:workspace/mcp + let mcpMatch = pathname.match(/^\/([^/]+)\/mcp$/); + if (mcpMatch) { + const orgHandle = mcpMatch[1]!; + if (isReservedHandleSegment(orgHandle)) return null; + return { kind: "mcp", context: { kind: "global", orgHandle } }; + } + mcpMatch = pathname.match(/^\/([^/]+)\/([^/]+)\/mcp$/); + if (mcpMatch) { + const orgHandle = mcpMatch[1]!; + const workspaceSlug = mcpMatch[2]!; + if (isReservedHandleSegment(orgHandle) || isReservedHandleSegment(workspaceSlug)) { + return null; + } + return { + kind: "mcp", + context: { kind: "workspace", orgHandle, workspaceSlug }, + }; + } + + // /.well-known/oauth-protected-resource/:org/mcp and /:org/:workspace/mcp + const wellKnownPrefix = "/.well-known/oauth-protected-resource"; + if (pathname.startsWith(`${wellKnownPrefix}/`)) { + const remainder = pathname.slice(wellKnownPrefix.length); + let m = remainder.match(/^\/([^/]+)\/mcp$/); + if (m) { + const orgHandle = m[1]!; + if (isReservedHandleSegment(orgHandle)) return null; + return { + kind: "oauth-protected-resource", + context: { kind: "global", orgHandle }, + }; + } + m = remainder.match(/^\/([^/]+)\/([^/]+)\/mcp$/); + if (m) { + const orgHandle = m[1]!; + const workspaceSlug = m[2]!; + if (isReservedHandleSegment(orgHandle) || isReservedHandleSegment(workspaceSlug)) { + return null; + } + return { + kind: "oauth-protected-resource", + context: { kind: "workspace", orgHandle, workspaceSlug }, + }; + } + } + return null; }; +/** Build the `:org/mcp` (or `:org/:workspace/mcp`) public URL for a context. */ +const mcpPathForContext = (context: UrlContextSegments): string => { + if (context.kind === "fallback") return LEGACY_MCP_PATH; + if (context.kind === "global") return `/${context.orgHandle}/mcp`; + return `/${context.orgHandle}/${context.workspaceSlug}/mcp`; +}; + +const protectedResourceMetadataPathForContext = ( + context: UrlContextSegments, +): string => { + if (context.kind === "fallback") return LEGACY_PROTECTED_RESOURCE_METADATA_PATH; + if (context.kind === "global") + return `/.well-known/oauth-protected-resource/${context.orgHandle}/mcp`; + return `/.well-known/oauth-protected-resource/${context.orgHandle}/${context.workspaceSlug}/mcp`; +}; + /** * Raw Effect-native MCP app. Exported so alternate entry points (e.g. the * vitest-pool-workers test worker) can provide their own auth layers because @@ -649,15 +897,26 @@ export const classifyMcpPath = (pathname: string): McpRoute => { export const mcpApp: Effect.Effect< HttpServerResponse.HttpServerResponse, never, - HttpServerRequest.HttpServerRequest | McpAuth | McpOrganizationAuth + | HttpServerRequest.HttpServerRequest + | McpAuth + | McpOrganizationAuth + | McpUrlContextResolver > = Effect.gen(function* () { const httpRequest = yield* HttpServerRequest.HttpServerRequest; const request = httpRequest.source as Request; const route = classifyMcpPath(new URL(request.url).pathname); if (request.method === "OPTIONS") return corsPreflight; - if (route === "oauth-protected-resource") return yield* protectedResourceMetadata; - if (route === "oauth-authorization-server") return yield* authorizationServerMetadata; + if (!route) return jsonRpcError(404, -32601, "Not found"); + if (route.kind === "oauth-protected-resource") + return yield* protectedResourceMetadataFor(route.context); + if (route.kind === "oauth-authorization-server") + return yield* authorizationServerMetadata; + + // The protected-resource metadata URL we hand back in `WWW-Authenticate` + // must match the URL context the request came in on, so the client's + // metadata fetch lands on the right per-org/workspace document. + const resourceMetadataUrl = `${RESOURCE_ORIGIN}${protectedResourceMetadataPathForContext(route.context)}`; const auth = yield* McpAuth; const authResult = yield* auth.verifyBearer(request).pipe(Effect.result); @@ -680,16 +939,61 @@ export const mcpApp: Effect.Effect< }); if (authValue._tag === "Unauthorized") { - return unauthorized(authValue, PROTECTED_RESOURCE_METADATA_URL); + return unauthorized(authValue, resourceMetadataUrl); } const token = authValue.token; + + // Resolve the URL `:org` (and optional `:workspace`) — for the legacy + // `/mcp` fallback the resolver looks up the user's first org membership. + const resolver = yield* McpUrlContextResolver; + const resolved = yield* resolver + .resolve(route.context, token) + .pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + console.error("[mcp] url context resolution failed:", cause); + return null; + }), + ), + ); + if (!resolved) { + return jsonRpcError(500, -32603, "Internal server error"); + } + if ("_tag" in resolved && (resolved._tag === "global" || resolved._tag === "workspace")) { + yield* Effect.annotateCurrentSpan({ + "mcp.url.org_id": resolved.resolved.organization.id, + "mcp.url.workspace_id": + resolved._tag === "workspace" ? resolved.resolved.workspace.id : "", + }); + } else if (resolved._tag === "OrgNotFound") { + return jsonRpcError( + 404, + -32001, + `Organization "${resolved.handle}" not found`, + ); + } else if (resolved._tag === "WorkspaceNotFound") { + return jsonRpcError( + 404, + -32001, + `Workspace "${resolved.slug}" not found in "${resolved.orgHandle}"`, + ); + } else if (resolved._tag === "NoFallbackOrg") { + return jsonRpcError( + 403, + -32001, + "No organization in session — log in via the web app first", + ); + } + // After the discriminator narrow, `resolved` is `ResolvedMcpContext`. + const context = resolved as ResolvedMcpContext; + switch (request.method) { case "POST": - return yield* dispatchPost(request, token); + return yield* dispatchPost(request, token, context); case "GET": - return yield* dispatchGet(request, token); + return yield* dispatchGet(request, token, context); case "DELETE": - return yield* dispatchDelete(request, token); + return yield* dispatchDelete(request, token, context); default: return jsonRpcError(405, -32001, "Method not allowed"); } @@ -705,7 +1009,16 @@ export const mcpApp: Effect.Effect< ); const rawMcpFetch = HttpEffect.toWebHandler( - mcpApp.pipe(Effect.provide(Layer.mergeAll(McpAuthLive, McpOrganizationAuthLive, TelemetryLive))), + mcpApp.pipe( + Effect.provide( + Layer.mergeAll( + McpAuthLive, + McpOrganizationAuthLive, + McpUrlContextResolverLive, + TelemetryLive, + ), + ), + ), ); /** diff --git a/apps/cloud/src/start.ts b/apps/cloud/src/start.ts index f85778c1f..3f0eed40e 100644 --- a/apps/cloud/src/start.ts +++ b/apps/cloud/src/start.ts @@ -50,12 +50,24 @@ const parseCookie = (cookieHeader: string | null, name: string): string | null = }; // --------------------------------------------------------------------------- -// MCP middleware — routes /mcp and /.well-known/* to the MCP handler +// MCP middleware — routes /mcp, /:org/mcp, /:org/:workspace/mcp, and +// /.well-known/* to the MCP handler. The handler returns null when the +// path doesn't classify as an MCP route, so the middleware falls through +// to TanStack Start. // --------------------------------------------------------------------------- +const ORG_MCP_PATTERN = /^\/[^/]+\/mcp(?:\/|$)/; +const WORKSPACE_MCP_PATTERN = /^\/[^/]+\/[^/]+\/mcp(?:\/|$)/; + +const isMcpPath = (pathname: string): boolean => + pathname === "/mcp" || + pathname.startsWith("/.well-known/") || + ORG_MCP_PATTERN.test(pathname) || + WORKSPACE_MCP_PATTERN.test(pathname); + const mcpRequestMiddleware = createMiddleware({ type: "request" }).server( async ({ pathname, request, next }) => { - if (pathname === "/mcp" || pathname.startsWith("/.well-known/")) { + if (isMcpPath(pathname)) { const response = await mcpFetch(request); if (response) return response; } diff --git a/apps/cloud/src/test-worker.ts b/apps/cloud/src/test-worker.ts index 5cfaf8091..19121b24d 100644 --- a/apps/cloud/src/test-worker.ts +++ b/apps/cloud/src/test-worker.ts @@ -23,15 +23,21 @@ import { McpAuthLive, McpOrganizationAuth, McpOrganizationAuthLive, + McpUrlContextResolver, + McpUrlContextResolverLive, classifyMcpPath, mcpAuthorized, mcpApp, mcpUnauthorized, } from "./mcp"; import { McpJwtVerificationError } from "./mcp-auth"; -import { slugifyHandle } from "./services/ids"; -import { organizations } from "./services/schema"; +import { newId, slugifyHandle } from "./services/ids"; +import { organizations, workspaces } from "./services/schema"; import { pickFreeOrgHandle } from "./services/user-store"; +import { resolveOrgContext, resolveWorkspaceContext } from "./services/url-context"; +import { DbService } from "./services/db"; +import { CoreSharedServices } from "./api/core-shared-services"; +import { UserStoreService } from "./auth/context"; import { parseTestBearer } from "./test-bearer"; import { DoTelemetryLive } from "./services/telemetry"; @@ -59,6 +65,93 @@ const TestMcpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ Effect.succeed(!organizationId.startsWith("revoked_")), }); +// --------------------------------------------------------------------------- +// Test URL-context resolver +// --------------------------------------------------------------------------- +// +// `:org` / `:org/:workspace` paths route through the real DB-backed resolver +// (the test seed-org endpoint inserts the same rows the resolver reads). +// +// The `/mcp` fallback uses the bearer's encoded `organizationId` as the +// "user's first org membership" — we can't reach WorkOS from the test +// isolate. We still upsert the org row from the bearer because legacy tests +// don't pre-seed for the fallback path. +// --------------------------------------------------------------------------- + +const TestDbLive = DbService.Live; +const TestUserStoreLive = UserStoreService.Live.pipe(Layer.provide(TestDbLive)); +const TestUrlContextServices = Layer.mergeAll( + TestDbLive, + TestUserStoreLive, + CoreSharedServices, +); + +const TestMcpUrlContextResolverLive = Layer.succeed(McpUrlContextResolver)({ + resolve: (segments, token) => + Effect.gen(function* () { + if (segments.kind === "global") { + const resolved = yield* resolveOrgContext(segments.orgHandle).pipe( + Effect.catchTag("OrganizationHandleNotFound", () => + Effect.succeed(null), + ), + ); + if (!resolved) { + return { _tag: "OrgNotFound", handle: segments.orgHandle } as const; + } + return { _tag: "global", resolved } as const; + } + if (segments.kind === "workspace") { + const resolved = yield* resolveWorkspaceContext( + segments.orgHandle, + segments.workspaceSlug, + ).pipe( + Effect.catchTags({ + OrganizationHandleNotFound: () => Effect.succeed(null), + WorkspaceSlugNotFound: () => Effect.succeed(null), + }), + ); + if (!resolved) { + const orgOnly = yield* resolveOrgContext(segments.orgHandle).pipe( + Effect.catchTag("OrganizationHandleNotFound", () => + Effect.succeed(null), + ), + ); + if (!orgOnly) { + return { _tag: "OrgNotFound", handle: segments.orgHandle } as const; + } + return { + _tag: "WorkspaceNotFound", + orgHandle: segments.orgHandle, + slug: segments.workspaceSlug, + } as const; + } + return { _tag: "workspace", resolved } as const; + } + // Fallback: the test bearer carries an encoded organizationId — that + // doubles as the "first org" hint here. Auto-mirror the org row if + // the test didn't pre-seed it; production does the same via + // `resolveOrganization` against WorkOS, which we can't reach from + // the test isolate. + if (!token.organizationId) { + return { _tag: "NoFallbackOrg", userId: token.accountId } as const; + } + const users = yield* UserStoreService; + const existing = yield* users.use((s) => + s.getOrganization(token.organizationId!), + ); + if (existing) { + return { _tag: "global", resolved: { organization: existing } } as const; + } + const created = yield* users.use((s) => + s.upsertOrganization({ + id: token.organizationId!, + name: token.organizationId!, + }), + ); + return { _tag: "global", resolved: { organization: created } } as const; + }).pipe(Effect.provide(TestUrlContextServices)), +}); + // --------------------------------------------------------------------------- // Test seed endpoint // --------------------------------------------------------------------------- @@ -94,17 +187,60 @@ const handleSeedOrg = async ( try { const db = drizzle(sql, { schema: { organizations } }); const handle = await pickFreeOrgHandle(db, slugifyHandle(body.name)); - await db + const [row] = await db .insert(organizations) .values({ id: body.id, name: body.name, handle }) .onConflictDoUpdate({ target: organizations.id, set: { name: body.name }, - }); + }) + .returning(); + return new Response(JSON.stringify({ handle: row?.handle ?? handle }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } finally { + await sql.end({ timeout: 0 }).catch(() => undefined); + } +}; + +const handleSeedWorkspace = async ( + request: Request, + envArg: Record, +): Promise => { + const body = (await request.json()) as { + organizationId: string; + name: string; + slug?: string; + id?: string; + }; + const sql: Sql = postgres(seedConnectionString(envArg), { + max: 1, + idle_timeout: 0, + max_lifetime: 30, + connect_timeout: 10, + onnotice: () => undefined, + }); + try { + const db = drizzle(sql, { schema: { workspaces } }); + const slug = body.slug ?? slugifyHandle(body.name); + const id = body.id ?? newId("workspace"); + const [row] = await db + .insert(workspaces) + .values({ + id, + organizationId: body.organizationId, + slug, + name: body.name, + }) + .returning(); + return new Response(JSON.stringify({ id: row!.id, slug: row!.slug }), { + status: 200, + headers: { "content-type": "application/json" }, + }); } finally { await sql.end({ timeout: 0 }).catch(() => undefined); } - return new Response(null, { status: 204 }); }; // Provide a WebSdk-backed tracer on the worker side so the `mcp.request` span @@ -115,13 +251,27 @@ const handleSeedOrg = async ( const testMcpFetch = HttpEffect.toWebHandler( mcpApp.pipe( Effect.provide( - Layer.mergeAll(TestMcpAuthLive, TestMcpOrganizationAuthLive, DoTelemetryLive), + Layer.mergeAll( + TestMcpAuthLive, + TestMcpOrganizationAuthLive, + TestMcpUrlContextResolverLive, + DoTelemetryLive, + ), ), ), ); const realAuthMcpFetch = HttpEffect.toWebHandler( - mcpApp.pipe(Effect.provide(Layer.mergeAll(McpAuthLive, McpOrganizationAuthLive, DoTelemetryLive))), + mcpApp.pipe( + Effect.provide( + Layer.mergeAll( + McpAuthLive, + McpOrganizationAuthLive, + McpUrlContextResolverLive, + DoTelemetryLive, + ), + ), + ), ); export default { @@ -130,6 +280,9 @@ export default { if (url.pathname === "/__test__/seed-org" && request.method === "POST") { return handleSeedOrg(request, envArg); } + if (url.pathname === "/__test__/seed-workspace" && request.method === "POST") { + return handleSeedWorkspace(request, envArg); + } if (url.pathname === "/__test__/real-auth-mcp") { const mcpUrl = new URL(request.url); mcpUrl.pathname = "/mcp"; diff --git a/packages/react/src/plugins/oauth-sign-in.tsx b/packages/react/src/plugins/oauth-sign-in.tsx index c931df54d..bff268559 100644 --- a/packages/react/src/plugins/oauth-sign-in.tsx +++ b/packages/react/src/plugins/oauth-sign-in.tsx @@ -48,8 +48,93 @@ export type StartOAuthAuthorizationInput void; }; -export function oauthCallbackUrl(path = "/api/oauth/callback"): string { - return typeof window === "undefined" ? path : `${window.location.origin}${path}`; +// Reserved web-route segments at the top level — the cloud app reserves +// these so they can never collide with an org/workspace handle. Anything +// else is treated as `:org` (and optionally `:workspace`) for the purpose +// of rebuilding the OAuth callback path. +const RESERVED_TOP_LEVEL_SEGMENTS = new Set([ + "api", + "ingest", + "_astro", + "home", + "setup", + "privacy", + "terms", + "mcp", + "login", + "logout", + "callback", + "_build", + "_server", + "static", + "assets", +]); + +const RESERVED_SECOND_LEVEL_SEGMENTS = new Set([ + "-", + "sources", + "secrets", + "policies", + "connections", + "tools", + "settings", + "billing", + "_org", +]); + +/** + * Read the URL `:org` (and optional `:workspace`) prefix off the current + * `window.location.pathname`. Returns `""` server-side or when the page + * isn't under an org context. Used by `oauthCallbackUrl` to round-trip + * through the same org/workspace context the source/connection lives in, + * so the callback handler builds the matching scope stack. + */ +export function currentOrgWorkspacePathPrefix( + pathname: string = typeof window === "undefined" ? "" : window.location.pathname, +): string { + const parts = pathname.split("/").filter((p) => p.length > 0); + if (parts.length === 0) return ""; + const first = parts[0]!; + if (RESERVED_TOP_LEVEL_SEGMENTS.has(first)) return ""; + if (parts.length === 1) return `/${first}`; + const second = parts[1]!; + if (RESERVED_SECOND_LEVEL_SEGMENTS.has(second)) return `/${first}`; + return `/${first}/${second}`; +} + +/** + * Build the OAuth callback URL the cloud-side handler will receive when + * the authorization server bounces the user's browser back. Mirrors the + * org/workspace context off the current page path so the callback resolves + * the same scope stack as the page that initiated the flow. + * + * Default `path` mounts the callback under `/api`. Pass a custom `path` + * for plugins that own a sub-path (e.g. `/api/oauth/callback` historically; + * still supported, see overload below). + */ +export function oauthCallbackUrl( + path = "/api/oauth/callback", + options?: { readonly contextPathPrefix?: string }, +): string { + if (typeof window === "undefined") return path; + // Give callers an explicit override (used by tests and server-rendered + // contexts that already know the prefix). Empty string disables the + // prefix, matching the legacy single-arg shape. + const prefix = + options && "contextPathPrefix" in options + ? options.contextPathPrefix ?? "" + : currentOrgWorkspacePathPrefix(); + + // Inject the org/workspace prefix between `/api` and the rest of the + // callback path: /api/oauth/callback → /api/:org/oauth/callback or + // /api/:org/:workspace/oauth/callback. + let prefixedPath = path; + if (prefix && path.startsWith("/api/")) { + prefixedPath = `/api${prefix}${path.slice("/api".length)}`; + } else if (prefix && path === "/api") { + prefixedPath = `/api${prefix}`; + } + return `${window.location.origin}${prefixedPath}`; } export function oauthConnectionId(input: {