Unified · Split
| @@ -188,8 +188,8 @@ describe("makeApiLive (prod handler factory) request scoping", () => { · 187 unmodified lines | ||
| 188 | 188 | // has built the per-request layer. We don't care about the response — |
| 189 | 189 | // only that the layer was built once per request. `/integrations` is a |
| 190 | 190 | // v2 protected route (the old `/scope` group was removed). |
| 191 | − await handler(new Request("http://test.local/integrations")); | |
| 192 | − await handler(new Request("http://test.local/integrations")); | |
| 191 | + await handler(new Request("http://test.local/integrations"), Context.empty()); | |
| 192 | + await handler(new Request("http://test.local/integrations"), Context.empty()); | |
| 193 | 193 | |
| 194 | 194 | expect(counts.acquires).toBe(2); |
| 195 | 195 | expect(counts.releases).toBe(2); |
| @@ -4,7 +4,7 @@ import { Layer } from "effect"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { makeProtectedApiLayer, requestScopedMiddleware } from "@executor-js/api/server"; |
| 6 | 6 | |
| 7 | −import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live"; | |
| 7 | +import { SessionAuthLive } from "../auth/middleware-live"; | |
| 8 | 8 | import { UserStoreService } from "../auth/context"; |
| 9 | 9 | import { |
| 10 | 10 | CloudAuthPublicHandlers, |
| @@ -14,6 +14,7 @@ import { · 3 unmodified lines | ||
| 14 | 14 | import { DbService } from "../db/db"; |
| 15 | 15 | import { WorkerTelemetryLive } from "../observability/telemetry"; |
| 16 | 16 | import { OrgHttpApi } from "../org/api"; |
| 17 | +import { orgAuthMiddleware } from "../org/auth-middleware"; | |
| 17 | 18 | import { OrgHandlers } from "../org/handlers"; |
| 18 | 19 | import { ErrorCaptureLive } from "../observability"; |
| 19 | 20 | |
| @@ -61,23 +62,24 @@ export const makeNonProtectedApiLive = (rsLive: Layer.Layer<DbService | UserStor · 41 unmodified lines | ||
| 61 | 62 | Layer.provideMerge(AutumnService.Default), |
| 62 | 63 | ); |
| 63 | 64 | |
| 64 | −// Cloud-only WorkOS domain-verification routes. Auth is enforced by `OrgAuth` | |
| 65 | −// middleware declared on `OrgHttpApi`. The domain handlers read the boot | |
| 66 | −// `WorkOSClient` plus the `AuthContext` from `OrgAuthLive`; the | |
| 67 | −// `getDomainVerificationLink` handler also gates on billing, so | |
| 68 | −// `AutumnService.Default` is provided HERE (not on the neutral boot core). | |
| 69 | −// Unlike the member endpoints that used to live here, they need no per-request | |
| 70 | −// DB scoping (and `OrgAuthLive` stays session-scoped — see its note). | |
| 71 | −export const OrgApiLive = HttpApiBuilder.layer(OrgHttpApi).pipe( | |
| 72 | − Layer.provide(OrgHandlers), | |
| 73 | − Layer.provideMerge(OrgAuthLive), | |
| 74 | − Layer.provideMerge(AutumnService.Default), | |
| 75 | −); | |
| 65 | +// Cloud-only WorkOS domain-verification routes. Auth is enforced by a router | |
| 66 | +// middleware that resolves the URL org selector header before falling back to | |
| 67 | +// the session org, so slug lookup uses the same per-request UserStoreService as | |
| 68 | +// the account and protected APIs. The `getDomainVerificationLink` handler also | |
| 69 | +// gates on billing, so `AutumnService.Default` is provided here, not on the | |
| 70 | +// neutral boot core. | |
| 71 | +export const makeOrgApiLive = (rsLive: Layer.Layer<DbService | UserStoreService>) => | |
| 72 | + HttpApiBuilder.layer(OrgHttpApi).pipe( | |
| 73 | + Layer.provide(OrgHandlers), | |
| 74 | + Layer.provide(orgAuthMiddleware(rsLive)), | |
| 75 | + Layer.provideMerge(AutumnService.Default), | |
| 76 | + ); | |
| 76 | 77 | |
| 77 | 78 | // Default export uses the production per-request layer. Existing callers that |
| 78 | 79 | // import `NonProtectedApiLive` continue to work; the `make*` factory exists for |
| 79 | 80 | // tests that need to swap in a fake. |
| 80 | 81 | export const NonProtectedApiLive = makeNonProtectedApiLive(RequestScopedServicesLive); |
| 82 | +export const OrgApiLive = makeOrgApiLive(RequestScopedServicesLive); | |
| 81 | 83 | |
| 82 | 84 | // --------------------------------------------------------------------------- |
| 83 | 85 | // Protected API |
| @@ -12,8 +12,8 @@ import { CloudDocsLive } from "../extensions/docs"; · 11 unmodified lines | ||
| 12 | 12 | import { ApiErrorLoggingLive } from "../observability/error-logging"; |
| 13 | 13 | import { |
| 14 | 14 | BootSharedServices, |
| 15 | − OrgApiLive, | |
| 16 | 15 | RequestScopedServicesLive, |
| 16 | + makeOrgApiLive, | |
| 17 | 17 | makeNonProtectedApiLive, |
| 18 | 18 | } from "./layers"; |
| 19 | 19 | import { makeProtectedApiLive } from "./protected"; |
| @@ -35,7 +35,7 @@ export const makeApiLive = (requestScopedLive: Layer.Layer<DbService | UserStore · 15 unmodified lines | ||
| 35 | 35 | ); |
| 36 | 36 | return Layer.mergeAll( |
| 37 | 37 | makeNonProtectedApiLive(requestScopedLive), |
| 38 | − OrgApiLive, | |
| 38 | + makeOrgApiLive(requestScopedLive), | |
| 39 | 39 | makeAccountApiLive(requestScopedLive), |
| 40 | 40 | CloudDocsLive, |
| 41 | 41 | makeProtectedApiLive(requestScopedLive), |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; |
| 2 | −import { HttpServerResponse } from "effect/unstable/http"; | |
| 2 | +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 3 | 3 | import { Duration, Effect, Predicate } from "effect"; |
| 4 | +import { isValidOrgSlug } from "@executor-js/api"; | |
| 4 | 5 | |
| 5 | 6 | import { |
| 6 | 7 | AUTH_PATHS, |
| @@ -25,7 +26,12 @@ import { · 18 unmodified lines | ||
| 25 | 26 | isOverFreeOrganizationLimit, |
| 26 | 27 | shouldApplyFreeOrganizationLimit, |
| 27 | 28 | } from "../extensions/billing/plans"; |
| 28 | −import { authorizeOrganization, resolveOrganization } from "./organization"; | |
| 29 | +import { | |
| 30 | + ORG_SELECTOR_HEADER, | |
| 31 | + authorizeOrganization, | |
| 32 | + authorizeOrganizationSelector, | |
| 33 | + resolveOrganization, | |
| 34 | +} from "./organization"; | |
| 29 | 35 | import type { |
| 30 | 36 | McpSessionApprovalResult, |
| 31 | 37 | McpSessionResumeApprovalResult, |
| @@ -82,14 +88,37 @@ const timingSafeEqual = (a: string, b: string): boolean => { · 50 unmodified lines | ||
| 82 | 88 | return diff === 0; |
| 83 | 89 | }; |
| 84 | 90 | |
| 85 | −const requireSessionOrganizationId = Effect.gen(function* () { | |
| 91 | +const requestHeaders = Effect.map(HttpServerRequest.HttpServerRequest.asEffect(), (req) => ({ | |
| 92 | + ...req.headers, | |
| 93 | +})); | |
| 94 | + | |
| 95 | +const firstPathSegment = (path: string): string | null => { | |
| 96 | + const pathname = path.split(/[?#]/, 1)[0] ?? ""; | |
| 97 | + const segment = pathname.split("/")[1]; | |
| 98 | + return segment && isValidOrgSlug(segment) ? segment : null; | |
| 99 | +}; | |
| 100 | + | |
| 101 | +const requestedOrgSelectorFromReturnTo = (returnTo: string): string | null => | |
| 102 | + firstPathSegment(returnTo); | |
| 103 | + | |
| 104 | +const requireSelectedOrganization = Effect.gen(function* () { | |
| 86 | 105 | const session = yield* SessionContext; |
| 87 | − if (!session.organizationId) { | |
| 106 | + const headers = yield* requestHeaders; | |
| 107 | + const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; | |
| 108 | + if (!selector) { | |
| 88 | 109 | return yield* new NoOrganization(); |
| 89 | 110 | } |
| 111 | + | |
| 112 | + const org = yield* authorizeOrganizationSelector(session.accountId, selector).pipe( | |
| 113 | + Effect.catch(() => Effect.fail(new NoOrganization())), | |
| 114 | + ); | |
| 115 | + if (!org) { | |
| 116 | + return yield* new NoOrganization(); | |
| 117 | + } | |
| 118 | + | |
| 90 | 119 | return { |
| 91 | 120 | ...session, |
| 92 | − organizationId: session.organizationId, | |
| 121 | + organizationId: org.id, | |
| 93 | 122 | }; |
| 94 | 123 | }); |
| 95 | 124 | |
| @@ -194,38 +223,47 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 98 unmodified lines | ||
| 194 | 223 | |
| 195 | 224 | let sealedSession = result.sealedSession; |
| 196 | 225 | |
| 197 | − // If the auth response didn't surface an org but the user is already | |
| 198 | − // an *active* member of one, rehydrate the session with it. Pending | |
| 199 | − // memberships (which represent unaccepted invitations on WorkOS's | |
| 200 | − // side) are skipped — refreshing into one 400s, and silently | |
| 201 | − // attaching an unaccepted org would also bypass invite consent. | |
| 202 | − // If they have no active memberships, leave the session org-less — | |
| 203 | − // AuthGate's onboarding flow surfaces pending invites and the | |
| 204 | − // create-org form. We never auto-create organizations on login. | |
| 205 | − if (!result.organizationId && sealedSession) { | |
| 226 | + // Resume where the SSR gate interrupted them. The state passed the | |
| 227 | + // CSRF check above whenever it's present, but it's still a | |
| 228 | + // round-tripped value, so the returnTo inside it is re-validated like | |
| 229 | + // any other untrusted path. | |
| 230 | + const returnTo = safeReturnTo(decodeLoginState(query.state)?.returnTo) ?? "/"; | |
| 231 | + const requestedOrgSelector = requestedOrgSelectorFromReturnTo(returnTo); | |
| 232 | + const requestedOrg = requestedOrgSelector | |
| 233 | + ? yield* authorizeOrganizationSelector(result.user.id, requestedOrgSelector).pipe( | |
| 234 | + Effect.orElseSucceed(() => null), | |
| 235 | + ) | |
| 236 | + : null; | |
| 237 | + | |
| 238 | + // Prefer the org in the URL that sent the user to login. If the URL | |
| 239 | + // is bare, or not an org route, fall back to WorkOS's org and then to | |
| 240 | + // the first active membership for org-less sessions. Pending | |
| 241 | + // memberships are skipped because refreshing into one 400s and would | |
| 242 | + // bypass invite consent. | |
| 243 | + let targetOrganizationId = requestedOrg?.id ?? result.organizationId ?? null; | |
| 244 | + if (!targetOrganizationId && !requestedOrgSelector) { | |
| 206 | 245 | const memberships = yield* workos.listUserMemberships(result.user.id); |
| 207 | 246 | const existingActive = memberships.data.find((m) => m.status === "active"); |
| 208 | − if (existingActive) { | |
| 209 | − // Best-effort refresh — if WorkOS rejects (e.g. the membership | |
| 210 | − // was just revoked), fall through to an org-less session rather | |
| 211 | − // than 500ing the entire callback. | |
| 212 | − const refreshed = yield* workos | |
| 213 | − .refreshSession(sealedSession, existingActive.organizationId) | |
| 214 | − .pipe(Effect.orElseSucceed(() => null)); | |
| 215 | − if (refreshed) sealedSession = refreshed; | |
| 216 | − } | |
| 247 | + targetOrganizationId = existingActive?.organizationId ?? null; | |
| 248 | + } | |
| 249 | + | |
| 250 | + if ( | |
| 251 | + targetOrganizationId && | |
| 252 | + targetOrganizationId !== result.organizationId && | |
| 253 | + sealedSession | |
| 254 | + ) { | |
| 255 | + // Best-effort refresh: if WorkOS rejects, fall through with the | |
| 256 | + // original session instead of 500ing the entire callback. | |
| 257 | + const refreshed = yield* workos | |
| 258 | + .refreshSession(sealedSession, targetOrganizationId) | |
| 259 | + .pipe(Effect.orElseSucceed(() => null)); | |
| 260 | + if (refreshed) sealedSession = refreshed; | |
| 217 | 261 | } |
| 218 | 262 | |
| 219 | 263 | if (!sealedSession) { |
| 220 | 264 | return HttpServerResponse.text("Failed to create session", { status: 500 }); |
| 221 | 265 | } |
| 222 | 266 | |
| 223 | − // Resume where the SSR gate interrupted them. The state passed the | |
| 224 | − // CSRF check above whenever it's present, but it's still a | |
| 225 | − // round-tripped value — so the returnTo inside it is re-validated | |
| 226 | − // like any other untrusted path. | |
| 227 | − const returnTo = safeReturnTo(decodeLoginState(query.state)?.returnTo) ?? "/"; | |
| 228 | − | |
| 229 | 267 | return deleteResponseCookie( |
| 230 | 268 | setResponseCookie( |
| 231 | 269 | HttpServerResponse.redirect(returnTo, { status: 302 }), |
| @@ -489,7 +527,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 257 unmodified lines | ||
| 489 | 527 | ) |
| 490 | 528 | .handle("getMcpPaused", ({ params }) => |
| 491 | 529 | Effect.gen(function* () { |
| 492 | − const owner = yield* requireSessionOrganizationId; | |
| 530 | + const owner = yield* requireSelectedOrganization; | |
| 493 | 531 | const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId); |
| 494 | 532 | const result = yield* Effect.promise( |
| 495 | 533 | () => |
| @@ -511,7 +549,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 15 unmodified lines | ||
| 511 | 549 | ) |
| 512 | 550 | .handle("resumeMcpExecution", ({ params, payload }) => |
| 513 | 551 | Effect.gen(function* () { |
| 514 | − const owner = yield* requireSessionOrganizationId; | |
| 552 | + const owner = yield* requireSelectedOrganization; | |
| 515 | 553 | const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId); |
| 516 | 554 | const result = yield* Effect.promise( |
| 517 | 555 | () => |
| @@ -85,13 +85,6 @@ export const OrgAuthLive = Layer.effect( · 84 unmodified lines | ||
| 85 | 85 | return yield* Effect.fail(new NoOrganization()); |
| 86 | 86 | } |
| 87 | 87 | |
| 88 | − // NOTE: the domains plane stays scoped to the SESSION org, not the URL | |
| 89 | − // org. Unlike the data + account planes (which resolve the selector | |
| 90 | − // header), this `HttpApiMiddleware` security handler may carry no | |
| 91 | − // residual requirement, so it can't reach the per-request | |
| 92 | − // `UserStoreService` a slug→id resolution needs. The domains surface | |
| 93 | − // (org-settings → domain verification) is niche; URL-scoping it would | |
| 94 | − // mean converting this to an HttpRouter middleware — deferred. | |
| 95 | 88 | const session = sessionFromSealed(result, Redacted.value(credential)); |
| 96 | 89 | const auth = { |
| 97 | 90 | accountId: session.accountId, |
| @@ -33,8 +33,9 @@ import { · 32 unmodified lines | ||
| 33 | 33 | NonProtectedApi, |
| 34 | 34 | } from "../auth/handlers"; |
| 35 | 35 | import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; |
| 36 | −import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live"; | |
| 36 | +import { SessionAuthLive } from "../auth/middleware-live"; | |
| 37 | 37 | import { OrgApi, OrgHttpApi } from "../org/api"; |
| 38 | +import { orgAuthMiddleware } from "../org/auth-middleware"; | |
| 38 | 39 | import { OrgHandlers } from "../org/handlers"; |
| 39 | 40 | import { AutumnService } from "../extensions/billing/service"; |
| 40 | 41 | import { DbService } from "../db/db"; |
| @@ -79,11 +80,13 @@ export const makeCloudExtensionRoutes = (rsLive: Layer.Layer<DbService | UserSto · 38 unmodified lines | ||
| 79 | 80 | Layer.provide(apiPrefixedRouter), |
| 80 | 81 | ); |
| 81 | 82 | |
| 82 | − // Cloud-only WorkOS domain-verification routes; `OrgAuth` enforces an | |
| 83 | − // authenticated org session. No per-request DB scoping needed. | |
| 83 | + // Cloud-only WorkOS domain-verification routes; the auth middleware resolves | |
| 84 | + // the URL org selector header before falling back to the session org, so slug | |
| 85 | + // lookup needs the same request-scoped UserStoreService as other org-scoped | |
| 86 | + // APIs. | |
| 84 | 87 | const OrgRoutes = HttpApiBuilder.layer(OrgHttpApi).pipe( |
| 85 | 88 | Layer.provide(OrgHandlers), |
| 86 | − Layer.provideMerge(OrgAuthLive), | |
| 89 | + Layer.provide(orgAuthMiddleware(rsLive)), | |
| 87 | 90 | Layer.provideMerge(AutumnService.Default), |
| 88 | 91 | Layer.provide(apiPrefixedRouter), |
| 89 | 92 | ); |
| @@ -217,6 +217,7 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> { · 216 unmodified lines | ||
| 217 | 217 | origin: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", |
| 218 | 218 | executionId, |
| 219 | 219 | sessionId: self.sessionId, |
| 220 | + organizationSlug: sessionMeta.organizationSlug, | |
| 220 | 221 | }), |
| 221 | 222 | } |
| 222 | 223 | : { mode: sessionElicitationMode }, |
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { WorkOSError } from "../auth/errors"; |
| 4 | −import { OrgAuth } from "../auth/middleware"; | |
| 5 | 4 | |
| 6 | 5 | // --------------------------------------------------------------------------- |
| 7 | 6 | // Cloud-local org API — the WorkOS domain-verification surface only. Members / |
| @@ -59,5 +58,5 @@ export class OrgApi extends HttpApiGroup.make("org") · 51 unmodified lines | ||
| 59 | 58 | }), |
| 60 | 59 | ) {} |
| 61 | 60 | |
| 62 | −/** Org API with org-level auth — requires authenticated session with an org. */ | |
| 63 | −export const OrgHttpApi = HttpApi.make("org").add(OrgApi).middleware(OrgAuth); | |
| 61 | +/** Org API with org-level auth supplied by the router middleware in auth-middleware.ts. */ | |
| 62 | +export const OrgHttpApi = HttpApi.make("org").add(OrgApi); | |
| @@ -0,0 +1,67 @@ | ||
| 1 | +import { Effect, Layer } from "effect"; | |
| 2 | +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 3 | + | |
| 4 | +import { AuthContext, requestScopedMiddleware } from "@executor-js/api/server"; | |
| 5 | + | |
| 6 | +import { UserStoreService } from "../auth/context"; | |
| 7 | +import { sessionFromSealed } from "../auth/middleware"; | |
| 8 | +import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; | |
| 9 | +import { WorkOSClient } from "../auth/workos"; | |
| 10 | +import { DbService } from "../db/db"; | |
| 11 | + | |
| 12 | +const unauthorized = () => | |
| 13 | + HttpServerResponse.jsonUnsafe( | |
| 14 | + { | |
| 15 | + error: "Invalid or expired session", | |
| 16 | + code: "invalid_session", | |
| 17 | + }, | |
| 18 | + { status: 401 }, | |
| 19 | + ); | |
| 20 | + | |
| 21 | +const noOrganization = () => | |
| 22 | + HttpServerResponse.jsonUnsafe( | |
| 23 | + { | |
| 24 | + error: "No organization in session", | |
| 25 | + code: "no_organization", | |
| 26 | + }, | |
| 27 | + { status: 403 }, | |
| 28 | + ); | |
| 29 | + | |
| 30 | +const OrgAuthMiddleware = HttpRouter.middleware<{ provides: AuthContext }>()( | |
| 31 | + Effect.gen(function* () { | |
| 32 | + const captured = yield* Effect.context<WorkOSClient>(); | |
| 33 | + const workos = yield* WorkOSClient; | |
| 34 | + return (httpEffect) => | |
| 35 | + Effect.gen(function* () { | |
| 36 | + const request = yield* HttpServerRequest.HttpServerRequest; | |
| 37 | + const cookieValue = request.cookies["wos-session"] ?? ""; | |
| 38 | + const result = yield* workos | |
| 39 | + .authenticateSealedSession(cookieValue) | |
| 40 | + .pipe(Effect.orElseSucceed(() => null)); | |
| 41 | + if (!result) return unauthorized(); | |
| 42 | + | |
| 43 | + const selector = request.headers[ORG_SELECTOR_HEADER] ?? result.organizationId; | |
| 44 | + if (!selector) return noOrganization(); | |
| 45 | + | |
| 46 | + const org = yield* authorizeOrganizationSelector(result.userId, selector).pipe( | |
| 47 | + Effect.orElseSucceed(() => null), | |
| 48 | + ); | |
| 49 | + if (!org) return noOrganization(); | |
| 50 | + | |
| 51 | + const session = sessionFromSealed(result, cookieValue); | |
| 52 | + const auth = AuthContext.of({ | |
| 53 | + accountId: session.accountId, | |
| 54 | + organizationId: org.id, | |
| 55 | + email: session.email, | |
| 56 | + name: session.name, | |
| 57 | + avatarUrl: session.avatarUrl, | |
| 58 | + roles: [], | |
| 59 | + }); | |
| 60 | + | |
| 61 | + return yield* Effect.provideService(httpEffect, AuthContext, auth); | |
| 62 | + }).pipe(Effect.provideContext(captured)); | |
| 63 | + }), | |
| 64 | +); | |
| 65 | + | |
| 66 | +export const orgAuthMiddleware = (rsLive: Layer.Layer<DbService | UserStoreService>) => | |
| 67 | + OrgAuthMiddleware.combine(requestScopedMiddleware(rsLive)).layer; | |
| @@ -5,6 +5,7 @@ import { AuthContext } from "@executor-js/api/server"; · 4 unmodified lines | ||
| 5 | 5 | import { env } from "cloudflare:workers"; |
| 6 | 6 | import { WorkOSClient } from "../auth/workos"; |
| 7 | 7 | import { AutumnService } from "../extensions/billing/service"; |
| 8 | +import { resolveOrganization } from "../auth/organization"; | |
| 8 | 9 | import { Forbidden, OrgHttpApi } from "./api"; |
| 9 | 10 | |
| 10 | 11 | // --------------------------------------------------------------------------- |
| @@ -89,13 +90,16 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 78 unmodified lines | ||
| 89 | 90 | return yield* new Forbidden(); |
| 90 | 91 | } |
| 91 | 92 | |
| 93 | + const org = yield* resolveOrganization(auth.organizationId).pipe( | |
| 94 | + Effect.catchCause(() => Effect.succeed(null)), | |
| 95 | + ); | |
| 96 | + const returnPath = org?.slug ? `/${org.slug}/org` : "/org"; | |
| 92 | 97 | const workos = yield* WorkOSClient; |
| 93 | − // WorkOS requires an ABSOLUTE returnUrl — fall back to the pinned origin | |
| 94 | − // (matching every other VITE_PUBLIC_SITE_URL site in this host), not a | |
| 95 | − // bare relative path that WorkOS would reject. | |
| 98 | + // WorkOS requires an absolute returnUrl. Keep the URL org slug in that | |
| 99 | + // return path so the browser comes back to the same selected org. | |
| 96 | 100 | const { link } = yield* workos.generateDomainVerificationPortalLink( |
| 97 | 101 | auth.organizationId, |
| 98 | − `${env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh"}/org`, | |
| 102 | + `${env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh"}${returnPath}`, | |
| 99 | 103 | ); |
| 100 | 104 | return { link }; |
| 101 | 105 | }), |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; |
| 2 | −import { FetchHttpClient } from "effect/unstable/http"; | |
| 2 | +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | |
| 3 | 3 | import { addGroup } from "@executor-js/api"; |
| 4 | 4 | import { getBaseUrl } from "@executor-js/react/api/base-url"; |
| 5 | +import { EXECUTOR_ORG_HEADER, getActiveOrgSlug } from "@executor-js/react/api/server-connection"; | |
| 5 | 6 | import { CloudAuthApi } from "../auth/api"; |
| 6 | 7 | import { OrgApi } from "../org/api"; |
| 7 | 8 | |
| @@ -14,6 +15,10 @@ const CloudApiClient = AtomHttpApi.Service<"CloudApiClient">()("CloudApiClient", · 6 unmodified lines | ||
| 14 | 15 | api: CloudApi, |
| 15 | 16 | httpClient: FetchHttpClient.layer, |
| 16 | 17 | baseUrl: getBaseUrl(), |
| 18 | + transformClient: HttpClient.mapRequest((request) => { | |
| 19 | + const orgSlug = getActiveOrgSlug(); | |
| 20 | + return orgSlug ? HttpClientRequest.setHeader(request, EXECUTOR_ORG_HEADER, orgSlug) : request; | |
| 21 | + }), | |
| 17 | 22 | }); |
| 18 | 23 | |
| 19 | 24 | export { CloudApiClient }; |
| @@ -108,6 +108,7 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 107 unmodified lines | ||
| 108 | 108 | origin: origin ?? "https://unconfigured-origin.invalid", |
| 109 | 109 | executionId, |
| 110 | 110 | sessionId: self.sessionId, |
| 111 | + organizationSlug: sessionMeta.organizationSlug, | |
| 111 | 112 | }); |
| 112 | 113 | }, |
| 113 | 114 | } |
| @@ -0,0 +1,206 @@ | ||
| 1 | +// Cloud-only: MCP browser approval URLs must preserve the session's org scope. | |
| 2 | +// | |
| 3 | +// The MCP session is created in org A, then the browser cookie is moved to org B | |
| 4 | +// before the human opens the approval link. The returned approval URL itself | |
| 5 | +// must carry org A's slug so the rendered approval page scopes both GET and | |
| 6 | +// resume POST requests to org A, not the cookie-pinned org B. | |
| 7 | +import { randomBytes } from "node:crypto"; | |
| 8 | + | |
| 9 | +import { expect } from "@effect/vitest"; | |
| 10 | +import { Effect } from "effect"; | |
| 11 | +import type { Page, Request } from "playwright"; | |
| 12 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 13 | + | |
| 14 | +import { scenario } from "../src/scenario"; | |
| 15 | +import { Api, Browser, Mcp, Target } from "../src/services"; | |
| 16 | +import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp"; | |
| 17 | + | |
| 18 | +const coreApi = composePluginApi([] as const); | |
| 19 | + | |
| 20 | +const GATE_TOOL = "executor.coreTools.policies.list"; | |
| 21 | + | |
| 22 | +const GATED_CODE = ` | |
| 23 | +const result = await tools.executor.coreTools.policies.list({}); | |
| 24 | +return JSON.stringify(result); | |
| 25 | +`; | |
| 26 | + | |
| 27 | +const cookiePair = (response: Response, name: string): string | undefined => { | |
| 28 | + for (const header of response.headers.getSetCookie?.() ?? []) { | |
| 29 | + if (header.startsWith(`${name}=`)) return header.split(";")[0]; | |
| 30 | + } | |
| 31 | + return undefined; | |
| 32 | +}; | |
| 33 | + | |
| 34 | +const cookieValue = (pair: string): string => { | |
| 35 | + const [, value] = pair.split(/=(.*)/s); | |
| 36 | + if (!value) throw new Error("cookie pair has no value"); | |
| 37 | + return value; | |
| 38 | +}; | |
| 39 | + | |
| 40 | +const cookieOf = (identity: { readonly headers?: Record<string, string> }): string => | |
| 41 | + identity.headers?.cookie ?? ""; | |
| 42 | + | |
| 43 | +const originHeaders = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); | |
| 44 | + | |
| 45 | +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| 46 | + | |
| 47 | +const setWorkosSessionCookie = async (page: Page, baseUrl: string, cookie: string) => { | |
| 48 | + await page.context().addCookies([ | |
| 49 | + { | |
| 50 | + name: "wos-session", | |
| 51 | + value: cookieValue(cookie), | |
| 52 | + url: baseUrl, | |
| 53 | + }, | |
| 54 | + ]); | |
| 55 | +}; | |
| 56 | + | |
| 57 | +const expectOrgShell = async ( | |
| 58 | + page: Page, | |
| 59 | + org: { readonly name: string; readonly slug: string }, | |
| 60 | +) => { | |
| 61 | + await page.waitForURL( | |
| 62 | + (url) => url.pathname === `/${org.slug}` || url.pathname === `/${org.slug}/`, | |
| 63 | + { | |
| 64 | + timeout: 30_000, | |
| 65 | + }, | |
| 66 | + ); | |
| 67 | + await page.getByRole("button", { name: new RegExp(escapeRegExp(org.name)) }).waitFor(); | |
| 68 | + await page.getByRole("heading", { name: "Integrations" }).waitFor(); | |
| 69 | +}; | |
| 70 | + | |
| 71 | +const activeOrg = (baseUrl: string, cookie: string) => | |
| 72 | + Effect.promise(async () => { | |
| 73 | + const response = await fetch(new URL("/api/auth/me", baseUrl), { | |
| 74 | + headers: { cookie }, | |
| 75 | + }); | |
| 76 | + if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`); | |
| 77 | + const body = (await response.json()) as { | |
| 78 | + organization: { id: string; name: string; slug: string } | null; | |
| 79 | + }; | |
| 80 | + if (!body.organization) throw new Error("identity has no active organization"); | |
| 81 | + return body.organization; | |
| 82 | + }); | |
| 83 | + | |
| 84 | +const createOrganization = (baseUrl: string, cookie: string, name: string) => | |
| 85 | + Effect.promise(async () => { | |
| 86 | + const response = await fetch(new URL("/api/auth/create-organization", baseUrl), { | |
| 87 | + method: "POST", | |
| 88 | + headers: { | |
| 89 | + "content-type": "application/json", | |
| 90 | + cookie, | |
| 91 | + ...originHeaders(baseUrl), | |
| 92 | + }, | |
| 93 | + body: JSON.stringify({ name }), | |
| 94 | + }); | |
| 95 | + if (!response.ok) { | |
| 96 | + throw new Error(`/api/auth/create-organization failed (${response.status})`); | |
| 97 | + } | |
| 98 | + const session = cookiePair(response, "wos-session"); | |
| 99 | + if (!session) throw new Error("create organization did not refresh the session"); | |
| 100 | + const org = (await response.json()) as { id: string; name: string; slug: string }; | |
| 101 | + return { org, session }; | |
| 102 | + }); | |
| 103 | + | |
| 104 | +const approvalApiRequest = | |
| 105 | + (approval: McpBrowserApproval, method: "GET" | "POST") => | |
| 106 | + (request: Request): boolean => { | |
| 107 | + const url = request.url(); | |
| 108 | + const executionPath = `/executions/${encodeURIComponent(approval.executionId)}`; | |
| 109 | + return ( | |
| 110 | + request.method() === method && | |
| 111 | + url.includes("/api/mcp-sessions/") && | |
| 112 | + url.includes(executionPath) && | |
| 113 | + (method === "POST" ? url.endsWith(`${executionPath}/resume`) : !url.endsWith("/resume")) | |
| 114 | + ); | |
| 115 | + }; | |
| 116 | + | |
| 117 | +scenario( | |
| 118 | + "MCP approval · URL-scoped org survives approval while the session cookie points elsewhere", | |
| 119 | + { timeout: 180_000 }, | |
| 120 | + Effect.gen(function* () { | |
| 121 | + const target = yield* Target; | |
| 122 | + const api = yield* Api; | |
| 123 | + const browser = yield* Browser; | |
| 124 | + const mcp = yield* Mcp; | |
| 125 | + | |
| 126 | + const identity = yield* target.newIdentity(); | |
| 127 | + const sessionA = cookieOf(identity); | |
| 128 | + const orgA = yield* activeOrg(target.baseUrl, sessionA); | |
| 129 | + const client = yield* api.client(coreApi, identity); | |
| 130 | + | |
| 131 | + const policy = yield* client.policies.create({ | |
| 132 | + payload: { owner: "org", pattern: GATE_TOOL, action: "require_approval" }, | |
| 133 | + }); | |
| 134 | + | |
| 135 | + yield* Effect.gen(function* () { | |
| 136 | + const mcpSession = mcp.session(identity, { elicitationMode: "browser" }); | |
| 137 | + yield* mcpSession.listTools(); | |
| 138 | + | |
| 139 | + const paused = yield* mcpSession.call("execute", { code: GATED_CODE }); | |
| 140 | + const approval = parseBrowserApproval(paused); | |
| 141 | + const approvalUrl = new URL(approval.approvalUrl); | |
| 142 | + expect(approvalUrl.pathname, "the real MCP approval URL is pinned to the session org").toBe( | |
| 143 | + `/${orgA.slug}/resume/${approval.executionId}`, | |
| 144 | + ); | |
| 145 | + | |
| 146 | + const { org: orgB, session: sessionB } = yield* createOrganization( | |
| 147 | + target.baseUrl, | |
| 148 | + sessionA, | |
| 149 | + `MCP Approval Org B ${randomBytes(3).toString("hex")}`, | |
| 150 | + ); | |
| 151 | + expect(orgB.slug, "the browser can be pinned to a different org").not.toBe(orgA.slug); | |
| 152 | + | |
| 153 | + const [resumed] = yield* Effect.all( | |
| 154 | + [ | |
| 155 | + mcpSession.awaitResume(approval.executionId), | |
| 156 | + browser.session(identity, async ({ page, step }) => { | |
| 157 | + await step("Land in the original organization", async () => { | |
| 158 | + await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" }); | |
| 159 | + await expectOrgShell(page, orgA); | |
| 160 | + }); | |
| 161 | + | |
| 162 | + await step("The browser session is switched to another organization", async () => { | |
| 163 | + await setWorkosSessionCookie(page, target.baseUrl, sessionB); | |
| 164 | + await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" }); | |
| 165 | + await expectOrgShell(page, orgB); | |
| 166 | + }); | |
| 167 | + | |
| 168 | + await step("Open the original organization's approval URL and approve", async () => { | |
| 169 | + const loadRequest = page.waitForRequest(approvalApiRequest(approval, "GET"), { | |
| 170 | + timeout: 30_000, | |
| 171 | + }); | |
| 172 | + await page.goto(approval.approvalUrl, { waitUntil: "networkidle" }); | |
| 173 | + expect( | |
| 174 | + (await loadRequest).headers()["x-executor-organization"], | |
| 175 | + "loading the approval page scopes the paused execution lookup to org A", | |
| 176 | + ).toBe(orgA.slug); | |
| 177 | + | |
| 178 | + await page.getByRole("button", { name: "Approve" }).waitFor(); | |
| 179 | + const resumeRequest = page.waitForRequest(approvalApiRequest(approval, "POST"), { | |
| 180 | + timeout: 30_000, | |
| 181 | + }); | |
| 182 | + await page.getByRole("button", { name: "Approve" }).click(); | |
| 183 | + expect( | |
| 184 | + (await resumeRequest).headers()["x-executor-organization"], | |
| 185 | + "approving the paused execution scopes the resume POST to org A", | |
| 186 | + ).toBe(orgA.slug); | |
| 187 | + await page.getByText("Approve sent").waitFor(); | |
| 188 | + }); | |
| 189 | + }), | |
| 190 | + ], | |
| 191 | + { concurrency: "unbounded" }, | |
| 192 | + ); | |
| 193 | + | |
| 194 | + expect(resumed.ok, "the approved execution completed without error").toBe(true); | |
| 195 | + expect(resumed.text, "the gated tool ran in org A and returned its policy").toContain( | |
| 196 | + policy.id, | |
| 197 | + ); | |
| 198 | + }).pipe( | |
| 199 | + Effect.ensuring( | |
| 200 | + client.policies | |
| 201 | + .remove({ params: { policyId: policy.id }, payload: { owner: "org" } }) | |
| 202 | + .pipe(Effect.ignore), | |
| 203 | + ), | |
| 204 | + ); | |
| 205 | + }), | |
| 206 | +); | |
| @@ -53,6 +53,8 @@ export const readElicitationMode = (request: Request): McpElicitationMode => { · 52 unmodified lines | ||
| 53 | 53 | |
| 54 | 54 | /** |
| 55 | 55 | * Build the console approval URL for a paused execution: |
| 56 | + * `<origin>/<organizationSlug>/resume/<executionId>?mcp_session_id=<sessionId>` | |
| 57 | + * when the host knows the org slug, otherwise | |
| 56 | 58 | * `<origin>/resume/<executionId>?mcp_session_id=<sessionId>`. The |
| 57 | 59 | * `mcp_session_id` query routes the console's resume page back to the host's |
| 58 | 60 | * approval endpoint for that session. |
| @@ -61,8 +63,10 @@ export const buildResumeApprovalUrl = (input: { · 2 unmodified lines | ||
| 61 | 63 | readonly origin: string | URL; |
| 62 | 64 | readonly executionId: string; |
| 63 | 65 | readonly sessionId?: string | null; |
| 66 | + readonly organizationSlug?: string | null; | |
| 64 | 67 | }): string => { |
| 65 | − const url = new URL(`/resume/${encodeURIComponent(input.executionId)}`, input.origin); | |
| 68 | + const orgPath = input.organizationSlug ? `/${encodeURIComponent(input.organizationSlug)}` : ""; | |
| 69 | + const url = new URL(`${orgPath}/resume/${encodeURIComponent(input.executionId)}`, input.origin); | |
| 66 | 70 | if (input.sessionId) url.searchParams.set("mcp_session_id", input.sessionId); |
| 67 | 71 | return url.toString(); |
| 68 | 72 | }; |