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 | .getByRole("button", { name: "Add Google Photos Picker API" }) | 93 | .getByRole("button", { name: "Add Google Photos Picker API" }) |
| 94 | .waitFor(); | 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 | readonly specOverrides?: readonly unknown[]; | 120 | readonly specOverrides?: readonly unknown[]; |
| 121 | readonly authTemplate?: readonly IntegrationPresetAuthentication[]; | 121 | readonly authTemplate?: readonly IntegrationPresetAuthentication[]; |
| 122 | readonly healthCheck?: HealthCheckSpec; | 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 | export type IntegrationPresetAuthentication = | 131 | export type IntegrationPresetAuthentication = |
| @@ -608,6 +608,10 @@ export interface IntegrationPreset { · 607 unmodified lines | |||
| 608 | readonly specOverrides?: readonly unknown[]; | 608 | readonly specOverrides?: readonly unknown[]; |
| 609 | readonly authTemplate?: readonly IntegrationPresetAuthentication[]; | 609 | readonly authTemplate?: readonly IntegrationPresetAuthentication[]; |
| 610 | readonly healthCheck?: HealthCheckSpec; | 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 | readonly transport?: "remote" | "stdio"; | 615 | readonly transport?: "remote" | "stdio"; |
| 612 | readonly command?: string; | 616 | readonly command?: string; |
| 613 | readonly args?: readonly string[]; | 617 | readonly args?: readonly string[]; |
| @@ -55,7 +55,7 @@ export const graphqlIntegrationPlugin: IntegrationPlugin = { · 54 unmodified lines | |||
| 55 | label: "GraphQL", | 55 | label: "GraphQL", |
| 56 | add: lazy(importAdd), | 56 | add: lazy(importAdd), |
| 57 | accounts: lazy(importAccounts), | 57 | accounts: lazy(importAccounts), |
| 58 | presets: graphqlPresets, | 58 | presets: graphqlPresets.map((preset) => ({ ...preset, registryListed: true })), |
| 59 | preload: () => { | 59 | preload: () => { |
| 60 | void importAdd(); | 60 | void importAdd(); |
| 61 | void importAccounts(); | 61 | void importAccounts(); |
| @@ -36,6 +36,11 @@ const AddRemoteServerPayload = Schema.Struct({ · 35 unmodified lines | |||
| 36 | description: Schema.optional(Schema.String), | 36 | description: Schema.optional(Schema.String), |
| 37 | endpoint: Schema.String, | 37 | endpoint: Schema.String, |
| 38 | remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])), | 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 | slug: Schema.optional(Schema.String), | 44 | slug: Schema.optional(Schema.String), |
| 40 | queryParams: Schema.optional(StringMap), | 45 | queryParams: Schema.optional(StringMap), |
| 41 | headers: Schema.optional(StringMap), | 46 | headers: Schema.optional(StringMap), |
| @@ -119,4 +119,37 @@ describe("McpHandlers", () => { · 118 unmodified lines | |||
| 119 | expect(body.message).toContain("Do you need to provide an API key"); | 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 | description?: string; | 65 | description?: string; |
| 66 | endpoint: string; | 66 | endpoint: string; |
| 67 | remoteTransport?: "streamable-http" | "sse" | "auto"; | 67 | remoteTransport?: "streamable-http" | "sse" | "auto"; |
| 68 | versionNegotiation?: "auto" | "legacy"; | ||
| 68 | queryParams?: Record<string, string>; | 69 | queryParams?: Record<string, string>; |
| 69 | headers?: Record<string, string>; | 70 | headers?: Record<string, string>; |
| 70 | slug?: string; | 71 | slug?: string; |
| @@ -81,6 +82,7 @@ const toServerInput = ( · 10 unmodified lines | |||
| 81 | description: p.description, | 82 | description: p.description, |
| 82 | endpoint: p.endpoint, | 83 | endpoint: p.endpoint, |
| 83 | remoteTransport: p.remoteTransport, | 84 | remoteTransport: p.remoteTransport, |
| 85 | versionNegotiation: p.versionNegotiation, | ||
| 84 | queryParams: p.queryParams, | 86 | queryParams: p.queryParams, |
| 85 | headers: p.headers, | 87 | headers: p.headers, |
| 86 | slug: p.slug, | 88 | slug: p.slug, |
| @@ -92,11 +92,13 @@ export const createMcpIntegrationPlugin = ( · 91 unmodified lines | |||
| 92 | <LazyAddMcpIntegration {...props} allowStdio={allowStdio} /> | 92 | <LazyAddMcpIntegration {...props} allowStdio={allowStdio} /> |
| 93 | ); | 93 | ); |
| 94 | 94 | ||
| 95 | const presets = allowStdio | 95 | const base = allowStdio |
| 96 | ? mcpPresets | 96 | ? mcpPresets |
| 97 | : mcpPresets.filter( | 97 | : mcpPresets.filter( |
| 98 | (p) => !("transport" in p && (p as { transport?: string }).transport === "stdio"), | 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 | return { | 103 | return { |
| 102 | key: "mcp", | 104 | key: "mcp", |
| @@ -346,6 +346,7 @@ export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets · 345 unmodified lines | |||
| 346 | ...(preset.featured ? { featured: preset.featured } : {}), | 346 | ...(preset.featured ? { featured: preset.featured } : {}), |
| 347 | family: "google", | 347 | family: "google", |
| 348 | specFormat: "google-discovery", | 348 | specFormat: "google-discovery", |
| 349 | registryListed: true, | ||
| 349 | defaultSlug: googleServiceSlug(preset.id), | 350 | defaultSlug: googleServiceSlug(preset.id), |
| 350 | authTemplate: googleCatalogAuthTemplate(preset.id), | 351 | authTemplate: googleCatalogAuthTemplate(preset.id), |
| 351 | ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}), | 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 | ...(preset.featured ? { featured: preset.featured } : {}), | 569 | ...(preset.featured ? { featured: preset.featured } : {}), |
| 570 | family: "microsoft", | 570 | family: "microsoft", |
| 571 | specFormat: "microsoft-graph", | 571 | specFormat: "microsoft-graph", |
| 572 | registryListed: true, | ||
| 572 | defaultSlug: microsoftServiceSlug(preset.id), | 573 | defaultSlug: microsoftServiceSlug(preset.id), |
| 573 | authTemplate: microsoftGraphCatalogAuthTemplate(preset), | 574 | authTemplate: microsoftGraphCatalogAuthTemplate(preset), |
| 574 | ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}), | 575 | ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}), |
| @@ -11,8 +11,9 @@ import { AuthTemplateSlug } from "@executor-js/sdk/shared"; · 10 unmodified lines | |||
| 11 | import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity"; | 11 | import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity"; |
| 12 | import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | 12 | import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; |
| 13 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 13 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 14 | import { openApiPresets, type OpenApiPreset } from "../sdk/presets"; | 14 | import { openApiPresets } from "../sdk/presets"; |
| 15 | import { addOpenApiSpec } from "./atoms"; | 15 | import { detectedAuthenticationTemplates } from "../sdk/derive-auth"; |
| 16 | import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; | ||
| 16 | import { openApiWireAuthInput, templateFromPlacements } from "./auth-method-config"; | 17 | import { openApiWireAuthInput, templateFromPlacements } from "./auth-method-config"; |
| 17 | import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides"; | 18 | import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides"; |
| 18 | 19 | ||
| @@ -26,10 +27,15 @@ const normalizedSpecUrl = (url: string): string => { · 7 unmodified lines | |||
| 26 | 27 | ||
| 27 | /** The preset table still knows things no spec can say — GitHub's OAuth | 28 | /** The preset table still knows things no spec can say — GitHub's OAuth |
| 28 | * endpoints against a spec that declares NO security at all. A registry row | 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 | * whose URL is a preset's URL gets that knowledge pulled across. Matches the |
| 30 | const presetForSpecUrl = (url: string): OpenApiPreset | undefined => { | 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 | const target = normalizedSpecUrl(url); | 37 | const target = normalizedSpecUrl(url); |
| 32 | return openApiPresets.find( | 38 | return presets.find( |
| 33 | (preset) => preset.url !== undefined && normalizedSpecUrl(preset.url) === target, | 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 | * configuration: omitting `authenticationTemplate` and `baseUrl` tells the | 44 | * configuration: omitting `authenticationTemplate` and `baseUrl` tells the |
| 39 | * server to derive both from the document, exactly what the add page's | 45 | * server to derive both from the document, exactly what the add page's |
| 40 | * untouched defaults submit. Registry spec overrides ride along. */ | 46 | * untouched defaults submit. Registry spec overrides ride along. */ |
| 41 | function useOpenApiQuickAdd(): ( | 47 | function makeUseQuickAdd(presets: readonly IntegrationPreset[]) { |
| 42 | input: IntegrationQuickAddInput, | 48 | return function useOpenApiQuickAdd(): ( |
| 43 | ) => Promise<IntegrationQuickAddResult> { | 49 | input: IntegrationQuickAddInput, |
| 44 | const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" }); | 50 | ) => Promise<IntegrationQuickAddResult> { |
| 45 | return useCallback( | 51 | const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" }); |
| 46 | async (input) => { | 52 | const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); |
| 47 | const slug = slugifyNamespace(input.slug ?? input.name); | 53 | return useCallback( |
| 48 | if (!slug) return { ok: false, reason: "no derivable slug" }; | 54 | async (input) => { |
| 49 | // Registry overrides arrive as untyped JSON; a malformed patch goes to | 55 | const slug = slugifyNamespace(input.slug ?? input.name); |
| 50 | // the configuration screen (its editor renders the parse error) rather | 56 | if (!slug) return { ok: false, reason: "no derivable slug" }; |
| 51 | // than being silently dropped from a "successful" quick add. | 57 | // Registry overrides arrive as untyped JSON; a malformed patch goes to |
| 52 | const specOverrides = input.specOverrides | 58 | // the configuration screen (its editor renders the parse error) rather |
| 53 | ? decodeOpenApiSpecOverrides(input.specOverrides) | 59 | // than being silently dropped from a "successful" quick add. |
| 54 | : undefined; | 60 | const specOverrides = input.specOverrides |
| 55 | if (input.specOverrides && input.specOverrides.length > 0 && specOverrides === undefined) { | 61 | ? decodeOpenApiSpecOverrides(input.specOverrides) |
| 56 | return { ok: false, reason: "unparseable spec overrides" }; | 62 | : undefined; |
| 57 | } | 63 | if (input.specOverrides && input.specOverrides.length > 0 && specOverrides === undefined) { |
| 58 | const preset = presetForSpecUrl(input.url); | 64 | return { ok: false, reason: "unparseable spec overrides" }; |
| 59 | // Declared methods travel from BOTH knowledge sources: the preset's | 65 | } |
| 60 | // OAuth template (endpoints a spec cannot carry) and the registry's | 66 | const preset = presetForSpecUrl(presets, input.url); |
| 61 | // header pattern (GitHub's spec declares no security at all, but the | 67 | // The FULL add page's method policy, replicated exactly: a preset's |
| 62 | // registry knows a PAT goes in the Authorization header). When neither | 68 | // OAuth template wins outright; otherwise EVERY spec-detected method is |
| 63 | // knows anything, omitting the field lets the spec's own schemes derive. | 69 | // preserved (an explicit template suppresses server-side derivation, so |
| 64 | const presetMethods = (preset?.authTemplate ?? []).flatMap((template) => | 70 | // sending only the registry header would erase a spec's declared OAuth |
| 65 | template.kind === "oauth2" | 71 | // and key schemes); the registry's header pattern is appended only when |
| 66 | ? [ | 72 | // the detected set has no API-key method (GitHub declares no security |
| 67 | openApiWireAuthInput({ | 73 | // at all, and a PAT header is how most calls actually authenticate). |
| 68 | ...template, | 74 | const registryPlacement = input.authHeader |
| 69 | slug: AuthTemplateSlug.make(template.slug), | 75 | ? placementFromHeaderPattern(input.authHeader) |
| 70 | resource: template.resource ?? undefined, | 76 | : null; |
| 71 | }), | 77 | const presetMethods = (preset?.authTemplate ?? []).flatMap((template) => |
| 72 | ] | 78 | template.kind === "oauth2" |
| 73 | : [], | 79 | ? [ |
| 74 | ); | 80 | openApiWireAuthInput({ |
| 75 | const registryPlacement = input.authHeader | 81 | ...template, |
| 76 | ? placementFromHeaderPattern(input.authHeader) | 82 | slug: AuthTemplateSlug.make(template.slug), |
| 77 | : null; | 83 | resource: template.resource ?? undefined, |
| 78 | const authenticationTemplate = [ | 84 | }), |
| 79 | ...presetMethods, | 85 | ] |
| 80 | ...(registryPlacement | 86 | : [], |
| 81 | ? [openApiWireAuthInput(templateFromPlacements([registryPlacement]))] | 87 | ); |
| 82 | : []), | 88 | let authenticationTemplate = presetMethods; |
| 83 | ]; | 89 | if (presetMethods.length === 0 && registryPlacement) { |
| 84 | const presetOverrides = | 90 | // Composing with spec knowledge needs the spec: one preview call, |
| 85 | specOverrides && specOverrides.length > 0 ? specOverrides : preset?.specOverrides; | 91 | // only on this path (a plain registry row with no auth facts still |
| 86 | const exit = await doAdd({ | 92 | // adds with zero extra round trips). |
| 87 | payload: { | 93 | const previewExit = await doPreview({ payload: { spec: input.url } }); |
| 88 | spec: { kind: "url" as const, url: input.url }, | 94 | if (Exit.isFailure(previewExit)) return { ok: false, reason: "preview failed" }; |
| 89 | slug, | 95 | const previewSummary = previewExit.value; |
| 90 | name: input.name, | 96 | const detected = detectedAuthenticationTemplates( |
| 91 | ...(input.domain ? { displayDomain: input.domain } : {}), | 97 | previewSummary.headerPresets, |
| 92 | ...(preset?.specFormat ? { specFormat: preset.specFormat } : {}), | 98 | previewSummary.oauth2Presets, |
| 93 | ...(preset?.family ? { family: preset.family } : {}), | 99 | previewSummary.servers[0]?.url ?? "", |
| 94 | ...(preset?.healthCheck ? { healthCheck: preset.healthCheck } : {}), | 100 | ); |
| 95 | ...(presetOverrides && presetOverrides.length > 0 | 101 | const detectedHasApiKey = detected.some((template) => template.kind === "apikey"); |
| 96 | ? { specOverrides: presetOverrides } | 102 | authenticationTemplate = [ |
| 97 | : {}), | 103 | ...detected.map(openApiWireAuthInput), |
| 98 | ...(authenticationTemplate.length > 0 ? { authenticationTemplate } : {}), | 104 | ...(detectedHasApiKey |
| 99 | }, | 105 | ? [] |
| 100 | reactivityKeys: integrationWriteKeys, | 106 | : [openApiWireAuthInput(templateFromPlacements([registryPlacement]))]), |
| 101 | }); | 107 | ]; |
| 102 | if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" }; | 108 | } else if (presetMethods.length > 0 && registryPlacement) { |
| 103 | return { ok: true, slug: String(exit.value.slug) }; | 109 | authenticationTemplate = [ |
| 104 | }, | 110 | ...presetMethods, |
| 105 | [doAdd], | 111 | openApiWireAuthInput(templateFromPlacements([registryPlacement])), |
| 106 | ); | 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 | const importAdd = () => import("./AddOpenApiIntegration"); | 146 | const importAdd = () => import("./AddOpenApiIntegration"); |
| @@ -114,21 +151,27 @@ export interface OpenApiClientConfig { · 4 unmodified lines | |||
| 114 | readonly presets?: readonly IntegrationPreset[]; | 151 | readonly presets?: readonly IntegrationPreset[]; |
| 115 | } | 152 | } |
| 116 | 153 | ||
| 117 | export const createOpenApiIntegrationPlugin = ( | 154 | export const createOpenApiIntegrationPlugin = (config?: OpenApiClientConfig): IntegrationPlugin => { |
| 118 | config?: OpenApiClientConfig, | 155 | // Built-ins are registry-listed (the picker shows the registry's card); |
| 119 | ): IntegrationPlugin => ({ | 156 | // a deployment's custom presets are not, and keep their own cards. |
| 120 | key: "openapi", | 157 | const presets: readonly IntegrationPreset[] = [ |
| 121 | label: "OpenAPI", | 158 | ...openApiPresets.map((preset) => ({ ...preset, registryListed: true })), |
| 122 | add: lazy(importAdd), | 159 | ...(config?.presets ?? []), |
| 123 | editSheet: lazy(importEditSheet), | 160 | ]; |
| 124 | accounts: lazy(importAccounts), | 161 | return { |
| 125 | presets: [...openApiPresets, ...(config?.presets ?? [])], | 162 | key: "openapi", |
| 126 | preload: () => { | 163 | label: "OpenAPI", |
| 127 | void importAdd(); | 164 | add: lazy(importAdd), |
| 128 | void importEditSheet(); | 165 | editSheet: lazy(importEditSheet), |
| 129 | void importAccounts(); | 166 | accounts: lazy(importAccounts), |
| 130 | }, | 167 | presets, |
| 131 | useQuickAdd: useOpenApiQuickAdd, | 168 | preload: () => { |
| 132 | }); | 169 | void importAdd(); |
| 170 | void importEditSheet(); | ||
| 171 | void importAccounts(); | ||
| 172 | }, | ||
| 173 | useQuickAdd: makeUseQuickAdd(presets), | ||
| 174 | }; | ||
| 175 | }; | ||
| 133 | 176 | ||
| 134 | export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin(); | 177 | export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin(); |
| @@ -250,18 +250,35 @@ const connectUrlOf = (surface: SurfaceRecord, kind: CatalogKind): string | undef · 249 unmodified lines | |||
| 250 | ? surface.url | 250 | ? surface.url |
| 251 | : undefined; | 251 | : undefined; |
| 252 | 252 | ||
| 253 | const normalizedProductToken = (value: string): string => | ||
| 254 | value.toLowerCase().replace(/[^a-z0-9]/g, ""); | ||
| 255 | |||
| 253 | export const pickConnectTarget = ( | 256 | export const pickConnectTarget = ( |
| 254 | payload: unknown, | 257 | payload: unknown, |
| 255 | kind: CatalogKind, | 258 | kind: CatalogKind, |
| 259 | preferredName?: string, | ||
| 256 | ): CatalogConnectTarget | undefined => | 260 | ): CatalogConnectTarget | undefined => |
| 257 | Option.match(decodeSurfaceDocument(payload), { | 261 | Option.match(decodeSurfaceDocument(payload), { |
| 258 | onNone: () => undefined, | 262 | onNone: () => undefined, |
| 259 | onSome: ({ surfaces }) => { | 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 | const url = connectUrlOf(surface, kind); | 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 | export const resolveConnectTarget = ( | 287 | export const resolveConnectTarget = ( |
| 271 | domain: string, | 288 | domain: string, |
| 272 | kinds: readonly CatalogKind[], | 289 | kinds: readonly CatalogKind[], |
| 290 | preferredName?: string, | ||
| 273 | ): Effect.Effect<CatalogConnectTarget | undefined, CatalogRequestError> => { | 291 | ): Effect.Effect<CatalogConnectTarget | undefined, CatalogRequestError> => { |
| 274 | const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN); | 292 | const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN); |
| 275 | return Effect.map(fetchCatalogJson(url), (payload) => { | 293 | return Effect.map(fetchCatalogJson(url), (payload) => { |
| 276 | for (const kind of kinds) { | 294 | for (const kind of kinds) { |
| 277 | const target = pickConnectTarget(payload, kind); | 295 | const target = pickConnectTarget(payload, kind, preferredName); |
| 278 | if (target) return target; | 296 | if (target) return target; |
| 279 | } | 297 | } |
| 280 | return undefined; | 298 | return undefined; |
| @@ -331,18 +331,33 @@ type QuickAddFn = (input: IntegrationQuickAddInput) => Promise<IntegrationQuickA · 330 unmodified lines | |||
| 331 | * and mounting one bridge per plugin is how a fixed set of per-plugin hooks | 331 | * and mounting one bridge per plugin is how a fixed set of per-plugin hooks |
| 332 | * stays rules-of-hooks clean. */ | 332 | * stays rules-of-hooks clean. */ |
| 333 | function QuickAddBridge(props: { | 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 | readonly register: (key: string, fn: QuickAddFn | null) => void; | 339 | readonly register: (key: string, fn: QuickAddFn | null) => void; |
| 336 | }) { | 340 | }) { |
| 337 | const { plugin, register } = props; | 341 | const { pluginKey, useQuickAdd, register } = props; |
| 338 | const fn = plugin.useQuickAdd?.() ?? null; | 342 | const fn = useQuickAdd(); |
| 339 | useEffect(() => { | 343 | useEffect(() => { |
| 340 | register(plugin.key, fn); | 344 | register(pluginKey, fn); |
| 341 | return () => register(plugin.key, null); | 345 | return () => register(pluginKey, null); |
| 342 | }, [plugin.key, fn, register]); | 346 | }, [pluginKey, fn, register]); |
| 343 | return null; | 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 | // Page | 362 | // Page |
| 348 | // --------------------------------------------------------------------------- | 363 | // --------------------------------------------------------------------------- |
| @@ -599,11 +614,13 @@ export function IntegrationBrowsePage() { · 250 unmodified lines | |||
| 599 | kind: CatalogKind, | 614 | kind: CatalogKind, |
| 600 | title: string, | 615 | title: string, |
| 601 | rowKey: string, | 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 | 623 | const knownUrl = surface?.url; |
| 605 | // spending a round trip re-asking for something we were handed. | ||
| 606 | const surface = entry.surfaces?.find((candidate) => candidate.kind === kind); | ||
| 607 | if (knownUrl) { | 624 | if (knownUrl) { |
| 608 | const added = await tryQuickAdd({ | 625 | const added = await tryQuickAdd({ |
| 609 | kind, | 626 | kind, |
| @@ -629,7 +646,11 @@ export function IntegrationBrowsePage() { · 19 unmodified lines | |||
| 629 | if (resolvingDomain !== null) return; | 646 | if (resolvingDomain !== null) return; |
| 630 | setResolvingDomain(entry.domain); | 647 | setResolvingDomain(entry.domain); |
| 631 | setRowError(null); | 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 | setResolvingDomain(null); | 654 | setResolvingDomain(null); |
| 634 | if (Exit.isFailure(exit) || !exit.value) { | 655 | if (Exit.isFailure(exit) || !exit.value) { |
| 635 | setRowError({ | 656 | setRowError({ |
| @@ -682,7 +703,14 @@ export function IntegrationBrowsePage() { · 46 unmodified lines | |||
| 682 | const presetRows = useMemo<readonly Row[]>(() => { | 703 | const presetRows = useMemo<readonly Row[]>(() => { |
| 683 | const rows: Row[] = []; | 704 | const rows: Row[] = []; |
| 684 | for (const entry of allPresets) { | 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 | if (text.length > 0) { | 714 | if (text.length > 0) { |
| 687 | const corpus = | 715 | const corpus = |
| 688 | `${entry.preset.name} ${entry.preset.summary ?? ""} ${entry.preset.family ?? ""} ${entry.pluginLabel}`.toLowerCase(); | 716 | `${entry.preset.name} ${entry.preset.summary ?? ""} ${entry.preset.family ?? ""} ${entry.pluginLabel}`.toLowerCase(); |
| @@ -748,7 +776,8 @@ export function IntegrationBrowsePage() { · 59 unmodified lines | |||
| 748 | ...(description ? { description } : {}), | 776 | ...(description ? { description } : {}), |
| 749 | iconUrl: | 777 | iconUrl: |
| 750 | (known && "icon" in known ? known.icon : undefined) ?? catalogLogoUrl(entry.domain, 10), | 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 | added: | 781 | added: |
| 753 | quickAddedSlug !== undefined || isAdded(surface.kind, known?.slug, entry.domain, title), | 782 | quickAddedSlug !== undefined || isAdded(surface.kind, known?.slug, entry.domain, title), |
| 754 | ...(quickAddedSlug !== undefined ? { freshlyAdded: true } : {}), | 783 | ...(quickAddedSlug !== undefined ? { freshlyAdded: true } : {}), |
| @@ -880,8 +909,13 @@ export function IntegrationBrowsePage() { · 125 unmodified lines | |||
| 880 | // search, and ONLY the results grid scrolls — an endless list makes the | 909 | // search, and ONLY the results grid scrolls — an endless list makes the |
| 881 | // page scrollbar meaningless and drags the search box off screen. | 910 | // page scrollbar meaningless and drags the search box off screen. |
| 882 | <div className="flex min-h-0 flex-1 flex-col" data-slot="page-container"> | 911 | <div className="flex min-h-0 flex-1 flex-col" data-slot="page-container"> |
| 883 | {integrationPlugins.map((plugin) => ( | 912 | {quickAddCapablePlugins(integrationPlugins).map((plugin) => ( |
| 884 | <QuickAddBridge key={plugin.key} plugin={plugin} register={registerQuickAdd} /> | 913 | <QuickAddBridge |
| 914 | key={plugin.key} | ||
| 915 | pluginKey={plugin.key} | ||
| 916 | useQuickAdd={plugin.useQuickAdd!} | ||
| 917 | register={registerQuickAdd} | ||
| 918 | /> | ||
| 885 | ))} | 919 | ))} |
| 886 | <div className="mx-auto w-full max-w-4xl shrink-0 px-6 pt-10 lg:px-8 lg:pt-14"> | 920 | <div className="mx-auto w-full max-w-4xl shrink-0 px-6 pt-10 lg:px-8 lg:pt-14"> |
| 887 | <PageHeader | 921 | <PageHeader |