Unified · Split
| @@ -188,8 +188,8 @@ describe("makeApiLive (prod handler factory) request scoping", () => { · 187 unmodified lines | |||
| 188 | // has built the per-request layer. We don't care about the response — | 188 | // has built the per-request layer. We don't care about the response — |
| 189 | // only that the layer was built once per request. `/integrations` is a | 189 | // only that the layer was built once per request. `/integrations` is a |
| 190 | // v2 protected route (the old `/scope` group was removed). | 190 | // v2 protected route (the old `/scope` group was removed). |
| 191 | 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")); | 192 | await handler(new Request("http://test.local/integrations"), Context.empty()); |
| 193 | 193 | ||
| 194 | expect(counts.acquires).toBe(2); | 194 | expect(counts.acquires).toBe(2); |
| 195 | expect(counts.releases).toBe(2); | 195 | expect(counts.releases).toBe(2); |
| @@ -4,7 +4,7 @@ import { Layer } from "effect"; · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { makeProtectedApiLayer, requestScopedMiddleware } from "@executor-js/api/server"; | 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 | import { UserStoreService } from "../auth/context"; | 8 | import { UserStoreService } from "../auth/context"; |
| 9 | import { | 9 | import { |
| 10 | CloudAuthPublicHandlers, | 10 | CloudAuthPublicHandlers, |
| @@ -14,6 +14,7 @@ import { · 3 unmodified lines | |||
| 14 | import { DbService } from "../db/db"; | 14 | import { DbService } from "../db/db"; |
| 15 | import { WorkerTelemetryLive } from "../observability/telemetry"; | 15 | import { WorkerTelemetryLive } from "../observability/telemetry"; |
| 16 | import { OrgHttpApi } from "../org/api"; | 16 | import { OrgHttpApi } from "../org/api"; |
| 17 | import { orgAuthMiddleware } from "../org/auth-middleware"; | ||
| 17 | import { OrgHandlers } from "../org/handlers"; | 18 | import { OrgHandlers } from "../org/handlers"; |
| 18 | import { ErrorCaptureLive } from "../observability"; | 19 | import { ErrorCaptureLive } from "../observability"; |
| 19 | 20 | ||
| @@ -61,23 +62,24 @@ export const makeNonProtectedApiLive = (rsLive: Layer.Layer<DbService | UserStor · 41 unmodified lines | |||
| 61 | Layer.provideMerge(AutumnService.Default), | 62 | Layer.provideMerge(AutumnService.Default), |
| 62 | ); | 63 | ); |
| 63 | 64 | ||
| 64 | // Cloud-only WorkOS domain-verification routes. Auth is enforced by `OrgAuth` | 65 | // Cloud-only WorkOS domain-verification routes. Auth is enforced by a router |
| 65 | // middleware declared on `OrgHttpApi`. The domain handlers read the boot | 66 | // middleware that resolves the URL org selector header before falling back to |
| 66 | // `WorkOSClient` plus the `AuthContext` from `OrgAuthLive`; the | 67 | // the session org, so slug lookup uses the same per-request UserStoreService as |
| 67 | // `getDomainVerificationLink` handler also gates on billing, so | 68 | // the account and protected APIs. The `getDomainVerificationLink` handler also |
| 68 | // `AutumnService.Default` is provided HERE (not on the neutral boot core). | 69 | // gates on billing, so `AutumnService.Default` is provided here, not on the |
| 69 | // Unlike the member endpoints that used to live here, they need no per-request | 70 | // neutral boot core. |
| 70 | // DB scoping (and `OrgAuthLive` stays session-scoped — see its note). | 71 | export const makeOrgApiLive = (rsLive: Layer.Layer<DbService | UserStoreService>) => |
| 71 | export const OrgApiLive = HttpApiBuilder.layer(OrgHttpApi).pipe( | 72 | HttpApiBuilder.layer(OrgHttpApi).pipe( |
| 72 | Layer.provide(OrgHandlers), | 73 | Layer.provide(OrgHandlers), |
| 73 | Layer.provideMerge(OrgAuthLive), | 74 | Layer.provide(orgAuthMiddleware(rsLive)), |
| 74 | Layer.provideMerge(AutumnService.Default), | 75 | Layer.provideMerge(AutumnService.Default), |
| 75 | ); | 76 | ); |
| 76 | 77 | ||
| 77 | // Default export uses the production per-request layer. Existing callers that | 78 | // Default export uses the production per-request layer. Existing callers that |
| 78 | // import `NonProtectedApiLive` continue to work; the `make*` factory exists for | 79 | // import `NonProtectedApiLive` continue to work; the `make*` factory exists for |
| 79 | // tests that need to swap in a fake. | 80 | // tests that need to swap in a fake. |
| 80 | export const NonProtectedApiLive = makeNonProtectedApiLive(RequestScopedServicesLive); | 81 | export const NonProtectedApiLive = makeNonProtectedApiLive(RequestScopedServicesLive); |
| 82 | export const OrgApiLive = makeOrgApiLive(RequestScopedServicesLive); | ||
| 81 | 83 | ||
| 82 | // --------------------------------------------------------------------------- | 84 | // --------------------------------------------------------------------------- |
| 83 | // Protected API | 85 | // Protected API |
| @@ -12,8 +12,8 @@ import { CloudDocsLive } from "../extensions/docs"; · 11 unmodified lines | |||
| 12 | import { ApiErrorLoggingLive } from "../observability/error-logging"; | 12 | import { ApiErrorLoggingLive } from "../observability/error-logging"; |
| 13 | import { | 13 | import { |
| 14 | BootSharedServices, | 14 | BootSharedServices, |
| 15 | OrgApiLive, | ||
| 16 | RequestScopedServicesLive, | 15 | RequestScopedServicesLive, |
| 16 | makeOrgApiLive, | ||
| 17 | makeNonProtectedApiLive, | 17 | makeNonProtectedApiLive, |
| 18 | } from "./layers"; | 18 | } from "./layers"; |
| 19 | import { makeProtectedApiLive } from "./protected"; | 19 | import { makeProtectedApiLive } from "./protected"; |
| @@ -35,7 +35,7 @@ export const makeApiLive = (requestScopedLive: Layer.Layer<DbService | UserStore · 15 unmodified lines | |||
| 35 | ); | 35 | ); |
| 36 | return Layer.mergeAll( | 36 | return Layer.mergeAll( |
| 37 | makeNonProtectedApiLive(requestScopedLive), | 37 | makeNonProtectedApiLive(requestScopedLive), |
| 38 | OrgApiLive, | 38 | makeOrgApiLive(requestScopedLive), |
| 39 | makeAccountApiLive(requestScopedLive), | 39 | makeAccountApiLive(requestScopedLive), |
| 40 | CloudDocsLive, | 40 | CloudDocsLive, |
| 41 | makeProtectedApiLive(requestScopedLive), | 41 | makeProtectedApiLive(requestScopedLive), |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; | 1 | import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; |
| 2 | import { HttpServerResponse } from "effect/unstable/http"; | 2 | import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 3 | import { Duration, Effect, Predicate } from "effect"; | 3 | import { Duration, Effect, Predicate } from "effect"; |
| 4 | import { isValidOrgSlug } from "@executor-js/api"; | ||
| 4 | 5 | ||
| 5 | import { | 6 | import { |
| 6 | AUTH_PATHS, | 7 | AUTH_PATHS, |
| @@ -25,7 +26,12 @@ import { · 18 unmodified lines | |||
| 25 | isOverFreeOrganizationLimit, | 26 | isOverFreeOrganizationLimit, |
| 26 | shouldApplyFreeOrganizationLimit, | 27 | shouldApplyFreeOrganizationLimit, |
| 27 | } from "../extensions/billing/plans"; | 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 | import type { | 35 | import type { |
| 30 | McpSessionApprovalResult, | 36 | McpSessionApprovalResult, |
| 31 | McpSessionResumeApprovalResult, | 37 | McpSessionResumeApprovalResult, |
| @@ -82,14 +88,37 @@ const timingSafeEqual = (a: string, b: string): boolean => { · 50 unmodified lines | |||
| 82 | return diff === 0; | 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 | const session = yield* SessionContext; | 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 | return yield* new NoOrganization(); | 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 | return { | 119 | return { |
| 91 | ...session, | 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 | let sealedSession = result.sealedSession; | 224 | let sealedSession = result.sealedSession; |
| 196 | 225 | ||
| 197 | // If the auth response didn't surface an org but the user is already | 226 | // Resume where the SSR gate interrupted them. The state passed the |
| 198 | // an *active* member of one, rehydrate the session with it. Pending | 227 | // CSRF check above whenever it's present, but it's still a |
| 199 | // memberships (which represent unaccepted invitations on WorkOS's | 228 | // round-tripped value, so the returnTo inside it is re-validated like |
| 200 | // side) are skipped — refreshing into one 400s, and silently | 229 | // any other untrusted path. |
| 201 | // attaching an unaccepted org would also bypass invite consent. | 230 | const returnTo = safeReturnTo(decodeLoginState(query.state)?.returnTo) ?? "/"; |
| 202 | // If they have no active memberships, leave the session org-less — | 231 | const requestedOrgSelector = requestedOrgSelectorFromReturnTo(returnTo); |
| 203 | // AuthGate's onboarding flow surfaces pending invites and the | 232 | const requestedOrg = requestedOrgSelector |
| 204 | // create-org form. We never auto-create organizations on login. | 233 | ? yield* authorizeOrganizationSelector(result.user.id, requestedOrgSelector).pipe( |
| 205 | if (!result.organizationId && sealedSession) { | 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 | const memberships = yield* workos.listUserMemberships(result.user.id); | 245 | const memberships = yield* workos.listUserMemberships(result.user.id); |
| 207 | const existingActive = memberships.data.find((m) => m.status === "active"); | 246 | const existingActive = memberships.data.find((m) => m.status === "active"); |
| 208 | if (existingActive) { | 247 | targetOrganizationId = existingActive?.organizationId ?? null; |
| 209 | // Best-effort refresh — if WorkOS rejects (e.g. the membership | 248 | } |
| 210 | // was just revoked), fall through to an org-less session rather | 249 | |
| 211 | // than 500ing the entire callback. | 250 | if ( |
| 212 | const refreshed = yield* workos | 251 | targetOrganizationId && |
| 213 | .refreshSession(sealedSession, existingActive.organizationId) | 252 | targetOrganizationId !== result.organizationId && |
| 214 | .pipe(Effect.orElseSucceed(() => null)); | 253 | sealedSession |
| 215 | if (refreshed) sealedSession = refreshed; | 254 | ) { |
| 216 | } | 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 | if (!sealedSession) { | 263 | if (!sealedSession) { |
| 220 | return HttpServerResponse.text("Failed to create session", { status: 500 }); | 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 | return deleteResponseCookie( | 267 | return deleteResponseCookie( |
| 230 | setResponseCookie( | 268 | setResponseCookie( |
| 231 | HttpServerResponse.redirect(returnTo, { status: 302 }), | 269 | HttpServerResponse.redirect(returnTo, { status: 302 }), |
| @@ -489,7 +527,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 257 unmodified lines | |||
| 489 | ) | 527 | ) |
| 490 | .handle("getMcpPaused", ({ params }) => | 528 | .handle("getMcpPaused", ({ params }) => |
| 491 | Effect.gen(function* () { | 529 | Effect.gen(function* () { |
| 492 | const owner = yield* requireSessionOrganizationId; | 530 | const owner = yield* requireSelectedOrganization; |
| 493 | const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId); | 531 | const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId); |
| 494 | const result = yield* Effect.promise( | 532 | const result = yield* Effect.promise( |
| 495 | () => | 533 | () => |
| @@ -511,7 +549,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 15 unmodified lines | |||
| 511 | ) | 549 | ) |
| 512 | .handle("resumeMcpExecution", ({ params, payload }) => | 550 | .handle("resumeMcpExecution", ({ params, payload }) => |
| 513 | Effect.gen(function* () { | 551 | Effect.gen(function* () { |
| 514 | const owner = yield* requireSessionOrganizationId; | 552 | const owner = yield* requireSelectedOrganization; |
| 515 | const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId); | 553 | const stub = yield* requireMcpSessionStub(params.mcpSessionId, params.executionId); |
| 516 | const result = yield* Effect.promise( | 554 | const result = yield* Effect.promise( |
| 517 | () => | 555 | () => |
| @@ -85,13 +85,6 @@ export const OrgAuthLive = Layer.effect( · 84 unmodified lines | |||
| 85 | return yield* Effect.fail(new NoOrganization()); | 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 | const session = sessionFromSealed(result, Redacted.value(credential)); | 88 | const session = sessionFromSealed(result, Redacted.value(credential)); |
| 96 | const auth = { | 89 | const auth = { |
| 97 | accountId: session.accountId, | 90 | accountId: session.accountId, |
| @@ -33,8 +33,9 @@ import { · 32 unmodified lines | |||
| 33 | NonProtectedApi, | 33 | NonProtectedApi, |
| 34 | } from "../auth/handlers"; | 34 | } from "../auth/handlers"; |
| 35 | import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; | 35 | import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; |
| 36 | import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live"; | 36 | import { SessionAuthLive } from "../auth/middleware-live"; |
| 37 | import { OrgApi, OrgHttpApi } from "../org/api"; | 37 | import { OrgApi, OrgHttpApi } from "../org/api"; |
| 38 | import { orgAuthMiddleware } from "../org/auth-middleware"; | ||
| 38 | import { OrgHandlers } from "../org/handlers"; | 39 | import { OrgHandlers } from "../org/handlers"; |
| 39 | import { AutumnService } from "../extensions/billing/service"; | 40 | import { AutumnService } from "../extensions/billing/service"; |
| 40 | import { DbService } from "../db/db"; | 41 | import { DbService } from "../db/db"; |
| @@ -79,11 +80,13 @@ export const makeCloudExtensionRoutes = (rsLive: Layer.Layer<DbService | UserSto · 38 unmodified lines | |||
| 79 | Layer.provide(apiPrefixedRouter), | 80 | Layer.provide(apiPrefixedRouter), |
| 80 | ); | 81 | ); |
| 81 | 82 | ||
| 82 | // Cloud-only WorkOS domain-verification routes; `OrgAuth` enforces an | 83 | // Cloud-only WorkOS domain-verification routes; the auth middleware resolves |
| 83 | // authenticated org session. No per-request DB scoping needed. | 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 | const OrgRoutes = HttpApiBuilder.layer(OrgHttpApi).pipe( | 87 | const OrgRoutes = HttpApiBuilder.layer(OrgHttpApi).pipe( |
| 85 | Layer.provide(OrgHandlers), | 88 | Layer.provide(OrgHandlers), |
| 86 | Layer.provideMerge(OrgAuthLive), | 89 | Layer.provide(orgAuthMiddleware(rsLive)), |
| 87 | Layer.provideMerge(AutumnService.Default), | 90 | Layer.provideMerge(AutumnService.Default), |
| 88 | Layer.provide(apiPrefixedRouter), | 91 | Layer.provide(apiPrefixedRouter), |
| 89 | ); | 92 | ); |
| @@ -217,6 +217,7 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> { · 216 unmodified lines | |||
| 217 | origin: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", | 217 | origin: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", |
| 218 | executionId, | 218 | executionId, |
| 219 | sessionId: self.sessionId, | 219 | sessionId: self.sessionId, |
| 220 | organizationSlug: sessionMeta.organizationSlug, | ||
| 220 | }), | 221 | }), |
| 221 | } | 222 | } |
| 222 | : { mode: sessionElicitationMode }, | 223 | : { mode: sessionElicitationMode }, |
| @@ -1,7 +1,6 @@ | |||
| 1 | import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | 1 | import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; |
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | import { WorkOSError } from "../auth/errors"; | 3 | import { WorkOSError } from "../auth/errors"; |
| 4 | import { OrgAuth } from "../auth/middleware"; | ||
| 5 | 4 | ||
| 6 | // --------------------------------------------------------------------------- | 5 | // --------------------------------------------------------------------------- |
| 7 | // Cloud-local org API — the WorkOS domain-verification surface only. Members / | 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. */ | 61 | /** Org API with org-level auth supplied by the router middleware in auth-middleware.ts. */ |
| 63 | export const OrgHttpApi = HttpApi.make("org").add(OrgApi).middleware(OrgAuth); | 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 | import { env } from "cloudflare:workers"; | 5 | import { env } from "cloudflare:workers"; |
| 6 | import { WorkOSClient } from "../auth/workos"; | 6 | import { WorkOSClient } from "../auth/workos"; |
| 7 | import { AutumnService } from "../extensions/billing/service"; | 7 | import { AutumnService } from "../extensions/billing/service"; |
| 8 | import { resolveOrganization } from "../auth/organization"; | ||
| 8 | import { Forbidden, OrgHttpApi } from "./api"; | 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 | return yield* new Forbidden(); | 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 | const workos = yield* WorkOSClient; | 97 | const workos = yield* WorkOSClient; |
| 93 | // WorkOS requires an ABSOLUTE returnUrl — fall back to the pinned origin | 98 | // WorkOS requires an absolute returnUrl. Keep the URL org slug in that |
| 94 | // (matching every other VITE_PUBLIC_SITE_URL site in this host), not a | 99 | // return path so the browser comes back to the same selected org. |
| 95 | // bare relative path that WorkOS would reject. | ||
| 96 | const { link } = yield* workos.generateDomainVerificationPortalLink( | 100 | const { link } = yield* workos.generateDomainVerificationPortalLink( |
| 97 | auth.organizationId, | 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 | return { link }; | 104 | return { link }; |
| 101 | }), | 105 | }), |
| @@ -1,7 +1,8 @@ | |||
| 1 | import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; | 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 | import { addGroup } from "@executor-js/api"; | 3 | import { addGroup } from "@executor-js/api"; |
| 4 | import { getBaseUrl } from "@executor-js/react/api/base-url"; | 4 | import { getBaseUrl } from "@executor-js/react/api/base-url"; |
| 5 | import { EXECUTOR_ORG_HEADER, getActiveOrgSlug } from "@executor-js/react/api/server-connection"; | ||
| 5 | import { CloudAuthApi } from "../auth/api"; | 6 | import { CloudAuthApi } from "../auth/api"; |
| 6 | import { OrgApi } from "../org/api"; | 7 | import { OrgApi } from "../org/api"; |
| 7 | 8 | ||
| @@ -14,6 +15,10 @@ const CloudApiClient = AtomHttpApi.Service<"CloudApiClient">()("CloudApiClient", · 6 unmodified lines | |||
| 14 | api: CloudApi, | 15 | api: CloudApi, |
| 15 | httpClient: FetchHttpClient.layer, | 16 | httpClient: FetchHttpClient.layer, |
| 16 | baseUrl: getBaseUrl(), | 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 | export { CloudApiClient }; | 24 | export { CloudApiClient }; |
| @@ -108,6 +108,7 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> { · 107 unmodified lines | |||
| 108 | origin: origin ?? "https://unconfigured-origin.invalid", | 108 | origin: origin ?? "https://unconfigured-origin.invalid", |
| 109 | executionId, | 109 | executionId, |
| 110 | sessionId: self.sessionId, | 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 | * Build the console approval URL for a paused execution: | 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 | * `<origin>/resume/<executionId>?mcp_session_id=<sessionId>`. The | 58 | * `<origin>/resume/<executionId>?mcp_session_id=<sessionId>`. The |
| 57 | * `mcp_session_id` query routes the console's resume page back to the host's | 59 | * `mcp_session_id` query routes the console's resume page back to the host's |
| 58 | * approval endpoint for that session. | 60 | * approval endpoint for that session. |
| @@ -61,8 +63,10 @@ export const buildResumeApprovalUrl = (input: { · 2 unmodified lines | |||
| 61 | readonly origin: string | URL; | 63 | readonly origin: string | URL; |
| 62 | readonly executionId: string; | 64 | readonly executionId: string; |
| 63 | readonly sessionId?: string | null; | 65 | readonly sessionId?: string | null; |
| 66 | readonly organizationSlug?: string | null; | ||
| 64 | }): string => { | 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 | if (input.sessionId) url.searchParams.set("mcp_session_id", input.sessionId); | 70 | if (input.sessionId) url.searchParams.set("mcp_session_id", input.sessionId); |
| 67 | return url.toString(); | 71 | return url.toString(); |
| 68 | }; | 72 | }; |