The active org moves off the WorkOS session cookie and onto the URL — the cookie still proves login identity, but membership is verified per request against the URL-resolved org. This unblocks "in same browser tab, switch orgs by changing the URL" without a full session refresh. Web side: - All protected routes move to `apps/cloud/src/routes/$org/...`. The bracket-escape `[-]` convention keeps `[-].billing` and `[-].settings` as literal `-` URL segments under `/$org/-/...`. - A new `OrgRouteProvider` resolves the URL handle via `auth.organizations` and exposes `{ orgId, orgName, orgHandle }` to descendants. Unknown handles redirect to the first membership. - `/` index redirects to the first membership's `/$handle`. AuthGate routes `organizations.length === 0` to onboarding. - The org switcher is now a `<Link>` to `/$org`; `switchOrganization`, `organizationsAtom`, and the related session-refresh path are gone. - Cloud's NavItem wrapper goes away — sidebar links use typed `<Link>` directly with a `navItemClassName(active)` helper. API side: - Cloud's protected API mounts under `/api/:org/...` via a `HttpRouter.prefixed("/api/:org")` layer. - `ExecutionStackMiddleware` reads the URL `:org` segment, looks up the org row by handle, and verifies membership against WorkOS — independent of `session.organizationId`. - The frontend executor client sets its base URL to `${origin}/api/${orgHandle}` once the org layout resolves. - `/auth/me` returns `organizations: Array<{id, handle, name}>` instead of a single org. `createOrganization` and `acceptInvitation` no longer refresh the session — the URL is the source of truth, the client just navigates to the new handle. Test harness: - The harness mirrors the prefix layer, lazily seeds an organizations row whose handle equals the requested orgId, and rewrites outgoing request URLs to include `/api/${orgId}`. The `x-test-org-id` header is gone; `x-test-user-id` remains for tests that need a non-default user.
Showing the changes to apps/cloud/src/api.request-scope.node.test.ts (1 of 23 files). Show all
Unified · Split
| @@ -183,11 +183,12 @@ describe("makeApiLive (prod handler factory) request scoping", () => { · 182 unmodified lines | ||
| 183 | 183 | }).handler; |
| 184 | 184 | |
| 185 | 185 | // Hit a protected route. ExecutionStackMiddleware short-circuits with |
| 186 | − // 403 (no session cookie) but not before `requestScopedMiddleware` | |
| 187 | − // has built the per-request layer. We don't care about the response — | |
| 188 | − // only that the layer was built once per request. | |
| 189 | − await handler(new Request("http://test.local/scope")); | |
| 190 | − await handler(new Request("http://test.local/scope")); | |
| 186 | + // 401/404 (no session cookie / unknown org) but not before | |
| 187 | + // `requestScopedMiddleware` has built the per-request layer. We don't | |
| 188 | + // care about the response — only that the layer was built once per | |
| 189 | + // request. The protected API mounts under `/api/:org/...`. | |
| 190 | + await handler(new Request("http://test.local/api/test_org/scope")); | |
| 191 | + await handler(new Request("http://test.local/api/test_org/scope")); | |
| 191 | 192 | |
| 192 | 193 | expect(counts.acquires).toBe(2); |
| 193 | 194 | expect(counts.releases).toBe(2); |