Selection carries the CLICKED surface instead of re-finding by kind, and the kinds-only fallback disambiguates same-kind products by the row's product name against surface slugs — two same-kind products on one domain no longer merge one card's URL with the other's slug, auth, and overrides. The OpenAPI quick add replicates the full add page's method policy: preset OAuth wins outright; otherwise every spec-detected method is preserved (one preview call, only when the registry declared a header) and the registry key is appended only when the spec has no key method. The MCP remote add carries versionNegotiation through the payload schema and handler map, with a round-trip test — the legacy pin previously died at the HTTP boundary. URL presets hide only when registry-listed (built-ins and the provider catalogs declare it), so a deployment's custom preset keeps its card, and quick add matches the plugin's complete preset list. Quick-add bridges render only for plugins with the hook and call it unconditionally; duplicate plugin keys get one bridge.
Showing the changes to packages/plugins/openapi/src (3 of 13 files). Show all
Unified · Split
| @@ -346,6 +346,7 @@ export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets · 345 unmodified lines | ||
| 346 | 346 | ...(preset.featured ? { featured: preset.featured } : {}), |
| 347 | 347 | family: "google", |
| 348 | 348 | specFormat: "google-discovery", |
| 349 | + registryListed: true, | |
| 349 | 350 | defaultSlug: googleServiceSlug(preset.id), |
| 350 | 351 | authTemplate: googleCatalogAuthTemplate(preset.id), |
| 351 | 352 | ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}), |
| @@ -569,6 +569,7 @@ export const microsoftCatalog: readonly IntegrationPreset[] = microsoftGraphScop · 568 unmodified lines | ||
| 569 | 569 | ...(preset.featured ? { featured: preset.featured } : {}), |
| 570 | 570 | family: "microsoft", |
| 571 | 571 | specFormat: "microsoft-graph", |
| 572 | + registryListed: true, | |
| 572 | 573 | defaultSlug: microsoftServiceSlug(preset.id), |
| 573 | 574 | authTemplate: microsoftGraphCatalogAuthTemplate(preset), |
| 574 | 575 | ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}), |
| @@ -11,8 +11,9 @@ import { AuthTemplateSlug } from "@executor-js/sdk/shared"; · 10 unmodified lines | ||
| 11 | 11 | import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity"; |
| 12 | 12 | import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; |
| 13 | 13 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 14 | −import { openApiPresets, type OpenApiPreset } from "../sdk/presets"; | |
| 15 | −import { addOpenApiSpec } from "./atoms"; | |
| 14 | +import { openApiPresets } from "../sdk/presets"; | |
| 15 | +import { detectedAuthenticationTemplates } from "../sdk/derive-auth"; | |
| 16 | +import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; | |
| 16 | 17 | import { openApiWireAuthInput, templateFromPlacements } from "./auth-method-config"; |
| 17 | 18 | import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides"; |
| 18 | 19 | |
| @@ -26,10 +27,15 @@ const normalizedSpecUrl = (url: string): string => { · 7 unmodified lines | ||
| 26 | 27 | |
| 27 | 28 | /** The preset table still knows things no spec can say — GitHub's OAuth |
| 28 | 29 | * endpoints against a spec that declares NO security at all. A registry row |
| 29 | − * whose URL is a preset's URL gets that knowledge pulled across. */ | |
| 30 | −const presetForSpecUrl = (url: string): OpenApiPreset | undefined => { | |
| 30 | + * whose URL is a preset's URL gets that knowledge pulled across. Matches the | |
| 31 | + * plugin's COMPLETE preset list, so a deployment's custom presets | |
| 32 | + * contribute too, not just the built-ins. */ | |
| 33 | +const presetForSpecUrl = ( | |
| 34 | + presets: readonly IntegrationPreset[], | |
| 35 | + url: string, | |
| 36 | +): IntegrationPreset | undefined => { | |
| 31 | 37 | const target = normalizedSpecUrl(url); |
| 32 | − return openApiPresets.find( | |
| 38 | + return presets.find( | |
| 33 | 39 | (preset) => preset.url !== undefined && normalizedSpecUrl(preset.url) === target, |
| 34 | 40 | ); |
| 35 | 41 | }; |
| @@ -38,72 +44,103 @@ const presetForSpecUrl = (url: string): OpenApiPreset | undefined => { · 2 unmodified lines | ||
| 38 | 44 | * configuration: omitting `authenticationTemplate` and `baseUrl` tells the |
| 39 | 45 | * server to derive both from the document, exactly what the add page's |
| 40 | 46 | * untouched defaults submit. Registry spec overrides ride along. */ |
| 41 | −function useOpenApiQuickAdd(): ( | |
| 42 | − input: IntegrationQuickAddInput, | |
| 43 | −) => Promise<IntegrationQuickAddResult> { | |
| 44 | − const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" }); | |
| 45 | − return useCallback( | |
| 46 | − async (input) => { | |
| 47 | − const slug = slugifyNamespace(input.slug ?? input.name); | |
| 48 | − if (!slug) return { ok: false, reason: "no derivable slug" }; | |
| 49 | − // Registry overrides arrive as untyped JSON; a malformed patch goes to | |
| 50 | − // the configuration screen (its editor renders the parse error) rather | |
| 51 | − // than being silently dropped from a "successful" quick add. | |
| 52 | − const specOverrides = input.specOverrides | |
| 53 | − ? decodeOpenApiSpecOverrides(input.specOverrides) | |
| 54 | − : undefined; | |
| 55 | − if (input.specOverrides && input.specOverrides.length > 0 && specOverrides === undefined) { | |
| 56 | − return { ok: false, reason: "unparseable spec overrides" }; | |
| 57 | − } | |
| 58 | − const preset = presetForSpecUrl(input.url); | |
| 59 | − // Declared methods travel from BOTH knowledge sources: the preset's | |
| 60 | − // OAuth template (endpoints a spec cannot carry) and the registry's | |
| 61 | − // header pattern (GitHub's spec declares no security at all, but the | |
| 62 | − // registry knows a PAT goes in the Authorization header). When neither | |
| 63 | − // knows anything, omitting the field lets the spec's own schemes derive. | |
| 64 | − const presetMethods = (preset?.authTemplate ?? []).flatMap((template) => | |
| 65 | − template.kind === "oauth2" | |
| 66 | − ? [ | |
| 67 | − openApiWireAuthInput({ | |
| 68 | − ...template, | |
| 69 | − slug: AuthTemplateSlug.make(template.slug), | |
| 70 | − resource: template.resource ?? undefined, | |
| 71 | − }), | |
| 72 | − ] | |
| 73 | − : [], | |
| 74 | − ); | |
| 75 | − const registryPlacement = input.authHeader | |
| 76 | − ? placementFromHeaderPattern(input.authHeader) | |
| 77 | − : null; | |
| 78 | − const authenticationTemplate = [ | |
| 79 | − ...presetMethods, | |
| 80 | − ...(registryPlacement | |
| 81 | − ? [openApiWireAuthInput(templateFromPlacements([registryPlacement]))] | |
| 82 | − : []), | |
| 83 | − ]; | |
| 84 | − const presetOverrides = | |
| 85 | − specOverrides && specOverrides.length > 0 ? specOverrides : preset?.specOverrides; | |
| 86 | − const exit = await doAdd({ | |
| 87 | − payload: { | |
| 88 | − spec: { kind: "url" as const, url: input.url }, | |
| 89 | − slug, | |
| 90 | − name: input.name, | |
| 91 | − ...(input.domain ? { displayDomain: input.domain } : {}), | |
| 92 | − ...(preset?.specFormat ? { specFormat: preset.specFormat } : {}), | |
| 93 | − ...(preset?.family ? { family: preset.family } : {}), | |
| 94 | − ...(preset?.healthCheck ? { healthCheck: preset.healthCheck } : {}), | |
| 95 | − ...(presetOverrides && presetOverrides.length > 0 | |
| 96 | − ? { specOverrides: presetOverrides } | |
| 97 | − : {}), | |
| 98 | − ...(authenticationTemplate.length > 0 ? { authenticationTemplate } : {}), | |
| 99 | − }, | |
| 100 | − reactivityKeys: integrationWriteKeys, | |
| 101 | − }); | |
| 102 | − if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" }; | |
| 103 | − return { ok: true, slug: String(exit.value.slug) }; | |
| 104 | − }, | |
| 105 | − [doAdd], | |
| 106 | − ); | |
| 47 | +function makeUseQuickAdd(presets: readonly IntegrationPreset[]) { | |
| 48 | + return function useOpenApiQuickAdd(): ( | |
| 49 | + input: IntegrationQuickAddInput, | |
| 50 | + ) => Promise<IntegrationQuickAddResult> { | |
| 51 | + const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" }); | |
| 52 | + const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); | |
| 53 | + return useCallback( | |
| 54 | + async (input) => { | |
| 55 | + const slug = slugifyNamespace(input.slug ?? input.name); | |
| 56 | + if (!slug) return { ok: false, reason: "no derivable slug" }; | |
| 57 | + // Registry overrides arrive as untyped JSON; a malformed patch goes to | |
| 58 | + // the configuration screen (its editor renders the parse error) rather | |
| 59 | + // than being silently dropped from a "successful" quick add. | |
| 60 | + const specOverrides = input.specOverrides | |
| 61 | + ? decodeOpenApiSpecOverrides(input.specOverrides) | |
| 62 | + : undefined; | |
| 63 | + if (input.specOverrides && input.specOverrides.length > 0 && specOverrides === undefined) { | |
| 64 | + return { ok: false, reason: "unparseable spec overrides" }; | |
| 65 | + } | |
| 66 | + const preset = presetForSpecUrl(presets, input.url); | |
| 67 | + // The FULL add page's method policy, replicated exactly: a preset's | |
| 68 | + // OAuth template wins outright; otherwise EVERY spec-detected method is | |
| 69 | + // preserved (an explicit template suppresses server-side derivation, so | |
| 70 | + // sending only the registry header would erase a spec's declared OAuth | |
| 71 | + // and key schemes); the registry's header pattern is appended only when | |
| 72 | + // the detected set has no API-key method (GitHub declares no security | |
| 73 | + // at all, and a PAT header is how most calls actually authenticate). | |
| 74 | + const registryPlacement = input.authHeader | |
| 75 | + ? placementFromHeaderPattern(input.authHeader) | |
| 76 | + : null; | |
| 77 | + const presetMethods = (preset?.authTemplate ?? []).flatMap((template) => | |
| 78 | + template.kind === "oauth2" | |
| 79 | + ? [ | |
| 80 | + openApiWireAuthInput({ | |
| 81 | + ...template, | |
| 82 | + slug: AuthTemplateSlug.make(template.slug), | |
| 83 | + resource: template.resource ?? undefined, | |
| 84 | + }), | |
| 85 | + ] | |
| 86 | + : [], | |
| 87 | + ); | |
| 88 | + let authenticationTemplate = presetMethods; | |
| 89 | + if (presetMethods.length === 0 && registryPlacement) { | |
| 90 | + // Composing with spec knowledge needs the spec: one preview call, | |
| 91 | + // only on this path (a plain registry row with no auth facts still | |
| 92 | + // adds with zero extra round trips). | |
| 93 | + const previewExit = await doPreview({ payload: { spec: input.url } }); | |
| 94 | + if (Exit.isFailure(previewExit)) return { ok: false, reason: "preview failed" }; | |
| 95 | + const previewSummary = previewExit.value; | |
| 96 | + const detected = detectedAuthenticationTemplates( | |
| 97 | + previewSummary.headerPresets, | |
| 98 | + previewSummary.oauth2Presets, | |
| 99 | + previewSummary.servers[0]?.url ?? "", | |
| 100 | + ); | |
| 101 | + const detectedHasApiKey = detected.some((template) => template.kind === "apikey"); | |
| 102 | + authenticationTemplate = [ | |
| 103 | + ...detected.map(openApiWireAuthInput), | |
| 104 | + ...(detectedHasApiKey | |
| 105 | + ? [] | |
| 106 | + : [openApiWireAuthInput(templateFromPlacements([registryPlacement]))]), | |
| 107 | + ]; | |
| 108 | + } else if (presetMethods.length > 0 && registryPlacement) { | |
| 109 | + authenticationTemplate = [ | |
| 110 | + ...presetMethods, | |
| 111 | + openApiWireAuthInput(templateFromPlacements([registryPlacement])), | |
| 112 | + ]; | |
| 113 | + } | |
| 114 | + // Preset overrides arrive as the same untyped JSON shape the registry | |
| 115 | + // sends; decode both through the one boundary. | |
| 116 | + const presetOverrides = | |
| 117 | + specOverrides && specOverrides.length > 0 | |
| 118 | + ? specOverrides | |
| 119 | + : preset?.specOverrides | |
| 120 | + ? decodeOpenApiSpecOverrides(preset.specOverrides) | |
| 121 | + : undefined; | |
| 122 | + const exit = await doAdd({ | |
| 123 | + payload: { | |
| 124 | + spec: { kind: "url" as const, url: input.url }, | |
| 125 | + slug, | |
| 126 | + name: input.name, | |
| 127 | + ...(input.domain ? { displayDomain: input.domain } : {}), | |
| 128 | + ...(preset?.specFormat ? { specFormat: preset.specFormat } : {}), | |
| 129 | + ...(preset?.family ? { family: preset.family } : {}), | |
| 130 | + ...(preset?.healthCheck ? { healthCheck: preset.healthCheck } : {}), | |
| 131 | + ...(presetOverrides && presetOverrides.length > 0 | |
| 132 | + ? { specOverrides: presetOverrides } | |
| 133 | + : {}), | |
| 134 | + ...(authenticationTemplate.length > 0 ? { authenticationTemplate } : {}), | |
| 135 | + }, | |
| 136 | + reactivityKeys: integrationWriteKeys, | |
| 137 | + }); | |
| 138 | + if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" }; | |
| 139 | + return { ok: true, slug: String(exit.value.slug) }; | |
| 140 | + }, | |
| 141 | + [doAdd, doPreview], | |
| 142 | + ); | |
| 143 | + }; | |
| 107 | 144 | } |
| 108 | 145 | |
| 109 | 146 | const importAdd = () => import("./AddOpenApiIntegration"); |
| @@ -114,21 +151,27 @@ export interface OpenApiClientConfig { · 4 unmodified lines | ||
| 114 | 151 | readonly presets?: readonly IntegrationPreset[]; |
| 115 | 152 | } |
| 116 | 153 | |
| 117 | −export const createOpenApiIntegrationPlugin = ( | |
| 118 | − config?: OpenApiClientConfig, | |
| 119 | −): IntegrationPlugin => ({ | |
| 120 | − key: "openapi", | |
| 121 | − label: "OpenAPI", | |
| 122 | − add: lazy(importAdd), | |
| 123 | − editSheet: lazy(importEditSheet), | |
| 124 | − accounts: lazy(importAccounts), | |
| 125 | − presets: [...openApiPresets, ...(config?.presets ?? [])], | |
| 126 | − preload: () => { | |
| 127 | − void importAdd(); | |
| 128 | − void importEditSheet(); | |
| 129 | − void importAccounts(); | |
| 130 | − }, | |
| 131 | − useQuickAdd: useOpenApiQuickAdd, | |
| 132 | −}); | |
| 154 | +export const createOpenApiIntegrationPlugin = (config?: OpenApiClientConfig): IntegrationPlugin => { | |
| 155 | + // Built-ins are registry-listed (the picker shows the registry's card); | |
| 156 | + // a deployment's custom presets are not, and keep their own cards. | |
| 157 | + const presets: readonly IntegrationPreset[] = [ | |
| 158 | + ...openApiPresets.map((preset) => ({ ...preset, registryListed: true })), | |
| 159 | + ...(config?.presets ?? []), | |
| 160 | + ]; | |
| 161 | + return { | |
| 162 | + key: "openapi", | |
| 163 | + label: "OpenAPI", | |
| 164 | + add: lazy(importAdd), | |
| 165 | + editSheet: lazy(importEditSheet), | |
| 166 | + accounts: lazy(importAccounts), | |
| 167 | + presets, | |
| 168 | + preload: () => { | |
| 169 | + void importAdd(); | |
| 170 | + void importEditSheet(); | |
| 171 | + void importAccounts(); | |
| 172 | + }, | |
| 173 | + useQuickAdd: makeUseQuickAdd(presets), | |
| 174 | + }; | |
| 175 | +}; | |
| 133 | 176 | |
| 134 | 177 | export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin(); |