Unified · Split
| @@ -3,12 +3,17 @@ · 2 unmodified lines | |||
| 3 | // | 3 | // |
| 4 | // Every one of these is a thin wrapper over a REAL product endpoint — there is | 4 | // Every one of these is a thin wrapper over a REAL product endpoint — there is |
| 5 | // no back door here. They were copy-pasted verbatim into four cloud scenarios | 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 | 6 | // before living here, and the copies drifted in the two ways that matter, both |
| 7 | // of them carried `forBrowser`, which is the fix for the fact that `joinOrg` | 7 | // now folded in: |
| 8 | // refreshes `headers.cookie` (the sealed session is re-issued on the new org) | 8 | // |
| 9 | // but leaves `cookies` on the PRE-join value — and the browser context is | 9 | // - only one carried `forBrowser`, the fix for the fact that `joinOrg` |
| 10 | // seeded from `cookies`. A scenario using a stale copy browses as the person's | 10 | // refreshes `headers.cookie` (the sealed session is re-issued on the new |
| 11 | // old org while its API calls use the new one. | 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 | // `cloud/*.test.ts` is a vitest `include` of `*.test.ts` only, so this module | 18 | // `cloud/*.test.ts` is a vitest `include` of `*.test.ts` only, so this module |
| 14 | // is never collected as a suite. | 19 | // is never collected as a suite. |
| @@ -16,6 +21,23 @@ import { Effect } from "effect"; · 1 unmodified line | |||
| 16 | 21 | ||
| 17 | import type { Identity, Target as TargetShape } from "../../src/target"; | 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 | /** The session cookie header this identity authenticates API calls with. */ | 41 | /** The session cookie header this identity authenticates API calls with. */ |
| 20 | export const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? ""; | 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 | /** POST as `identity`, failing loudly on a non-2xx (a silent skip here would | 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 | export const postJson = ( | 67 | export const postJson = ( |
| 41 | target: TargetShape, | 68 | target: TargetShape, |
| 42 | path: string, | 69 | path: string, |
| @@ -47,9 +74,9 @@ export const postJson = ( · 4 unmodified lines | |||
| 47 | const response = await fetch(new URL(path, target.baseUrl), { | 74 | const response = await fetch(new URL(path, target.baseUrl), { |
| 48 | method: "POST", | 75 | method: "POST", |
| 49 | headers: { | 76 | headers: { |
| 77 | ...(identity.headers ?? {}), | ||
| 50 | "content-type": "application/json", | 78 | "content-type": "application/json", |
| 51 | origin: new URL(target.baseUrl).origin, | 79 | origin: new URL(target.baseUrl).origin, |
| 52 | cookie: cookieOf(identity), | ||
| 53 | }, | 80 | }, |
| 54 | body: JSON.stringify(body), | 81 | body: JSON.stringify(body), |
| 55 | }); | 82 | }); |
| @@ -59,13 +86,27 @@ export const postJson = ( · 3 unmodified lines | |||
| 59 | return response; | 86 | return response; |
| 60 | }); | 87 | }); |
| 61 | 88 | ||
| 62 | /** The identity re-bound to the refreshed session cookie a response set. */ | 89 | /** The identity re-bound to the refreshed session cookie a response set, and |
| 63 | export const withRefreshedSession = (identity: Identity, response: Response): Identity => { | 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 | const refreshed = (response.headers.getSetCookie?.() ?? []) | 97 | const refreshed = (response.headers.getSetCookie?.() ?? []) |
| 65 | .find((header) => header.startsWith("wos-session=")) | 98 | .find((header) => header.startsWith("wos-session=")) |
| 66 | ?.split(";")[0]; | 99 | ?.split(";")[0]; |
| 67 | if (!refreshed) throw new Error("response did not refresh the session cookie"); | 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 | * invite carries no roleSlug, so the joiner lands as a plain MEMBER (the actor | 114 | * invite carries no roleSlug, so the joiner lands as a plain MEMBER (the actor |
| 74 | * an access-denial assertion needs); pass `roleSlug` for anything else. | 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 | 117 | * The returned identity is bound to the newly-joined org — the re-sealed |
| 77 | * accept-invitation re-seals the session onto it. Calling this twice for one | 118 | * session from accept-invitation, scoped by the selector to the org they were |
| 78 | * person is how a scenario builds a multi-org member: the WorkOS membership in | 119 | * invited INTO (`admin`'s). Calling this twice for one person is how a scenario |
| 79 | * the earlier org persists, and the session simply points at the later one. | 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 | export const joinOrg = ( | 123 | export const joinOrg = ( |
| 82 | target: TargetShape, | 124 | target: TargetShape, |
| @@ -93,7 +135,7 @@ export const joinOrg = ( · 10 unmodified lines | |||
| 93 | const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, { | 135 | const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, { |
| 94 | invitationId: invitation.id, | 136 | invitationId: invitation.id, |
| 95 | }); | 137 | }); |
| 96 | return withRefreshedSession(member, acceptResponse); | 138 | return withRefreshedSession(member, acceptResponse, orgSelectorOf(admin)); |
| 97 | }); | 139 | }); |
| 98 | 140 | ||
| 99 | /** Create another org for this account; returns the identity bound to it. */ | 141 | /** Create another org for this account; returns the identity bound to it. */ |
| @@ -104,22 +146,10 @@ export const createOrg = ( · 4 unmodified lines | |||
| 104 | ): Effect.Effect<Identity> => | 146 | ): Effect.Effect<Identity> => |
| 105 | Effect.gen(function* () { | 147 | Effect.gen(function* () { |
| 106 | const response = yield* postJson(target, "/api/auth/create-organization", identity, { name }); | 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 | /** This identity, scoped to `organizationId` for the requests it makes. */ | 153 | /** This identity, scoped to `organizationId` for the requests it makes. */ |
| 124 | export const inOrg = (identity: Identity, organizationId: string): Identity => ({ | 154 | export const inOrg = (identity: Identity, organizationId: string): Identity => ({ |
| 125 | ...identity, | 155 | ...identity, |