import { useMemo } from "react"; import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { OAuthClientSlug, type IntegrationSlug, type OAuthClientOrigin, type Owner, } from "@executor-js/sdk/shared"; import { getDomain } from "tldts"; import { oauthClientsOptimisticAtom } from "../api/atoms"; // --------------------------------------------------------------------------- // OAuth client (registered app) selection for an integration's connect flow. // // An owner can register MANY apps; each is a distinct owner-scoped `oauth_client` // row with its own slug. The connect flow lists the MANUAL apps usable for an // integration and lets the user pick one or register a new one. Matching is by // recorded intent first (an app registered from this integration's dialog, or // whose OAuth endpoint HOST exactly matches the integration's declared host), // then by registrable root domain as a subdued near-miss tier. User-owned apps // are listed before workspace ones. // // DCR (dynamic client registration) clients are plumbing, not apps: they are // minted automatically and reused per authorization server, so they NEVER appear // in this picker. They stay visible for management elsewhere (the modal's // "Auto-registered clients" surface), keyed off `origin.kind`. // --------------------------------------------------------------------------- export interface OAuthClientOption { readonly owner: Owner; readonly slug: OAuthClientSlug; readonly grant: "authorization_code" | "client_credentials"; readonly authorizationUrl: string; readonly tokenUrl: string; readonly clientId: string; /** How the app came to exist. `manual` apps are pickable (with `integration` * recording which dialog registered them); `dynamic_client_registration` * apps are excluded from the picker entirely. */ readonly origin: OAuthClientOrigin; } /** True for the auto-minted DCR clients that must never surface in the app * picker. Legacy null-origin rows are already classified by the backend's * `parseOAuthClientOrigin` heuristic, so this reads the parsed kind directly. */ export const isDcrClient = (app: OAuthClientOption): boolean => app.origin.kind === "dynamic_client_registration"; const hostOf = (url: string): string | undefined => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host" try { return new URL(url).host.toLowerCase(); } catch { return undefined; } }; /** The registrable ("tld+1") root domain of a URL, e.g. * `accounts.google.com` → `google.com`. Falls back to the full host for * localhost / IP literals (where `tldts.getDomain` returns null) so local-dev * MCP servers still match by exact host. Returns undefined for unparseable URLs. */ const getRootDomain = (url: string): string | undefined => { const root = getDomain(url); if (root) return root.toLowerCase(); return hostOf(url); }; export interface UseOAuthClientsResult { /** Tier 1 — apps that match this integration by RECORDED INTENT (registered * from its dialog) or EXACT endpoint host, user-owned first. These are the * apps that belong to this integration; the picker defaults to the first one. * When an endpoint was declared but nothing matched exactly, this is EMPTY. */ readonly clients: readonly OAuthClientOption[]; /** Tier 2 — apps that only match by registrable root domain (a near-miss: same * provider family, different exact host / not stamped to this integration). * Surfaced in a visually separate, subdued "Other apps on this provider" * section — NEVER silently mixed into `clients`. Present whenever there are * such near-misses, even when tier 1 is non-empty. */ readonly nearMatches: readonly OAuthClientOption[]; /** Unrelated apps (different root domain), kept only as the opt-in escape hatch * ("use a different registered app") when NOTHING matched the declared * endpoint at all. Empty once anything in tier 1 or tier 2 matched. */ readonly otherClients: readonly OAuthClientOption[]; /** True until the clients list has loaded at least once. */ readonly loading: boolean; /** * Whether an app is EXACT-matched (tier 1) to the integration. * * - `true` — either no endpoint filter was requested (the integration * declares no endpoints), or at least one app matched by recorded intent or * exact endpoint host. `clients` is that tier-1 subset. * - `false` — the integration declared endpoint(s) but no app matched exactly. * `clients` is EMPTY (the UI shows an empty state + a register CTA). Any * root-domain near-misses are still offered via `nearMatches`; truly * unrelated apps via `otherClients`. */ readonly endpointMatched: boolean; /** Convenience flag for the UI: a register-an-app CTA should be shown because * an endpoint was declared and nothing exact-matched. Equals `!endpointMatched` * once loaded. */ readonly displayRegisterCTA: boolean; } /** Stable empty-list reference so the picker memo doesn't re-run while the * optimistic clients atom is still loading (a fresh `[]` each render would * invalidate the memo key). */ const EMPTY_CLIENTS: readonly OAuthClientOption[] = []; /** Host/root equality that matches ONLY when both sides parsed to a real value. * `hostOf`/`getRootDomain` return undefined for URLs `new URL()` can't parse, * so a bare `a === b` would treat two unparseable endpoints as equal * (`undefined === undefined`). Every host/root comparison in this module must * go through this so an unparseable value never counts as a match. */ const hostEq = (a: string | undefined, b: string | undefined): boolean => a !== undefined && b !== undefined && a === b; /** Sort apps user-owned first (so the user's own apps surface before shared * workspace apps). */ const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClientOption[] => [...apps].sort((a: OAuthClientOption, b: OAuthClientOption) => a.owner === b.owner ? 0 : a.owner === "user" ? -1 : 1, ); /** * Pure matcher (no React/atoms) — split owner-visible apps into three honest * tiers for an integration's connect picker. * * FIRST, DCR clients are dropped entirely: they are auto-minted plumbing reused * per authorization server, not apps a user picks. They stay manageable through * a separate surface, never the picker. * * Then the remaining MANUAL apps are graded: * - `matched` (tier 1): the app was registered from THIS integration's dialog * (`origin.integration === integration`, recorded intent), OR its OAuth * endpoint HOST exactly matches the integration's declared host (token host * when a token endpoint is declared, else the authorize host). This is the * subset that truly belongs to the integration. * - `nearMatches` (tier 2): the app shares the integration's REGISTRABLE ROOT * DOMAIN ("tld+1") but is not a tier-1 match — a same-provider near-miss (e.g. * an app on `mcp.cloudflare.com` for a REST integration on `api.cloudflare.com`). * Silently promoting these into tier 1 is the bug this change fixes. * - `unmatched`: unrelated provider (different root), offered only as the escape * hatch when nothing matched at all. * * When the integration declares no endpoints (and no match is required), every * manual app is tier 1 (no filter to grade against). */ export function selectClientsForEndpoints( all: readonly OAuthClientOption[], endpoints: { readonly tokenUrl?: string; readonly authorizationUrl?: string; /** The integration whose picker this is. A manual app stamped with this * integration (recorded intent) is a tier-1 match regardless of host. */ readonly integration?: IntegrationSlug; /** When set, an integration that targets a SPECIFIC server (MCP, whose * endpoints are discovered at connect) must match by endpoint — absent * endpoints mean NO match (show the register CTA), never "every app * matches". Prevents auto-selecting an unrelated provider's app. */ readonly requireEndpointMatch?: boolean; }, ): { readonly matched: readonly OAuthClientOption[]; readonly nearMatches: readonly OAuthClientOption[]; readonly unmatched: readonly OAuthClientOption[]; readonly endpointMatched: boolean; } { // DCR clients are plumbing, never picker options. const manual = all.filter((app) => !isDcrClient(app)); const intent = endpoints.integration; const matchesIntent = (app: OAuthClientOption): boolean => intent != null && app.origin.kind === "manual" && app.origin.integration != null && app.origin.integration === intent; const wantedTokenHost = endpoints.tokenUrl ? hostOf(endpoints.tokenUrl) : undefined; const wantedAuthorizationHost = endpoints.authorizationUrl ? hostOf(endpoints.authorizationUrl) : undefined; const wantedTokenRoot = endpoints.tokenUrl ? getRootDomain(endpoints.tokenUrl) : undefined; const wantedAuthorizationRoot = endpoints.authorizationUrl ? getRootDomain(endpoints.authorizationUrl) : undefined; // No declared endpoints. A server-targeting integration must NOT match every // app (it would auto-select an unrelated provider); surface the register CTA // instead. Otherwise (no endpoint filter at all) every manual app is usable. if (!wantedTokenRoot && !wantedAuthorizationRoot) { if (endpoints.requireEndpointMatch) { // Even with no declared endpoints, an app registered from THIS dialog is a // real tier-1 match (the user built it here on purpose). const matched = manual.filter(matchesIntent); const rest = manual.filter((app) => !matchesIntent(app)); return { matched: sortUserFirst(matched), nearMatches: [], unmatched: sortUserFirst(rest), endpointMatched: matched.length > 0, }; } return { matched: sortUserFirst(manual), nearMatches: [], unmatched: [], endpointMatched: true, }; } const matched: OAuthClientOption[] = []; const nearMatches: OAuthClientOption[] = []; const unmatched: OAuthClientOption[] = []; for (const app of manual) { const appTokenHost = hostOf(app.tokenUrl); const appAuthorizationHost = hostOf(app.authorizationUrl); const appTokenRoot = getRootDomain(app.tokenUrl); const appAuthorizationRoot = getRootDomain(app.authorizationUrl); // Exact HOST match against the declared endpoint (token host first, since the // token endpoint is what the SDK actually calls; authorize host as fallback // when no token endpoint was declared). const exactHostMatch = wantedTokenHost ? hostEq(appTokenHost, wantedTokenHost) : hostEq(appAuthorizationHost, wantedAuthorizationHost) || hostEq(appTokenHost, wantedAuthorizationHost); // Same registrable root domain (the old, looser heuristic) — now only a // tier-2 signal. const rootMatch = wantedTokenRoot ? hostEq(appTokenRoot, wantedTokenRoot) : hostEq(appAuthorizationRoot, wantedAuthorizationRoot) || hostEq(appTokenRoot, wantedAuthorizationRoot); if (matchesIntent(app) || exactHostMatch) matched.push(app); else if (rootMatch) nearMatches.push(app); else unmatched.push(app); } return { matched: sortUserFirst(matched), nearMatches: sortUserFirst(nearMatches), unmatched: sortUserFirst(unmatched), endpointMatched: matched.length > 0, }; } export function useOAuthClientsForIntegration(opts: { readonly tokenUrl?: string; readonly authorizationUrl?: string; readonly integration?: IntegrationSlug; readonly requireEndpointMatch?: boolean; }): UseOAuthClientsResult { // Read the optimistic list so a just-registered/edited/removed app paints // immediately, instead of flashing the stale server list until the refetch // lands. The modal's management menu reads the same optimistic atom, so the // picker rows and their actions stay consistent. const clientsResult = useAtomValue(oauthClientsOptimisticAtom); const loaded = AsyncResult.isSuccess(clientsResult); const all = loaded ? (clientsResult.value as readonly OAuthClientOption[]) : EMPTY_CLIENTS; // Memoize the grade: `selectClientsForEndpoints` parses every client's URLs // with tldts, and the modal passes a FRESH inline options object each render, // so without this every keystroke would re-grade all clients. Key on the // primitive inputs plus the `all` array reference (a new optimistic list is a // new array), NOT on `opts` identity. const selection = useMemo( () => selectClientsForEndpoints(all, { tokenUrl: opts.tokenUrl, authorizationUrl: opts.authorizationUrl, integration: opts.integration, requireEndpointMatch: opts.requireEndpointMatch, }), [all, opts.tokenUrl, opts.authorizationUrl, opts.integration, opts.requireEndpointMatch], ); if (!loaded) { return { clients: [], nearMatches: [], otherClients: [], loading: true, endpointMatched: true, displayRegisterCTA: false, }; } const { matched, nearMatches, unmatched, endpointMatched } = selection; // EXPLICIT outcome: `clients` is the tier-1 (exact/intent) subset the picker // defaults into. `nearMatches` (root-domain-only) is always surfaced, but in a // separate subdued section so it is never mistaken for a real match. When an // endpoint was declared but nothing exact-matched, `clients` is EMPTY and a // register CTA is flagged; the unrelated `unmatched` apps stay in `otherClients` // as the last-resort escape hatch. return { clients: endpointMatched ? matched : [], nearMatches, otherClients: endpointMatched ? [] : unmatched, loading: false, endpointMatched, displayRegisterCTA: !endpointMatched, }; } /** * The auto-registered (DCR) clients relevant to an integration, for the * management surface (list + delete). These are hidden from the app picker but * must stay visible SOMEWHERE. A DCR client is relevant when it records this * integration as its origin (Part A stamps `origin.integration`), or — for * legacy clients minted before the stamp — when its endpoints share the * integration's declared/discovered root domain. */ export function selectDcrClientsForIntegration( all: readonly OAuthClientOption[], opts: { readonly integration?: IntegrationSlug; readonly tokenUrl?: string; readonly authorizationUrl?: string; }, ): readonly OAuthClientOption[] { const wantedTokenRoot = opts.tokenUrl ? getRootDomain(opts.tokenUrl) : undefined; const wantedAuthorizationRoot = opts.authorizationUrl ? getRootDomain(opts.authorizationUrl) : undefined; const relevant = all.filter((app) => { if (!isDcrClient(app)) return false; if ( opts.integration != null && app.origin.kind === "dynamic_client_registration" && app.origin.integration != null && app.origin.integration === opts.integration ) { return true; } if (!wantedTokenRoot && !wantedAuthorizationRoot) return false; const appTokenRoot = getRootDomain(app.tokenUrl); const appAuthorizationRoot = getRootDomain(app.authorizationUrl); return ( hostEq(appTokenRoot, wantedTokenRoot) || hostEq(appAuthorizationRoot, wantedTokenRoot) || hostEq(appAuthorizationRoot, wantedAuthorizationRoot) || hostEq(appTokenRoot, wantedAuthorizationRoot) ); }); return sortUserFirst(relevant); } const slugifyName = (name: string): string => name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); /** A unique OAuth client slug derived from a display name, deduped against the * owner's existing client slugs. */ export function uniqueClientSlug(name: string, existing: readonly string[]): OAuthClientSlug { const base = slugifyName(name) || "oauth-app"; if (!existing.includes(base)) return OAuthClientSlug.make(base); let suffix = 2; while (existing.includes(`${base}-${suffix}`)) suffix += 1; return OAuthClientSlug.make(`${base}-${suffix}`); } /** * Optimistic (client-side) DCR client slug derived from the authorization * server host, for immediate/placeholder display only. * * The AUTHORITATIVE slug is computed server-side by `dcrClientSlug` in * `packages/core/sdk/src/oauth-service.ts`, which is resource-aware (an AS * serving multiple RFC 8707 resources gets distinct, hash-suffixed slugs). This * host-only form deliberately ignores resource: it is a best-effort placeholder * the UI shows before the server responds, and the server recomputes and * persists the real slug on registration. Do NOT rely on it matching the stored * slug for a multi-resource server. */ export function optimisticDcrClientSlug(issuerOrEndpoint: string): OAuthClientSlug { const host = hostOf(issuerOrEndpoint); const base = host === undefined ? "" : slugifyName(host); return OAuthClientSlug.make(`dcr-${base || "authorization-server"}`); } /** Humanize a client slug for display ("spotify-prod" → "Spotify prod"). */ export function clientDisplayName(slug: string): string { const text = slug.replace(/[-_]/g, " ").trim(); return text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : slug; } /** The host shown next to an app in the picker (the token endpoint's host). */ export function clientHost(tokenUrl: string): string { return hostOf(tokenUrl) ?? tokenUrl; }