// Cloud session + membership helpers shared by the scenarios that need MORE // than one identity in an org. // // Every one of these is a thin wrapper over a REAL product endpoint — there is // no back door here. They were copy-pasted verbatim into four cloud scenarios // before living here, and the copies drifted in the two ways that matter, both // now folded in: // // - only one carried `forBrowser`, the fix for the fact that `joinOrg` // refreshes `headers.cookie` (the sealed session is re-issued on the new // org) but leaves `cookies` on the PRE-join value — and the browser context // is seeded from `cookies`. A scenario on a stale copy browses as the // person's old org while its API calls use the new one. // - only one forwarded the ORG SELECTOR header. Org-scoped endpoints fail // closed without it (`AccountNoOrganization`), so a cookie-only copy cannot // even build the fixture: `/api/account/members/invite` 403s. // // `cloud/*.test.ts` is a vitest `include` of `*.test.ts` only, so this module // is never collected as a suite. import { Effect } from "effect"; import type { Identity, Target as TargetShape } from "../../src/target"; /** * The per-request org selector. `/api/auth/switch-organization` was removed * (#1000): the URL prefix or this header is the scope authority now, and the * session's own org is only a default. * * This is the RIGHT way to read one account's data in a specific org: a session * cookie captured before the account joined another org is stale the moment the * later `accept-invitation` re-seals it, so holding onto the old cookie reads as * an unauthenticated caller. Take the CURRENT identity and point it at the org * you mean. * * Mirrors `ORG_SELECTOR_HEADER` in `apps/cloud/src/auth/organization.ts` * (`EXECUTOR_ORG_SELECTOR_HEADER` in the SDK's shared exports); duplicated as a * literal so the e2e suite stays a black-box client of the deployed product. */ const ORG_SELECTOR_HEADER = "x-executor-organization"; /** The session cookie header this identity authenticates API calls with. */ export const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? ""; /** * The identity re-bound so the BROWSER carries the same session the API calls * do. `joinOrg` / `createOrg` refresh `headers.cookie` (the org is baked into * the sealed session) but leave `cookies` on the pre-change value, and the * browser context is seeded from `cookies`. */ export const forBrowser = (identity: Identity): Identity => { const cookie = cookieOf(identity); const separator = cookie.indexOf("="); if (separator < 0) throw new Error("identity carries no session cookie"); return { ...identity, cookies: [{ name: cookie.slice(0, separator), value: cookie.slice(separator + 1) }], }; }; /** POST as `identity`, failing loudly on a non-2xx (a silent skip here would * leave the scenario asserting against a half-built fixture). * * Sends the identity's OWN headers, not just its cookie: org-scoped endpoints * (`/api/account/members/invite` among them) fail closed with * `AccountNoOrganization` when the org selector header is absent, because the * session's own org is only a default and is never fallen back to. */ export const postJson = ( target: TargetShape, path: string, identity: Identity, body: unknown, ): Effect.Effect => Effect.promise(async () => { const response = await fetch(new URL(path, target.baseUrl), { method: "POST", headers: { ...(identity.headers ?? {}), "content-type": "application/json", origin: new URL(target.baseUrl).origin, }, body: JSON.stringify(body), }); if (!response.ok) { throw new Error(`${path} failed (${response.status}): ${await response.text()}`); } return response; }); /** The identity re-bound to the refreshed session cookie a response set, and * scoped to `orgSelector` — carrying the cookie alone would leave every * org-scoped read the identity goes on to make failing closed. */ export const withRefreshedSession = ( identity: Identity, response: Response, orgSelector: string, ): Identity => { const refreshed = (response.headers.getSetCookie?.() ?? []) .find((header) => header.startsWith("wos-session=")) ?.split(";")[0]; if (!refreshed) throw new Error("response did not refresh the session cookie"); return { ...identity, headers: { cookie: refreshed, [ORG_SELECTOR_HEADER]: orgSelector } }; }; /** The org selector this identity's requests carry — the same header the web * client derives from the console URL's slug. */ export const orgSelectorOf = (identity: Identity): string => { const selector = identity.headers?.[ORG_SELECTOR_HEADER]; if (!selector) throw new Error(`identity ${identity.label} carries no org selector header`); return selector; }; /** * Invite `member` into `admin`'s org and accept — the real invite flow. The * invite carries no roleSlug, so the joiner lands as a plain MEMBER (the actor * an access-denial assertion needs); pass `roleSlug` for anything else. * * The returned identity is bound to the newly-joined org — the re-sealed * session from accept-invitation, scoped by the selector to the org they were * invited INTO (`admin`'s). Calling this twice for one person is how a scenario * builds a multi-org member: the WorkOS membership in the earlier org persists, * and both the session and the selector point at the later one. */ export const joinOrg = ( target: TargetShape, admin: Identity, member: Identity, options: { readonly roleSlug?: string } = {}, ): Effect.Effect => Effect.gen(function* () { const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { email: member.credentials?.email, ...(options.roleSlug === undefined ? {} : { roleSlug: options.roleSlug }), }); const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string }; const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, { invitationId: invitation.id, }); return withRefreshedSession(member, acceptResponse, orgSelectorOf(admin)); }); /** Create another org for this account; returns the identity bound to it. */ export const createOrg = ( target: TargetShape, identity: Identity, name: string, ): Effect.Effect => Effect.gen(function* () { const response = yield* postJson(target, "/api/auth/create-organization", identity, { name }); const created = (yield* Effect.promise(() => response.clone().json())) as { id: string }; return withRefreshedSession(identity, response, created.id); }); /** This identity, scoped to `organizationId` for the requests it makes. */ export const inOrg = (identity: Identity, organizationId: string): Identity => ({ ...identity, headers: { ...identity.headers, [ORG_SELECTOR_HEADER]: organizationId }, }); /** The caller's own account id — the `externalId` the admin plane reports and * the users page renders in mono. */ export const accountIdOf = (target: TargetShape, identity: Identity): Effect.Effect => Effect.promise(async () => { const response = await fetch(new URL("/api/account/me", target.baseUrl), { headers: { cookie: cookieOf(identity) }, }); if (!response.ok) throw new Error(`/api/account/me failed (${response.status})`); const body = (await response.json()) as { user: { id: string } }; return body.user.id; }); /** The org this identity's session is currently bound to: id and URL slug. */ export const activeOrg = ( target: TargetShape, identity: Identity, ): Effect.Effect<{ readonly id: string; readonly slug: string }> => Effect.promise(async () => { const response = await fetch(new URL("/api/auth/me", target.baseUrl), { headers: { cookie: cookieOf(identity) }, }); if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`); const body = (await response.json()) as { organization: { id: string; slug: string } | null; }; if (!body.organization) throw new Error("identity has no active organization"); return { id: body.organization.id, slug: body.organization.slug }; }); /** Every org this account is a member of, plus the one the session is on. */ export const organizationsOf = ( target: TargetShape, identity: Identity, ): Effect.Effect<{ readonly organizations: ReadonlyArray<{ readonly id: string; readonly slug: string }>; readonly activeOrganizationId: string | null; }> => Effect.promise(async () => { const response = await fetch(new URL("/api/auth/organizations", target.baseUrl), { headers: { cookie: cookieOf(identity) }, }); if (!response.ok) throw new Error(`/api/auth/organizations failed (${response.status})`); return (await response.json()) as { organizations: ReadonlyArray<{ id: string; slug: string }>; activeOrganizationId: string | null; }; });