diff --git a/apps/cloud/src/api.request-scope.node.test.ts b/apps/cloud/src/api.request-scope.node.test.ts index 8d2908360..ae3b80fca 100644 --- a/apps/cloud/src/api.request-scope.node.test.ts +++ b/apps/cloud/src/api.request-scope.node.test.ts @@ -193,11 +193,12 @@ describe("makeApiLive (prod handler factory) request scoping", () => { }).handler; // Hit a protected route. ExecutionStackMiddleware short-circuits with - // 403 (no session cookie) but not before `requestScopedMiddleware` - // has built the per-request layer. We don't care about the response — - // only that the layer was built once per request. - await handler(new Request("http://test.local/scope")); - await handler(new Request("http://test.local/scope")); + // 401/404 (no session cookie / unknown org) but not before + // `requestScopedMiddleware` has built the per-request layer. We don't + // care about the response — only that the layer was built once per + // request. The protected API mounts under `/api/:org/...`. + await handler(new Request("http://test.local/api/test_org/scope")); + await handler(new Request("http://test.local/api/test_org/scope")); expect(counts.acquires).toBe(2); expect(counts.releases).toBe(2); diff --git a/apps/cloud/src/api/protected.ts b/apps/cloud/src/api/protected.ts index e74b95f9f..2cb02df8a 100644 --- a/apps/cloud/src/api/protected.ts +++ b/apps/cloud/src/api/protected.ts @@ -24,6 +24,7 @@ import { WorkOSAuth } from "../auth/workos"; import { AutumnService } from "../services/autumn"; import { DbService } from "../services/db"; import { makeExecutionStack } from "../services/execution-stack"; +import { resolveOrgContext } from "../services/url-context"; import { HttpResponseError } from "./error-response"; import { RequestScopedServicesLive } from "./layers"; import { @@ -33,6 +34,16 @@ import { } from "./protected-layers"; import { requestScopedMiddleware } from "./request-scoped"; +// Pull the URL `:org` segment from a request path. The protected API mounts +// under `/api/:org/...` — anything else is a programming error and surfaces as +// a typed `no_organization` response so the framework's error pipeline can +// render it. +const orgHandleFromPath = (pathname: string): string | null => { + const parts = pathname.split("/").filter((part) => part.length > 0); + if (parts.length < 2 || parts[0] !== "api") return null; + return parts[1] ?? null; +}; + // Pre-compute the per-plugin `Effect.provideService(extensionService, // executor[id])` chain. The plugin spec carries the Service tag so // this file doesn't import each plugin's `*/api` directly. @@ -79,19 +90,41 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{ const webRequest = yield* HttpServerRequest.toWeb(request); const workos = yield* WorkOSAuth; const session = yield* workos.authenticateRequest(webRequest); - if (!session || !session.organizationId) { + if (!session) { return yield* new HttpResponseError({ - status: 403, + status: 401, + code: "unauthorized", + message: "Unauthorized", + }); + } + // The URL is the source of truth for active org. Pull the handle + // off the request path, resolve it to an org row, and verify + // membership against WorkOS — independent of `session.organizationId`. + const url = new URL(webRequest.url); + const handle = orgHandleFromPath(url.pathname); + if (!handle) { + return yield* new HttpResponseError({ + status: 404, code: "no_organization", - message: "No organization in session", + message: "Missing organization in URL", }); } - const org = yield* authorizeOrganization(session.userId, session.organizationId); + const resolved = yield* resolveOrgContext(handle).pipe( + Effect.catchTag("OrganizationHandleNotFound", () => Effect.succeed(null)), + ); + if (!resolved) { + return yield* new HttpResponseError({ + status: 404, + code: "no_organization", + message: `Organization "${handle}" not found`, + }); + } + const org = yield* authorizeOrganization(session.userId, resolved.organization.id); if (!org) { return yield* new HttpResponseError({ status: 403, code: "no_organization", - message: "No organization in session", + message: "Not a member of this organization", }); } const auth = AuthContext.of({ @@ -116,6 +149,16 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{ }), ); +// Layer that swaps the boot router with a `:org`-prefixed view, so every +// route registered by `ProtectedCloudApiLive` mounts under `/api/:org/*`. +// `HttpRouter.prefixed` returns a wrapper that delegates to the underlying +// router state — the outer router-config layer still owns the actual +// FindMyWay instance, so non-protected routes (auth, autumn, swagger) keep +// their unprefixed paths. +const PrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)( + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed("/api/:org")), +); + // `rsLive` is the per-request DB layer. Combining it into the auth // middleware collapses `requires: DbService | UserStoreService` to // never (so `.layer` is a real Layer instead of the "Need to combine" @@ -131,6 +174,7 @@ export const makeProtectedApiLive = ( ).layer; return ProtectedCloudApiLive.pipe( Layer.provide(protectedMiddleware), + Layer.provide(PrefixedRouterLayer), Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })), Layer.provideMerge(RouterConfig), ); diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index ba0e28d5c..c1578a7d7 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -12,26 +12,18 @@ const AuthUser = Schema.Struct({ const AuthOrganization = Schema.Struct({ id: Schema.String, + handle: Schema.String, name: Schema.String, }); const AuthMeResponse = Schema.Struct({ user: AuthUser, - organization: Schema.NullOr(AuthOrganization), -}); - -const AuthOrganizationSummary = Schema.Struct({ - id: Schema.String, - name: Schema.String, + /** Memberships, with the org `handle` URL routes use. Sorted alphabetically. */ + organizations: Schema.Array(AuthOrganization), }); const AuthOrganizationsResponse = Schema.Struct({ - organizations: Schema.Array(AuthOrganizationSummary), - activeOrganizationId: Schema.NullOr(Schema.String), -}); - -const SwitchOrganizationBody = Schema.Struct({ - organizationId: Schema.String, + organizations: Schema.Array(AuthOrganization), }); const CreateOrganizationBody = Schema.Struct({ @@ -40,6 +32,7 @@ const CreateOrganizationBody = Schema.Struct({ const CreateOrganizationResponse = Schema.Struct({ id: Schema.String, + handle: Schema.String, name: Schema.String, }); @@ -52,7 +45,6 @@ export const AUTH_PATHS = { login: "/api/auth/login", logout: "/api/auth/logout", callback: "/api/auth/callback", - switchOrganization: "/api/auth/switch-organization", } as const; const AuthErrors = [UserStoreError, WorkOSError] as const; @@ -82,12 +74,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") error: WorkOSError, }), ) - .add( - HttpApiEndpoint.post("switchOrganization", "/auth/switch-organization", { - payload: SwitchOrganizationBody, - error: WorkOSError, - }), - ) .add( HttpApiEndpoint.post("createOrganization", "/auth/create-organization", { payload: CreateOrganizationBody, diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 72315432b..3795f7c29 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -1,14 +1,12 @@ import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServerResponse } from "effect/unstable/http"; import { Duration, Effect } from "effect"; -import { setCookie, deleteCookie } from "@tanstack/react-start/server"; +import { deleteCookie } from "@tanstack/react-start/server"; import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api"; import { SessionContext } from "./middleware"; import { UserStoreService } from "./context"; -import { authorizeOrganization } from "./authorize-organization"; import { env } from "cloudflare:workers"; -import { WorkOSError } from "./errors"; import { WorkOSAuth } from "./workos"; const COOKIE_OPTIONS = { @@ -172,9 +170,39 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( .handle("me", () => Effect.gen(function* () { const session = yield* SessionContext; - const org = session.organizationId - ? yield* authorizeOrganization(session.accountId, session.organizationId) - : null; + const users = yield* UserStoreService; + const workos = yield* WorkOSAuth; + + const memberships = yield* workos.listUserMemberships(session.accountId); + // Mirror each org locally so the local handle exists; ignore mirror + // failures (the directory layer already has the canonical name + + // membership — a transient db error here shouldn't blank the user's + // org list). + const orgs = yield* Effect.all( + memberships.data.map((m) => + workos.getOrganization(m.organizationId).pipe( + Effect.flatMap((org) => + users + .use((s) => + s.upsertOrganization({ id: org.id, name: org.name }), + ) + .pipe( + Effect.map((mirror) => ({ + id: mirror.id, + handle: mirror.handle, + name: org.name, + })), + ), + ), + Effect.orElseSucceed(() => null), + ), + ), + { concurrency: "unbounded" }, + ); + + const organizations = orgs + .filter((v: T | null): v is T => v !== null) + .sort((a, b) => a.name.localeCompare(b.name)); return { user: { @@ -183,7 +211,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( name: session.name, avatarUrl: session.avatarUrl, }, - organization: org ? { id: org.id, name: org.name } : null, + organizations, }; }), ) @@ -194,37 +222,37 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( .handle("organizations", () => Effect.gen(function* () { const workos = yield* WorkOSAuth; + const users = yield* UserStoreService; const session = yield* SessionContext; const memberships = yield* workos.listUserMemberships(session.accountId); - const organizations = yield* Effect.all( + const orgs = yield* Effect.all( memberships.data.map((m) => workos.getOrganization(m.organizationId).pipe( - Effect.map((org) => ({ id: org.id, name: org.name })), + Effect.flatMap((org) => + users + .use((s) => + s.upsertOrganization({ id: org.id, name: org.name }), + ) + .pipe( + Effect.map((mirror) => ({ + id: mirror.id, + handle: mirror.handle, + name: org.name, + })), + ), + ), Effect.orElseSucceed(() => null), ), ), { concurrency: "unbounded" }, ); - return { - organizations: organizations.filter((org): org is NonNullable => org !== null), - activeOrganizationId: session.organizationId, - }; - }), - ) - .handle("switchOrganization", ({ payload }) => - Effect.gen(function* () { - const workos = yield* WorkOSAuth; - const session = yield* SessionContext; + const organizations = orgs + .filter((v: T | null): v is T => v !== null) + .sort((a, b) => a.name.localeCompare(b.name)); - const refreshed = yield* workos.refreshSession( - session.sealedSession, - payload.organizationId, - ); - if (refreshed) { - setCookie("wos-session", refreshed, COOKIE_OPTIONS); - } + return { organizations }; }), ) .handle("createOrganization", ({ payload }) => @@ -236,36 +264,15 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( const name = payload.name.trim(); const org = yield* workos.createOrganization(name); yield* workos.createMembership(org.id, session.accountId, "admin"); - yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name })); - - // Try to attach the new org to the current session. This can fail - // (or silently return a session still scoped to the old org) when - // the caller's current session is stale — most commonly after the - // user was removed from the org their cookie is pinned to. In that - // case we can't repair the session in-place, so we clear the - // cookie and fail loudly; the frontend will bounce to login and - // the callback's rehydrate path will pick up the new membership. - const refreshed = yield* workos.refreshSession(session.sealedSession, org.id); - const verified = refreshed - ? yield* workos.authenticateSealedSession(refreshed) - : null; - - if (!refreshed || !verified || verified.organizationId !== org.id) { - yield* Effect.logWarning( - "createOrganization: unable to attach new org to current session", - { - userId: session.accountId, - newOrgId: org.id, - refreshReturnedSession: refreshed != null, - verifiedOrgId: verified?.organizationId ?? null, - }, - ); - deleteCookie("wos-session", { path: "/" }); - return yield* Effect.fail(new WorkOSError()); - } - - setCookie("wos-session", refreshed, COOKIE_OPTIONS); - return { id: org.id, name: org.name }; + const mirrored = yield* users.use((s) => + s.upsertOrganization({ id: org.id, name: org.name }), + ); + // No session refresh — the URL is the source of truth for active + // org now, so the client just navigates to /:handle/... after + // create. The WorkOS session's `organizationId` only proves login + // identity; it doesn't gate per-org access (that goes through the + // membership check on the URL-resolved org). + return { id: org.id, handle: mirrored.handle, name: org.name }; }), ), ); diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index 793e86d72..99e741e26 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -9,201 +9,181 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as ToolsRouteImport } from './routes/tools' -import { Route as SecretsRouteImport } from './routes/secrets' -import { Route as PoliciesRouteImport } from './routes/policies' -import { Route as OrgRouteImport } from './routes/org' -import { Route as ConnectionsRouteImport } from './routes/connections' -import { Route as BillingRouteImport } from './routes/billing' +import { Route as OrgRouteImport } from './routes/$org' import { Route as IndexRouteImport } from './routes/index' -import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' -import { Route as BillingPlansRouteImport } from './routes/billing_.plans' -import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' +import { Route as OrgIndexRouteImport } from './routes/$org/index' +import { Route as OrgToolsRouteImport } from './routes/$org/tools' +import { Route as OrgSecretsRouteImport } from './routes/$org/secrets' +import { Route as OrgPoliciesRouteImport } from './routes/$org/policies' +import { Route as OrgConnectionsRouteImport } from './routes/$org/connections' +import { Route as OrgSourcesNamespaceRouteImport } from './routes/$org/sources.$namespace' +import { Route as OrgChar91Char93SettingsRouteImport } from './routes/$org/[-].settings' +import { Route as OrgChar91Char93BillingRouteImport } from './routes/$org/[-].billing' +import { Route as OrgSourcesAddPluginKeyRouteImport } from './routes/$org/sources.add.$pluginKey' +import { Route as OrgChar91Char93BillingPlansRouteImport } from './routes/$org/[-].billing_.plans' -const ToolsRoute = ToolsRouteImport.update({ +const OrgRoute = OrgRouteImport.update({ + id: '/$org', + path: '/$org', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const OrgIndexRoute = OrgIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => OrgRoute, +} as any) +const OrgToolsRoute = OrgToolsRouteImport.update({ id: '/tools', path: '/tools', - getParentRoute: () => rootRouteImport, + getParentRoute: () => OrgRoute, } as any) -const SecretsRoute = SecretsRouteImport.update({ +const OrgSecretsRoute = OrgSecretsRouteImport.update({ id: '/secrets', path: '/secrets', - getParentRoute: () => rootRouteImport, + getParentRoute: () => OrgRoute, } as any) -const PoliciesRoute = PoliciesRouteImport.update({ +const OrgPoliciesRoute = OrgPoliciesRouteImport.update({ id: '/policies', path: '/policies', - getParentRoute: () => rootRouteImport, -} as any) -const OrgRoute = OrgRouteImport.update({ - id: '/org', - path: '/org', - getParentRoute: () => rootRouteImport, + getParentRoute: () => OrgRoute, } as any) -const ConnectionsRoute = ConnectionsRouteImport.update({ +const OrgConnectionsRoute = OrgConnectionsRouteImport.update({ id: '/connections', path: '/connections', - getParentRoute: () => rootRouteImport, -} as any) -const BillingRoute = BillingRouteImport.update({ - id: '/billing', - path: '/billing', - getParentRoute: () => rootRouteImport, -} as any) -const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => rootRouteImport, + getParentRoute: () => OrgRoute, } as any) -const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ +const OrgSourcesNamespaceRoute = OrgSourcesNamespaceRouteImport.update({ id: '/sources/$namespace', path: '/sources/$namespace', - getParentRoute: () => rootRouteImport, + getParentRoute: () => OrgRoute, } as any) -const BillingPlansRoute = BillingPlansRouteImport.update({ - id: '/billing_/plans', - path: '/billing/plans', - getParentRoute: () => rootRouteImport, +const OrgChar91Char93SettingsRoute = OrgChar91Char93SettingsRouteImport.update({ + id: '/-/settings', + path: '/-/settings', + getParentRoute: () => OrgRoute, +} as any) +const OrgChar91Char93BillingRoute = OrgChar91Char93BillingRouteImport.update({ + id: '/-/billing', + path: '/-/billing', + getParentRoute: () => OrgRoute, } as any) -const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ +const OrgSourcesAddPluginKeyRoute = OrgSourcesAddPluginKeyRouteImport.update({ id: '/sources/add/$pluginKey', path: '/sources/add/$pluginKey', - getParentRoute: () => rootRouteImport, + getParentRoute: () => OrgRoute, } as any) +const OrgChar91Char93BillingPlansRoute = + OrgChar91Char93BillingPlansRouteImport.update({ + id: '/-/billing_/plans', + path: '/-/billing/plans', + getParentRoute: () => OrgRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/billing': typeof BillingRoute - '/connections': typeof ConnectionsRoute - '/org': typeof OrgRoute - '/policies': typeof PoliciesRoute - '/secrets': typeof SecretsRoute - '/tools': typeof ToolsRoute - '/billing/plans': typeof BillingPlansRoute - '/sources/$namespace': typeof SourcesNamespaceRoute - '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute + '/$org': typeof OrgRouteWithChildren + '/$org/connections': typeof OrgConnectionsRoute + '/$org/policies': typeof OrgPoliciesRoute + '/$org/secrets': typeof OrgSecretsRoute + '/$org/tools': typeof OrgToolsRoute + '/$org/': typeof OrgIndexRoute + '/$org/-/billing': typeof OrgChar91Char93BillingRoute + '/$org/-/settings': typeof OrgChar91Char93SettingsRoute + '/$org/sources/$namespace': typeof OrgSourcesNamespaceRoute + '/$org/-/billing/plans': typeof OrgChar91Char93BillingPlansRoute + '/$org/sources/add/$pluginKey': typeof OrgSourcesAddPluginKeyRoute } export interface FileRoutesByTo { '/': typeof IndexRoute - '/billing': typeof BillingRoute - '/connections': typeof ConnectionsRoute - '/org': typeof OrgRoute - '/policies': typeof PoliciesRoute - '/secrets': typeof SecretsRoute - '/tools': typeof ToolsRoute - '/billing/plans': typeof BillingPlansRoute - '/sources/$namespace': typeof SourcesNamespaceRoute - '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute + '/$org/connections': typeof OrgConnectionsRoute + '/$org/policies': typeof OrgPoliciesRoute + '/$org/secrets': typeof OrgSecretsRoute + '/$org/tools': typeof OrgToolsRoute + '/$org': typeof OrgIndexRoute + '/$org/-/billing': typeof OrgChar91Char93BillingRoute + '/$org/-/settings': typeof OrgChar91Char93SettingsRoute + '/$org/sources/$namespace': typeof OrgSourcesNamespaceRoute + '/$org/-/billing/plans': typeof OrgChar91Char93BillingPlansRoute + '/$org/sources/add/$pluginKey': typeof OrgSourcesAddPluginKeyRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute - '/billing': typeof BillingRoute - '/connections': typeof ConnectionsRoute - '/org': typeof OrgRoute - '/policies': typeof PoliciesRoute - '/secrets': typeof SecretsRoute - '/tools': typeof ToolsRoute - '/billing_/plans': typeof BillingPlansRoute - '/sources/$namespace': typeof SourcesNamespaceRoute - '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute + '/$org': typeof OrgRouteWithChildren + '/$org/connections': typeof OrgConnectionsRoute + '/$org/policies': typeof OrgPoliciesRoute + '/$org/secrets': typeof OrgSecretsRoute + '/$org/tools': typeof OrgToolsRoute + '/$org/': typeof OrgIndexRoute + '/$org/-/billing': typeof OrgChar91Char93BillingRoute + '/$org/-/settings': typeof OrgChar91Char93SettingsRoute + '/$org/sources/$namespace': typeof OrgSourcesNamespaceRoute + '/$org/-/billing_/plans': typeof OrgChar91Char93BillingPlansRoute + '/$org/sources/add/$pluginKey': typeof OrgSourcesAddPluginKeyRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' - | '/billing' - | '/connections' - | '/org' - | '/policies' - | '/secrets' - | '/tools' - | '/billing/plans' - | '/sources/$namespace' - | '/sources/add/$pluginKey' + | '/$org' + | '/$org/connections' + | '/$org/policies' + | '/$org/secrets' + | '/$org/tools' + | '/$org/' + | '/$org/-/billing' + | '/$org/-/settings' + | '/$org/sources/$namespace' + | '/$org/-/billing/plans' + | '/$org/sources/add/$pluginKey' fileRoutesByTo: FileRoutesByTo to: | '/' - | '/billing' - | '/connections' - | '/org' - | '/policies' - | '/secrets' - | '/tools' - | '/billing/plans' - | '/sources/$namespace' - | '/sources/add/$pluginKey' + | '/$org/connections' + | '/$org/policies' + | '/$org/secrets' + | '/$org/tools' + | '/$org' + | '/$org/-/billing' + | '/$org/-/settings' + | '/$org/sources/$namespace' + | '/$org/-/billing/plans' + | '/$org/sources/add/$pluginKey' id: | '__root__' | '/' - | '/billing' - | '/connections' - | '/org' - | '/policies' - | '/secrets' - | '/tools' - | '/billing_/plans' - | '/sources/$namespace' - | '/sources/add/$pluginKey' + | '/$org' + | '/$org/connections' + | '/$org/policies' + | '/$org/secrets' + | '/$org/tools' + | '/$org/' + | '/$org/-/billing' + | '/$org/-/settings' + | '/$org/sources/$namespace' + | '/$org/-/billing_/plans' + | '/$org/sources/add/$pluginKey' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute - BillingRoute: typeof BillingRoute - ConnectionsRoute: typeof ConnectionsRoute - OrgRoute: typeof OrgRoute - PoliciesRoute: typeof PoliciesRoute - SecretsRoute: typeof SecretsRoute - ToolsRoute: typeof ToolsRoute - BillingPlansRoute: typeof BillingPlansRoute - SourcesNamespaceRoute: typeof SourcesNamespaceRoute - SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute + OrgRoute: typeof OrgRouteWithChildren } declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/tools': { - id: '/tools' - path: '/tools' - fullPath: '/tools' - preLoaderRoute: typeof ToolsRouteImport - parentRoute: typeof rootRouteImport - } - '/secrets': { - id: '/secrets' - path: '/secrets' - fullPath: '/secrets' - preLoaderRoute: typeof SecretsRouteImport - parentRoute: typeof rootRouteImport - } - '/policies': { - id: '/policies' - path: '/policies' - fullPath: '/policies' - preLoaderRoute: typeof PoliciesRouteImport - parentRoute: typeof rootRouteImport - } - '/org': { - id: '/org' - path: '/org' - fullPath: '/org' + '/$org': { + id: '/$org' + path: '/$org' + fullPath: '/$org' preLoaderRoute: typeof OrgRouteImport parentRoute: typeof rootRouteImport } - '/connections': { - id: '/connections' - path: '/connections' - fullPath: '/connections' - preLoaderRoute: typeof ConnectionsRouteImport - parentRoute: typeof rootRouteImport - } - '/billing': { - id: '/billing' - path: '/billing' - fullPath: '/billing' - preLoaderRoute: typeof BillingRouteImport - parentRoute: typeof rootRouteImport - } '/': { id: '/' path: '/' @@ -211,41 +191,110 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/sources/$namespace': { - id: '/sources/$namespace' + '/$org/': { + id: '/$org/' + path: '/' + fullPath: '/$org/' + preLoaderRoute: typeof OrgIndexRouteImport + parentRoute: typeof OrgRoute + } + '/$org/tools': { + id: '/$org/tools' + path: '/tools' + fullPath: '/$org/tools' + preLoaderRoute: typeof OrgToolsRouteImport + parentRoute: typeof OrgRoute + } + '/$org/secrets': { + id: '/$org/secrets' + path: '/secrets' + fullPath: '/$org/secrets' + preLoaderRoute: typeof OrgSecretsRouteImport + parentRoute: typeof OrgRoute + } + '/$org/policies': { + id: '/$org/policies' + path: '/policies' + fullPath: '/$org/policies' + preLoaderRoute: typeof OrgPoliciesRouteImport + parentRoute: typeof OrgRoute + } + '/$org/connections': { + id: '/$org/connections' + path: '/connections' + fullPath: '/$org/connections' + preLoaderRoute: typeof OrgConnectionsRouteImport + parentRoute: typeof OrgRoute + } + '/$org/sources/$namespace': { + id: '/$org/sources/$namespace' path: '/sources/$namespace' - fullPath: '/sources/$namespace' - preLoaderRoute: typeof SourcesNamespaceRouteImport - parentRoute: typeof rootRouteImport + fullPath: '/$org/sources/$namespace' + preLoaderRoute: typeof OrgSourcesNamespaceRouteImport + parentRoute: typeof OrgRoute } - '/billing_/plans': { - id: '/billing_/plans' - path: '/billing/plans' - fullPath: '/billing/plans' - preLoaderRoute: typeof BillingPlansRouteImport - parentRoute: typeof rootRouteImport + '/$org/-/settings': { + id: '/$org/-/settings' + path: '/-/settings' + fullPath: '/$org/-/settings' + preLoaderRoute: typeof OrgChar91Char93SettingsRouteImport + parentRoute: typeof OrgRoute + } + '/$org/-/billing': { + id: '/$org/-/billing' + path: '/-/billing' + fullPath: '/$org/-/billing' + preLoaderRoute: typeof OrgChar91Char93BillingRouteImport + parentRoute: typeof OrgRoute } - '/sources/add/$pluginKey': { - id: '/sources/add/$pluginKey' + '/$org/sources/add/$pluginKey': { + id: '/$org/sources/add/$pluginKey' path: '/sources/add/$pluginKey' - fullPath: '/sources/add/$pluginKey' - preLoaderRoute: typeof SourcesAddPluginKeyRouteImport - parentRoute: typeof rootRouteImport + fullPath: '/$org/sources/add/$pluginKey' + preLoaderRoute: typeof OrgSourcesAddPluginKeyRouteImport + parentRoute: typeof OrgRoute + } + '/$org/-/billing_/plans': { + id: '/$org/-/billing_/plans' + path: '/-/billing/plans' + fullPath: '/$org/-/billing/plans' + preLoaderRoute: typeof OrgChar91Char93BillingPlansRouteImport + parentRoute: typeof OrgRoute } } } +interface OrgRouteChildren { + OrgConnectionsRoute: typeof OrgConnectionsRoute + OrgPoliciesRoute: typeof OrgPoliciesRoute + OrgSecretsRoute: typeof OrgSecretsRoute + OrgToolsRoute: typeof OrgToolsRoute + OrgIndexRoute: typeof OrgIndexRoute + OrgChar91Char93BillingRoute: typeof OrgChar91Char93BillingRoute + OrgChar91Char93SettingsRoute: typeof OrgChar91Char93SettingsRoute + OrgSourcesNamespaceRoute: typeof OrgSourcesNamespaceRoute + OrgChar91Char93BillingPlansRoute: typeof OrgChar91Char93BillingPlansRoute + OrgSourcesAddPluginKeyRoute: typeof OrgSourcesAddPluginKeyRoute +} + +const OrgRouteChildren: OrgRouteChildren = { + OrgConnectionsRoute: OrgConnectionsRoute, + OrgPoliciesRoute: OrgPoliciesRoute, + OrgSecretsRoute: OrgSecretsRoute, + OrgToolsRoute: OrgToolsRoute, + OrgIndexRoute: OrgIndexRoute, + OrgChar91Char93BillingRoute: OrgChar91Char93BillingRoute, + OrgChar91Char93SettingsRoute: OrgChar91Char93SettingsRoute, + OrgSourcesNamespaceRoute: OrgSourcesNamespaceRoute, + OrgChar91Char93BillingPlansRoute: OrgChar91Char93BillingPlansRoute, + OrgSourcesAddPluginKeyRoute: OrgSourcesAddPluginKeyRoute, +} + +const OrgRouteWithChildren = OrgRoute._addFileChildren(OrgRouteChildren) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, - BillingRoute: BillingRoute, - ConnectionsRoute: ConnectionsRoute, - OrgRoute: OrgRoute, - PoliciesRoute: PoliciesRoute, - SecretsRoute: SecretsRoute, - ToolsRoute: ToolsRoute, - BillingPlansRoute: BillingPlansRoute, - SourcesNamespaceRoute: SourcesNamespaceRoute, - SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, + OrgRoute: OrgRouteWithChildren, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/cloud/src/routes/$org.tsx b/apps/cloud/src/routes/$org.tsx new file mode 100644 index 000000000..24b5356df --- /dev/null +++ b/apps/cloud/src/routes/$org.tsx @@ -0,0 +1,62 @@ +import { createFileRoute, useNavigate, useParams } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { AutumnProvider } from "autumn-js/react"; +import { ExecutorProvider } from "@executor-js/react/api/provider"; +import { setBaseUrl } from "@executor-js/react/api/base-url"; +import { Toaster } from "@executor-js/react/components/sonner"; +import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { plugins as clientPlugins } from "virtual:executor/plugins-client"; + +import { findOrgByHandle, useAuth } from "../web/auth"; +import { OrgRouteProvider } from "../web/org-route"; +import { Shell, ShellSkeleton } from "../web/shell"; + +export const Route = createFileRoute("/$org")({ + component: OrgLayout, +}); + +function OrgLayout() { + const auth = useAuth(); + const navigate = useNavigate(); + const { org: handle } = useParams({ from: Route.id }); + + // Redirect to the first membership when the URL handle is unknown. We only + // run the redirect once auth resolves to authenticated; loading/unauth are + // already handled by AuthGate in __root. + const matched = auth.status === "authenticated" ? findOrgByHandle(auth, handle) : null; + const fallback = + auth.status === "authenticated" ? (auth.organizations[0] ?? null) : null; + + useEffect(() => { + if (auth.status !== "authenticated") return; + if (matched) return; + if (!fallback) return; + void navigate({ to: "/$org", params: { org: fallback.handle }, replace: true }); + }, [auth.status, matched, fallback, navigate]); + + if (auth.status !== "authenticated") return null; + if (!matched) return null; + + // Point the executor API client at this org's prefixed routes. Done before + // first render of the executor providers so all queries see the right URL. + // The cloud app is single-tenant per page, so a one-shot setter is fine — + // when the URL handle changes, this re-runs at the start of the next render. + if (typeof window !== "undefined") { + setBaseUrl(`${window.location.origin}/api/${matched.handle}`); + } + + return ( + + + }> + + + + + + + + ); +} diff --git a/apps/cloud/src/routes/billing.tsx b/apps/cloud/src/routes/$org/[-].billing.tsx similarity index 96% rename from apps/cloud/src/routes/billing.tsx rename to apps/cloud/src/routes/$org/[-].billing.tsx index d2815f32e..a1221482f 100644 --- a/apps/cloud/src/routes/billing.tsx +++ b/apps/cloud/src/routes/$org/[-].billing.tsx @@ -2,10 +2,11 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useCustomer, useListPlans } from "autumn-js/react"; import { Button } from "@executor-js/react/components/button"; import { Badge } from "@executor-js/react/components/badge"; +import { useOrgRoute } from "../../web/org-route"; type Plan = NonNullable["data"]>[number]; -export const Route = createFileRoute("/billing")({ +export const Route = createFileRoute("/$org/-/billing")({ component: BillingPage, }); @@ -16,6 +17,7 @@ const PLAN_TAGLINES: Record = { }; function BillingPage() { + const { orgHandle } = useOrgRoute(); const { data: customer, openCustomerPortal, isLoading: customerLoading } = useCustomer(); const { data: plans, isLoading: plansLoading } = useListPlans(); @@ -101,7 +103,8 @@ function BillingPage() { )} Manage diff --git a/apps/cloud/src/routes/billing_.plans.tsx b/apps/cloud/src/routes/$org/[-].billing_.plans.tsx similarity index 98% rename from apps/cloud/src/routes/billing_.plans.tsx rename to apps/cloud/src/routes/$org/[-].billing_.plans.tsx index 568dc6d4e..f8204135c 100644 --- a/apps/cloud/src/routes/billing_.plans.tsx +++ b/apps/cloud/src/routes/$org/[-].billing_.plans.tsx @@ -3,10 +3,11 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useCustomer, useListPlans } from "autumn-js/react"; import { Button } from "@executor-js/react/components/button"; import { Badge } from "@executor-js/react/components/badge"; +import { useOrgRoute } from "../../web/org-route"; type Plan = NonNullable["data"]>[number]; -export const Route = createFileRoute("/billing_/plans")({ +export const Route = createFileRoute("/$org/-/billing_/plans")({ component: PlansPage, }); @@ -68,6 +69,7 @@ const ENTERPRISE_MAILTO = `mailto:rhys@executor.sh?subject=${encodeURIComponent( )}`; function PlansPage() { + const { orgHandle } = useOrgRoute(); const { attach, openCustomerPortal, isLoading: customerLoading } = useCustomer(); const { data: plans, isLoading: plansLoading, isFetching } = useListPlans(); const [loadingPlan, setLoadingPlan] = useState(null); @@ -83,7 +85,8 @@ function PlansPage() {
diff --git a/apps/cloud/src/routes/org.tsx b/apps/cloud/src/routes/$org/[-].settings.tsx similarity index 98% rename from apps/cloud/src/routes/org.tsx rename to apps/cloud/src/routes/$org/[-].settings.tsx index d7b334ac2..b6f467de7 100644 --- a/apps/cloud/src/routes/org.tsx +++ b/apps/cloud/src/routes/$org/[-].settings.tsx @@ -51,10 +51,10 @@ import { getDomainVerificationLink, deleteDomain, updateOrgName, -} from "../web/org-atoms"; -import { useAuth } from "../web/auth"; +} from "../../web/org-atoms"; +import { useOrgRoute } from "../../web/org-route"; -export const Route = createFileRoute("/org")({ +export const Route = createFileRoute("/$org/-/settings")({ component: OrgPage, }); @@ -109,9 +109,8 @@ function formatLastActive(lastActiveAt: string | null): string { } function OrgPage() { - const auth = useAuth(); - const orgName = - auth.status === "authenticated" ? (auth.organization?.name ?? "Organization") : "Organization"; + const { orgName: routeOrgName, orgHandle } = useOrgRoute(); + const orgName = routeOrgName ?? "Organization"; const membersResult = useAtomValue(orgMembersAtom); const rolesResult = useAtomValue(orgRolesAtom); const domainsResult = useAtomValue(orgDomainsAtom); @@ -255,7 +254,7 @@ function OrgPage() {

Domain verification is available on the Professional plan.

- + diff --git a/apps/cloud/src/routes/connections.tsx b/apps/cloud/src/routes/$org/connections.tsx similarity index 74% rename from apps/cloud/src/routes/connections.tsx rename to apps/cloud/src/routes/$org/connections.tsx index ae9f0af5a..fe1afeb4e 100644 --- a/apps/cloud/src/routes/connections.tsx +++ b/apps/cloud/src/routes/$org/connections.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { ConnectionsPage } from "@executor-js/react/pages/connections"; -export const Route = createFileRoute("/connections")({ +export const Route = createFileRoute("/$org/connections")({ component: () => , }); diff --git a/apps/cloud/src/routes/$org/index.tsx b/apps/cloud/src/routes/$org/index.tsx new file mode 100644 index 000000000..4164cdbac --- /dev/null +++ b/apps/cloud/src/routes/$org/index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SourcesPage } from "@executor-js/react/pages/sources"; + +export const Route = createFileRoute("/$org/")({ + component: SourcesPage, +}); diff --git a/apps/cloud/src/routes/policies.tsx b/apps/cloud/src/routes/$org/policies.tsx similarity index 74% rename from apps/cloud/src/routes/policies.tsx rename to apps/cloud/src/routes/$org/policies.tsx index a9de9ff6f..c624f3eb8 100644 --- a/apps/cloud/src/routes/policies.tsx +++ b/apps/cloud/src/routes/$org/policies.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { PoliciesPage } from "@executor-js/react/pages/policies"; -export const Route = createFileRoute("/policies")({ +export const Route = createFileRoute("/$org/policies")({ component: () => , }); diff --git a/apps/cloud/src/routes/secrets.tsx b/apps/cloud/src/routes/$org/secrets.tsx similarity index 86% rename from apps/cloud/src/routes/secrets.tsx rename to apps/cloud/src/routes/$org/secrets.tsx index aff164652..1162352ba 100644 --- a/apps/cloud/src/routes/secrets.tsx +++ b/apps/cloud/src/routes/$org/secrets.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { SecretsPage } from "@executor-js/react/pages/secrets"; -export const Route = createFileRoute("/secrets")({ +export const Route = createFileRoute("/$org/secrets")({ component: () => ( { const { namespace } = Route.useParams(); return ; diff --git a/apps/cloud/src/routes/sources.add.$pluginKey.tsx b/apps/cloud/src/routes/$org/sources.add.$pluginKey.tsx similarity index 90% rename from apps/cloud/src/routes/sources.add.$pluginKey.tsx rename to apps/cloud/src/routes/$org/sources.add.$pluginKey.tsx index 9d5043201..db85fc2bf 100644 --- a/apps/cloud/src/routes/sources.add.$pluginKey.tsx +++ b/apps/cloud/src/routes/$org/sources.add.$pluginKey.tsx @@ -10,7 +10,7 @@ const SearchParams = Schema.toStandardSchemaV1( }), ); -export const Route = createFileRoute("/sources/add/$pluginKey")({ +export const Route = createFileRoute("/$org/sources/add/$pluginKey")({ validateSearch: SearchParams, component: () => { const { pluginKey } = Route.useParams(); diff --git a/apps/cloud/src/routes/tools.tsx b/apps/cloud/src/routes/$org/tools.tsx similarity index 73% rename from apps/cloud/src/routes/tools.tsx rename to apps/cloud/src/routes/$org/tools.tsx index 25929fd2b..bf5af6cd1 100644 --- a/apps/cloud/src/routes/tools.tsx +++ b/apps/cloud/src/routes/$org/tools.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { ToolsPage } from "@executor-js/react/pages/tools"; -export const Route = createFileRoute("/tools")({ +export const Route = createFileRoute("/$org/tools")({ component: ToolsPage, }); diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index 5374743dc..152b59912 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -1,18 +1,12 @@ import React from "react"; import * as Sentry from "@sentry/react"; -import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; -import { AutumnProvider } from "autumn-js/react"; +import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router"; import posthog from "posthog-js"; import { PostHogProvider } from "posthog-js/react"; -import { ExecutorProvider } from "@executor-js/react/api/provider"; -import { Skeleton } from "@executor-js/react/components/skeleton"; -import { Toaster } from "@executor-js/react/components/sonner"; -import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; -import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import { AuthProvider, useAuth } from "../web/auth"; import { LoginPage } from "../web/pages/login"; import { OnboardingPage } from "../web/pages/onboarding"; -import { Shell } from "../web/shell"; +import { ShellSkeleton } from "../web/shell"; import appCss from "@executor-js/react/globals.css?url"; if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_SENTRY_DSN) { @@ -84,67 +78,6 @@ function RootComponent() { ); } -function ShellSkeleton() { - return ( -
- {/* Desktop sidebar skeleton */} - - - {/* Main content skeleton */} -
- {/* Mobile top bar */} -
- - -
-
- -
-
-
- - -
- -
-
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
-
-
- ); -} - function AuthGate() { const auth = useAuth(); @@ -156,18 +89,9 @@ function AuthGate() { return ; } - if (auth.organization == null) { + if (auth.organizations.length === 0) { return ; } - return ( - - }> - - - - - - - ); + return ; } diff --git a/apps/cloud/src/routes/index.tsx b/apps/cloud/src/routes/index.tsx index 01273b87a..ad876beea 100644 --- a/apps/cloud/src/routes/index.tsx +++ b/apps/cloud/src/routes/index.tsx @@ -1,6 +1,26 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { SourcesPage } from "@executor-js/react/pages/sources"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import { useAuth } from "../web/auth"; export const Route = createFileRoute("/")({ - component: SourcesPage, + component: IndexRedirect, }); + +function IndexRedirect() { + const auth = useAuth(); + const navigate = useNavigate(); + const firstHandle = + auth.status === "authenticated" ? (auth.organizations[0]?.handle ?? null) : null; + + useEffect(() => { + if (!firstHandle) return; + void navigate({ to: "/$org", params: { org: firstHandle }, replace: true }); + }, [firstHandle, navigate]); + + return ( +
+ Loading… +
+ ); +} diff --git a/apps/cloud/src/services/__test-harness__/api-harness.ts b/apps/cloud/src/services/__test-harness__/api-harness.ts index e09a371ec..a730779a5 100644 --- a/apps/cloud/src/services/__test-harness__/api-harness.ts +++ b/apps/cloud/src/services/__test-harness__/api-harness.ts @@ -5,14 +5,16 @@ // two test-only swaps: // // - `OrgAuthLive` is replaced with `FakeOrgAuthLive`, which reads -// the scope id off `x-test-org-id` instead of the WorkOS cookie. +// the org handle from the URL `/api/:org/...` prefix instead of +// the WorkOS cookie. // - `workos-vault` is configured with an in-memory `WorkOSVaultClient` // so secret writes never reach WorkOS's real API. // // Tests get a `fetchForOrg(orgId)` they can hand to `FetchHttpClient` // and then call `HttpApiClient.make(ProtectedCloudApi)` against it. // Each test picks its own org id (usually a random UUID) so rows don't -// collide across tests. +// collide across tests. The harness seeds an organizations row whose +// `handle` equals the org id so `resolveOrgContext(orgId)` succeeds. import { Effect, Layer } from "effect"; import { HttpApiBuilder, HttpApiClient, HttpApiSwagger } from "effect/unstable/httpapi"; @@ -51,9 +53,13 @@ import { import { DbService } from "../db"; import { orgScopeId, userOrgScopeId } from "../ids"; import { buildGlobalScopeStack } from "../scope-stack"; +import { organizations } from "../schema"; export const TEST_BASE_URL = "http://test.local"; -export const TEST_ORG_HEADER = "x-test-org-id"; +/** + * Optional header for tests that need to act as a specific user. The org + * id always comes from the URL prefix; only the user is opt-in. + */ export const TEST_USER_HEADER = "x-test-user-id"; // `asOrg(orgId, …)` callers don't care which specific user they are, only @@ -95,16 +101,43 @@ const createTestScopedExecutor = ( }); }); +// Seed a test organization row whose handle equals the supplied id so the +// production middleware resolution path (`resolveOrgContext(handle)`) works +// against the test db. Uses `onConflictDoNothing` so repeated `asOrg(orgId, +// …)` calls within a test don't fight each other. Lives inside the request +// pipeline (so DbService is already provided) instead of at factory time +// — bringing up its own DbService.Live in a Node test process leaks a +// postgres.js socket that ECONNRESETs across test files. +const seedTestOrg = (orgId: string) => + Effect.gen(function* () { + const { db } = yield* DbService; + yield* Effect.promise(() => + db + .insert(organizations) + .values({ id: orgId, handle: orgId, name: `Org ${orgId}` }) + .onConflictDoNothing(), + ); + }); + // --------------------------------------------------------------------------- // HTTP plumbing // --------------------------------------------------------------------------- +// Pull the URL `:org` segment from a request path. The protected API mounts +// under `/api/:org/...`. Returning `null` for a malformed prefix forces the +// downstream handler to surface a typed error rather than panicking. +const orgHandleFromPath = (pathname: string): string | null => { + const parts = pathname.split("/").filter((part) => part.length > 0); + if (parts.length < 2 || parts[0] !== "api") return null; + return parts[1] ?? null; +}; + // Test version of the production `ExecutionStackMiddleware` — reads the -// `x-test-org-id` (and optional `x-test-user-id`) header, builds a -// test-scoped executor against the live postgres test db with a fake -// WorkOS vault, and provides `AuthContext` + the executor services to the -// handler. Mirrors prod's HttpRouter middleware but with test-mode -// constructors. +// org handle from the URL `/api/:org/...` prefix (matching production), +// builds a test-scoped executor against the live postgres test db with a +// fake WorkOS vault, and provides `AuthContext` + the executor services +// to the handler. The optional `x-test-user-id` header overrides the +// default per-org user. const TestExecutionStackMiddleware = HttpRouter.middleware<{ provides: | AuthContext @@ -121,10 +154,18 @@ const TestExecutionStackMiddleware = HttpRouter.middleware<{ return (httpEffect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; - const orgId = request.headers[TEST_ORG_HEADER]; - if (!orgId || typeof orgId !== "string") { - return yield* Effect.die(new Error("missing x-test-org-id")); + const webRequest = yield* HttpServerRequest.toWeb(request); + const url = new URL(webRequest.url); + const orgId = orgHandleFromPath(url.pathname); + if (!orgId) { + return yield* Effect.die( + new Error(`missing /api/:org prefix in ${url.pathname}`), + ); } + // Lazily seed the org row so production-mode `resolveOrgContext` (used + // anywhere that takes the URL handle as truth) finds it. The test + // harness can't pre-seed at factory time without leaking sockets. + yield* seedTestOrg(orgId); const userHeader = request.headers[TEST_USER_HEADER]; const userId = typeof userHeader === "string" && userHeader.length > 0 @@ -155,9 +196,20 @@ const TestExecutionStackMiddleware = HttpRouter.middleware<{ }), ).layer; +// Mirror the production setup — the protected API mounts under `/api/:org` +// via a prefixed router view. The outer `HttpRouter` from +// `HttpServer.layerServices` is the underlying state; the prefix wrapper +// rewrites added paths only. +const PrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)( + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => + router.prefixed("/api/:org"), + ), +); + const TestApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( Layer.provide(ProtectedCloudApiHandlers), Layer.provide(TestExecutionStackMiddleware), + Layer.provide(PrefixedRouterLayer), Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })), Layer.provideMerge(RouterConfig), Layer.provideMerge(DbService.Live), @@ -166,12 +218,38 @@ const TestApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( const handler = HttpRouter.toWebHandler(TestApiLive, { disableLogger: true }).handler; +// Rewrite outgoing request URLs to `/api/${orgId}${path}` so the prefixed +// router matches. Tests construct `HttpApiClient.make(...)` against +// `TEST_BASE_URL` and call endpoint methods that build paths like +// `/scopes/.../sources` — we splice the org segment in front before the +// request reaches the in-process handler. +const rewriteRequestForOrg = async ( + base: Request, + orgId: string, + extraHeaders: Record = {}, +): Promise => { + const url = new URL(base.url); + if (!url.pathname.startsWith(`/api/${orgId}/`) && url.pathname !== `/api/${orgId}`) { + url.pathname = `/api/${orgId}${url.pathname.startsWith("/") ? "" : "/"}${url.pathname}`; + } + // Buffer the body — Node's `RequestInit` rejects stream bodies without + // `duplex: "half"`, and forwarding a Request through `new Request(url, {...})` + // is fragile across runtimes. ArrayBuffer survives the round-trip cleanly. + const body = + base.method === "GET" || base.method === "HEAD" + ? undefined + : await base.arrayBuffer(); + return new Request(url.toString(), { + method: base.method, + headers: { ...Object.fromEntries(base.headers), ...extraHeaders }, + body, + }); +}; + export const fetchForOrg = (orgId: string): typeof globalThis.fetch => - ((input: RequestInfo | URL, init?: RequestInit) => { + (async (input: RequestInfo | URL, init?: RequestInit) => { const base = input instanceof Request ? input : new Request(input, init); - const req = new Request(base, { - headers: { ...Object.fromEntries(base.headers), [TEST_ORG_HEADER]: orgId }, - }); + const req = await rewriteRequestForOrg(base, orgId); return handler(req); }) as typeof globalThis.fetch; @@ -179,15 +257,9 @@ export const fetchForUser = ( userId: string, orgId: string, ): typeof globalThis.fetch => - ((input: RequestInfo | URL, init?: RequestInit) => { + (async (input: RequestInfo | URL, init?: RequestInit) => { const base = input instanceof Request ? input : new Request(input, init); - const req = new Request(base, { - headers: { - ...Object.fromEntries(base.headers), - [TEST_ORG_HEADER]: orgId, - [TEST_USER_HEADER]: userId, - }, - }); + const req = await rewriteRequestForOrg(base, orgId, { [TEST_USER_HEADER]: userId }); return handler(req); }) as typeof globalThis.fetch; diff --git a/apps/cloud/src/web/auth.tsx b/apps/cloud/src/web/auth.tsx index bca81cdb8..9394933c7 100644 --- a/apps/cloud/src/web/auth.tsx +++ b/apps/cloud/src/web/auth.tsx @@ -1,5 +1,4 @@ import React, { createContext, useContext, useEffect } from "react"; -import * as Atom from "effect/unstable/reactivity/Atom"; import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { usePostHog } from "posthog-js/react"; @@ -18,8 +17,9 @@ type AuthUser = { avatarUrl: string | null; }; -type AuthOrganization = { +export type AuthOrganization = { id: string; + handle: string; name: string; }; @@ -32,14 +32,6 @@ export const authAtom = CloudApiClient.query("cloudAuth", "me", { reactivityKeys: [ReactivityKey.auth], }); -export const organizationsAtom = Atom.refreshOnWindowFocus( - CloudApiClient.query("cloudAuth", "organizations", { - timeToLive: "1 minute", - reactivityKeys: [ReactivityKey.auth], - }), -); - -export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization"); export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization"); // --------------------------------------------------------------------------- @@ -49,7 +41,11 @@ export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOr type AuthState = | { status: "loading" } | { status: "unauthenticated" } - | { status: "authenticated"; user: AuthUser; organization: AuthOrganization | null }; + | { + status: "authenticated"; + user: AuthUser; + organizations: ReadonlyArray; + }; const AuthContext = createContext({ status: "loading" }); @@ -64,7 +60,7 @@ const AuthProviderClient = ({ children }: { children: React.ReactNode }) => { onSuccess: ({ value }) => ({ status: "authenticated" as const, user: value.user, - organization: value.organization, + organizations: value.organizations, }), onFailure: () => ({ status: "unauthenticated" as const }), }); @@ -72,21 +68,26 @@ const AuthProviderClient = ({ children }: { children: React.ReactNode }) => { const userId = state.status === "authenticated" ? state.user.id : null; const email = state.status === "authenticated" ? state.user.email : null; const name = state.status === "authenticated" ? state.user.name : null; - const orgId = state.status === "authenticated" ? (state.organization?.id ?? null) : null; - const orgName = state.status === "authenticated" ? (state.organization?.name ?? null) : null; + // PostHog org grouping uses the first membership; the user can navigate + // between orgs in-session. If we want richer grouping later we can + // re-emit on URL change. + const firstOrgId = + state.status === "authenticated" ? (state.organizations[0]?.id ?? null) : null; + const firstOrgName = + state.status === "authenticated" ? (state.organizations[0]?.name ?? null) : null; const isUnauthenticated = state.status === "unauthenticated"; useEffect(() => { if (!posthog) return; if (userId) { posthog.identify(userId, { email, name }); - if (orgId) { - posthog.group("organization", orgId, { name: orgName }); + if (firstOrgId) { + posthog.group("organization", firstOrgId, { name: firstOrgName }); } } else if (isUnauthenticated) { posthog.reset(); } - }, [posthog, userId, email, name, orgId, orgName, isUnauthenticated]); + }, [posthog, userId, email, name, firstOrgId, firstOrgName, isUnauthenticated]); return {children}; }; @@ -97,3 +98,12 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { } return {children}; }; + +/** Find the organization in the auth state matching a given URL handle. */ +export const findOrgByHandle = ( + state: AuthState, + handle: string, +): AuthOrganization | null => { + if (state.status !== "authenticated") return null; + return state.organizations.find((o) => o.handle === handle) ?? null; +}; diff --git a/apps/cloud/src/web/components/create-organization-form.tsx b/apps/cloud/src/web/components/create-organization-form.tsx index dc0684aae..cc9774bf8 100644 --- a/apps/cloud/src/web/components/create-organization-form.tsx +++ b/apps/cloud/src/web/components/create-organization-form.tsx @@ -7,7 +7,7 @@ import { Label } from "@executor-js/react/components/label"; import { createOrganization } from "../auth"; -type CreatedOrganization = { id: string; name: string }; +type CreatedOrganization = { id: string; handle: string; name: string }; export function useCreateOrganizationForm(options: { defaultName?: string; diff --git a/apps/cloud/src/web/org-route.tsx b/apps/cloud/src/web/org-route.tsx new file mode 100644 index 000000000..83afbcef6 --- /dev/null +++ b/apps/cloud/src/web/org-route.tsx @@ -0,0 +1,36 @@ +import React, { createContext, useContext } from "react"; + +// --------------------------------------------------------------------------- +// OrgRouteContext — provided by the `/$org` layout, consumed by descendants +// that need to know the URL-active organization. The handle drives all link +// generation; the id flows to API calls when one slips outside the URL prefix. +// --------------------------------------------------------------------------- + +export type OrgRouteValue = { + readonly orgId: string; + readonly orgName: string; + readonly orgHandle: string; +}; + +export const OrgRouteContext = createContext(null); + +export const OrgRouteProvider = (props: { + value: OrgRouteValue; + children: React.ReactNode; +}) => ( + + {props.children} + +); + +export const useOrgRoute = (): OrgRouteValue => { + const value = useContext(OrgRouteContext); + if (!value) { + throw new Error("useOrgRoute must be used within an OrgRouteProvider"); + } + return value; +}; + +/** Optional variant for code rendered both inside and outside the org layout. */ +export const useOrgRouteOptional = (): OrgRouteValue | null => + useContext(OrgRouteContext); diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index a8f4825d9..cf8105686 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -1,12 +1,10 @@ import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; -import { useAtomValue, useAtomSet } from "@effect/atom-react"; -import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import * as Exit from "effect/Exit"; import { useSourcesWithPending } from "@executor-js/react/api/optimistic"; import { useScope } from "@executor-js/react/api/scope-context"; import { Button } from "@executor-js/react/components/button"; import { Skeleton } from "@executor-js/react/components/skeleton"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { Dialog, DialogClose, @@ -29,20 +27,94 @@ import { } from "@executor-js/react/components/dropdown-menu"; import { SourceFavicon } from "@executor-js/react/components/source-favicon"; import { CommandPalette } from "@executor-js/react/components/command-palette"; -import { authWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { AUTH_PATHS } from "../auth/api"; -import { organizationsAtom, switchOrganization, useAuth } from "./auth"; +import { useAuth } from "./auth"; +import { useOrgRoute } from "./org-route"; import { CreateOrganizationFields, useCreateOrganizationForm, } from "./components/create-organization-form"; +// ── ShellSkeleton ──────────────────────────────────────────────────────── + +export function ShellSkeleton() { + return ( +
+ {/* Desktop sidebar skeleton */} + + + {/* Main content skeleton */} +
+ {/* Mobile top bar */} +
+ + +
+
+ +
+
+
+ + +
+ +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+
+
+ ); +} + // ── NavItem ────────────────────────────────────────────────────────────── -function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) { +function NavItem(props: { + to: string; + params: Record; + label: string; + active: boolean; + onNavigate?: () => void; +}) { return ( void }) { + const { orgHandle } = useOrgRoute(); const scopeId = useScope(); const sources = useSourcesWithPending(scopeId); @@ -84,14 +157,14 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { ) : (
{value.map((s) => { - const detailPath = `/sources/${s.id}`; + const detailPath = `/${orgHandle}/sources/${s.id}`; const active = props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); return ( { - if (organizationId === props.activeOrganizationId) return; - const exit = await doSwitchOrganization({ - payload: { organizationId }, - reactivityKeys: authWriteKeys, - }); - if (Exit.isSuccess(exit)) window.location.reload(); - }; + const auth = useAuth(); - return AsyncResult.match(organizations, { - onInitial: () => Loading…, - onFailure: () => Failed to load organizations, - onSuccess: ({ value }) => - value.organizations.length === 0 ? ( - No organizations - ) : ( - <> - {value.organizations.map((organization: { id: string; name: string }) => { - const isActive = organization.id === props.activeOrganizationId; - return ( - handleSwitch(organization.id)} - className="text-xs" - > - {organization.name} - {isActive && } - - ); - })} - - ), - }); + if (auth.status !== "authenticated") { + return Loading…; + } + if (auth.organizations.length === 0) { + return No organizations; + } + return ( + <> + {auth.organizations.map((organization) => { + const isActive = organization.id === props.activeOrganizationId; + return ( + + + {organization.name} + {isActive && } + + + ); + })} + + ); } function CheckIcon() { @@ -203,6 +266,7 @@ function CheckIcon() { function UserFooter() { const auth = useAuth(); + const orgRoute = useOrgRoute(); const [createOrganizationOpen, setCreateOrganizationOpen] = useState(false); const suggestedOrganizationName = @@ -212,7 +276,15 @@ function UserFooter() { const form = useCreateOrganizationForm({ defaultName: suggestedOrganizationName, - onSuccess: () => window.location.reload(), + // The form returns the new org's handle on success — navigate via the URL + // by reloading at the new handle. Once we wire useNavigate in here we can + // do a soft navigation instead. + onSuccess: (org) => { + // Navigate to the new org's URL — the URL is the source of truth for + // active org now, so a hard reload at the new handle re-renders the + // shell with the right context. + window.location.href = `/${org.handle}`; + }, }); if (auth.status !== "authenticated") return null; @@ -243,9 +315,7 @@ function UserFooter() {

{auth.user.name ?? auth.user.email}

- {auth.organization && ( -

{auth.organization.name}

- )} +

{orgRoute.orgName}

- - {auth.organization?.name ?? "No organization"} - + {orgRoute.orgName} - + void; showBrand?: boolean }) { - const isHome = props.pathname === "/"; - const isSecrets = props.pathname === "/secrets"; - const isConnections = props.pathname === "/connections"; - const isPolicies = props.pathname === "/policies"; - const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/"); - const isOrg = props.pathname === "/org"; + const { orgHandle } = useOrgRoute(); + const orgPrefix = `/${orgHandle}`; + const params = { org: orgHandle }; + const isHome = + props.pathname === orgPrefix || props.pathname === `${orgPrefix}/`; + const isSecrets = props.pathname === `${orgPrefix}/secrets`; + const isConnections = props.pathname === `${orgPrefix}/connections`; + const isPolicies = props.pathname === `${orgPrefix}/policies`; + const isBilling = + props.pathname === `${orgPrefix}/-/billing` || + props.pathname.startsWith(`${orgPrefix}/-/billing/`); + const isOrg = props.pathname === `${orgPrefix}/-/settings`; return ( <> {props.showBrand !== false && (
- + executor
)}