diff --git a/apps/cloud/src/api/layers.ts b/apps/cloud/src/api/layers.ts index c53645061..35b5e81b1 100644 --- a/apps/cloud/src/api/layers.ts +++ b/apps/cloud/src/api/layers.ts @@ -64,25 +64,50 @@ 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`). +// OrgHttpApi mounts under `/api/:org/...` AND `/api/:org/:workspace/...` so +// the same org-level endpoints (workspaces list/create, members, etc.) stay +// reachable from either context — the URL-context fetch wrapper in the +// react package always prefixes outgoing `/api/...` requests with the +// page's URL handle pair. `start.ts` strips the leading `/api` before +// forwarding, so the prefixes here omit it. +// +// Each mount needs its own `HttpApiBuilder.layer(OrgHttpApi)` instance +// because Effect's Layer system memoizes a shared instance and only the +// first prefix's routes would register otherwise (see +// `apps/cloud/src/api/protected.ts` for the same pattern). const OrgPrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)( Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed("/:org"), ), ); -export const makeOrgApiLive = ( - rsLive: Layer.Layer, -) => +const OrgWorkspacePrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)( + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => + router.prefixed("/:org/:workspace"), + ), +); + +const makeOrgLayer = () => HttpApiBuilder.layer(OrgHttpApi).pipe( Layer.provide(Layer.mergeAll(OrgHandlers, WorkspacesHandlers)), - Layer.provide(requestScopedMiddleware(rsLive).layer), + ); + +export const makeOrgApiLive = ( + rsLive: Layer.Layer, +) => { + const requestScopedLayer = requestScopedMiddleware(rsLive).layer; + const orgMount = makeOrgLayer().pipe( + Layer.provide(requestScopedLayer), Layer.provideMerge(OrgAuthLive), Layer.provide(OrgPrefixedRouterLayer), ); + const workspaceMount = makeOrgLayer().pipe( + Layer.provide(requestScopedLayer), + Layer.provideMerge(OrgAuthLive), + Layer.provide(OrgWorkspacePrefixedRouterLayer), + ); + return Layer.mergeAll(orgMount, workspaceMount); +}; // Default exports use the production per-request layer. Existing callers // that import `NonProtectedApiLive`/`OrgApiLive` continue to work; the diff --git a/apps/cloud/src/api/protected.ts b/apps/cloud/src/api/protected.ts index c582aed6e..ec310681c 100644 --- a/apps/cloud/src/api/protected.ts +++ b/apps/cloud/src/api/protected.ts @@ -2,13 +2,17 @@ // because `makeExecutionStack` imports `cloudflare:workers`, which the test // harness can't load in the workerd test runtime. -import { HttpApiSwagger } from "effect/unstable/httpapi"; +import { HttpApiBuilder, HttpApiSwagger } from "effect/unstable/httpapi"; import { HttpRouter, HttpServerRequest, } from "effect/unstable/http"; import { Effect, Layer } from "effect"; +import { observabilityMiddleware } from "@executor-js/api"; + +import { ErrorCaptureLive } from "../observability"; + import { ExecutionEngineService, ExecutorService, @@ -32,7 +36,7 @@ import { HttpResponseError } from "./error-response"; import { RequestScopedServicesLive } from "./layers"; import { ProtectedCloudApi, - ProtectedCloudApiLive, + ProtectedCloudApiHandlers, RouterConfig, } from "./protected-layers"; import { requestScopedMiddleware } from "./request-scoped"; @@ -206,17 +210,32 @@ const WorkspacePrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)( // the layer rebuilds per HTTP request, satisfying Cloudflare Workers' // I/O isolation. Exposed as a factory so tests can swap in a counting // fake — see `apps/cloud/src/api.request-scope.node.test.ts`. +// Build a fresh `HttpApiBuilder.layer(ProtectedCloudApi)` per mount. Each +// layer instance registers its own `router.add(...)` calls; sharing one +// instance across two prefixed routers makes Effect's layer-memoization +// install only the first prefix, leaving `/:org/:workspace/...` unrouted. +const makeProtectedLayer = () => + HttpApiBuilder.layer(ProtectedCloudApi).pipe( + Layer.provide( + Layer.mergeAll( + ProtectedCloudApiHandlers, + observabilityMiddleware(ProtectedCloudApi), + ), + ), + Layer.provide(ErrorCaptureLive), + ); + export const makeProtectedApiLive = ( rsLive: Layer.Layer, ) => { const protectedMiddleware = ExecutionStackMiddleware.combine( requestScopedMiddleware(rsLive), ).layer; - const orgMount = ProtectedCloudApiLive.pipe( + const orgMount = makeProtectedLayer().pipe( Layer.provide(protectedMiddleware), Layer.provide(OrgPrefixedRouterLayer), ); - const workspaceMount = ProtectedCloudApiLive.pipe( + const workspaceMount = makeProtectedLayer().pipe( Layer.provide(protectedMiddleware), Layer.provide(WorkspacePrefixedRouterLayer), ); diff --git a/apps/cloud/src/routes/$org.tsx b/apps/cloud/src/routes/$org.tsx index 24b5356df..f974ad820 100644 --- a/apps/cloud/src/routes/$org.tsx +++ b/apps/cloud/src/routes/$org.tsx @@ -2,7 +2,6 @@ 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"; @@ -37,14 +36,6 @@ function OrgLayout() { 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 ( { + it.effect( + "policies write at every scope level in the URL-resolved stack", + () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + const slug = `ws_${crypto.randomUUID().slice(0, 8)}`; + const userId = `u_${crypto.randomUUID().slice(0, 8)}`; + const wsScope = testWorkspaceScopeId(org, slug); + const orgScope = orgScopeId(org); + const userOrg = testUserOrgScopeId(userId, org); + const userWs = testUserWorkspaceScopeId(userId, org, slug); + + // Each level gets a distinct action so the listing can match + // rows back to their scopes deterministically. + yield* asWorkspaceUser(userId, org, slug, (client) => + Effect.gen(function* () { + yield* client.policies.create({ + params: { scopeId: userWs }, + payload: { pattern: PATTERN, action: "block" }, + }); + yield* client.policies.create({ + params: { scopeId: wsScope }, + payload: { pattern: PATTERN, action: "require_approval" }, + }); + yield* client.policies.create({ + params: { scopeId: userOrg }, + payload: { pattern: PATTERN, action: "approve" }, + }); + yield* client.policies.create({ + params: { scopeId: orgScope }, + payload: { pattern: PATTERN, action: "require_approval" }, + }); + }), + ); + + // Listing from workspace context returns every row, each tagged + // with its owning scope id. + const list = yield* asWorkspaceUser(userId, org, slug, (client) => + client.policies.list({ params: { scopeId: wsScope } }), + ); + const byScope = new Map(list.map((p) => [p.scopeId, p.action])); + expect(byScope.get(userWs)).toBe("block"); + expect(byScope.get(wsScope)).toBe("require_approval"); + expect(byScope.get(userOrg)).toBe("approve"); + expect(byScope.get(orgScope)).toBe("require_approval"); + + // Sort order: innermost-first. The user-workspace row lands at + // index 0, the org row at index N-1. + const innermost = list[0]; + const outermost = list[list.length - 1]; + expect(innermost?.scopeId).toBe(userWs); + expect(outermost?.scopeId).toBe(orgScope); + }), + ); + + it.effect( + "innermost matching policy wins — user-workspace beats workspace beats org", + () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + const slug = `ws_${crypto.randomUUID().slice(0, 8)}`; + const userId = `u_${crypto.randomUUID().slice(0, 8)}`; + const wsScope = testWorkspaceScopeId(org, slug); + const orgScope = orgScopeId(org); + const userWs = testUserWorkspaceScopeId(userId, org, slug); + + // Three same-pattern rows with different actions across the + // stack. The SDK's `resolveToolPolicy` (in + // `packages/core/sdk/src/policies.ts`) ranks by `scope_id` → + // `position` and picks the innermost match. + yield* asWorkspaceUser(userId, org, slug, (client) => + Effect.gen(function* () { + yield* client.policies.create({ + params: { scopeId: orgScope }, + payload: { pattern: PATTERN, action: "block" }, + }); + yield* client.policies.create({ + params: { scopeId: wsScope }, + payload: { pattern: PATTERN, action: "require_approval" }, + }); + yield* client.policies.create({ + params: { scopeId: userWs }, + payload: { pattern: PATTERN, action: "approve" }, + }); + }), + ); + + // Listing returns all 3, but the innermost is at position 0. + // That ordering is what `resolveToolPolicy` consumes when the + // executor looks up the effective policy for a tool id. + const list = yield* asWorkspaceUser(userId, org, slug, (client) => + client.policies.list({ params: { scopeId: wsScope } }), + ); + const policies = list.filter((p) => p.pattern === PATTERN); + expect(policies).toHaveLength(3); + expect(policies[0]?.scopeId).toBe(userWs); + expect(policies[0]?.action).toBe("approve"); + }), + ); +}); diff --git a/apps/cloud/src/web/workspace-route.tsx b/apps/cloud/src/web/workspace-route.tsx index 20104a657..d37b31089 100644 --- a/apps/cloud/src/web/workspace-route.tsx +++ b/apps/cloud/src/web/workspace-route.tsx @@ -1,9 +1,16 @@ -import React, { createContext, useContext } from "react"; +import React, { createContext, useContext, useMemo } from "react"; +import { useParams } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; + +import { workspacesAtom } from "./workspaces"; // --------------------------------------------------------------------------- -// WorkspaceRouteContext — provided by the `/$org/$workspace` layout, consumed -// by descendants (shell nav, nav links, etc.) that need to know the URL-active -// workspace. Mirrors `OrgRouteContext` but for the inner workspace segment. +// WorkspaceRouteContext — provided by the `/$org/$workspace` layout. The +// hook below also falls back to deriving the value from URL params + +// `workspacesAtom` so callers rendered ABOVE the workspace layout (e.g. the +// Shell + UserFooter, which live in the parent `/$org` layout) still see the +// active workspace. // --------------------------------------------------------------------------- export type WorkspaceRouteValue = { @@ -25,16 +32,39 @@ export const WorkspaceRouteProvider = (props: { ); export const useWorkspaceRoute = (): WorkspaceRouteValue => { - const value = useContext(WorkspaceRouteContext); + const value = useOptionalWorkspaceRoute(); if (!value) { throw new Error( - "useWorkspaceRoute must be used within a WorkspaceRouteProvider", + "useWorkspaceRoute requires a workspace URL segment or WorkspaceRouteProvider", ); } return value; }; -/** Optional variant for shell components rendered both inside and outside the - * workspace layout. Returns `null` when the URL is org-only. */ -export const useOptionalWorkspaceRoute = (): WorkspaceRouteValue | null => - useContext(WorkspaceRouteContext); +/** + * Returns the active workspace if one is encoded in the URL, otherwise null. + * Resolution order: + * 1. WorkspaceRouteContext (set by the `/$org/$workspace` layout) + * 2. URL `workspace` param + workspacesAtom lookup (so callers rendered + * above the layout — the parent shell — still see workspace context). + */ +export const useOptionalWorkspaceRoute = (): WorkspaceRouteValue | null => { + const fromContext = useContext(WorkspaceRouteContext); + const params = useParams({ strict: false }) as { + workspace?: string; + }; + const slug = params.workspace ?? null; + const result = useAtomValue(workspacesAtom); + return useMemo(() => { + if (fromContext) return fromContext; + if (!slug) return null; + if (!AsyncResult.isSuccess(result)) return null; + const found = result.value.workspaces.find((w) => w.slug === slug); + if (!found) return null; + return { + workspaceId: found.id, + workspaceSlug: found.slug, + workspaceName: found.name, + }; + }, [fromContext, slug, result]); +}; diff --git a/packages/core/sdk/src/client.ts b/packages/core/sdk/src/client.ts index 3424f7d07..a1c6bd134 100644 --- a/packages/core/sdk/src/client.ts +++ b/packages/core/sdk/src/client.ts @@ -192,6 +192,13 @@ export interface CreatePluginAtomClientOptions { * when forwarding to the Effect handler) — same convention as the * core `ExecutorApiClient`. */ readonly baseUrl?: string; + /** Override the HTTP client layer. Hosts that need URL-context-aware + * prefixing (e.g. cloud's `/api/:org/...` mounting) inject a wrapped + * `FetchHttpClient.layer` here so plugin requests pick up the active + * context the same way the core `ExecutorApiClient` does. */ + readonly httpClient?: import("effect").Layer.Layer< + import("effect/unstable/http").HttpClient.HttpClient + >; } /** @@ -211,14 +218,14 @@ export const createPluginAtomClient = < group: G, options: CreatePluginAtomClientOptions = {}, ) => { - const { baseUrl = "/api" } = options; + const { baseUrl = "/api", httpClient = FetchHttpClient.layer } = options; const pluginId = group.identifier; const bundle = HttpApi.make(`plugin-${pluginId}`).add(group); return AtomHttpApi.Service<`Plugin_${G["identifier"]}Client`>()( `Plugin_${pluginId}Client`, { api: bundle, - httpClient: FetchHttpClient.layer, + httpClient, baseUrl, }, ); diff --git a/packages/plugins/google-discovery/src/react/client.ts b/packages/plugins/google-discovery/src/react/client.ts index 4b296f6d1..1fb11f4cf 100644 --- a/packages/plugins/google-discovery/src/react/client.ts +++ b/packages/plugins/google-discovery/src/react/client.ts @@ -1,8 +1,12 @@ import { createPluginAtomClient } from "@executor-js/sdk/client"; import { getBaseUrl } from "@executor-js/react/api/base-url"; +import { ContextAwareHttpClient } from "@executor-js/react/api/http-client"; import { GoogleDiscoveryGroup } from "../api/group"; export const GoogleDiscoveryClient = createPluginAtomClient( GoogleDiscoveryGroup, - { baseUrl: getBaseUrl() }, + { + baseUrl: getBaseUrl(), + httpClient: ContextAwareHttpClient, + }, ); diff --git a/packages/plugins/graphql/src/react/client.ts b/packages/plugins/graphql/src/react/client.ts index 986ec8842..686e3a823 100644 --- a/packages/plugins/graphql/src/react/client.ts +++ b/packages/plugins/graphql/src/react/client.ts @@ -1,7 +1,9 @@ import { createPluginAtomClient } from "@executor-js/sdk/client"; import { getBaseUrl } from "@executor-js/react/api/base-url"; +import { ContextAwareHttpClient } from "@executor-js/react/api/http-client"; import { GraphqlGroup } from "../api/group"; export const GraphqlClient = createPluginAtomClient(GraphqlGroup, { baseUrl: getBaseUrl(), + httpClient: ContextAwareHttpClient, }); diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index af1b43a66..b512c9708 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -175,7 +175,12 @@ export const McpGroup = HttpApiGroup.make("mcp") params: ScopeParams, payload: NamespacePayload, success: RefreshSourceResponse, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], + error: [ + InternalError, + McpConnectionError, + McpToolDiscoveryError, + InvalidSourceWriteTarget, + ], }), ) .add( diff --git a/packages/plugins/mcp/src/react/client.ts b/packages/plugins/mcp/src/react/client.ts index 0d024dd35..19fd8fb67 100644 --- a/packages/plugins/mcp/src/react/client.ts +++ b/packages/plugins/mcp/src/react/client.ts @@ -1,7 +1,9 @@ import { createPluginAtomClient } from "@executor-js/sdk/client"; import { getBaseUrl } from "@executor-js/react/api/base-url"; +import { ContextAwareHttpClient } from "@executor-js/react/api/http-client"; import { McpGroup } from "../api/group"; export const McpClient = createPluginAtomClient(McpGroup, { baseUrl: getBaseUrl(), + httpClient: ContextAwareHttpClient, }); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index cb4c73695..d792fdccf 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -1058,7 +1058,7 @@ export interface McpPluginExtension { readonly refreshSource: ( namespace: string, scope: string, - ) => Effect.Effect<{ readonly toolCount: number }, McpExtensionFailure>; + ) => Effect.Effect<{ readonly toolCount: number }, McpSourceWriteFailure>; readonly getSource: ( namespace: string, scope: string, diff --git a/packages/plugins/onepassword/src/react/client.ts b/packages/plugins/onepassword/src/react/client.ts index adaebba2b..b85d2407f 100644 --- a/packages/plugins/onepassword/src/react/client.ts +++ b/packages/plugins/onepassword/src/react/client.ts @@ -1,7 +1,9 @@ import { createPluginAtomClient } from "@executor-js/sdk/client"; import { getBaseUrl } from "@executor-js/react/api/base-url"; +import { ContextAwareHttpClient } from "@executor-js/react/api/http-client"; import { OnePasswordGroup } from "../api/group"; export const OnePasswordClient = createPluginAtomClient(OnePasswordGroup, { baseUrl: getBaseUrl(), + httpClient: ContextAwareHttpClient, }); diff --git a/packages/plugins/openapi/src/react/client.ts b/packages/plugins/openapi/src/react/client.ts index b6af64f21..cb3be0383 100644 --- a/packages/plugins/openapi/src/react/client.ts +++ b/packages/plugins/openapi/src/react/client.ts @@ -1,7 +1,9 @@ import { createPluginAtomClient } from "@executor-js/sdk/client"; import { getBaseUrl } from "@executor-js/react/api/base-url"; +import { ContextAwareHttpClient } from "@executor-js/react/api/http-client"; import { OpenApiGroup } from "../api/group"; export const OpenApiClient = createPluginAtomClient(OpenApiGroup, { baseUrl: getBaseUrl(), + httpClient: ContextAwareHttpClient, }); diff --git a/packages/react/src/api/app-link.tsx b/packages/react/src/api/app-link.tsx new file mode 100644 index 000000000..6a061d33f --- /dev/null +++ b/packages/react/src/api/app-link.tsx @@ -0,0 +1,68 @@ +import { forwardRef } from "react"; +import { Link, useLocation, type LinkComponentProps } from "@tanstack/react-router"; + +import { appPrefixFor, parseUrlContext } from "./url-context"; + +// --------------------------------------------------------------------------- +// AppLink — TanStack `` that automatically prepends the active URL +// context (`/:org` or `/:org/:workspace`) to absolute `to` strings on cloud, +// while leaving them unchanged on the local app (no prefix). +// +// Use it instead of `` in code that lives in `@executor-js/react` and +// renders in both apps. The `to` prop is the absolute path against the +// flat (local) route tree — e.g. `to="/sources/add/$pluginKey"` — and the +// component resolves it to the right URL at render time. +// +// Param interpolation (`$name` -> params[name]) is handled here too so +// callers don't have to drop into manual string concat. Search/hash flow +// through to TanStack unchanged when the prefix is empty; on cloud they +// are spliced into the resolved string. + +const interpolate = ( + path: string, + params?: Record, +): string => { + if (!params) return path; + return path.replace(/\$([a-zA-Z_][\w]*)/g, (_match, name: string) => { + const v = params[name]; + return v != null ? encodeURIComponent(String(v)) : `$${name}`; + }); +}; + +const buildSearch = ( + search?: Record | ((prev: unknown) => unknown), +): string => { + if (!search || typeof search === "function") return ""; + const usp = new URLSearchParams(); + for (const [k, v] of Object.entries(search)) { + if (v == null) continue; + usp.set(k, String(v)); + } + const s = usp.toString(); + return s ? `?${s}` : ""; +}; + +export type AppLinkProps = Omit & { + /** Absolute path with TanStack-style `$param` placeholders, e.g. + * `/sources/add/$pluginKey`. Resolved to the active URL context on + * cloud and passed through unchanged on local. */ + to: string; + params?: Record; + search?: Record; +}; + +export const AppLink = forwardRef( + function AppLink({ to, params, search, ...rest }, ref) { + const location = useLocation(); + const ctx = parseUrlContext(location.pathname); + const prefix = appPrefixFor(ctx); + const interpolated = interpolate(to, params); + const normalized = interpolated.startsWith("/") + ? interpolated + : `/${interpolated}`; + const resolved = `${prefix}${normalized}${buildSearch(search)}`; + return ( + + ); + }, +); diff --git a/packages/react/src/api/base-url.tsx b/packages/react/src/api/base-url.tsx index 44a96b97f..3e1db08f8 100644 --- a/packages/react/src/api/base-url.tsx +++ b/packages/react/src/api/base-url.tsx @@ -1,12 +1,14 @@ +// --------------------------------------------------------------------------- +// Static API base URL — `${origin}/api` on the browser, dev fallback in SSR. +// Per-org / per-workspace prefixing happens at fetch time inside +// `ContextAwareHttpClient` (see `./http-client.tsx`); callers don't try to +// thread the active context through the base URL because Effect's +// `AtomHttpApi.Service` snapshots `baseUrl` at module load. +// --------------------------------------------------------------------------- + const DEFAULT_BASE_URL = "http://127.0.0.1:4000"; -let baseUrl = +export const getBaseUrl = (): string => typeof window !== "undefined" && typeof window.location?.origin === "string" ? `${window.location.origin}/api` : `${DEFAULT_BASE_URL}/api`; - -export const getBaseUrl = (): string => baseUrl; - -export const setBaseUrl = (url: string): void => { - baseUrl = url; -}; diff --git a/packages/react/src/api/href.tsx b/packages/react/src/api/href.tsx new file mode 100644 index 000000000..bf3c89ade --- /dev/null +++ b/packages/react/src/api/href.tsx @@ -0,0 +1,90 @@ +import { useCallback } from "react"; +import { useLocation } from "@tanstack/react-router"; + +// --------------------------------------------------------------------------- +// useAppHref — prepend the URL-context prefix (`/:org` or +// `/:org/:workspace`) to absolute app paths on cloud so shared pages built +// against the flat local route tree still navigate correctly. Local stays +// at no prefix. +// --------------------------------------------------------------------------- +// +// The shared `@executor-js/react` pages (sources, connections, command +// palette, etc.) hard-code `` against the local +// app's flat route tree. On cloud those routes live under +// `/${org}` (and optionally `/${org}/${workspace}`) — but the shared +// components can't statically depend on the cloud's route shape. +// +// `useAppHref(path, params?)` pulls the active org (and workspace) handle +// off `useLocation().pathname` and returns the right URL string. Pass it +// as `` instead of an absolute +// path. The hook is a no-op on local — there's no prefix to add. + +const RESERVED_FIRST_SEGMENTS = new Set(["api", "ingest", "assets", "auth"]); + +const splitContextPrefix = ( + pathname: string, +): { prefix: string; rest: string } => { + const parts = pathname.split("/").filter((p) => p.length > 0); + if (parts.length === 0) return { prefix: "", rest: pathname }; + const org = parts[0]!; + if (RESERVED_FIRST_SEGMENTS.has(org)) return { prefix: "", rest: pathname }; + // Reserved org-admin marker `/:org/-/...` keeps the prefix at org-only. + const second = parts[1]; + if (!second || second === "-") { + return { + prefix: `/${org}`, + rest: parts.slice(1).join("/"), + }; + } + return { + prefix: `/${org}/${second}`, + rest: parts.slice(2).join("/"), + }; +}; + +const interpolate = (path: string, params?: Record): string => { + if (!params) return path; + return path.replace(/\$([a-zA-Z_][\w]*)/g, (_match, name: string) => { + const v = params[name]; + return v != null ? encodeURIComponent(v) : `$${name}`; + }); +}; + +const buildSearch = (search?: Record): string => { + if (!search) return ""; + const usp = new URLSearchParams(); + for (const [k, v] of Object.entries(search)) { + if (v == null) continue; + usp.set(k, String(v)); + } + const s = usp.toString(); + return s ? `?${s}` : ""; +}; + +/** + * Returns a function that prefixes app-absolute paths with the active URL + * context. Use it instead of hard-coded Link `to` strings in components + * shared between local and cloud. + * + * Example: + * const appHref = useAppHref(); + * Add + */ +export const useAppHref = () => { + const location = useLocation(); + return useCallback( + ( + path: string, + params?: Record, + search?: Record, + ): string => { + const { prefix } = splitContextPrefix(location.pathname); + const interpolated = interpolate(path, params); + const normalized = interpolated.startsWith("/") + ? interpolated + : `/${interpolated}`; + return `${prefix}${normalized}${buildSearch(search)}`; + }, + [location.pathname], + ); +}; diff --git a/packages/react/src/api/http-client.tsx b/packages/react/src/api/http-client.tsx index 6fe283921..26ca3d22d 100644 --- a/packages/react/src/api/http-client.tsx +++ b/packages/react/src/api/http-client.tsx @@ -1,6 +1,8 @@ import { FetchHttpClient } from "effect/unstable/http"; import { Layer } from "effect"; +import { apiPrefixFor, parseUrlContext } from "./url-context"; + // --------------------------------------------------------------------------- // URL-context aware HTTP client layer // --------------------------------------------------------------------------- @@ -18,29 +20,6 @@ import { Layer } from "effect"; // based on the current `window.location.pathname`. Auth/admin routes that // stay unprefixed on the server (`/api/auth/...`, `/api/sentry-tunnel`, the // autumn billing proxy) are passed through untouched. -// -// This file lives in `@executor-js/react` so both the executor API client -// (`./client`) and the cloud-specific CloudApiClient share the same fetch -// wrapper. - -const RESERVED_FIRST_SEGMENTS = new Set(["api", "ingest", "assets", "auth"]); - -const apiPrefixFromLocation = (): string | null => { - if (typeof window === "undefined") return null; - const parts = window.location.pathname - .split("/") - .filter((p) => p.length > 0); - if (parts.length === 0) return null; - const org = parts[0]!; - if (RESERVED_FIRST_SEGMENTS.has(org)) return null; - // Workspace is only present when the second segment isn't the reserved - // `-` admin marker (`/:org/-/billing` etc are org-only). - const second = parts[1]; - if (second && second !== "-") { - return `/api/${org}/${second}`; - } - return `/api/${org}`; -}; const UNPREFIXED_API_PATHS = [ "/api/auth/", @@ -50,7 +29,11 @@ const UNPREFIXED_API_PATHS = [ const wrapFetch = (inner: typeof globalThis.fetch): typeof globalThis.fetch => (input, init) => { - const prefix = apiPrefixFromLocation(); + const ctx = + typeof window !== "undefined" + ? parseUrlContext(window.location.pathname) + : { kind: "none" as const }; + const prefix = apiPrefixFor(ctx); if (!prefix) return inner(input, init); const rewriteUrl = (raw: string): string => { @@ -65,6 +48,7 @@ const wrapFetch = (inner: typeof globalThis.fetch): typeof globalThis.fetch => if (UNPREFIXED_API_PATHS.some((p) => url.pathname.startsWith(p))) { return url.toString(); } + // Already prefixed — leave alone. if (url.pathname.startsWith(`${prefix}/`) || url.pathname === prefix) { return url.toString(); } diff --git a/packages/react/src/api/scope-context.tsx b/packages/react/src/api/scope-context.tsx index cdb533a46..1839f92f4 100644 --- a/packages/react/src/api/scope-context.tsx +++ b/packages/react/src/api/scope-context.tsx @@ -1,9 +1,11 @@ import * as React from "react"; -import { useAtomValue } from "@effect/atom-react"; +import { useLocation } from "@tanstack/react-router"; +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import type { ScopeId } from "@executor-js/sdk"; import { scopeAtom } from "./atoms"; +import { parseUrlContext } from "./url-context"; // --------------------------------------------------------------------------- // Scope context — bridges the server's `/scope/info` payload into React. @@ -55,9 +57,26 @@ const ScopeContext = React.createContext(null); /** * Provides the server scope to all children. * Renders the optional `fallback` until the scope is fetched. + * + * The scope endpoint's response depends on the URL context — the cloud + * middleware builds a workspace executor when the URL has `/:org/:workspace` + * and a global executor otherwise — so `scopeAtom`'s cache must invalidate + * when the user navigates between contexts. We watch `window.location.pathname` + * (parsed via `parseUrlContext`) and trigger a refresh whenever the active + * org/workspace pair changes. The cached value is reused inside a single + * context (cheap re-renders don't refetch). */ export function ScopeProvider(props: React.PropsWithChildren<{ fallback?: React.ReactNode }>) { const result = useAtomValue(scopeAtom); + const refresh = useAtomRefresh(scopeAtom); + const contextKey = useUrlContextKey(); + const lastKey = React.useRef(contextKey); + React.useEffect(() => { + if (lastKey.current !== contextKey) { + lastKey.current = contextKey; + refresh(); + } + }, [contextKey, refresh]); if (AsyncResult.isSuccess(result)) { return {props.children}; @@ -66,6 +85,23 @@ export function ScopeProvider(props: React.PropsWithChildren<{ fallback?: React. return <>{props.fallback ?? null}; } +/** + * Returns a stable cache key derived from the active URL context. Different + * `/:org` and `/:org/:workspace` paths produce different keys; same context + * with different leaf paths returns the same key (so we don't refetch on + * page navigation within the same scope stack). + */ +function useUrlContextKey(): string { + const location = useLocation(); + return React.useMemo(() => { + const ctx = parseUrlContext(location.pathname); + if (ctx.kind === "workspace") + return `ws:${ctx.orgHandle}/${ctx.workspaceSlug}`; + if (ctx.kind === "global") return `org:${ctx.orgHandle}`; + return "none"; + }, [location.pathname]); +} + /** * Returns the active display/write scope id. Prefer `useActiveWriteScopeId()` * for new code — this hook is kept as an alias so existing callers don't diff --git a/packages/react/src/api/url-context.tsx b/packages/react/src/api/url-context.tsx new file mode 100644 index 000000000..b157e2e37 --- /dev/null +++ b/packages/react/src/api/url-context.tsx @@ -0,0 +1,66 @@ +// --------------------------------------------------------------------------- +// URL context parsing — shared between the fetch wrapper and the AppLink +// helpers so both agree on what counts as a workspace slug vs an org-level +// sub-route. Plan reserves `-` as the org-admin marker; the sub-route names +// below are the org-level pages that share the URL space with workspace +// slugs (so a workspace can't be named "sources" etc.). +// --------------------------------------------------------------------------- + +/** First-segment names that aren't an org handle. */ +export const RESERVED_FIRST_SEGMENTS = new Set([ + "api", + "ingest", + "assets", + "auth", +]); + +/** + * Second-segment names that aren't a workspace slug — they're either the + * org-admin marker (`-`) or org-level sub-routes that live at `/:org/`. + * Anything else in the second slot is a workspace slug. + */ +export const RESERVED_SECOND_SEGMENTS = new Set([ + "-", + "sources", + "connections", + "secrets", + "policies", + "tools", +]); + +export type UrlContext = + | { kind: "global"; orgHandle: string } + | { kind: "workspace"; orgHandle: string; workspaceSlug: string } + | { kind: "none" }; + +export const parseUrlContext = (pathname: string): UrlContext => { + const parts = pathname.split("/").filter((p) => p.length > 0); + if (parts.length === 0) return { kind: "none" }; + const orgHandle = parts[0]!; + if (RESERVED_FIRST_SEGMENTS.has(orgHandle)) return { kind: "none" }; + const second = parts[1]; + if (!second || RESERVED_SECOND_SEGMENTS.has(second)) { + return { kind: "global", orgHandle }; + } + return { kind: "workspace", orgHandle, workspaceSlug: second }; +}; + +/** API URL prefix for the current page. `null` outside an org URL (e.g. on + * the local app where org-prefixing is a no-op). */ +export const apiPrefixFor = (ctx: UrlContext): string | null => { + if (ctx.kind === "global") return `/api/${ctx.orgHandle}`; + if (ctx.kind === "workspace") { + return `/api/${ctx.orgHandle}/${ctx.workspaceSlug}`; + } + return null; +}; + +/** App-route prefix for building hrefs. Empty string when there is no org + * context (local app, login screens). */ +export const appPrefixFor = (ctx: UrlContext): string => { + if (ctx.kind === "global") return `/${ctx.orgHandle}`; + if (ctx.kind === "workspace") { + return `/${ctx.orgHandle}/${ctx.workspaceSlug}`; + } + return ""; +}; diff --git a/packages/react/src/components/command-palette.tsx b/packages/react/src/components/command-palette.tsx index 849d9ac58..be86eb827 100644 --- a/packages/react/src/components/command-palette.tsx +++ b/packages/react/src/components/command-palette.tsx @@ -5,6 +5,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { PlusIcon } from "lucide-react"; import { SourceFavicon } from "./source-favicon"; import { sourcesAtom } from "../api/atoms"; +import { useAppHref } from "../api/href"; import { useActiveWriteScopeId } from "../hooks/use-scope"; import { useSourcePlugins } from "@executor-js/sdk/client"; import { @@ -101,26 +102,25 @@ export function CommandPalette() { // Path templates depend on the consuming app's route tree (local vs cloud's // `/$org/...`). The shared package can't be typed against both — `as never` // defers to runtime routing. + const appHref = useAppHref(); const goToSource = useCallback( (id: string) => { close(); void navigate({ - to: "/sources/$namespace" as never, - params: { namespace: id } as never, + to: appHref("/sources/$namespace", { namespace: id }) as never, }); }, - [close, navigate], + [close, navigate, appHref], ); const goToAdd = useCallback( (pluginKey: string) => { close(); void navigate({ - to: "/sources/add/$pluginKey" as never, - params: { pluginKey } as never, + to: appHref("/sources/add/$pluginKey", { pluginKey }) as never, }); }, - [close, navigate], + [close, navigate, appHref], ); const goToPreset = useCallback( @@ -129,12 +129,10 @@ export function CommandPalette() { const search: Record = { preset: presetId }; if (presetUrl) search.url = presetUrl; void navigate({ - to: "/sources/add/$pluginKey" as never, - params: { pluginKey } as never, - search: search as never, + to: appHref("/sources/add/$pluginKey", { pluginKey }, search) as never, }); }, - [close, navigate], + [close, navigate, appHref], ); return ( diff --git a/packages/react/src/pages/policies.tsx b/packages/react/src/pages/policies.tsx index ade532161..3704afa73 100644 --- a/packages/react/src/pages/policies.tsx +++ b/packages/react/src/pages/policies.tsx @@ -13,6 +13,11 @@ import { } from "../api/atoms"; import { policyWriteKeys } from "../api/reactivity-keys"; import { useActiveWriteScopeId } from "../hooks/use-scope"; +import { + CredentialTargetSelector, + useCredentialTargetState, +} from "../plugins/credential-target-selector"; +import type { ScopeId } from "@executor-js/sdk"; import { badgeVariants } from "../components/badge"; import { cn } from "../lib/utils"; import { @@ -102,11 +107,20 @@ const isValidPattern = (pattern: string): boolean => { // --------------------------------------------------------------------------- function AddPolicyForm(props: { - onSubmit: (input: { pattern: string; action: ToolPolicyAction }) => void; + onSubmit: (input: { + pattern: string; + action: ToolPolicyAction; + scopeId: ScopeId; + }) => void; busy: boolean; }) { const [pattern, setPattern] = useState(""); const [action, setAction] = useState("require_approval"); + // Policies live at every scope in the URL context's stack — same model + // as secrets/connections. The selector defaults to the active write + // scope (workspace in workspace context, org in global) and lets the + // user opt into a personal-only override without leaving the form. + const target = useCredentialTargetState(); const valid = isValidPattern(pattern); return ( @@ -115,7 +129,7 @@ function AddPolicyForm(props: { onSubmit={(e) => { e.preventDefault(); if (!valid) return; - props.onSubmit({ pattern, action }); + props.onSubmit({ pattern, action, scopeId: target.value }); setPattern(""); setAction("require_approval"); }} @@ -154,6 +168,12 @@ function AddPolicyForm(props: { +
@@ -420,10 +425,10 @@ function PresetGrid(props: { if (preset.url) search.url = preset.url; return ( - @@ -447,7 +452,7 @@ function PresetGrid(props: { {pluginLabel} - + ); }) @@ -488,7 +493,7 @@ function SourceGrid(props: { sources: readonly SourceRow[] }) { searchText={`${s.name} ${s.id} ${s.kind}`} className={overridden ? "opacity-60" : undefined} > - + @@ -509,7 +514,7 @@ function SourceGrid(props: { sources: readonly SourceRow[] }) { {s.kind} )} - + ); })} diff --git a/packages/react/src/pages/tools.tsx b/packages/react/src/pages/tools.tsx index 6ece963d2..95af604bc 100644 --- a/packages/react/src/pages/tools.tsx +++ b/packages/react/src/pages/tools.tsx @@ -1,10 +1,10 @@ import { useMemo, useState } from "react"; -import { Link } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { effectivePolicyFromSorted } from "@executor-js/sdk"; import { policiesOptimisticAtom, toolsAtom } from "../api/atoms"; +import { AppLink } from "../api/app-link"; import { useActiveWriteScopeId } from "../hooks/use-scope"; import { usePolicyActions } from "../hooks/use-policy-actions"; import { ToolTree, type ToolSummary } from "../components/tool-tree"; @@ -77,7 +77,7 @@ export function ToolsPage() {