From d05d7cb73a8a97c20ee33c140331c312968fd5c0 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 4 May 2026 01:54:53 -0700 Subject: [PATCH] Reject source writes to personal scopes; visible target selector in add-source forms Source-definition writes can only target shareable scopes (org or workspace). Personal scopes (`user_org_*` / `user_workspace_*`) are reserved for credentials, connections, and policies in the v1 product model. The SDK now raises `InvalidSourceWriteTargetError` when `ctx.core.sources.register` is called with a personal scope; the openapi / mcp / graphql / google-discovery API groups expose this as a 422 on their addSource/addSpec endpoints so clients see a typed recoverable error rather than a 500. UI: every add-source form now mounts a `SourceTargetSelector` shared component (`packages/react/src/plugins/source-target-selector.tsx`) that renders Workspace / Global options and passes the selected scope id explicitly to the underlying API call. The selector defaults to the URL context's active write scope and skips personal scopes, so the caller never invents a default that the SDK would reject. The cloud test harness covers the legal write paths (workspace and org targets from workspace context). The personal-scope rejection has SDK test coverage in `executor.test.ts`; the HTTP layer wires the same error through with `httpApiStatus: 422`. --- .../src/services/source-target.node.test.ts | 98 ++++++++++++ packages/core/sdk/src/errors.ts | 23 +++ packages/core/sdk/src/executor.test.ts | 60 ++++++++ packages/core/sdk/src/executor.ts | 24 ++- packages/core/sdk/src/index.ts | 1 + packages/core/sdk/src/plugin.ts | 6 +- .../plugins/google-discovery/src/api/group.ts | 12 +- .../src/react/AddGoogleDiscoverySource.tsx | 14 +- .../google-discovery/src/sdk/plugin.ts | 6 +- packages/plugins/graphql/src/api/group.ts | 8 +- .../graphql/src/react/AddGraphqlSource.tsx | 14 +- packages/plugins/graphql/src/sdk/plugin.ts | 10 +- packages/plugins/mcp/src/api/group.ts | 17 ++- .../plugins/mcp/src/react/AddMcpSource.tsx | 21 ++- packages/plugins/mcp/src/sdk/plugin.ts | 12 +- packages/plugins/openapi/src/api/group.ts | 15 +- .../openapi/src/react/AddOpenApiSource.tsx | 16 +- packages/plugins/openapi/src/sdk/plugin.ts | 14 +- .../src/plugins/source-target-selector.tsx | 144 ++++++++++++++++++ 19 files changed, 493 insertions(+), 22 deletions(-) create mode 100644 apps/cloud/src/services/source-target.node.test.ts create mode 100644 packages/react/src/plugins/source-target-selector.tsx diff --git a/apps/cloud/src/services/source-target.node.test.ts b/apps/cloud/src/services/source-target.node.test.ts new file mode 100644 index 000000000..8c43aac99 --- /dev/null +++ b/apps/cloud/src/services/source-target.node.test.ts @@ -0,0 +1,98 @@ +// Source-definition write target invariant — the cloud half of the +// `InvalidSourceWriteTargetError` contract. The SDK-level test in +// `packages/core/sdk/src/executor.test.ts` covers the rejection path; this +// suite covers the HTTP boundary cases the SDK can't see: +// +// - addSpec under the URL context's workspace scope succeeds and lands +// at `workspace_`. +// - addSpec under the org/global scope from workspace context succeeds +// (still legal — `org` is in the workspace stack). +// +// The personal-scope rejection paths are exercised at the SDK level, +// because Effect's HTTP path matcher has trouble round-tripping the cloud's +// long compound `user_*` scope ids; that's a routing limitation, not a +// product gap, and we still get coverage of the SDK guard from the SDK +// suite. The InvalidSourceWriteTargetError is wired through the openapi / +// mcp / graphql / google-discovery API groups with `httpApiStatus: 422`, so +// when the SDK fires the error, the HTTP edge already has a schema for it. + +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + asOrg, + asWorkspace, + orgScopeId, + testWorkspaceScopeId, +} from "./__test-harness__/api-harness"; + +const SPEC = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Target Test", version: "1.0.0" }, + paths: { + "/ping": { + get: { + operationId: "ping", + summary: "ping", + responses: { "200": { description: "ok" } }, + }, + }, + }, +}); + +describe("source-definition write target invariant (HTTP)", () => { + it.effect( + "addSpec under the workspace scope from workspace context succeeds and lands at workspace scope", + () => + 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); + + yield* asWorkspace(org, slug, (client) => + client.openapi.addSpec({ + params: { scopeId: wsScope }, + payload: { spec: SPEC, namespace }, + }), + ); + + const sources = yield* asWorkspace(org, slug, (client) => + client.sources.list({ params: { scopeId: wsScope } }), + ); + const row = sources.find((s) => s.id === namespace); + expect(row?.scopeId).toBe(wsScope); + }), + ); + + it.effect( + "addSpec from workspace context targeting the global org scope is allowed (still in stack)", + () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + const slug = `ws_${crypto.randomUUID().slice(0, 8)}`; + const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; + const orgScope = orgScopeId(org); + + yield* asWorkspace(org, slug, (client) => + client.openapi.addSpec({ + params: { scopeId: orgScope }, + payload: { spec: SPEC, namespace }, + }), + ); + + const sources = yield* asWorkspace(org, slug, (client) => + client.sources.list({ params: { scopeId: orgScope } }), + ); + const row = sources.find((s) => s.id === namespace); + expect(row?.scopeId).toBe(orgScope); + + // Same row visible from the org-only context too — confirms the + // write actually landed at the global scope, not at the workspace. + const orgVisible = yield* asOrg(org, (client) => + client.sources.list({ params: { scopeId: orgScope } }), + ); + expect(orgVisible.find((s) => s.id === namespace)).toBeDefined(); + }), + ); +}); diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index d625aaf78..9c78b058b 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -69,6 +69,28 @@ export class SourceRemovalNotAllowedError extends Schema.TaggedErrorClass()( + "InvalidSourceWriteTargetError", + { + scopeId: Schema.String, + reason: Schema.String, + }, +) { + static annotations = { httpApiStatus: 422 }; +} + // --------------------------------------------------------------------------- // Secrets // --------------------------------------------------------------------------- @@ -153,6 +175,7 @@ export type ExecutorError = | ToolBlockedError | SourceNotFoundError | SourceRemovalNotAllowedError + | InvalidSourceWriteTargetError | SecretNotFoundError | SecretResolutionError | SecretOwnedByConnectionError diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index b1c3ac8a7..3b530e261 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -452,6 +452,66 @@ describe("createExecutor", () => { }), ); + it.effect( + "rejects sources.register at a personal scope (user_org_*) with InvalidSourceWriteTargetError", + () => + Effect.gen(function* () { + // Mirrors the cloud's workspace stack: the request reached this + // executor with a personal scope id in the stack (legal for + // secret/connection writes) but is now trying to register a + // source definition there. The SDK guard fires regardless of + // which plugin invoked `core.sources.register`. + const personalScope = ScopeId.make("user_org_u1_org_a"); + const orgScope = ScopeId.make("org_a"); + const personalPlugin = definePlugin(() => ({ + id: "personal-test" as const, + storage: () => ({}), + extension: (ctx) => ({ + registerAt: (scope: ScopeId) => + ctx.core.sources.register({ + id: "x", + scope, + kind: "personal-test", + name: "x", + canRemove: true, + tools: [{ name: "tool", description: "" }], + }), + }), + })); + + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [personalPlugin()] as const, + scopes: [ + new Scope({ + id: personalScope, + name: "personal", + createdAt: new Date(), + }), + new Scope({ + id: orgScope, + name: "org", + createdAt: new Date(), + }), + ], + }), + ); + + const exit = yield* executor[ + "personal-test" + ].registerAt(personalScope).pipe(Effect.exit); + expect(exit._tag).toBe("Failure"); + const err = Result.isFailure(exit) ? exit.cause : null; + const errStr = JSON.stringify(err); + expect(errStr).toContain("InvalidSourceWriteTargetError"); + + // Same call to a non-personal scope (the org) succeeds. + yield* executor["personal-test"].registerAt(orgScope); + const sources = yield* executor.sources.list(); + expect(sources.find((s) => s.id === "x")?.scopeId).toBe(orgScope); + }), + ); + it.effect("handles deeply-namespaced tool names (dots in name)", () => Effect.gen(function* () { const namespacedPlugin = definePlugin(() => ({ diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 67a593429..5d19635f2 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -48,6 +48,7 @@ import { ConnectionProviderNotRegisteredError, ConnectionReauthRequiredError, ConnectionRefreshNotSupportedError, + InvalidSourceWriteTargetError, NoHandlerError, PluginNotLoadedError, SecretOwnedByConnectionError, @@ -412,6 +413,16 @@ const staticDeclToTool = ( // never touch these functions. // --------------------------------------------------------------------------- +// Source-definition writes only target shareable scopes (org/workspace). +// Personal scopes (user-org / user-workspace in cloud) are reserved for +// credentials, connections, and policies — sources written there would +// be invisible to anyone else, which the v1 product model excludes. The +// cloud's id helpers in `apps/cloud/src/services/ids.ts` produce +// `user_org_*` / `user_workspace_*` prefixes; deployments without those +// conventions (local CLI) never trigger the check. +const isPersonalScope = (scopeId: string): boolean => + scopeId.startsWith("user_org_") || scopeId.startsWith("user_workspace_"); + // Upsert shape: delete any existing source + tools + definitions for // `input.id` before creating fresh rows. Keeps replayable — boot-time // sync from executor.jsonc can call register() on rows that already @@ -420,8 +431,19 @@ const writeSourceInput = ( core: TypedAdapter, pluginId: string, input: SourceInput, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { + if (isPersonalScope(input.scope)) { + return yield* Effect.fail( + new InvalidSourceWriteTargetError({ + scopeId: input.scope, + reason: + "source-definition writes must target a shareable scope " + + "(org or workspace); personal scopes are reserved for " + + "credentials, connections, and policies.", + }), + ); + } yield* deleteSourceById(core, input.id, input.scope); const now = new Date(); diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 67a1957bf..4b1aaf7f5 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -51,6 +51,7 @@ export { NoHandlerError, SourceNotFoundError, SourceRemovalNotAllowedError, + InvalidSourceWriteTargetError, PluginNotLoadedError, SecretNotFoundError, SecretResolutionError, diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 0f690f561..968058038 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -33,6 +33,7 @@ import type { ConnectionProviderNotRegisteredError, ConnectionReauthRequiredError, ConnectionRefreshNotSupportedError, + InvalidSourceWriteTargetError, SecretOwnedByConnectionError, } from "./errors"; import type { OAuthService } from "./oauth"; @@ -113,7 +114,10 @@ export interface PluginCtx { readonly sources: { readonly register: ( input: SourceInput, - ) => Effect.Effect; + ) => Effect.Effect< + void, + StorageFailure | InvalidSourceWriteTargetError + >; readonly unregister: ( sourceId: string, ) => Effect.Effect; diff --git a/packages/plugins/google-discovery/src/api/group.ts b/packages/plugins/google-discovery/src/api/group.ts index 7f823bfee..9771e47c6 100644 --- a/packages/plugins/google-discovery/src/api/group.ts +++ b/packages/plugins/google-discovery/src/api/group.ts @@ -1,8 +1,16 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { ScopeId, SecretBackedValue } from "@executor-js/sdk/core"; +import { + InvalidSourceWriteTargetError, + ScopeId, + SecretBackedValue, +} from "@executor-js/sdk/core"; import { InternalError } from "@executor-js/api"; import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "../sdk/errors"; + +const InvalidSourceWriteTarget = InvalidSourceWriteTargetError.annotate({ + httpApiStatus: 422, +}); import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source"; export { HttpApiSchema }; @@ -114,7 +122,7 @@ export const GoogleDiscoveryGroup = HttpApiGroup.make("googleDiscovery") params: { scopeId: ScopeId }, payload: AddSourcePayload, success: AddSourceResponse, - error: GoogleDiscoveryErrors, + error: [...GoogleDiscoveryErrors, InvalidSourceWriteTarget], }), ) .add( diff --git a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx index bca1c86aa..debb8512d 100644 --- a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +++ b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx @@ -3,7 +3,10 @@ import { useAtomSet } from "@effect/atom-react"; import { usePendingSources } from "@executor-js/react/api/optimistic"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; -import { useScope } from "@executor-js/react/api/scope-context"; +import { + SourceTargetSelector, + useSourceTargetState, +} from "@executor-js/react/plugins/source-target-selector"; import type { SecretPickerSecret } from "@executor-js/react/plugins/secret-picker"; import { CreatableSecretPicker } from "@executor-js/react/plugins/secret-header-auth"; import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; @@ -201,7 +204,8 @@ export default function AddGoogleDiscoverySource(props: { slugifyNamespace(probe?.name ?? selectedTemplate?.name ?? "") || "google"; - const scopeId = useScope(); + const target = useSourceTargetState(); + const scopeId = target.value; const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promise" }); const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promise" }); const { beginAdd } = usePendingSources(); @@ -452,6 +456,12 @@ export default function AddGoogleDiscoverySource(props: { namespacePlaceholder="google_sheets" /> + + {probe && (
diff --git a/packages/plugins/google-discovery/src/sdk/plugin.ts b/packages/plugins/google-discovery/src/sdk/plugin.ts index db5aea6bc..74fbbe04b 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.ts @@ -1,6 +1,7 @@ import { Effect, Option } from "effect"; import { + InvalidSourceWriteTargetError, SourceDetectionResult, definePlugin, resolveSecretBackedMap, @@ -95,7 +96,10 @@ export interface GoogleDiscoveryPluginExtension { input: GoogleDiscoveryAddSourceInput, ) => Effect.Effect< { readonly toolCount: number; readonly namespace: string }, - GoogleDiscoveryParseError | GoogleDiscoverySourceError | StorageFailure + | GoogleDiscoveryParseError + | GoogleDiscoverySourceError + | InvalidSourceWriteTargetError + | StorageFailure >; readonly removeSource: (namespace: string, scope: string) => Effect.Effect; readonly getSource: ( diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index d32719aa2..f7fb98c57 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -1,6 +1,6 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { ScopeId } from "@executor-js/sdk/core"; +import { InvalidSourceWriteTargetError, ScopeId } from "@executor-js/sdk/core"; import { InternalError } from "@executor-js/api"; import { @@ -9,6 +9,10 @@ import { } from "../sdk/errors"; import { GraphqlSourceAuth, HeaderValue } from "../sdk/types"; +const InvalidSourceWriteTarget = InvalidSourceWriteTargetError.annotate({ + httpApiStatus: 422, +}); + // StoredGraphqlSource shape as an HTTP response schema. Kept local to the // api layer because the sdk-side `StoredGraphqlSource` is a plain interface. export const StoredSourceSchema = Schema.Struct({ @@ -102,7 +106,7 @@ export const GraphqlGroup = HttpApiGroup.make("graphql") params: ScopeParams, payload: AddSourcePayload, success: AddSourceResponse, - error: GraphqlErrors, + error: [...GraphqlErrors, InvalidSourceWriteTarget], }), ) .add( diff --git a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx index c4c615434..437028e75 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx @@ -1,7 +1,10 @@ import { useCallback, useState } from "react"; import { useAtomSet } from "@effect/atom-react"; -import { useScope } from "@executor-js/react/api/scope-context"; +import { + SourceTargetSelector, + useSourceTargetState, +} from "@executor-js/react/plugins/source-target-selector"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { usePendingSources } from "@executor-js/react/api/optimistic"; import { @@ -54,7 +57,8 @@ export default function AddGraphqlSource(props: { const [authMode, setAuthMode] = useState("none"); const [tokens, setTokens] = useState(null); - const scopeId = useScope(); + const target = useSourceTargetState(); + const scopeId = target.value; const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" }); const { beginAdd } = usePendingSources(); const secretList = useSecretPickerSecrets(); @@ -171,6 +175,12 @@ export default function AddGraphqlSource(props: { + + Effect.Effect< { readonly toolCount: number; readonly namespace: string }, - GraphqlExtensionFailure + GraphqlSourceWriteFailure >; /** Remove all tools from a previously added GraphQL source by namespace. diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 9796f6732..af1b43a66 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -1,9 +1,17 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { ScopeId, SecretBackedMap } from "@executor-js/sdk/core"; +import { + InvalidSourceWriteTargetError, + ScopeId, + SecretBackedMap, +} from "@executor-js/sdk/core"; import { InternalError } from "@executor-js/api"; import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; + +const InvalidSourceWriteTarget = InvalidSourceWriteTargetError.annotate({ + httpApiStatus: 422, +}); import { McpStoredSourceSchema } from "../sdk/stored-source"; // --------------------------------------------------------------------------- @@ -146,7 +154,12 @@ export const McpGroup = HttpApiGroup.make("mcp") params: ScopeParams, payload: AddSourcePayload, success: AddSourceResponse, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], + error: [ + InternalError, + McpConnectionError, + McpToolDiscoveryError, + InvalidSourceWriteTarget, + ], }), ) .add( diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index 35c8db9db..b9bf1711f 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -1,7 +1,10 @@ import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useAtomSet } from "@effect/atom-react"; -import { useScope } from "@executor-js/react/api/scope-context"; +import { + SourceTargetSelector, + useSourceTargetState, +} from "@executor-js/react/plugins/source-target-selector"; import { Button } from "@executor-js/react/components/button"; import { CardStack, @@ -267,7 +270,8 @@ export default function AddMcpSource(props: { remoteUrl ? { step: "url" as const, url: remoteUrl } : init, ); - const scopeId = useScope(); + const target = useSourceTargetState(); + const scopeId = target.value; const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" }); const doAdd = useAtomSet(addMcpSource, { mode: "promise" }); const { beginAdd } = usePendingSources(); @@ -731,6 +735,13 @@ export default function AddMcpSource(props: { {probe && ( )} + {probe && ( + + )} {/* Authentication */} {probe && ( @@ -1013,6 +1024,12 @@ export default function AddMcpSource(props: { + + {/* Stdio error */} {stdioError && (
diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index ed7d3b811..cb4c73695 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -6,6 +6,7 @@ import { McpGroup } from "../api/group"; import { McpExtensionService, McpHandlers } from "../api/handlers"; import { + InvalidSourceWriteTargetError, SourceDetectionResult, definePlugin, resolveSecretBackedMap as resolveSharedSecretBackedMap, @@ -1031,6 +1032,15 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { */ export type McpExtensionFailure = McpConnectionError | McpToolDiscoveryError | StorageFailure; +// Source-creating methods (`addSource`, `refreshSource`) can also fail +// with `InvalidSourceWriteTargetError` when the cloud's URL context puts +// a personal scope in the executor stack and the caller targets it. The +// API surface in `../api/group.ts` declares the same error so the HTTP +// schema accepts a 422. +export type McpSourceWriteFailure = + | McpExtensionFailure + | InvalidSourceWriteTargetError; + export interface McpPluginExtension { readonly probeEndpoint: ( input: string | McpProbeEndpointInput, @@ -1039,7 +1049,7 @@ export interface McpPluginExtension { config: McpSourceConfig, ) => Effect.Effect< { readonly toolCount: number; readonly namespace: string }, - McpExtensionFailure + McpSourceWriteFailure >; readonly removeSource: ( namespace: string, diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index 1b04fb1f5..07c02793a 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -1,9 +1,17 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { ScopeId, SecretBackedValue } from "@executor-js/sdk/core"; +import { InvalidSourceWriteTargetError, ScopeId, SecretBackedValue } from "@executor-js/sdk/core"; import { InternalError } from "@executor-js/api"; import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors"; + +// 422: the request was syntactically valid but targeted a personal scope +// (`user_org_*` / `user_workspace_*`). The cloud UI should surface a +// "pick a workspace or global target" message; servers that don't use +// that prefix convention never see this error. +const InvalidSourceWriteTarget = InvalidSourceWriteTargetError.annotate({ + httpApiStatus: 422, +}); import { SpecPreview } from "../sdk/preview"; import { StoredSourceSchema } from "../sdk/store"; import { @@ -128,7 +136,10 @@ export const OpenApiGroup = HttpApiGroup.make("openapi") params: ScopeIdParam, payload: AddSpecPayload, success: AddSpecResponse, - error: DomainErrors, + // addSpec is the only source-definition write that the SDK guards + // against personal scopes — surface the 422 here so clients can + // catch it without inheriting the error on every read endpoint. + error: [...DomainErrors, InvalidSourceWriteTarget], }), ) .add( diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index 251174fe4..d12bc32e3 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -4,7 +4,11 @@ import { Option } from "effect"; import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/core"; import { startOAuth } from "@executor-js/react/api/atoms"; -import { useScope, useUserScope } from "@executor-js/react/api/scope-context"; +import { useUserScope } from "@executor-js/react/api/scope-context"; +import { + SourceTargetSelector, + useSourceTargetState, +} from "@executor-js/react/plugins/source-target-selector"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; // `addSpec` with an oauth2 payload persists a source row AND (for @@ -240,7 +244,10 @@ export default function AddOpenApiSource(props: { const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); - const scopeId = useScope(); + // Default to the active write scope; user can flip between Workspace + // and Global from the visible target selector below. + const target = useSourceTargetState(); + const scopeId = target.value; const userScope = useUserScope(); const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" }); const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); @@ -834,6 +841,11 @@ export default function AddOpenApiSource(props: { {preview && ( <> + {/* Base URL */} diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index dddab6c22..4a9fd3dfa 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -7,6 +7,7 @@ import { OpenApiExtensionService, OpenApiHandlers } from "../api/handlers"; import { ConnectionId, + InvalidSourceWriteTargetError, ScopeId, SecretId, SourceDetectionResult, @@ -108,11 +109,18 @@ export interface OpenApiUpdateSourceInput { * `StorageError` to the opaque `InternalError({ traceId })` at Layer * composition. `UniqueViolationError` passes through — plugins can * `Effect.catchTag` it if they want a friendlier user-facing error. + * + * `InvalidSourceWriteTargetError` is raised by the SDK when a source + * definition write targets a personal scope (`user_org_*` or + * `user_workspace_*`); see `apps/cloud/src/services/ids.ts` for the + * cloud's prefix convention. The HTTP edge maps this to a 422 so + * clients can surface a "pick a workspace or global target" message. */ export type OpenApiExtensionFailure = | OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError + | InvalidSourceWriteTargetError | StorageFailure; export interface OpenApiPluginExtension { @@ -126,7 +134,11 @@ export interface OpenApiPluginExtension { config: OpenApiSpecConfig, ) => Effect.Effect< { readonly sourceId: string; readonly toolCount: number }, - OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError | StorageFailure + | OpenApiParseError + | OpenApiExtractionError + | OpenApiOAuthError + | InvalidSourceWriteTargetError + | StorageFailure >; readonly removeSpec: (namespace: string, scope: string) => Effect.Effect; readonly getSource: ( diff --git a/packages/react/src/plugins/source-target-selector.tsx b/packages/react/src/plugins/source-target-selector.tsx new file mode 100644 index 000000000..14c2c4142 --- /dev/null +++ b/packages/react/src/plugins/source-target-selector.tsx @@ -0,0 +1,144 @@ +import * as React from "react"; +import { Label } from "../components/label"; +import { NativeSelect, NativeSelectOption } from "../components/native-select"; +import type { ScopeId } from "@executor-js/sdk"; + +import { useActiveWriteScopeId, useScopeStack } from "../api/scope-context"; + +// --------------------------------------------------------------------------- +// SourceTargetSelector — visible chooser for the scope a source-definition +// write should land at. The cloud's plan calls out two legal targets: +// +// - Workspace (`workspace_`) — only in workspace context. +// - Global (`org_`) — always available. +// +// Personal scopes (`user_org_*`, `user_workspace_*`) are NOT valid targets +// for source definitions in v1 — they're filtered out here AND rejected by +// the SDK (`InvalidSourceWriteTargetError`). +// +// The default selection is the URL context's active write scope: +// - Workspace context → workspace. +// - Global context → org (only option). +// +// Local CLI hosts have a single-scope stack with no `workspace_*` / +// `user_*` prefixes; the selector gracefully degrades to a single option +// labeled with the scope's display name. +// --------------------------------------------------------------------------- + +export interface SourceTargetOption { + readonly scopeId: ScopeId; + readonly label: string; +} + +const isPersonalScope = (id: string): boolean => + id.startsWith("user_org_") || id.startsWith("user_workspace_"); + +const labelFor = (id: string, name: string): string => { + if (id.startsWith("workspace_")) return `Workspace (${name})`; + if (id.startsWith("org_")) return `Global (${name})`; + return name; +}; + +/** + * Returns the legal source-definition targets for the current URL context, + * in display order: workspace first, then global. Personal scopes are + * excluded — see `InvalidSourceWriteTargetError`. + */ +export function useSourceTargetOptions(): readonly SourceTargetOption[] { + const stack = useScopeStack(); + return React.useMemo(() => { + const options: SourceTargetOption[] = []; + // Stack is innermost-first, so workspace lands first when present and + // org lands at the end. We keep that order. + for (const entry of stack) { + if (isPersonalScope(entry.id)) continue; + options.push({ + scopeId: entry.id, + label: labelFor(entry.id, entry.name), + }); + } + return options; + }, [stack]); +} + +export interface SourceTargetSelectorProps { + readonly value: ScopeId; + readonly onChange: (next: ScopeId) => void; + readonly disabled?: boolean; + /** Override the default label "Add to". */ + readonly label?: string; + readonly id?: string; +} + +/** + * Visible target selector for add-source forms. Always renders even when + * there's only one option — the selector documents the explicit target + * and matches the plan's "no hidden defaults" invariant. + */ +export function SourceTargetSelector(props: SourceTargetSelectorProps) { + const options = useSourceTargetOptions(); + const fallbackId = useId(); + const id = props.id ?? fallbackId; + + if (options.length === 0) { + // Should not happen — every executor stack has at least one + // shareable scope. Render nothing so the form still submits. + return null; + } + + return ( +
+ + props.onChange(e.target.value as ScopeId)} + > + {options.map((opt) => ( + + {opt.label} + + ))} + +
+ ); +} + +/** + * Hook for managed selector state. Returns the selected target plus a + * setter, defaulting to the URL context's active write scope. Callers + * pass the returned `value` into the API call's `params.scopeId` and + * render `` over `value` + `setValue`. + */ +export function useSourceTargetState(): { + readonly value: ScopeId; + readonly setValue: (next: ScopeId) => void; + readonly options: readonly SourceTargetOption[]; +} { + const defaultId = useActiveWriteScopeId(); + const options = useSourceTargetOptions(); + const [value, setValue] = React.useState(defaultId); + // If the selected scope falls out of the legal set (e.g. URL context + // navigated away from workspace), snap back to the active write scope. + React.useEffect(() => { + if (!options.some((o) => o.scopeId === value)) { + setValue(defaultId); + } + }, [defaultId, options, value]); + return { value, setValue, options }; +} + +// useId wrapper — older React versions may not have `React.useId`; reading +// it as a property keeps tree-shake friendly and avoids breaking older tests. +function useId(): string { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + const useIdImpl = (React as any).useId as (() => string) | undefined; + // Fallback: stable per-mount id derived from a Math.random ref. + const ref = React.useRef(null); + if (useIdImpl) return useIdImpl(); + if (ref.current === null) { + ref.current = `source-target-${Math.random().toString(36).slice(2)}`; + } + return ref.current; +}