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; +}