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"; /** True for host-operated first-party apps (config-declared, `first-party:` * slugs). They rank ABOVE user/workspace apps when they match an integration: * the one-click "nothing to paste" path is the default, BYO the escape hatch. */export const isFirstPartyClient = (app: OAuthClientOption): boolean => app.origin.kind === "first_party"; /** Mirror the host's first-party scope boundary in the picker. The server is * authoritative; this prevents offering a built-in app for a flow it will * reject. An explicit policy with unknown scopes fails closed. */const firstPartyClientAllowsScopes = ( app: OAuthClientOption, requestedScopes: readonly string[] | undefined, discoversScopes: boolean,): boolean => { if (app.origin.kind !== "first_party" || app.origin.allowedScopes === undefined) return true; // MCP providers discover their scopes at OAuth start. The server intersects // that catalog with this same allow-list before redirecting, so an absent // static scope list is safe only on the explicit discovery path. if (requestedScopes === undefined) return discoversScopes; const allowed = new Set(app.origin.allowedScopes); return requestedScopes.every((scope) => allowed.has(scope));}; 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 first-party apps first (the one-click default), then user-owned before * shared workspace apps. */const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClientOption[] => [...apps].sort((a: OAuthClientOption, b: OAuthClientOption) => { const aFirstParty = isFirstPartyClient(a); if (aFirstParty !== isFirstPartyClient(b)) return aFirstParty ? -1 : 1; return 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; /** Complete scope set declared by the selected OAuth auth method. Used to * hide scope-limited first-party apps that the host will reject. */ readonly scopes?: readonly string[]; /** The OAuth service discovers provider scopes at connect time and caps a * first-party app's result to its configured allow-list. */ readonly discoversScopes?: boolean; /** 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) && firstPartyClientAllowsScopes(app, endpoints.scopes, endpoints.discoversScopes === true), ); const intent = endpoints.integration; const matchesIntent = (app: OAuthClientOption): boolean => { if (intent == null) return false; // A first-party app declaring this integration is intent-matched the same // way a BYO app registered from this dialog is. if (app.origin.kind === "first_party") { return (app.origin.integrations ?? []).includes(intent); } return ( 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 scopes?: readonly string[]; readonly discoversScopes?: boolean; 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, scopes: opts.scopes, discoversScopes: opts.discoversScopes, requireEndpointMatch: opts.requireEndpointMatch, }), [ all, opts.tokenUrl, opts.authorizationUrl, opts.integration, opts.scopes, opts.discoversScopes, 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"). * First-party slugs drop their namespace prefix ("first-party:github" → * "Github") — the row's badge already says it's the built-in app. */export function clientDisplayName(slug: string): string { const bare = slug.startsWith("first-party:") ? slug.slice("first-party:".length) : slug; const text = bare.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;}
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";
/** True for host-operated first-party apps (config-declared, `first-party:`
* slugs). They rank ABOVE user/workspace apps when they match an integration:
* the one-click "nothing to paste" path is the default, BYO the escape hatch. */
export const isFirstPartyClient = (app: OAuthClientOption): boolean =>
app.origin.kind === "first_party";
/** Mirror the host's first-party scope boundary in the picker. The server is
* authoritative; this prevents offering a built-in app for a flow it will
* reject. An explicit policy with unknown scopes fails closed. */
const firstPartyClientAllowsScopes = (
app: OAuthClientOption,
requestedScopes: readonly string[] | undefined,
discoversScopes: boolean,
): boolean => {
if (app.origin.kind !== "first_party" || app.origin.allowedScopes === undefined) return true;
// MCP providers discover their scopes at OAuth start. The server intersects
// that catalog with this same allow-list before redirecting, so an absent
// static scope list is safe only on the explicit discovery path.
if (requestedScopes === undefined) return discoversScopes;
const allowed = new Set(app.origin.allowedScopes);
return requestedScopes.every((scope) => allowed.has(scope));
};
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 first-party apps first (the one-click default), then user-owned before
* shared workspace apps. */
const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClientOption[] =>
[...apps].sort((a: OAuthClientOption, b: OAuthClientOption) => {
const aFirstParty = isFirstPartyClient(a);
if (aFirstParty !== isFirstPartyClient(b)) return aFirstParty ? -1 : 1;
return 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;
/** Complete scope set declared by the selected OAuth auth method. Used to
* hide scope-limited first-party apps that the host will reject. */
readonly scopes?: readonly string[];
/** The OAuth service discovers provider scopes at connect time and caps a
* first-party app's result to its configured allow-list. */
readonly discoversScopes?: boolean;
/** 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) &&
firstPartyClientAllowsScopes(app, endpoints.scopes, endpoints.discoversScopes === true),
);
const intent = endpoints.integration;
const matchesIntent = (app: OAuthClientOption): boolean => {
if (intent == null) return false;
// A first-party app declaring this integration is intent-matched the same
// way a BYO app registered from this dialog is.
if (app.origin.kind === "first_party") {
return (app.origin.integrations ?? []).includes(intent);
}
return (
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 scopes?: readonly string[];
readonly discoversScopes?: boolean;
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,
scopes: opts.scopes,
discoversScopes: opts.discoversScopes,
requireEndpointMatch: opts.requireEndpointMatch,
}),
[
all,
opts.tokenUrl,
opts.authorizationUrl,
opts.integration,
opts.scopes,
opts.discoversScopes,
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").
* First-party slugs drop their namespace prefix ("first-party:github" →
* "Github") — the row's badge already says it's the built-in app. */
export function clientDisplayName(slug: string): string {
const bare = slug.startsWith("first-party:") ? slug.slice("first-party:".length) : slug;
const text = bare.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;
}