From b354f3ec6bc0d002532f53f0db647106d7693f56 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 4 May 2026 02:05:44 -0700 Subject: [PATCH 1/8] Policies expose the full storage stack with target selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK's `executor.policies.create` and `policies.list` already accept every scope in the URL-resolved stack (`assertScopedWrite` validates membership; `resolveToolPolicy` ranks by `scopeRank` so the innermost matching row wins). What was missing was the visible UI surface — the add-policy form pinned writes to the active write scope. `AddPolicyForm` now renders a `CredentialTargetSelector` ("Apply to") so users can target any of the four levels in workspace context (or both in global). The page-level `scopeId` continues to drive the optimistic list/cache family; the form passes the explicit target through to the API call. `policies-stack.node.test.ts` pins both invariants: - All four scope levels accept policy writes from workspace context, and listing tags each row with its owning scope. - When the same pattern is written at multiple scopes, `policies.list` returns rows sorted innermost-first — the executor's invocation path consumes that ordering for "innermost wins" precedence. --- .../src/services/policies-stack.node.test.ts | 120 ++++++++++++++++++ packages/react/src/pages/policies.tsx | 36 +++++- 2 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 apps/cloud/src/services/policies-stack.node.test.ts diff --git a/apps/cloud/src/services/policies-stack.node.test.ts b/apps/cloud/src/services/policies-stack.node.test.ts new file mode 100644 index 000000000..75d7cba6c --- /dev/null +++ b/apps/cloud/src/services/policies-stack.node.test.ts @@ -0,0 +1,120 @@ +// Policies storage-stack invariants — verifies that policy writes accept +// any scope in the workspace stack (`user-workspace → workspace → +// user-org → org`) and that listing from workspace context returns every +// row sorted innermost-first. Pins the SDK precedence rule used by tool +// invocation: the innermost row wins. + +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + asWorkspaceUser, + orgScopeId, + testUserOrgScopeId, + testUserWorkspaceScopeId, + testWorkspaceScopeId, +} from "./__test-harness__/api-harness"; + +const PATTERN = "*"; // every tool + +describe("policies storage stack in workspace context", () => { + 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/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: { +
From 53e1b2af2ea92b648eb729616b8ed624c4acb98c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 4 May 2026 09:28:16 -0700 Subject: [PATCH 6/8] Reserve org sub-route names so /:org/sources isn't read as a workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fetch wrapper and href helpers split window.location.pathname into :org / :workspace assuming the second segment is a workspace slug. On /rhys-org/sources/add/openapi that turned 'sources' into a workspace, so client requests for /api/scope ended up at /api/rhys-org/sources/scope and 404'd. Adds RESERVED_SECOND_SEGMENTS (sources, connections, secrets, policies, tools, plus the org-admin marker '-') in a shared parseUrlContext. Workspace slugs can't collide with these reserved names — same constraint TanStack's file-routes already imposes — and both the fetch prefixer and the new AppLink resolve URL context the same way. Also introduces as a TanStack Link wrapper that prepends the context prefix to absolute 'to' strings. The cloud-shared package now uses AppLink instead of hand-spliced strings via useAppHref for static hrefs (sources detail, source-add presets, manage-policies button). --- packages/react/src/api/app-link.tsx | 68 ++++++++++++++++++++++++++ packages/react/src/api/http-client.tsx | 32 +++--------- packages/react/src/api/url-context.tsx | 66 +++++++++++++++++++++++++ packages/react/src/pages/sources.tsx | 24 ++++----- packages/react/src/pages/tools.tsx | 6 +-- 5 files changed, 157 insertions(+), 39 deletions(-) create mode 100644 packages/react/src/api/app-link.tsx create mode 100644 packages/react/src/api/url-context.tsx 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/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/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/pages/sources.tsx b/packages/react/src/pages/sources.tsx index aec7b7702..595a7b838 100644 --- a/packages/react/src/pages/sources.tsx +++ b/packages/react/src/pages/sources.tsx @@ -1,5 +1,5 @@ import { Suspense, useCallback, useMemo, useState } from "react"; -import { Link, useNavigate } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; import { useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { PlusIcon } from "lucide-react"; @@ -10,6 +10,7 @@ import { type SourcePreset, } from "@executor-js/sdk/client"; import { detectSource } from "../api/atoms"; +import { AppLink } from "../api/app-link"; import { useAppHref } from "../api/href"; import { useSourcesWithPending } from "../api/optimistic"; import { useActiveWriteScopeId, useScopeStack } from "../hooks/use-scope"; @@ -314,14 +315,15 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) =>

Or add manually

{sourcePlugins.map((p) => ( - {p.label} - + ))}
@@ -376,7 +378,6 @@ function PresetGrid(props: { * search/URL input. Empty string disables filtering. */ searchQuery?: string; }) { - const appHref = useAppHref(); const allPresets = useMemo(() => { const entries: PresetEntry[] = []; for (const plugin of props.plugins) { @@ -424,8 +425,10 @@ function PresetGrid(props: { if (preset.url) search.url = preset.url; return ( - @@ -449,7 +452,7 @@ function PresetGrid(props: { {pluginLabel} - +
); }) @@ -466,7 +469,6 @@ function PresetGrid(props: { function SourceGrid(props: { sources: readonly SourceRow[] }) { const sourcePlugins = useSourcePlugins(); - const appHref = useAppHref(); const pluginByKind = useMemo(() => { const out = new Map(); for (const p of sourcePlugins) out.set(p.key, p); @@ -491,7 +493,7 @@ function SourceGrid(props: { sources: readonly SourceRow[] }) { searchText={`${s.name} ${s.id} ${s.kind}`} className={overridden ? "opacity-60" : undefined} > - + @@ -512,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 1efcb043b..95af604bc 100644 --- a/packages/react/src/pages/tools.tsx +++ b/packages/react/src/pages/tools.tsx @@ -1,11 +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 { useAppHref } from "../api/href"; +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"; @@ -15,7 +14,6 @@ import { Skeleton } from "../components/skeleton"; export function ToolsPage() { const scopeId = useActiveWriteScopeId(); - const appHref = useAppHref(); const tools = useAtomValue(toolsAtom(scopeId)); const policies = useAtomValue(policiesOptimisticAtom(scopeId)); const policyActions = usePolicyActions(scopeId); @@ -79,7 +77,7 @@ export function ToolsPage() {
From 6545b050e184270d6ad9761dbd13190d9d1af2c6 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 4 May 2026 09:33:22 -0700 Subject: [PATCH 7/8] Make ContextAwareHttpClient the single source of truth for /api prefixing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin clients (openapi, mcp, graphql, onepassword, google-discovery) all snapshot getBaseUrl() at module load. routes/$org.tsx + routes/ $org/$workspace.tsx then mutated the same module variable via setBaseUrl, racing module load order — when a plugin module loaded AFTER setBaseUrl fired it baked /api/${handle} into outgoing URLs. The fetch wrapper then prefixed AGAIN, producing /api/${handle}/${workspace}/${handle}/scopes/... and 404s. createPluginAtomClient now accepts an httpClient option. Each plugin client passes ContextAwareHttpClient so URL-rewriting is consistent across the executor + cloud auth + plugin clients. Drops setBaseUrl entirely; getBaseUrl is back to a pure function returning ${origin}/api. Removes the setBaseUrl calls from /$org.tsx and /$org/$workspace.tsx — they're no longer needed since the wrapper handles per-request prefixing. --- apps/cloud/src/routes/$org.tsx | 9 --------- apps/cloud/src/routes/$org/$workspace.tsx | 13 ------------- packages/core/sdk/src/client.ts | 11 +++++++++-- .../plugins/google-discovery/src/react/client.ts | 6 +++++- packages/plugins/graphql/src/react/client.ts | 2 ++ packages/plugins/mcp/src/react/client.ts | 2 ++ packages/plugins/onepassword/src/react/client.ts | 2 ++ packages/plugins/openapi/src/react/client.ts | 2 ++ packages/react/src/api/base-url.tsx | 16 +++++++++------- 9 files changed, 31 insertions(+), 32 deletions(-) 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 ( ; } /** @@ -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/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/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/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; -}; From cfc3b4210a7479f89839fc4bebf68d5c249eeb2a Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 4 May 2026 09:39:05 -0700 Subject: [PATCH 8/8] Refresh scopeAtom when URL context changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scopeAtom caches /scope responses for 5 minutes. The cloud middleware returns different scope stacks for /api/:org/scope (global) vs /api/:org/:workspace/scope (workspace), so the cached value is stale the moment the user navigates between contexts — the SourceTargetSelector on /:org/:workspace/sources/add/openapi only saw the org stack and showed Global as the only option. ScopeProvider now reads useLocation(), derives a context key (org-handle / workspace-slug pair), and triggers useAtomRefresh on change. Stays cached within a single context — same-context page navigation doesn't refetch. --- packages/react/src/api/scope-context.tsx | 38 +++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) 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