History

Move web routes under /:org and API routes under /api/:org

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.

Rhys Sullivan committed 6 months ago

Commit b7508931 parent 0a99d1723 files · +792 −510Browse files

Unified · Split

23 files
  1. apps/cloud/src/api.request-scope.node.test.ts +6 −5
  2. apps/cloud/src/api/protected.ts +50 −5
  3. apps/cloud/src/auth/api.ts +5 −19
  4. apps/cloud/src/auth/handlers.ts +64 −77
  5. apps/cloud/src/routeTree.gen.ts +216 −167
  6. apps/cloud/src/routes/$org.tsx +61 −0
  7. apps/cloud/src/routes/$org/[-].billing.tsx +5 −2
  8. apps/cloud/src/routes/$org/[-].billing_.plans.tsx +7 −4
  9. apps/cloud/src/routes/$org/[-].settings.tsx +10 −10
  10. apps/cloud/src/routes/$org/connections.tsx +1 −1
  11. apps/cloud/src/routes/$org/index.tsx +6 −0
  12. apps/cloud/src/routes/$org/policies.tsx +1 −1
  13. apps/cloud/src/routes/$org/secrets.tsx +1 −1
  14. apps/cloud/src/routes/$org/sources.$namespace.tsx +1 −1
  15. apps/cloud/src/routes/$org/sources.add.$pluginKey.tsx +1 −1
  16. apps/cloud/src/routes/$org/tools.tsx +1 −1
  17. apps/cloud/src/routes/__root.tsx +4 −80
  18. apps/cloud/src/routes/index.tsx +23 −3
  19. apps/cloud/src/services/__test-harness__/api-harness.ts +93 −24
  20. apps/cloud/src/web/auth.tsx +23 −17
  21. apps/cloud/src/web/components/create-organization-form.tsx +1 −1
  22. apps/cloud/src/web/org-route.tsx +31 −0
  23. apps/cloud/src/web/shell.tsx +181 −90
apps/cloud/src/api.request-scope.node.test.ts+6−5
@@ -183,11 +183,12 @@ describe("makeApiLive (prod handler factory) request scoping", () => { · 182 unmodified lines
183183 }).handler;
184184
185185 // 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"));
191192
192193 expect(counts.acquires).toBe(2);
193194 expect(counts.releases).toBe(2);
apps/cloud/src/api/protected.ts+50−5
@@ -21,11 +21,22 @@ import { WorkOSAuth } from "../auth/workos"; · 20 unmodified lines
2121 import { AutumnService } from "../services/autumn";
2222 import { DbService } from "../services/db";
2323 import { makeExecutionStack } from "../services/execution-stack";
24+import { makeUserStore } from "../services/user-store";
2425 import { HttpResponseError } from "./error-response";
2526 import { RequestScopedServicesLive } from "./layers";
2627 import { ProtectedCloudApi, ProtectedCloudApiLive, RouterConfig } from "./protected-layers";
2728 import { requestScopedMiddleware } from "./request-scoped";
2829
30+// Pull the URL `:org` segment from a request path. The protected API mounts
31+// under `/api/:org/...` — anything else is a programming error and surfaces as
32+// a typed `no_organization` response so the framework's error pipeline can
33+// render it.
34+const orgHandleFromPath = (pathname: string): string | null => {
35+ const parts = pathname.split("/").filter((part) => part.length > 0);
36+ if (parts.length < 2 || parts[0] !== "api") return null;
37+ return parts[1] ?? null;
38+};
39+
2940 // Pre-compute the per-plugin `Effect.provideService(extensionService,
3041 // executor[id])` chain. The plugin spec carries the Service tag so
3142 // this file doesn't import each plugin's `*/api` directly.
@@ -72,19 +83,42 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{ · 40 unmodified lines
7283 const webRequest = yield* HttpServerRequest.toWeb(request);
7384 const workos = yield* WorkOSAuth;
7485 const session = yield* workos.authenticateRequest(webRequest);
75− if (!session || !session.organizationId) {
86+ if (!session) {
7687 return yield* new HttpResponseError({
77− status: 403,
88+ status: 401,
89+ code: "unauthorized",
90+ message: "Unauthorized",
91+ });
92+ }
93+ // The URL is the source of truth for active org. Pull the handle
94+ // off the request path, resolve it to an org row, and verify
95+ // membership against WorkOS — independent of `session.organizationId`.
96+ const url = new URL(webRequest.url);
97+ const handle = orgHandleFromPath(url.pathname);
98+ if (!handle) {
99+ return yield* new HttpResponseError({
100+ status: 404,
78101 code: "no_organization",
79− message: "No organization in session",
102+ message: "Missing organization in URL",
80103 });
81104 }
82− const org = yield* authorizeOrganization(session.userId, session.organizationId);
105+ const { db } = yield* DbService;
106+ const resolved = yield* Effect.promise(() =>
107+ makeUserStore(db).getOrganizationByHandle(handle),
108+ );
109+ if (!resolved) {
110+ return yield* new HttpResponseError({
111+ status: 404,
112+ code: "no_organization",
113+ message: `Organization "${handle}" not found`,
114+ });
115+ }
116+ const org = yield* authorizeOrganization(session.userId, resolved.id);
83117 if (!org) {
84118 return yield* new HttpResponseError({
85119 status: 403,
86120 code: "no_organization",
87− message: "No organization in session",
121+ message: "Not a member of this organization",
88122 });
89123 }
90124 const auth = AuthContext.of({
@@ -105,6 +139,16 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{ · 14 unmodified lines
105139 }),
106140 );
107141
142+// Layer that swaps the boot router with a `:org`-prefixed view, so every
143+// route registered by `ProtectedCloudApiLive` mounts under `/api/:org/*`.
144+// `HttpRouter.prefixed` returns a wrapper that delegates to the underlying
145+// router state — the outer router-config layer still owns the actual
146+// FindMyWay instance, so non-protected routes (auth, autumn, swagger) keep
147+// their unprefixed paths.
148+const PrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)(
149+ Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed("/api/:org")),
150+);
151+
108152 // `rsLive` is the per-request DB layer. Combining it into the auth
109153 // middleware collapses `requires: DbService | UserStoreService` to
110154 // never (so `.layer` is a real Layer instead of the "Need to combine"
@@ -118,6 +162,7 @@ export const makeProtectedApiLive = (rsLive: Layer.Layer<DbService | UserStoreSe · 7 unmodified lines
118162 ).layer;
119163 return ProtectedCloudApiLive.pipe(
120164 Layer.provide(protectedMiddleware),
165+ Layer.provide(PrefixedRouterLayer),
121166 Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })),
122167 Layer.provideMerge(RouterConfig),
123168 );
apps/cloud/src/auth/api.ts+5−19
@@ -12,26 +12,18 @@ const AuthUser = Schema.Struct({ · 11 unmodified lines
1212
1313 const AuthOrganization = Schema.Struct({
1414 id: Schema.String,
15+ handle: Schema.String,
1516 name: Schema.String,
1617 });
1718
1819 const AuthMeResponse = Schema.Struct({
1920 user: AuthUser,
20− organization: Schema.NullOr(AuthOrganization),
21−});
22−
23−const AuthOrganizationSummary = Schema.Struct({
24− id: Schema.String,
25− name: Schema.String,
21+ /** Memberships, with the org `handle` URL routes use. Sorted alphabetically. */
22+ organizations: Schema.Array(AuthOrganization),
2623 });
2724
2825 const AuthOrganizationsResponse = Schema.Struct({
29− organizations: Schema.Array(AuthOrganizationSummary),
30− activeOrganizationId: Schema.NullOr(Schema.String),
31−});
32−
33−const SwitchOrganizationBody = Schema.Struct({
34− organizationId: Schema.String,
26+ organizations: Schema.Array(AuthOrganization),
3527 });
3628
3729 const CreateOrganizationBody = Schema.Struct({
@@ -40,6 +32,7 @@ const CreateOrganizationBody = Schema.Struct({ · 2 unmodified lines
4032
4133 const CreateOrganizationResponse = Schema.Struct({
4234 id: Schema.String,
35+ handle: Schema.String,
4336 name: Schema.String,
4437 });
4538
@@ -81,7 +74,6 @@ export const AUTH_PATHS = { · 35 unmodified lines
8174 login: "/api/auth/login",
8275 logout: "/api/auth/logout",
8376 callback: "/api/auth/callback",
84− switchOrganization: "/api/auth/switch-organization",
8577 } as const;
8678
8779 const AuthErrors = [UserStoreError, WorkOSError] as const;
@@ -111,12 +103,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 23 unmodified lines
111103 error: WorkOSError,
112104 }),
113105 )
114− .add(
115− HttpApiEndpoint.post("switchOrganization", "/auth/switch-organization", {
116− payload: SwitchOrganizationBody,
117− error: WorkOSError,
118− }),
119− )
120106 .add(
121107 HttpApiEndpoint.post("createOrganization", "/auth/create-organization", {
122108 payload: CreateOrganizationBody,
apps/cloud/src/auth/handlers.ts+64−77
@@ -1,12 +1,11 @@
11 import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";
22 import { HttpServerResponse } from "effect/unstable/http";
33 import { Duration, Effect } from "effect";
4−import { setCookie, deleteCookie } from "@tanstack/react-start/server";
4+import { deleteCookie } from "@tanstack/react-start/server";
55
66 import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";
77 import { SessionContext } from "./middleware";
88 import { UserStoreService } from "./context";
9−import { authorizeOrganization } from "./authorize-organization";
109 import { env } from "cloudflare:workers";
1110 import { WorkOSError } from "./errors";
1211 import { WorkOSAuth } from "./workos";
@@ -181,9 +180,37 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 168 unmodified lines
181180 .handle("me", () =>
182181 Effect.gen(function* () {
183182 const session = yield* SessionContext;
184− const org = session.organizationId
185− ? yield* authorizeOrganization(session.accountId, session.organizationId)
186− : null;
183+ const users = yield* UserStoreService;
184+ const workos = yield* WorkOSAuth;
185+
186+ const memberships = yield* workos.listUserMemberships(session.accountId);
187+ // Mirror each org locally so the local handle exists; ignore mirror
188+ // failures (the directory layer already has the canonical name +
189+ // membership — a transient db error here shouldn't blank the user's
190+ // org list).
191+ const orgs = yield* Effect.all(
192+ memberships.data.map((m) =>
193+ workos.getOrganization(m.organizationId).pipe(
194+ Effect.flatMap((org) =>
195+ users
196+ .use((s) => s.upsertOrganization({ id: org.id, name: org.name }))
197+ .pipe(
198+ Effect.map((mirror) => ({
199+ id: mirror.id,
200+ handle: mirror.handle,
201+ name: org.name,
202+ })),
203+ ),
204+ ),
205+ Effect.orElseSucceed(() => null),
206+ ),
207+ ),
208+ { concurrency: "unbounded" },
209+ );
210+
211+ const organizations = orgs
212+ .filter(<T>(v: T | null): v is T => v !== null)
213+ .sort((a, b) => a.name.localeCompare(b.name));
187214
188215 return {
189216 user: {
@@ -192,7 +219,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 2 unmodified lines
192219 name: session.name,
193220 avatarUrl: session.avatarUrl,
194221 },
195− organization: org ? { id: org.id, name: org.name } : null,
222+ organizations,
196223 };
197224 }),
198225 )
@@ -203,39 +230,35 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 4 unmodified lines
203230 .handle("organizations", () =>
204231 Effect.gen(function* () {
205232 const workos = yield* WorkOSAuth;
233+ const users = yield* UserStoreService;
206234 const session = yield* SessionContext;
207235
208236 const memberships = yield* workos.listUserMemberships(session.accountId);
209− const organizations = yield* Effect.all(
237+ const orgs = yield* Effect.all(
210238 memberships.data.map((m) =>
211239 workos.getOrganization(m.organizationId).pipe(
212− Effect.map((org) => ({ id: org.id, name: org.name })),
240+ Effect.flatMap((org) =>
241+ users
242+ .use((s) => s.upsertOrganization({ id: org.id, name: org.name }))
243+ .pipe(
244+ Effect.map((mirror) => ({
245+ id: mirror.id,
246+ handle: mirror.handle,
247+ name: org.name,
248+ })),
249+ ),
250+ ),
213251 Effect.orElseSucceed(() => null),
214252 ),
215253 ),
216254 { concurrency: "unbounded" },
217255 );
218256
219− return {
220− organizations: organizations.filter(
221− (org): org is NonNullable<typeof org> => org !== null,
222− ),
223− activeOrganizationId: session.organizationId,
224− };
225− }),
226− )
227− .handle("switchOrganization", ({ payload }) =>
228− Effect.gen(function* () {
229− const workos = yield* WorkOSAuth;
230− const session = yield* SessionContext;
257+ const organizations = orgs
258+ .filter(<T>(v: T | null): v is T => v !== null)
259+ .sort((a, b) => a.name.localeCompare(b.name));
231260
232− const refreshed = yield* workos.refreshSession(
233− session.sealedSession,
234− payload.organizationId,
235− );
236− if (refreshed) {
237− setCookie("wos-session", refreshed, COOKIE_OPTIONS);
238− }
261+ return { organizations };
239262 }),
240263 )
241264 .handle("createOrganization", ({ payload }) =>
@@ -247,34 +270,15 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 5 unmodified lines
247270 const name = payload.name.trim();
248271 const org = yield* workos.createOrganization(name);
249272 yield* workos.createMembership(org.id, session.accountId, "admin");
250− yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));
251−
252− // Try to attach the new org to the current session. This can fail
253− // (or silently return a session still scoped to the old org) when
254− // the caller's current session is stale — most commonly after the
255− // user was removed from the org their cookie is pinned to. In that
256− // case we can't repair the session in-place, so we clear the
257− // cookie and fail loudly; the frontend will bounce to login and
258− // the callback's rehydrate path will pick up the new membership.
259− const refreshed = yield* workos.refreshSession(session.sealedSession, org.id);
260− const verified = refreshed ? yield* workos.authenticateSealedSession(refreshed) : null;
261−
262− if (!refreshed || !verified || verified.organizationId !== org.id) {
263− yield* Effect.logWarning(
264− "createOrganization: unable to attach new org to current session",
265− {
266− userId: session.accountId,
267− newOrgId: org.id,
268− refreshReturnedSession: refreshed != null,
269− verifiedOrgId: verified?.organizationId ?? null,
270− },
271− );
272− deleteCookie("wos-session", { path: "/" });
273− return yield* new WorkOSError();
274− }
275−
276− setCookie("wos-session", refreshed, COOKIE_OPTIONS);
277− return { id: org.id, name: org.name };
273+ const mirrored = yield* users.use((s) =>
274+ s.upsertOrganization({ id: org.id, name: org.name }),
275+ );
276+ // No session refresh — the URL is the source of truth for active
277+ // org now, so the client just navigates to /:handle/... after
278+ // create. The WorkOS session's `organizationId` only proves login
279+ // identity; it doesn't gate per-org access (that goes through the
280+ // membership check on the URL-resolved org).
281+ return { id: org.id, handle: mirrored.handle, name: org.name };
278282 }),
279283 )
280284 .handle("pendingInvitations", () =>
@@ -330,7 +334,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 49 unmodified lines
330334 Effect.gen(function* () {
331335 const workos = yield* WorkOSAuth;
332336 const users = yield* UserStoreService;
333− const session = yield* SessionContext;
334337
335338 const invitation = yield* workos.acceptInvitation(payload.invitationId);
336339
@@ -343,30 +346,14 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 6 unmodified lines
343346 return yield* new WorkOSError();
344347 }
345348
346− // Mirror the org locally so domain tables can FK against it.
349+ // Mirror the org locally so domain tables can FK against it and
350+ // the URL handle exists by the time the client navigates.
347351 const org = yield* workos.getOrganization(invitation.organizationId);
348352 yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));
349353
350− // Attach the just-accepted org to the current session. Same shape
351− // as createOrganization: refresh + verify; if we can't pin the
352− // session in-place, clear the cookie and let the user bounce
353− // through login again. The acceptance has already succeeded
354− // server-side, so the next login will pick up the membership.
355− const refreshed = yield* workos.refreshSession(session.sealedSession, org.id);
356− const verified = refreshed ? yield* workos.authenticateSealedSession(refreshed) : null;
357−
358− if (!refreshed || !verified || verified.organizationId !== org.id) {
359− yield* Effect.logWarning("acceptInvitation: unable to attach org to current session", {
360− userId: session.accountId,
361− orgId: org.id,
362− refreshReturnedSession: refreshed != null,
363− verifiedOrgId: verified?.organizationId ?? null,
364− });
365− deleteCookie("wos-session", { path: "/" });
366− return yield* new WorkOSError();
367− }
368−
369− setCookie("wos-session", refreshed, COOKIE_OPTIONS);
354+ // No session refresh — the URL is the source of truth for active
355+ // org. The client refreshes the auth atom and navigates to the
356+ // new org's `/$handle` after this resolves.
370357 return { id: org.id, name: org.name };
371358 }),
372359 ),
apps/cloud/src/routeTree.gen.ts+216−167
@@ -9,201 +9,181 @@ · 8 unmodified lines
99 // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
1010
1111 import { Route as rootRouteImport } from './routes/__root'
12−import { Route as ToolsRouteImport } from './routes/tools'
13−import { Route as SecretsRouteImport } from './routes/secrets'
14−import { Route as PoliciesRouteImport } from './routes/policies'
15−import { Route as OrgRouteImport } from './routes/org'
16−import { Route as ConnectionsRouteImport } from './routes/connections'
17−import { Route as BillingRouteImport } from './routes/billing'
12+import { Route as OrgRouteImport } from './routes/$org'
1813 import { Route as IndexRouteImport } from './routes/index'
19−import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace'
20−import { Route as BillingPlansRouteImport } from './routes/billing_.plans'
21−import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey'
14+import { Route as OrgIndexRouteImport } from './routes/$org/index'
15+import { Route as OrgToolsRouteImport } from './routes/$org/tools'
16+import { Route as OrgSecretsRouteImport } from './routes/$org/secrets'
17+import { Route as OrgPoliciesRouteImport } from './routes/$org/policies'
18+import { Route as OrgConnectionsRouteImport } from './routes/$org/connections'
19+import { Route as OrgSourcesNamespaceRouteImport } from './routes/$org/sources.$namespace'
20+import { Route as OrgChar91Char93SettingsRouteImport } from './routes/$org/[-].settings'
21+import { Route as OrgChar91Char93BillingRouteImport } from './routes/$org/[-].billing'
22+import { Route as OrgSourcesAddPluginKeyRouteImport } from './routes/$org/sources.add.$pluginKey'
23+import { Route as OrgChar91Char93BillingPlansRouteImport } from './routes/$org/[-].billing_.plans'
2224
23−const ToolsRoute = ToolsRouteImport.update({
25+const OrgRoute = OrgRouteImport.update({
26+ id: '/$org',
27+ path: '/$org',
28+ getParentRoute: () => rootRouteImport,
29+} as any)
30+const IndexRoute = IndexRouteImport.update({
31+ id: '/',
32+ path: '/',
33+ getParentRoute: () => rootRouteImport,
34+} as any)
35+const OrgIndexRoute = OrgIndexRouteImport.update({
36+ id: '/',
37+ path: '/',
38+ getParentRoute: () => OrgRoute,
39+} as any)
40+const OrgToolsRoute = OrgToolsRouteImport.update({
2441 id: '/tools',
2542 path: '/tools',
26− getParentRoute: () => rootRouteImport,
43+ getParentRoute: () => OrgRoute,
2744 } as any)
28−const SecretsRoute = SecretsRouteImport.update({
45+const OrgSecretsRoute = OrgSecretsRouteImport.update({
2946 id: '/secrets',
3047 path: '/secrets',
31− getParentRoute: () => rootRouteImport,
48+ getParentRoute: () => OrgRoute,
3249 } as any)
33−const PoliciesRoute = PoliciesRouteImport.update({
50+const OrgPoliciesRoute = OrgPoliciesRouteImport.update({
3451 id: '/policies',
3552 path: '/policies',
36− getParentRoute: () => rootRouteImport,
37−} as any)
38−const OrgRoute = OrgRouteImport.update({
39− id: '/org',
40− path: '/org',
41− getParentRoute: () => rootRouteImport,
53+ getParentRoute: () => OrgRoute,
4254 } as any)
43−const ConnectionsRoute = ConnectionsRouteImport.update({
55+const OrgConnectionsRoute = OrgConnectionsRouteImport.update({
4456 id: '/connections',
4557 path: '/connections',
46− getParentRoute: () => rootRouteImport,
47−} as any)
48−const BillingRoute = BillingRouteImport.update({
49− id: '/billing',
50− path: '/billing',
51− getParentRoute: () => rootRouteImport,
52−} as any)
53−const IndexRoute = IndexRouteImport.update({
54− id: '/',
55− path: '/',
56− getParentRoute: () => rootRouteImport,
58+ getParentRoute: () => OrgRoute,
5759 } as any)
58−const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({
60+const OrgSourcesNamespaceRoute = OrgSourcesNamespaceRouteImport.update({
5961 id: '/sources/$namespace',
6062 path: '/sources/$namespace',
61− getParentRoute: () => rootRouteImport,
63+ getParentRoute: () => OrgRoute,
6264 } as any)
63−const BillingPlansRoute = BillingPlansRouteImport.update({
64− id: '/billing_/plans',
65− path: '/billing/plans',
66− getParentRoute: () => rootRouteImport,
65+const OrgChar91Char93SettingsRoute = OrgChar91Char93SettingsRouteImport.update({
66+ id: '/-/settings',
67+ path: '/-/settings',
68+ getParentRoute: () => OrgRoute,
69+} as any)
70+const OrgChar91Char93BillingRoute = OrgChar91Char93BillingRouteImport.update({
71+ id: '/-/billing',
72+ path: '/-/billing',
73+ getParentRoute: () => OrgRoute,
6774 } as any)
68−const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({
75+const OrgSourcesAddPluginKeyRoute = OrgSourcesAddPluginKeyRouteImport.update({
6976 id: '/sources/add/$pluginKey',
7077 path: '/sources/add/$pluginKey',
71− getParentRoute: () => rootRouteImport,
78+ getParentRoute: () => OrgRoute,
7279 } as any)
80+const OrgChar91Char93BillingPlansRoute =
81+ OrgChar91Char93BillingPlansRouteImport.update({
82+ id: '/-/billing_/plans',
83+ path: '/-/billing/plans',
84+ getParentRoute: () => OrgRoute,
85+ } as any)
7386
7487 export interface FileRoutesByFullPath {
7588 '/': typeof IndexRoute
76− '/billing': typeof BillingRoute
77− '/connections': typeof ConnectionsRoute
78− '/org': typeof OrgRoute
79− '/policies': typeof PoliciesRoute
80− '/secrets': typeof SecretsRoute
81− '/tools': typeof ToolsRoute
82− '/billing/plans': typeof BillingPlansRoute
83− '/sources/$namespace': typeof SourcesNamespaceRoute
84− '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
89+ '/$org': typeof OrgRouteWithChildren
90+ '/$org/connections': typeof OrgConnectionsRoute
91+ '/$org/policies': typeof OrgPoliciesRoute
92+ '/$org/secrets': typeof OrgSecretsRoute
93+ '/$org/tools': typeof OrgToolsRoute
94+ '/$org/': typeof OrgIndexRoute
95+ '/$org/-/billing': typeof OrgChar91Char93BillingRoute
96+ '/$org/-/settings': typeof OrgChar91Char93SettingsRoute
97+ '/$org/sources/$namespace': typeof OrgSourcesNamespaceRoute
98+ '/$org/-/billing/plans': typeof OrgChar91Char93BillingPlansRoute
99+ '/$org/sources/add/$pluginKey': typeof OrgSourcesAddPluginKeyRoute
85100 }
86101 export interface FileRoutesByTo {
87102 '/': typeof IndexRoute
88− '/billing': typeof BillingRoute
89− '/connections': typeof ConnectionsRoute
90− '/org': typeof OrgRoute
91− '/policies': typeof PoliciesRoute
92− '/secrets': typeof SecretsRoute
93− '/tools': typeof ToolsRoute
94− '/billing/plans': typeof BillingPlansRoute
95− '/sources/$namespace': typeof SourcesNamespaceRoute
96− '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
103+ '/$org/connections': typeof OrgConnectionsRoute
104+ '/$org/policies': typeof OrgPoliciesRoute
105+ '/$org/secrets': typeof OrgSecretsRoute
106+ '/$org/tools': typeof OrgToolsRoute
107+ '/$org': typeof OrgIndexRoute
108+ '/$org/-/billing': typeof OrgChar91Char93BillingRoute
109+ '/$org/-/settings': typeof OrgChar91Char93SettingsRoute
110+ '/$org/sources/$namespace': typeof OrgSourcesNamespaceRoute
111+ '/$org/-/billing/plans': typeof OrgChar91Char93BillingPlansRoute
112+ '/$org/sources/add/$pluginKey': typeof OrgSourcesAddPluginKeyRoute
97113 }
98114 export interface FileRoutesById {
99115 __root__: typeof rootRouteImport
100116 '/': typeof IndexRoute
101− '/billing': typeof BillingRoute
102− '/connections': typeof ConnectionsRoute
103− '/org': typeof OrgRoute
104− '/policies': typeof PoliciesRoute
105− '/secrets': typeof SecretsRoute
106− '/tools': typeof ToolsRoute
107− '/billing_/plans': typeof BillingPlansRoute
108− '/sources/$namespace': typeof SourcesNamespaceRoute
109− '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
117+ '/$org': typeof OrgRouteWithChildren
118+ '/$org/connections': typeof OrgConnectionsRoute
119+ '/$org/policies': typeof OrgPoliciesRoute
120+ '/$org/secrets': typeof OrgSecretsRoute
121+ '/$org/tools': typeof OrgToolsRoute
122+ '/$org/': typeof OrgIndexRoute
123+ '/$org/-/billing': typeof OrgChar91Char93BillingRoute
124+ '/$org/-/settings': typeof OrgChar91Char93SettingsRoute
125+ '/$org/sources/$namespace': typeof OrgSourcesNamespaceRoute
126+ '/$org/-/billing_/plans': typeof OrgChar91Char93BillingPlansRoute
127+ '/$org/sources/add/$pluginKey': typeof OrgSourcesAddPluginKeyRoute
110128 }
111129 export interface FileRouteTypes {
112130 fileRoutesByFullPath: FileRoutesByFullPath
113131 fullPaths:
114132 | '/'
115− | '/billing'
116− | '/connections'
117− | '/org'
118− | '/policies'
119− | '/secrets'
120− | '/tools'
121− | '/billing/plans'
122− | '/sources/$namespace'
123− | '/sources/add/$pluginKey'
133+ | '/$org'
134+ | '/$org/connections'
135+ | '/$org/policies'
136+ | '/$org/secrets'
137+ | '/$org/tools'
138+ | '/$org/'
139+ | '/$org/-/billing'
140+ | '/$org/-/settings'
141+ | '/$org/sources/$namespace'
142+ | '/$org/-/billing/plans'
143+ | '/$org/sources/add/$pluginKey'
124144 fileRoutesByTo: FileRoutesByTo
125145 to:
126146 | '/'
127− | '/billing'
128− | '/connections'
129− | '/org'
130− | '/policies'
131− | '/secrets'
132− | '/tools'
133− | '/billing/plans'
134− | '/sources/$namespace'
135− | '/sources/add/$pluginKey'
147+ | '/$org/connections'
148+ | '/$org/policies'
149+ | '/$org/secrets'
150+ | '/$org/tools'
151+ | '/$org'
152+ | '/$org/-/billing'
153+ | '/$org/-/settings'
154+ | '/$org/sources/$namespace'
155+ | '/$org/-/billing/plans'
156+ | '/$org/sources/add/$pluginKey'
136157 id:
137158 | '__root__'
138159 | '/'
139− | '/billing'
140− | '/connections'
141− | '/org'
142− | '/policies'
143− | '/secrets'
144− | '/tools'
145− | '/billing_/plans'
146− | '/sources/$namespace'
147− | '/sources/add/$pluginKey'
160+ | '/$org'
161+ | '/$org/connections'
162+ | '/$org/policies'
163+ | '/$org/secrets'
164+ | '/$org/tools'
165+ | '/$org/'
166+ | '/$org/-/billing'
167+ | '/$org/-/settings'
168+ | '/$org/sources/$namespace'
169+ | '/$org/-/billing_/plans'
170+ | '/$org/sources/add/$pluginKey'
148171 fileRoutesById: FileRoutesById
149172 }
150173 export interface RootRouteChildren {
151174 IndexRoute: typeof IndexRoute
152− BillingRoute: typeof BillingRoute
153− ConnectionsRoute: typeof ConnectionsRoute
154− OrgRoute: typeof OrgRoute
155− PoliciesRoute: typeof PoliciesRoute
156− SecretsRoute: typeof SecretsRoute
157− ToolsRoute: typeof ToolsRoute
158− BillingPlansRoute: typeof BillingPlansRoute
159− SourcesNamespaceRoute: typeof SourcesNamespaceRoute
160− SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute
175+ OrgRoute: typeof OrgRouteWithChildren
161176 }
162177
163178 declare module '@tanstack/react-router' {
164179 interface FileRoutesByPath {
165− '/tools': {
166− id: '/tools'
167− path: '/tools'
168− fullPath: '/tools'
169− preLoaderRoute: typeof ToolsRouteImport
170− parentRoute: typeof rootRouteImport
171− }
172− '/secrets': {
173− id: '/secrets'
174− path: '/secrets'
175− fullPath: '/secrets'
176− preLoaderRoute: typeof SecretsRouteImport
177− parentRoute: typeof rootRouteImport
178− }
179− '/policies': {
180− id: '/policies'
181− path: '/policies'
182− fullPath: '/policies'
183− preLoaderRoute: typeof PoliciesRouteImport
184− parentRoute: typeof rootRouteImport
185− }
186− '/org': {
187− id: '/org'
188− path: '/org'
189− fullPath: '/org'
180+ '/$org': {
181+ id: '/$org'
182+ path: '/$org'
183+ fullPath: '/$org'
190184 preLoaderRoute: typeof OrgRouteImport
191185 parentRoute: typeof rootRouteImport
192186 }
193− '/connections': {
194− id: '/connections'
195− path: '/connections'
196− fullPath: '/connections'
197− preLoaderRoute: typeof ConnectionsRouteImport
198− parentRoute: typeof rootRouteImport
199− }
200− '/billing': {
201− id: '/billing'
202− path: '/billing'
203− fullPath: '/billing'
204− preLoaderRoute: typeof BillingRouteImport
205− parentRoute: typeof rootRouteImport
206− }
207187 '/': {
208188 id: '/'
209189 path: '/'
@@ -211,41 +191,110 @@ declare module '@tanstack/react-router' { · 1 unmodified line
211191 preLoaderRoute: typeof IndexRouteImport
212192 parentRoute: typeof rootRouteImport
213193 }
214− '/sources/$namespace': {
215− id: '/sources/$namespace'
194+ '/$org/': {
195+ id: '/$org/'
196+ path: '/'
197+ fullPath: '/$org/'
198+ preLoaderRoute: typeof OrgIndexRouteImport
199+ parentRoute: typeof OrgRoute
200+ }
201+ '/$org/tools': {
202+ id: '/$org/tools'
203+ path: '/tools'
204+ fullPath: '/$org/tools'
205+ preLoaderRoute: typeof OrgToolsRouteImport
206+ parentRoute: typeof OrgRoute
207+ }
208+ '/$org/secrets': {
209+ id: '/$org/secrets'
210+ path: '/secrets'
211+ fullPath: '/$org/secrets'
212+ preLoaderRoute: typeof OrgSecretsRouteImport
213+ parentRoute: typeof OrgRoute
214+ }
215+ '/$org/policies': {
216+ id: '/$org/policies'
217+ path: '/policies'
218+ fullPath: '/$org/policies'
219+ preLoaderRoute: typeof OrgPoliciesRouteImport
220+ parentRoute: typeof OrgRoute
221+ }
222+ '/$org/connections': {
223+ id: '/$org/connections'
224+ path: '/connections'
225+ fullPath: '/$org/connections'
226+ preLoaderRoute: typeof OrgConnectionsRouteImport
227+ parentRoute: typeof OrgRoute
228+ }
229+ '/$org/sources/$namespace': {
230+ id: '/$org/sources/$namespace'
216231 path: '/sources/$namespace'
217− fullPath: '/sources/$namespace'
218− preLoaderRoute: typeof SourcesNamespaceRouteImport
219− parentRoute: typeof rootRouteImport
232+ fullPath: '/$org/sources/$namespace'
233+ preLoaderRoute: typeof OrgSourcesNamespaceRouteImport
234+ parentRoute: typeof OrgRoute
220235 }
221− '/billing_/plans': {
222− id: '/billing_/plans'
223− path: '/billing/plans'
224− fullPath: '/billing/plans'
225− preLoaderRoute: typeof BillingPlansRouteImport
226− parentRoute: typeof rootRouteImport
236+ '/$org/-/settings': {
237+ id: '/$org/-/settings'
238+ path: '/-/settings'
239+ fullPath: '/$org/-/settings'
240+ preLoaderRoute: typeof OrgChar91Char93SettingsRouteImport
241+ parentRoute: typeof OrgRoute
242+ }
243+ '/$org/-/billing': {
244+ id: '/$org/-/billing'
245+ path: '/-/billing'
246+ fullPath: '/$org/-/billing'
247+ preLoaderRoute: typeof OrgChar91Char93BillingRouteImport
248+ parentRoute: typeof OrgRoute
227249 }
228− '/sources/add/$pluginKey': {
229− id: '/sources/add/$pluginKey'
250+ '/$org/sources/add/$pluginKey': {
251+ id: '/$org/sources/add/$pluginKey'
230252 path: '/sources/add/$pluginKey'
231− fullPath: '/sources/add/$pluginKey'
232− preLoaderRoute: typeof SourcesAddPluginKeyRouteImport
233− parentRoute: typeof rootRouteImport
253+ fullPath: '/$org/sources/add/$pluginKey'
254+ preLoaderRoute: typeof OrgSourcesAddPluginKeyRouteImport
255+ parentRoute: typeof OrgRoute
256+ }
257+ '/$org/-/billing_/plans': {
258+ id: '/$org/-/billing_/plans'
259+ path: '/-/billing/plans'
260+ fullPath: '/$org/-/billing/plans'
261+ preLoaderRoute: typeof OrgChar91Char93BillingPlansRouteImport
262+ parentRoute: typeof OrgRoute
234263 }
235264 }
236265 }
237266
267+interface OrgRouteChildren {
268+ OrgConnectionsRoute: typeof OrgConnectionsRoute
269+ OrgPoliciesRoute: typeof OrgPoliciesRoute
270+ OrgSecretsRoute: typeof OrgSecretsRoute
271+ OrgToolsRoute: typeof OrgToolsRoute
272+ OrgIndexRoute: typeof OrgIndexRoute
273+ OrgChar91Char93BillingRoute: typeof OrgChar91Char93BillingRoute
274+ OrgChar91Char93SettingsRoute: typeof OrgChar91Char93SettingsRoute
275+ OrgSourcesNamespaceRoute: typeof OrgSourcesNamespaceRoute
276+ OrgChar91Char93BillingPlansRoute: typeof OrgChar91Char93BillingPlansRoute
277+ OrgSourcesAddPluginKeyRoute: typeof OrgSourcesAddPluginKeyRoute
278+}
279+
280+const OrgRouteChildren: OrgRouteChildren = {
281+ OrgConnectionsRoute: OrgConnectionsRoute,
282+ OrgPoliciesRoute: OrgPoliciesRoute,
283+ OrgSecretsRoute: OrgSecretsRoute,
284+ OrgToolsRoute: OrgToolsRoute,
285+ OrgIndexRoute: OrgIndexRoute,
286+ OrgChar91Char93BillingRoute: OrgChar91Char93BillingRoute,
287+ OrgChar91Char93SettingsRoute: OrgChar91Char93SettingsRoute,
288+ OrgSourcesNamespaceRoute: OrgSourcesNamespaceRoute,
289+ OrgChar91Char93BillingPlansRoute: OrgChar91Char93BillingPlansRoute,
290+ OrgSourcesAddPluginKeyRoute: OrgSourcesAddPluginKeyRoute,
291+}
292+
293+const OrgRouteWithChildren = OrgRoute._addFileChildren(OrgRouteChildren)
294+
238295 const rootRouteChildren: RootRouteChildren = {
239296 IndexRoute: IndexRoute,
240− BillingRoute: BillingRoute,
241− ConnectionsRoute: ConnectionsRoute,
242− OrgRoute: OrgRoute,
243− PoliciesRoute: PoliciesRoute,
244− SecretsRoute: SecretsRoute,
245− ToolsRoute: ToolsRoute,
246− BillingPlansRoute: BillingPlansRoute,
247− SourcesNamespaceRoute: SourcesNamespaceRoute,
248− SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute,
297+ OrgRoute: OrgRouteWithChildren,
249298 }
250299 export const routeTree = rootRouteImport
251300 ._addFileChildren(rootRouteChildren)
apps/cloud/src/routes/$org.tsx+61−0
@@ -0,0 +1,61 @@
1+import { createFileRoute, useNavigate, useParams } from "@tanstack/react-router";
2+import { useEffect } from "react";
3+import { AutumnProvider } from "autumn-js/react";
4+import { ExecutorProvider } from "@executor-js/react/api/provider";
5+import { setBaseUrl } from "@executor-js/react/api/base-url";
6+import { Toaster } from "@executor-js/react/components/sonner";
7+import { ExecutorPluginsProvider } from "@executor-js/sdk/client";
8+import { plugins as clientPlugins } from "virtual:executor/plugins-client";
9+
10+import { findOrgByHandle, useAuth } from "../web/auth";
11+import { OrgRouteProvider } from "../web/org-route";
12+import { Shell, ShellSkeleton } from "../web/shell";
13+
14+export const Route = createFileRoute("/$org")({
15+ component: OrgLayout,
16+});
17+
18+function OrgLayout() {
19+ const auth = useAuth();
20+ const navigate = useNavigate();
21+ const { org: handle } = useParams({ from: Route.id });
22+
23+ // Redirect to the first membership when the URL handle is unknown. We only
24+ // run the redirect once auth resolves to authenticated; loading/unauth are
25+ // already handled by AuthGate in __root.
26+ const matched = auth.status === "authenticated" ? findOrgByHandle(auth, handle) : null;
27+ const fallback = auth.status === "authenticated" ? (auth.organizations[0] ?? null) : null;
28+
29+ useEffect(() => {
30+ if (auth.status !== "authenticated") return;
31+ if (matched) return;
32+ if (!fallback) return;
33+ void navigate({ to: "/$org", params: { org: fallback.handle }, replace: true });
34+ }, [auth.status, matched, fallback, navigate]);
35+
36+ if (auth.status !== "authenticated") return null;
37+ if (!matched) return null;
38+
39+ // Point the executor API client at this org's prefixed routes. Done before
40+ // first render of the executor providers so all queries see the right URL.
41+ // The cloud app is single-tenant per page, so a one-shot setter is fine —
42+ // when the URL handle changes, this re-runs at the start of the next render.
43+ if (typeof window !== "undefined") {
44+ setBaseUrl(`${window.location.origin}/api/${matched.handle}`);
45+ }
46+
47+ return (
48+ <OrgRouteProvider
49+ value={{ orgId: matched.id, orgName: matched.name, orgHandle: matched.handle }}
50+ >
51+ <AutumnProvider pathPrefix="/autumn">
52+ <ExecutorProvider fallback={<ShellSkeleton />}>
53+ <ExecutorPluginsProvider plugins={clientPlugins}>
54+ <Shell />
55+ <Toaster />
56+ </ExecutorPluginsProvider>
57+ </ExecutorProvider>
58+ </AutumnProvider>
59+ </OrgRouteProvider>
60+ );
61+}
apps/cloud/src/routes/$org/[-].billing.tsxrenamed from apps/cloud/src/routes/billing.tsx+5−2
@@ -2,10 +2,11 @@ import { createFileRoute, Link } from "@tanstack/react-router"; · 1 unmodified line
22 import { useCustomer, useListPlans } from "autumn-js/react";
33 import { Button } from "@executor-js/react/components/button";
44 import { Badge } from "@executor-js/react/components/badge";
5+import { useOrgRoute } from "../../web/org-route";
56
67 type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];
78
8−export const Route = createFileRoute("/billing")({
9+export const Route = createFileRoute("/$org/-/billing")({
910 component: BillingPage,
1011 });
1112
@@ -15,6 +16,7 @@ const PLAN_TAGLINES: Record<string, string> = { · 3 unmodified lines
1516 };
1617
1718 function BillingPage() {
19+ const { orgHandle } = useOrgRoute();
1820 const { data: customer, openCustomerPortal, isLoading: customerLoading } = useCustomer();
1921 const { data: plans, isLoading: plansLoading } = useListPlans();
2022
@@ -99,7 +101,8 @@ function BillingPage() { · 78 unmodified lines
99101 </Button>
100102 )}
101103 <Link
102− to="/billing/plans"
104+ to="/$org/-/billing/plans"
105+ params={{ org: orgHandle }}
103106 className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
104107 >
105108 Manage
apps/cloud/src/routes/$org/[-].billing_.plans.tsxrenamed from apps/cloud/src/routes/billing_.plans.tsx+7−4
@@ -1,6 +1,6 @@
11 import { useEffect, useState } from "react";
22 import { createFileRoute, Link } from "@tanstack/react-router";
3−import { useAuth } from "../web/auth";
3+import { useAuth } from "../../web/auth";
44 import { useCustomer, useListPlans } from "autumn-js/react";
55 import { Button } from "@executor-js/react/components/button";
66 import { Badge } from "@executor-js/react/components/badge";
@@ -16,10 +16,11 @@ import { · 9 unmodified lines
1616 } from "@executor-js/react/components/dialog";
1717 import { Input } from "@executor-js/react/components/input";
1818 import { Label } from "@executor-js/react/components/label";
19+import { useOrgRoute } from "../../web/org-route";
1920
2021 type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];
2122
22−export const Route = createFileRoute("/billing_/plans")({
23+export const Route = createFileRoute("/$org/-/billing_/plans")({
2324 component: PlansPage,
2425 });
2526
@@ -77,6 +78,7 @@ const ENTERPRISE_MAILTO = `mailto:rhys@executor.sh?subject=${encodeURIComponent( · 51 unmodified lines
7778 )}`;
7879
7980 function PlansPage() {
81+ const { orgHandle } = useOrgRoute();
8082 const { attach, openCustomerPortal, isLoading: customerLoading } = useCustomer();
8183 const { data: plans, isLoading: plansLoading, isFetching } = useListPlans();
8284 const [loadingPlan, setLoadingPlan] = useState<string | null>(null);
@@ -92,7 +94,8 @@ function PlansPage() { · 9 unmodified lines
9294 <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">
9395 <div className="mb-8">
9496 <Link
95− to="/billing"
97+ to="/$org/-/billing"
98+ params={{ org: orgHandle }}
9699 className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors mb-4"
97100 >
98101 <svg viewBox="0 0 16 16" fill="none" className="size-3.5">
@@ -307,9 +310,9 @@ function PlansPage() { · 208 unmodified lines
307310
308311 function SlackContactCta() {
309312 const auth = useAuth();
313+ const { orgName } = useOrgRoute();
310314 const signedIn = auth.status === "authenticated" ? auth : null;
311315 const prefillEmail = signedIn?.user.email ?? "";
312− const orgName = signedIn?.organization?.name ?? "";
313316
314317 const [open, setOpen] = useState(false);
315318 const [email, setEmail] = useState(prefillEmail);
apps/cloud/src/routes/$org/[-].settings.tsxrenamed from apps/cloud/src/routes/org.tsx+10−10
@@ -1,6 +1,6 @@
11 import { useReducer, useState } from "react";
22 import { Cause, Exit, Result } from "effect";
3−import { Forbidden } from "../org/api";
3+import { Forbidden } from "../../org/api";
44 import { createFileRoute, Link } from "@tanstack/react-router";
55 import { useAtomValue, useAtomSet } from "@effect/atom-react";
66 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
@@ -52,10 +52,10 @@ import { · 45 unmodified lines
5252 getDomainVerificationLink,
5353 deleteDomain,
5454 updateOrgName,
55−} from "../web/org-atoms";
56−import { useAuth } from "../web/auth";
55+} from "../../web/org-atoms";
56+import { useOrgRoute } from "../../web/org-route";
5757
58−export const Route = createFileRoute("/org")({
58+export const Route = createFileRoute("/$org/-/settings")({
5959 component: OrgPage,
6060 });
6161
@@ -110,9 +110,8 @@ function formatLastActive(lastActiveAt: string | null): string { · 48 unmodified lines
110110 }
111111
112112 function OrgPage() {
113− const auth = useAuth();
114− const orgName =
115− auth.status === "authenticated" ? (auth.organization?.name ?? "Organization") : "Organization";
113+ const { orgName: routeOrgName, orgHandle } = useOrgRoute();
114+ const orgName = routeOrgName ?? "Organization";
116115 const membersResult = useAtomValue(orgMembersAtom);
117116 const rolesResult = useAtomValue(orgRolesAtom);
118117 const domainsResult = useAtomValue(orgDomainsAtom);
@@ -262,7 +261,7 @@ function OrgPage() { · 143 unmodified lines
262261 <p className="text-sm text-muted-foreground">
263262 Join by domain is available on the Team plan.
264263 </p>
265− <Link to="/billing/plans">
264+ <Link to="/$org/-/billing/plans" params={{ org: orgHandle }}>
266265 <Button size="sm" variant="outline">
267266 Upgrade
268267 </Button>
@@ -322,7 +321,7 @@ function OrgPage() { · 53 unmodified lines
322321 Invite member
323322 </Button>
324323 ) : (
325− <Link to="/billing/plans">
324+ <Link to="/$org/-/billing/plans" params={{ org: orgHandle }}>
326325 <Button size="sm" className="min-w-32">
327326 Upgrade
328327 </Button>
@@ -608,6 +607,7 @@ function DomainCard({ domain: d, onDelete }: { domain: DomainData; onDelete: () · 279 unmodified lines
608607 }
609608
610609 function InviteErrorAlert({ cause }: { cause: Cause.Cause<unknown> }) {
610+ const { orgHandle } = useOrgRoute();
611611 const failure = Cause.findError(cause);
612612 const error = Result.isSuccess(failure) ? failure.success : null;
613613
@@ -617,7 +617,7 @@ function InviteErrorAlert({ cause }: { cause: Cause.Cause<unknown> }) { · 3 unmodified lines
617617 <p className="text-sm text-destructive">
618618 You've reached your member limit. Upgrade to Team to invite more.
619619 </p>
620− <Link to="/billing/plans">
620+ <Link to="/$org/-/billing/plans" params={{ org: orgHandle }}>
621621 <Button size="sm" variant="outline">
622622 Upgrade
623623 </Button>
apps/cloud/src/routes/$org/connections.tsxrenamed from apps/cloud/src/routes/connections.tsx+1−1
@@ -1,6 +1,6 @@
11 import { createFileRoute } from "@tanstack/react-router";
22 import { ConnectionsPage } from "@executor-js/react/pages/connections";
33
4−export const Route = createFileRoute("/connections")({
4+export const Route = createFileRoute("/$org/connections")({
55 component: () => <ConnectionsPage />,
66 });
apps/cloud/src/routes/$org/index.tsx+6−0
@@ -0,0 +1,6 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+import { SourcesPage } from "@executor-js/react/pages/sources";
3+
4+export const Route = createFileRoute("/$org/")({
5+ component: SourcesPage,
6+});
apps/cloud/src/routes/$org/policies.tsxrenamed from apps/cloud/src/routes/policies.tsx+1−1
@@ -1,6 +1,6 @@
11 import { createFileRoute } from "@tanstack/react-router";
22 import { PoliciesPage } from "@executor-js/react/pages/policies";
33
4−export const Route = createFileRoute("/policies")({
4+export const Route = createFileRoute("/$org/policies")({
55 component: () => <PoliciesPage />,
66 });
apps/cloud/src/routes/$org/secrets.tsxrenamed from apps/cloud/src/routes/secrets.tsx+1−1
@@ -1,7 +1,7 @@
11 import { createFileRoute } from "@tanstack/react-router";
22 import { SecretsPage } from "@executor-js/react/pages/secrets";
33
4−export const Route = createFileRoute("/secrets")({
4+export const Route = createFileRoute("/$org/secrets")({
55 component: () => (
66 <SecretsPage
77 addSecretDescription="Store a credential or API key for this organization."
apps/cloud/src/routes/$org/sources.$namespace.tsxrenamed from apps/cloud/src/routes/sources.$namespace.tsx+1−1
@@ -1,7 +1,7 @@
11 import { createFileRoute } from "@tanstack/react-router";
22 import { SourceDetailPage } from "@executor-js/react/pages/source-detail";
33
4−export const Route = createFileRoute("/sources/$namespace")({
4+export const Route = createFileRoute("/$org/sources/$namespace")({
55 component: () => {
66 const { namespace } = Route.useParams();
77 return <SourceDetailPage namespace={namespace} />;
apps/cloud/src/routes/$org/sources.add.$pluginKey.tsxrenamed from apps/cloud/src/routes/sources.add.$pluginKey.tsx+1−1
@@ -10,7 +10,7 @@ const SearchParams = Schema.toStandardSchemaV1( · 9 unmodified lines
1010 }),
1111 );
1212
13−export const Route = createFileRoute("/sources/add/$pluginKey")({
13+export const Route = createFileRoute("/$org/sources/add/$pluginKey")({
1414 validateSearch: SearchParams,
1515 component: () => {
1616 const { pluginKey } = Route.useParams();
apps/cloud/src/routes/$org/tools.tsxrenamed from apps/cloud/src/routes/tools.tsx+1−1
@@ -1,6 +1,6 @@
11 import { createFileRoute } from "@tanstack/react-router";
22 import { ToolsPage } from "@executor-js/react/pages/tools";
33
4−export const Route = createFileRoute("/tools")({
4+export const Route = createFileRoute("/$org/tools")({
55 component: ToolsPage,
66 });
apps/cloud/src/routes/__root.tsx+4−80
@@ -1,18 +1,12 @@
11 import React from "react";
22 import * as Sentry from "@sentry/react";
3−import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";
4−import { AutumnProvider } from "autumn-js/react";
3+import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router";
54 import posthog from "posthog-js";
65 import { PostHogProvider } from "posthog-js/react";
7−import { ExecutorProvider } from "@executor-js/react/api/provider";
8−import { Skeleton } from "@executor-js/react/components/skeleton";
9−import { Toaster } from "@executor-js/react/components/sonner";
10−import { ExecutorPluginsProvider } from "@executor-js/sdk/client";
11−import { plugins as clientPlugins } from "virtual:executor/plugins-client";
126 import { AuthProvider, useAuth } from "../web/auth";
137 import { LoginPage } from "../web/pages/login";
148 import { OnboardingPage } from "../web/pages/onboarding";
15−import { Shell } from "../web/shell";
9+import { ShellSkeleton } from "../web/shell";
1610 import appCss from "@executor-js/react/globals.css?url";
1711
1812 if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_SENTRY_DSN) {
@@ -90,67 +84,6 @@ function RootComponent() { · 71 unmodified lines
9084 );
9185 }
9286
93−function ShellSkeleton() {
94− return (
95− <div className="flex h-screen overflow-hidden">
96− {/* Desktop sidebar skeleton */}
97− <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
98− <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">
99− <Skeleton className="h-4 w-20" />
100− </div>
101− <nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-2">
102− <Skeleton className="h-7 w-full rounded-md" />
103− <Skeleton className="h-7 w-full rounded-md" />
104− <Skeleton className="h-7 w-full rounded-md" />
105− <Skeleton className="h-7 w-full rounded-md" />
106− <div className="mt-5 mb-2 px-2.5">
107− <Skeleton className="h-3 w-14" />
108− </div>
109− <div className="flex flex-col gap-1">
110− <Skeleton className="h-7 w-11/12 rounded-md" />
111− <Skeleton className="h-7 w-10/12 rounded-md" />
112− <Skeleton className="h-7 w-9/12 rounded-md" />
113− </div>
114− </nav>
115− <div className="shrink-0 border-t border-sidebar-border px-3 py-2.5">
116− <div className="flex items-center gap-2.5">
117− <Skeleton className="size-7 rounded-full" />
118− <div className="flex min-w-0 flex-1 flex-col gap-1">
119− <Skeleton className="h-3 w-24" />
120− <Skeleton className="h-3 w-16" />
121− </div>
122− </div>
123− </div>
124− </aside>
125−
126− {/* Main content skeleton */}
127− <main className="flex min-h-0 flex-1 flex-col overflow-hidden">
128− {/* Mobile top bar */}
129− <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4 md:hidden">
130− <Skeleton className="size-7 rounded-md" />
131− <Skeleton className="h-4 w-20" />
132− <div className="w-7 shrink-0" />
133− </div>
134−
135− <div className="flex min-h-0 flex-1 flex-col gap-6 px-6 py-8">
136− <div className="flex items-center justify-between">
137− <div className="flex flex-col gap-2">
138− <Skeleton className="h-6 w-40" />
139− <Skeleton className="h-4 w-64" />
140− </div>
141− <Skeleton className="h-8 w-28 rounded-md" />
142− </div>
143− <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
144− {Array.from({ length: 6 }).map((_, i) => (
145− <Skeleton key={i} className="h-24 w-full rounded-lg" />
146− ))}
147− </div>
148− </div>
149− </main>
150− </div>
151− );
152−}
153−
15487 function AuthGate() {
15588 const auth = useAuth();
15689
@@ -162,18 +95,9 @@ function AuthGate() { · 5 unmodified lines
16295 return <LoginPage />;
16396 }
16497
165− if (auth.organization == null) {
98+ if (auth.organizations.length === 0) {
16699 return <OnboardingPage />;
167100 }
168101
169− return (
170− <AutumnProvider pathPrefix="/api/autumn">
171− <ExecutorProvider fallback={<ShellSkeleton />}>
172− <ExecutorPluginsProvider plugins={clientPlugins}>
173− <Shell />
174− <Toaster />
175− </ExecutorPluginsProvider>
176− </ExecutorProvider>
177− </AutumnProvider>
178− );
102+ return <Outlet />;
179103 }
apps/cloud/src/routes/index.tsx+23−3
@@ -1,6 +1,26 @@
1−import { createFileRoute } from "@tanstack/react-router";
2−import { SourcesPage } from "@executor-js/react/pages/sources";
1+import { createFileRoute, useNavigate } from "@tanstack/react-router";
2+import { useEffect } from "react";
3+
4+import { useAuth } from "../web/auth";
35
46 export const Route = createFileRoute("/")({
5− component: SourcesPage,
7+ component: IndexRedirect,
68 });
9+
10+function IndexRedirect() {
11+ const auth = useAuth();
12+ const navigate = useNavigate();
13+ const firstHandle =
14+ auth.status === "authenticated" ? (auth.organizations[0]?.handle ?? null) : null;
15+
16+ useEffect(() => {
17+ if (!firstHandle) return;
18+ void navigate({ to: "/$org", params: { org: firstHandle }, replace: true });
19+ }, [firstHandle, navigate]);
20+
21+ return (
22+ <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
23+ Loading…
24+ </div>
25+ );
26+}
apps/cloud/src/services/__test-harness__/api-harness.ts+93−24
@@ -5,14 +5,16 @@ · 4 unmodified lines
55 // two test-only swaps:
66 //
77 // - `OrgAuthLive` is replaced with `FakeOrgAuthLive`, which reads
8−// the scope id off `x-test-org-id` instead of the WorkOS cookie.
8+// the org handle from the URL `/api/:org/...` prefix instead of
9+// the WorkOS cookie.
910 // - `workos-vault` is configured with an in-memory `WorkOSVaultClient`
1011 // so secret writes never reach WorkOS's real API.
1112 //
1213 // Tests get a `fetchForOrg(orgId)` they can hand to `FetchHttpClient`
1314 // and then call `HttpApiClient.make(ProtectedCloudApi)` against it.
1415 // Each test picks its own org id (usually a random UUID) so rows don't
15−// collide across tests.
16+// collide across tests. The harness seeds an organizations row whose
17+// `handle` equals the org id so `resolveOrgContext(orgId)` succeeds.
1618
1719 import { Effect, Layer } from "effect";
1820 import { HttpApiBuilder, HttpApiClient, HttpApiSwagger } from "effect/unstable/httpapi";
@@ -38,9 +40,13 @@ import { · 19 unmodified lines
3840 RouterConfig,
3941 } from "../../api/protected-layers";
4042 import { DbService } from "../db";
43+import { organizations } from "../schema";
4144
4245 export const TEST_BASE_URL = "http://test.local";
43−export const TEST_ORG_HEADER = "x-test-org-id";
46+/**
47+ * Optional header for tests that need to act as a specific user. The org
48+ * id always comes from the URL prefix; only the user is opt-in.
49+ */
4450 export const TEST_USER_HEADER = "x-test-user-id";
4551
4652 // Mirrors apps/cloud/src/services/executor.ts#createScopedExecutor — the
@@ -89,16 +95,43 @@ const createTestScopedExecutor = (userId: string, orgId: string, orgName: string · 42 unmodified lines
8995 });
9096 });
9197
98+// Seed a test organization row whose handle equals the supplied id so the
99+// production middleware resolution path (`resolveOrgContext(handle)`) works
100+// against the test db. Uses `onConflictDoNothing` so repeated `asOrg(orgId,
101+// …)` calls within a test don't fight each other. Lives inside the request
102+// pipeline (so DbService is already provided) instead of at factory time
103+// — bringing up its own DbService.Live in a Node test process leaks a
104+// postgres.js socket that ECONNRESETs across test files.
105+const seedTestOrg = (orgId: string) =>
106+ Effect.gen(function* () {
107+ const { db } = yield* DbService;
108+ yield* Effect.promise(() =>
109+ db
110+ .insert(organizations)
111+ .values({ id: orgId, handle: orgId, name: `Org ${orgId}` })
112+ .onConflictDoNothing(),
113+ );
114+ });
115+
92116 // ---------------------------------------------------------------------------
93117 // HTTP plumbing
94118 // ---------------------------------------------------------------------------
95119
120+// Pull the URL `:org` segment from a request path. The protected API mounts
121+// under `/api/:org/...`. Returning `null` for a malformed prefix forces the
122+// downstream handler to surface a typed error rather than panicking.
123+const orgHandleFromPath = (pathname: string): string | null => {
124+ const parts = pathname.split("/").filter((part) => part.length > 0);
125+ if (parts.length < 2 || parts[0] !== "api") return null;
126+ return parts[1] ?? null;
127+};
128+
96129 // Test version of the production `ExecutionStackMiddleware` — reads the
97−// `x-test-org-id` (and optional `x-test-user-id`) header, builds a
98−// test-scoped executor against the live postgres test db with a fake
99−// WorkOS vault, and provides `AuthContext` + the executor services to the
100−// handler. Mirrors prod's HttpRouter middleware but with test-mode
101−// constructors.
130+// org handle from the URL `/api/:org/...` prefix (matching production),
131+// builds a test-scoped executor against the live postgres test db with a
132+// fake WorkOS vault, and provides `AuthContext` + the executor services
133+// to the handler. The optional `x-test-user-id` header overrides the
134+// default per-org user.
102135 const TestExecutionStackMiddleware = HttpRouter.middleware<{
103136 provides:
104137 | AuthContext
@@ -115,11 +148,20 @@ const TestExecutionStackMiddleware = HttpRouter.middleware<{ · 10 unmodified lines
115148 return (httpEffect) =>
116149 Effect.gen(function* () {
117150 const request = yield* HttpServerRequest.HttpServerRequest;
118− const orgId = request.headers[TEST_ORG_HEADER];
119− if (!orgId || typeof orgId !== "string") {
120− // oxlint-disable-next-line executor/no-effect-escape-hatch, executor/no-error-constructor -- boundary: test HTTP harness has no request context without x-test-org-id
121− return yield* Effect.die(new Error("missing x-test-org-id"));
151+ const webRequest = yield* HttpServerRequest.toWeb(request);
152+ const url = new URL(webRequest.url);
153+ const orgId = orgHandleFromPath(url.pathname);
154+ if (!orgId) {
155+ // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: test HTTP harness has no request context without /api/:org prefix
156+ return yield* Effect.die(
157+ // oxlint-disable-next-line executor/no-error-constructor -- boundary: test HTTP harness invariant on missing prefix
158+ new Error(`missing /api/:org prefix in ${url.pathname}`),
159+ );
122160 }
161+ // Lazily seed the org row so production-mode `resolveOrgContext` (used
162+ // anywhere that takes the URL handle as truth) finds it. The test
163+ // harness can't pre-seed at factory time without leaking sockets.
164+ yield* seedTestOrg(orgId);
123165 const userHeader = request.headers[TEST_USER_HEADER];
124166 const userId =
125167 typeof userHeader === "string" && userHeader.length > 0
@@ -150,9 +192,18 @@ const TestExecutionStackMiddleware = HttpRouter.middleware<{ · 24 unmodified lines
150192 }),
151193 ).layer;
152194
195+// Mirror the production setup — the protected API mounts under `/api/:org`
196+// via a prefixed router view. The outer `HttpRouter` from
197+// `HttpServer.layerServices` is the underlying state; the prefix wrapper
198+// rewrites added paths only.
199+const PrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)(
200+ Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed("/api/:org")),
201+);
202+
153203 const TestApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe(
154204 Layer.provide(ProtectedCloudApiHandlers),
155205 Layer.provide(TestExecutionStackMiddleware),
206+ Layer.provide(PrefixedRouterLayer),
156207 Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })),
157208 Layer.provideMerge(RouterConfig),
158209 Layer.provideMerge(DbService.Live),
@@ -161,25 +212,43 @@ const TestApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( · 2 unmodified lines
161212
162213 const handler = HttpRouter.toWebHandler(TestApiLive, { disableLogger: true }).handler;
163214
215+// Rewrite outgoing request URLs to `/api/${orgId}${path}` so the prefixed
216+// router matches. Tests construct `HttpApiClient.make(...)` against
217+// `TEST_BASE_URL` and call endpoint methods that build paths like
218+// `/scopes/.../sources` — we splice the org segment in front before the
219+// request reaches the in-process handler.
220+const rewriteRequestForOrg = async (
221+ base: Request,
222+ orgId: string,
223+ extraHeaders: Record<string, string> = {},
224+): Promise<Request> => {
225+ const url = new URL(base.url);
226+ if (!url.pathname.startsWith(`/api/${orgId}/`) && url.pathname !== `/api/${orgId}`) {
227+ url.pathname = `/api/${orgId}${url.pathname.startsWith("/") ? "" : "/"}${url.pathname}`;
228+ }
229+ // Buffer the body — Node's `RequestInit` rejects stream bodies without
230+ // `duplex: "half"`, and forwarding a Request through `new Request(url, {...})`
231+ // is fragile across runtimes. ArrayBuffer survives the round-trip cleanly.
232+ const body =
233+ base.method === "GET" || base.method === "HEAD" ? undefined : await base.arrayBuffer();
234+ return new Request(url.toString(), {
235+ method: base.method,
236+ headers: { ...Object.fromEntries(base.headers), ...extraHeaders },
237+ body,
238+ });
239+};
240+
164241 export const fetchForOrg = (orgId: string): typeof globalThis.fetch =>
165− ((input: RequestInfo | URL, init?: RequestInit) => {
242+ (async (input: RequestInfo | URL, init?: RequestInit) => {
166243 const base = input instanceof Request ? input : new Request(input, init);
167− const req = new Request(base, {
168− headers: { ...Object.fromEntries(base.headers), [TEST_ORG_HEADER]: orgId },
169− });
244+ const req = await rewriteRequestForOrg(base, orgId);
170245 return handler(req);
171246 }) as typeof globalThis.fetch;
172247
173248 export const fetchForUser = (userId: string, orgId: string): typeof globalThis.fetch =>
174− ((input: RequestInfo | URL, init?: RequestInit) => {
249+ (async (input: RequestInfo | URL, init?: RequestInit) => {
175250 const base = input instanceof Request ? input : new Request(input, init);
176− const req = new Request(base, {
177− headers: {
178− ...Object.fromEntries(base.headers),
179− [TEST_ORG_HEADER]: orgId,
180− [TEST_USER_HEADER]: userId,
181− },
182− });
251+ const req = await rewriteRequestForOrg(base, orgId, { [TEST_USER_HEADER]: userId });
183252 return handler(req);
184253 }) as typeof globalThis.fetch;
185254
apps/cloud/src/web/auth.tsx+23−17
@@ -1,5 +1,4 @@
11 import React, { createContext, useContext, useEffect } from "react";
2−import * as Atom from "effect/unstable/reactivity/Atom";
32 import { useAtomValue } from "@effect/atom-react";
43 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
54 import { usePostHog } from "posthog-js/react";
@@ -18,8 +17,9 @@ type AuthUser = { · 12 unmodified lines
1817 avatarUrl: string | null;
1918 };
2019
21−type AuthOrganization = {
20+export type AuthOrganization = {
2221 id: string;
22+ handle: string;
2323 name: string;
2424 };
2525
@@ -32,14 +32,6 @@ export const authAtom = CloudApiClient.query("cloudAuth", "me", { · 6 unmodified lines
3232 reactivityKeys: [ReactivityKey.auth],
3333 });
3434
35−export const organizationsAtom = Atom.refreshOnWindowFocus(
36− CloudApiClient.query("cloudAuth", "organizations", {
37− timeToLive: "1 minute",
38− reactivityKeys: [ReactivityKey.auth],
39− }),
40−);
41−
42−export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization");
4335 export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");
4436
4537 export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", {
@@ -56,7 +48,11 @@ export const acceptInvitation = CloudApiClient.mutation("cloudAuth", "acceptInvi · 10 unmodified lines
5648 type AuthState =
5749 | { status: "loading" }
5850 | { status: "unauthenticated" }
59− | { status: "authenticated"; user: AuthUser; organization: AuthOrganization | null };
51+ | {
52+ status: "authenticated";
53+ user: AuthUser;
54+ organizations: ReadonlyArray<AuthOrganization>;
55+ };
6056
6157 const AuthContext = createContext<AuthState>({ status: "loading" });
6258
@@ -71,7 +67,7 @@ const AuthProviderClient = ({ children }: { children: React.ReactNode }) => { · 8 unmodified lines
7167 onSuccess: ({ value }) => ({
7268 status: "authenticated" as const,
7369 user: value.user,
74− organization: value.organization,
70+ organizations: value.organizations,
7571 }),
7672 onFailure: () => ({ status: "unauthenticated" as const }),
7773 });
@@ -79,21 +75,25 @@ const AuthProviderClient = ({ children }: { children: React.ReactNode }) => { · 1 unmodified line
7975 const userId = state.status === "authenticated" ? state.user.id : null;
8076 const email = state.status === "authenticated" ? state.user.email : null;
8177 const name = state.status === "authenticated" ? state.user.name : null;
82− const orgId = state.status === "authenticated" ? (state.organization?.id ?? null) : null;
83− const orgName = state.status === "authenticated" ? (state.organization?.name ?? null) : null;
78+ // PostHog org grouping uses the first membership; the user can navigate
79+ // between orgs in-session. If we want richer grouping later we can
80+ // re-emit on URL change.
81+ const firstOrgId = state.status === "authenticated" ? (state.organizations[0]?.id ?? null) : null;
82+ const firstOrgName =
83+ state.status === "authenticated" ? (state.organizations[0]?.name ?? null) : null;
8484 const isUnauthenticated = state.status === "unauthenticated";
8585
8686 useEffect(() => {
8787 if (!posthog) return;
8888 if (userId) {
8989 posthog.identify(userId, { email, name });
90− if (orgId) {
91− posthog.group("organization", orgId, { name: orgName });
90+ if (firstOrgId) {
91+ posthog.group("organization", firstOrgId, { name: firstOrgName });
9292 }
9393 } else if (isUnauthenticated) {
9494 posthog.reset();
9595 }
96− }, [posthog, userId, email, name, orgId, orgName, isUnauthenticated]);
96+ }, [posthog, userId, email, name, firstOrgId, firstOrgName, isUnauthenticated]);
9797
9898 return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
9999 };
@@ -104,3 +104,9 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { · 4 unmodified lines
104104 }
105105 return <AuthProviderClient>{children}</AuthProviderClient>;
106106 };
107+
108+/** Find the organization in the auth state matching a given URL handle. */
109+export const findOrgByHandle = (state: AuthState, handle: string): AuthOrganization | null => {
110+ if (state.status !== "authenticated") return null;
111+ return state.organizations.find((o) => o.handle === handle) ?? null;
112+};
apps/cloud/src/web/components/create-organization-form.tsx+1−1
@@ -7,7 +7,7 @@ import { Label } from "@executor-js/react/components/label"; · 6 unmodified lines
77
88 import { createOrganization } from "../auth";
99
10−type CreatedOrganization = { id: string; name: string };
10+type CreatedOrganization = { id: string; handle: string; name: string };
1111
1212 export function useCreateOrganizationForm(options: {
1313 defaultName?: string;
apps/cloud/src/web/org-route.tsx+31−0
@@ -0,0 +1,31 @@
1+import React, { createContext, useContext } from "react";
2+
3+// ---------------------------------------------------------------------------
4+// OrgRouteContext — provided by the `/$org` layout, consumed by descendants
5+// that need to know the URL-active organization. The handle drives all link
6+// generation; the id flows to API calls when one slips outside the URL prefix.
7+// ---------------------------------------------------------------------------
8+
9+export type OrgRouteValue = {
10+ readonly orgId: string;
11+ readonly orgName: string;
12+ readonly orgHandle: string;
13+};
14+
15+export const OrgRouteContext = createContext<OrgRouteValue | null>(null);
16+
17+export const OrgRouteProvider = (props: { value: OrgRouteValue; children: React.ReactNode }) => (
18+ <OrgRouteContext.Provider value={props.value}>{props.children}</OrgRouteContext.Provider>
19+);
20+
21+export const useOrgRoute = (): OrgRouteValue => {
22+ const value = useContext(OrgRouteContext);
23+ if (!value) {
24+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: React hook invariant
25+ throw new Error("useOrgRoute must be used within an OrgRouteProvider");
26+ }
27+ return value;
28+};
29+
30+/** Optional variant for code rendered both inside and outside the org layout. */
31+export const useOrgRouteOptional = (): OrgRouteValue | null => useContext(OrgRouteContext);
apps/cloud/src/web/shell.tsx+181−90
@@ -1,8 +1,7 @@
11 import { Link, Outlet, useLocation } from "@tanstack/react-router";
22 import { useEffect, useRef, useState } from "react";
3−import { useAtomValue, useAtomSet } from "@effect/atom-react";
3+import { useAtomValue } from "@effect/atom-react";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5−import * as Exit from "effect/Exit";
65 import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms";
76 import { useScope } from "@executor-js/react/api/scope-context";
87 import { Button } from "@executor-js/react/components/button";
@@ -29,36 +28,95 @@ import { · 20 unmodified lines
2928 } from "@executor-js/react/components/dropdown-menu";
3029 import { SourceFavicon } from "@executor-js/react/components/source-favicon";
3130 import { CommandPalette } from "@executor-js/react/components/command-palette";
32−import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
3331 import { AUTH_PATHS } from "../auth/api";
34−import { organizationsAtom, switchOrganization, useAuth } from "./auth";
32+import { useAuth } from "./auth";
33+import { useOrgRoute } from "./org-route";
3534 import {
3635 CreateOrganizationFields,
3736 useCreateOrganizationForm,
3837 } from "./components/create-organization-form";
3938
40−// ── NavItem ──────────────────────────────────────────────────────────────
39+// ── ShellSkeleton ────────────────────────────────────────────────────────
4140
42−function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) {
41+export function ShellSkeleton() {
4342 return (
44− <Link
45− to={props.to}
46− onClick={props.onNavigate}
47− className={[
48− "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors",
49− props.active
50− ? "bg-sidebar-active text-foreground font-medium"
51− : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
52− ].join(" ")}
53− >
54− {props.label}
55− </Link>
43+ <div className="flex h-screen overflow-hidden">
44+ {/* Desktop sidebar skeleton */}
45+ <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
46+ <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">
47+ <Skeleton className="h-4 w-20" />
48+ </div>
49+ <nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-2">
50+ <Skeleton className="h-7 w-full rounded-md" />
51+ <Skeleton className="h-7 w-full rounded-md" />
52+ <Skeleton className="h-7 w-full rounded-md" />
53+ <Skeleton className="h-7 w-full rounded-md" />
54+ <div className="mt-5 mb-2 px-2.5">
55+ <Skeleton className="h-3 w-14" />
56+ </div>
57+ <div className="flex flex-col gap-1">
58+ <Skeleton className="h-7 w-11/12 rounded-md" />
59+ <Skeleton className="h-7 w-10/12 rounded-md" />
60+ <Skeleton className="h-7 w-9/12 rounded-md" />
61+ </div>
62+ </nav>
63+ <div className="shrink-0 border-t border-sidebar-border px-3 py-2.5">
64+ <div className="flex items-center gap-2.5">
65+ <Skeleton className="size-7 rounded-full" />
66+ <div className="flex min-w-0 flex-1 flex-col gap-1">
67+ <Skeleton className="h-3 w-24" />
68+ <Skeleton className="h-3 w-16" />
69+ </div>
70+ </div>
71+ </div>
72+ </aside>
73+
74+ {/* Main content skeleton */}
75+ <main className="flex min-h-0 flex-1 flex-col overflow-hidden">
76+ {/* Mobile top bar */}
77+ <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4 md:hidden">
78+ <Skeleton className="size-7 rounded-md" />
79+ <Skeleton className="h-4 w-20" />
80+ <div className="w-7 shrink-0" />
81+ </div>
82+
83+ <div className="flex min-h-0 flex-1 flex-col gap-6 px-6 py-8">
84+ <div className="flex items-center justify-between">
85+ <div className="flex flex-col gap-2">
86+ <Skeleton className="h-6 w-40" />
87+ <Skeleton className="h-4 w-64" />
88+ </div>
89+ <Skeleton className="h-8 w-28 rounded-md" />
90+ </div>
91+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
92+ {Array.from({ length: 6 }).map((_, i) => (
93+ <Skeleton key={i} className="h-24 w-full rounded-lg" />
94+ ))}
95+ </div>
96+ </div>
97+ </main>
98+ </div>
5699 );
57100 }
58101
102+// ── NavItem styling ──────────────────────────────────────────────────────
103+//
104+// Sidebar links share active/inactive styling but each call site uses a
105+// statically-known route template — keep the className helper here and let
106+// the call site own the typed `<Link>`.
107+
108+const navItemClassName = (active: boolean) =>
109+ [
110+ "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors",
111+ active
112+ ? "bg-sidebar-active text-foreground font-medium"
113+ : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
114+ ].join(" ");
115+
59116 // ── SourceList ───────────────────────────────────────────────────────────
60117
61118 function SourceList(props: { pathname: string; onNavigate?: () => void }) {
119+ const { orgHandle } = useOrgRoute();
62120 const scopeId = useScope();
63121 const sources = useAtomValue(sourcesOptimisticAtom(scopeId));
64122
@@ -84,14 +142,14 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { · 19 unmodified lines
84142 ) : (
85143 <div className="flex flex-col gap-px">
86144 {value.map((s) => {
87− const detailPath = `/sources/${s.id}`;
145+ const detailPath = `/${orgHandle}/sources/${s.id}`;
88146 const active =
89147 props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`);
90148 return (
91149 <Link
92150 key={s.id}
93− to="/sources/$namespace"
94− params={{ namespace: s.id }}
151+ to="/$org/sources/$namespace"
152+ params={{ org: orgHandle, namespace: s.id }}
95153 onClick={props.onNavigate}
96154 className={[
97155 "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors",
@@ -148,43 +206,33 @@ function Avatar(props: { · 50 unmodified lines
148206 }
149207
150208 function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) {
151− const organizations = useAtomValue(organizationsAtom);
152− const doSwitchOrganization = useAtomSet(switchOrganization, { mode: "promiseExit" });
153−
154− const handleSwitch = async (organizationId: string) => {
155− if (organizationId === props.activeOrganizationId) return;
156− const exit = await doSwitchOrganization({
157− payload: { organizationId },
158− reactivityKeys: authWriteKeys,
159− });
160− if (Exit.isSuccess(exit)) window.location.reload();
161− };
209+ const auth = useAuth();
162210
163− return AsyncResult.match(organizations, {
164− onInitial: () => <DropdownMenuItem disabled>Loading…</DropdownMenuItem>,
165− onFailure: () => <DropdownMenuItem disabled>Failed to load organizations</DropdownMenuItem>,
166− onSuccess: ({ value }) =>
167− value.organizations.length === 0 ? (
168− <DropdownMenuItem disabled>No organizations</DropdownMenuItem>
169− ) : (
170− <>
171− {value.organizations.map((organization: { id: string; name: string }) => {
172− const isActive = organization.id === props.activeOrganizationId;
173− return (
174− <DropdownMenuItem
175− key={organization.id}
176− disabled={isActive}
177− onClick={() => handleSwitch(organization.id)}
178− className="text-xs"
179− >
180− <span className="min-w-0 flex-1 truncate">{organization.name}</span>
181− {isActive && <CheckIcon />}
182− </DropdownMenuItem>
183− );
184− })}
185− </>
186− ),
187− });
211+ if (auth.status !== "authenticated") {
212+ return <DropdownMenuItem disabled>Loading…</DropdownMenuItem>;
213+ }
214+ if (auth.organizations.length === 0) {
215+ return <DropdownMenuItem disabled>No organizations</DropdownMenuItem>;
216+ }
217+ return (
218+ <>
219+ {auth.organizations.map((organization) => {
220+ const isActive = organization.id === props.activeOrganizationId;
221+ return (
222+ <DropdownMenuItem key={organization.id} disabled={isActive} className="text-xs" asChild>
223+ <Link
224+ to="/$org"
225+ params={{ org: organization.handle }}
226+ className="flex w-full items-center gap-2"
227+ >
228+ <span className="min-w-0 flex-1 truncate">{organization.name}</span>
229+ {isActive && <CheckIcon />}
230+ </Link>
231+ </DropdownMenuItem>
232+ );
233+ })}
234+ </>
235+ );
188236 }
189237
190238 function CheckIcon() {
@@ -203,6 +251,7 @@ function CheckIcon() { · 12 unmodified lines
203251
204252 function UserFooter() {
205253 const auth = useAuth();
254+ const orgRoute = useOrgRoute();
206255 const [createOrganizationOpen, setCreateOrganizationOpen] = useState(false);
207256
208257 const suggestedOrganizationName =
@@ -212,7 +261,15 @@ function UserFooter() { · 3 unmodified lines
212261
213262 const form = useCreateOrganizationForm({
214263 defaultName: suggestedOrganizationName,
215− onSuccess: () => window.location.reload(),
264+ // The form returns the new org's handle on success — navigate via the URL
265+ // by reloading at the new handle. Once we wire useNavigate in here we can
266+ // do a soft navigation instead.
267+ onSuccess: (org) => {
268+ // Navigate to the new org's URL — the URL is the source of truth for
269+ // active org now, so a hard reload at the new handle re-renders the
270+ // shell with the right context.
271+ window.location.href = `/${org.handle}`;
272+ },
216273 });
217274
218275 if (auth.status !== "authenticated") return null;
@@ -243,9 +300,7 @@ function UserFooter() { · 24 unmodified lines
243300 <p className="truncate text-xs font-medium text-foreground">
244301 {auth.user.name ?? auth.user.email}
245302 </p>
246− {auth.organization && (
247− <p className="truncate text-xs text-muted-foreground">{auth.organization.name}</p>
248− )}
303+ <p className="truncate text-xs text-muted-foreground">{orgRoute.orgName}</p>
249304 </div>
250305 <svg
251306 viewBox="0 0 16 16"
@@ -268,12 +323,10 @@ function UserFooter() { · 16 unmodified lines
268323 </DropdownMenuLabel>
269324 <DropdownMenuSub>
270325 <DropdownMenuSubTrigger className="text-xs">
271− <span className="min-w-0 flex-1 truncate">
272− {auth.organization?.name ?? "No organization"}
273− </span>
326+ <span className="min-w-0 flex-1 truncate">{orgRoute.orgName}</span>
274327 </DropdownMenuSubTrigger>
275328 <DropdownMenuSubContent className="w-56">
276− <OrganizationSwitcherItems activeOrganizationId={auth.organization?.id ?? null} />
329+ <OrganizationSwitcherItems activeOrganizationId={orgRoute.orgId} />
277330 <DropdownMenuSeparator />
278331 <DropdownMenuItem
279332 className="text-xs"
@@ -354,40 +407,77 @@ function UserFooter() { · 74 unmodified lines
354407 // ── SidebarContent ───────────────────────────────────────────────────────
355408
356409 function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) {
357− const isHome = props.pathname === "/";
358− const isSecrets = props.pathname === "/secrets";
359− const isConnections = props.pathname === "/connections";
360− const isPolicies = props.pathname === "/policies";
361− const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/");
362− const isOrg = props.pathname === "/org";
410+ const { orgHandle } = useOrgRoute();
411+ const orgPrefix = `/${orgHandle}`;
412+ const params = { org: orgHandle };
413+ const isHome = props.pathname === orgPrefix || props.pathname === `${orgPrefix}/`;
414+ const isSecrets = props.pathname === `${orgPrefix}/secrets`;
415+ const isConnections = props.pathname === `${orgPrefix}/connections`;
416+ const isPolicies = props.pathname === `${orgPrefix}/policies`;
417+ const isBilling =
418+ props.pathname === `${orgPrefix}/-/billing` ||
419+ props.pathname.startsWith(`${orgPrefix}/-/billing/`);
420+ const isOrg = props.pathname === `${orgPrefix}/-/settings`;
363421
364422 return (
365423 <>
366424 {props.showBrand !== false && (
367425 <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">
368− <Link to="/" className="flex items-center gap-1.5">
426+ <Link to="/$org" params={params} className="flex items-center gap-1.5">
369427 <span className="font-display text-base tracking-tight text-foreground">executor</span>
370428 </Link>
371429 </div>
372430 )}
373431
374432 <nav className="flex flex-1 flex-col overflow-y-auto p-2">
375− <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} />
376− <NavItem
377− to="/connections"
378− label="Connections"
379− active={isConnections}
380− onNavigate={props.onNavigate}
381− />
382− <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} />
383− <NavItem
384− to="/policies"
385− label="Policies"
386− active={isPolicies}
387− onNavigate={props.onNavigate}
388− />
389− <NavItem to="/org" label="Organization" active={isOrg} onNavigate={props.onNavigate} />
390− <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} />
433+ <Link
434+ to="/$org"
435+ params={params}
436+ onClick={props.onNavigate}
437+ className={navItemClassName(isHome)}
438+ >
439+ Sources
440+ </Link>
441+ <Link
442+ to="/$org/connections"
443+ params={params}
444+ onClick={props.onNavigate}
445+ className={navItemClassName(isConnections)}
446+ >
447+ Connections
448+ </Link>
449+ <Link
450+ to="/$org/secrets"
451+ params={params}
452+ onClick={props.onNavigate}
453+ className={navItemClassName(isSecrets)}
454+ >
455+ Secrets
456+ </Link>
457+ <Link
458+ to="/$org/policies"
459+ params={params}
460+ onClick={props.onNavigate}
461+ className={navItemClassName(isPolicies)}
462+ >
463+ Policies
464+ </Link>
465+ <Link
466+ to="/$org/-/settings"
467+ params={params}
468+ onClick={props.onNavigate}
469+ className={navItemClassName(isOrg)}
470+ >
471+ Organization
472+ </Link>
473+ <Link
474+ to="/$org/-/billing"
475+ params={params}
476+ onClick={props.onNavigate}
477+ className={navItemClassName(isBilling)}
478+ >
479+ Billing
480+ </Link>
391481
392482 <div className="mt-5 mb-1 px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">
393483 <span>Sources</span>
@@ -404,6 +494,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 10 unmodified lines
404494 // ── Shell ─────────────────────────────────────────────────────────────────
405495
406496 export function Shell() {
497+ const { orgHandle } = useOrgRoute();
407498 const location = useLocation();
408499 const pathname = location.pathname;
409500 const lastPathname = useRef(pathname);
@@ -443,7 +534,7 @@ export function Shell() { · 33 unmodified lines
443534 />
444535 <div className="relative flex h-full w-[84vw] max-w-xs flex-col border-r border-sidebar-border bg-sidebar shadow-2xl">
445536 <div className="flex h-12 shrink-0 items-center justify-between border-b border-sidebar-border px-4">
446− <Link to="/" className="flex items-center gap-1.5">
537+ <Link to="/$org" params={{ org: orgHandle }} className="flex items-center gap-1.5">
447538 <span className="font-display text-base tracking-tight text-foreground">
448539 executor
449540 </span>
@@ -496,7 +587,7 @@ export function Shell() { · 46 unmodified lines
496587 />
497588 </svg>
498589 </Button>
499− <Link to="/" className="flex items-center gap-1.5">
590+ <Link to="/$org" params={{ org: orgHandle }} className="flex items-center gap-1.5">
500591 <span className="font-display text-base tracking-tight text-foreground">executor</span>
501592 </Link>
502593 <div className="w-8 shrink-0" />