import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { OAuthClientSlug, 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 apps usable for an // integration (owner-visible clients whose OAuth endpoints share a registrable // root domain with the integration's declared endpoints when known) and lets the // user pick one or register a new one. User-owned apps are listed before // workspace ones. When NOTHING matches a declared endpoint, the picker shows an // empty state + a "register an app" CTA rather than unrelated providers' apps. // --------------------------------------------------------------------------- export interface OAuthClientOption { readonly owner: Owner; readonly slug: OAuthClientSlug; readonly grant: "authorization_code" | "client_credentials"; readonly authorizationUrl: string; readonly tokenUrl: string; readonly clientId: string; } 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 { /** Apps usable for this integration, user-owned first. When an endpoint was * declared but nothing matched, this is EMPTY (the unmatched apps move to * `otherClients`). */ readonly clients: readonly OAuthClientOption[]; /** Unmatched owner-visible apps — surfaced only as an opt-in escape hatch * ("use a different registered app") when no app matched the declared * endpoint. Empty when `endpointMatched` is true. */ readonly otherClients: readonly OAuthClientOption[]; /** True until the clients list has loaded at least once. */ readonly loading: boolean; /** * Whether the returned `clients` are matched to the integration's declared * OAuth endpoints (by registrable root domain across authorize + token). * * - `true` — either no endpoint filter was requested (the integration * declares no endpoints), or at least one registered app's authorize/token * root domain matched. `clients` are the matched subset. * - `false` — the integration declared endpoint(s) but NO registered app * matched. `clients` is then EMPTY (so the UI shows an empty state + a * register CTA rather than unrelated providers' apps), and the unmatched * apps are surfaced separately in `otherClients` for the opt-in escape hatch. */ readonly endpointMatched: boolean; /** Convenience flag for the UI: a register-an-app CTA should be shown because * an endpoint was declared and nothing matched. Equals `!endpointMatched` * once loaded. */ readonly displayRegisterCTA: boolean; } /** 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 the ones that * match the integration's declared OAuth endpoints and the ones that don't. * * Matching is by REGISTRABLE ROOT DOMAIN ("tld+1"). When the integration * declares a token endpoint, an app must match by token endpoint root; the token * endpoint is what the SDK will call during code exchange/refresh and avoids * authorize-root coincidences. When only an authorization endpoint is declared, * the authorize root is used as the fallback compatibility signal. Unrelated * providers (different root) never match. * * When the integration declares no endpoints, every app is "matched" (no filter). */ export function selectClientsForEndpoints( all: readonly OAuthClientOption[], endpoints: { readonly tokenUrl?: string; readonly authorizationUrl?: string; /** 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 unmatched: readonly OAuthClientOption[]; readonly endpointMatched: boolean; } { 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 app is usable. if (!wantedTokenRoot && !wantedAuthorizationRoot) { if (endpoints.requireEndpointMatch) { return { matched: [], unmatched: sortUserFirst(all), endpointMatched: false }; } return { matched: sortUserFirst(all), unmatched: [], endpointMatched: true }; } const matched: OAuthClientOption[] = []; const unmatched: OAuthClientOption[] = []; for (const app of all) { const appTokenRoot = getRootDomain(app.tokenUrl); const appAuthorizationRoot = getRootDomain(app.authorizationUrl); const fits = wantedTokenRoot ? appTokenRoot === wantedTokenRoot : appAuthorizationRoot === wantedAuthorizationRoot || appTokenRoot === wantedAuthorizationRoot; if (fits) matched.push(app); else unmatched.push(app); } return { matched: sortUserFirst(matched), unmatched: sortUserFirst(unmatched), endpointMatched: matched.length > 0, }; } export function useOAuthClientsForIntegration(opts: { readonly tokenUrl?: string; readonly authorizationUrl?: string; 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); if (!AsyncResult.isSuccess(clientsResult)) { return { clients: [], otherClients: [], loading: true, endpointMatched: true, displayRegisterCTA: false, }; } const all = clientsResult.value as readonly OAuthClientOption[]; const { matched, unmatched, endpointMatched } = selectClientsForEndpoints(all, { tokenUrl: opts.tokenUrl, authorizationUrl: opts.authorizationUrl, requireEndpointMatch: opts.requireEndpointMatch, }); // EXPLICIT outcome: when at least one app matched (or no endpoint was // declared) we present the matched subset. When an endpoint was declared but // nothing matched, `clients` is EMPTY — the unmatched apps move to // `otherClients` for an opt-in escape hatch — and we flag a register CTA so // the UI offers "register an app" instead of surfacing unrelated providers. return { clients: endpointMatched ? matched : [], otherClients: endpointMatched ? [] : unmatched, loading: false, endpointMatched, displayRegisterCTA: !endpointMatched, }; } 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}`); } /** 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; }