From ff26f9782930840e2658fbdd328b2f3e6da869c6 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 13 May 2026 21:52:48 -0700 Subject: [PATCH] Share HTTP credential handling --- .../core/sdk/src/http-credentials.test.ts | 149 ++++++++++ packages/core/sdk/src/http-credentials.ts | 228 ++++++++++++++ packages/core/sdk/src/index.ts | 14 + packages/plugins/graphql/src/sdk/plugin.ts | 240 +++++---------- packages/plugins/mcp/src/sdk/plugin.ts | 277 +++++++----------- packages/plugins/openapi/src/sdk/plugin.ts | 259 +++++----------- 6 files changed, 642 insertions(+), 525 deletions(-) create mode 100644 packages/core/sdk/src/http-credentials.test.ts create mode 100644 packages/core/sdk/src/http-credentials.ts diff --git a/packages/core/sdk/src/http-credentials.test.ts b/packages/core/sdk/src/http-credentials.test.ts new file mode 100644 index 000000000..442e1433f --- /dev/null +++ b/packages/core/sdk/src/http-credentials.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect } from "effect"; + +import { CredentialBindingRef } from "./credential-bindings"; +import { prepareHttpCredentialMap, resolveConfiguredHttpCredentialMap } from "./http-credentials"; +import { ConnectionId, CredentialBindingId, ScopeId, SecretId } from "./ids"; + +class TestCredentialError extends Data.TaggedError("TestCredentialError")<{ + readonly message: string; +}> {} + +describe("http credential helpers", () => { + it.effect("prepares direct secret inputs into configured credential bindings", () => + Effect.sync(() => { + const prepared = prepareHttpCredentialMap({ + values: { + Authorization: { + secretId: "api-token", + prefix: "Bearer ", + targetScope: ScopeId.make("user"), + secretScopeId: ScopeId.make("org"), + }, + "X-Static": "static", + "X-Slot": { kind: "binding", slot: "header:x-slot" }, + }, + slotForName: (name) => `header:${name.toLowerCase()}`, + }); + + expect(prepared.values).toEqual({ + Authorization: { + kind: "binding", + slot: "header:authorization", + prefix: "Bearer ", + }, + "X-Static": "static", + "X-Slot": { kind: "binding", slot: "header:x-slot" }, + }); + expect(prepared.bindings).toEqual([ + { + slotKey: "header:authorization", + targetScope: ScopeId.make("user"), + value: { + kind: "secret", + secretId: SecretId.make("api-token"), + secretScopeId: ScopeId.make("org"), + }, + }, + ]); + }), + ); + + it.effect("resolves configured text and secret bindings", () => + Effect.gen(function* () { + const now = new Date("2026-01-01T00:00:00.000Z"); + const bindings = [ + CredentialBindingRef.make({ + id: CredentialBindingId.make("secret-binding"), + pluginId: "test", + sourceId: "source", + sourceScopeId: ScopeId.make("org"), + scopeId: ScopeId.make("user"), + slotKey: "header:authorization", + value: { kind: "secret", secretId: SecretId.make("api-token") }, + createdAt: now, + updatedAt: now, + }), + CredentialBindingRef.make({ + id: CredentialBindingId.make("text-binding"), + pluginId: "test", + sourceId: "source", + sourceScopeId: ScopeId.make("org"), + scopeId: ScopeId.make("user"), + slotKey: "query_param:mode", + value: { kind: "text", text: "fast" }, + createdAt: now, + updatedAt: now, + }), + ]; + + const resolved = yield* resolveConfiguredHttpCredentialMap({ + credentialBindings: { + listForSource: () => Effect.succeed(bindings), + }, + pluginId: "test", + sourceId: "source", + sourceScope: ScopeId.make("org"), + values: { + Authorization: { + kind: "binding", + slot: "header:authorization", + prefix: "Bearer ", + }, + mode: { kind: "binding", slot: "query_param:mode" }, + }, + getSecretAtScope: (secretId, scopeId) => + Effect.succeed( + secretId === SecretId.make("api-token") && scopeId === ScopeId.make("user") + ? "token" + : null, + ), + onMissingBinding: (name) => new TestCredentialError({ message: `missing binding ${name}` }), + onMissingSecret: (name) => new TestCredentialError({ message: `missing secret ${name}` }), + }); + + expect(resolved).toEqual({ + Authorization: "Bearer token", + mode: "fast", + }); + }), + ); + + it.effect("treats connection bindings as missing for HTTP credential values", () => + Effect.gen(function* () { + const now = new Date("2026-01-01T00:00:00.000Z"); + const failure = yield* resolveConfiguredHttpCredentialMap({ + credentialBindings: { + listForSource: () => + Effect.succeed([ + CredentialBindingRef.make({ + id: CredentialBindingId.make("connection-binding"), + pluginId: "test", + sourceId: "source", + sourceScopeId: ScopeId.make("org"), + scopeId: ScopeId.make("user"), + slotKey: "header:authorization", + value: { + kind: "connection", + connectionId: ConnectionId.make("conn"), + }, + createdAt: now, + updatedAt: now, + }), + ]), + }, + pluginId: "test", + sourceId: "source", + sourceScope: ScopeId.make("org"), + values: { + Authorization: { kind: "binding", slot: "header:authorization" }, + }, + getSecretAtScope: () => Effect.succeed(null), + onMissingBinding: (name) => new TestCredentialError({ message: `missing binding ${name}` }), + onMissingSecret: (name) => new TestCredentialError({ message: `missing secret ${name}` }), + }).pipe(Effect.flip); + + expect(failure.message).toBe("missing binding Authorization"); + }), + ); +}); diff --git a/packages/core/sdk/src/http-credentials.ts b/packages/core/sdk/src/http-credentials.ts new file mode 100644 index 000000000..1ef143f20 --- /dev/null +++ b/packages/core/sdk/src/http-credentials.ts @@ -0,0 +1,228 @@ +import { Effect } from "effect"; + +import type { StorageFailure } from "@executor-js/storage-core"; + +import { + ConfiguredCredentialBinding, + type ConfiguredCredentialValue, + type CredentialBindingRef, + type CredentialBindingsFacade, + type CredentialBindingValue, +} from "./credential-bindings"; +import { ScopeId, SecretId } from "./ids"; + +export type HttpCredentialInput = ConfiguredCredentialValue | DirectHttpSecretCredentialInput; + +export interface DirectHttpSecretCredentialInput { + readonly secretId: string; + readonly prefix?: string; + readonly targetScope?: string; + readonly secretScopeId?: string; +} + +export interface PreparedHttpCredentialBinding { + readonly slotKey: string; + readonly value: CredentialBindingValue; + readonly targetScope?: ScopeId; +} + +export interface PreparedHttpCredentialMap { + readonly values: Record; + readonly bindings: readonly PreparedHttpCredentialBinding[]; +} + +const scopeId = (scope: ScopeId | string): ScopeId => ScopeId.make(String(scope)); + +const isConfiguredBinding = (value: HttpCredentialInput): value is ConfiguredCredentialBinding => + typeof value === "object" && value !== null && "kind" in value && value.kind === "binding"; + +const isDirectHttpSecretCredentialInput = ( + value: HttpCredentialInput, +): value is DirectHttpSecretCredentialInput => + typeof value === "object" && value !== null && "secretId" in value; + +export const prepareHttpCredentialMap = (options: { + readonly values: Record | undefined; + readonly slotForName: (name: string) => string; +}): PreparedHttpCredentialMap => { + const values: Record = {}; + const bindings: PreparedHttpCredentialBinding[] = []; + + for (const [name, value] of Object.entries(options.values ?? {})) { + if (typeof value === "string") { + values[name] = value; + continue; + } + + if (isConfiguredBinding(value)) { + values[name] = value; + continue; + } + + if (!isDirectHttpSecretCredentialInput(value)) continue; + + const slotKey = options.slotForName(name); + values[name] = ConfiguredCredentialBinding.make({ + kind: "binding", + slot: slotKey, + prefix: value.prefix, + }); + bindings.push({ + slotKey, + targetScope: + "targetScope" in value && value.targetScope ? scopeId(value.targetScope) : undefined, + value: { + kind: "secret", + secretId: SecretId.make(value.secretId), + ...("secretScopeId" in value && value.secretScopeId + ? { secretScopeId: scopeId(value.secretScopeId) } + : {}), + }, + }); + } + + return { values, bindings }; +}; + +export const resolveSourceCredentialBinding = (options: { + readonly credentialBindings: Pick; + readonly pluginId: string; + readonly sourceId: string; + readonly sourceScope: ScopeId | string; + readonly slotKey: string; +}): Effect.Effect => + Effect.gen(function* () { + const bindings = yield* options.credentialBindings.listForSource({ + pluginId: options.pluginId, + sourceId: options.sourceId, + sourceScope: scopeId(options.sourceScope), + }); + return bindings.find((binding) => binding.slotKey === options.slotKey) ?? null; + }); + +export type SecretCredentialBindingRef = Omit & { + readonly value: Extract; +}; + +export const resolveConfiguredHttpCredentialMap = (options: { + readonly credentialBindings: Pick; + readonly pluginId: string; + readonly sourceId: string; + readonly sourceScope: ScopeId | string; + readonly values: Record | undefined; + readonly empty?: "undefined" | "record"; + readonly getSecretAtScope: ( + secretId: SecretId, + scopeId: ScopeId, + context: { + readonly name: string; + readonly binding: SecretCredentialBindingRef; + }, + ) => Effect.Effect; + readonly onMissingBinding: (name: string, value: ConfiguredCredentialBinding) => PluginError; + readonly onMissingSecret: (name: string, binding: SecretCredentialBindingRef) => PluginError; +}): Effect.Effect | undefined, SecretError | PluginError | StorageFailure> => + Effect.gen(function* () { + const entries = Object.entries(options.values ?? {}); + if (entries.length === 0) { + return options.empty === "record" ? {} : undefined; + } + + const resolved: Record = {}; + for (const [name, value] of entries) { + if (typeof value === "string") { + resolved[name] = value; + continue; + } + + const binding = yield* resolveSourceCredentialBinding({ + credentialBindings: options.credentialBindings, + pluginId: options.pluginId, + sourceId: options.sourceId, + sourceScope: options.sourceScope, + slotKey: value.slot, + }); + if (binding?.value.kind === "secret") { + const secretBinding = binding as SecretCredentialBindingRef; + const secret = yield* options.getSecretAtScope( + secretBinding.value.secretId, + secretBinding.value.secretScopeId ?? secretBinding.scopeId, + { name, binding: secretBinding }, + ); + if (secret === null) { + return yield* Effect.fail(options.onMissingSecret(name, secretBinding)); + } + resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; + continue; + } + + if (binding?.value.kind === "text") { + resolved[name] = value.prefix ? `${value.prefix}${binding.value.text}` : binding.value.text; + continue; + } + + return yield* Effect.fail(options.onMissingBinding(name, value)); + } + + return Object.keys(resolved).length > 0 || options.empty === "record" ? resolved : undefined; + }); + +export const targetScopeForPreparedHttpCredentialBinding = ( + fallbackTargetScope: ScopeId | string | undefined, + binding: PreparedHttpCredentialBinding, + onMissing: (binding: PreparedHttpCredentialBinding) => E, +): Effect.Effect => { + const targetScope = binding.targetScope ?? fallbackTargetScope; + return targetScope ? Effect.succeed(scopeId(targetScope)) : Effect.fail(onMissing(binding)); +}; + +export const setPreparedHttpCredentialBindings = (options: { + readonly credentialBindings: Pick; + readonly pluginId: string; + readonly sourceId: string; + readonly sourceScope: ScopeId | string; + readonly fallbackTargetScope?: ScopeId | string; + readonly bindings: readonly PreparedHttpCredentialBinding[]; + readonly onMissingTargetScope: (binding: PreparedHttpCredentialBinding) => E; +}): Effect.Effect => + Effect.forEach( + options.bindings, + (binding) => + Effect.gen(function* () { + const targetScope = yield* targetScopeForPreparedHttpCredentialBinding( + options.fallbackTargetScope, + binding, + options.onMissingTargetScope, + ); + yield* options.credentialBindings.set({ + targetScope, + pluginId: options.pluginId, + sourceId: options.sourceId, + sourceScope: scopeId(options.sourceScope), + slotKey: binding.slotKey, + value: binding.value, + }); + }), + { discard: true }, + ); + +export const replacePreparedHttpCredentialBindingsForSource = (options: { + readonly credentialBindings: Pick; + readonly pluginId: string; + readonly sourceId: string; + readonly sourceScope: ScopeId | string; + readonly targetScope: ScopeId | string; + readonly slotPrefixes: readonly string[]; + readonly bindings: readonly PreparedHttpCredentialBinding[]; +}): Effect.Effect => + options.credentialBindings.replaceForSource({ + targetScope: scopeId(options.targetScope), + pluginId: options.pluginId, + sourceId: options.sourceId, + sourceScope: scopeId(options.sourceScope), + slotPrefixes: [...options.slotPrefixes], + bindings: options.bindings.map((binding) => ({ + slotKey: binding.slotKey, + value: binding.value, + })), + }); diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index e84082501..f917b82ac 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -147,6 +147,20 @@ export { type CredentialBindingsFacade, } from "./credential-bindings"; +export { + prepareHttpCredentialMap, + replacePreparedHttpCredentialBindingsForSource, + resolveConfiguredHttpCredentialMap, + resolveSourceCredentialBinding, + setPreparedHttpCredentialBindings, + targetScopeForPreparedHttpCredentialBinding, + type DirectHttpSecretCredentialInput, + type HttpCredentialInput, + type PreparedHttpCredentialBinding, + type PreparedHttpCredentialMap, + type SecretCredentialBindingRef, +} from "./http-credentials"; + // Usage tracking — secret/connection refs across plugins export { Usage, type UsagesForSecretInput, type UsagesForConnectionInput } from "./usages"; diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 3f8d86d0b..43f9cb832 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -4,15 +4,18 @@ import { HttpClient } from "effect/unstable/http"; import { ConnectionId, - ConfiguredCredentialBinding, - type CredentialBindingRef, - type CredentialBindingValue, definePlugin, + prepareHttpCredentialMap, + replacePreparedHttpCredentialBindingsForSource, + resolveConfiguredHttpCredentialMap, + resolveSourceCredentialBinding, + setPreparedHttpCredentialBindings, tool, ScopeId, SecretId, SourceDetectionResult, StorageError, + type PreparedHttpCredentialBinding, type PluginCtx, type StorageFailure, type ToolAnnotations, @@ -327,30 +330,6 @@ const scopeRanks = (ctx: PluginCtx): ReadonlyMap = const scopeRank = (ranks: ReadonlyMap, scopeId: string): number => ranks.get(scopeId) ?? Infinity; -const resolveGraphqlCredentialBinding = ( - 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: GRAPHQL_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 ?? null; - }); - const validateGraphqlBindingTarget = ( ctx: PluginCtx, input: { @@ -417,62 +396,11 @@ const targetScopeForBinding = ( ); }; -const canonicalizeCredentialMap = ( - values: Record | undefined, - slotForName: (name: string) => string, -): { - readonly values: Record; - readonly bindings: ReadonlyArray<{ - readonly slot: string; - readonly value: CredentialBindingValue; - readonly targetScope?: string; - }>; -} => { - const nextValues: Record = {}; - const bindings: Array<{ - slot: string; - value: CredentialBindingValue; - targetScope?: string; - }> = []; - for (const [name, value] of Object.entries(values ?? {})) { - if (typeof value === "string") { - nextValues[name] = value; - continue; - } - if ("kind" in value) { - nextValues[name] = 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: value.secretScopeId } - : {}), - }, - }); - } - return { values: nextValues, bindings }; -}; - const canonicalizeAuth = ( auth: GraphqlSourceAuthInput | undefined, ): { readonly auth: GraphqlSourceAuth; - readonly bindings: ReadonlyArray<{ - readonly slot: string; - readonly value: CredentialBindingValue; - readonly targetScope?: string; - }>; + readonly bindings: readonly PreparedHttpCredentialBinding[]; } => { if (!auth || auth.kind === "none") return { auth: { kind: "none" }, bindings: [] }; if ("connectionSlot" in auth) return { auth, bindings: [] }; @@ -480,7 +408,7 @@ const canonicalizeAuth = ( auth: { kind: "oauth2", connectionSlot: GRAPHQL_OAUTH_CONNECTION_SLOT }, bindings: [ { - slot: GRAPHQL_OAUTH_CONNECTION_SLOT, + slotKey: GRAPHQL_OAUTH_CONNECTION_SLOT, value: { kind: "connection", connectionId: ConnectionId.make(auth.connectionId), @@ -490,7 +418,7 @@ const canonicalizeAuth = ( }; }; -const resolveGraphqlBindingValueMap = ( +const resolveConfiguredGraphqlCredentialMap = ( ctx: PluginCtx, values: Record | undefined, params: { @@ -500,49 +428,26 @@ const resolveGraphqlBindingValueMap = ( 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; - } - const binding = yield* resolveGraphqlCredentialBinding( - ctx, - params.sourceId, - params.sourceScope, - value.slot, - ); - if (binding?.value.kind === "secret") { - const secret = yield* ctx.secrets - .getAtScope(binding.value.secretId, binding.scopeId) - .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 "${binding.value.secretId}" for ${params.missingLabel} "${name}"`, - ), - ); - } - resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; - continue; - } - if (binding?.value.kind === "text") { - resolved[name] = value.prefix ? `${value.prefix}${binding.value.text}` : binding.value.text; - continue; - } - return yield* Effect.fail( - params.makeError(`Missing binding for ${params.missingLabel} "${name}"`), - ); - } - return Object.keys(resolved).length > 0 ? resolved : undefined; + resolveConfiguredHttpCredentialMap({ + credentialBindings: ctx.credentialBindings, + pluginId: GRAPHQL_PLUGIN_ID, + sourceId: params.sourceId, + sourceScope: params.sourceScope, + values, + getSecretAtScope: (secretId, scope, { name }) => + ctx.secrets + .getAtScope(secretId, scope) + .pipe( + Effect.catchTag("SecretOwnedByConnectionError", () => + Effect.fail(params.makeError(`Secret not found for ${params.missingLabel} "${name}"`)), + ), + ), + onMissingBinding: (name) => + params.makeError(`Missing binding for ${params.missingLabel} "${name}"`), + onMissingSecret: (name, binding) => + params.makeError( + `Missing secret "${binding.value.secretId}" for ${params.missingLabel} "${name}"`, + ), }); const resolveGraphqlStoredOAuthHeader = ( @@ -553,12 +458,13 @@ const resolveGraphqlStoredOAuthHeader = ( ) => Effect.gen(function* () { if (!auth || auth.kind === "none") return undefined; - const binding = yield* resolveGraphqlCredentialBinding( - ctx, + const binding = yield* resolveSourceCredentialBinding({ + credentialBindings: ctx.credentialBindings, + pluginId: GRAPHQL_PLUGIN_ID, sourceId, sourceScope, - auth.connectionSlot, - ); + slotKey: auth.connectionSlot, + }); if (binding?.value.kind !== "connection") { return yield* new GraphqlInvocationError({ message: `Missing OAuth connection binding for GraphQL source "${sourceId}"`, @@ -596,7 +502,7 @@ const makeGraphqlExtension = ( continue; } if ("kind" in value) { - const slotResolved = yield* resolveGraphqlBindingValueMap( + const slotResolved = yield* resolveConfiguredGraphqlCredentialMap( ctx, { [name]: value }, { @@ -646,12 +552,13 @@ const makeGraphqlExtension = ( "connectionId" in auth ? { id: auth.connectionId, scope: targetScope ?? sourceScope } : yield* Effect.gen(function* () { - const binding = yield* resolveGraphqlCredentialBinding( - ctx, + const binding = yield* resolveSourceCredentialBinding({ + credentialBindings: ctx.credentialBindings, + pluginId: GRAPHQL_PLUGIN_ID, sourceId, sourceScope, - auth.connectionSlot, - ); + slotKey: auth.connectionSlot, + }); return binding?.value.kind === "connection" ? { id: binding.value.connectionId, scope: binding.scopeId } : null; @@ -678,11 +585,14 @@ const makeGraphqlExtension = ( ctx.transaction( Effect.gen(function* () { const namespace = config.namespace ?? namespaceFromEndpoint(config.endpoint); - const canonicalHeaders = canonicalizeCredentialMap(config.headers, graphqlHeaderSlot); - const canonicalQueryParams = canonicalizeCredentialMap( - config.queryParams, - graphqlQueryParamSlot, - ); + const canonicalHeaders = prepareHttpCredentialMap({ + values: config.headers, + slotForName: graphqlHeaderSlot, + }); + const canonicalQueryParams = prepareHttpCredentialMap({ + values: config.queryParams, + slotForName: graphqlQueryParamSlot, + }); const canonicalAuth = canonicalizeAuth(config.auth); const directBindings = [ ...canonicalHeaders.bindings, @@ -783,22 +693,18 @@ 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), - pluginId: GRAPHQL_PLUGIN_ID, - sourceId: namespace, - sourceScope: ScopeId.make(config.scope), - slotKey: binding.slot, - value: binding.value, - }); - } - } + yield* setPreparedHttpCredentialBindings({ + credentialBindings: ctx.credentialBindings, + pluginId: GRAPHQL_PLUGIN_ID, + sourceId: namespace, + sourceScope: config.scope, + fallbackTargetScope: config.credentialTargetScope, + bindings: directBindings, + onMissingTargetScope: () => + new GraphqlIntrospectionError({ + message: "credentialTargetScope is required when adding direct GraphQL credentials", + }), + }); return { toolCount: prepared.length, namespace }; }), @@ -840,11 +746,17 @@ const makeGraphqlExtension = ( if (!existing) return; const canonicalHeaders = input.headers !== undefined - ? canonicalizeCredentialMap(input.headers, graphqlHeaderSlot) + ? prepareHttpCredentialMap({ + values: input.headers, + slotForName: graphqlHeaderSlot, + }) : null; const canonicalQueryParams = input.queryParams !== undefined - ? canonicalizeCredentialMap(input.queryParams, graphqlQueryParamSlot) + ? prepareHttpCredentialMap({ + values: input.queryParams, + slotForName: graphqlQueryParamSlot, + }) : null; const canonicalAuth = input.auth !== undefined ? canonicalizeAuth(input.auth) : null; const directBindings = [ @@ -876,16 +788,14 @@ const makeGraphqlExtension = ( auth: canonicalAuth?.auth, }); if (affectedPrefixes.length > 0 || directBindings.length > 0) { - yield* ctx.credentialBindings.replaceForSource({ - targetScope: ScopeId.make(replacementTargetScope), + yield* replacePreparedHttpCredentialBindingsForSource({ + credentialBindings: ctx.credentialBindings, + targetScope: replacementTargetScope, pluginId: GRAPHQL_PLUGIN_ID, sourceId: namespace, - sourceScope: ScopeId.make(scope), + sourceScope: scope, slotPrefixes: affectedPrefixes, - bindings: directBindings.map((binding) => ({ - slotKey: binding.slot, - value: binding.value, - })), + bindings: directBindings, }); } }), @@ -956,7 +866,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { } const resolvedHeaders = - (yield* resolveGraphqlBindingValueMap(ctx, source.headers, { + (yield* resolveConfiguredGraphqlCredentialMap(ctx, source.headers, { sourceId: source.namespace, sourceScope: source.scope, missingLabel: "header", @@ -964,7 +874,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { new GraphqlInvocationError({ message, statusCode: Option.none() }), })) ?? {}; const resolvedQueryParams = - (yield* resolveGraphqlBindingValueMap(ctx, source.queryParams, { + (yield* resolveConfiguredGraphqlCredentialMap(ctx, source.queryParams, { sourceId: source.namespace, sourceScope: source.scope, missingLabel: "query parameter", diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 80f82759c..8d11d7799 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -15,15 +15,20 @@ import type { HttpClient } from "effect/unstable/http"; import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; import { - ConfiguredCredentialBinding, ConnectionId, type CredentialBindingRef, type CredentialBindingValue, + prepareHttpCredentialMap, + replacePreparedHttpCredentialBindingsForSource, + resolveConfiguredHttpCredentialMap, + resolveSourceCredentialBinding, ScopeId, SecretId, + setPreparedHttpCredentialBindings, SourceDetectionResult, definePlugin, resolveSecretBackedMap as resolveSharedSecretBackedMap, + type PreparedHttpCredentialBinding, type PluginCtx, type StorageFailure, StorageError, @@ -232,30 +237,6 @@ const scopeRanks = (ctx: PluginCtx): ReadonlyMap, scopeId: string): number => ranks.get(scopeId) ?? Infinity; -const resolveMcpCredentialBinding = ( - 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: MCP_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 ?? null; - }); - const validateMcpBindingTarget = ( ctx: PluginCtx, input: { @@ -312,10 +293,10 @@ const bindingTargetScope = ( const targetScopeForBinding = ( fallbackTargetScope: string | undefined, - binding: { readonly targetScope?: string }, + binding: PreparedHttpCredentialBinding, ): Effect.Effect => { const targetScope = binding.targetScope ?? fallbackTargetScope; - if (targetScope) return Effect.succeed(targetScope); + if (targetScope) return Effect.succeed(String(targetScope)); return Effect.fail( new McpConnectionError({ transport: "remote", @@ -324,58 +305,11 @@ const targetScopeForBinding = ( ); }; -const canonicalizeCredentialMap = ( - values: Record | undefined, - slotForName: (name: string) => string, -): { - readonly values: Record; - readonly bindings: ReadonlyArray<{ - readonly slot: string; - readonly value: CredentialBindingValue; - readonly targetScope?: string; - }>; -} => { - const nextValues: Record = {}; - const bindings: Array<{ slot: string; value: CredentialBindingValue; targetScope?: string }> = []; - for (const [name, value] of Object.entries(values ?? {})) { - if (typeof value === "string") { - nextValues[name] = value; - continue; - } - if ("kind" in value) { - nextValues[name] = 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: value.secretScopeId } - : {}), - }, - }); - } - return { values: nextValues, bindings }; -}; - const canonicalizeAuth = ( auth: McpConnectionAuthInput | undefined, ): { readonly auth: McpConnectionAuth; - readonly bindings: ReadonlyArray<{ - readonly slot: string; - readonly value: CredentialBindingValue; - readonly targetScope?: string; - }>; + readonly bindings: readonly PreparedHttpCredentialBinding[]; } => { if (!auth || auth.kind === "none") return { auth: { kind: "none" }, bindings: [] }; if (auth.kind === "header") { @@ -389,7 +323,7 @@ const canonicalizeAuth = ( }, bindings: [ { - slot: MCP_HEADER_AUTH_SLOT, + slotKey: MCP_HEADER_AUTH_SLOT, targetScope: auth.targetScope, value: { kind: "secret", @@ -401,9 +335,9 @@ const canonicalizeAuth = ( }; } if ("connectionSlot" in auth) return { auth, bindings: [] }; - const bindings: Array<{ slot: string; value: CredentialBindingValue; targetScope?: string }> = [ + const bindings: PreparedHttpCredentialBinding[] = [ { - slot: MCP_OAUTH_CONNECTION_SLOT, + slotKey: MCP_OAUTH_CONNECTION_SLOT, value: { kind: "connection", connectionId: ConnectionId.make(auth.connectionId), @@ -412,13 +346,13 @@ const canonicalizeAuth = ( ]; if (auth.clientIdSecretId) { bindings.push({ - slot: MCP_OAUTH_CLIENT_ID_SLOT, + slotKey: MCP_OAUTH_CLIENT_ID_SLOT, value: { kind: "secret", secretId: SecretId.make(auth.clientIdSecretId) }, }); } if (auth.clientSecretSecretId) { bindings.push({ - slot: MCP_OAUTH_CLIENT_SECRET_SLOT, + slotKey: MCP_OAUTH_CLIENT_SECRET_SLOT, value: { kind: "secret", secretId: SecretId.make(auth.clientSecretSecretId) }, }); } @@ -512,60 +446,42 @@ const plainStringMap = ( return entries.length > 0 ? Object.fromEntries(entries) : undefined; }; -const resolveMcpBindingValueMap = ( +const resolveConfiguredMcpCredentialMap = ( 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; - } - const binding = yield* resolveMcpCredentialBinding( - ctx, - params.sourceId, - params.sourceScope, - value.slot, - ); - if (binding?.value.kind === "secret") { - const secret = yield* ctx.secrets.getAtScope(binding.value.secretId, binding.scopeId).pipe( - Effect.catchTag("SecretOwnedByConnectionError", () => - Effect.fail( - new McpConnectionError({ - transport: "remote", - message: `Failed to resolve secret for ${params.missingLabel} "${name}"`, - }), - ), + resolveConfiguredHttpCredentialMap({ + credentialBindings: ctx.credentialBindings, + pluginId: MCP_PLUGIN_ID, + sourceId: params.sourceId, + sourceScope: params.sourceScope, + values, + getSecretAtScope: (secretId, scope, { name }) => + ctx.secrets.getAtScope(secretId, scope).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 "${binding.value.secretId}" for ${params.missingLabel} "${name}"`, - }); - } - resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; - continue; - } - if (binding?.value.kind === "text") { - resolved[name] = value.prefix ? `${value.prefix}${binding.value.text}` : binding.value.text; - continue; - } - return yield* new McpConnectionError({ + ), + ), + onMissingBinding: (name) => + new McpConnectionError({ transport: "remote", message: `Missing binding for ${params.missingLabel} "${name}"`, - }); - } - return Object.keys(resolved).length > 0 ? resolved : undefined; + }), + onMissingSecret: (name, binding) => + new McpConnectionError({ + transport: "remote", + message: `Missing secret "${binding.value.secretId}" for ${params.missingLabel} "${name}"`, + }), }); const resolveMcpCredentialInputMap = ( @@ -587,7 +503,7 @@ const resolveMcpCredentialInputMap = ( continue; } if ("kind" in value) { - const slotResolved = yield* resolveMcpBindingValueMap( + const slotResolved = yield* resolveConfiguredMcpCredentialMap( ctx, { [name]: value }, { @@ -632,18 +548,26 @@ const resolveMcpHeaderAuth = ( ): Effect.Effect, McpConnectionError | StorageFailure> => Effect.gen(function* () { if (auth.kind !== "header") return {}; - const binding = yield* resolveMcpCredentialBinding(ctx, sourceId, sourceScope, auth.secretSlot); + const binding = yield* resolveSourceCredentialBinding({ + credentialBindings: ctx.credentialBindings, + pluginId: MCP_PLUGIN_ID, + sourceId, + sourceScope, + slotKey: auth.secretSlot, + }); if (binding?.value.kind === "secret") { - const secret = yield* ctx.secrets.getAtScope(binding.value.secretId, binding.scopeId).pipe( - Effect.catchTag("SecretOwnedByConnectionError", () => - Effect.fail( - new McpConnectionError({ - transport: "remote", - message: `Failed to resolve header auth binding "${auth.secretSlot}"`, - }), + const secret = yield* ctx.secrets + .getAtScope(binding.value.secretId, binding.value.secretScopeId ?? binding.scopeId) + .pipe( + Effect.catchTag("SecretOwnedByConnectionError", () => + Effect.fail( + new McpConnectionError({ + transport: "remote", + message: `Failed to resolve header auth binding "${auth.secretSlot}"`, + }), + ), ), - ), - ); + ); if (secret === null) { return yield* new McpConnectionError({ transport: "remote", @@ -671,12 +595,13 @@ const resolveMcpStoredOauthProvider = ( ): Effect.Effect => Effect.gen(function* () { if (auth.kind !== "oauth2") return undefined; - const binding = yield* resolveMcpCredentialBinding( - ctx, + const binding = yield* resolveSourceCredentialBinding({ + credentialBindings: ctx.credentialBindings, + pluginId: MCP_PLUGIN_ID, sourceId, sourceScope, - auth.connectionSlot, - ); + slotKey: auth.connectionSlot, + }); if (binding?.value.kind !== "connection") { return yield* new McpConnectionError({ transport: "remote", @@ -740,12 +665,13 @@ const resolveMcpInputAuth = ( "connectionId" in auth ? { id: ConnectionId.make(auth.connectionId), scope: targetScope ?? sourceScope } : yield* Effect.gen(function* () { - const binding = yield* resolveMcpCredentialBinding( - ctx, + const binding = yield* resolveSourceCredentialBinding({ + credentialBindings: ctx.credentialBindings, + pluginId: MCP_PLUGIN_ID, sourceId, sourceScope, - auth.connectionSlot, - ); + slotKey: auth.connectionSlot, + }); return binding?.value.kind === "connection" ? { id: binding.value.connectionId, scope: binding.scopeId } : null; @@ -801,12 +727,12 @@ const resolveConnectorInput = ( } return Effect.gen(function* () { - const resolvedHeaders = yield* resolveMcpBindingValueMap(ctx, sd.headers, { + const resolvedHeaders = yield* resolveConfiguredMcpCredentialMap(ctx, sd.headers, { sourceId, sourceScope, missingLabel: "header", }); - const resolvedQueryParams = yield* resolveMcpBindingValueMap(ctx, sd.queryParams, { + const resolvedQueryParams = yield* resolveConfiguredMcpCredentialMap(ctx, sd.queryParams, { sourceId, sourceScope, missingLabel: "query parameter", @@ -1191,8 +1117,14 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { const canonicalRemote = config.transport === "remote" ? { - headers: canonicalizeCredentialMap(config.headers, mcpHeaderSlot), - queryParams: canonicalizeCredentialMap(config.queryParams, mcpQueryParamSlot), + headers: prepareHttpCredentialMap({ + values: config.headers, + slotForName: mcpHeaderSlot, + }), + queryParams: prepareHttpCredentialMap({ + values: config.queryParams, + slotForName: mcpQueryParamSlot, + }), auth: canonicalizeAuth(config.auth), } : null; @@ -1360,22 +1292,21 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { })), }); - 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, - }); - } - } + yield* setPreparedHttpCredentialBindings({ + credentialBindings: ctx.credentialBindings, + pluginId: MCP_PLUGIN_ID, + sourceId: namespace, + sourceScope: config.scope, + fallbackTargetScope: + config.transport === "remote" ? config.credentialTargetScope : undefined, + bindings: directBindings, + onMissingTargetScope: () => + new McpConnectionError({ + transport: "remote", + message: + "credentialTargetScope is required when adding direct MCP credentials", + }), + }); }), ) .pipe( @@ -1526,11 +1457,17 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { const canonicalHeaders = input.headers !== undefined - ? canonicalizeCredentialMap(input.headers, mcpHeaderSlot) + ? prepareHttpCredentialMap({ + values: input.headers, + slotForName: mcpHeaderSlot, + }) : null; const canonicalQueryParams = input.queryParams !== undefined - ? canonicalizeCredentialMap(input.queryParams, mcpQueryParamSlot) + ? prepareHttpCredentialMap({ + values: input.queryParams, + slotForName: mcpQueryParamSlot, + }) : null; const canonicalAuth = input.auth !== undefined ? canonicalizeAuth(input.auth) : null; const directBindings = [ @@ -1575,16 +1512,14 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { config: updatedConfig, }); if (affectedPrefixes.length > 0 || directBindings.length > 0) { - yield* ctx.credentialBindings.replaceForSource({ - targetScope: ScopeId.make(replacementTargetScope), + yield* replacePreparedHttpCredentialBindingsForSource({ + credentialBindings: ctx.credentialBindings, + targetScope: replacementTargetScope, pluginId: MCP_PLUGIN_ID, sourceId: namespace, - sourceScope: ScopeId.make(scope), + sourceScope: scope, slotPrefixes: affectedPrefixes, - bindings: directBindings.map((binding) => ({ - slotKey: binding.slot, - value: binding.value, - })), + bindings: directBindings, }); } }), diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 4119bdc06..1eff2ec0f 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -4,14 +4,17 @@ import { HttpClient } from "effect/unstable/http"; import { ScopeId, - SecretId, SourceDetectionResult, StorageError, definePlugin, + prepareHttpCredentialMap, + replacePreparedHttpCredentialBindingsForSource, + resolveConfiguredHttpCredentialMap, tool, + resolveSourceCredentialBinding, resolveSecretBackedMap, - type CredentialBindingRef, - type CredentialBindingValue, + setPreparedHttpCredentialBindings, + type PreparedHttpCredentialBinding, type PluginCtx, type StorageFailure, type ToolAnnotations, @@ -42,7 +45,6 @@ import { import { HeaderValue as HeaderValueSchema, ConfiguredHeaderValue as ConfiguredHeaderValueSchema, - ConfiguredHeaderBinding, OAuth2SourceConfig, OpenApiCredentialInput as OpenApiCredentialInputSchema, type OpenApiCredentialInput as OpenApiCredentialInputValue, @@ -262,46 +264,13 @@ const canonicalizeHeaders = ( headers: Record | undefined, ): { readonly headers: Record; - readonly bindings: ReadonlyArray<{ - readonly slot: string; - readonly value: CredentialBindingValue; - readonly targetScope?: string; - }>; + readonly bindings: readonly PreparedHttpCredentialBinding[]; } => { - const nextHeaders: Record = {}; - const bindings: Array<{ - slot: string; - value: CredentialBindingValue; - targetScope?: string; - }> = []; - for (const [name, value] of Object.entries(headers ?? {})) { - if (typeof value === "string") { - nextHeaders[name] = value; - continue; - } - if ("kind" in value) { - nextHeaders[name] = value; - continue; - } - const slot = headerSlotFromName(name); - nextHeaders[name] = ConfiguredHeaderBinding.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: value.secretScopeId } - : {}), - }, - }); - } - return { headers: nextHeaders, bindings }; + const prepared = prepareHttpCredentialMap({ + values: headers, + slotForName: headerSlotFromName, + }); + return { headers: prepared.values, bindings: prepared.bindings }; }; const canonicalizeCredentialMap = ( @@ -309,47 +278,12 @@ const canonicalizeCredentialMap = ( slotForName: (name: string) => string, ): { readonly values: Record; - readonly bindings: ReadonlyArray<{ - readonly slot: string; - readonly value: CredentialBindingValue; - readonly targetScope?: string; - }>; -} => { - const nextValues: Record = {}; - const bindings: Array<{ - slot: string; - value: CredentialBindingValue; - targetScope?: string; - }> = []; - for (const [name, value] of Object.entries(values ?? {})) { - if (typeof value === "string") { - nextValues[name] = value; - continue; - } - if ("kind" in value) { - nextValues[name] = value; - continue; - } - const slot = slotForName(name); - nextValues[name] = ConfiguredHeaderBinding.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: value.secretScopeId } - : {}), - }, - }); - } - return { values: nextValues, bindings }; -}; + readonly bindings: readonly PreparedHttpCredentialBinding[]; +} => + prepareHttpCredentialMap({ + values, + slotForName, + }); const canonicalizeSpecFetchCredentials = ( credentials: @@ -360,11 +294,7 @@ const canonicalizeSpecFetchCredentials = ( | undefined, ): { readonly credentials?: SourceConfig["specFetchCredentials"]; - readonly bindings: ReadonlyArray<{ - readonly slot: string; - readonly value: CredentialBindingValue; - readonly targetScope?: string; - }>; + readonly bindings: readonly PreparedHttpCredentialBinding[]; } => { const headers = canonicalizeCredentialMap(credentials?.headers, specFetchHeaderSlotFromName); const queryParams = canonicalizeCredentialMap( @@ -390,10 +320,7 @@ const canonicalizeOAuth2 = ( oauth2: OpenApiOAuthInput | undefined, ): { readonly oauth2?: OAuth2SourceConfig; - readonly bindings: ReadonlyArray<{ - readonly slot: string; - readonly value: CredentialBindingValue; - }>; + readonly bindings: readonly PreparedHttpCredentialBinding[]; } => { if (!oauth2) return { bindings: [] }; return { @@ -418,30 +345,6 @@ const scopeRanks = (ctx: PluginCtx): ReadonlyMap = const scopeRank = (ranks: ReadonlyMap, scopeId: string): number => ranks.get(scopeId) ?? Infinity; -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 ?? null; - }); - const validateOpenApiBindingTarget = ( ctx: PluginCtx, input: { @@ -484,7 +387,7 @@ const validateOpenApiBindingTarget = ( const targetScopeForBinding = ( fallbackTargetScope: string | undefined, - binding: { readonly slot: string; readonly targetScope?: string }, + binding: PreparedHttpCredentialBinding, ): Effect.Effect => { const targetScope = binding.targetScope ?? fallbackTargetScope; if (targetScope) return Effect.succeed(targetScope); @@ -560,49 +463,32 @@ const resolveConfiguredValueMap = ( readonly missingLabel: string; }, ): Effect.Effect, OpenApiOAuthError | StorageFailure> => - Effect.gen(function* () { - const resolved: Record = {}; - for (const [name, value] of Object.entries(params.values)) { - if (typeof value === "string") { - resolved[name] = value; - continue; - } - const binding = yield* resolveOpenApiCredentialBinding( - ctx, - params.sourceId, - params.sourceScope, - value.slot, - ); - if (binding?.value.kind === "secret") { - const secret = yield* ctx.secrets - .getAtScope(binding.value.secretId, binding.value.secretScopeId ?? binding.scopeId) - .pipe( - Effect.catchTag("SecretOwnedByConnectionError", () => - Effect.fail( - new OpenApiOAuthError({ - message: `Secret not found for header "${name}"`, - }), - ), - ), - ); - if (secret === null) { - return yield* new OpenApiOAuthError({ - message: `Missing secret "${binding.value.secretId}" for ${params.missingLabel} "${name}"`, - }); - } - resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; - continue; - } - if (binding?.value.kind === "text") { - resolved[name] = value.prefix ? `${value.prefix}${binding.value.text}` : binding.value.text; - continue; - } - return yield* new OpenApiOAuthError({ + resolveConfiguredHttpCredentialMap({ + credentialBindings: ctx.credentialBindings, + pluginId: OPENAPI_PLUGIN_ID, + sourceId: params.sourceId, + sourceScope: params.sourceScope, + values: params.values, + empty: "record", + getSecretAtScope: (secretId, scope, { name }) => + ctx.secrets.getAtScope(secretId, scope).pipe( + Effect.catchTag("SecretOwnedByConnectionError", () => + Effect.fail( + new OpenApiOAuthError({ + message: `Secret not found for ${params.missingLabel} "${name}"`, + }), + ), + ), + ), + onMissingBinding: (name) => + new OpenApiOAuthError({ message: `Missing binding for ${params.missingLabel} "${name}"`, - }); - } - return resolved; - }); + }), + onMissingSecret: (name, binding) => + new OpenApiOAuthError({ + message: `Missing secret "${binding.value.secretId}" for ${params.missingLabel} "${name}"`, + }), + }).pipe(Effect.map((resolved) => resolved ?? {})); const resolveConfiguredHeaders = ( ctx: PluginCtx, @@ -657,12 +543,13 @@ const resolveOAuthConnectionId = ( StorageFailure > => Effect.gen(function* () { - const binding = yield* resolveOpenApiCredentialBinding( - ctx, - params.sourceId, - params.sourceScope, - params.oauth2.connectionSlot, - ); + const binding = yield* resolveSourceCredentialBinding({ + credentialBindings: ctx.credentialBindings, + pluginId: OPENAPI_PLUGIN_ID, + sourceId: params.sourceId, + sourceScope: params.sourceScope, + slotKey: params.oauth2.connectionSlot, + }); if (binding?.value.kind === "connection") { const connectionId = binding.value.connectionId; const connection = yield* ctx.connections.getAtScope(connectionId, binding.scopeId); @@ -872,22 +759,18 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { })), }); - if (directBindings.length > 0) { - for (const binding of directBindings) { - const bindingTargetScope = yield* targetScopeForBinding( - input.credentialTargetScope, - binding, - ); - yield* ctx.credentialBindings.set({ - targetScope: ScopeId.make(bindingTargetScope), - pluginId: OPENAPI_PLUGIN_ID, - sourceId: namespace, - sourceScope: ScopeId.make(input.scope), - slotKey: binding.slot, - value: binding.value, - }); - } - } + yield* setPreparedHttpCredentialBindings({ + credentialBindings: ctx.credentialBindings, + pluginId: OPENAPI_PLUGIN_ID, + sourceId: namespace, + sourceScope: input.scope, + fallbackTargetScope: input.credentialTargetScope, + bindings: directBindings, + onMissingTargetScope: () => + new OpenApiOAuthError({ + message: "credentialTargetScope is required when adding direct OpenAPI credentials", + }), + }); if (Object.keys(hoistedDefs).length > 0) { yield* ctx.core.definitions.register({ @@ -1083,16 +966,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { oauth2: canonicalOAuth2?.oauth2, }); if (affectedPrefixes.length > 0 || directBindings.length > 0) { - yield* ctx.credentialBindings.replaceForSource({ - targetScope: ScopeId.make(targetScope), + yield* replacePreparedHttpCredentialBindingsForSource({ + credentialBindings: ctx.credentialBindings, + targetScope, pluginId: OPENAPI_PLUGIN_ID, sourceId: namespace, - sourceScope: ScopeId.make(scope), + sourceScope: scope, slotPrefixes: affectedPrefixes, - bindings: directBindings.map((binding) => ({ - slotKey: binding.slot, - value: binding.value, - })), + bindings: directBindings, }); } }),