Unified · Split
| @@ -3,12 +3,17 @@ · 2 unmodified lines | ||
| 3 | 3 | // |
| 4 | 4 | // Every one of these is a thin wrapper over a REAL product endpoint — there is |
| 5 | 5 | // no back door here. They were copy-pasted verbatim into four cloud scenarios |
| 6 | −// before living here, and the copies drifted in the way that matters: only one | |
| 7 | −// of them carried `forBrowser`, which is the fix for the fact that `joinOrg` | |
| 8 | −// refreshes `headers.cookie` (the sealed session is re-issued on the new org) | |
| 9 | −// but leaves `cookies` on the PRE-join value — and the browser context is | |
| 10 | −// seeded from `cookies`. A scenario using a stale copy browses as the person's | |
| 11 | −// old org while its API calls use the new one. | |
| 6 | +// before living here, and the copies drifted in the two ways that matter, both | |
| 7 | +// now folded in: | |
| 8 | +// | |
| 9 | +// - only one carried `forBrowser`, the fix for the fact that `joinOrg` | |
| 10 | +// refreshes `headers.cookie` (the sealed session is re-issued on the new | |
| 11 | +// org) but leaves `cookies` on the PRE-join value — and the browser context | |
| 12 | +// is seeded from `cookies`. A scenario on a stale copy browses as the | |
| 13 | +// person's old org while its API calls use the new one. | |
| 14 | +// - only one forwarded the ORG SELECTOR header. Org-scoped endpoints fail | |
| 15 | +// closed without it (`AccountNoOrganization`), so a cookie-only copy cannot | |
| 16 | +// even build the fixture: `/api/account/members/invite` 403s. | |
| 12 | 17 | // |
| 13 | 18 | // `cloud/*.test.ts` is a vitest `include` of `*.test.ts` only, so this module |
| 14 | 19 | // is never collected as a suite. |
| @@ -16,6 +21,23 @@ import { Effect } from "effect"; · 1 unmodified line | ||
| 16 | 21 | |
| 17 | 22 | import type { Identity, Target as TargetShape } from "../../src/target"; |
| 18 | 23 | |
| 24 | +/** | |
| 25 | + * The per-request org selector. `/api/auth/switch-organization` was removed | |
| 26 | + * (#1000): the URL prefix or this header is the scope authority now, and the | |
| 27 | + * session's own org is only a default. | |
| 28 | + * | |
| 29 | + * This is the RIGHT way to read one account's data in a specific org: a session | |
| 30 | + * cookie captured before the account joined another org is stale the moment the | |
| 31 | + * later `accept-invitation` re-seals it, so holding onto the old cookie reads as | |
| 32 | + * an unauthenticated caller. Take the CURRENT identity and point it at the org | |
| 33 | + * you mean. | |
| 34 | + * | |
| 35 | + * Mirrors `ORG_SELECTOR_HEADER` in `apps/cloud/src/auth/organization.ts` | |
| 36 | + * (`EXECUTOR_ORG_SELECTOR_HEADER` in the SDK's shared exports); duplicated as a | |
| 37 | + * literal so the e2e suite stays a black-box client of the deployed product. | |
| 38 | + */ | |
| 39 | +const ORG_SELECTOR_HEADER = "x-executor-organization"; | |
| 40 | + | |
| 19 | 41 | /** The session cookie header this identity authenticates API calls with. */ |
| 20 | 42 | export const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? ""; |
| 21 | 43 | |
| @@ -36,7 +58,12 @@ export const forBrowser = (identity: Identity): Identity => { · 14 unmodified lines | ||
| 36 | 58 | }; |
| 37 | 59 | |
| 38 | 60 | /** POST as `identity`, failing loudly on a non-2xx (a silent skip here would |
| 39 | − * leave the scenario asserting against a half-built fixture). */ | |
| 61 | + * leave the scenario asserting against a half-built fixture). | |
| 62 | + * | |
| 63 | + * Sends the identity's OWN headers, not just its cookie: org-scoped endpoints | |
| 64 | + * (`/api/account/members/invite` among them) fail closed with | |
| 65 | + * `AccountNoOrganization` when the org selector header is absent, because the | |
| 66 | + * session's own org is only a default and is never fallen back to. */ | |
| 40 | 67 | export const postJson = ( |
| 41 | 68 | target: TargetShape, |
| 42 | 69 | path: string, |
| @@ -47,9 +74,9 @@ export const postJson = ( · 4 unmodified lines | ||
| 47 | 74 | const response = await fetch(new URL(path, target.baseUrl), { |
| 48 | 75 | method: "POST", |
| 49 | 76 | headers: { |
| 77 | + ...(identity.headers ?? {}), | |
| 50 | 78 | "content-type": "application/json", |
| 51 | 79 | origin: new URL(target.baseUrl).origin, |
| 52 | − cookie: cookieOf(identity), | |
| 53 | 80 | }, |
| 54 | 81 | body: JSON.stringify(body), |
| 55 | 82 | }); |
| @@ -59,13 +86,27 @@ export const postJson = ( · 3 unmodified lines | ||
| 59 | 86 | return response; |
| 60 | 87 | }); |
| 61 | 88 | |
| 62 | −/** The identity re-bound to the refreshed session cookie a response set. */ | |
| 63 | −export const withRefreshedSession = (identity: Identity, response: Response): Identity => { | |
| 89 | +/** The identity re-bound to the refreshed session cookie a response set, and | |
| 90 | + * scoped to `orgSelector` — carrying the cookie alone would leave every | |
| 91 | + * org-scoped read the identity goes on to make failing closed. */ | |
| 92 | +export const withRefreshedSession = ( | |
| 93 | + identity: Identity, | |
| 94 | + response: Response, | |
| 95 | + orgSelector: string, | |
| 96 | +): Identity => { | |
| 64 | 97 | const refreshed = (response.headers.getSetCookie?.() ?? []) |
| 65 | 98 | .find((header) => header.startsWith("wos-session=")) |
| 66 | 99 | ?.split(";")[0]; |
| 67 | 100 | if (!refreshed) throw new Error("response did not refresh the session cookie"); |
| 68 | − return { ...identity, headers: { cookie: refreshed } }; | |
| 101 | + return { ...identity, headers: { cookie: refreshed, [ORG_SELECTOR_HEADER]: orgSelector } }; | |
| 102 | +}; | |
| 103 | + | |
| 104 | +/** The org selector this identity's requests carry — the same header the web | |
| 105 | + * client derives from the console URL's slug. */ | |
| 106 | +export const orgSelectorOf = (identity: Identity): string => { | |
| 107 | + const selector = identity.headers?.[ORG_SELECTOR_HEADER]; | |
| 108 | + if (!selector) throw new Error(`identity ${identity.label} carries no org selector header`); | |
| 109 | + return selector; | |
| 69 | 110 | }; |
| 70 | 111 | |
| 71 | 112 | /** |
| @@ -73,10 +114,11 @@ export const withRefreshedSession = (identity: Identity, response: Response): Id · 1 unmodified line | ||
| 73 | 114 | * invite carries no roleSlug, so the joiner lands as a plain MEMBER (the actor |
| 74 | 115 | * an access-denial assertion needs); pass `roleSlug` for anything else. |
| 75 | 116 | * |
| 76 | − * The returned identity is bound to the newly-joined org, because | |
| 77 | − * accept-invitation re-seals the session onto it. Calling this twice for one | |
| 78 | − * person is how a scenario builds a multi-org member: the WorkOS membership in | |
| 79 | − * the earlier org persists, and the session simply points at the later one. | |
| 117 | + * The returned identity is bound to the newly-joined org — the re-sealed | |
| 118 | + * session from accept-invitation, scoped by the selector to the org they were | |
| 119 | + * invited INTO (`admin`'s). Calling this twice for one person is how a scenario | |
| 120 | + * builds a multi-org member: the WorkOS membership in the earlier org persists, | |
| 121 | + * and both the session and the selector point at the later one. | |
| 80 | 122 | */ |
| 81 | 123 | export const joinOrg = ( |
| 82 | 124 | target: TargetShape, |
| @@ -93,7 +135,7 @@ export const joinOrg = ( · 10 unmodified lines | ||
| 93 | 135 | const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, { |
| 94 | 136 | invitationId: invitation.id, |
| 95 | 137 | }); |
| 96 | − return withRefreshedSession(member, acceptResponse); | |
| 138 | + return withRefreshedSession(member, acceptResponse, orgSelectorOf(admin)); | |
| 97 | 139 | }); |
| 98 | 140 | |
| 99 | 141 | /** Create another org for this account; returns the identity bound to it. */ |
| @@ -104,22 +146,10 @@ export const createOrg = ( · 4 unmodified lines | ||
| 104 | 146 | ): Effect.Effect<Identity> => |
| 105 | 147 | Effect.gen(function* () { |
| 106 | 148 | const response = yield* postJson(target, "/api/auth/create-organization", identity, { name }); |
| 107 | − return withRefreshedSession(identity, response); | |
| 149 | + const created = (yield* Effect.promise(() => response.clone().json())) as { id: string }; | |
| 150 | + return withRefreshedSession(identity, response, created.id); | |
| 108 | 151 | }); |
| 109 | 152 | |
| 110 | −/** | |
| 111 | − * The per-request org selector. `/api/auth/switch-organization` was removed | |
| 112 | − * (#1000): the URL prefix or this header is the scope authority now, and the | |
| 113 | − * session's own org is only a default. | |
| 114 | − * | |
| 115 | − * This is the RIGHT way to read one account's data in a specific org: a session | |
| 116 | − * cookie captured before the account joined another org is stale the moment the | |
| 117 | − * later `accept-invitation` re-seals it, so holding onto the old cookie reads as | |
| 118 | − * an unauthenticated caller. Take the CURRENT identity and point it at the org | |
| 119 | − * you mean. | |
| 120 | − */ | |
| 121 | −const ORG_SELECTOR_HEADER = "x-executor-organization"; | |
| 122 | − | |
| 123 | 153 | /** This identity, scoped to `organizationId` for the requests it makes. */ |
| 124 | 154 | export const inOrg = (identity: Identity, organizationId: string): Identity => ({ |
| 125 | 155 | ...identity, |