diff --git a/apps/cloud/src/api.request-scope.node.test.ts b/apps/cloud/src/api.request-scope.node.test.ts index d87a8e336..1cb26364a 100644 --- a/apps/cloud/src/api.request-scope.node.test.ts +++ b/apps/cloud/src/api.request-scope.node.test.ts @@ -183,11 +183,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 f7f5fb44a..7b00063bb 100644 --- a/apps/cloud/src/api/protected.ts +++ b/apps/cloud/src/api/protected.ts @@ -21,11 +21,22 @@ import { WorkOSAuth } from "../auth/workos"; import { AutumnService } from "../services/autumn"; import { DbService } from "../services/db"; import { makeExecutionStack } from "../services/execution-stack"; +import { makeUserStore } from "../services/user-store"; import { HttpResponseError } from "./error-response"; import { RequestScopedServicesLive } from "./layers"; import { ProtectedCloudApi, ProtectedCloudApiLive, RouterConfig } 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. @@ -72,19 +83,42 @@ 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 { db } = yield* DbService; + const resolved = yield* Effect.promise(() => + makeUserStore(db).getOrganizationByHandle(handle), + ); + if (!resolved) { + return yield* new HttpResponseError({ + status: 404, + code: "no_organization", + message: `Organization "${handle}" not found`, + }); + } + const org = yield* authorizeOrganization(session.userId, resolved.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({ @@ -105,6 +139,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" @@ -118,6 +162,7 @@ export const makeProtectedApiLive = (rsLive: Layer.Layer 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: { @@ -192,7 +219,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( name: session.name, avatarUrl: session.avatarUrl, }, - organization: org ? { id: org.id, name: org.name } : null, + organizations, }; }), ) @@ -203,39 +230,35 @@ 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 }) => @@ -247,34 +270,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* 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 }; }), ) .handle("pendingInvitations", () => @@ -330,7 +334,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( Effect.gen(function* () { const workos = yield* WorkOSAuth; const users = yield* UserStoreService; - const session = yield* SessionContext; const invitation = yield* workos.acceptInvitation(payload.invitationId); @@ -343,30 +346,14 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( return yield* new WorkOSError(); } - // Mirror the org locally so domain tables can FK against it. + // Mirror the org locally so domain tables can FK against it and + // the URL handle exists by the time the client navigates. const org = yield* workos.getOrganization(invitation.organizationId); yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name })); - // Attach the just-accepted org to the current session. Same shape - // as createOrganization: refresh + verify; if we can't pin the - // session in-place, clear the cookie and let the user bounce - // through login again. The acceptance has already succeeded - // server-side, so the next login will pick up the 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("acceptInvitation: unable to attach org to current session", { - userId: session.accountId, - orgId: org.id, - refreshReturnedSession: refreshed != null, - verifiedOrgId: verified?.organizationId ?? null, - }); - deleteCookie("wos-session", { path: "/" }); - return yield* new WorkOSError(); - } - - setCookie("wos-session", refreshed, COOKIE_OPTIONS); + // No session refresh — the URL is the source of truth for active + // org. The client refreshes the auth atom and navigates to the + // new org's `/$handle` after this resolves. return { id: org.id, 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..e7c12c944 --- /dev/null +++ b/apps/cloud/src/routes/$org.tsx @@ -0,0 +1,64 @@ +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 { CloudNavTargets } from "../web/nav-targets"; +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 ac4731671..4c05f1916 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, }); @@ -15,6 +16,7 @@ const PLAN_TAGLINES: Record = { }; function BillingPage() { + const { orgHandle } = useOrgRoute(); const { data: customer, openCustomerPortal, isLoading: customerLoading } = useCustomer(); const { data: plans, isLoading: plansLoading } = useListPlans(); @@ -99,7 +101,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 e77f3448b..283481758 100644 --- a/apps/cloud/src/routes/billing_.plans.tsx +++ b/apps/cloud/src/routes/$org/[-].billing_.plans.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { createFileRoute, Link } from "@tanstack/react-router"; -import { useAuth } from "../web/auth"; +import { useAuth } from "../../web/auth"; import { useCustomer, useListPlans } from "autumn-js/react"; import { Button } from "@executor-js/react/components/button"; import { Badge } from "@executor-js/react/components/badge"; @@ -16,10 +16,11 @@ import { } from "@executor-js/react/components/dialog"; import { Input } from "@executor-js/react/components/input"; import { Label } from "@executor-js/react/components/label"; +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, }); @@ -77,6 +78,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); @@ -92,7 +94,8 @@ function PlansPage() {
@@ -307,9 +310,9 @@ function PlansPage() { function SlackContactCta() { const auth = useAuth(); + const { orgName } = useOrgRoute(); const signedIn = auth.status === "authenticated" ? auth : null; const prefillEmail = signedIn?.user.email ?? ""; - const orgName = signedIn?.organization?.name ?? ""; const [open, setOpen] = useState(false); const [email, setEmail] = useState(prefillEmail); 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 e66bd45fd..197c68433 100644 --- a/apps/cloud/src/routes/org.tsx +++ b/apps/cloud/src/routes/$org/[-].settings.tsx @@ -1,6 +1,6 @@ import { useReducer, useState } from "react"; import { Cause, Exit, Result } from "effect"; -import { Forbidden } from "../org/api"; +import { Forbidden } from "../../org/api"; import { createFileRoute, Link } from "@tanstack/react-router"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; @@ -52,10 +52,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, }); @@ -110,9 +110,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); @@ -262,7 +261,7 @@ function OrgPage() {

Join by domain is available on the Team plan.

- + @@ -322,7 +321,7 @@ function OrgPage() { Invite member ) : ( - + @@ -608,6 +607,7 @@ function DomainCard({ domain: d, onDelete }: { domain: DomainData; onDelete: () } function InviteErrorAlert({ cause }: { cause: Cause.Cause }) { + const { orgHandle } = useOrgRoute(); const failure = Cause.findError(cause); const error = Result.isSuccess(failure) ? failure.success : null; @@ -617,7 +617,7 @@ function InviteErrorAlert({ cause }: { cause: Cause.Cause }) {

You've reached your member limit. Upgrade to Team to invite more.

- + 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 89% rename from apps/cloud/src/routes/sources.add.$pluginKey.tsx rename to apps/cloud/src/routes/$org/sources.add.$pluginKey.tsx index 48d58b32d..ea0656c9b 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 37aa03ec6..3e6490b65 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) { @@ -90,67 +84,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(); @@ -162,18 +95,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 17ae583e3..705235b0c 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"; @@ -38,9 +40,13 @@ import { RouterConfig, } from "../../api/protected-layers"; import { DbService } from "../db"; +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"; // Mirrors apps/cloud/src/services/executor.ts#createScopedExecutor — the @@ -89,16 +95,43 @@ const createTestScopedExecutor = (userId: string, orgId: string, orgName: string }); }); +// 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 @@ -115,11 +148,20 @@ 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") { - // 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 - 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) { + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: test HTTP harness has no request context without /api/:org prefix + return yield* Effect.die( + // oxlint-disable-next-line executor/no-error-constructor -- boundary: test HTTP harness invariant on missing prefix + 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 @@ -150,9 +192,18 @@ 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), @@ -161,25 +212,43 @@ 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; 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 457fee83c..765c8b10e 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"); export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", { @@ -56,7 +48,11 @@ export const acceptInvitation = CloudApiClient.mutation("cloudAuth", "acceptInvi type AuthState = | { status: "loading" } | { status: "unauthenticated" } - | { status: "authenticated"; user: AuthUser; organization: AuthOrganization | null }; + | { + status: "authenticated"; + user: AuthUser; + organizations: ReadonlyArray; + }; const AuthContext = createContext({ status: "loading" }); @@ -71,7 +67,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 }), }); @@ -79,21 +75,25 @@ 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}; }; @@ -104,3 +104,9 @@ 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/nav-targets.tsx b/apps/cloud/src/web/nav-targets.tsx new file mode 100644 index 000000000..0585270ff --- /dev/null +++ b/apps/cloud/src/web/nav-targets.tsx @@ -0,0 +1,49 @@ +// --------------------------------------------------------------------------- +// Cloud app NavTargets — mounts shared `@executor-js/react` components against +// the cloud route tree, where source/policy routes live under `/$org/...`. +// Reads the URL-active org handle from `OrgRouteContext` so every link/nav +// target carries the right `:org` param. +// --------------------------------------------------------------------------- + +import { Link, useNavigate } from "@tanstack/react-router"; +import { useMemo } from "react"; + +import { NavTargetsProvider, type NavTargets } from "@executor-js/react/api/nav-targets"; + +import { useOrgRoute } from "./org-route"; + +export const CloudNavTargets = ({ children }: { children: React.ReactNode }) => { + const navigate = useNavigate(); + const { orgHandle } = useOrgRoute(); + + const value = useMemo( + () => ({ + SourceLink: ({ namespace, ...rest }) => ( + + ), + AddSourceLink: ({ pluginKey, search, ...rest }) => ( + + ), + PoliciesLink: (props) => , + goToSource: (namespace) => + void navigate({ + to: "/$org/sources/$namespace", + params: { org: orgHandle, namespace }, + }), + goToAddSource: (pluginKey, search) => + void navigate({ + to: "/$org/sources/add/$pluginKey", + params: { org: orgHandle, pluginKey }, + search, + }), + }), + [navigate, orgHandle], + ); + + return {children}; +}; diff --git a/apps/cloud/src/web/org-route.tsx b/apps/cloud/src/web/org-route.tsx new file mode 100644 index 000000000..9ec2384e8 --- /dev/null +++ b/apps/cloud/src/web/org-route.tsx @@ -0,0 +1,31 @@ +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) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: React hook invariant + 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 fe2c17e35..56f716122 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -1,8 +1,7 @@ import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; -import { useAtomValue, useAtomSet } from "@effect/atom-react"; +import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import * as Exit from "effect/Exit"; import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; import { useScope } from "@executor-js/react/api/scope-context"; import { Button } from "@executor-js/react/components/button"; @@ -29,36 +28,95 @@ 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"; -// ── NavItem ────────────────────────────────────────────────────────────── +// ── ShellSkeleton ──────────────────────────────────────────────────────── -function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) { +export function ShellSkeleton() { return ( - - {props.label} - +
+ {/* Desktop sidebar skeleton */} + + + {/* Main content skeleton */} +
+ {/* Mobile top bar */} +
+ + +
+
+ +
+
+
+ + +
+ +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+
+
); } +// ── NavItem styling ────────────────────────────────────────────────────── +// +// Sidebar links share active/inactive styling but each call site uses a +// statically-known route template — keep the className helper here and let +// the call site own the typed ``. + +const navItemClassName = (active: boolean) => + [ + "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors", + active + ? "bg-sidebar-active text-foreground font-medium" + : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", + ].join(" "); + // ── SourceList ─────────────────────────────────────────────────────────── function SourceList(props: { pathname: string; onNavigate?: () => void }) { + const { orgHandle } = useOrgRoute(); const scopeId = useScope(); const sources = useAtomValue(sourcesOptimisticAtom(scopeId)); @@ -84,14 +142,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 +251,7 @@ function CheckIcon() { function UserFooter() { const auth = useAuth(); + const orgRoute = useOrgRoute(); const [createOrganizationOpen, setCreateOrganizationOpen] = useState(false); const suggestedOrganizationName = @@ -212,7 +261,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 +300,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
)}