import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";import { Clock, Duration, Effect, Predicate } from "effect";import { isValidOrgSlug } from "@executor-js/api"; import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi, McpExecutionNotFoundError, McpSessionForbiddenError, OrganizationDeletionForbidden, OrganizationDeletionIncomplete,} from "./api";import { MemberDirectory, NoOrganization } from "@executor-js/api/server";// Pure constants/codec module (no React) — safe in the backend graph.import { AUTH_HINT_COOKIE } from "@executor-js/react/multiplayer/auth-hint";import { SessionContext, SessionCookies } from "./middleware";import { encodeLoginState, decodeLoginState } from "./login-state";import { safeReturnTo } from "./return-to";import { UserStoreService } from "./context";import { mirrorMembership, mirrorSignIn } from "./mirror-feeders";import { env } from "cloudflare:workers";import { WorkOSError } from "./errors";import { WorkOSClient } from "./workos";import { AutumnService, autumnStatusOf } from "../extensions/billing/service";import { forkReportMemberSeats } from "../extensions/billing/member-seats";import { captureCauseEffect } from "../observability";import { hasPaidOrganizationSubscription, isOverFreeOrganizationLimit, shouldApplyFreeOrganizationLimit,} from "../extensions/billing/plans";import { LAST_ORG_COOKIE } from "./last-org-cookie";import { ORG_SELECTOR_HEADER, authorizeOrganization, authorizeOrganizationSelector, markOrganizationDeleted, resolveOrganization, type AuthorizeOrganizationOptions,} from "./organization";import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; const COOKIE_OPTIONS = { path: "/", httpOnly: true, sameSite: "lax" as const, maxAge: 60 * 60 * 24 * 7, secure: true,}; const STATE_COOKIE = "wos-login-state";const STATE_COOKIE_OPTIONS = { path: "/", httpOnly: true, sameSite: "lax" as const, maxAge: 10 * 60, secure: true,}; const RESPONSE_COOKIE_OPTIONS = { ...COOKIE_OPTIONS, maxAge: Duration.days(7),}; const RESPONSE_STATE_COOKIE_OPTIONS = { ...STATE_COOKIE_OPTIONS, maxAge: Duration.minutes(10),}; const DELETE_COOKIE_OPTIONS = { path: "/", httpOnly: true, sameSite: "lax" as const, maxAge: 0, expires: new Date(0), secure: true,}; const randomNonce = (): string => { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");}; const timingSafeEqual = (a: string, b: string): boolean => { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a.charCodeAt(i) ^ b.charCodeAt(i); } return diff === 0;}; const requestHeaders = Effect.map(HttpServerRequest.HttpServerRequest.asEffect(), (req) => ({ ...req.headers,})); const firstPathSegment = (path: string): string | null => { const pathname = path.split(/[?#]/, 1)[0] ?? ""; const segment = pathname.split("/")[1]; return segment && isValidOrgSlug(segment) ? segment : null;}; const requestedOrgSelectorFromReturnTo = (returnTo: string): string | null => firstPathSegment(returnTo); const selectedOrganization = (options: AuthorizeOrganizationOptions = {}) => Effect.gen(function* () { const session = yield* SessionContext; const headers = yield* requestHeaders; const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; if (!selector) { return yield* new NoOrganization(); } const org = yield* authorizeOrganizationSelector(session.accountId, selector, options).pipe( Effect.catch(() => Effect.fail(new NoOrganization())), ); if (!org) { return yield* new NoOrganization(); } return { ...session, organizationId: org.id, memberRole: org.memberRole, }; }); const requireSelectedOrganization = selectedOrganization(); const getMcpSessionStub = (mcpSessionId: string) => mcpSessionStub(env.MCP_SESSION, mcpSessionId); const failMcpApprovalResult = ( result: { readonly status: "not_found" | "forbidden" }, params: { readonly mcpSessionId: string; readonly executionId: string },) => { if (result.status === "forbidden") { return Effect.fail(new McpSessionForbiddenError({ mcpSessionId: params.mcpSessionId })); } return Effect.fail(new McpExecutionNotFoundError({ executionId: params.executionId }));}; const setResponseCookie = ( response: HttpServerResponse.HttpServerResponse, name: string, value: string, options: typeof RESPONSE_COOKIE_OPTIONS,) => HttpServerResponse.setCookieUnsafe(response, name, value, options); const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, name: string) => HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS); // ---------------------------------------------------------------------------// Single non-protected API surface — public (login/callback/logout) + session// (me/organizations/switch-organization). The session group has SessionAuth on it.// --------------------------------------------------------------------------- export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi); // ---------------------------------------------------------------------------// Public auth handlers (no authentication required)// --------------------------------------------------------------------------- export const CloudAuthPublicHandlers = HttpApiBuilder.group( NonProtectedApi, "cloudAuthPublic", (handlers) => handlers .handleRaw("login", ({ query }) => Effect.gen(function* () { const workos = yield* WorkOSClient; // Use the explicit public site URL — in dev, the request's Host // header points at the internal proxy target, not the public URL // WorkOS needs to redirect back to. const origin = env.VITE_PUBLIC_SITE_URL ?? ""; // OAuth round-trips `state` verbatim, so the validated returnTo // rides inside it next to the CSRF nonce — no extra cookie. const state = encodeLoginState({ nonce: randomNonce(), returnTo: safeReturnTo(query.returnTo) ?? undefined, }); const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`, state); return setResponseCookie( HttpServerResponse.redirect(url, { status: 302 }), STATE_COOKIE, state, RESPONSE_STATE_COOKIE_OPTIONS, ); }), ) .handleRaw("callback", ({ request, query }) => Effect.gen(function* () { const workos = yield* WorkOSClient; const users = yield* UserStoreService; // Hosted invitations can start at WorkOS without app-issued state. // Discard that unbound code and start a fresh browser-bound login. // Exchanging it here would allow login CSRF. if (query.state === undefined) { return deleteResponseCookie( HttpServerResponse.redirect(AUTH_PATHS.login, { status: 302 }), STATE_COOKIE, ); } const cookieState = request.cookies[STATE_COOKIE] ?? null; // Only exchange codes bound to the state cookie set on /login. if (!cookieState || !timingSafeEqual(cookieState, query.state)) { return deleteResponseCookie( HttpServerResponse.text("Invalid login state", { status: 400 }), STATE_COOKIE, ); } const result = yield* workos.authenticateWithCode(query.code); // ONE membership list for the whole callback. It feeds the mirror // (the user + every org they hold a membership in, all already in // hand) and it is the membership check for every landing-org // candidate below, so the callback makes no per-candidate WorkOS // call. The user's account row is minted by the mirror's user // upsert. The list's fetch instant, taken before the read, stamps // the organization names it carries (see `mirrorSignIn`). const fetchedAt = new Date(yield* Clock.currentTimeMillis); const memberships = yield* workos.listUserMemberships(result.user.id); yield* mirrorSignIn(result.user, memberships.data, fetchedAt); let sealedSession = result.sealedSession; // Resume where the SSR gate interrupted them. The state passed the // CSRF check above, but it's still a // round-tripped value, so the returnTo inside it is re-validated like // any other untrusted path. const returnTo = safeReturnTo(decodeLoginState(query.state)?.returnTo) ?? "/"; const requestedOrgSelector = requestedOrgSelectorFromReturnTo(returnTo); // An org SLUG (both candidate sources below are slug-validated, so // an `org_…` id never reaches here) resolves to its id only when the // list above holds an ACTIVE membership in it. Pending memberships // are skipped because refreshing into one 400s and would bypass // invite consent. A slug that fails to resolve (unknown, or a store // hiccup) is not a candidate, the same as an org the user is not in. const activeOrganizationIds = new Set( memberships.data.filter((m) => m.status === "active").map((m) => m.organizationId), ); const activeOrganizationFor = (slug: string) => users .use("getOrganizationBySlug", (s) => s.getOrganizationBySlug(slug)) .pipe( Effect.map((org) => (org && activeOrganizationIds.has(org.id) ? org.id : null)), Effect.orElseSucceed(() => null), ); // Prefer the org in the URL that sent the user to login. If the URL // is bare, or not an org route, prefer the org this browser last // worked in (the last-org cookie — it outlives the session precisely // so a fresh login lands where the user left off), then WorkOS's // org, then the first active membership for org-less sessions. // The cookie is membership-checked like any selector, so a stale // one just falls through. let targetOrganizationId = requestedOrgSelector ? yield* activeOrganizationFor(requestedOrgSelector) : null; if (!targetOrganizationId && !requestedOrgSelector) { const lastOrgSlug = request.cookies[LAST_ORG_COOKIE]; targetOrganizationId = lastOrgSlug && isValidOrgSlug(lastOrgSlug) ? yield* activeOrganizationFor(lastOrgSlug) : null; } targetOrganizationId ??= result.organizationId ?? null; if (!targetOrganizationId && !requestedOrgSelector) { const existingActive = memberships.data.find((m) => m.status === "active"); targetOrganizationId = existingActive?.organizationId ?? null; } // Seat changes the app never sees a mutation for (invitation // acceptance in AuthKit, SSO JIT provisioning, join by domain, // WorkOS dashboard edits) all end in a sign-in, so every login // reconciles the landed org's billed seat count. Forked: billing // must not delay the login. if (targetOrganizationId) { yield* forkReportMemberSeats(targetOrganizationId); } if ( targetOrganizationId && targetOrganizationId !== result.organizationId && sealedSession ) { // Best-effort refresh: if WorkOS rejects, fall through with the // original session instead of 500ing the entire callback. const refreshed = yield* workos .refreshSession(sealedSession, targetOrganizationId) .pipe(Effect.orElseSucceed(() => null)); if (refreshed) sealedSession = refreshed; } if (!sealedSession) { return HttpServerResponse.text("Failed to create session", { status: 500, }); } return deleteResponseCookie( setResponseCookie( HttpServerResponse.redirect(returnTo, { status: 302 }), "wos-session", sealedSession, RESPONSE_COOKIE_OPTIONS, ), STATE_COOKIE, ); }), ) .handleRaw("logout", ({ request }) => Effect.gen(function* () { const workos = yield* WorkOSClient; // The session this browser presents, NOT one the middleware vouched // for — signing out of a session that has already ended must still // sign the browser out (see the group declaration in ./api.ts). const sealedSession = request.cookies["wos-session"] ?? ""; // WorkOS's documented sign-out: send the browser through the WorkOS // logout endpoint, which ends the AuthKit session upstream and then // redirects to the registered sign-out URL. Without this hop, the // hosted session survives and the next "Sign in" silently // re-authenticates (issue #1445). Fail-open when the cookie won't // unseal — there is then nothing to end upstream, and local sign-out // must still complete, so fall back to "/". const origin = env.VITE_PUBLIC_SITE_URL ?? ""; const logoutUrl = sealedSession ? yield* workos.logoutUrl(sealedSession, origin ? `${origin}/` : undefined) : null; const response = HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302, }); // Drop only what this browser actually presented. Both cookies are // SameSite=Lax, so a cross-site form POST carries neither — it gets // the bare redirect and cannot be used to sign anyone out. if (!sealedSession && request.cookies[AUTH_HINT_COOKIE] === undefined) return response; // The auth-hint travels with the session: leaving it behind would // make the next page load optimistically paint the app shell for a // signed-out browser. return deleteResponseCookie( deleteResponseCookie( HttpServerResponse.setHeader(response, "Clear-Site-Data", '"cache", "storage"'), "wos-session", ), AUTH_HINT_COOKIE, ); }), ) // CLI device-login discovery. The WorkOS device endpoints live on the // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production, // the SAME base the SDK uses, so e2e points the CLI at the emulator with // zero extra wiring). The CLI runs RFC 8628 against them as a public // client (no secret) and gets a WorkOS access-token JWT back. .handle("cliLogin", () => Effect.sync(() => { const base = (env.WORKOS_API_URL ?? "https://api.workos.com").replace(/\/+$/, ""); return { provider: "workos" as const, deviceAuthorizationEndpoint: `${base}/user_management/authorize/device`, tokenEndpoint: `${base}/user_management/authenticate`, clientId: env.WORKOS_CLIENT_ID, }; }), ),); // ---------------------------------------------------------------------------// Session auth handlers (require session, may or may not have an org)// --------------------------------------------------------------------------- export const CloudSessionAuthHandlers = HttpApiBuilder.group( NonProtectedApi, "cloudAuth", (handlers) => handlers .handle("me", () => Effect.gen(function* () { const session = yield* SessionContext; const org = session.organizationId ? yield* authorizeOrganization(session.accountId, session.organizationId) : null; return { user: { id: session.accountId, email: session.email, name: session.name, avatarUrl: session.avatarUrl, }, organization: org ? { id: org.id, name: org.name, slug: org.slug } : null, }; }), ) .handle("organizations", () => Effect.gen(function* () { const directory = yield* MemberDirectory; const session = yield* SessionContext; // The caller's memberships (active + pending, as WorkOS listed them // before) from the local mirror — one indexed read, no WorkOS call. const memberships = yield* directory.membershipsOf(session.accountId); // Resolve through the mirror (not WorkOS directly) so each org's // URL slug is minted/read — the switcher navigates to `/<slug>`. // An org marked deleted (its deletion is in progress or failed // part-way, see deleteOrganization) refuses every session, so it // is not a place the switcher can go. const organizations = yield* Effect.all( memberships.map((m) => resolveOrganization(m.organizationId).pipe( Effect.map((org) => org.deletedAt === null ? { id: org.id, name: org.name, slug: org.slug, } : null, ), Effect.orElseSucceed(() => null), ), ), { concurrency: "unbounded" }, ); return { organizations: organizations.filter(Predicate.isNotNull), activeOrganizationId: session.organizationId, }; }), ) .handle("createOrganization", ({ payload }) => Effect.gen(function* () { const workos = yield* WorkOSClient; const users = yield* UserStoreService; const session = yield* SessionContext; const autumn = yield* AutumnService; const name = payload.name.trim(); // The free-organizations-per-user limit counts the caller's ACTIVE // memberships, read from the local mirror. const directory = yield* MemberDirectory; const activeMemberships = yield* directory.membershipsOf(session.accountId, ["active"]); if (isOverFreeOrganizationLimit(activeMemberships)) { const paidOrganizationIds = yield* Effect.all( activeMemberships.map((membership) => autumn .use((client) => client.customers.getOrCreate({ customerId: membership.organizationId, }), ) .pipe( Effect.map((customer) => hasPaidOrganizationSubscription(customer.subscriptions) ? membership.organizationId : null, ), ), ), { concurrency: 3 }, ).pipe( // Any Autumn failure here (outage or missing customer) leaves the // paid/free split unknown, and the limit must fail closed. Effect.mapError(() => new WorkOSError()), Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))), ); if (shouldApplyFreeOrganizationLimit(activeMemberships, paidOrganizationIds)) { return yield* new WorkOSError(); } } const org = yield* workos.createOrganization(name); const membership = yield* workos.createMembership(org.id, session.accountId, "admin"); // `upsertOrganization` mints the slug at insert — no separate heal step. const mirrored = yield* users.use("upsertOrganization", (s) => s.upsertOrganization({ id: org.id, name: org.name, updatedAt: new Date(org.updatedAt), }), ); // Write-through: the creator's admin membership, from the create // response, lands in the mirror before anything reads it. yield* mirrorMembership(membership); // Provision the org's billing customer while we're the ones creating // the org. Without this the first billing call an org ever makes is a // non-creating one (balance check / usage track), which 404s and keepsLines 1–500 of 864. Load more
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { Clock, Duration, Effect, Predicate } from "effect"; import { isValidOrgSlug } from "@executor-js/api"; import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi, McpExecutionNotFoundError, McpSessionForbiddenError, OrganizationDeletionForbidden, OrganizationDeletionIncomplete, } from "./api"; import { MemberDirectory, NoOrganization } from "@executor-js/api/server"; // Pure constants/codec module (no React) — safe in the backend graph. import { AUTH_HINT_COOKIE } from "@executor-js/react/multiplayer/auth-hint"; import { SessionContext, SessionCookies } from "./middleware"; import { encodeLoginState, decodeLoginState } from "./login-state"; import { safeReturnTo } from "./return-to"; import { UserStoreService } from "./context"; import { mirrorMembership, mirrorSignIn } from "./mirror-feeders"; import { env } from "cloudflare:workers"; import { WorkOSError } from "./errors"; import { WorkOSClient } from "./workos"; import { AutumnService, autumnStatusOf } from "../extensions/billing/service"; import { forkReportMemberSeats } from "../extensions/billing/member-seats"; import { captureCauseEffect } from "../observability"; import { hasPaidOrganizationSubscription, isOverFreeOrganizationLimit, shouldApplyFreeOrganizationLimit, } from "../extensions/billing/plans"; import { LAST_ORG_COOKIE } from "./last-org-cookie"; import { ORG_SELECTOR_HEADER, authorizeOrganization, authorizeOrganizationSelector, markOrganizationDeleted, resolveOrganization, type AuthorizeOrganizationOptions, } from "./organization"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; const COOKIE_OPTIONS = { path: "/", httpOnly: true, sameSite: "lax" as const, maxAge: 60 * 60 * 24 * 7, secure: true, }; const STATE_COOKIE = "wos-login-state"; const STATE_COOKIE_OPTIONS = { path: "/", httpOnly: true, sameSite: "lax" as const, maxAge: 10 * 60, secure: true, }; const RESPONSE_COOKIE_OPTIONS = { ...COOKIE_OPTIONS, maxAge: Duration.days(7), }; const RESPONSE_STATE_COOKIE_OPTIONS = { ...STATE_COOKIE_OPTIONS, maxAge: Duration.minutes(10), }; const DELETE_COOKIE_OPTIONS = { path: "/", httpOnly: true, sameSite: "lax" as const, maxAge: 0, expires: new Date(0), secure: true, }; const randomNonce = (): string => { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); }; const timingSafeEqual = (a: string, b: string): boolean => { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a.charCodeAt(i) ^ b.charCodeAt(i); } return diff === 0; }; const requestHeaders = Effect.map(HttpServerRequest.HttpServerRequest.asEffect(), (req) => ({ ...req.headers, })); const firstPathSegment = (path: string): string | null => { const pathname = path.split(/[?#]/, 1)[0] ?? ""; const segment = pathname.split("/")[1]; return segment && isValidOrgSlug(segment) ? segment : null; }; const requestedOrgSelectorFromReturnTo = (returnTo: string): string | null => firstPathSegment(returnTo); const selectedOrganization = (options: AuthorizeOrganizationOptions = {}) => Effect.gen(function* () { const session = yield* SessionContext; const headers = yield* requestHeaders; const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; if (!selector) { return yield* new NoOrganization(); } const org = yield* authorizeOrganizationSelector(session.accountId, selector, options).pipe( Effect.catch(() => Effect.fail(new NoOrganization())), ); if (!org) { return yield* new NoOrganization(); } return { ...session, organizationId: org.id, memberRole: org.memberRole, }; }); const requireSelectedOrganization = selectedOrganization(); const getMcpSessionStub = (mcpSessionId: string) => mcpSessionStub(env.MCP_SESSION, mcpSessionId); const failMcpApprovalResult = ( result: { readonly status: "not_found" | "forbidden" }, params: { readonly mcpSessionId: string; readonly executionId: string }, ) => { if (result.status === "forbidden") { return Effect.fail(new McpSessionForbiddenError({ mcpSessionId: params.mcpSessionId })); } return Effect.fail(new McpExecutionNotFoundError({ executionId: params.executionId })); }; const setResponseCookie = ( response: HttpServerResponse.HttpServerResponse, name: string, value: string, options: typeof RESPONSE_COOKIE_OPTIONS, ) => HttpServerResponse.setCookieUnsafe(response, name, value, options); const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, name: string) => HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS); // --------------------------------------------------------------------------- // Single non-protected API surface — public (login/callback/logout) + session // (me/organizations/switch-organization). The session group has SessionAuth on it. // --------------------------------------------------------------------------- export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi); // --------------------------------------------------------------------------- // Public auth handlers (no authentication required) // --------------------------------------------------------------------------- export const CloudAuthPublicHandlers = HttpApiBuilder.group( NonProtectedApi, "cloudAuthPublic", (handlers) => handlers .handleRaw("login", ({ query }) => Effect.gen(function* () { const workos = yield* WorkOSClient; // Use the explicit public site URL — in dev, the request's Host // header points at the internal proxy target, not the public URL // WorkOS needs to redirect back to. const origin = env.VITE_PUBLIC_SITE_URL ?? ""; // OAuth round-trips `state` verbatim, so the validated returnTo // rides inside it next to the CSRF nonce — no extra cookie. const state = encodeLoginState({ nonce: randomNonce(), returnTo: safeReturnTo(query.returnTo) ?? undefined, }); const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`, state); return setResponseCookie( HttpServerResponse.redirect(url, { status: 302 }), STATE_COOKIE, state, RESPONSE_STATE_COOKIE_OPTIONS, ); }), ) .handleRaw("callback", ({ request, query }) => Effect.gen(function* () { const workos = yield* WorkOSClient; const users = yield* UserStoreService; // Hosted invitations can start at WorkOS without app-issued state. // Discard that unbound code and start a fresh browser-bound login. // Exchanging it here would allow login CSRF. if (query.state === undefined) { return deleteResponseCookie( HttpServerResponse.redirect(AUTH_PATHS.login, { status: 302 }), STATE_COOKIE, ); } const cookieState = request.cookies[STATE_COOKIE] ?? null; // Only exchange codes bound to the state cookie set on /login. if (!cookieState || !timingSafeEqual(cookieState, query.state)) { return deleteResponseCookie( HttpServerResponse.text("Invalid login state", { status: 400 }), STATE_COOKIE, ); } const result = yield* workos.authenticateWithCode(query.code); // ONE membership list for the whole callback. It feeds the mirror // (the user + every org they hold a membership in, all already in // hand) and it is the membership check for every landing-org // candidate below, so the callback makes no per-candidate WorkOS // call. The user's account row is minted by the mirror's user // upsert. The list's fetch instant, taken before the read, stamps // the organization names it carries (see `mirrorSignIn`). const fetchedAt = new Date(yield* Clock.currentTimeMillis); const memberships = yield* workos.listUserMemberships(result.user.id); yield* mirrorSignIn(result.user, memberships.data, fetchedAt); let sealedSession = result.sealedSession; // Resume where the SSR gate interrupted them. The state passed the // CSRF check above, but it's still a // round-tripped value, so the returnTo inside it is re-validated like // any other untrusted path. const returnTo = safeReturnTo(decodeLoginState(query.state)?.returnTo) ?? "/"; const requestedOrgSelector = requestedOrgSelectorFromReturnTo(returnTo); // An org SLUG (both candidate sources below are slug-validated, so // an `org_…` id never reaches here) resolves to its id only when the // list above holds an ACTIVE membership in it. Pending memberships // are skipped because refreshing into one 400s and would bypass // invite consent. A slug that fails to resolve (unknown, or a store // hiccup) is not a candidate, the same as an org the user is not in. const activeOrganizationIds = new Set( memberships.data.filter((m) => m.status === "active").map((m) => m.organizationId), ); const activeOrganizationFor = (slug: string) => users .use("getOrganizationBySlug", (s) => s.getOrganizationBySlug(slug)) .pipe( Effect.map((org) => (org && activeOrganizationIds.has(org.id) ? org.id : null)), Effect.orElseSucceed(() => null), ); // Prefer the org in the URL that sent the user to login. If the URL // is bare, or not an org route, prefer the org this browser last // worked in (the last-org cookie — it outlives the session precisely // so a fresh login lands where the user left off), then WorkOS's // org, then the first active membership for org-less sessions. // The cookie is membership-checked like any selector, so a stale // one just falls through. let targetOrganizationId = requestedOrgSelector ? yield* activeOrganizationFor(requestedOrgSelector) : null; if (!targetOrganizationId && !requestedOrgSelector) { const lastOrgSlug = request.cookies[LAST_ORG_COOKIE]; targetOrganizationId = lastOrgSlug && isValidOrgSlug(lastOrgSlug) ? yield* activeOrganizationFor(lastOrgSlug) : null; } targetOrganizationId ??= result.organizationId ?? null; if (!targetOrganizationId && !requestedOrgSelector) { const existingActive = memberships.data.find((m) => m.status === "active"); targetOrganizationId = existingActive?.organizationId ?? null; } // Seat changes the app never sees a mutation for (invitation // acceptance in AuthKit, SSO JIT provisioning, join by domain, // WorkOS dashboard edits) all end in a sign-in, so every login // reconciles the landed org's billed seat count. Forked: billing // must not delay the login. if (targetOrganizationId) { yield* forkReportMemberSeats(targetOrganizationId); } if ( targetOrganizationId && targetOrganizationId !== result.organizationId && sealedSession ) { // Best-effort refresh: if WorkOS rejects, fall through with the // original session instead of 500ing the entire callback. const refreshed = yield* workos .refreshSession(sealedSession, targetOrganizationId) .pipe(Effect.orElseSucceed(() => null)); if (refreshed) sealedSession = refreshed; } if (!sealedSession) { return HttpServerResponse.text("Failed to create session", { status: 500, }); } return deleteResponseCookie( setResponseCookie( HttpServerResponse.redirect(returnTo, { status: 302 }), "wos-session", sealedSession, RESPONSE_COOKIE_OPTIONS, ), STATE_COOKIE, ); }), ) .handleRaw("logout", ({ request }) => Effect.gen(function* () { const workos = yield* WorkOSClient; // The session this browser presents, NOT one the middleware vouched // for — signing out of a session that has already ended must still // sign the browser out (see the group declaration in ./api.ts). const sealedSession = request.cookies["wos-session"] ?? ""; // WorkOS's documented sign-out: send the browser through the WorkOS // logout endpoint, which ends the AuthKit session upstream and then // redirects to the registered sign-out URL. Without this hop, the // hosted session survives and the next "Sign in" silently // re-authenticates (issue #1445). Fail-open when the cookie won't // unseal — there is then nothing to end upstream, and local sign-out // must still complete, so fall back to "/". const origin = env.VITE_PUBLIC_SITE_URL ?? ""; const logoutUrl = sealedSession ? yield* workos.logoutUrl(sealedSession, origin ? `${origin}/` : undefined) : null; const response = HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302, }); // Drop only what this browser actually presented. Both cookies are // SameSite=Lax, so a cross-site form POST carries neither — it gets // the bare redirect and cannot be used to sign anyone out. if (!sealedSession && request.cookies[AUTH_HINT_COOKIE] === undefined) return response; // The auth-hint travels with the session: leaving it behind would // make the next page load optimistically paint the app shell for a // signed-out browser. return deleteResponseCookie( deleteResponseCookie( HttpServerResponse.setHeader(response, "Clear-Site-Data", '"cache", "storage"'), "wos-session", ), AUTH_HINT_COOKIE, ); }), ) // CLI device-login discovery. The WorkOS device endpoints live on the // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production, // the SAME base the SDK uses, so e2e points the CLI at the emulator with // zero extra wiring). The CLI runs RFC 8628 against them as a public // client (no secret) and gets a WorkOS access-token JWT back. .handle("cliLogin", () => Effect.sync(() => { const base = (env.WORKOS_API_URL ?? "https://api.workos.com").replace(/\/+$/, ""); return { provider: "workos" as const, deviceAuthorizationEndpoint: `${base}/user_management/authorize/device`, tokenEndpoint: `${base}/user_management/authenticate`, clientId: env.WORKOS_CLIENT_ID, }; }), ), ); // --------------------------------------------------------------------------- // Session auth handlers (require session, may or may not have an org) // --------------------------------------------------------------------------- export const CloudSessionAuthHandlers = HttpApiBuilder.group( NonProtectedApi, "cloudAuth", (handlers) => handlers .handle("me", () => Effect.gen(function* () { const session = yield* SessionContext; const org = session.organizationId ? yield* authorizeOrganization(session.accountId, session.organizationId) : null; return { user: { id: session.accountId, email: session.email, name: session.name, avatarUrl: session.avatarUrl, }, organization: org ? { id: org.id, name: org.name, slug: org.slug } : null, }; }), ) .handle("organizations", () => Effect.gen(function* () { const directory = yield* MemberDirectory; const session = yield* SessionContext; // The caller's memberships (active + pending, as WorkOS listed them // before) from the local mirror — one indexed read, no WorkOS call. const memberships = yield* directory.membershipsOf(session.accountId); // Resolve through the mirror (not WorkOS directly) so each org's // URL slug is minted/read — the switcher navigates to `/<slug>`. // An org marked deleted (its deletion is in progress or failed // part-way, see deleteOrganization) refuses every session, so it // is not a place the switcher can go. const organizations = yield* Effect.all( memberships.map((m) => resolveOrganization(m.organizationId).pipe( Effect.map((org) => org.deletedAt === null ? { id: org.id, name: org.name, slug: org.slug, } : null, ), Effect.orElseSucceed(() => null), ), ), { concurrency: "unbounded" }, ); return { organizations: organizations.filter(Predicate.isNotNull), activeOrganizationId: session.organizationId, }; }), ) .handle("createOrganization", ({ payload }) => Effect.gen(function* () { const workos = yield* WorkOSClient; const users = yield* UserStoreService; const session = yield* SessionContext; const autumn = yield* AutumnService; const name = payload.name.trim(); // The free-organizations-per-user limit counts the caller's ACTIVE // memberships, read from the local mirror. const directory = yield* MemberDirectory; const activeMemberships = yield* directory.membershipsOf(session.accountId, ["active"]); if (isOverFreeOrganizationLimit(activeMemberships)) { const paidOrganizationIds = yield* Effect.all( activeMemberships.map((membership) => autumn .use((client) => client.customers.getOrCreate({ customerId: membership.organizationId, }), ) .pipe( Effect.map((customer) => hasPaidOrganizationSubscription(customer.subscriptions) ? membership.organizationId : null, ), ), ), { concurrency: 3 }, ).pipe( // Any Autumn failure here (outage or missing customer) leaves the // paid/free split unknown, and the limit must fail closed. Effect.mapError(() => new WorkOSError()), Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))), ); if (shouldApplyFreeOrganizationLimit(activeMemberships, paidOrganizationIds)) { return yield* new WorkOSError(); } } const org = yield* workos.createOrganization(name); const membership = yield* workos.createMembership(org.id, session.accountId, "admin"); // `upsertOrganization` mints the slug at insert — no separate heal step. const mirrored = yield* users.use("upsertOrganization", (s) => s.upsertOrganization({ id: org.id, name: org.name, updatedAt: new Date(org.updatedAt), }), ); // Write-through: the creator's admin membership, from the create // response, lands in the mirror before anything reads it. yield* mirrorMembership(membership); // Provision the org's billing customer while we're the ones creating // the org. Without this the first billing call an org ever makes is a // non-creating one (balance check / usage track), which 404s and keeps