diff --git a/sources/cli.json b/sources/cli.json index da66a488..27cd3a39 100644 --- a/sources/cli.json +++ b/sources/cli.json @@ -20,6 +20,7 @@ { "name": "aws", "domain": "amazonaws.com", "install": "brew install awscli && aws configure", "docs": "https://docs.aws.amazon.com/cli/", "description": "The AWS command-line interface across every AWS service." }, { "name": "az", "domain": "azure.com", "install": "brew install azure-cli && az login", "docs": "https://learn.microsoft.com/cli/azure/", "description": "Manage Azure resources, identities, and deployments." }, { "name": "linear", "domain": "linear.app", "install": "npm i -g @linear/cli && linear auth", "docs": "https://linear.app/developers", "description": "Create and manage Linear issues and projects from the terminal." }, - { "name": "shopify", "domain": "shopify.com", "install": "npm i -g @shopify/cli && shopify auth login", "docs": "https://shopify.dev/docs/api/shopify-cli", "description": "Build, test, and deploy Shopify apps and themes." } + { "name": "shopify", "domain": "shopify.com", "install": "npm i -g @shopify/cli && shopify auth login", "docs": "https://shopify.dev/docs/api/shopify-cli", "description": "Build, test, and deploy Shopify apps and themes." }, + { "name": "clerk", "domain": "clerk.com", "install": "npm i -g clerk && clerk auth login", "docs": "https://clerk.com/docs/cli", "repo": "https://github.com/clerk/cli", "description": "Set up Clerk auth, manage users and apps, and call the Clerk API from the terminal." } ] } diff --git a/sources/openapi-manual.json b/sources/openapi-manual.json index 0d6b1bf8..8756d346 100644 --- a/sources/openapi-manual.json +++ b/sources/openapi-manual.json @@ -17,6 +17,45 @@ "updated": "2026-06-29T00:00:00.000Z", "added": "2026-06-29T00:00:00.000Z" }, + { + "provider": "clerk.com", + "providerName": "Clerk", + "service": "Backend API", + "versionKey": "latest", + "title": "Clerk Backend API", + "description": "The Clerk REST Backend API, meant to be accessed by backend servers — manage users, organizations, sessions, and instance configuration at https://api.clerk.com/v1 with a secret-key bearer token.", + "openapiVer": "3.0.3", + "origin": "https://clerk.com/docs/reference/spec/bapi/latest.yml", + "categories": ["security", "developer_tools"], + "updated": "2026-07-06T00:00:00.000Z", + "added": "2026-07-06T00:00:00.000Z" + }, + { + "provider": "clerk.com", + "providerName": "Clerk", + "service": "Frontend API", + "versionKey": "latest", + "title": "Clerk Frontend API", + "description": "The Clerk REST Frontend API, accessed from browsers and native clients — the form-based API that powers Clerk's SDKs, served per-instance (e.g. https://{slug}.clerk.accounts.dev in development).", + "openapiVer": "3.0.3", + "origin": "https://clerk.com/docs/reference/spec/fapi/latest.yml", + "categories": ["security", "developer_tools"], + "updated": "2026-07-06T00:00:00.000Z", + "added": "2026-07-06T00:00:00.000Z" + }, + { + "provider": "clerk.com", + "providerName": "Clerk", + "service": "Platform API", + "versionKey": "beta", + "title": "Clerk Platform API", + "description": "The Clerk REST Platform API for programmatic management of workspace resources — applications, domains, and transfers — at https://api.clerk.com/v1 with a Platform access token. Currently in private beta.", + "openapiVer": "3.0.3", + "origin": "https://clerk.com/docs/reference/spec/platform/latest.yml", + "categories": ["security", "developer_tools"], + "updated": "2026-07-06T00:00:00.000Z", + "added": "2026-07-06T00:00:00.000Z" + }, { "provider": "datadoghq.com", "providerName": "Datadog", diff --git a/src/lib/discover.test.ts b/src/lib/discover.test.ts index 3218437c..c76b2190 100644 --- a/src/lib/discover.test.ts +++ b/src/lib/discover.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { PROBE_KEYS } from "./conventions.ts"; -import { discover, slackMcpAppManifestUrl, type ChatFn, type WebBackend } from "./discover.ts"; +import { discover, mergeDeclared, slackMcpAppManifestUrl, type ChatFn, type WebBackend } from "./discover.ts"; import type { DetectionResult } from "./detect.ts"; const web: WebBackend = { @@ -189,3 +189,159 @@ describe("discover MCP onboarding overrides", () => { expect(setup).not.toContain("api.slack.com/apps?new_app=1"); }); }); + +describe("mergeDeclared", () => { + const declaredSource = "https://clerk.example/.well-known/integrations.json"; + const declaredBasis = { via: "declared", source: declaredSource } as const; + + function resultWith(surfaces: object[]): Parameters[0] { + return { + version: 3, + domain: "clerk.example", + summary: "", + credentials: {}, + surfaces, + } as Parameters[0]; + } + + function detectionWith(surfaces: object[]): DetectionResult { + return { + ...baseDetection(""), + integrationsJson: { + url: declaredSource, + result: { version: 3, credentials: {}, surfaces }, + }, + } as DetectionResult; + } + + test("merges a declared surface into its discovered twin when only one carries a spec", () => { + const r = resultWith([ + { + type: "http", + slug: "backend-api", + name: "Backend API", + url: "https://api.clerk.example/v1", + basis: { via: "discovered", evidence: [] }, + auth: { status: "unknown" }, + }, + ]); + mergeDeclared( + r, + detectionWith([ + { + type: "http", + slug: "backend-api", + name: "Backend API", + url: "https://api.clerk.example/v1", + spec: "https://clerk.example/spec/bapi/latest.yml", + basis: declaredBasis, + auth: { status: "unknown" }, + }, + ]), + ); + + expect(r.surfaces).toHaveLength(1); + expect(r.surfaces[0].slug).toBe("backend-api"); + expect(r.surfaces[0].spec).toBe("https://clerk.example/spec/bapi/latest.yml"); + expect(r.surfaces[0].basis.via).toBe("declared"); + }); + + test("falls back to type+name when locator urls are templated differently", () => { + const r = resultWith([ + { + type: "http", + slug: "frontend-api", + name: "Frontend API", + url: "https://{domain}.accounts.example", + basis: { via: "discovered", evidence: [] }, + auth: { status: "unknown" }, + }, + ]); + mergeDeclared( + r, + detectionWith([ + { + type: "http", + slug: "frontend-api", + name: "Frontend API", + url: "https://{frontend_api}", + spec: "https://clerk.example/spec/fapi/latest.yml", + basis: declaredBasis, + auth: { status: "unknown" }, + }, + ]), + ); + + expect(r.surfaces).toHaveLength(1); + expect(r.surfaces[0].spec).toBe("https://clerk.example/spec/fapi/latest.yml"); + }); + + test("still appends genuinely new declared surfaces", () => { + const r = resultWith([ + { + type: "http", + slug: "backend-api", + name: "Backend API", + url: "https://api.clerk.example/v1", + basis: { via: "discovered", evidence: [] }, + auth: { status: "unknown" }, + }, + ]); + mergeDeclared( + r, + detectionWith([ + { + type: "http", + slug: "platform-api", + name: "Platform API", + url: "https://api.clerk.example/v1#platform", + basis: declaredBasis, + auth: { status: "unknown" }, + }, + ]), + ); + + expect(r.surfaces).toHaveLength(2); + expect(r.surfaces.map((s) => s.slug)).toEqual(["backend-api", "platform-api"]); + }); + + test("does not merge distinct spec-bearing APIs that share a base url", () => { + const r = resultWith([ + { + type: "http", + slug: "backend-api", + name: "Backend API", + url: "https://api.clerk.example/v1", + basis: { via: "discovered", evidence: [] }, + auth: { status: "unknown" }, + }, + ]); + mergeDeclared( + r, + detectionWith([ + { + type: "http", + slug: "backend-api", + name: "Backend API", + url: "https://api.clerk.example/v1", + spec: "https://clerk.example/spec/bapi/latest.yml", + basis: declaredBasis, + auth: { status: "unknown" }, + }, + { + type: "http", + slug: "platform-api", + name: "Platform API", + url: "https://api.clerk.example/v1", + spec: "https://clerk.example/spec/platform/latest.yml", + basis: declaredBasis, + auth: { status: "unknown" }, + }, + ]), + ); + + expect(r.surfaces.map((s) => s.slug)).toEqual(["backend-api", "platform-api"]); + expect(r.surfaces[0].spec).toBe("https://clerk.example/spec/bapi/latest.yml"); + expect(r.surfaces[1].spec).toBe("https://clerk.example/spec/platform/latest.yml"); + }); +}); diff --git a/src/lib/discover.ts b/src/lib/discover.ts index 021f9857..9ba40467 100644 --- a/src/lib/discover.ts +++ b/src/lib/discover.ts @@ -736,9 +736,24 @@ function markDeclaredSurface(surface: Surface, source: string): Surface { return surface; } -function surfaceLocator(s: Surface): string { - const packageId = s.packages?.[0]?.identifier; - return `${s.type}|${(s.spec || s.url || s.command || packageId || s.name).toLowerCase()}`; +/** Every key a surface can be recognized by. A declared surface must merge + * into its discovered twin even when one carries a spec and the other only a + * base url (or their urls are templated differently), so matching tries each + * facet — ending with type+name, the same pair the batch validator treats as + * a duplicate. A surface with a spec does NOT expose its url as a facet: + * distinct APIs can share a base url (Clerk's Backend and Platform APIs both + * live at api.clerk.com/v1), and the spec is what tells them apart. */ +function surfaceLocators(s: Surface): string[] { + const keys: string[] = []; + const add = (value: string | undefined) => { + if (value) keys.push(`${s.type}|${value.toLowerCase()}`); + }; + add(s.spec); + if (!s.spec) add(s.url); + add(s.command); + add(s.packages?.[0]?.identifier); + add(s.name); + return keys; } function entryKey(entry: AuthEntry): string { @@ -802,7 +817,7 @@ function mergeDeclaredSurface(existing: Surface, declared: Surface): void { mergeDeclaredAuth(existing, declared); } -function mergeDeclared(r: DiscoveryResult, detect: DetectionResult, emit?: Emit): void { +export function mergeDeclared(r: DiscoveryResult, detect: DetectionResult, emit?: Emit): void { const declared = detect.integrationsJson; if (!declared?.result) return; const source = declared.url; @@ -812,11 +827,17 @@ function mergeDeclared(r: DiscoveryResult, detect: DetectionResult, emit?: Emit) emit?.({ kind: "credential", id, credential: r.credentials[id] }); } } - const byLocator = new Map(r.surfaces.map((surface) => [surfaceLocator(surface), surface])); + const byLocator = new Map(); + const index = (surface: Surface): void => { + for (const key of surfaceLocators(surface)) if (!byLocator.has(key)) byLocator.set(key, surface); + }; + for (const surface of r.surfaces) index(surface); for (const rawSurface of declared.result.surfaces ?? []) { const surface = markDeclaredSurface(cloneJson(rawSurface) as Surface, source); if (!surface.slug) surface.slug = assignSlug(surface.name || "Declared surface", r.surfaces); - const existing = byLocator.get(surfaceLocator(surface)); + const existing = surfaceLocators(surface) + .map((key) => byLocator.get(key)) + .find(Boolean); if (existing) { mergeDeclaredSurface(existing, surface); emit?.({ kind: "surface", surface: existing }); @@ -824,7 +845,7 @@ function mergeDeclared(r: DiscoveryResult, detect: DetectionResult, emit?: Emit) } surface.slug = assignSlug(surface.slug || surface.name || "Declared surface", r.surfaces); r.surfaces.push(surface); - byLocator.set(surfaceLocator(surface), surface); + index(surface); emit?.({ kind: "surface", surface }); } }