diff --git a/apps/cloud/src/api/layers.ts b/apps/cloud/src/api/layers.ts index ca210aa13..c53645061 100644 --- a/apps/cloud/src/api/layers.ts +++ b/apps/cloud/src/api/layers.ts @@ -1,6 +1,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { HttpServer } from "effect/unstable/http"; -import { Layer } from "effect"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; +import { Effect, Layer } from "effect"; import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live"; import { UserStoreService } from "../auth/context"; @@ -63,6 +63,17 @@ export const makeNonProtectedApiLive = ( // Routes scoped to a specific org (membership management, switching, etc.). // Auth is enforced by `OrgAuth` middleware declared on `OrgHttpApi`. +// +// OrgHttpApi mounts under `/api/:org/...` so workspace endpoints are +// addressable per-org (`POST /api/:org/workspaces`, +// `GET /api/:org/workspaces/:slug`). `start.ts` strips the leading `/api` +// before forwarding, so the prefix here is `/:org` (not `/api/:org`). +const OrgPrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)( + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => + router.prefixed("/:org"), + ), +); + export const makeOrgApiLive = ( rsLive: Layer.Layer, ) => @@ -70,6 +81,7 @@ export const makeOrgApiLive = ( Layer.provide(Layer.mergeAll(OrgHandlers, WorkspacesHandlers)), Layer.provide(requestScopedMiddleware(rsLive).layer), Layer.provideMerge(OrgAuthLive), + Layer.provide(OrgPrefixedRouterLayer), ); // Default exports use the production per-request layer. Existing callers diff --git a/apps/cloud/src/api/protected.ts b/apps/cloud/src/api/protected.ts index 2cb02df8a..c582aed6e 100644 --- a/apps/cloud/src/api/protected.ts +++ b/apps/cloud/src/api/protected.ts @@ -24,7 +24,10 @@ 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 { + resolveOrgContext, + resolveWorkspaceContext, +} from "../services/url-context"; import { HttpResponseError } from "./error-response"; import { RequestScopedServicesLive } from "./layers"; import { @@ -34,16 +37,6 @@ 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. @@ -53,24 +46,19 @@ const provideExecutorExtensions = providePluginExtensions(cloudPlugins); // 1. authenticates the WorkOS sealed session, // 2. verifies live org membership (closes the JWT-cache gap — see // `auth/authorize-organization.ts`), -// 3. resolves the org name, +// 3. resolves the org name (and optionally workspace from the URL), // 4. builds the per-request executor + engine, // 5. provides `AuthContext` + the execution-stack services to the handler. // -// Replaces both the old outer `Effect.gen` in this file (which did its own -// WorkOS lookup) and the per-route `OrgAuth` HttpApiMiddleware (which did -// a second one). -// // Errors are NOT caught here: failures propagate as typed errors and are // rendered to a JSON response by the framework's `Respondable` pipeline -// (see `HttpResponseError` in `./error-response.ts`). Letting `unhandled` -// pass through is what satisfies `HttpRouter.middleware`'s brand check -// without any type casts. +// (see `HttpResponseError` in `./error-response.ts`). // -// `DbService` and `UserStoreService` are pulled from per-request context -// — `RequestScopedServicesMiddleware` (combined below) provides them -// fresh per request so the postgres.js socket lives in the request -// fiber's scope, not the worker's boot scope. +// Workspace requests (`/api/:org/:workspace/...`) follow the same auth +// path — workspaces don't have separate ACLs in v1, so org membership is +// the only check. The middleware reads `:org` and `:workspace` off +// `RouteContext.params` and picks the correct executor factory +// (`createWorkspaceExecutor` vs `createGlobalExecutor`). const ExecutionStackMiddleware = HttpRouter.middleware<{ // The plugin extension Services this middleware satisfies are derived // from `typeof cloudPlugins` — no per-plugin `*ExtensionService` @@ -86,6 +74,9 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{ const longLived = yield* Effect.context(); return (httpEffect) => Effect.gen(function* () { + const params = yield* HttpRouter.params; + const handle = params["org"]; + const workspaceSlug = params["workspace"] ?? null; const request = yield* HttpServerRequest.HttpServerRequest; const webRequest = yield* HttpServerRequest.toWeb(request); const workos = yield* WorkOSAuth; @@ -97,11 +88,6 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{ 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, @@ -109,6 +95,51 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{ message: "Missing organization in URL", }); } + if (workspaceSlug) { + const resolved = yield* resolveWorkspaceContext(handle, workspaceSlug).pipe( + Effect.catchTag("OrganizationHandleNotFound", () => + Effect.succeed(null), + ), + Effect.catchTag("WorkspaceSlugNotFound", () => + Effect.succeed(null), + ), + ); + if (!resolved) { + return yield* new HttpResponseError({ + status: 404, + code: "no_organization", + message: `Context "${handle}/${workspaceSlug}" not found`, + }); + } + const org = yield* authorizeOrganization(session.userId, resolved.organization.id); + if (!org) { + return yield* new HttpResponseError({ + status: 403, + code: "no_organization", + message: "Not a member of this organization", + }); + } + const auth = AuthContext.of({ + accountId: session.userId, + organizationId: org.id, + email: session.email, + name: `${session.firstName ?? ""} ${session.lastName ?? ""}`.trim() || null, + avatarUrl: session.avatarUrl ?? null, + }); + const { executor, engine } = yield* makeExecutionStack({ + userId: auth.accountId, + organizationId: org.id, + organizationName: org.name, + workspaceId: resolved.workspace.id, + workspaceName: resolved.workspace.name, + }); + return yield* httpEffect.pipe( + Effect.provideService(AuthContext, auth), + Effect.provideService(ExecutorService, executor), + Effect.provideService(ExecutionEngineService, engine), + provideExecutorExtensions(executor), + ); + } const resolved = yield* resolveOrgContext(handle).pipe( Effect.catchTag("OrganizationHandleNotFound", () => Effect.succeed(null)), ); @@ -149,14 +180,23 @@ 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")), +// Layers that swap the boot router with prefixed views. Two prefixes serve +// the SAME endpoints — the request URL determines which scope stack the +// middleware builds. `HttpRouter.prefixed` returns a wrapper that delegates +// to the underlying router state, so non-protected routes (auth, autumn, +// swagger) keep their unprefixed paths. +// +// `start.ts` strips the leading `/api` before handing off to the API handler, +// so prefixes inside this router omit it. Public URLs are +// `/api/:org/...` and `/api/:org/:workspace/...` end-to-end. +const OrgPrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)( + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed("/:org")), +); + +const WorkspacePrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)( + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => + router.prefixed("/:org/:workspace"), + ), ); // `rsLive` is the per-request DB layer. Combining it into the auth @@ -172,9 +212,15 @@ export const makeProtectedApiLive = ( const protectedMiddleware = ExecutionStackMiddleware.combine( requestScopedMiddleware(rsLive), ).layer; - return ProtectedCloudApiLive.pipe( + const orgMount = ProtectedCloudApiLive.pipe( Layer.provide(protectedMiddleware), - Layer.provide(PrefixedRouterLayer), + Layer.provide(OrgPrefixedRouterLayer), + ); + const workspaceMount = ProtectedCloudApiLive.pipe( + Layer.provide(protectedMiddleware), + Layer.provide(WorkspacePrefixedRouterLayer), + ); + return Layer.mergeAll(orgMount, workspaceMount).pipe( Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })), Layer.provideMerge(RouterConfig), ); diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index 99e741e26..2d03b6054 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -16,11 +16,19 @@ 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 OrgWorkspaceRouteImport } from './routes/$org/$workspace' +import { Route as OrgWorkspaceIndexRouteImport } from './routes/$org/$workspace/index' 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 OrgWorkspaceToolsRouteImport } from './routes/$org/$workspace/tools' +import { Route as OrgWorkspaceSecretsRouteImport } from './routes/$org/$workspace/secrets' +import { Route as OrgWorkspacePoliciesRouteImport } from './routes/$org/$workspace/policies' +import { Route as OrgWorkspaceConnectionsRouteImport } from './routes/$org/$workspace/connections' import { Route as OrgSourcesAddPluginKeyRouteImport } from './routes/$org/sources.add.$pluginKey' import { Route as OrgChar91Char93BillingPlansRouteImport } from './routes/$org/[-].billing_.plans' +import { Route as OrgWorkspaceSourcesNamespaceRouteImport } from './routes/$org/$workspace/sources.$namespace' +import { Route as OrgWorkspaceSourcesAddPluginKeyRouteImport } from './routes/$org/$workspace/sources.add.$pluginKey' const OrgRoute = OrgRouteImport.update({ id: '/$org', @@ -57,6 +65,16 @@ const OrgConnectionsRoute = OrgConnectionsRouteImport.update({ path: '/connections', getParentRoute: () => OrgRoute, } as any) +const OrgWorkspaceRoute = OrgWorkspaceRouteImport.update({ + id: '/$workspace', + path: '/$workspace', + getParentRoute: () => OrgRoute, +} as any) +const OrgWorkspaceIndexRoute = OrgWorkspaceIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => OrgWorkspaceRoute, +} as any) const OrgSourcesNamespaceRoute = OrgSourcesNamespaceRouteImport.update({ id: '/sources/$namespace', path: '/sources/$namespace', @@ -72,6 +90,26 @@ const OrgChar91Char93BillingRoute = OrgChar91Char93BillingRouteImport.update({ path: '/-/billing', getParentRoute: () => OrgRoute, } as any) +const OrgWorkspaceToolsRoute = OrgWorkspaceToolsRouteImport.update({ + id: '/tools', + path: '/tools', + getParentRoute: () => OrgWorkspaceRoute, +} as any) +const OrgWorkspaceSecretsRoute = OrgWorkspaceSecretsRouteImport.update({ + id: '/secrets', + path: '/secrets', + getParentRoute: () => OrgWorkspaceRoute, +} as any) +const OrgWorkspacePoliciesRoute = OrgWorkspacePoliciesRouteImport.update({ + id: '/policies', + path: '/policies', + getParentRoute: () => OrgWorkspaceRoute, +} as any) +const OrgWorkspaceConnectionsRoute = OrgWorkspaceConnectionsRouteImport.update({ + id: '/connections', + path: '/connections', + getParentRoute: () => OrgWorkspaceRoute, +} as any) const OrgSourcesAddPluginKeyRoute = OrgSourcesAddPluginKeyRouteImport.update({ id: '/sources/add/$pluginKey', path: '/sources/add/$pluginKey', @@ -83,20 +121,40 @@ const OrgChar91Char93BillingPlansRoute = path: '/-/billing/plans', getParentRoute: () => OrgRoute, } as any) +const OrgWorkspaceSourcesNamespaceRoute = + OrgWorkspaceSourcesNamespaceRouteImport.update({ + id: '/sources/$namespace', + path: '/sources/$namespace', + getParentRoute: () => OrgWorkspaceRoute, + } as any) +const OrgWorkspaceSourcesAddPluginKeyRoute = + OrgWorkspaceSourcesAddPluginKeyRouteImport.update({ + id: '/sources/add/$pluginKey', + path: '/sources/add/$pluginKey', + getParentRoute: () => OrgWorkspaceRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/$org': typeof OrgRouteWithChildren + '/$org/$workspace': typeof OrgWorkspaceRouteWithChildren '/$org/connections': typeof OrgConnectionsRoute '/$org/policies': typeof OrgPoliciesRoute '/$org/secrets': typeof OrgSecretsRoute '/$org/tools': typeof OrgToolsRoute '/$org/': typeof OrgIndexRoute + '/$org/$workspace/connections': typeof OrgWorkspaceConnectionsRoute + '/$org/$workspace/policies': typeof OrgWorkspacePoliciesRoute + '/$org/$workspace/secrets': typeof OrgWorkspaceSecretsRoute + '/$org/$workspace/tools': typeof OrgWorkspaceToolsRoute '/$org/-/billing': typeof OrgChar91Char93BillingRoute '/$org/-/settings': typeof OrgChar91Char93SettingsRoute '/$org/sources/$namespace': typeof OrgSourcesNamespaceRoute + '/$org/$workspace/': typeof OrgWorkspaceIndexRoute + '/$org/$workspace/sources/$namespace': typeof OrgWorkspaceSourcesNamespaceRoute '/$org/-/billing/plans': typeof OrgChar91Char93BillingPlansRoute '/$org/sources/add/$pluginKey': typeof OrgSourcesAddPluginKeyRoute + '/$org/$workspace/sources/add/$pluginKey': typeof OrgWorkspaceSourcesAddPluginKeyRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -105,42 +163,65 @@ export interface FileRoutesByTo { '/$org/secrets': typeof OrgSecretsRoute '/$org/tools': typeof OrgToolsRoute '/$org': typeof OrgIndexRoute + '/$org/$workspace/connections': typeof OrgWorkspaceConnectionsRoute + '/$org/$workspace/policies': typeof OrgWorkspacePoliciesRoute + '/$org/$workspace/secrets': typeof OrgWorkspaceSecretsRoute + '/$org/$workspace/tools': typeof OrgWorkspaceToolsRoute '/$org/-/billing': typeof OrgChar91Char93BillingRoute '/$org/-/settings': typeof OrgChar91Char93SettingsRoute '/$org/sources/$namespace': typeof OrgSourcesNamespaceRoute + '/$org/$workspace': typeof OrgWorkspaceIndexRoute + '/$org/$workspace/sources/$namespace': typeof OrgWorkspaceSourcesNamespaceRoute '/$org/-/billing/plans': typeof OrgChar91Char93BillingPlansRoute '/$org/sources/add/$pluginKey': typeof OrgSourcesAddPluginKeyRoute + '/$org/$workspace/sources/add/$pluginKey': typeof OrgWorkspaceSourcesAddPluginKeyRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/$org': typeof OrgRouteWithChildren + '/$org/$workspace': typeof OrgWorkspaceRouteWithChildren '/$org/connections': typeof OrgConnectionsRoute '/$org/policies': typeof OrgPoliciesRoute '/$org/secrets': typeof OrgSecretsRoute '/$org/tools': typeof OrgToolsRoute '/$org/': typeof OrgIndexRoute + '/$org/$workspace/connections': typeof OrgWorkspaceConnectionsRoute + '/$org/$workspace/policies': typeof OrgWorkspacePoliciesRoute + '/$org/$workspace/secrets': typeof OrgWorkspaceSecretsRoute + '/$org/$workspace/tools': typeof OrgWorkspaceToolsRoute '/$org/-/billing': typeof OrgChar91Char93BillingRoute '/$org/-/settings': typeof OrgChar91Char93SettingsRoute '/$org/sources/$namespace': typeof OrgSourcesNamespaceRoute + '/$org/$workspace/': typeof OrgWorkspaceIndexRoute + '/$org/$workspace/sources/$namespace': typeof OrgWorkspaceSourcesNamespaceRoute '/$org/-/billing_/plans': typeof OrgChar91Char93BillingPlansRoute '/$org/sources/add/$pluginKey': typeof OrgSourcesAddPluginKeyRoute + '/$org/$workspace/sources/add/$pluginKey': typeof OrgWorkspaceSourcesAddPluginKeyRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' | '/$org' + | '/$org/$workspace' | '/$org/connections' | '/$org/policies' | '/$org/secrets' | '/$org/tools' | '/$org/' + | '/$org/$workspace/connections' + | '/$org/$workspace/policies' + | '/$org/$workspace/secrets' + | '/$org/$workspace/tools' | '/$org/-/billing' | '/$org/-/settings' | '/$org/sources/$namespace' + | '/$org/$workspace/' + | '/$org/$workspace/sources/$namespace' | '/$org/-/billing/plans' | '/$org/sources/add/$pluginKey' + | '/$org/$workspace/sources/add/$pluginKey' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -149,25 +230,40 @@ export interface FileRouteTypes { | '/$org/secrets' | '/$org/tools' | '/$org' + | '/$org/$workspace/connections' + | '/$org/$workspace/policies' + | '/$org/$workspace/secrets' + | '/$org/$workspace/tools' | '/$org/-/billing' | '/$org/-/settings' | '/$org/sources/$namespace' + | '/$org/$workspace' + | '/$org/$workspace/sources/$namespace' | '/$org/-/billing/plans' | '/$org/sources/add/$pluginKey' + | '/$org/$workspace/sources/add/$pluginKey' id: | '__root__' | '/' | '/$org' + | '/$org/$workspace' | '/$org/connections' | '/$org/policies' | '/$org/secrets' | '/$org/tools' | '/$org/' + | '/$org/$workspace/connections' + | '/$org/$workspace/policies' + | '/$org/$workspace/secrets' + | '/$org/$workspace/tools' | '/$org/-/billing' | '/$org/-/settings' | '/$org/sources/$namespace' + | '/$org/$workspace/' + | '/$org/$workspace/sources/$namespace' | '/$org/-/billing_/plans' | '/$org/sources/add/$pluginKey' + | '/$org/$workspace/sources/add/$pluginKey' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -226,6 +322,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OrgConnectionsRouteImport parentRoute: typeof OrgRoute } + '/$org/$workspace': { + id: '/$org/$workspace' + path: '/$workspace' + fullPath: '/$org/$workspace' + preLoaderRoute: typeof OrgWorkspaceRouteImport + parentRoute: typeof OrgRoute + } + '/$org/$workspace/': { + id: '/$org/$workspace/' + path: '/' + fullPath: '/$org/$workspace/' + preLoaderRoute: typeof OrgWorkspaceIndexRouteImport + parentRoute: typeof OrgWorkspaceRoute + } '/$org/sources/$namespace': { id: '/$org/sources/$namespace' path: '/sources/$namespace' @@ -247,6 +357,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OrgChar91Char93BillingRouteImport parentRoute: typeof OrgRoute } + '/$org/$workspace/tools': { + id: '/$org/$workspace/tools' + path: '/tools' + fullPath: '/$org/$workspace/tools' + preLoaderRoute: typeof OrgWorkspaceToolsRouteImport + parentRoute: typeof OrgWorkspaceRoute + } + '/$org/$workspace/secrets': { + id: '/$org/$workspace/secrets' + path: '/secrets' + fullPath: '/$org/$workspace/secrets' + preLoaderRoute: typeof OrgWorkspaceSecretsRouteImport + parentRoute: typeof OrgWorkspaceRoute + } + '/$org/$workspace/policies': { + id: '/$org/$workspace/policies' + path: '/policies' + fullPath: '/$org/$workspace/policies' + preLoaderRoute: typeof OrgWorkspacePoliciesRouteImport + parentRoute: typeof OrgWorkspaceRoute + } + '/$org/$workspace/connections': { + id: '/$org/$workspace/connections' + path: '/connections' + fullPath: '/$org/$workspace/connections' + preLoaderRoute: typeof OrgWorkspaceConnectionsRouteImport + parentRoute: typeof OrgWorkspaceRoute + } '/$org/sources/add/$pluginKey': { id: '/$org/sources/add/$pluginKey' path: '/sources/add/$pluginKey' @@ -261,10 +399,49 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OrgChar91Char93BillingPlansRouteImport parentRoute: typeof OrgRoute } + '/$org/$workspace/sources/$namespace': { + id: '/$org/$workspace/sources/$namespace' + path: '/sources/$namespace' + fullPath: '/$org/$workspace/sources/$namespace' + preLoaderRoute: typeof OrgWorkspaceSourcesNamespaceRouteImport + parentRoute: typeof OrgWorkspaceRoute + } + '/$org/$workspace/sources/add/$pluginKey': { + id: '/$org/$workspace/sources/add/$pluginKey' + path: '/sources/add/$pluginKey' + fullPath: '/$org/$workspace/sources/add/$pluginKey' + preLoaderRoute: typeof OrgWorkspaceSourcesAddPluginKeyRouteImport + parentRoute: typeof OrgWorkspaceRoute + } } } +interface OrgWorkspaceRouteChildren { + OrgWorkspaceConnectionsRoute: typeof OrgWorkspaceConnectionsRoute + OrgWorkspacePoliciesRoute: typeof OrgWorkspacePoliciesRoute + OrgWorkspaceSecretsRoute: typeof OrgWorkspaceSecretsRoute + OrgWorkspaceToolsRoute: typeof OrgWorkspaceToolsRoute + OrgWorkspaceIndexRoute: typeof OrgWorkspaceIndexRoute + OrgWorkspaceSourcesNamespaceRoute: typeof OrgWorkspaceSourcesNamespaceRoute + OrgWorkspaceSourcesAddPluginKeyRoute: typeof OrgWorkspaceSourcesAddPluginKeyRoute +} + +const OrgWorkspaceRouteChildren: OrgWorkspaceRouteChildren = { + OrgWorkspaceConnectionsRoute: OrgWorkspaceConnectionsRoute, + OrgWorkspacePoliciesRoute: OrgWorkspacePoliciesRoute, + OrgWorkspaceSecretsRoute: OrgWorkspaceSecretsRoute, + OrgWorkspaceToolsRoute: OrgWorkspaceToolsRoute, + OrgWorkspaceIndexRoute: OrgWorkspaceIndexRoute, + OrgWorkspaceSourcesNamespaceRoute: OrgWorkspaceSourcesNamespaceRoute, + OrgWorkspaceSourcesAddPluginKeyRoute: OrgWorkspaceSourcesAddPluginKeyRoute, +} + +const OrgWorkspaceRouteWithChildren = OrgWorkspaceRoute._addFileChildren( + OrgWorkspaceRouteChildren, +) + interface OrgRouteChildren { + OrgWorkspaceRoute: typeof OrgWorkspaceRouteWithChildren OrgConnectionsRoute: typeof OrgConnectionsRoute OrgPoliciesRoute: typeof OrgPoliciesRoute OrgSecretsRoute: typeof OrgSecretsRoute @@ -278,6 +455,7 @@ interface OrgRouteChildren { } const OrgRouteChildren: OrgRouteChildren = { + OrgWorkspaceRoute: OrgWorkspaceRouteWithChildren, OrgConnectionsRoute: OrgConnectionsRoute, OrgPoliciesRoute: OrgPoliciesRoute, OrgSecretsRoute: OrgSecretsRoute, diff --git a/apps/cloud/src/routes/$org/$workspace.tsx b/apps/cloud/src/routes/$org/$workspace.tsx new file mode 100644 index 000000000..2821b9870 --- /dev/null +++ b/apps/cloud/src/routes/$org/$workspace.tsx @@ -0,0 +1,72 @@ +import { createFileRoute, Outlet, useNavigate, useParams } from "@tanstack/react-router"; +import { useEffect, useMemo } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { setBaseUrl } from "@executor-js/react/api/base-url"; + +import { useOrgRoute } from "../../web/org-route"; +import { WorkspaceRouteProvider } from "../../web/workspace-route"; +import { workspacesAtom } from "../../web/workspaces"; + +export const Route = createFileRoute("/$org/$workspace")({ + component: WorkspaceLayout, +}); + +function WorkspaceLayout() { + const navigate = useNavigate(); + const { org, workspace: slug } = useParams({ from: Route.id }); + const { orgHandle } = useOrgRoute(); + const result = useAtomValue(workspacesAtom); + + // Resolve the slug from the listWorkspaces query. The CloudApiClient is + // already bound to the org-prefixed baseUrl by the parent `/$org` layout, so + // this `listWorkspaces` call hits `/api/$org/workspaces`. We only navigate + // away once the query has succeeded — until then we render the loading view + // (mirrors how `/$org` handles its membership lookup). + const { workspace, ready } = useMemo(() => { + if (AsyncResult.isSuccess(result)) { + const found = + result.value.workspaces.find((w) => w.slug === slug) ?? null; + return { workspace: found, ready: true }; + } + return { workspace: null, ready: false }; + }, [result, slug]); + + useEffect(() => { + if (!ready) return; + if (workspace) return; + void navigate({ to: "/$org", params: { org }, replace: true }); + }, [ready, workspace, navigate, org]); + + if (!ready) return null; + if (!workspace) return null; + + // Sanity check: render under the same orgHandle that produced the listing. + // If the org param drifts mid-navigation we'd resolve a stale workspace — + // surfaces as a fast remount once the parent updates. + if (orgHandle !== org) return null; + + // Re-point the executor API base URL at the workspace-prefixed mount. + // Mirrors the parent `/$org` layout's `setBaseUrl` call but tacks on + // `/${slug}` so executor-side queries (sources/secrets/connections/...) + // hit `/api/${org}/${workspace}/...` and the middleware builds the + // workspace scope stack. On unmount/back-nav the parent layout re-runs + // and resets the URL to the org-only prefix. + if (typeof window !== "undefined") { + setBaseUrl( + `${window.location.origin}/api/${orgHandle}/${workspace.slug}`, + ); + } + + return ( + + + + ); +} diff --git a/apps/cloud/src/routes/$org/$workspace/connections.tsx b/apps/cloud/src/routes/$org/$workspace/connections.tsx new file mode 100644 index 000000000..5b1b0bb64 --- /dev/null +++ b/apps/cloud/src/routes/$org/$workspace/connections.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ConnectionsPage } from "@executor-js/react/pages/connections"; + +export const Route = createFileRoute("/$org/$workspace/connections")({ + component: () => , +}); diff --git a/apps/cloud/src/routes/$org/$workspace/index.tsx b/apps/cloud/src/routes/$org/$workspace/index.tsx new file mode 100644 index 000000000..d275b2d8d --- /dev/null +++ b/apps/cloud/src/routes/$org/$workspace/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/$workspace/")({ + component: SourcesPage, +}); diff --git a/apps/cloud/src/routes/$org/$workspace/policies.tsx b/apps/cloud/src/routes/$org/$workspace/policies.tsx new file mode 100644 index 000000000..5ab23e83e --- /dev/null +++ b/apps/cloud/src/routes/$org/$workspace/policies.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { PoliciesPage } from "@executor-js/react/pages/policies"; + +export const Route = createFileRoute("/$org/$workspace/policies")({ + component: () => , +}); diff --git a/apps/cloud/src/routes/$org/$workspace/secrets.tsx b/apps/cloud/src/routes/$org/$workspace/secrets.tsx new file mode 100644 index 000000000..60ca6e68b --- /dev/null +++ b/apps/cloud/src/routes/$org/$workspace/secrets.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SecretsPage } from "@executor-js/react/pages/secrets"; + +export const Route = createFileRoute("/$org/$workspace/secrets")({ + component: () => ( + + ), +}); diff --git a/apps/cloud/src/routes/$org/$workspace/sources.$namespace.tsx b/apps/cloud/src/routes/$org/$workspace/sources.$namespace.tsx new file mode 100644 index 000000000..d07d836d6 --- /dev/null +++ b/apps/cloud/src/routes/$org/$workspace/sources.$namespace.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SourceDetailPage } from "@executor-js/react/pages/source-detail"; + +export const Route = createFileRoute("/$org/$workspace/sources/$namespace")({ + component: () => { + const { namespace } = Route.useParams(); + return ; + }, +}); diff --git a/apps/cloud/src/routes/$org/$workspace/sources.add.$pluginKey.tsx b/apps/cloud/src/routes/$org/$workspace/sources.add.$pluginKey.tsx new file mode 100644 index 000000000..932264535 --- /dev/null +++ b/apps/cloud/src/routes/$org/$workspace/sources.add.$pluginKey.tsx @@ -0,0 +1,27 @@ +import { Schema } from "effect"; +import { createFileRoute } from "@tanstack/react-router"; +import { SourcesAddPage } from "@executor-js/react/pages/sources-add"; + +const SearchParams = Schema.toStandardSchemaV1( + Schema.Struct({ + url: Schema.optional(Schema.String), + preset: Schema.optional(Schema.String), + namespace: Schema.optional(Schema.String), + }), +); + +export const Route = createFileRoute("/$org/$workspace/sources/add/$pluginKey")({ + validateSearch: SearchParams, + component: () => { + const { pluginKey } = Route.useParams(); + const { url, preset, namespace } = Route.useSearch(); + return ( + + ); + }, +}); diff --git a/apps/cloud/src/routes/$org/$workspace/tools.tsx b/apps/cloud/src/routes/$org/$workspace/tools.tsx new file mode 100644 index 000000000..0be097af9 --- /dev/null +++ b/apps/cloud/src/routes/$org/$workspace/tools.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ToolsPage } from "@executor-js/react/pages/tools"; + +export const Route = createFileRoute("/$org/$workspace/tools")({ + component: ToolsPage, +}); diff --git a/apps/cloud/src/services/__test-harness__/api-harness.ts b/apps/cloud/src/services/__test-harness__/api-harness.ts index a730779a5..6dd1bcd27 100644 --- a/apps/cloud/src/services/__test-harness__/api-harness.ts +++ b/apps/cloud/src/services/__test-harness__/api-harness.ts @@ -15,8 +15,14 @@ // Each test picks its own org id (usually a random UUID) so rows don't // collide across tests. The harness seeds an organizations row whose // `handle` equals the org id so `resolveOrgContext(orgId)` succeeds. +// +// Workspace requests use `asWorkspace(orgId, workspaceSlug, …)`, which +// pre-seeds the workspace row + rewrites outgoing URLs to +// `/api/${orgId}/${workspaceSlug}${path}`. The middleware reads both +// segments off the URL params and builds a workspace-scoped executor. import { Effect, Layer } from "effect"; +import { eq } from "drizzle-orm"; import { HttpApiBuilder, HttpApiClient, HttpApiSwagger } from "effect/unstable/httpapi"; import { FetchHttpClient, @@ -51,9 +57,17 @@ import { RouterConfig, } from "../../api/protected-layers"; import { DbService } from "../db"; -import { orgScopeId, userOrgScopeId } from "../ids"; -import { buildGlobalScopeStack } from "../scope-stack"; -import { organizations } from "../schema"; +import { + orgScopeId, + userOrgScopeId, + userWorkspaceScopeId, + workspaceScopeId, +} from "../ids"; +import { + buildGlobalScopeStack, + buildWorkspaceScopeStack, +} from "../scope-stack"; +import { organizations, workspaces } from "../schema"; export const TEST_BASE_URL = "http://test.local"; /** @@ -81,6 +95,7 @@ const createTestScopedExecutor = ( userId: string, orgId: string, orgName: string, + workspace: { id: string; name: string } | null, ) => Effect.gen(function* () { const { db } = yield* DbService; @@ -88,12 +103,21 @@ const createTestScopedExecutor = ( const schema = collectSchemas(plugins); const adapter = makePostgresAdapter({ db, schema }); const blobs = makePostgresBlobStore({ db }); + const scopes = workspace + ? buildWorkspaceScopeStack({ + userId, + organizationId: orgId, + organizationName: orgName, + workspaceId: workspace.id, + workspaceName: workspace.name, + }) + : buildGlobalScopeStack({ + userId, + organizationId: orgId, + organizationName: orgName, + }); return yield* createExecutor({ - scopes: buildGlobalScopeStack({ - userId, - organizationId: orgId, - organizationName: orgName, - }), + scopes, adapter, blobs, plugins, @@ -119,25 +143,88 @@ const seedTestOrg = (orgId: string) => ); }); +/** + * Same approach as `seedTestOrg`: idempotent insert of a workspace under the + * given org so `resolveWorkspaceContext(orgId, slug)` succeeds. Returns the + * workspace row (loaded via SELECT after the upsert), so callers know the + * generated `workspace_<...>` id without a second round-trip. + */ +const seedTestWorkspace = (orgId: string, slug: string) => + Effect.gen(function* () { + const { db } = yield* DbService; + const id = `workspace_test_${orgId}_${slug}`; + yield* Effect.promise(() => + db + .insert(workspaces) + .values({ + id, + organizationId: orgId, + slug, + name: `Workspace ${slug}`, + }) + .onConflictDoNothing(), + ); + const rows = yield* Effect.promise(() => + db + .select() + .from(workspaces) + .where(eq(workspaces.organizationId, orgId)), + ); + const found = rows.find((r) => r.slug === slug); + if (!found) { + return yield* Effect.die( + new Error(`failed to seed workspace ${slug} in org ${orgId}`), + ); + } + return found; + }); + // --------------------------------------------------------------------------- // 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 => { +// Pull the URL `:org` (+ optional `:workspace`) segments from a request path. +// The protected API mounts under `/api/:org/...` and `/api/:org/:workspace/...`. +// Returning `null` for a malformed prefix forces the downstream handler to +// surface a typed error rather than panicking. +// +// Workspace detection is conservative: any path with three+ segments after +// `/api/` is *potentially* workspace-scoped, but the tests pre-seed the +// workspace row before issuing the request via `asWorkspace(...)`, so we +// gate on the seeded set. That avoids accidentally treating an org-only +// endpoint with extra path segments (e.g. `/scopes/:id/sources`) as a +// workspace request. +const seededWorkspaces = new Map>(); +const orgHandleFromPath = (pathname: string): + | { orgId: string; workspaceSlug: string | null } + | null => { const parts = pathname.split("/").filter((part) => part.length > 0); if (parts.length < 2 || parts[0] !== "api") return null; - return parts[1] ?? null; + const orgId = parts[1] ?? null; + if (!orgId) return null; + const candidate = parts[2] ?? null; + const orgSet = seededWorkspaces.get(orgId); + const workspaceSlug = + candidate && orgSet?.has(candidate) ? candidate : null; + return { orgId, workspaceSlug }; +}; + +const rememberWorkspace = (orgId: string, slug: string) => { + let set = seededWorkspaces.get(orgId); + if (!set) { + set = new Set(); + seededWorkspaces.set(orgId, set); + } + set.add(slug); }; // Test version of the production `ExecutionStackMiddleware` — reads the -// 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. +// org (and optional workspace) handle from the URL prefix (matching +// production: `/api/:org/...` and `/api/:org/:workspace/...`), 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 @@ -156,23 +243,36 @@ const TestExecutionStackMiddleware = HttpRouter.middleware<{ const request = yield* HttpServerRequest.HttpServerRequest; const webRequest = yield* HttpServerRequest.toWeb(request); const url = new URL(webRequest.url); - const orgId = orgHandleFromPath(url.pathname); - if (!orgId) { + const parsed = orgHandleFromPath(url.pathname); + if (!parsed) { 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. + const { orgId, workspaceSlug } = parsed; + // 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); + // Resolve the workspace row (if present) BEFORE building the + // executor — `buildWorkspaceScopeStack` needs the deterministic + // `workspace_` to scope reads/writes against. + const workspace = workspaceSlug + ? yield* seedTestWorkspace(orgId, workspaceSlug) + : null; const userHeader = request.headers[TEST_USER_HEADER]; const userId = typeof userHeader === "string" && userHeader.length > 0 ? userHeader : defaultUserFor(orgId); const orgName = `Org ${orgId}`; - const executor = yield* createTestScopedExecutor(userId, orgId, orgName); + const executor = yield* createTestScopedExecutor( + userId, + orgId, + orgName, + workspace ? { id: workspace.id, name: workspace.name } : null, + ); const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor(), @@ -197,19 +297,36 @@ 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)( +// AND `/api/:org/:workspace` via prefixed router views. The outer +// `HttpRouter` from `HttpServer.layerServices` is the underlying state; +// each prefix wrapper rewrites added paths only. Both prefixes serve the +// SAME endpoints — the request URL determines which scope stack the +// middleware builds. +const OrgPrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)( Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed("/api/:org"), ), ); -const TestApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( +const WorkspacePrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)( + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => + router.prefixed("/api/:org/:workspace"), + ), +); + +const orgMount = HttpApiBuilder.layer(ProtectedCloudApi).pipe( Layer.provide(ProtectedCloudApiHandlers), Layer.provide(TestExecutionStackMiddleware), - Layer.provide(PrefixedRouterLayer), + Layer.provide(OrgPrefixedRouterLayer), +); + +const workspaceMount = HttpApiBuilder.layer(ProtectedCloudApi).pipe( + Layer.provide(ProtectedCloudApiHandlers), + Layer.provide(TestExecutionStackMiddleware), + Layer.provide(WorkspacePrefixedRouterLayer), +); + +const TestApiLive = Layer.mergeAll(orgMount, workspaceMount).pipe( Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })), Layer.provideMerge(RouterConfig), Layer.provideMerge(DbService.Live), @@ -218,19 +335,20 @@ 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 ( +// Rewrite outgoing request URLs to `/api/${orgId}${path}` (or +// `/api/${orgId}/${workspaceSlug}${path}` for workspace requests) 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 (+workspace) segment in front +// before the request reaches the in-process handler. +const rewriteRequestForPrefix = async ( base: Request, - orgId: string, + prefix: 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}`; + if (!url.pathname.startsWith(`${prefix}/`) && url.pathname !== prefix) { + url.pathname = `${prefix}${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, {...})` @@ -246,6 +364,25 @@ const rewriteRequestForOrg = async ( }); }; +const rewriteRequestForOrg = ( + base: Request, + orgId: string, + extraHeaders: Record = {}, +): Promise => + rewriteRequestForPrefix(base, `/api/${orgId}`, extraHeaders); + +const rewriteRequestForWorkspace = ( + base: Request, + orgId: string, + workspaceSlug: string, + extraHeaders: Record = {}, +): Promise => + rewriteRequestForPrefix( + base, + `/api/${orgId}/${workspaceSlug}`, + extraHeaders, + ); + export const fetchForOrg = (orgId: string): typeof globalThis.fetch => (async (input: RequestInfo | URL, init?: RequestInit) => { const base = input instanceof Request ? input : new Request(input, init); @@ -263,6 +400,26 @@ export const fetchForUser = ( return handler(req); }) as typeof globalThis.fetch; +export const fetchForWorkspace = ( + orgId: string, + workspaceSlug: string, + userId?: string, +): typeof globalThis.fetch => + (async (input: RequestInfo | URL, init?: RequestInit) => { + const base = input instanceof Request ? input : new Request(input, init); + const extraHeaders: Record = {}; + if (userId !== undefined) { + extraHeaders[TEST_USER_HEADER] = userId; + } + const req = await rewriteRequestForWorkspace( + base, + orgId, + workspaceSlug, + extraHeaders, + ); + return handler(req); + }) as typeof globalThis.fetch; + export const clientLayerForOrg = (orgId: string) => FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(fetchForOrg(orgId))), @@ -275,6 +432,19 @@ export const clientLayerForUser = (userId: string, orgId: string) => ), ); +export const clientLayerForWorkspace = ( + orgId: string, + workspaceSlug: string, + userId?: string, +) => + FetchHttpClient.layer.pipe( + Layer.provide( + Layer.succeed(FetchHttpClient.Fetch)( + fetchForWorkspace(orgId, workspaceSlug, userId), + ), + ), + ); + // Constructs an HttpApiClient bound to the given org, hands it to `body`, // and provides the org-scoped fetch layer in one step. Keeps per-test // Effect blocks focused on the actual assertions. @@ -289,6 +459,46 @@ export const asOrg = ( return yield* body(client); }).pipe(Effect.provide(clientLayerForOrg(orgId))) as Effect.Effect; +/** + * Run the body with a `ProtectedCloudApi` client whose URLs target the + * `/api/${orgId}/${workspaceSlug}/...` mount. The harness pre-registers + * the slug so the in-process middleware treats subsequent requests as + * workspace-scoped (third URL segment after `/api/` is treated as a + * workspace slug only when seeded — see `orgHandleFromPath`). The actual + * row insert happens lazily on first request inside the middleware via + * `seedTestWorkspace`, so the executor's scope stack ends up with the + * deterministic `workspace_<...>` id. + */ +export const asWorkspace = ( + orgId: string, + workspaceSlug: string, + body: (client: ApiShape) => Effect.Effect, +): Effect.Effect => { + rememberWorkspace(orgId, workspaceSlug); + return Effect.gen(function* () { + const client = yield* HttpApiClient.make(ProtectedCloudApi, { baseUrl: TEST_BASE_URL }); + return yield* body(client); + }).pipe( + Effect.provide(clientLayerForWorkspace(orgId, workspaceSlug)), + ) as Effect.Effect; +}; + +/** As `asWorkspace` but threads a specific user id through. */ +export const asWorkspaceUser = ( + userId: string, + orgId: string, + workspaceSlug: string, + body: (client: ApiShape) => Effect.Effect, +): Effect.Effect => { + rememberWorkspace(orgId, workspaceSlug); + return Effect.gen(function* () { + const client = yield* HttpApiClient.make(ProtectedCloudApi, { baseUrl: TEST_BASE_URL }); + return yield* body(client); + }).pipe( + Effect.provide(clientLayerForWorkspace(orgId, workspaceSlug, userId)), + ) as Effect.Effect; +}; + // Same as `asOrg` but also threads a specific user id through the fake // OrgAuth, so the built executor's user-org scope id is // `user-org:${userId}:${orgId}`. Use this for tests that care about @@ -310,6 +520,19 @@ export const asUser = ( export const testUserOrgScopeId = (userId: string, orgId: string) => userOrgScopeId(userId, orgId); +// Workspace-scoped variants. The harness derives workspace ids +// deterministically from the seed slug (`workspace_test__`), +// so tests can build expected scope ids without round-tripping the row. +export const testWorkspaceId = (orgId: string, slug: string) => + `workspace_test_${orgId}_${slug}`; +export const testWorkspaceScopeId = (orgId: string, slug: string) => + workspaceScopeId(testWorkspaceId(orgId, slug)); +export const testUserWorkspaceScopeId = ( + userId: string, + orgId: string, + slug: string, +) => userWorkspaceScopeId(userId, testWorkspaceId(orgId, slug)); + // Re-exports so call sites don't need a second import. export { ProtectedCloudApi }; -export { orgScopeId, userOrgScopeId }; +export { orgScopeId, userOrgScopeId, workspaceScopeId, userWorkspaceScopeId }; diff --git a/apps/cloud/src/services/workspace-context.node.test.ts b/apps/cloud/src/services/workspace-context.node.test.ts new file mode 100644 index 000000000..8a1325c82 --- /dev/null +++ b/apps/cloud/src/services/workspace-context.node.test.ts @@ -0,0 +1,93 @@ +// Workspace-prefixed API requests — verify that hitting +// `/api/${orgId}/${workspaceSlug}/...` builds an executor whose scope stack +// is the workspace stack (not the org-only stack), and that the same +// `ProtectedCloudApi` schema serves both prefixes. +// +// In v1 there are no workspace ACLs — org membership is the only check — +// so the test only needs to exercise that the URL truly drives scope stack +// construction. Adding a source under the workspace scope and listing it +// from both contexts pins down the executor wiring. + +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + asOrg, + asWorkspace, + orgScopeId, + testWorkspaceScopeId, +} from "./__test-harness__/api-harness"; + +const MINIMAL_OPENAPI_SPEC = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Workspace Test API", version: "1.0.0" }, + paths: { + "/ping": { + get: { + operationId: "ping", + summary: "ping", + responses: { "200": { description: "ok" } }, + }, + }, + }, +}); + +describe("workspace-prefixed protected API", () => { + it.effect( + "addSpec at the workspace scope id is visible from workspace context but not from org global", + () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + const slug = `ws_${crypto.randomUUID().slice(0, 8)}`; + const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; + const wsScope = testWorkspaceScopeId(org, slug); + + // Write under the workspace scope. The middleware sees the + // `/api/${org}/${slug}/...` prefix, resolves the workspace, and + // builds `[user_workspace, workspace, user_org, org]`. Listing + // workspace sources should include the new namespace because the + // executor walks that stack on read. + yield* asWorkspace(org, slug, (client) => + client.openapi.addSpec({ + params: { scopeId: wsScope }, + payload: { spec: MINIMAL_OPENAPI_SPEC, namespace }, + }), + ); + + const wsSources = yield* asWorkspace(org, slug, (client) => + client.sources.list({ params: { scopeId: wsScope } }), + ); + expect(wsSources.map((s) => s.id)).toContain(namespace); + + // From global org context the executor stack is just + // `[user_org, org]` — the workspace-scoped row should be invisible. + const orgSources = yield* asOrg(org, (client) => + client.sources.list({ params: { scopeId: orgScopeId(org) } }), + ); + expect(orgSources.map((s) => s.id)).not.toContain(namespace); + }), + ); + + it.effect("workspace context inherits global sources via the scope stack", () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + const slug = `ws_${crypto.randomUUID().slice(0, 8)}`; + const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; + + // Add a source under the org/global scope... + yield* asOrg(org, (client) => + client.openapi.addSpec({ + params: { scopeId: orgScopeId(org) }, + payload: { spec: MINIMAL_OPENAPI_SPEC, namespace }, + }), + ); + + // ...and read it from inside a workspace context. The workspace stack + // ends in `org_`, so the inherited source must show up. + const sources = yield* asWorkspace(org, slug, (client) => + client.sources.list({ params: { scopeId: orgScopeId(org) } }), + ); + expect(sources.map((s) => s.id)).toContain(namespace); + }), + ); +}); diff --git a/apps/cloud/src/web/client.tsx b/apps/cloud/src/web/client.tsx index 4bcf7acc1..ac2faac32 100644 --- a/apps/cloud/src/web/client.tsx +++ b/apps/cloud/src/web/client.tsx @@ -1,18 +1,23 @@ import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; -import { FetchHttpClient } from "effect/unstable/http"; import { addGroup } from "@executor-js/api"; import { getBaseUrl } from "@executor-js/react/api/base-url"; +import { ContextAwareHttpClient } from "@executor-js/react/api/http-client"; import { CloudAuthApi } from "../auth/api"; import { OrgApi } from "../org/api"; +import { WorkspacesApi } from "../workspaces/api"; // --------------------------------------------------------------------------- -// Cloud API client — core API + cloud auth + org +// Cloud API client — core API + cloud auth + org + workspaces // --------------------------------------------------------------------------- +// +// Uses the same URL-context-aware fetch wrapper as the executor client so +// org-prefixed routes (`/api/:org/...`) are addressed correctly while +// auth/sentry/autumn routes stay unprefixed. -const CloudApi = addGroup(CloudAuthApi).add(OrgApi); +const CloudApi = addGroup(CloudAuthApi).add(OrgApi).add(WorkspacesApi); const CloudApiClient = AtomHttpApi.Service<"CloudApiClient">()("CloudApiClient", { api: CloudApi, - httpClient: FetchHttpClient.layer, + httpClient: ContextAwareHttpClient, baseUrl: getBaseUrl(), }); diff --git a/apps/cloud/src/web/components/create-workspace-form.tsx b/apps/cloud/src/web/components/create-workspace-form.tsx new file mode 100644 index 000000000..e33d5fa3d --- /dev/null +++ b/apps/cloud/src/web/components/create-workspace-form.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; +import { useAtomSet } from "@effect/atom-react"; +import * as Exit from "effect/Exit"; +import { workspaceWriteKeys } from "@executor-js/react/api/reactivity-keys"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; + +import { createWorkspaceMutation } from "../workspaces"; + +type CreatedWorkspace = { + id: string; + organizationId: string; + slug: string; + name: string; +}; + +export function useCreateWorkspaceForm(options: { + defaultName?: string; + onSuccess: (workspace: CreatedWorkspace) => void; + onFailure?: () => void; +}) { + const doCreate = useAtomSet(createWorkspaceMutation, { mode: "promiseExit" }); + const [name, setName] = useState(options.defaultName ?? ""); + const [error, setError] = useState(null); + const [creating, setCreating] = useState(false); + + const reset = (nextName = options.defaultName ?? "") => { + setName(nextName); + setError(null); + setCreating(false); + }; + + const submit = async () => { + const trimmed = name.trim(); + if (!trimmed) { + setError("Workspace name is required."); + return; + } + setCreating(true); + setError(null); + const exit = await doCreate({ + payload: { name: trimmed }, + reactivityKeys: workspaceWriteKeys, + }); + setCreating(false); + if (Exit.isSuccess(exit)) { + options.onSuccess(exit.value); + } else { + setError("Failed to create workspace."); + options.onFailure?.(); + } + }; + + return { + name, + setName, + error, + setError, + creating, + submit, + reset, + canSubmit: name.trim().length > 0, + }; +} + +export function CreateWorkspaceFields(props: { + name: string; + onNameChange: (name: string) => void; + error: string | null; + onSubmit: () => void; +}) { + return ( +
+
+ + + props.onNameChange((event.target as HTMLInputElement).value) + } + onKeyDown={(event) => { + if (event.key === "Enter") props.onSubmit(); + }} + className="h-9 text-sm" + /> +
+ + {props.error && ( +
+

{props.error}

+
+ )} +
+ ); +} diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index cf8105686..5cb7b8075 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -1,5 +1,6 @@ -import { Link, Outlet, useLocation } from "@tanstack/react-router"; +import { Link, Outlet, useLocation, useNavigate } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; 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"; @@ -30,10 +31,16 @@ import { CommandPalette } from "@executor-js/react/components/command-palette"; import { AUTH_PATHS } from "../auth/api"; import { useAuth } from "./auth"; import { useOrgRoute } from "./org-route"; +import { useOptionalWorkspaceRoute } from "./workspace-route"; +import { workspacesAtom } from "./workspaces"; import { CreateOrganizationFields, useCreateOrganizationForm, } from "./components/create-organization-form"; +import { + CreateWorkspaceFields, + useCreateWorkspaceForm, +} from "./components/create-workspace-form"; // ── ShellSkeleton ──────────────────────────────────────────────────────── @@ -132,6 +139,7 @@ function NavItem(props: { function SourceList(props: { pathname: string; onNavigate?: () => void }) { const { orgHandle } = useOrgRoute(); + const workspace = useOptionalWorkspaceRoute(); const scopeId = useScope(); const sources = useSourcesWithPending(scopeId); @@ -157,14 +165,26 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { ) : (
{value.map((s) => { - const detailPath = `/${orgHandle}/sources/${s.id}`; + const detailPath = workspace + ? `/${orgHandle}/${workspace.workspaceSlug}/sources/${s.id}` + : `/${orgHandle}/sources/${s.id}`; const active = props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); + const to = workspace + ? "/$org/$workspace/sources/$namespace" + : "/$org/sources/$namespace"; + const params: Record = workspace + ? { + org: orgHandle, + workspace: workspace.workspaceSlug, + namespace: s.id, + } + : { org: orgHandle, namespace: s.id }; return ( " switcher items. Mirrors the structure laid +// out in the workspaces plan: ` / Global` pinned at the top, then a +// separator, then ` / ` for each workspace. +// +// The query for workspaces runs against the *active* org only (the +// CloudApiClient's baseUrl tracks the current `/$org` URL). For non-active +// orgs we just show the Global entry — switching to that org loads its +// workspaces fresh on next render. +function ContextSwitcherItems(props: { + activeOrganizationId: string | null; + activeWorkspaceId: string | null; +}) { const auth = useAuth(); + const workspacesResult = useAtomValue(workspacesAtom); + const workspaces = + AsyncResult.isSuccess(workspacesResult) ? workspacesResult.value.workspaces : null; if (auth.status !== "authenticated") { return Loading…; @@ -229,21 +263,59 @@ function OrganizationSwitcherItems(props: { activeOrganizationId: string | null if (auth.organizations.length === 0) { return No organizations; } + return ( <> {auth.organizations.map((organization) => { - const isActive = organization.id === props.activeOrganizationId; + const isActiveOrg = organization.id === props.activeOrganizationId; + const orgWorkspaces = isActiveOrg ? (workspaces ?? []) : []; + const isGlobalActive = isActiveOrg && props.activeWorkspaceId === null; return ( - - + + {organization.name} + + - {organization.name} - {isActive && } - - + + Global + {isGlobalActive && } + + + {orgWorkspaces.length > 0 && } + {orgWorkspaces.map((workspace) => { + const isActive = workspace.id === props.activeWorkspaceId; + return ( + + + + {workspace.name} + + {isActive && } + + + ); + })} +
); })} @@ -267,7 +339,10 @@ function CheckIcon() { function UserFooter() { const auth = useAuth(); const orgRoute = useOrgRoute(); + const workspaceRoute = useOptionalWorkspaceRoute(); + const navigate = useNavigate(); const [createOrganizationOpen, setCreateOrganizationOpen] = useState(false); + const [createWorkspaceOpen, setCreateWorkspaceOpen] = useState(false); const suggestedOrganizationName = auth.status === "authenticated" && auth.user.name?.trim() !== "" && auth.user.name != null @@ -287,6 +362,19 @@ function UserFooter() { }, }); + // Workspace name suggestion is intentionally generic — workspaces are + // project-shaped, not user-shaped. The user can always rename later. + const workspaceForm = useCreateWorkspaceForm({ + defaultName: "", + onSuccess: (workspace) => { + setCreateWorkspaceOpen(false); + void navigate({ + to: "/$org/$workspace", + params: { org: orgRoute.orgHandle, workspace: workspace.slug }, + }); + }, + }); + if (auth.status !== "authenticated") return null; const openCreateOrganization = () => { @@ -294,6 +382,19 @@ function UserFooter() { setCreateOrganizationOpen(true); }; + const openCreateWorkspace = () => { + workspaceForm.reset(""); + setCreateWorkspaceOpen(true); + }; + + // Trigger label format per the plan: ` / Global` or + // ` / `. The org name is constant in this layout + // (parent route resolves it); the workspace name only appears under + // workspace context. + const contextLabel = workspaceRoute + ? `${orgRoute.orgName} / ${workspaceRoute.workspaceName}` + : `${orgRoute.orgName} / Global`; + return (
- - - - - - - Organization - - - - {orgRoute.orgName} - - - - - { - event.preventDefault(); - openCreateOrganization(); - }} + +
+

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

+

{contextLabel}

+
+ - Create organization -
-
-
- - - Signed in as - - - -
-

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

- {auth.user.name && ( -

{auth.user.email}

- )} -
-
- { - await fetch(AUTH_PATHS.logout, { method: "POST" }); - window.location.href = "/"; + + + + + + + Context + + + + {contextLabel} + + + + + { + event.preventDefault(); + openCreateWorkspace(); + }} + > + Create workspace + + { + event.preventDefault(); + openCreateOrganization(); + }} + > + Create organization + + + + + + Signed in as + + + +
+

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

+ {auth.user.name && ( +

{auth.user.email}

+ )} +
+
+ { + await fetch(AUTH_PATHS.logout, { method: "POST" }); + window.location.href = "/"; + }} + > + Sign out + +
+
+ + + + Create workspace + + Workspaces are project contexts inside {orgRoute.orgName}. They share global + sources and add their own. + + + + { + workspaceForm.setName(name); + if (workspaceForm.error) workspaceForm.setError(null); }} - > - Sign out - - - + error={workspaceForm.error} + onSubmit={() => void workspaceForm.submit()} + /> + + + + + + + + +
@@ -423,23 +579,67 @@ function UserFooter() { function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) { const { orgHandle } = useOrgRoute(); + const workspaceRoute = useOptionalWorkspaceRoute(); + 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 inWorkspace = workspaceRoute !== null; + const wsPrefix = inWorkspace + ? `${orgPrefix}/${workspaceRoute.workspaceSlug}` + : null; + const navPrefix = wsPrefix ?? orgPrefix; + + const isHome = props.pathname === navPrefix || props.pathname === `${navPrefix}/`; + const isSecrets = props.pathname === `${navPrefix}/secrets`; + const isConnections = props.pathname === `${navPrefix}/connections`; + const isPolicies = props.pathname === `${navPrefix}/policies`; + // Org-admin paths (billing/settings) only render in global context — they + // don't have workspace equivalents per the plan ("In workspace context, the + // main working nav remains focused on sources, connections, secrets, and + // policies"). const isBilling = props.pathname === `${orgPrefix}/-/billing` || props.pathname.startsWith(`${orgPrefix}/-/billing/`); const isOrg = props.pathname === `${orgPrefix}/-/settings`; + // Build link targets. Workspace context uses the `/$org/$workspace/...` + // routes; global context stays on `/$org/...`. Casting `to` and `params` is + // localized to the union here and matches the existing `as never` pattern + // NavItem already uses for hand-picked typed templates. + type Link = { to: string; params: Record }; + const sourcesLink: Link = inWorkspace + ? { + to: "/$org/$workspace", + params: { org: orgHandle, workspace: workspaceRoute.workspaceSlug }, + } + : { to: "/$org", params: { org: orgHandle } }; + const connectionsLink: Link = inWorkspace + ? { + to: "/$org/$workspace/connections", + params: { org: orgHandle, workspace: workspaceRoute.workspaceSlug }, + } + : { to: "/$org/connections", params: { org: orgHandle } }; + const secretsLink: Link = inWorkspace + ? { + to: "/$org/$workspace/secrets", + params: { org: orgHandle, workspace: workspaceRoute.workspaceSlug }, + } + : { to: "/$org/secrets", params: { org: orgHandle } }; + const policiesLink: Link = inWorkspace + ? { + to: "/$org/$workspace/policies", + params: { org: orgHandle, workspace: workspaceRoute.workspaceSlug }, + } + : { to: "/$org/policies", params: { org: orgHandle } }; + return ( <> {props.showBrand !== false && (
- + executor
@@ -447,47 +647,51 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show