History

Registry auth for OpenAPI and preset add flows; product-key matching

GitHub's official OpenAPI description declares no securitySchemes, so the add flow offered OAuth (from the preset template) and nothing else, while a PAT bearer header is how most calls actually authenticate. The registry's declared placement now seeds an API key method alongside detected OAuth; a spec-declared key method still wins over the registry's version of the same fact. Preset-to-catalog matching moves from rendered titles to a product key (normalized name plus kind), so 'GitHub REST' and the registry's 'GitHub API' count as one product — titles and domains both proved unreliable joins. MCP surfaces carry their auth facts too.

Rhys Sullivan committed 2 months ago

Commit 97c4ef41 parent d3acfe32 files · +50 −16Browse files

Unified · Split

2 files
  1. packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx +21 −2
  2. packages/react/src/pages/integration-browse.tsx +29 −14
packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx+21−2
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
25 type AuthMethodRow,25 type AuthMethodRow,
26 type AuthMethodSeed,26 type AuthMethodSeed,
27} from "@executor-js/react/components/auth-method-list-editor";27} from "@executor-js/react/components/auth-method-list-editor";
28import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
28import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";29import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";
29import { FieldLabel } from "@executor-js/react/components/field";30import { FieldLabel } from "@executor-js/react/components/field";
30import { FloatActions } from "@executor-js/react/components/float-actions";31import { FloatActions } from "@executor-js/react/components/float-actions";
@@ -170,6 +171,8 @@ export default function AddOpenApiIntegration(props: { · 139 unmodified lines
170 initialUrl?: string;171 initialUrl?: string;
171 initialPreset?: string;172 initialPreset?: string;
172 initialNamespace?: string;173 initialNamespace?: string;
174 initialAuthHeader?: string;
175 initialAuthNote?: string;
173}) {176}) {
174 const integrationPlugins = useIntegrationPlugins();177 const integrationPlugins = useIntegrationPlugins();
175 const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi");178 const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi");
@@ -297,14 +300,26 @@ export default function AddOpenApiIntegration(props: { · 121 unmodified lines
297 ...(preview?.headerPresets ?? []).map((preset) => preset.label),300 ...(preview?.headerPresets ?? []).map((preset) => preset.label),
298 ...(preview?.oauth2Presets ?? []).map((preset) => preset.label),301 ...(preview?.oauth2Presets ?? []).map((preset) => preset.label),
299 ];302 ];
300 return authenticationTemplate.map(303 const detected = authenticationTemplate.map(
301 (template: Authentication, index: number): AuthMethodSeed => ({304 (template: Authentication, index: number): AuthMethodSeed => ({
302 value: editorValueFromAuthentication(template),305 value: editorValueFromAuthentication(template),
303 slug: String(template.slug),306 slug: String(template.slug),
304 ...(labels[index] !== undefined ? { label: labels[index] } : {}),307 ...(labels[index] !== undefined ? { label: labels[index] } : {}),
305 }),308 }),
306 );309 );
307 }, [preview, authenticationTemplate]);310 if (preview === null) return detected;
311 // The registry's declared credential placement fills what the document
312 // leaves out. GitHub's official description famously declares no
313 // securitySchemes at all, and even with the OAuth preset template a PAT
314 // bearer header is how most calls actually authenticate — so the key
315 // method is offered alongside OAuth. A spec-declared key method wins over
316 // the registry's version of the same fact.
317 const placement = props.initialAuthHeader
318 ? placementFromHeaderPattern(props.initialAuthHeader)
319 : null;
320 if (!placement || detected.some((seed) => seed.value.kind === "apikey")) return detected;
321 return [...detected, { value: { kind: "apikey", placements: [placement] } }];
322 }, [preview, authenticationTemplate, props.initialAuthHeader]);
308 const authMethodList = useAuthMethodList(authMethodSeeds);323 const authMethodList = useAuthMethodList(authMethodSeeds);
309 324
310 // The methods to register, mapped back to stored `Authentication[]`. Drops325 // The methods to register, mapped back to stored `Authentication[]`. Drops
@@ -601,6 +616,10 @@ export default function AddOpenApiIntegration(props: { · 290 unmodified lines
601 />616 />
602 )}617 )}
603 618
619 {preview && props.initialAuthNote ? (
620 <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p>
621 ) : null}
622
604 {preview ? (623 {preview ? (
605 <AddOpenApiHealthCheckSection624 <AddOpenApiHealthCheckSection
606 presetHealthCheck={activePreset?.healthCheck}625 presetHealthCheck={activePreset?.healthCheck}
packages/react/src/pages/integration-browse.tsx+29−14
@@ -98,6 +98,16 @@ const withSurface = (name: string, surface: string): string => { · 97 unmodified lines
98 return already ? name : `${name} ${surface}`;98 return already ? name : `${name} ${surface}`;
99};99};
100 100
101/** The key on which a preset and a catalog row count as the same product:
102 * the name with surface words and punctuation stripped, plus the kind.
103 * "GitHub REST" and "GitHub API" are one product; "Stripe API" and
104 * "Stripe MCP" are not. */
105const productKey = (name: string, kind: string): string =>
106 `${name
107 .toLowerCase()
108 .replace(/\b(api|rest|graphql|mcp)\b/g, "")
109 .replace(/[^a-z0-9]/g, "")}|${kind}`;
110
101const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = {111const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = {
102 high: 3,112 high: 3,
103 medium: 2,113 medium: 2,
@@ -151,6 +161,8 @@ interface Row { · 47 unmodified lines
151 readonly key: string;161 readonly key: string;
152 readonly testId: string;162 readonly testId: string;
153 readonly title: string;163 readonly title: string;
164 /** Surface kind for product matching (productKey); not rendered. */
165 readonly kindKey: string;
154 /** The domain, shown beside a prettified title so near-namesakes stay166 /** The domain, shown beside a prettified title so near-namesakes stay
155 * distinguishable. Omitted when the title is already the domain. */167 * distinguishable. Omitted when the title is already the domain. */
156 readonly domain?: string;168 readonly domain?: string;
@@ -492,8 +504,8 @@ export function IntegrationBrowsePage() { · 335 unmodified lines
492 // catalog row for the same product must not read as two different kinds of504 // catalog row for the same product must not read as two different kinds of
493 // thing — the row shows the registry's identity (the domain) like every505 // thing — the row shows the registry's identity (the domain) like every
494 // other result, and the preset only supplies the better add flow. Keyed by506 // other result, and the preset only supplies the better add flow. Keyed by
495 // the rendered title, the same match the dedupe below uses.507 // productKey, the same match the dedupe below uses.
496 const catalogIdentityByTitle = useMemo(() => {508 const catalogIdentityByKey = useMemo(() => {
497 const map = new Map<509 const map = new Map<
498 string,510 string,
499 { readonly domain: string; readonly auth?: CatalogSurface["auth"] }511 { readonly domain: string; readonly auth?: CatalogSurface["auth"] }
@@ -505,10 +517,9 @@ export function IntegrationBrowsePage() { · 5 unmodified lines
505 ? entry.surfaces517 ? entry.surfaces
506 : entry.kinds.map((kind) => ({ kind }));518 : entry.kinds.map((kind) => ({ kind }));
507 for (const surface of surfaces) {519 for (const surface of surfaces) {
508 const word = SURFACE_WORD[surface.kind] ?? CATALOG_KIND_LABEL[surface.kind];520 const key = productKey(pretty, surface.kind);
509 const title = withSurface(pretty, word).toLowerCase();521 if (!map.has(key)) {
510 if (!map.has(title)) {522 map.set(key, {
511 map.set(title, {
512 domain: entry.domain,523 domain: entry.domain,
513 ...("auth" in surface && surface.auth ? { auth: surface.auth } : {}),524 ...("auth" in surface && surface.auth ? { auth: surface.auth } : {}),
514 });525 });
@@ -529,11 +540,13 @@ export function IntegrationBrowsePage() { · 14 unmodified lines
529 }540 }
530 const surface = SURFACE_WORD[entry.pluginKey] ?? entry.pluginLabel;541 const surface = SURFACE_WORD[entry.pluginKey] ?? entry.pluginLabel;
531 const title = withSurface(entry.preset.name, surface);542 const title = withSurface(entry.preset.name, surface);
532 const identity = catalogIdentityByTitle.get(title.toLowerCase());543 const kindKey = entry.pluginKey === "google" ? "openapi" : entry.pluginKey;
544 const identity = catalogIdentityByKey.get(productKey(entry.preset.name, kindKey));
533 rows.push({545 rows.push({
534 key: `preset-${entry.pluginKey}-${entry.preset.id}`,546 key: `preset-${entry.pluginKey}-${entry.preset.id}`,
535 testId: `preset-${entry.preset.id}`,547 testId: `preset-${entry.preset.id}`,
536 title,548 title,
549 kindKey,
537 ...(identity ? { domain: identity.domain } : {}),550 ...(identity ? { domain: identity.domain } : {}),
538 ...(entry.preset.summary ? { description: entry.preset.summary } : {}),551 ...(entry.preset.summary ? { description: entry.preset.summary } : {}),
539 ...(entry.preset.icon ? { iconUrl: entry.preset.icon } : {}),552 ...(entry.preset.icon ? { iconUrl: entry.preset.icon } : {}),
@@ -543,7 +556,7 @@ export function IntegrationBrowsePage() { · 3 unmodified lines
543 });556 });
544 }557 }
545 return rows;558 return rows;
546 }, [allPresets, kind, text, pickPreset, isAdded, catalogIdentityByTitle]);559 }, [allPresets, kind, text, pickPreset, isAdded, catalogIdentityByKey]);
547 560
548 // --- Catalog rows: one per (service, surface) -----------------------------561 // --- Catalog rows: one per (service, surface) -----------------------------
549 const catalogRows = useMemo<readonly Row[]>(() => {562 const catalogRows = useMemo<readonly Row[]>(() => {
@@ -567,6 +580,7 @@ export function IntegrationBrowsePage() { · 17 unmodified lines
567 key: `catalog-${entry.domain}-${surface.kind}-${known?.slug ?? pretty}`,580 key: `catalog-${entry.domain}-${surface.kind}-${known?.slug ?? pretty}`,
568 testId: `catalog-${known?.slug ?? `${entry.domain}-${surface.kind}`}`,581 testId: `catalog-${known?.slug ?? `${entry.domain}-${surface.kind}`}`,
569 title: withSurface(pretty, word),582 title: withSurface(pretty, word),
583 kindKey: surface.kind,
570 domain: entry.domain,584 domain: entry.domain,
571 ...(description ? { description } : {}),585 ...(description ? { description } : {}),
572 iconUrl: catalogLogoUrl(entry.domain, 10),586 iconUrl: catalogLogoUrl(entry.domain, 10),
@@ -577,12 +591,13 @@ export function IntegrationBrowsePage() { · 4 unmodified lines
577 });591 });
578 });592 });
579 // A registry row for something a preset already offers is a worse copy of593 // A registry row for something a preset already offers is a worse copy of
580 // it: same service, no auth template, no health check. Matched on the594 // it: same service, no auth template, no health check. Matched on
581 // rendered title because a preset's domain is inferred from its icon and595 // productKey — normalized name plus kind — because neither titles nor
582 // often does not match the registry's (the Gmail preset's icon is Google's,596 // domains line up reliably: the Gmail preset's icon is Google's (so a
583 // so a domain comparison let a second "Gmail API" through).597 // domain comparison let a second "Gmail API" through), and the GitHub
584 const presetTitles = new Set(presetRows.map((row) => row.title.toLowerCase()));598 // preset says "GitHub REST" where the registry row says "GitHub API".
585 rows = rows.filter((row) => !presetTitles.has(row.title.toLowerCase()));599 const presetKeys = new Set(presetRows.map((row) => productKey(row.title, row.kindKey)));
600 rows = rows.filter((row) => !presetKeys.has(productKey(row.title, row.kindKey)));
586 if (text.length === 0) return rows;601 if (text.length === 0) return rows;
587 // A name match beats a mention in the blurb: searching "gmail" should not602 // A name match beats a mention in the blurb: searching "gmail" should not
588 // rank a CRM that merely describes itself as living inside Gmail above603 // rank a CRM that merely describes itself as living inside Gmail above

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t