diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index 4eb425572..09e45c242 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -78,6 +78,10 @@ import { googleStandardUserOAuthPresets, openApiPresets, } from "../sdk/presets"; +import { + secretCredentialBindingsSubmitError, + type PendingSecretCredentialBinding, +} from "../sdk/secret-binding-validation"; import { GOOGLE_BUNDLE_PRESET_ID } from "../sdk/google-presets"; import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; import { @@ -719,17 +723,10 @@ export default function AddOpenApiSource(props: { const resolvedBaseUrl = baseUrl.trim(); const sourceScope = ScopeId.make(scopeId); - type PendingSecretBinding = { - readonly slot: string; - readonly secretId: string; - readonly scope: ScopeId; - readonly secretScope: ScopeId; - }; - const configuredHeaders: Record = {}; - const headerBindings: PendingSecretBinding[] = []; + const headerBindings: PendingSecretCredentialBinding[] = []; const configuredQueryParams: Record = {}; - const queryParamBindings: PendingSecretBinding[] = []; + const queryParamBindings: PendingSecretCredentialBinding[] = []; for (const ch of customHeaders) { if (!ch.name.trim()) continue; const slot = headerBindingSlot(ch.name.trim()); @@ -768,7 +765,7 @@ export default function AddOpenApiSource(props: { string, string | { kind: "secret"; prefix?: string } > = {}; - const specFetchBindings: PendingSecretBinding[] = []; + const specFetchBindings: PendingSecretCredentialBinding[] = []; for (const header of specFetchCredentials.headers) { const name = header.name.trim(); if (!name || !header.secretId) continue; @@ -1641,6 +1638,41 @@ export default function AddOpenApiSource(props: { return; } + const pendingSecretBindings: PendingSecretCredentialBinding[] = [ + ...headerBindings, + ...queryParamBindings, + ...specFetchBindings, + ...(configuredOAuth2 && oauth2ClientIdSecretId + ? [ + { + slot: configuredOAuth2.clientIdSlot, + secretId: oauth2ClientIdSecretId, + scope: clientIdBindingScope, + secretScope: clientIdBindingScope, + }, + ] + : []), + ...(configuredOAuth2?.clientSecretSlot && oauth2ClientSecretSecretId + ? [ + { + slot: configuredOAuth2.clientSecretSlot, + secretId: oauth2ClientSecretSecretId, + scope: clientSecretBindingScope, + secretScope: clientSecretBindingScope, + }, + ] + : []), + ]; + const missingSecretMessage = secretCredentialBindingsSubmitError( + pendingSecretBindings, + secretList, + ); + if (missingSecretMessage) { + setAddError(missingSecretMessage); + setAdding(false); + return; + } + const namespace = resolvedSourceId; const specForAdd = googleServicePickerEnabled && selectedGooglePresets.length > 0 diff --git a/packages/plugins/openapi/src/sdk/secret-binding-validation.test.ts b/packages/plugins/openapi/src/sdk/secret-binding-validation.test.ts new file mode 100644 index 000000000..7ec87223b --- /dev/null +++ b/packages/plugins/openapi/src/sdk/secret-binding-validation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { ScopeId } from "@executor-js/sdk/shared"; + +import { + findMissingSecretCredentialBindings, + secretCredentialBindingsSubmitError, + type PendingSecretCredentialBinding, +} from "./secret-binding-validation"; + +const binding = (secretId: string, secretScope: string): PendingSecretCredentialBinding => ({ + slot: "header:authorization", + secretId, + scope: ScopeId.make(secretScope), + secretScope: ScopeId.make(secretScope), +}); + +describe("OpenAPI secret binding validation", () => { + it("keeps bindings whose secret exists in the selected credential scope", () => { + const missing = findMissingSecretCredentialBindings( + [binding("cloudflare-api-authorization", "mulroy-cloud")], + [{ id: "cloudflare-api-authorization", scopeId: "mulroy-cloud" }], + ); + + expect(missing).toEqual([]); + }); + + it("returns a submit-blocking error when a selected secret no longer exists", () => { + const message = secretCredentialBindingsSubmitError( + [binding("cloudflare-dmmulroy-api-authorization", "mulroy-cloud")], + [{ id: "cloudflare-api-authorization-dmmulroy", scopeId: "mulroy-cloud" }], + ); + + expect(message).toContain("cloudflare-dmmulroy-api-authorization"); + expect(message).toContain("before adding the source"); + }); + + it("requires the secret to exist in the same credential scope", () => { + const staleOrgBinding = binding("api-token", "mulroy-cloud"); + const missing = findMissingSecretCredentialBindings( + [staleOrgBinding], + [{ id: "api-token", scopeId: "user-dmmulroy" }], + ); + + expect(missing).toEqual([staleOrgBinding]); + }); +}); diff --git a/packages/plugins/openapi/src/sdk/secret-binding-validation.ts b/packages/plugins/openapi/src/sdk/secret-binding-validation.ts new file mode 100644 index 000000000..e69c4ac0a --- /dev/null +++ b/packages/plugins/openapi/src/sdk/secret-binding-validation.ts @@ -0,0 +1,42 @@ +import type { ScopeId } from "@executor-js/sdk/shared"; + +export interface PendingSecretCredentialBinding { + readonly slot: string; + readonly secretId: string; + readonly scope: ScopeId; + readonly secretScope: ScopeId; +} + +export interface AvailableSecretRef { + readonly id: string; + readonly scopeId: string | ScopeId; +} + +const secretKey = (scopeId: string | ScopeId, secretId: string): string => + `${String(scopeId)}\u0000${secretId}`; + +export const findMissingSecretCredentialBindings = ( + bindings: readonly PendingSecretCredentialBinding[], + secrets: readonly AvailableSecretRef[], +): readonly PendingSecretCredentialBinding[] => { + const available = new Set(secrets.map((secret) => secretKey(secret.scopeId, secret.id))); + return bindings.filter( + (binding) => !available.has(secretKey(binding.secretScope, binding.secretId)), + ); +}; + +export const missingSecretCredentialBindingsMessage = ( + missing: readonly PendingSecretCredentialBinding[], +): string | null => { + if (missing.length === 0) return null; + if (missing.length === 1) { + return `Secret "${missing[0]!.secretId}" no longer exists in the selected credential scope. Choose an existing secret or create a new one before adding the source.`; + } + return `${missing.length} selected secrets no longer exist in their selected credential scopes. Choose existing secrets or create new ones before adding the source.`; +}; + +export const secretCredentialBindingsSubmitError = ( + bindings: readonly PendingSecretCredentialBinding[], + secrets: readonly AvailableSecretRef[], +): string | null => + missingSecretCredentialBindingsMessage(findMissingSecretCredentialBindings(bindings, secrets));