From aa52c517cb9ec08a28e77756285a0e5324ebb175 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 17 May 2026 19:41:52 -0700 Subject: [PATCH 01/19] Consolidate OpenAPI source bindings --- .../src/services/sources-api.node.test.ts | 32 +- .../services/tenant-isolation.node.test.ts | 14 +- notes/plugin-derived-source-configure.md | 356 ++++++++++++++++ packages/core/api/src/handlers/sources.ts | 38 ++ packages/core/api/src/sources/api.ts | 44 ++ packages/core/execution/src/promise.ts | 6 + .../core/sdk/src/credential-bindings.test.ts | 45 +++ packages/core/sdk/src/credential-bindings.ts | 43 ++ packages/core/sdk/src/executor.ts | 132 ++++++ packages/core/sdk/src/index.ts | 6 + packages/core/sdk/src/shared.ts | 13 + packages/plugins/openapi/src/api/group.ts | 96 +++-- packages/plugins/openapi/src/api/handlers.ts | 28 +- .../openapi/src/react/AddOpenApiSource.tsx | 57 ++- .../openapi/src/react/EditOpenApiSource.tsx | 69 ++-- packages/plugins/openapi/src/react/atoms.ts | 17 +- .../src/sdk/client-credentials-oauth.test.ts | 12 +- .../openapi/src/sdk/credential-status.ts | 6 +- packages/plugins/openapi/src/sdk/index.ts | 6 +- .../src/sdk/multi-scope-bearer.test.ts | 130 +++--- .../openapi/src/sdk/multi-scope-oauth.test.ts | 48 +-- .../openapi/src/sdk/oauth-refresh.test.ts | 12 +- .../plugins/openapi/src/sdk/plugin.test.ts | 152 +++---- packages/plugins/openapi/src/sdk/plugin.ts | 381 +++++++++++------- packages/plugins/openapi/src/sdk/store.ts | 15 + packages/plugins/openapi/src/sdk/types.ts | 45 +-- .../src/sdk/usage-scope-isolation.test.ts | 20 +- packages/react/src/api/atoms.tsx | 15 + 28 files changed, 1279 insertions(+), 559 deletions(-) create mode 100644 notes/plugin-derived-source-configure.md diff --git a/apps/cloud/src/services/sources-api.node.test.ts b/apps/cloud/src/services/sources-api.node.test.ts index c471a71a8..b639a7fe9 100644 --- a/apps/cloud/src/services/sources-api.node.test.ts +++ b/apps/cloud/src/services/sources-api.node.test.ts @@ -517,13 +517,12 @@ describe("sources api (HTTP)", () => { value: "alice-secret", }, }); - const binding = yield* client.openapi.setSourceBinding({ + const binding = yield* client.sources.setBinding({ params: { scopeId: ScopeId.make(aliceScope) }, payload: { - sourceId: namespace, - sourceScope: ScopeId.make(orgId), scope: ScopeId.make(aliceScope), - slot: "header:authorization", + source: { id: namespace, scope: ScopeId.make(orgId) }, + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("alice_pat"), @@ -534,7 +533,7 @@ describe("sources api (HTTP)", () => { sourceId: namespace, sourceScopeId: ScopeId.make(orgId), scopeId: ScopeId.make(aliceScope), - slot: "header:authorization", + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("alice_pat"), @@ -555,13 +554,12 @@ describe("sources api (HTTP)", () => { value: "bob-secret", }, }); - yield* client.openapi.setSourceBinding({ + yield* client.sources.setBinding({ params: { scopeId: ScopeId.make(bobScope) }, payload: { - sourceId: namespace, - sourceScope: ScopeId.make(orgId), scope: ScopeId.make(bobScope), - slot: "header:authorization", + source: { id: namespace, scope: ScopeId.make(orgId) }, + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("bob_pat"), @@ -572,10 +570,10 @@ describe("sources api (HTTP)", () => { ); const aliceBindings = yield* asUser(aliceId, orgId, (client) => - client.openapi.listSourceBindings({ + client.sources.listBindings({ params: { scopeId: ScopeId.make(aliceScope), - namespace, + sourceId: namespace, sourceScopeId: ScopeId.make(orgId), }, }), @@ -583,7 +581,7 @@ describe("sources api (HTTP)", () => { expect(aliceBindings).toContainEqual( expect.objectContaining({ scopeId: ScopeId.make(aliceScope), - slot: "header:authorization", + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("alice_pat"), @@ -594,17 +592,17 @@ describe("sources api (HTTP)", () => { expect( aliceBindings.some( (binding) => - binding.slot === "header:authorization" && + binding.slotKey === "header:authorization" && binding.value.kind === "secret" && binding.value.secretId === SecretId.make("bob_pat"), ), ).toBe(false); const bobBindings = yield* asUser(bobId, orgId, (client) => - client.openapi.listSourceBindings({ + client.sources.listBindings({ params: { scopeId: ScopeId.make(bobScope), - namespace, + sourceId: namespace, sourceScopeId: ScopeId.make(orgId), }, }), @@ -612,7 +610,7 @@ describe("sources api (HTTP)", () => { expect(bobBindings).toContainEqual( expect.objectContaining({ scopeId: ScopeId.make(bobScope), - slot: "header:authorization", + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("bob_pat"), @@ -623,7 +621,7 @@ describe("sources api (HTTP)", () => { expect( bobBindings.some( (binding) => - binding.slot === "header:authorization" && + binding.slotKey === "header:authorization" && 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 81288fd2e..6245b5909 100644 --- a/apps/cloud/src/services/tenant-isolation.node.test.ts +++ b/apps/cloud/src/services/tenant-isolation.node.test.ts @@ -263,13 +263,12 @@ describe("tenant isolation (HTTP)", () => { }, }, }); - yield* client.openapi.setSourceBinding({ + yield* client.sources.setBinding({ params: { scopeId: ScopeId.make(orgA) }, payload: { - sourceId: namespaceA, - sourceScope: ScopeId.make(orgA), scope: ScopeId.make(orgA), - slot: "header:authorization", + source: { id: namespaceA, scope: ScopeId.make(orgA) }, + slotKey: "header:authorization", value: { kind: "secret", secretId: secretIdA }, }, }); @@ -299,13 +298,12 @@ describe("tenant isolation (HTTP)", () => { params: { scopeId: ScopeId.make(orgA) }, payload: makeTenantOpenApiSourcePayload(namespaceA), }); - yield* client.openapi.setSourceBinding({ + yield* client.sources.setBinding({ params: { scopeId: ScopeId.make(orgA) }, payload: { - sourceId: namespaceA, - sourceScope: ScopeId.make(orgA), scope: ScopeId.make(orgA), - slot: "auth:conn", + source: { id: namespaceA, scope: ScopeId.make(orgA) }, + slotKey: "auth:conn", value: { kind: "connection", connectionId: connectionIdA }, }, }); diff --git a/notes/plugin-derived-source-configure.md b/notes/plugin-derived-source-configure.md new file mode 100644 index 000000000..a5afd8a4a --- /dev/null +++ b/notes/plugin-derived-source-configure.md @@ -0,0 +1,356 @@ +# Plugin-Derived Source Configure Notes + +Date: 2026-05-17 +Status: planning + +## Summary + +`executor.sources.configure(...)` can exist without making headers, query +params, OAuth, database passwords, CLI env vars, or any other protocol-specific +concept part of core. + +The key distinction: + +- Core owns source identity, scoped binding storage, resolution, validation, and + dispatch. +- Plugins own their configure schemas and the translation from configure input + to core bindings. +- Shared UI lives in reusable plugin-family components, not in core source + semantics. + +This means `executor.openapi.configure(...)` and +`executor.graphql.configure(...)` can remain the typed plugin-native APIs, while +`executor.sources.configure(...)` becomes a generic dispatcher derived from the +installed plugin implementations. + +## Why Not Put Headers In Core + +Core needs to support many source types: + +- OpenAPI and GraphQL over HTTP. +- MCP over HTTP or stdio. +- Databases. +- CLIs. +- Future source types we have not named yet. + +Only some of these have request headers, query params, or OAuth. A database +source may have a connection string, username, password, TLS certs, and schema +selection. A CLI source may have env vars, argv templates, working directory, +and stdin. If core's public configure model says `headers` and `query`, it is +quietly HTTP-specific. + +Core's portable primitive is still: + +```ts +{ + source: { id, scope }, + scope, + slot, + value, +} +``` + +where the owning plugin defines what `slot` means. + +## Desired API Shape + +Plugin APIs remain first-class: + +```ts +await executor.openapi.configure(source, { + scope: user, + request: { + headers: { + Authorization: SecretId.make("stripe_api_key"), + }, + }, +}); +``` + +```ts +await executor.graphql.configure(source, { + scope: user, + request: { + headers: { + Authorization: SecretId.make("github_token"), + }, + }, +}); +``` + +The generic source API is derived from those plugin implementations: + +```ts +await executor.sources.configure(source, { + type: "openapi", + scope: user, + request: { + headers: { + Authorization: SecretId.make("stripe_api_key"), + }, + }, +}); +``` + +The `type` discriminant is for dispatch and type narrowing. The actual source +record should still be checked so callers cannot configure an OpenAPI source +with a GraphQL payload. + +```ts +const storedSource = yield * sources.get(source); + +if (storedSource.type !== input.type) { + return ( + yield * + Effect.fail( + new SourceTypeMismatch({ + source, + expected: storedSource.type, + received: input.type, + }), + ) + ); +} +``` + +## Registration Model + +Each plugin registers its configure implementation with core: + +```ts +openapiPlugin.registerSourceConfigure({ + type: "openapi", + schema: OpenApiConfigureInput, + configure: openApiConfigure, +}); +``` + +```ts +graphqlPlugin.registerSourceConfigure({ + type: "graphql", + schema: GraphqlConfigureInput, + configure: graphqlConfigure, +}); +``` + +Core dispatch stays small: + +```ts +const configure = (source, input) => + Effect.gen(function* () { + const storedSource = yield* Sources.get(source); + const implementation = yield* SourceConfigureRegistry.get(input.type); + + if (storedSource.type !== input.type) { + return yield* Effect.fail( + new SourceTypeMismatch({ + source, + expected: storedSource.type, + received: input.type, + }), + ); + } + + const parsed = yield* Schema.decodeUnknown(implementation.schema)(input); + + return yield* implementation.configure(source, parsed); + }); +``` + +The plugin implementation compiles its domain-specific configure input into +core binding operations: + +```ts +const openApiConfigure = (source, input) => + Effect.gen(function* () { + const bindings = yield* compileOpenApiConfigureBindings(source, input); + + yield* Sources.replaceBindings({ + source, + scope: input.scope, + bindings, + }); + }); +``` + +## Shared HTTP Credential Pieces + +OpenAPI, GraphQL, and HTTP MCP should reuse HTTP credential vocabulary, but +that vocabulary should live in a shared protocol helper, not core. + +```ts +type HttpRequestCredentialConfig = { + headers?: Record; + query?: Record; + oauth?: OAuthCredentialConfig; +}; +``` + +Then plugins embed that helper where it makes sense: + +```ts +type OpenApiConfigureInput = { + type: "openapi"; + scope: ScopeId; + request?: HttpRequestCredentialConfig; + specFetch?: HttpRequestCredentialConfig; +}; +``` + +```ts +type GraphqlConfigureInput = { + type: "graphql"; + scope: ScopeId; + request?: HttpRequestCredentialConfig; + introspection?: HttpRequestCredentialConfig; +}; +``` + +```ts +type HttpMcpConfigureInput = { + type: "mcp"; + scope: ScopeId; + request?: HttpRequestCredentialConfig; +}; +``` + +Database and CLI plugins should not see this shape unless they opt into it. + +## Shared UI Direction + +The generic UI should call one mutation: + +```ts +configureSource(source, input); +``` + +but forms should be plugin-specific or plugin-family-specific. + +HTTP-ish plugins can share components: + +```tsx + setConfig({ ...config, request })} +/> +``` + +OpenAPI can use it twice: + +```tsx + + +``` + +GraphQL can use it for request and introspection credentials: + +```tsx + + +``` + +MCP HTTP can use it for its request transport credentials: + +```tsx + +``` + +Other source families get their own shared components: + +```tsx + + +``` + +The reuse boundary is therefore explicit: + +- Core mutation and atoms are shared. +- Plugin configure schemas are plugin-owned. +- HTTP credential UI is shared only by plugins that use HTTP credential shapes. +- Database and CLI UI are not forced through HTTP concepts. + +## OAuth Notes + +OAuth should be part of the HTTP credential helper, but it should not be modeled +as just another header value. OAuth configuration needs room for: + +- Authorization URL. +- Token URL. +- Client ID. +- Client secret. +- Scopes. +- PKCE and auth-code state. +- Refresh behavior. +- Token placement after exchange. + +The resulting access token may be placed into a header or query param, but the +configuration and lifecycle are richer than a raw `Authorization` binding. + +Avoid double nesting like: + +```ts +oauth2: { + oauth2: { + ... + }, +} +``` + +Prefer a single OAuth object embedded at the credential boundary: + +```ts +request: { + oauth: { + clientId: SecretId.make("client_id"), + clientSecret: SecretId.make("client_secret"), + authorizationUrl: "https://example.com/oauth/authorize", + tokenUrl: "https://example.com/oauth/token", + scopes: ["read", "write"], + placement: { + header: "Authorization", + scheme: "Bearer", + }, + }, +} +``` + +## MCP And Auth-Dependent Metadata + +MCP can expose different tool descriptions depending on auth state. GraphQL can +also theoretically expose different introspection results by credential scope. + +This does not invalidate the shared configure dispatch model, but it means the +plugin must decide whether metadata is global source shape or scoped resolved +shape. + +For MCP especially, avoid assuming a single globally cached tool manifest per +source forever. A future MCP implementation may need: + +- shared source transport config; +- scoped credential bindings; +- scoped or credential-derived tool metadata cache. + +That is plugin behavior, not core binding behavior. + +## Design Guardrails + +- Do not add `headers`, `query`, or `oauth` as universal core source fields. +- Do keep `executor.openapi.configure` and other plugin-native configure APIs. +- Do derive `executor.sources.configure` from registered plugin implementations. +- Do validate that `input.type` matches the stored source type. +- Do compile plugin configure input into core binding writes internally. +- Do build shared React components around explicit plugin-family shapes such as + HTTP request credentials. +- Do not require database, CLI, or future non-HTTP sources to fit the HTTP + credential model. + +## Likely Implementation Order + +1. Keep core binding APIs protocol-agnostic. +2. Make OpenAPI's configure implementation compile to those core bindings. +3. Introduce source-level configure dispatch over registered plugin configure + implementations. +4. Move OpenAPI UI to the generic configure mutation while keeping OpenAPI's + form domain-specific. +5. Extract shared HTTP credential config types and UI components when GraphQL or + MCP is ported, rather than abstracting from OpenAPI alone. diff --git a/packages/core/api/src/handlers/sources.ts b/packages/core/api/src/handlers/sources.ts index 775ffdb8e..6c322ef69 100644 --- a/packages/core/api/src/handlers/sources.ts +++ b/packages/core/api/src/handlers/sources.ts @@ -86,5 +86,43 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han })); }), ), + ) + .handle("listBindings", ({ params: path }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.sources.listBindings({ + source: { + id: path.sourceId, + scope: path.sourceScopeId, + }, + }); + }), + ), + ) + .handle("setBinding", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.sources.setBinding(payload); + }), + ), + ) + .handle("removeBinding", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.sources.removeBinding(payload); + return { removed: true }; + }), + ), + ) + .handle("replaceBindings", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.sources.replaceBindings(payload); + }), + ), ), ); diff --git a/packages/core/api/src/sources/api.ts b/packages/core/api/src/sources/api.ts index 5e6b9a4eb..82eeba131 100644 --- a/packages/core/api/src/sources/api.ts +++ b/packages/core/api/src/sources/api.ts @@ -2,8 +2,12 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; import { InternalError, + CredentialBindingRef, + RemoveSourceCredentialBindingInput, ScopeId, + SetSourceCredentialBindingInput, SourceRemovalNotAllowedError, + ReplaceSourceCredentialBindingsInput, ToolId, } from "@executor-js/sdk/shared"; @@ -13,6 +17,11 @@ import { const ScopeParams = { scopeId: ScopeId }; const SourceParams = { scopeId: ScopeId, sourceId: Schema.String }; +const SourceBindingParams = { + scopeId: ScopeId, + sourceId: Schema.String, + sourceScopeId: ScopeId, +}; // --------------------------------------------------------------------------- // Response schemas @@ -109,4 +118,39 @@ export const SourcesApi = HttpApiGroup.make("sources") success: Schema.Array(DetectResultResponse), error: InternalError, }), + ) + .add( + HttpApiEndpoint.get( + "listBindings", + "/scopes/:scopeId/sources/:sourceId/base/:sourceScopeId/bindings", + { + params: SourceBindingParams, + success: Schema.Array(CredentialBindingRef), + error: InternalError, + }, + ), + ) + .add( + HttpApiEndpoint.post("setBinding", "/scopes/:scopeId/source-bindings", { + params: ScopeParams, + payload: SetSourceCredentialBindingInput, + success: CredentialBindingRef, + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.post("removeBinding", "/scopes/:scopeId/source-bindings/remove", { + params: ScopeParams, + payload: RemoveSourceCredentialBindingInput, + success: Schema.Struct({ removed: Schema.Boolean }), + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.post("replaceBindings", "/scopes/:scopeId/source-bindings/replace", { + params: ScopeParams, + payload: ReplaceSourceCredentialBindingsInput, + success: Schema.Array(CredentialBindingRef), + error: InternalError, + }), ); diff --git a/packages/core/execution/src/promise.ts b/packages/core/execution/src/promise.ts index d3dd385d2..49c100ffc 100644 --- a/packages/core/execution/src/promise.ts +++ b/packages/core/execution/src/promise.ts @@ -92,6 +92,11 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => ({ refresh: (input) => fromPromise(() => pe.sources.refresh(input)), detect: (url) => fromPromise(() => pe.sources.detect(url)), definitions: (id) => fromPromise(() => pe.sources.definitions(id)), + listBindings: (input) => fromPromise(() => pe.sources.listBindings(input)), + resolveBinding: (input) => fromPromise(() => pe.sources.resolveBinding(input)), + setBinding: (input) => fromPromise(() => pe.sources.setBinding(input)), + removeBinding: (input) => fromPromise(() => pe.sources.removeBinding(input)), + replaceBindings: (input) => fromPromise(() => pe.sources.replaceBindings(input)), }, secrets: { get: (id) => fromPromise(() => pe.secrets.get(id)), @@ -120,6 +125,7 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => ({ }, credentialBindings: { listForSource: (input) => fromPromise(() => pe.credentialBindings.listForSource(input)), + resolveBinding: (input) => fromPromise(() => pe.credentialBindings.resolveBinding(input)), resolve: (input) => fromPromise(() => pe.credentialBindings.resolve(input)), set: (input) => fromPromise(() => pe.credentialBindings.set(input)), remove: (input) => fromPromise(() => pe.credentialBindings.remove(input)), diff --git a/packages/core/sdk/src/credential-bindings.test.ts b/packages/core/sdk/src/credential-bindings.test.ts index e1c824c7b..c3526ef00 100644 --- a/packages/core/sdk/src/credential-bindings.test.ts +++ b/packages/core/sdk/src/credential-bindings.test.ts @@ -172,6 +172,51 @@ describe("credential bindings", () => { }), ); + it.effect("exposes source binding helpers over the source facade", () => + Effect.gen(function* () { + const harness = makeHarness(); + const orgExecutor = yield* harness.create([harness.scopes.org]); + yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); + + const userExecutor = yield* harness.create([ + harness.scopes.userWorkspaceA, + harness.scopes.org, + ]); + yield* setSecret(userExecutor, harness.scopes.userWorkspaceA.id, "api-token", "sk-user-a"); + const binding = yield* userExecutor.sources.setBinding({ + scope: harness.scopes.userWorkspaceA.id, + source: { + id: TEST_SOURCE_ID, + scope: harness.scopes.org.id, + }, + slotKey: TEST_SLOT, + value: { kind: "secret", secretId: SecretId.make("api-token") }, + }); + + const listed = yield* userExecutor.sources.listBindings({ + source: { + id: TEST_SOURCE_ID, + scope: harness.scopes.org.id, + }, + }); + const resolved = yield* userExecutor.sources.resolveBinding({ + source: { + id: TEST_SOURCE_ID, + scope: harness.scopes.org.id, + }, + slotKey: TEST_SLOT, + }); + + expect(listed.map((row) => row.id)).toEqual([binding.id]); + expect(resolved?.id).toBe(binding.id); + expect(resolved?.value).toEqual({ + kind: "secret", + secretId: SecretId.make("api-token"), + secretScopeId: harness.scopes.userWorkspaceA.id, + }); + }), + ); + it.effect("workspace credential bindings shadow org bindings without copying the source", () => Effect.gen(function* () { const harness = makeHarness(); diff --git a/packages/core/sdk/src/credential-bindings.ts b/packages/core/sdk/src/credential-bindings.ts index 7890860ce..dd592a2e8 100644 --- a/packages/core/sdk/src/credential-bindings.ts +++ b/packages/core/sdk/src/credential-bindings.ts @@ -107,6 +107,46 @@ export const ReplaceCredentialBindingsInput = Schema.Struct({ }); export type ReplaceCredentialBindingsInput = typeof ReplaceCredentialBindingsInput.Type; +export const SourceCredentialBindingSource = Schema.Struct({ + id: Schema.String, + scope: ScopeId, +}); +export type SourceCredentialBindingSource = typeof SourceCredentialBindingSource.Type; + +export const SourceCredentialBindingSourceInput = Schema.Struct({ + source: SourceCredentialBindingSource, +}); +export type SourceCredentialBindingSourceInput = typeof SourceCredentialBindingSourceInput.Type; + +export const SourceCredentialBindingSlotInput = Schema.Struct({ + source: SourceCredentialBindingSource, + slotKey: Schema.String, +}); +export type SourceCredentialBindingSlotInput = typeof SourceCredentialBindingSlotInput.Type; + +export const SetSourceCredentialBindingInput = Schema.Struct({ + scope: ScopeId, + source: SourceCredentialBindingSource, + slotKey: Schema.String, + value: CredentialBindingValue, +}); +export type SetSourceCredentialBindingInput = typeof SetSourceCredentialBindingInput.Type; + +export const RemoveSourceCredentialBindingInput = Schema.Struct({ + scope: ScopeId, + source: SourceCredentialBindingSource, + slotKey: Schema.String, +}); +export type RemoveSourceCredentialBindingInput = typeof RemoveSourceCredentialBindingInput.Type; + +export const ReplaceSourceCredentialBindingsInput = Schema.Struct({ + scope: ScopeId, + source: SourceCredentialBindingSource, + slotPrefixes: Schema.Array(Schema.String), + bindings: Schema.Array(ReplaceCredentialBindingValue), +}); +export type ReplaceSourceCredentialBindingsInput = typeof ReplaceSourceCredentialBindingsInput.Type; + export const CredentialBindingResolutionStatus = Schema.Literals([ "resolved", "missing", @@ -129,6 +169,9 @@ export interface CredentialBindingsFacade { readonly listForSource: ( input: CredentialBindingSourceInput, ) => Effect.Effect; + readonly resolveBinding: ( + input: CredentialBindingSlotInput, + ) => Effect.Effect; readonly resolve: ( input: CredentialBindingSlotInput, ) => Effect.Effect; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index bad0fadc1..d0f9e12d2 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -46,9 +46,14 @@ import { type CredentialBindingSlotInput, type CredentialBindingSourceInput, type RemoveCredentialBindingInput, + type RemoveSourceCredentialBindingInput, type ReplaceCredentialBindingsInput, + type ReplaceSourceCredentialBindingsInput, ResolvedCredentialSlot, type SetCredentialBindingInput, + type SetSourceCredentialBindingInput, + type SourceCredentialBindingSlotInput, + type SourceCredentialBindingSourceInput, } from "./credential-bindings"; import { coreSchema, @@ -222,6 +227,21 @@ export type Executor = { readonly definitions: ( sourceId: string, ) => Effect.Effect, StorageFailure>; + readonly listBindings: ( + input: SourceCredentialBindingSourceInput, + ) => Effect.Effect; + readonly resolveBinding: ( + input: SourceCredentialBindingSlotInput, + ) => Effect.Effect; + readonly setBinding: ( + input: SetSourceCredentialBindingInput, + ) => Effect.Effect; + readonly removeBinding: ( + input: RemoveSourceCredentialBindingInput, + ) => Effect.Effect; + readonly replaceBindings: ( + input: ReplaceSourceCredentialBindingsInput, + ) => Effect.Effect; }; readonly secrets: { @@ -2097,6 +2117,17 @@ export const createExecutor = => + Effect.gen(function* () { + if (!scopeIds.includes(input.sourceScope)) return null; + return yield* core.findFirst("source", { + where: byScopedId(input.sourceScope, input.sourceId), + }); + }); + const findSecretRowAtScope = (input: { readonly secretId: string; readonly scopeId: string; @@ -2505,6 +2536,13 @@ export const createExecutor = + Effect.gen(function* () { + const rows = yield* credentialBindingRowsForSlot(input); + const row = findInnermost(rows); + return row ? credentialBindingRowToRef(row) : null; + }); + const credentialBindingResolve = (input: CredentialBindingSlotInput) => Effect.gen(function* () { const rows = yield* credentialBindingRowsForSlot(input); @@ -2589,6 +2627,7 @@ export const createExecutor = + Effect.gen(function* () { + const source = yield* findSourceOwnerRowAtScope({ + sourceId: input.source.id, + sourceScope: input.source.scope, + }); + return source + ? ({ + pluginId: source.plugin_id, + sourceId: input.source.id, + sourceScope: input.source.scope, + } satisfies CredentialBindingSourceInput) + : null; + }); + + const sourceBindingList = (input: SourceCredentialBindingSourceInput) => + Effect.gen(function* () { + const bindingInput = yield* credentialBindingInputForSource(input); + return bindingInput ? yield* credentialBindingListForSource(bindingInput) : []; + }); + + const sourceBindingResolve = (input: SourceCredentialBindingSlotInput) => + Effect.gen(function* () { + const bindingInput = yield* credentialBindingInputForSource(input); + return bindingInput + ? yield* credentialBindingResolveBinding({ + ...bindingInput, + slotKey: input.slotKey, + }) + : null; + }); + + const sourceBindingSet = (input: SetSourceCredentialBindingInput) => + Effect.gen(function* () { + const bindingInput = yield* credentialBindingInputForSource(input); + if (!bindingInput) { + return yield* new StorageError({ + message: + `Cannot set credential binding for source "${input.source.id}" ` + + `at scope "${input.source.scope}": source is not visible.`, + cause: undefined, + }); + } + return yield* credentialBindingSet({ + ...bindingInput, + targetScope: input.scope, + slotKey: input.slotKey, + value: input.value, + }); + }); + + const sourceBindingRemove = (input: RemoveSourceCredentialBindingInput) => + Effect.gen(function* () { + const bindingInput = yield* credentialBindingInputForSource(input); + if (!bindingInput) { + return yield* new StorageError({ + message: + `Cannot remove credential binding for source "${input.source.id}" ` + + `at scope "${input.source.scope}": source is not visible.`, + cause: undefined, + }); + } + yield* credentialBindingRemove({ + ...bindingInput, + targetScope: input.scope, + slotKey: input.slotKey, + }); + }); + + const sourceBindingReplace = (input: ReplaceSourceCredentialBindingsInput) => + Effect.gen(function* () { + const bindingInput = yield* credentialBindingInputForSource(input); + if (!bindingInput) { + return yield* new StorageError({ + message: + `Cannot replace credential bindings for source "${input.source.id}" ` + + `at scope "${input.source.scope}": source is not visible.`, + cause: undefined, + }); + } + return yield* credentialBindingReplaceForSource({ + ...bindingInput, + targetScope: input.scope, + slotPrefixes: input.slotPrefixes, + bindings: input.bindings, + }); + }); + const oauthBundle = makeOAuth2Service({ fuma, secretsGet: (id) => @@ -3713,6 +3840,11 @@ export const createExecutor = + .handle("configure", ({ payload }) => 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 }; + return yield* ext.configure(payload.source, payload as OpenApiConfigureInput); }), ), ), diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index aa113ff3b..4a6688cc6 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -6,8 +6,13 @@ import * as Match from "effect/Match"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/shared"; -import { startOAuth } from "@executor-js/react/api/atoms"; +import { + ConnectionId, + ScopeId, + SecretId, + SetSourceCredentialBindingInput, +} from "@executor-js/sdk/shared"; +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"; @@ -58,7 +63,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 { @@ -70,7 +75,7 @@ import { specFetchHeaderBindingSlot, specFetchQueryParamBindingSlot, } from "../sdk/source-contracts"; -import { OAuth2SourceConfig, OpenApiSourceBindingInput, type ServerInfo } from "../sdk/types"; +import { OAuth2SourceConfig, type ServerInfo } from "../sdk/types"; import { expandServerUrlOptions } from "../sdk/openapi-utils"; export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; @@ -285,7 +290,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(); @@ -757,11 +762,10 @@ export default function AddOpenApiSource(props: { for (const binding of headerBindings) { const bindingExit = await doSetBinding({ params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ - sourceId, - sourceScope, + payload: SetSourceCredentialBindingInput.make({ + source: { id: sourceId, scope: sourceScope }, scope: binding.scope, - slot: binding.slot, + slotKey: binding.slot, value: { kind: "secret", secretId: SecretId.make(binding.secretId), @@ -780,11 +784,10 @@ export default function AddOpenApiSource(props: { for (const binding of queryParamBindings) { const bindingExit = await doSetBinding({ params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ - sourceId, - sourceScope, + payload: SetSourceCredentialBindingInput.make({ + source: { id: sourceId, scope: sourceScope }, scope: binding.scope, - slot: binding.slot, + slotKey: binding.slot, value: { kind: "secret", secretId: SecretId.make(binding.secretId), @@ -803,11 +806,10 @@ export default function AddOpenApiSource(props: { for (const binding of specFetchBindings) { const bindingExit = await doSetBinding({ params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ - sourceId, - sourceScope, + payload: SetSourceCredentialBindingInput.make({ + source: { id: sourceId, scope: sourceScope }, scope: binding.scope, - slot: binding.slot, + slotKey: binding.slot, value: { kind: "secret", secretId: SecretId.make(binding.secretId), @@ -826,11 +828,10 @@ export default function AddOpenApiSource(props: { if (configuredOAuth2 && oauth2ClientIdSecretId) { const bindingExit = await doSetBinding({ params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ - sourceId, - sourceScope, + payload: SetSourceCredentialBindingInput.make({ + source: { id: sourceId, scope: sourceScope }, scope: sourceScope, - slot: configuredOAuth2.clientIdSlot, + slotKey: configuredOAuth2.clientIdSlot, value: { kind: "secret", secretId: SecretId.make(oauth2ClientIdSecretId), @@ -849,11 +850,10 @@ export default function AddOpenApiSource(props: { if (configuredOAuth2?.clientSecretSlot && oauth2ClientSecretSecretId) { const bindingExit = await doSetBinding({ params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ - sourceId, - sourceScope, + payload: SetSourceCredentialBindingInput.make({ + source: { id: sourceId, scope: sourceScope }, scope: sourceScope, - slot: configuredOAuth2.clientSecretSlot, + slotKey: configuredOAuth2.clientSecretSlot, value: { kind: "secret", secretId: SecretId.make(oauth2ClientSecretSecretId), @@ -872,11 +872,10 @@ export default function AddOpenApiSource(props: { if (configuredOAuth2 && oauth2Auth) { const bindingExit = await doSetBinding({ params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ - sourceId, - sourceScope, + payload: SetSourceCredentialBindingInput.make({ + source: { id: sourceId, scope: sourceScope }, scope: oauthTokenBindingScope, - slot: configuredOAuth2.connectionSlot, + slotKey: configuredOAuth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(oauth2Auth.connectionId), diff --git a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx index da0522c0f..096746a5c 100644 --- a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx @@ -5,7 +5,13 @@ 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, + 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"; @@ -22,7 +28,14 @@ import { import { FilterTabs } from "@executor-js/react/components/filter-tabs"; import { Input } from "@executor-js/react/components/input"; import { sourceWriteKeys as openApiWriteKeys } from "@executor-js/react/api/reactivity-keys"; -import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/shared"; +import { + ConnectionId, + CredentialBindingRef, + RemoveSourceCredentialBindingInput, + ScopeId, + SecretId, + SetSourceCredentialBindingInput, +} from "@executor-js/sdk/shared"; import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; import { oauthCallbackUrl, @@ -37,13 +50,7 @@ import { } 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, openApiSourceBindingsAtom, updateOpenApiSource } from "./atoms"; import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; import { OPENAPI_OAUTH_CALLBACK_PATH, @@ -52,11 +59,7 @@ import { resolveOAuthUrl, } from "./AddOpenApiSource"; import { oauth2ClientSecretSlot } from "../sdk/source-contracts"; -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); @@ -80,6 +83,8 @@ type SlotDef = readonly label: string; }; +type OpenApiCredentialBindingRow = CredentialBindingRef & { readonly slot: string }; + const slugify = (value: string): string => value .trim() @@ -137,10 +142,10 @@ export default function EditOpenApiSource(props: { 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 +157,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 OpenApiCredentialBindingRow[] = AsyncResult.isSuccess(bindingsResult) ? bindingsResult.value : []; const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; @@ -363,11 +368,10 @@ export default function EditOpenApiSource(props: { setError(null); const exit = await doSetBinding({ params: { scopeId: displayScope }, - payload: OpenApiSourceBindingInput.make({ - sourceId: props.sourceId, - sourceScope, + payload: SetSourceCredentialBindingInput.make({ + source: { id: props.sourceId, scope: sourceScope }, scope: targetScope, - slot, + slotKey: slot, value: { kind: "secret", secretId: SecretId.make(trimmed), @@ -387,12 +391,11 @@ export default function EditOpenApiSource(props: { setError(null); const exit = await doRemoveBinding({ params: { scopeId: displayScope }, - payload: { - sourceId: props.sourceId, - sourceScope, - slot, + payload: RemoveSourceCredentialBindingInput.make({ + source: { id: props.sourceId, scope: sourceScope }, scope: targetScope, - }, + slotKey: slot, + }), reactivityKeys: sourceWriteKeys, }); if (Exit.isFailure(exit)) { @@ -490,11 +493,10 @@ export default function EditOpenApiSource(props: { } const setBindingExit = await doSetBinding({ params: { scopeId: displayScope }, - payload: OpenApiSourceBindingInput.make({ - sourceId: props.sourceId, - sourceScope, + payload: SetSourceCredentialBindingInput.make({ + source: { id: props.sourceId, scope: sourceScope }, scope: targetScope, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(response.completedConnection.connectionId), @@ -558,11 +560,10 @@ export default function EditOpenApiSource(props: { onSuccess: async (result) => { const setBindingExit = await doSetBinding({ params: { scopeId: displayScope }, - payload: OpenApiSourceBindingInput.make({ - sourceId: props.sourceId, - sourceScope, + payload: SetSourceCredentialBindingInput.make({ + source: { id: props.sourceId, scope: sourceScope }, scope: targetScope, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(result.connectionId), diff --git a/packages/plugins/openapi/src/react/atoms.ts b/packages/plugins/openapi/src/react/atoms.ts index 2e64e7d21..fced5f5a9 100644 --- a/packages/plugins/openapi/src/react/atoms.ts +++ b/packages/plugins/openapi/src/react/atoms.ts @@ -1,7 +1,7 @@ import type { ScopeId } from "@executor-js/sdk/shared"; import * as Atom from "effect/unstable/reactivity/Atom"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; +import { sourceCredentialBindingsAtom, sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; import { OpenApiClient } from "./client"; @@ -21,11 +21,12 @@ export const openApiSourceBindingsAtom = ( namespace: string, sourceScopeId: ScopeId, ) => - OpenApiClient.query("openapi", "listSourceBindings", { - params: { scopeId, namespace, sourceScopeId }, - timeToLive: "15 seconds", - reactivityKeys: [ReactivityKey.sources, ReactivityKey.secrets, ReactivityKey.connections], - }); + Atom.mapResult(sourceCredentialBindingsAtom(scopeId, namespace, sourceScopeId), (rows) => + rows.map((row) => ({ + ...row, + slot: row.slotKey, + })), + ); // --------------------------------------------------------------------------- // Mutation atoms @@ -63,7 +64,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 e58d90fe0..e55d220ff 100644 --- a/packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts +++ b/packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts @@ -19,6 +19,7 @@ import { ScopeId, SecretId, SetSecretInput, + SetSourceCredentialBindingInput, type InvokeOptions, type SecretProvider, } from "@executor-js/sdk"; @@ -30,7 +31,7 @@ import { } from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; -import { OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; +import { OAuth2SourceConfig } from "./types"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; @@ -213,12 +214,11 @@ describe("OpenAPI client_credentials OAuth", () => { namespace: "petstore", oauth2, }); - yield* userExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: userScope.id, + yield* userExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "petstore", scope: userScope.id }, scope: userScope.id, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(completedConnection.connectionId), diff --git a/packages/plugins/openapi/src/sdk/credential-status.ts b/packages/plugins/openapi/src/sdk/credential-status.ts index 1114f04c8..e5a177cd6 100644 --- a/packages/plugins/openapi/src/sdk/credential-status.ts +++ b/packages/plugins/openapi/src/sdk/credential-status.ts @@ -1,12 +1,12 @@ -import type { ConnectionId, ScopeId } from "@executor-js/sdk/shared"; +import type { ConnectionId, CredentialBindingValue, ScopeId } from "@executor-js/sdk/shared"; import { oauth2ClientSecretSlot } from "./source-contracts"; -import type { ConfiguredHeaderValue, OpenApiSourceBindingValue } from "./types"; +import type { ConfiguredHeaderValue } from "./types"; export type BindingRowForCredentialStatus = { readonly slot: string; readonly scopeId: ScopeId; - readonly value: OpenApiSourceBindingValue; + readonly value: CredentialBindingValue; }; export type SourceForCredentialStatus = { diff --git a/packages/plugins/openapi/src/sdk/index.ts b/packages/plugins/openapi/src/sdk/index.ts index b2f89c7b0..0ff57399f 100644 --- a/packages/plugins/openapi/src/sdk/index.ts +++ b/packages/plugins/openapi/src/sdk/index.ts @@ -4,8 +4,11 @@ export { invoke, invokeWithLayer, resolveHeaders, annotationsForOperation } from export { openApiPlugin, type OpenApiSpecConfig, + type OpenApiConfigureCredentialInput, + type OpenApiConfigureInput, type OpenApiPluginExtension, type OpenApiPluginOptions, + type OpenApiSourceRef, type OpenApiUpdateSourceInput, } from "./plugin"; export { @@ -50,9 +53,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 35d648c7b..59523c9f4 100644 --- a/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts +++ b/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts @@ -29,6 +29,8 @@ import { ScopeId, SecretId, SetSecretInput, + SetSourceCredentialBindingInput, + RemoveSourceCredentialBindingInput, ToolInvocationError, type InvokeOptions, type SecretProvider, @@ -42,7 +44,7 @@ import { } from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; -import { ConfiguredHeaderBinding, OpenApiSourceBindingInput } from "./types"; +import { ConfiguredHeaderBinding } from "./types"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; @@ -223,48 +225,44 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { // 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, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: aliceScope.id, - slot: "auth:vercel_api_token", + slotKey: "auth:vercel_api_token", value: { kind: "secret", secretId: SecretId.make("vercel_api_token"), }, }), ); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: aliceScope.id, - slot: "query_param:vercel_team_token", + slotKey: "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, + yield* bobExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: bobScope.id, - slot: "auth:vercel_api_token", + slotKey: "auth:vercel_api_token", value: { kind: "secret", secretId: SecretId.make("vercel_api_token"), }, }), ); - yield* bobExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* bobExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: bobScope.id, - slot: "query_param:vercel_team_token", + slotKey: "query_param:vercel_team_token", value: { kind: "secret", secretId: SecretId.make("vercel_team_token"), @@ -422,24 +420,22 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { }), ); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: aliceScope.id, - slot: "auth:personal-token", + slotKey: "auth:personal-token", value: { kind: "secret", secretId: SecretId.make("alice_vercel_pat"), }, }), ); - yield* bobExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* bobExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: bobScope.id, - slot: "auth:personal-token", + slotKey: "auth:personal-token", value: { kind: "secret", secretId: SecretId.make("bob_vercel_pat"), @@ -541,12 +537,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { value: "org-token", }), ); - yield* adminExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* adminExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: orgScope.id, - slot: "auth:token", + slotKey: "auth:token", value: { kind: "secret", secretId: SecretId.make("org_vercel_pat"), @@ -568,12 +563,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { value: "alice-token", }), ); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: aliceScope.id, - slot: "auth:token", + slotKey: "auth:token", value: { kind: "secret", secretId: SecretId.make("alice_vercel_pat"), @@ -589,11 +583,12 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { "Bearer alice-token", ); - yield* aliceExec.openapi.removeSourceBinding( - "vercel", - String(orgScope.id), - "auth:token", - String(aliceScope.id), + yield* aliceExec.sources.removeBinding( + RemoveSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, + slotKey: "auth:token", + scope: aliceScope.id, + }), ); const fallbackResult = unwrapInvocation( @@ -602,12 +597,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { expect(fallbackResult.error).toBeNull(); expect((fallbackResult.data as EchoHeaders | null)?.authorization).toBe("Bearer org-token"); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: aliceScope.id, - slot: "auth:token", + slotKey: "auth:token", value: { kind: "secret", secretId: SecretId.make("alice_vercel_pat"), @@ -628,10 +622,9 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { }, }); - const bindingsAfterReadd = yield* aliceExec.openapi.listSourceBindings( - "vercel", - String(orgScope.id), - ); + const bindingsAfterReadd = yield* aliceExec.sources.listBindings({ + source: { id: "vercel", scope: orgScope.id }, + }); expect(bindingsAfterReadd).toEqual([]); const error = yield* Effect.flip( @@ -791,12 +784,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { value: "alice-token", }), ); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: aliceScope.id, - slot: "auth:token", + slotKey: "auth:token", value: { kind: "secret", secretId: SecretId.make("alice_vercel_pat"), @@ -886,12 +878,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { value: "org-token", }), ); - yield* adminExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* adminExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: orgScope.id, - slot: "auth:shared-token", + slotKey: "auth:shared-token", value: { kind: "secret", secretId: SecretId.make("shared-token") }, }), ); @@ -989,12 +980,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { }), ); - yield* userExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* userExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: userScope.id, - slot: "auth:personal-choice", + slotKey: "auth:personal-choice", value: { kind: "secret", secretId: SecretId.make("org-choice-token"), 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 85cde6e4d..cdfce81e2 100644 --- a/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts +++ b/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts @@ -21,6 +21,7 @@ import { ScopeId, SecretId, SetSecretInput, + SetSourceCredentialBindingInput, type InvokeOptions, type SecretProvider, } from "@executor-js/sdk"; @@ -32,7 +33,7 @@ import { } from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; -import { OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; +import { OAuth2SourceConfig } from "./types"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; @@ -280,12 +281,11 @@ describe("OpenAPI multi-scope OAuth", () => { namespace: "petstore", oauth2, }); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: aliceScope.id, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "petstore", scope: aliceScope.id }, scope: aliceScope.id, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(aliceAuth.connectionId) }, }), ); @@ -294,12 +294,11 @@ describe("OpenAPI multi-scope OAuth", () => { namespace: "petstore", oauth2, }); - yield* bobExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: bobScope.id, + yield* bobExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "petstore", scope: bobScope.id }, scope: bobScope.id, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(bobAuth.connectionId) }, }), ); @@ -539,12 +538,11 @@ describe("OpenAPI multi-scope OAuth", () => { namespace: "petstore", oauth2, }); - yield* adminExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: orgScope.id, + yield* adminExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "petstore", scope: orgScope.id }, scope: orgScope.id, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(adminAuth) }, }), ); @@ -555,21 +553,19 @@ describe("OpenAPI multi-scope OAuth", () => { // 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, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "petstore", scope: orgScope.id }, scope: aliceScope.id, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(aliceAuth) }, }), ); - yield* bobExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: orgScope.id, + yield* bobExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "petstore", scope: orgScope.id }, scope: bobScope.id, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(bobAuth) }, }), ); diff --git a/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts b/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts index 0ba99b4f7..98d1dd7cc 100644 --- a/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts +++ b/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts @@ -24,6 +24,7 @@ import { SecretId, Scope, SetSecretInput, + SetSourceCredentialBindingInput, TokenMaterial, OAUTH2_PROVIDER_KEY, createExecutor, @@ -39,7 +40,7 @@ import { } from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; -import { OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; +import { OAuth2SourceConfig } from "./types"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; @@ -205,12 +206,11 @@ const bindOAuthConnection = ( connectionId: string, oauth2: OAuth2SourceConfig, ) => - executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: scopeId, + executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "petstore", scope: scopeId }, scope: scopeId, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(connectionId) }, }), ); diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 7410a89cf..d6b074062 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -12,6 +12,8 @@ import { ScopeId, SecretId, SetSecretInput, + SetSourceCredentialBindingInput, + RemoveSourceCredentialBindingInput, type InvokeOptions, type SecretProvider, } from "@executor-js/sdk"; @@ -20,7 +22,7 @@ import type { ConfigFileSink } from "@executor-js/config"; const TEST_SCOPE = "test-scope"; import { openApiPlugin } from "./plugin"; -import { ConfiguredHeaderBinding, OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; +import { ConfiguredHeaderBinding, OAuth2SourceConfig } from "./types"; import { addOpenApiTestSource, makeOpenApiHttpApiTestSourceConfig, @@ -406,10 +408,9 @@ describe("OpenAPI Plugin", () => { yield* executor.openapi.addSpec(input); - const bindings = yield* executor.openapi.listSourceBindings( - "org_direct_user_credential", - String(orgScope), - ); + const bindings = yield* executor.sources.listBindings({ + source: { id: "org_direct_user_credential", scope: orgScope }, + }); expect(bindings).toEqual([]); }), ); @@ -445,12 +446,11 @@ describe("OpenAPI Plugin", () => { }, }), ); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "stale_binding", - sourceScope: ScopeId.make(TEST_SCOPE), + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "stale_binding", scope: ScopeId.make(TEST_SCOPE) }, scope: ScopeId.make(TEST_SCOPE), - slot: "header:x-old", + slotKey: "header:x-old", value: { kind: "secret", secretId: SecretId.make("old-token") }, }), ); @@ -459,7 +459,9 @@ describe("OpenAPI Plugin", () => { headers: {}, }); - const bindings = yield* executor.openapi.listSourceBindings("stale_binding", TEST_SCOPE); + const bindings = yield* executor.sources.listBindings({ + source: { id: "stale_binding", scope: ScopeId.make(TEST_SCOPE) }, + }); expect(bindings).toEqual([]); }), ); @@ -504,12 +506,11 @@ describe("OpenAPI Plugin", () => { oauth2: oldOAuth, }), ); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "stale_oauth", - sourceScope: ScopeId.make(TEST_SCOPE), + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "stale_oauth", scope: ScopeId.make(TEST_SCOPE) }, scope: ScopeId.make(TEST_SCOPE), - slot: oldOAuth.clientIdSlot, + slotKey: oldOAuth.clientIdSlot, value: { kind: "secret", secretId: SecretId.make("old-client-id") }, }), ); @@ -528,8 +529,10 @@ describe("OpenAPI Plugin", () => { }), }); - const bindings = yield* executor.openapi.listSourceBindings("stale_oauth", TEST_SCOPE); - expect(bindings.some((binding) => binding.slot === oldOAuth.clientIdSlot)).toBe(false); + const bindings = yield* executor.sources.listBindings({ + source: { id: "stale_oauth", scope: ScopeId.make(TEST_SCOPE) }, + }); + expect(bindings.some((binding) => binding.slotKey === oldOAuth.clientIdSlot)).toBe(false); }), ); @@ -561,24 +564,28 @@ describe("OpenAPI Plugin", () => { scope: TEST_SCOPE, namespace: "authed", headers: { - Authorization: { kind: "secret", prefix: "Bearer " }, "X-Static": "hello", }, }); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "authed", - sourceScope: ScopeId.make(TEST_SCOPE), + const configured = yield* executor.openapi.configure( + { id: "authed", scope: TEST_SCOPE }, + { scope: ScopeId.make(TEST_SCOPE), - slot: "header:authorization", - value: { kind: "secret", secretId: SecretId.make("test-api-token") }, - }), + headers: { + Authorization: { + kind: "secret", + secretId: "test-api-token", + prefix: "Bearer ", + }, + }, + }, ); const result = unwrapInvocation( yield* executor.tools.invoke("authed.items.echoHeaders", {}, autoApprove), ); + expect(configured.map((binding) => binding.slotKey)).toEqual(["header:authorization"]); expect(result.error).toBeNull(); const data = result.data as { authorization?: string; "x-static"?: string }; expect(data.authorization).toBe("Bearer secret-value-123"); @@ -622,10 +629,9 @@ describe("OpenAPI Plugin", () => { }), ); - const bindings = yield* executor.openapi.listSourceBindings( - "default_target_scope", - TEST_SCOPE, - ); + const bindings = yield* executor.sources.listBindings({ + source: { id: "default_target_scope", scope: ScopeId.make(TEST_SCOPE) }, + }); expect(bindings).toEqual([]); }), ); @@ -681,12 +687,11 @@ describe("OpenAPI Plugin", () => { }), }, }); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "noauth", - sourceScope: ScopeId.make(TEST_SCOPE), + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "noauth", scope: ScopeId.make(TEST_SCOPE) }, scope: ScopeId.make(TEST_SCOPE), - slot: "header:authorization", + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("missing-token") }, }), ); @@ -902,7 +907,7 @@ describe("OpenAPI Plugin", () => { }), ); - it.effect("listSourceBindings returns [] for a removed source", () => + it.effect("source bindings list returns [] 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 @@ -926,7 +931,9 @@ describe("OpenAPI Plugin", () => { ); yield* executor.openapi.removeSpec("removable", TEST_SCOPE); - const bindings = yield* executor.openapi.listSourceBindings("removable", TEST_SCOPE); + const bindings = yield* executor.sources.listBindings({ + source: { id: "removable", scope: ScopeId.make(TEST_SCOPE) }, + }); expect(bindings).toEqual([]); }), ); @@ -1216,12 +1223,11 @@ describe("OpenAPI Plugin", () => { }, }, }); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "shared_spec_fetch", - sourceScope: ORG_SCOPE, + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "shared_spec_fetch", scope: ORG_SCOPE }, scope: ORG_SCOPE, - slot: "spec_fetch_header:x-spec-token", + slotKey: "spec_fetch_header:x-spec-token", value: { kind: "secret", secretId: SecretId.make("org-spec-token") }, }), ); @@ -1330,12 +1336,11 @@ describe("OpenAPI Plugin", () => { 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), + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "deferred", scope: ScopeId.make(TEST_SCOPE) }, scope: ScopeId.make(TEST_SCOPE), - slot: stored!.config.oauth2!.clientIdSlot, + slotKey: stored!.config.oauth2!.clientIdSlot, value: { kind: "secret", secretId: SecretId.make("acme-client-id"), @@ -1343,12 +1348,12 @@ describe("OpenAPI Plugin", () => { }), ); - const clientIdBinding = yield* executor.openapi - .listSourceBindings("deferred", TEST_SCOPE) + const clientIdBinding = yield* executor.sources + .listBindings({ source: { id: "deferred", scope: ScopeId.make(TEST_SCOPE) } }) .pipe( Effect.map( (bindings) => - bindings.find((binding) => binding.slot === stored!.config.oauth2!.clientIdSlot) ?? + bindings.find((binding) => binding.slotKey === stored!.config.oauth2!.clientIdSlot) ?? null, ), ); @@ -1358,13 +1363,14 @@ describe("OpenAPI Plugin", () => { secretScopeId: ScopeId.make(TEST_SCOPE), }); - const connectionBinding = yield* executor.openapi - .listSourceBindings("deferred", TEST_SCOPE) + const connectionBinding = yield* executor.sources + .listBindings({ source: { id: "deferred", scope: ScopeId.make(TEST_SCOPE) } }) .pipe( Effect.map( (bindings) => - bindings.find((binding) => binding.slot === stored!.config.oauth2!.connectionSlot) ?? - null, + bindings.find( + (binding) => binding.slotKey === stored!.config.oauth2!.connectionSlot, + ) ?? null, ), ); expect(connectionBinding).toBeNull(); @@ -1407,23 +1413,21 @@ describe("OpenAPI Plugin", () => { queryParams: { token: { kind: "secret" } }, }), ); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "with_secret", - sourceScope: ScopeId.make(TEST_SCOPE), + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "with_secret", scope: ScopeId.make(TEST_SCOPE) }, scope: ScopeId.make(TEST_SCOPE), - slot: "query_param:token", + slotKey: "query_param:token", value: { kind: "secret", secretId: SecretId.make("api-key") }, }), ); // Configure a slot binding pointing at the same secret. - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "with_secret", - sourceScope: ScopeId.make(TEST_SCOPE), + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "with_secret", scope: ScopeId.make(TEST_SCOPE) }, scope: ScopeId.make(TEST_SCOPE), - slot: "header:authorization", + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("api-key") }, }), ); @@ -1459,12 +1463,11 @@ describe("OpenAPI Plugin", () => { baseUrl: "http://example.com", }), ); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "ref", - sourceScope: ScopeId.make(TEST_SCOPE), + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "ref", scope: ScopeId.make(TEST_SCOPE) }, scope: ScopeId.make(TEST_SCOPE), - slot: "header:authorization", + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("locked") }, }), ); @@ -1480,11 +1483,12 @@ describe("OpenAPI Plugin", () => { 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* executor.sources.removeBinding( + RemoveSourceCredentialBindingInput.make({ + source: { id: "ref", scope: ScopeId.make(TEST_SCOPE) }, + slotKey: "header:authorization", + scope: ScopeId.make(TEST_SCOPE), + }), ); yield* executor.secrets.remove( RemoveSecretInput.make({ diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 76cf1f885..918eeec6d 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -3,13 +3,16 @@ import type { Layer } from "effect"; import { HttpClient } from "effect/unstable/http"; import { + ConnectionId, ScopeId, + SecretId, SourceDetectionResult, StorageError, ToolResult, definePlugin, tool, resolveSecretBackedMap, + type CredentialBindingValue, type CredentialBindingRef, type PluginCtx, type StorageFailure, @@ -41,9 +44,6 @@ import { HeaderValue as HeaderValueSchema, ConfiguredHeaderBinding, OAuth2SourceConfig, - OpenApiSourceBindingInput, - OpenApiSourceBindingRef, - type OpenApiSourceBindingValue, OperationBinding, type ConfiguredHeaderValue as ConfiguredHeaderValueValue, type HeaderValue as HeaderValueValue, @@ -180,6 +180,45 @@ export interface OpenApiUpdateSourceInput { readonly oauth2?: OpenApiOAuthInput; } +export interface OpenApiSourceRef { + readonly id: string; + readonly scope: string; +} + +export type OpenApiConfigureCredentialInput = + | string + | { + readonly kind: "text"; + readonly text: string; + readonly prefix?: string; + } + | { + readonly kind: "secret"; + readonly secretId: string; + readonly secretScope?: string; + readonly prefix?: string; + } + | { + readonly kind: "connection"; + readonly connectionId: string; + }; + +export interface OpenApiConfigureInput { + /** Scope where these concrete credential values are saved. */ + readonly scope: string; + readonly headers?: Record; + readonly queryParams?: Record; + readonly specFetchCredentials?: { + readonly headers?: Record; + readonly queryParams?: Record; + }; + readonly oauth2?: { + readonly clientId?: OpenApiConfigureCredentialInput; + readonly clientSecret?: OpenApiConfigureCredentialInput; + readonly connection?: OpenApiConfigureCredentialInput; + }; +} + /** * Errors any OpenAPI extension method may surface. The first three are * plugin-domain tagged errors that flow directly to clients (4xx, each @@ -219,19 +258,10 @@ 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; + readonly configure: ( + source: OpenApiSourceRef, + input: OpenApiConfigureInput, + ) => Effect.Effect; } // --------------------------------------------------------------------------- @@ -444,18 +474,88 @@ const canonicalizeOAuth2 = ( oauth2: OpenApiOAuthInput | undefined, ): { readonly oauth2?: OAuth2SourceConfig; - readonly bindings: ReadonlyArray<{ - readonly slot: string; - readonly value: OpenApiSourceBindingValue; - }>; } => { - if (!oauth2) return { bindings: [] }; + if (!oauth2) return {}; return { oauth2, - bindings: [], }; }; +const configuredValueFromConfigureInput = ( + slot: string, + input: OpenApiConfigureCredentialInput, +): { + readonly configured: ConfiguredHeaderValue; + readonly value: CredentialBindingValue; +} => { + if (typeof input === "string") { + return { + configured: ConfiguredHeaderBinding.make({ kind: "binding", slot }), + value: { kind: "text", text: input }, + }; + } + if (input.kind === "text") { + return { + configured: ConfiguredHeaderBinding.make({ + kind: "binding", + slot, + prefix: input.prefix, + }), + value: { kind: "text", text: input.text }, + }; + } + if (input.kind === "secret") { + return { + configured: ConfiguredHeaderBinding.make({ + kind: "binding", + slot, + prefix: input.prefix, + }), + value: { + kind: "secret", + secretId: SecretId.make(input.secretId), + ...(input.secretScope ? { secretScopeId: ScopeId.make(input.secretScope) } : {}), + }, + }; + } + return { + configured: ConfiguredHeaderBinding.make({ kind: "binding", slot }), + value: { kind: "connection", connectionId: ConnectionId.make(input.connectionId) }, + }; +}; + +const configureMap = ( + values: Record | undefined, + slotForName: (name: string) => string, +): { + readonly configured: Record; + readonly bindings: ReadonlyArray<{ + readonly slotKey: string; + readonly value: CredentialBindingValue; + }>; +} => { + const configured: Record = {}; + const bindings: Array<{ + readonly slotKey: string; + readonly value: CredentialBindingValue; + }> = []; + for (const [name, input] of Object.entries(values ?? {})) { + const slotKey = slotForName(name); + const next = configuredValueFromConfigureInput(slotKey, input); + configured[name] = next.configured; + bindings.push({ slotKey, value: next.value }); + } + return { configured, bindings }; +}; + +const mergeConfiguredValues = ( + current: Record | undefined, + next: Record, +): Record | undefined => { + if (Object.keys(next).length === 0) return current; + return { ...(current ?? {}), ...next }; +}; + interface EffectiveSourceConfig { readonly config: SourceConfig; readonly headersSource: StoredSource; @@ -472,98 +572,17 @@ 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.gen(function* () { - const ranks = scopeRanks(ctx); - const sourceSourceRank = scopeRank(ranks, sourceScope); - if (sourceSourceRank === Infinity) return null; - const bindings = yield* ctx.credentialBindings.listForSource({ - pluginId: OPENAPI_PLUGIN_ID, - sourceId, - sourceScope: ScopeId.make(sourceScope), - }); - const binding = bindings - .filter( - (candidate) => - 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; - }); - -const validateOpenApiBindingTarget = ( - ctx: PluginCtx, - input: { - readonly sourceScope: string; - readonly targetScope: string; - readonly sourceId: string; - }, -): Effect.Effect => - Effect.gen(function* () { - const ranks = scopeRanks(ctx); - const sourceSourceRank = scopeRank(ranks, input.sourceScope); - const targetRank = scopeRank(ranks, input.targetScope); - const scopeList = `[${ctx.scopes.map((s) => s.id).join(", ")}]`; - if (sourceSourceRank === Infinity) { - return yield* new StorageError({ - message: - `OpenAPI source binding references source scope "${input.sourceScope}" ` + - `which is not in the executor's scope stack ${scopeList}.`, - cause: undefined, - }); - } - if (targetRank === Infinity) { - return yield* new StorageError({ - message: - `OpenAPI source binding targets scope "${input.targetScope}" which is not ` + - `in the executor's scope stack ${scopeList}.`, - cause: undefined, - }); - } - if (targetRank > sourceSourceRank) { - return yield* new StorageError({ - message: - `OpenAPI source bindings for "${input.sourceId}" cannot be written at ` + - `outer scope "${input.targetScope}" because the base source lives at ` + - `"${input.sourceScope}"`, - cause: undefined, - }); - } +): Effect.Effect => + ctx.credentialBindings.resolveBinding({ + pluginId: OPENAPI_PLUGIN_ID, + sourceId, + sourceScope: ScopeId.make(sourceScope), + slotKey: slot, }); const findOuterSource = ( @@ -638,7 +657,7 @@ const resolveConfiguredValueMap = ( resolved[name] = value; continue; } - const binding = yield* resolveOpenApiSourceBinding( + const binding = yield* resolveOpenApiCredentialBinding( ctx, params.sourceId, params.sourceScope, @@ -728,7 +747,7 @@ const resolveOAuthConnectionId = ( StorageFailure > => Effect.gen(function* () { - const binding = yield* resolveOpenApiSourceBinding( + const binding = yield* resolveOpenApiCredentialBinding( ctx, params.sourceId, params.sourceScope, @@ -1085,13 +1104,6 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { }); } } - if (affectedPrefixes.length > 0) { - yield* validateOpenApiBindingTarget(ctx, { - sourceId: namespace, - sourceScope: scope, - targetScope, - }); - } yield* ctx.transaction( Effect.gen(function* () { yield* ctx.storage.updateSourceMeta(namespace, scope, { @@ -1115,41 +1127,106 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { ); }), - listSourceBindings: (sourceId: string, sourceScope: string) => - listOpenApiSourceBindings(ctx, sourceId, sourceScope), - - setSourceBinding: (input: OpenApiSourceBindingInput) => + configure: (source: OpenApiSourceRef, input: OpenApiConfigureInput) => 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); - }), + const existing = yield* ctx.storage.getSource(source.id, source.scope); + if (!existing) { + return yield* new StorageError({ + message: + `Cannot configure OpenAPI source "${source.id}" at scope "${source.scope}": ` + + "source is not visible.", + cause: undefined, + }); + } - 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, - }); + const headers = configureMap(input.headers, headerSlotFromName); + const queryParams = configureMap(input.queryParams, queryParamSlotFromName); + const specFetchHeaders = configureMap( + input.specFetchCredentials?.headers, + specFetchHeaderSlotFromName, + ); + const specFetchQueryParams = configureMap( + input.specFetchCredentials?.queryParams, + specFetchQueryParamSlotFromName, + ); + const oauth2 = existing.config.oauth2; + const oauth2Bindings: Array<{ + readonly slotKey: string; + readonly value: CredentialBindingValue; + }> = []; + if (oauth2 && input.oauth2?.clientId) { + oauth2Bindings.push({ + slotKey: oauth2.clientIdSlot, + value: configuredValueFromConfigureInput(oauth2.clientIdSlot, input.oauth2.clientId) + .value, + }); + } + if (oauth2?.clientSecretSlot && input.oauth2?.clientSecret) { + oauth2Bindings.push({ + slotKey: oauth2.clientSecretSlot, + value: configuredValueFromConfigureInput( + oauth2.clientSecretSlot, + input.oauth2.clientSecret, + ).value, + }); + } + if (oauth2 && input.oauth2?.connection) { + oauth2Bindings.push({ + slotKey: oauth2.connectionSlot, + value: configuredValueFromConfigureInput( + oauth2.connectionSlot, + input.oauth2.connection, + ).value, + }); + } + + const specFetchCredentials = + Object.keys(specFetchHeaders.configured).length === 0 && + Object.keys(specFetchQueryParams.configured).length === 0 + ? existing.config.specFetchCredentials + : { + headers: mergeConfiguredValues( + existing.config.specFetchCredentials?.headers, + specFetchHeaders.configured, + ), + queryParams: mergeConfiguredValues( + existing.config.specFetchCredentials?.queryParams, + specFetchQueryParams.configured, + ), + }; + + return yield* ctx.transaction( + Effect.gen(function* () { + yield* ctx.storage.updateSourceMeta(source.id, source.scope, { + headers: mergeConfiguredValues(existing.config.headers, headers.configured), + queryParams: mergeConfiguredValues( + existing.config.queryParams, + queryParams.configured, + ), + specFetchCredentials, + }); + const refs: CredentialBindingRef[] = []; + for (const binding of [ + ...headers.bindings, + ...queryParams.bindings, + ...specFetchHeaders.bindings, + ...specFetchQueryParams.bindings, + ...oauth2Bindings, + ]) { + refs.push( + yield* ctx.credentialBindings.set({ + targetScope: ScopeId.make(input.scope), + pluginId: OPENAPI_PLUGIN_ID, + sourceId: source.id, + sourceScope: ScopeId.make(source.scope), + slotKey: binding.slotKey, + value: binding.value, + }), + ); + } + return refs; + }), + ); }), }; }, diff --git a/packages/plugins/openapi/src/sdk/store.ts b/packages/plugins/openapi/src/sdk/store.ts index a4e22a73c..a1bd57ccf 100644 --- a/packages/plugins/openapi/src/sdk/store.ts +++ b/packages/plugins/openapi/src/sdk/store.ts @@ -292,6 +292,7 @@ export interface OpenapiStore { readonly baseUrl?: string; readonly headers?: Record; readonly queryParams?: Record; + readonly specFetchCredentials?: OpenApiSpecFetchCredentials; readonly oauth2?: OAuth2SourceConfig; }, ) => Effect.Effect; @@ -537,6 +538,20 @@ export const makeDefaultOpenapiStore = ({ patch.queryParams, ); } + if (patch.specFetchCredentials !== undefined) { + yield* replaceChildRows( + "openapi_source_spec_fetch_header", + namespace, + scope, + patch.specFetchCredentials.headers, + ); + yield* replaceChildRows( + "openapi_source_spec_fetch_query_param", + namespace, + scope, + patch.specFetchCredentials.queryParams, + ); + } }), getSource: (namespace, scope) => diff --git a/packages/plugins/openapi/src/sdk/types.ts b/packages/plugins/openapi/src/sdk/types.ts index 52e8a7a09..a8f4edff4 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/shared"; +import { ScopedSecretCredentialInput, SecretBackedValue } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- // 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 54a10f1be..404d05a87 100644 --- a/packages/plugins/openapi/src/sdk/usage-scope-isolation.test.ts +++ b/packages/plugins/openapi/src/sdk/usage-scope-isolation.test.ts @@ -13,6 +13,7 @@ import { type ConnectionProvider, type SecretProvider, SetSecretInput, + SetSourceCredentialBindingInput, definePlugin, } from "@executor-js/sdk"; import { makeTestWorkspaceLayer, TestWorkspace } from "@executor-js/sdk/testing"; @@ -22,7 +23,6 @@ import { } from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; -import { OpenApiSourceBindingInput } from "./types"; const PingGroup = HttpApiGroup.make("default", { topLevel: true }).add( HttpApiEndpoint.get("ping", "/ping"), @@ -114,12 +114,11 @@ layer(makeTestWorkspaceLayer({ scopes: [orgA], plugins }), { timeout: "15 second scope: String(orgA.id), namespace: "secret_private_source", }); - yield* orgAExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "secret_private_source", - sourceScope: orgA.id, + yield* orgAExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "secret_private_source", scope: orgA.id }, scope: orgA.id, - slot: "header:authorization", + slotKey: "header:authorization", value: { kind: "secret", secretId }, }), ); @@ -181,12 +180,11 @@ layer(makeTestWorkspaceLayer({ scopes: [orgA], plugins }), { timeout: "15 second scope: String(orgA.id), namespace: "connection_private_source", }); - yield* orgAExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "connection_private_source", - sourceScope: orgA.id, + yield* orgAExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "connection_private_source", scope: orgA.id }, scope: orgA.id, - slot: "oauth:connection", + slotKey: "oauth:connection", value: { kind: "connection", connectionId }, }), ); diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 352226055..814274d40 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -136,6 +136,21 @@ export const refreshSource = ExecutorApiClient.mutation("sources", "refresh"); export const detectSource = ExecutorApiClient.mutation("sources", "detect"); +export const sourceCredentialBindingsAtom = ( + scopeId: ScopeId, + sourceId: string, + sourceScopeId: ScopeId, +) => + ExecutorApiClient.query("sources", "listBindings", { + params: { scopeId, sourceId, sourceScopeId }, + timeToLive: "15 seconds", + reactivityKeys: [ReactivityKey.sources, ReactivityKey.secrets, ReactivityKey.connections], + }); + +export const setSourceCredentialBinding = ExecutorApiClient.mutation("sources", "setBinding"); + +export const removeSourceCredentialBinding = ExecutorApiClient.mutation("sources", "removeBinding"); + // --------------------------------------------------------------------------- // OAuth — one atom pair drives sign-in for every plugin. The plugin's // `Add*Source` / `*SignInButton` component passes the `strategy` descriptor From c2721c9c0b1483c99ffc84f5e43ac2d3d7ea8a6d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 10:35:54 -0700 Subject: [PATCH 02/19] Overhaul plugin source configuration --- .../drizzle/0017_plugin_storage_sources.sql | 172 +++++ apps/cloud/drizzle/meta/_journal.json | 7 + apps/cloud/src/mcp-miniflare.e2e.node.test.ts | 13 +- apps/cloud/src/services/executor-schema.ts | 202 +---- .../src/services/sources-api.node.test.ts | 20 +- .../services/tenant-isolation.node.test.ts | 15 +- .../drizzle/0011_plugin_storage_sources.sql | 215 ++++++ apps/local/drizzle/meta/_journal.json | 7 + apps/local/src/server/executor-schema.ts | 269 +------ .../server/migrate-graphql-bindings.test.ts | 164 ++-- .../src/server/migrate-mcp-bindings.test.ts | 138 ++-- .../server/migrate-openapi-bindings.test.ts | 202 ++--- apps/marketing/src/pages/api/detect.ts | 1 + bun.lock | 31 + examples/all-plugins/src/main.ts | 1 + examples/promise-sdk/src/main.ts | 1 + notes/plugin-source-configuration-overhaul.md | 729 ++++++++++++++++++ packages/core/api/src/handlers/sources.ts | 13 + packages/core/api/src/sources/api.ts | 18 + packages/core/config/src/schema.ts | 4 +- packages/core/execution/src/promise.ts | 1 + packages/core/sdk/src/core-schema.ts | 9 + packages/core/sdk/src/executor.test.ts | 86 ++- packages/core/sdk/src/executor.ts | 225 ++++++ packages/core/sdk/src/index.ts | 12 + packages/core/sdk/src/plugin-storage.ts | 55 ++ packages/core/sdk/src/plugin.ts | 29 +- packages/core/sdk/src/shared.ts | 11 + packages/plugins/graphql/package.json | 1 + packages/plugins/graphql/src/api/group.ts | 80 +- packages/plugins/graphql/src/api/handlers.ts | 56 +- packages/plugins/graphql/src/promise.ts | 2 +- .../graphql/src/react/AddGraphqlSource.tsx | 76 +- .../graphql/src/react/EditGraphqlSource.tsx | 68 +- .../graphql/src/react/GraphqlSignInButton.tsx | 16 +- packages/plugins/graphql/src/react/atoms.ts | 29 +- packages/plugins/graphql/src/sdk/index.ts | 4 +- .../plugins/graphql/src/sdk/plugin.test.ts | 242 ++++-- packages/plugins/graphql/src/sdk/plugin.ts | 529 ++++--------- packages/plugins/graphql/src/sdk/store.ts | 521 ++++--------- packages/plugins/graphql/src/sdk/types.ts | 30 +- packages/plugins/http-source/CHANGELOG.md | 7 + packages/plugins/http-source/package.json | 79 ++ packages/plugins/http-source/src/index.ts | 1 + .../plugins/http-source/src/react/index.ts | 23 + .../plugins/http-source/src/sdk/configure.ts | 183 +++++ packages/plugins/http-source/src/sdk/index.ts | 53 ++ .../plugins/http-source/src/sdk/manifest.ts | 76 ++ .../plugins/http-source/src/sdk/resolve.ts | 73 ++ packages/plugins/http-source/src/sdk/slots.ts | 27 + packages/plugins/http-source/src/sdk/types.ts | 116 +++ packages/plugins/http-source/tsconfig.json | 23 + packages/plugins/http-source/tsup.config.ts | 14 + packages/plugins/http-source/vitest.config.ts | 7 + packages/plugins/mcp/package.json | 1 + packages/plugins/mcp/src/api/group.ts | 88 +-- packages/plugins/mcp/src/api/handlers.test.ts | 4 - packages/plugins/mcp/src/api/handlers.ts | 70 +- packages/plugins/mcp/src/promise.ts | 2 +- .../plugins/mcp/src/react/AddMcpSource.tsx | 95 ++- .../plugins/mcp/src/react/EditMcpSource.tsx | 57 +- .../plugins/mcp/src/react/McpSignInButton.tsx | 16 +- packages/plugins/mcp/src/react/atoms.ts | 26 +- packages/plugins/mcp/src/sdk/binding-store.ts | 556 ++++--------- packages/plugins/mcp/src/sdk/index.ts | 3 +- .../src/sdk/per-user-auth-isolation.test.ts | 67 +- packages/plugins/mcp/src/sdk/plugin.test.ts | 171 ++-- packages/plugins/mcp/src/sdk/plugin.ts | 680 +++++----------- packages/plugins/mcp/src/sdk/types.ts | 37 +- packages/plugins/openapi/package.json | 1 + packages/plugins/openapi/src/api/group.ts | 24 +- packages/plugins/openapi/src/api/handlers.ts | 18 - packages/plugins/openapi/src/promise.ts | 2 +- .../openapi/src/react/AddOpenApiSource.tsx | 2 +- .../openapi/src/react/EditOpenApiSource.tsx | 57 +- packages/plugins/openapi/src/react/atoms.ts | 2 - packages/plugins/openapi/src/sdk/index.ts | 1 - .../plugins/openapi/src/sdk/plugin.test.ts | 105 ++- packages/plugins/openapi/src/sdk/plugin.ts | 363 +++++---- packages/plugins/openapi/src/sdk/store.ts | 650 +++++----------- packages/react/src/api/atoms.tsx | 2 + .../react/src/plugins/http-credentials.tsx | 109 +++ 82 files changed, 4430 insertions(+), 3765 deletions(-) create mode 100644 apps/cloud/drizzle/0017_plugin_storage_sources.sql create mode 100644 apps/local/drizzle/0011_plugin_storage_sources.sql create mode 100644 notes/plugin-source-configuration-overhaul.md create mode 100644 packages/core/sdk/src/plugin-storage.ts create mode 100644 packages/plugins/http-source/CHANGELOG.md create mode 100644 packages/plugins/http-source/package.json create mode 100644 packages/plugins/http-source/src/index.ts create mode 100644 packages/plugins/http-source/src/react/index.ts create mode 100644 packages/plugins/http-source/src/sdk/configure.ts create mode 100644 packages/plugins/http-source/src/sdk/index.ts create mode 100644 packages/plugins/http-source/src/sdk/manifest.ts create mode 100644 packages/plugins/http-source/src/sdk/resolve.ts create mode 100644 packages/plugins/http-source/src/sdk/slots.ts create mode 100644 packages/plugins/http-source/src/sdk/types.ts create mode 100644 packages/plugins/http-source/tsconfig.json create mode 100644 packages/plugins/http-source/tsup.config.ts create mode 100644 packages/plugins/http-source/vitest.config.ts diff --git a/apps/cloud/drizzle/0017_plugin_storage_sources.sql b/apps/cloud/drizzle/0017_plugin_storage_sources.sql new file mode 100644 index 000000000..a0ef902b1 --- /dev/null +++ b/apps/cloud/drizzle/0017_plugin_storage_sources.sql @@ -0,0 +1,172 @@ +CREATE TABLE IF NOT EXISTS "plugin_storage" ( + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "id" varchar(255) NOT NULL, + "scope_id" varchar(255) NOT NULL, + "plugin_id" text NOT NULL, + "collection" text NOT NULL, + "key" text NOT NULL, + "data" json NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "plugin_storage_scope_id_id_uidx" ON "plugin_storage" USING btree ("scope_id","id"); +--> statement-breakpoint +INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") +SELECT + 'plugin_storage_' || md5('openapi:source:' || s."scope_id" || ':' || s."id"), + '["openapi","source",' || to_json(s."id")::text || ']', + s."scope_id", + 'openapi', + 'source', + s."id", + json_build_object( + 'namespace', s."id", + 'scope', s."scope_id", + 'name', s."name", + 'config', json_strip_nulls(json_build_object( + 'spec', s."spec", + 'sourceUrl', s."source_url", + 'baseUrl', s."base_url", + 'headers', h."headers", + 'queryParams', q."queryParams", + 'specFetchCredentials', CASE WHEN sfh."headers" IS NULL AND sfq."queryParams" IS NULL THEN NULL ELSE json_strip_nulls(json_build_object('headers', sfh."headers", 'queryParams', sfq."queryParams")) END, + 'oauth2', s."oauth2" + )) + ), + now(), + now() +FROM "openapi_source" s +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "headers" + FROM "openapi_source_header" + GROUP BY "scope_id", "source_id" +) h ON h."scope_id" = s."scope_id" AND h."source_id" = s."id" +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "queryParams" + FROM "openapi_source_query_param" + GROUP BY "scope_id", "source_id" +) q ON q."scope_id" = s."scope_id" AND q."source_id" = s."id" +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "headers" + FROM "openapi_source_spec_fetch_header" + GROUP BY "scope_id", "source_id" +) sfh ON sfh."scope_id" = s."scope_id" AND sfh."source_id" = s."id" +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "queryParams" + FROM "openapi_source_spec_fetch_query_param" + GROUP BY "scope_id", "source_id" +) sfq ON sfq."scope_id" = s."scope_id" AND sfq."source_id" = s."id" +ON CONFLICT DO NOTHING; +--> statement-breakpoint +INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") +SELECT 'plugin_storage_' || md5('openapi:operation:' || o."scope_id" || ':' || o."id"), '["openapi","operation",' || to_json(o."id")::text || ']', o."scope_id", 'openapi', 'operation', o."id", json_build_object('toolId', o."id", 'sourceId', o."source_id", 'binding', o."binding"), now(), now() +FROM "openapi_operation" o +ON CONFLICT DO NOTHING; +--> statement-breakpoint +INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") +SELECT + 'plugin_storage_' || md5('graphql:source:' || s."scope_id" || ':' || s."id"), + '["graphql","source",' || to_json(s."id")::text || ']', + s."scope_id", + 'graphql', + 'source', + s."id", + json_build_object( + 'namespace', s."id", + 'scope', s."scope_id", + 'name', s."name", + 'endpoint', s."endpoint", + 'headers', COALESCE(h."headers", '{}'::json), + 'queryParams', COALESCE(q."queryParams", '{}'::json), + 'auth', CASE WHEN s."auth_kind" = 'oauth2' AND s."auth_connection_slot" IS NOT NULL THEN json_build_object('kind', 'oauth2', 'connectionSlot', s."auth_connection_slot") ELSE json_build_object('kind', 'none') END + ), + now(), + now() +FROM "graphql_source" s +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "headers" + FROM "graphql_source_header" + GROUP BY "scope_id", "source_id" +) h ON h."scope_id" = s."scope_id" AND h."source_id" = s."id" +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "queryParams" + FROM "graphql_source_query_param" + GROUP BY "scope_id", "source_id" +) q ON q."scope_id" = s."scope_id" AND q."source_id" = s."id" +ON CONFLICT DO NOTHING; +--> statement-breakpoint +INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") +SELECT 'plugin_storage_' || md5('graphql:operation:' || o."scope_id" || ':' || o."id"), '["graphql","operation",' || to_json(o."id")::text || ']', o."scope_id", 'graphql', 'operation', o."id", json_build_object('toolId', o."id", 'sourceId', o."source_id", 'binding', o."binding"), now(), now() +FROM "graphql_operation" o +ON CONFLICT DO NOTHING; +--> statement-breakpoint +INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") +SELECT + 'plugin_storage_' || md5('mcp:source:' || s."scope_id" || ':' || s."id"), + '["mcp","source",' || to_json(s."id")::text || ']', + s."scope_id", + 'mcp', + 'source', + s."id", + json_build_object( + 'namespace', s."id", + 'scope', s."scope_id", + 'name', s."name", + 'config', CASE WHEN s."config"->>'transport' = 'remote' THEN jsonb_strip_nulls(s."config"::jsonb || jsonb_build_object( + 'headers', h."headers", + 'queryParams', q."queryParams", + 'auth', CASE + WHEN s."auth_kind" = 'header' THEN json_build_object('kind', 'header', 'headerName', COALESCE(s."auth_header_name", ''), 'secretSlot', s."auth_header_slot", 'prefix', s."auth_header_prefix") + WHEN s."auth_kind" = 'oauth2' THEN json_build_object('kind', 'oauth2', 'connectionSlot', s."auth_connection_slot", 'clientIdSlot', s."auth_client_id_slot", 'clientSecretSlot', s."auth_client_secret_slot") + ELSE json_build_object('kind', 'none') + END + )) ELSE s."config"::jsonb END + ), + now(), + now() +FROM "mcp_source" s +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "headers" + FROM "mcp_source_header" + GROUP BY "scope_id", "source_id" +) h ON h."scope_id" = s."scope_id" AND h."source_id" = s."id" +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "queryParams" + FROM "mcp_source_query_param" + GROUP BY "scope_id", "source_id" +) q ON q."scope_id" = s."scope_id" AND q."source_id" = s."id" +ON CONFLICT DO NOTHING; +--> statement-breakpoint +INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") +SELECT 'plugin_storage_' || md5('mcp:binding:' || b."scope_id" || ':' || b."id"), '["mcp","binding",' || to_json(b."id")::text || ']', b."scope_id", 'mcp', 'binding', b."id", json_build_object('namespace', b."source_id", 'toolId', b."id", 'binding', b."binding"), b."created_at", now() +FROM "mcp_binding" b +ON CONFLICT DO NOTHING; +--> statement-breakpoint +DROP TABLE IF EXISTS "openapi_source"; +--> statement-breakpoint +DROP TABLE IF EXISTS "openapi_operation"; +--> statement-breakpoint +DROP TABLE IF EXISTS "openapi_source_header"; +--> statement-breakpoint +DROP TABLE IF EXISTS "openapi_source_query_param"; +--> statement-breakpoint +DROP TABLE IF EXISTS "openapi_source_spec_fetch_header"; +--> statement-breakpoint +DROP TABLE IF EXISTS "openapi_source_spec_fetch_query_param"; +--> statement-breakpoint +DROP TABLE IF EXISTS "graphql_source"; +--> statement-breakpoint +DROP TABLE IF EXISTS "graphql_source_header"; +--> statement-breakpoint +DROP TABLE IF EXISTS "graphql_source_query_param"; +--> statement-breakpoint +DROP TABLE IF EXISTS "graphql_operation"; +--> statement-breakpoint +DROP TABLE IF EXISTS "mcp_source"; +--> statement-breakpoint +DROP TABLE IF EXISTS "mcp_source_header"; +--> statement-breakpoint +DROP TABLE IF EXISTS "mcp_source_query_param"; +--> statement-breakpoint +DROP TABLE IF EXISTS "mcp_binding"; diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 2a55c3b59..5988a6188 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1778781460169, "tag": "0016_fumadb_cutover", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1779087600000, + "tag": "0017_plugin_storage_sources", + "breakpoints": true } ] } diff --git a/apps/cloud/src/mcp-miniflare.e2e.node.test.ts b/apps/cloud/src/mcp-miniflare.e2e.node.test.ts index 5e52d9b86..429a4a79c 100644 --- a/apps/cloud/src/mcp-miniflare.e2e.node.test.ts +++ b/apps/cloud/src/mcp-miniflare.e2e.node.test.ts @@ -61,9 +61,10 @@ const ApproveHandlers = HttpApiBuilder.group(UpstreamApi, "approve", (h) => // Services // --------------------------------------------------------------------------- -class Upstream extends Context.Service()( - "MiniflareE2E/Upstream", -) {} +class Upstream extends Context.Service< + Upstream, + { readonly baseUrl: string; readonly specJson: string } +>()("MiniflareE2E/Upstream") {} class Worker extends Context.Service< Worker, @@ -110,7 +111,7 @@ const UpstreamLive = Layer.effect( return { server, scope }; }), ({ scope }) => Scope.close(scope, Exit.void), - ).pipe(Effect.map(({ server }) => ({ specJson: server.specJson }))), + ).pipe(Effect.map(({ server }) => ({ baseUrl: server.baseUrl, specJson: server.specJson }))), ); // --------------------------------------------------------------------------- @@ -755,7 +756,7 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) () => Effect.gen(function* () { const { baseUrl, seedOrg } = yield* Worker; - const { specJson } = yield* Upstream; + const { baseUrl: upstreamBaseUrl, specJson } = yield* Upstream; const orgId = nextOrgId(); yield* Effect.promise(() => seedOrg(orgId, "Elicit Org")); @@ -781,7 +782,7 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) // `HttpApiGroup` name ("approve") becomes part of the sandbox path, // so the invocation reads `tools.approveapi.approve.approveThing`. const code = [ - `await tools.executor.openapi.addSource({ scope: ${JSON.stringify(orgId)}, spec: ${JSON.stringify(specJson)}, namespace: "approveapi" });`, + `await tools.executor.openapi.addSource({ scope: ${JSON.stringify(orgId)}, name: "Approve API", baseUrl: ${JSON.stringify(upstreamBaseUrl)}, spec: { kind: "blob", value: ${JSON.stringify(specJson)} }, namespace: "approveapi" });`, `return await tools.approveapi.approve.approveThing({});`, ].join("\n"); const result = yield* Effect.promise(() => diff --git a/apps/cloud/src/services/executor-schema.ts b/apps/cloud/src/services/executor-schema.ts index d6f1f381b..e4c9eb815 100644 --- a/apps/cloud/src/services/executor-schema.ts +++ b/apps/cloud/src/services/executor-schema.ts @@ -111,6 +111,20 @@ export const credential_binding = pgTable("credential_binding", { uniqueIndex("credential_binding_scope_id_id_uidx").on(table.scope_id, table.id) ]) +export const plugin_storage = pgTable("plugin_storage", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + plugin_id: text("plugin_id").notNull(), + collection: text("collection").notNull(), + key: text("key").notNull(), + data: json("data").notNull(), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull() +}, (table) => [ + uniqueIndex("plugin_storage_scope_id_id_uidx").on(table.scope_id, table.id) +]) + export const tool_policy = pgTable("tool_policy", { row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), id: varchar("id", { length: 255 }).notNull(), @@ -134,192 +148,6 @@ export const blob = pgTable("blob", { uniqueIndex("blob_id_uidx").on(table.id) ]) -export const openapi_source = pgTable("openapi_source", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - name: text("name").notNull(), - spec: text("spec").notNull(), - source_url: text("source_url"), - base_url: text("base_url"), - oauth2: json("oauth2") -}, (table) => [ - uniqueIndex("openapi_source_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const openapi_operation = pgTable("openapi_operation", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - binding: json("binding").notNull() -}, (table) => [ - uniqueIndex("openapi_operation_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const openapi_source_header = pgTable("openapi_source_header", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("openapi_source_header_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const openapi_source_query_param = pgTable("openapi_source_query_param", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("openapi_source_query_param_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const openapi_source_spec_fetch_header = pgTable("openapi_source_spec_fetch_header", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("openapi_source_spec_fetch_header_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const openapi_source_spec_fetch_query_param = pgTable("openapi_source_spec_fetch_query_param", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("openapi_source_spec_fetch_query_param_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const mcp_source = pgTable("mcp_source", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - name: text("name").notNull(), - config: json("config").notNull(), - auth_kind: text("auth_kind").notNull().default("none"), - auth_header_name: text("auth_header_name"), - auth_header_slot: text("auth_header_slot"), - auth_header_prefix: text("auth_header_prefix"), - auth_connection_slot: text("auth_connection_slot"), - auth_client_id_slot: text("auth_client_id_slot"), - auth_client_secret_slot: text("auth_client_secret_slot"), - created_at: timestamp("created_at").notNull() -}, (table) => [ - uniqueIndex("mcp_source_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const mcp_source_header = pgTable("mcp_source_header", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("mcp_source_header_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const mcp_source_query_param = pgTable("mcp_source_query_param", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("mcp_source_query_param_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const mcp_binding = pgTable("mcp_binding", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - binding: json("binding").notNull(), - created_at: timestamp("created_at").notNull() -}, (table) => [ - uniqueIndex("mcp_binding_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const graphql_source = pgTable("graphql_source", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - name: text("name").notNull(), - endpoint: text("endpoint").notNull(), - auth_kind: text("auth_kind").notNull().default("none"), - auth_connection_slot: text("auth_connection_slot") -}, (table) => [ - uniqueIndex("graphql_source_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const graphql_source_header = pgTable("graphql_source_header", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("graphql_source_header_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const graphql_source_query_param = pgTable("graphql_source_query_param", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("graphql_source_query_param_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const graphql_operation = pgTable("graphql_operation", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - binding: json("binding").notNull() -}, (table) => [ - uniqueIndex("graphql_operation_scope_id_id_uidx").on(table.scope_id, table.id) -]) - export const workos_vault_metadata = pgTable("workos_vault_metadata", { row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), id: varchar("id", { length: 255 }).notNull(), @@ -334,4 +162,4 @@ export const workos_vault_metadata = pgTable("workos_vault_metadata", { export const private_executor_cloud_settings = pgTable("private_executor_cloud_settings", { id: varchar("id", { length: 255 }).primaryKey().notNull(), version: varchar("version", { length: 255 }).notNull().default("1.0.0") -}) \ No newline at end of file +}) diff --git a/apps/cloud/src/services/sources-api.node.test.ts b/apps/cloud/src/services/sources-api.node.test.ts index b639a7fe9..9937f1379 100644 --- a/apps/cloud/src/services/sources-api.node.test.ts +++ b/apps/cloud/src/services/sources-api.node.test.ts @@ -230,7 +230,6 @@ describe("sources api (HTTP)", () => { .addSource({ params: { scopeId }, payload: { - targetScope: scopeId, transport: "remote", name: "Broken MCP", endpoint: "http://127.0.0.1:1/mcp", @@ -275,7 +274,6 @@ describe("sources api (HTTP)", () => { client.graphql.addSource({ params: { scopeId }, payload: { - targetScope: scopeId, endpoint: server.endpoint, namespace, name: "Cloud GraphQL", @@ -344,7 +342,6 @@ describe("sources api (HTTP)", () => { client.mcp.addSource({ params: { scopeId }, payload: { - targetScope: scopeId, transport: "remote", name: "Cloud MCP", endpoint: server.endpoint, @@ -453,7 +450,7 @@ describe("sources api (HTTP)", () => { }), ); - it.effect("openapi.updateSource round-trips baseUrl + name changes", () => + it.effect("sources.configure round-trips OpenAPI baseUrl + name changes", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; @@ -464,12 +461,17 @@ describe("sources api (HTTP)", () => { params: { scopeId: ScopeId.make(org) }, payload: makeMinimalOpenApiSourcePayload(namespace), }); - yield* client.openapi.updateSource({ - params: { scopeId: ScopeId.make(org), namespace }, + yield* client.sources.configure({ + params: { scopeId: ScopeId.make(org) }, payload: { - sourceScope: ScopeId.make(org), - name: "Renamed API", - baseUrl: "https://override.example.com", + source: { id: namespace, scope: ScopeId.make(org) }, + scope: ScopeId.make(org), + type: "openapi", + config: { + scope: org, + name: "Renamed API", + baseUrl: "https://override.example.com", + }, }, }); }), diff --git a/apps/cloud/src/services/tenant-isolation.node.test.ts b/apps/cloud/src/services/tenant-isolation.node.test.ts index 6245b5909..b1c850db6 100644 --- a/apps/cloud/src/services/tenant-isolation.node.test.ts +++ b/apps/cloud/src/services/tenant-isolation.node.test.ts @@ -346,12 +346,17 @@ describe("tenant isolation (HTTP)", () => { ); yield* asOrg(orgA, (client) => - client.openapi.updateSource({ - params: { scopeId: ScopeId.make(orgA), namespace }, + client.sources.configure({ + params: { scopeId: ScopeId.make(orgA) }, payload: { - sourceScope: ScopeId.make(orgA), - name: "Org A Updated API", - baseUrl: "https://org-a-updated.example.com", + source: { id: namespace, scope: ScopeId.make(orgA) }, + scope: ScopeId.make(orgA), + type: "openapi", + config: { + scope: orgA, + name: "Org A Updated API", + baseUrl: "https://org-a-updated.example.com", + }, }, }), ); diff --git a/apps/local/drizzle/0011_plugin_storage_sources.sql b/apps/local/drizzle/0011_plugin_storage_sources.sql new file mode 100644 index 000000000..c9cfeeb68 --- /dev/null +++ b/apps/local/drizzle/0011_plugin_storage_sources.sql @@ -0,0 +1,215 @@ +CREATE TABLE IF NOT EXISTS `openapi_source` (`id` text NOT NULL, `scope_id` text NOT NULL, `name` text NOT NULL, `spec` text NOT NULL, `source_url` text, `base_url` text, `oauth2` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `openapi_operation` (`id` text NOT NULL, `scope_id` text NOT NULL, `source_id` text NOT NULL, `binding` text NOT NULL); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `openapi_source_header` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `openapi_source_query_param` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `openapi_source_spec_fetch_header` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `openapi_source_spec_fetch_query_param` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `graphql_source` (`id` text NOT NULL, `scope_id` text NOT NULL, `name` text NOT NULL, `endpoint` text NOT NULL, `auth_kind` text NOT NULL DEFAULT 'none', `auth_connection_slot` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `graphql_source_header` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `graphql_source_query_param` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `graphql_operation` (`id` text NOT NULL, `scope_id` text NOT NULL, `source_id` text NOT NULL, `binding` text NOT NULL); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `mcp_source` (`id` text NOT NULL, `scope_id` text NOT NULL, `name` text NOT NULL, `config` text NOT NULL, `auth_kind` text NOT NULL DEFAULT 'none', `auth_header_name` text, `auth_header_slot` text, `auth_header_prefix` text, `auth_connection_slot` text, `auth_client_id_slot` text, `auth_client_secret_slot` text, `created_at` integer); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `mcp_source_header` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `mcp_source_query_param` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `mcp_binding` (`id` text NOT NULL, `scope_id` text NOT NULL, `source_id` text NOT NULL, `binding` text NOT NULL, `created_at` integer); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `plugin_storage` ( + `id` text NOT NULL, + `scope_id` text NOT NULL, + `plugin_id` text NOT NULL, + `collection` text NOT NULL, + `key` text NOT NULL, + `data` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + PRIMARY KEY(`scope_id`, `id`) +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `plugin_storage_scope_id_idx` ON `plugin_storage` (`scope_id`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `plugin_storage_plugin_id_collection_idx` ON `plugin_storage` (`plugin_id`, `collection`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `plugin_storage_key_idx` ON `plugin_storage` (`key`); +--> statement-breakpoint +INSERT OR REPLACE INTO `plugin_storage` (`id`, `scope_id`, `plugin_id`, `collection`, `key`, `data`, `created_at`, `updated_at`) +SELECT + json_array('openapi', 'source', s.`id`), + s.`scope_id`, + 'openapi', + 'source', + s.`id`, + json_object( + 'namespace', s.`id`, + 'scope', s.`scope_id`, + 'name', s.`name`, + 'config', json_patch( + json_patch( + json_patch( + json_patch( + json_patch( + json_patch( + json_object('spec', s.`spec`), + CASE WHEN s.`source_url` IS NULL THEN json_object() ELSE json_object('sourceUrl', s.`source_url`) END + ), + CASE WHEN s.`base_url` IS NULL THEN json_object() ELSE json_object('baseUrl', s.`base_url`) END + ), + CASE WHEN h.`headers` IS NULL THEN json_object() ELSE json_object('headers', json(h.`headers`)) END + ), + CASE WHEN q.`queryParams` IS NULL THEN json_object() ELSE json_object('queryParams', json(q.`queryParams`)) END + ), + CASE WHEN sfh.`headers` IS NULL AND sfq.`queryParams` IS NULL THEN json_object() ELSE json_object('specFetchCredentials', json_patch( + CASE WHEN sfh.`headers` IS NULL THEN json_object() ELSE json_object('headers', json(sfh.`headers`)) END, + CASE WHEN sfq.`queryParams` IS NULL THEN json_object() ELSE json_object('queryParams', json(sfq.`queryParams`)) END + )) END + ), + CASE WHEN s.`oauth2` IS NULL THEN json_object() ELSE json_object('oauth2', json(s.`oauth2`)) END + ) + ), + unixepoch('now') * 1000, + unixepoch('now') * 1000 +FROM `openapi_source` s +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `headers` + FROM `openapi_source_header` + GROUP BY `scope_id`, `source_id` +) h ON h.`scope_id` = s.`scope_id` AND h.`source_id` = s.`id` +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `queryParams` + FROM `openapi_source_query_param` + GROUP BY `scope_id`, `source_id` +) q ON q.`scope_id` = s.`scope_id` AND q.`source_id` = s.`id` +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `headers` + FROM `openapi_source_spec_fetch_header` + GROUP BY `scope_id`, `source_id` +) sfh ON sfh.`scope_id` = s.`scope_id` AND sfh.`source_id` = s.`id` +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `queryParams` + FROM `openapi_source_spec_fetch_query_param` + GROUP BY `scope_id`, `source_id` +) sfq ON sfq.`scope_id` = s.`scope_id` AND sfq.`source_id` = s.`id`; +--> statement-breakpoint +INSERT OR REPLACE INTO `plugin_storage` (`id`, `scope_id`, `plugin_id`, `collection`, `key`, `data`, `created_at`, `updated_at`) +SELECT json_array('openapi', 'operation', o.`id`), o.`scope_id`, 'openapi', 'operation', o.`id`, json_object('toolId', o.`id`, 'sourceId', o.`source_id`, 'binding', json(o.`binding`)), unixepoch('now') * 1000, unixepoch('now') * 1000 +FROM `openapi_operation` o; +--> statement-breakpoint +INSERT OR REPLACE INTO `plugin_storage` (`id`, `scope_id`, `plugin_id`, `collection`, `key`, `data`, `created_at`, `updated_at`) +SELECT + json_array('graphql', 'source', s.`id`), + s.`scope_id`, + 'graphql', + 'source', + s.`id`, + json_object( + 'namespace', s.`id`, + 'scope', s.`scope_id`, + 'name', s.`name`, + 'endpoint', s.`endpoint`, + 'headers', COALESCE(json(h.`headers`), json_object()), + 'queryParams', COALESCE(json(q.`queryParams`), json_object()), + 'auth', CASE WHEN s.`auth_kind` = 'oauth2' AND s.`auth_connection_slot` IS NOT NULL THEN json_object('kind', 'oauth2', 'connectionSlot', s.`auth_connection_slot`) ELSE json_object('kind', 'none') END + ), + unixepoch('now') * 1000, + unixepoch('now') * 1000 +FROM `graphql_source` s +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `headers` + FROM `graphql_source_header` + GROUP BY `scope_id`, `source_id` +) h ON h.`scope_id` = s.`scope_id` AND h.`source_id` = s.`id` +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `queryParams` + FROM `graphql_source_query_param` + GROUP BY `scope_id`, `source_id` +) q ON q.`scope_id` = s.`scope_id` AND q.`source_id` = s.`id`; +--> statement-breakpoint +INSERT OR REPLACE INTO `plugin_storage` (`id`, `scope_id`, `plugin_id`, `collection`, `key`, `data`, `created_at`, `updated_at`) +SELECT json_array('graphql', 'operation', o.`id`), o.`scope_id`, 'graphql', 'operation', o.`id`, json_object('toolId', o.`id`, 'sourceId', o.`source_id`, 'binding', json(o.`binding`)), unixepoch('now') * 1000, unixepoch('now') * 1000 +FROM `graphql_operation` o; +--> statement-breakpoint +INSERT OR REPLACE INTO `plugin_storage` (`id`, `scope_id`, `plugin_id`, `collection`, `key`, `data`, `created_at`, `updated_at`) +SELECT + json_array('mcp', 'source', s.`id`), + s.`scope_id`, + 'mcp', + 'source', + s.`id`, + json_object( + 'namespace', s.`id`, + 'scope', s.`scope_id`, + 'name', s.`name`, + 'config', CASE WHEN json_extract(s.`config`, '$.transport') = 'remote' THEN json_patch( + json_patch( + json_patch( + json(s.`config`), + CASE WHEN h.`headers` IS NULL THEN json_object() ELSE json_object('headers', json(h.`headers`)) END + ), + CASE WHEN q.`queryParams` IS NULL THEN json_object() ELSE json_object('queryParams', json(q.`queryParams`)) END + ), + json_object('auth', + CASE + WHEN s.`auth_kind` = 'header' THEN json_object('kind', 'header', 'headerName', COALESCE(s.`auth_header_name`, ''), 'secretSlot', s.`auth_header_slot`, 'prefix', s.`auth_header_prefix`) + WHEN s.`auth_kind` = 'oauth2' THEN json_object('kind', 'oauth2', 'connectionSlot', s.`auth_connection_slot`, 'clientIdSlot', s.`auth_client_id_slot`, 'clientSecretSlot', s.`auth_client_secret_slot`) + ELSE json_object('kind', 'none') + END + ) + ) ELSE json(s.`config`) END + ), + unixepoch('now') * 1000, + unixepoch('now') * 1000 +FROM `mcp_source` s +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `headers` + FROM `mcp_source_header` + GROUP BY `scope_id`, `source_id` +) h ON h.`scope_id` = s.`scope_id` AND h.`source_id` = s.`id` +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `queryParams` + FROM `mcp_source_query_param` + GROUP BY `scope_id`, `source_id` +) q ON q.`scope_id` = s.`scope_id` AND q.`source_id` = s.`id`; +--> statement-breakpoint +INSERT OR REPLACE INTO `plugin_storage` (`id`, `scope_id`, `plugin_id`, `collection`, `key`, `data`, `created_at`, `updated_at`) +SELECT json_array('mcp', 'binding', b.`id`), b.`scope_id`, 'mcp', 'binding', b.`id`, json_object('namespace', b.`source_id`, 'toolId', b.`id`, 'binding', json(b.`binding`)), COALESCE(b.`created_at`, unixepoch('now') * 1000), unixepoch('now') * 1000 +FROM `mcp_binding` b; +--> statement-breakpoint +DROP TABLE IF EXISTS `openapi_source`; +--> statement-breakpoint +DROP TABLE IF EXISTS `openapi_operation`; +--> statement-breakpoint +DROP TABLE IF EXISTS `openapi_source_header`; +--> statement-breakpoint +DROP TABLE IF EXISTS `openapi_source_query_param`; +--> statement-breakpoint +DROP TABLE IF EXISTS `openapi_source_spec_fetch_header`; +--> statement-breakpoint +DROP TABLE IF EXISTS `openapi_source_spec_fetch_query_param`; +--> statement-breakpoint +DROP TABLE IF EXISTS `graphql_source`; +--> statement-breakpoint +DROP TABLE IF EXISTS `graphql_source_header`; +--> statement-breakpoint +DROP TABLE IF EXISTS `graphql_source_query_param`; +--> statement-breakpoint +DROP TABLE IF EXISTS `graphql_operation`; +--> statement-breakpoint +DROP TABLE IF EXISTS `mcp_source`; +--> statement-breakpoint +DROP TABLE IF EXISTS `mcp_source_header`; +--> statement-breakpoint +DROP TABLE IF EXISTS `mcp_source_query_param`; +--> statement-breakpoint +DROP TABLE IF EXISTS `mcp_binding`; diff --git a/apps/local/drizzle/meta/_journal.json b/apps/local/drizzle/meta/_journal.json index 6654a84a3..96892e22b 100644 --- a/apps/local/drizzle/meta/_journal.json +++ b/apps/local/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1778192434063, "tag": "0010_add_credential_binding_secret_scope", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1779087600000, + "tag": "0011_plugin_storage_sources", + "breakpoints": true } ] } diff --git a/apps/local/src/server/executor-schema.ts b/apps/local/src/server/executor-schema.ts index b9a465130..7f36764ba 100644 --- a/apps/local/src/server/executor-schema.ts +++ b/apps/local/src/server/executor-schema.ts @@ -156,206 +156,40 @@ export const credential_binding = sqliteTable( ], ); -export const tool_policy = sqliteTable( - "tool_policy", +export const plugin_storage = sqliteTable( + "plugin_storage", { id: text("id").notNull(), scope_id: text("scope_id").notNull(), - pattern: text("pattern").notNull(), - action: text("action").notNull(), - position: text("position").notNull(), + plugin_id: text("plugin_id").notNull(), + collection: text("collection").notNull(), + key: text("key").notNull(), + data: text("data", { mode: "json" }).notNull(), created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), }, (table) => [ primaryKey({ columns: [table.scope_id, table.id] }), - index("tool_policy_scope_id_position_idx").on(table.scope_id, table.position), - ], -); - -export const openapi_source = sqliteTable( - "openapi_source", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - name: text("name").notNull(), - spec: text("spec").notNull(), - source_url: text("source_url"), - base_url: text("base_url"), - oauth2: text("oauth2", { mode: "json" }), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_source_scope_id_idx").on(table.scope_id), - ], -); - -export const openapi_operation = sqliteTable( - "openapi_operation", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - binding: text("binding", { mode: "json" }).notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_operation_scope_id_idx").on(table.scope_id), - index("openapi_operation_source_id_idx").on(table.source_id), - ], -); - -export const openapi_source_header = sqliteTable( - "openapi_source_header", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_source_header_scope_id_idx").on(table.scope_id), - index("openapi_source_header_source_id_idx").on(table.source_id), - ], -); - -export const openapi_source_query_param = sqliteTable( - "openapi_source_query_param", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_source_query_param_scope_id_idx").on(table.scope_id), - index("openapi_source_query_param_source_id_idx").on(table.source_id), - ], -); - -export const openapi_source_spec_fetch_header = sqliteTable( - "openapi_source_spec_fetch_header", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_source_spec_fetch_header_scope_id_idx").on(table.scope_id), - index("openapi_source_spec_fetch_header_source_id_idx").on(table.source_id), + index("plugin_storage_scope_id_idx").on(table.scope_id), + index("plugin_storage_plugin_id_collection_idx").on(table.plugin_id, table.collection), + index("plugin_storage_key_idx").on(table.key), ], ); -export const openapi_source_spec_fetch_query_param = sqliteTable( - "openapi_source_spec_fetch_query_param", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_source_spec_fetch_query_param_scope_id_idx").on(table.scope_id), - index("openapi_source_spec_fetch_query_param_source_id_idx").on(table.source_id), - ], -); - -export const mcp_source = sqliteTable( - "mcp_source", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - name: text("name").notNull(), - config: text("config", { mode: "json" }).notNull(), - auth_kind: text({ enum: ["none", "header", "oauth2"] }) - .default("none") - .notNull(), - auth_header_name: text("auth_header_name"), - auth_header_slot: text("auth_header_slot"), - auth_header_prefix: text("auth_header_prefix"), - auth_connection_slot: text("auth_connection_slot"), - auth_client_id_slot: text("auth_client_id_slot"), - auth_client_secret_slot: text("auth_client_secret_slot"), - created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("mcp_source_scope_id_idx").on(table.scope_id), - ], -); - -export const mcp_source_header = sqliteTable( - "mcp_source_header", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("mcp_source_header_scope_id_idx").on(table.scope_id), - index("mcp_source_header_source_id_idx").on(table.source_id), - ], -); - -export const mcp_source_query_param = sqliteTable( - "mcp_source_query_param", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("mcp_source_query_param_scope_id_idx").on(table.scope_id), - index("mcp_source_query_param_source_id_idx").on(table.source_id), - ], -); - -export const mcp_binding = sqliteTable( - "mcp_binding", +export const tool_policy = sqliteTable( + "tool_policy", { id: text("id").notNull(), scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - binding: text("binding", { mode: "json" }).notNull(), + pattern: text("pattern").notNull(), + action: text("action").notNull(), + position: text("position").notNull(), created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), }, (table) => [ primaryKey({ columns: [table.scope_id, table.id] }), - index("mcp_binding_scope_id_idx").on(table.scope_id), - index("mcp_binding_source_id_idx").on(table.source_id), + index("tool_policy_scope_id_position_idx").on(table.scope_id, table.position), ], ); @@ -444,74 +278,3 @@ export const google_discovery_binding = sqliteTable( index("google_discovery_binding_source_id_idx").on(table.source_id), ], ); - -export const graphql_source = sqliteTable( - "graphql_source", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - name: text("name").notNull(), - endpoint: text("endpoint").notNull(), - auth_kind: text({ enum: ["none", "oauth2"] }) - .default("none") - .notNull(), - auth_connection_slot: text("auth_connection_slot"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("graphql_source_scope_id_idx").on(table.scope_id), - ], -); - -export const graphql_source_header = sqliteTable( - "graphql_source_header", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("graphql_source_header_scope_id_idx").on(table.scope_id), - index("graphql_source_header_source_id_idx").on(table.source_id), - ], -); - -export const graphql_source_query_param = sqliteTable( - "graphql_source_query_param", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("graphql_source_query_param_scope_id_idx").on(table.scope_id), - index("graphql_source_query_param_source_id_idx").on(table.source_id), - ], -); - -export const graphql_operation = sqliteTable( - "graphql_operation", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - binding: text("binding", { mode: "json" }).notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("graphql_operation_scope_id_idx").on(table.scope_id), - index("graphql_operation_source_id_idx").on(table.source_id), - ], -); diff --git a/apps/local/src/server/migrate-graphql-bindings.test.ts b/apps/local/src/server/migrate-graphql-bindings.test.ts index 6615dcb87..699d71f97 100644 --- a/apps/local/src/server/migrate-graphql-bindings.test.ts +++ b/apps/local/src/server/migrate-graphql-bindings.test.ts @@ -18,28 +18,10 @@ const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); const NullableString = Schema.NullOr(Schema.String); -const GraphqlAuthRow = Schema.Struct({ - auth_kind: Schema.String, - auth_connection_slot: NullableString, -}); - const TableInfoRow = Schema.Struct({ name: Schema.String, }); -const GraphqlHeaderRow = Schema.Struct({ - name: Schema.String, - kind: Schema.String, - text_value: NullableString, - slot_key: NullableString, - prefix: NullableString, -}); - -const GraphqlQueryParamRow = Schema.Struct({ - kind: Schema.String, - slot_key: Schema.String, -}); - const BindingRow = Schema.Struct({ scope_id: Schema.String, plugin_id: Schema.String, @@ -51,24 +33,12 @@ const BindingRow = Schema.Struct({ connection_id: NullableString, }); -const CountRow = Schema.Struct({ - n: Schema.Number, -}); +const PluginStorageRow = Schema.Struct({ data: Schema.String }); -const GraphqlHeaderIdRow = Schema.Struct({ - id: Schema.String, - source_id: Schema.String, - name: Schema.String, - text_value: Schema.String, -}); - -const decodeAuthRow = Schema.decodeUnknownSync(GraphqlAuthRow); const decodeTableInfoRows = Schema.decodeUnknownSync(Schema.Array(TableInfoRow)); -const decodeHeaderRows = Schema.decodeUnknownSync(Schema.Array(GraphqlHeaderRow)); -const decodeQueryParamRow = Schema.decodeUnknownSync(GraphqlQueryParamRow); const decodeBindingRows = Schema.decodeUnknownSync(Schema.Array(BindingRow)); -const decodeCountRow = Schema.decodeUnknownSync(CountRow); -const decodeHeaderIdRows = Schema.decodeUnknownSync(Schema.Array(GraphqlHeaderIdRow)); +const decodePluginStorageRow = Schema.decodeUnknownSync(PluginStorageRow); +const decodePluginStorageData = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); let dir: string; @@ -103,13 +73,17 @@ describe("graphql credential migrations", () => { migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); const after = new Database(dbPath, { readonly: true }); - const row = decodeAuthRow( - after - .prepare("SELECT auth_kind, auth_connection_slot FROM graphql_source WHERE id = ?") - .get("github"), - ); - expect(row.auth_kind).toBe("oauth2"); - expect(row.auth_connection_slot).toBe("auth:oauth2:connection"); + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("graphql", "source", "github"), + ).data, + ) as { readonly auth: { readonly kind: string; readonly connectionSlot?: string } }; + expect(source.auth.kind).toBe("oauth2"); + expect(source.auth.connectionSlot).toBe("auth:oauth2:connection"); const bindings = decodeBindingRows( after .prepare( @@ -174,40 +148,28 @@ describe("graphql credential migrations", () => { migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); const after = new Database(dbPath, { readonly: true }); - const headerRows = decodeHeaderRows( - after - .prepare( - "SELECT name, kind, text_value, slot_key, prefix FROM graphql_source_header WHERE source_id = ? ORDER BY name", - ) - .all("example"), - ); - expect(headerRows).toHaveLength(3); - - const byName = new Map(headerRows.map((r) => [r.name, r])); - expect(byName.get("X-Static")).toMatchObject({ - kind: "text", - text_value: "literal-value", - slot_key: null, - }); - expect(byName.get("Authorization")).toMatchObject({ - kind: "binding", - text_value: null, - slot_key: "header:authorization", - prefix: null, + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("graphql", "source", "example"), + ).data, + ) as { + readonly headers: Record; + readonly queryParams: Record; + }; + expect(source.headers).toMatchObject({ + "X-Static": "literal-value", + Authorization: { kind: "binding", slot: "header:authorization" }, + "X-Bearer": { kind: "binding", slot: "header:x-bearer", prefix: "Bearer " }, }); - expect(byName.get("X-Bearer")).toMatchObject({ + expect(source.queryParams.api_key).toMatchObject({ kind: "binding", - slot_key: "header:x-bearer", - prefix: "Bearer ", + slot: "query_param:api-key", }); - const paramRow = decodeQueryParamRow( - after - .prepare("SELECT kind, slot_key FROM graphql_source_query_param WHERE source_id = ?") - .get("example"), - ); - expect(paramRow).toMatchObject({ kind: "binding", slot_key: "query_param:api-key" }); - const bindings = decodeBindingRows( after .prepare( @@ -298,20 +260,17 @@ describe("graphql credential migrations", () => { migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); const after = new Database(dbPath, { readonly: true }); - const row = decodeAuthRow( - after - .prepare("SELECT auth_kind, auth_connection_slot FROM graphql_source WHERE id = ?") - .get("bare"), - ); - expect(row.auth_kind).toBe("none"); - expect(row.auth_connection_slot).toBeNull(); - - const headerCount = decodeCountRow( - after - .prepare("SELECT count(*) as n FROM graphql_source_header WHERE source_id = ?") - .get("bare"), - ).n; - expect(headerCount).toBe(0); + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("graphql", "source", "bare"), + ).data, + ) as { readonly auth: { readonly kind: string }; readonly headers: Record }; + expect(source.auth.kind).toBe("none"); + expect(source.headers).toEqual({}); after.close(); }); @@ -344,28 +303,21 @@ describe("graphql credential migrations", () => { migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); const after = new Database(dbPath, { readonly: true }); - const rows = decodeHeaderIdRows( - after - .prepare( - "SELECT id, source_id, name, text_value FROM graphql_source_header ORDER BY source_id, name", - ) - .all(), - ); - expect(rows).toHaveLength(2); - expect(rows).toEqual([ - { - id: '["a","b:c"]', - source_id: "a", - name: "b:c", - text_value: "second", - }, - { - id: '["a:b","c"]', - source_id: "a:b", - name: "c", - text_value: "first", - }, - ]); + const rows = after + .prepare( + "SELECT key, data FROM plugin_storage WHERE plugin_id = ? AND collection = ? ORDER BY key", + ) + .all("graphql", "source") + .map((row) => { + const decoded = decodePluginStorageRow(row); + return { key: (row as { key: string }).key, data: decodePluginStorageData(decoded.data) }; + }) as ReadonlyArray<{ + readonly key: string; + readonly data: { readonly headers: Record }; + }>; + expect(rows.map((row) => row.key)).toEqual(["a", "a:b"]); + expect(rows[0]?.data.headers).toEqual({ "b:c": "second" }); + expect(rows[1]?.data.headers).toEqual({ c: "first" }); after.close(); }); }); diff --git a/apps/local/src/server/migrate-mcp-bindings.test.ts b/apps/local/src/server/migrate-mcp-bindings.test.ts index dc1eff6ac..6b00033a9 100644 --- a/apps/local/src/server/migrate-mcp-bindings.test.ts +++ b/apps/local/src/server/migrate-mcp-bindings.test.ts @@ -16,16 +16,9 @@ import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pr const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); -const ConfigJson = Schema.fromJsonString( - Schema.Struct({ - auth: Schema.optional(Schema.Unknown), - command: Schema.optional(Schema.String), - endpoint: Schema.optional(Schema.String), - transport: Schema.String, - }), -); - -const decodeConfigJson = Schema.decodeUnknownSync(ConfigJson); +const PluginStorageRow = Schema.Struct({ data: Schema.String }); +const decodePluginStorageRow = Schema.decodeUnknownSync(PluginStorageRow); +const decodePluginStorageData = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const tempDirs: Array = []; @@ -72,21 +65,32 @@ describe("mcp credential migrations", () => { migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); const after = new Database(dbPath, { readonly: true }); - const row = after - .prepare( - "SELECT auth_kind, auth_header_name, auth_header_slot, auth_header_prefix, config FROM mcp_source WHERE id = ?", - ) - .get("remote-headers") as { - auth_kind: string; - auth_header_name: string; - auth_header_slot: string; - auth_header_prefix: string; - config: string; + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("mcp", "source", "remote-headers"), + ).data, + ) as { + readonly config: { + readonly auth?: { + readonly kind: string; + readonly headerName?: string; + readonly secretSlot?: string; + readonly prefix?: string; + }; + readonly endpoint?: string; + readonly transport: string; + }; }; - expect(row.auth_kind).toBe("header"); - expect(row.auth_header_name).toBe("X-API-Key"); - expect(row.auth_header_slot).toBe("auth:header"); - expect(row.auth_header_prefix).toBe("Bearer "); + expect(source.config.auth).toMatchObject({ + kind: "header", + headerName: "X-API-Key", + secretSlot: "auth:header", + prefix: "Bearer ", + }); const binding = after .prepare( "SELECT slot_key, kind, secret_id FROM credential_binding WHERE plugin_id = ? AND source_id = ? AND slot_key = ?", @@ -97,11 +101,8 @@ describe("mcp credential migrations", () => { kind: "secret", secret_id: "tok-secret", }); - // The auth key should be stripped from config json after migration. - const config = decodeConfigJson(row.config); - expect(config.auth).toBeUndefined(); - expect(config.transport).toBe("remote"); - expect(config.endpoint).toBe("https://example.com/mcp"); + expect(source.config.transport).toBe("remote"); + expect(source.config.endpoint).toBe("https://example.com/mcp"); after.close(); }); @@ -142,15 +143,27 @@ describe("mcp credential migrations", () => { migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); const after = new Database(dbPath, { readonly: true }); - const row = after - .prepare( - "SELECT auth_kind, auth_connection_slot, auth_client_id_slot, auth_client_secret_slot FROM mcp_source WHERE id = ?", - ) - .get("remote-oauth") as Record; - expect(row.auth_kind).toBe("oauth2"); - expect(row.auth_connection_slot).toBe("auth:oauth2:connection"); - expect(row.auth_client_id_slot).toBe("auth:oauth2:client-id"); - expect(row.auth_client_secret_slot).toBe("auth:oauth2:client-secret"); + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("mcp", "source", "remote-oauth"), + ).data, + ) as { + readonly config: { + readonly auth?: Record; + readonly headers?: Record; + readonly queryParams?: Record; + }; + }; + expect(source.config.auth).toMatchObject({ + kind: "oauth2", + connectionSlot: "auth:oauth2:connection", + clientIdSlot: "auth:oauth2:client-id", + clientSecretSlot: "auth:oauth2:client-secret", + }); const authBindings = after .prepare( @@ -171,34 +184,18 @@ describe("mcp credential migrations", () => { secret_id: "client-secret-sec", }); - const headers = after - .prepare( - "SELECT name, kind, text_value, slot_key, prefix FROM mcp_source_header WHERE source_id = ? ORDER BY name", - ) - .all("remote-oauth") as ReadonlyArray>; - expect(headers).toHaveLength(2); - const byName = new Map(headers.map((h) => [h.name, h])); - expect(byName.get("X-Trace")).toMatchObject({ - kind: "text", - text_value: "static", - }); - expect(byName.get("X-Token")).toMatchObject({ - kind: "binding", - slot_key: "header:x-token", + expect(source.config.headers).toMatchObject({ + "X-Trace": "static", + "X-Token": { kind: "binding", slot: "header:x-token" }, }); expect(bySlot.get("header:x-token")).toMatchObject({ kind: "secret", secret_id: "extra-tok", }); - const params = after - .prepare("SELECT name, kind, slot_key FROM mcp_source_query_param WHERE source_id = ?") - .all("remote-oauth") as ReadonlyArray>; - expect(params).toHaveLength(1); - expect(params[0]).toMatchObject({ - name: "org", + expect(source.config.queryParams?.org).toMatchObject({ kind: "binding", - slot_key: "query_param:org", + slot: "query_param:org", }); expect(bySlot.get("query_param:org")).toMatchObject({ kind: "secret", @@ -263,18 +260,17 @@ describe("mcp credential migrations", () => { migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); const after = new Database(dbPath, { readonly: true }); - const row = after - .prepare("SELECT auth_kind, auth_header_slot, config FROM mcp_source WHERE id = ?") - .get("stdio-only") as { - auth_kind: string; - auth_header_slot: string | null; - config: string; - }; - expect(row.auth_kind).toBe("none"); - expect(row.auth_header_slot).toBeNull(); - const config = decodeConfigJson(row.config); - expect(config.transport).toBe("stdio"); - expect(config.command).toBe("/usr/bin/server"); + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("mcp", "source", "stdio-only"), + ).data, + ) as { readonly config: { readonly transport: string; readonly command?: string } }; + expect(source.config.transport).toBe("stdio"); + expect(source.config.command).toBe("/usr/bin/server"); after.close(); }); }); diff --git a/apps/local/src/server/migrate-openapi-bindings.test.ts b/apps/local/src/server/migrate-openapi-bindings.test.ts index 493ec2bf5..cc4503100 100644 --- a/apps/local/src/server/migrate-openapi-bindings.test.ts +++ b/apps/local/src/server/migrate-openapi-bindings.test.ts @@ -31,36 +31,8 @@ const BindingRow = Schema.Struct({ text_value: Schema.NullOr(Schema.String), }); -const QueryParamRow = Schema.Struct({ - name: Schema.String, - kind: Schema.String, - text_value: Schema.NullOr(Schema.String), - slot_key: Schema.NullOr(Schema.String), - prefix: Schema.NullOr(Schema.String), -}); - -const HeaderRow = QueryParamRow; - -const FetchHeaderRow = Schema.Struct({ - name: Schema.String, - kind: Schema.String, - slot_key: Schema.NullOr(Schema.String), - prefix: Schema.NullOr(Schema.String), -}); - -const FetchQueryParamRow = Schema.Struct({ - name: Schema.String, - kind: Schema.String, - slot_key: Schema.NullOr(Schema.String), - prefix: Schema.NullOr(Schema.String), -}); - -const SourceJsonRow = Schema.Struct({ - oauth2: Schema.NullOr(Schema.String), -}); - -const TableInfoRow = Schema.Struct({ - name: Schema.String, +const PluginStorageRow = Schema.Struct({ + data: Schema.String, }); const CountRow = Schema.Struct({ @@ -68,16 +40,9 @@ const CountRow = Schema.Struct({ }); const decodeBindingRows = Schema.decodeUnknownSync(Schema.Array(BindingRow)); -const decodeQueryParamRows = Schema.decodeUnknownSync(Schema.Array(QueryParamRow)); -const decodeHeaderRows = Schema.decodeUnknownSync(Schema.Array(HeaderRow)); -const decodeFetchHeaderRows = Schema.decodeUnknownSync(Schema.Array(FetchHeaderRow)); -const decodeFetchQueryParamRows = Schema.decodeUnknownSync(Schema.Array(FetchQueryParamRow)); -const decodeTableInfoRows = Schema.decodeUnknownSync(Schema.Array(TableInfoRow)); const decodeCountRow = Schema.decodeUnknownSync(CountRow); -const decodeSourceJsonRow = Schema.decodeUnknownSync(SourceJsonRow); -const decodeJsonRecord = Schema.decodeUnknownSync( - Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), -); +const decodePluginStorageRow = Schema.decodeUnknownSync(PluginStorageRow); +const decodePluginStorageData = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); describe("0007_normalize_plugin_secret_refs (openapi)", () => { let dir: string; @@ -257,55 +222,44 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { const after = openDatabase(dbPath, { readonly: true }); - const qpRows = decodeQueryParamRows( - after - .prepare( - "SELECT name, kind, text_value, slot_key, prefix FROM openapi_source_query_param WHERE source_id = ? ORDER BY name", - ) - .all("src"), - ); - expect(qpRows).toHaveLength(2); - const byName = new Map(qpRows.map((r) => [r.name, r])); - expect(byName.get("api_key")).toMatchObject({ - kind: "binding", - slot_key: "query_param:api-key", - prefix: null, - }); - expect(byName.get("flag")).toMatchObject({ - kind: "text", - text_value: "true", - slot_key: null, + const sourceData = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("openapi", "source", "src"), + ).data, + ) as { + readonly config: { + readonly queryParams?: Record; + readonly specFetchCredentials?: { + readonly headers?: Record; + readonly queryParams?: Record; + }; + }; + }; + expect(sourceData.config.queryParams).toMatchObject({ + api_key: { kind: "binding", slot: "query_param:api-key" }, + flag: "true", }); - - const fetchHeaders = decodeFetchHeaderRows( - after - .prepare( - "SELECT name, kind, slot_key, prefix FROM openapi_source_spec_fetch_header WHERE source_id = ?", - ) - .all("src"), - ); - expect(fetchHeaders).toHaveLength(1); - expect(fetchHeaders[0]).toMatchObject({ - name: "Authorization", + expect(sourceData.config.specFetchCredentials?.headers?.Authorization).toMatchObject({ kind: "binding", - slot_key: "spec_fetch_header:authorization", + slot: "spec_fetch_header:authorization", prefix: "Bearer ", }); - - const fetchQp = decodeFetchQueryParamRows( + expect(sourceData.config.specFetchCredentials?.queryParams?.token).toMatchObject({ + kind: "binding", + slot: "spec_fetch_query_param:token", + }); + const oldQueryParamTableCount = decodeCountRow( after .prepare( - "SELECT name, kind, slot_key, prefix FROM openapi_source_spec_fetch_query_param WHERE source_id = ?", + "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_query_param'", ) - .all("src"), + .get(), ); - expect(fetchQp).toHaveLength(1); - expect(fetchQp[0]).toMatchObject({ - name: "token", - kind: "binding", - slot_key: "spec_fetch_query_param:token", - prefix: null, - }); + expect(oldQueryParamTableCount.n).toBe(0); const bindings = decodeBindingRows( after @@ -320,10 +274,14 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { ["spec_fetch_query_param:token", "secret", "fetch-qp"], ]); - // Old json columns dropped. - const cols = decodeTableInfoRows(after.prepare("PRAGMA table_info('openapi_source')").all()); - expect(cols.some((c) => c.name === "query_params")).toBe(false); - expect(cols.some((c) => c.name === "invocation_config")).toBe(false); + const oldSourceTableCount = decodeCountRow( + after + .prepare( + "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source'", + ) + .get(), + ); + expect(oldSourceTableCount.n).toBe(0); }); it("fails instead of silently collapsing colliding legacy query parameter slots", () => { @@ -423,37 +381,35 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { closeDatabase(drizzleSqlite); const after = openDatabase(dbPath, { readonly: true }); - const source = decodeSourceJsonRow( - after.prepare("SELECT oauth2 FROM openapi_source WHERE id = ?").get("src"), - ); - const headerRows = decodeHeaderRows( + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("openapi", "source", "src"), + ).data, + ) as { + readonly config: { + readonly headers?: Record; + readonly oauth2?: Record; + }; + }; + expect(source.config.headers).toMatchObject({ + Authorization: { kind: "binding", slot: "header:authorization", prefix: "Bearer " }, + "X-Static": "literal", + "X-Already": { kind: "binding", slot: "header:x-already" }, + }); + const oldHeaderTableCount = decodeCountRow( after .prepare( - "SELECT name, kind, text_value, slot_key, prefix FROM openapi_source_header WHERE source_id = ? ORDER BY name", + "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_header'", ) - .all("src"), + .get(), ); - expect(headerRows).toHaveLength(3); - const headersByName = new Map(headerRows.map((row) => [row.name, row])); - expect(headersByName.get("Authorization")).toMatchObject({ - kind: "binding", - text_value: null, - slot_key: "header:authorization", - prefix: "Bearer ", - }); - expect(headersByName.get("X-Static")).toMatchObject({ - kind: "text", - text_value: "literal", - slot_key: null, - prefix: null, - }); - expect(headersByName.get("X-Already")).toMatchObject({ - kind: "binding", - slot_key: "header:x-already", - prefix: null, - }); + expect(oldHeaderTableCount.n).toBe(0); - const migratedOAuth2 = decodeJsonRecord(source.oauth2 ?? "{}"); + const migratedOAuth2 = source.config.oauth2 ?? {}; expect(migratedOAuth2).toMatchObject({ kind: "oauth2", securitySchemeName: "oauth2", @@ -480,8 +436,14 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { ["oauth2:oauth2:connection", "connection", null, "conn-1"], ]); - const cols = decodeTableInfoRows(after.prepare("PRAGMA table_info('openapi_source')").all()); - expect(cols.some((c) => c.name === "headers")).toBe(false); + const oldSourceTableCount = decodeCountRow( + after + .prepare( + "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source'", + ) + .get(), + ); + expect(oldSourceTableCount.n).toBe(0); }); it("survives empty / missing json on bindings and sources", () => { @@ -501,11 +463,15 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { closeDatabase(drizzleSqlite); const after = openDatabase(dbPath, { readonly: true }); - const qpCount = decodeCountRow( - after - .prepare("SELECT count(*) as n FROM openapi_source_query_param WHERE source_id = ?") - .get("bare"), - ).n; - expect(qpCount).toBe(0); + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("openapi", "source", "bare"), + ).data, + ) as { readonly config: { readonly queryParams?: unknown } }; + expect(source.config.queryParams).toBeUndefined(); }); }); diff --git a/apps/marketing/src/pages/api/detect.ts b/apps/marketing/src/pages/api/detect.ts index f7e66c76a..7bc0b4054 100644 --- a/apps/marketing/src/pages/api/detect.ts +++ b/apps/marketing/src/pages/api/detect.ts @@ -94,6 +94,7 @@ export const POST: APIRoute = async ({ request }) => { } else if (match.kind === "graphql") { yield* executor.graphql.addSource({ endpoint: match.endpoint, + name: match.name, namespace: match.namespace, scope: "test-scope", }); diff --git a/bun.lock b/bun.lock index ead45222a..2780ff876 100644 --- a/bun.lock +++ b/bun.lock @@ -684,6 +684,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", + "@executor-js/plugin-http-source": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", "graphql": "^16.12.0", @@ -716,6 +717,32 @@ "react", ], }, + "packages/plugins/http-source": { + "name": "@executor-js/plugin-http-source", + "version": "1.4.29", + "dependencies": { + "@executor-js/sdk": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/react": "workspace:*", + "@types/node": "catalog:", + "@types/react": "catalog:", + "bun-types": "catalog:", + "react": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:", + }, + "peerDependencies": { + "@executor-js/react": "workspace:*", + "react": "catalog:", + }, + "optionalPeers": [ + "@executor-js/react", + "react", + ], + }, "packages/plugins/keychain": { "name": "@executor-js/plugin-keychain", "version": "1.4.29", @@ -739,6 +766,7 @@ "@cfworker/json-schema": "^4.1.1", "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", + "@executor-js/plugin-http-source": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "effect": "catalog:", @@ -810,6 +838,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", + "@executor-js/plugin-http-source": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", "openapi-types": "^12.1.3", @@ -1374,6 +1403,8 @@ "@executor-js/plugin-graphql": ["@executor-js/plugin-graphql@workspace:packages/plugins/graphql"], + "@executor-js/plugin-http-source": ["@executor-js/plugin-http-source@workspace:packages/plugins/http-source"], + "@executor-js/plugin-keychain": ["@executor-js/plugin-keychain@workspace:packages/plugins/keychain"], "@executor-js/plugin-mcp": ["@executor-js/plugin-mcp@workspace:packages/plugins/mcp"], diff --git a/examples/all-plugins/src/main.ts b/examples/all-plugins/src/main.ts index b61e2f1fc..c8c4d2a1e 100644 --- a/examples/all-plugins/src/main.ts +++ b/examples/all-plugins/src/main.ts @@ -356,6 +356,7 @@ const program = Effect.gen(function* () { const gqlResult = yield* executor.graphql.addSource({ endpoint: "https://example.com/graphql", + name: "Example GraphQL", introspectionJson, namespace: "example-graphql", scope: "example-scope", diff --git a/examples/promise-sdk/src/main.ts b/examples/promise-sdk/src/main.ts index 55cffd6f8..d14463ed2 100644 --- a/examples/promise-sdk/src/main.ts +++ b/examples/promise-sdk/src/main.ts @@ -72,6 +72,7 @@ await executor.openapi.addSpec({ await executor.graphql.addSource({ endpoint: "https://graphql.anilist.co", + name: "AniList", namespace: "anilist", scope: "my-app", }); diff --git a/notes/plugin-source-configuration-overhaul.md b/notes/plugin-source-configuration-overhaul.md new file mode 100644 index 000000000..2c8312270 --- /dev/null +++ b/notes/plugin-source-configuration-overhaul.md @@ -0,0 +1,729 @@ +# Plugin Source Configuration Overhaul + +Date: 2026-05-18 +Status: implemented in progress + +## Goal + +Turn the source credential/configuration work into a full plugin overhaul instead +of an incremental OpenAPI cleanup. + +Implementation goal for this PR: + +```txt +PR #844 should become the complete source-configuration overhaul for first-party +source plugins. OpenAPI, GraphQL, and MCP should all move onto plugin-derived +configure APIs, shared core credential bindings, shared HTTP credential helpers +where applicable, JSON-backed plugin source config, and composed shared React +credential UI. The old plugin-specific binding wrappers, endpoints, stores, +credential child tables, and duplicated UI atoms should be removed rather than +kept as compatibility shims. +``` + +By the end, OpenAPI, GraphQL, and MCP should share the same underlying source +configuration architecture: + +- Core owns generic source identity, scoped credential bindings, source + configure dispatch, and validation. +- Plugins own their source-specific configure schemas and source config + decoding. +- HTTP-ish plugins share HTTP credential config, runtime helpers, and React + components through a shared HTTP package. +- Plugin-private storage remains an overridable plugin facility, backed by one + shared plugin storage table rather than plugin-specific SQL tables. +- Duplicated OpenAPI/GraphQL/MCP binding endpoints, stores, React atoms, and + child tables should be deleted aggressively. + +The desired result is that a future improvement to HTTP credential handling +such as adding a plaintext header path, changing the secret input UI, or +improving OAuth flows benefits OpenAPI, GraphQL, and MCP HTTP rather than being +reimplemented per plugin. + +## Settled Decisions + +- This can be one large PR/overhaul, not a series of small incremental PRs. +- `executor.sources.configure(...)` should be implemented in this overhaul. +- Plugin-native configure APIs should remain: + - `executor.openapi.configure(...)` + - `executor.graphql.configure(...)` + - `executor.mcp.configure(...)` +- Low-level binding APIs should be hidden from normal consumers where possible. + They may remain exported for internal/advanced use, but product and SDK + happy paths should lead with configure. +- Source config may move to JSON. Use Effect Schema at boundaries. +- Migrations are one-shot migrations. Preserve existing source configuration and + credential values. +- Persist source config; derive slot manifests from source config. +- Add a shared HTTP package, likely `@executor-js/plugin-http-source`. +- MCP configure should be transport-discriminated. +- Shared UI components can omit MCP stdio for this pass. +- Plaintext credential values are not needed unless a current flow already has + them. +- OAuth should be designed for the long-term model, not as a short-term header + hack. +- UI should be hand-composed from reusable components, not generated entirely + from manifests. +- GraphQL can use the same request URL/credential config for introspection for + now. +- MCP may require auth at add time if auth is needed to list tools. +- Naming like `request`, `specFetch`, `introspection`, `transport`, `env`, and + `query` is acceptable. + +## Storage Model + +Do not kill plugin storage. + +The overhaul should separate three storage concerns that are currently blurred: + +1. First-class Executor source records. +2. Scoped credential values. +3. Plugin-private state/cache/documents. + +### First-Class Source Records + +Sources are product entities. Core needs to list them, scope them, remove them, +refresh them, attach tools/policies to them, and expose them through generic +source APIs. + +Source records should remain first-class, but plugin-specific source details can +move into plugin-owned JSON config decoded by Effect Schema. + +For example: + +```txt +openapi_source + id + scope_id + name + config_json +``` + +```txt +graphql_source + id + scope_id + name + config_json +``` + +```txt +mcp_source + id + scope_id + name + config_json +``` + +The exact physical table shape can follow the repo's current storage layout, but +the duplicated credential child tables should go away where they only represent +source config and binding placeholders. + +Plugin packages own their config schemas: + +```ts +export const OpenApiStoredSourceConfig = Schema.Struct({ + request: Schema.optional(HttpRequestSourceConfig), + specFetch: Schema.optional(HttpRequestSourceConfig), + // OpenAPI-specific spec/base URL/operation details... +}); +``` + +```ts +export const GraphqlStoredSourceConfig = Schema.Struct({ + request: Schema.optional(HttpRequestSourceConfig), + // GraphQL-specific endpoint/schema details... +}); +``` + +```ts +export const McpStoredSourceConfig = Schema.Union( + Schema.Struct({ + transport: Schema.Literal("http"), + request: Schema.optional(HttpRequestSourceConfig), + // MCP HTTP details... + }), + Schema.Struct({ + transport: Schema.Literal("stdio"), + command: Schema.String, + args: Schema.Array(Schema.String), + env: Schema.Record({ key: Schema.String, value: ProcessEnvSourceConfig }), + }), +); +``` + +### Credential Bindings + +Core `credential_binding` remains the only place scoped credential values live. + +```txt +credential_binding + plugin_id + source_id + source_scope_id + scope_id + slot_key + kind + secret_id + secret_scope_id + connection_id + text_value + created_at + updated_at +``` + +Source config declares slots and their wire-format meaning. Credential bindings +store scoped values for those slots. + +Example OpenAPI source config: + +```ts +{ + request: { + headers: { + Authorization: { + slotKey: "request.headers.authorization", + prefix: "Bearer ", + }, + }, + query: {}, + }, + specFetch: { + headers: {}, + query: {}, + }, +} +``` + +Example value: + +```txt +credential_binding + plugin_id = "openapi" + source_id = "stripe" + source_scope_id = "org_acme" + scope_id = "user_rhys" + slot_key = "request.headers.authorization" + kind = "secret" + secret_id = "stripe_api_key" +``` + +### Plugin-Private Storage + +Plugin storage should remain as an overridable facility. + +The target is one shared table for all plugin-private storage, partitioned by +plugin and collection: + +```txt +plugin_storage + plugin_id + collection + scope_id + key + data_json + created_at + updated_at +``` + +Primary key: + +```txt +(plugin_id, collection, scope_id, key) +``` + +This is for plugin-owned private state/cache/documents: + +- OAuth state caches. +- Remote metadata caches. +- Probe results. +- Background job state. +- Plugin-specific documents that are not first-class Executor sources. + +The plugin storage API should remain overridable when constructing the executor +or installing/creating a plugin. This is separate from source storage and +credential binding storage. + +Conceptually: + +```ts +createExecutor({ + pluginStorage: myPluginStorageProvider, +}); +``` + +or, if plugin-level override is already the local pattern: + +```ts +openApiPlugin({ + storage: myPluginStorageProvider, +}); +``` + +The exact wiring should preserve the repo's existing ability for users to +override plugin storage. The overhaul should consolidate plugin-private storage +tables, not remove plugin storage. + +### What We Are Deleting + +Delete plugin-specific storage that only duplicates source config or +credential-binding behavior. + +Likely delete/replace: + +- OpenAPI source binding wrappers and backing adapters. +- OpenAPI header/query/spec-fetch child tables that only store slot config. +- GraphQL header/query child tables. +- MCP header/query child tables. +- Plugin-specific binding resolvers/listers/validators. +- Plugin-specific HTTP binding endpoints. +- Plugin-specific React binding atoms. + +Do not delete: + +- First-class source records. +- Core `credential_binding`. +- Generic plugin-private storage. +- The ability to provide/override plugin storage. + +## Configure API Model + +Core gets a plugin-derived configure dispatch: + +```ts +await executor.sources.configure(source, { + type: "openapi", + scope: user, + request: { + headers: { + Authorization: SecretId.make("stripe_api_key"), + }, + }, +}); +``` + +Plugin-native APIs remain: + +```ts +await executor.openapi.configure(source, { + scope: user, + request: { + headers: { + Authorization: SecretId.make("stripe_api_key"), + }, + }, +}); +``` + +```ts +await executor.graphql.configure(source, { + scope: user, + request: { + headers: { + Authorization: SecretId.make("github_token"), + }, + }, +}); +``` + +```ts +await executor.mcp.configure(source, { + scope: user, + transport: "http", + request: { + headers: { + Authorization: SecretId.make("mcp_token"), + }, + }, +}); +``` + +MCP stdio is transport-specific and should not be forced through HTTP helpers: + +```ts +await executor.mcp.configure(source, { + scope: user, + transport: "stdio", + env: { + GITHUB_TOKEN: SecretId.make("github_token"), + }, +}); +``` + +Core dispatch: + +```ts +const configure = (source, input) => + Effect.gen(function* () { + const storedSource = yield* Sources.get(source); + const implementation = yield* SourceConfigureRegistry.get(input.type); + + if (storedSource.type !== input.type) { + return yield* new SourceTypeMismatch({ + source, + expected: storedSource.type, + received: input.type, + }); + } + + const parsed = yield* Schema.decodeUnknown(implementation.schema)(input); + + return yield* implementation.configure(source, parsed); + }); +``` + +Plugin registration: + +```ts +openapiPlugin.registerSourceConfigure({ + type: "openapi", + schema: OpenApiConfigureInput, + configure: openApiConfigure, + manifest: deriveOpenApiCredentialManifest, +}); +``` + +The plugin configure implementation compiles plugin input into core binding +operations: + +```ts +const openApiConfigure = (source, input) => + Effect.gen(function* () { + const bindings = yield* compileOpenApiConfigureBindings(source, input); + + yield* Sources.replaceBindings({ + source, + scope: input.scope, + slotPrefixes: ["request.", "specFetch."], + bindings, + }); + }); +``` + +## Shared HTTP Package + +Create a shared package for HTTP source helpers, likely: + +```txt +packages/plugins/http-source +``` + +Package name: + +```txt +@executor-js/plugin-http-source +``` + +This package is not core. It exists because OpenAPI, GraphQL, and MCP HTTP share +HTTP credential concepts while databases, CLIs, and MCP stdio do not. + +It should own: + +- HTTP credential config types. +- Header/query slot key helpers. +- OAuth config types. +- Binding compiler helpers. +- Runtime resolution helpers. +- Helpers that apply resolved credentials to HTTP requests. +- Slot manifest helpers. +- React credential components, either directly or via a `/react` subpath export. + +Possible layout: + +```txt +packages/plugins/http-source + src/ + sdk/ + types.ts + slots.ts + configure.ts + resolve.ts + oauth.ts + react/ + HttpCredentialsProvider.tsx + HttpHeaderCredentials.tsx + HttpQueryCredentials.tsx + OAuthCredentials.tsx + index.ts +``` + +Example shared config: + +```ts +type HttpRequestSourceConfig = { + headers?: Record; + query?: Record; + oauth?: HttpOAuthSourceConfig; +}; +``` + +Example configure input: + +```ts +type HttpRequestConfigureInput = { + headers?: Record; + query?: Record; + oauth?: HttpOAuthConfigureInput; +}; +``` + +OpenAPI can embed the shared shape twice: + +```ts +type OpenApiConfigureInput = { + type: "openapi"; + scope: ScopeId; + request?: HttpRequestConfigureInput; + specFetch?: HttpRequestConfigureInput; +}; +``` + +GraphQL can embed it once for now: + +```ts +type GraphqlConfigureInput = { + type: "graphql"; + scope: ScopeId; + request?: HttpRequestConfigureInput; +}; +``` + +MCP HTTP can embed it behind a transport discriminant: + +```ts +type McpConfigureInput = + | { + type: "mcp"; + transport: "http"; + scope: ScopeId; + request?: HttpRequestConfigureInput; + } + | { + type: "mcp"; + transport: "stdio"; + scope: ScopeId; + env?: Record; + }; +``` + +## OAuth Direction + +OAuth belongs with HTTP helpers, but it should not be modeled as only a header +value. OAuth configuration needs to support long-term flow requirements: + +- Authorization URL. +- Token URL. +- Client ID. +- Client secret. +- Scopes. +- PKCE/auth-code state. +- Refresh behavior. +- Resulting token placement. + +## Implementation Notes + +This branch implements the core shape described above: + +- `executor.sources.configure(...)` dispatches through the owning plugin's + registered `sourceConfigure` implementation. +- `executor.openapi.configure(...)`, `executor.graphql.configure(...)`, and + `executor.mcp.configure(...)` remain plugin-native entry points. +- OpenAPI, GraphQL, and MCP source/operation/plugin rows now use the shared + `plugin_storage` table instead of plugin-specific SQL source/operation tables. +- Core `credential_binding` remains the shared source credential value store. +- GraphQL and MCP no longer expose plugin-specific source binding HTTP + endpoints or SDK wrapper methods; React callers use core source credential + binding atoms. +- Local and cloud one-shot migrations copy old plugin source rows into + `plugin_storage` and drop the old plugin-specific source/config tables. +- The shared HTTP source package exists as `@executor-js/plugin-http-source`. + +The React layer still intentionally composes the existing shared credential +components instead of replacing whole source forms with generated UIs. That +keeps MCP stdio out of HTTP-specific components while making header/query/OAuth +credential changes land in shared components used by OpenAPI, GraphQL, and MCP +HTTP. + +- User-scoped connections. + +The resulting access token may be placed in a header or query parameter, but the +OAuth lifecycle is richer than raw header configuration. + +Avoid double nesting: + +```ts +oauth2: { + oauth2: { + // ... + }, +} +``` + +Prefer one OAuth object at the HTTP credential boundary: + +```ts +request: { + oauth: { + clientId: SecretId.make("client_id"), + clientSecret: SecretId.make("client_secret"), + authorizationUrl: "https://example.com/oauth/authorize", + tokenUrl: "https://example.com/oauth/token", + scopes: ["read", "write"], + placement: { + header: "Authorization", + scheme: "Bearer", + }, + }, +} +``` + +## Slot Manifest + +Persist source config, derive manifests from it. + +The manifest is for UI/status/validation. It should not be the source of truth +if it can be derived from plugin-owned config. + +Example derived manifest entry: + +```ts +{ + slotKey: "request.headers.authorization", + label: "Authorization", + family: "http.header", + required: true, + valueKind: "secret", + placement: { + header: "Authorization", + prefix: "Bearer ", + }, +} +``` + +Core may expose generic manifest/query APIs, but it should not interpret +`family: "http.header"` beyond using it as metadata. The HTTP package and UI +components interpret HTTP metadata. + +## React/UI Direction + +Use composition, not a mega-form with many boolean props. + +Avoid: + +```tsx + +``` + +Prefer plugin forms composed from shared sections: + +```tsx + + + + + +``` + +```tsx + + + +``` + +```tsx + + + +``` + +MCP stdio can be omitted from shared HTTP components for now. It should later +compose process/env components instead of forcing itself through the HTTP +credential UI. + +Shared UI pieces likely include: + +- `CredentialValueInput` +- `SecretPicker` +- `ConnectionPicker` +- `HttpHeaderCredentials` +- `HttpQueryCredentials` +- `OAuthCredentials` +- `CredentialStatusList` +- `SourceConfigureProvider` + +The generic UI mutation should call: + +```ts +configureSource(source, input); +``` + +Plugin forms produce typed configure payloads. + +## MCP Notes + +MCP stdio causes code-sharing problems if MCP is treated as one credential +family. The sharing boundary should be transport-level: + +- OpenAPI, GraphQL, and MCP HTTP share HTTP credential helpers. +- MCP stdio and future CLI sources should share process/env helpers later. +- Databases should have their own connection helper family later. + +It is acceptable for MCP add/import to require auth if the server needs auth to +list tools. Longer term, MCP may need scoped/auth-derived tool metadata because +some servers expose different tools/descriptions based on auth state. + +This overhaul does not need to solve scoped MCP metadata fully, but it should +avoid baking in an assumption that one global tool manifest is always correct. + +## Migration Plan + +This is a one-shot migration that preserves existing config and values. + +Migration responsibilities: + +1. Move concrete credential values into `credential_binding` if any remain in + plugin-specific tables/JSON. +2. Move source declaration/config into plugin-owned JSON config. +3. Preserve source IDs, names, scopes, base URLs, specs, endpoints, transports, + and tool relationships. +4. Preserve OAuth connections and client credentials. +5. Drop or ignore old plugin-specific child tables after data is migrated. + +Tests should cover: + +- OpenAPI header/query/spec-fetch migration. +- OpenAPI OAuth/client credential migration. +- GraphQL header/query/auth migration. +- MCP HTTP header/query/auth migration. +- MCP stdio source survival. +- Collision detection where legacy slot canonicalization would collapse names. +- Existing source listing and tool invocation after migration. +- Secret/connection usage isolation after migration. + +## Implementation Order + +Even in one large PR, sequence the work internally: + +1. Document the architecture and update the existing notes. +2. Add/finish core source binding and configure registry. +3. Add generic plugin storage table/provider if not already present. +4. Create `@executor-js/plugin-http-source`. +5. Port OpenAPI to configure + HTTP helpers + JSON source config. +6. Port GraphQL to configure + HTTP helpers + JSON source config. +7. Port MCP with transport-discriminated configure. +8. Replace React credential flows with composed shared components. +9. Add one-shot migrations and migration tests. +10. Delete old plugin-specific binding APIs, stores, tables, atoms, and helpers. +11. Run full verification and fix fallout. + +## Success Criteria + +- Normal SDK users configure credentials through plugin-native configure APIs. +- Generic UI can call `executor.sources.configure(...)`. +- Core does not expose headers/query/OAuth as universal source concepts. +- OpenAPI/GraphQL/MCP do not each implement their own binding resolver/lister. +- HTTP credential UI changes apply to OpenAPI, GraphQL, and MCP HTTP. +- Plugin storage remains overridable and is backed by one shared plugin storage + table for plugin-private data. +- Source config remains first-class enough for source listing, tools, policies, + refresh, and scopes. +- Old plugin-specific credential child tables and endpoints are gone. diff --git a/packages/core/api/src/handlers/sources.ts b/packages/core/api/src/handlers/sources.ts index 6c322ef69..a72b2eb9c 100644 --- a/packages/core/api/src/handlers/sources.ts +++ b/packages/core/api/src/handlers/sources.ts @@ -87,6 +87,19 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han }), ), ) + .handle("configure", ({ params: path, payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.sources.configure({ + source: payload.source, + scope: payload.scope ?? path.scopeId, + type: payload.type, + config: payload.config, + }); + }), + ), + ) .handle("listBindings", ({ params: path }) => capture( Effect.gen(function* () { diff --git a/packages/core/api/src/sources/api.ts b/packages/core/api/src/sources/api.ts index 82eeba131..e61889279 100644 --- a/packages/core/api/src/sources/api.ts +++ b/packages/core/api/src/sources/api.ts @@ -64,6 +64,16 @@ const DetectRequest = Schema.Struct({ url: Schema.String.check(Schema.isMaxLength(2_048)), }); +const ConfigureSourceRequest = Schema.Struct({ + source: Schema.Struct({ + id: Schema.String, + scope: ScopeId, + }), + scope: ScopeId, + type: Schema.optional(Schema.String), + config: Schema.Unknown, +}); + const DetectResultResponse = Schema.Struct({ kind: Schema.String, confidence: Schema.Literals(["high", "medium", "low"]), @@ -119,6 +129,14 @@ export const SourcesApi = HttpApiGroup.make("sources") error: InternalError, }), ) + .add( + HttpApiEndpoint.post("configure", "/scopes/:scopeId/sources/configure", { + params: ScopeParams, + payload: ConfigureSourceRequest, + success: Schema.Unknown, + error: InternalError, + }), + ) .add( HttpApiEndpoint.get( "listBindings", diff --git a/packages/core/config/src/schema.ts b/packages/core/config/src/schema.ts index 0343d8f46..15f06a839 100644 --- a/packages/core/config/src/schema.ts +++ b/packages/core/config/src/schema.ts @@ -71,8 +71,8 @@ export const McpRemoteSourceConfig = Schema.Struct({ endpoint: Schema.String, remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])), namespace: Schema.optional(Schema.String), - queryParams: Schema.optional(StringMap), - headers: Schema.optional(StringMap), + queryParams: Schema.optional(ConfigHeaders), + headers: Schema.optional(ConfigHeaders), auth: Schema.optional(McpAuthConfig), }); export type McpRemoteSourceConfig = typeof McpRemoteSourceConfig.Type; diff --git a/packages/core/execution/src/promise.ts b/packages/core/execution/src/promise.ts index 49c100ffc..6333746b8 100644 --- a/packages/core/execution/src/promise.ts +++ b/packages/core/execution/src/promise.ts @@ -92,6 +92,7 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => ({ refresh: (input) => fromPromise(() => pe.sources.refresh(input)), detect: (url) => fromPromise(() => pe.sources.detect(url)), definitions: (id) => fromPromise(() => pe.sources.definitions(id)), + configure: (input) => fromPromise(() => pe.sources.configure(input)), listBindings: (input) => fromPromise(() => pe.sources.listBindings(input)), resolveBinding: (input) => fromPromise(() => pe.sources.resolveBinding(input)), setBinding: (input) => fromPromise(() => pe.sources.setBinding(input)), diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 588cc3eac..491cb190e 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -194,6 +194,14 @@ export const coreTables = defineTables({ created_at: dateColumn("created_at"), }), credential_binding: credentialBindingTable, + plugin_storage: scopedExecutorTable("plugin_storage", { + plugin_id: textColumn("plugin_id"), + collection: textColumn("collection"), + key: textColumn("key"), + data: jsonColumn("data"), + created_at: dateColumn("created_at"), + updated_at: dateColumn("updated_at"), + }), tool_policy: scopedExecutorTable("tool_policy", { pattern: textColumn("pattern"), action: textColumn("action"), @@ -216,6 +224,7 @@ export type ToolRow = FumaRow; export type DefinitionRow = FumaRow; export type SecretRow = FumaRow; export type ConnectionRow = FumaRow; +export type PluginStorageRow = FumaRow; type CredentialBindingRowBase = Omit< FumaRow, diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index 76dbefddf..f36055a37 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Data, Effect, Predicate } from "effect"; +import { Data, Effect, Predicate, Schema } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { scopedExecutorTable, textColumn } from "./core-schema"; @@ -189,6 +189,50 @@ const caseSensitiveDynamicPlugin = definePlugin(() => ({ invokeTool: ({ toolRow }) => Effect.succeed({ invokedToolId: toolRow.id }), }))(); +const configurableSourcePlugin = definePlugin(() => ({ + id: "configurable" as const, + storage: ({ pluginStorage }) => ({ + get: (scope: string, sourceId = "configured-source") => + pluginStorage.getAtScope<{ readonly header: string; readonly sourceScope: string }>({ + scope, + collection: "source-config", + key: sourceId, + }), + visible: (sourceId = "configured-source") => + pluginStorage.get<{ readonly header: string; readonly sourceScope: string }>({ + collection: "source-config", + key: sourceId, + }), + }), + extension: (ctx) => ({ + registerSource: (scope: string) => + ctx.core.sources.register({ + id: "configured-source", + scope, + kind: "configurable", + name: "Configurable Source", + canRemove: true, + tools: [{ name: "run", description: "run configurable source" }], + }), + getConfigAtScope: (scope: string) => ctx.storage.get(scope), + getVisibleConfig: () => ctx.storage.visible(), + }), + sourceConfigure: { + type: "configurable", + schema: Schema.Struct({ header: Schema.String }), + configure: ({ ctx, sourceId, sourceScope, targetScope, config }) => + ctx.pluginStorage.put({ + scope: targetScope, + collection: "source-config", + key: sourceId, + data: { + ...(config as { readonly header: string }), + sourceScope, + }, + }), + }, +}))(); + describe("createExecutor", () => { it.effect("rolls back plugin and core writes from ctx.transaction failures", () => Effect.gen(function* () { @@ -389,4 +433,44 @@ describe("createExecutor", () => { expect(error.suggestions).toEqual(["case_source.listdashboards"]); }), ); + + it.effect("dispatches source.configure through the owning plugin with explicit scopes", () => + Effect.gen(function* () { + const orgScope = Scope.make({ + id: ScopeId.make("org"), + name: "Org", + createdAt: new Date(), + }); + const userScope = Scope.make({ + id: ScopeId.make("user"), + name: "User", + createdAt: new Date(), + }); + const executor = yield* createExecutor({ + scopes: [userScope, orgScope], + plugins: [configurableSourcePlugin] as const, + onElicitation: "accept-all", + }); + + yield* executor.configurable.registerSource("org"); + yield* executor.sources.configure({ + source: { id: "configured-source", scope: "org" }, + scope: "org", + type: "configurable", + config: { header: "org-token" }, + }); + yield* executor.sources.configure({ + source: { id: "configured-source", scope: "org" }, + scope: "user", + type: "configurable", + config: { header: "user-token" }, + }); + + const orgConfig = yield* executor.configurable.getConfigAtScope("org"); + const visibleConfig = yield* executor.configurable.getVisibleConfig(); + + expect(orgConfig?.data).toEqual({ header: "org-token", sourceScope: "org" }); + expect(visibleConfig?.data).toEqual({ header: "user-token", sourceScope: "org" }); + }), + ); }); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index d0f9e12d2..9c12f7814 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -115,6 +115,11 @@ import type { StaticToolSchema, StorageDeps, } from "./plugin"; +import { + pluginStorageId, + type PluginStorageEntry, + type PluginStorageFacade, +} from "./plugin-storage"; import type { Scope } from "./scope"; import { RemoveSecretInput, SecretRef, SetSecretInput, type SecretProvider } from "./secrets"; import { Usage } from "./usages"; @@ -227,6 +232,15 @@ export type Executor = { readonly definitions: ( sourceId: string, ) => Effect.Effect, StorageFailure>; + readonly configure: (input: { + readonly source: { + readonly id: string; + readonly scope: ScopeId | string; + }; + readonly scope: ScopeId | string; + readonly type?: string; + readonly config: unknown; + }) => Effect.Effect; readonly listBindings: ( input: SourceCredentialBindingSourceInput, ) => Effect.Effect; @@ -548,6 +562,21 @@ const toToolJsonSchema = ( }); }; +const decodeConfigureInput = ( + schema: StaticToolSchema | Schema.Decoder | undefined, + input: unknown, +): Effect.Effect => { + if (schema == null) return Effect.succeed(input); + if (!("~standard" in schema)) { + return Schema.decodeUnknownEffect(schema)(input); + } + return Effect.promise(() => Promise.resolve(schema["~standard"].validate(input))).pipe( + Effect.flatMap((result) => + "value" in result ? Effect.succeed(result.value) : Effect.fail(result), + ), + ); +}; + const EXECUTOR_SOURCE_ID = "executor"; const EXECUTOR_SOURCE: StaticSourceDecl = { id: EXECUTOR_SOURCE_ID, @@ -723,6 +752,128 @@ const makeCoreDb = (fuma: ReturnType) => ({ ), }); +const pluginStorageEntryFromRow = (row: CoreRow<"plugin_storage">): PluginStorageEntry => ({ + id: row.id, + scopeId: ScopeId.make(row.scope_id), + pluginId: row.plugin_id, + collection: row.collection, + key: row.key, + data: row.data as T, + createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at), + updatedAt: row.updated_at instanceof Date ? row.updated_at : new Date(row.updated_at), +}); + +const makePluginStorageFacade = (input: { + readonly core: ReturnType; + readonly pluginId: string; + readonly scopeIds: readonly string[]; +}): PluginStorageFacade => { + const whereFor = (collection: string, key?: string) => + scopedWhere(input.scopeIds, (b) => + b.and( + b("plugin_id", "=", input.pluginId), + b("collection", "=", collection), + key === undefined ? true : b("key", "=", key), + ), + ); + + const sortByScopePrecedence = (rows: readonly CoreRow<"plugin_storage">[]) => + [...rows].sort((left, right) => { + const leftIndex = input.scopeIds.indexOf(left.scope_id); + const rightIndex = input.scopeIds.indexOf(right.scope_id); + return leftIndex - rightIndex || left.key.localeCompare(right.key); + }); + + const getVisible = (collection: string, key: string) => + input.core + .findMany("plugin_storage", { where: whereFor(collection, key) }) + .pipe(Effect.map((rows) => sortByScopePrecedence(rows)[0] ?? null)) + .pipe(Effect.map((row) => (row ? pluginStorageEntryFromRow(row) : null))); + + return { + get: (storageInput) => getVisible(storageInput.collection, storageInput.key), + getAtScope: (storageInput) => + input.core + .findFirst("plugin_storage", { + where: byScopedId( + storageInput.scope, + pluginStorageId({ + pluginId: input.pluginId, + collection: storageInput.collection, + key: storageInput.key, + }), + ), + }) + .pipe(Effect.map((row) => (row ? pluginStorageEntryFromRow(row) : null))), + list: (storageInput) => + input.core.findMany("plugin_storage", { where: whereFor(storageInput.collection) }).pipe( + Effect.map((rows) => + sortByScopePrecedence(rows) + .filter((row) => + storageInput.keyPrefix === undefined + ? true + : row.key.startsWith(storageInput.keyPrefix), + ) + .map((row) => pluginStorageEntryFromRow(row)), + ), + ), + put: (storageInput) => + Effect.gen(function* () { + if (!input.scopeIds.includes(storageInput.scope)) { + return yield* new StorageError({ + message: `Unknown plugin storage target scope: ${storageInput.scope}`, + cause: undefined, + }); + } + const id = pluginStorageId({ + pluginId: input.pluginId, + collection: storageInput.collection, + key: storageInput.key, + }); + const existing = yield* input.core.findFirst("plugin_storage", { + where: byScopedId(storageInput.scope, id), + }); + const now = new Date(); + if (existing) { + yield* input.core.updateMany("plugin_storage", { + where: byScopedId(storageInput.scope, id), + set: { + data: storageInput.data, + updated_at: now, + }, + }); + return pluginStorageEntryFromRow({ + ...existing, + data: storageInput.data, + updated_at: now, + }); + } + const row = yield* input.core.create("plugin_storage", { + id, + scope_id: storageInput.scope, + plugin_id: input.pluginId, + collection: storageInput.collection, + key: storageInput.key, + data: storageInput.data, + created_at: now, + updated_at: now, + }); + return pluginStorageEntryFromRow(row); + }), + remove: (storageInput) => + input.core.deleteMany("plugin_storage", { + where: byScopedId( + storageInput.scope, + pluginStorageId({ + pluginId: input.pluginId, + collection: storageInput.collection, + key: storageInput.key, + }), + ), + }), + }; +}; + // --------------------------------------------------------------------------- // Dynamic-row writers — used by ctx.core.sources.register. Static sources // never touch these functions. @@ -2725,6 +2876,72 @@ export const createExecutor = + Effect.gen(function* () { + yield* assertScopeInStack("source configure source scope", input.source.scope); + yield* assertScopeInStack("source configure target scope", input.scope); + + const source = yield* core.findFirst("source", { + where: byScopedId(input.source.scope, input.source.id), + }); + if (!source) { + return yield* new StorageError({ + message: + `Cannot configure source "${input.source.id}" at scope ` + + `"${input.source.scope}": source is not visible.`, + cause: undefined, + }); + } + + const runtime = runtimes.get(source.plugin_id); + const configure = runtime?.plugin.sourceConfigure; + if (!runtime || !configure) { + return yield* new StorageError({ + message: `Plugin "${source.plugin_id}" does not support source.configure.`, + cause: undefined, + }); + } + if (input.type !== undefined && input.type !== configure.type) { + return yield* new StorageError({ + message: + `Source configure type mismatch for plugin "${source.plugin_id}": ` + + `expected "${configure.type}", received "${input.type}".`, + cause: undefined, + }); + } + + const decoded = yield* decodeConfigureInput(configure.schema, input.config).pipe( + Effect.mapError((cause) => + storageFailureFromUnknown( + `Invalid source.configure payload for ${configure.type}`, + cause, + ), + ), + ); + + return yield* configure + .configure({ + ctx: runtime.ctx, + sourceId: input.source.id, + sourceScope: input.source.scope, + targetScope: input.scope, + config: decoded, + }) + .pipe( + Effect.mapError((cause) => + pluginStorageFailure(source.plugin_id, "sourceConfigure", cause), + ), + ); + }); + const oauthBundle = makeOAuth2Service({ fuma, secretsGet: (id) => @@ -2759,6 +2976,11 @@ export const createExecutor = = { scopes, storage, + pluginStorage, httpClientLayer: config.httpClientLayer ?? FetchHttpClient.layer, core: { sources: { @@ -3840,6 +4064,7 @@ export const createExecutor = { + readonly id: string; + readonly scopeId: ScopeId | string; + readonly pluginId: string; + readonly collection: string; + readonly key: string; + readonly data: T; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface PluginStorageFacade { + readonly get: ( + input: PluginStorageKeyInput, + ) => Effect.Effect | null, StorageFailure>; + readonly getAtScope: ( + input: PluginStorageScopedKeyInput, + ) => Effect.Effect | null, StorageFailure>; + readonly list: ( + input: PluginStorageListInput, + ) => Effect.Effect[], StorageFailure>; + readonly put: ( + input: PluginStoragePutInput, + ) => Effect.Effect, StorageFailure>; + readonly remove: (input: PluginStorageScopedKeyInput) => Effect.Effect; +} + +export const pluginStorageId = (input: { + readonly pluginId: string; + readonly collection: string; + readonly key: string; +}): string => JSON.stringify([input.pluginId, input.collection, input.key]); diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 5a241c751..f333c7192 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, type Schema as EffectSchema } from "effect"; import type { Context, Layer } from "effect"; import type { HttpClient } from "effect/unstable/http"; import type { HttpApiGroup } from "effect/unstable/httpapi"; @@ -33,6 +33,7 @@ import type { SecretOwnedByConnectionError, } from "./errors"; import type { OAuthService } from "./oauth"; +import type { PluginStorageFacade } from "./plugin-storage"; import type { Scope } from "./scope"; import type { RemoveSecretInput, SecretProvider, SecretRef, SetSecretInput } from "./secrets"; import type { Usage, UsagesForConnectionInput, UsagesForSecretInput } from "./usages"; @@ -55,6 +56,7 @@ export interface StorageDeps>; readonly blobs: PluginBlobStore; + readonly pluginStorage: PluginStorageFacade; } // --------------------------------------------------------------------------- @@ -84,6 +86,7 @@ export interface PluginCtx { */ readonly scopes: readonly Scope[]; readonly storage: TStore; + readonly pluginStorage: PluginStorageFacade; readonly httpClientLayer: Layer.Layer; readonly core: { @@ -352,6 +355,22 @@ export interface SourceLifecycleInput { readonly scope: string; } +export interface ConfigureSourceHandlerInput { + readonly ctx: PluginCtx; + readonly sourceId: string; + readonly sourceScope: string; + readonly targetScope: string; + readonly config: unknown; +} + +export interface SourceConfigureDecl { + readonly type: string; + readonly schema?: StaticToolSchema | EffectSchema.Decoder; + readonly configure: ( + input: ConfigureSourceHandlerInput, + ) => Effect.Effect; +} + // --------------------------------------------------------------------------- // PluginSpec — what a `definePlugin(factory)` call returns. // --------------------------------------------------------------------------- @@ -534,6 +553,14 @@ export interface PluginSpec< readonly refreshSource?: (input: SourceLifecycleInput) => Effect.Effect; + /** Core-dispatched source configuration. The executor resolves the + * source row, finds the owning plugin, and calls this handler with + * an explicit source scope plus explicit target scope for credential + * values. Plugin-native `openapi.configure` style methods can call + * the same implementation, but callers do not need to know about + * low-level credential bindings. */ + readonly sourceConfigure?: SourceConfigureDecl; + /** URL autodetection hook. When the user pastes a URL in the * onboarding UI, `executor.sources.detect(url)` fans out to every * plugin's `detect`. Return a `SourceDetectionResult` if you diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 8df0b0506..19d714a5e 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -55,8 +55,19 @@ export { SourceCredentialBindingSourceInput, SourceCredentialBindingSlotInput, credentialSlotKey, + credentialSlotPart, } from "./credential-bindings"; +export { + pluginStorageId, + type PluginStorageEntry, + type PluginStorageFacade, + type PluginStorageKeyInput, + type PluginStorageListInput, + type PluginStoragePutInput, + type PluginStorageScopedKeyInput, +} from "./plugin-storage"; + export { SourceDetectionResult, type Source } from "./types"; export { Usage } from "./usages"; diff --git a/packages/plugins/graphql/package.json b/packages/plugins/graphql/package.json index bb0b2a479..89388786f 100644 --- a/packages/plugins/graphql/package.json +++ b/packages/plugins/graphql/package.json @@ -63,6 +63,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", + "@executor-js/plugin-http-source": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", "graphql": "^16.12.0", diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index f190e6e3e..51f564191 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -4,13 +4,11 @@ import { InternalError, ScopeId } from "@executor-js/sdk/shared"; import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors"; import { + GraphqlConfiguredValueInput, ConfiguredGraphqlCredentialValue, - GraphqlCredentialInput, GraphqlSourceAuth, - GraphqlSourceAuthInput, - GraphqlSourceBindingInput, - GraphqlSourceBindingRef, } from "../sdk/types"; +import { OAuth2SourceConfig } from "@executor-js/plugin-http-source/sdk"; // StoredGraphqlSource shape as an HTTP response schema. Kept local to the // api layer because the sdk-side `StoredGraphqlSource` is a plain interface. @@ -37,47 +35,18 @@ const SourceParams = { namespace: Schema.String, }; -const SourceBindingParams = { - scopeId: ScopeId, - namespace: Schema.String, - sourceScopeId: ScopeId, -}; - // --------------------------------------------------------------------------- // Payloads // --------------------------------------------------------------------------- const AddSourcePayload = Schema.Struct({ - targetScope: ScopeId, endpoint: Schema.String, - name: Schema.optional(Schema.String), + name: Schema.String, introspectionJson: Schema.optional(Schema.String), - namespace: Schema.optional(Schema.String), - headers: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInput)), - queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInput)), - credentialTargetScope: Schema.optional(ScopeId), - auth: Schema.optional(GraphqlSourceAuthInput), -}); - -const UpdateSourcePayload = Schema.Struct({ - sourceScope: ScopeId, - name: Schema.optional(Schema.String), - endpoint: Schema.optional(Schema.String), - headers: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInput)), - queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInput)), - credentialTargetScope: Schema.optional(ScopeId), - auth: Schema.optional(GraphqlSourceAuthInput), -}); - -const UpdateSourceResponse = Schema.Struct({ - updated: Schema.Boolean, -}); - -const RemoveBindingPayload = Schema.Struct({ - sourceId: Schema.String, - sourceScope: ScopeId, - slot: Schema.String, - scope: ScopeId, + namespace: Schema.String, + headers: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInput)), + queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInput)), + oauth2: Schema.optional(OAuth2SourceConfig), }); // --------------------------------------------------------------------------- @@ -128,41 +97,6 @@ export const GraphqlGroup = HttpApiGroup.make("graphql") success: Schema.NullOr(StoredSourceSchema), error: GraphqlErrors, }), - ) - .add( - HttpApiEndpoint.patch("updateSource", "/scopes/:scopeId/graphql/sources/:namespace", { - params: SourceParams, - payload: UpdateSourcePayload, - 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..8fbf43c8d 100644 --- a/packages/plugins/graphql/src/api/handlers.ts +++ b/packages/plugins/graphql/src/api/handlers.ts @@ -3,8 +3,7 @@ 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 type { GraphqlPluginExtension } from "../sdk/plugin"; import { GraphqlGroup } from "./group"; // --------------------------------------------------------------------------- @@ -41,20 +40,19 @@ const ExecutorApiWithGraphql = addGroup(GraphqlGroup); export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "graphql", (handlers) => handlers - .handle("addSource", ({ payload }) => + .handle("addSource", ({ params: path, payload }) => capture( Effect.gen(function* () { const ext = yield* GraphqlExtensionService; const result = yield* ext.addSource({ endpoint: payload.endpoint, - scope: payload.targetScope, + scope: path.scopeId, name: payload.name, introspectionJson: payload.introspectionJson, namespace: payload.namespace, headers: payload.headers, queryParams: payload.queryParams, - credentialTargetScope: payload.credentialTargetScope, - auth: payload.auth, + oauth2: payload.oauth2, }); return { toolCount: result.toolCount, @@ -71,51 +69,5 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra return source ? { ...source, scope: ScopeId.make(source.scope) } : null; }), ), - ) - .handle("updateSource", ({ params: path, payload }) => - capture( - Effect.gen(function* () { - const ext = yield* GraphqlExtensionService; - yield* ext.updateSource(path.namespace, payload.sourceScope, { - name: payload.name, - endpoint: payload.endpoint, - headers: payload.headers, - queryParams: payload.queryParams, - credentialTargetScope: payload.credentialTargetScope, - auth: payload.auth, - } as GraphqlUpdateSourceInput); - 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/promise.ts b/packages/plugins/graphql/src/promise.ts index 6f4c6bebc..4685c9488 100644 --- a/packages/plugins/graphql/src/promise.ts +++ b/packages/plugins/graphql/src/promise.ts @@ -3,6 +3,6 @@ export type { GraphqlPluginOptions, GraphqlPluginExtension, GraphqlSourceConfig, - GraphqlUpdateSourceInput, + GraphqlConfigureSourceInput, HeaderValue, } from "./sdk/plugin"; diff --git a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx index 8d2ea5e97..185fa4bd2 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx @@ -6,13 +6,15 @@ import * as Schema from "effect/Schema"; import { useScope } from "@executor-js/react/api/scope-context"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; +import { configureSource } from "@executor-js/react/api/atoms"; import { HttpCredentialsEditor, httpCredentialsValid, - serializeScopedHttpCredentials, + serializeConfigureHttpCredentials, serializeHttpCredentials, + serializeTemplateHttpCredentials, type HttpCredentialsState, -} from "@executor-js/react/plugins/http-credentials"; +} from "@executor-js/plugin-http-source/react"; import { sourceDisplayNameFromUrl, slugifyNamespace, @@ -37,7 +39,7 @@ import { Spinner } from "@executor-js/react/components/spinner"; import { addGraphqlSourceOptimistic } from "./atoms"; import { initialGraphqlCredentials } from "./defaults"; import { GraphqlSourceFields } from "./GraphqlSourceFields"; -import type { GraphqlCredentialInput } from "../sdk/types"; +import type { GraphqlConfiguredValueInput, GraphqlCredentialInput } from "../sdk/types"; const ErrorMessage = Schema.Struct({ message: Schema.String }); const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); @@ -75,6 +77,7 @@ export default function AddGraphqlSource(props: { const doAdd = useAtomSet(addGraphqlSourceOptimistic(scopeId), { mode: "promiseExit", }); + const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); const secretList = useSecretPickerSecrets(); const oauth = useOAuthPopupFlow({ popupName: "graphql-oauth", @@ -129,35 +132,24 @@ export default function AddGraphqlSource(props: { const handleAdd = async () => { setAdding(true); setAddError(null); - const { headers: headerMap, queryParams } = serializeScopedHttpCredentials( - credentials, - requestCredentialTargetScope, - ); + const { headers: templateHeaders, queryParams: templateQueryParams } = + serializeTemplateHttpCredentials(credentials); + const { headers: configureHeaders, queryParams: configureQueryParams } = + serializeConfigureHttpCredentials(credentials, requestCredentialTargetScope); const { trimmedEndpoint, namespace, displayName } = sourceIdentity(); const exit = await doAdd({ params: { scopeId }, payload: { - targetScope: scopeId, endpoint: trimmedEndpoint, name: displayName, namespace, - ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}), - ...(Object.keys(queryParams).length > 0 - ? { - queryParams: queryParams as Record, - } + ...(Object.keys(templateHeaders).length > 0 + ? { headers: templateHeaders as Record } : {}), - credentialTargetScope: - authMode === "oauth2" && tokens - ? oauthCredentialTargetScope - : requestCredentialTargetScope, - ...(authMode === "oauth2" && tokens + ...(Object.keys(templateQueryParams).length > 0 ? { - auth: { - kind: "oauth2" as const, - connectionId: tokens.connectionId, - }, + queryParams: templateQueryParams as Record, } : {}), }, @@ -168,6 +160,46 @@ export default function AddGraphqlSource(props: { setAdding(false); return; } + if ( + Object.keys(configureHeaders).length > 0 || + Object.keys(configureQueryParams).length > 0 || + (authMode === "oauth2" && tokens) + ) { + const configureExit = await doConfigure({ + params: { scopeId }, + payload: { + source: { id: exit.value.namespace, scope: scopeId }, + scope: requestCredentialTargetScope, + type: "graphql", + config: { + ...(Object.keys(configureHeaders).length > 0 + ? { headers: configureHeaders as Record } + : {}), + ...(Object.keys(configureQueryParams).length > 0 + ? { queryParams: configureQueryParams as Record } + : {}), + ...(authMode === "oauth2" && tokens + ? { + auth: { + oauth2: { + connection: { + kind: "connection" as const, + connectionId: tokens.connectionId, + }, + }, + }, + } + : {}), + }, + }, + reactivityKeys: sourceWriteKeys, + }); + if (Exit.isFailure(configureExit)) { + setAddError(errorMessageFromExit(configureExit, "Failed to configure source")); + setAdding(false); + return; + } + } props.onComplete(); }; diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index 89b496f9c..b494bd739 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -2,22 +2,21 @@ 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, graphqlSourceBindingsAtom } from "./atoms"; import { - graphqlSourceAtom, - graphqlSourceBindingsAtom, - setGraphqlSourceBinding, - updateGraphqlSource, -} from "./atoms"; -import { connectionsAtom } from "@executor-js/react/api/atoms"; + configureSource, + connectionsAtom, + setSourceCredentialBinding, +} 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"; import { HttpCredentialsEditor, + serializeConfigureHttpCredentials, serializeHttpCredentials, - serializeScopedHttpCredentials, type HttpCredentialsState, -} from "@executor-js/react/plugins/http-credentials"; +} from "@executor-js/plugin-http-source/react"; import { effectiveCredentialBindingForScope, httpCredentialsFromConfiguredCredentialBindings, @@ -32,9 +31,8 @@ import { Badge } from "@executor-js/react/components/badge"; import { ScopeId } from "@executor-js/sdk/shared"; import { GraphqlSourceFields } from "./GraphqlSourceFields"; import { - GRAPHQL_OAUTH_CONNECTION_SLOT, type GraphqlCredentialInput, - GraphqlSourceBindingInput, + type GraphqlSourceAuthInput, type GraphqlSourceBindingRef, } from "../sdk/types"; import type { StoredGraphqlSource } from "../sdk/store"; @@ -66,8 +64,8 @@ function EditForm(props: { sourceScope, initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), }); - const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promiseExit" }); - const setBinding = useAtomSet(setGraphqlSourceBinding, { mode: "promise" }); + const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); + const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const secretList = useSecretPickerSecrets(); const connectionsResult = useAtomValue(connectionsAtom(displayScope)); @@ -118,44 +116,35 @@ function EditForm(props: { const handleSave = async () => { setSaving(true); setError(null); - const { headers, queryParams } = serializeScopedHttpCredentials( + const { headers, queryParams } = serializeConfigureHttpCredentials( credentials, credentialTargetScope, ); - const payload: { - sourceScope: ScopeId; + const config: { name?: string; endpoint?: string; headers?: Record; queryParams?: Record; - credentialTargetScope?: ScopeId; - auth?: { kind: "none" } | { kind: "oauth2"; connectionSlot: string }; + auth?: GraphqlSourceAuthInput; } = { - sourceScope, name: metadataDirty ? identity.name.trim() || undefined : undefined, endpoint: metadataDirty ? endpoint.trim() || undefined : undefined, }; if (credentialsDirty) { - payload.headers = headers; - payload.queryParams = queryParams as Record; - payload.credentialTargetScope = credentialTargetScope; + config.headers = headers; + config.queryParams = queryParams as Record; } if (authDirty) { - payload.auth = - authMode === "oauth2" - ? { - kind: "oauth2", - connectionSlot: - props.initial.auth.kind === "oauth2" - ? props.initial.auth.connectionSlot - : GRAPHQL_OAUTH_CONNECTION_SLOT, - } - : { kind: "none" }; - payload.credentialTargetScope = credentialTargetScope; + config.auth = authMode === "oauth2" ? { oauth2: {} } : { kind: "none" }; } - const exit = await doUpdate({ - params: { scopeId: displayScope, namespace: props.sourceId }, - payload, + const exit = await doConfigure({ + params: { scopeId: displayScope }, + payload: { + source: { id: props.sourceId, scope: sourceScope }, + scope: credentialTargetScope, + type: "graphql", + config, + }, reactivityKeys: sourceWriteKeys, }); @@ -247,13 +236,12 @@ function EditForm(props: { onConnected={async (connectionId) => { await setBinding({ params: { scopeId: oauthCredentialTargetScope }, - payload: GraphqlSourceBindingInput.make({ - sourceId: props.sourceId, - sourceScope, + payload: { scope: oauthCredentialTargetScope, - slot: oauth2.connectionSlot, + source: { id: props.sourceId, scope: sourceScope }, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId }, - }), + }, reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], }); }} diff --git a/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx b/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx index d6d9d2978..c94e7181a 100644 --- a/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx +++ b/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx @@ -1,7 +1,7 @@ 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 } 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 +9,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/shared"; -import { graphqlSourceAtom, graphqlSourceBindingsAtom, setGraphqlSourceBinding } from "./atoms"; -import { GraphqlSourceBindingInput } from "../sdk/types"; +import { graphqlSourceAtom, graphqlSourceBindingsAtom } from "./atoms"; export default function GraphqlSignInButton(props: { sourceId: string }) { const scopeId = useScope(); @@ -23,7 +22,7 @@ export default function GraphqlSignInButton(props: { sourceId: string }) { graphqlSourceBindingsAtom(userScopeId, 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; @@ -60,13 +59,12 @@ export default function GraphqlSignInButton(props: { sourceId: string }) { onConnected={async (connectionId) => { await setBinding({ params: { scopeId: userScopeId }, - payload: GraphqlSourceBindingInput.make({ - sourceId: props.sourceId, - sourceScope, + payload: { scope: userScopeId, - slot: oauth2.connectionSlot, + source: { id: props.sourceId, scope: sourceScope }, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId }, - }), + }, reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], }); }} diff --git a/packages/plugins/graphql/src/react/atoms.ts b/packages/plugins/graphql/src/react/atoms.ts index c120ddd93..ad815aaec 100644 --- a/packages/plugins/graphql/src/react/atoms.ts +++ b/packages/plugins/graphql/src/react/atoms.ts @@ -1,9 +1,10 @@ import type { ScopeId } from "@executor-js/sdk/shared"; import * as Atom from "effect/unstable/reactivity/Atom"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; +import { sourceCredentialBindingsAtom, sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; import { GraphqlClient } from "./client"; +import { GraphqlSourceBindingRef } from "../sdk/types"; // --------------------------------------------------------------------------- // Query atoms @@ -21,11 +22,19 @@ export const graphqlSourceBindingsAtom = ( namespace: string, sourceScopeId: ScopeId, ) => - GraphqlClient.query("graphql", "listSourceBindings", { - params: { scopeId, namespace, sourceScopeId }, - timeToLive: "15 seconds", - reactivityKeys: [ReactivityKey.sources, ReactivityKey.secrets, ReactivityKey.connections], - }); + Atom.mapResult(sourceCredentialBindingsAtom(scopeId, namespace, sourceScopeId), (rows) => + rows.map((row) => + GraphqlSourceBindingRef.make({ + sourceId: row.sourceId, + sourceScopeId: row.sourceScopeId, + scopeId: row.scopeId, + slot: row.slotKey, + value: row.value, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }), + ), + ); // --------------------------------------------------------------------------- // Mutation atoms @@ -41,7 +50,7 @@ export const addGraphqlSourceOptimistic = Atom.family((scopeId: ScopeId) => const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; const source = { id, - scopeId: arg.payload.targetScope, + scopeId, kind: "graphql", pluginId: "graphql", name: arg.payload.name ?? id, @@ -59,9 +68,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..1e32f31ce 100644 --- a/packages/plugins/graphql/src/sdk/index.ts +++ b/packages/plugins/graphql/src/sdk/index.ts @@ -6,7 +6,8 @@ export { type GraphqlSourceConfig, type GraphqlPluginExtension, type GraphqlPluginOptions, - type GraphqlUpdateSourceInput, + type GraphqlConfigureSourceInput, + type GraphqlSourceRef, } from "./plugin"; export { graphqlSchema, @@ -31,7 +32,6 @@ export { GraphqlOperationKind, GraphqlSourceAuth, GraphqlSourceAuthInput, - GraphqlSourceBindingInput, GraphqlSourceBindingRef, GraphqlSourceBindingValue, InvocationConfig, diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index c97cd9c8e..7c48d6baa 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -20,7 +20,7 @@ import { memorySecretsPlugin } from "@executor-js/sdk/testing"; import { graphqlPlugin } from "./plugin"; import { endpointForTelemetry } from "./invoke"; import { introspect } from "./introspect"; -import { GraphqlSourceBindingInput, graphqlHeaderSlot, graphqlQueryParamSlot } from "./types"; +import { GRAPHQL_OAUTH_CONNECTION_SLOT, graphqlHeaderSlot, graphqlQueryParamSlot } from "./types"; import type { IntrospectionResult } from "./introspect"; import { makeGreetingGraphqlSchema, @@ -29,6 +29,17 @@ import { } from "../testing"; const TEST_SCOPE = "test-scope"; +const graphqlOAuth2Config = { + kind: "oauth2" as const, + securitySchemeName: "OAuth2", + flow: "authorizationCode" as const, + tokenUrl: "https://auth.example.test/token", + authorizationUrl: "https://auth.example.test/authorize", + clientIdSlot: "auth:oauth2:client-id", + clientSecretSlot: null, + connectionSlot: GRAPHQL_OAUTH_CONNECTION_SLOT, + scopes: [], +}; // --------------------------------------------------------------------------- // Mock introspection response @@ -281,8 +292,19 @@ describe("graphqlPlugin real protocol server", () => { endpoint: server.endpoint, scope: TEST_SCOPE, namespace: "oauth_graph", - credentialTargetScope: TEST_SCOPE, - auth: { kind: "oauth2", connectionId }, + oauth2: graphqlOAuth2Config, + }); + yield* executor.sources.configure({ + source: { id: "oauth_graph", scope: ScopeId.make(TEST_SCOPE) }, + scope: ScopeId.make(TEST_SCOPE), + type: "graphql", + config: { + auth: { + oauth2: { + connection: { kind: "connection", connectionId }, + }, + }, + }, }); yield* server.clearRequests; @@ -411,7 +433,7 @@ describe("graphqlPlugin", () => { }), ); - it.effect("updateSource patches endpoint/headers without re-registering", () => + it.effect("sources.configure patches endpoint/headers without re-registering", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ plugins: [graphqlPlugin()] as const }), @@ -424,9 +446,14 @@ describe("graphqlPlugin", () => { namespace: "patched", }); - yield* executor.graphql.updateSource("patched", TEST_SCOPE, { - endpoint: "http://localhost:5000/graphql", - headers: { "x-custom": "abc" }, + yield* executor.sources.configure({ + source: { id: "patched", scope: ScopeId.make(TEST_SCOPE) }, + scope: ScopeId.make(TEST_SCOPE), + type: "graphql", + config: { + endpoint: "http://localhost:5000/graphql", + headers: { "x-custom": "abc" }, + }, }); const source = yield* executor.graphql.getSource("patched", TEST_SCOPE); @@ -459,6 +486,7 @@ describe("graphqlPlugin", () => { { scope: String(orgScope), endpoint: "http://localhost:4000/graphql", + name: "Via Static", introspectionJson, namespace: "via_static", }, @@ -486,7 +514,7 @@ describe("graphqlPlugin", () => { expect(schema!.inputTypeScript).toContain("endpoint: string"); expect( (schema!.inputSchema as { properties?: Record }).properties, - ).toHaveProperty("credentialTargetScope"); + ).not.toHaveProperty("targetScope"); expect(schema!.inputTypeScript).not.toBe("Record"); }), ); @@ -537,6 +565,7 @@ describe("graphqlPlugin", () => { { endpoint: server.endpoint, scope: TEST_SCOPE, + name: "Header Materialization", namespace: "header_materialization", headers: { authorization: "Bearer sample-token", @@ -651,7 +680,7 @@ describe("graphqlPlugin", () => { }), ); - it.effect("updateSource on user shadow does not mutate the org row", () => + it.effect("sources.configure on user shadow does not mutate the org row", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ @@ -675,9 +704,14 @@ describe("graphqlPlugin", () => { name: "User Source", }); - yield* executor.graphql.updateSource("shared", USER_SCOPE, { - name: "User Renamed", - endpoint: "http://user-new.example.com/graphql", + yield* executor.sources.configure({ + source: { id: "shared", scope: ScopeId.make(USER_SCOPE) }, + scope: ScopeId.make(USER_SCOPE), + type: "graphql", + config: { + name: "User Renamed", + endpoint: "http://user-new.example.com/graphql", + }, }); const userView = yield* executor.graphql.getSource("shared", USER_SCOPE); @@ -735,32 +769,38 @@ describe("graphqlPlugin", () => { namespace: "shared_credentials", introspectionJson, headers: { - Authorization: { secretId: "org-token", prefix: "Bearer " }, + Authorization: { kind: "secret", prefix: "Bearer " }, }, queryParams: { - token: { secretId: "org-query" }, + token: { kind: "secret" }, + }, + }); + yield* executor.sources.configure({ + source: { id: "shared_credentials", scope: ScopeId.make(ORG_SCOPE) }, + scope: ScopeId.make(ORG_SCOPE), + type: "graphql", + config: { + headers: { + Authorization: { kind: "secret", secretId: "org-token", prefix: "Bearer " }, + }, + queryParams: { + token: { kind: "secret", secretId: "org-query" }, + }, }, - 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* executor.sources.setBinding({ + source: { id: "shared_credentials", scope: ScopeId.make(ORG_SCOPE) }, + scope: ScopeId.make(USER_SCOPE), + slotKey: graphqlHeaderSlot("Authorization"), + value: { kind: "secret", secretId: SecretId.make("user-token") }, + }); + yield* executor.sources.setBinding({ + source: { id: "shared_credentials", scope: ScopeId.make(ORG_SCOPE) }, + scope: 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", { @@ -777,7 +817,7 @@ describe("graphqlPlugin", () => { }), ); - it.effect("addSource stores direct GraphQL credential bindings at each row scope", () => + it.effect("sources.configure stores GraphQL credential bindings at the target scope", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ @@ -807,34 +847,46 @@ describe("graphqlPlugin", () => { namespace: "row_scoped_credentials", introspectionJson, headers: { - Authorization: { - secretId: "row-user-token", - prefix: "Bearer ", - targetScope: USER_SCOPE, - }, + Authorization: { kind: "secret", prefix: "Bearer " }, }, queryParams: { - token: { - secretId: "row-org-query", - targetScope: ORG_SCOPE, + token: { kind: "secret" }, + }, + }); + yield* executor.sources.configure({ + source: { id: "row_scoped_credentials", scope: ScopeId.make(ORG_SCOPE) }, + scope: ScopeId.make(ORG_SCOPE), + type: "graphql", + config: { + headers: { + Authorization: { + kind: "secret", + secretId: "row-user-token", + prefix: "Bearer ", + }, + }, + queryParams: { + token: { + kind: "secret", + secretId: "row-org-query", + }, }, }, }); - const bindings = yield* executor.graphql.listSourceBindings( - "row_scoped_credentials", - ORG_SCOPE, - ); + const bindings = yield* executor.sources.listBindings({ + source: { id: "row_scoped_credentials", scope: ScopeId.make(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, - ).toBe(ScopeId.make(USER_SCOPE)); + bindings.find((binding) => binding.slotKey === graphqlHeaderSlot("Authorization"))?.scopeId, + ).toBe(ScopeId.make(ORG_SCOPE)); expect( - bindings.find((binding) => binding.slot === graphqlQueryParamSlot("token"))?.scopeId, + bindings.find((binding) => binding.slotKey === graphqlQueryParamSlot("token"))?.scopeId, ).toBe(ScopeId.make(ORG_SCOPE)); }), ); @@ -863,9 +915,18 @@ describe("graphqlPlugin", () => { namespace: "org_bound_secret", introspectionJson, headers: { - Authorization: { secretId: "shared-token", prefix: "Bearer " }, + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + }); + yield* executor.sources.configure({ + source: { id: "org_bound_secret", scope: ScopeId.make(ORG_SCOPE) }, + scope: ScopeId.make(ORG_SCOPE), + type: "graphql", + config: { + headers: { + Authorization: { kind: "secret", secretId: "shared-token", prefix: "Bearer " }, + }, }, - credentialTargetScope: ORG_SCOPE, }); yield* executor.secrets.set({ @@ -926,8 +987,19 @@ describe("graphqlPlugin", () => { scope: ORG_SCOPE, namespace: "org_bound_connection", introspectionJson, - auth: { kind: "oauth2", connectionId }, - credentialTargetScope: ORG_SCOPE, + oauth2: graphqlOAuth2Config, + }); + yield* executor.sources.configure({ + source: { id: "org_bound_connection", scope: ScopeId.make(ORG_SCOPE) }, + scope: ScopeId.make(ORG_SCOPE), + type: "graphql", + config: { + auth: { + oauth2: { + connection: { kind: "connection", connectionId }, + }, + }, + }, }); yield* executor.connections.create( @@ -962,7 +1034,7 @@ describe("graphqlPlugin", () => { }), ); - it.effect("updateSource removes bindings for credential slots no longer present", () => + it.effect("sources.configure removes bindings for credential slots no longer present", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ @@ -984,15 +1056,25 @@ describe("graphqlPlugin", () => { scope: ORG_SCOPE, namespace: "stale_binding", introspectionJson, - headers: { "X-Old": { secretId: "old-token" } }, - credentialTargetScope: ORG_SCOPE, + headers: { "X-Old": { kind: "secret" } }, + }); + yield* executor.sources.configure({ + source: { id: "stale_binding", scope: ScopeId.make(ORG_SCOPE) }, + scope: ScopeId.make(ORG_SCOPE), + type: "graphql", + config: { headers: { "X-Old": { kind: "secret", secretId: "old-token" } } }, }); - yield* executor.graphql.updateSource("stale_binding", ORG_SCOPE, { - headers: {}, + yield* executor.sources.configure({ + source: { id: "stale_binding", scope: ScopeId.make(ORG_SCOPE) }, + scope: ScopeId.make(ORG_SCOPE), + type: "graphql", + config: { headers: {} }, }); - const bindings = yield* executor.graphql.listSourceBindings("stale_binding", ORG_SCOPE); + const bindings = yield* executor.sources.listBindings({ + source: { id: "stale_binding", scope: ScopeId.make(ORG_SCOPE) }, + }); expect(bindings).toEqual([]); }), ); @@ -1025,11 +1107,21 @@ describe("graphqlPlugin", () => { scope: TEST_SCOPE, introspectionJson, namespace: "with_secret", - credentialTargetScope: TEST_SCOPE, headers: { - Authorization: { secretId: "api-key", prefix: "Bearer " }, + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + queryParams: { token: { kind: "secret" } }, + }); + yield* executor.sources.configure({ + source: { id: "with_secret", scope: ScopeId.make(TEST_SCOPE) }, + scope: ScopeId.make(TEST_SCOPE), + type: "graphql", + config: { + headers: { + Authorization: { kind: "secret", secretId: "api-key", prefix: "Bearer " }, + }, + queryParams: { token: { kind: "secret", secretId: "api-key" } }, }, - queryParams: { token: { secretId: "api-key" } }, }); const usages = yield* executor.secrets.usages(SecretId.make("api-key")); @@ -1064,8 +1156,13 @@ describe("graphqlPlugin", () => { scope: TEST_SCOPE, introspectionJson, namespace: "ref", - credentialTargetScope: TEST_SCOPE, - headers: { "X-Token": { secretId: "locked" } }, + headers: { "X-Token": { kind: "secret" } }, + }); + yield* executor.sources.configure({ + source: { id: "ref", scope: ScopeId.make(TEST_SCOPE) }, + scope: ScopeId.make(TEST_SCOPE), + type: "graphql", + config: { headers: { "X-Token": { kind: "secret", secretId: "locked" } } }, }); const result = yield* executor.secrets @@ -1124,8 +1221,19 @@ describe("graphqlPlugin", () => { scope: TEST_SCOPE, introspectionJson, namespace: "oauth_ref", - credentialTargetScope: TEST_SCOPE, - auth: { kind: "oauth2", connectionId }, + oauth2: graphqlOAuth2Config, + }); + yield* executor.sources.configure({ + source: { id: "oauth_ref", scope: ScopeId.make(TEST_SCOPE) }, + scope: ScopeId.make(TEST_SCOPE), + type: "graphql", + config: { + auth: { + oauth2: { + connection: { kind: "connection", connectionId }, + }, + }, + }, }); const usages = yield* executor.connections.usages(connectionId); diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 76fb6cf35..6c2a3b450 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -3,13 +3,10 @@ import type { Layer } from "effect"; import { HttpClient } from "effect/unstable/http"; import { - ConnectionId, - ConfiguredCredentialBinding, type CredentialBindingRef, definePlugin, tool, ScopeId, - SecretId, SourceDetectionResult, StorageError, ToolResult, @@ -18,6 +15,12 @@ import { type ToolAnnotations, type ToolRow, } from "@executor-js/sdk/core"; +import { + compileHttpNamedCredentialMap, + OAuth2SourceConfig, + httpCredentialInputToBindingValue, + type HttpConfiguredValueInput, +} from "@executor-js/plugin-http-source/sdk"; import { headersToConfigValues, @@ -34,7 +37,7 @@ import { type IntrospectionTypeRef, } from "./introspect"; import { extract } from "./extract"; -import { GraphqlIntrospectionError, GraphqlInvocationError } from "./errors"; +import { GraphqlInvocationError } from "./errors"; import { invokeWithLayer } from "./invoke"; import { graphqlSchema, @@ -45,15 +48,16 @@ import { } from "./store"; import { ExtractedField, + GraphqlConfiguredValueInput as GraphqlConfiguredValueInputSchema, GRAPHQL_OAUTH_CONNECTION_SLOT, GraphqlCredentialInput as GraphqlCredentialInputSchema, GraphqlSourceAuthInput as GraphqlSourceAuthInputSchema, - GraphqlSourceBindingInput, GraphqlSourceBindingRef, graphqlHeaderSlot, graphqlQueryParamSlot, OperationBinding, type ConfiguredGraphqlCredentialValue, + type GraphqlConfiguredValueInput, type GraphqlCredentialInput, type GraphqlSourceAuth, type HeaderValue as HeaderValueValue, @@ -92,34 +96,35 @@ export interface GraphqlSourceConfig { * every inner (per-user) scope via fall-through reads. */ readonly scope: string; - /** Display name for the source. Falls back to namespace if not provided. */ - readonly name?: string; + /** Display name for the source. */ + readonly name: string; /** Optional: introspection JSON text (if endpoint doesn't support introspection) */ readonly introspectionJson?: string; - /** Namespace for the tools (derived from endpoint if not provided) */ - readonly namespace?: string; - /** Headers applied to every request. Direct secrets are rewritten to slots. */ - readonly headers?: Record; - /** Query parameters applied to every request. Direct secrets are rewritten to slots. */ - readonly queryParams?: Record; - /** - * Scope that owns any direct credentials supplied on this call. Required - * whenever headers/queryParams/auth carry direct secret or connection ids. - */ - readonly credentialTargetScope?: string; + /** Namespace for the tools. */ + readonly namespace: string; + /** Headers applied to every request. Secret entries declare source-owned slots. */ + readonly headers?: Record; + /** Query parameters applied to every request. Secret entries declare source-owned slots. */ + readonly queryParams?: Record; /** Optional OAuth2 credential used as a Bearer token for every request. */ - readonly auth?: GraphqlSourceAuthInput; + readonly oauth2?: OAuth2SourceConfig; } const StaticAddSourceInputSchema = Schema.Struct({ scope: Schema.String, endpoint: Schema.String, - name: Schema.optional(Schema.String), + name: Schema.String, introspectionJson: Schema.optional(Schema.String), - namespace: Schema.optional(Schema.String), + namespace: Schema.String, + headers: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInputSchema)), + queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInputSchema)), + oauth2: Schema.optional(OAuth2SourceConfig), +}); +const SourceConfigureInputSchema = Schema.Struct({ + name: Schema.optional(Schema.String), + endpoint: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)), queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)), - credentialTargetScope: Schema.optional(Schema.String), auth: Schema.optional(GraphqlSourceAuthInputSchema), }); @@ -134,12 +139,17 @@ const StaticAddSourceOutputStandardSchema = Schema.toStandardSchemaV1( // Plugin extension // --------------------------------------------------------------------------- -export interface GraphqlUpdateSourceInput { +export interface GraphqlSourceRef { + readonly id: string; + readonly scope: string; +} + +export interface GraphqlConfigureSourceInput { + readonly scope: string; readonly name?: string; readonly endpoint?: string; readonly headers?: Record; readonly queryParams?: Record; - readonly credentialTargetScope?: string; readonly auth?: GraphqlSourceAuthInput; } @@ -354,25 +364,6 @@ const coreBindingToGraphqlBinding = (binding: CredentialBindingRef): GraphqlSour 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 = ( ctx: PluginCtx, sourceId: string, @@ -437,79 +428,41 @@ const validateGraphqlBindingTarget = ( } }); -const bindingTargetScope = ( - targetScope: string | undefined, - bindings: readonly unknown[], -): Effect.Effect => { - if (bindings.length === 0) return Effect.succeed(undefined); - if (targetScope) return Effect.succeed(targetScope); - return Effect.fail( - new GraphqlIntrospectionError({ - message: "credentialTargetScope is required when adding direct GraphQL credentials", - }), - ); -}; - -const targetScopeForBinding = ( - fallbackTargetScope: string | undefined, - binding: { readonly targetScope?: string }, -): Effect.Effect => { - const targetScope = binding.targetScope ?? fallbackTargetScope; - if (targetScope) return Effect.succeed(targetScope); - return Effect.fail( - new GraphqlIntrospectionError({ - message: "credentialTargetScope is required when adding direct GraphQL credentials", - }), - ); -}; +const canonicalizeCredentialMap = compileHttpNamedCredentialMap; -const canonicalizeCredentialMap = ( - values: Record | undefined, +const canonicalizeConfiguredValueMap = ( + values: Record | undefined, slotForName: (name: string) => string, -): { - readonly values: Record; - readonly bindings: ReadonlyArray<{ - readonly slot: string; - readonly value: GraphqlSourceBindingValue; - readonly targetScope?: string; - }>; -} => { - const nextValues: Record = {}; - const bindings: Array<{ - slot: string; - value: GraphqlSourceBindingValue; - targetScope?: string; - }> = []; +): Record => { + const next: Record = {}; for (const [name, value] of Object.entries(values ?? {})) { if (typeof value === "string") { - nextValues[name] = value; + next[name] = value; continue; } - if ("kind" in value) { - nextValues[name] = value; - continue; - } - const slot = slotForName(name); - nextValues[name] = ConfiguredCredentialBinding.make({ + next[name] = { kind: "binding", - slot, + slot: slotForName(name), prefix: value.prefix, - }); - bindings.push({ - slot, - targetScope: "targetScope" in value ? value.targetScope : undefined, - value: { - kind: "secret", - secretId: SecretId.make(value.secretId), - ...("secretScopeId" in value && value.secretScopeId - ? { secretScopeId: value.secretScopeId } - : {}), - }, - }); + }; + } + return next; +}; + +const resolveConfiguredValueMap = ( + values: Record | undefined, +): Record | undefined => { + if (!values) return undefined; + const resolved: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (typeof value === "string") resolved[name] = value; } - return { values: nextValues, bindings }; + return Object.keys(resolved).length > 0 ? resolved : undefined; }; +const authFromOAuth2Source = (oauth2: OAuth2SourceConfig | undefined): GraphqlSourceAuth => + oauth2 ? { kind: "oauth2", connectionSlot: oauth2.connectionSlot } : { kind: "none" }; + const canonicalizeAuth = ( auth: GraphqlSourceAuthInput | undefined, ): { @@ -520,19 +473,18 @@ const canonicalizeAuth = ( readonly targetScope?: string; }>; } => { - if (!auth || auth.kind === "none") return { auth: { kind: "none" }, bindings: [] }; - if ("connectionSlot" in auth) return { auth, bindings: [] }; + if (!auth || "kind" in auth || !auth.oauth2) return { auth: { kind: "none" }, bindings: [] }; + const connection = auth.oauth2.connection; return { auth: { kind: "oauth2", connectionSlot: GRAPHQL_OAUTH_CONNECTION_SLOT }, - bindings: [ - { - slot: GRAPHQL_OAUTH_CONNECTION_SLOT, - value: { - kind: "connection", - connectionId: ConnectionId.make(auth.connectionId), - }, - }, - ], + bindings: connection + ? [ + { + slot: GRAPHQL_OAUTH_CONNECTION_SLOT, + value: httpCredentialInputToBindingValue(connection), + }, + ] + : [], }; }; @@ -623,161 +575,26 @@ const makeGraphqlExtension = ( httpClientLayer: Layer.Layer, configFile: ConfigFileSink | undefined, ) => { - const resolveCredentialInputMap = ( - values: Record | undefined, - params: { - readonly sourceId: string; - readonly sourceScope: string; - readonly targetScope?: string; - readonly missingLabel: string; - readonly makeError: (message: string) => E; - }, - ): Effect.Effect | undefined, E | StorageFailure> => - Effect.gen(function* () { - if (!values) return undefined; - const resolved: Record = {}; - for (const [name, value] of Object.entries(values)) { - if (typeof value === "string") { - resolved[name] = value; - continue; - } - if ("kind" in value) { - const slotResolved = yield* resolveGraphqlBindingValueMap( - ctx, - { [name]: value }, - { - sourceId: params.sourceId, - sourceScope: params.sourceScope, - missingLabel: params.missingLabel, - makeError: params.makeError, - }, - ); - if (slotResolved?.[name] !== undefined) resolved[name] = slotResolved[name]; - continue; - } - const secretScope = - "secretScopeId" in value - ? (value.secretScopeId ?? value.targetScope) - : (params.targetScope ?? params.sourceScope); - const secret = yield* ctx.secrets - .getAtScope(SecretId.make(value.secretId), secretScope) - .pipe( - Effect.catchTag("SecretOwnedByConnectionError", () => - Effect.fail( - params.makeError(`Secret not found for ${params.missingLabel} "${name}"`), - ), - ), - ); - if (secret === null) { - return yield* Effect.fail( - params.makeError( - `Missing secret "${value.secretId}" for ${params.missingLabel} "${name}"`, - ), - ); - } - resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; - } - return Object.keys(resolved).length > 0 ? resolved : undefined; - }); - - const resolveOAuthInputHeader = ( - sourceId: string, - sourceScope: string, - targetScope: string | undefined, - auth: GraphqlSourceAuthInput | undefined, - ) => - Effect.gen(function* () { - if (!auth || auth.kind === "none") return undefined; - const connection = - "connectionId" in auth - ? { id: auth.connectionId, scope: targetScope ?? sourceScope } - : yield* Effect.gen(function* () { - const binding = yield* resolveGraphqlSourceBinding( - ctx, - sourceId, - sourceScope, - auth.connectionSlot, - ); - return binding?.value.kind === "connection" - ? { id: binding.value.connectionId, scope: binding.scopeId } - : null; - }); - if (connection === null) { - return yield* new GraphqlIntrospectionError({ - message: `Missing OAuth connection binding for "${sourceId}"`, - }); - } - const accessToken = yield* ctx.connections - .accessTokenAtScope(connection.id, connection.scope) - .pipe( - Effect.mapError( - () => - new GraphqlIntrospectionError({ - message: `Failed to resolve OAuth connection "${connection.id}"`, - }), - ), - ); - return { Authorization: `Bearer ${accessToken}` }; - }); - const addSourceInternal = (config: GraphqlSourceConfig) => ctx.transaction( Effect.gen(function* () { - const namespace = config.namespace ?? namespaceFromEndpoint(config.endpoint); - const canonicalHeaders = canonicalizeCredentialMap(config.headers, graphqlHeaderSlot); - const canonicalQueryParams = canonicalizeCredentialMap( + const namespace = config.namespace; + const canonicalHeaders = canonicalizeConfiguredValueMap(config.headers, graphqlHeaderSlot); + const canonicalQueryParams = canonicalizeConfiguredValueMap( config.queryParams, graphqlQueryParamSlot, ); - const canonicalAuth = canonicalizeAuth(config.auth); - const directBindings = [ - ...canonicalHeaders.bindings, - ...canonicalQueryParams.bindings, - ...canonicalAuth.bindings, - ]; - for (const binding of directBindings) { - const bindingTargetScope = yield* targetScopeForBinding( - config.credentialTargetScope, - binding, - ); - yield* validateGraphqlBindingTarget(ctx, { - sourceId: namespace, - sourceScope: config.scope, - targetScope: bindingTargetScope, - }); - } - const targetScope = - directBindings[0] !== undefined - ? yield* targetScopeForBinding(config.credentialTargetScope, directBindings[0]) - : undefined; + const auth = authFromOAuth2Source(config.oauth2); let introspectionResult: IntrospectionResult; if (config.introspectionJson) { introspectionResult = yield* parseIntrospectionJson(config.introspectionJson); } else { - const resolvedHeaders = yield* resolveCredentialInputMap(config.headers, { - sourceId: namespace, - sourceScope: config.scope, - targetScope, - missingLabel: "header", - makeError: (message) => new GraphqlIntrospectionError({ message }), - }); - const oauthHeader = yield* resolveOAuthInputHeader( - namespace, - config.scope, - targetScope, - config.auth, - ); - const resolvedQueryParams = yield* resolveCredentialInputMap(config.queryParams, { - sourceId: namespace, - sourceScope: config.scope, - targetScope, - missingLabel: "query parameter", - makeError: (message) => new GraphqlIntrospectionError({ message }), - }); + const resolvedHeaders = resolveConfiguredValueMap(config.headers); + const resolvedQueryParams = resolveConfiguredValueMap(config.queryParams); introspectionResult = yield* introspect( config.endpoint, - { ...(resolvedHeaders ?? {}), ...(oauthHeader ?? {}) }, + resolvedHeaders, resolvedQueryParams, ).pipe(Effect.provide(httpClientLayer)); } @@ -792,9 +609,9 @@ const makeGraphqlExtension = ( scope: config.scope, name: displayName, endpoint: config.endpoint, - headers: canonicalHeaders.values, - queryParams: canonicalQueryParams.values, - auth: canonicalAuth.auth, + headers: canonicalHeaders, + queryParams: canonicalQueryParams, + auth, }; const storedOps: StoredOperation[] = prepared.map((p) => ({ @@ -829,26 +646,70 @@ const makeGraphqlExtension = ( }); } - if (directBindings.length > 0) { - for (const binding of directBindings) { - const bindingTargetScope = yield* targetScopeForBinding( - config.credentialTargetScope, - binding, - ); - yield* ctx.credentialBindings.set({ - targetScope: ScopeId.make(bindingTargetScope), + return { toolCount: prepared.length, namespace }; + }), + ); + + const configureSource = ( + namespace: string, + scope: string, + targetScope: string, + input: Omit, + ) => + Effect.gen(function* () { + const existing = yield* ctx.storage.getSource(namespace, scope); + if (!existing) return; + const canonicalHeaders = + input.headers !== undefined + ? canonicalizeCredentialMap(input.headers, graphqlHeaderSlot) + : null; + const canonicalQueryParams = + input.queryParams !== undefined + ? canonicalizeCredentialMap(input.queryParams, graphqlQueryParamSlot) + : null; + const canonicalAuth = input.auth !== undefined ? canonicalizeAuth(input.auth) : null; + const directBindings = [ + ...(canonicalHeaders?.bindings ?? []), + ...(canonicalQueryParams?.bindings ?? []), + ...(canonicalAuth?.bindings ?? []), + ]; + if (directBindings.length > 0) { + yield* validateGraphqlBindingTarget(ctx, { + sourceId: namespace, + sourceScope: scope, + targetScope, + }); + } + const affectedPrefixes = [ + ...(input.headers !== undefined ? ["header:"] : []), + ...(input.queryParams !== undefined ? ["query_param:"] : []), + ...(input.auth !== undefined ? ["auth:"] : []), + ]; + yield* ctx.transaction( + Effect.gen(function* () { + yield* ctx.storage.updateSourceMeta(namespace, scope, { + name: input.name?.trim() || undefined, + endpoint: input.endpoint, + headers: canonicalHeaders?.values, + queryParams: canonicalQueryParams?.values, + auth: canonicalAuth?.auth, + }); + if (affectedPrefixes.length > 0 || directBindings.length > 0) { + yield* ctx.credentialBindings.replaceForSource({ + targetScope: ScopeId.make(targetScope), pluginId: GRAPHQL_PLUGIN_ID, sourceId: namespace, - sourceScope: ScopeId.make(config.scope), - slotKey: binding.slot, - value: binding.value, + sourceScope: ScopeId.make(scope), + slotPrefixes: affectedPrefixes, + bindings: directBindings.map((binding) => ({ + slotKey: binding.slot, + value: binding.value, + })), }); } - } - - return { toolCount: prepared.length, namespace }; - }), - ); + }), + ); + }); return { addSource: (config: GraphqlSourceConfig) => @@ -880,100 +741,10 @@ const makeGraphqlExtension = ( getSource: (namespace: string, scope: string) => ctx.storage.getSource(namespace, scope), - updateSource: (namespace: string, scope: string, input: GraphqlUpdateSourceInput) => - Effect.gen(function* () { - const existing = yield* ctx.storage.getSource(namespace, scope); - if (!existing) return; - const canonicalHeaders = - input.headers !== undefined - ? canonicalizeCredentialMap(input.headers, graphqlHeaderSlot) - : null; - const canonicalQueryParams = - input.queryParams !== undefined - ? canonicalizeCredentialMap(input.queryParams, graphqlQueryParamSlot) - : null; - const canonicalAuth = input.auth !== undefined ? canonicalizeAuth(input.auth) : null; - const directBindings = [ - ...(canonicalHeaders?.bindings ?? []), - ...(canonicalQueryParams?.bindings ?? []), - ...(canonicalAuth?.bindings ?? []), - ]; - const targetScope = yield* bindingTargetScope(input.credentialTargetScope, directBindings); - if (targetScope) { - yield* validateGraphqlBindingTarget(ctx, { - sourceId: namespace, - sourceScope: scope, - targetScope, - }); - } - const affectedPrefixes = [ - ...(input.headers !== undefined ? ["header:"] : []), - ...(input.queryParams !== undefined ? ["query_param:"] : []), - ...(input.auth !== undefined ? ["auth:"] : []), - ]; - const replacementTargetScope = targetScope ?? input.credentialTargetScope ?? scope; - yield* ctx.transaction( - Effect.gen(function* () { - yield* ctx.storage.updateSourceMeta(namespace, scope, { - name: input.name?.trim() || undefined, - endpoint: input.endpoint, - headers: canonicalHeaders?.values, - queryParams: canonicalQueryParams?.values, - auth: canonicalAuth?.auth, - }); - if (affectedPrefixes.length > 0 || directBindings.length > 0) { - yield* ctx.credentialBindings.replaceForSource({ - targetScope: ScopeId.make(replacementTargetScope), - pluginId: GRAPHQL_PLUGIN_ID, - sourceId: namespace, - sourceScope: ScopeId.make(scope), - slotPrefixes: affectedPrefixes, - bindings: directBindings.map((binding) => ({ - slotKey: binding.slot, - value: binding.value, - })), - }); - } - }), - ); - }), - - 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); - }), + configureSource, - 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, - }); - }), + configure: (source: GraphqlSourceRef, input: GraphqlConfigureSourceInput) => + configureSource(source.id, source.scope, input.scope, input), }; }; @@ -993,6 +764,22 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { options?.configFile, ), + sourceConfigure: { + type: "graphql", + schema: SourceConfigureInputSchema, + configure: ({ ctx, sourceId, sourceScope, targetScope, config }) => + makeGraphqlExtension( + ctx, + options?.httpClientLayer ?? ctx.httpClientLayer, + options?.configFile, + ).configureSource( + sourceId, + sourceScope, + targetScope, + config as GraphqlConfigureSourceInput, + ), + }, + staticSources: (self) => [ { id: "graphql", @@ -1019,7 +806,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; // toolRow.scope_id is the resolved owning scope of the tool // (innermost-wins from the executor's stack). The matching - // graphql_operation + graphql_source rows live at the same + // GraphQL operation + source plugin-storage rows live at the same // scope, so pin every store lookup to it instead of relying on // stack-wide scope fall-through. const toolScope = toolRow.scope_id; diff --git a/packages/plugins/graphql/src/sdk/store.ts b/packages/plugins/graphql/src/sdk/store.ts index e02d044c4..5b6d96dee 100644 --- a/packages/plugins/graphql/src/sdk/store.ts +++ b/packages/plugins/graphql/src/sdk/store.ts @@ -1,76 +1,24 @@ -import { Effect, Schema } from "effect"; +import { Effect, Option, Predicate, Schema } from "effect"; import { ConfiguredCredentialBinding, type FumaTables, - jsonColumn, - nullableTextColumn, - scopedExecutorTable, + type PluginStorageEntry, type StorageDeps, type StorageFailure, - textColumn, } from "@executor-js/sdk/core"; import { + GraphqlSourceAuth, OperationBinding, type ConfiguredGraphqlCredentialValue, - type GraphqlSourceAuth, } from "./types"; -// --------------------------------------------------------------------------- -// Schema — four tables: -// - graphql_source: endpoint + auth structure + display name per source. -// Auth carries a connection slot; concrete per-user/per-workspace -// connection ids live in core credential_binding rows. -// - graphql_source_header / graphql_source_query_param: one row per -// header/param entry. `kind` discriminates literal text from a -// credential slot binding. PK is `(scope_id, id)` where id is a JSON -// tuple `[source_id,name]` so user-provided separators cannot collide. -// - graphql_operation: per-tool OperationBinding blob. Operation -// bindings don't reference secrets/connections, so they stay as -// JSON — that's a legit JSON case (the binding shape is plugin- -// internal opaque data). -// --------------------------------------------------------------------------- - -export const graphqlSchema = { - graphql_source: scopedExecutorTable("graphql_source", { - name: textColumn("name"), - endpoint: textColumn("endpoint"), - auth_kind: textColumn("auth_kind").defaultTo("none"), - auth_connection_slot: nullableTextColumn("auth_connection_slot"), - }), - graphql_source_header: scopedExecutorTable("graphql_source_header", { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }), - graphql_source_query_param: scopedExecutorTable("graphql_source_query_param", { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }), - graphql_operation: scopedExecutorTable("graphql_operation", { - source_id: textColumn("source_id"), - binding: jsonColumn("binding"), - }), -} satisfies FumaTables; - +export const graphqlSchema = {} satisfies FumaTables; export type GraphqlSchema = typeof graphqlSchema; -// --------------------------------------------------------------------------- -// In-memory value shapes -// --------------------------------------------------------------------------- - export interface StoredGraphqlSource { readonly namespace: string; - /** Executor scope id this source row lives in. Writes stamp this on - * `scope_id`; reads choose scope explicitly in the FumaDB query. */ readonly scope: string; readonly name: string; readonly endpoint: string; @@ -85,140 +33,123 @@ export interface StoredOperation { readonly binding: OperationBinding; } +const SOURCE_COLLECTION = "source"; +const OPERATION_COLLECTION = "operation"; + const OperationBindingFromJsonString = Schema.fromJsonString(OperationBinding); const decodeOperationBindingFromJsonString = Schema.decodeUnknownSync( OperationBindingFromJsonString, ); const decodeOperationBinding = Schema.decodeUnknownSync(OperationBinding); +const encodeBinding = Schema.encodeSync(OperationBinding); const decodeBinding = (value: unknown): OperationBinding => { - if (typeof value === "string") { - return decodeOperationBindingFromJsonString(value); - } + if (typeof value === "string") return decodeOperationBindingFromJsonString(value); return decodeOperationBinding(value); }; -const encodeBinding = Schema.encodeSync(OperationBinding); - const toJsonRecord = (value: unknown): Record => value as Record; -const SourceRow = Schema.Struct({ - id: Schema.String, - scope_id: Schema.String, - name: Schema.String, - endpoint: Schema.String, - auth_kind: Schema.Literals(["none", "oauth2"]), - auth_connection_slot: Schema.NullOr(Schema.String).pipe(Schema.optionalKey), +const OptionalNullableString = Schema.optional(Schema.NullOr(Schema.String)); +const ConfiguredCredentialBindingStorage = Schema.Struct({ + kind: Schema.Literal("binding"), + slot: Schema.String, + prefix: OptionalNullableString, }); - -const ChildValueRow = Schema.Struct({ +const ConfiguredCredentialValueStorage = Schema.Union([ + Schema.String, + ConfiguredCredentialBindingStorage, +]); +const CredentialMapStorage = Schema.Record(Schema.String, ConfiguredCredentialValueStorage); +const SourceStorage = Schema.Struct({ + namespace: Schema.String, + scope: Schema.String, name: Schema.String, - kind: Schema.Literals(["text", "binding"]), - text_value: Schema.NullOr(Schema.String).pipe(Schema.optionalKey), - slot_key: Schema.NullOr(Schema.String).pipe(Schema.optionalKey), - prefix: Schema.NullOr(Schema.String).pipe(Schema.optionalKey), + endpoint: Schema.String, + headers: Schema.optional(CredentialMapStorage), + queryParams: Schema.optional(CredentialMapStorage), + auth: GraphqlSourceAuth, }); - -const OperationRow = Schema.Struct({ - id: Schema.String, - source_id: Schema.String, +const OperationStorage = Schema.Struct({ + toolId: Schema.String, + sourceId: Schema.String, binding: Schema.Unknown, }); +const decodeSourceStorage = Schema.decodeUnknownOption(SourceStorage); +const decodeOperationStorage = Schema.decodeUnknownOption(OperationStorage); -const decodeSourceRow = Schema.decodeUnknownSync(SourceRow); -const decodeChildValueRow = Schema.decodeUnknownSync(ChildValueRow); -const decodeOperationRow = Schema.decodeUnknownSync(OperationRow); - -// Header / query-param rows: collapse the flat columns back into a source -// structure map keyed by header/param name. Concrete credential values are -// resolved through core credential_binding rows at invocation time. -const rowsToValueMap = ( - rows: readonly Record[], +const normalizeCredentialMap = ( + values: Readonly> | undefined, ): Record => { - const out: Record = {}; - for (const rawRow of rows) { - const row = decodeChildValueRow(rawRow); - const name = row.name; - if (row.kind === "binding" && typeof row.slot_key === "string") { - out[name] = - typeof row.prefix === "string" - ? ConfiguredCredentialBinding.make({ - kind: "binding", - slot: row.slot_key, - prefix: row.prefix, - }) - : ConfiguredCredentialBinding.make({ - kind: "binding", - slot: row.slot_key, - }); - } else if (row.kind === "text" && typeof row.text_value === "string") { - out[name] = row.text_value; + if (!values) return {}; + const normalized: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (typeof value === "string") { + normalized[name] = value; + continue; } + normalized[name] = + value.prefix != null + ? ConfiguredCredentialBinding.make({ + kind: "binding", + slot: value.slot, + prefix: value.prefix, + }) + : ConfiguredCredentialBinding.make({ + kind: "binding", + slot: value.slot, + }); } - return out; + return normalized; }; -interface GraphqlChildValueInsert { - id: string; - scope_id: string; - source_id: string; - name: string; - kind: "text" | "binding"; - text_value?: string; - slot_key?: string; - prefix?: string; -} +const sourceData = (source: StoredGraphqlSource) => ({ + namespace: source.namespace, + scope: source.scope, + name: source.name, + endpoint: source.endpoint, + headers: source.headers, + queryParams: source.queryParams, + auth: source.auth, +}); -// Encode one entry of a source credential map into a child row. Used by the -// writer for both `graphql_source_header` and `graphql_source_query_param`. -const valueToChildRow = ( - sourceId: string, - scope: string, - name: string, - value: ConfiguredGraphqlCredentialValue, -): GraphqlChildValueInsert => { - const id = JSON.stringify([sourceId, name]); - if (typeof value === "string") { - return { - id, - scope_id: scope, - source_id: sourceId, - name, - kind: "text", - text_value: value, - }; - } +const operationData = (operation: StoredOperation) => ({ + toolId: operation.toolId, + sourceId: operation.sourceId, + binding: toJsonRecord(encodeBinding(operation.binding)), +}); + +const rowToSource = (row: PluginStorageEntry): StoredGraphqlSource | null => { + const decoded = decodeSourceStorage(row.data); + if (Option.isNone(decoded)) return null; + const source = decoded.value; return { - id, - scope_id: scope, - source_id: sourceId, - name, - kind: "binding", - slot_key: value.slot, - prefix: value.prefix, + namespace: source.namespace, + scope: source.scope, + name: source.name, + endpoint: source.endpoint, + headers: normalizeCredentialMap(source.headers), + queryParams: normalizeCredentialMap(source.queryParams), + auth: source.auth, }; }; -const rowToAuth = (row: typeof SourceRow.Type): GraphqlSourceAuth => { - if (row.auth_kind === "oauth2" && typeof row.auth_connection_slot === "string") { - return { kind: "oauth2", connectionSlot: row.auth_connection_slot }; - } - return { kind: "none" }; +const rowToOperation = (row: PluginStorageEntry): StoredOperation | null => { + const decoded = decodeOperationStorage(row.data); + if (Option.isNone(decoded)) return null; + const operation = decoded.value; + return { + toolId: operation.toolId, + sourceId: operation.sourceId, + binding: decodeBinding(operation.binding), + }; }; -// --------------------------------------------------------------------------- -// Store interface -// --------------------------------------------------------------------------- - -// Every read/write that targets a single row pins BOTH the natural id -// (namespace, toolId) AND the owning `scope_id`. Scope is a normal FumaDB -// predicate here, not hidden behavior. export interface GraphqlStore { readonly upsertSource: ( input: StoredGraphqlSource, operations: readonly StoredOperation[], ) => Effect.Effect; - readonly updateSourceMeta: ( namespace: string, scope: string, @@ -230,258 +161,126 @@ export interface GraphqlStore { readonly auth?: GraphqlSourceAuth; }, ) => Effect.Effect; - readonly getSource: ( namespace: string, scope: string, ) => Effect.Effect; - readonly listSources: () => Effect.Effect; - readonly getOperationByToolId: ( toolId: string, scope: string, ) => Effect.Effect; - readonly listOperationsBySource: ( sourceId: string, scope: string, ) => Effect.Effect; - readonly removeSource: (namespace: string, scope: string) => Effect.Effect; } -// --------------------------------------------------------------------------- -// Default store implementation -// --------------------------------------------------------------------------- - export const makeDefaultGraphqlStore = ({ - fuma, - scopes, + pluginStorage, }: StorageDeps): GraphqlStore => { - const scopeIds = scopes.map((scope) => String(scope.id)); - - const loadHeaders = (sourceId: string, scope: string) => - fuma - .use("graphql_source_header.findManyBySourceScope", (db) => - db.findMany("graphql_source_header", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ) - .pipe(Effect.map(rowsToValueMap)); - - const loadQueryParams = (sourceId: string, scope: string) => - fuma - .use("graphql_source_query_param.findManyBySourceScope", (db) => - db.findMany("graphql_source_query_param", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ) - .pipe(Effect.map(rowsToValueMap)); - - const rowToSourceWithChildren = ( - row: Record, - ): Effect.Effect => - Effect.gen(function* () { - const source = decodeSourceRow(row); - const sourceId = source.id; - const scope = source.scope_id; - const headers = yield* loadHeaders(sourceId, scope); - const queryParams = yield* loadQueryParams(sourceId, scope); - return { - namespace: sourceId, - scope, - name: source.name, - endpoint: source.endpoint, - headers, - queryParams, - auth: rowToAuth(source), - }; - }); - - const rowToOperation = (row: Record): StoredOperation => { - const operation = decodeOperationRow(row); - return { - toolId: operation.id, - sourceId: operation.source_id, - binding: decodeBinding(operation.binding), - }; - }; + const listOperationRowsForSourceScope = (sourceId: string, scope: string) => + pluginStorage + .list({ + collection: OPERATION_COLLECTION, + keyPrefix: `${sourceId}.`, + }) + .pipe( + Effect.map((rows) => + rows.filter((row) => { + if (String(row.scopeId) !== scope) return false; + return rowToOperation(row)?.sourceId === sourceId; + }), + ), + ); - // Replace child rows for a source by deleting then bulk-inserting. Used - // by both upsertSource (full rewrite) and updateSourceMeta (partial - // patch when headers/queryParams is supplied). - const replaceChildren = ( - tableName: "graphql_source_header" | "graphql_source_query_param", - sourceId: string, - scope: string, - values: Record, - ) => + const removeOperationsForSourceScope = (sourceId: string, scope: string) => Effect.gen(function* () { - yield* fuma.use(`${tableName}.deleteManyBySourceScope`, (db) => - db.deleteMany(tableName, { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ); - const entries = Object.entries(values); - if (entries.length === 0) return; - yield* fuma - .use(`${tableName}.createMany`, (db) => - db.createMany( - tableName, - entries.map(([name, value]) => valueToChildRow(sourceId, scope, name, value)), - ), - ) - .pipe(Effect.asVoid); + const rows = yield* listOperationRowsForSourceScope(sourceId, scope); + for (const row of rows) { + yield* pluginStorage.remove({ + scope, + collection: OPERATION_COLLECTION, + key: row.key, + }); + } }); const deleteSource = (namespace: string, scope: string) => Effect.gen(function* () { - yield* fuma.use("graphql_operation.deleteManyBySourceScope", (db) => - db.deleteMany("graphql_operation", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - yield* fuma.use("graphql_source_header.deleteManyBySourceScope", (db) => - db.deleteMany("graphql_source_header", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - yield* fuma.use("graphql_source_query_param.deleteManyBySourceScope", (db) => - db.deleteMany("graphql_source_query_param", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - yield* fuma.use("graphql_source.deleteManyByScopedId", (db) => - db.deleteMany("graphql_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); + yield* removeOperationsForSourceScope(namespace, scope); + yield* pluginStorage.remove({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + }); }); return { upsertSource: (input, operations) => Effect.gen(function* () { yield* deleteSource(input.namespace, input.scope); - yield* fuma.use("graphql_source.create", (db) => - db.create("graphql_source", { - id: input.namespace, - scope_id: input.scope, - name: input.name, - endpoint: input.endpoint, - auth_kind: input.auth.kind, - auth_connection_slot: input.auth.kind === "oauth2" ? input.auth.connectionSlot : null, - }), - ); - yield* replaceChildren( - "graphql_source_header", - input.namespace, - input.scope, - input.headers, - ); - yield* replaceChildren( - "graphql_source_query_param", - input.namespace, - input.scope, - input.queryParams, - ); - if (operations.length > 0) { - yield* fuma - .use("graphql_operation.createMany", (db) => - db.createMany( - "graphql_operation", - operations.map((op) => ({ - id: op.toolId, - scope_id: input.scope, - source_id: op.sourceId, - binding: toJsonRecord(encodeBinding(op.binding)), - })), - ), - ) - .pipe(Effect.asVoid); + yield* pluginStorage.put({ + scope: input.scope, + collection: SOURCE_COLLECTION, + key: input.namespace, + data: sourceData(input), + }); + for (const operation of operations) { + yield* pluginStorage.put({ + scope: input.scope, + collection: OPERATION_COLLECTION, + key: operation.toolId, + data: operationData(operation), + }); } }), updateSourceMeta: (namespace, scope, patch) => Effect.gen(function* () { - const existing = yield* fuma.use("graphql_source.findFirstByScopedId", (db) => - db.findFirst("graphql_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); + const existing = yield* pluginStorage.getAtScope({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + }); if (!existing) return; - const update: Partial<{ - name: string; - endpoint: string; - auth_kind: string; - auth_connection_slot: string | null; - }> = {}; - if (patch.name !== undefined) update.name = patch.name; - if (patch.endpoint !== undefined) update.endpoint = patch.endpoint; - if (patch.auth !== undefined) { - update.auth_kind = patch.auth.kind; - update.auth_connection_slot = - patch.auth.kind === "oauth2" ? patch.auth.connectionSlot : null; - } - if (Object.keys(update).length > 0) { - yield* fuma.use("graphql_source.updateManyByScopedId", (db) => - db.updateMany("graphql_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - set: update, - }), - ); - } - if (patch.headers !== undefined) { - yield* replaceChildren("graphql_source_header", namespace, scope, patch.headers); - } - if (patch.queryParams !== undefined) { - yield* replaceChildren("graphql_source_query_param", namespace, scope, patch.queryParams); - } + const source = rowToSource(existing); + if (!source) return; + yield* pluginStorage.put({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + data: sourceData({ + ...source, + name: patch.name ?? source.name, + endpoint: patch.endpoint ?? source.endpoint, + headers: patch.headers ?? source.headers, + queryParams: patch.queryParams ?? source.queryParams, + auth: patch.auth ?? source.auth, + }), + }); }), getSource: (namespace, scope) => - Effect.gen(function* () { - const row = yield* fuma.use("graphql_source.findFirstByScopedId", (db) => - db.findFirst("graphql_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - if (!row) return null; - return yield* rowToSourceWithChildren(row); - }), + pluginStorage + .getAtScope({ scope, collection: SOURCE_COLLECTION, key: namespace }) + .pipe(Effect.map((row) => (row ? rowToSource(row) : null))), listSources: () => - Effect.gen(function* () { - const rows = yield* fuma.use("graphql_source.findMany", (db) => - db.findMany("graphql_source", { - where: (b) => - scopeIds.length === 1 - ? b("scope_id", "=", scopeIds[0]!) - : b("scope_id", "in", [...scopeIds]), - }), - ); - return yield* Effect.forEach(rows, rowToSourceWithChildren, { - concurrency: "unbounded", - }); - }), + pluginStorage + .list({ collection: SOURCE_COLLECTION }) + .pipe(Effect.map((rows) => rows.map(rowToSource).filter(Predicate.isNotNull))), getOperationByToolId: (toolId, scope) => - fuma - .use("graphql_operation.findFirstByScopedId", (db) => - db.findFirst("graphql_operation", { - where: (b) => b.and(b("id", "=", toolId), b("scope_id", "=", scope)), - }), - ) + pluginStorage + .getAtScope({ scope, collection: OPERATION_COLLECTION, key: toolId }) .pipe(Effect.map((row) => (row ? rowToOperation(row) : null))), listOperationsBySource: (sourceId, scope) => - fuma - .use("graphql_operation.findManyBySourceScope", (db) => - db.findMany("graphql_operation", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ) - .pipe(Effect.map((rows) => rows.map(rowToOperation))), + listOperationRowsForSourceScope(sourceId, scope).pipe( + Effect.map((rows) => rows.map(rowToOperation).filter(Predicate.isNotNull)), + ), removeSource: (namespace, scope) => deleteSource(namespace, scope), }; diff --git a/packages/plugins/graphql/src/sdk/types.ts b/packages/plugins/graphql/src/sdk/types.ts index c6d55edb1..bf39200f6 100644 --- a/packages/plugins/graphql/src/sdk/types.ts +++ b/packages/plugins/graphql/src/sdk/types.ts @@ -3,10 +3,10 @@ import { ConfiguredCredentialValue, CredentialBindingValue, credentialSlotKey, - ScopedSecretCredentialInput, SecretBackedValue, ScopeId, } from "@executor-js/sdk/shared"; +import { HttpConfiguredValueInput, HttpCredentialInput } from "@executor-js/plugin-http-source/sdk"; // --------------------------------------------------------------------------- // GraphQL operation kind @@ -73,11 +73,9 @@ export type QueryParamValue = typeof QueryParamValue.Type; export const ConfiguredGraphqlCredentialValue = ConfiguredCredentialValue; export type ConfiguredGraphqlCredentialValue = typeof ConfiguredGraphqlCredentialValue.Type; -export const GraphqlCredentialInput = Schema.Union([ - ScopedSecretCredentialInput, - HeaderValue, - ConfiguredGraphqlCredentialValue, -]); +export const GraphqlConfiguredValueInput = HttpConfiguredValueInput; +export type GraphqlConfiguredValueInput = typeof GraphqlConfiguredValueInput.Type; +export const GraphqlCredentialInput = HttpCredentialInput; export type GraphqlCredentialInput = typeof GraphqlCredentialInput.Type; export const graphqlHeaderSlot = (name: string): string => credentialSlotKey("header", name); @@ -99,10 +97,15 @@ export const GraphqlSourceAuth = Schema.Union([ export type GraphqlSourceAuth = typeof GraphqlSourceAuth.Type; export const GraphqlSourceAuthInput = Schema.Union([ - GraphqlSourceAuth, Schema.Struct({ - kind: Schema.Literal("oauth2"), - connectionId: Schema.String, + kind: Schema.Literal("none"), + }), + Schema.Struct({ + oauth2: Schema.optional( + Schema.Struct({ + connection: Schema.optional(HttpCredentialInput), + }), + ), }), ]); export type GraphqlSourceAuthInput = typeof GraphqlSourceAuthInput.Type; @@ -110,15 +113,6 @@ 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, diff --git a/packages/plugins/http-source/CHANGELOG.md b/packages/plugins/http-source/CHANGELOG.md new file mode 100644 index 000000000..60531db46 --- /dev/null +++ b/packages/plugins/http-source/CHANGELOG.md @@ -0,0 +1,7 @@ +# @executor-js/plugin-http-source + +## 1.4.29 + +### Patch Changes + +- Initial shared HTTP source helpers package. diff --git a/packages/plugins/http-source/package.json b/packages/plugins/http-source/package.json new file mode 100644 index 000000000..cca50ff7f --- /dev/null +++ b/packages/plugins/http-source/package.json @@ -0,0 +1,79 @@ +{ + "name": "@executor-js/plugin-http-source", + "version": "1.4.29", + "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/http-source", + "bugs": { + "url": "https://github.com/RhysSullivan/executor/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/RhysSullivan/executor.git", + "directory": "packages/plugins/http-source" + }, + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": "./src/index.ts", + "./sdk": "./src/sdk/index.ts", + "./react": "./src/react/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./sdk": { + "import": { + "types": "./dist/sdk.d.ts", + "default": "./dist/sdk.js" + } + }, + "./react": { + "import": { + "types": "./dist/react.d.ts", + "default": "./dist/react.js" + } + } + } + }, + "scripts": { + "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", + "typecheck": "tsgo --noEmit", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest", + "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@executor-js/sdk": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/react": "workspace:*", + "@types/node": "catalog:", + "@types/react": "catalog:", + "bun-types": "catalog:", + "react": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "@executor-js/react": "workspace:*", + "react": "catalog:" + }, + "peerDependenciesMeta": { + "@executor-js/react": { + "optional": true + }, + "react": { + "optional": true + } + } +} diff --git a/packages/plugins/http-source/src/index.ts b/packages/plugins/http-source/src/index.ts new file mode 100644 index 000000000..d0f24d3bb --- /dev/null +++ b/packages/plugins/http-source/src/index.ts @@ -0,0 +1 @@ +export * from "./sdk/index"; diff --git a/packages/plugins/http-source/src/react/index.ts b/packages/plugins/http-source/src/react/index.ts new file mode 100644 index 000000000..88c1f4fa9 --- /dev/null +++ b/packages/plugins/http-source/src/react/index.ts @@ -0,0 +1,23 @@ +export { + emptyHttpCredentials, + HttpCredentialsEditor, + httpCredentialsFromValues, + httpCredentialsValid, + serializeHeaderCredentials, + serializeHttpCredentials, + serializeConfigureHttpCredentials, + serializeConfigureHeaderCredentials, + serializeConfigureQueryCredentials, + serializeTemplateHttpCredentials, + serializeTemplateHeaderCredentials, + serializeTemplateQueryCredentials, + serializeQueryCredentials, + serializeScopedHeaderCredentials, + serializeScopedHttpCredentials, + serializeScopedQueryCredentials, + type HttpCredentialsState, + type HttpConfigureCredentialInput, + type HttpTemplateCredentialInput, + type QueryParamState, + type SecretBackedValue, +} from "@executor-js/react/plugins/http-credentials"; diff --git a/packages/plugins/http-source/src/sdk/configure.ts b/packages/plugins/http-source/src/sdk/configure.ts new file mode 100644 index 000000000..1aba3a329 --- /dev/null +++ b/packages/plugins/http-source/src/sdk/configure.ts @@ -0,0 +1,183 @@ +import { Data, Effect } from "effect"; +import { + ConnectionId, + ConfiguredCredentialBinding, + type ConfiguredCredentialValue, + type CredentialBindingValue, + type ReplaceCredentialBindingValue, + type ScopedSecretCredentialInput, + SecretId, + ScopeId, +} from "@executor-js/sdk/shared"; + +import type { + HttpCredentialInput, + HttpRequestConfigureInput, + HttpRequestSourceConfig, +} from "./types"; + +export class UnknownHttpCredentialFieldError extends Data.TaggedError( + "UnknownHttpCredentialFieldError", +)<{ + readonly section: string; + readonly placement: "headers" | "query"; + readonly fieldName: string; + readonly declared: readonly string[]; +}> {} + +export type HttpNamedCredentialInput = + | ConfiguredCredentialValue + | ScopedSecretCredentialInput + | { + readonly secretId: string; + readonly prefix?: string; + readonly targetScope?: string; + readonly secretScopeId?: string; + }; + +export interface CompiledHttpNamedCredentialBinding { + readonly slot: string; + readonly value: CredentialBindingValue; + readonly targetScope?: string; +} + +export const compileHttpNamedCredentialMap = ( + values: Record | undefined, + slotForName: (name: string) => string, +): { + readonly values: Record; + readonly bindings: readonly CompiledHttpNamedCredentialBinding[]; +} => { + const nextValues: Record = {}; + const bindings: CompiledHttpNamedCredentialBinding[] = []; + for (const [name, value] of Object.entries(values ?? {})) { + if (typeof value === "string") { + nextValues[name] = value; + continue; + } + if ("kind" in value) { + if (value.kind === "binding") { + nextValues[name] = value; + continue; + } + const slot = slotForName(name); + nextValues[name] = ConfiguredCredentialBinding.make({ + kind: "binding", + slot, + prefix: "prefix" in value ? value.prefix : undefined, + }); + bindings.push({ + slot, + value: httpCredentialInputToBindingValue(value), + }); + continue; + } + const slot = slotForName(name); + nextValues[name] = ConfiguredCredentialBinding.make({ + kind: "binding", + slot, + prefix: value.prefix, + }); + bindings.push({ + slot, + targetScope: "targetScope" in value ? value.targetScope : undefined, + value: { + kind: "secret", + secretId: SecretId.make(value.secretId), + ...("secretScopeId" in value && value.secretScopeId + ? { secretScopeId: ScopeId.make(value.secretScopeId) } + : {}), + }, + }); + } + return { values: nextValues, bindings }; +}; + +export const httpCredentialInputToBindingValue = ( + input: HttpCredentialInput, +): CredentialBindingValue => { + if (typeof input === "string") { + return { + kind: "text", + text: input, + }; + } + if (input.kind === "text") { + return { + kind: "text", + text: input.text, + }; + } + if (input.kind === "secret") { + return { + kind: "secret", + secretId: SecretId.make(input.secretId), + ...(input.secretScope ? { secretScopeId: ScopeId.make(input.secretScope) } : {}), + }; + } + if (input.kind === "connection") { + return { + kind: "connection", + connectionId: ConnectionId.make(input.connectionId), + }; + } + return input; +}; + +export const compileHttpRequestConfigureBindings = (input: { + readonly section: string; + readonly sourceConfig: HttpRequestSourceConfig | undefined; + readonly configure: HttpRequestConfigureInput | undefined; +}): Effect.Effect => + Effect.gen(function* () { + const configure = input.configure; + if (!configure) return []; + + const bindings: ReplaceCredentialBindingValue[] = []; + + for (const [placement, configuredValues] of [ + ["headers", configure.headers], + ["query", configure.query], + ] as const) { + const declared = input.sourceConfig?.[placement] ?? {}; + for (const [name, value] of Object.entries(configuredValues ?? {})) { + const slot = declared[name]; + if (!slot) { + return yield* new UnknownHttpCredentialFieldError({ + section: input.section, + placement, + fieldName: name, + declared: Object.keys(declared), + }); + } + bindings.push({ + slotKey: slot.slotKey, + value: httpCredentialInputToBindingValue(value), + }); + } + } + + const oauth = configure.oauth; + if (oauth && input.sourceConfig?.oauth) { + if (oauth.clientId) { + bindings.push({ + slotKey: input.sourceConfig.oauth.clientIdSlot, + value: httpCredentialInputToBindingValue(oauth.clientId), + }); + } + if (oauth.clientSecret) { + bindings.push({ + slotKey: input.sourceConfig.oauth.clientSecretSlot ?? "", + value: httpCredentialInputToBindingValue(oauth.clientSecret), + }); + } + if (oauth.connection) { + bindings.push({ + slotKey: input.sourceConfig.oauth.connectionSlot, + value: httpCredentialInputToBindingValue(oauth.connection), + }); + } + } + + return bindings.filter((binding) => binding.slotKey.length > 0); + }); diff --git a/packages/plugins/http-source/src/sdk/index.ts b/packages/plugins/http-source/src/sdk/index.ts new file mode 100644 index 000000000..f93dc1140 --- /dev/null +++ b/packages/plugins/http-source/src/sdk/index.ts @@ -0,0 +1,53 @@ +export { + HttpCredentialInput, + HttpConfiguredValueInput, + HttpCredentialManifestEntry, + HttpCredentialSlotConfig, + HttpOAuthConfigureInput, + OAuth2Flow, + OAuth2SourceConfig, + HttpOAuthSourceConfig, + HttpOAuthTokenPlacement, + HttpRequestConfigureInput, + HttpRequestSourceConfig, + type HttpCredentialInput as HttpCredentialInputType, + type HttpConfiguredValueInput as HttpConfiguredValueInputType, + type HttpCredentialManifestEntry as HttpCredentialManifestEntryType, + type HttpCredentialSlotConfig as HttpCredentialSlotConfigType, + type HttpOAuthConfigureInput as HttpOAuthConfigureInputType, + type OAuth2Flow as OAuth2FlowType, + type OAuth2SourceConfig as OAuth2SourceConfigType, + type HttpOAuthSourceConfig as HttpOAuthSourceConfigType, + type HttpOAuthTokenPlacement as HttpOAuthTokenPlacementType, + type HttpRequestConfigureInput as HttpRequestConfigureInputType, + type HttpRequestSourceConfig as HttpRequestSourceConfigType, +} from "./types"; + +export { + httpCredentialSlotKey, + httpHeaderSlotKey, + httpOAuthClientIdSlotKey, + httpOAuthClientSecretSlotKey, + httpOAuthConnectionSlotKey, + httpQuerySlotKey, + httpSectionSlotPrefix, + type HttpCredentialPlacement, + type HttpCredentialSection, +} from "./slots"; + +export { + compileHttpNamedCredentialMap, + UnknownHttpCredentialFieldError, + compileHttpRequestConfigureBindings, + httpCredentialInputToBindingValue, + type CompiledHttpNamedCredentialBinding, + type HttpNamedCredentialInput, +} from "./configure"; + +export { deriveHttpCredentialManifest } from "./manifest"; + +export { + applyHttpRequestCredentials, + resolveHttpRequestCredentials, + type ResolvedHttpRequestCredentials, +} from "./resolve"; diff --git a/packages/plugins/http-source/src/sdk/manifest.ts b/packages/plugins/http-source/src/sdk/manifest.ts new file mode 100644 index 000000000..c222ca537 --- /dev/null +++ b/packages/plugins/http-source/src/sdk/manifest.ts @@ -0,0 +1,76 @@ +import type { HttpCredentialManifestEntry, HttpRequestSourceConfig } from "./types"; + +export const deriveHttpCredentialManifest = (input: { + readonly section: string; + readonly config: HttpRequestSourceConfig | undefined; +}): readonly HttpCredentialManifestEntry[] => { + const config = input.config; + if (!config) return []; + + const entries: HttpCredentialManifestEntry[] = []; + + for (const [name, slot] of Object.entries(config.headers ?? {})) { + entries.push({ + slotKey: slot.slotKey, + label: slot.label ?? name, + family: "http.header", + required: slot.required ?? false, + ...(slot.prefix ? { prefix: slot.prefix } : {}), + placement: { + section: input.section, + name, + }, + }); + } + + for (const [name, slot] of Object.entries(config.query ?? {})) { + entries.push({ + slotKey: slot.slotKey, + label: slot.label ?? name, + family: "http.query", + required: slot.required ?? false, + ...(slot.prefix ? { prefix: slot.prefix } : {}), + placement: { + section: input.section, + name, + }, + }); + } + + if (config.oauth) { + entries.push({ + slotKey: config.oauth.connectionSlot, + label: "OAuth connection", + family: "http.oauth", + required: true, + placement: { + section: input.section, + name: "oauth.connection", + }, + }); + entries.push({ + slotKey: config.oauth.clientIdSlot, + label: "OAuth client ID", + family: "http.oauth", + required: true, + placement: { + section: input.section, + name: "oauth.clientId", + }, + }); + if (config.oauth.clientSecretSlot) { + entries.push({ + slotKey: config.oauth.clientSecretSlot, + label: "OAuth client secret", + family: "http.oauth", + required: true, + placement: { + section: input.section, + name: "oauth.clientSecret", + }, + }); + } + } + + return entries; +}; diff --git a/packages/plugins/http-source/src/sdk/resolve.ts b/packages/plugins/http-source/src/sdk/resolve.ts new file mode 100644 index 000000000..bc64664a7 --- /dev/null +++ b/packages/plugins/http-source/src/sdk/resolve.ts @@ -0,0 +1,73 @@ +import { Effect } from "effect"; +import type { CredentialBindingRef } from "@executor-js/sdk/shared"; + +import type { HttpRequestSourceConfig } from "./types"; + +export interface ResolvedHttpRequestCredentials { + readonly headers?: Readonly>; + readonly query?: Readonly>; +} + +export const resolveHttpRequestCredentials = (input: { + readonly config: HttpRequestSourceConfig | undefined; + readonly resolveBinding: (slotKey: string) => Effect.Effect; + readonly getSecret: (id: string, scope: string | undefined) => Effect.Effect; + readonly getConnectionAccessToken?: (id: string) => Effect.Effect; +}): Effect.Effect => + Effect.gen(function* () { + const headers: Record = {}; + const query: Record = {}; + + for (const [target, config] of [ + [headers, input.config?.headers], + [query, input.config?.query], + ] as const) { + for (const [name, slot] of Object.entries(config ?? {})) { + const binding = yield* input.resolveBinding(slot.slotKey); + if (!binding) continue; + const value = yield* resolveBindingValue(binding, input); + if (value == null) continue; + target[name] = slot.prefix ? `${slot.prefix}${value}` : value; + } + } + + return { + ...(Object.keys(headers).length > 0 ? { headers } : {}), + ...(Object.keys(query).length > 0 ? { query } : {}), + }; + }); + +const resolveBindingValue = ( + binding: CredentialBindingRef, + input: { + readonly getSecret: (id: string, scope: string | undefined) => Effect.Effect; + readonly getConnectionAccessToken?: (id: string) => Effect.Effect; + }, +): Effect.Effect => { + if (binding.value.kind === "text") return Effect.succeed(binding.value.text); + if (binding.value.kind === "secret") { + return input.getSecret(binding.value.secretId, binding.value.secretScopeId); + } + if (input.getConnectionAccessToken) { + return input.getConnectionAccessToken(binding.value.connectionId); + } + return Effect.succeed(null); +}; + +export const applyHttpRequestCredentials = ( + url: URL, + init: RequestInit, + credentials: ResolvedHttpRequestCredentials, +): RequestInit => { + for (const [name, value] of Object.entries(credentials.query ?? {})) { + url.searchParams.set(name, value); + } + const headers = new Headers(init.headers); + for (const [name, value] of Object.entries(credentials.headers ?? {})) { + headers.set(name, value); + } + return { + ...init, + headers, + }; +}; diff --git a/packages/plugins/http-source/src/sdk/slots.ts b/packages/plugins/http-source/src/sdk/slots.ts new file mode 100644 index 000000000..403f2bdd0 --- /dev/null +++ b/packages/plugins/http-source/src/sdk/slots.ts @@ -0,0 +1,27 @@ +import { credentialSlotPart } from "@executor-js/sdk/shared"; + +export type HttpCredentialSection = "request" | "specFetch" | "introspection"; +export type HttpCredentialPlacement = "headers" | "query"; + +export const httpCredentialSlotKey = ( + section: HttpCredentialSection, + placement: HttpCredentialPlacement, + name: string, +): string => `${section}.${placement}.${credentialSlotPart(name)}`; + +export const httpHeaderSlotKey = (section: HttpCredentialSection, name: string): string => + httpCredentialSlotKey(section, "headers", name); + +export const httpQuerySlotKey = (section: HttpCredentialSection, name: string): string => + httpCredentialSlotKey(section, "query", name); + +export const httpOAuthConnectionSlotKey = (section: HttpCredentialSection): string => + `${section}.oauth.connection`; + +export const httpOAuthClientIdSlotKey = (section: HttpCredentialSection): string => + `${section}.oauth.clientId`; + +export const httpOAuthClientSecretSlotKey = (section: HttpCredentialSection): string => + `${section}.oauth.clientSecret`; + +export const httpSectionSlotPrefix = (section: HttpCredentialSection): string => `${section}.`; diff --git a/packages/plugins/http-source/src/sdk/types.ts b/packages/plugins/http-source/src/sdk/types.ts new file mode 100644 index 000000000..ce19dbd8b --- /dev/null +++ b/packages/plugins/http-source/src/sdk/types.ts @@ -0,0 +1,116 @@ +import { Schema } from "effect"; + +export const HttpCredentialSlotConfig = Schema.Struct({ + slotKey: Schema.String, + label: Schema.optional(Schema.String), + required: Schema.optional(Schema.Boolean), + prefix: Schema.optional(Schema.String), +}).annotate({ identifier: "HttpCredentialSlotConfig" }); +export type HttpCredentialSlotConfig = typeof HttpCredentialSlotConfig.Type; + +export const HttpOAuthTokenPlacement = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("header"), + name: Schema.String, + scheme: Schema.optional(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("query"), + name: Schema.String, + }), +]).annotate({ identifier: "HttpOAuthTokenPlacement" }); +export type HttpOAuthTokenPlacement = typeof HttpOAuthTokenPlacement.Type; + +export const HttpOAuthSourceConfig = Schema.Struct({ + authorizationUrl: Schema.NullOr(Schema.String), + tokenUrl: Schema.String, + issuerUrl: Schema.optional(Schema.NullOr(Schema.String)), + clientIdSlot: Schema.String, + clientSecretSlot: Schema.NullOr(Schema.String), + connectionSlot: Schema.String, + scopes: Schema.Array(Schema.String), + placement: HttpOAuthTokenPlacement, +}).annotate({ identifier: "HttpOAuthSourceConfig" }); +export type HttpOAuthSourceConfig = typeof HttpOAuthSourceConfig.Type; + +export const HttpRequestSourceConfig = Schema.Struct({ + headers: Schema.optional(Schema.Record(Schema.String, HttpCredentialSlotConfig)), + query: Schema.optional(Schema.Record(Schema.String, HttpCredentialSlotConfig)), + oauth: Schema.optional(HttpOAuthSourceConfig), +}).annotate({ identifier: "HttpRequestSourceConfig" }); +export type HttpRequestSourceConfig = typeof HttpRequestSourceConfig.Type; + +export const HttpCredentialInput = Schema.Union([ + Schema.String, + Schema.Struct({ + kind: Schema.Literal("text"), + text: Schema.String, + prefix: Schema.optional(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("secret"), + secretId: Schema.String, + secretScope: Schema.optional(Schema.String), + prefix: Schema.optional(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("connection"), + connectionId: Schema.String, + }), +]); +export type HttpCredentialInput = typeof HttpCredentialInput.Type; + +export const HttpConfiguredValueInput = Schema.Union([ + Schema.String, + Schema.Struct({ + kind: Schema.Literal("secret"), + prefix: Schema.optional(Schema.String), + }), +]); +export type HttpConfiguredValueInput = typeof HttpConfiguredValueInput.Type; + +export const OAuth2Flow = Schema.Literals(["authorizationCode", "clientCredentials"]); +export type OAuth2Flow = typeof OAuth2Flow.Type; + +export const OAuth2SourceConfig = Schema.Struct({ + kind: Schema.Literal("oauth2"), + securitySchemeName: Schema.String, + flow: OAuth2Flow, + tokenUrl: Schema.String, + authorizationUrl: Schema.NullOr(Schema.String), + issuerUrl: Schema.optional(Schema.NullOr(Schema.String)), + clientIdSlot: Schema.String, + clientSecretSlot: Schema.NullOr(Schema.String), + connectionSlot: Schema.String, + scopes: Schema.Array(Schema.String), +}).annotate({ identifier: "HttpOAuth2SourceConfig" }); +export type OAuth2SourceConfig = typeof OAuth2SourceConfig.Type; + +export const HttpOAuthConfigureInput = Schema.Struct({ + clientId: Schema.optional(HttpCredentialInput), + clientSecret: Schema.optional(Schema.NullOr(HttpCredentialInput)), + connection: Schema.optional(HttpCredentialInput), +}).annotate({ identifier: "HttpOAuthConfigureInput" }); +export type HttpOAuthConfigureInput = typeof HttpOAuthConfigureInput.Type; + +export const HttpRequestConfigureInput = Schema.Struct({ + headers: Schema.optional(Schema.Record(Schema.String, HttpCredentialInput)), + query: Schema.optional(Schema.Record(Schema.String, HttpCredentialInput)), + oauth: Schema.optional(HttpOAuthConfigureInput), +}).annotate({ identifier: "HttpRequestConfigureInput" }); +export type HttpRequestConfigureInput = typeof HttpRequestConfigureInput.Type; + +export const HttpCredentialManifestEntry = Schema.Struct({ + slotKey: Schema.String, + label: Schema.String, + family: Schema.Literals(["http.header", "http.query", "http.oauth"]), + required: Schema.Boolean, + prefix: Schema.optional(Schema.String), + placement: Schema.optional( + Schema.Struct({ + section: Schema.String, + name: Schema.String, + }), + ), +}).annotate({ identifier: "HttpCredentialManifestEntry" }); +export type HttpCredentialManifestEntry = typeof HttpCredentialManifestEntry.Type; diff --git a/packages/plugins/http-source/tsconfig.json b/packages/plugins/http-source/tsconfig.json new file mode 100644 index 000000000..1504bed72 --- /dev/null +++ b/packages/plugins/http-source/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM"], + "types": ["bun-types", "node"], + "noUnusedLocals": true, + "noImplicitOverride": true, + "jsx": "react-jsx", + "plugins": [ + { + "name": "@effect/language-service", + "ignoreEffectSuggestionsInTscExitCode": true, + "ignoreEffectWarningsInTscExitCode": true, + "diagnosticSeverity": {} + } + ] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/plugins/http-source/tsup.config.ts b/packages/plugins/http-source/tsup.config.ts new file mode 100644 index 000000000..285c3758d --- /dev/null +++ b/packages/plugins/http-source/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + sdk: "src/sdk/index.ts", + react: "src/react/index.ts", + }, + format: ["esm"], + dts: false, + sourcemap: true, + clean: true, + external: [/^@executor-js\//, /^effect/, /^@effect\//, /^react/], +}); diff --git a/packages/plugins/http-source/vitest.config.ts b/packages/plugins/http-source/vitest.config.ts new file mode 100644 index 000000000..324c1ee66 --- /dev/null +++ b/packages/plugins/http-source/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + }, +}); diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index 8d1ce175c..4db5e8160 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -64,6 +64,7 @@ "@cfworker/json-schema": "^4.1.1", "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", + "@executor-js/plugin-http-source": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "effect": "catalog:" diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 697cb0ad8..4267d9d3d 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -4,12 +4,8 @@ import { InternalError, ScopeId, SecretBackedMap } from "@executor-js/sdk/shared import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; import { McpStoredSourceSchema } from "../sdk/stored-source"; -import { - McpConnectionAuthInput, - McpCredentialInput, - McpSourceBindingInput, - McpSourceBindingRef, -} from "../sdk/types"; +import { McpConfiguredValueInput } from "../sdk/types"; +import { OAuth2SourceConfig } from "@executor-js/plugin-http-source/sdk"; // --------------------------------------------------------------------------- // Params @@ -17,17 +13,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) -// --------------------------------------------------------------------------- - -const AuthPayload = McpConnectionAuthInput; const StringMap = Schema.Record(Schema.String, Schema.String); // --------------------------------------------------------------------------- @@ -35,20 +20,17 @@ const StringMap = Schema.Record(Schema.String, Schema.String); // --------------------------------------------------------------------------- const AddRemoteSourcePayload = Schema.Struct({ - targetScope: ScopeId, transport: Schema.Literal("remote"), name: Schema.String, endpoint: Schema.String, remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])), namespace: Schema.optional(Schema.String), - queryParams: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), - headers: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), - auth: Schema.optional(AuthPayload), - credentialTargetScope: Schema.optional(ScopeId), + queryParams: Schema.optional(Schema.Record(Schema.String, McpConfiguredValueInput)), + headers: Schema.optional(Schema.Record(Schema.String, McpConfiguredValueInput)), + oauth2: Schema.optional(OAuth2SourceConfig), }); const AddStdioSourcePayload = Schema.Struct({ - targetScope: ScopeId, transport: Schema.Literal("stdio"), name: Schema.String, command: Schema.String, @@ -60,24 +42,6 @@ const AddStdioSourcePayload = Schema.Struct({ const AddSourcePayload = Schema.Union([AddRemoteSourcePayload, AddStdioSourcePayload]); -// --------------------------------------------------------------------------- -// Other payloads -// --------------------------------------------------------------------------- - -const UpdateSourcePayload = Schema.Struct({ - sourceScope: ScopeId, - name: Schema.optional(Schema.String), - endpoint: Schema.optional(Schema.String), - headers: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), - queryParams: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), - credentialTargetScope: Schema.optional(ScopeId), - auth: Schema.optional(AuthPayload), -}); - -const UpdateSourceResponse = Schema.Struct({ - updated: Schema.Boolean, -}); - const ProbeEndpointPayload = Schema.Struct({ endpoint: Schema.String, headers: Schema.optional(SecretBackedMap), @@ -98,13 +62,6 @@ const NamespacePayload = Schema.Struct({ namespace: Schema.String, }); -const RemoveBindingPayload = Schema.Struct({ - sourceId: Schema.String, - sourceScope: ScopeId, - slot: Schema.String, - scope: ScopeId, -}); - // --------------------------------------------------------------------------- // Responses // --------------------------------------------------------------------------- @@ -177,41 +134,6 @@ export const McpGroup = HttpApiGroup.make("mcp") success: Schema.NullOr(McpStoredSourceSchema), error: [InternalError, McpConnectionError, McpToolDiscoveryError], }), - ) - .add( - HttpApiEndpoint.patch("updateSource", "/scopes/:scopeId/mcp/sources/:namespace", { - params: SourceParams, - payload: UpdateSourcePayload, - 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..b245e5d87 100644 --- a/packages/plugins/mcp/src/api/handlers.test.ts +++ b/packages/plugins/mcp/src/api/handlers.test.ts @@ -28,10 +28,6 @@ const failingExtension: McpPluginExtension = { removeSource: () => unused, 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..542765008 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -3,13 +3,9 @@ import { Context, Effect } from "effect"; import { addGroup, capture } from "@executor-js/api"; import { ScopeId } from "@executor-js/sdk/core"; -import type { - McpPluginExtension, - McpProbeEndpointInput, - McpSourceConfig, - McpUpdateSourceInput, -} from "../sdk/plugin"; -import type { McpCredentialInput } from "../sdk/types"; +import type { OAuth2SourceConfigType } from "@executor-js/plugin-http-source/sdk"; +import type { McpPluginExtension, McpProbeEndpointInput, McpSourceConfig } from "../sdk/plugin"; +import type { McpConfiguredValueInput } from "../sdk/types"; import { McpStoredSourceSchema } from "../sdk/stored-source"; import { McpGroup } from "./group"; @@ -66,11 +62,10 @@ const toSourceConfig = ( name: string; endpoint: string; remoteTransport?: "streamable-http" | "sse" | "auto"; - queryParams?: Record; - headers?: Record; + queryParams?: Record; + headers?: Record; namespace?: string; - auth?: { kind: string } & Record; - credentialTargetScope?: string; + oauth2?: OAuth2SourceConfigType; }; return { @@ -82,8 +77,7 @@ const toSourceConfig = ( queryParams: p.queryParams, headers: p.headers, namespace: p.namespace, - auth: p.auth as McpSourceConfig extends { auth?: infer A } ? A : never, - credentialTargetScope: p.credentialTargetScope, + oauth2: p.oauth2, }; }; @@ -110,12 +104,12 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand }), ), ) - .handle("addSource", ({ payload }) => + .handle("addSource", ({ params: path, payload }) => capture( Effect.gen(function* () { const ext = yield* McpExtensionService; return yield* ext.addSource( - toSourceConfig(payload as Parameters[0], payload.targetScope), + toSourceConfig(payload as Parameters[0], path.scopeId), ); }), ), @@ -152,51 +146,5 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand : null; }), ), - ) - .handle("updateSource", ({ params: path, payload }) => - capture( - Effect.gen(function* () { - const ext = yield* McpExtensionService; - yield* ext.updateSource(path.namespace, payload.sourceScope, { - name: payload.name, - endpoint: payload.endpoint, - headers: payload.headers, - queryParams: payload.queryParams, - credentialTargetScope: payload.credentialTargetScope, - auth: payload.auth as McpUpdateSourceInput["auth"], - }); - 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/promise.ts b/packages/plugins/mcp/src/promise.ts index 0334cb51b..223d18cff 100644 --- a/packages/plugins/mcp/src/promise.ts +++ b/packages/plugins/mcp/src/promise.ts @@ -6,5 +6,5 @@ export type { McpRemoteSourceConfig, McpStdioSourceConfig, McpProbeResult, - McpUpdateSourceInput, + McpConfigureSourceInput, } from "./sdk/plugin"; diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index 6efbb94b0..1d9ba3fa9 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { useScope } from "@executor-js/react/api/scope-context"; +import { configureSource } from "@executor-js/react/api/atoms"; import { Button } from "@executor-js/react/components/button"; import { CardStack, @@ -24,9 +25,10 @@ import { emptyHttpCredentials, httpCredentialsValid, HttpCredentialsEditor, - serializeScopedHttpCredentials, + serializeConfigureHttpCredentials, serializeHttpCredentials, -} from "@executor-js/react/plugins/http-credentials"; + serializeTemplateHttpCredentials, +} from "@executor-js/plugin-http-source/react"; import { sourceDisplayNameFromUrl, slugifyNamespace, @@ -51,7 +53,7 @@ import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; import { mcpPresets, type McpPreset } from "../sdk/presets"; -import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types"; +import type { McpConfiguredValueInput, McpCredentialInput } from "../sdk/types"; const ErrorMessage = Schema.Struct({ message: Schema.String }); const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); @@ -304,6 +306,7 @@ export default function AddMcpSource(props: { const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), { mode: "promiseExit", }); + const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); const secretList = useSecretPickerSecrets(); const oauth = useOAuthPopupFlow({ popupName: "mcp-oauth", @@ -437,49 +440,37 @@ export default function AddMcpSource(props: { const handleAddRemote = useCallback(async () => { if (!probe) return; dispatch({ type: "add-start" }); - const auth = - remoteAuthMode === "oauth2" - ? tokens - ? { - kind: "oauth2" as const, - connectionId: tokens.connectionId, - } - : { - kind: "oauth2" as const, - connectionSlot: MCP_OAUTH_CONNECTION_SLOT, - } - : { kind: "none" as const }; const headers = Object.fromEntries( remoteHeaders .map((header) => [header.name.trim(), header.value.trim()] as const) .filter(([name, value]) => name && value), ); - const credentials = serializeScopedHttpCredentials( + const templateCredentials = serializeTemplateHttpCredentials(remoteCredentials); + const configureCredentials = serializeConfigureHttpCredentials( remoteCredentials, requestCredentialTargetScope, ); - const remoteRequestHeaders: Record = { + const remoteRequestHeaders: Record = { ...headers, - ...credentials.headers, + ...templateCredentials.headers, }; const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name; const slugNamespace = slugifyNamespace(remoteIdentity.namespace); const exit = await doAdd({ params: { scopeId }, payload: { - targetScope: scopeId, transport: "remote" as const, name: displayName, namespace: slugNamespace || undefined, endpoint: state.url.trim(), - auth, - credentialTargetScope: - remoteAuthMode === "oauth2" && tokens - ? oauthCredentialTargetScope - : requestCredentialTargetScope, ...(Object.keys(remoteRequestHeaders).length > 0 ? { headers: remoteRequestHeaders } : {}), - ...(Object.keys(credentials.queryParams).length > 0 - ? { queryParams: credentials.queryParams } + ...(Object.keys(templateCredentials.queryParams).length > 0 + ? { + queryParams: templateCredentials.queryParams as Record< + string, + McpConfiguredValueInput + >, + } : {}), }, reactivityKeys: sourceWriteKeys, @@ -491,6 +482,55 @@ export default function AddMcpSource(props: { }); return; } + if ( + Object.keys(configureCredentials.headers).length > 0 || + Object.keys(configureCredentials.queryParams).length > 0 || + (remoteAuthMode === "oauth2" && tokens) + ) { + const configureExit = await doConfigure({ + params: { scopeId }, + payload: { + source: { id: exit.value.namespace, scope: scopeId }, + scope: requestCredentialTargetScope, + type: "mcp", + config: { + ...(Object.keys(configureCredentials.headers).length > 0 + ? { + headers: configureCredentials.headers as Record, + } + : {}), + ...(Object.keys(configureCredentials.queryParams).length > 0 + ? { + queryParams: configureCredentials.queryParams as Record< + string, + McpCredentialInput + >, + } + : {}), + ...(remoteAuthMode === "oauth2" && tokens + ? { + auth: { + oauth2: { + connection: { + kind: "connection" as const, + connectionId: tokens.connectionId, + }, + }, + }, + } + : {}), + }, + }, + reactivityKeys: sourceWriteKeys, + }); + if (Exit.isFailure(configureExit)) { + dispatch({ + type: "add-fail", + error: errorMessageFromExit(configureExit, "Failed to configure source"), + }); + return; + } + } props.onComplete(); }, [ probe, @@ -501,10 +541,10 @@ export default function AddMcpSource(props: { tokens, state.url, doAdd, + doConfigure, props, scopeId, requestCredentialTargetScope, - oauthCredentialTargetScope, ]); // ---- Stdio actions ---- @@ -560,7 +600,6 @@ export default function AddMcpSource(props: { const exit = await doAdd({ params: { scopeId }, payload: { - targetScope: scopeId, transport: "stdio" as const, name: displayName, namespace: slugNamespace || undefined, diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index 00d48a859..263504468 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, mcpSourceBindingsAtom } from "./atoms"; import { - mcpSourceAtom, - mcpSourceBindingsAtom, - setMcpSourceBinding, - updateMcpSource, -} from "./atoms"; -import { connectionsAtom } from "@executor-js/react/api/atoms"; + configureSource, + connectionsAtom, + setSourceCredentialBinding, +} 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"; @@ -16,10 +15,10 @@ import { useCredentialTargetScope } from "@executor-js/react/plugins/credential- import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; import { HttpCredentialsEditor, + serializeConfigureHttpCredentials, serializeHttpCredentials, - serializeScopedHttpCredentials, type HttpCredentialsState, -} from "@executor-js/react/plugins/http-credentials"; +} from "@executor-js/plugin-http-source/react"; import { effectiveCredentialBindingForScope, httpCredentialsFromConfiguredCredentialBindings, @@ -30,11 +29,7 @@ import { Button } from "@executor-js/react/components/button"; import { Badge } from "@executor-js/react/components/badge"; import { ScopeId } from "@executor-js/sdk/shared"; import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; -import { - McpSourceBindingInput, - type McpCredentialInput, - type McpSourceBindingRef, -} from "../sdk/types"; +import { type McpCredentialInput, type McpSourceBindingRef } from "../sdk/types"; import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; // --------------------------------------------------------------------------- @@ -61,8 +56,8 @@ function RemoteEditForm(props: { sourceScope, initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), }); - const doUpdate = useAtomSet(updateMcpSource, { mode: "promiseExit" }); - const setBinding = useAtomSet(setMcpSourceBinding, { mode: "promise" }); + const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); + const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const secretList = useSecretPickerSecrets(); const connectionsResult = useAtomValue(connectionsAtom(displayScope)); @@ -111,30 +106,31 @@ function RemoteEditForm(props: { const handleSave = async () => { setSaving(true); setError(null); - const { headers, queryParams } = serializeScopedHttpCredentials( + const { headers, queryParams } = serializeConfigureHttpCredentials( credentials, credentialTargetScope, ); - const payload: { - sourceScope: ScopeId; + const config: { name?: string; endpoint?: string; headers?: Record; queryParams?: Record; - credentialTargetScope?: ScopeId; } = { - sourceScope, name: metadataDirty ? identity.name.trim() || undefined : undefined, endpoint: metadataDirty ? endpoint.trim() || undefined : undefined, }; if (credentialsDirty) { - payload.headers = headers; - payload.queryParams = queryParams as Record; - payload.credentialTargetScope = credentialTargetScope; + config.headers = headers; + config.queryParams = queryParams as Record; } - const exit = await doUpdate({ - params: { scopeId: displayScope, namespace: props.sourceId }, - payload, + const exit = await doConfigure({ + params: { scopeId: displayScope }, + payload: { + source: { id: props.sourceId, scope: sourceScope }, + scope: credentialTargetScope, + type: "mcp", + config, + }, reactivityKeys: sourceWriteKeys, }); if (Exit.isFailure(exit)) { @@ -206,13 +202,12 @@ function RemoteEditForm(props: { onConnected={async (connectionId) => { await setBinding({ params: { scopeId: oauthCredentialTargetScope }, - payload: McpSourceBindingInput.make({ - sourceId: props.sourceId, - sourceScope, + payload: { scope: oauthCredentialTargetScope, - slot: oauth2.connectionSlot, + source: { id: props.sourceId, scope: sourceScope }, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId }, - }), + }, reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], }); }} diff --git a/packages/plugins/mcp/src/react/McpSignInButton.tsx b/packages/plugins/mcp/src/react/McpSignInButton.tsx index d57ce641d..165211328 100644 --- a/packages/plugins/mcp/src/react/McpSignInButton.tsx +++ b/packages/plugins/mcp/src/react/McpSignInButton.tsx @@ -4,14 +4,13 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { ScopeId } from "@executor-js/sdk/shared"; 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 } 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, mcpSourceBindingsAtom } from "./atoms"; import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; -import { McpSourceBindingInput } from "../sdk/types"; // --------------------------------------------------------------------------- // McpSignInButton — top-bar action on the source detail page. @@ -34,7 +33,7 @@ export default function McpSignInButton(props: { sourceId: string }) { mcpSourceBindingsAtom(userScopeId, 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; @@ -76,13 +75,12 @@ export default function McpSignInButton(props: { sourceId: string }) { onConnected={async (nextConnectionId) => { await setBinding({ params: { scopeId: userScopeId }, - payload: McpSourceBindingInput.make({ - sourceId: props.sourceId, - sourceScope, + payload: { scope: userScopeId, - slot: oauth2.connectionSlot, + source: { id: props.sourceId, scope: sourceScope }, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: nextConnectionId }, - }), + }, reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], }); }} diff --git a/packages/plugins/mcp/src/react/atoms.ts b/packages/plugins/mcp/src/react/atoms.ts index da0b59730..86ac9b4ac 100644 --- a/packages/plugins/mcp/src/react/atoms.ts +++ b/packages/plugins/mcp/src/react/atoms.ts @@ -1,9 +1,10 @@ import type { ScopeId } from "@executor-js/sdk/shared"; import * as Atom from "effect/unstable/reactivity/Atom"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; +import { sourceCredentialBindingsAtom, sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; import { McpClient } from "./client"; +import { McpSourceBindingRef } from "../sdk/types"; // --------------------------------------------------------------------------- // Query atoms @@ -21,11 +22,19 @@ export const mcpSourceBindingsAtom = ( namespace: string, sourceScopeId: ScopeId, ) => - McpClient.query("mcp", "listSourceBindings", { - params: { scopeId, namespace, sourceScopeId }, - timeToLive: "15 seconds", - reactivityKeys: [ReactivityKey.sources, ReactivityKey.secrets, ReactivityKey.connections], - }); + Atom.mapResult(sourceCredentialBindingsAtom(scopeId, namespace, sourceScopeId), (rows) => + rows.map((row) => + McpSourceBindingRef.make({ + sourceId: row.sourceId, + sourceScopeId: row.sourceScopeId, + scopeId: row.scopeId, + slot: row.slotKey, + value: row.value, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }), + ), + ); // --------------------------------------------------------------------------- // Mutation atoms @@ -41,7 +50,7 @@ export const addMcpSourceOptimistic = Atom.family((scopeId: ScopeId) => const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; const source = { id, - scopeId: arg.payload.targetScope, + scopeId, kind: "mcp", pluginId: "mcp", name: arg.payload.name ?? id, @@ -61,6 +70,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/binding-store.ts b/packages/plugins/mcp/src/sdk/binding-store.ts index 8b9d8a9f0..adc6c40e1 100644 --- a/packages/plugins/mcp/src/sdk/binding-store.ts +++ b/packages/plugins/mcp/src/sdk/binding-store.ts @@ -1,98 +1,22 @@ -// --------------------------------------------------------------------------- -// MCP plugin storage — four tables: -// - mcp_source: per-source structural data (transport, endpoint, -// stdio command/args/env, etc.) plus the auth flattened into -// columns so source-owned credential slots are queryable. The non-ref -// structural data still lives in `config` as JSON because it's -// plugin-private and varies by transport (`remote` vs `stdio` -// have different shapes). -// - mcp_source_header / mcp_source_query_param: child tables for -// remote sources' headers and query_params SecretBackedMap entries. -// - mcp_binding: per-tool McpToolBinding (toolId/toolName/description/ -// input+output schemas/annotations). Stays JSON: it carries no -// refs, and `inputSchema` / `outputSchema` are arbitrary -// user-supplied JSON Schemas — a legitimate JSON case. -// OAuth session storage lives at the core level in `oauth2_session` -// and is owned by `ctx.oauth`. -// --------------------------------------------------------------------------- - -import { Effect, Option, Schema } from "effect"; +import { Effect, Option, Predicate, Schema } from "effect"; import { - ConfiguredCredentialBinding, - dateColumn, type FumaTables, - jsonColumn, - nullableTextColumn, - scopedExecutorTable, + type PluginStorageEntry, type StorageDeps, type StorageFailure, - textColumn, } from "@executor-js/sdk/core"; -import { - McpToolBinding, - McpStoredSourceData, - type McpConnectionAuth, - type ConfiguredMcpCredentialValue, -} from "./types"; - -// --------------------------------------------------------------------------- -// Schema -// --------------------------------------------------------------------------- - -export const mcpSchema = { - mcp_source: scopedExecutorTable("mcp_source", { - name: textColumn("name"), - // Plugin-private structural data minus the ref-bearing fields - // (auth, headers, queryParams). For remote sources: transport, - // endpoint, remoteTransport. For stdio: transport, command, - // args, env, cwd. - config: jsonColumn("config"), - // Flattened McpConnectionAuth. The stored source only names slots; - // concrete per-user/per-workspace values live in core credential_binding. - auth_kind: textColumn("auth_kind").defaultTo("none"), - auth_header_name: nullableTextColumn("auth_header_name"), - auth_header_slot: nullableTextColumn("auth_header_slot"), - auth_header_prefix: nullableTextColumn("auth_header_prefix"), - auth_connection_slot: nullableTextColumn("auth_connection_slot"), - auth_client_id_slot: nullableTextColumn("auth_client_id_slot"), - auth_client_secret_slot: nullableTextColumn("auth_client_secret_slot"), - created_at: dateColumn("created_at"), - }), - mcp_source_header: scopedExecutorTable("mcp_source_header", { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }), - mcp_source_query_param: scopedExecutorTable("mcp_source_query_param", { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }), - mcp_binding: scopedExecutorTable("mcp_binding", { - source_id: textColumn("source_id"), - binding: jsonColumn("binding"), - created_at: dateColumn("created_at"), - }), -} satisfies FumaTables; +import { McpStoredSourceData, McpToolBinding } from "./types"; +export const mcpSchema = {} satisfies FumaTables; export type McpSchema = typeof mcpSchema; -// --------------------------------------------------------------------------- -// Serialization helpers — JSON columns round-trip as either plain objects -// or serialized strings depending on the backend. -// --------------------------------------------------------------------------- +const SOURCE_COLLECTION = "source"; +const BINDING_COLLECTION = "binding"; const decodeSourceData = Schema.decodeUnknownSync(McpStoredSourceData); const encodeSourceData = Schema.encodeSync(McpStoredSourceData); - const decodeBinding = Schema.decodeUnknownSync(McpToolBinding); const encodeBinding = Schema.encodeSync(McpToolBinding); const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); @@ -102,145 +26,13 @@ const coerceJson = (value: unknown): unknown => { return Option.getOrElse(decodeJson(value), () => value); }; -// --- auth column packing/unpacking ------------------------------------------ - -interface AuthColumns { - readonly auth_kind: "none" | "header" | "oauth2"; - readonly auth_header_name?: string; - readonly auth_header_slot?: string; - readonly auth_header_prefix?: string; - readonly auth_connection_slot?: string; - readonly auth_client_id_slot?: string; - readonly auth_client_secret_slot?: string; -} - -const authToColumns = (auth: McpConnectionAuth): AuthColumns => { - if (auth.kind === "header") { - return { - auth_kind: "header", - auth_header_name: auth.headerName, - auth_header_slot: auth.secretSlot, - auth_header_prefix: auth.prefix, - }; - } - if (auth.kind === "oauth2") { - return { - auth_kind: "oauth2", - auth_connection_slot: auth.connectionSlot, - auth_client_id_slot: auth.clientIdSlot, - auth_client_secret_slot: auth.clientSecretSlot, - }; - } - return { auth_kind: "none" }; -}; - -const columnsToAuth = (row: Record): McpConnectionAuth => { - const kind = row.auth_kind; - if (kind === "header" && typeof row.auth_header_slot === "string") { - const prefix = row.auth_header_prefix as string | null | undefined; - return { - kind: "header", - headerName: (row.auth_header_name as string | null) ?? "", - secretSlot: row.auth_header_slot, - ...(prefix ? { prefix } : {}), - }; - } - if (kind === "oauth2" && typeof row.auth_connection_slot === "string") { - const cid = row.auth_client_id_slot as string | null | undefined; - const csec = row.auth_client_secret_slot as string | null | undefined; - return { - kind: "oauth2", - connectionSlot: row.auth_connection_slot, - ...(cid ? { clientIdSlot: cid } : {}), - ...(csec ? { clientSecretSlot: csec } : {}), - }; - } - return { kind: "none" }; -}; - -// --- ConfiguredCredentialValue map <-> child rows --------------------------- - -interface ConfiguredCredentialRow { - readonly id: string; - readonly scope_id: string; - readonly source_id: string; - readonly name: string; - readonly kind: "text" | "binding"; - readonly text_value?: string; - readonly slot_key?: string; - readonly prefix?: string; - readonly [k: string]: unknown; -} - -const valueMapToRows = ( - sourceId: string, - scope: string, - values: Record | undefined, -): readonly ConfiguredCredentialRow[] => { - if (!values) return []; - return Object.entries(values).map(([name, value]) => { - const id = JSON.stringify([sourceId, name]); - if (typeof value === "string") { - return { - id, - scope_id: scope, - source_id: sourceId, - name, - kind: "text", - text_value: value, - }; - } - return { - id, - scope_id: scope, - source_id: sourceId, - name, - kind: "binding", - slot_key: value.slot, - prefix: value.prefix, - }; - }); -}; - -const rowsToValueMap = ( - rows: readonly Record[], -): Record => { - const out: Record = {}; - for (const row of rows) { - if (typeof row.name !== "string") continue; - const name = row.name; - if (row.kind === "binding" && typeof row.slot_key === "string") { - const prefix = row.prefix as string | undefined | null; - out[name] = prefix - ? ConfiguredCredentialBinding.make({ kind: "binding", slot: row.slot_key, prefix }) - : ConfiguredCredentialBinding.make({ kind: "binding", slot: row.slot_key }); - } else if (row.kind === "text" && typeof row.text_value === "string") { - out[name] = row.text_value; - } - } - return out; -}; - -// --------------------------------------------------------------------------- -// Stored source (decoded) — what callers see -// --------------------------------------------------------------------------- - export interface McpStoredSource { readonly namespace: string; - /** Executor scope id this source row lives in. Writes stamp this on - * `scope_id`; reads choose scope explicitly in the FumaDB query. */ readonly scope: string; readonly name: string; readonly config: McpStoredSourceData; } -// --------------------------------------------------------------------------- -// Store interface -// --------------------------------------------------------------------------- - -// Every read/write that targets a single row pins BOTH the natural id -// (namespace, toolId, sessionId) AND the owning `scope_id`. Scope is a -// normal FumaDB predicate here, not hidden behavior. export interface McpBindingStore { readonly listBindingsBySource: ( namespace: string, @@ -252,7 +44,6 @@ export interface McpBindingStore { }>, StorageFailure >; - readonly getBinding: ( toolId: string, scope: string, @@ -260,7 +51,6 @@ export interface McpBindingStore { { readonly binding: McpToolBinding; readonly namespace: string } | null, StorageFailure >; - readonly putBindings: ( namespace: string, scope: string, @@ -269,12 +59,10 @@ export interface McpBindingStore { readonly binding: McpToolBinding; }>, ) => Effect.Effect; - readonly removeBindingsByNamespace: ( namespace: string, scope: string, ) => Effect.Effect; - readonly getSource: ( namespace: string, scope: string, @@ -287,228 +75,154 @@ export interface McpBindingStore { readonly removeSource: (namespace: string, scope: string) => Effect.Effect; } -// --------------------------------------------------------------------------- -// Factory -// --------------------------------------------------------------------------- +const sourceData = (source: McpStoredSource) => ({ + namespace: source.namespace, + scope: source.scope, + name: source.name, + config: encodeSourceData(source.config), +}); + +const bindingData = ( + namespace: string, + entry: { + readonly toolId: string; + readonly binding: McpToolBinding; + }, +) => ({ + namespace, + toolId: entry.toolId, + binding: encodeBinding(entry.binding), +}); + +const rowToSource = (row: PluginStorageEntry): McpStoredSource | null => { + const raw = coerceJson(row.data); + if (!raw || typeof raw !== "object") return null; + const record = raw as Record; + if ( + typeof record.namespace !== "string" || + typeof record.scope !== "string" || + typeof record.name !== "string" + ) { + return null; + } + return { + namespace: record.namespace, + scope: record.scope, + name: record.name, + config: decodeSourceData(coerceJson(record.config)), + }; +}; -export const makeMcpStore = ({ fuma }: StorageDeps): McpBindingStore => { +const rowToBinding = ( + row: PluginStorageEntry, +): { + readonly toolId: string; + readonly namespace: string; + readonly binding: McpToolBinding; +} | null => { + const raw = coerceJson(row.data); + if (!raw || typeof raw !== "object") return null; + const record = raw as Record; + if (typeof record.toolId !== "string" || typeof record.namespace !== "string") return null; return { - listBindingsBySource: (namespace, scope) => - Effect.gen(function* () { - const rows = yield* fuma.use("mcp_binding.findManyBySourceScope", (db) => - db.findMany("mcp_binding", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), + toolId: record.toolId, + namespace: record.namespace, + binding: decodeBinding(coerceJson(record.binding)), + }; +}; + +export const makeMcpStore = ({ pluginStorage }: StorageDeps): McpBindingStore => { + const listBindingRowsForSourceScope = (namespace: string, scope: string) => + pluginStorage + .list({ + collection: BINDING_COLLECTION, + keyPrefix: `${namespace}.`, + }) + .pipe( + Effect.map((rows) => + rows.filter((row) => { + if (String(row.scopeId) !== scope) return false; + return rowToBinding(row)?.namespace === namespace; }), - ); - return rows.map((row) => ({ - toolId: String(row.id), - binding: decodeBinding(coerceJson(row.binding)), - })); - }), + ), + ); + + const removeBindingsForSourceScope = (namespace: string, scope: string) => + Effect.gen(function* () { + const rows = yield* listBindingRowsForSourceScope(namespace, scope); + for (const row of rows) { + yield* pluginStorage.remove({ + scope, + collection: BINDING_COLLECTION, + key: row.key, + }); + } + }); + + return { + listBindingsBySource: (namespace, scope) => + listBindingRowsForSourceScope(namespace, scope).pipe( + Effect.map((rows) => + rows + .map(rowToBinding) + .filter(Predicate.isNotNull) + .map((row) => ({ toolId: row.toolId, binding: row.binding })), + ), + ), getBinding: (toolId, scope) => - Effect.gen(function* () { - const row = yield* fuma.use("mcp_binding.findFirstByScopedId", (db) => - db.findFirst("mcp_binding", { - where: (b) => b.and(b("id", "=", toolId), b("scope_id", "=", scope)), - }), - ); - if (!row) return null; - const binding = decodeBinding(coerceJson(row.binding)); - return { binding, namespace: String(row.source_id) }; - }), + pluginStorage.getAtScope({ scope, collection: BINDING_COLLECTION, key: toolId }).pipe( + Effect.map((row) => { + const binding = row ? rowToBinding(row) : null; + return binding ? { binding: binding.binding, namespace: binding.namespace } : null; + }), + ), putBindings: (namespace, scope, entries) => Effect.gen(function* () { - if (entries.length === 0) return; - const now = new Date(); - yield* fuma - .use("mcp_binding.createMany", (db) => - db.createMany( - "mcp_binding", - entries.map((e) => ({ - id: e.toolId, - scope_id: scope, - source_id: namespace, - binding: encodeBinding(e.binding), - created_at: now, - })), - ), - ) - .pipe(Effect.asVoid); + for (const entry of entries) { + yield* pluginStorage.put({ + scope, + collection: BINDING_COLLECTION, + key: entry.toolId, + data: bindingData(namespace, entry), + }); + } }), - removeBindingsByNamespace: (namespace, scope) => - fuma - .use("mcp_binding.deleteManyBySourceScope", (db) => - db.deleteMany("mcp_binding", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ) - .pipe(Effect.asVoid), + removeBindingsByNamespace: (namespace, scope) => removeBindingsForSourceScope(namespace, scope), getSource: (namespace, scope) => - Effect.gen(function* () { - const row = yield* fuma.use("mcp_source.findFirstByScopedId", (db) => - db.findFirst("mcp_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - if (!row) return null; - return { - namespace: String(row.id), - scope: String(row.scope_id), - name: String(row.name), - config: yield* hydrateSourceData(row, namespace, scope), - }; - }), + pluginStorage + .getAtScope({ scope, collection: SOURCE_COLLECTION, key: namespace }) + .pipe(Effect.map((row) => (row ? rowToSource(row) : null))), getSourceConfig: (namespace, scope) => - Effect.gen(function* () { - const row = yield* fuma.use("mcp_source.findFirstByScopedId", (db) => - db.findFirst("mcp_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - if (!row) return null; - return yield* hydrateSourceData(row, namespace, scope); - }), + pluginStorage.getAtScope({ scope, collection: SOURCE_COLLECTION, key: namespace }).pipe( + Effect.map((row) => { + const source = row ? rowToSource(row) : null; + return source?.config ?? null; + }), + ), putSource: (source) => - Effect.gen(function* () { - const now = new Date(); - // Drop the source row and its child rows; recreate. Two-step - // matches the existing put-overwrites-existing semantic. - yield* fuma.use("mcp_source.deleteManyByScopedId", (db) => - db.deleteMany("mcp_source", { - where: (b) => b.and(b("id", "=", source.namespace), b("scope_id", "=", source.scope)), - }), - ); - yield* deleteSourceChildren(source.namespace, source.scope); - - const auth: McpConnectionAuth = - source.config.transport === "remote" ? source.config.auth : { kind: "none" }; - const authCols = authToColumns(auth); - const headers = source.config.transport === "remote" ? source.config.headers : undefined; - const queryParams = - source.config.transport === "remote" ? source.config.queryParams : undefined; - - // The encoded config keeps every plugin-private field but - // strips auth/headers/queryParams — those moved to columns/ - // child tables. We round-trip through encodeSourceData so the - // remaining fields stay in the same JSON shape decode expects. - const encodedConfig = stripExtractedFields( - encodeSourceData(source.config) as Record, - ); - - yield* fuma.use("mcp_source.create", (db) => - db.create("mcp_source", { - id: source.namespace, - scope_id: source.scope, - name: source.name, - config: encodedConfig, - created_at: now, - ...authCols, - }), - ); - - const headerRows = valueMapToRows(source.namespace, source.scope, headers); - if (headerRows.length > 0) { - yield* fuma - .use("mcp_source_header.createMany", (db) => - db.createMany("mcp_source_header", [...headerRows]), - ) - .pipe(Effect.asVoid); - } - const paramRows = valueMapToRows(source.namespace, source.scope, queryParams); - if (paramRows.length > 0) { - yield* fuma - .use("mcp_source_query_param.createMany", (db) => - db.createMany("mcp_source_query_param", [...paramRows]), - ) - .pipe(Effect.asVoid); - } - }), + pluginStorage + .put({ + scope: source.scope, + collection: SOURCE_COLLECTION, + key: source.namespace, + data: sourceData(source), + }) + .pipe(Effect.asVoid), removeSource: (namespace, scope) => Effect.gen(function* () { - yield* fuma.use("mcp_binding.deleteManyBySourceScope", (db) => - db.deleteMany("mcp_binding", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - yield* deleteSourceChildren(namespace, scope); - yield* fuma.use("mcp_source.deleteManyByScopedId", (db) => - db.deleteMany("mcp_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); + yield* removeBindingsForSourceScope(namespace, scope); + yield* pluginStorage.remove({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + }); }), }; - - // --------------------------------------------------------------------- - // Private helpers — depend on `fuma` so they live inside the closure. - // --------------------------------------------------------------------- - - function deleteSourceChildren(namespace: string, scope: string) { - return Effect.gen(function* () { - for (const model of ["mcp_source_header", "mcp_source_query_param"] as const) { - yield* fuma.use(`${model}.deleteManyBySourceScope`, (db) => - db.deleteMany(model, { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - } - }); - } - - function hydrateSourceData( - row: Record, - namespace: string, - scope: string, - ): Effect.Effect { - return Effect.gen(function* () { - // The stored JSON has auth/headers/queryParams stripped (those - // moved to columns / child tables). We must rehydrate the full - // shape BEFORE handing it to the schema decoder, because - // `McpRemoteSourceData.auth` is required. - const partial = coerceJson(row.config) as Record; - if (partial.transport !== "remote") { - // stdio sources have no extracted fields — decode as-is. - return decodeSourceData(partial); - } - const headerRows = yield* fuma.use("mcp_source_header.findManyBySourceScope", (db) => - db.findMany("mcp_source_header", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - const paramRows = yield* fuma.use("mcp_source_query_param.findManyBySourceScope", (db) => - db.findMany("mcp_source_query_param", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - const headers = rowsToValueMap(headerRows); - const queryParams = rowsToValueMap(paramRows); - const reassembled = { - ...partial, - ...(Object.keys(headers).length > 0 ? { headers } : {}), - ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}), - auth: columnsToAuth(row), - }; - return decodeSourceData(reassembled); - }); - } -}; - -// Strip auth/headers/queryParams from the encoded source-data shape. -// Keeps the remaining structural fields (transport, endpoint, etc.) in -// the JSON config column. Per-transport: only the remote variant has -// these fields, so this is a no-op for stdio. -const stripExtractedFields = (encoded: Record): Record => { - if (encoded.transport !== "remote") return encoded; - const { auth, headers, queryParams, ...rest } = encoded; - void auth; - void headers; - void queryParams; - return rest; }; diff --git a/packages/plugins/mcp/src/sdk/index.ts b/packages/plugins/mcp/src/sdk/index.ts index fae9d4b51..3bb8d1138 100644 --- a/packages/plugins/mcp/src/sdk/index.ts +++ b/packages/plugins/mcp/src/sdk/index.ts @@ -6,7 +6,7 @@ export { type McpRemoteSourceConfig, type McpStdioSourceConfig, type McpProbeResult, - type McpUpdateSourceInput, + type McpConfigureSourceInput, } from "./plugin"; export { @@ -26,7 +26,6 @@ export { McpConnectionAuth, McpConnectionAuthInput, McpCredentialInput, - McpSourceBindingInput, McpSourceBindingRef, mcpHeaderSlot, mcpQueryParamSlot, diff --git a/packages/plugins/mcp/src/sdk/per-user-auth-isolation.test.ts b/packages/plugins/mcp/src/sdk/per-user-auth-isolation.test.ts index fc75f6154..fc2f4c936 100644 --- a/packages/plugins/mcp/src/sdk/per-user-auth-isolation.test.ts +++ b/packages/plugins/mcp/src/sdk/per-user-auth-isolation.test.ts @@ -16,11 +16,27 @@ import { import { makeTestConfig, memorySecretsPlugin } from "@executor-js/sdk/testing"; import { mcpPlugin } from "./plugin"; +import { + MCP_OAUTH_CLIENT_ID_SLOT, + MCP_OAUTH_CLIENT_SECRET_SLOT, + MCP_OAUTH_CONNECTION_SLOT, +} from "./types"; import { makeEchoMcpServer, serveMcpServer } from "../testing"; const USER_A = ScopeId.make("user-a"); const USER_B = ScopeId.make("user-b"); const ORG = ScopeId.make("org"); +const mcpOAuth2Config = { + kind: "oauth2" as const, + securitySchemeName: "OAuth2", + flow: "authorizationCode" as const, + tokenUrl: "https://auth.example.test/token", + authorizationUrl: "https://auth.example.test/authorize", + clientIdSlot: MCP_OAUTH_CLIENT_ID_SLOT, + clientSecretSlot: MCP_OAUTH_CLIENT_SECRET_SLOT, + connectionSlot: MCP_OAUTH_CONNECTION_SLOT, + scopes: [], +}; const scope = (id: ScopeId, name: string): Scope => Scope.make({ id, name, createdAt: new Date() }); @@ -97,11 +113,22 @@ describe("per-user MCP auth isolation", () => { yield* execUserA.mcp.addSource({ transport: "remote", scope: ORG, - credentialTargetScope: USER_A, name: "Shared MCP", endpoint: server.url, namespace: "iso_test", - auth: { kind: "oauth2", connectionId: sharedConnId }, + oauth2: mcpOAuth2Config, + }); + yield* execUserA.sources.configure({ + source: { id: "iso_test", scope: ORG }, + scope: USER_A, + type: "mcp", + config: { + auth: { + oauth2: { + connection: { kind: "connection", connectionId: sharedConnId }, + }, + }, + }, }); const userATools = yield* execUserA.tools.list(); @@ -167,15 +194,21 @@ describe("per-user MCP auth isolation", () => { yield* execUserA.mcp.addSource({ transport: "remote", scope: ORG, - credentialTargetScope: USER_A, name: "Shared MCP (header)", endpoint: server.url, namespace: "iso_header", - auth: { - kind: "header", - headerName: "Authorization", - secretId: secret, - prefix: "Bearer ", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + }); + yield* execUserA.sources.configure({ + source: { id: "iso_header", scope: ORG }, + scope: USER_A, + type: "mcp", + config: { + headers: { + Authorization: { kind: "secret", secretId: secret, prefix: "Bearer " }, + }, }, }); @@ -238,15 +271,21 @@ describe("per-user MCP auth isolation", () => { yield* execUserA.mcp.addSource({ transport: "remote", scope: ORG, - credentialTargetScope: ORG, name: "Shared MCP org header", endpoint: server.url, namespace: "org_header", - auth: { - kind: "header", - headerName: "Authorization", - secretId, - prefix: "Bearer ", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + }); + yield* execUserA.sources.configure({ + source: { id: "org_header", scope: ORG }, + scope: ORG, + type: "mcp", + config: { + headers: { + Authorization: { kind: "secret", secretId, prefix: "Bearer " }, + }, }, }); diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 88ba85c50..836b3a595 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -16,10 +16,26 @@ import { import { makeTestConfig } from "@executor-js/sdk/testing"; import { mcpPlugin, userFacingProbeMessage } from "./plugin"; -import { MCP_OAUTH_CONNECTION_SLOT } from "./types"; +import { + MCP_OAUTH_CLIENT_ID_SLOT, + MCP_OAUTH_CLIENT_SECRET_SLOT, + MCP_OAUTH_CONNECTION_SLOT, +} from "./types"; import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; import { makeAnnotationsMcpServer, serveMcpServer } from "../testing"; +const mcpOAuth2Config = { + kind: "oauth2" as const, + securitySchemeName: "OAuth2", + flow: "authorizationCode" as const, + tokenUrl: "https://auth.example.test/token", + authorizationUrl: "https://auth.example.test/authorize", + clientIdSlot: MCP_OAUTH_CLIENT_ID_SLOT, + clientSecretSlot: MCP_OAUTH_CLIENT_SECRET_SLOT, + connectionSlot: MCP_OAUTH_CONNECTION_SLOT, + scopes: [], +}; + // --------------------------------------------------------------------------- // Memory secrets plugin — without a writable provider in the stack, // `executor.connections.create` has nowhere to persist its owned @@ -371,7 +387,7 @@ describe("mcpPlugin", () => { }), ); - it.effect("updateSource on user shadow does not mutate the org row", () => + it.effect("sources.configure on user shadow does not mutate the org row", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ @@ -391,9 +407,14 @@ describe("mcpPlugin", () => { endpoint: "http://127.0.0.1:1/user-mcp", }); - yield* executor.mcp.updateSource("shared", USER_SCOPE, { - name: "User Renamed", - endpoint: "http://127.0.0.1:1/user-new-mcp", + yield* executor.sources.configure({ + source: { id: "shared", scope: ScopeId.make(USER_SCOPE) }, + scope: ScopeId.make(USER_SCOPE), + type: "mcp", + config: { + name: "User Renamed", + endpoint: "http://127.0.0.1:1/user-new-mcp", + }, }); const userView = yield* executor.mcp.getSource("shared", USER_SCOPE); @@ -410,7 +431,7 @@ describe("mcpPlugin", () => { }), ); - it.effect("updateSource removes bindings for credential slots no longer present", () => + it.effect("sources.configure removes bindings for credential slots no longer present", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ @@ -433,33 +454,44 @@ describe("mcpPlugin", () => { name: "stale binding", endpoint: "http://127.0.0.1:1/mcp", namespace: "stale_binding", - credentialTargetScope: "test-scope", - auth: { - kind: "header", - headerName: "Authorization", - secretId: "old-token", - prefix: "Bearer ", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, }, }) .pipe(Effect.result); + yield* executor.sources.configure({ + source: { id: "stale_binding", scope: ScopeId.make("test-scope") }, + scope: ScopeId.make("test-scope"), + type: "mcp", + config: { + headers: { + Authorization: { kind: "secret", secretId: "old-token", prefix: "Bearer " }, + }, + }, + }); - yield* executor.mcp.updateSource("stale_binding", "test-scope", { - auth: { kind: "none" }, + yield* executor.sources.configure({ + source: { id: "stale_binding", scope: ScopeId.make("test-scope") }, + scope: ScopeId.make("test-scope"), + type: "mcp", + config: { headers: {} }, }); - const bindings = yield* executor.mcp.listSourceBindings("stale_binding", "test-scope"); + const bindings = yield* executor.sources.listBindings({ + source: { id: "stale_binding", scope: ScopeId.make("test-scope") }, + }); expect(bindings).toEqual([]); }), ); // ------------------------------------------------------------------------- - // updateSource must persist auth changes to the config file too — + // sources.configure must persist auth changes to the config file too — // otherwise the next boot replays the file's stale auth and silently // overwrites the DB. Symmetric with addSource/removeSource which // already write through. // ------------------------------------------------------------------------- - it.effect("updateSource writes auth changes back to the config file", () => + it.effect("sources.configure writes auth changes back to the config file", () => Effect.gen(function* () { const calls: Array<{ op: "upsert" | "remove"; payload: unknown }> = []; const stubSink = { @@ -496,25 +528,32 @@ describe("mcpPlugin", () => { name: "Sentry", endpoint: "http://127.0.0.1:1/sentry-mcp", namespace: "sentry", - credentialTargetScope: "test-scope", - auth: { - kind: "header", - headerName: "Authorization", - secretId: "sentry-token-old", - prefix: "Bearer ", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, }, }) .pipe(Effect.result); + yield* executor.sources.configure({ + source: { id: "sentry", scope: ScopeId.make("test-scope") }, + scope: ScopeId.make("test-scope"), + type: "mcp", + config: { + headers: { + Authorization: { kind: "secret", secretId: "sentry-token-old", prefix: "Bearer " }, + }, + }, + }); calls.length = 0; // ignore the addSource upsert; we're asserting on update - yield* executor.mcp.updateSource("sentry", "test-scope", { - credentialTargetScope: ScopeId.make("test-scope"), - auth: { - kind: "header", - headerName: "Authorization", - secretId: "sentry-token-new", - prefix: "Bearer ", + yield* executor.sources.configure({ + source: { id: "sentry", scope: ScopeId.make("test-scope") }, + scope: ScopeId.make("test-scope"), + type: "mcp", + config: { + headers: { + Authorization: { kind: "secret", secretId: "sentry-token-new", prefix: "Bearer " }, + }, }, }); @@ -524,11 +563,11 @@ describe("mcpPlugin", () => { kind: "mcp", transport: "remote", namespace: "sentry", - auth: { - kind: "header", - headerName: "Authorization", - secret: "secret-public-ref:sentry-token-new", - prefix: "Bearer ", + headers: { + Authorization: { + value: "secret-public-ref:sentry-token-new", + prefix: "Bearer ", + }, }, }); }), @@ -563,10 +602,7 @@ describe("mcpPlugin", () => { endpoint: "http://127.0.0.1:1/deferred-mcp", remoteTransport: "auto", namespace: "deferred_oauth", - auth: { - kind: "oauth2", - connectionSlot: MCP_OAUTH_CONNECTION_SLOT, - }, + oauth2: mcpOAuth2Config, }) .pipe(Effect.result); @@ -608,10 +644,7 @@ describe("mcpPlugin", () => { endpoint: "http://127.0.0.1:1/needs-auth-mcp", remoteTransport: "auto", namespace: "needs_auth", - auth: { - kind: "oauth2", - connectionSlot: MCP_OAUTH_CONNECTION_SLOT, - }, + oauth2: mcpOAuth2Config, }) .pipe(Effect.result); @@ -664,10 +697,7 @@ describe("mcpPlugin", () => { endpoint: "http://127.0.0.1:1/team-mcp", remoteTransport: "auto", namespace: "team_mcp", - auth: { - kind: "oauth2", - connectionSlot: MCP_OAUTH_CONNECTION_SLOT, - }, + oauth2: mcpOAuth2Config, }) .pipe(Effect.result); @@ -705,11 +735,10 @@ describe("mcpPlugin", () => { }, }), ); - yield* executor.mcp.setSourceBinding({ - sourceId: "team_mcp", - sourceScope: ORG_SCOPE_ID, + yield* executor.sources.setBinding({ + source: { id: "team_mcp", scope: ORG_SCOPE_ID }, scope: USER_SCOPE_ID, - slot: MCP_OAUTH_CONNECTION_SLOT, + slotKey: MCP_OAUTH_CONNECTION_SLOT, value: { kind: "connection", connectionId }, }); @@ -770,21 +799,30 @@ describe("mcpPlugin", () => { name: "header-auth", endpoint: "http://127.0.0.1:1/mcp", namespace: "header_auth_source", - credentialTargetScope: "test-scope", - auth: { - kind: "header", - headerName: "X-API-Key", - secretId: "shared-key", + headers: { + "X-API-Key": { kind: "secret" }, + "X-Trace": { kind: "secret" }, }, - headers: { "X-Trace": { secretId: "shared-key" } }, - queryParams: { ping: { secretId: "other-secret" } }, + queryParams: { ping: { kind: "secret" } }, }) .pipe(Effect.result); + yield* executor.sources.configure({ + source: { id: "header_auth_source", scope: ScopeId.make("test-scope") }, + scope: ScopeId.make("test-scope"), + type: "mcp", + config: { + headers: { + "X-API-Key": { kind: "secret", secretId: "shared-key" }, + "X-Trace": { kind: "secret", secretId: "shared-key" }, + }, + queryParams: { ping: { kind: "secret", secretId: "other-secret" } }, + }, + }); const usages = yield* executor.secrets.usages(SecretId.make("shared-key")); expect(usages.length).toBe(2); const slots = usages.map((u) => u.slot).sort(); - expect(slots).toEqual(["auth:header", "header:x-trace"]); + expect(slots).toEqual(["header:x-api-key", "header:x-trace"]); expect(usages.every((u) => u.pluginId === "mcp")).toBe(true); expect(usages.every((u) => u.ownerKind === "credential-binding")).toBe(true); @@ -832,10 +870,21 @@ describe("mcpPlugin", () => { name: "oauth-source", endpoint: "http://127.0.0.1:1/mcp", namespace: "oauth_ref", - credentialTargetScope: "test-scope", - auth: { kind: "oauth2", connectionId: "conn-xyz" }, + oauth2: mcpOAuth2Config, }) .pipe(Effect.result); + yield* executor.sources.configure({ + source: { id: "oauth_ref", scope: ScopeId.make("test-scope") }, + scope: ScopeId.make("test-scope"), + type: "mcp", + config: { + auth: { + oauth2: { + connection: { kind: "connection", connectionId: "conn-xyz" }, + }, + }, + }, + }); const usages = yield* executor.connections.usages(ConnectionId.make("conn-xyz")); expect(usages.length).toBe(1); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index ee13f962e..e931f26f4 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -16,12 +16,9 @@ import type { HttpClient } from "effect/unstable/http"; import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; import { - ConfiguredCredentialBinding, - ConnectionId, type CredentialBindingRef, type CredentialBindingValue, ScopeId, - SecretId, SourceDetectionResult, ToolResult, definePlugin, @@ -31,6 +28,12 @@ import { StorageError, type ToolAnnotations, } from "@executor-js/sdk/core"; +import { + compileHttpNamedCredentialMap, + OAuth2SourceConfig, + httpCredentialInputToBindingValue, + type HttpConfiguredValueInput, +} from "@executor-js/plugin-http-source/sdk"; import { makeMcpStore, @@ -45,18 +48,17 @@ import { invokeMcpTool } from "./invoke"; import { deriveMcpNamespace, type McpToolManifest, type McpToolManifestEntry } from "./manifest"; import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; import { - MCP_HEADER_AUTH_SLOT, MCP_OAUTH_CLIENT_ID_SLOT, MCP_OAUTH_CLIENT_SECRET_SLOT, MCP_OAUTH_CONNECTION_SLOT, + McpConnectionAuthInput, + McpCredentialInput, McpToolBinding, - McpSourceBindingInput, McpSourceBindingRef, mcpHeaderSlot, mcpQueryParamSlot, type McpConnectionAuth, - type McpConnectionAuthInput, - type McpCredentialInput, + type McpConfiguredValueInput as McpConfiguredValueInputType, type McpSourceBindingValue, type SecretBackedValue, type McpStoredSourceData, @@ -64,12 +66,13 @@ import { } from "./types"; import { - SECRET_REF_PREFIX, type ConfigFileSink, + type ConfigHeaderValue, type McpAuthConfig, type McpRemoteSourceConfig as McpRemoteConfigEntry, type McpStdioSourceConfig as McpStdioConfigEntry, type SourceConfig, + headerToConfigValue, } from "@executor-js/config"; // --------------------------------------------------------------------------- @@ -89,15 +92,10 @@ export interface McpRemoteSourceConfig extends McpSourceScopeField { readonly name: string; readonly endpoint: string; readonly remoteTransport?: "streamable-http" | "sse" | "auto"; - readonly queryParams?: Record; - readonly headers?: Record; + readonly queryParams?: Record; + readonly headers?: Record; readonly namespace?: string; - readonly auth?: McpConnectionAuthInput; - /** - * Scope that owns any direct credentials supplied on this call. Required - * whenever headers/queryParams/auth carry direct secret or connection ids. - */ - readonly credentialTargetScope?: string; + readonly oauth2?: OAuth2SourceConfig; } export interface McpStdioSourceConfig extends McpSourceScopeField { @@ -111,6 +109,15 @@ export interface McpStdioSourceConfig extends McpSourceScopeField { } export type McpSourceConfig = McpRemoteSourceConfig | McpStdioSourceConfig; +type McpConfigFileRemoteSourceConfig = Omit< + McpRemoteSourceConfig, + "headers" | "queryParams" | "oauth2" +> & { + readonly headers?: Record; + readonly queryParams?: Record; + readonly auth?: McpConnectionAuthInput; +}; +type McpConfigFileSourceConfig = McpConfigFileRemoteSourceConfig | McpStdioSourceConfig; // --------------------------------------------------------------------------- // Extension types @@ -130,14 +137,18 @@ export interface McpProbeResult { readonly serverName: string | null; } -export interface McpUpdateSourceInput { - readonly name?: string; - readonly endpoint?: string; - readonly headers?: Record; - readonly queryParams?: Record; - readonly credentialTargetScope?: string; - readonly auth?: McpConnectionAuthInput; -} +const McpConfigureSourcePayloadSchema = Schema.Struct({ + name: Schema.optional(Schema.String), + endpoint: Schema.optional(Schema.String), + headers: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), + queryParams: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), + auth: Schema.optional(McpConnectionAuthInput), +}); +const McpConfigureSourceInputSchema = Schema.Struct({ + scope: Schema.String, + ...McpConfigureSourcePayloadSchema.fields, +}); +export type McpConfigureSourceInput = typeof McpConfigureSourceInputSchema.Type; export interface McpProbeEndpointInput { readonly endpoint: string; @@ -266,25 +277,6 @@ const coreBindingToMcpBinding = (binding: CredentialBindingRef): McpSourceBindin 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 = ( ctx: PluginCtx, sourceId: string, @@ -349,77 +341,48 @@ const validateMcpBindingTarget = ( } }); -const bindingTargetScope = ( - targetScope: string | undefined, - bindings: readonly unknown[], -): Effect.Effect => { - if (bindings.length === 0) return Effect.succeed(undefined); - if (targetScope) return Effect.succeed(targetScope); - return Effect.fail( - new McpConnectionError({ - transport: "remote", - message: "credentialTargetScope is required when adding direct MCP credentials", - }), - ); -}; - -const targetScopeForBinding = ( - fallbackTargetScope: string | undefined, - binding: { readonly targetScope?: string }, -): Effect.Effect => { - const targetScope = binding.targetScope ?? fallbackTargetScope; - if (targetScope) return Effect.succeed(targetScope); - return Effect.fail( - new McpConnectionError({ - transport: "remote", - message: "credentialTargetScope is required when adding direct MCP credentials", - }), - ); -}; +const canonicalizeCredentialMap = compileHttpNamedCredentialMap; -const canonicalizeCredentialMap = ( - values: Record | undefined, +const canonicalizeConfiguredValueMap = ( + values: Record | undefined, slotForName: (name: string) => string, -): { - readonly values: Record; - readonly bindings: ReadonlyArray<{ - readonly slot: string; - readonly value: McpSourceBindingValue; - readonly targetScope?: string; - }>; -} => { - const nextValues: Record = {}; - const bindings: Array<{ slot: string; value: McpSourceBindingValue; targetScope?: string }> = []; +): Record => { + const next: Record = {}; for (const [name, value] of Object.entries(values ?? {})) { if (typeof value === "string") { - nextValues[name] = value; - continue; - } - if ("kind" in value) { - nextValues[name] = value; + next[name] = value; continue; } - const slot = slotForName(name); - nextValues[name] = ConfiguredCredentialBinding.make({ + next[name] = { kind: "binding", - slot, + slot: slotForName(name), prefix: value.prefix, - }); - bindings.push({ - slot, - targetScope: "targetScope" in value ? value.targetScope : undefined, - value: { - kind: "secret", - secretId: SecretId.make(value.secretId), - ...("secretScopeId" in value && value.secretScopeId - ? { secretScopeId: value.secretScopeId } - : {}), - }, - }); + }; + } + return next; +}; + +const resolveConfiguredValueMap = ( + values: Record | undefined, +): Record | undefined => { + if (!values) return undefined; + const resolved: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (typeof value === "string") resolved[name] = value; } - return { values: nextValues, bindings }; + return Object.keys(resolved).length > 0 ? resolved : undefined; }; +const authFromOAuth2Source = (oauth2: OAuth2SourceConfig | undefined): McpConnectionAuth => + oauth2 + ? { + kind: "oauth2", + connectionSlot: oauth2.connectionSlot, + clientIdSlot: oauth2.clientIdSlot, + ...(oauth2.clientSecretSlot ? { clientSecretSlot: oauth2.clientSecretSlot } : {}), + } + : { kind: "none" }; + const canonicalizeAuth = ( auth: McpConnectionAuthInput | undefined, ): { @@ -430,57 +393,33 @@ const canonicalizeAuth = ( readonly targetScope?: string; }>; } => { - if (!auth || auth.kind === "none") return { auth: { kind: "none" }, bindings: [] }; - if (auth.kind === "header") { - if ("secretSlot" in auth) return { auth, bindings: [] }; - return { - auth: { - kind: "header", - headerName: auth.headerName, - secretSlot: MCP_HEADER_AUTH_SLOT, - prefix: auth.prefix, - }, - bindings: [ - { - slot: MCP_HEADER_AUTH_SLOT, - targetScope: auth.targetScope, - value: { - kind: "secret", - secretId: SecretId.make(auth.secretId), - ...(auth.secretScopeId ? { secretScopeId: auth.secretScopeId } : {}), - }, - }, - ], - }; - } - if ("connectionSlot" in auth) return { auth, bindings: [] }; - const bindings: Array<{ slot: string; value: McpSourceBindingValue; targetScope?: string }> = [ - { + if (!auth || "kind" in auth || !auth.oauth2) return { auth: { kind: "none" }, bindings: [] }; + const oauth = auth.oauth2; + const bindings: Array<{ slot: string; value: McpSourceBindingValue; targetScope?: string }> = []; + if (oauth.connection) { + bindings.push({ slot: MCP_OAUTH_CONNECTION_SLOT, - value: { - kind: "connection", - connectionId: ConnectionId.make(auth.connectionId), - }, - }, - ]; - if (auth.clientIdSecretId) { + value: httpCredentialInputToBindingValue(oauth.connection), + }); + } + if (oauth.clientId) { bindings.push({ slot: MCP_OAUTH_CLIENT_ID_SLOT, - value: { kind: "secret", secretId: SecretId.make(auth.clientIdSecretId) }, + value: httpCredentialInputToBindingValue(oauth.clientId), }); } - if (auth.clientSecretSecretId) { + if (oauth.clientSecret) { bindings.push({ slot: MCP_OAUTH_CLIENT_SECRET_SLOT, - value: { kind: "secret", secretId: SecretId.make(auth.clientSecretSecretId) }, + value: httpCredentialInputToBindingValue(oauth.clientSecret), }); } return { auth: { kind: "oauth2", connectionSlot: MCP_OAUTH_CONNECTION_SLOT, - ...(auth.clientIdSecretId ? { clientIdSlot: MCP_OAUTH_CLIENT_ID_SLOT } : {}), - ...(auth.clientSecretSecretId ? { clientSecretSlot: MCP_OAUTH_CLIENT_SECRET_SLOT } : {}), + ...(oauth.clientId ? { clientIdSlot: MCP_OAUTH_CLIENT_ID_SLOT } : {}), + ...(oauth.clientSecret ? { clientSecretSlot: MCP_OAUTH_CLIENT_SECRET_SLOT } : {}), }, bindings, }; @@ -555,14 +494,25 @@ const resolveSecretBackedMap = ( ), ); -const plainStringMap = ( - values: Record | undefined, -): Record | undefined => { +const credentialInputMapToConfigValues = ( + values: Record | undefined, +): Record | undefined => { if (!values) return undefined; - const entries = Object.entries(values).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ); - return entries.length > 0 ? Object.fromEntries(entries) : undefined; + const out: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (typeof value === "string") { + out[name] = value; + continue; + } + if (value.kind === "secret" && "secretId" in value) { + out[name] = headerToConfigValue({ secretId: value.secretId, prefix: value.prefix }); + continue; + } + if (value.kind === "text") { + out[name] = value.prefix ? `${value.prefix}${value.text}` : value.text; + } + } + return Object.keys(out).length > 0 ? out : undefined; }; const resolveMcpBindingValueMap = ( @@ -621,62 +571,6 @@ const resolveMcpBindingValueMap = ( return Object.keys(resolved).length > 0 ? resolved : undefined; }); -const resolveMcpCredentialInputMap = ( - ctx: PluginCtx, - values: Record | undefined, - params: { - readonly sourceId: string; - readonly sourceScope: string; - readonly targetScope?: string; - readonly missingLabel: string; - }, -): Effect.Effect | undefined, McpConnectionError | StorageFailure> => - Effect.gen(function* () { - if (!values) return undefined; - const resolved: Record = {}; - for (const [name, value] of Object.entries(values)) { - if (typeof value === "string") { - resolved[name] = value; - continue; - } - if ("kind" in value) { - const slotResolved = yield* resolveMcpBindingValueMap( - ctx, - { [name]: value }, - { - sourceId: params.sourceId, - sourceScope: params.sourceScope, - missingLabel: params.missingLabel, - }, - ); - if (slotResolved?.[name] !== undefined) resolved[name] = slotResolved[name]; - continue; - } - const secretScope = - "secretScopeId" in value - ? (value.secretScopeId ?? value.targetScope) - : (params.targetScope ?? params.sourceScope); - const secret = yield* ctx.secrets.getAtScope(SecretId.make(value.secretId), secretScope).pipe( - Effect.catchTag("SecretOwnedByConnectionError", () => - Effect.fail( - new McpConnectionError({ - transport: "remote", - message: `Failed to resolve secret for ${params.missingLabel} "${name}"`, - }), - ), - ), - ); - if (secret === null) { - return yield* new McpConnectionError({ - transport: "remote", - message: `Missing secret "${value.secretId}" for ${params.missingLabel} "${name}"`, - }); - } - resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; - } - return Object.keys(resolved).length > 0 ? resolved : undefined; - }); - const resolveMcpHeaderAuth = ( ctx: PluginCtx, sourceId: string, @@ -746,78 +640,6 @@ const resolveMcpStoredOauthProvider = ( return makeOAuthProvider(accessToken); }); -const resolveMcpInputAuth = ( - ctx: PluginCtx, - sourceId: string, - sourceScope: string, - targetScope: string | undefined, - auth: McpConnectionAuthInput | undefined, -): Effect.Effect< - { readonly headers: Record; readonly authProvider?: OAuthClientProvider }, - McpConnectionError | StorageFailure -> => - Effect.gen(function* () { - if (!auth || auth.kind === "none") return { headers: {} }; - if (auth.kind === "header") { - if ("secretSlot" in auth) { - const headers = yield* resolveMcpHeaderAuth(ctx, sourceId, sourceScope, auth); - return { headers }; - } - const secretScope = auth.secretScopeId ?? auth.targetScope ?? targetScope ?? sourceScope; - const secret = yield* ctx.secrets.getAtScope(SecretId.make(auth.secretId), secretScope).pipe( - Effect.catchTag("SecretOwnedByConnectionError", () => - Effect.fail( - new McpConnectionError({ - transport: "remote", - message: `Failed to resolve secret "${auth.secretId}"`, - }), - ), - ), - ); - if (secret === null) { - return yield* new McpConnectionError({ - transport: "remote", - message: `Failed to resolve secret "${auth.secretId}"`, - }); - } - return { - headers: { [auth.headerName]: auth.prefix ? `${auth.prefix}${secret}` : secret }, - }; - } - const connection = - "connectionId" in auth - ? { id: ConnectionId.make(auth.connectionId), scope: targetScope ?? sourceScope } - : yield* Effect.gen(function* () { - const binding = yield* resolveMcpSourceBinding( - ctx, - sourceId, - sourceScope, - auth.connectionSlot, - ); - return binding?.value.kind === "connection" - ? { id: binding.value.connectionId, scope: binding.scopeId } - : null; - }); - if (connection === null) { - return yield* new McpConnectionError({ - transport: "remote", - message: `Missing OAuth connection binding for MCP source "${sourceId}"`, - }); - } - const accessToken = yield* ctx.connections - .accessTokenAtScope(connection.id, connection.scope) - .pipe( - Effect.mapError( - ({ message }) => - new McpConnectionError({ - transport: "remote", - message: `Failed to resolve OAuth connection "${connection.id}": ${message}`, - }), - ), - ); - return { headers: {}, authProvider: makeOAuthProvider(accessToken) }; - }); - // --------------------------------------------------------------------------- // Shared connector resolution — reads secrets, builds stdio/remote input // --------------------------------------------------------------------------- @@ -946,24 +768,16 @@ export interface McpPluginOptions { readonly configFile?: ConfigFileSink; } -const secretRef = (id: string): string => `${SECRET_REF_PREFIX}${id}`; - const authToConfig = (auth: McpConnectionAuthInput | undefined): McpAuthConfig | undefined => { if (!auth) return undefined; - if (auth.kind === "none") return { kind: "none" }; - if (auth.kind === "header") { - if (!("secretId" in auth)) return undefined; - return { - kind: "header", - headerName: auth.headerName, - secret: secretRef(auth.secretId), - prefix: auth.prefix, - }; + if ("kind" in auth) return { kind: "none" }; + const connection = auth.oauth2?.connection; + if (!connection || typeof connection === "string" || connection.kind !== "connection") { + return undefined; } - if (!("connectionId" in auth)) return undefined; return { kind: "oauth2", - connectionId: auth.connectionId, + connectionId: connection.connectionId, }; }; @@ -971,8 +785,8 @@ const authToConfig = (auth: McpConnectionAuthInput | undefined): McpAuthConfig | // Storage-form → input-form reconstruction // // `toMcpConfigEntry` consumes the `McpSourceConfig` *input* shape — the -// legacy form with `secretId` / `connectionId`, which `authToConfig` and -// `plainStringMap` know how to render into the file. Stored remote data +// configure form, which `authToConfig` and `credentialInputMapToConfigValues` +// know how to render into the file. Stored remote data // is in slot form (`secretSlot`, `{kind: "binding", slot}`), so writing // the file from a stored row needs the slot → secret/connection lookups // realized first. Walk the source's `credential_binding` rows and rebuild @@ -988,7 +802,9 @@ const toCredentialInput = ( if (!value) return undefined; if (value.kind === "secret") { return { + kind: "secret", secretId: value.secretId, + ...(value.secretScopeId ? { secretScope: value.secretScopeId } : {}), ...(configured.prefix ? { prefix: configured.prefix } : {}), }; } @@ -1019,15 +835,17 @@ const toAuthInput = ( const value = bySlot.get(auth.secretSlot); if (value?.kind !== "secret") return undefined; return { - kind: "header", - headerName: auth.headerName, - secretId: value.secretId, - prefix: auth.prefix, + kind: "none", }; } const connection = bySlot.get(auth.connectionSlot); - if (connection?.kind !== "connection") return undefined; - return { kind: "oauth2", connectionId: connection.connectionId }; + return { + oauth2: { + ...(connection?.kind === "connection" + ? { connection: { kind: "connection" as const, connectionId: connection.connectionId } } + : {}), + }, + }; }; const inputFormFromStored = ( @@ -1036,7 +854,7 @@ const inputFormFromStored = ( scope: string, sourceName: string, namespace: string, -): McpSourceConfig => { +): McpConfigFileSourceConfig => { if (stored.transport === "stdio") { return { transport: "stdio", @@ -1066,7 +884,7 @@ const inputFormFromStored = ( const toMcpConfigEntry = ( namespace: string, sourceName: string, - config: McpSourceConfig, + config: McpConfigFileSourceConfig, ): SourceConfig => { if (config.transport === "stdio") { const entry: McpStdioConfigEntry = { @@ -1087,8 +905,8 @@ const toMcpConfigEntry = ( name: sourceName, endpoint: config.endpoint, remoteTransport: config.remoteTransport, - queryParams: plainStringMap(config.queryParams), - headers: plainStringMap(config.headers), + queryParams: credentialInputMapToConfigValues(config.queryParams), + headers: credentialInputMapToConfigValues(config.headers), namespace, auth: authToConfig(config.auth), }; @@ -1239,40 +1057,21 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { const canonicalRemote = config.transport === "remote" ? { - headers: canonicalizeCredentialMap(config.headers, mcpHeaderSlot), - queryParams: canonicalizeCredentialMap(config.queryParams, mcpQueryParamSlot), - auth: canonicalizeAuth(config.auth), + headers: canonicalizeConfiguredValueMap(config.headers, mcpHeaderSlot), + queryParams: canonicalizeConfiguredValueMap( + config.queryParams, + mcpQueryParamSlot, + ), + auth: authFromOAuth2Source(config.oauth2), } : null; - const directBindings = canonicalRemote - ? [ - ...canonicalRemote.headers.bindings, - ...canonicalRemote.queryParams.bindings, - ...canonicalRemote.auth.bindings, - ] - : []; - for (const binding of directBindings) { - const bindingTargetScope = yield* targetScopeForBinding( - config.transport === "remote" ? config.credentialTargetScope : undefined, - binding, - ); - yield* validateMcpBindingTarget(ctx, { - sourceId: namespace, - sourceScope: config.scope, - targetScope: bindingTargetScope, - }); - } - const targetScope = - config.transport === "remote" && directBindings[0] - ? yield* targetScopeForBinding(config.credentialTargetScope, directBindings[0]) - : undefined; const sd = toStoredSourceData( config, canonicalRemote ? { - headers: canonicalRemote.headers.values, - queryParams: canonicalRemote.queryParams.values, - auth: canonicalRemote.auth.auth, + headers: canonicalRemote.headers, + queryParams: canonicalRemote.queryParams, + auth: canonicalRemote.auth, } : undefined, ); @@ -1284,55 +1083,24 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { // connection awaiting per-user sign-in, header secret // awaiting upload) but the source row should still land so // it shows up in the list and exposes a Sign-in affordance. - const resolved = yield* ( + const resolved: Result.Result = config.transport === "remote" - ? Effect.gen(function* () { - const resolvedHeaders = yield* resolveMcpCredentialInputMap(ctx, config.headers, { - sourceId: namespace, - sourceScope: config.scope, - targetScope, - missingLabel: "header", - }); - const resolvedQueryParams = yield* resolveMcpCredentialInputMap( - ctx, - config.queryParams, - { - sourceId: namespace, - sourceScope: config.scope, - targetScope, - missingLabel: "query parameter", - }, - ); - const resolvedAuth = yield* resolveMcpInputAuth( - ctx, - namespace, - config.scope, - targetScope, - config.auth, - ); - const headers = { - ...(resolvedHeaders ?? {}), - ...resolvedAuth.headers, - }; - return { - transport: "remote" as const, - endpoint: config.endpoint, - remoteTransport: config.remoteTransport ?? "auto", - queryParams: resolvedQueryParams, - headers: Object.keys(headers).length > 0 ? headers : undefined, - authProvider: resolvedAuth.authProvider, - }; + ? Result.succeed({ + transport: "remote" as const, + endpoint: config.endpoint, + remoteTransport: config.remoteTransport ?? "auto", + queryParams: resolveConfiguredValueMap(config.queryParams), + headers: resolveConfiguredValueMap(config.headers), }) - : resolveConnectorInput(namespace, config.scope, sd, ctx, allowStdio) - ).pipe( - Effect.result, - Effect.withSpan("mcp.plugin.resolve_connector", { - attributes: { - "mcp.source.namespace": namespace, - "mcp.source.transport": sd.transport, - }, - }), - ); + : yield* resolveConnectorInput(namespace, config.scope, sd, ctx, allowStdio).pipe( + Effect.result, + Effect.withSpan("mcp.plugin.resolve_connector", { + attributes: { + "mcp.source.namespace": namespace, + "mcp.source.transport": sd.transport, + }, + }), + ); if (Result.isFailure(resolved) && sd.transport === "stdio") { return yield* Effect.fail(resolved.failure); @@ -1407,23 +1175,6 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { outputSchema: e.outputSchema, })), }); - - if (directBindings.length > 0) { - for (const binding of directBindings) { - const bindingTargetScope = yield* targetScopeForBinding( - config.transport === "remote" ? config.credentialTargetScope : undefined, - binding, - ); - yield* ctx.credentialBindings.set({ - targetScope: ScopeId.make(bindingTargetScope), - pluginId: MCP_PLUGIN_ID, - sourceId: namespace, - sourceScope: ScopeId.make(config.scope), - slotKey: binding.slot, - value: binding.value, - }); - } - } }), ) .pipe( @@ -1567,9 +1318,29 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { }), ); - const updateSource = (namespace: string, scope: string, input: McpUpdateSourceInput) => + const getSource = (namespace: string, scope: string) => + ctx.storage.getSource(namespace, scope).pipe( + Effect.withSpan("mcp.plugin.get_source", { + attributes: { "mcp.source.namespace": namespace }, + }), + ); + + return { + probeEndpoint, + addSource, + removeSource, + refreshSource, + getSource, + }; + }, + + sourceConfigure: { + type: "mcp", + schema: McpConfigureSourcePayloadSchema, + configure: ({ ctx, sourceId, sourceScope, targetScope, config }) => Effect.gen(function* () { - const existing = yield* ctx.storage.getSource(namespace, scope); + const input = config as Omit; + const existing = yield* ctx.storage.getSource(sourceId, sourceScope); if (!existing || existing.config.transport !== "remote") return; const canonicalHeaders = @@ -1586,48 +1357,42 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { ...(canonicalQueryParams?.bindings ?? []), ...(canonicalAuth?.bindings ?? []), ]; - const targetScope = yield* bindingTargetScope( - input.credentialTargetScope, - directBindings, - ); - if (targetScope) { + if (directBindings.length > 0) { yield* validateMcpBindingTarget(ctx, { - sourceId: namespace, - sourceScope: scope, + sourceId, + sourceScope, targetScope, }); } - const remote = existing.config; const updatedConfig: McpStoredSourceData = { - ...remote, + ...existing.config, ...(input.endpoint !== undefined ? { endpoint: input.endpoint } : {}), ...(canonicalHeaders ? { headers: canonicalHeaders.values } : {}), ...(canonicalAuth ? { auth: canonicalAuth.auth } : {}), ...(canonicalQueryParams ? { queryParams: canonicalQueryParams.values } : {}), }; - const sourceName = input.name?.trim() || existing.name; - const affectedPrefixes = [ ...(input.headers !== undefined ? ["header:"] : []), ...(input.queryParams !== undefined ? ["query_param:"] : []), ...(input.auth !== undefined ? ["auth:"] : []), ]; - const replacementTargetScope = targetScope ?? input.credentialTargetScope ?? scope; + + const sourceName = input.name?.trim() || existing.name; yield* ctx.transaction( Effect.gen(function* () { yield* ctx.storage.putSource({ - namespace, - scope, + namespace: sourceId, + scope: sourceScope, name: sourceName, config: updatedConfig, }); if (affectedPrefixes.length > 0 || directBindings.length > 0) { yield* ctx.credentialBindings.replaceForSource({ - targetScope: ScopeId.make(replacementTargetScope), + targetScope: ScopeId.make(targetScope), pluginId: MCP_PLUGIN_ID, - sourceId: namespace, - sourceScope: ScopeId.make(scope), + sourceId, + sourceScope: ScopeId.make(sourceScope), slotPrefixes: affectedPrefixes, bindings: directBindings.map((binding) => ({ slotKey: binding.slot, @@ -1637,79 +1402,24 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { } }), ); - - if (configFile) { + if (options?.configFile) { const bindings = yield* ctx.credentialBindings.listForSource({ pluginId: MCP_PLUGIN_ID, - sourceId: namespace, - sourceScope: ScopeId.make(scope), + sourceId, + sourceScope: ScopeId.make(sourceScope), }); const inputForm = inputFormFromStored( bindings, updatedConfig, - scope, + sourceScope, sourceName, - namespace, + sourceId, ); - yield* configFile - .upsertSource(toMcpConfigEntry(namespace, sourceName, inputForm)) + yield* options.configFile + .upsertSource(toMcpConfigEntry(sourceId, sourceName, inputForm)) .pipe(Effect.withSpan("mcp.plugin.config_file.upsert")); } - }).pipe( - Effect.withSpan("mcp.plugin.update_source", { - attributes: { "mcp.source.namespace": namespace }, - }), - ); - - const getSource = (namespace: string, scope: string) => - ctx.storage.getSource(namespace, scope).pipe( - Effect.withSpan("mcp.plugin.get_source", { - attributes: { "mcp.source.namespace": namespace }, - }), - ); - - return { - probeEndpoint, - addSource, - removeSource, - 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, - }); - }), - }; + }), }, invokeTool: ({ ctx, toolRow, args, elicit }) => @@ -1718,7 +1428,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { // toolRow.scope_id is the resolved owning scope of the tool // (innermost-wins from the executor's stack). The matching - // mcp_binding + mcp_source rows live at the same scope, so + // MCP binding + source plugin-storage rows live at the same scope, so // pin every store lookup to it instead of relying on stack-wide // scope fall-through. const toolScope = toolRow.scope_id; @@ -2002,22 +1712,4 @@ export interface McpPluginExtension { namespace: string, scope: string, ) => Effect.Effect; - readonly updateSource: ( - namespace: string, - 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 c908f7994..d09d90fd4 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -3,11 +3,15 @@ import { ConfiguredCredentialValue, CredentialBindingValue, credentialSlotKey, - ScopedSecretCredentialInput, ScopeId, SecretBackedMap, SecretBackedValue, } from "@executor-js/sdk/shared"; +import { + HttpConfiguredValueInput, + HttpCredentialInput, + HttpOAuthConfigureInput, +} from "@executor-js/plugin-http-source/sdk"; export { SecretBackedMap, SecretBackedValue }; @@ -25,11 +29,10 @@ export type McpTransport = typeof McpTransport.Type; export const ConfiguredMcpCredentialValue = ConfiguredCredentialValue; export type ConfiguredMcpCredentialValue = typeof ConfiguredMcpCredentialValue.Type; -export const McpCredentialInput = Schema.Union([ - ScopedSecretCredentialInput, - SecretBackedValue, - ConfiguredMcpCredentialValue, -]); +export const McpConfiguredValueInput = HttpConfiguredValueInput; +export type McpConfiguredValueInput = typeof McpConfiguredValueInput.Type; + +export const McpCredentialInput = HttpCredentialInput; export type McpCredentialInput = typeof McpCredentialInput.Type; export const mcpHeaderSlot = (name: string): string => credentialSlotKey("header", name); @@ -68,20 +71,11 @@ export const McpConnectionAuth = Schema.Union([ export type McpConnectionAuth = typeof McpConnectionAuth.Type; export const McpConnectionAuthInput = Schema.Union([ - McpConnectionAuth, Schema.Struct({ - kind: Schema.Literal("header"), - headerName: Schema.String, - secretId: Schema.String, - prefix: Schema.optional(Schema.String), - targetScope: Schema.optional(ScopeId), - secretScopeId: Schema.optional(ScopeId), + kind: Schema.Literal("none"), }), Schema.Struct({ - kind: Schema.Literal("oauth2"), - connectionId: Schema.String, - clientIdSecretId: Schema.optional(Schema.String), - clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)), + oauth2: Schema.optional(HttpOAuthConfigureInput), }), ]); export type McpConnectionAuthInput = typeof McpConnectionAuthInput.Type; @@ -89,15 +83,6 @@ 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, diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index 1d555d424..33ff33f8d 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -63,6 +63,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", + "@executor-js/plugin-http-source": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", "openapi-types": "^12.1.3", diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index f335a63a2..62bf31c77 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -75,17 +75,6 @@ const PreviewSpecPayload = Schema.Struct({ specFetchCredentials: Schema.optional(PreviewSpecFetchCredentialsPayload), }); -const UpdateSourcePayload = Schema.Struct({ - sourceScope: ScopeId, - name: Schema.optional(Schema.String), - baseUrl: Schema.optional(Schema.String), - headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), - queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), - // Set after a successful re-authenticate to refresh the source's - // stored OAuth2 metadata. - oauth2: Schema.optional(OAuth2SourceConfig), -}); - const ConfigureCredentialPayload = Schema.Union([ Schema.String, Schema.Struct({ @@ -128,10 +117,7 @@ const ConfigurePayload = Schema.Struct({ connection: Schema.optional(ConfigureCredentialPayload), }), ), -}); - -const UpdateSourceResponse = Schema.Struct({ - updated: Schema.Boolean, + oauth2Source: Schema.optional(OAuth2SourceConfig), }); // --------------------------------------------------------------------------- @@ -187,14 +173,6 @@ export const OpenApiGroup = HttpApiGroup.make("openapi") error: DomainErrors, }), ) - .add( - HttpApiEndpoint.patch("updateSource", "/scopes/:scopeId/openapi/sources/:namespace", { - params: SourceParams, - payload: UpdateSourcePayload, - success: UpdateSourceResponse, - error: DomainErrors, - }), - ) .add( HttpApiEndpoint.post("configure", "/scopes/:scopeId/openapi/configure", { params: ScopeIdParam, diff --git a/packages/plugins/openapi/src/api/handlers.ts b/packages/plugins/openapi/src/api/handlers.ts index 24e0b78b6..3be0cd284 100644 --- a/packages/plugins/openapi/src/api/handlers.ts +++ b/packages/plugins/openapi/src/api/handlers.ts @@ -8,7 +8,6 @@ import type { OpenApiPluginExtension, OpenApiPreviewSpecFetchCredentialsInput, OpenApiSpecFetchCredentialsInput, - OpenApiUpdateSourceInput, } from "../sdk/plugin"; import { StoredSourceSchema } from "../sdk/store"; import { OpenApiGroup } from "./group"; @@ -110,23 +109,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope }), ), ) - .handle("updateSource", ({ params: path, payload }) => - capture( - Effect.gen(function* () { - const ext = yield* OpenApiExtensionService; - yield* ext.updateSource(path.namespace, payload.sourceScope, { - name: payload.name, - baseUrl: payload.baseUrl, - headers: payload.headers as Record | undefined, - queryParams: payload.queryParams as - | Record - | undefined, - oauth2: payload.oauth2, - } as OpenApiUpdateSourceInput); - return { updated: true }; - }), - ), - ) .handle("configure", ({ payload }) => capture( Effect.gen(function* () { diff --git a/packages/plugins/openapi/src/promise.ts b/packages/plugins/openapi/src/promise.ts index c2eab908d..5de173010 100644 --- a/packages/plugins/openapi/src/promise.ts +++ b/packages/plugins/openapi/src/promise.ts @@ -3,6 +3,6 @@ export type { OpenApiPluginOptions, OpenApiPluginExtension, OpenApiSpecConfig, - OpenApiUpdateSourceInput, + OpenApiConfigureInput, HeaderValue, } from "./sdk/plugin"; diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index 4a6688cc6..fab1464f3 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -29,7 +29,7 @@ import { emptyHttpCredentials, serializeHttpCredentials, type HttpCredentialsState, -} from "@executor-js/react/plugins/http-credentials"; +} from "@executor-js/plugin-http-source/react"; import { oauthCallbackUrl, useOAuthPopupFlow, diff --git a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx index 096746a5c..b6c1652e9 100644 --- a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx @@ -7,6 +7,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { connectionsAtom, + configureSource, removeSourceCredentialBinding, setSourceCredentialBinding, sourceAtom, @@ -50,7 +51,7 @@ import { } from "@executor-js/react/plugins/credential-bindings"; import { SecretCredentialSlotBindings } from "@executor-js/react/plugins/credential-slot-bindings"; -import { openApiSourceAtom, openApiSourceBindingsAtom, updateOpenApiSource } from "./atoms"; +import { openApiSourceAtom, openApiSourceBindingsAtom } from "./atoms"; import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; import { OPENAPI_OAUTH_CALLBACK_PATH, @@ -141,7 +142,7 @@ export default function EditOpenApiSource(props: { const connectionsResult = useAtomValue(connectionsAtom(displayScope)); const secretList = useSecretPickerSecrets(); - const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promiseExit" }); + const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); const doSetBinding = useAtomSet(setSourceCredentialBinding, { mode: "promiseExit", }); @@ -232,12 +233,17 @@ export default function EditOpenApiSource(props: { setSourceSaveState("saving"); setError(null); void (async () => { - const exit = await doUpdate({ - params: { scopeId: displayScope, namespace: props.sourceId }, + const exit = await doConfigure({ + params: { scopeId: displayScope }, payload: { - sourceScope, - name: nextName || undefined, - baseUrl: nextBaseUrl || undefined, + source: { id: props.sourceId, scope: sourceScope }, + scope: sourceScope, + type: "openapi", + config: { + scope: sourceScope, + name: nextName || undefined, + baseUrl: nextBaseUrl || undefined, + }, }, reactivityKeys: openApiWriteKeys, }); @@ -258,7 +264,7 @@ export default function EditOpenApiSource(props: { }, [ baseUrl, displayScope, - doUpdate, + doConfigure, loadedSourceKey, name, props.sourceId, @@ -655,22 +661,27 @@ export default function EditOpenApiSource(props: { const seq = ++oauth2EndpointsSaveSeq.current; setOAuth2EndpointsSaveState("saving"); setError(null); - const exit = await doUpdate({ - params: { scopeId: displayScope, namespace: props.sourceId }, + const exit = await doConfigure({ + params: { scopeId: displayScope }, payload: { - sourceScope, - oauth2: OAuth2SourceConfig.make({ - kind: "oauth2", - securitySchemeName: oauth2.securitySchemeName, - flow: oauth2.flow, - tokenUrl: trimmedTokenUrl, - authorizationUrl: isAuthCode ? trimmedAuthUrl || null : null, - issuerUrl: oauth2.issuerUrl ?? null, - clientIdSlot: oauth2.clientIdSlot, - clientSecretSlot: oauth2.clientSecretSlot, - connectionSlot: oauth2.connectionSlot, - scopes: [...oauth2.scopes], - }), + source: { id: props.sourceId, scope: sourceScope }, + scope: sourceScope, + type: "openapi", + config: { + scope: sourceScope, + oauth2Source: OAuth2SourceConfig.make({ + kind: "oauth2", + securitySchemeName: oauth2.securitySchemeName, + flow: oauth2.flow, + tokenUrl: trimmedTokenUrl, + authorizationUrl: isAuthCode ? trimmedAuthUrl || null : null, + issuerUrl: oauth2.issuerUrl ?? null, + clientIdSlot: oauth2.clientIdSlot, + clientSecretSlot: oauth2.clientSecretSlot, + connectionSlot: oauth2.connectionSlot, + scopes: [...oauth2.scopes], + }), + }, }, reactivityKeys: openApiWriteKeys, }); diff --git a/packages/plugins/openapi/src/react/atoms.ts b/packages/plugins/openapi/src/react/atoms.ts index fced5f5a9..4f885b509 100644 --- a/packages/plugins/openapi/src/react/atoms.ts +++ b/packages/plugins/openapi/src/react/atoms.ts @@ -62,5 +62,3 @@ export const addOpenApiSpecOptimistic = Atom.family((scopeId: ScopeId) => }), ), ); - -export const updateOpenApiSource = OpenApiClient.mutation("openapi", "updateSource"); diff --git a/packages/plugins/openapi/src/sdk/index.ts b/packages/plugins/openapi/src/sdk/index.ts index 0ff57399f..d51886795 100644 --- a/packages/plugins/openapi/src/sdk/index.ts +++ b/packages/plugins/openapi/src/sdk/index.ts @@ -9,7 +9,6 @@ export { type OpenApiPluginExtension, type OpenApiPluginOptions, type OpenApiSourceRef, - type OpenApiUpdateSourceInput, } from "./plugin"; export { openapiSchema, diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index d6b074062..5adffef88 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -415,7 +415,7 @@ describe("OpenAPI Plugin", () => { }), ); - it.effect("updateSource removes bindings for credential slots no longer present", () => + it.effect("sources.configure removes bindings for credential slots no longer present", () => Effect.gen(function* () { const clientLayer = FetchHttpClient.layer; @@ -455,8 +455,11 @@ describe("OpenAPI Plugin", () => { }), ); - yield* executor.openapi.updateSource("stale_binding", TEST_SCOPE, { - headers: {}, + yield* executor.sources.configure({ + source: { id: "stale_binding", scope: ScopeId.make(TEST_SCOPE) }, + scope: ScopeId.make(TEST_SCOPE), + type: "openapi", + config: { scope: TEST_SCOPE, headers: {} }, }); const bindings = yield* executor.sources.listBindings({ @@ -466,7 +469,7 @@ describe("OpenAPI Plugin", () => { }), ); - it.effect("updateSource removes stale OAuth2 bindings when the OAuth template changes", () => + it.effect("sources.configure removes stale OAuth2 bindings when the OAuth template changes", () => Effect.gen(function* () { const clientLayer = FetchHttpClient.layer; @@ -515,18 +518,27 @@ describe("OpenAPI Plugin", () => { }), ); - yield* executor.openapi.updateSource("stale_oauth", TEST_SCOPE, { - oauth2: OAuth2SourceConfig.make({ - kind: "oauth2", - securitySchemeName: "new", - flow: "authorizationCode", - tokenUrl: "https://auth.example.com/token", - authorizationUrl: "https://auth.example.com/authorize", - clientIdSlot: "oauth2:new:client-id", - clientSecretSlot: null, - connectionSlot: "oauth2:new:connection", - scopes: ["read"], - }), + yield* executor.sources.configure({ + source: { id: "stale_oauth", scope: ScopeId.make(TEST_SCOPE) }, + scope: ScopeId.make(TEST_SCOPE), + type: "openapi", + config: { + scope: TEST_SCOPE, + oauth2: { + clientId: { kind: "secret", secretId: "old-client-id" }, + }, + oauth2Source: OAuth2SourceConfig.make({ + kind: "oauth2", + securitySchemeName: "new", + flow: "authorizationCode", + tokenUrl: "https://auth.example.com/token", + authorizationUrl: "https://auth.example.com/authorize", + clientIdSlot: "oauth2:new:client-id", + clientSecretSlot: null, + connectionSlot: "oauth2:new:connection", + scopes: ["read"], + }), + }, }); const bindings = yield* executor.sources.listBindings({ @@ -1035,7 +1047,7 @@ describe("OpenAPI Plugin", () => { expect(userView?.config.baseUrl).toBe("https://org.example.com"); expect( - queryCalls.some((call) => call.method === "findMany" && call.table === "openapi_source"), + queryCalls.some((call) => call.method === "findMany" && call.table === "plugin_storage"), ).toBe(false); }), ); @@ -1081,7 +1093,7 @@ describe("OpenAPI Plugin", () => { }), ); - it.effect("updateSource on user shadow cannot override the inherited base URL", () => + it.effect("sources.configure on user shadow cannot override the inherited base URL", () => Effect.gen(function* () { const clientLayer = FetchHttpClient.layer; @@ -1112,10 +1124,16 @@ describe("OpenAPI Plugin", () => { }), ); - const updateResult = yield* executor.openapi - .updateSource("shared", String(USER_SCOPE), { - name: "User Renamed", - baseUrl: "https://user-new.example.com", + const updateResult = yield* executor.sources + .configure({ + source: { id: "shared", scope: USER_SCOPE }, + scope: USER_SCOPE, + type: "openapi", + config: { + scope: String(USER_SCOPE), + name: "User Renamed", + baseUrl: "https://user-new.example.com", + }, }) .pipe( Effect.match({ @@ -1127,7 +1145,7 @@ describe("OpenAPI Plugin", () => { const userView = yield* executor.openapi.getSource("shared", String(USER_SCOPE)); const orgView = yield* executor.openapi.getSource("shared", String(ORG_SCOPE)); - expect(updateResult).toMatchObject({ _tag: "OpenApiOAuthError" }); + expect(updateResult).toMatchObject({ _tag: "StorageError" }); expect(userView?.name).toBe("User Source"); expect(userView?.config.baseUrl).toBe("https://org.example.com"); expect(orgView?.name).toBe("Org Source"); @@ -1241,15 +1259,20 @@ describe("OpenAPI Plugin", () => { ); const userRowsBefore = yield* Effect.promise(() => - db.findMany("openapi_source_spec_fetch_header", { + db.findMany("plugin_storage", { where: (b) => b.and( b("scope_id", "=", String(USER_SCOPE)), - b("source_id", "=", "shared_spec_fetch"), + b("plugin_id", "=", "openapi"), + b("collection", "=", "source"), + b("key", "=", "shared_spec_fetch"), ), }), ); - expect(userRowsBefore).toEqual([]); + const userConfigBefore = userRowsBefore[0]?.data as + | { readonly config?: { readonly specFetchCredentials?: unknown } } + | undefined; + expect(userConfigBefore?.config?.specFetchCredentials).toBeUndefined(); const requestsBefore = server.requestCount(); yield* executor.sources.refresh({ @@ -1260,25 +1283,43 @@ describe("OpenAPI Plugin", () => { expect(server.requestCount()).toBeGreaterThan(requestsBefore); expect(server.lastToken()).toBe("org-token"); const orgRowsAfter = yield* Effect.promise(() => - db.findMany("openapi_source_spec_fetch_header", { + db.findMany("plugin_storage", { where: (b) => b.and( b("scope_id", "=", String(ORG_SCOPE)), - b("source_id", "=", "shared_spec_fetch"), + b("plugin_id", "=", "openapi"), + b("collection", "=", "source"), + b("key", "=", "shared_spec_fetch"), ), }), ); const userRowsAfter = yield* Effect.promise(() => - db.findMany("openapi_source_spec_fetch_header", { + db.findMany("plugin_storage", { where: (b) => b.and( b("scope_id", "=", String(USER_SCOPE)), - b("source_id", "=", "shared_spec_fetch"), + b("plugin_id", "=", "openapi"), + b("collection", "=", "source"), + b("key", "=", "shared_spec_fetch"), ), }), ); - expect(orgRowsAfter).toHaveLength(1); - expect(userRowsAfter).toEqual([]); + const orgConfigAfter = orgRowsAfter[0]?.data as + | { + readonly config?: { + readonly specFetchCredentials?: { + readonly headers?: Readonly>; + }; + }; + } + | undefined; + const userConfigAfter = userRowsAfter[0]?.data as + | { readonly config?: { readonly specFetchCredentials?: unknown } } + | undefined; + expect(orgConfigAfter?.config?.specFetchCredentials?.headers).toHaveProperty( + "X-Spec-Token", + ); + expect(userConfigAfter?.config?.specFetchCredentials).toBeUndefined(); }), ), ); diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 918eeec6d..c85ac60f1 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -170,16 +170,6 @@ export interface OpenApiSpecConfig { readonly oauth2?: OpenApiOAuthInput; } -export interface OpenApiUpdateSourceInput { - readonly name?: string; - readonly baseUrl?: string; - readonly headers?: Record; - readonly queryParams?: Record; - /** Refresh the source's stored OAuth2 metadata after a successful - * re-authenticate. */ - readonly oauth2?: OpenApiOAuthInput; -} - export interface OpenApiSourceRef { readonly id: string; readonly scope: string; @@ -203,22 +193,6 @@ export type OpenApiConfigureCredentialInput = readonly connectionId: string; }; -export interface OpenApiConfigureInput { - /** Scope where these concrete credential values are saved. */ - readonly scope: string; - readonly headers?: Record; - readonly queryParams?: Record; - readonly specFetchCredentials?: { - readonly headers?: Record; - readonly queryParams?: Record; - }; - readonly oauth2?: { - readonly clientId?: OpenApiConfigureCredentialInput; - readonly clientSecret?: OpenApiConfigureCredentialInput; - readonly connection?: OpenApiConfigureCredentialInput; - }; -} - /** * Errors any OpenAPI extension method may surface. The first three are * plugin-domain tagged errors that flow directly to clients (4xx, each @@ -253,11 +227,6 @@ export interface OpenApiPluginExtension { namespace: string, scope: string, ) => Effect.Effect; - readonly updateSource: ( - namespace: string, - scope: string, - input: OpenApiUpdateSourceInput, - ) => Effect.Effect; readonly configure: ( source: OpenApiSourceRef, input: OpenApiConfigureInput, @@ -290,6 +259,48 @@ const OpenApiConfiguredValueInputSchema = Schema.Union([ Schema.String, OpenApiSecretShapeInputSchema, ]); +const OpenApiConfigureCredentialInputSchema = Schema.Union([ + Schema.String, + Schema.Struct({ + kind: Schema.Literal("text"), + text: Schema.String, + prefix: Schema.optional(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("secret"), + secretId: Schema.String, + secretScope: Schema.optional(Schema.String), + prefix: Schema.optional(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("connection"), + connectionId: Schema.String, + }), +]); +const OpenApiConfigureInputSchema = Schema.Struct({ + scope: Schema.String, + name: Schema.optional(Schema.String), + baseUrl: Schema.optional(Schema.String), + headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfigureCredentialInputSchema)), + queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfigureCredentialInputSchema)), + specFetchCredentials: Schema.optional( + Schema.Struct({ + headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfigureCredentialInputSchema)), + queryParams: Schema.optional( + Schema.Record(Schema.String, OpenApiConfigureCredentialInputSchema), + ), + }), + ), + oauth2: Schema.optional( + Schema.Struct({ + clientId: Schema.optional(OpenApiConfigureCredentialInputSchema), + clientSecret: Schema.optional(OpenApiConfigureCredentialInputSchema), + connection: Schema.optional(OpenApiConfigureCredentialInputSchema), + }), + ), + oauth2Source: Schema.optional(OAuth2SourceConfig), +}); +export type OpenApiConfigureInput = typeof OpenApiConfigureInputSchema.Type; const OpenApiOAuthInputSchema = OAuth2SourceConfig; const AddSourceInputSchema = Schema.Struct({ @@ -548,13 +559,118 @@ const configureMap = ( return { configured, bindings }; }; -const mergeConfiguredValues = ( - current: Record | undefined, - next: Record, -): Record | undefined => { - if (Object.keys(next).length === 0) return current; - return { ...(current ?? {}), ...next }; -}; +const configureOpenApiSource = ( + ctx: PluginCtx, + source: OpenApiSourceRef, + input: OpenApiConfigureInput, +): Effect.Effect => + Effect.gen(function* () { + const existing = yield* ctx.storage.getSource(source.id, source.scope); + if (!existing) { + return yield* new StorageError({ + message: + `Cannot configure OpenAPI source "${source.id}" at scope "${source.scope}": ` + + "source is not visible.", + cause: undefined, + }); + } + + const headers = configureMap(input.headers, headerSlotFromName); + const queryParams = configureMap(input.queryParams, queryParamSlotFromName); + const specFetchHeaders = configureMap( + input.specFetchCredentials?.headers, + specFetchHeaderSlotFromName, + ); + const specFetchQueryParams = configureMap( + input.specFetchCredentials?.queryParams, + specFetchQueryParamSlotFromName, + ); + const oauth2 = existing.config.oauth2; + const oauth2Bindings: Array<{ + readonly slotKey: string; + readonly value: CredentialBindingValue; + }> = []; + if (oauth2 && input.oauth2?.clientId) { + oauth2Bindings.push({ + slotKey: oauth2.clientIdSlot, + value: configuredValueFromConfigureInput(oauth2.clientIdSlot, input.oauth2.clientId).value, + }); + } + if (oauth2?.clientSecretSlot && input.oauth2?.clientSecret) { + oauth2Bindings.push({ + slotKey: oauth2.clientSecretSlot, + value: configuredValueFromConfigureInput(oauth2.clientSecretSlot, input.oauth2.clientSecret) + .value, + }); + } + if (oauth2 && input.oauth2?.connection) { + oauth2Bindings.push({ + slotKey: oauth2.connectionSlot, + value: configuredValueFromConfigureInput(oauth2.connectionSlot, input.oauth2.connection) + .value, + }); + } + + const specFetchCredentials = + input.specFetchCredentials === undefined + ? existing.config.specFetchCredentials + : { + headers: specFetchHeaders.configured, + queryParams: specFetchQueryParams.configured, + }; + const affectedPrefixes = [ + ...(input.headers !== undefined ? ["header:"] : []), + ...(input.queryParams !== undefined ? ["query_param:"] : []), + ...(input.specFetchCredentials?.headers !== undefined ? ["spec_fetch:header:"] : []), + ...(input.specFetchCredentials?.queryParams !== undefined ? ["spec_fetch:query_param:"] : []), + ...(input.oauth2 !== undefined ? ["oauth2:"] : []), + ]; + const bindings = [ + ...headers.bindings, + ...queryParams.bindings, + ...specFetchHeaders.bindings, + ...specFetchQueryParams.bindings, + ...oauth2Bindings, + ]; + const nextConfiguredValues = ( + current: Record | undefined, + next: Record, + provided: boolean, + ): Record | undefined => { + if (!provided) return current; + if (Object.keys(next).length === 0) return {}; + return { ...(current ?? {}), ...next }; + }; + + return yield* ctx.transaction( + Effect.gen(function* () { + yield* ctx.storage.updateSourceMeta(source.id, source.scope, { + headers: nextConfiguredValues( + existing.config.headers, + headers.configured, + input.headers !== undefined, + ), + queryParams: nextConfiguredValues( + existing.config.queryParams, + queryParams.configured, + input.queryParams !== undefined, + ), + specFetchCredentials, + }); + if (affectedPrefixes.length > 0 || bindings.length > 0) { + return yield* ctx.credentialBindings.replaceForSource({ + targetScope: ScopeId.make(input.scope), + pluginId: OPENAPI_PLUGIN_ID, + sourceId: source.id, + sourceScope: ScopeId.make(source.scope), + slotPrefixes: affectedPrefixes, + bindings, + }); + } + return []; + }), + ); + }); interface EffectiveSourceConfig { readonly config: SourceConfig; @@ -1078,157 +1194,38 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { }; }), - updateSource: (namespace: string, scope: string, input: OpenApiUpdateSourceInput) => - Effect.gen(function* () { - const existing = yield* ctx.storage.getSource(namespace, scope); - if (!existing) return; - const canonicalHeaders = - input.headers !== undefined ? canonicalizeHeaders(input.headers) : null; - const canonicalOAuth2 = - input.oauth2 !== undefined ? canonicalizeOAuth2(input.oauth2) : null; - const canonicalQueryParams = - input.queryParams !== undefined - ? canonicalizeCredentialMap(input.queryParams, queryParamSlotFromName) - : null; - const affectedPrefixes = [ - ...(input.headers !== undefined ? ["header:"] : []), - ...(input.queryParams !== undefined ? ["query_param:"] : []), - ...(input.oauth2 !== undefined ? ["oauth2:"] : []), - ]; - const targetScope = scope; + configure: (source: OpenApiSourceRef, input: OpenApiConfigureInput) => + configureOpenApiSource(ctx, source, input), + }; + }, + + sourceConfigure: { + type: "openapi", + schema: OpenApiConfigureInputSchema, + configure: ({ ctx, sourceId, sourceScope, config }) => + Effect.gen(function* () { + const input = config as OpenApiConfigureInput; + if ( + input.name !== undefined || + input.baseUrl !== undefined || + input.oauth2Source !== undefined + ) { if (input.baseUrl !== undefined && input.baseUrl.trim() !== "") { - const outerSource = yield* findOuterSource(ctx, namespace, scope); + const outerSource = yield* findOuterSource(ctx, sourceId, sourceScope); if (outerSource) { return yield* new OpenApiOAuthError({ message: "OpenAPI source shadows inherit the outer source base URL", }); } } - yield* ctx.transaction( - Effect.gen(function* () { - yield* ctx.storage.updateSourceMeta(namespace, scope, { - name: input.name?.trim() || undefined, - baseUrl: input.baseUrl, - headers: canonicalHeaders?.headers, - queryParams: canonicalQueryParams?.values, - oauth2: canonicalOAuth2?.oauth2, - }); - if (affectedPrefixes.length > 0) { - yield* ctx.credentialBindings.replaceForSource({ - targetScope: ScopeId.make(targetScope), - pluginId: OPENAPI_PLUGIN_ID, - sourceId: namespace, - sourceScope: ScopeId.make(scope), - slotPrefixes: affectedPrefixes, - bindings: [], - }); - } - }), - ); - }), - - configure: (source: OpenApiSourceRef, input: OpenApiConfigureInput) => - Effect.gen(function* () { - const existing = yield* ctx.storage.getSource(source.id, source.scope); - if (!existing) { - return yield* new StorageError({ - message: - `Cannot configure OpenAPI source "${source.id}" at scope "${source.scope}": ` + - "source is not visible.", - cause: undefined, - }); - } - - const headers = configureMap(input.headers, headerSlotFromName); - const queryParams = configureMap(input.queryParams, queryParamSlotFromName); - const specFetchHeaders = configureMap( - input.specFetchCredentials?.headers, - specFetchHeaderSlotFromName, - ); - const specFetchQueryParams = configureMap( - input.specFetchCredentials?.queryParams, - specFetchQueryParamSlotFromName, - ); - const oauth2 = existing.config.oauth2; - const oauth2Bindings: Array<{ - readonly slotKey: string; - readonly value: CredentialBindingValue; - }> = []; - if (oauth2 && input.oauth2?.clientId) { - oauth2Bindings.push({ - slotKey: oauth2.clientIdSlot, - value: configuredValueFromConfigureInput(oauth2.clientIdSlot, input.oauth2.clientId) - .value, - }); - } - if (oauth2?.clientSecretSlot && input.oauth2?.clientSecret) { - oauth2Bindings.push({ - slotKey: oauth2.clientSecretSlot, - value: configuredValueFromConfigureInput( - oauth2.clientSecretSlot, - input.oauth2.clientSecret, - ).value, - }); - } - if (oauth2 && input.oauth2?.connection) { - oauth2Bindings.push({ - slotKey: oauth2.connectionSlot, - value: configuredValueFromConfigureInput( - oauth2.connectionSlot, - input.oauth2.connection, - ).value, - }); - } - - const specFetchCredentials = - Object.keys(specFetchHeaders.configured).length === 0 && - Object.keys(specFetchQueryParams.configured).length === 0 - ? existing.config.specFetchCredentials - : { - headers: mergeConfiguredValues( - existing.config.specFetchCredentials?.headers, - specFetchHeaders.configured, - ), - queryParams: mergeConfiguredValues( - existing.config.specFetchCredentials?.queryParams, - specFetchQueryParams.configured, - ), - }; - - return yield* ctx.transaction( - Effect.gen(function* () { - yield* ctx.storage.updateSourceMeta(source.id, source.scope, { - headers: mergeConfiguredValues(existing.config.headers, headers.configured), - queryParams: mergeConfiguredValues( - existing.config.queryParams, - queryParams.configured, - ), - specFetchCredentials, - }); - const refs: CredentialBindingRef[] = []; - for (const binding of [ - ...headers.bindings, - ...queryParams.bindings, - ...specFetchHeaders.bindings, - ...specFetchQueryParams.bindings, - ...oauth2Bindings, - ]) { - refs.push( - yield* ctx.credentialBindings.set({ - targetScope: ScopeId.make(input.scope), - pluginId: OPENAPI_PLUGIN_ID, - sourceId: source.id, - sourceScope: ScopeId.make(source.scope), - slotKey: binding.slotKey, - value: binding.value, - }), - ); - } - return refs; - }), - ); - }), - }; + yield* ctx.storage.updateSourceMeta(sourceId, sourceScope, { + name: input.name?.trim() || undefined, + baseUrl: input.baseUrl, + oauth2: input.oauth2Source, + }); + } + return yield* configureOpenApiSource(ctx, { id: sourceId, scope: sourceScope }, input); + }), }, staticSources: (self) => [ @@ -1263,7 +1260,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; // toolRow.scope_id is the resolved owning scope of the tool // (innermost-wins from the executor's stack). The matching - // openapi_operation + openapi_source rows live at the same + // OpenAPI operation + source plugin-storage rows live at the same // scope, so pin every store lookup to it instead of relying on // stack-wide scope fall-through. const toolScope = toolRow.scope_id; diff --git a/packages/plugins/openapi/src/sdk/store.ts b/packages/plugins/openapi/src/sdk/store.ts index a1bd57ccf..eb1d6e4eb 100644 --- a/packages/plugins/openapi/src/sdk/store.ts +++ b/packages/plugins/openapi/src/sdk/store.ts @@ -1,22 +1,17 @@ -import { Effect, Option, Schema } from "effect"; +import { Effect, Option, Predicate, Schema } from "effect"; import { - type FumaRow, type FumaTables, - jsonColumn, - nullableJsonColumn, - nullableTextColumn, - scopedExecutorTable, + type PluginStorageEntry, type StorageDeps, type StorageFailure, - textColumn, } from "@executor-js/sdk/core"; import { - ConfiguredHeaderValue, ConfiguredHeaderBinding, OAuth2SourceConfig, OperationBinding, + type ConfiguredHeaderValue, } from "./types"; export { StoredSourceSchema, @@ -28,87 +23,11 @@ export { queryParamBindingSlot, } from "./source-contracts"; -// --------------------------------------------------------------------------- -// Schema: -// - openapi_source: one row per onboarded spec (baseUrl, oauth2, ...) -// - openapi_operation: one row per operation binding keyed by tool id -// --------------------------------------------------------------------------- - -// Each of the source-owned credential-structure child tables (`openapi_source_header`, -// `openapi_source_query_param`, -// `openapi_source_spec_fetch_header`, -// `openapi_source_spec_fetch_query_param`) shares the same column shape: -// id/scope_id/source_id/name plus a `kind` enum that discriminates a -// literal text value from a credential slot binding (with optional prefix). -// The fields are inlined per-table because FumaDB's table type -// narrowing relies on the literal types staying on the original -// declaration site. - -export const openapiSchema = { - openapi_source: scopedExecutorTable("openapi_source", { - name: textColumn("name"), - spec: textColumn("spec"), - // Origin URL the spec was fetched from. Set when `addSpec` was - // invoked with an http(s) URL; null when the caller passed raw - // spec text. Drives `canRefresh` on the core source row and - // is the address re-fetched on `refreshSource`. - source_url: nullableTextColumn("source_url"), - base_url: nullableTextColumn("base_url"), - // OAuth2 stays JSON because it is one typed source-owned config object - // carrying slot names, not concrete secret/connection ids. - oauth2: nullableJsonColumn("oauth2"), - }), - openapi_operation: scopedExecutorTable("openapi_operation", { - source_id: textColumn("source_id"), - binding: jsonColumn("binding"), - }), - openapi_source_header: scopedExecutorTable("openapi_source_header", { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }), - openapi_source_query_param: scopedExecutorTable("openapi_source_query_param", { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }), - openapi_source_spec_fetch_header: scopedExecutorTable("openapi_source_spec_fetch_header", { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }), - openapi_source_spec_fetch_query_param: scopedExecutorTable( - "openapi_source_spec_fetch_query_param", - { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }, - ), -} satisfies FumaTables; - +export const openapiSchema = {} satisfies FumaTables; export type OpenapiSchema = typeof openapiSchema; -// --------------------------------------------------------------------------- -// In-memory shapes -// --------------------------------------------------------------------------- - export interface SourceConfig { readonly spec: string; - /** Origin URL when the spec was fetched from http(s). Absent for - * raw-text adds. Persisted so `refreshSource` can re-fetch. */ readonly sourceUrl?: string; readonly baseUrl?: string; readonly namespace?: string; @@ -125,8 +44,6 @@ export interface OpenApiSpecFetchCredentials { export interface StoredSource { readonly namespace: string; - /** Executor scope id this source row lives in. Writes stamp this on - * `scope_id`; reads choose scope explicitly in the FumaDB query. */ readonly scope: string; readonly name: string; readonly config: SourceConfig; @@ -138,10 +55,8 @@ export interface StoredOperation { readonly binding: OperationBinding; } -// --------------------------------------------------------------------------- -// Schema encode/decode — OperationBinding has Option fields, so we must use -// Schema.encode/decode rather than plain JSON to round-trip correctly. -// --------------------------------------------------------------------------- +const SOURCE_COLLECTION = "source"; +const OPERATION_COLLECTION = "operation"; const encodeBinding = Schema.encodeSync(OperationBinding); const decodeBinding = Schema.decodeUnknownSync(OperationBinding); @@ -155,107 +70,40 @@ const encodeOAuth2SourceConfig = Schema.encodeSync(OAuth2SourceConfig); const NullableString = Schema.NullOr(Schema.String); const OptionalNullableString = Schema.optional(NullableString); - -const ChildStorageRow = Schema.Struct({ - name: Schema.String, - kind: Schema.Literals(["text", "binding"]), - text_value: OptionalNullableString, - slot_key: OptionalNullableString, +const ConfiguredHeaderBindingStorage = Schema.Struct({ + kind: Schema.Literal("binding"), + slot: Schema.String, prefix: OptionalNullableString, }); -const decodeChildStorageRowOption = Schema.decodeUnknownOption(ChildStorageRow); - -const SourceStorageRow = Schema.Struct({ - id: Schema.String, - scope_id: Schema.String, - name: Schema.String, +const ConfiguredHeaderValueStorage = Schema.Union([Schema.String, ConfiguredHeaderBindingStorage]); +const ConfiguredHeaderMapStorage = Schema.Record(Schema.String, ConfiguredHeaderValueStorage); +const SpecFetchCredentialsStorage = Schema.Struct({ + headers: Schema.optional(ConfiguredHeaderMapStorage), + queryParams: Schema.optional(ConfiguredHeaderMapStorage), +}); +const SourceConfigStorage = Schema.Struct({ spec: Schema.String, - source_url: OptionalNullableString, - base_url: OptionalNullableString, + sourceUrl: Schema.optional(Schema.String), + baseUrl: Schema.optional(Schema.String), + namespace: Schema.optional(Schema.String), + headers: Schema.optional(ConfiguredHeaderMapStorage), + queryParams: Schema.optional(ConfiguredHeaderMapStorage), + specFetchCredentials: Schema.optional(SpecFetchCredentialsStorage), oauth2: Schema.optional(Schema.Unknown), }); -const decodeSourceStorageRow = Schema.decodeUnknownSync(SourceStorageRow); - -const OperationStorageRow = Schema.Struct({ - id: Schema.String, - source_id: Schema.String, +const SourceStorage = Schema.Struct({ + namespace: Schema.String, + scope: Schema.String, + name: Schema.String, + config: SourceConfigStorage, +}); +const OperationStorage = Schema.Struct({ + toolId: Schema.String, + sourceId: Schema.String, binding: Schema.Unknown, }); -const decodeOperationStorageRow = Schema.decodeUnknownSync(OperationStorageRow); - -type OpenapiSourceRow = FumaRow; -type OpenapiOperationRow = FumaRow; - -const openapiCredentialChildTables = [ - "openapi_source_header", - "openapi_source_query_param", - "openapi_source_spec_fetch_header", - "openapi_source_spec_fetch_query_param", -] as const satisfies readonly (keyof OpenapiSchema)[]; - -// Collapse a structural credential map into the flat child-table column -// shape used by openapi_source_header, openapi_source_query_param, and -// the two openapi_source_spec_fetch_* tables. Returns one record per entry. -const valueMapToChildRows = ( - sourceId: string, - scope: string, - values: Record | undefined, -) => { - if (!values) return []; - return Object.entries(values).map(([name, value]) => { - const id = JSON.stringify([sourceId, name]); - if (typeof value === "string") { - return { - id, - scope_id: scope, - source_id: sourceId, - name, - kind: "text", - text_value: value, - slot_key: null, - prefix: null, - }; - } - return { - id, - scope_id: scope, - source_id: sourceId, - name, - kind: "binding", - text_value: null, - slot_key: value.slot, - prefix: value.prefix ?? null, - }; - }); -}; - -const childRowsToValueMap = ( - rows: readonly Record[], -): Record => { - const out: Record = {}; - for (const row of rows) { - const decoded = decodeChildStorageRowOption(row); - if (Option.isSome(decoded)) { - const child = decoded.value; - if (child.kind === "binding" && child.slot_key != null) { - out[child.name] = - child.prefix != null - ? ConfiguredHeaderBinding.make({ - kind: "binding", - slot: child.slot_key, - prefix: child.prefix, - }) - : ConfiguredHeaderBinding.make({ - kind: "binding", - slot: child.slot_key, - }); - } else if (child.kind === "text" && child.text_value != null) { - out[child.name] = child.text_value; - } - } - } - return out; -}; +const decodeSourceStorage = Schema.decodeUnknownOption(SourceStorage); +const decodeOperationStorage = Schema.decodeUnknownOption(OperationStorage); const toJsonRecord = (value: unknown): Record => value as Record; @@ -265,25 +113,93 @@ const normalizeStoredOAuth2 = (value: unknown): OAuth2SourceConfig | undefined = typeof value === "string" ? decodeOAuth2SourceConfigJsonOption(value) : decodeOAuth2SourceConfigOption(value); - if (Option.isSome(sourceConfig)) { - return sourceConfig.value; - } + if (Option.isSome(sourceConfig)) return sourceConfig.value; return undefined; }; -// --------------------------------------------------------------------------- -// Store interface -// --------------------------------------------------------------------------- +const normalizeConfiguredMap = ( + values: Readonly> | undefined, +): Record | undefined => { + if (!values) return undefined; + const normalized: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (typeof value === "string") { + normalized[name] = value; + } else { + normalized[name] = + value.prefix != null + ? ConfiguredHeaderBinding.make({ + kind: "binding", + slot: value.slot, + prefix: value.prefix, + }) + : ConfiguredHeaderBinding.make({ + kind: "binding", + slot: value.slot, + }); + } + } + return normalized; +}; + +const encodeSourceConfig = (config: SourceConfig): Record => ({ + spec: config.spec, + ...(config.sourceUrl ? { sourceUrl: config.sourceUrl } : {}), + ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), + ...(config.namespace ? { namespace: config.namespace } : {}), + ...(config.headers ? { headers: config.headers } : {}), + ...(config.queryParams ? { queryParams: config.queryParams } : {}), + ...(config.specFetchCredentials ? { specFetchCredentials: config.specFetchCredentials } : {}), + ...(config.oauth2 ? { oauth2: toJsonRecord(encodeOAuth2SourceConfig(config.oauth2)) } : {}), +}); + +const rowToSource = (row: PluginStorageEntry): StoredSource | null => { + const decoded = decodeSourceStorage(row.data); + if (Option.isNone(decoded)) return null; + const stored = decoded.value; + const oauth2 = normalizeStoredOAuth2(stored.config.oauth2); + return { + namespace: stored.namespace, + scope: stored.scope, + name: stored.name, + config: { + spec: stored.config.spec, + sourceUrl: stored.config.sourceUrl, + baseUrl: stored.config.baseUrl, + namespace: stored.config.namespace, + headers: normalizeConfiguredMap(stored.config.headers), + queryParams: normalizeConfiguredMap(stored.config.queryParams), + specFetchCredentials: stored.config.specFetchCredentials + ? { + headers: normalizeConfiguredMap(stored.config.specFetchCredentials.headers), + queryParams: normalizeConfiguredMap(stored.config.specFetchCredentials.queryParams), + } + : undefined, + oauth2, + }, + }; +}; + +const rowToOperation = (row: PluginStorageEntry): StoredOperation | null => { + const decoded = decodeOperationStorage(row.data); + if (Option.isNone(decoded)) return null; + const operation = decoded.value; + return { + toolId: operation.toolId, + sourceId: operation.sourceId, + binding: decodeBinding( + typeof operation.binding === "string" + ? decodeBindingJson(operation.binding) + : operation.binding, + ), + }; +}; -// Every read/write that targets a single row pins BOTH the natural id -// (namespace, toolId, sessionId) AND the owning `scope_id`. Scope is a -// normal FumaDB predicate here, not hidden behavior. export interface OpenapiStore { readonly upsertSource: ( input: StoredSource, operations: readonly StoredOperation[], ) => Effect.Effect; - readonly updateSourceMeta: ( namespace: string, scope: string, @@ -296,307 +212,145 @@ export interface OpenapiStore { readonly oauth2?: OAuth2SourceConfig; }, ) => Effect.Effect; - readonly getSource: ( namespace: string, scope: string, ) => Effect.Effect; - readonly listSources: () => Effect.Effect; - readonly getOperationByToolId: ( toolId: string, scope: string, ) => Effect.Effect; - readonly listOperationsBySource: ( sourceId: string, scope: string, ) => Effect.Effect; - readonly removeSource: (namespace: string, scope: string) => Effect.Effect; - - // --------------------------------------------------------------------- - // Query params and spec-fetch credentials are source-owned structural - // rows only. Secret/connection ownership and usages live in core - // `credential_binding`. } -// --------------------------------------------------------------------------- -// Default store implementation -// --------------------------------------------------------------------------- - export const makeDefaultOpenapiStore = ({ - fuma, - scopes, + pluginStorage, }: StorageDeps): OpenapiStore => { - const scopeIds = scopes.map((scope) => String(scope.id)); - - const loadChildValueMap = ( - tableName: (typeof openapiCredentialChildTables)[number], - sourceId: string, - scope: string, - ) => - fuma - .use(`${tableName}.findMany`, (db) => - db.findMany(tableName, { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ) - .pipe(Effect.map(childRowsToValueMap)); + const sourceData = (source: StoredSource) => ({ + namespace: source.namespace, + scope: source.scope, + name: source.name, + config: encodeSourceConfig(source.config), + }); - const rowToSource = (row: OpenapiSourceRow): Effect.Effect => - Effect.gen(function* () { - const sourceRow = decodeSourceStorageRow(row); - const sourceId = sourceRow.id; - const scope = sourceRow.scope_id; - const oauth2 = normalizeStoredOAuth2(sourceRow.oauth2); + const operationData = (operation: StoredOperation) => ({ + toolId: operation.toolId, + sourceId: operation.sourceId, + binding: toJsonRecord(encodeBinding(operation.binding)), + }); - const headers = yield* loadChildValueMap("openapi_source_header", sourceId, scope); - const queryParams = yield* loadChildValueMap("openapi_source_query_param", sourceId, scope); - const specFetchHeaders = yield* loadChildValueMap( - "openapi_source_spec_fetch_header", - sourceId, - scope, - ); - const specFetchQueryParams = yield* loadChildValueMap( - "openapi_source_spec_fetch_query_param", - sourceId, - scope, + const listOperationRowsForSourceScope = (sourceId: string, scope: string) => + pluginStorage + .list({ + collection: OPERATION_COLLECTION, + keyPrefix: `${sourceId}.`, + }) + .pipe( + Effect.map((rows) => + rows.filter( + (row) => String(row.scopeId) === scope && rowToOperation(row)?.sourceId === sourceId, + ), + ), ); - const specFetchCredentials: OpenApiSpecFetchCredentials | undefined = - Object.keys(specFetchHeaders).length === 0 && Object.keys(specFetchQueryParams).length === 0 - ? undefined - : { - ...(Object.keys(specFetchHeaders).length > 0 ? { headers: specFetchHeaders } : {}), - ...(Object.keys(specFetchQueryParams).length > 0 - ? { queryParams: specFetchQueryParams } - : {}), - }; - - return { - namespace: sourceId, - scope, - name: sourceRow.name, - config: { - spec: sourceRow.spec, - sourceUrl: sourceRow.source_url ?? undefined, - baseUrl: sourceRow.base_url ?? undefined, - headers, - queryParams, - specFetchCredentials, - oauth2, - }, - }; - }); - const rowToOperation = (row: OpenapiOperationRow): StoredOperation => { - const operationRow = decodeOperationStorageRow(row); - return { - toolId: operationRow.id, - sourceId: operationRow.source_id, - binding: decodeBinding( - typeof operationRow.binding === "string" - ? decodeBindingJson(operationRow.binding) - : operationRow.binding, - ), - }; - }; - - // Replace the rows of one child table for a source: delete then bulk - // insert. Single helper so upsertSource and updateSourceMeta both - // funnel through the same write path. - const replaceChildRows = ( - tableName: (typeof openapiCredentialChildTables)[number], - sourceId: string, - scope: string, - values: Record | undefined, - ) => + const removeOperationsForSourceScope = (sourceId: string, scope: string) => Effect.gen(function* () { - yield* fuma.use(`${tableName}.deleteMany`, (db) => - db.deleteMany(tableName, { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ); - const rows = valueMapToChildRows(sourceId, scope, values); - if (rows.length === 0) return; - yield* fuma.use(`${tableName}.createMany`, (db) => db.createMany(tableName, rows)); + const rows = yield* listOperationRowsForSourceScope(sourceId, scope); + for (const row of rows) { + yield* pluginStorage.remove({ + scope, + collection: OPERATION_COLLECTION, + key: row.key, + }); + } }); const deleteSource = (namespace: string, scope: string) => Effect.gen(function* () { - yield* fuma.use("openapi_operation.deleteMany", (db) => - db.deleteMany("openapi_operation", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - // Drop every child table's rows for this source/scope. - for (const tableName of openapiCredentialChildTables) { - yield* fuma.use(`${tableName}.deleteMany`, (db) => - db.deleteMany(tableName, { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - } - yield* fuma.use("openapi_source.deleteMany", (db) => - db.deleteMany("openapi_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); + yield* removeOperationsForSourceScope(namespace, scope); + yield* pluginStorage.remove({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + }); }); return { upsertSource: (input, operations) => Effect.gen(function* () { yield* deleteSource(input.namespace, input.scope); - yield* fuma.use("openapi_source.createMany", (db) => - db.createMany("openapi_source", [ - { - id: input.namespace, - scope_id: input.scope, - name: input.name, - spec: input.config.spec, - source_url: input.config.sourceUrl ?? null, - base_url: input.config.baseUrl ?? null, - oauth2: input.config.oauth2 - ? toJsonRecord(encodeOAuth2SourceConfig(input.config.oauth2)) - : null, - }, - ]), - ); - yield* replaceChildRows( - "openapi_source_header", - input.namespace, - input.scope, - input.config.headers, - ); - yield* replaceChildRows( - "openapi_source_query_param", - input.namespace, - input.scope, - input.config.queryParams, - ); - yield* replaceChildRows( - "openapi_source_spec_fetch_header", - input.namespace, - input.scope, - input.config.specFetchCredentials?.headers, - ); - yield* replaceChildRows( - "openapi_source_spec_fetch_query_param", - input.namespace, - input.scope, - input.config.specFetchCredentials?.queryParams, - ); - if (operations.length > 0) { - yield* fuma.use("openapi_operation.createMany", (db) => - db.createMany( - "openapi_operation", - operations.map((op) => ({ - id: op.toolId, - scope_id: input.scope, - source_id: op.sourceId, - binding: toJsonRecord(encodeBinding(op.binding)), - })), - ), - ); + yield* pluginStorage.put({ + scope: input.scope, + collection: SOURCE_COLLECTION, + key: input.namespace, + data: sourceData(input), + }); + for (const operation of operations) { + yield* pluginStorage.put({ + scope: input.scope, + collection: OPERATION_COLLECTION, + key: operation.toolId, + data: operationData(operation), + }); } }), updateSourceMeta: (namespace, scope, patch) => Effect.gen(function* () { - const existingRow = yield* fuma.use("openapi_source.findFirst", (db) => - db.findFirst("openapi_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - if (!existingRow) return; - const existing = yield* rowToSource(existingRow); - - const nextName = patch.name?.trim() || existing.name; - const nextBaseUrl = patch.baseUrl !== undefined ? patch.baseUrl : existing.config.baseUrl; - const nextOAuth2 = patch.oauth2 !== undefined ? patch.oauth2 : existing.config.oauth2; - - yield* fuma.use("openapi_source.updateMany", (db) => - db.updateMany("openapi_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - set: { - name: nextName, - base_url: nextBaseUrl ?? null, - oauth2: nextOAuth2 ? toJsonRecord(encodeOAuth2SourceConfig(nextOAuth2)) : null, - }, - }), - ); - if (patch.headers !== undefined) { - yield* replaceChildRows("openapi_source_header", namespace, scope, patch.headers); - } - if (patch.queryParams !== undefined) { - yield* replaceChildRows( - "openapi_source_query_param", - namespace, - scope, - patch.queryParams, - ); - } - if (patch.specFetchCredentials !== undefined) { - yield* replaceChildRows( - "openapi_source_spec_fetch_header", - namespace, - scope, - patch.specFetchCredentials.headers, - ); - yield* replaceChildRows( - "openapi_source_spec_fetch_query_param", - namespace, - scope, - patch.specFetchCredentials.queryParams, - ); - } + const existing = yield* pluginStorage.getAtScope({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + }); + if (!existing) return; + const source = rowToSource(existing); + if (!source) return; + const next: StoredSource = { + ...source, + name: patch.name?.trim() || source.name, + config: { + ...source.config, + ...(patch.baseUrl !== undefined ? { baseUrl: patch.baseUrl } : {}), + ...(patch.headers !== undefined ? { headers: patch.headers } : {}), + ...(patch.queryParams !== undefined ? { queryParams: patch.queryParams } : {}), + ...(patch.specFetchCredentials !== undefined + ? { specFetchCredentials: patch.specFetchCredentials } + : {}), + ...(patch.oauth2 !== undefined ? { oauth2: patch.oauth2 } : {}), + }, + }; + yield* pluginStorage.put({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + data: sourceData(next), + }); }), getSource: (namespace, scope) => - Effect.gen(function* () { - const row = yield* fuma.use("openapi_source.findFirst", (db) => - db.findFirst("openapi_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - if (!row) return null; - return yield* rowToSource(row); - }), + pluginStorage + .getAtScope({ scope, collection: SOURCE_COLLECTION, key: namespace }) + .pipe(Effect.map((row) => (row ? rowToSource(row) : null))), listSources: () => - Effect.gen(function* () { - const rows = yield* fuma.use("openapi_source.findMany", (db) => - db.findMany("openapi_source", { - where: (b) => - scopeIds.length === 1 - ? b("scope_id", "=", scopeIds[0]!) - : b("scope_id", "in", [...scopeIds]), - }), - ); - return yield* Effect.forEach(rows, rowToSource, { - concurrency: "unbounded", - }); - }), + pluginStorage + .list({ collection: SOURCE_COLLECTION }) + .pipe(Effect.map((rows) => rows.map(rowToSource).filter(Predicate.isNotNull))), getOperationByToolId: (toolId, scope) => - fuma - .use("openapi_operation.findFirst", (db) => - db.findFirst("openapi_operation", { - where: (b) => b.and(b("id", "=", toolId), b("scope_id", "=", scope)), - }), - ) + pluginStorage + .getAtScope({ scope, collection: OPERATION_COLLECTION, key: toolId }) .pipe(Effect.map((row) => (row ? rowToOperation(row) : null))), listOperationsBySource: (sourceId, scope) => - fuma - .use("openapi_operation.findMany", (db) => - db.findMany("openapi_operation", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ) - .pipe(Effect.map((rows) => rows.map(rowToOperation))), + listOperationRowsForSourceScope(sourceId, scope).pipe( + Effect.map((rows) => rows.map(rowToOperation).filter(Predicate.isNotNull)), + ), removeSource: (namespace, scope) => deleteSource(namespace, scope), }; diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 814274d40..89a40fa2e 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -136,6 +136,8 @@ export const refreshSource = ExecutorApiClient.mutation("sources", "refresh"); export const detectSource = ExecutorApiClient.mutation("sources", "detect"); +export const configureSource = ExecutorApiClient.mutation("sources", "configure"); + export const sourceCredentialBindingsAtom = ( scopeId: ScopeId, sourceId: string, diff --git a/packages/react/src/plugins/http-credentials.tsx b/packages/react/src/plugins/http-credentials.tsx index 643da48d9..1212c781c 100644 --- a/packages/react/src/plugins/http-credentials.tsx +++ b/packages/react/src/plugins/http-credentials.tsx @@ -143,6 +143,115 @@ export const serializeScopedHttpCredentials = ( queryParams: serializeScopedQueryCredentials(credentials.queryParams, fallbackTargetScope), }); +export type HttpConfigureCredentialInput = + | string + | { + readonly kind: "text"; + readonly text: string; + readonly prefix?: string; + } + | { + readonly kind: "secret"; + readonly secretId: string; + readonly secretScope?: ScopeId; + readonly prefix?: string; + }; + +export const serializeConfigureHeaderCredentials = ( + headers: readonly HeaderState[], + fallbackSecretScope: ScopeId, +): Record => { + const result: Record = {}; + for (const header of headers) { + const name = header.name.trim(); + if (!name || !header.secretId) continue; + result[name] = { + kind: "secret", + secretId: header.secretId, + secretScope: header.secretScope ?? fallbackSecretScope, + ...(header.prefix ? { prefix: header.prefix } : {}), + }; + } + return result; +}; + +export const serializeConfigureQueryCredentials = ( + queryParams: readonly QueryParamState[], + fallbackSecretScope: ScopeId, +): Record => { + const result: Record = {}; + for (const param of queryParams) { + const name = param.name.trim(); + if (!name) continue; + if (param.secretId) { + result[name] = { + kind: "secret", + secretId: param.secretId, + secretScope: param.secretScope ?? fallbackSecretScope, + ...(param.prefix ? { prefix: param.prefix } : {}), + }; + continue; + } + if (param.literalValue?.trim()) { + result[name] = param.literalValue.trim(); + } + } + return result; +}; + +export const serializeConfigureHttpCredentials = ( + credentials: HttpCredentialsState, + fallbackSecretScope: ScopeId, +) => ({ + headers: serializeConfigureHeaderCredentials(credentials.headers, fallbackSecretScope), + queryParams: serializeConfigureQueryCredentials(credentials.queryParams, fallbackSecretScope), +}); + +export type HttpTemplateCredentialInput = + | string + | { readonly kind: "secret"; readonly prefix?: string }; + +export const serializeTemplateHeaderCredentials = ( + headers: readonly HeaderState[], +): Record => { + const result: Record = {}; + for (const header of headers) { + const name = header.name.trim(); + if (!name || !header.secretId) continue; + result[name] = { + kind: "secret", + ...(header.prefix ? { prefix: header.prefix } : {}), + }; + } + return result; +}; + +export const serializeTemplateQueryCredentials = ( + queryParams: readonly QueryParamState[], +): Record => { + const result: Record = {}; + for (const param of queryParams) { + const name = param.name.trim(); + if (!name) continue; + if (param.secretId) { + result[name] = { + kind: "secret", + ...(param.prefix ? { prefix: param.prefix } : {}), + }; + continue; + } + if (param.literalValue?.trim()) { + result[name] = param.literalValue.trim(); + } + } + return result; +}; + +export const serializeTemplateHttpCredentials = (credentials: HttpCredentialsState) => ({ + headers: serializeTemplateHeaderCredentials(credentials.headers), + queryParams: serializeTemplateQueryCredentials(credentials.queryParams), +}); + export const httpCredentialsValid = (credentials: HttpCredentialsState): boolean => credentials.headers.every((header) => header.name.trim() && header.secretId) && credentials.queryParams.every((param) => { From f28c5b6f6a40cf16d20e2e095e63d66a0095f18f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 10:39:17 -0700 Subject: [PATCH 03/19] Restore OpenAPI credential scope selection --- .../openapi/src/react/AddOpenApiSource.tsx | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index fab1464f3..9ee48361e 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -276,10 +276,6 @@ export default function AddOpenApiSource(props: { const [oauthTokenTargetScope, setOAuthTokenTargetScope] = useState( defaultOAuthTokenTargetScope, ); - const sourceCredentialScopeOptions = useMemo( - () => credentialScopeOptions.filter((option) => option.scopeId === scopeId), - [credentialScopeOptions, scopeId], - ); useEffect(() => { if (!credentialScopeOptions.some((option) => option.scopeId === oauthTokenTargetScope)) { setOAuthTokenTargetScope(defaultOAuthTokenTargetScope); @@ -294,10 +290,6 @@ export default function AddOpenApiSource(props: { mode: "promiseExit", }); const secretList = useSecretPickerSecrets(); - const sourceCredentialSecrets = useMemo( - () => secretList.filter((secret) => secret.scopeId === String(scopeId)), - [scopeId, secretList], - ); const oauth = useOAuthPopupFlow({ popupName: OPENAPI_OAUTH_POPUP_NAME, popupBlockedMessage: "OAuth popup was blocked by the browser", @@ -354,11 +346,12 @@ export default function AddOpenApiSource(props: { const slot = headerBindingSlot(ch.name.trim()); configuredHeaders[ch.name.trim()] = { kind: "secret", prefix: ch.prefix }; if (ch.secretId) { + const targetScope = ch.targetScope ?? sourceScope; headerBindings.push({ slot, secretId: ch.secretId, - scope: sourceScope, - secretScope: ch.secretScope ?? sourceScope, + scope: targetScope, + secretScope: ch.secretScope ?? targetScope, }); } } @@ -367,12 +360,13 @@ export default function AddOpenApiSource(props: { if (!name) continue; if (param.secretId) { const slot = queryParamBindingSlot(name); + const targetScope = param.targetScope ?? sourceScope; configuredQueryParams[name] = { kind: "secret", prefix: param.prefix }; queryParamBindings.push({ slot, secretId: param.secretId, - scope: sourceScope, - secretScope: param.secretScope ?? sourceScope, + scope: targetScope, + secretScope: param.secretScope ?? targetScope, }); continue; } @@ -389,24 +383,26 @@ export default function AddOpenApiSource(props: { for (const header of specFetchCredentials.headers) { const name = header.name.trim(); if (!name || !header.secretId) continue; + const targetScope = header.targetScope ?? sourceScope; configuredSpecFetchHeaders[name] = { kind: "secret", prefix: header.prefix }; specFetchBindings.push({ slot: specFetchHeaderBindingSlot(name), secretId: header.secretId, - scope: sourceScope, - secretScope: header.secretScope ?? sourceScope, + scope: targetScope, + secretScope: header.secretScope ?? targetScope, }); } for (const param of specFetchCredentials.queryParams) { const name = param.name.trim(); if (!name) continue; if (param.secretId) { + const targetScope = param.targetScope ?? sourceScope; configuredSpecFetchQueryParams[name] = { kind: "secret", prefix: param.prefix }; specFetchBindings.push({ slot: specFetchQueryParamBindingSlot(name), secretId: param.secretId, - scope: sourceScope, - secretScope: param.secretScope ?? sourceScope, + scope: targetScope, + secretScope: param.secretScope ?? targetScope, }); continue; } @@ -948,8 +944,8 @@ export default function AddOpenApiSource(props: { existingSecrets={secretList} sourceName={identity.name} targetScope={sourceScope} - credentialScopeOptions={sourceCredentialScopeOptions} - bindingScopeOptions={sourceCredentialScopeOptions} + credentialScopeOptions={credentialScopeOptions} + bindingScopeOptions={credentialScopeOptions} restrictSecretsToTargetScope labels={{ headers: "Spec fetch headers", @@ -1087,8 +1083,8 @@ export default function AddOpenApiSource(props: { existingSecrets={secretList} sourceName={identity.name} targetScope={sourceScope} - credentialScopeOptions={sourceCredentialScopeOptions} - bindingScopeOptions={sourceCredentialScopeOptions} + credentialScopeOptions={credentialScopeOptions} + bindingScopeOptions={credentialScopeOptions} restrictSecretsToTargetScope emptyLabel="No credentials yet. Add the header value this method should use." /> @@ -1101,8 +1097,8 @@ export default function AddOpenApiSource(props: { existingSecrets={secretList} sourceName={identity.name} targetScope={sourceScope} - credentialScopeOptions={sourceCredentialScopeOptions} - bindingScopeOptions={sourceCredentialScopeOptions} + credentialScopeOptions={credentialScopeOptions} + bindingScopeOptions={credentialScopeOptions} restrictSecretsToTargetScope sections={{ headers: false, queryParams: true }} labels={{ queryParams: "Runtime query parameters" }} @@ -1141,11 +1137,11 @@ export default function AddOpenApiSource(props: { setOauth2ClientIdScope(secretScopeId ?? sourceScope); setOauth2AuthState(null); }} - secrets={sourceCredentialSecrets} + secrets={secretList} sourceName={identity.name} secretLabel="Client ID" targetScope={oauth2ClientIdScope ?? sourceScope} - credentialScopeOptions={sourceCredentialScopeOptions} + credentialScopeOptions={credentialScopeOptions} onCreatedScope={setOauth2ClientIdScope} /> @@ -1173,11 +1169,11 @@ export default function AddOpenApiSource(props: { setOauth2ClientSecretScope(secretScopeId ?? sourceScope); setOauth2AuthState(null); }} - secrets={sourceCredentialSecrets} + secrets={secretList} sourceName={identity.name} secretLabel="Client Secret" targetScope={oauth2ClientSecretScope ?? sourceScope} - credentialScopeOptions={sourceCredentialScopeOptions} + credentialScopeOptions={credentialScopeOptions} onCreatedScope={setOauth2ClientSecretScope} /> From 1949d2a3f5e99c67464215e75f7591eefc54cded Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 10:49:47 -0700 Subject: [PATCH 04/19] Unify MCP request header inputs --- .../plugins/mcp/src/react/AddMcpSource.tsx | 172 ++---------------- .../src/plugins/credential-bindings.test.ts | 2 + .../react/src/plugins/credential-bindings.tsx | 5 +- packages/react/src/plugins/headers-list.tsx | 131 ++++++++++++- .../react/src/plugins/http-credentials.tsx | 36 +++- .../react/src/plugins/secret-header-auth.tsx | 27 ++- 6 files changed, 197 insertions(+), 176 deletions(-) diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index 1d9ba3fa9..e2cab01c1 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -1,4 +1,4 @@ -import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useReducer, useCallback, useEffect, useRef, useState } from "react"; import { useAtomSet } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import * as Match from "effect/Match"; @@ -11,14 +11,12 @@ import { Button } from "@executor-js/react/components/button"; import { CardStack, CardStackContent, - CardStackEntry, CardStackEntryField, } from "@executor-js/react/components/card-stack"; import { FieldLabel } from "@executor-js/react/components/field"; import { FilterTabs } from "@executor-js/react/components/filter-tabs"; import { FloatActions } from "@executor-js/react/components/float-actions"; import { Input } from "@executor-js/react/components/input"; -import { Label } from "@executor-js/react/components/label"; import { Spinner } from "@executor-js/react/components/spinner"; import { Textarea } from "@executor-js/react/components/textarea"; import { @@ -47,6 +45,10 @@ import { CredentialUsageRow, useCredentialTargetScope, } from "@executor-js/react/plugins/credential-target-scope"; +import { + defaultHeaderAuthPresets, + type HeaderAuthPreset, +} from "@executor-js/react/plugins/secret-header-auth"; type RemoteAuthMode = "none" | "oauth2"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; @@ -55,6 +57,11 @@ import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; import { mcpPresets, type McpPreset } from "../sdk/presets"; import type { McpConfiguredValueInput, McpCredentialInput } from "../sdk/types"; +const mcpHeaderPresets: readonly HeaderAuthPreset[] = [ + { key: "text", label: "Plaintext header", name: "", valueKind: "text" }, + ...defaultHeaderAuthPresets, +]; + const ErrorMessage = Schema.Struct({ message: Schema.String }); const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly> = { @@ -96,11 +103,6 @@ type ProbeResult = { serverName: string | null; }; -type PlainHeader = { - name: string; - value: string; -}; - type State = | { step: "url"; url: string } | { step: "probing"; url: string; probe: ProbeResult | null } @@ -316,7 +318,6 @@ export default function AddMcpSource(props: { }); const [remoteAuthMode, setRemoteAuthMode] = useState("none"); - const [remoteHeaders, setRemoteHeaders] = useState([]); const [remoteCredentials, setRemoteCredentials] = useState(() => emptyHttpCredentials()); const probe = "probe" in state ? state.probe : null; @@ -331,18 +332,10 @@ export default function AddMcpSource(props: { const isOAuthBusy = state.step === "oauth-starting" || state.step === "oauth-waiting" || oauth.busy; const canUseNone = probe?.requiresOAuth !== true || probe.supportsDynamicRegistration === false; - const remoteHeadersComplete = remoteHeaders.every( - (header) => header.name.trim() && header.value.trim(), - ); const remoteCredentialsComplete = httpCredentialsValid(remoteCredentials); const authReady = remoteAuthMode === "none" ? canUseNone : tokens !== null; const canAdd = - Boolean(probe) && - authReady && - remoteHeadersComplete && - remoteCredentialsComplete && - !isAdding && - !isOAuthBusy; + Boolean(probe) && authReady && remoteCredentialsComplete && !isAdding && !isOAuthBusy; // Probe failures are shown inline on the URL field; other failures // (OAuth start, add source) render in the bottom error block. const probeError = state.step === "error" && state.probe === null ? state.error : null; @@ -440,20 +433,15 @@ export default function AddMcpSource(props: { const handleAddRemote = useCallback(async () => { if (!probe) return; dispatch({ type: "add-start" }); - const headers = Object.fromEntries( - remoteHeaders - .map((header) => [header.name.trim(), header.value.trim()] as const) - .filter(([name, value]) => name && value), - ); const templateCredentials = serializeTemplateHttpCredentials(remoteCredentials); const configureCredentials = serializeConfigureHttpCredentials( remoteCredentials, requestCredentialTargetScope, ); - const remoteRequestHeaders: Record = { - ...headers, - ...templateCredentials.headers, - }; + const remoteRequestHeaders = templateCredentials.headers as Record< + string, + McpConfiguredValueInput + >; const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name; const slugNamespace = slugifyNamespace(remoteIdentity.namespace); const exit = await doAdd({ @@ -535,7 +523,6 @@ export default function AddMcpSource(props: { }, [ probe, remoteAuthMode, - remoteHeaders, remoteCredentials, remoteIdentity, tokens, @@ -678,6 +665,7 @@ export default function AddMcpSource(props: { targetScope={requestCredentialTargetScope} credentialScopeOptions={credentialScopeOptions} bindingScopeOptions={credentialScopeOptions} + headerPresets={mcpHeaderPresets} labels={{ headers: "Request headers", queryParams: "Query parameters", @@ -786,108 +774,6 @@ export default function AddMcpSource(props: { )} - {/* Additional headers */} - {probe && ( -
-
- -

- Plaintext headers sent with every request. Use request headers above for - secret-backed values. -

-
- - - - {remoteHeaders.length === 0 ? ( - No headers} - onClick={() => - setRemoteHeaders((headers) => [...headers, { name: "", value: "" }]) - } - /> - ) : ( - <> - {remoteHeaders.map((header, index) => ( - -
- - -
-
-
- - - setRemoteHeaders((headers) => - headers.map((current, headerIndex) => - headerIndex === index - ? { - ...current, - name: (event.target as HTMLInputElement).value, - } - : current, - ), - ) - } - placeholder="X-Organization-Id" - className="h-8 text-xs font-mono" - /> -
-
- - - setRemoteHeaders((headers) => - headers.map((current, headerIndex) => - headerIndex === index - ? { - ...current, - value: (event.target as HTMLInputElement).value, - } - : current, - ), - ) - } - placeholder="workspace-id" - className="h-8 text-xs font-mono" - /> -
-
-
- ))} - - setRemoteHeaders((headers) => [...headers, { name: "", value: "" }]) - } - /> - - )} -
-
-
- )} - {/* Error (OAuth / add source). Probe errors show inline on the field. */} {otherError && (
@@ -1008,29 +894,3 @@ export default function AddMcpSource(props: {
); } - -function AddPlainHeaderRow({ - onClick, - leading, -}: { - readonly onClick: () => void; - readonly leading?: ReactNode; -}) { - return ( - // oxlint-disable-next-line react/forbid-elements - - ); -} diff --git a/packages/react/src/plugins/credential-bindings.test.ts b/packages/react/src/plugins/credential-bindings.test.ts index 45523b9a7..303cb6dbb 100644 --- a/packages/react/src/plugins/credential-bindings.test.ts +++ b/packages/react/src/plugins/credential-bindings.test.ts @@ -49,6 +49,7 @@ describe("credential binding editor helpers", () => { { name: "Authorization", secretId: "personal-api-token", + valueKind: "secret", prefix: "Bearer ", presetKey: "bearer", targetScope: personalScope, @@ -59,6 +60,7 @@ describe("credential binding editor helpers", () => { { name: "token", secretId: null, + valueKind: "text", literalValue: "literal-token", }, ]); diff --git a/packages/react/src/plugins/credential-bindings.tsx b/packages/react/src/plugins/credential-bindings.tsx index 848aa9650..b32bfdb47 100644 --- a/packages/react/src/plugins/credential-bindings.tsx +++ b/packages/react/src/plugins/credential-bindings.tsx @@ -93,7 +93,7 @@ const queryParamFromConfiguredCredential = ( bindings: ReadonlyMap, ): QueryParamState | null => { if (typeof value === "string") { - return { name, secretId: null, literalValue: value }; + return { name, secretId: null, literalValue: value, valueKind: "text" }; } const binding = bindings.get(value.slot); @@ -101,6 +101,7 @@ const queryParamFromConfiguredCredential = ( return { name, secretId: binding.value.secretId, + valueKind: "secret", prefix: value.prefix, targetScope: binding.scopeId, secretScope: binding.value.secretScopeId, @@ -108,7 +109,7 @@ const queryParamFromConfiguredCredential = ( } if (binding?.value.kind === "text") { - return { name, secretId: null, literalValue: binding.value.text }; + return { name, secretId: null, literalValue: binding.value.text, valueKind: "text" }; } return null; diff --git a/packages/react/src/plugins/headers-list.tsx b/packages/react/src/plugins/headers-list.tsx index a3d1540ab..8994496a6 100644 --- a/packages/react/src/plugins/headers-list.tsx +++ b/packages/react/src/plugins/headers-list.tsx @@ -9,6 +9,8 @@ import { CardStackEmpty, CardStackEntry, } from "../components/card-stack"; +import { Field, FieldGroup, FieldLabel } from "../components/field"; +import { Input } from "../components/input"; import { defaultHeaderAuthPresets, type HeaderAuthPreset, @@ -46,6 +48,7 @@ export interface HeadersListProps { /** Scope choices for where this source credential is used. */ readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; readonly restrictSecretsToTargetScope?: boolean; + readonly defaultValueKind?: HeaderState["valueKind"]; } export function HeadersList({ @@ -64,6 +67,7 @@ export function HeadersList({ credentialScopeOptions, bindingScopeOptions, restrictSecretsToTargetScope, + defaultValueKind = "secret", }: HeadersListProps) { const [picking, setPicking] = useState(false); const canAddMore = !singleHeader || headers.length === 0; @@ -84,6 +88,7 @@ export function HeadersList({ prefix: preset.prefix, presetKey: preset.key, secretId: null, + valueKind: preset.valueKind ?? defaultValueKind, targetScope, }, ]); @@ -97,6 +102,8 @@ export function HeadersList({ secretId: string | null; prefix?: string; presetKey?: string; + valueKind?: HeaderState["valueKind"]; + literalValue?: string; targetScope?: ScopeId; secretScope?: ScopeId; }>, @@ -132,13 +139,10 @@ export function HeadersList({ ) : ( <> {headers.map((header, index) => ( - updateHeader(index, update)} onSelectSecret={(secretId, scopeId) => updateHeader(index, { @@ -149,7 +153,6 @@ export function HeadersList({ onRemove={singleHeader ? undefined : () => removeHeader(index)} existingSecrets={existingSecrets} sourceName={sourceName} - targetScope={header.targetScope ?? targetScope} credentialScopeOptions={credentialScopeOptions} bindingScopeOptions={bindingScopeOptions} restrictSecretsToTargetScope={restrictSecretsToTargetScope} @@ -167,6 +170,120 @@ export function HeadersList({ ); } +function HeaderRow(props: { + readonly header: HeaderState; + readonly targetScope: ScopeId; + readonly onChange: ( + update: Partial<{ + name: string; + secretId: string | null; + prefix?: string; + presetKey?: string; + valueKind?: HeaderState["valueKind"]; + literalValue?: string; + targetScope?: ScopeId; + secretScope?: ScopeId; + }>, + ) => void; + readonly onSelectSecret: (secretId: string, scopeId?: ScopeId) => void; + readonly onRemove?: () => void; + readonly existingSecrets: readonly SecretPickerSecret[]; + readonly sourceName?: string; + readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; + readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; + readonly restrictSecretsToTargetScope?: boolean; + readonly copy?: Partial; + readonly previewComponent?: SecretCredentialPreviewComponent; +}) { + if (props.header.valueKind === "text") { + return ( + props.onChange(update)} + onRemove={props.onRemove} + rowLabel={props.copy?.rowLabel} + nameLabel={props.copy?.nameLabel} + namePlaceholder={props.copy?.namePlaceholder} + /> + ); + } + + return ( + + ); +} + +function TextHeaderRow(props: { + readonly name: string; + readonly value: string; + readonly onChange: (update: { name?: string; literalValue?: string }) => void; + readonly onRemove?: () => void; + readonly rowLabel?: string; + readonly nameLabel?: string; + readonly namePlaceholder?: string; +}) { + return ( +
+
+ + {props.rowLabel ?? "Header"} + + {props.onRemove && ( + + )} +
+ + + {props.nameLabel ?? "Name"} + props.onChange({ name: (event.target as HTMLInputElement).value })} + placeholder={props.namePlaceholder ?? "X-Organization-Id"} + className="font-mono" + /> + + + Value + + props.onChange({ literalValue: (event.target as HTMLInputElement).value }) + } + placeholder="workspace-id" + className="font-mono" + /> + + +
+ ); +} + interface AddHeaderRowProps { readonly onClick: () => void; readonly leading?: ReactNode; diff --git a/packages/react/src/plugins/http-credentials.tsx b/packages/react/src/plugins/http-credentials.tsx index 1212c781c..01fe29daa 100644 --- a/packages/react/src/plugins/http-credentials.tsx +++ b/packages/react/src/plugins/http-credentials.tsx @@ -21,6 +21,7 @@ export type { SecretBackedValue }; export type QueryParamState = { name: string; secretId: string | null; + valueKind?: "secret" | "text"; prefix?: string; literalValue?: string; targetScope?: ScopeId; @@ -50,15 +51,15 @@ export const httpCredentialsFromValues = (input: { ), queryParams: Object.entries(input.queryParams ?? {}).map(([name, value]) => { if (typeof value === "string") { - return { name, secretId: null, literalValue: value }; + return { name, secretId: null, literalValue: value, valueKind: "text" as const }; } - return { name, secretId: value.secretId, prefix: value.prefix }; + return { name, secretId: value.secretId, prefix: value.prefix, valueKind: "secret" as const }; }), }); export const serializeHeaderCredentials = ( headers: readonly HeaderState[], -): Record => headersFromState(headers); +): Record => headersFromState(headers); export const serializeQueryCredentials = ( queryParams: readonly QueryParamState[], @@ -84,7 +85,7 @@ export const serializeQueryCredentials = ( export const serializeHttpCredentials = ( credentials: HttpCredentialsState, ): { - readonly headers: Record; + readonly headers: Record; readonly queryParams: Record; } => ({ headers: serializeHeaderCredentials(credentials.headers), @@ -164,7 +165,14 @@ export const serializeConfigureHeaderCredentials = ( const result: Record = {}; for (const header of headers) { const name = header.name.trim(); - if (!name || !header.secretId) continue; + if (!name) continue; + if (header.valueKind === "text") { + if (header.literalValue?.trim()) { + result[name] = header.literalValue.trim(); + } + continue; + } + if (!header.secretId) continue; result[name] = { kind: "secret", secretId: header.secretId, @@ -217,7 +225,14 @@ export const serializeTemplateHeaderCredentials = ( const result: Record = {}; for (const header of headers) { const name = header.name.trim(); - if (!name || !header.secretId) continue; + if (!name) continue; + if (header.valueKind === "text") { + if (header.literalValue?.trim()) { + result[name] = header.literalValue.trim(); + } + continue; + } + if (!header.secretId) continue; result[name] = { kind: "secret", ...(header.prefix ? { prefix: header.prefix } : {}), @@ -253,7 +268,12 @@ export const serializeTemplateHttpCredentials = (credentials: HttpCredentialsSta }); export const httpCredentialsValid = (credentials: HttpCredentialsState): boolean => - credentials.headers.every((header) => header.name.trim() && header.secretId) && + credentials.headers.every((header) => { + if (!header.name.trim()) return false; + return header.valueKind === "text" + ? Boolean(header.literalValue?.trim()) + : Boolean(header.secretId); + }) && credentials.queryParams.every((param) => { if (!param.name.trim()) return false; return Boolean(param.secretId || param.literalValue?.trim()); @@ -276,6 +296,7 @@ export function HttpCredentialsEditor(props: { readonly headers?: string; readonly queryParams?: string; }; + readonly headerPresets?: readonly HeaderAuthPreset[]; }) { const showHeaders = props.sections?.headers ?? true; const showQueryParams = props.sections?.queryParams ?? true; @@ -294,6 +315,7 @@ export function HttpCredentialsEditor(props: { credentialScopeOptions={props.credentialScopeOptions} bindingScopeOptions={props.bindingScopeOptions} restrictSecretsToTargetScope={props.restrictSecretsToTargetScope} + presets={props.headerPresets} /> )} diff --git a/packages/react/src/plugins/secret-header-auth.tsx b/packages/react/src/plugins/secret-header-auth.tsx index 40d495b54..eb89a571e 100644 --- a/packages/react/src/plugins/secret-header-auth.tsx +++ b/packages/react/src/plugins/secret-header-auth.tsx @@ -34,6 +34,7 @@ export interface HeaderAuthPreset { readonly label: string; readonly name: string; readonly prefix?: string; + readonly valueKind?: HeaderValueKind; } export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [ @@ -203,6 +204,8 @@ export function QueryParamCredentialValuePreview(props: SecretCredentialPreviewP export type HeaderState = { name: string; secretId: string | null; + valueKind?: HeaderValueKind; + literalValue?: string; prefix?: string; presetKey?: string; fromPreset?: boolean; @@ -212,6 +215,8 @@ export type HeaderState = { secretScope?: ScopeId; }; +export type HeaderValueKind = "secret" | "text"; + export function matchPresetKey(name: string, prefix?: string): string { const preset = defaultHeaderAuthPresets.find((p) => p.name === name && p.prefix === prefix) ?? @@ -257,11 +262,18 @@ export function headerValueToState( value: { secretId: string; prefix?: string } | string, ): HeaderState { if (typeof value === "string") { - return { name, secretId: null, presetKey: matchPresetKey(name, undefined) }; + return { + name, + secretId: null, + literalValue: value, + valueKind: "text", + presetKey: matchPresetKey(name, undefined), + }; } return { name, secretId: value.secretId, + valueKind: "secret", prefix: value.prefix, presetKey: matchPresetKey(name, value.prefix), }; @@ -269,11 +281,18 @@ export function headerValueToState( export function headersFromState( entries: readonly HeaderState[], -): Record { - const result: Record = {}; +): Record { + const result: Record = {}; for (const entry of entries) { const name = entry.name.trim(); - if (!name || !entry.secretId) continue; + if (!name) continue; + if (entry.valueKind === "text") { + if (entry.literalValue?.trim()) { + result[name] = entry.literalValue.trim(); + } + continue; + } + if (!entry.secretId) continue; result[name] = { secretId: entry.secretId, ...(entry.prefix ? { prefix: entry.prefix } : {}), From 0fb2c34881ebf8747c62546ddd155a20657715b8 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 10:57:42 -0700 Subject: [PATCH 05/19] Use credential scope for inline secret creation --- packages/react/src/plugins/headers-list.tsx | 4 --- .../react/src/plugins/secret-header-auth.tsx | 30 ++----------------- 2 files changed, 3 insertions(+), 31 deletions(-) diff --git a/packages/react/src/plugins/headers-list.tsx b/packages/react/src/plugins/headers-list.tsx index 8994496a6..305e92b82 100644 --- a/packages/react/src/plugins/headers-list.tsx +++ b/packages/react/src/plugins/headers-list.tsx @@ -64,7 +64,6 @@ export function HeadersList({ rowPreviewComponent, sourceName, targetScope, - credentialScopeOptions, bindingScopeOptions, restrictSecretsToTargetScope, defaultValueKind = "secret", @@ -153,7 +152,6 @@ export function HeadersList({ onRemove={singleHeader ? undefined : () => removeHeader(index)} existingSecrets={existingSecrets} sourceName={sourceName} - credentialScopeOptions={credentialScopeOptions} bindingScopeOptions={bindingScopeOptions} restrictSecretsToTargetScope={restrictSecretsToTargetScope} copy={rowCopy} @@ -189,7 +187,6 @@ function HeaderRow(props: { readonly onRemove?: () => void; readonly existingSecrets: readonly SecretPickerSecret[]; readonly sourceName?: string; - readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; readonly restrictSecretsToTargetScope?: boolean; readonly copy?: Partial; @@ -222,7 +219,6 @@ function HeaderRow(props: { existingSecrets={props.existingSecrets} sourceName={props.sourceName} targetScope={props.header.targetScope ?? props.targetScope} - credentialScopeOptions={props.credentialScopeOptions} bindingScopeOptions={props.bindingScopeOptions} restrictSecretsToTargetScope={props.restrictSecretsToTargetScope} copy={props.copy} diff --git a/packages/react/src/plugins/secret-header-auth.tsx b/packages/react/src/plugins/secret-header-auth.tsx index eb89a571e..84bfdf194 100644 --- a/packages/react/src/plugins/secret-header-auth.tsx +++ b/packages/react/src/plugins/secret-header-auth.tsx @@ -21,10 +21,7 @@ import { } from "../components/select"; import { SecretForm } from "./secret-form"; import { SecretPicker, type SecretPickerSecret } from "./secret-picker"; -import { - CredentialTargetScopeSelector, - type CredentialTargetScopeOption, -} from "./credential-target-scope"; +import type { CredentialTargetScopeOption } from "./credential-target-scope"; import { secretsForCredentialTarget } from "./secret-credential-scope"; export { secretsForCredentialTarget }; @@ -64,29 +61,16 @@ function CreateSecretContent(props: { onCancel?: () => void; fallbackId?: string; targetScope: ScopeId; - credentialScopeOptions?: readonly CredentialTargetScopeOption[]; }) { - const [scopeId, setScopeId] = useState(props.targetScope); - const activeScope = props.credentialScopeOptions?.find((option) => option.scopeId === scopeId); - return ( props.onCreated(secretId, scopeId)} + scopeId={props.targetScope} + onCreated={(secretId) => props.onCreated(secretId, props.targetScope)} >
- {props.credentialScopeOptions && props.credentialScopeOptions.length > 1 && ( - - )}
@@ -114,7 +98,6 @@ export function InlineCreateSecret(props: { onCancel: () => void; fallbackId?: string; targetScope: ScopeId; - credentialScopeOptions?: readonly CredentialTargetScopeOption[]; }) { return (
@@ -134,7 +117,6 @@ function CreateSecretDialog(props: { readonly onCreated: (secretId: string, scopeId: ScopeId) => void; readonly fallbackId?: string; readonly targetScope: ScopeId; - readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; }) { return ( @@ -152,7 +134,6 @@ function CreateSecretDialog(props: { onCreated={props.onCreated} onCancel={() => props.onOpenChange(false)} targetScope={props.targetScope} - credentialScopeOptions={props.credentialScopeOptions} /> @@ -332,7 +313,6 @@ export function SecretHeaderAuthRow(props: { */ sourceName?: string; targetScope: ScopeId; - credentialScopeOptions?: readonly CredentialTargetScopeOption[]; bindingScopeOptions?: readonly CredentialTargetScopeOption[]; restrictSecretsToTargetScope?: boolean; }) { @@ -354,7 +334,6 @@ export function SecretHeaderAuthRow(props: { previewComponent: PreviewComponent = HeaderCredentialValuePreview, sourceName, targetScope, - credentialScopeOptions, bindingScopeOptions, restrictSecretsToTargetScope = false, } = props; @@ -378,7 +357,6 @@ export function SecretHeaderAuthRow(props: { setCreating(false); }} targetScope={targetScope} - credentialScopeOptions={credentialScopeOptions} />
@@ -519,7 +497,6 @@ export function CreatableSecretPicker(props: { sourceName, secretLabel, targetScope, - credentialScopeOptions, onCreatedScope, suggestedId: suggestedIdProp, } = props; @@ -542,7 +519,6 @@ export function CreatableSecretPicker(props: { setCreating(false); }} targetScope={targetScope} - credentialScopeOptions={credentialScopeOptions} /> ); } From 65a23ba1b741bc19f4861136967ed06e695ef1a7 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 11:01:09 -0700 Subject: [PATCH 06/19] Scope credential secret pickers --- packages/plugins/openapi/src/react/AddOpenApiSource.tsx | 3 --- packages/react/src/plugins/headers-list.tsx | 7 +------ packages/react/src/plugins/http-credentials.tsx | 3 --- packages/react/src/plugins/secret-header-auth.tsx | 7 ++----- 4 files changed, 3 insertions(+), 17 deletions(-) diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index 9ee48361e..92d6b2a70 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -946,7 +946,6 @@ export default function AddOpenApiSource(props: { targetScope={sourceScope} credentialScopeOptions={credentialScopeOptions} bindingScopeOptions={credentialScopeOptions} - restrictSecretsToTargetScope labels={{ headers: "Spec fetch headers", queryParams: "Spec fetch query parameters", @@ -1085,7 +1084,6 @@ export default function AddOpenApiSource(props: { targetScope={sourceScope} credentialScopeOptions={credentialScopeOptions} bindingScopeOptions={credentialScopeOptions} - restrictSecretsToTargetScope emptyLabel="No credentials yet. Add the header value this method should use." />
@@ -1099,7 +1097,6 @@ export default function AddOpenApiSource(props: { targetScope={sourceScope} credentialScopeOptions={credentialScopeOptions} bindingScopeOptions={credentialScopeOptions} - restrictSecretsToTargetScope sections={{ headers: false, queryParams: true }} labels={{ queryParams: "Runtime query parameters" }} /> diff --git a/packages/react/src/plugins/headers-list.tsx b/packages/react/src/plugins/headers-list.tsx index 305e92b82..585bd8832 100644 --- a/packages/react/src/plugins/headers-list.tsx +++ b/packages/react/src/plugins/headers-list.tsx @@ -43,11 +43,10 @@ export interface HeadersListProps { readonly sourceName?: string; /** Inline-created secrets are written to this explicit scope. */ readonly targetScope: ScopeId; - /** Scope choices shown only inside the inline "+ New secret" form. */ + /** Scope choices available for where this source credential is used. */ readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; /** Scope choices for where this source credential is used. */ readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; - readonly restrictSecretsToTargetScope?: boolean; readonly defaultValueKind?: HeaderState["valueKind"]; } @@ -65,7 +64,6 @@ export function HeadersList({ sourceName, targetScope, bindingScopeOptions, - restrictSecretsToTargetScope, defaultValueKind = "secret", }: HeadersListProps) { const [picking, setPicking] = useState(false); @@ -153,7 +151,6 @@ export function HeadersList({ existingSecrets={existingSecrets} sourceName={sourceName} bindingScopeOptions={bindingScopeOptions} - restrictSecretsToTargetScope={restrictSecretsToTargetScope} copy={rowCopy} previewComponent={rowPreviewComponent} /> @@ -188,7 +185,6 @@ function HeaderRow(props: { readonly existingSecrets: readonly SecretPickerSecret[]; readonly sourceName?: string; readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; - readonly restrictSecretsToTargetScope?: boolean; readonly copy?: Partial; readonly previewComponent?: SecretCredentialPreviewComponent; }) { @@ -220,7 +216,6 @@ function HeaderRow(props: { sourceName={props.sourceName} targetScope={props.header.targetScope ?? props.targetScope} bindingScopeOptions={props.bindingScopeOptions} - restrictSecretsToTargetScope={props.restrictSecretsToTargetScope} copy={props.copy} previewComponent={props.previewComponent} /> diff --git a/packages/react/src/plugins/http-credentials.tsx b/packages/react/src/plugins/http-credentials.tsx index 01fe29daa..e2f9f341c 100644 --- a/packages/react/src/plugins/http-credentials.tsx +++ b/packages/react/src/plugins/http-credentials.tsx @@ -287,7 +287,6 @@ export function HttpCredentialsEditor(props: { readonly targetScope: ScopeId; readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; - readonly restrictSecretsToTargetScope?: boolean; readonly sections?: { readonly headers?: boolean; readonly queryParams?: boolean; @@ -314,7 +313,6 @@ export function HttpCredentialsEditor(props: { targetScope={props.targetScope} credentialScopeOptions={props.credentialScopeOptions} bindingScopeOptions={props.bindingScopeOptions} - restrictSecretsToTargetScope={props.restrictSecretsToTargetScope} presets={props.headerPresets} /> @@ -331,7 +329,6 @@ export function HttpCredentialsEditor(props: { targetScope={props.targetScope} credentialScopeOptions={props.credentialScopeOptions} bindingScopeOptions={props.bindingScopeOptions} - restrictSecretsToTargetScope={props.restrictSecretsToTargetScope} presets={queryParamPresets} emptyLabel="No query parameters" addLabel="Add query parameter" diff --git a/packages/react/src/plugins/secret-header-auth.tsx b/packages/react/src/plugins/secret-header-auth.tsx index 84bfdf194..065902e4a 100644 --- a/packages/react/src/plugins/secret-header-auth.tsx +++ b/packages/react/src/plugins/secret-header-auth.tsx @@ -314,7 +314,6 @@ export function SecretHeaderAuthRow(props: { sourceName?: string; targetScope: ScopeId; bindingScopeOptions?: readonly CredentialTargetScopeOption[]; - restrictSecretsToTargetScope?: boolean; }) { const [creating, setCreating] = useState(false); const nameInputId = useId(); @@ -335,7 +334,6 @@ export function SecretHeaderAuthRow(props: { sourceName, targetScope, bindingScopeOptions, - restrictSecretsToTargetScope = false, } = props; const isCustom = presetKey === "custom" || presetKey === undefined; @@ -343,7 +341,6 @@ export function SecretHeaderAuthRow(props: { const headerLabel = name.trim() || "Custom Header"; const suggestedName = [sourceName?.trim(), headerLabel].filter(Boolean).join(" "); const scopedSecrets = secretsForCredentialTarget(existingSecrets, targetScope); - const selectableSecrets = restrictSecretsToTargetScope ? scopedSecrets : existingSecrets; return (
@@ -426,7 +423,7 @@ export function SecretHeaderAuthRow(props: { value={secretId} valueScopeId={secretScope ? String(secretScope) : undefined} onSelect={(id, scopeId) => onSelectSecret(id, ScopeId.make(scopeId))} - secrets={selectableSecrets} + secrets={scopedSecrets} onCreateNew={() => setCreating(true)} />
@@ -528,7 +525,7 @@ export function CreatableSecretPicker(props: { value={value} valueScopeId={String(targetScope)} onSelect={(id, scopeId) => onSelect(id, ScopeId.make(scopeId))} - secrets={secrets} + secrets={scopedSecrets} placeholder={placeholder} onCreateNew={() => setCreating(true)} /> From 1ccb0187c2c3cccdf41691a3f06a7892a3f39d0e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 11:35:21 -0700 Subject: [PATCH 07/19] Use initial credentials during source add --- packages/plugins/graphql/src/api/group.ts | 10 + packages/plugins/graphql/src/api/handlers.ts | 1 + .../graphql/src/react/AddGraphqlSource.tsx | 73 ++++---- .../plugins/graphql/src/sdk/plugin.test.ts | 43 +++++ packages/plugins/graphql/src/sdk/plugin.ts | 159 +++++++++++++++- packages/plugins/mcp/src/api/group.ts | 10 +- packages/plugins/mcp/src/api/handlers.ts | 2 + .../plugins/mcp/src/react/AddMcpSource.tsx | 88 ++++----- packages/plugins/mcp/src/sdk/plugin.test.ts | 46 ++++- packages/plugins/mcp/src/sdk/plugin.ts | 172 +++++++++++++++++- 10 files changed, 499 insertions(+), 105 deletions(-) diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index 51f564191..b6c97062a 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -6,7 +6,9 @@ import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors import { GraphqlConfiguredValueInput, ConfiguredGraphqlCredentialValue, + GraphqlCredentialInput, GraphqlSourceAuth, + GraphqlSourceAuthInput, } from "../sdk/types"; import { OAuth2SourceConfig } from "@executor-js/plugin-http-source/sdk"; @@ -47,6 +49,14 @@ const AddSourcePayload = Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInput)), queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInput)), oauth2: Schema.optional(OAuth2SourceConfig), + credentials: Schema.optional( + Schema.Struct({ + scope: ScopeId, + headers: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInput)), + queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInput)), + auth: Schema.optional(GraphqlSourceAuthInput), + }), + ), }); // --------------------------------------------------------------------------- diff --git a/packages/plugins/graphql/src/api/handlers.ts b/packages/plugins/graphql/src/api/handlers.ts index 8fbf43c8d..38c1a4cfb 100644 --- a/packages/plugins/graphql/src/api/handlers.ts +++ b/packages/plugins/graphql/src/api/handlers.ts @@ -53,6 +53,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra headers: payload.headers, queryParams: payload.queryParams, oauth2: payload.oauth2, + credentials: payload.credentials, }); return { toolCount: result.toolCount, diff --git a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx index 185fa4bd2..e504d3491 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx @@ -6,7 +6,6 @@ import * as Schema from "effect/Schema"; import { useScope } from "@executor-js/react/api/scope-context"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; -import { configureSource } from "@executor-js/react/api/atoms"; import { HttpCredentialsEditor, httpCredentialsValid, @@ -77,7 +76,6 @@ export default function AddGraphqlSource(props: { const doAdd = useAtomSet(addGraphqlSourceOptimistic(scopeId), { mode: "promiseExit", }); - const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); const secretList = useSecretPickerSecrets(); const oauth = useOAuthPopupFlow({ popupName: "graphql-oauth", @@ -136,6 +134,10 @@ export default function AddGraphqlSource(props: { serializeTemplateHttpCredentials(credentials); const { headers: configureHeaders, queryParams: configureQueryParams } = serializeConfigureHttpCredentials(credentials, requestCredentialTargetScope); + const hasInitialCredentials = + Object.keys(configureHeaders).length > 0 || + Object.keys(configureQueryParams).length > 0 || + (authMode === "oauth2" && tokens); const { trimmedEndpoint, namespace, displayName } = sourceIdentity(); const exit = await doAdd({ @@ -152,6 +154,33 @@ export default function AddGraphqlSource(props: { queryParams: templateQueryParams as Record, } : {}), + ...(hasInitialCredentials + ? { + credentials: { + scope: requestCredentialTargetScope, + ...(Object.keys(configureHeaders).length > 0 + ? { headers: configureHeaders as Record } + : {}), + ...(Object.keys(configureQueryParams).length > 0 + ? { + queryParams: configureQueryParams as Record, + } + : {}), + ...(authMode === "oauth2" && tokens + ? { + auth: { + oauth2: { + connection: { + kind: "connection" as const, + connectionId: tokens.connectionId, + }, + }, + }, + } + : {}), + }, + } + : {}), }, reactivityKeys: sourceWriteKeys, }); @@ -160,46 +189,6 @@ export default function AddGraphqlSource(props: { setAdding(false); return; } - if ( - Object.keys(configureHeaders).length > 0 || - Object.keys(configureQueryParams).length > 0 || - (authMode === "oauth2" && tokens) - ) { - const configureExit = await doConfigure({ - params: { scopeId }, - payload: { - source: { id: exit.value.namespace, scope: scopeId }, - scope: requestCredentialTargetScope, - type: "graphql", - config: { - ...(Object.keys(configureHeaders).length > 0 - ? { headers: configureHeaders as Record } - : {}), - ...(Object.keys(configureQueryParams).length > 0 - ? { queryParams: configureQueryParams as Record } - : {}), - ...(authMode === "oauth2" && tokens - ? { - auth: { - oauth2: { - connection: { - kind: "connection" as const, - connectionId: tokens.connectionId, - }, - }, - }, - } - : {}), - }, - }, - reactivityKeys: sourceWriteKeys, - }); - if (Exit.isFailure(configureExit)) { - setAddError(errorMessageFromExit(configureExit, "Failed to configure source")); - setAdding(false); - return; - } - } props.onComplete(); }; diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 7c48d6baa..9c5781c29 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -187,6 +187,49 @@ describe("graphqlPlugin real protocol server", () => { }), ); + it.effect("uses initial credential bindings for add-time introspection", () => + Effect.gen(function* () { + const server = yield* serveGraphqlTestServer({ + schema: makeGreetingGraphqlSchema(), + auth: { + validateAuthorization: (authorization) => + Effect.succeed(authorization === "Bearer secret-token"), + }, + }); + const executor = yield* createExecutor( + makeTestConfig({ plugins: [memorySecretsPlugin(), graphqlPlugin()] as const }), + ); + yield* executor.secrets.set({ + id: SecretId.make("github-token"), + scope: ScopeId.make(TEST_SCOPE), + name: "GitHub token", + value: "secret-token", + provider: "memory", + }); + + const result = yield* executor.graphql.addSource({ + endpoint: server.endpoint, + scope: TEST_SCOPE, + namespace: "initial_credentials", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + credentials: { + scope: TEST_SCOPE, + headers: { + Authorization: { kind: "secret", secretId: "github-token" }, + }, + }, + }); + + expect(result).toEqual({ toolCount: 2, namespace: "initial_credentials" }); + const requests = yield* server.requests; + expect( + requests.some((request) => request.headers.authorization === "Bearer secret-token"), + ).toBe(true); + }), + ); + it.effect("invokes a live query with headers and query params", () => Effect.gen(function* () { const server = yield* serveGreetingServer; diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 6c2a3b450..62224919a 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -4,6 +4,7 @@ import { HttpClient } from "effect/unstable/http"; import { type CredentialBindingRef, + type CredentialBindingValue, definePlugin, tool, ScopeId, @@ -37,7 +38,7 @@ import { type IntrospectionTypeRef, } from "./introspect"; import { extract } from "./extract"; -import { GraphqlInvocationError } from "./errors"; +import { GraphqlIntrospectionError, GraphqlInvocationError } from "./errors"; import { invokeWithLayer } from "./invoke"; import { graphqlSchema, @@ -108,8 +109,18 @@ export interface GraphqlSourceConfig { readonly queryParams?: Record; /** Optional OAuth2 credential used as a Bearer token for every request. */ readonly oauth2?: OAuth2SourceConfig; + /** Initial credential bindings used while adding and introspecting this source. */ + readonly credentials?: GraphqlInitialCredentialsInput; } +const GraphqlInitialCredentialsInputSchema = Schema.Struct({ + scope: Schema.String, + headers: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)), + queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)), + auth: Schema.optional(GraphqlSourceAuthInputSchema), +}); +type GraphqlInitialCredentialsInput = typeof GraphqlInitialCredentialsInputSchema.Type; + const StaticAddSourceInputSchema = Schema.Struct({ scope: Schema.String, endpoint: Schema.String, @@ -119,6 +130,7 @@ const StaticAddSourceInputSchema = Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInputSchema)), queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInputSchema)), oauth2: Schema.optional(OAuth2SourceConfig), + credentials: Schema.optional(GraphqlInitialCredentialsInputSchema), }); const SourceConfigureInputSchema = Schema.Struct({ name: Schema.optional(Schema.String), @@ -488,6 +500,73 @@ const canonicalizeAuth = ( }; }; +const resolveInitialCredentialValueMap = ( + ctx: PluginCtx, + values: Record, + bindings: ReadonlyArray<{ readonly slot: string; readonly value: CredentialBindingValue }>, + targetScope: string, +): Effect.Effect | undefined, GraphqlIntrospectionError | StorageFailure> => + Effect.gen(function* () { + const bySlot = new Map(bindings.map((binding) => [binding.slot, binding.value] as const)); + const resolved: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (typeof value === "string") { + resolved[name] = value; + continue; + } + const binding = bySlot.get(value.slot); + if (binding?.kind === "secret") { + const secret = yield* ctx.secrets + .getAtScope(binding.secretId, binding.secretScopeId ?? ScopeId.make(targetScope)) + .pipe( + Effect.catchTag("SecretOwnedByConnectionError", () => + Effect.fail( + new GraphqlIntrospectionError({ + message: `Secret not found for ${name}`, + }), + ), + ), + ); + if (secret === null) { + return yield* new GraphqlIntrospectionError({ + message: `Missing secret "${binding.secretId}" for ${name}`, + }); + } + resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; + continue; + } + if (binding?.kind === "text") { + resolved[name] = value.prefix ? `${value.prefix}${binding.text}` : binding.text; + } + } + return Object.keys(resolved).length > 0 ? resolved : undefined; + }); + +const resolveInitialOAuthHeaders = ( + ctx: PluginCtx, + bindings: ReadonlyArray<{ readonly slot: string; readonly value: CredentialBindingValue }>, + targetScope: string, +): Effect.Effect | undefined, GraphqlIntrospectionError | StorageFailure> => + Effect.gen(function* () { + const connection = bindings.find( + (binding) => + binding.slot === GRAPHQL_OAUTH_CONNECTION_SLOT && binding.value.kind === "connection", + ); + if (!connection || connection.value.kind !== "connection") return undefined; + const connectionId = connection.value.connectionId; + const accessToken = yield* ctx.connections + .accessTokenAtScope(connectionId, ScopeId.make(targetScope)) + .pipe( + Effect.mapError( + ({ message }) => + new GraphqlIntrospectionError({ + message: `Failed to resolve OAuth connection "${connectionId}": ${message}`, + }), + ), + ); + return { Authorization: `Bearer ${accessToken}` }; + }); + const resolveGraphqlBindingValueMap = ( ctx: PluginCtx, values: Record | undefined, @@ -585,17 +664,69 @@ const makeGraphqlExtension = ( graphqlQueryParamSlot, ); const auth = authFromOAuth2Source(config.oauth2); + const initialHeaders = + config.credentials?.headers !== undefined + ? canonicalizeCredentialMap(config.credentials.headers, graphqlHeaderSlot) + : null; + const initialQueryParams = + config.credentials?.queryParams !== undefined + ? canonicalizeCredentialMap(config.credentials.queryParams, graphqlQueryParamSlot) + : null; + const initialAuth = + config.credentials?.auth !== undefined ? canonicalizeAuth(config.credentials.auth) : null; + const initialBindings = [ + ...(initialHeaders?.bindings ?? []), + ...(initialQueryParams?.bindings ?? []), + ...(initialAuth?.bindings ?? []), + ]; + const initialScope = config.credentials?.scope; + if (initialScope && initialBindings.length > 0) { + yield* validateGraphqlBindingTarget(ctx, { + sourceId: namespace, + sourceScope: config.scope, + targetScope: initialScope, + }); + } let introspectionResult: IntrospectionResult; if (config.introspectionJson) { introspectionResult = yield* parseIntrospectionJson(config.introspectionJson); } else { - const resolvedHeaders = resolveConfiguredValueMap(config.headers); - const resolvedQueryParams = resolveConfiguredValueMap(config.queryParams); + const resolvedInitialHeaders = + initialHeaders && initialScope + ? yield* resolveInitialCredentialValueMap( + ctx, + canonicalHeaders, + initialHeaders.bindings, + initialScope, + ) + : undefined; + const resolvedOAuthHeaders = + initialAuth && initialScope + ? yield* resolveInitialOAuthHeaders(ctx, initialAuth.bindings, initialScope) + : undefined; + const resolvedHeaders = { + ...(resolveConfiguredValueMap(config.headers) ?? {}), + ...(resolvedInitialHeaders ?? {}), + ...(resolvedOAuthHeaders ?? {}), + }; + const resolvedInitialQueryParams = + initialQueryParams && initialScope + ? yield* resolveInitialCredentialValueMap( + ctx, + canonicalQueryParams, + initialQueryParams.bindings, + initialScope, + ) + : undefined; + const resolvedQueryParams = { + ...(resolveConfiguredValueMap(config.queryParams) ?? {}), + ...(resolvedInitialQueryParams ?? {}), + }; introspectionResult = yield* introspect( config.endpoint, - resolvedHeaders, - resolvedQueryParams, + Object.keys(resolvedHeaders).length > 0 ? resolvedHeaders : undefined, + Object.keys(resolvedQueryParams).length > 0 ? resolvedQueryParams : undefined, ).pipe(Effect.provide(httpClientLayer)); } @@ -621,7 +752,6 @@ const makeGraphqlExtension = ( })); yield* ctx.storage.upsertSource(storedSource, storedOps); - yield* ctx.core.sources.register({ id: namespace, scope: config.scope, @@ -637,6 +767,23 @@ const makeGraphqlExtension = ( inputSchema: p.inputSchema, })), }); + if (initialScope && initialBindings.length > 0) { + yield* ctx.credentialBindings.replaceForSource({ + targetScope: ScopeId.make(initialScope), + pluginId: GRAPHQL_PLUGIN_ID, + sourceId: namespace, + sourceScope: ScopeId.make(config.scope), + slotPrefixes: [ + ...(config.credentials?.headers !== undefined ? ["header:"] : []), + ...(config.credentials?.queryParams !== undefined ? ["query_param:"] : []), + ...(config.credentials?.auth !== undefined ? ["auth:"] : []), + ], + bindings: initialBindings.map((binding) => ({ + slotKey: binding.slot, + value: binding.value, + })), + }); + } if (Object.keys(definitions).length > 0) { yield* ctx.core.definitions.register({ diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 4267d9d3d..86eec6e51 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -4,7 +4,7 @@ import { InternalError, ScopeId, SecretBackedMap } from "@executor-js/sdk/shared import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; import { McpStoredSourceSchema } from "../sdk/stored-source"; -import { McpConfiguredValueInput } from "../sdk/types"; +import { McpConfiguredValueInput, McpConnectionAuthInput, McpCredentialInput } from "../sdk/types"; import { OAuth2SourceConfig } from "@executor-js/plugin-http-source/sdk"; // --------------------------------------------------------------------------- @@ -28,6 +28,14 @@ const AddRemoteSourcePayload = Schema.Struct({ queryParams: Schema.optional(Schema.Record(Schema.String, McpConfiguredValueInput)), headers: Schema.optional(Schema.Record(Schema.String, McpConfiguredValueInput)), oauth2: Schema.optional(OAuth2SourceConfig), + credentials: Schema.optional( + Schema.Struct({ + scope: ScopeId, + headers: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), + queryParams: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), + auth: Schema.optional(McpConnectionAuthInput), + }), + ), }); const AddStdioSourcePayload = Schema.Struct({ diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index 542765008..bedb4e8ab 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -66,6 +66,7 @@ const toSourceConfig = ( headers?: Record; namespace?: string; oauth2?: OAuth2SourceConfigType; + credentials?: Extract["credentials"]; }; return { @@ -78,6 +79,7 @@ const toSourceConfig = ( headers: p.headers, namespace: p.namespace, oauth2: p.oauth2, + credentials: p.credentials, }; }; diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index e2cab01c1..3e5fe5fab 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -6,7 +6,6 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { useScope } from "@executor-js/react/api/scope-context"; -import { configureSource } from "@executor-js/react/api/atoms"; import { Button } from "@executor-js/react/components/button"; import { CardStack, @@ -308,7 +307,6 @@ export default function AddMcpSource(props: { const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), { mode: "promiseExit", }); - const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); const secretList = useSecretPickerSecrets(); const oauth = useOAuthPopupFlow({ popupName: "mcp-oauth", @@ -442,6 +440,10 @@ export default function AddMcpSource(props: { string, McpConfiguredValueInput >; + const hasInitialCredentials = + Object.keys(configureCredentials.headers).length > 0 || + Object.keys(configureCredentials.queryParams).length > 0 || + (remoteAuthMode === "oauth2" && tokens); const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name; const slugNamespace = slugifyNamespace(remoteIdentity.namespace); const exit = await doAdd({ @@ -460,6 +462,38 @@ export default function AddMcpSource(props: { >, } : {}), + ...(hasInitialCredentials + ? { + credentials: { + scope: requestCredentialTargetScope, + ...(Object.keys(configureCredentials.headers).length > 0 + ? { + headers: configureCredentials.headers as Record, + } + : {}), + ...(Object.keys(configureCredentials.queryParams).length > 0 + ? { + queryParams: configureCredentials.queryParams as Record< + string, + McpCredentialInput + >, + } + : {}), + ...(remoteAuthMode === "oauth2" && tokens + ? { + auth: { + oauth2: { + connection: { + kind: "connection" as const, + connectionId: tokens.connectionId, + }, + }, + }, + } + : {}), + }, + } + : {}), }, reactivityKeys: sourceWriteKeys, }); @@ -470,55 +504,6 @@ export default function AddMcpSource(props: { }); return; } - if ( - Object.keys(configureCredentials.headers).length > 0 || - Object.keys(configureCredentials.queryParams).length > 0 || - (remoteAuthMode === "oauth2" && tokens) - ) { - const configureExit = await doConfigure({ - params: { scopeId }, - payload: { - source: { id: exit.value.namespace, scope: scopeId }, - scope: requestCredentialTargetScope, - type: "mcp", - config: { - ...(Object.keys(configureCredentials.headers).length > 0 - ? { - headers: configureCredentials.headers as Record, - } - : {}), - ...(Object.keys(configureCredentials.queryParams).length > 0 - ? { - queryParams: configureCredentials.queryParams as Record< - string, - McpCredentialInput - >, - } - : {}), - ...(remoteAuthMode === "oauth2" && tokens - ? { - auth: { - oauth2: { - connection: { - kind: "connection" as const, - connectionId: tokens.connectionId, - }, - }, - }, - } - : {}), - }, - }, - reactivityKeys: sourceWriteKeys, - }); - if (Exit.isFailure(configureExit)) { - dispatch({ - type: "add-fail", - error: errorMessageFromExit(configureExit, "Failed to configure source"), - }); - return; - } - } props.onComplete(); }, [ probe, @@ -528,7 +513,6 @@ export default function AddMcpSource(props: { tokens, state.url, doAdd, - doConfigure, props, scopeId, requestCredentialTargetScope, diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 836b3a595..d1fe17c88 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -22,7 +22,7 @@ import { MCP_OAUTH_CONNECTION_SLOT, } from "./types"; import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; -import { makeAnnotationsMcpServer, serveMcpServer } from "../testing"; +import { makeAnnotationsMcpServer, makeGreetingMcpServer, serveMcpServer } from "../testing"; const mcpOAuth2Config = { kind: "oauth2" as const, @@ -271,6 +271,50 @@ describe("mcpPlugin", () => { }), ); + it.effect("uses initial credential bindings for add-time tool discovery", () => + Effect.gen(function* () { + const server = yield* serveMcpServer(makeGreetingMcpServer, { + auth: { + validateAuthorization: (authorization) => + Effect.succeed(authorization === "Bearer secret-token"), + }, + }); + const executor = yield* createExecutor( + makeTestConfig({ plugins: [makeMemorySecretsPlugin()(), mcpPlugin()] as const }), + ); + yield* executor.secrets.set({ + id: SecretId.make("mcp-token"), + scope: ScopeId.make("test-scope"), + name: "MCP token", + value: "secret-token", + provider: "memory", + }); + + const result = yield* executor.mcp.addSource({ + transport: "remote", + scope: "test-scope", + name: "authenticated", + endpoint: server.endpoint, + namespace: "authenticated_mcp", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + credentials: { + scope: "test-scope", + headers: { + Authorization: { kind: "secret", secretId: "mcp-token" }, + }, + }, + }); + + expect(result).toEqual({ toolCount: 1, namespace: "authenticated_mcp" }); + const requests = yield* server.requests; + expect(requests.some((request) => request.authorization === "Bearer secret-token")).toBe( + true, + ); + }), + ); + // ------------------------------------------------------------------------- // Multi-scope shadowing — regression suite covering the bug class where // store reads/writes that don't pin scope_id collapse onto whichever visible diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index e931f26f4..d976fe0f2 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -96,6 +96,7 @@ export interface McpRemoteSourceConfig extends McpSourceScopeField { readonly headers?: Record; readonly namespace?: string; readonly oauth2?: OAuth2SourceConfig; + readonly credentials?: McpInitialCredentialsInput; } export interface McpStdioSourceConfig extends McpSourceScopeField { @@ -150,6 +151,14 @@ const McpConfigureSourceInputSchema = Schema.Struct({ }); export type McpConfigureSourceInput = typeof McpConfigureSourceInputSchema.Type; +const McpInitialCredentialsInputSchema = Schema.Struct({ + scope: Schema.String, + headers: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), + queryParams: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), + auth: Schema.optional(McpConnectionAuthInput), +}); +type McpInitialCredentialsInput = typeof McpInitialCredentialsInputSchema.Type; + export interface McpProbeEndpointInput { readonly endpoint: string; readonly headers?: Record; @@ -571,6 +580,77 @@ const resolveMcpBindingValueMap = ( return Object.keys(resolved).length > 0 ? resolved : undefined; }); +const resolveInitialMcpCredentialValueMap = ( + ctx: PluginCtx, + values: Record, + bindings: ReadonlyArray<{ readonly slot: string; readonly value: CredentialBindingValue }>, + targetScope: string, + missingLabel: string, +): Effect.Effect | undefined, McpConnectionError | StorageFailure> => + Effect.gen(function* () { + const bySlot = new Map(bindings.map((binding) => [binding.slot, binding.value] as const)); + const resolved: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (typeof value === "string") { + resolved[name] = value; + continue; + } + const binding = bySlot.get(value.slot); + if (binding?.kind === "secret") { + const secret = yield* ctx.secrets + .getAtScope(binding.secretId, binding.secretScopeId ?? ScopeId.make(targetScope)) + .pipe( + Effect.catchTag("SecretOwnedByConnectionError", () => + Effect.fail( + new McpConnectionError({ + transport: "remote", + message: `Failed to resolve secret for ${missingLabel} "${name}"`, + }), + ), + ), + ); + if (secret === null) { + return yield* new McpConnectionError({ + transport: "remote", + message: `Missing secret "${binding.secretId}" for ${missingLabel} "${name}"`, + }); + } + resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; + continue; + } + if (binding?.kind === "text") { + resolved[name] = value.prefix ? `${value.prefix}${binding.text}` : binding.text; + } + } + return Object.keys(resolved).length > 0 ? resolved : undefined; + }); + +const resolveInitialMcpOauthProvider = ( + ctx: PluginCtx, + bindings: ReadonlyArray<{ readonly slot: string; readonly value: CredentialBindingValue }>, + targetScope: string, +): Effect.Effect => + Effect.gen(function* () { + const connection = bindings.find( + (binding) => + binding.slot === MCP_OAUTH_CONNECTION_SLOT && binding.value.kind === "connection", + ); + if (!connection || connection.value.kind !== "connection") return undefined; + const connectionId = connection.value.connectionId; + const accessToken = yield* ctx.connections + .accessTokenAtScope(connectionId, ScopeId.make(targetScope)) + .pipe( + Effect.mapError( + ({ message }) => + new McpConnectionError({ + transport: "remote", + message: `Failed to resolve OAuth connection "${connectionId}": ${message}`, + }), + ), + ); + return makeOAuthProvider(accessToken); + }); + const resolveMcpHeaderAuth = ( ctx: PluginCtx, sourceId: string, @@ -1065,6 +1145,36 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { auth: authFromOAuth2Source(config.oauth2), } : null; + const initialRemote = + config.transport === "remote" && config.credentials + ? { + scope: config.credentials.scope, + headers: + config.credentials.headers !== undefined + ? canonicalizeCredentialMap(config.credentials.headers, mcpHeaderSlot) + : null, + queryParams: + config.credentials.queryParams !== undefined + ? canonicalizeCredentialMap(config.credentials.queryParams, mcpQueryParamSlot) + : null, + auth: + config.credentials.auth !== undefined + ? canonicalizeAuth(config.credentials.auth) + : null, + } + : null; + const initialBindings = [ + ...(initialRemote?.headers?.bindings ?? []), + ...(initialRemote?.queryParams?.bindings ?? []), + ...(initialRemote?.auth?.bindings ?? []), + ]; + if (initialRemote && initialBindings.length > 0) { + yield* validateMcpBindingTarget(ctx, { + sourceId: namespace, + sourceScope: config.scope, + targetScope: initialRemote.scope, + }); + } const sd = toStoredSourceData( config, canonicalRemote @@ -1083,14 +1193,54 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { // connection awaiting per-user sign-in, header secret // awaiting upload) but the source row should still land so // it shows up in the list and exposes a Sign-in affordance. + const initialQueryParams = + initialRemote?.queryParams && + (yield* resolveInitialMcpCredentialValueMap( + ctx, + canonicalRemote?.queryParams ?? initialRemote.queryParams.values, + initialRemote.queryParams.bindings, + initialRemote.scope, + "query parameter", + )); + const initialHeaders = + initialRemote?.headers && + (yield* resolveInitialMcpCredentialValueMap( + ctx, + canonicalRemote?.headers ?? initialRemote.headers.values, + initialRemote.headers.bindings, + initialRemote.scope, + "header", + )); + const remoteQueryParams = { + ...(config.transport === "remote" + ? (resolveConfiguredValueMap(config.queryParams) ?? {}) + : {}), + ...(initialQueryParams || {}), + }; + const remoteHeaders = { + ...(config.transport === "remote" + ? (resolveConfiguredValueMap(config.headers) ?? {}) + : {}), + ...(initialHeaders || {}), + }; + const initialAuthProvider = + initialRemote?.auth !== null && initialRemote?.auth !== undefined + ? yield* resolveInitialMcpOauthProvider( + ctx, + initialRemote.auth.bindings, + initialRemote.scope, + ) + : undefined; const resolved: Result.Result = config.transport === "remote" ? Result.succeed({ transport: "remote" as const, endpoint: config.endpoint, remoteTransport: config.remoteTransport ?? "auto", - queryParams: resolveConfiguredValueMap(config.queryParams), - headers: resolveConfiguredValueMap(config.headers), + queryParams: + Object.keys(remoteQueryParams).length > 0 ? remoteQueryParams : undefined, + headers: Object.keys(remoteHeaders).length > 0 ? remoteHeaders : undefined, + authProvider: initialAuthProvider, }) : yield* resolveConnectorInput(namespace, config.scope, sd, ctx, allowStdio).pipe( Effect.result, @@ -1158,7 +1308,6 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { binding: toBinding(e), })), ); - yield* ctx.core.sources.register({ id: namespace, scope: config.scope, @@ -1175,6 +1324,23 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { outputSchema: e.outputSchema, })), }); + if (initialRemote && initialBindings.length > 0) { + yield* ctx.credentialBindings.replaceForSource({ + targetScope: ScopeId.make(initialRemote.scope), + pluginId: MCP_PLUGIN_ID, + sourceId: namespace, + sourceScope: ScopeId.make(config.scope), + slotPrefixes: [ + ...(initialRemote.headers !== null ? ["header:"] : []), + ...(initialRemote.queryParams !== null ? ["query_param:"] : []), + ...(initialRemote.auth !== null ? ["auth:"] : []), + ], + bindings: initialBindings.map((binding) => ({ + slotKey: binding.slot, + value: binding.value, + })), + }); + } }), ) .pipe( From 315fa95f93fd4855fc685d6f6300b3cd2762ae88 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 11:43:47 -0700 Subject: [PATCH 08/19] Show MCP OAuth sign-in for user scope --- .../mcp/src/react/McpSourceSummary.tsx | 24 ++++++++++++++++--- .../src/plugins/source-credential-status.tsx | 13 ++++++---- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/plugins/mcp/src/react/McpSourceSummary.tsx b/packages/plugins/mcp/src/react/McpSourceSummary.tsx index dc90c1c69..777cce401 100644 --- a/packages/plugins/mcp/src/react/McpSourceSummary.tsx +++ b/packages/plugins/mcp/src/react/McpSourceSummary.tsx @@ -3,6 +3,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { connectionsAtom } from "@executor-js/react/api/atoms"; import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; +import { Button } from "@executor-js/react/components/button"; import { SourceCredentialNotice, SourceCredentialStatusBadge, @@ -12,6 +13,7 @@ import { import { ScopeId } from "@executor-js/sdk/shared"; import { mcpSourceAtom, mcpSourceBindingsAtom } from "./atoms"; +import McpSignInButton from "./McpSignInButton"; import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; const sourceCredentialSlots = ( @@ -62,9 +64,9 @@ 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), + mcpSourceBindingsAtom(userScope, props.sourceId, sourceScope), ); - const connectionsResult = useAtomValue(connectionsAtom(displayScope)); + const connectionsResult = useAtomValue(connectionsAtom(userScope)); if (!source) return null; const slots = sourceCredentialSlots(source as McpStoredSourceSchemaType); @@ -86,7 +88,23 @@ export default function McpSourceSummary(props: { }); if (props.variant === "panel") { - return ; + const needsOAuth = missing.includes("OAuth sign-in"); + const needsConfiguration = missing.some((label) => label !== "OAuth sign-in"); + return ( + + {needsOAuth && } + {needsConfiguration && props.onAction && ( + + )} +
+ } + /> + ); } return ; diff --git a/packages/react/src/plugins/source-credential-status.tsx b/packages/react/src/plugins/source-credential-status.tsx index 99ac68cf0..4c3d27e58 100644 --- a/packages/react/src/plugins/source-credential-status.tsx +++ b/packages/react/src/plugins/source-credential-status.tsx @@ -1,5 +1,6 @@ import { Badge } from "../components/badge"; import { Button } from "../components/button"; +import type { ReactNode } from "react"; export { effectiveSourceCredentialBinding, missingSourceCredentialLabels, @@ -31,6 +32,7 @@ export function SourceCredentialStatusBadge(props: { readonly missing: readonly export function SourceCredentialNotice(props: { readonly missing: readonly string[]; + readonly action?: ReactNode; readonly onAction?: () => void; }) { if (props.missing.length === 0) return null; @@ -44,11 +46,12 @@ export function SourceCredentialNotice(props: { Missing {props.missing.join(", ")}
- {props.onAction && ( - - )} + {props.action ?? + (props.onAction && ( + + ))} ); From bcbcb76944b3709c0aef87828b3cafaac648c85b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 11:52:40 -0700 Subject: [PATCH 09/19] Persist OAuth source auth from initial credentials --- .../plugins/graphql/src/sdk/plugin.test.ts | 53 ++++++++++++++++++ packages/plugins/graphql/src/sdk/plugin.ts | 4 +- packages/plugins/mcp/src/sdk/plugin.test.ts | 56 +++++++++++++++++++ packages/plugins/mcp/src/sdk/plugin.ts | 26 +++++---- 4 files changed, 127 insertions(+), 12 deletions(-) diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 9c5781c29..0b0e2cc84 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -230,6 +230,59 @@ describe("graphqlPlugin real protocol server", () => { }), ); + it.effect("marks source oauth-backed when add-time credentials include oauth", () => + Effect.gen(function* () { + const server = yield* serveGraphqlTestServer({ + schema: makeGreetingGraphqlSchema(), + auth: { + validateAuthorization: (authorization) => + Effect.succeed(authorization === "Bearer oauth-token"), + }, + }); + const executor = yield* createExecutor( + makeTestConfig({ plugins: [memorySecretsPlugin(), graphqlPlugin()] as const }), + ); + const connectionId = ConnectionId.make("graphql-oauth2-initial"); + yield* executor.connections.create( + CreateConnectionInput.make({ + id: connectionId, + scope: ScopeId.make(TEST_SCOPE), + provider: "oauth2", + identityLabel: "Initial GraphQL OAuth", + accessToken: TokenMaterial.make({ + secretId: SecretId.make(`${connectionId}.access_token`), + name: "Initial GraphQL OAuth Access Token", + value: "oauth-token", + }), + refreshToken: null, + expiresAt: null, + oauthScope: null, + providerState: null, + }), + ); + + const result = yield* executor.graphql.addSource({ + endpoint: server.endpoint, + scope: TEST_SCOPE, + namespace: "initial_oauth_graphql", + credentials: { + scope: TEST_SCOPE, + auth: { + oauth2: { + connection: { kind: "connection", connectionId }, + }, + }, + }, + }); + + expect(result).toEqual({ toolCount: 2, namespace: "initial_oauth_graphql" }); + const source = yield* executor.graphql.getSource("initial_oauth_graphql", TEST_SCOPE); + expect(source?.auth.kind).toBe("oauth2"); + if (source?.auth.kind !== "oauth2") return; + expect(source.auth.connectionSlot).toBe(GRAPHQL_OAUTH_CONNECTION_SLOT); + }), + ); + it.effect("invokes a live query with headers and query params", () => Effect.gen(function* () { const server = yield* serveGreetingServer; diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 62224919a..a7d755e39 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -663,7 +663,6 @@ const makeGraphqlExtension = ( config.queryParams, graphqlQueryParamSlot, ); - const auth = authFromOAuth2Source(config.oauth2); const initialHeaders = config.credentials?.headers !== undefined ? canonicalizeCredentialMap(config.credentials.headers, graphqlHeaderSlot) @@ -674,6 +673,9 @@ const makeGraphqlExtension = ( : null; const initialAuth = config.credentials?.auth !== undefined ? canonicalizeAuth(config.credentials.auth) : null; + const auth = config.oauth2 + ? authFromOAuth2Source(config.oauth2) + : (initialAuth?.auth ?? { kind: "none" }); const initialBindings = [ ...(initialHeaders?.bindings ?? []), ...(initialQueryParams?.bindings ?? []), diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index d1fe17c88..03720c674 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -315,6 +315,62 @@ describe("mcpPlugin", () => { }), ); + it.effect("marks source oauth-backed when add-time credentials include oauth", () => + Effect.gen(function* () { + const server = yield* serveMcpServer(makeGreetingMcpServer, { + auth: { + validateAuthorization: (authorization) => + Effect.succeed(authorization === "Bearer oauth-token"), + }, + }); + const executor = yield* createExecutor( + makeTestConfig({ plugins: [makeMemorySecretsPlugin()(), mcpPlugin()] as const }), + ); + const connectionId = ConnectionId.make("mcp-oauth2-initial"); + yield* executor.connections.create( + CreateConnectionInput.make({ + id: connectionId, + scope: ScopeId.make("test-scope"), + provider: OAUTH2_PROVIDER_KEY, + identityLabel: "Initial MCP OAuth", + accessToken: TokenMaterial.make({ + secretId: SecretId.make(`${connectionId}.access_token`), + name: "Initial MCP OAuth Access Token", + value: "oauth-token", + }), + refreshToken: null, + expiresAt: null, + oauthScope: null, + providerState: null, + }), + ); + + const result = yield* executor.mcp.addSource({ + transport: "remote", + scope: "test-scope", + name: "initial oauth", + endpoint: server.endpoint, + namespace: "initial_oauth_mcp", + credentials: { + scope: "test-scope", + auth: { + oauth2: { + connection: { kind: "connection", connectionId }, + }, + }, + }, + }); + + expect(result).toEqual({ toolCount: 1, namespace: "initial_oauth_mcp" }); + const stored = yield* executor.mcp.getSource("initial_oauth_mcp", "test-scope"); + expect(stored?.config.transport).toBe("remote"); + if (stored?.config.transport !== "remote") return; + expect(stored.config.auth.kind).toBe("oauth2"); + if (stored.config.auth.kind !== "oauth2") return; + expect(stored.config.auth.connectionSlot).toBe(MCP_OAUTH_CONNECTION_SLOT); + }), + ); + // ------------------------------------------------------------------------- // Multi-scope shadowing — regression suite covering the bug class where // store reads/writes that don't pin scope_id collapse onto whichever visible diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index d976fe0f2..6f69f1412 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -1142,7 +1142,6 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { config.queryParams, mcpQueryParamSlot, ), - auth: authFromOAuth2Source(config.oauth2), } : null; const initialRemote = @@ -1163,6 +1162,20 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { : null, } : null; + const remoteAuth = + config.transport === "remote" + ? config.oauth2 + ? authFromOAuth2Source(config.oauth2) + : (initialRemote?.auth?.auth ?? ({ kind: "none" } as McpConnectionAuth)) + : null; + const remoteCredentials = + canonicalRemote && remoteAuth + ? { + headers: canonicalRemote.headers, + queryParams: canonicalRemote.queryParams, + auth: remoteAuth, + } + : undefined; const initialBindings = [ ...(initialRemote?.headers?.bindings ?? []), ...(initialRemote?.queryParams?.bindings ?? []), @@ -1175,16 +1188,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { targetScope: initialRemote.scope, }); } - const sd = toStoredSourceData( - config, - canonicalRemote - ? { - headers: canonicalRemote.headers, - queryParams: canonicalRemote.queryParams, - auth: canonicalRemote.auth, - } - : undefined, - ); + const sd = toStoredSourceData(config, remoteCredentials); // Stdio sources are gated — a resolver failure there is a // config error the admin must fix before the source makes From 1f6d6358e0c8a825e5a883ddab0861db47e596e3 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 12:01:54 -0700 Subject: [PATCH 10/19] Scope OAuth client credential secrets --- packages/core/sdk/src/oauth-service.ts | 33 ++++++++-- packages/core/sdk/src/oauth.ts | 4 ++ .../openapi/src/react/AddOpenApiSource.tsx | 63 +++++++++++++++++-- .../openapi/src/react/EditOpenApiSource.tsx | 13 ++++ 4 files changed, 103 insertions(+), 10 deletions(-) diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index b3ee38113..4357ea698 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -252,6 +252,19 @@ export const makeOAuth2Service = ( params.scopeId && deps.secretsGetAtScope ? deps.secretsGetAtScope(params.secretId, params.scopeId) : deps.secretsGet(params.secretId); + const secretsGetResolvedAtScope = (params: { + readonly secretId: string; + readonly scopeId?: string | null; + }) => + params.scopeId && deps.secretsGetAtScope + ? deps + .secretsGetAtScope(params.secretId, params.scopeId) + .pipe( + Effect.map((value) => + value === null ? null : { value, scopeId: params.scopeId ?? null }, + ), + ) + : secretsGetResolved(params.secretId); // ------------------------------------------------------------------- // probe @@ -450,7 +463,10 @@ export const makeOAuth2Service = ( strategy: OAuthAuthorizationCodeStrategy, ): Effect.Effect => Effect.gen(function* () { - const clientIdRef = yield* secretsGetResolved(strategy.clientIdSecretId).pipe( + const clientIdRef = yield* secretsGetResolvedAtScope({ + secretId: strategy.clientIdSecretId, + scopeId: strategy.clientIdSecretScopeId, + }).pipe( Effect.mapError( (err) => // Storage failure propagates; null returns aren't errors — the @@ -491,7 +507,10 @@ export const makeOAuth2Service = ( clientIdSecretScopeId: clientIdRef.scopeId, clientSecretSecretId: strategy.clientSecretSecretId ?? null, clientSecretSecretScopeId: strategy.clientSecretSecretId - ? ((yield* secretsGetResolved(strategy.clientSecretSecretId))?.scopeId ?? null) + ? ((yield* secretsGetResolvedAtScope({ + secretId: strategy.clientSecretSecretId, + scopeId: strategy.clientSecretSecretScopeId, + }))?.scopeId ?? null) : null, scopes: [...strategy.scopes], scopeSeparator: strategy.scopeSeparator, @@ -517,8 +536,14 @@ export const makeOAuth2Service = ( strategy: OAuthClientCredentialsStrategy, ): Effect.Effect => Effect.gen(function* () { - const clientIdRef = yield* secretsGetResolved(strategy.clientIdSecretId); - const clientSecretRef = yield* secretsGetResolved(strategy.clientSecretSecretId); + const clientIdRef = yield* secretsGetResolvedAtScope({ + secretId: strategy.clientIdSecretId, + scopeId: strategy.clientIdSecretScopeId, + }); + const clientSecretRef = yield* secretsGetResolvedAtScope({ + secretId: strategy.clientSecretSecretId, + scopeId: strategy.clientSecretSecretScopeId, + }); if (clientIdRef === null || clientSecretRef === null) { return yield* new OAuthStartError({ message: "client_id / client_secret secret not found", diff --git a/packages/core/sdk/src/oauth.ts b/packages/core/sdk/src/oauth.ts index 6f24dccdc..8e1f52e79 100644 --- a/packages/core/sdk/src/oauth.ts +++ b/packages/core/sdk/src/oauth.ts @@ -60,9 +60,11 @@ export const OAuthAuthorizationCodeStrategy = Schema.Struct({ * an inline string so the value lives at the scope where the caller * configured it and shadowing behaves consistently. */ clientIdSecretId: Schema.String, + clientIdSecretScopeId: Schema.optional(Schema.NullOr(Schema.String)), /** Secret id for `client_secret`. Null for public clients using * PKCE without a confidential secret. */ clientSecretSecretId: Schema.NullOr(Schema.String), + clientSecretSecretScopeId: Schema.optional(Schema.NullOr(Schema.String)), scopes: Schema.Array(Schema.String), /** Separator between scopes. RFC 6749 says space; some providers * (GitHub classic) use comma. */ @@ -84,7 +86,9 @@ export const OAuthClientCredentialsStrategy = Schema.Struct({ kind: Schema.Literal("client-credentials"), tokenEndpoint: Schema.String, clientIdSecretId: Schema.String, + clientIdSecretScopeId: Schema.optional(Schema.NullOr(Schema.String)), clientSecretSecretId: Schema.String, + clientSecretSecretScopeId: Schema.optional(Schema.NullOr(Schema.String)), scopes: Schema.optional(Schema.Array(Schema.String)), scopeSeparator: Schema.optional(Schema.String), clientAuth: Schema.optional(Schema.Literals(["body", "basic"])), diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index 92d6b2a70..4866a6cd4 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -281,6 +281,24 @@ export default function AddOpenApiSource(props: { setOAuthTokenTargetScope(defaultOAuthTokenTargetScope); } }, [credentialScopeOptions, defaultOAuthTokenTargetScope, oauthTokenTargetScope]); + useEffect(() => { + if ( + oauth2ClientIdScope && + !credentialScopeOptions.some((option) => option.scopeId === oauth2ClientIdScope) + ) { + setOauth2ClientIdScope(null); + setOauth2ClientIdSecretId(null); + setOauth2AuthState(null); + } + if ( + oauth2ClientSecretScope && + !credentialScopeOptions.some((option) => option.scopeId === oauth2ClientSecretScope) + ) { + setOauth2ClientSecretScope(null); + setOauth2ClientSecretSecretId(null); + setOauth2AuthState(null); + } + }, [credentialScopeOptions, oauth2ClientIdScope, oauth2ClientSecretScope]); const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), { mode: "promiseExit", @@ -595,6 +613,8 @@ export default function AddOpenApiSource(props: { const displayName = identity.name.trim() || selectedOAuth2Preset.securitySchemeName; const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl); + const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope; + const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope; if (selectedOAuth2Preset.flow === "clientCredentials") { // RFC 6749 §4.4: no user-interactive consent step. The client_secret @@ -617,7 +637,9 @@ export default function AddOpenApiSource(props: { kind: "client-credentials", tokenEndpoint: tokenUrl, clientIdSecretId: oauth2ClientIdSecretId, + clientIdSecretScopeId: String(clientIdSecretScope), clientSecretSecretId: oauth2ClientSecretSecretId, + clientSecretSecretScopeId: String(clientSecretSecretScope), scopes: [...oauth2SelectedScopes], }, pluginId: "openapi", @@ -664,7 +686,11 @@ export default function AddOpenApiSource(props: { tokenEndpoint: tokenUrl, issuerUrl, clientIdSecretId: oauth2ClientIdSecretId, + clientIdSecretScopeId: String(clientIdSecretScope), clientSecretSecretId: oauth2ClientSecretSecretId ?? null, + clientSecretSecretScopeId: oauth2ClientSecretSecretId + ? String(clientSecretSecretScope) + : null, scopes: [...oauth2SelectedScopes], }, pluginId: "openapi", @@ -711,6 +737,9 @@ export default function AddOpenApiSource(props: { selectedOAuth2Fingerprint, oauth, oauthTokenTargetScope, + oauth2ClientIdScope, + oauth2ClientSecretScope, + sourceScope, ]); const handleCancelOAuth2 = useCallback(() => { @@ -752,8 +781,8 @@ export default function AddOpenApiSource(props: { const sourceId = exit.value.namespace; const oauthTokenBindingScope = ScopeId.make(oauthTokenTargetScope); - const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope; - const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope; + const clientIdBindingScope = oauth2ClientIdScope ?? sourceScope; + const clientSecretBindingScope = oauth2ClientSecretScope ?? sourceScope; for (const binding of headerBindings) { const bindingExit = await doSetBinding({ @@ -826,12 +855,12 @@ export default function AddOpenApiSource(props: { params: { scopeId }, payload: SetSourceCredentialBindingInput.make({ source: { id: sourceId, scope: sourceScope }, - scope: sourceScope, + scope: clientIdBindingScope, slotKey: configuredOAuth2.clientIdSlot, value: { kind: "secret", secretId: SecretId.make(oauth2ClientIdSecretId), - secretScopeId: clientIdSecretScope, + secretScopeId: clientIdBindingScope, }, }), reactivityKeys: bindingWriteKeys, @@ -848,12 +877,12 @@ export default function AddOpenApiSource(props: { params: { scopeId }, payload: SetSourceCredentialBindingInput.make({ source: { id: sourceId, scope: sourceScope }, - scope: sourceScope, + scope: clientSecretBindingScope, slotKey: configuredOAuth2.clientSecretSlot, value: { kind: "secret", secretId: SecretId.make(oauth2ClientSecretSecretId), - secretScopeId: clientSecretSecretScope, + secretScopeId: clientSecretBindingScope, }, }), reactivityKeys: bindingWriteKeys, @@ -1142,6 +1171,17 @@ export default function AddOpenApiSource(props: { onCreatedScope={setOauth2ClientIdScope} /> + { + setOauth2ClientIdScope(targetScope); + setOauth2ClientIdSecretId(null); + setOauth2AuthState(null); + }} + label="Used by" + help="Choose where this OAuth client ID credential lives." + />
@@ -1174,6 +1214,17 @@ export default function AddOpenApiSource(props: { onCreatedScope={setOauth2ClientSecretScope} />
+ { + setOauth2ClientSecretScope(targetScope); + setOauth2ClientSecretSecretId(null); + setOauth2AuthState(null); + }} + label="Used by" + help="Choose where this OAuth client secret credential lives." + />
diff --git a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx index b6c1652e9..f37fc5a2e 100644 --- a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx @@ -431,6 +431,7 @@ export default function EditOpenApiSource(props: { return; } const clientIdSecretId = clientIdBinding.value.secretId; + const clientIdSecretScopeId = clientIdBinding.value.secretScopeId ?? clientIdBinding.scopeId; if ( oauth2.flow === "clientCredentials" && (!clientSecretBinding || !isSecretCredentialBindingValue(clientSecretBinding.value)) @@ -444,6 +445,10 @@ export default function EditOpenApiSource(props: { isSecretCredentialBindingValue(clientSecretBinding.value) ? clientSecretBinding.value : null; + const clientSecretSecretScopeId = + clientSecretBinding && isSecretCredentialBindingValue(clientSecretBinding.value) + ? (clientSecretBinding.value.secretScopeId ?? clientSecretBinding.scopeId) + : null; const existingConnection = exactCredentialBindingForScope( bindingRows, @@ -481,7 +486,11 @@ export default function EditOpenApiSource(props: { kind: "client-credentials", tokenEndpoint: tokenUrl, clientIdSecretId, + clientIdSecretScopeId: String(clientIdSecretScopeId), clientSecretSecretId: clientSecretValue!.secretId, + clientSecretSecretScopeId: clientSecretSecretScopeId + ? String(clientSecretSecretScopeId) + : null, scopes: [...oauth2.scopes], }, pluginId: "openapi", @@ -537,10 +546,14 @@ export default function EditOpenApiSource(props: { tokenEndpoint: tokenUrl, issuerUrl, clientIdSecretId, + clientIdSecretScopeId: String(clientIdSecretScopeId), clientSecretSecretId: clientSecretBinding && isSecretCredentialBindingValue(clientSecretBinding.value) ? clientSecretBinding.value.secretId : null, + clientSecretSecretScopeId: clientSecretSecretScopeId + ? String(clientSecretSecretScopeId) + : null, scopes: [...oauth2.scopes], }, pluginId: "openapi", From 0736a9caaba7b218b100d88e2aa71aecf043dda1 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 12:32:19 -0700 Subject: [PATCH 11/19] Default edit OAuth connections to active scope --- packages/plugins/graphql/src/react/EditGraphqlSource.tsx | 2 +- packages/plugins/mcp/src/react/EditMcpSource.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index b494bd739..03429c934 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -62,7 +62,7 @@ function EditForm(props: { setCredentialTargetScope: setOAuthCredentialTargetScope, } = useCredentialTargetScope({ sourceScope, - initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), + initialTargetScope: displayScope, }); const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index 263504468..07561a082 100644 --- a/packages/plugins/mcp/src/react/EditMcpSource.tsx +++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx @@ -54,7 +54,7 @@ function RemoteEditForm(props: { setCredentialTargetScope: setOAuthCredentialTargetScope, } = useCredentialTargetScope({ sourceScope, - initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), + initialTargetScope: displayScope, }); const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); From a694933b45fabebdab8310f120c2c38cd820ef7a Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 12:42:39 -0700 Subject: [PATCH 12/19] Clean up OpenAPI OAuth edit layout --- .../openapi/src/react/EditOpenApiSource.tsx | 343 +++++++++++++----- 1 file changed, 253 insertions(+), 90 deletions(-) diff --git a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx index f37fc5a2e..1edde3207 100644 --- a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx @@ -23,10 +23,9 @@ import { CardStackEntry, CardStackEntryContent, CardStackEntryDescription, - CardStackEntryField, CardStackEntryTitle, + CardStackEntryField, } from "@executor-js/react/components/card-stack"; -import { FilterTabs } from "@executor-js/react/components/filter-tabs"; import { Input } from "@executor-js/react/components/input"; import { sourceWriteKeys as openApiWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { @@ -43,6 +42,10 @@ import { useOAuthPopupFlow, type OAuthCompletionPayload, } from "@executor-js/react/plugins/oauth-sign-in"; +import { + CredentialControlField, + CredentialUsageRow, +} from "@executor-js/react/plugins/credential-target-scope"; import { effectiveCredentialBindingForScope, exactCredentialBindingForScope, @@ -50,6 +53,7 @@ import { isSecretCredentialBindingValue, } from "@executor-js/react/plugins/credential-bindings"; import { SecretCredentialSlotBindings } from "@executor-js/react/plugins/credential-slot-bindings"; +import { CreatableSecretPicker } from "@executor-js/react/plugins/secret-header-auth"; import { openApiSourceAtom, openApiSourceBindingsAtom } from "./atoms"; import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; @@ -178,6 +182,9 @@ export default function EditOpenApiSource(props: { const [selectedOAuthTokenScope, setSelectedOAuthTokenScope] = useState( userScope !== sourceScopeId ? userScope : sourceScopeId, ); + const [selectedOAuthCredentialScope, setSelectedOAuthCredentialScope] = + useState(sourceScopeId); + const [oauthEndpointsOpen, setOAuthEndpointsOpen] = useState(false); const [oauth2AuthorizationUrl, setOAuth2AuthorizationUrl] = useState( source?.config.oauth2?.authorizationUrl ?? "", ); @@ -200,6 +207,7 @@ export default function EditOpenApiSource(props: { useEffect(() => { setSelectedOAuthTokenScope(userScope !== sourceScopeId ? userScope : sourceScopeId); + setSelectedOAuthCredentialScope(sourceScopeId); }, [sourceScopeId, userScope]); useEffect(() => { @@ -211,6 +219,7 @@ export default function EditOpenApiSource(props: { setOAuth2AuthorizationUrl(source.config.oauth2?.authorizationUrl ?? ""); setOAuth2TokenUrl(source.config.oauth2?.tokenUrl ?? ""); setOAuth2EndpointsSaveState("idle"); + setOAuthEndpointsOpen(false); setSourceSaveState("idle"); setLoadedSourceKey(sourceKey); }, [loadedSourceKey, source, sourceScopeId]); @@ -351,6 +360,10 @@ export default function EditOpenApiSource(props: { credentialScopes[0]!; const activeOAuthTokenScopeId = activeOAuthTokenScope.scopeId; const activeOAuthTokenScopeLabel = activeOAuthTokenScope.label; + const activeOAuthCredentialScope = + credentialScopes.find((entry) => entry.scopeId === selectedOAuthCredentialScope) ?? + organizationCredentialScope; + const activeOAuthCredentialScopeId = activeOAuthCredentialScope.scopeId; if (!source) { return ( @@ -360,6 +373,15 @@ export default function EditOpenApiSource(props: {
); } + const oauthClientSecretSlot = source.config.oauth2 + ? effectiveClientSecretSlot(source.config.oauth2) + : null; + const nonOAuthSecretSlots = secretSlots.filter( + (slot) => + slot.kind === "secret" && + (!source.config.oauth2 || + (slot.slot !== source.config.oauth2.clientIdSlot && slot.slot !== oauthClientSecretSlot)), + ); const setSecretBinding = async ( targetScope: ScopeId, @@ -635,25 +657,32 @@ export default function EditOpenApiSource(props: { - - - Secrets - - - - slot.kind === "secret")} - bindingScopes={secretBindingScopes} - bindingRows={bindingRows} - scopeRanks={scopeRanks} - secrets={secretList} - sourceId={props.sourceId} - sourceName={source.name} - credentialScopeOptions={credentialScopeOptions} - busyKey={busyKey} - onSetSecretBinding={setSecretBinding} - onClearBinding={clearBinding} - /> + {nonOAuthSecretSlots.length > 0 && ( + <> + + + Request credentials + + Headers and query parameters sent with every API request. + + + + + + + )} {source.config.oauth2 && (() => { @@ -755,15 +784,164 @@ export default function EditOpenApiSource(props: { : "Organization connection is missing" : `No ${activeOAuthTokenScopeLabel.toLowerCase()} connection`; const connectDisabled = isConnecting || endpointsDirty || saving; + const clientSecretSlot = effectiveClientSecretSlot(oauth2); + const renderAppSecret = (input: { + readonly slot: string; + readonly label: string; + readonly hint?: string; + }) => { + const exactSecret = exactCredentialBindingForScope( + bindingRows, + input.slot, + activeOAuthCredentialScopeId, + ); + const effectiveSecret = effectiveCredentialBindingForScope( + bindingRows, + input.slot, + activeOAuthCredentialScopeId, + scopeRanks, + ); + const exactSecretId = + exactSecret && isSecretCredentialBindingValue(exactSecret.value) + ? exactSecret.value.secretId + : null; + const inheritedSecret = + !exactSecretId && + effectiveSecret && + effectiveSecret.scopeId !== activeOAuthCredentialScopeId && + isSecretCredentialBindingValue(effectiveSecret.value) + ? effectiveSecret + : null; + const status = exactSecretId + ? `Saved to ${activeOAuthCredentialScope.label.toLowerCase()}` + : inheritedSecret + ? "Using organization credential" + : "Not set"; + const inputKey = `${activeOAuthCredentialScopeId}:${input.slot}`; + const clearKey = `${activeOAuthCredentialScopeId}:${input.slot}:clear`; + + return ( +
+
+
+
{input.label}
+ {input.hint && ( +
{input.hint}
+ )} +
+ {status} +
+
+
+ + void setSecretBinding( + activeOAuthCredentialScopeId, + input.slot, + secretId, + secretScopeId ?? activeOAuthCredentialScopeId, + ) + } + secrets={secretList} + placeholder="Select or create a secret" + targetScope={activeOAuthCredentialScopeId} + credentialScopeOptions={credentialScopeOptions} + suggestedId={`source-binding-${slugify(props.sourceId)}-${slugify( + input.slot, + )}-${slugify(activeOAuthCredentialScopeId)}`} + sourceName={source.name} + secretLabel={input.label} + /> +
+ {exactSecretId && ( + + )} + {busyKey === inputKey && ( + Saving… + )} +
+
+ ); + }; return ( <> - OAuth Endpoints + OAuth - Override the URLs from the OpenAPI spec when a provider publishes the wrong - values. + Configure app credentials and connect accounts for this source. + + + + + +
+ {renderAppSecret({ slot: oauth2.clientIdSlot, label: "Client ID" })} + {renderAppSecret({ + slot: clientSecretSlot, + label: "Client secret", + hint: + oauth2.flow === "authorizationCode" + ? "Optional for public PKCE clients" + : undefined, + })} +
+
+
+ + + +
+ + {statusText} + + +
+ {endpointsDirty && ( +

+ Save endpoint changes before reconnecting. +

+ )} +
+
+
+ + + Advanced endpoints + + Override provider URLs only when the OpenAPI spec is wrong.
@@ -773,78 +951,63 @@ export default function EditOpenApiSource(props: { )}
- {isAuthCode && ( - - - setOAuth2AuthorizationUrl((e.target as HTMLInputElement).value) - } - className="font-mono text-sm" - /> - - )} - - setOAuth2TokenUrl((e.target as HTMLInputElement).value)} - className="font-mono text-sm" - /> - - -
-
- {oauth2RedirectUrl} - -
-

- Add this to your OAuth app's allowed redirects. -

-
-
- {credentialScopes.length > 1 && ( - - - OAuth token - - Choose where the signed-in OAuth token is saved. - - - ({ - value: entry.scopeId, - label: entry.label, - }))} - value={activeOAuthTokenScopeId} - onChange={setSelectedOAuthTokenScope} - /> - - )} - -
-
{statusText}
- - {endpointsDirty && ( -

- Save endpoint changes before reconnecting. -

+ {oauthEndpointsOpen && ( + <> + {isAuthCode && ( + + + setOAuth2AuthorizationUrl((e.target as HTMLInputElement).value) + } + className="font-mono text-sm" + /> + )} -
-
+ + setOAuth2TokenUrl((e.target as HTMLInputElement).value)} + className="font-mono text-sm" + /> + + +
+
+ + {oauth2RedirectUrl} + + +
+

+ Add this to your OAuth app's allowed redirects. +

+
+
+ + + + Save endpoint changes before reconnecting. + + + + + + )} ); })()} From aa46ca857d1ad012d738a06d9c8aa3fc878cfa01 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 14:11:55 -0700 Subject: [PATCH 13/19] Read edit OAuth state from user scope --- .../plugins/graphql/src/react/EditGraphqlSource.tsx | 10 ++++++---- packages/plugins/mcp/src/react/EditMcpSource.tsx | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index 03429c934..18c658532 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -8,7 +8,7 @@ import { connectionsAtom, setSourceCredentialBinding, } from "@executor-js/react/api/atoms"; -import { useScope, useScopeStack } from "@executor-js/react/api/scope-context"; +import { useScope, useScopeStack, useUserScope } 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"; import { @@ -51,6 +51,7 @@ function EditForm(props: { onSave: () => void; }) { const displayScope = useScope(); + const userScope = useUserScope(); const scopeStack = useScopeStack(); const sourceScope = ScopeId.make(props.initial.scope); const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({ @@ -62,12 +63,12 @@ function EditForm(props: { setCredentialTargetScope: setOAuthCredentialTargetScope, } = useCredentialTargetScope({ sourceScope, - initialTargetScope: displayScope, + initialTargetScope: userScope, }); const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const secretList = useSecretPickerSecrets(); - const connectionsResult = useAtomValue(connectionsAtom(displayScope)); + const connectionsResult = useAtomValue(connectionsAtom(userScope)); const identity = useSourceIdentity({ fallbackName: props.initial.name, @@ -272,12 +273,13 @@ function EditForm(props: { export default function EditGraphqlSource(props: { sourceId: string; onSave: () => void }) { const scopeId = useScope(); + const userScope = useUserScope(); const sourceResult = useAtomValue(graphqlSourceAtom(scopeId, props.sourceId)); const source = AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; const sourceScope = source ? ScopeId.make(source.scope) : scopeId; const bindingsResult = useAtomValue( - graphqlSourceBindingsAtom(scopeId, props.sourceId, sourceScope), + graphqlSourceBindingsAtom(userScope, props.sourceId, sourceScope), ); if (!AsyncResult.isSuccess(sourceResult) || !source || !AsyncResult.isSuccess(bindingsResult)) { diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index 07561a082..a42e5093a 100644 --- a/packages/plugins/mcp/src/react/EditMcpSource.tsx +++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx @@ -8,7 +8,7 @@ import { connectionsAtom, setSourceCredentialBinding, } from "@executor-js/react/api/atoms"; -import { useScope, useScopeStack } from "@executor-js/react/api/scope-context"; +import { useScope, useScopeStack, useUserScope } 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"; import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope"; @@ -43,6 +43,7 @@ function RemoteEditForm(props: { onSave: () => void; }) { const displayScope = useScope(); + const userScope = useUserScope(); const scopeStack = useScopeStack(); const sourceScope = ScopeId.make(props.initial.scope); const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({ @@ -54,12 +55,12 @@ function RemoteEditForm(props: { setCredentialTargetScope: setOAuthCredentialTargetScope, } = useCredentialTargetScope({ sourceScope, - initialTargetScope: displayScope, + initialTargetScope: userScope, }); const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const secretList = useSecretPickerSecrets(); - const connectionsResult = useAtomValue(connectionsAtom(displayScope)); + const connectionsResult = useAtomValue(connectionsAtom(userScope)); const identity = useSourceIdentity({ fallbackName: props.initial.name, @@ -285,6 +286,7 @@ export default function EditMcpSource({ readonly onSave: () => void; }) { const scopeId = useScope(); + const userScope = useUserScope(); const sourceResult = useAtomValue(mcpSourceAtom(scopeId, sourceId)) as AsyncResult.AsyncResult< McpStoredSourceSchemaType | null, unknown @@ -292,7 +294,7 @@ 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(mcpSourceBindingsAtom(userScope, sourceId, sourceScope)); if (!AsyncResult.isSuccess(sourceResult) || !source || !AsyncResult.isSuccess(bindingsResult)) { return ( From c6763d6d789b8d6f093ea3c0f479ce19204e3276 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 14:20:36 -0700 Subject: [PATCH 14/19] Clarify inherited OAuth connection state --- .../graphql/src/react/EditGraphqlSource.tsx | 34 ++++---- .../plugins/mcp/src/react/EditMcpSource.tsx | 35 +++++---- .../react/src/plugins/credential-bindings.tsx | 2 +- packages/react/src/plugins/oauth-sign-in.tsx | 6 ++ .../plugins/source-oauth-connection.test.ts | 49 ++++++++++++ .../src/plugins/source-oauth-connection.tsx | 78 ++++++++++++++++++- 6 files changed, 168 insertions(+), 36 deletions(-) create mode 100644 packages/react/src/plugins/source-oauth-connection.test.ts diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index 18c658532..f1a47c72e 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -18,7 +18,6 @@ import { type HttpCredentialsState, } from "@executor-js/plugin-http-source/react"; import { - effectiveCredentialBindingForScope, httpCredentialsFromConfiguredCredentialBindings, initialCredentialTargetScope, } from "@executor-js/react/plugins/credential-bindings"; @@ -26,7 +25,10 @@ import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/ import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope"; import { Button } from "@executor-js/react/components/button"; import { FilterTabs } from "@executor-js/react/components/filter-tabs"; -import { SourceOAuthConnectionControl } from "@executor-js/react/plugins/source-oauth-connection"; +import { + SourceOAuthConnectionControl, + sourceOAuthConnectionUiState, +} from "@executor-js/react/plugins/source-oauth-connection"; import { Badge } from "@executor-js/react/components/badge"; import { ScopeId } from "@executor-js/sdk/shared"; import { GraphqlSourceFields } from "./GraphqlSourceFields"; @@ -94,19 +96,16 @@ function EditForm(props: { const oauth2 = props.initial.auth.kind === "oauth2" ? props.initial.auth : null; const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); - const connectionBinding = oauth2 - ? effectiveCredentialBindingForScope( - props.bindings, - oauth2.connectionSlot, - oauthCredentialTargetScope, + const oauthConnectionState = oauth2 + ? sourceOAuthConnectionUiState({ + bindings: props.bindings, + connectionSlot: oauth2.connectionSlot, + tokenScope: oauthCredentialTargetScope, scopeRanks, - ) + credentialScopeOptions, + connections, + }) : null; - const boundConnectionId = - connectionBinding?.value.kind === "connection" ? connectionBinding.value.connectionId : null; - const isConnected = - boundConnectionId !== null && - connections.some((connection) => connection.id === boundConnectionId); const oauthRequestCredentials = serializeHttpCredentials(credentials); const handleCredentialsChange = (next: HttpCredentialsState) => { @@ -219,7 +218,7 @@ function EditForm(props: { )} - {oauth2 && ( + {oauth2 && oauthConnectionState && ( { await setBinding({ params: { scopeId: oauthCredentialTargetScope }, @@ -246,6 +247,7 @@ function EditForm(props: { reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], }); }} + signInLabel={oauthConnectionState.signInLabel} /> )} diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index a42e5093a..9a40fd525 100644 --- a/packages/plugins/mcp/src/react/EditMcpSource.tsx +++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx @@ -20,11 +20,13 @@ import { type HttpCredentialsState, } from "@executor-js/plugin-http-source/react"; import { - effectiveCredentialBindingForScope, httpCredentialsFromConfiguredCredentialBindings, initialCredentialTargetScope, } from "@executor-js/react/plugins/credential-bindings"; -import { SourceOAuthConnectionControl } from "@executor-js/react/plugins/source-oauth-connection"; +import { + SourceOAuthConnectionControl, + sourceOAuthConnectionUiState, +} from "@executor-js/react/plugins/source-oauth-connection"; import { Button } from "@executor-js/react/components/button"; import { Badge } from "@executor-js/react/components/badge"; import { ScopeId } from "@executor-js/sdk/shared"; @@ -84,19 +86,16 @@ function RemoteEditForm(props: { const oauth2 = props.initial.config.auth.kind === "oauth2" ? props.initial.config.auth : null; const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); - const connectionBinding = oauth2 - ? effectiveCredentialBindingForScope( - props.bindings, - oauth2.connectionSlot, - oauthCredentialTargetScope, + const oauthConnectionState = oauth2 + ? sourceOAuthConnectionUiState({ + bindings: props.bindings, + connectionSlot: oauth2.connectionSlot, + tokenScope: oauthCredentialTargetScope, scopeRanks, - ) + credentialScopeOptions, + connections, + }) : null; - const boundConnectionId = - connectionBinding?.value.kind === "connection" ? connectionBinding.value.connectionId : null; - const isConnected = - boundConnectionId !== null && - connections.some((connection) => connection.id === boundConnectionId); const oauthRequestCredentials = serializeHttpCredentials(credentials); const handleCredentialsChange = (next: HttpCredentialsState) => { @@ -185,7 +184,7 @@ function RemoteEditForm(props: { bindingScopeOptions={credentialScopeOptions} /> - {oauth2 && ( + {oauth2 && oauthConnectionState && ( { await setBinding({ params: { scopeId: oauthCredentialTargetScope }, @@ -213,6 +214,8 @@ function RemoteEditForm(props: { }); }} reconnectingLabel="Reconnecting…" + reconnectLabel="Reconnect" + signInLabel={oauthConnectionState.signInLabel} signingInLabel="Signing in…" /> )} diff --git a/packages/react/src/plugins/credential-bindings.tsx b/packages/react/src/plugins/credential-bindings.tsx index b32bfdb47..0aa37d91b 100644 --- a/packages/react/src/plugins/credential-bindings.tsx +++ b/packages/react/src/plugins/credential-bindings.tsx @@ -14,7 +14,7 @@ type ConfiguredCredentialValueLike = readonly prefix?: string; }; -type CredentialBindingRefLike = { +export type CredentialBindingRefLike = { readonly slot: string; readonly scopeId: ScopeId; readonly value: CredentialBindingValue; diff --git a/packages/react/src/plugins/oauth-sign-in.tsx b/packages/react/src/plugins/oauth-sign-in.tsx index 6cb4d4b90..ab20c77c7 100644 --- a/packages/react/src/plugins/oauth-sign-in.tsx +++ b/packages/react/src/plugins/oauth-sign-in.tsx @@ -386,6 +386,8 @@ export function SourceOAuthSignInButton(props: { readonly detectPopupClosed?: boolean; readonly reconnectingLabel?: string; readonly signingInLabel?: string; + readonly reconnectLabel?: string; + readonly signInLabel?: string; }) { const { connectionId, @@ -400,7 +402,9 @@ export function SourceOAuthSignInButton(props: { popupName, queryParams, reconnectingLabel, + reconnectLabel, signingInLabel, + signInLabel, sourceLabel, tokenScope, } = props; @@ -453,7 +457,9 @@ export function SourceOAuthSignInButton(props: { isConnected={isConnected} onSignIn={() => void handleSignIn()} reconnectingLabel={reconnectingLabel} + reconnectLabel={reconnectLabel} signingInLabel={signingInLabel} + signInLabel={signInLabel} /> ); } diff --git a/packages/react/src/plugins/source-oauth-connection.test.ts b/packages/react/src/plugins/source-oauth-connection.test.ts new file mode 100644 index 000000000..6d9f43b77 --- /dev/null +++ b/packages/react/src/plugins/source-oauth-connection.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ConnectionId, ScopeId } from "@executor-js/sdk/shared"; + +import { sourceOAuthConnectionUiState } from "./source-oauth-connection"; + +describe("source OAuth connection UI state", () => { + it("shows when the selected scope is using an inherited connection", () => { + const personalScope = ScopeId.make("user_1"); + const organizationScope = ScopeId.make("org_1"); + const connectionId = ConnectionId.make("connection_1"); + + expect( + sourceOAuthConnectionUiState({ + bindings: [ + { + slot: "auth:oauth2:connection", + scopeId: organizationScope, + value: { kind: "connection", connectionId }, + }, + ], + connectionSlot: "auth:oauth2:connection", + tokenScope: personalScope, + scopeRanks: new Map([ + [personalScope, 0], + [organizationScope, 1], + ]), + credentialScopeOptions: [ + { + scopeId: personalScope, + label: "Personal", + description: "Saved only for your account.", + }, + { + scopeId: organizationScope, + label: "Organization", + description: "Shared with everyone who can use this source.", + }, + ], + connections: [{ id: connectionId }], + }), + ).toEqual({ + connectionId: null, + isConnected: true, + buttonIsConnected: false, + statusLabel: "Using Organization connection", + signInLabel: "Sign in personally", + }); + }); +}); diff --git a/packages/react/src/plugins/source-oauth-connection.tsx b/packages/react/src/plugins/source-oauth-connection.tsx index 3d41302dc..703d927cb 100644 --- a/packages/react/src/plugins/source-oauth-connection.tsx +++ b/packages/react/src/plugins/source-oauth-connection.tsx @@ -5,8 +5,70 @@ import { CredentialUsageRow, type CredentialTargetScopeOption, } from "./credential-target-scope"; +import { + effectiveCredentialBindingForScope, + exactCredentialBindingForScope, + type CredentialBindingRefLike, +} from "./credential-bindings"; import { SourceOAuthSignInButton } from "./oauth-sign-in"; +export const sourceOAuthConnectionUiState = (input: { + readonly bindings: readonly CredentialBindingRefLike[]; + readonly connectionSlot: string; + readonly tokenScope: ScopeId; + readonly scopeRanks: ReadonlyMap; + readonly credentialScopeOptions: readonly CredentialTargetScopeOption[]; + readonly connections: readonly { readonly id: ConnectionId }[]; +}): { + readonly connectionId: ConnectionId | null; + readonly isConnected: boolean; + readonly buttonIsConnected: boolean; + readonly statusLabel: string; + readonly signInLabel: string; +} => { + const effectiveBinding = effectiveCredentialBindingForScope( + input.bindings, + input.connectionSlot, + input.tokenScope, + input.scopeRanks, + ); + const exactBinding = exactCredentialBindingForScope( + input.bindings, + input.connectionSlot, + input.tokenScope, + ); + const effectiveConnectionId = + effectiveBinding?.value.kind === "connection" ? effectiveBinding.value.connectionId : null; + const exactConnectionId = + exactBinding?.value.kind === "connection" ? exactBinding.value.connectionId : null; + const isConnected = + effectiveConnectionId !== null && + input.connections.some((connection) => connection.id === effectiveConnectionId); + const buttonIsConnected = + exactConnectionId !== null && + input.connections.some((connection) => connection.id === exactConnectionId); + const selectedScopeLabel = + input.credentialScopeOptions.find((option) => option.scopeId === input.tokenScope)?.label ?? + "selected scope"; + const inheritedScopeLabel = + effectiveBinding && effectiveBinding.scopeId !== input.tokenScope + ? (input.credentialScopeOptions.find((option) => option.scopeId === effectiveBinding.scopeId) + ?.label ?? "Organization") + : null; + + return { + connectionId: exactConnectionId, + isConnected, + buttonIsConnected, + statusLabel: buttonIsConnected + ? `Connected in ${selectedScopeLabel}` + : isConnected && inheritedScopeLabel + ? `Using ${inheritedScopeLabel} connection` + : `No ${selectedScopeLabel} connection`, + signInLabel: inheritedScopeLabel ? "Sign in personally" : "Sign in", + }; +}; + export function SourceOAuthConnectionControl(props: { readonly popupName: string; readonly pluginId: string; @@ -21,11 +83,17 @@ export function SourceOAuthConnectionControl(props: { readonly headers?: Record; readonly queryParams?: Record; readonly isConnected: boolean; + readonly buttonIsConnected?: boolean; + readonly statusLabel?: string; readonly onConnected: (connectionId: ConnectionId) => void | Promise; readonly disabled?: boolean; readonly reconnectingLabel?: string; readonly signingInLabel?: string; + readonly reconnectLabel?: string; + readonly signInLabel?: string; }) { + const buttonIsConnected = props.buttonIsConnected ?? props.isConnected; + return ( {props.isConnected ? ( - Connected + {props.statusLabel ?? "Connected"} ) : ( - Not connected + + {props.statusLabel ?? "Not connected"} + )}
From f4614465b8af3a1dcbf5533894f7d07bef78af9d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 15:00:32 -0700 Subject: [PATCH 15/19] Match OpenAPI edit OAuth credential scope UI --- .../openapi/src/react/EditOpenApiSource.tsx | 175 ++++++++++-------- 1 file changed, 97 insertions(+), 78 deletions(-) diff --git a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx index 1edde3207..065fda94e 100644 --- a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx @@ -26,6 +26,8 @@ import { CardStackEntryTitle, CardStackEntryField, } from "@executor-js/react/components/card-stack"; +import { FieldLabel } from "@executor-js/react/components/field"; +import { HelpTooltip } from "@executor-js/react/components/help-tooltip"; import { Input } from "@executor-js/react/components/input"; import { sourceWriteKeys as openApiWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { @@ -44,6 +46,7 @@ import { } from "@executor-js/react/plugins/oauth-sign-in"; import { CredentialControlField, + CredentialScopeDropdown, CredentialUsageRow, } from "@executor-js/react/plugins/credential-target-scope"; import { @@ -182,7 +185,9 @@ export default function EditOpenApiSource(props: { const [selectedOAuthTokenScope, setSelectedOAuthTokenScope] = useState( userScope !== sourceScopeId ? userScope : sourceScopeId, ); - const [selectedOAuthCredentialScope, setSelectedOAuthCredentialScope] = + const [selectedOAuthClientIdScope, setSelectedOAuthClientIdScope] = + useState(sourceScopeId); + const [selectedOAuthClientSecretScope, setSelectedOAuthClientSecretScope] = useState(sourceScopeId); const [oauthEndpointsOpen, setOAuthEndpointsOpen] = useState(false); const [oauth2AuthorizationUrl, setOAuth2AuthorizationUrl] = useState( @@ -207,7 +212,8 @@ export default function EditOpenApiSource(props: { useEffect(() => { setSelectedOAuthTokenScope(userScope !== sourceScopeId ? userScope : sourceScopeId); - setSelectedOAuthCredentialScope(sourceScopeId); + setSelectedOAuthClientIdScope(sourceScopeId); + setSelectedOAuthClientSecretScope(sourceScopeId); }, [sourceScopeId, userScope]); useEffect(() => { @@ -360,10 +366,6 @@ export default function EditOpenApiSource(props: { credentialScopes[0]!; const activeOAuthTokenScopeId = activeOAuthTokenScope.scopeId; const activeOAuthTokenScopeLabel = activeOAuthTokenScope.label; - const activeOAuthCredentialScope = - credentialScopes.find((entry) => entry.scopeId === selectedOAuthCredentialScope) ?? - organizationCredentialScope; - const activeOAuthCredentialScopeId = activeOAuthCredentialScope.scopeId; if (!source) { return ( @@ -789,16 +791,21 @@ export default function EditOpenApiSource(props: { readonly slot: string; readonly label: string; readonly hint?: string; + readonly scopeId: ScopeId; + readonly onScopeChange: (scope: ScopeId) => void; }) => { + const activeScope = + credentialScopes.find((entry) => entry.scopeId === input.scopeId) ?? + organizationCredentialScope; const exactSecret = exactCredentialBindingForScope( bindingRows, input.slot, - activeOAuthCredentialScopeId, + activeScope.scopeId, ); const effectiveSecret = effectiveCredentialBindingForScope( bindingRows, input.slot, - activeOAuthCredentialScopeId, + activeScope.scopeId, scopeRanks, ); const exactSecretId = @@ -808,67 +815,80 @@ export default function EditOpenApiSource(props: { const inheritedSecret = !exactSecretId && effectiveSecret && - effectiveSecret.scopeId !== activeOAuthCredentialScopeId && + effectiveSecret.scopeId !== activeScope.scopeId && isSecretCredentialBindingValue(effectiveSecret.value) ? effectiveSecret : null; const status = exactSecretId - ? `Saved to ${activeOAuthCredentialScope.label.toLowerCase()}` + ? `${activeScope.label} credential set` : inheritedSecret ? "Using organization credential" : "Not set"; - const inputKey = `${activeOAuthCredentialScopeId}:${input.slot}`; - const clearKey = `${activeOAuthCredentialScopeId}:${input.slot}:clear`; + const inputKey = `${activeScope.scopeId}:${input.slot}`; + const clearKey = `${activeScope.scopeId}:${input.slot}:clear`; return ( -
-
-
-
{input.label}
- {input.hint && ( -
{input.hint}
- )} -
- {status} -
-
-
- - void setSecretBinding( - activeOAuthCredentialScopeId, - input.slot, - secretId, - secretScopeId ?? activeOAuthCredentialScopeId, - ) - } - secrets={secretList} - placeholder="Select or create a secret" - targetScope={activeOAuthCredentialScopeId} - credentialScopeOptions={credentialScopeOptions} - suggestedId={`source-binding-${slugify(props.sourceId)}-${slugify( - input.slot, - )}-${slugify(activeOAuthCredentialScopeId)}`} - sourceName={source.name} - secretLabel={input.label} - /> +
+ + {input.label}{" "} + {input.hint && · {input.hint}} + +
+
+
+ Secret + + Select or create the OAuth {input.label.toLowerCase()} secret. + + + {status} + +
+
+
+ + void setSecretBinding( + activeScope.scopeId, + input.slot, + secretId, + secretScopeId ?? activeScope.scopeId, + ) + } + secrets={secretList} + placeholder="Select or create a secret" + targetScope={activeScope.scopeId} + credentialScopeOptions={credentialScopeOptions} + suggestedId={`source-binding-${slugify(props.sourceId)}-${slugify( + input.slot, + )}-${slugify(activeScope.scopeId)}`} + sourceName={source.name} + secretLabel={input.label} + /> +
+ {exactSecretId && ( + + )} + {busyKey === inputKey && ( + Saving… + )} +
- {exactSecretId && ( - - )} - {busyKey === inputKey && ( - Saving… - )} +
); @@ -885,25 +905,24 @@ export default function EditOpenApiSource(props: { - -
- {renderAppSecret({ slot: oauth2.clientIdSlot, label: "Client ID" })} - {renderAppSecret({ - slot: clientSecretSlot, - label: "Client secret", - hint: - oauth2.flow === "authorizationCode" - ? "Optional for public PKCE clients" - : undefined, - })} -
-
+
+ {renderAppSecret({ + slot: oauth2.clientIdSlot, + label: "Client ID", + scopeId: ScopeId.make(selectedOAuthClientIdScope), + onScopeChange: setSelectedOAuthClientIdScope, + })} + {renderAppSecret({ + slot: clientSecretSlot, + label: "Client secret", + hint: + oauth2.flow === "authorizationCode" + ? "Optional for public clients with PKCE" + : undefined, + scopeId: ScopeId.make(selectedOAuthClientSecretScope), + onScopeChange: setSelectedOAuthClientSecretScope, + })} +
Date: Mon, 18 May 2026 15:10:50 -0700 Subject: [PATCH 16/19] Accept standard GraphQL introspection type refs --- .../src/services/sources-api.node.test.ts | 60 ++++++++ .../plugins/graphql/src/sdk/introspect.ts | 8 +- .../plugins/graphql/src/sdk/plugin.test.ts | 139 ++++++++++++++++++ 3 files changed, 205 insertions(+), 2 deletions(-) diff --git a/apps/cloud/src/services/sources-api.node.test.ts b/apps/cloud/src/services/sources-api.node.test.ts index 9937f1379..a8cfa8ee5 100644 --- a/apps/cloud/src/services/sources-api.node.test.ts +++ b/apps/cloud/src/services/sources-api.node.test.ts @@ -325,6 +325,66 @@ describe("sources api (HTTP)", () => { }), ); + it.effect( + "GraphQL add accepts a user-scoped bearer credential for org source introspection", + () => + Effect.gen(function* () { + const server = yield* serveGraphqlTestServer({ + schema: makeGreetingGraphqlSchema({ includeMutation: false }), + auth: { + validateAuthorization: (authorization) => + Effect.succeed(authorization === "Bearer github-token"), + }, + }); + const orgId = `org_${crypto.randomUUID()}`; + const userId = `user_${crypto.randomUUID()}`; + const userScope = testUserOrgScopeId(userId, orgId); + const namespace = `github_graphql_${crypto.randomUUID().replace(/-/g, "_")}`; + + yield* asUser(userId, orgId, (client) => + client.secrets.set({ + params: { scopeId: ScopeId.make(userScope) }, + payload: { + id: SecretId.make("github-graphql-authorization"), + name: "Github GraphQL Authorization", + value: "github-token", + }, + }), + ); + + const added = yield* asUser(userId, orgId, (client) => + client.graphql.addSource({ + params: { scopeId: ScopeId.make(orgId) }, + payload: { + endpoint: server.endpoint, + namespace, + name: "Github GraphQL", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + credentials: { + scope: ScopeId.make(userScope), + headers: { + Authorization: { + kind: "secret", + secretId: "github-graphql-authorization", + secretScope: userScope, + prefix: "Bearer ", + }, + }, + }, + }, + }), + ); + + expect(added).toEqual({ namespace, toolCount: 1 }); + const requests = yield* server.requests; + expect( + requests.some((request) => request.headers.authorization === "Bearer github-token"), + ).toBe(true); + }), + ); + it.effect("added MCP source can be inspected and invoked through execution", () => Effect.gen(function* () { const server = yield* serveMcpServer(() => diff --git a/packages/plugins/graphql/src/sdk/introspect.ts b/packages/plugins/graphql/src/sdk/introspect.ts index ec0c936a3..dc5ae8008 100644 --- a/packages/plugins/graphql/src/sdk/introspect.ts +++ b/packages/plugins/graphql/src/sdk/introspect.ts @@ -84,7 +84,7 @@ const INTROSPECTION_QUERY = ` const IntrospectionTypeRefLeaf = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), - ofType: Schema.Null, + ofType: Schema.optional(Schema.Null), }); const IntrospectionTypeRef5 = Schema.Struct({ @@ -165,7 +165,11 @@ const IntrospectionJsonSchema = Schema.Union([ IntrospectionResultSchema, ]); -export type IntrospectionTypeRef = typeof IntrospectionTypeRefSchema.Type; +export interface IntrospectionTypeRef { + readonly kind: string; + readonly name: string | null; + readonly ofType?: IntrospectionTypeRef | null; +} export type IntrospectionInputValue = typeof IntrospectionInputValueSchema.Type; export type IntrospectionField = typeof IntrospectionFieldSchema.Type; export type IntrospectionEnumValue = NonNullable< diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 0b0e2cc84..cdd201dff 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -162,6 +162,78 @@ describe("graphqlPlugin real protocol server", () => { }), ); + it.effect("accepts standard introspection responses with omitted deepest ofType", () => + Effect.gen(function* () { + const deepType = { + kind: "NON_NULL", + name: null, + ofType: { + kind: "LIST", + name: null, + ofType: { + kind: "NON_NULL", + name: null, + ofType: { + kind: "LIST", + name: null, + ofType: { + kind: "NON_NULL", + name: null, + ofType: { + kind: "SCALAR", + name: "String", + }, + }, + }, + }, + }, + }; + const server = yield* serveTestHttpApp(() => + Effect.succeed( + HttpServerResponse.jsonUnsafe({ + data: { + __schema: { + queryType: { name: "Query" }, + mutationType: null, + types: [ + { + kind: "OBJECT", + name: "Query", + description: null, + fields: [ + { + name: "deep", + description: null, + args: [], + type: deepType, + }, + ], + inputFields: null, + enumValues: null, + }, + { + kind: "SCALAR", + name: "String", + description: null, + fields: null, + inputFields: null, + enumValues: null, + }, + ], + }, + }, + }), + ), + ); + + const result = yield* introspect(server.url("/graphql")).pipe( + Effect.provide(server.httpClientLayer), + ); + + expect(result.__schema.queryType?.name).toBe("Query"); + }), + ); + it.effect("adds a source by introspecting the live GraphQL endpoint", () => Effect.gen(function* () { const server = yield* serveGreetingServer; @@ -230,6 +302,73 @@ describe("graphqlPlugin real protocol server", () => { }), ); + it.effect( + "uses user-scoped initial credential bindings for org-scope add-time introspection", + () => + Effect.gen(function* () { + const orgScope = "org-scope"; + const userScope = "user-scope"; + const server = yield* serveGraphqlTestServer({ + schema: makeGreetingGraphqlSchema(), + auth: { + validateAuthorization: (authorization) => + Effect.succeed(authorization === "Bearer user-secret-token"), + }, + }); + const executor = yield* createExecutor( + makeTestConfig({ + scopes: [ + Scope.make({ + id: ScopeId.make(userScope), + name: "user", + createdAt: new Date(), + }), + Scope.make({ + id: ScopeId.make(orgScope), + name: "org", + createdAt: new Date(), + }), + ], + plugins: [memorySecretsPlugin(), graphqlPlugin()] as const, + }), + ); + yield* executor.secrets.set({ + id: SecretId.make("github-graphql-authorization"), + scope: ScopeId.make(userScope), + name: "GitHub GraphQL Authorization", + value: "user-secret-token", + provider: "memory", + }); + + const result = yield* executor.graphql.addSource({ + endpoint: server.endpoint, + scope: orgScope, + name: "Github GraphQL", + namespace: "github_graphql", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + credentials: { + scope: userScope, + headers: { + Authorization: { + kind: "secret", + secretId: "github-graphql-authorization", + secretScope: userScope, + prefix: "Bearer ", + }, + }, + }, + }); + + expect(result).toEqual({ toolCount: 2, namespace: "github_graphql" }); + const requests = yield* server.requests; + expect( + requests.some((request) => request.headers.authorization === "Bearer user-secret-token"), + ).toBe(true); + }), + ); + it.effect("marks source oauth-backed when add-time credentials include oauth", () => Effect.gen(function* () { const server = yield* serveGraphqlTestServer({ From b93c6acae2ad81a92cc2264b793dce10430e95f2 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 15:19:45 -0700 Subject: [PATCH 17/19] Surface GraphQL introspection upstream errors --- .../plugins/graphql/src/sdk/introspect.ts | 33 +++++++++++++++++-- .../plugins/graphql/src/sdk/plugin.test.ts | 23 +++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/plugins/graphql/src/sdk/introspect.ts b/packages/plugins/graphql/src/sdk/introspect.ts index dc5ae8008..28487f32e 100644 --- a/packages/plugins/graphql/src/sdk/introspect.ts +++ b/packages/plugins/graphql/src/sdk/introspect.ts @@ -1,4 +1,4 @@ -import { Effect, Schema } from "effect"; +import { Effect, Option, Schema } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { GraphqlIntrospectionError } from "./errors"; @@ -160,6 +160,17 @@ const IntrospectionResponseSchema = Schema.Struct({ errors: Schema.optional(Schema.Array(Schema.Unknown)), }); +const UpstreamErrorResponseSchema = Schema.Struct({ + message: Schema.optional(Schema.String), + errors: Schema.optional( + Schema.Array( + Schema.Struct({ + message: Schema.optional(Schema.String), + }), + ), + ), +}); + const IntrospectionJsonSchema = Schema.Union([ Schema.Struct({ data: IntrospectionResultSchema }), IntrospectionResultSchema, @@ -179,6 +190,15 @@ export type IntrospectionType = typeof IntrospectionTypeSchema.Type; export type IntrospectionSchema = (typeof IntrospectionResultSchema.Type)["__schema"]; export type IntrospectionResult = typeof IntrospectionResultSchema.Type; +const firstUpstreamErrorMessage = (value: unknown): string | null => { + const decoded = Schema.decodeUnknownOption(UpstreamErrorResponseSchema)(value); + return Option.match(decoded, { + onNone: () => null, + onSome: (response) => + response.message ?? response.errors?.find((error) => error.message)?.message ?? null, + }); +}; + // --------------------------------------------------------------------------- // Introspect a GraphQL endpoint // --------------------------------------------------------------------------- @@ -224,8 +244,12 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( ); if (response.status !== 200) { + const raw = yield* response.json.pipe(Effect.catch(() => Effect.succeed(null))); + const upstreamMessage = raw === null ? null : firstUpstreamErrorMessage(raw); return yield* new GraphqlIntrospectionError({ - message: `Introspection failed with status ${response.status}`, + message: upstreamMessage + ? `Introspection failed with status ${response.status}: ${upstreamMessage}` + : `Introspection failed with status ${response.status}`, }); } @@ -249,8 +273,11 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( ); if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) { + const upstreamMessage = firstUpstreamErrorMessage(json); return yield* new GraphqlIntrospectionError({ - message: `Introspection returned ${json.errors.length} error(s)`, + message: upstreamMessage + ? `Introspection returned ${json.errors.length} error(s): ${upstreamMessage}` + : `Introspection returned ${json.errors.length} error(s)`, }); } diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index cdd201dff..a7f20c02a 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -162,6 +162,29 @@ describe("graphqlPlugin real protocol server", () => { }), ); + it.effect("includes safe upstream JSON messages in introspection status errors", () => + Effect.gen(function* () { + const server = yield* serveTestHttpApp(() => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { message: "Resource protected by organization SSO" }, + { status: 403 }, + ), + ), + ); + + const error = yield* introspect(server.url("/graphql")).pipe( + Effect.provide(server.httpClientLayer), + Effect.flip, + ); + + expect(error).toHaveProperty( + "message", + "Introspection failed with status 403: Resource protected by organization SSO", + ); + }), + ); + it.effect("accepts standard introspection responses with omitted deepest ofType", () => Effect.gen(function* () { const deepType = { From 0a39af36788c6e31752bf581bcc1054fbea53d68 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 17:28:31 -0700 Subject: [PATCH 18/19] Overhaul plugin source credential configuration --- packages/core/sdk/src/credential-bindings.ts | 19 +-- packages/core/sdk/src/executor.ts | 4 +- packages/core/sdk/src/index.ts | 2 - packages/core/sdk/src/shared.ts | 2 - .../graphql/src/react/EditGraphqlSource.tsx | 129 +++++++------- .../graphql/src/react/GraphqlSignInButton.tsx | 2 +- packages/plugins/graphql/src/react/atoms.ts | 16 +- .../graphql/src/react/defaults.test.ts | 2 +- .../plugins/graphql/src/react/defaults.ts | 2 +- packages/plugins/graphql/src/sdk/index.ts | 2 - .../plugins/graphql/src/sdk/introspect.ts | 57 +++++-- .../plugins/graphql/src/sdk/plugin.test.ts | 36 +++- packages/plugins/graphql/src/sdk/plugin.ts | 19 +-- packages/plugins/graphql/src/sdk/types.ts | 16 -- .../src/react/http-credentials.test.ts | 64 +++++++ .../src/react}/http-credentials.tsx | 94 ++++++++++- .../plugins/http-source/src/react/index.ts | 3 +- .../plugins/http-source/src/sdk/configure.ts | 75 +-------- packages/plugins/http-source/src/sdk/index.ts | 22 --- .../plugins/http-source/src/sdk/manifest.ts | 76 --------- .../plugins/http-source/src/sdk/resolve.ts | 73 -------- packages/plugins/http-source/src/sdk/types.ts | 64 +------ .../plugins/mcp/src/react/EditMcpSource.tsx | 122 ++++++++------ .../plugins/mcp/src/react/McpSignInButton.tsx | 2 +- packages/plugins/mcp/src/react/atoms.ts | 16 +- packages/plugins/mcp/src/sdk/index.ts | 2 - packages/plugins/mcp/src/sdk/plugin.ts | 21 +-- packages/plugins/mcp/src/sdk/types.ts | 16 -- .../openapi/src/react/EditOpenApiSource.tsx | 133 ++++----------- packages/plugins/openapi/src/sdk/types.ts | 28 ++-- .../src/plugins/credential-bindings.test.ts | 63 +------ .../react/src/plugins/credential-bindings.tsx | 88 +--------- .../src/plugins/credential-slot-bindings.tsx | 39 ++++- .../plugins/source-credential-bindings.tsx | 158 ++++++++++++++++++ .../plugins/source-credential-status-core.ts | 5 +- .../plugins/source-credential-status.test.ts | 4 +- .../plugins/source-oauth-connection.test.ts | 2 +- 37 files changed, 652 insertions(+), 826 deletions(-) create mode 100644 packages/plugins/http-source/src/react/http-credentials.test.ts rename packages/{react/src/plugins => plugins/http-source/src/react}/http-credentials.tsx (78%) delete mode 100644 packages/plugins/http-source/src/sdk/manifest.ts delete mode 100644 packages/plugins/http-source/src/sdk/resolve.ts create mode 100644 packages/react/src/plugins/source-credential-bindings.tsx diff --git a/packages/core/sdk/src/credential-bindings.ts b/packages/core/sdk/src/credential-bindings.ts index dd592a2e8..2f50f0bd3 100644 --- a/packages/core/sdk/src/credential-bindings.ts +++ b/packages/core/sdk/src/credential-bindings.ts @@ -57,15 +57,14 @@ export const CredentialBindingRef = Schema.Struct({ }); export type CredentialBindingRef = typeof CredentialBindingRef.Type; -export const SetCredentialBindingInput = Schema.Struct({ - targetScope: ScopeId, - pluginId: Schema.String, - sourceId: Schema.String, - sourceScope: ScopeId, - slotKey: Schema.String, - value: CredentialBindingValue, -}); -export type SetCredentialBindingInput = typeof SetCredentialBindingInput.Type; +export type SetPluginCredentialBindingInput = { + readonly targetScope: ScopeId; + readonly pluginId: string; + readonly sourceId: string; + readonly sourceScope: ScopeId; + readonly slotKey: string; + readonly value: CredentialBindingValue; +}; export const CredentialBindingSourceInput = Schema.Struct({ pluginId: Schema.String, @@ -176,7 +175,7 @@ export interface CredentialBindingsFacade { input: CredentialBindingSlotInput, ) => Effect.Effect; readonly set: ( - input: SetCredentialBindingInput, + input: SetPluginCredentialBindingInput, ) => Effect.Effect; readonly remove: (input: RemoveCredentialBindingInput) => Effect.Effect; readonly replaceForSource: ( diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 9c12f7814..5674d7f23 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -50,7 +50,7 @@ import { type ReplaceCredentialBindingsInput, type ReplaceSourceCredentialBindingsInput, ResolvedCredentialSlot, - type SetCredentialBindingInput, + type SetPluginCredentialBindingInput, type SetSourceCredentialBindingInput, type SourceCredentialBindingSlotInput, type SourceCredentialBindingSourceInput, @@ -2381,7 +2381,7 @@ export const createExecutor = + const credentialBindingSet = (input: SetPluginCredentialBindingInput) => Effect.gen(function* () { yield* assertScopeInStack("credential binding targetScope", input.targetScope); yield* assertScopeInStack("credential binding sourceScope", input.sourceScope); diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 99b895853..1fc5000a8 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -141,8 +141,6 @@ export { ConfiguredCredentialValue, ScopedSecretCredentialInput, CredentialBindingRef, - SetCredentialBindingInput, - CredentialBindingSourceInput, CredentialBindingSlotInput, RemoveCredentialBindingInput, RemoveSourceCredentialBindingInput, diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 19d714a5e..a57f64b31 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -41,12 +41,10 @@ export { ConfiguredCredentialValue, CredentialBindingRef, CredentialBindingValue, - CredentialBindingSourceInput, CredentialBindingSlotInput, RemoveCredentialBindingInput, RemoveSourceCredentialBindingInput, ScopedSecretCredentialInput, - SetCredentialBindingInput, SetSourceCredentialBindingInput, ReplaceCredentialBindingValue, ReplaceCredentialBindingsInput, diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index f1a47c72e..a96a7818c 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; @@ -8,35 +8,42 @@ import { connectionsAtom, setSourceCredentialBinding, } from "@executor-js/react/api/atoms"; -import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; +import { useScope, useUserScope } 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"; import { - HttpCredentialsEditor, - serializeConfigureHttpCredentials, + httpCredentialsFromConfiguredCredentialBindings, serializeHttpCredentials, type HttpCredentialsState, } from "@executor-js/plugin-http-source/react"; -import { - httpCredentialsFromConfiguredCredentialBindings, - initialCredentialTargetScope, -} from "@executor-js/react/plugins/credential-bindings"; import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity"; import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope"; +import { + useSourceCredentialBindingScopes, + useSourceCredentialBindingWriter, +} from "@executor-js/react/plugins/source-credential-bindings"; import { Button } from "@executor-js/react/components/button"; +import { + CardStack, + CardStackContent, + CardStackEntry, + CardStackEntryContent, + CardStackEntryDescription, + CardStackEntryTitle, +} from "@executor-js/react/components/card-stack"; import { FilterTabs } from "@executor-js/react/components/filter-tabs"; import { SourceOAuthConnectionControl, sourceOAuthConnectionUiState, } from "@executor-js/react/plugins/source-oauth-connection"; import { Badge } from "@executor-js/react/components/badge"; -import { ScopeId } from "@executor-js/sdk/shared"; -import { GraphqlSourceFields } from "./GraphqlSourceFields"; +import { type CredentialBindingRef, ScopeId } from "@executor-js/sdk/shared"; import { - type GraphqlCredentialInput, - type GraphqlSourceAuthInput, - type GraphqlSourceBindingRef, -} from "../sdk/types"; + SecretCredentialSlotBindings, + secretCredentialSlotsFromHttpConfig, +} from "@executor-js/react/plugins/credential-slot-bindings"; +import { GraphqlSourceFields } from "./GraphqlSourceFields"; +import type { GraphqlSourceAuthInput } from "../sdk/types"; import type { StoredGraphqlSource } from "../sdk/store"; type EditableSource = StoredGraphqlSource; @@ -49,17 +56,12 @@ type AuthMode = "none" | "oauth2"; function EditForm(props: { sourceId: string; initial: EditableSource; - bindings: readonly GraphqlSourceBindingRef[]; + bindings: readonly CredentialBindingRef[]; onSave: () => void; }) { const displayScope = useScope(); const userScope = useUserScope(); - const scopeStack = useScopeStack(); const sourceScope = ScopeId.make(props.initial.scope); - const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({ - sourceScope, - initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), - }); const { credentialTargetScope: oauthCredentialTargetScope, setCredentialTargetScope: setOAuthCredentialTargetScope, @@ -68,7 +70,7 @@ function EditForm(props: { initialTargetScope: userScope, }); const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); - const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); + const setConnectionBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const secretList = useSecretPickerSecrets(); const connectionsResult = useAtomValue(connectionsAtom(userScope)); @@ -77,25 +79,36 @@ function EditForm(props: { fallbackNamespace: props.initial.namespace, }); const [endpoint, setEndpoint] = useState(props.initial.endpoint); - const [credentials, setCredentials] = useState(() => - httpCredentialsFromConfiguredCredentialBindings({ - headers: props.initial.headers, - queryParams: props.initial.queryParams, - bindings: props.bindings, - }), + const credentials = useMemo( + () => + httpCredentialsFromConfiguredCredentialBindings({ + headers: props.initial.headers, + queryParams: props.initial.queryParams, + bindings: props.bindings, + }), + [props.bindings, props.initial.headers, props.initial.queryParams], ); const [authMode, setAuthMode] = useState(props.initial.auth.kind); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const [credentialsDirty, setCredentialsDirty] = useState(false); const [authDirty, setAuthDirty] = useState(false); + const { busyKey, setSecretBinding, clearBinding } = useSourceCredentialBindingWriter({ + displayScope, + source: { id: props.sourceId, scope: sourceScope }, + onError: setError, + }); const identityDirty = identity.name.trim() !== props.initial.name.trim(); const metadataDirty = identityDirty || endpoint.trim() !== props.initial.endpoint.trim(); - const dirty = metadataDirty || credentialsDirty || authDirty; + const dirty = metadataDirty || authDirty; const oauth2 = props.initial.auth.kind === "oauth2" ? props.initial.auth : null; const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; - const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); + const { credentialScopeOptions, secretBindingScopes, scopeRanks } = + useSourceCredentialBindingScopes({ sourceScope }); + const secretSlots = secretCredentialSlotsFromHttpConfig({ + headers: props.initial.headers, + queryParams: props.initial.queryParams, + }); const oauthConnectionState = oauth2 ? sourceOAuthConnectionUiState({ bindings: props.bindings, @@ -108,32 +121,17 @@ function EditForm(props: { : null; const oauthRequestCredentials = serializeHttpCredentials(credentials); - const handleCredentialsChange = (next: HttpCredentialsState) => { - setCredentials(next); - setCredentialsDirty(true); - }; - const handleSave = async () => { setSaving(true); setError(null); - const { headers, queryParams } = serializeConfigureHttpCredentials( - credentials, - credentialTargetScope, - ); const config: { name?: string; endpoint?: string; - headers?: Record; - queryParams?: Record; auth?: GraphqlSourceAuthInput; } = { name: metadataDirty ? identity.name.trim() || undefined : undefined, endpoint: metadataDirty ? endpoint.trim() || undefined : undefined, }; - if (credentialsDirty) { - config.headers = headers; - config.queryParams = queryParams as Record; - } if (authDirty) { config.auth = authMode === "oauth2" ? { oauth2: {} } : { kind: "none" }; } @@ -141,7 +139,7 @@ function EditForm(props: { params: { scopeId: displayScope }, payload: { source: { id: props.sourceId, scope: sourceScope }, - scope: credentialTargetScope, + scope: sourceScope, type: "graphql", config, }, @@ -154,7 +152,6 @@ function EditForm(props: { return; } - setCredentialsDirty(false); setAuthDirty(false); props.onSave(); setSaving(false); @@ -185,15 +182,33 @@ function EditForm(props: { namespaceReadOnly /> - + {secretSlots.length > 0 && ( + + + + + Request credentials + + Headers and query parameters sent with every GraphQL request. + + + + + + + )} {/* Temporarily hidden while we revisit GraphQL OAuth discovery and UX. */}
@@ -236,7 +251,7 @@ function EditForm(props: { buttonIsConnected={oauthConnectionState.buttonIsConnected} statusLabel={oauthConnectionState.statusLabel} onConnected={async (connectionId) => { - await setBinding({ + await setConnectionBinding({ params: { scopeId: oauthCredentialTargetScope }, payload: { scope: oauthCredentialTargetScope, diff --git a/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx b/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx index c94e7181a..4288c2b89 100644 --- a/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx +++ b/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx @@ -27,7 +27,7 @@ export default function GraphqlSignInButton(props: { sourceId: string }) { 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; diff --git a/packages/plugins/graphql/src/react/atoms.ts b/packages/plugins/graphql/src/react/atoms.ts index ad815aaec..c5e9bf2d8 100644 --- a/packages/plugins/graphql/src/react/atoms.ts +++ b/packages/plugins/graphql/src/react/atoms.ts @@ -4,7 +4,6 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { sourceCredentialBindingsAtom, sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; import { GraphqlClient } from "./client"; -import { GraphqlSourceBindingRef } from "../sdk/types"; // --------------------------------------------------------------------------- // Query atoms @@ -21,20 +20,7 @@ export const graphqlSourceBindingsAtom = ( scopeId: ScopeId, namespace: string, sourceScopeId: ScopeId, -) => - Atom.mapResult(sourceCredentialBindingsAtom(scopeId, namespace, sourceScopeId), (rows) => - rows.map((row) => - GraphqlSourceBindingRef.make({ - sourceId: row.sourceId, - sourceScopeId: row.sourceScopeId, - scopeId: row.scopeId, - slot: row.slotKey, - value: row.value, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }), - ), - ); +) => sourceCredentialBindingsAtom(scopeId, namespace, sourceScopeId); // --------------------------------------------------------------------------- // Mutation atoms diff --git a/packages/plugins/graphql/src/react/defaults.test.ts b/packages/plugins/graphql/src/react/defaults.test.ts index 1bbfba512..a9ed885c3 100644 --- a/packages/plugins/graphql/src/react/defaults.test.ts +++ b/packages/plugins/graphql/src/react/defaults.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { httpCredentialsValid } from "@executor-js/react/plugins/http-credentials"; +import { httpCredentialsValid } from "@executor-js/plugin-http-source/react"; import { initialGraphqlCredentials } from "./defaults"; describe("initialGraphqlCredentials", () => { diff --git a/packages/plugins/graphql/src/react/defaults.ts b/packages/plugins/graphql/src/react/defaults.ts index fbf38618c..24f25d790 100644 --- a/packages/plugins/graphql/src/react/defaults.ts +++ b/packages/plugins/graphql/src/react/defaults.ts @@ -1,6 +1,6 @@ import { emptyHttpCredentials, type HttpCredentialsState, -} from "@executor-js/react/plugins/http-credentials"; +} from "@executor-js/plugin-http-source/react"; export const initialGraphqlCredentials = (): HttpCredentialsState => emptyHttpCredentials(); diff --git a/packages/plugins/graphql/src/sdk/index.ts b/packages/plugins/graphql/src/sdk/index.ts index 1e32f31ce..16ba47c7c 100644 --- a/packages/plugins/graphql/src/sdk/index.ts +++ b/packages/plugins/graphql/src/sdk/index.ts @@ -32,8 +32,6 @@ export { GraphqlOperationKind, GraphqlSourceAuth, GraphqlSourceAuthInput, - GraphqlSourceBindingRef, - GraphqlSourceBindingValue, InvocationConfig, InvocationResult, OperationBinding, diff --git a/packages/plugins/graphql/src/sdk/introspect.ts b/packages/plugins/graphql/src/sdk/introspect.ts index 28487f32e..771e4de30 100644 --- a/packages/plugins/graphql/src/sdk/introspect.ts +++ b/packages/plugins/graphql/src/sdk/introspect.ts @@ -84,7 +84,7 @@ const INTROSPECTION_QUERY = ` const IntrospectionTypeRefLeaf = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), - ofType: Schema.optional(Schema.Null), + ofType: Schema.Null, }); const IntrospectionTypeRef5 = Schema.Struct({ @@ -175,12 +175,11 @@ const IntrospectionJsonSchema = Schema.Union([ Schema.Struct({ data: IntrospectionResultSchema }), IntrospectionResultSchema, ]); +const JsonTextSchema = Schema.fromJsonString(Schema.Unknown); -export interface IntrospectionTypeRef { - readonly kind: string; - readonly name: string | null; - readonly ofType?: IntrospectionTypeRef | null; -} +const decodeUpstreamErrorResponse = Schema.decodeUnknownOption(UpstreamErrorResponseSchema); + +export type IntrospectionTypeRef = typeof IntrospectionTypeRefSchema.Type; export type IntrospectionInputValue = typeof IntrospectionInputValueSchema.Type; export type IntrospectionField = typeof IntrospectionFieldSchema.Type; export type IntrospectionEnumValue = NonNullable< @@ -191,14 +190,41 @@ export type IntrospectionSchema = (typeof IntrospectionResultSchema.Type)["__sch export type IntrospectionResult = typeof IntrospectionResultSchema.Type; const firstUpstreamErrorMessage = (value: unknown): string | null => { - const decoded = Schema.decodeUnknownOption(UpstreamErrorResponseSchema)(value); + const decoded = decodeUpstreamErrorResponse(value); return Option.match(decoded, { onNone: () => null, - onSome: (response) => - response.message ?? response.errors?.find((error) => error.message)?.message ?? null, + onSome: (response) => { + if (response.message) return response.message; + for (const entry of response.errors ?? []) { + const message = entry.message; + if (message) return message; + } + return null; + }, }); }; +const redactUpstreamBody = (body: string): string => + body + .replaceAll( + /("(?:access_token|refresh_token|id_token|client_secret|token|authorization)"\s*:\s*")[^"]*(")/gi, + "$1[redacted]$2", + ) + .replaceAll( + /((?:access_token|refresh_token|id_token|client_secret|token|authorization)=)[^&\s]*/gi, + "$1[redacted]", + ) + .replaceAll( + /((?:authorization|access-token|refresh-token|id-token|client-secret|token)\s*:\s*)(?:bearer\s+)?[^\s,;]+/gi, + "$1[redacted]", + ); + +const upstreamTextMessage = (body: string): string | null => { + const text = redactUpstreamBody(body.replaceAll(/\s+/g, " ").trim()); + if (text.length === 0) return null; + return text.length > 500 ? `${text.slice(0, 500)}...` : text; +}; + // --------------------------------------------------------------------------- // Introspect a GraphQL endpoint // --------------------------------------------------------------------------- @@ -222,6 +248,8 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( let request = HttpClientRequest.post(requestEndpoint).pipe( HttpClientRequest.setHeader("Content-Type", "application/json"), + HttpClientRequest.setHeader("Accept", "application/json"), + HttpClientRequest.setHeader("User-Agent", "executor-graphql"), HttpClientRequest.bodyJsonUnsafe({ query: INTROSPECTION_QUERY, }), @@ -244,8 +272,15 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( ); if (response.status !== 200) { - const raw = yield* response.json.pipe(Effect.catch(() => Effect.succeed(null))); - const upstreamMessage = raw === null ? null : firstUpstreamErrorMessage(raw); + const responseText = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))); + const raw = responseText + ? yield* Schema.decodeUnknownEffect(JsonTextSchema)(responseText).pipe( + Effect.catch(() => Effect.succeed(null)), + ) + : null; + const upstreamMessage = upstreamTextMessage( + (raw === null ? null : firstUpstreamErrorMessage(raw)) ?? responseText, + ); return yield* new GraphqlIntrospectionError({ message: upstreamMessage ? `Introspection failed with status ${response.status}: ${upstreamMessage}` diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index a7f20c02a..b84198b9f 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -145,11 +145,11 @@ describe("graphqlPlugin real protocol server", () => { ); }); - it.effect("does not include upstream response bodies in introspection status errors", () => + it.effect("includes redacted upstream text in introspection status errors", () => Effect.gen(function* () { const server = yield* serveGraphqlFailureTestServer({ status: 500, - body: "internal token value", + body: 'upstream failed {"access_token":"secret-value"} token=another-secret', }); const error = yield* introspect(server.endpoint).pipe( @@ -157,8 +157,12 @@ describe("graphqlPlugin real protocol server", () => { Effect.flip, ); - expect(error).toHaveProperty("message", "Introspection failed with status 500"); - expect(error).not.toHaveProperty("message", expect.stringContaining("internal token value")); + expect(error).toHaveProperty( + "message", + 'Introspection failed with status 500: upstream failed {"access_token":"[redacted]"} token=[redacted]', + ); + expect(error).not.toHaveProperty("message", expect.stringContaining("secret-value")); + expect(error).not.toHaveProperty("message", expect.stringContaining("another-secret")); }), ); @@ -185,6 +189,30 @@ describe("graphqlPlugin real protocol server", () => { }), ); + it.effect("redacts secrets from upstream JSON messages in introspection status errors", () => + Effect.gen(function* () { + const server = yield* serveTestHttpApp(() => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { message: "Authorization: Bearer github-secret-token" }, + { status: 403 }, + ), + ), + ); + + const error = yield* introspect(server.url("/graphql")).pipe( + Effect.provide(server.httpClientLayer), + Effect.flip, + ); + + expect(error).toHaveProperty( + "message", + "Introspection failed with status 403: Authorization: [redacted]", + ); + expect(error).not.toHaveProperty("message", expect.stringContaining("github-secret-token")); + }), + ); + it.effect("accepts standard introspection responses with omitted deepest ofType", () => Effect.gen(function* () { const deepType = { diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index a7d755e39..1eb840e09 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -53,7 +53,6 @@ import { GRAPHQL_OAUTH_CONNECTION_SLOT, GraphqlCredentialInput as GraphqlCredentialInputSchema, GraphqlSourceAuthInput as GraphqlSourceAuthInputSchema, - GraphqlSourceBindingRef, graphqlHeaderSlot, graphqlQueryParamSlot, OperationBinding, @@ -63,7 +62,6 @@ import { type GraphqlSourceAuth, type HeaderValue as HeaderValueValue, type GraphqlSourceAuthInput, - type GraphqlSourceBindingValue, type GraphqlOperationKind, } from "./types"; @@ -365,23 +363,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 resolveGraphqlSourceBinding = ( ctx: PluginCtx, sourceId: string, sourceScope: string, slot: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const ranks = scopeRanks(ctx); const sourceSourceRank = scopeRank(ranks, sourceScope); @@ -397,7 +384,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 = ( @@ -481,7 +468,7 @@ const canonicalizeAuth = ( readonly auth: GraphqlSourceAuth; readonly bindings: ReadonlyArray<{ readonly slot: string; - readonly value: GraphqlSourceBindingValue; + readonly value: CredentialBindingValue; readonly targetScope?: string; }>; } => { diff --git a/packages/plugins/graphql/src/sdk/types.ts b/packages/plugins/graphql/src/sdk/types.ts index bf39200f6..105cad22c 100644 --- a/packages/plugins/graphql/src/sdk/types.ts +++ b/packages/plugins/graphql/src/sdk/types.ts @@ -1,10 +1,8 @@ import { Effect, Schema } from "effect"; import { ConfiguredCredentialValue, - CredentialBindingValue, credentialSlotKey, SecretBackedValue, - ScopeId, } from "@executor-js/sdk/shared"; import { HttpConfiguredValueInput, HttpCredentialInput } from "@executor-js/plugin-http-source/sdk"; @@ -110,20 +108,6 @@ export const GraphqlSourceAuthInput = Schema.Union([ ]); export type GraphqlSourceAuthInput = typeof GraphqlSourceAuthInput.Type; -export const GraphqlSourceBindingValue = CredentialBindingValue; -export type GraphqlSourceBindingValue = typeof GraphqlSourceBindingValue.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/http-source/src/react/http-credentials.test.ts b/packages/plugins/http-source/src/react/http-credentials.test.ts new file mode 100644 index 000000000..d859e32bc --- /dev/null +++ b/packages/plugins/http-source/src/react/http-credentials.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ScopeId, SecretId } from "@executor-js/sdk/shared"; + +import { httpCredentialsFromConfiguredCredentialBindings } from "./http-credentials"; + +describe("httpCredentialsFromConfiguredCredentialBindings", () => { + it("hydrates configured credentials with binding and secret scopes", () => { + const personalScope = ScopeId.make("user_1"); + const organizationScope = ScopeId.make("org_1"); + + const credentials = httpCredentialsFromConfiguredCredentialBindings({ + headers: { + Authorization: { + slot: "header:authorization", + prefix: "Bearer ", + }, + }, + queryParams: { + token: { + slot: "query_param:token", + }, + }, + bindings: [ + { + slotKey: "header:authorization", + scopeId: personalScope, + value: { + kind: "secret", + secretId: SecretId.make("personal-api-token"), + secretScopeId: organizationScope, + }, + }, + { + slotKey: "query_param:token", + scopeId: organizationScope, + value: { + kind: "text", + text: "literal-token", + }, + }, + ], + }); + + expect(credentials.headers).toEqual([ + { + name: "Authorization", + secretId: "personal-api-token", + valueKind: "secret", + prefix: "Bearer ", + presetKey: "bearer", + targetScope: personalScope, + secretScope: organizationScope, + }, + ]); + expect(credentials.queryParams).toEqual([ + { + name: "token", + secretId: null, + valueKind: "text", + literalValue: "literal-token", + }, + ]); + }); +}); diff --git a/packages/react/src/plugins/http-credentials.tsx b/packages/plugins/http-source/src/react/http-credentials.tsx similarity index 78% rename from packages/react/src/plugins/http-credentials.tsx rename to packages/plugins/http-source/src/react/http-credentials.tsx index e2f9f341c..108fdd5dc 100644 --- a/packages/react/src/plugins/http-credentials.tsx +++ b/packages/plugins/http-source/src/react/http-credentials.tsx @@ -4,17 +4,21 @@ import type { SecretBackedValue, } from "@executor-js/sdk/shared"; -import { FieldLabel } from "../components/field"; -import { HeadersList } from "./headers-list"; +import { FieldLabel } from "@executor-js/react/components/field"; +import { HeadersList } from "@executor-js/react/plugins/headers-list"; import { headerValueToState, headersFromState, QueryParamCredentialValuePreview, type HeaderAuthPreset, type HeaderState, -} from "./secret-header-auth"; -import type { CredentialTargetScopeOption } from "./credential-target-scope"; -import type { SecretPickerSecret } from "./secret-picker"; +} from "@executor-js/react/plugins/secret-header-auth"; +import type { CredentialTargetScopeOption } from "@executor-js/react/plugins/credential-target-scope"; +import { + type ConfiguredCredentialValueLike, + type CredentialBindingRefLike, +} from "@executor-js/react/plugins/credential-bindings"; +import type { SecretPickerSecret } from "@executor-js/react/plugins/secret-picker"; export type { SecretBackedValue }; @@ -262,6 +266,86 @@ export const serializeTemplateQueryCredentials = ( return result; }; +const bindingBySlot = ( + bindings: readonly CredentialBindingRefLike[], +): ReadonlyMap => + new Map(bindings.map((binding) => [binding.slotKey, binding])); + +const headerFromConfiguredCredential = ( + name: string, + value: ConfiguredCredentialValueLike, + bindings: ReadonlyMap, +): HeaderState | null => { + if (typeof value === "string") { + return headerValueToState(name, value); + } + + const binding = bindings.get(value.slot); + if (binding?.value.kind === "secret") { + return { + ...headerValueToState(name, { + secretId: binding.value.secretId, + prefix: value.prefix, + }), + targetScope: binding.scopeId, + secretScope: binding.value.secretScopeId, + }; + } + + if (binding?.value.kind === "text") { + return headerValueToState(name, binding.value.text); + } + + return null; +}; + +const queryParamFromConfiguredCredential = ( + name: string, + value: ConfiguredCredentialValueLike, + bindings: ReadonlyMap, +): QueryParamState | null => { + if (typeof value === "string") { + return { name, secretId: null, literalValue: value, valueKind: "text" }; + } + + const binding = bindings.get(value.slot); + if (binding?.value.kind === "secret") { + return { + name, + secretId: binding.value.secretId, + valueKind: "secret", + prefix: value.prefix, + targetScope: binding.scopeId, + secretScope: binding.value.secretScopeId, + }; + } + + if (binding?.value.kind === "text") { + return { name, secretId: null, literalValue: binding.value.text, valueKind: "text" }; + } + + return null; +}; + +export const httpCredentialsFromConfiguredCredentialBindings = (input: { + readonly headers?: Record | null; + readonly queryParams?: Record | null; + readonly bindings: readonly CredentialBindingRefLike[]; +}): HttpCredentialsState => { + const bindings = bindingBySlot(input.bindings); + + return { + headers: Object.entries(input.headers ?? {}).flatMap(([name, value]) => { + const state = headerFromConfiguredCredential(name, value, bindings); + return state ? [state] : []; + }), + queryParams: Object.entries(input.queryParams ?? {}).flatMap(([name, value]) => { + const state = queryParamFromConfiguredCredential(name, value, bindings); + return state ? [state] : []; + }), + }; +}; + export const serializeTemplateHttpCredentials = (credentials: HttpCredentialsState) => ({ headers: serializeTemplateHeaderCredentials(credentials.headers), queryParams: serializeTemplateQueryCredentials(credentials.queryParams), diff --git a/packages/plugins/http-source/src/react/index.ts b/packages/plugins/http-source/src/react/index.ts index 88c1f4fa9..bf1de8c2b 100644 --- a/packages/plugins/http-source/src/react/index.ts +++ b/packages/plugins/http-source/src/react/index.ts @@ -1,6 +1,7 @@ export { emptyHttpCredentials, HttpCredentialsEditor, + httpCredentialsFromConfiguredCredentialBindings, httpCredentialsFromValues, httpCredentialsValid, serializeHeaderCredentials, @@ -20,4 +21,4 @@ export { type HttpTemplateCredentialInput, type QueryParamState, type SecretBackedValue, -} from "@executor-js/react/plugins/http-credentials"; +} from "./http-credentials"; diff --git a/packages/plugins/http-source/src/sdk/configure.ts b/packages/plugins/http-source/src/sdk/configure.ts index 1aba3a329..09a9520c5 100644 --- a/packages/plugins/http-source/src/sdk/configure.ts +++ b/packages/plugins/http-source/src/sdk/configure.ts @@ -1,29 +1,14 @@ -import { Data, Effect } from "effect"; import { ConnectionId, ConfiguredCredentialBinding, type ConfiguredCredentialValue, type CredentialBindingValue, - type ReplaceCredentialBindingValue, type ScopedSecretCredentialInput, SecretId, ScopeId, } from "@executor-js/sdk/shared"; -import type { - HttpCredentialInput, - HttpRequestConfigureInput, - HttpRequestSourceConfig, -} from "./types"; - -export class UnknownHttpCredentialFieldError extends Data.TaggedError( - "UnknownHttpCredentialFieldError", -)<{ - readonly section: string; - readonly placement: "headers" | "query"; - readonly fieldName: string; - readonly declared: readonly string[]; -}> {} +import type { HttpCredentialInput } from "./types"; export type HttpNamedCredentialInput = | ConfiguredCredentialValue @@ -123,61 +108,3 @@ export const httpCredentialInputToBindingValue = ( } return input; }; - -export const compileHttpRequestConfigureBindings = (input: { - readonly section: string; - readonly sourceConfig: HttpRequestSourceConfig | undefined; - readonly configure: HttpRequestConfigureInput | undefined; -}): Effect.Effect => - Effect.gen(function* () { - const configure = input.configure; - if (!configure) return []; - - const bindings: ReplaceCredentialBindingValue[] = []; - - for (const [placement, configuredValues] of [ - ["headers", configure.headers], - ["query", configure.query], - ] as const) { - const declared = input.sourceConfig?.[placement] ?? {}; - for (const [name, value] of Object.entries(configuredValues ?? {})) { - const slot = declared[name]; - if (!slot) { - return yield* new UnknownHttpCredentialFieldError({ - section: input.section, - placement, - fieldName: name, - declared: Object.keys(declared), - }); - } - bindings.push({ - slotKey: slot.slotKey, - value: httpCredentialInputToBindingValue(value), - }); - } - } - - const oauth = configure.oauth; - if (oauth && input.sourceConfig?.oauth) { - if (oauth.clientId) { - bindings.push({ - slotKey: input.sourceConfig.oauth.clientIdSlot, - value: httpCredentialInputToBindingValue(oauth.clientId), - }); - } - if (oauth.clientSecret) { - bindings.push({ - slotKey: input.sourceConfig.oauth.clientSecretSlot ?? "", - value: httpCredentialInputToBindingValue(oauth.clientSecret), - }); - } - if (oauth.connection) { - bindings.push({ - slotKey: input.sourceConfig.oauth.connectionSlot, - value: httpCredentialInputToBindingValue(oauth.connection), - }); - } - } - - return bindings.filter((binding) => binding.slotKey.length > 0); - }); diff --git a/packages/plugins/http-source/src/sdk/index.ts b/packages/plugins/http-source/src/sdk/index.ts index f93dc1140..5768ccbb7 100644 --- a/packages/plugins/http-source/src/sdk/index.ts +++ b/packages/plugins/http-source/src/sdk/index.ts @@ -1,26 +1,14 @@ export { HttpCredentialInput, HttpConfiguredValueInput, - HttpCredentialManifestEntry, - HttpCredentialSlotConfig, HttpOAuthConfigureInput, OAuth2Flow, OAuth2SourceConfig, - HttpOAuthSourceConfig, - HttpOAuthTokenPlacement, - HttpRequestConfigureInput, - HttpRequestSourceConfig, type HttpCredentialInput as HttpCredentialInputType, type HttpConfiguredValueInput as HttpConfiguredValueInputType, - type HttpCredentialManifestEntry as HttpCredentialManifestEntryType, - type HttpCredentialSlotConfig as HttpCredentialSlotConfigType, type HttpOAuthConfigureInput as HttpOAuthConfigureInputType, type OAuth2Flow as OAuth2FlowType, type OAuth2SourceConfig as OAuth2SourceConfigType, - type HttpOAuthSourceConfig as HttpOAuthSourceConfigType, - type HttpOAuthTokenPlacement as HttpOAuthTokenPlacementType, - type HttpRequestConfigureInput as HttpRequestConfigureInputType, - type HttpRequestSourceConfig as HttpRequestSourceConfigType, } from "./types"; export { @@ -37,17 +25,7 @@ export { export { compileHttpNamedCredentialMap, - UnknownHttpCredentialFieldError, - compileHttpRequestConfigureBindings, httpCredentialInputToBindingValue, type CompiledHttpNamedCredentialBinding, type HttpNamedCredentialInput, } from "./configure"; - -export { deriveHttpCredentialManifest } from "./manifest"; - -export { - applyHttpRequestCredentials, - resolveHttpRequestCredentials, - type ResolvedHttpRequestCredentials, -} from "./resolve"; diff --git a/packages/plugins/http-source/src/sdk/manifest.ts b/packages/plugins/http-source/src/sdk/manifest.ts deleted file mode 100644 index c222ca537..000000000 --- a/packages/plugins/http-source/src/sdk/manifest.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { HttpCredentialManifestEntry, HttpRequestSourceConfig } from "./types"; - -export const deriveHttpCredentialManifest = (input: { - readonly section: string; - readonly config: HttpRequestSourceConfig | undefined; -}): readonly HttpCredentialManifestEntry[] => { - const config = input.config; - if (!config) return []; - - const entries: HttpCredentialManifestEntry[] = []; - - for (const [name, slot] of Object.entries(config.headers ?? {})) { - entries.push({ - slotKey: slot.slotKey, - label: slot.label ?? name, - family: "http.header", - required: slot.required ?? false, - ...(slot.prefix ? { prefix: slot.prefix } : {}), - placement: { - section: input.section, - name, - }, - }); - } - - for (const [name, slot] of Object.entries(config.query ?? {})) { - entries.push({ - slotKey: slot.slotKey, - label: slot.label ?? name, - family: "http.query", - required: slot.required ?? false, - ...(slot.prefix ? { prefix: slot.prefix } : {}), - placement: { - section: input.section, - name, - }, - }); - } - - if (config.oauth) { - entries.push({ - slotKey: config.oauth.connectionSlot, - label: "OAuth connection", - family: "http.oauth", - required: true, - placement: { - section: input.section, - name: "oauth.connection", - }, - }); - entries.push({ - slotKey: config.oauth.clientIdSlot, - label: "OAuth client ID", - family: "http.oauth", - required: true, - placement: { - section: input.section, - name: "oauth.clientId", - }, - }); - if (config.oauth.clientSecretSlot) { - entries.push({ - slotKey: config.oauth.clientSecretSlot, - label: "OAuth client secret", - family: "http.oauth", - required: true, - placement: { - section: input.section, - name: "oauth.clientSecret", - }, - }); - } - } - - return entries; -}; diff --git a/packages/plugins/http-source/src/sdk/resolve.ts b/packages/plugins/http-source/src/sdk/resolve.ts deleted file mode 100644 index bc64664a7..000000000 --- a/packages/plugins/http-source/src/sdk/resolve.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Effect } from "effect"; -import type { CredentialBindingRef } from "@executor-js/sdk/shared"; - -import type { HttpRequestSourceConfig } from "./types"; - -export interface ResolvedHttpRequestCredentials { - readonly headers?: Readonly>; - readonly query?: Readonly>; -} - -export const resolveHttpRequestCredentials = (input: { - readonly config: HttpRequestSourceConfig | undefined; - readonly resolveBinding: (slotKey: string) => Effect.Effect; - readonly getSecret: (id: string, scope: string | undefined) => Effect.Effect; - readonly getConnectionAccessToken?: (id: string) => Effect.Effect; -}): Effect.Effect => - Effect.gen(function* () { - const headers: Record = {}; - const query: Record = {}; - - for (const [target, config] of [ - [headers, input.config?.headers], - [query, input.config?.query], - ] as const) { - for (const [name, slot] of Object.entries(config ?? {})) { - const binding = yield* input.resolveBinding(slot.slotKey); - if (!binding) continue; - const value = yield* resolveBindingValue(binding, input); - if (value == null) continue; - target[name] = slot.prefix ? `${slot.prefix}${value}` : value; - } - } - - return { - ...(Object.keys(headers).length > 0 ? { headers } : {}), - ...(Object.keys(query).length > 0 ? { query } : {}), - }; - }); - -const resolveBindingValue = ( - binding: CredentialBindingRef, - input: { - readonly getSecret: (id: string, scope: string | undefined) => Effect.Effect; - readonly getConnectionAccessToken?: (id: string) => Effect.Effect; - }, -): Effect.Effect => { - if (binding.value.kind === "text") return Effect.succeed(binding.value.text); - if (binding.value.kind === "secret") { - return input.getSecret(binding.value.secretId, binding.value.secretScopeId); - } - if (input.getConnectionAccessToken) { - return input.getConnectionAccessToken(binding.value.connectionId); - } - return Effect.succeed(null); -}; - -export const applyHttpRequestCredentials = ( - url: URL, - init: RequestInit, - credentials: ResolvedHttpRequestCredentials, -): RequestInit => { - for (const [name, value] of Object.entries(credentials.query ?? {})) { - url.searchParams.set(name, value); - } - const headers = new Headers(init.headers); - for (const [name, value] of Object.entries(credentials.headers ?? {})) { - headers.set(name, value); - } - return { - ...init, - headers, - }; -}; diff --git a/packages/plugins/http-source/src/sdk/types.ts b/packages/plugins/http-source/src/sdk/types.ts index ce19dbd8b..f57018a20 100644 --- a/packages/plugins/http-source/src/sdk/types.ts +++ b/packages/plugins/http-source/src/sdk/types.ts @@ -1,45 +1,5 @@ import { Schema } from "effect"; -export const HttpCredentialSlotConfig = Schema.Struct({ - slotKey: Schema.String, - label: Schema.optional(Schema.String), - required: Schema.optional(Schema.Boolean), - prefix: Schema.optional(Schema.String), -}).annotate({ identifier: "HttpCredentialSlotConfig" }); -export type HttpCredentialSlotConfig = typeof HttpCredentialSlotConfig.Type; - -export const HttpOAuthTokenPlacement = Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("header"), - name: Schema.String, - scheme: Schema.optional(Schema.String), - }), - Schema.Struct({ - kind: Schema.Literal("query"), - name: Schema.String, - }), -]).annotate({ identifier: "HttpOAuthTokenPlacement" }); -export type HttpOAuthTokenPlacement = typeof HttpOAuthTokenPlacement.Type; - -export const HttpOAuthSourceConfig = Schema.Struct({ - authorizationUrl: Schema.NullOr(Schema.String), - tokenUrl: Schema.String, - issuerUrl: Schema.optional(Schema.NullOr(Schema.String)), - clientIdSlot: Schema.String, - clientSecretSlot: Schema.NullOr(Schema.String), - connectionSlot: Schema.String, - scopes: Schema.Array(Schema.String), - placement: HttpOAuthTokenPlacement, -}).annotate({ identifier: "HttpOAuthSourceConfig" }); -export type HttpOAuthSourceConfig = typeof HttpOAuthSourceConfig.Type; - -export const HttpRequestSourceConfig = Schema.Struct({ - headers: Schema.optional(Schema.Record(Schema.String, HttpCredentialSlotConfig)), - query: Schema.optional(Schema.Record(Schema.String, HttpCredentialSlotConfig)), - oauth: Schema.optional(HttpOAuthSourceConfig), -}).annotate({ identifier: "HttpRequestSourceConfig" }); -export type HttpRequestSourceConfig = typeof HttpRequestSourceConfig.Type; - export const HttpCredentialInput = Schema.Union([ Schema.String, Schema.Struct({ @@ -83,7 +43,7 @@ export const OAuth2SourceConfig = Schema.Struct({ clientSecretSlot: Schema.NullOr(Schema.String), connectionSlot: Schema.String, scopes: Schema.Array(Schema.String), -}).annotate({ identifier: "HttpOAuth2SourceConfig" }); +}).annotate({ identifier: "OAuth2SourceConfig" }); export type OAuth2SourceConfig = typeof OAuth2SourceConfig.Type; export const HttpOAuthConfigureInput = Schema.Struct({ @@ -92,25 +52,3 @@ export const HttpOAuthConfigureInput = Schema.Struct({ connection: Schema.optional(HttpCredentialInput), }).annotate({ identifier: "HttpOAuthConfigureInput" }); export type HttpOAuthConfigureInput = typeof HttpOAuthConfigureInput.Type; - -export const HttpRequestConfigureInput = Schema.Struct({ - headers: Schema.optional(Schema.Record(Schema.String, HttpCredentialInput)), - query: Schema.optional(Schema.Record(Schema.String, HttpCredentialInput)), - oauth: Schema.optional(HttpOAuthConfigureInput), -}).annotate({ identifier: "HttpRequestConfigureInput" }); -export type HttpRequestConfigureInput = typeof HttpRequestConfigureInput.Type; - -export const HttpCredentialManifestEntry = Schema.Struct({ - slotKey: Schema.String, - label: Schema.String, - family: Schema.Literals(["http.header", "http.query", "http.oauth"]), - required: Schema.Boolean, - prefix: Schema.optional(Schema.String), - placement: Schema.optional( - Schema.Struct({ - section: Schema.String, - name: Schema.String, - }), - ), -}).annotate({ identifier: "HttpCredentialManifestEntry" }); -export type HttpCredentialManifestEntry = typeof HttpCredentialManifestEntry.Type; diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index 9a40fd525..b83f98187 100644 --- a/packages/plugins/mcp/src/react/EditMcpSource.tsx +++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Exit from "effect/Exit"; @@ -8,30 +8,40 @@ import { connectionsAtom, setSourceCredentialBinding, } from "@executor-js/react/api/atoms"; -import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; +import { useScope, useUserScope } 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"; import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope"; import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; import { - HttpCredentialsEditor, - serializeConfigureHttpCredentials, + httpCredentialsFromConfiguredCredentialBindings, serializeHttpCredentials, type HttpCredentialsState, } from "@executor-js/plugin-http-source/react"; import { - httpCredentialsFromConfiguredCredentialBindings, - initialCredentialTargetScope, -} from "@executor-js/react/plugins/credential-bindings"; + useSourceCredentialBindingScopes, + useSourceCredentialBindingWriter, +} from "@executor-js/react/plugins/source-credential-bindings"; import { SourceOAuthConnectionControl, sourceOAuthConnectionUiState, } from "@executor-js/react/plugins/source-oauth-connection"; import { Button } from "@executor-js/react/components/button"; +import { + CardStack, + CardStackContent, + CardStackEntry, + CardStackEntryContent, + CardStackEntryDescription, + CardStackEntryTitle, +} from "@executor-js/react/components/card-stack"; import { Badge } from "@executor-js/react/components/badge"; -import { ScopeId } from "@executor-js/sdk/shared"; +import { type CredentialBindingRef, ScopeId } from "@executor-js/sdk/shared"; +import { + SecretCredentialSlotBindings, + secretCredentialSlotsFromHttpConfig, +} from "@executor-js/react/plugins/credential-slot-bindings"; import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; -import { type McpCredentialInput, type McpSourceBindingRef } from "../sdk/types"; import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; // --------------------------------------------------------------------------- @@ -41,17 +51,12 @@ 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(); const userScope = useUserScope(); - const scopeStack = useScopeStack(); const sourceScope = ScopeId.make(props.initial.scope); - const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({ - sourceScope, - initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), - }); const { credentialTargetScope: oauthCredentialTargetScope, setCredentialTargetScope: setOAuthCredentialTargetScope, @@ -60,7 +65,7 @@ function RemoteEditForm(props: { initialTargetScope: userScope, }); const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); - const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); + const setConnectionBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const secretList = useSecretPickerSecrets(); const connectionsResult = useAtomValue(connectionsAtom(userScope)); @@ -69,23 +74,34 @@ function RemoteEditForm(props: { fallbackNamespace: props.initial.namespace, }); const [endpoint, setEndpoint] = useState(props.initial.config.endpoint); - const [credentials, setCredentials] = useState(() => - httpCredentialsFromConfiguredCredentialBindings({ - headers: props.initial.config.headers, - queryParams: props.initial.config.queryParams, - bindings: props.bindings, - }), + const credentials = useMemo( + () => + httpCredentialsFromConfiguredCredentialBindings({ + headers: props.initial.config.headers, + queryParams: props.initial.config.queryParams, + bindings: props.bindings, + }), + [props.bindings, props.initial.config.headers, props.initial.config.queryParams], ); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const [credentialsDirty, setCredentialsDirty] = useState(false); + const { busyKey, setSecretBinding, clearBinding } = useSourceCredentialBindingWriter({ + displayScope, + source: { id: props.sourceId, scope: sourceScope }, + onError: setError, + }); const identityDirty = identity.name.trim() !== props.initial.name.trim(); const metadataDirty = identityDirty || endpoint.trim() !== props.initial.config.endpoint.trim(); - const dirty = metadataDirty || credentialsDirty; + const dirty = metadataDirty; const oauth2 = props.initial.config.auth.kind === "oauth2" ? props.initial.config.auth : null; const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; - const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); + const { credentialScopeOptions, secretBindingScopes, scopeRanks } = + useSourceCredentialBindingScopes({ sourceScope }); + const secretSlots = secretCredentialSlotsFromHttpConfig({ + headers: props.initial.config.headers, + queryParams: props.initial.config.queryParams, + }); const oauthConnectionState = oauth2 ? sourceOAuthConnectionUiState({ bindings: props.bindings, @@ -98,36 +114,21 @@ function RemoteEditForm(props: { : null; const oauthRequestCredentials = serializeHttpCredentials(credentials); - const handleCredentialsChange = (next: HttpCredentialsState) => { - setCredentials(next); - setCredentialsDirty(true); - }; - const handleSave = async () => { setSaving(true); setError(null); - const { headers, queryParams } = serializeConfigureHttpCredentials( - credentials, - credentialTargetScope, - ); const config: { name?: string; endpoint?: string; - headers?: Record; - queryParams?: Record; } = { name: metadataDirty ? identity.name.trim() || undefined : undefined, endpoint: metadataDirty ? endpoint.trim() || undefined : undefined, }; - if (credentialsDirty) { - config.headers = headers; - config.queryParams = queryParams as Record; - } const exit = await doConfigure({ params: { scopeId: displayScope }, payload: { source: { id: props.sourceId, scope: sourceScope }, - scope: credentialTargetScope, + scope: sourceScope, type: "mcp", config, }, @@ -138,7 +139,6 @@ function RemoteEditForm(props: { setSaving(false); return; } - setCredentialsDirty(false); setSaving(false); props.onSave(); }; @@ -174,15 +174,33 @@ function RemoteEditForm(props: { namespaceReadOnly /> - + {secretSlots.length > 0 && ( + + + + + Request credentials + + Headers and query parameters sent with every MCP request. + + + + + + + )} {oauth2 && oauthConnectionState && ( { - await setBinding({ + await setConnectionBinding({ params: { scopeId: oauthCredentialTargetScope }, payload: { scope: oauthCredentialTargetScope, diff --git a/packages/plugins/mcp/src/react/McpSignInButton.tsx b/packages/plugins/mcp/src/react/McpSignInButton.tsx index 165211328..b966d3611 100644 --- a/packages/plugins/mcp/src/react/McpSignInButton.tsx +++ b/packages/plugins/mcp/src/react/McpSignInButton.tsx @@ -42,7 +42,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; diff --git a/packages/plugins/mcp/src/react/atoms.ts b/packages/plugins/mcp/src/react/atoms.ts index 86ac9b4ac..8a61977a0 100644 --- a/packages/plugins/mcp/src/react/atoms.ts +++ b/packages/plugins/mcp/src/react/atoms.ts @@ -4,7 +4,6 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { sourceCredentialBindingsAtom, sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; import { McpClient } from "./client"; -import { McpSourceBindingRef } from "../sdk/types"; // --------------------------------------------------------------------------- // Query atoms @@ -21,20 +20,7 @@ export const mcpSourceBindingsAtom = ( scopeId: ScopeId, namespace: string, sourceScopeId: ScopeId, -) => - Atom.mapResult(sourceCredentialBindingsAtom(scopeId, namespace, sourceScopeId), (rows) => - rows.map((row) => - McpSourceBindingRef.make({ - sourceId: row.sourceId, - sourceScopeId: row.sourceScopeId, - scopeId: row.scopeId, - slot: row.slotKey, - value: row.value, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }), - ), - ); +) => sourceCredentialBindingsAtom(scopeId, namespace, sourceScopeId); // --------------------------------------------------------------------------- // Mutation atoms diff --git a/packages/plugins/mcp/src/sdk/index.ts b/packages/plugins/mcp/src/sdk/index.ts index 3bb8d1138..e397f095d 100644 --- a/packages/plugins/mcp/src/sdk/index.ts +++ b/packages/plugins/mcp/src/sdk/index.ts @@ -26,8 +26,6 @@ export { McpConnectionAuth, McpConnectionAuthInput, McpCredentialInput, - McpSourceBindingRef, mcpHeaderSlot, mcpQueryParamSlot, - type McpSourceBindingValue, } from "./types"; diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 6f69f1412..e437f30b6 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -54,12 +54,10 @@ import { McpConnectionAuthInput, McpCredentialInput, McpToolBinding, - McpSourceBindingRef, mcpHeaderSlot, mcpQueryParamSlot, type McpConnectionAuth, type McpConfiguredValueInput as McpConfiguredValueInputType, - type McpSourceBindingValue, type SecretBackedValue, type McpStoredSourceData, type ConfiguredMcpCredentialValue, @@ -275,23 +273,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 resolveMcpSourceBinding = ( ctx: PluginCtx, sourceId: string, sourceScope: string, slot: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const ranks = scopeRanks(ctx); const sourceSourceRank = scopeRank(ranks, sourceScope); @@ -307,7 +294,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 = ( @@ -398,13 +385,13 @@ const canonicalizeAuth = ( readonly auth: McpConnectionAuth; readonly bindings: ReadonlyArray<{ readonly slot: string; - readonly value: McpSourceBindingValue; + readonly value: CredentialBindingValue; readonly targetScope?: string; }>; } => { if (!auth || "kind" in auth || !auth.oauth2) return { auth: { kind: "none" }, bindings: [] }; const oauth = auth.oauth2; - const bindings: Array<{ slot: string; value: McpSourceBindingValue; targetScope?: string }> = []; + const bindings: Array<{ slot: string; value: CredentialBindingValue; targetScope?: string }> = []; if (oauth.connection) { bindings.push({ slot: MCP_OAUTH_CONNECTION_SLOT, diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index d09d90fd4..4e8b2730d 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -1,9 +1,7 @@ import { Effect, Schema } from "effect"; import { ConfiguredCredentialValue, - CredentialBindingValue, credentialSlotKey, - ScopeId, SecretBackedMap, SecretBackedValue, } from "@executor-js/sdk/shared"; @@ -80,20 +78,6 @@ export const McpConnectionAuthInput = Schema.Union([ ]); export type McpConnectionAuthInput = typeof McpConnectionAuthInput.Type; -export const McpSourceBindingValue = CredentialBindingValue; -export type McpSourceBindingValue = typeof McpSourceBindingValue.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/openapi/src/react/EditOpenApiSource.tsx b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx index 065fda94e..b6dd8e5f8 100644 --- a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx @@ -8,12 +8,11 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { connectionsAtom, configureSource, - removeSourceCredentialBinding, setSourceCredentialBinding, sourceAtom, startOAuth, } from "@executor-js/react/api/atoms"; -import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; +import { useScope, 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"; import { CopyButton } from "@executor-js/react/components/copy-button"; @@ -33,9 +32,7 @@ import { sourceWriteKeys as openApiWriteKeys } from "@executor-js/react/api/reac import { ConnectionId, CredentialBindingRef, - RemoveSourceCredentialBindingInput, ScopeId, - SecretId, SetSourceCredentialBindingInput, } from "@executor-js/sdk/shared"; import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; @@ -56,6 +53,10 @@ import { isSecretCredentialBindingValue, } from "@executor-js/react/plugins/credential-bindings"; import { SecretCredentialSlotBindings } from "@executor-js/react/plugins/credential-slot-bindings"; +import { + useSourceCredentialBindingScopes, + useSourceCredentialBindingWriter, +} from "@executor-js/react/plugins/source-credential-bindings"; import { CreatableSecretPicker } from "@executor-js/react/plugins/secret-header-auth"; import { openApiSourceAtom, openApiSourceBindingsAtom } from "./atoms"; @@ -91,7 +92,7 @@ type SlotDef = readonly label: string; }; -type OpenApiCredentialBindingRow = CredentialBindingRef & { readonly slot: string }; +type OpenApiCredentialBindingRow = CredentialBindingRef; const slugify = (value: string): string => value @@ -128,7 +129,6 @@ export default function EditOpenApiSource(props: { readonly onSave: () => void; }) { const displayScope = useScope(); - const scopeStack = useScopeStack(); const userScope = useUserScope(); const sourceSummaryResult = useAtomValue(sourceAtom(props.sourceId, displayScope)); const sourceSummary = @@ -137,10 +137,13 @@ export default function EditOpenApiSource(props: { : null; const sourceScopeId = sourceSummary?.scopeId ?? displayScope; const sourceScope = ScopeId.make(sourceScopeId); - const scopeRanks = useMemo( - () => new Map(scopeStack.map((scope, index) => [scope.id, index] as const)), - [scopeStack], - ); + const { + credentialScopes, + credentialScopeOptions, + organizationCredentialScope, + secretBindingScopes, + scopeRanks, + } = useSourceCredentialBindingScopes({ sourceScope }); const sourceResult = useAtomValue(openApiSourceAtom(sourceScope, props.sourceId)); const bindingsResult = useAtomValue( @@ -153,9 +156,6 @@ export default function EditOpenApiSource(props: { const doSetBinding = useAtomSet(setSourceCredentialBinding, { mode: "promiseExit", }); - const doRemoveBinding = useAtomSet(removeSourceCredentialBinding, { - mode: "promiseExit", - }); const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); const oauth = useOAuthPopupFlow({ popupName: OPENAPI_OAUTH_POPUP_NAME, @@ -176,6 +176,12 @@ export default function EditOpenApiSource(props: { const [sourceSaveState, setSourceSaveState] = useState<"idle" | "saving" | "saved">("idle"); const [error, setError] = useState(null); const [busyKey, setBusyKey] = useState(null); + const sourceBindingWriter = useSourceCredentialBindingWriter({ + displayScope, + source: { id: props.sourceId, scope: sourceScope }, + onError: setError, + errorMessageFromExit, + }); const [pendingOAuthConnection, setPendingOAuthConnection] = useState<{ readonly scopeId: ScopeId; readonly slot: string; @@ -328,39 +334,6 @@ export default function EditOpenApiSource(props: { return slots; }, [source]); - const credentialScopes = useMemo(() => { - const entries = [{ scopeId: ScopeId.make(sourceScopeId), label: "Organization" }]; - if (userScope !== sourceScopeId) { - entries.unshift({ scopeId: ScopeId.make(userScope), label: "Personal" }); - } else { - entries[0] = { - scopeId: ScopeId.make(sourceScopeId), - label: "Credentials", - }; - } - return entries; - }, [sourceScopeId, userScope]); - const credentialScopeOptions = useMemo( - () => - credentialScopes.map((entry) => ({ - scopeId: entry.scopeId, - label: entry.label, - description: - entry.label === "Personal" - ? "Saved only for your account." - : "Shared with everyone who can use this source.", - })), - [credentialScopes], - ); - const organizationCredentialScope = - credentialScopes.find((entry) => entry.label === "Organization") ?? credentialScopes[0]!; - const personalCredentialScope = - credentialScopes.find((entry) => entry.label === "Personal") ?? null; - const secretBindingScopes = - personalCredentialScope && - personalCredentialScope.scopeId !== organizationCredentialScope.scopeId - ? [organizationCredentialScope, personalCredentialScope] - : [organizationCredentialScope]; const activeOAuthTokenScope = credentialScopes.find((entry) => entry.scopeId === selectedOAuthTokenScope) ?? credentialScopes[0]!; @@ -385,55 +358,6 @@ export default function EditOpenApiSource(props: { (slot.slot !== source.config.oauth2.clientIdSlot && slot.slot !== oauthClientSecretSlot)), ); - const setSecretBinding = async ( - targetScope: ScopeId, - slot: string, - secretId: string, - secretScope: ScopeId, - ) => { - const inputKey = `${targetScope}:${slot}`; - const trimmed = secretId.trim(); - if (!trimmed) return; - setBusyKey(inputKey); - setError(null); - const exit = await doSetBinding({ - params: { scopeId: displayScope }, - payload: SetSourceCredentialBindingInput.make({ - source: { id: props.sourceId, scope: sourceScope }, - scope: targetScope, - slotKey: slot, - value: { - kind: "secret", - secretId: SecretId.make(trimmed), - secretScopeId: secretScope, - }, - }), - reactivityKeys: sourceWriteKeys, - }); - if (Exit.isFailure(exit)) { - setError(errorMessageFromExit(exit, "Failed to save credential binding")); - } - setBusyKey(null); - }; - - const clearBinding = async (targetScope: ScopeId, slot: string) => { - setBusyKey(`${targetScope}:${slot}:clear`); - setError(null); - const exit = await doRemoveBinding({ - params: { scopeId: displayScope }, - payload: RemoveSourceCredentialBindingInput.make({ - source: { id: props.sourceId, scope: sourceScope }, - scope: targetScope, - slotKey: slot, - }), - reactivityKeys: sourceWriteKeys, - }); - if (Exit.isFailure(exit)) { - setError(errorMessageFromExit(exit, "Failed to clear credential binding")); - } - setBusyKey(null); - }; - const connectOAuth = async (targetScope: ScopeId) => { const oauth2 = source.config.oauth2; if (!oauth2) return; @@ -679,9 +603,9 @@ export default function EditOpenApiSource(props: { sourceId={props.sourceId} sourceName={source.name} credentialScopeOptions={credentialScopeOptions} - busyKey={busyKey} - onSetSecretBinding={setSecretBinding} - onClearBinding={clearBinding} + busyKey={sourceBindingWriter.busyKey} + onSetSecretBinding={sourceBindingWriter.setSecretBinding} + onClearBinding={sourceBindingWriter.clearBinding} /> )} @@ -849,7 +773,7 @@ export default function EditOpenApiSource(props: { - void setSecretBinding( + void sourceBindingWriter.setSecretBinding( activeScope.scopeId, input.slot, secretId, @@ -871,13 +795,18 @@ export default function EditOpenApiSource(props: { )} - {busyKey === inputKey && ( + {sourceBindingWriter.busyKey === inputKey && ( Saving… )}
diff --git a/packages/plugins/openapi/src/sdk/types.ts b/packages/plugins/openapi/src/sdk/types.ts index a8f4edff4..e89afa65e 100644 --- a/packages/plugins/openapi/src/sdk/types.ts +++ b/packages/plugins/openapi/src/sdk/types.ts @@ -1,5 +1,16 @@ import { Schema } from "effect"; import { ScopedSecretCredentialInput, SecretBackedValue } from "@executor-js/sdk/shared"; +import { + OAuth2Flow as HttpOAuth2Flow, + OAuth2SourceConfig as SharedOAuth2SourceConfig, + type OAuth2FlowType, + type OAuth2SourceConfigType, +} from "@executor-js/plugin-http-source/sdk"; + +export const OAuth2Flow = HttpOAuth2Flow; +export type OAuth2Flow = OAuth2FlowType; +export const OAuth2SourceConfig = SharedOAuth2SourceConfig; +export type OAuth2SourceConfig = OAuth2SourceConfigType; // --------------------------------------------------------------------------- // Branded IDs @@ -182,23 +193,6 @@ export type OpenApiCredentialInput = typeof OpenApiCredentialInput.Type; // copies can't drift under normal reconnect flows. // --------------------------------------------------------------------------- -export const OAuth2Flow = Schema.Literals(["authorizationCode", "clientCredentials"]); -export type OAuth2Flow = typeof OAuth2Flow.Type; - -export const OAuth2SourceConfig = Schema.Struct({ - kind: Schema.Literal("oauth2"), - securitySchemeName: Schema.String, - flow: OAuth2Flow, - tokenUrl: Schema.String, - authorizationUrl: Schema.NullOr(Schema.String), - issuerUrl: Schema.optional(Schema.NullOr(Schema.String)), - clientIdSlot: Schema.String, - clientSecretSlot: Schema.NullOr(Schema.String), - connectionSlot: Schema.String, - scopes: Schema.Array(Schema.String), -}).annotate({ identifier: "OpenApiOAuth2SourceConfig" }); -export type OAuth2SourceConfig = typeof OAuth2SourceConfig.Type; - export const InvocationResult = Schema.Struct({ status: Schema.Number, headers: Schema.Record(Schema.String, Schema.String), diff --git a/packages/react/src/plugins/credential-bindings.test.ts b/packages/react/src/plugins/credential-bindings.test.ts index 303cb6dbb..2c743affb 100644 --- a/packages/react/src/plugins/credential-bindings.test.ts +++ b/packages/react/src/plugins/credential-bindings.test.ts @@ -2,70 +2,11 @@ import { describe, expect, it } from "@effect/vitest"; import { ScopeId, SecretId } from "@executor-js/sdk/shared"; import { - httpCredentialsFromConfiguredCredentialBindings, initialCredentialTargetScope, secretBackedValuesFromConfiguredCredentialBindings, } from "./credential-bindings"; describe("credential binding editor helpers", () => { - it("hydrates configured credentials with binding and secret scopes", () => { - const personalScope = ScopeId.make("user_1"); - const organizationScope = ScopeId.make("org_1"); - - const credentials = httpCredentialsFromConfiguredCredentialBindings({ - headers: { - Authorization: { - slot: "header:authorization", - prefix: "Bearer ", - }, - }, - queryParams: { - token: { - slot: "query_param:token", - }, - }, - bindings: [ - { - slot: "header:authorization", - scopeId: personalScope, - value: { - kind: "secret", - secretId: SecretId.make("personal-api-token"), - secretScopeId: organizationScope, - }, - }, - { - slot: "query_param:token", - scopeId: organizationScope, - value: { - kind: "text", - text: "literal-token", - }, - }, - ], - }); - - expect(credentials.headers).toEqual([ - { - name: "Authorization", - secretId: "personal-api-token", - valueKind: "secret", - prefix: "Bearer ", - presetKey: "bearer", - targetScope: personalScope, - secretScope: organizationScope, - }, - ]); - expect(credentials.queryParams).toEqual([ - { - name: "token", - secretId: null, - valueKind: "text", - literalValue: "literal-token", - }, - ]); - }); - it("uses the first binding as the initial target scope", () => { const sourceScope = ScopeId.make("org_1"); const personalScope = ScopeId.make("user_1"); @@ -73,7 +14,7 @@ describe("credential binding editor helpers", () => { expect( initialCredentialTargetScope(sourceScope, [ { - slot: "header:authorization", + slotKey: "header:authorization", scopeId: personalScope, value: { kind: "secret", @@ -97,7 +38,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 0aa37d91b..8fb296f7d 100644 --- a/packages/react/src/plugins/credential-bindings.tsx +++ b/packages/react/src/plugins/credential-bindings.tsx @@ -4,10 +4,7 @@ import { type SecretBackedValue, } from "@executor-js/sdk/shared"; -import type { HttpCredentialsState, QueryParamState } from "./http-credentials"; -import { headerValueToState, type HeaderState } from "./secret-header-auth"; - -type ConfiguredCredentialValueLike = +export type ConfiguredCredentialValueLike = | string | { readonly slot: string; @@ -15,7 +12,7 @@ type ConfiguredCredentialValueLike = }; export type CredentialBindingRefLike = { - readonly slot: string; + readonly slotKey: string; readonly scopeId: ScopeId; readonly value: CredentialBindingValue; }; @@ -23,7 +20,7 @@ export type CredentialBindingRefLike = { const bindingBySlot = ( bindings: readonly CredentialBindingRefLike[], ): ReadonlyMap => - new Map(bindings.map((binding) => [binding.slot, binding])); + new Map(bindings.map((binding) => [binding.slotKey, binding])); export const initialCredentialTargetScope = ( sourceScope: ScopeId, @@ -35,7 +32,7 @@ export const exactCredentialBindingForScope = ( slot: string, scopeId: ScopeId, ): CredentialBindingRefLike | null => - rows.find((row) => row.slot === slot && row.scopeId === scopeId) ?? 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; @@ -47,7 +44,7 @@ export const effectiveCredentialBindingForScope = ( ranks: ReadonlyMap, ): CredentialBindingRefLike | 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 = ( @@ -59,62 +56,6 @@ export const isConnectionCredentialBindingValue = ( ): value is Extract => value.kind === "connection"; -const headerFromConfiguredCredential = ( - name: string, - value: ConfiguredCredentialValueLike, - bindings: ReadonlyMap, -): HeaderState | null => { - if (typeof value === "string") { - return headerValueToState(name, value); - } - - const binding = bindings.get(value.slot); - if (binding?.value.kind === "secret") { - return { - ...headerValueToState(name, { - secretId: binding.value.secretId, - prefix: value.prefix, - }), - targetScope: binding.scopeId, - secretScope: binding.value.secretScopeId, - }; - } - - if (binding?.value.kind === "text") { - return headerValueToState(name, binding.value.text); - } - - return null; -}; - -const queryParamFromConfiguredCredential = ( - name: string, - value: ConfiguredCredentialValueLike, - bindings: ReadonlyMap, -): QueryParamState | null => { - if (typeof value === "string") { - return { name, secretId: null, literalValue: value, valueKind: "text" }; - } - - const binding = bindings.get(value.slot); - if (binding?.value.kind === "secret") { - return { - name, - secretId: binding.value.secretId, - valueKind: "secret", - prefix: value.prefix, - targetScope: binding.scopeId, - secretScope: binding.value.secretScopeId, - }; - } - - if (binding?.value.kind === "text") { - return { name, secretId: null, literalValue: binding.value.text, valueKind: "text" }; - } - - return null; -}; - export const secretBackedValuesFromConfiguredCredentialBindings = ( values: Record | undefined | null, bindingsInput: readonly CredentialBindingRefLike[], @@ -141,22 +82,3 @@ export const secretBackedValuesFromConfiguredCredentialBindings = ( return Object.keys(out).length > 0 ? out : undefined; }; - -export const httpCredentialsFromConfiguredCredentialBindings = (input: { - readonly headers?: Record | null; - readonly queryParams?: Record | null; - readonly bindings: readonly CredentialBindingRefLike[]; -}): HttpCredentialsState => { - const bindings = bindingBySlot(input.bindings); - - return { - headers: Object.entries(input.headers ?? {}).flatMap(([name, value]) => { - const state = headerFromConfiguredCredential(name, value, bindings); - return state ? [state] : []; - }), - queryParams: Object.entries(input.queryParams ?? {}).flatMap(([name, value]) => { - const state = queryParamFromConfiguredCredential(name, value, bindings); - return state ? [state] : []; - }), - }; -}; diff --git a/packages/react/src/plugins/credential-slot-bindings.tsx b/packages/react/src/plugins/credential-slot-bindings.tsx index be222d75c..3dcde4838 100644 --- a/packages/react/src/plugins/credential-slot-bindings.tsx +++ b/packages/react/src/plugins/credential-slot-bindings.tsx @@ -11,6 +11,19 @@ import { import { CreatableSecretPicker } from "./secret-header-auth"; import type { SecretPickerSecret } from "./secret-picker"; +type ConfiguredHttpCredentialValue = + | string + | { + readonly kind: "binding"; + readonly slot: string; + readonly prefix?: string; + }; + +export type ConfiguredHttpCredentialMap = + | Readonly> + | undefined + | null; + export type SecretCredentialSlot = { readonly slot: string; readonly label: string; @@ -23,7 +36,7 @@ export type CredentialBindingScope = { }; type CredentialSlotBindingRef = { - readonly slot: string; + readonly slotKey: string; readonly scopeId: ScopeId; readonly value: CredentialBindingValue; }; @@ -45,6 +58,30 @@ const rowTitle = (bindingScope: CredentialBindingScope, bindingScopeCount: numbe ? "Source credential" : "Organization default"; +export const secretCredentialSlotsFromHttpConfig = (input: { + readonly headers?: ConfiguredHttpCredentialMap; + readonly queryParams?: ConfiguredHttpCredentialMap; +}): readonly SecretCredentialSlot[] => { + const slots: SecretCredentialSlot[] = []; + for (const [name, value] of Object.entries(input.headers ?? {})) { + if (typeof value === "string") continue; + slots.push({ + slot: value.slot, + label: name, + hint: value.prefix ? `Prefix: ${value.prefix}` : undefined, + }); + } + for (const [name, value] of Object.entries(input.queryParams ?? {})) { + if (typeof value === "string") continue; + slots.push({ + slot: value.slot, + label: name, + hint: value.prefix ? `Prefix: ${value.prefix}` : undefined, + }); + } + return slots; +}; + export function SecretCredentialSlotBindings(props: { readonly slots: readonly SecretCredentialSlot[]; readonly bindingScopes: readonly CredentialBindingScope[]; diff --git a/packages/react/src/plugins/source-credential-bindings.tsx b/packages/react/src/plugins/source-credential-bindings.tsx new file mode 100644 index 000000000..799a5da5c --- /dev/null +++ b/packages/react/src/plugins/source-credential-bindings.tsx @@ -0,0 +1,158 @@ +import { useMemo, useState } from "react"; +import { useAtomSet } from "@effect/atom-react"; +import * as Exit from "effect/Exit"; + +import { + RemoveSourceCredentialBindingInput, + ScopeId, + SecretId, + SetSourceCredentialBindingInput, +} from "@executor-js/sdk/shared"; + +import { removeSourceCredentialBinding, setSourceCredentialBinding } from "../api/atoms"; +import { sourceWriteKeys } from "../api/reactivity-keys"; +import { useScopeStack, useUserScope } from "../api/scope-context"; +import type { CredentialBindingRefLike } from "./credential-bindings"; +import type { CredentialBindingScope } from "./credential-slot-bindings"; +import type { CredentialTargetScopeOption } from "./credential-target-scope"; + +export function useSourceCredentialBindingScopes(input: { readonly sourceScope: ScopeId }): { + readonly credentialScopes: readonly CredentialBindingScope[]; + readonly credentialScopeOptions: readonly CredentialTargetScopeOption[]; + readonly organizationCredentialScope: CredentialBindingScope; + readonly personalCredentialScope: CredentialBindingScope | null; + readonly secretBindingScopes: readonly CredentialBindingScope[]; + readonly scopeRanks: ReadonlyMap; +} { + const userScope = useUserScope(); + const scopeStack = useScopeStack(); + + const credentialScopes = useMemo(() => { + const entries: CredentialBindingScope[] = []; + if (userScope !== input.sourceScope) { + entries.push({ scopeId: ScopeId.make(userScope), label: "Personal" }); + } + entries.push({ + scopeId: input.sourceScope, + label: userScope === input.sourceScope ? "Credentials" : "Organization", + }); + return entries; + }, [input.sourceScope, userScope]); + + const credentialScopeOptions = useMemo( + () => + credentialScopes.map((entry) => ({ + scopeId: entry.scopeId, + label: entry.label, + description: + entry.label === "Personal" + ? "Saved only for your account." + : "Shared with everyone who can use this source.", + })), + [credentialScopes], + ); + + const organizationCredentialScope = credentialScopes[credentialScopes.length - 1]!; + const personalCredentialScope = + credentialScopes.find((entry) => entry.label === "Personal") ?? null; + const secretBindingScopes = + personalCredentialScope && + personalCredentialScope.scopeId !== organizationCredentialScope.scopeId + ? [organizationCredentialScope, personalCredentialScope] + : [organizationCredentialScope]; + const scopeRanks = useMemo( + () => new Map(scopeStack.map((scope, index) => [scope.id, index] as const)), + [scopeStack], + ); + + return { + credentialScopes, + credentialScopeOptions, + organizationCredentialScope, + personalCredentialScope, + secretBindingScopes, + scopeRanks, + }; +} + +export const initialSourceCredentialScope = ( + sourceScope: ScopeId, + bindings: readonly CredentialBindingRefLike[], +): ScopeId => bindings[0]?.scopeId ?? sourceScope; + +export function useSourceCredentialBindingWriter(input: { + readonly displayScope: ScopeId; + readonly source: { + readonly id: string; + readonly scope: ScopeId; + }; + readonly onError: (message: string | null) => void; + readonly errorMessageFromExit?: (exit: Exit.Exit, fallback: string) => string; +}): { + readonly busyKey: string | null; + readonly setSecretBinding: ( + targetScope: ScopeId, + slot: string, + secretId: string, + secretScope: ScopeId, + ) => Promise; + readonly clearBinding: (targetScope: ScopeId, slot: string) => Promise; +} { + const [busyKey, setBusyKey] = useState(null); + const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promiseExit" }); + const removeBinding = useAtomSet(removeSourceCredentialBinding, { mode: "promiseExit" }); + const errorMessage = + input.errorMessageFromExit ?? + ((_exit: Exit.Exit, fallback: string) => fallback); + + const setSecretBinding = async ( + targetScope: ScopeId, + slot: string, + secretId: string, + secretScope: ScopeId, + ) => { + const inputKey = `${targetScope}:${slot}`; + const trimmed = secretId.trim(); + if (!trimmed) return; + setBusyKey(inputKey); + input.onError(null); + const exit = await setBinding({ + params: { scopeId: input.displayScope }, + payload: SetSourceCredentialBindingInput.make({ + source: input.source, + scope: targetScope, + slotKey: slot, + value: { + kind: "secret", + secretId: SecretId.make(trimmed), + secretScopeId: secretScope, + }, + }), + reactivityKeys: sourceWriteKeys, + }); + if (Exit.isFailure(exit)) { + input.onError(errorMessage(exit, "Failed to save credential binding")); + } + setBusyKey(null); + }; + + const clearBinding = async (targetScope: ScopeId, slot: string) => { + setBusyKey(`${targetScope}:${slot}:clear`); + input.onError(null); + const exit = await removeBinding({ + params: { scopeId: input.displayScope }, + payload: RemoveSourceCredentialBindingInput.make({ + source: input.source, + scope: targetScope, + slotKey: slot, + }), + reactivityKeys: sourceWriteKeys, + }); + if (Exit.isFailure(exit)) { + input.onError(errorMessage(exit, "Failed to clear credential binding")); + } + setBusyKey(null); + }; + + return { busyKey, setSecretBinding, clearBinding }; +} diff --git a/packages/react/src/plugins/source-credential-status-core.ts b/packages/react/src/plugins/source-credential-status-core.ts index b1ee2bf17..19c8c24a5 100644 --- a/packages/react/src/plugins/source-credential-status-core.ts +++ b/packages/react/src/plugins/source-credential-status-core.ts @@ -15,7 +15,7 @@ export type SourceCredentialSlot = }; export type SourceCredentialBindingRef = { - readonly slot: string; + readonly slotKey: string; readonly scopeId: ScopeId; readonly value: CredentialBindingValue; }; @@ -31,7 +31,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 0cd61756c..f5f924c38 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", diff --git a/packages/react/src/plugins/source-oauth-connection.test.ts b/packages/react/src/plugins/source-oauth-connection.test.ts index 6d9f43b77..30dc02057 100644 --- a/packages/react/src/plugins/source-oauth-connection.test.ts +++ b/packages/react/src/plugins/source-oauth-connection.test.ts @@ -13,7 +13,7 @@ describe("source OAuth connection UI state", () => { sourceOAuthConnectionUiState({ bindings: [ { - slot: "auth:oauth2:connection", + slotKey: "auth:oauth2:connection", scopeId: organizationScope, value: { kind: "connection", connectionId }, }, From 7cbd012bd935f20bbe5b6e00f883ddfdd28e49c8 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 17:41:47 -0700 Subject: [PATCH 19/19] Fix GraphQL introspection response decoding --- packages/plugins/graphql/src/sdk/introspect.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/plugins/graphql/src/sdk/introspect.ts b/packages/plugins/graphql/src/sdk/introspect.ts index 771e4de30..bd9f7741f 100644 --- a/packages/plugins/graphql/src/sdk/introspect.ts +++ b/packages/plugins/graphql/src/sdk/introspect.ts @@ -84,37 +84,37 @@ const INTROSPECTION_QUERY = ` const IntrospectionTypeRefLeaf = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), - ofType: Schema.Null, + ofType: Schema.optional(Schema.Null), }); const IntrospectionTypeRef5 = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), - ofType: Schema.NullOr(IntrospectionTypeRefLeaf), + ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRefLeaf)), }); const IntrospectionTypeRef4 = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), - ofType: Schema.NullOr(IntrospectionTypeRef5), + ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef5)), }); const IntrospectionTypeRef3 = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), - ofType: Schema.NullOr(IntrospectionTypeRef4), + ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef4)), }); const IntrospectionTypeRef2 = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), - ofType: Schema.NullOr(IntrospectionTypeRef3), + ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef3)), }); const IntrospectionTypeRefSchema = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), - ofType: Schema.NullOr(IntrospectionTypeRef2), + ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef2)), }); const IntrospectionInputValueSchema = Schema.Struct({