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.
Unified · Split
| @@ -93,6 +93,27 @@ scenario( · 92 unmodified lines | ||
| 93 | 93 | .getByRole("button", { name: "Add Google Photos Picker API" }) |
| 94 | 94 | .waitFor(); |
| 95 | 95 | }); |
| 96 | + | |
| 97 | + await step("Adding the SECOND same-kind card keeps its own identity", async () => { | |
| 98 | + // The regression this guards: selection re-derived the surface by | |
| 99 | + // KIND, so the second product of a kind inherited the first's slug, | |
| 100 | + // auth, and overrides. | |
| 101 | + await page | |
| 102 | + .getByTestId("catalog-photos.google.com-openapi") | |
| 103 | + .filter({ hasText: "Google Photos Picker API" }) | |
| 104 | + .getByRole("button", { name: "Add Google Photos Picker API" }) | |
| 105 | + .click(); | |
| 106 | + const pickerCard = page | |
| 107 | + .getByTestId("catalog-photos.google.com-openapi") | |
| 108 | + .filter({ hasText: "Google Photos Picker API" }); | |
| 109 | + await pickerCard | |
| 110 | + .getByRole("link", { name: "View Google Photos Picker API" }) | |
| 111 | + .waitFor({ timeout: 120_000 }); | |
| 112 | + const href = await pickerCard | |
| 113 | + .getByRole("link", { name: "View Google Photos Picker API" }) | |
| 114 | + .getAttribute("href"); | |
| 115 | + expect(href).toContain("/integrations/google_photos_picker"); | |
| 116 | + }); | |
| 96 | 117 | }); |
| 97 | 118 | }), |
| 98 | 119 | ); |
| @@ -120,6 +120,12 @@ export interface IntegrationPreset { · 119 unmodified lines | ||
| 120 | 120 | readonly specOverrides?: readonly unknown[]; |
| 121 | 121 | readonly authTemplate?: readonly IntegrationPresetAuthentication[]; |
| 122 | 122 | readonly healthCheck?: HealthCheckSpec; |
| 123 | + /** The public registry lists this product, so the picker shows the | |
| 124 | + * registry's card instead of a preset card (the preset's knowledge still | |
| 125 | + * rides quick add). Built-in presets set this; a deployment's custom | |
| 126 | + * preset does not, and keeps its own card — hiding it would make a | |
| 127 | + * private API undiscoverable. */ | |
| 128 | + readonly registryListed?: boolean; | |
| 123 | 129 | } |
| 124 | 130 | |
| 125 | 131 | export type IntegrationPresetAuthentication = |
| @@ -608,6 +608,10 @@ export interface IntegrationPreset { · 607 unmodified lines | ||
| 608 | 608 | readonly specOverrides?: readonly unknown[]; |
| 609 | 609 | readonly authTemplate?: readonly IntegrationPresetAuthentication[]; |
| 610 | 610 | readonly healthCheck?: HealthCheckSpec; |
| 611 | + /** The public registry lists this product: the picker shows the registry's | |
| 612 | + * card, and the preset's knowledge rides quick add instead. A custom | |
| 613 | + * deployment preset leaves this unset and keeps its own card. */ | |
| 614 | + readonly registryListed?: boolean; | |
| 611 | 615 | readonly transport?: "remote" | "stdio"; |
| 612 | 616 | readonly command?: string; |
| 613 | 617 | readonly args?: readonly string[]; |
| @@ -55,7 +55,7 @@ export const graphqlIntegrationPlugin: IntegrationPlugin = { · 54 unmodified lines | ||
| 55 | 55 | label: "GraphQL", |
| 56 | 56 | add: lazy(importAdd), |
| 57 | 57 | accounts: lazy(importAccounts), |
| 58 | − presets: graphqlPresets, | |
| 58 | + presets: graphqlPresets.map((preset) => ({ ...preset, registryListed: true })), | |
| 59 | 59 | preload: () => { |
| 60 | 60 | void importAdd(); |
| 61 | 61 | void importAccounts(); |
| @@ -36,6 +36,11 @@ const AddRemoteServerPayload = Schema.Struct({ · 35 unmodified lines | ||
| 36 | 36 | description: Schema.optional(Schema.String), |
| 37 | 37 | endpoint: Schema.String, |
| 38 | 38 | remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])), |
| 39 | + /** Pin legacy protocol negotiation for a server that echoes the modern | |
| 40 | + * revision but breaks its contract (the probe reports when only legacy | |
| 41 | + * worked). Omitting this here silently stripped the pin from the stored | |
| 42 | + * integration, leaving it unusable against exactly that server. */ | |
| 43 | + versionNegotiation: Schema.optional(Schema.Literals(["auto", "legacy"])), | |
| 39 | 44 | slug: Schema.optional(Schema.String), |
| 40 | 45 | queryParams: Schema.optional(StringMap), |
| 41 | 46 | headers: Schema.optional(StringMap), |
| @@ -119,4 +119,37 @@ describe("McpHandlers", () => { · 118 unmodified lines | ||
| 119 | 119 | expect(body.message).toContain("Do you need to provide an API key"); |
| 120 | 120 | }), |
| 121 | 121 | ); |
| 122 | + | |
| 123 | + it.effect("the remote add carries versionNegotiation through to the extension", () => | |
| 124 | + Effect.gen(function* () { | |
| 125 | + // The probe's legacy retry is pointless if the pin dies at the HTTP | |
| 126 | + // boundary: this exact field was silently stripped by the payload | |
| 127 | + // schema and the handler's explicit field map. | |
| 128 | + let received: unknown; | |
| 129 | + const web = yield* webHandlerFor({ | |
| 130 | + ...failingExtension, | |
| 131 | + addServer: (input) => { | |
| 132 | + received = input; | |
| 133 | + return Effect.succeed({ slug: "pinned", tools: [] } as never); | |
| 134 | + }, | |
| 135 | + }); | |
| 136 | + const response = yield* Effect.promise(() => | |
| 137 | + (web.handler as (request: Request) => Promise<Response>)( | |
| 138 | + new Request("http://localhost/mcp/servers", { | |
| 139 | + method: "POST", | |
| 140 | + headers: { "content-type": "application/json" }, | |
| 141 | + body: JSON.stringify({ | |
| 142 | + transport: "remote", | |
| 143 | + name: "Echoing server", | |
| 144 | + endpoint: "https://example.com/mcp", | |
| 145 | + versionNegotiation: "legacy", | |
| 146 | + authenticationTemplate: [{ kind: "none" }], | |
| 147 | + }), | |
| 148 | + }), | |
| 149 | + ), | |
| 150 | + ); | |
| 151 | + expect(response.status).toBeLessThan(500); | |
| 152 | + expect((received as { versionNegotiation?: string }).versionNegotiation).toBe("legacy"); | |
| 153 | + }), | |
| 154 | + ); | |
| 122 | 155 | }); |
| @@ -65,6 +65,7 @@ const toServerInput = ( · 64 unmodified lines | ||
| 65 | 65 | description?: string; |
| 66 | 66 | endpoint: string; |
| 67 | 67 | remoteTransport?: "streamable-http" | "sse" | "auto"; |
| 68 | + versionNegotiation?: "auto" | "legacy"; | |
| 68 | 69 | queryParams?: Record<string, string>; |
| 69 | 70 | headers?: Record<string, string>; |
| 70 | 71 | slug?: string; |
| @@ -81,6 +82,7 @@ const toServerInput = ( · 10 unmodified lines | ||
| 81 | 82 | description: p.description, |
| 82 | 83 | endpoint: p.endpoint, |
| 83 | 84 | remoteTransport: p.remoteTransport, |
| 85 | + versionNegotiation: p.versionNegotiation, | |
| 84 | 86 | queryParams: p.queryParams, |
| 85 | 87 | headers: p.headers, |
| 86 | 88 | slug: p.slug, |
| @@ -92,11 +92,13 @@ export const createMcpIntegrationPlugin = ( · 91 unmodified lines | ||
| 92 | 92 | <LazyAddMcpIntegration {...props} allowStdio={allowStdio} /> |
| 93 | 93 | ); |
| 94 | 94 | |
| 95 | − const presets = allowStdio | |
| 95 | + const base = allowStdio | |
| 96 | 96 | ? mcpPresets |
| 97 | 97 | : mcpPresets.filter( |
| 98 | 98 | (p) => !("transport" in p && (p as { transport?: string }).transport === "stdio"), |
| 99 | 99 | ); |
| 100 | + // Built-ins are registry-listed; a deployment's custom presets would not be. | |
| 101 | + const presets = base.map((p) => ({ ...p, registryListed: true })); | |
| 100 | 102 | |
| 101 | 103 | return { |
| 102 | 104 | key: "mcp", |
| @@ -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(); |
| @@ -250,18 +250,35 @@ const connectUrlOf = (surface: SurfaceRecord, kind: CatalogKind): string | undef · 249 unmodified lines | ||
| 250 | 250 | ? surface.url |
| 251 | 251 | : undefined; |
| 252 | 252 | |
| 253 | +const normalizedProductToken = (value: string): string => | |
| 254 | + value.toLowerCase().replace(/[^a-z0-9]/g, ""); | |
| 255 | + | |
| 253 | 256 | export const pickConnectTarget = ( |
| 254 | 257 | payload: unknown, |
| 255 | 258 | kind: CatalogKind, |
| 259 | + preferredName?: string, | |
| 256 | 260 | ): CatalogConnectTarget | undefined => |
| 257 | 261 | Option.match(decodeSurfaceDocument(payload), { |
| 258 | 262 | onNone: () => undefined, |
| 259 | 263 | onSome: ({ surfaces }) => { |
| 260 | − for (const surface of surfaces) { | |
| 264 | + // One domain can carry several same-kind products (Google Photos | |
| 265 | + // Library and Picker share photos.google.com). When the caller names | |
| 266 | + // the product, a slug whose normalized form matches wins; first-of-kind | |
| 267 | + // is only the answer when nothing identifies the product. | |
| 268 | + const usable = surfaces.flatMap((surface) => { | |
| 261 | 269 | const url = connectUrlOf(surface, kind); |
| 262 | − if (url) return { kind, url, ...(surface.slug ? { slug: surface.slug } : {}) }; | |
| 270 | + return url | |
| 271 | + ? [{ kind, url, ...(surface.slug ? { slug: surface.slug } : {}) } as CatalogConnectTarget] | |
| 272 | + : []; | |
| 273 | + }); | |
| 274 | + if (preferredName) { | |
| 275 | + const token = normalizedProductToken(preferredName); | |
| 276 | + const named = usable.find( | |
| 277 | + (target) => target.slug && normalizedProductToken(target.slug) === token, | |
| 278 | + ); | |
| 279 | + if (named) return named; | |
| 263 | 280 | } |
| 264 | − return undefined; | |
| 281 | + return usable[0]; | |
| 265 | 282 | }, |
| 266 | 283 | }); |
| 267 | 284 | |
| @@ -270,11 +287,12 @@ export const pickConnectTarget = ( · 2 unmodified lines | ||
| 270 | 287 | export const resolveConnectTarget = ( |
| 271 | 288 | domain: string, |
| 272 | 289 | kinds: readonly CatalogKind[], |
| 290 | + preferredName?: string, | |
| 273 | 291 | ): Effect.Effect<CatalogConnectTarget | undefined, CatalogRequestError> => { |
| 274 | 292 | const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN); |
| 275 | 293 | return Effect.map(fetchCatalogJson(url), (payload) => { |
| 276 | 294 | for (const kind of kinds) { |
| 277 | − const target = pickConnectTarget(payload, kind); | |
| 295 | + const target = pickConnectTarget(payload, kind, preferredName); | |
| 278 | 296 | if (target) return target; |
| 279 | 297 | } |
| 280 | 298 | return undefined; |
| @@ -331,18 +331,33 @@ type QuickAddFn = (input: IntegrationQuickAddInput) => Promise<IntegrationQuickA · 330 unmodified lines | ||
| 331 | 331 | * and mounting one bridge per plugin is how a fixed set of per-plugin hooks |
| 332 | 332 | * stays rules-of-hooks clean. */ |
| 333 | 333 | function QuickAddBridge(props: { |
| 334 | − readonly plugin: IntegrationPlugin; | |
| 334 | + readonly pluginKey: string; | |
| 335 | + /** The plugin's hook, called UNCONDITIONALLY — the host renders a bridge | |
| 336 | + * only for plugins that have one, so this component's hook sequence never | |
| 337 | + * depends on a plugin object's shape. */ | |
| 338 | + readonly useQuickAdd: () => QuickAddFn; | |
| 335 | 339 | readonly register: (key: string, fn: QuickAddFn | null) => void; |
| 336 | 340 | }) { |
| 337 | − const { plugin, register } = props; | |
| 338 | − const fn = plugin.useQuickAdd?.() ?? null; | |
| 341 | + const { pluginKey, useQuickAdd, register } = props; | |
| 342 | + const fn = useQuickAdd(); | |
| 339 | 343 | useEffect(() => { |
| 340 | − register(plugin.key, fn); | |
| 341 | − return () => register(plugin.key, null); | |
| 342 | − }, [plugin.key, fn, register]); | |
| 344 | + register(pluginKey, fn); | |
| 345 | + return () => register(pluginKey, null); | |
| 346 | + }, [pluginKey, fn, register]); | |
| 343 | 347 | return null; |
| 344 | 348 | } |
| 345 | 349 | |
| 350 | +/** One bridge per plugin KEY: a duplicate key would share the React key and | |
| 351 | + * the callback slot, letting either copy's cleanup delete the other. */ | |
| 352 | +function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) { | |
| 353 | + const seen = new Set<string>(); | |
| 354 | + return plugins.filter((plugin) => { | |
| 355 | + if (!plugin.useQuickAdd || seen.has(plugin.key)) return false; | |
| 356 | + seen.add(plugin.key); | |
| 357 | + return true; | |
| 358 | + }); | |
| 359 | +} | |
| 360 | + | |
| 346 | 361 | // --------------------------------------------------------------------------- |
| 347 | 362 | // Page |
| 348 | 363 | // --------------------------------------------------------------------------- |
| @@ -599,11 +614,13 @@ export function IntegrationBrowsePage() { · 250 unmodified lines | ||
| 599 | 614 | kind: CatalogKind, |
| 600 | 615 | title: string, |
| 601 | 616 | rowKey: string, |
| 602 | − knownUrl?: string, | |
| 617 | + // The EXACT surface this card was built from. Never re-derived from | |
| 618 | + // `kind`: one domain can carry two same-kind products (Google Photos | |
| 619 | + // Library and Picker), and a kind lookup silently merged the clicked | |
| 620 | + // card's URL with the FIRST surface's slug, auth, and overrides. | |
| 621 | + surface?: CatalogSurface, | |
| 603 | 622 | ) => { |
| 604 | − // The registry already told us where this surface lives — go, rather than | |
| 605 | − // spending a round trip re-asking for something we were handed. | |
| 606 | − const surface = entry.surfaces?.find((candidate) => candidate.kind === kind); | |
| 623 | + const knownUrl = surface?.url; | |
| 607 | 624 | if (knownUrl) { |
| 608 | 625 | const added = await tryQuickAdd({ |
| 609 | 626 | kind, |
| @@ -629,7 +646,11 @@ export function IntegrationBrowsePage() { · 19 unmodified lines | ||
| 629 | 646 | if (resolvingDomain !== null) return; |
| 630 | 647 | setResolvingDomain(entry.domain); |
| 631 | 648 | setRowError(null); |
| 632 | − const exit = await Effect.runPromiseExit(resolveConnectTarget(entry.domain, [kind])); | |
| 649 | + // The row's product name disambiguates same-kind surfaces in the doc | |
| 650 | + // (the registry row itself was kinds-only, so the name is all we have). | |
| 651 | + const exit = await Effect.runPromiseExit( | |
| 652 | + resolveConnectTarget(entry.domain, [kind], entry.name ?? undefined), | |
| 653 | + ); | |
| 633 | 654 | setResolvingDomain(null); |
| 634 | 655 | if (Exit.isFailure(exit) || !exit.value) { |
| 635 | 656 | setRowError({ |
| @@ -682,7 +703,14 @@ export function IntegrationBrowsePage() { · 46 unmodified lines | ||
| 682 | 703 | const presetRows = useMemo<readonly Row[]>(() => { |
| 683 | 704 | const rows: Row[] = []; |
| 684 | 705 | for (const entry of allPresets) { |
| 685 | − if (entry.preset.url !== undefined || entry.preset.endpoint !== undefined) continue; | |
| 706 | + // A URL preset the registry lists is the registry row's job. A custom | |
| 707 | + // deployment preset has no registry row — hiding it would make a | |
| 708 | + // private API undiscoverable, so it keeps its card. | |
| 709 | + if ( | |
| 710 | + (entry.preset.url !== undefined || entry.preset.endpoint !== undefined) && | |
| 711 | + entry.preset.registryListed === true | |
| 712 | + ) | |
| 713 | + continue; | |
| 686 | 714 | if (text.length > 0) { |
| 687 | 715 | const corpus = |
| 688 | 716 | `${entry.preset.name} ${entry.preset.summary ?? ""} ${entry.preset.family ?? ""} ${entry.pluginLabel}`.toLowerCase(); |
| @@ -748,7 +776,8 @@ export function IntegrationBrowsePage() { · 59 unmodified lines | ||
| 748 | 776 | ...(description ? { description } : {}), |
| 749 | 777 | iconUrl: |
| 750 | 778 | (known && "icon" in known ? known.icon : undefined) ?? catalogLogoUrl(entry.domain, 10), |
| 751 | − onSelect: () => void pickCatalogEntry(entry, surface.kind, title, rowKey, known?.url), | |
| 779 | + onSelect: () => | |
| 780 | + void pickCatalogEntry(entry, surface.kind, title, rowKey, known ?? undefined), | |
| 752 | 781 | added: |
| 753 | 782 | quickAddedSlug !== undefined || isAdded(surface.kind, known?.slug, entry.domain, title), |
| 754 | 783 | ...(quickAddedSlug !== undefined ? { freshlyAdded: true } : {}), |
| @@ -880,8 +909,13 @@ export function IntegrationBrowsePage() { · 125 unmodified lines | ||
| 880 | 909 | // search, and ONLY the results grid scrolls — an endless list makes the |
| 881 | 910 | // page scrollbar meaningless and drags the search box off screen. |
| 882 | 911 | <div className="flex min-h-0 flex-1 flex-col" data-slot="page-container"> |
| 883 | − {integrationPlugins.map((plugin) => ( | |
| 884 | − <QuickAddBridge key={plugin.key} plugin={plugin} register={registerQuickAdd} /> | |
| 912 | + {quickAddCapablePlugins(integrationPlugins).map((plugin) => ( | |
| 913 | + <QuickAddBridge | |
| 914 | + key={plugin.key} | |
| 915 | + pluginKey={plugin.key} | |
| 916 | + useQuickAdd={plugin.useQuickAdd!} | |
| 917 | + register={registerQuickAdd} | |
| 918 | + /> | |
| 885 | 919 | ))} |
| 886 | 920 | <div className="mx-auto w-full max-w-4xl shrink-0 px-6 pt-10 lg:px-8 lg:pt-14"> |
| 887 | 921 | <PageHeader |