From 9b4836366c35fcce950876cf0794dfc0ca2a0819 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 13 May 2026 20:37:05 -0700 Subject: [PATCH] Standardize source credential bindings --- .../src/services/sources-api.node.test.ts | 38 +-- .../services/tenant-isolation.node.test.ts | 14 +- packages/core/api/src/api.ts | 2 + .../core/api/src/credential-bindings.test.ts | 162 ++++++++++ .../core/api/src/credential-bindings/api.ts | 47 +++ .../api/src/handlers/credential-bindings.ts | 42 +++ packages/core/api/src/handlers/index.ts | 3 + packages/core/api/src/index.ts | 1 + packages/core/api/src/server.ts | 1 + packages/plugins/graphql/src/api/group.ts | 42 --- packages/plugins/graphql/src/api/handlers.ts | 31 -- .../graphql/src/react/EditGraphqlSource.tsx | 34 +-- .../graphql/src/react/GraphqlSignInButton.tsx | 24 +- .../src/react/GraphqlSourceSummary.tsx | 6 +- packages/plugins/graphql/src/react/atoms.ts | 15 - packages/plugins/graphql/src/sdk/index.ts | 3 - .../plugins/graphql/src/sdk/plugin.test.ts | 60 ++-- packages/plugins/graphql/src/sdk/plugin.ts | 89 +----- packages/plugins/graphql/src/sdk/types.ts | 25 -- packages/plugins/graphql/src/testing/index.ts | 40 +++ packages/plugins/mcp/src/api/group.ts | 46 +-- packages/plugins/mcp/src/api/handlers.test.ts | 3 - packages/plugins/mcp/src/api/handlers.ts | 30 -- .../plugins/mcp/src/react/EditMcpSource.tsx | 35 ++- .../plugins/mcp/src/react/McpSignInButton.tsx | 24 +- .../mcp/src/react/McpSourceSummary.tsx | 6 +- packages/plugins/mcp/src/react/atoms.ts | 13 - packages/plugins/mcp/src/sdk/index.ts | 3 - packages/plugins/mcp/src/sdk/plugin.test.ts | 13 +- packages/plugins/mcp/src/sdk/plugin.ts | 107 +------ packages/plugins/mcp/src/sdk/types.ts | 24 -- packages/plugins/mcp/src/testing/index.ts | 41 +++ packages/plugins/openapi/src/api/group.ts | 46 +-- packages/plugins/openapi/src/api/handlers.ts | 31 -- .../openapi/src/react/AddOpenApiSource.tsx | 68 ++--- .../openapi/src/react/EditOpenApiSource.tsx | 72 ++--- .../src/react/OpenApiSourceSummary.tsx | 19 +- packages/plugins/openapi/src/react/atoms.ts | 15 - .../src/sdk/client-credentials-oauth.test.ts | 25 +- .../openapi/src/sdk/credential-status.test.ts | 103 ------- .../openapi/src/sdk/credential-status.ts | 107 ------- packages/plugins/openapi/src/sdk/index.ts | 3 - .../src/sdk/multi-scope-bearer.test.ts | 287 ++++++++---------- .../openapi/src/sdk/multi-scope-oauth.test.ts | 83 +++-- .../openapi/src/sdk/oauth-refresh.test.ts | 19 +- .../plugins/openapi/src/sdk/plugin.test.ts | 178 +++++------ packages/plugins/openapi/src/sdk/plugin.ts | 106 +------ packages/plugins/openapi/src/sdk/types.ts | 45 +-- .../src/sdk/usage-scope-isolation.test.ts | 34 +-- packages/plugins/openapi/src/testing/index.ts | 62 ++++ packages/react/src/api/atoms.tsx | 19 ++ .../src/plugins/credential-bindings.test.ts | 8 +- .../react/src/plugins/credential-bindings.tsx | 32 +- .../src/plugins/credential-slot-bindings.tsx | 11 +- .../plugins/source-credential-status-core.ts | 13 +- .../plugins/source-credential-status.test.ts | 4 +- 56 files changed, 1011 insertions(+), 1403 deletions(-) create mode 100644 packages/core/api/src/credential-bindings.test.ts create mode 100644 packages/core/api/src/credential-bindings/api.ts create mode 100644 packages/core/api/src/handlers/credential-bindings.ts delete mode 100644 packages/plugins/openapi/src/sdk/credential-status.test.ts delete mode 100644 packages/plugins/openapi/src/sdk/credential-status.ts diff --git a/apps/cloud/src/services/sources-api.node.test.ts b/apps/cloud/src/services/sources-api.node.test.ts index 1920aac69..94ead3550 100644 --- a/apps/cloud/src/services/sources-api.node.test.ts +++ b/apps/cloud/src/services/sources-api.node.test.ts @@ -760,13 +760,14 @@ describe("sources api (HTTP)", () => { value: "alice-secret", }, }); - const binding = yield* client.openapi.setSourceBinding({ + const binding = yield* client.credentialBindings.set({ params: { scopeId: ScopeId.make(aliceScope) }, payload: { + targetScope: ScopeId.make(aliceScope), + pluginId: "openapi", sourceId: namespace, sourceScope: ScopeId.make(orgId), - scope: ScopeId.make(aliceScope), - slot: "auth:personal-token", + slotKey: "auth:personal-token", value: { kind: "secret", secretId: SecretId.make("alice_pat"), @@ -777,7 +778,7 @@ describe("sources api (HTTP)", () => { sourceId: namespace, sourceScopeId: ScopeId.make(orgId), scopeId: ScopeId.make(aliceScope), - slot: "auth:personal-token", + slotKey: "auth:personal-token", value: { kind: "secret", secretId: SecretId.make("alice_pat"), @@ -798,13 +799,14 @@ describe("sources api (HTTP)", () => { value: "bob-secret", }, }); - yield* client.openapi.setSourceBinding({ + yield* client.credentialBindings.set({ params: { scopeId: ScopeId.make(bobScope) }, payload: { + targetScope: ScopeId.make(bobScope), + pluginId: "openapi", sourceId: namespace, sourceScope: ScopeId.make(orgId), - scope: ScopeId.make(bobScope), - slot: "auth:personal-token", + slotKey: "auth:personal-token", value: { kind: "secret", secretId: SecretId.make("bob_pat"), @@ -815,18 +817,19 @@ describe("sources api (HTTP)", () => { ); const aliceBindings = yield* asUser(aliceId, orgId, (client) => - client.openapi.listSourceBindings({ + client.credentialBindings.listForSource({ params: { scopeId: ScopeId.make(aliceScope), - namespace, - sourceScopeId: ScopeId.make(orgId), + pluginId: "openapi", + sourceId: namespace, + sourceScope: ScopeId.make(orgId), }, }), ); expect(aliceBindings).toContainEqual( expect.objectContaining({ scopeId: ScopeId.make(aliceScope), - slot: "auth:personal-token", + slotKey: "auth:personal-token", value: { kind: "secret", secretId: SecretId.make("alice_pat"), @@ -837,25 +840,26 @@ describe("sources api (HTTP)", () => { expect( aliceBindings.some( (binding) => - binding.slot === "auth:personal-token" && + binding.slotKey === "auth:personal-token" && binding.value.kind === "secret" && binding.value.secretId === SecretId.make("bob_pat"), ), ).toBe(false); const bobBindings = yield* asUser(bobId, orgId, (client) => - client.openapi.listSourceBindings({ + client.credentialBindings.listForSource({ params: { scopeId: ScopeId.make(bobScope), - namespace, - sourceScopeId: ScopeId.make(orgId), + pluginId: "openapi", + sourceId: namespace, + sourceScope: ScopeId.make(orgId), }, }), ); expect(bobBindings).toContainEqual( expect.objectContaining({ scopeId: ScopeId.make(bobScope), - slot: "auth:personal-token", + slotKey: "auth:personal-token", value: { kind: "secret", secretId: SecretId.make("bob_pat"), @@ -866,7 +870,7 @@ describe("sources api (HTTP)", () => { expect( bobBindings.some( (binding) => - binding.slot === "auth:personal-token" && + binding.slotKey === "auth:personal-token" && binding.value.kind === "secret" && binding.value.secretId === SecretId.make("alice_pat"), ), diff --git a/apps/cloud/src/services/tenant-isolation.node.test.ts b/apps/cloud/src/services/tenant-isolation.node.test.ts index 765f14018..382510d80 100644 --- a/apps/cloud/src/services/tenant-isolation.node.test.ts +++ b/apps/cloud/src/services/tenant-isolation.node.test.ts @@ -272,13 +272,14 @@ describe("tenant isolation (HTTP)", () => { }, }, }); - yield* client.openapi.setSourceBinding({ + yield* client.credentialBindings.set({ params: { scopeId: ScopeId.make(orgA) }, payload: { + targetScope: ScopeId.make(orgA), + pluginId: "openapi", sourceId: namespaceA, sourceScope: ScopeId.make(orgA), - scope: ScopeId.make(orgA), - slot: "auth:token", + slotKey: "auth:token", value: { kind: "secret", secretId: secretIdA }, }, }); @@ -319,13 +320,14 @@ describe("tenant isolation (HTTP)", () => { }, }, }); - yield* client.openapi.setSourceBinding({ + yield* client.credentialBindings.set({ params: { scopeId: ScopeId.make(orgA) }, payload: { + targetScope: ScopeId.make(orgA), + pluginId: "openapi", sourceId: namespaceA, sourceScope: ScopeId.make(orgA), - scope: ScopeId.make(orgA), - slot: "auth:conn", + slotKey: "auth:conn", value: { kind: "connection", connectionId: connectionIdA }, }, }); diff --git a/packages/core/api/src/api.ts b/packages/core/api/src/api.ts index 3a9137a01..d8081408d 100644 --- a/packages/core/api/src/api.ts +++ b/packages/core/api/src/api.ts @@ -9,6 +9,7 @@ import { ExecutionsApi } from "./executions/api"; import { ScopeApi } from "./scope/api"; import { OAuthApi } from "./oauth/api"; import { PoliciesApi } from "./policies/api"; +import { CredentialBindingsApi } from "./credential-bindings/api"; export const CoreExecutorApi = HttpApi.make("executor") .add(ToolsApi) @@ -19,6 +20,7 @@ export const CoreExecutorApi = HttpApi.make("executor") .add(ScopeApi) .add(OAuthApi) .add(PoliciesApi) + .add(CredentialBindingsApi) .annotateMerge( OpenApi.annotations({ title: "Executor API", diff --git a/packages/core/api/src/credential-bindings.test.ts b/packages/core/api/src/credential-bindings.test.ts new file mode 100644 index 000000000..7051b98f1 --- /dev/null +++ b/packages/core/api/src/credential-bindings.test.ts @@ -0,0 +1,162 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; +import { + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, + HttpRouter, + HttpServer, +} from "effect/unstable/http"; +import { describe, expect, it } from "@effect/vitest"; +import { Context, Effect, Layer } from "effect"; + +import { + Scope, + ScopeId, + createExecutor, + definePlugin, + makeTestConfig, + type Executor, +} from "@executor-js/sdk"; + +import { ExecutorApi } from "./api"; +import { observabilityMiddleware } from "./observability"; +import { CoreHandlers, ExecutionEngineService, ExecutorService } from "./server"; + +const TEST_PLUGIN_ID = "credentialApiTest"; +const TEST_SOURCE_ID = "shared-api"; +const TEST_SLOT = "request.header.Authorization"; + +const webHandlerFor = (executor: Executor) => + Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler( + HttpApiBuilder.layer(ExecutorApi).pipe( + Layer.provide(CoreHandlers), + Layer.provide(observabilityMiddleware(ExecutorApi)), + Layer.provide(Layer.succeed(ExecutorService)(executor)), + Layer.provide( + Layer.succeed(ExecutionEngineService)({} as ExecutionEngineService["Service"]), + ), + Layer.provideMerge(HttpServer.layerServices), + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), + ), + { disableLogger: true }, + ), + ), + (web) => Effect.promise(() => web.dispose()), + ); + +const handlerContextFor = (executor: Executor) => + Context.make(ExecutorService, executor).pipe( + Context.add(ExecutionEngineService, {} as ExecutionEngineService["Service"]), + ); + +const apiClientFor = (executor: Executor) => + Effect.gen(function* () { + const web = yield* webHandlerFor(executor); + const context = handlerContextFor(executor); + const httpClient = HttpClient.make((request, _url, signal) => + Effect.gen(function* () { + const webRequest = yield* HttpClientRequest.toWeb(request, { signal }).pipe( + Effect.mapError( + (cause) => + new HttpClientError.HttpClientError({ + reason: new HttpClientError.InvalidUrlError({ request, cause }), + }), + ), + ); + const response = yield* Effect.promise(() => web.handler(webRequest, context)); + return HttpClientResponse.fromWeb(request, response); + }), + ); + return yield* HttpApiClient.makeWith(ExecutorApi, { + httpClient, + baseUrl: "http://localhost", + }); + }); + +const scope = (id: ScopeId, name: string) => Scope.make({ id, name, createdAt: new Date() }); + +const credentialApiTestPlugin = definePlugin(() => ({ + id: TEST_PLUGIN_ID, + storage: () => ({}), + extension: (ctx) => ({ + registerSource: (targetScope: ScopeId) => + ctx.core.sources.register({ + id: TEST_SOURCE_ID, + scope: targetScope, + kind: "test-api", + name: "Shared API", + tools: [{ name: "read", description: "read from the shared API" }], + }), + }), +})); + +describe("credential binding API", () => { + it.effect("sets, lists, and removes source credential bindings", () => + Effect.gen(function* () { + const userScope = ScopeId.make("api-user"); + const orgScope = ScopeId.make("api-org"); + const executor = yield* createExecutor( + makeTestConfig({ + scopes: [scope(userScope, "user"), scope(orgScope, "org")], + plugins: [credentialApiTestPlugin()] as const, + }), + ); + yield* executor.credentialApiTest.registerSource(orgScope); + const client = yield* apiClientFor(executor); + + const created = yield* client.credentialBindings.set({ + params: { scopeId: userScope }, + payload: { + targetScope: userScope, + pluginId: TEST_PLUGIN_ID, + sourceId: TEST_SOURCE_ID, + sourceScope: orgScope, + slotKey: TEST_SLOT, + value: { kind: "text", text: "test-token" }, + }, + }); + expect(created).toMatchObject({ + slotKey: TEST_SLOT, + scopeId: String(userScope), + value: { kind: "text", text: "test-token" }, + }); + + const listed = yield* client.credentialBindings.listForSource({ + params: { + scopeId: userScope, + pluginId: TEST_PLUGIN_ID, + sourceId: TEST_SOURCE_ID, + sourceScope: orgScope, + }, + }); + expect(listed).toHaveLength(1); + expect(listed[0]).toMatchObject({ slotKey: TEST_SLOT, scopeId: String(userScope) }); + + const removed = yield* client.credentialBindings.remove({ + params: { scopeId: userScope }, + payload: { + targetScope: userScope, + pluginId: TEST_PLUGIN_ID, + sourceId: TEST_SOURCE_ID, + sourceScope: orgScope, + slotKey: TEST_SLOT, + }, + }); + expect(removed).toEqual({ removed: true }); + + const afterRemove = yield* client.credentialBindings.listForSource({ + params: { + scopeId: userScope, + pluginId: TEST_PLUGIN_ID, + sourceId: TEST_SOURCE_ID, + sourceScope: orgScope, + }, + }); + expect(afterRemove).toEqual([]); + }), + ); +}); diff --git a/packages/core/api/src/credential-bindings/api.ts b/packages/core/api/src/credential-bindings/api.ts new file mode 100644 index 000000000..47e12eb07 --- /dev/null +++ b/packages/core/api/src/credential-bindings/api.ts @@ -0,0 +1,47 @@ +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; +import { + CredentialBindingRef, + RemoveCredentialBindingInput, + ScopeId, + SetCredentialBindingInput, +} from "@executor-js/sdk"; + +import { InternalError } from "../observability"; + +const ScopeParams = { scopeId: ScopeId }; +const CredentialBindingSourceParams = { + scopeId: ScopeId, + pluginId: Schema.String, + sourceId: Schema.String, + sourceScope: ScopeId, +}; + +export const CredentialBindingsApi = HttpApiGroup.make("credentialBindings") + .add( + HttpApiEndpoint.get( + "listForSource", + "/scopes/:scopeId/credential-bindings/:pluginId/sources/:sourceId/base/:sourceScope", + { + params: CredentialBindingSourceParams, + success: Schema.Array(CredentialBindingRef), + error: InternalError, + }, + ), + ) + .add( + HttpApiEndpoint.post("set", "/scopes/:scopeId/credential-bindings", { + params: ScopeParams, + payload: SetCredentialBindingInput, + success: CredentialBindingRef, + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.post("remove", "/scopes/:scopeId/credential-bindings/remove", { + params: ScopeParams, + payload: RemoveCredentialBindingInput, + success: Schema.Struct({ removed: Schema.Boolean }), + error: InternalError, + }), + ); diff --git a/packages/core/api/src/handlers/credential-bindings.ts b/packages/core/api/src/handlers/credential-bindings.ts new file mode 100644 index 000000000..759f7d47c --- /dev/null +++ b/packages/core/api/src/handlers/credential-bindings.ts @@ -0,0 +1,42 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { Effect } from "effect"; + +import { ExecutorApi } from "../api"; +import { ExecutorService } from "../services"; +import { capture } from "@executor-js/api"; + +export const CredentialBindingsHandlers = HttpApiBuilder.group( + ExecutorApi, + "credentialBindings", + (handlers) => + handlers + .handle("listForSource", ({ params }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.credentialBindings.listForSource({ + pluginId: params.pluginId, + sourceId: params.sourceId, + sourceScope: params.sourceScope, + }); + }), + ), + ) + .handle("set", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.credentialBindings.set(payload); + }), + ), + ) + .handle("remove", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.credentialBindings.remove(payload); + return { removed: true }; + }), + ), + ), +); diff --git a/packages/core/api/src/handlers/index.ts b/packages/core/api/src/handlers/index.ts index 1c0b608f0..564701904 100644 --- a/packages/core/api/src/handlers/index.ts +++ b/packages/core/api/src/handlers/index.ts @@ -8,6 +8,7 @@ import { ScopeHandlers } from "./scope"; import { ExecutionsHandlers } from "./executions"; import { OAuthHandlers } from "./oauth"; import { PoliciesHandlers } from "./policies"; +import { CredentialBindingsHandlers } from "./credential-bindings"; export { ToolsHandlers } from "./tools"; export { SourcesHandlers } from "./sources"; @@ -17,6 +18,7 @@ export { ScopeHandlers } from "./scope"; export { ExecutionsHandlers } from "./executions"; export { OAuthHandlers } from "./oauth"; export { PoliciesHandlers } from "./policies"; +export { CredentialBindingsHandlers } from "./credential-bindings"; export const CoreHandlers = Layer.mergeAll( ToolsHandlers, @@ -28,4 +30,5 @@ export const CoreHandlers = Layer.mergeAll( OAuthHandlers, OAuthHandlers, PoliciesHandlers, + CredentialBindingsHandlers, ); diff --git a/packages/core/api/src/index.ts b/packages/core/api/src/index.ts index 36af28026..19660bb5b 100644 --- a/packages/core/api/src/index.ts +++ b/packages/core/api/src/index.ts @@ -13,6 +13,7 @@ export { ConnectionsApi } from "./connections/api"; export { ExecutionsApi } from "./executions/api"; export { ScopeApi } from "./scope/api"; export { OAuthApi } from "./oauth/api"; +export { CredentialBindingsApi } from "./credential-bindings/api"; export { OAUTH_POPUP_MESSAGE_TYPE, isOAuthPopupResult, diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index a2ca278ca..0c27483d7 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -4,6 +4,7 @@ export { ToolsHandlers, SourcesHandlers, SecretsHandlers, + CredentialBindingsHandlers, ScopeHandlers, ExecutionsHandlers, } from "./handlers"; diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index bc12efb4b..88d85f1e9 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -8,8 +8,6 @@ import { GraphqlCredentialInput, GraphqlSourceAuth, GraphqlSourceAuthInput, - GraphqlSourceBindingInput, - GraphqlSourceBindingRef, } from "../sdk/types"; // StoredGraphqlSource shape as an HTTP response schema. Kept local to the @@ -37,12 +35,6 @@ const SourceParams = { namespace: Schema.String, }; -const SourceBindingParams = { - scopeId: ScopeId, - namespace: Schema.String, - sourceScopeId: ScopeId, -}; - // --------------------------------------------------------------------------- // Payloads // --------------------------------------------------------------------------- @@ -73,13 +65,6 @@ const UpdateSourceResponse = Schema.Struct({ updated: Schema.Boolean, }); -const RemoveBindingPayload = Schema.Struct({ - sourceId: Schema.String, - sourceScope: ScopeId, - slot: Schema.String, - scope: ScopeId, -}); - // --------------------------------------------------------------------------- // Responses // --------------------------------------------------------------------------- @@ -136,33 +121,6 @@ export const GraphqlGroup = HttpApiGroup.make("graphql") success: UpdateSourceResponse, error: GraphqlErrors, }), - ) - .add( - HttpApiEndpoint.get( - "listSourceBindings", - "/scopes/:scopeId/graphql/sources/:namespace/base/:sourceScopeId/bindings", - { - params: SourceBindingParams, - success: Schema.Array(GraphqlSourceBindingRef), - error: GraphqlErrors, - }, - ), - ) - .add( - HttpApiEndpoint.post("setSourceBinding", "/scopes/:scopeId/graphql/source-bindings", { - params: ScopeParams, - payload: GraphqlSourceBindingInput, - success: GraphqlSourceBindingRef, - error: GraphqlErrors, - }), - ) - .add( - HttpApiEndpoint.post("removeSourceBinding", "/scopes/:scopeId/graphql/source-bindings/remove", { - params: ScopeParams, - payload: RemoveBindingPayload, - success: Schema.Struct({ removed: Schema.Boolean }), - error: GraphqlErrors, - }), ); // Plugin domain errors carry their own HTTP status (4xx); // `InternalError` is the shared opaque 500 translated at the HTTP edge. diff --git a/packages/plugins/graphql/src/api/handlers.ts b/packages/plugins/graphql/src/api/handlers.ts index 579e5bf33..26c7da41b 100644 --- a/packages/plugins/graphql/src/api/handlers.ts +++ b/packages/plugins/graphql/src/api/handlers.ts @@ -4,7 +4,6 @@ import { Context, Effect } from "effect"; import { addGroup, capture } from "@executor-js/api"; import { ScopeId } from "@executor-js/sdk/core"; import type { GraphqlPluginExtension, GraphqlUpdateSourceInput } from "../sdk/plugin"; -import { GraphqlSourceBindingInput } from "../sdk/types"; import { GraphqlGroup } from "./group"; // --------------------------------------------------------------------------- @@ -87,35 +86,5 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra return { updated: true }; }), ), - ) - .handle("listSourceBindings", ({ params: path }) => - capture( - Effect.gen(function* () { - const ext = yield* GraphqlExtensionService; - return yield* ext.listSourceBindings(path.namespace, path.sourceScopeId); - }), - ), - ) - .handle("setSourceBinding", ({ payload }) => - capture( - Effect.gen(function* () { - const ext = yield* GraphqlExtensionService; - return yield* ext.setSourceBinding(GraphqlSourceBindingInput.make(payload)); - }), - ), - ) - .handle("removeSourceBinding", ({ payload }) => - capture( - Effect.gen(function* () { - const ext = yield* GraphqlExtensionService; - yield* ext.removeSourceBinding( - payload.sourceId, - payload.sourceScope, - payload.slot, - payload.scope, - ); - return { removed: true }; - }), - ), ), ); diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index 38575526f..a57cc5acd 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -2,13 +2,12 @@ import { useState } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { graphqlSourceAtom, updateGraphqlSource } from "./atoms"; import { - graphqlSourceAtom, - graphqlSourceBindingsAtom, - setGraphqlSourceBinding, - updateGraphqlSource, -} from "./atoms"; -import { connectionsAtom } from "@executor-js/react/api/atoms"; + connectionsAtom, + setSourceCredentialBinding, + sourceCredentialBindingsAtom, +} from "@executor-js/react/api/atoms"; import { useScope, useScopeStack } from "@executor-js/react/api/scope-context"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; @@ -30,13 +29,9 @@ import { FilterTabs } from "@executor-js/react/components/filter-tabs"; import { SourceOAuthConnectionControl } from "@executor-js/react/plugins/source-oauth-connection"; import { Badge } from "@executor-js/react/components/badge"; import { ScopeId } from "@executor-js/sdk/core"; +import type { CredentialBindingRef } from "@executor-js/sdk/core"; import { GraphqlSourceFields } from "./GraphqlSourceFields"; -import { - GRAPHQL_OAUTH_CONNECTION_SLOT, - type GraphqlCredentialInput, - GraphqlSourceBindingInput, - type GraphqlSourceBindingRef, -} from "../sdk/types"; +import { GRAPHQL_OAUTH_CONNECTION_SLOT, type GraphqlCredentialInput } from "../sdk/types"; import type { StoredGraphqlSource } from "../sdk/store"; type EditableSource = StoredGraphqlSource; @@ -49,7 +44,7 @@ type AuthMode = "none" | "oauth2"; function EditForm(props: { sourceId: string; initial: EditableSource; - bindings: readonly GraphqlSourceBindingRef[]; + bindings: readonly CredentialBindingRef[]; onSave: () => void; }) { const displayScope = useScope(); @@ -67,7 +62,7 @@ function EditForm(props: { initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), }); const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promiseExit" }); - const setBinding = useAtomSet(setGraphqlSourceBinding, { mode: "promise" }); + const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const secretList = useSecretPickerSecrets(); const connectionsResult = useAtomValue(connectionsAtom(displayScope)); @@ -247,13 +242,14 @@ function EditForm(props: { onConnected={async (connectionId) => { await setBinding({ params: { scopeId: oauthCredentialTargetScope }, - payload: GraphqlSourceBindingInput.make({ + payload: { + targetScope: oauthCredentialTargetScope, + pluginId: "graphql", sourceId: props.sourceId, sourceScope, - scope: oauthCredentialTargetScope, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId }, - }), + }, reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], }); }} @@ -289,7 +285,7 @@ export default function EditGraphqlSource(props: { sourceId: string; onSave: () AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; const sourceScope = source ? ScopeId.make(source.scope) : scopeId; const bindingsResult = useAtomValue( - graphqlSourceBindingsAtom(scopeId, props.sourceId, sourceScope), + sourceCredentialBindingsAtom(scopeId, "graphql", props.sourceId, sourceScope), ); if (!AsyncResult.isSuccess(sourceResult) || !source || !AsyncResult.isSuccess(bindingsResult)) { diff --git a/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx b/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx index 497367b0e..bf353247b 100644 --- a/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx +++ b/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx @@ -1,7 +1,11 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { connectionsAtom } from "@executor-js/react/api/atoms"; +import { + connectionsAtom, + setSourceCredentialBinding, + sourceCredentialBindingsAtom, +} from "@executor-js/react/api/atoms"; import { useScope, useUserScope } from "@executor-js/react/api/scope-context"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { SourceOAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; @@ -9,8 +13,7 @@ import { slugifyNamespace } from "@executor-js/react/plugins/source-identity"; import { secretBackedValuesFromConfiguredCredentialBindings } from "@executor-js/react/plugins/credential-bindings"; import { ScopeId } from "@executor-js/sdk/core"; -import { graphqlSourceAtom, graphqlSourceBindingsAtom, setGraphqlSourceBinding } from "./atoms"; -import { GraphqlSourceBindingInput } from "../sdk/types"; +import { graphqlSourceAtom } from "./atoms"; export default function GraphqlSignInButton(props: { sourceId: string }) { const scopeId = useScope(); @@ -20,15 +23,15 @@ export default function GraphqlSignInButton(props: { sourceId: string }) { AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; const sourceScope = source ? ScopeId.make(source.scope) : scopeId; const bindingsResult = useAtomValue( - graphqlSourceBindingsAtom(userScopeId, props.sourceId, sourceScope), + sourceCredentialBindingsAtom(userScopeId, "graphql", props.sourceId, sourceScope), ); const connectionsResult = useAtomValue(connectionsAtom(userScopeId)); - const setBinding = useAtomSet(setGraphqlSourceBinding, { mode: "promise" }); + const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const oauth2 = source?.auth.kind === "oauth2" ? source.auth : null; const bindings = AsyncResult.isSuccess(bindingsResult) ? bindingsResult.value : null; const connectionBinding = bindings?.find( - (binding) => oauth2 !== null && binding.slot === oauth2.connectionSlot, + (binding) => oauth2 !== null && binding.slotKey === oauth2.connectionSlot, ); const boundConnectionId = connectionBinding?.value.kind === "connection" ? connectionBinding.value.connectionId : null; @@ -60,13 +63,14 @@ export default function GraphqlSignInButton(props: { sourceId: string }) { onConnected={async (connectionId) => { await setBinding({ params: { scopeId: userScopeId }, - payload: GraphqlSourceBindingInput.make({ + payload: { + targetScope: userScopeId, + pluginId: "graphql", sourceId: props.sourceId, sourceScope, - scope: userScopeId, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId }, - }), + }, reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], }); }} diff --git a/packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx b/packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx index 5e47539de..cc523e2f8 100644 --- a/packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx +++ b/packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx @@ -1,7 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { connectionsAtom } from "@executor-js/react/api/atoms"; +import { connectionsAtom, sourceCredentialBindingsAtom } from "@executor-js/react/api/atoms"; import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; import { SourceCredentialNotice, @@ -11,7 +11,7 @@ import { } from "@executor-js/react/plugins/source-credential-status"; import { ScopeId } from "@executor-js/sdk/core"; -import { graphqlSourceAtom, graphqlSourceBindingsAtom } from "./atoms"; +import { graphqlSourceAtom } from "./atoms"; import type { StoredGraphqlSource } from "../sdk/store"; const sourceCredentialSlots = (source: StoredGraphqlSource): readonly SourceCredentialSlot[] => { @@ -45,7 +45,7 @@ export default function GraphqlSourceSummary(props: { AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; const sourceScope = source ? ScopeId.make(source.scope) : displayScope; const bindingsResult = useAtomValue( - graphqlSourceBindingsAtom(displayScope, props.sourceId, sourceScope), + sourceCredentialBindingsAtom(displayScope, "graphql", props.sourceId, sourceScope), ); const connectionsResult = useAtomValue(connectionsAtom(displayScope)); diff --git a/packages/plugins/graphql/src/react/atoms.ts b/packages/plugins/graphql/src/react/atoms.ts index c1d080cde..5a3d34260 100644 --- a/packages/plugins/graphql/src/react/atoms.ts +++ b/packages/plugins/graphql/src/react/atoms.ts @@ -16,17 +16,6 @@ export const graphqlSourceAtom = (scopeId: ScopeId, namespace: string) => reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools], }); -export const graphqlSourceBindingsAtom = ( - scopeId: ScopeId, - namespace: string, - sourceScopeId: ScopeId, -) => - GraphqlClient.query("graphql", "listSourceBindings", { - params: { scopeId, namespace, sourceScopeId }, - timeToLive: "15 seconds", - reactivityKeys: [ReactivityKey.sources, ReactivityKey.secrets, ReactivityKey.connections], - }); - // --------------------------------------------------------------------------- // Mutation atoms // --------------------------------------------------------------------------- @@ -61,7 +50,3 @@ export const addGraphqlSourceOptimistic = Atom.family((scopeId: ScopeId) => ); export const updateGraphqlSource = GraphqlClient.mutation("graphql", "updateSource"); - -export const setGraphqlSourceBinding = GraphqlClient.mutation("graphql", "setSourceBinding"); - -export const removeGraphqlSourceBinding = GraphqlClient.mutation("graphql", "removeSourceBinding"); diff --git a/packages/plugins/graphql/src/sdk/index.ts b/packages/plugins/graphql/src/sdk/index.ts index 52d1ab8d4..9a227f2b8 100644 --- a/packages/plugins/graphql/src/sdk/index.ts +++ b/packages/plugins/graphql/src/sdk/index.ts @@ -31,9 +31,6 @@ export { GraphqlOperationKind, GraphqlSourceAuth, GraphqlSourceAuthInput, - GraphqlSourceBindingInput, - GraphqlSourceBindingRef, - GraphqlSourceBindingValue, InvocationConfig, InvocationResult, OperationBinding, diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index f64362efe..24c0e5bed 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -20,9 +20,14 @@ import { memorySecretsPlugin, serveTestHttpApp } from "@executor-js/sdk/testing" import { graphqlPlugin } from "./plugin"; import { endpointForTelemetry } from "./invoke"; import { introspect } from "./introspect"; -import { GraphqlSourceBindingInput, graphqlHeaderSlot, graphqlQueryParamSlot } from "./types"; +import { graphqlHeaderSlot, graphqlQueryParamSlot } from "./types"; import type { IntrospectionResult } from "./introspect"; -import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing"; +import { + listGraphqlCredentialBindings, + makeGreetingGraphqlSchema, + serveGraphqlTestServer, + setGraphqlCredentialBinding, +} from "../testing"; const TEST_SCOPE = "test-scope"; @@ -705,24 +710,20 @@ describe("graphqlPlugin", () => { credentialTargetScope: ORG_SCOPE, }); - yield* executor.graphql.setSourceBinding( - GraphqlSourceBindingInput.make({ - sourceId: "shared_credentials", - sourceScope: ScopeId.make(ORG_SCOPE), - scope: ScopeId.make(USER_SCOPE), - slot: graphqlHeaderSlot("Authorization"), - value: { kind: "secret", secretId: SecretId.make("user-token") }, - }), - ); - yield* executor.graphql.setSourceBinding( - GraphqlSourceBindingInput.make({ - sourceId: "shared_credentials", - sourceScope: ScopeId.make(ORG_SCOPE), - scope: ScopeId.make(USER_SCOPE), - slot: graphqlQueryParamSlot("token"), - value: { kind: "secret", secretId: SecretId.make("user-query") }, - }), - ); + yield* setGraphqlCredentialBinding(executor, { + sourceId: "shared_credentials", + sourceScope: ScopeId.make(ORG_SCOPE), + targetScope: ScopeId.make(USER_SCOPE), + slotKey: graphqlHeaderSlot("Authorization"), + value: { kind: "secret", secretId: SecretId.make("user-token") }, + }); + yield* setGraphqlCredentialBinding(executor, { + sourceId: "shared_credentials", + sourceScope: ScopeId.make(ORG_SCOPE), + targetScope: ScopeId.make(USER_SCOPE), + slotKey: graphqlQueryParamSlot("token"), + value: { kind: "secret", secretId: SecretId.make("user-query") }, + }); yield* server.clearRequests; const result = yield* executor.tools.invoke("shared_credentials.query.hello", { @@ -783,20 +784,20 @@ describe("graphqlPlugin", () => { }, }); - const bindings = yield* executor.graphql.listSourceBindings( - "row_scoped_credentials", - ORG_SCOPE, - ); + const bindings = yield* listGraphqlCredentialBindings(executor, { + sourceId: "row_scoped_credentials", + sourceScope: ORG_SCOPE, + }); - expect(bindings.map((binding) => binding.slot).sort()).toEqual([ + expect(bindings.map((binding) => binding.slotKey).sort()).toEqual([ graphqlHeaderSlot("Authorization"), graphqlQueryParamSlot("token"), ]); expect( - bindings.find((binding) => binding.slot === graphqlHeaderSlot("Authorization"))?.scopeId, + bindings.find((binding) => binding.slotKey === graphqlHeaderSlot("Authorization"))?.scopeId, ).toBe(ScopeId.make(USER_SCOPE)); expect( - bindings.find((binding) => binding.slot === graphqlQueryParamSlot("token"))?.scopeId, + bindings.find((binding) => binding.slotKey === graphqlQueryParamSlot("token"))?.scopeId, ).toBe(ScopeId.make(ORG_SCOPE)); }), ); @@ -954,7 +955,10 @@ describe("graphqlPlugin", () => { headers: {}, }); - const bindings = yield* executor.graphql.listSourceBindings("stale_binding", ORG_SCOPE); + const bindings = yield* listGraphqlCredentialBindings(executor, { + sourceId: "stale_binding", + sourceScope: ORG_SCOPE, + }); expect(bindings).toEqual([]); }), ); diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index f6d73836c..3f8d86d0b 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -6,6 +6,7 @@ import { ConnectionId, ConfiguredCredentialBinding, type CredentialBindingRef, + type CredentialBindingValue, definePlugin, tool, ScopeId, @@ -47,8 +48,6 @@ import { GRAPHQL_OAUTH_CONNECTION_SLOT, GraphqlCredentialInput as GraphqlCredentialInputSchema, GraphqlSourceAuthInput as GraphqlSourceAuthInputSchema, - GraphqlSourceBindingInput, - GraphqlSourceBindingRef, graphqlHeaderSlot, graphqlQueryParamSlot, OperationBinding, @@ -57,7 +56,6 @@ import { type GraphqlSourceAuth, type HeaderValue as HeaderValueValue, type GraphqlSourceAuthInput, - type GraphqlSourceBindingValue, type GraphqlOperationKind, } from "./types"; @@ -329,42 +327,12 @@ const scopeRanks = (ctx: PluginCtx): ReadonlyMap = const scopeRank = (ranks: ReadonlyMap, scopeId: string): number => ranks.get(scopeId) ?? Infinity; -const coreBindingToGraphqlBinding = (binding: CredentialBindingRef): GraphqlSourceBindingRef => - GraphqlSourceBindingRef.make({ - sourceId: binding.sourceId, - sourceScopeId: binding.sourceScopeId, - scopeId: binding.scopeId, - slot: binding.slotKey, - value: binding.value, - createdAt: binding.createdAt, - updatedAt: binding.updatedAt, - }); - -const listGraphqlSourceBindings = ( - ctx: PluginCtx, - sourceId: string, - sourceScope: string, -): Effect.Effect => - Effect.gen(function* () { - const ranks = scopeRanks(ctx); - const sourceSourceRank = scopeRank(ranks, sourceScope); - if (sourceSourceRank === Infinity) return []; - const bindings = yield* ctx.credentialBindings.listForSource({ - pluginId: GRAPHQL_PLUGIN_ID, - sourceId, - sourceScope: ScopeId.make(sourceScope), - }); - return bindings - .filter((binding) => scopeRank(ranks, binding.scopeId) <= sourceSourceRank) - .map(coreBindingToGraphqlBinding); - }); - -const resolveGraphqlSourceBinding = ( +const resolveGraphqlCredentialBinding = ( ctx: PluginCtx, sourceId: string, sourceScope: string, slot: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const ranks = scopeRanks(ctx); const sourceSourceRank = scopeRank(ranks, sourceScope); @@ -380,7 +348,7 @@ const resolveGraphqlSourceBinding = ( candidate.slotKey === slot && scopeRank(ranks, candidate.scopeId) <= sourceSourceRank, ) .sort((a, b) => scopeRank(ranks, a.scopeId) - scopeRank(ranks, b.scopeId))[0]; - return binding ? coreBindingToGraphqlBinding(binding) : null; + return binding ?? null; }); const validateGraphqlBindingTarget = ( @@ -456,14 +424,14 @@ const canonicalizeCredentialMap = ( readonly values: Record; readonly bindings: ReadonlyArray<{ readonly slot: string; - readonly value: GraphqlSourceBindingValue; + readonly value: CredentialBindingValue; readonly targetScope?: string; }>; } => { const nextValues: Record = {}; const bindings: Array<{ slot: string; - value: GraphqlSourceBindingValue; + value: CredentialBindingValue; targetScope?: string; }> = []; for (const [name, value] of Object.entries(values ?? {})) { @@ -502,7 +470,7 @@ const canonicalizeAuth = ( readonly auth: GraphqlSourceAuth; readonly bindings: ReadonlyArray<{ readonly slot: string; - readonly value: GraphqlSourceBindingValue; + readonly value: CredentialBindingValue; readonly targetScope?: string; }>; } => { @@ -540,7 +508,7 @@ const resolveGraphqlBindingValueMap = ( resolved[name] = value; continue; } - const binding = yield* resolveGraphqlSourceBinding( + const binding = yield* resolveGraphqlCredentialBinding( ctx, params.sourceId, params.sourceScope, @@ -585,7 +553,7 @@ const resolveGraphqlStoredOAuthHeader = ( ) => Effect.gen(function* () { if (!auth || auth.kind === "none") return undefined; - const binding = yield* resolveGraphqlSourceBinding( + const binding = yield* resolveGraphqlCredentialBinding( ctx, sourceId, sourceScope, @@ -678,7 +646,7 @@ const makeGraphqlExtension = ( "connectionId" in auth ? { id: auth.connectionId, scope: targetScope ?? sourceScope } : yield* Effect.gen(function* () { - const binding = yield* resolveGraphqlSourceBinding( + const binding = yield* resolveGraphqlCredentialBinding( ctx, sourceId, sourceScope, @@ -923,43 +891,6 @@ const makeGraphqlExtension = ( }), ); }), - - listSourceBindings: (sourceId: string, sourceScope: string) => - listGraphqlSourceBindings(ctx, sourceId, sourceScope), - - setSourceBinding: (input: GraphqlSourceBindingInput) => - Effect.gen(function* () { - yield* validateGraphqlBindingTarget(ctx, { - sourceId: input.sourceId, - sourceScope: input.sourceScope, - targetScope: input.scope, - }); - const binding = yield* ctx.credentialBindings.set({ - targetScope: input.scope, - pluginId: GRAPHQL_PLUGIN_ID, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - slotKey: input.slot, - value: input.value, - }); - return coreBindingToGraphqlBinding(binding); - }), - - removeSourceBinding: (sourceId: string, sourceScope: string, slot: string, scope: string) => - Effect.gen(function* () { - yield* validateGraphqlBindingTarget(ctx, { - sourceId, - sourceScope, - targetScope: scope, - }); - yield* ctx.credentialBindings.remove({ - targetScope: ScopeId.make(scope), - pluginId: GRAPHQL_PLUGIN_ID, - sourceId, - sourceScope: ScopeId.make(sourceScope), - slotKey: slot, - }); - }), }; }; diff --git a/packages/plugins/graphql/src/sdk/types.ts b/packages/plugins/graphql/src/sdk/types.ts index cb6e7702b..db054f123 100644 --- a/packages/plugins/graphql/src/sdk/types.ts +++ b/packages/plugins/graphql/src/sdk/types.ts @@ -1,11 +1,9 @@ import { Effect, Schema } from "effect"; import { ConfiguredCredentialValue, - CredentialBindingValue, credentialSlotKey, ScopedSecretCredentialInput, SecretBackedValue, - ScopeId, } from "@executor-js/sdk/core"; // --------------------------------------------------------------------------- @@ -107,29 +105,6 @@ export const GraphqlSourceAuthInput = Schema.Union([ ]); export type GraphqlSourceAuthInput = typeof GraphqlSourceAuthInput.Type; -export const GraphqlSourceBindingValue = CredentialBindingValue; -export type GraphqlSourceBindingValue = typeof GraphqlSourceBindingValue.Type; - -export const GraphqlSourceBindingInput = Schema.Struct({ - sourceId: Schema.String, - sourceScope: ScopeId, - scope: ScopeId, - slot: Schema.String, - value: GraphqlSourceBindingValue, -}); -export type GraphqlSourceBindingInput = typeof GraphqlSourceBindingInput.Type; - -export const GraphqlSourceBindingRef = Schema.Struct({ - sourceId: Schema.String, - sourceScopeId: ScopeId, - scopeId: ScopeId, - slot: Schema.String, - value: GraphqlSourceBindingValue, - createdAt: Schema.Date, - updatedAt: Schema.Date, -}); -export type GraphqlSourceBindingRef = typeof GraphqlSourceBindingRef.Type; - export const InvocationConfig = Schema.Struct({ /** The GraphQL endpoint URL */ endpoint: Schema.String, diff --git a/packages/plugins/graphql/src/testing/index.ts b/packages/plugins/graphql/src/testing/index.ts index b074f80b9..d3f450cf5 100644 --- a/packages/plugins/graphql/src/testing/index.ts +++ b/packages/plugins/graphql/src/testing/index.ts @@ -11,6 +11,11 @@ import { import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString } from "graphql"; import { createYoga, type GraphQLParams, type YogaInitialContext } from "graphql-yoga"; +import { + ScopeId, + type CredentialBindingsFacade, + type CredentialBindingValue, +} from "@executor-js/sdk"; import { serveTestHttpApp } from "@executor-js/sdk/testing"; const GraphqlRequestPayload = EffectSchema.Struct({ @@ -185,6 +190,41 @@ export const makeGreetingGraphqlSchema = (): GraphQLSchema => { return new GraphQLSchema({ query: Query, mutation: Mutation }); }; +type ScopeInput = ScopeId | string; + +const scopeId = (scope: ScopeInput): ScopeId => ScopeId.make(String(scope)); + +export interface GraphqlTestCredentialBindingInput { + readonly sourceId: string; + readonly sourceScope: ScopeInput; + readonly targetScope: ScopeInput; + readonly slotKey: string; + readonly value: CredentialBindingValue; +} + +export const setGraphqlCredentialBinding = ( + executor: { readonly credentialBindings: CredentialBindingsFacade }, + input: GraphqlTestCredentialBindingInput, +): ReturnType => + executor.credentialBindings.set({ + targetScope: scopeId(input.targetScope), + pluginId: "graphql", + sourceId: input.sourceId, + sourceScope: scopeId(input.sourceScope), + slotKey: input.slotKey, + value: input.value, + }); + +export const listGraphqlCredentialBindings = ( + executor: { readonly credentialBindings: CredentialBindingsFacade }, + input: { readonly sourceId: string; readonly sourceScope: ScopeInput }, +): ReturnType => + executor.credentialBindings.listForSource({ + pluginId: "graphql", + sourceId: input.sourceId, + sourceScope: scopeId(input.sourceScope), + }); + export const TestLayers = { greeting: () => GraphqlTestServer.layer({ schema: makeGreetingGraphqlSchema() }), }; diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 8c46d113f..23e9ed98b 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -4,12 +4,7 @@ import { InternalError, ScopeId, SecretBackedMap } from "@executor-js/sdk/core"; import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; import { McpStoredSourceSchema } from "../sdk/stored-source"; -import { - McpConnectionAuthInput, - McpCredentialInput, - McpSourceBindingInput, - McpSourceBindingRef, -} from "../sdk/types"; +import { McpConnectionAuthInput, McpCredentialInput } from "../sdk/types"; // --------------------------------------------------------------------------- // Params @@ -17,11 +12,6 @@ import { const ScopeParams = { scopeId: ScopeId }; const SourceParams = { scopeId: ScopeId, namespace: Schema.String }; -const SourceBindingParams = { - scopeId: ScopeId, - namespace: Schema.String, - sourceScopeId: ScopeId, -}; // --------------------------------------------------------------------------- // Auth payload (only for remote) @@ -98,13 +88,6 @@ const NamespacePayload = Schema.Struct({ namespace: Schema.String, }); -const RemoveBindingPayload = Schema.Struct({ - sourceId: Schema.String, - sourceScope: ScopeId, - slot: Schema.String, - scope: ScopeId, -}); - // --------------------------------------------------------------------------- // Responses // --------------------------------------------------------------------------- @@ -185,33 +168,6 @@ export const McpGroup = HttpApiGroup.make("mcp") success: UpdateSourceResponse, error: [InternalError, McpConnectionError, McpToolDiscoveryError], }), - ) - .add( - HttpApiEndpoint.get( - "listSourceBindings", - "/scopes/:scopeId/mcp/sources/:namespace/base/:sourceScopeId/bindings", - { - params: SourceBindingParams, - success: Schema.Array(McpSourceBindingRef), - error: [InternalError, McpConnectionError, McpToolDiscoveryError], - }, - ), - ) - .add( - HttpApiEndpoint.post("setSourceBinding", "/scopes/:scopeId/mcp/source-bindings", { - params: ScopeParams, - payload: McpSourceBindingInput, - success: McpSourceBindingRef, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], - }), - ) - .add( - HttpApiEndpoint.post("removeSourceBinding", "/scopes/:scopeId/mcp/source-bindings/remove", { - params: ScopeParams, - payload: RemoveBindingPayload, - success: Schema.Struct({ removed: Schema.Boolean }), - error: [InternalError, McpConnectionError, McpToolDiscoveryError], - }), ); // Errors declared once at the group level — every endpoint inherits. // Plugin domain errors carry their own HttpApiSchema status (4xx); diff --git a/packages/plugins/mcp/src/api/handlers.test.ts b/packages/plugins/mcp/src/api/handlers.test.ts index 8f3177566..60d4e1212 100644 --- a/packages/plugins/mcp/src/api/handlers.test.ts +++ b/packages/plugins/mcp/src/api/handlers.test.ts @@ -29,9 +29,6 @@ const failingExtension: McpPluginExtension = { refreshSource: () => unused, getSource: () => Effect.succeed(null), updateSource: () => unused, - listSourceBindings: () => Effect.succeed([]), - setSourceBinding: () => unused, - removeSourceBinding: () => unused, }; const Api = addGroup(McpGroup); diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index 14ec9f3ae..ae23a8de7 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -168,35 +168,5 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand return { updated: true }; }), ), - ) - .handle("listSourceBindings", ({ params: path }) => - capture( - Effect.gen(function* () { - const ext = yield* McpExtensionService; - return yield* ext.listSourceBindings(path.namespace, path.sourceScopeId); - }), - ), - ) - .handle("setSourceBinding", ({ payload }) => - capture( - Effect.gen(function* () { - const ext = yield* McpExtensionService; - return yield* ext.setSourceBinding(payload); - }), - ), - ) - .handle("removeSourceBinding", ({ payload }) => - capture( - Effect.gen(function* () { - const ext = yield* McpExtensionService; - yield* ext.removeSourceBinding( - payload.sourceId, - payload.sourceScope, - payload.slot, - payload.scope, - ); - return { removed: true }; - }), - ), ), ); diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index e0a1d69f0..1bb1370ed 100644 --- a/packages/plugins/mcp/src/react/EditMcpSource.tsx +++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx @@ -2,13 +2,12 @@ import { useState } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Exit from "effect/Exit"; +import { mcpSourceAtom, updateMcpSource } from "./atoms"; import { - mcpSourceAtom, - mcpSourceBindingsAtom, - setMcpSourceBinding, - updateMcpSource, -} from "./atoms"; -import { connectionsAtom } from "@executor-js/react/api/atoms"; + connectionsAtom, + setSourceCredentialBinding, + sourceCredentialBindingsAtom, +} from "@executor-js/react/api/atoms"; import { useScope, useScopeStack } from "@executor-js/react/api/scope-context"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity"; @@ -29,12 +28,9 @@ import { SourceOAuthConnectionControl } from "@executor-js/react/plugins/source- import { Button } from "@executor-js/react/components/button"; import { Badge } from "@executor-js/react/components/badge"; import { ScopeId } from "@executor-js/sdk/core"; +import type { CredentialBindingRef } from "@executor-js/sdk/core"; import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; -import { - McpSourceBindingInput, - type McpCredentialInput, - type McpSourceBindingRef, -} from "../sdk/types"; +import { type McpCredentialInput } from "../sdk/types"; import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; // --------------------------------------------------------------------------- @@ -44,7 +40,7 @@ import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; function RemoteEditForm(props: { sourceId: string; initial: McpStoredSourceSchemaType & { config: { transport: "remote" } }; - bindings: readonly McpSourceBindingRef[]; + bindings: readonly CredentialBindingRef[]; onSave: () => void; }) { const displayScope = useScope(); @@ -62,7 +58,7 @@ function RemoteEditForm(props: { initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), }); const doUpdate = useAtomSet(updateMcpSource, { mode: "promiseExit" }); - const setBinding = useAtomSet(setMcpSourceBinding, { mode: "promise" }); + const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const secretList = useSecretPickerSecrets(); const connectionsResult = useAtomValue(connectionsAtom(displayScope)); @@ -206,13 +202,14 @@ function RemoteEditForm(props: { onConnected={async (connectionId) => { await setBinding({ params: { scopeId: oauthCredentialTargetScope }, - payload: McpSourceBindingInput.make({ + payload: { + targetScope: oauthCredentialTargetScope, + pluginId: "mcp", sourceId: props.sourceId, sourceScope, - scope: oauthCredentialTargetScope, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId }, - }), + }, reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], }); }} @@ -297,7 +294,9 @@ export default function EditMcpSource({ const source = AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; const sourceScope = source ? ScopeId.make(source.scope) : scopeId; - const bindingsResult = useAtomValue(mcpSourceBindingsAtom(scopeId, sourceId, sourceScope)); + const bindingsResult = useAtomValue( + sourceCredentialBindingsAtom(scopeId, "mcp", sourceId, sourceScope), + ); if (!AsyncResult.isSuccess(sourceResult) || !source || !AsyncResult.isSuccess(bindingsResult)) { return ( diff --git a/packages/plugins/mcp/src/react/McpSignInButton.tsx b/packages/plugins/mcp/src/react/McpSignInButton.tsx index f1684367c..414c2316d 100644 --- a/packages/plugins/mcp/src/react/McpSignInButton.tsx +++ b/packages/plugins/mcp/src/react/McpSignInButton.tsx @@ -4,14 +4,17 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { ScopeId } from "@executor-js/sdk/core"; import { useScope, useUserScope } from "@executor-js/react/api/scope-context"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; -import { connectionsAtom } from "@executor-js/react/api/atoms"; +import { + connectionsAtom, + setSourceCredentialBinding, + sourceCredentialBindingsAtom, +} from "@executor-js/react/api/atoms"; import { SourceOAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; import { slugifyNamespace } from "@executor-js/react/plugins/source-identity"; import { secretBackedValuesFromConfiguredCredentialBindings } from "@executor-js/react/plugins/credential-bindings"; -import { mcpSourceAtom, mcpSourceBindingsAtom, setMcpSourceBinding } from "./atoms"; +import { mcpSourceAtom } from "./atoms"; import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; -import { McpSourceBindingInput } from "../sdk/types"; // --------------------------------------------------------------------------- // McpSignInButton — top-bar action on the source detail page. @@ -31,10 +34,10 @@ export default function McpSignInButton(props: { sourceId: string }) { AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; const sourceScope = source ? ScopeId.make(source.scope) : scopeId; const bindingsResult = useAtomValue( - mcpSourceBindingsAtom(userScopeId, props.sourceId, sourceScope), + sourceCredentialBindingsAtom(userScopeId, "mcp", props.sourceId, sourceScope), ); const connectionsResult = useAtomValue(connectionsAtom(userScopeId)); - const setBinding = useAtomSet(setMcpSourceBinding, { mode: "promise" }); + const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const remote = source && source.config.transport === "remote" ? source.config : null; const oauth2 = remote && remote.auth.kind === "oauth2" ? remote.auth : null; @@ -43,7 +46,7 @@ export default function McpSignInButton(props: { sourceId: string }) { : null; const bindings = AsyncResult.isSuccess(bindingsResult) ? bindingsResult.value : null; const connectionBinding = bindings?.find( - (binding) => binding.slot === oauth2?.connectionSlot && binding.value.kind === "connection", + (binding) => binding.slotKey === oauth2?.connectionSlot && binding.value.kind === "connection", ); const connectionId = connectionBinding?.value.kind === "connection" ? connectionBinding.value.connectionId : null; @@ -76,13 +79,14 @@ export default function McpSignInButton(props: { sourceId: string }) { onConnected={async (nextConnectionId) => { await setBinding({ params: { scopeId: userScopeId }, - payload: McpSourceBindingInput.make({ + payload: { + targetScope: userScopeId, + pluginId: "mcp", sourceId: props.sourceId, sourceScope, - scope: userScopeId, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: nextConnectionId }, - }), + }, reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], }); }} diff --git a/packages/plugins/mcp/src/react/McpSourceSummary.tsx b/packages/plugins/mcp/src/react/McpSourceSummary.tsx index cdae6d083..2d96c8e52 100644 --- a/packages/plugins/mcp/src/react/McpSourceSummary.tsx +++ b/packages/plugins/mcp/src/react/McpSourceSummary.tsx @@ -1,7 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { connectionsAtom } from "@executor-js/react/api/atoms"; +import { connectionsAtom, sourceCredentialBindingsAtom } from "@executor-js/react/api/atoms"; import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; import { SourceCredentialNotice, @@ -11,7 +11,7 @@ import { } from "@executor-js/react/plugins/source-credential-status"; import { ScopeId } from "@executor-js/sdk/core"; -import { mcpSourceAtom, mcpSourceBindingsAtom } from "./atoms"; +import { mcpSourceAtom } from "./atoms"; import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; const sourceCredentialSlots = ( @@ -62,7 +62,7 @@ export default function McpSourceSummary(props: { AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; const sourceScope = source ? ScopeId.make(source.scope) : displayScope; const bindingsResult = useAtomValue( - mcpSourceBindingsAtom(displayScope, props.sourceId, sourceScope), + sourceCredentialBindingsAtom(displayScope, "mcp", props.sourceId, sourceScope), ); const connectionsResult = useAtomValue(connectionsAtom(displayScope)); diff --git a/packages/plugins/mcp/src/react/atoms.ts b/packages/plugins/mcp/src/react/atoms.ts index 499a3978c..0ff97a7cb 100644 --- a/packages/plugins/mcp/src/react/atoms.ts +++ b/packages/plugins/mcp/src/react/atoms.ts @@ -16,17 +16,6 @@ export const mcpSourceAtom = (scopeId: ScopeId, namespace: string) => reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools], }); -export const mcpSourceBindingsAtom = ( - scopeId: ScopeId, - namespace: string, - sourceScopeId: ScopeId, -) => - McpClient.query("mcp", "listSourceBindings", { - params: { scopeId, namespace, sourceScopeId }, - timeToLive: "15 seconds", - reactivityKeys: [ReactivityKey.sources, ReactivityKey.secrets, ReactivityKey.connections], - }); - // --------------------------------------------------------------------------- // Mutation atoms // --------------------------------------------------------------------------- @@ -62,5 +51,3 @@ export const addMcpSourceOptimistic = Atom.family((scopeId: ScopeId) => export const removeMcpSource = McpClient.mutation("mcp", "removeSource"); export const refreshMcpSource = McpClient.mutation("mcp", "refreshSource"); export const updateMcpSource = McpClient.mutation("mcp", "updateSource"); -export const setMcpSourceBinding = McpClient.mutation("mcp", "setSourceBinding"); -export const removeMcpSourceBinding = McpClient.mutation("mcp", "removeSourceBinding"); diff --git a/packages/plugins/mcp/src/sdk/index.ts b/packages/plugins/mcp/src/sdk/index.ts index fae9d4b51..10c178ebc 100644 --- a/packages/plugins/mcp/src/sdk/index.ts +++ b/packages/plugins/mcp/src/sdk/index.ts @@ -26,9 +26,6 @@ export { McpConnectionAuth, McpConnectionAuthInput, McpCredentialInput, - McpSourceBindingInput, - McpSourceBindingRef, mcpHeaderSlot, mcpQueryParamSlot, - type McpSourceBindingValue, } from "./types"; diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index dffc216ee..5de04ca65 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -20,7 +20,7 @@ import { import { mcpPlugin, userFacingProbeMessage } from "./plugin"; import { MCP_OAUTH_CONNECTION_SLOT } from "./types"; import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; -import { serveMcpServer } from "../testing"; +import { listMcpCredentialBindings, serveMcpServer, setMcpCredentialBinding } from "../testing"; // --------------------------------------------------------------------------- // Memory secrets plugin — without a writable provider in the stack, @@ -449,7 +449,10 @@ describe("mcpPlugin", () => { auth: { kind: "none" }, }); - const bindings = yield* executor.mcp.listSourceBindings("stale_binding", "test-scope"); + const bindings = yield* listMcpCredentialBindings(executor, { + sourceId: "stale_binding", + sourceScope: "test-scope", + }); expect(bindings).toEqual([]); }), ); @@ -707,11 +710,11 @@ describe("mcpPlugin", () => { }, }), ); - yield* executor.mcp.setSourceBinding({ + yield* setMcpCredentialBinding(executor, { sourceId: "team_mcp", sourceScope: ORG_SCOPE_ID, - scope: USER_SCOPE_ID, - slot: MCP_OAUTH_CONNECTION_SLOT, + targetScope: USER_SCOPE_ID, + slotKey: MCP_OAUTH_CONNECTION_SLOT, value: { kind: "connection", connectionId }, }); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index e676656c4..80f82759c 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -48,14 +48,11 @@ import { MCP_OAUTH_CLIENT_SECRET_SLOT, MCP_OAUTH_CONNECTION_SLOT, McpToolBinding, - McpSourceBindingInput, - McpSourceBindingRef, mcpHeaderSlot, mcpQueryParamSlot, type McpConnectionAuth, type McpConnectionAuthInput, type McpCredentialInput, - type McpSourceBindingValue, type SecretBackedValue, type McpStoredSourceData, type ConfiguredMcpCredentialValue, @@ -235,42 +232,12 @@ const scopeRanks = (ctx: PluginCtx): ReadonlyMap, scopeId: string): number => ranks.get(scopeId) ?? Infinity; -const coreBindingToMcpBinding = (binding: CredentialBindingRef): McpSourceBindingRef => - McpSourceBindingRef.make({ - sourceId: binding.sourceId, - sourceScopeId: binding.sourceScopeId, - scopeId: binding.scopeId, - slot: binding.slotKey, - value: binding.value, - createdAt: binding.createdAt, - updatedAt: binding.updatedAt, - }); - -const listMcpSourceBindings = ( - ctx: PluginCtx, - sourceId: string, - sourceScope: string, -): Effect.Effect => - Effect.gen(function* () { - const ranks = scopeRanks(ctx); - const sourceSourceRank = scopeRank(ranks, sourceScope); - if (sourceSourceRank === Infinity) return []; - const bindings = yield* ctx.credentialBindings.listForSource({ - pluginId: MCP_PLUGIN_ID, - sourceId, - sourceScope: ScopeId.make(sourceScope), - }); - return bindings - .filter((binding) => scopeRank(ranks, binding.scopeId) <= sourceSourceRank) - .map(coreBindingToMcpBinding); - }); - -const resolveMcpSourceBinding = ( +const resolveMcpCredentialBinding = ( ctx: PluginCtx, sourceId: string, sourceScope: string, slot: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const ranks = scopeRanks(ctx); const sourceSourceRank = scopeRank(ranks, sourceScope); @@ -286,7 +253,7 @@ const resolveMcpSourceBinding = ( candidate.slotKey === slot && scopeRank(ranks, candidate.scopeId) <= sourceSourceRank, ) .sort((a, b) => scopeRank(ranks, a.scopeId) - scopeRank(ranks, b.scopeId))[0]; - return binding ? coreBindingToMcpBinding(binding) : null; + return binding ?? null; }); const validateMcpBindingTarget = ( @@ -364,12 +331,12 @@ const canonicalizeCredentialMap = ( readonly values: Record; readonly bindings: ReadonlyArray<{ readonly slot: string; - readonly value: McpSourceBindingValue; + readonly value: CredentialBindingValue; readonly targetScope?: string; }>; } => { const nextValues: Record = {}; - const bindings: Array<{ slot: string; value: McpSourceBindingValue; targetScope?: string }> = []; + const bindings: Array<{ slot: string; value: CredentialBindingValue; targetScope?: string }> = []; for (const [name, value] of Object.entries(values ?? {})) { if (typeof value === "string") { nextValues[name] = value; @@ -406,7 +373,7 @@ const canonicalizeAuth = ( readonly auth: McpConnectionAuth; readonly bindings: ReadonlyArray<{ readonly slot: string; - readonly value: McpSourceBindingValue; + readonly value: CredentialBindingValue; readonly targetScope?: string; }>; } => { @@ -434,7 +401,7 @@ const canonicalizeAuth = ( }; } if ("connectionSlot" in auth) return { auth, bindings: [] }; - const bindings: Array<{ slot: string; value: McpSourceBindingValue; targetScope?: string }> = [ + const bindings: Array<{ slot: string; value: CredentialBindingValue; targetScope?: string }> = [ { slot: MCP_OAUTH_CONNECTION_SLOT, value: { @@ -563,7 +530,7 @@ const resolveMcpBindingValueMap = ( resolved[name] = value; continue; } - const binding = yield* resolveMcpSourceBinding( + const binding = yield* resolveMcpCredentialBinding( ctx, params.sourceId, params.sourceScope, @@ -665,7 +632,7 @@ const resolveMcpHeaderAuth = ( ): Effect.Effect, McpConnectionError | StorageFailure> => Effect.gen(function* () { if (auth.kind !== "header") return {}; - const binding = yield* resolveMcpSourceBinding(ctx, sourceId, sourceScope, auth.secretSlot); + const binding = yield* resolveMcpCredentialBinding(ctx, sourceId, sourceScope, auth.secretSlot); if (binding?.value.kind === "secret") { const secret = yield* ctx.secrets.getAtScope(binding.value.secretId, binding.scopeId).pipe( Effect.catchTag("SecretOwnedByConnectionError", () => @@ -704,7 +671,12 @@ const resolveMcpStoredOauthProvider = ( ): Effect.Effect => Effect.gen(function* () { if (auth.kind !== "oauth2") return undefined; - const binding = yield* resolveMcpSourceBinding(ctx, sourceId, sourceScope, auth.connectionSlot); + const binding = yield* resolveMcpCredentialBinding( + ctx, + sourceId, + sourceScope, + auth.connectionSlot, + ); if (binding?.value.kind !== "connection") { return yield* new McpConnectionError({ transport: "remote", @@ -768,7 +740,7 @@ const resolveMcpInputAuth = ( "connectionId" in auth ? { id: ConnectionId.make(auth.connectionId), scope: targetScope ?? sourceScope } : yield* Effect.gen(function* () { - const binding = yield* resolveMcpSourceBinding( + const binding = yield* resolveMcpCredentialBinding( ctx, sourceId, sourceScope, @@ -1655,40 +1627,6 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { refreshSource, getSource, updateSource, - listSourceBindings: (sourceId: string, sourceScope: string) => - listMcpSourceBindings(ctx, sourceId, sourceScope), - setSourceBinding: (input: McpSourceBindingInput) => - Effect.gen(function* () { - yield* validateMcpBindingTarget(ctx, { - sourceId: input.sourceId, - sourceScope: input.sourceScope, - targetScope: input.scope, - }); - const binding = yield* ctx.credentialBindings.set({ - targetScope: input.scope, - pluginId: MCP_PLUGIN_ID, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - slotKey: input.slot, - value: input.value, - }); - return coreBindingToMcpBinding(binding); - }), - removeSourceBinding: (sourceId: string, sourceScope: string, slot: string, scope: string) => - Effect.gen(function* () { - yield* validateMcpBindingTarget(ctx, { - sourceId, - sourceScope, - targetScope: scope, - }); - yield* ctx.credentialBindings.remove({ - targetScope: ScopeId.make(scope), - pluginId: MCP_PLUGIN_ID, - sourceId, - sourceScope: ScopeId.make(sourceScope), - slotKey: slot, - }); - }), }; }, @@ -1977,17 +1915,4 @@ export interface McpPluginExtension { scope: string, input: McpUpdateSourceInput, ) => Effect.Effect; - readonly listSourceBindings: ( - sourceId: string, - sourceScope: string, - ) => Effect.Effect; - readonly setSourceBinding: ( - input: McpSourceBindingInput, - ) => Effect.Effect; - readonly removeSourceBinding: ( - sourceId: string, - sourceScope: string, - slot: string, - scope: string, - ) => Effect.Effect; } diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index 39c641e31..4cc26771d 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -1,7 +1,6 @@ import { Effect, Schema } from "effect"; import { ConfiguredCredentialValue, - CredentialBindingValue, credentialSlotKey, ScopedSecretCredentialInput, ScopeId, @@ -86,29 +85,6 @@ export const McpConnectionAuthInput = Schema.Union([ ]); export type McpConnectionAuthInput = typeof McpConnectionAuthInput.Type; -export const McpSourceBindingValue = CredentialBindingValue; -export type McpSourceBindingValue = typeof McpSourceBindingValue.Type; - -export const McpSourceBindingInput = Schema.Struct({ - sourceId: Schema.String, - sourceScope: ScopeId, - scope: ScopeId, - slot: Schema.String, - value: McpSourceBindingValue, -}); -export type McpSourceBindingInput = typeof McpSourceBindingInput.Type; - -export const McpSourceBindingRef = Schema.Struct({ - sourceId: Schema.String, - sourceScopeId: ScopeId, - scopeId: ScopeId, - slot: Schema.String, - value: McpSourceBindingValue, - createdAt: Schema.Date, - updatedAt: Schema.Date, -}); -export type McpSourceBindingRef = typeof McpSourceBindingRef.Type; - // --------------------------------------------------------------------------- // Stored source data — discriminated union on transport // --------------------------------------------------------------------------- diff --git a/packages/plugins/mcp/src/testing/index.ts b/packages/plugins/mcp/src/testing/index.ts index b7e49a053..3e29fa84b 100644 --- a/packages/plugins/mcp/src/testing/index.ts +++ b/packages/plugins/mcp/src/testing/index.ts @@ -1 +1,42 @@ +import { + ScopeId, + type CredentialBindingsFacade, + type CredentialBindingValue, +} from "@executor-js/sdk"; + export { serveMcpServer, type McpTestServer } from "./server"; + +type ScopeInput = ScopeId | string; + +const scopeId = (scope: ScopeInput): ScopeId => ScopeId.make(String(scope)); + +export interface McpTestCredentialBindingInput { + readonly sourceId: string; + readonly sourceScope: ScopeInput; + readonly targetScope: ScopeInput; + readonly slotKey: string; + readonly value: CredentialBindingValue; +} + +export const setMcpCredentialBinding = ( + executor: { readonly credentialBindings: CredentialBindingsFacade }, + input: McpTestCredentialBindingInput, +): ReturnType => + executor.credentialBindings.set({ + targetScope: scopeId(input.targetScope), + pluginId: "mcp", + sourceId: input.sourceId, + sourceScope: scopeId(input.sourceScope), + slotKey: input.slotKey, + value: input.value, + }); + +export const listMcpCredentialBindings = ( + executor: { readonly credentialBindings: CredentialBindingsFacade }, + input: { readonly sourceId: string; readonly sourceScope: ScopeInput }, +): ReturnType => + executor.credentialBindings.listForSource({ + pluginId: "mcp", + sourceId: input.sourceId, + sourceScope: scopeId(input.sourceScope), + }); diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index 6fea6f0cc..464d14017 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -10,11 +10,7 @@ import { import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors"; import { SpecPreview } from "../sdk/preview"; import { StoredSourceSchema } from "../sdk/store"; -import { - OAuth2SourceConfig, - OpenApiSourceBindingInput, - OpenApiSourceBindingRef, -} from "../sdk/types"; +import { OAuth2SourceConfig } from "../sdk/types"; // --------------------------------------------------------------------------- // Params @@ -36,12 +32,6 @@ const SourceParams = { namespace: Schema.String, }; -const SourceBindingParams = { - scopeId: ScopeId, - namespace: Schema.String, - sourceScopeId: ScopeId, -}; - const SpecFetchCredentialsPayload = Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)), queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)), @@ -102,13 +92,6 @@ const UpdateSourceResponse = Schema.Struct({ updated: Schema.Boolean, }); -const RemoveBindingPayload = Schema.Struct({ - sourceId: Schema.String, - sourceScope: ScopeId, - slot: Schema.String, - scope: ScopeId, -}); - // --------------------------------------------------------------------------- // Responses // --------------------------------------------------------------------------- @@ -169,31 +152,4 @@ export const OpenApiGroup = HttpApiGroup.make("openapi") success: UpdateSourceResponse, error: DomainErrors, }), - ) - .add( - HttpApiEndpoint.get( - "listSourceBindings", - "/scopes/:scopeId/openapi/sources/:namespace/base/:sourceScopeId/bindings", - { - params: SourceBindingParams, - success: Schema.Array(OpenApiSourceBindingRef), - error: DomainErrors, - }, - ), - ) - .add( - HttpApiEndpoint.post("setSourceBinding", "/scopes/:scopeId/openapi/source-bindings", { - params: ScopeIdParam, - payload: OpenApiSourceBindingInput, - success: OpenApiSourceBindingRef, - error: DomainErrors, - }), - ) - .add( - HttpApiEndpoint.post("removeSourceBinding", "/scopes/:scopeId/openapi/source-bindings/remove", { - params: ScopeIdParam, - payload: RemoveBindingPayload, - success: Schema.Struct({ removed: Schema.Boolean }), - error: DomainErrors, - }), ); diff --git a/packages/plugins/openapi/src/api/handlers.ts b/packages/plugins/openapi/src/api/handlers.ts index 25bfecbc3..295709735 100644 --- a/packages/plugins/openapi/src/api/handlers.ts +++ b/packages/plugins/openapi/src/api/handlers.ts @@ -10,7 +10,6 @@ import type { OpenApiSpecFetchCredentialsInput, OpenApiUpdateSourceInput, } from "../sdk/plugin"; -import { OpenApiSourceBindingInput } from "../sdk/types"; import { StoredSourceSchema } from "../sdk/store"; import { OpenApiGroup } from "./group"; @@ -121,35 +120,5 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope return { updated: true }; }), ), - ) - .handle("listSourceBindings", ({ params: path }) => - capture( - Effect.gen(function* () { - const ext = yield* OpenApiExtensionService; - return yield* ext.listSourceBindings(path.namespace, path.sourceScopeId); - }), - ), - ) - .handle("setSourceBinding", ({ payload }) => - capture( - Effect.gen(function* () { - const ext = yield* OpenApiExtensionService; - return yield* ext.setSourceBinding(OpenApiSourceBindingInput.make(payload)); - }), - ), - ) - .handle("removeSourceBinding", ({ payload }) => - capture( - Effect.gen(function* () { - const ext = yield* OpenApiExtensionService; - yield* ext.removeSourceBinding( - payload.sourceId, - payload.sourceScope, - payload.slot, - payload.scope, - ); - return { removed: true }; - }), - ), ), ); diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index 039c5d595..5110fce60 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/core"; -import { startOAuth } from "@executor-js/react/api/atoms"; +import { setSourceCredentialBinding, startOAuth } from "@executor-js/react/api/atoms"; import { useScope, useScopeStack } from "@executor-js/react/api/scope-context"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; @@ -57,7 +57,7 @@ import { Textarea } from "@executor-js/react/components/textarea"; import { Checkbox } from "@executor-js/react/components/checkbox"; import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; -import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; +import { addOpenApiSpecOptimistic, previewOpenApiSpec } from "./atoms"; import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; import { @@ -67,12 +67,7 @@ import { oauth2ConnectionSlot, queryParamBindingSlot, } from "../sdk/store"; -import { - ConfiguredHeaderBinding, - OAuth2SourceConfig, - OpenApiSourceBindingInput, - type ServerInfo, -} from "../sdk/types"; +import { ConfiguredHeaderBinding, OAuth2SourceConfig, type ServerInfo } from "../sdk/types"; import { expandServerUrlOptions } from "../sdk/openapi-utils"; export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; @@ -290,7 +285,7 @@ export default function AddOpenApiSource(props: { mode: "promiseExit", }); const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); - const doSetBinding = useAtomSet(setOpenApiSourceBinding, { + const doSetBinding = useAtomSet(setSourceCredentialBinding, { mode: "promiseExit", }); const secretList = useSecretPickerSecrets(); @@ -739,18 +734,19 @@ export default function AddOpenApiSource(props: { for (const binding of headerBindings) { const bindingExit = await doSetBinding({ - params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ + params: { scopeId: binding.scope }, + payload: { + targetScope: binding.scope, + pluginId: "openapi", sourceId, sourceScope, - scope: binding.scope, - slot: binding.slot, + slotKey: binding.slot, value: { kind: "secret", secretId: SecretId.make(binding.secretId), secretScopeId: binding.secretScope, }, - }), + }, reactivityKeys: bindingWriteKeys, }); if (Exit.isFailure(bindingExit)) { @@ -762,18 +758,19 @@ export default function AddOpenApiSource(props: { for (const binding of queryParamBindings) { const bindingExit = await doSetBinding({ - params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ + params: { scopeId: binding.scope }, + payload: { + targetScope: binding.scope, + pluginId: "openapi", sourceId, sourceScope, - scope: binding.scope, - slot: binding.slot, + slotKey: binding.slot, value: { kind: "secret", secretId: SecretId.make(binding.secretId), secretScopeId: binding.secretScope, }, - }), + }, reactivityKeys: bindingWriteKeys, }); if (Exit.isFailure(bindingExit)) { @@ -785,18 +782,19 @@ export default function AddOpenApiSource(props: { if (configuredOAuth2 && oauth2ClientIdSecretId) { const bindingExit = await doSetBinding({ - params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ + params: { scopeId: bindingScope }, + payload: { + targetScope: bindingScope, + pluginId: "openapi", sourceId, sourceScope, - scope: bindingScope, - slot: configuredOAuth2.clientIdSlot, + slotKey: configuredOAuth2.clientIdSlot, value: { kind: "secret", secretId: SecretId.make(oauth2ClientIdSecretId), secretScopeId: clientIdSecretScope, }, - }), + }, reactivityKeys: bindingWriteKeys, }); if (Exit.isFailure(bindingExit)) { @@ -808,18 +806,19 @@ export default function AddOpenApiSource(props: { if (configuredOAuth2?.clientSecretSlot && oauth2ClientSecretSecretId) { const bindingExit = await doSetBinding({ - params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ + params: { scopeId: bindingScope }, + payload: { + targetScope: bindingScope, + pluginId: "openapi", sourceId, sourceScope, - scope: bindingScope, - slot: configuredOAuth2.clientSecretSlot, + slotKey: configuredOAuth2.clientSecretSlot, value: { kind: "secret", secretId: SecretId.make(oauth2ClientSecretSecretId), secretScopeId: clientSecretSecretScope, }, - }), + }, reactivityKeys: bindingWriteKeys, }); if (Exit.isFailure(bindingExit)) { @@ -831,17 +830,18 @@ export default function AddOpenApiSource(props: { if (configuredOAuth2 && oauth2Auth) { const bindingExit = await doSetBinding({ - params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ + params: { scopeId: oauthTokenBindingScope }, + payload: { + targetScope: oauthTokenBindingScope, + pluginId: "openapi", sourceId, sourceScope, - scope: oauthTokenBindingScope, - slot: configuredOAuth2.connectionSlot, + slotKey: configuredOAuth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(oauth2Auth.connectionId), }, - }), + }, reactivityKeys: bindingWriteKeys, }); if (Exit.isFailure(bindingExit)) { diff --git a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx index 2e5eec1b9..fa391865e 100644 --- a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx @@ -5,7 +5,14 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { connectionsAtom, sourceAtom, startOAuth } from "@executor-js/react/api/atoms"; +import { + connectionsAtom, + removeSourceCredentialBinding, + setSourceCredentialBinding, + sourceAtom, + sourceCredentialBindingsAtom, + startOAuth, +} from "@executor-js/react/api/atoms"; import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { Button } from "@executor-js/react/components/button"; @@ -34,16 +41,11 @@ import { exactCredentialBindingForScope, isConnectionCredentialBindingValue, isSecretCredentialBindingValue, + type SourceCredentialBindingRef, } from "@executor-js/react/plugins/credential-bindings"; import { SecretCredentialSlotBindings } from "@executor-js/react/plugins/credential-slot-bindings"; -import { - openApiSourceAtom, - openApiSourceBindingsAtom, - removeOpenApiSourceBinding, - setOpenApiSourceBinding, - updateOpenApiSource, -} from "./atoms"; +import { openApiSourceAtom, updateOpenApiSource } from "./atoms"; import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; import { OPENAPI_OAUTH_CALLBACK_PATH, @@ -52,11 +54,7 @@ import { resolveOAuthUrl, } from "./AddOpenApiSource"; import { oauth2ClientSecretSlot } from "../sdk/store"; -import { - OAuth2SourceConfig, - OpenApiSourceBindingInput, - type OpenApiSourceBindingRef, -} from "../sdk/types"; +import { OAuth2SourceConfig } from "../sdk/types"; const ErrorMessage = Schema.Struct({ message: Schema.String }); const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); @@ -131,16 +129,16 @@ export default function EditOpenApiSource(props: { const sourceResult = useAtomValue(openApiSourceAtom(sourceScope, props.sourceId)); const bindingsResult = useAtomValue( - openApiSourceBindingsAtom(displayScope, props.sourceId, sourceScope), + sourceCredentialBindingsAtom(displayScope, "openapi", props.sourceId, sourceScope), ); const connectionsResult = useAtomValue(connectionsAtom(displayScope)); const secretList = useSecretPickerSecrets(); const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promiseExit" }); - const doSetBinding = useAtomSet(setOpenApiSourceBinding, { + const doSetBinding = useAtomSet(setSourceCredentialBinding, { mode: "promiseExit", }); - const doRemoveBinding = useAtomSet(removeOpenApiSourceBinding, { + const doRemoveBinding = useAtomSet(removeSourceCredentialBinding, { mode: "promiseExit", }); const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); @@ -152,7 +150,7 @@ export default function EditOpenApiSource(props: { const source = AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; - const bindingRows: readonly OpenApiSourceBindingRef[] = AsyncResult.isSuccess(bindingsResult) + const bindingRows: readonly SourceCredentialBindingRef[] = AsyncResult.isSuccess(bindingsResult) ? bindingsResult.value : []; const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; @@ -362,18 +360,19 @@ export default function EditOpenApiSource(props: { setBusyKey(inputKey); setError(null); const exit = await doSetBinding({ - params: { scopeId: displayScope }, - payload: OpenApiSourceBindingInput.make({ + params: { scopeId: targetScope }, + payload: { + targetScope, + pluginId: "openapi", sourceId: props.sourceId, sourceScope, - scope: targetScope, - slot, + slotKey: slot, value: { kind: "secret", secretId: SecretId.make(trimmed), secretScopeId: secretScope, }, - }), + }, reactivityKeys: sourceWriteKeys, }); if (Exit.isFailure(exit)) { @@ -386,12 +385,13 @@ export default function EditOpenApiSource(props: { setBusyKey(`${targetScope}:${slot}:clear`); setError(null); const exit = await doRemoveBinding({ - params: { scopeId: displayScope }, + params: { scopeId: targetScope }, payload: { + targetScope, + pluginId: "openapi", sourceId: props.sourceId, sourceScope, - slot, - scope: targetScope, + slotKey: slot, }, reactivityKeys: sourceWriteKeys, }); @@ -489,17 +489,18 @@ export default function EditOpenApiSource(props: { return; } const setBindingExit = await doSetBinding({ - params: { scopeId: displayScope }, - payload: OpenApiSourceBindingInput.make({ + params: { scopeId: targetScope }, + payload: { + targetScope, + pluginId: "openapi", sourceId: props.sourceId, sourceScope, - scope: targetScope, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(response.completedConnection.connectionId), }, - }), + }, reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], }); if (Exit.isFailure(setBindingExit)) { @@ -557,17 +558,18 @@ export default function EditOpenApiSource(props: { }), onSuccess: async (result) => { const setBindingExit = await doSetBinding({ - params: { scopeId: displayScope }, - payload: OpenApiSourceBindingInput.make({ + params: { scopeId: targetScope }, + payload: { + targetScope, + pluginId: "openapi", sourceId: props.sourceId, sourceScope, - scope: targetScope, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(result.connectionId), }, - }), + }, reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], }); if (Exit.isFailure(setBindingExit)) { diff --git a/packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx b/packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx index cdcdd7d15..7a7d800be 100644 --- a/packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx +++ b/packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx @@ -1,7 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { connectionsAtom, sourceAtom } from "@executor-js/react/api/atoms"; +import { + connectionsAtom, + sourceAtom, + sourceCredentialBindingsAtom, +} from "@executor-js/react/api/atoms"; import { Badge } from "@executor-js/react/components/badge"; import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; import { ScopeId } from "@executor-js/sdk/core"; @@ -11,9 +15,9 @@ import { missingSourceCredentialLabels, type SourceCredentialSlot, } from "@executor-js/react/plugins/source-credential-status"; +import { effectiveCredentialBindingForScope } from "@executor-js/react/plugins/credential-bindings"; -import { openApiSourceAtom, openApiSourceBindingsAtom } from "./atoms"; -import { effectiveBindingForScope } from "../sdk/credential-status"; +import { openApiSourceAtom } from "./atoms"; import { oauth2ClientSecretSlot, type StoredSourceSchemaType } from "../sdk/store"; function OAuthBadge() { @@ -80,7 +84,12 @@ export default function OpenApiSourceSummary(props: { : displayScope; const sourceResult = useAtomValue(openApiSourceAtom(ScopeId.make(sourceScopeId), props.sourceId)); const bindingsResult = useAtomValue( - openApiSourceBindingsAtom(displayScope, props.sourceId, ScopeId.make(sourceScopeId)), + sourceCredentialBindingsAtom( + displayScope, + "openapi", + props.sourceId, + ScopeId.make(sourceScopeId), + ), ); const connectionsResult = useAtomValue(connectionsAtom(displayScope)); @@ -118,7 +127,7 @@ export default function OpenApiSourceSummary(props: { if (missing.length > 0) return ; if (!oauth2) return null; - const connectionBinding = effectiveBindingForScope( + const connectionBinding = effectiveCredentialBindingForScope( bindings, oauth2.connectionSlot, credentialTargetScope, diff --git a/packages/plugins/openapi/src/react/atoms.ts b/packages/plugins/openapi/src/react/atoms.ts index 26516297e..64fec81ba 100644 --- a/packages/plugins/openapi/src/react/atoms.ts +++ b/packages/plugins/openapi/src/react/atoms.ts @@ -16,17 +16,6 @@ export const openApiSourceAtom = (scopeId: ScopeId, namespace: string) => reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools], }); -export const openApiSourceBindingsAtom = ( - scopeId: ScopeId, - namespace: string, - sourceScopeId: ScopeId, -) => - OpenApiClient.query("openapi", "listSourceBindings", { - params: { scopeId, namespace, sourceScopeId }, - timeToLive: "15 seconds", - reactivityKeys: [ReactivityKey.sources, ReactivityKey.secrets, ReactivityKey.connections], - }); - // --------------------------------------------------------------------------- // Mutation atoms // --------------------------------------------------------------------------- @@ -63,7 +52,3 @@ export const addOpenApiSpecOptimistic = Atom.family((scopeId: ScopeId) => ); export const updateOpenApiSource = OpenApiClient.mutation("openapi", "updateSource"); - -export const setOpenApiSourceBinding = OpenApiClient.mutation("openapi", "setSourceBinding"); - -export const removeOpenApiSourceBinding = OpenApiClient.mutation("openapi", "removeSourceBinding"); diff --git a/packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts b/packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts index d1e833587..40d1dafbe 100644 --- a/packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts +++ b/packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts @@ -41,7 +41,8 @@ import { serveTestHttpApp } from "@executor-js/sdk/testing"; import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory"; import { openApiPlugin } from "./plugin"; -import { OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; +import { OAuth2SourceConfig } from "./types"; +import { setOpenApiCredentialBinding } from "../testing"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; @@ -282,18 +283,16 @@ layer(TestLayer)("OpenAPI client_credentials OAuth", (it) => { baseUrl, oauth2, }); - yield* userExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: userScope.id, - scope: userScope.id, - slot: oauth2.connectionSlot, - value: { - kind: "connection", - connectionId: ConnectionId.make(completedConnection.connectionId), - }, - }), - ); + yield* setOpenApiCredentialBinding(userExec, { + sourceId: "petstore", + sourceScope: userScope.id, + targetScope: userScope.id, + slotKey: oauth2.connectionSlot, + value: { + kind: "connection", + connectionId: ConnectionId.make(completedConnection.connectionId), + }, + }); // Invoking the tool injects the freshly-minted bearer via // ctx.connections.accessToken. const result = (yield* userExec.tools.invoke( diff --git a/packages/plugins/openapi/src/sdk/credential-status.test.ts b/packages/plugins/openapi/src/sdk/credential-status.test.ts deleted file mode 100644 index 9539a5909..000000000 --- a/packages/plugins/openapi/src/sdk/credential-status.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk"; - -import { - effectiveBindingForScope, - missingCredentialLabels, - type BindingRowForCredentialStatus, - type SourceForCredentialStatus, -} from "./credential-status"; - -const userScope = ScopeId.make("user"); -const orgScope = ScopeId.make("org"); -const scopeRanks = new Map([ - [userScope, 0], - [orgScope, 1], -]); - -const source: SourceForCredentialStatus = { - config: { - headers: { - Authorization: { kind: "binding", slot: "header:authorization", prefix: "Bearer " }, - }, - oauth2: { - securitySchemeName: "oauth2", - flow: "clientCredentials", - clientIdSlot: "oauth2:oauth2:client-id", - clientSecretSlot: "oauth2:oauth2:client-secret", - connectionSlot: "oauth2:oauth2:connection", - }, - }, -}; - -const bindings = ( - scopeId: ScopeId, - slots: readonly string[], -): readonly BindingRowForCredentialStatus[] => - slots.map((slot) => ({ - slot, - scopeId, - value: - slot === "oauth2:oauth2:connection" - ? { - kind: "connection", - connectionId: ConnectionId.make(`${scopeId}-connection`), - } - : { - kind: "secret", - secretId: SecretId.make(`${scopeId}-${slot}`), - }, - })); - -const allSlots = [ - "header:authorization", - "oauth2:oauth2:client-id", - "oauth2:oauth2:client-secret", - "oauth2:oauth2:connection", -] as const; - -describe("OpenAPI credential status", () => { - it("treats personal bindings as satisfying the user's credential status for an org source", () => { - expect( - missingCredentialLabels(source, bindings(userScope, allSlots), userScope, scopeRanks, { - liveConnectionIds: [ConnectionId.make("user-connection")], - }), - ).toEqual([]); - }); - - it("falls back to shared org bindings when the user has no personal override", () => { - expect( - missingCredentialLabels(source, bindings(orgScope, allSlots), userScope, scopeRanks, { - liveConnectionIds: [ConnectionId.make("org-connection")], - }), - ).toEqual([]); - }); - - it("treats a stale connection binding as missing OAuth credentials", () => { - expect( - missingCredentialLabels(source, bindings(userScope, allSlots), userScope, scopeRanks, { - liveConnectionIds: [], - }), - ).toEqual(["OAuth client connection"]); - }); - - it("does not treat personal bindings as satisfying org-level credential status", () => { - expect( - missingCredentialLabels(source, bindings(userScope, allSlots), orgScope, scopeRanks), - ).toEqual(["Authorization", "Client ID", "Client Secret", "OAuth client connection"]); - }); - - it("prefers the personal binding over a shared org binding", () => { - const binding = effectiveBindingForScope( - [ - ...bindings(orgScope, ["oauth2:oauth2:connection"]), - ...bindings(userScope, ["oauth2:oauth2:connection"]), - ], - "oauth2:oauth2:connection", - userScope, - scopeRanks, - ); - - expect(binding?.scopeId).toEqual(userScope); - }); -}); diff --git a/packages/plugins/openapi/src/sdk/credential-status.ts b/packages/plugins/openapi/src/sdk/credential-status.ts deleted file mode 100644 index 50350bf91..000000000 --- a/packages/plugins/openapi/src/sdk/credential-status.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { ConnectionId, ScopeId } from "@executor-js/sdk/core"; - -import { oauth2ClientSecretSlot } from "./store"; -import type { ConfiguredHeaderValue, OpenApiSourceBindingValue } from "./types"; - -export type BindingRowForCredentialStatus = { - readonly slot: string; - readonly scopeId: ScopeId; - readonly value: OpenApiSourceBindingValue; -}; - -export type SourceForCredentialStatus = { - readonly config: { - readonly headers?: Record; - readonly oauth2?: { - readonly securitySchemeName: string; - readonly flow: "authorizationCode" | "clientCredentials"; - readonly clientIdSlot: string; - readonly clientSecretSlot: string | null; - readonly connectionSlot: string; - }; - }; -}; - -const scopeRank = (ranks: ReadonlyMap, scopeId: ScopeId): number => - ranks.get(scopeId) ?? Number.MAX_SAFE_INTEGER; - -export const effectiveBindingForScope = ( - rows: readonly BindingRowForCredentialStatus[], - slot: string, - targetScope: ScopeId, - ranks: ReadonlyMap, -): BindingRowForCredentialStatus | null => - rows - .filter( - (row) => row.slot === slot && scopeRank(ranks, row.scopeId) >= scopeRank(ranks, targetScope), - ) - .sort((a, b) => scopeRank(ranks, a.scopeId) - scopeRank(ranks, b.scopeId))[0] ?? null; - -const hasSecretBinding = ( - rows: readonly BindingRowForCredentialStatus[], - slot: string, - targetScope: ScopeId, - ranks: ReadonlyMap, -) => effectiveBindingForScope(rows, slot, targetScope, ranks)?.value.kind === "secret"; - -const hasConnectionBinding = ( - rows: readonly BindingRowForCredentialStatus[], - slot: string, - targetScope: ScopeId, - ranks: ReadonlyMap, - liveConnectionIds?: ReadonlySet, -) => { - const binding = effectiveBindingForScope(rows, slot, targetScope, ranks); - if (binding?.value.kind !== "connection") return false; - return liveConnectionIds ? liveConnectionIds.has(binding.value.connectionId) : true; -}; - -const effectiveClientSecretSlot = (oauth2: { - readonly securitySchemeName: string; - readonly clientSecretSlot: string | null; -}): string => oauth2.clientSecretSlot ?? oauth2ClientSecretSlot(oauth2.securitySchemeName); - -export function missingCredentialLabels( - source: SourceForCredentialStatus, - bindings: readonly BindingRowForCredentialStatus[], - targetScope: ScopeId, - ranks: ReadonlyMap, - options?: { - readonly liveConnectionIds?: ReadonlySet | readonly ConnectionId[]; - }, -): string[] { - const missing: string[] = []; - const rawLiveConnectionIds = options?.liveConnectionIds; - const liveConnectionIds = rawLiveConnectionIds - ? rawLiveConnectionIds instanceof Set - ? rawLiveConnectionIds - : new Set(rawLiveConnectionIds) - : undefined; - - for (const [headerName, value] of Object.entries(source.config.headers ?? {})) { - if (typeof value === "string") continue; - if (!hasSecretBinding(bindings, value.slot, targetScope, ranks)) { - missing.push(headerName); - } - } - - const oauth2 = source.config.oauth2; - if (!oauth2) return missing; - - if (!hasSecretBinding(bindings, oauth2.clientIdSlot, targetScope, ranks)) { - missing.push("Client ID"); - } - - const clientSecretSlot = effectiveClientSecretSlot(oauth2); - if (!hasSecretBinding(bindings, clientSecretSlot, targetScope, ranks)) { - missing.push("Client Secret"); - } - - if ( - !hasConnectionBinding(bindings, oauth2.connectionSlot, targetScope, ranks, liveConnectionIds) - ) { - missing.push(oauth2.flow === "clientCredentials" ? "OAuth client connection" : "OAuth sign-in"); - } - - return missing; -} diff --git a/packages/plugins/openapi/src/sdk/index.ts b/packages/plugins/openapi/src/sdk/index.ts index b2f89c7b0..547a6015f 100644 --- a/packages/plugins/openapi/src/sdk/index.ts +++ b/packages/plugins/openapi/src/sdk/index.ts @@ -50,9 +50,6 @@ export { InvocationResult, MediaBinding, OAuth2SourceConfig, - OpenApiSourceBindingInput, - OpenApiSourceBindingRef, - OpenApiSourceBindingValue, OperationBinding, OperationParameter, OperationRequestBody, diff --git a/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts b/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts index 21231dde3..eb4013a43 100644 --- a/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts +++ b/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts @@ -45,7 +45,12 @@ import { import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory"; import { openApiPlugin } from "./plugin"; -import { ConfiguredHeaderBinding, OpenApiSourceBindingInput } from "./types"; +import { ConfiguredHeaderBinding } from "./types"; +import { + listOpenApiCredentialBindings, + removeOpenApiCredentialBinding, + setOpenApiCredentialBinding, +} from "../testing"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; @@ -239,54 +244,46 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { // their own scope. Same secret id, same source, different // binding owner and provider value. // ------------------------------------------------------------- - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, - scope: aliceScope.id, - slot: "auth:vercel_api_token", - value: { - kind: "secret", - secretId: SecretId.make("vercel_api_token"), - }, - }), - ); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, - scope: aliceScope.id, - slot: "query_param:vercel_team_token", - value: { - kind: "secret", - secretId: SecretId.make("vercel_team_token"), - }, - }), - ); - yield* bobExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, - scope: bobScope.id, - slot: "auth:vercel_api_token", - value: { - kind: "secret", - secretId: SecretId.make("vercel_api_token"), - }, - }), - ); - yield* bobExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, - scope: bobScope.id, - slot: "query_param:vercel_team_token", - value: { - kind: "secret", - secretId: SecretId.make("vercel_team_token"), - }, - }), - ); + yield* setOpenApiCredentialBinding(aliceExec, { + sourceId: "vercel", + sourceScope: orgScope.id, + targetScope: aliceScope.id, + slotKey: "auth:vercel_api_token", + value: { + kind: "secret", + secretId: SecretId.make("vercel_api_token"), + }, + }); + yield* setOpenApiCredentialBinding(aliceExec, { + sourceId: "vercel", + sourceScope: orgScope.id, + targetScope: aliceScope.id, + slotKey: "query_param:vercel_team_token", + value: { + kind: "secret", + secretId: SecretId.make("vercel_team_token"), + }, + }); + yield* setOpenApiCredentialBinding(bobExec, { + sourceId: "vercel", + sourceScope: orgScope.id, + targetScope: bobScope.id, + slotKey: "auth:vercel_api_token", + value: { + kind: "secret", + secretId: SecretId.make("vercel_api_token"), + }, + }); + yield* setOpenApiCredentialBinding(bobExec, { + sourceId: "vercel", + sourceScope: orgScope.id, + targetScope: bobScope.id, + slotKey: "query_param:vercel_team_token", + value: { + kind: "secret", + secretId: SecretId.make("vercel_team_token"), + }, + }); // ------------------------------------------------------------- // 4. Invoking the shared tool through each user's executor @@ -448,30 +445,26 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { }), ); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, - scope: aliceScope.id, - slot: "auth:personal-token", - value: { - kind: "secret", - secretId: SecretId.make("alice_vercel_pat"), - }, - }), - ); - yield* bobExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, - scope: bobScope.id, - slot: "auth:personal-token", - value: { - kind: "secret", - secretId: SecretId.make("bob_vercel_pat"), - }, - }), - ); + yield* setOpenApiCredentialBinding(aliceExec, { + sourceId: "vercel", + sourceScope: orgScope.id, + targetScope: aliceScope.id, + slotKey: "auth:personal-token", + value: { + kind: "secret", + secretId: SecretId.make("alice_vercel_pat"), + }, + }); + yield* setOpenApiCredentialBinding(bobExec, { + sourceId: "vercel", + sourceScope: orgScope.id, + targetScope: bobScope.id, + slotKey: "auth:personal-token", + value: { + kind: "secret", + secretId: SecretId.make("bob_vercel_pat"), + }, + }); const aliceResult = (yield* aliceExec.tools.invoke( "vercel.projects.list", @@ -573,18 +566,16 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { value: "org-token", }), ); - yield* adminExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, - scope: orgScope.id, - slot: "auth:token", - value: { - kind: "secret", - secretId: SecretId.make("org_vercel_pat"), - }, - }), - ); + yield* setOpenApiCredentialBinding(adminExec, { + sourceId: "vercel", + sourceScope: orgScope.id, + targetScope: orgScope.id, + slotKey: "auth:token", + value: { + kind: "secret", + secretId: SecretId.make("org_vercel_pat"), + }, + }); const sharedResult = (yield* aliceExec.tools.invoke( "vercel.projects.list", @@ -602,18 +593,16 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { value: "alice-token", }), ); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, - scope: aliceScope.id, - slot: "auth:token", - value: { - kind: "secret", - secretId: SecretId.make("alice_vercel_pat"), - }, - }), - ); + yield* setOpenApiCredentialBinding(aliceExec, { + sourceId: "vercel", + sourceScope: orgScope.id, + targetScope: aliceScope.id, + slotKey: "auth:token", + value: { + kind: "secret", + secretId: SecretId.make("alice_vercel_pat"), + }, + }); const overrideResult = (yield* aliceExec.tools.invoke( "vercel.projects.list", @@ -623,12 +612,12 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { expect(overrideResult.error).toBeNull(); expect(overrideResult.data?.authorization).toBe("Bearer alice-token"); - yield* aliceExec.openapi.removeSourceBinding( - "vercel", - String(orgScope.id), - "auth:token", - String(aliceScope.id), - ); + yield* removeOpenApiCredentialBinding(aliceExec, { + sourceId: "vercel", + sourceScope: String(orgScope.id), + slotKey: "auth:token", + targetScope: String(aliceScope.id), + }); const fallbackResult = (yield* aliceExec.tools.invoke( "vercel.projects.list", @@ -638,18 +627,16 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { expect(fallbackResult.error).toBeNull(); expect(fallbackResult.data?.authorization).toBe("Bearer org-token"); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, - scope: aliceScope.id, - slot: "auth:token", - value: { - kind: "secret", - secretId: SecretId.make("alice_vercel_pat"), - }, - }), - ); + yield* setOpenApiCredentialBinding(aliceExec, { + sourceId: "vercel", + sourceScope: orgScope.id, + targetScope: aliceScope.id, + slotKey: "auth:token", + value: { + kind: "secret", + secretId: SecretId.make("alice_vercel_pat"), + }, + }); yield* adminExec.openapi.removeSpec("vercel", String(orgScope.id)); yield* adminExec.openapi.addSpec({ @@ -666,10 +653,10 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { }, }); - const bindingsAfterReadd = yield* aliceExec.openapi.listSourceBindings( - "vercel", - String(orgScope.id), - ); + const bindingsAfterReadd = yield* listOpenApiCredentialBindings(aliceExec, { + sourceId: "vercel", + sourceScope: String(orgScope.id), + }); expect(bindingsAfterReadd).toEqual([]); const error = yield* Effect.flip( @@ -835,18 +822,16 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { value: "alice-token", }), ); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, - scope: aliceScope.id, - slot: "auth:token", - value: { - kind: "secret", - secretId: SecretId.make("alice_vercel_pat"), - }, - }), - ); + yield* setOpenApiCredentialBinding(aliceExec, { + sourceId: "vercel", + sourceScope: orgScope.id, + targetScope: aliceScope.id, + slotKey: "auth:token", + value: { + kind: "secret", + secretId: SecretId.make("alice_vercel_pat"), + }, + }); const result = (yield* aliceExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as { data: { authorization?: string } | null; @@ -934,15 +919,13 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { value: "org-token", }), ); - yield* adminExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, - scope: orgScope.id, - slot: "auth:shared-token", - value: { kind: "secret", secretId: SecretId.make("shared-token") }, - }), - ); + yield* setOpenApiCredentialBinding(adminExec, { + sourceId: "vercel", + sourceScope: orgScope.id, + targetScope: orgScope.id, + slotKey: "auth:shared-token", + value: { kind: "secret", secretId: SecretId.make("shared-token") }, + }); yield* userExec.secrets.set( SetSecretInput.make({ @@ -1041,19 +1024,17 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { }), ); - yield* userExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, - scope: userScope.id, - slot: "auth:personal-choice", - value: { - kind: "secret", - secretId: SecretId.make("org-choice-token"), - secretScopeId: orgScope.id, - }, - }), - ); + yield* setOpenApiCredentialBinding(userExec, { + sourceId: "vercel", + sourceScope: orgScope.id, + targetScope: userScope.id, + slotKey: "auth:personal-choice", + value: { + kind: "secret", + secretId: SecretId.make("org-choice-token"), + secretScopeId: orgScope.id, + }, + }); const result = (yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as { data: { authorization?: string } | null; diff --git a/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts b/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts index 3b5c52e42..478b64607 100644 --- a/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts +++ b/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts @@ -43,7 +43,8 @@ import { serveTestHttpApp } from "@executor-js/sdk/testing"; import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory"; import { openApiPlugin } from "./plugin"; -import { OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; +import { OAuth2SourceConfig } from "./types"; +import { setOpenApiCredentialBinding } from "../testing"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; @@ -335,15 +336,13 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { baseUrl, oauth2, }); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: aliceScope.id, - scope: aliceScope.id, - slot: oauth2.connectionSlot, - value: { kind: "connection", connectionId: ConnectionId.make(aliceAuth.connectionId) }, - }), - ); + yield* setOpenApiCredentialBinding(aliceExec, { + sourceId: "petstore", + sourceScope: aliceScope.id, + targetScope: aliceScope.id, + slotKey: oauth2.connectionSlot, + value: { kind: "connection", connectionId: ConnectionId.make(aliceAuth.connectionId) }, + }); yield* bobExec.openapi.addSpec({ spec: specJson, scope: String(bobScope.id), @@ -351,15 +350,13 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { baseUrl, oauth2, }); - yield* bobExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: bobScope.id, - scope: bobScope.id, - slot: oauth2.connectionSlot, - value: { kind: "connection", connectionId: ConnectionId.make(bobAuth.connectionId) }, - }), - ); + yield* setOpenApiCredentialBinding(bobExec, { + sourceId: "petstore", + sourceScope: bobScope.id, + targetScope: bobScope.id, + slotKey: oauth2.connectionSlot, + value: { kind: "connection", connectionId: ConnectionId.make(bobAuth.connectionId) }, + }); // ------------------------------------------------------------- // 4. Invoke through each exec — Authorization must carry that @@ -603,15 +600,13 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { baseUrl, oauth2, }); - yield* adminExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: orgScope.id, - scope: orgScope.id, - slot: oauth2.connectionSlot, - value: { kind: "connection", connectionId: ConnectionId.make(adminAuth) }, - }), - ); + yield* setOpenApiCredentialBinding(adminExec, { + sourceId: "petstore", + sourceScope: orgScope.id, + targetScope: orgScope.id, + slotKey: oauth2.connectionSlot, + value: { kind: "connection", connectionId: ConnectionId.make(adminAuth) }, + }); // Alice signs in → resolves her shadowed user-scope creds // (`alice-client`), mints her own token, writes at user-alice. @@ -619,24 +614,20 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { // Bob signs in → no user-scope shadow, falls through to the // org defaults (`org-client`), writes at user-bob. const bobAuth = yield* startClientCredentials(bobExec, bobScope.id, startInput); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: orgScope.id, - scope: aliceScope.id, - slot: oauth2.connectionSlot, - value: { kind: "connection", connectionId: ConnectionId.make(aliceAuth) }, - }), - ); - yield* bobExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: orgScope.id, - scope: bobScope.id, - slot: oauth2.connectionSlot, - value: { kind: "connection", connectionId: ConnectionId.make(bobAuth) }, - }), - ); + yield* setOpenApiCredentialBinding(aliceExec, { + sourceId: "petstore", + sourceScope: orgScope.id, + targetScope: aliceScope.id, + slotKey: oauth2.connectionSlot, + value: { kind: "connection", connectionId: ConnectionId.make(aliceAuth) }, + }); + yield* setOpenApiCredentialBinding(bobExec, { + sourceId: "petstore", + sourceScope: orgScope.id, + targetScope: bobScope.id, + slotKey: oauth2.connectionSlot, + value: { kind: "connection", connectionId: ConnectionId.make(bobAuth) }, + }); // ---- Regression assertions ---- diff --git a/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts b/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts index 588473b4f..fa70d55bd 100644 --- a/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts +++ b/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts @@ -50,7 +50,8 @@ import { serveTestHttpApp } from "@executor-js/sdk/testing"; import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory"; import { openApiPlugin } from "./plugin"; -import { OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; +import { OAuth2SourceConfig } from "./types"; +import { setOpenApiCredentialBinding } from "../testing"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; @@ -261,15 +262,13 @@ const bindOAuthConnection = ( connectionId: string, oauth2: OAuth2SourceConfig, ) => - executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: scopeId, - scope: scopeId, - slot: oauth2.connectionSlot, - value: { kind: "connection", connectionId: ConnectionId.make(connectionId) }, - }), - ); + setOpenApiCredentialBinding(executor, { + sourceId: "petstore", + sourceScope: scopeId, + targetScope: scopeId, + slotKey: oauth2.connectionSlot, + value: { kind: "connection", connectionId: ConnectionId.make(connectionId) }, + }); // --------------------------------------------------------------------------- // Tests diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 3d2ae0fde..71ebc2a9e 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -31,8 +31,13 @@ import type { ConfigFileSink } from "@executor-js/config"; const TEST_SCOPE = "test-scope"; import { openApiPlugin } from "./plugin"; -import { ConfiguredHeaderBinding, OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; -import { makeOpenApiTestServer } from "../testing"; +import { ConfiguredHeaderBinding, OAuth2SourceConfig } from "./types"; +import { + listOpenApiCredentialBindings, + makeOpenApiTestServer, + removeOpenApiCredentialBinding, + setOpenApiCredentialBinding, +} from "../testing"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; @@ -399,14 +404,14 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { yield* executor.openapi.addSpec(input); - const bindings = yield* executor.openapi.listSourceBindings( - "org_direct_user_credential", - String(orgScope), - ); + const bindings = yield* listOpenApiCredentialBindings(executor, { + sourceId: "org_direct_user_credential", + sourceScope: String(orgScope), + }); expect(bindings).toHaveLength(1); expect(bindings[0]).toMatchObject({ scopeId: userScope, - slot: "query_param:token", + slotKey: "query_param:token", value: { kind: "secret", secretId: SecretId.make("user-query-token") }, }); }), @@ -450,7 +455,10 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { headers: {}, }); - const bindings = yield* executor.openapi.listSourceBindings("stale_binding", TEST_SCOPE); + const bindings = yield* listOpenApiCredentialBindings(executor, { + sourceId: "stale_binding", + sourceScope: TEST_SCOPE, + }); expect(bindings).toEqual([]); }), ); @@ -496,15 +504,13 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { baseUrl: "", oauth2: oldOAuth, }); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "stale_oauth", - sourceScope: ScopeId.make(TEST_SCOPE), - scope: ScopeId.make(TEST_SCOPE), - slot: oldOAuth.clientIdSlot, - value: { kind: "secret", secretId: SecretId.make("old-client-id") }, - }), - ); + yield* setOpenApiCredentialBinding(executor, { + sourceId: "stale_oauth", + sourceScope: ScopeId.make(TEST_SCOPE), + targetScope: ScopeId.make(TEST_SCOPE), + slotKey: oldOAuth.clientIdSlot, + value: { kind: "secret", secretId: SecretId.make("old-client-id") }, + }); yield* executor.openapi.updateSource("stale_oauth", TEST_SCOPE, { oauth2: OAuth2SourceConfig.make({ @@ -520,8 +526,11 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }), }); - const bindings = yield* executor.openapi.listSourceBindings("stale_oauth", TEST_SCOPE); - expect(bindings.some((binding) => binding.slot === oldOAuth.clientIdSlot)).toBe(false); + const bindings = yield* listOpenApiCredentialBindings(executor, { + sourceId: "stale_oauth", + sourceScope: TEST_SCOPE, + }); + expect(bindings.some((binding) => binding.slotKey === oldOAuth.clientIdSlot)).toBe(false); }), ); @@ -611,14 +620,14 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }, }); - const bindings = yield* executor.openapi.listSourceBindings( - "default_target_scope", - TEST_SCOPE, - ); + const bindings = yield* listOpenApiCredentialBindings(executor, { + sourceId: "default_target_scope", + sourceScope: TEST_SCOPE, + }); expect(bindings).toHaveLength(1); expect(bindings[0]).toMatchObject({ scopeId: ScopeId.make(TEST_SCOPE), - slot: "header:authorization", + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("config-sync-token") }, }); }), @@ -673,15 +682,13 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }), }, }); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "noauth", - sourceScope: ScopeId.make(TEST_SCOPE), - scope: ScopeId.make(TEST_SCOPE), - slot: "header:authorization", - value: { kind: "secret", secretId: SecretId.make("missing-token") }, - }), - ); + yield* setOpenApiCredentialBinding(executor, { + sourceId: "noauth", + sourceScope: ScopeId.make(TEST_SCOPE), + targetScope: ScopeId.make(TEST_SCOPE), + slotKey: "header:authorization", + value: { kind: "secret", secretId: SecretId.make("missing-token") }, + }); secretStore.delete(key(TEST_SCOPE, "missing-token")); const error = yield* Effect.flip( @@ -894,7 +901,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }), ); - it.effect("listSourceBindings returns [] for a removed source", () => + it.effect("core credential bindings return [] for a removed source", () => // Regression: the React bindings atom revalidates after a removeSpec // (sourceWriteKeys invalidate it) before unmount. The store used to // throw StorageError("source does not exist"), which surfaced to the @@ -919,7 +926,10 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }); yield* executor.openapi.removeSpec("removable", TEST_SCOPE); - const bindings = yield* executor.openapi.listSourceBindings("removable", TEST_SCOPE); + const bindings = yield* listOpenApiCredentialBindings(executor, { + sourceId: "removable", + sourceScope: TEST_SCOPE, + }); expect(bindings).toEqual([]); }), ); @@ -1297,40 +1307,40 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { expect(stored?.config.oauth2?.connectionSlot).toBe("oauth2:oauth2:connection"); expect(stored?.config.oauth2?.clientIdSlot).toBe("oauth2:oauth2:client-id"); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "deferred", - sourceScope: ScopeId.make(TEST_SCOPE), - scope: ScopeId.make(TEST_SCOPE), - slot: stored!.config.oauth2!.clientIdSlot, - value: { kind: "secret", secretId: SecretId.make("acme-client-id") }, - }), - ); + yield* setOpenApiCredentialBinding(executor, { + sourceId: "deferred", + sourceScope: ScopeId.make(TEST_SCOPE), + targetScope: ScopeId.make(TEST_SCOPE), + slotKey: stored!.config.oauth2!.clientIdSlot, + value: { kind: "secret", secretId: SecretId.make("acme-client-id") }, + }); - const clientIdBinding = yield* executor.openapi - .listSourceBindings("deferred", TEST_SCOPE) - .pipe( - Effect.map( - (bindings) => - bindings.find((binding) => binding.slot === stored!.config.oauth2!.clientIdSlot) ?? - null, - ), - ); + const clientIdBinding = yield* listOpenApiCredentialBindings(executor, { + sourceId: "deferred", + sourceScope: TEST_SCOPE, + }).pipe( + Effect.map( + (bindings) => + bindings.find((binding) => binding.slotKey === stored!.config.oauth2!.clientIdSlot) ?? + null, + ), + ); expect(clientIdBinding?.value).toEqual({ kind: "secret", secretId: SecretId.make("acme-client-id"), secretScopeId: ScopeId.make(TEST_SCOPE), }); - const connectionBinding = yield* executor.openapi - .listSourceBindings("deferred", TEST_SCOPE) - .pipe( - Effect.map( - (bindings) => - bindings.find((binding) => binding.slot === stored!.config.oauth2!.connectionSlot) ?? - null, - ), - ); + const connectionBinding = yield* listOpenApiCredentialBindings(executor, { + sourceId: "deferred", + sourceScope: TEST_SCOPE, + }).pipe( + Effect.map( + (bindings) => + bindings.find((binding) => binding.slotKey === stored!.config.oauth2!.connectionSlot) ?? + null, + ), + ); expect(connectionBinding).toBeNull(); // Tools should be listed even without a live connection; invocation @@ -1374,15 +1384,13 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }); // Configure a slot binding pointing at the same secret. - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "with_secret", - sourceScope: ScopeId.make(TEST_SCOPE), - scope: ScopeId.make(TEST_SCOPE), - slot: "header:authorization", - value: { kind: "secret", secretId: SecretId.make("api-key") }, - }), - ); + yield* setOpenApiCredentialBinding(executor, { + sourceId: "with_secret", + sourceScope: ScopeId.make(TEST_SCOPE), + targetScope: ScopeId.make(TEST_SCOPE), + slotKey: "header:authorization", + value: { kind: "secret", secretId: SecretId.make("api-key") }, + }); const usages = yield* executor.secrets.usages(SecretId.make("api-key")); expect(usages.length).toBe(2); @@ -1415,15 +1423,13 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { namespace: "ref", baseUrl: "http://example.com", }); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "ref", - sourceScope: ScopeId.make(TEST_SCOPE), - scope: ScopeId.make(TEST_SCOPE), - slot: "header:authorization", - value: { kind: "secret", secretId: SecretId.make("locked") }, - }), - ); + yield* setOpenApiCredentialBinding(executor, { + sourceId: "ref", + sourceScope: ScopeId.make(TEST_SCOPE), + targetScope: ScopeId.make(TEST_SCOPE), + slotKey: "header:authorization", + value: { kind: "secret", secretId: SecretId.make("locked") }, + }); const failure = yield* executor.secrets .remove( @@ -1436,12 +1442,12 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { expect(Predicate.isTagged(failure, "SecretInUseError")).toBe(true); // Detach the binding, then remove succeeds. - yield* executor.openapi.removeSourceBinding( - "ref", - ScopeId.make(TEST_SCOPE), - "header:authorization", - ScopeId.make(TEST_SCOPE), - ); + yield* removeOpenApiCredentialBinding(executor, { + sourceId: "ref", + sourceScope: ScopeId.make(TEST_SCOPE), + slotKey: "header:authorization", + targetScope: ScopeId.make(TEST_SCOPE), + }); yield* executor.secrets.remove( RemoveSecretInput.make({ id: SecretId.make("locked"), diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 7d0f0f6a9..4119bdc06 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -11,6 +11,7 @@ import { tool, resolveSecretBackedMap, type CredentialBindingRef, + type CredentialBindingValue, type PluginCtx, type StorageFailure, type ToolAnnotations, @@ -44,10 +45,7 @@ import { ConfiguredHeaderBinding, OAuth2SourceConfig, OpenApiCredentialInput as OpenApiCredentialInputSchema, - OpenApiSourceBindingInput, - OpenApiSourceBindingRef, type OpenApiCredentialInput as OpenApiCredentialInputValue, - type OpenApiSourceBindingValue, OperationBinding, type ConfiguredHeaderValue as ConfiguredHeaderValueValue, type HeaderValue as HeaderValueValue, @@ -142,19 +140,6 @@ export interface OpenApiPluginExtension { scope: string, input: OpenApiUpdateSourceInput, ) => Effect.Effect; - readonly listSourceBindings: ( - sourceId: string, - sourceScope: string, - ) => Effect.Effect; - readonly setSourceBinding: ( - input: OpenApiSourceBindingInput, - ) => Effect.Effect; - readonly removeSourceBinding: ( - sourceId: string, - sourceScope: string, - slot: string, - scope: string, - ) => Effect.Effect; } // --------------------------------------------------------------------------- @@ -279,14 +264,14 @@ const canonicalizeHeaders = ( readonly headers: Record; readonly bindings: ReadonlyArray<{ readonly slot: string; - readonly value: OpenApiSourceBindingValue; + readonly value: CredentialBindingValue; readonly targetScope?: string; }>; } => { const nextHeaders: Record = {}; const bindings: Array<{ slot: string; - value: OpenApiSourceBindingValue; + value: CredentialBindingValue; targetScope?: string; }> = []; for (const [name, value] of Object.entries(headers ?? {})) { @@ -326,14 +311,14 @@ const canonicalizeCredentialMap = ( readonly values: Record; readonly bindings: ReadonlyArray<{ readonly slot: string; - readonly value: OpenApiSourceBindingValue; + readonly value: CredentialBindingValue; readonly targetScope?: string; }>; } => { const nextValues: Record = {}; const bindings: Array<{ slot: string; - value: OpenApiSourceBindingValue; + value: CredentialBindingValue; targetScope?: string; }> = []; for (const [name, value] of Object.entries(values ?? {})) { @@ -377,7 +362,7 @@ const canonicalizeSpecFetchCredentials = ( readonly credentials?: SourceConfig["specFetchCredentials"]; readonly bindings: ReadonlyArray<{ readonly slot: string; - readonly value: OpenApiSourceBindingValue; + readonly value: CredentialBindingValue; readonly targetScope?: string; }>; } => { @@ -407,7 +392,7 @@ const canonicalizeOAuth2 = ( readonly oauth2?: OAuth2SourceConfig; readonly bindings: ReadonlyArray<{ readonly slot: string; - readonly value: OpenApiSourceBindingValue; + readonly value: CredentialBindingValue; }>; } => { if (!oauth2) return { bindings: [] }; @@ -433,42 +418,12 @@ const scopeRanks = (ctx: PluginCtx): ReadonlyMap = const scopeRank = (ranks: ReadonlyMap, scopeId: string): number => ranks.get(scopeId) ?? Infinity; -const coreBindingToOpenApiBinding = (binding: CredentialBindingRef): OpenApiSourceBindingRef => - OpenApiSourceBindingRef.make({ - sourceId: binding.sourceId, - sourceScopeId: binding.sourceScopeId, - scopeId: binding.scopeId, - slot: binding.slotKey, - value: binding.value, - createdAt: binding.createdAt, - updatedAt: binding.updatedAt, - }); - -const listOpenApiSourceBindings = ( - ctx: PluginCtx, - sourceId: string, - sourceScope: string, -): Effect.Effect => - Effect.gen(function* () { - const ranks = scopeRanks(ctx); - const sourceSourceRank = scopeRank(ranks, sourceScope); - if (sourceSourceRank === Infinity) return []; - const bindings = yield* ctx.credentialBindings.listForSource({ - pluginId: OPENAPI_PLUGIN_ID, - sourceId, - sourceScope: ScopeId.make(sourceScope), - }); - return bindings - .filter((binding) => scopeRank(ranks, binding.scopeId) <= sourceSourceRank) - .map(coreBindingToOpenApiBinding); - }); - -const resolveOpenApiSourceBinding = ( +const resolveOpenApiCredentialBinding = ( ctx: PluginCtx, sourceId: string, sourceScope: string, slot: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const ranks = scopeRanks(ctx); const sourceSourceRank = scopeRank(ranks, sourceScope); @@ -484,7 +439,7 @@ const resolveOpenApiSourceBinding = ( candidate.slotKey === slot && scopeRank(ranks, candidate.scopeId) <= sourceSourceRank, ) .sort((a, b) => scopeRank(ranks, a.scopeId) - scopeRank(ranks, b.scopeId))[0]; - return binding ? coreBindingToOpenApiBinding(binding) : null; + return binding ?? null; }); const validateOpenApiBindingTarget = ( @@ -612,7 +567,7 @@ const resolveConfiguredValueMap = ( resolved[name] = value; continue; } - const binding = yield* resolveOpenApiSourceBinding( + const binding = yield* resolveOpenApiCredentialBinding( ctx, params.sourceId, params.sourceScope, @@ -702,7 +657,7 @@ const resolveOAuthConnectionId = ( StorageFailure > => Effect.gen(function* () { - const binding = yield* resolveOpenApiSourceBinding( + const binding = yield* resolveOpenApiCredentialBinding( ctx, params.sourceId, params.sourceScope, @@ -1143,43 +1098,6 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { }), ); }), - - listSourceBindings: (sourceId: string, sourceScope: string) => - listOpenApiSourceBindings(ctx, sourceId, sourceScope), - - setSourceBinding: (input: OpenApiSourceBindingInput) => - Effect.gen(function* () { - yield* validateOpenApiBindingTarget(ctx, { - sourceId: input.sourceId, - sourceScope: input.sourceScope, - targetScope: input.scope, - }); - const binding = yield* ctx.credentialBindings.set({ - targetScope: input.scope, - pluginId: OPENAPI_PLUGIN_ID, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - slotKey: input.slot, - value: input.value, - }); - return coreBindingToOpenApiBinding(binding); - }), - - removeSourceBinding: (sourceId: string, sourceScope: string, slot: string, scope: string) => - Effect.gen(function* () { - yield* validateOpenApiBindingTarget(ctx, { - sourceId, - sourceScope, - targetScope: scope, - }); - yield* ctx.credentialBindings.remove({ - targetScope: ScopeId.make(scope), - pluginId: OPENAPI_PLUGIN_ID, - sourceId, - sourceScope: ScopeId.make(sourceScope), - slotKey: slot, - }); - }), }; }, diff --git a/packages/plugins/openapi/src/sdk/types.ts b/packages/plugins/openapi/src/sdk/types.ts index 9915d0169..14e39f93a 100644 --- a/packages/plugins/openapi/src/sdk/types.ts +++ b/packages/plugins/openapi/src/sdk/types.ts @@ -1,11 +1,5 @@ import { Schema } from "effect"; -import { - ConnectionId, - ScopeId, - ScopedSecretCredentialInput, - SecretBackedValue, - SecretId, -} from "@executor-js/sdk/core"; +import { ScopedSecretCredentialInput, SecretBackedValue } from "@executor-js/sdk/core"; // --------------------------------------------------------------------------- // Branded IDs @@ -165,43 +159,6 @@ export const OpenApiCredentialInput = Schema.Union([ ]); export type OpenApiCredentialInput = typeof OpenApiCredentialInput.Type; -export const OpenApiSourceBindingValue = Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("secret"), - secretId: SecretId, - secretScopeId: Schema.optional(ScopeId), - }), - Schema.Struct({ - kind: Schema.Literal("connection"), - connectionId: ConnectionId, - }), - Schema.Struct({ - kind: Schema.Literal("text"), - text: Schema.String, - }), -]); -export type OpenApiSourceBindingValue = typeof OpenApiSourceBindingValue.Type; - -export const OpenApiSourceBindingInput = Schema.Struct({ - sourceId: Schema.String, - sourceScope: ScopeId, - scope: ScopeId, - slot: Schema.String, - value: OpenApiSourceBindingValue, -}); -export type OpenApiSourceBindingInput = typeof OpenApiSourceBindingInput.Type; - -export const OpenApiSourceBindingRef = Schema.Struct({ - sourceId: Schema.String, - sourceScopeId: ScopeId, - scopeId: ScopeId, - slot: Schema.String, - value: OpenApiSourceBindingValue, - createdAt: Schema.Date, - updatedAt: Schema.Date, -}); -export type OpenApiSourceBindingRef = typeof OpenApiSourceBindingRef.Type; - // --------------------------------------------------------------------------- // OAuth2 source config — carries source-owned slots and API-level config to // kick off a fresh sign-in from the source detail UI without needing any diff --git a/packages/plugins/openapi/src/sdk/usage-scope-isolation.test.ts b/packages/plugins/openapi/src/sdk/usage-scope-isolation.test.ts index 470d5751e..e4f1db76c 100644 --- a/packages/plugins/openapi/src/sdk/usage-scope-isolation.test.ts +++ b/packages/plugins/openapi/src/sdk/usage-scope-isolation.test.ts @@ -17,7 +17,7 @@ import { } from "@executor-js/sdk"; import { openApiPlugin } from "./plugin"; -import { OpenApiSourceBindingInput } from "./types"; +import { setOpenApiCredentialBinding } from "../testing"; const specJson = JSON.stringify({ openapi: "3.0.0", @@ -105,15 +105,13 @@ describe("OpenAPI usage scope isolation", () => { namespace: "private_source", baseUrl: "http://example.com", }); - yield* orgAExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "private_source", - sourceScope: orgA.id, - scope: orgA.id, - slot: "header:authorization", - value: { kind: "secret", secretId }, - }), - ); + yield* setOpenApiCredentialBinding(orgAExec, { + sourceId: "private_source", + sourceScope: orgA.id, + targetScope: orgA.id, + slotKey: "header:authorization", + value: { kind: "secret", secretId }, + }); const usages = yield* orgBExec.secrets.usages(secretId); expect(usages).toEqual([]); @@ -179,15 +177,13 @@ describe("OpenAPI usage scope isolation", () => { namespace: "private_source", baseUrl: "http://example.com", }); - yield* orgAExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "private_source", - sourceScope: orgA.id, - scope: orgA.id, - slot: "oauth:connection", - value: { kind: "connection", connectionId }, - }), - ); + yield* setOpenApiCredentialBinding(orgAExec, { + sourceId: "private_source", + sourceScope: orgA.id, + targetScope: orgA.id, + slotKey: "oauth:connection", + value: { kind: "connection", connectionId }, + }); const usages = yield* orgBExec.connections.usages(connectionId); expect(usages).toEqual([]); diff --git a/packages/plugins/openapi/src/testing/index.ts b/packages/plugins/openapi/src/testing/index.ts index cdab74eb7..1c5252cd8 100644 --- a/packages/plugins/openapi/src/testing/index.ts +++ b/packages/plugins/openapi/src/testing/index.ts @@ -1,5 +1,10 @@ import { Context, Data, Effect, Layer, Predicate, Schema } from "effect"; import { HttpClient, HttpServer } from "effect/unstable/http"; +import { + ScopeId, + type CredentialBindingsFacade, + type CredentialBindingValue, +} from "@executor-js/sdk"; export class OpenApiTestServerAddressError extends Data.TaggedError( "OpenApiTestServerAddressError", @@ -84,3 +89,60 @@ export class OpenApiTestServer extends Context.Service ScopeId.make(String(scope)); + +export interface OpenApiTestCredentialBindingInput { + readonly sourceId: string; + readonly sourceScope: ScopeInput; + readonly targetScope: ScopeInput; + readonly slotKey: string; + readonly value: CredentialBindingValue; +} + +export const setOpenApiCredentialBinding = ( + executor: { readonly credentialBindings: CredentialBindingsFacade }, + input: OpenApiTestCredentialBindingInput, +): ReturnType => + executor.credentialBindings.set({ + targetScope: scopeId(input.targetScope), + pluginId: "openapi", + sourceId: input.sourceId, + sourceScope: scopeId(input.sourceScope), + slotKey: input.slotKey, + value: input.value, + }); + +export interface OpenApiTestCredentialBindingSourceInput { + readonly sourceId: string; + readonly sourceScope: ScopeInput; +} + +export const listOpenApiCredentialBindings = ( + executor: { readonly credentialBindings: CredentialBindingsFacade }, + input: OpenApiTestCredentialBindingSourceInput, +): ReturnType => + executor.credentialBindings.listForSource({ + pluginId: "openapi", + sourceId: input.sourceId, + sourceScope: scopeId(input.sourceScope), + }); + +export interface OpenApiTestRemoveCredentialBindingInput extends OpenApiTestCredentialBindingSourceInput { + readonly targetScope: ScopeInput; + readonly slotKey: string; +} + +export const removeOpenApiCredentialBinding = ( + executor: { readonly credentialBindings: CredentialBindingsFacade }, + input: OpenApiTestRemoveCredentialBindingInput, +): ReturnType => + executor.credentialBindings.remove({ + targetScope: scopeId(input.targetScope), + pluginId: "openapi", + sourceId: input.sourceId, + sourceScope: scopeId(input.sourceScope), + slotKey: input.slotKey, + }); diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 124a075b5..2fd53bb8c 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -105,6 +105,18 @@ export const connectionUsagesAtom = (scopeId: ScopeId, connectionId: ConnectionI reactivityKeys: [ReactivityKey.connections, ReactivityKey.sources, ReactivityKey.secrets], }); +export const sourceCredentialBindingsAtom = ( + scopeId: ScopeId, + pluginId: string, + sourceId: string, + sourceScope: ScopeId, +) => + ExecutorApiClient.query("credentialBindings", "listForSource", { + params: { scopeId, pluginId, sourceId, sourceScope }, + timeToLive: "15 seconds", + reactivityKeys: [ReactivityKey.sources, ReactivityKey.secrets, ReactivityKey.connections], + }); + export const policiesAtom = (scopeId: ScopeId) => ExecutorApiClient.query("policies", "list", { params: { scopeId }, @@ -146,6 +158,13 @@ export const completeOAuth = ExecutorApiClient.mutation("oauth", "complete"); export const cancelOAuth = ExecutorApiClient.mutation("oauth", "cancel"); +export const setSourceCredentialBinding = ExecutorApiClient.mutation("credentialBindings", "set"); + +export const removeSourceCredentialBinding = ExecutorApiClient.mutation( + "credentialBindings", + "remove", +); + export const createPolicy = ExecutorApiClient.mutation("policies", "create"); export const updatePolicy = ExecutorApiClient.mutation("policies", "update"); diff --git a/packages/react/src/plugins/credential-bindings.test.ts b/packages/react/src/plugins/credential-bindings.test.ts index 234b080cb..a76f12b3c 100644 --- a/packages/react/src/plugins/credential-bindings.test.ts +++ b/packages/react/src/plugins/credential-bindings.test.ts @@ -26,7 +26,7 @@ describe("credential binding editor helpers", () => { }, bindings: [ { - slot: "header:authorization", + slotKey: "header:authorization", scopeId: personalScope, value: { kind: "secret", @@ -35,7 +35,7 @@ describe("credential binding editor helpers", () => { }, }, { - slot: "query_param:token", + slotKey: "query_param:token", scopeId: organizationScope, value: { kind: "text", @@ -71,7 +71,7 @@ describe("credential binding editor helpers", () => { expect( initialCredentialTargetScope(sourceScope, [ { - slot: "header:authorization", + slotKey: "header:authorization", scopeId: personalScope, value: { kind: "secret", @@ -95,7 +95,7 @@ describe("credential binding editor helpers", () => { }, [ { - slot: "header:authorization", + slotKey: "header:authorization", scopeId: ScopeId.make("user_1"), value: { kind: "secret", diff --git a/packages/react/src/plugins/credential-bindings.tsx b/packages/react/src/plugins/credential-bindings.tsx index ccd97780d..3b1b10618 100644 --- a/packages/react/src/plugins/credential-bindings.tsx +++ b/packages/react/src/plugins/credential-bindings.tsx @@ -10,40 +10,40 @@ type ConfiguredCredentialValueLike = readonly prefix?: string; }; -type CredentialBindingRefLike = { - readonly slot: string; +export type SourceCredentialBindingRef = { + readonly slotKey: string; readonly scopeId: ScopeId; readonly value: CredentialBindingValue; }; const bindingBySlot = ( - bindings: readonly CredentialBindingRefLike[], -): ReadonlyMap => - new Map(bindings.map((binding) => [binding.slot, binding])); + bindings: readonly SourceCredentialBindingRef[], +): ReadonlyMap => + new Map(bindings.map((binding) => [binding.slotKey, binding])); export const initialCredentialTargetScope = ( sourceScope: ScopeId, - bindings: readonly CredentialBindingRefLike[], + bindings: readonly SourceCredentialBindingRef[], ): ScopeId => bindings[0]?.scopeId ?? sourceScope; export const exactCredentialBindingForScope = ( - rows: readonly CredentialBindingRefLike[], + rows: readonly SourceCredentialBindingRef[], slot: string, scopeId: ScopeId, -): CredentialBindingRefLike | null => - rows.find((row) => row.slot === slot && row.scopeId === scopeId) ?? null; +): SourceCredentialBindingRef | null => + rows.find((row) => row.slotKey === slot && row.scopeId === scopeId) ?? null; const scopeRank = (ranks: ReadonlyMap, scopeId: ScopeId): number => ranks.get(scopeId) ?? Number.MAX_SAFE_INTEGER; export const effectiveCredentialBindingForScope = ( - rows: readonly CredentialBindingRefLike[], + rows: readonly SourceCredentialBindingRef[], slot: string, targetScope: ScopeId, ranks: ReadonlyMap, -): CredentialBindingRefLike | null => +): SourceCredentialBindingRef | null => rows.find( - (row) => row.slot === slot && scopeRank(ranks, row.scopeId) >= scopeRank(ranks, targetScope), + (row) => row.slotKey === slot && scopeRank(ranks, row.scopeId) >= scopeRank(ranks, targetScope), ) ?? null; export const isSecretCredentialBindingValue = ( @@ -58,7 +58,7 @@ export const isConnectionCredentialBindingValue = ( const headerFromConfiguredCredential = ( name: string, value: ConfiguredCredentialValueLike, - bindings: ReadonlyMap, + bindings: ReadonlyMap, ): HeaderState | null => { if (typeof value === "string") { return headerValueToState(name, value); @@ -86,7 +86,7 @@ const headerFromConfiguredCredential = ( const queryParamFromConfiguredCredential = ( name: string, value: ConfiguredCredentialValueLike, - bindings: ReadonlyMap, + bindings: ReadonlyMap, ): QueryParamState | null => { if (typeof value === "string") { return { name, secretId: null, literalValue: value }; @@ -112,7 +112,7 @@ const queryParamFromConfiguredCredential = ( export const secretBackedValuesFromConfiguredCredentialBindings = ( values: Record | undefined | null, - bindingsInput: readonly CredentialBindingRefLike[], + bindingsInput: readonly SourceCredentialBindingRef[], ): Record | undefined => { const bindings = bindingBySlot(bindingsInput); const out: Record = {}; @@ -140,7 +140,7 @@ export const secretBackedValuesFromConfiguredCredentialBindings = ( export const httpCredentialsFromConfiguredCredentialBindings = (input: { readonly headers?: Record | null; readonly queryParams?: Record | null; - readonly bindings: readonly CredentialBindingRefLike[]; + readonly bindings: readonly SourceCredentialBindingRef[]; }): HttpCredentialsState => { const bindings = bindingBySlot(input.bindings); diff --git a/packages/react/src/plugins/credential-slot-bindings.tsx b/packages/react/src/plugins/credential-slot-bindings.tsx index 55867ed62..b7ecb59e9 100644 --- a/packages/react/src/plugins/credential-slot-bindings.tsx +++ b/packages/react/src/plugins/credential-slot-bindings.tsx @@ -1,4 +1,4 @@ -import { ScopeId, type CredentialBindingValue } from "@executor-js/sdk"; +import { ScopeId } from "@executor-js/sdk"; import { Button } from "../components/button"; import { CardStackEntryField } from "../components/card-stack"; @@ -7,6 +7,7 @@ import { effectiveCredentialBindingForScope, exactCredentialBindingForScope, isSecretCredentialBindingValue, + type SourceCredentialBindingRef, } from "./credential-bindings"; import { CreatableSecretPicker } from "./secret-header-auth"; import type { SecretPickerSecret } from "./secret-picker"; @@ -22,12 +23,6 @@ export type CredentialBindingScope = { readonly label: string; }; -type CredentialSlotBindingRef = { - readonly slot: string; - readonly scopeId: ScopeId; - readonly value: CredentialBindingValue; -}; - const slugify = (value: string): string => value .trim() @@ -48,7 +43,7 @@ const rowTitle = (bindingScope: CredentialBindingScope, bindingScopeCount: numbe export function SecretCredentialSlotBindings(props: { readonly slots: readonly SecretCredentialSlot[]; readonly bindingScopes: readonly CredentialBindingScope[]; - readonly bindingRows: readonly CredentialSlotBindingRef[]; + readonly bindingRows: readonly SourceCredentialBindingRef[]; readonly scopeRanks: ReadonlyMap; readonly secrets: readonly SecretPickerSecret[]; readonly sourceId: string; diff --git a/packages/react/src/plugins/source-credential-status-core.ts b/packages/react/src/plugins/source-credential-status-core.ts index e4ac7e15b..b57c6436c 100644 --- a/packages/react/src/plugins/source-credential-status-core.ts +++ b/packages/react/src/plugins/source-credential-status-core.ts @@ -1,4 +1,6 @@ -import type { ConnectionId, CredentialBindingValue, ScopeId } from "@executor-js/sdk"; +import type { ConnectionId, ScopeId } from "@executor-js/sdk"; + +import type { SourceCredentialBindingRef } from "./credential-bindings"; export type SourceCredentialSlot = | { @@ -14,11 +16,7 @@ export type SourceCredentialSlot = readonly optional?: boolean; }; -export type SourceCredentialBindingRef = { - readonly slot: string; - readonly scopeId: ScopeId; - readonly value: CredentialBindingValue; -}; +export type { SourceCredentialBindingRef } from "./credential-bindings"; const scopeRank = (ranks: ReadonlyMap, scopeId: ScopeId): number => ranks.get(scopeId) ?? Number.MAX_SAFE_INTEGER; @@ -31,7 +29,8 @@ export const effectiveSourceCredentialBinding = ( ): SourceCredentialBindingRef | null => rows .filter( - (row) => row.slot === slot && scopeRank(ranks, row.scopeId) >= scopeRank(ranks, targetScope), + (row) => + row.slotKey === slot && scopeRank(ranks, row.scopeId) >= scopeRank(ranks, targetScope), ) .sort((a, b) => scopeRank(ranks, a.scopeId) - scopeRank(ranks, b.scopeId))[0] ?? null; diff --git a/packages/react/src/plugins/source-credential-status.test.ts b/packages/react/src/plugins/source-credential-status.test.ts index 1e8abf381..0c4676044 100644 --- a/packages/react/src/plugins/source-credential-status.test.ts +++ b/packages/react/src/plugins/source-credential-status.test.ts @@ -22,7 +22,7 @@ const slots: readonly SourceCredentialSlot[] = [ const bindings = (scopeId: ScopeId): readonly SourceCredentialBindingRef[] => [ { - slot: "header:authorization", + slotKey: "header:authorization", scopeId, value: { kind: "secret", @@ -30,7 +30,7 @@ const bindings = (scopeId: ScopeId): readonly SourceCredentialBindingRef[] => [ }, }, { - slot: "auth:oauth2:connection", + slotKey: "auth:oauth2:connection", scopeId, value: { kind: "connection",