Files
PaperClipAI/packages/shared/src/connection-setup-state.ts
T
scotttongandClaude Opus 5 e0fce1623d feat(connections): name the four setup states once and derive the card verb
Step 3 of the connector simplification. The gallery card and the connect
screen were each deciding "what kind of connector is this" from raw method
flags, which is how a card comes to offer Connect for something that then
demands a pasted key.

`connectionSetupStateForMethod` resolves one of instant/authorize/paste/
register, and the card's verb is a function of it. Both follow the instance's
ownership availability rather than catalog order, so GitHub reads Add key
until an operator configures the cloud connector and Connect afterwards.

Two catalog realities the plan had wrong are now pinned by tests: a
provider-generated URL (Zapier, Arcade, Executor) is not instant because the
operator brings the endpoint, and a templated URL (Shopify) is not either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 10:57:42 -07:00

113 lines
4.6 KiB
TypeScript

import {
connectionMethodAcceptsCustomerOAuthClient,
connectionMethodRequiresConfiguration,
connectionMethodSupportsAutomaticOAuth,
getAvailableConnectionMethods,
getRecommendedConnectionMethod,
} from "./app-definitions.js";
import type { AppDefinition, ConnectionMethodDef } from "./types/app-definition.js";
/**
* The four states every connect screen lands in (PAP-659, "one shell, four
* states").
*
* The point of naming them here rather than in the wizard is that two surfaces
* have to agree: the gallery card decides its verb from the state, and the
* connect screen decides its body from the same state. When each re-derived it
* from raw method flags they drifted — a card offering "Connect" for something
* that then demanded a pasted URL is exactly the inconsistency the ticket is
* about.
*
* - `instant` nothing to supply: connect resolves entirely from the catalog.
* - `authorize` one click, then the provider's own consent screen.
* - `paste` one thing the operator has to bring: a key, a URL, a tenant.
* - `register` the provider cannot issue a client, so one must be registered
* in its console first. A recovery state, never a normal step.
*/
export type ConnectionSetupState = "instant" | "authorize" | "paste" | "register";
/**
* True when the catalog already knows the endpoint. A `serverUrlTemplate` does
* not count: its placeholders are the operator's to fill, which is a paste.
*/
function hasResolvedEndpoint(method: ConnectionMethodDef): boolean {
if (method.transport !== "mcp_remote") return true;
if (method.defaults?.serverUrlTemplate) return false;
return Boolean(method.defaults?.serverUrl);
}
export function connectionSetupStateForMethod(
method: ConnectionMethodDef | null | undefined,
): ConnectionSetupState | null {
if (!method) return null;
if (method.auth === "oauth") {
// An AI subscription signs in through the adapter's own device/OAuth flow.
// No client is ever registered, so `register` would be a false detour.
if (method.transport === "runtime_auth") return "authorize";
if (connectionMethodSupportsAutomaticOAuth(method)) return "authorize";
// A customer-owned client is only a *register* state when the provider
// offers nothing better. Live discovery can still overrule the catalog at
// connect time, which is why the flow re-resolves rather than trusting this.
if (connectionMethodAcceptsCustomerOAuthClient(method)) return "register";
return "authorize";
}
if (method.auth === "none") {
return connectionMethodRequiresConfiguration(method) || !hasResolvedEndpoint(method)
? "paste"
: "instant";
}
return "paste";
}
/**
* The default tool method for an app, or null.
*
* Gallery rows are not guaranteed to carry a method list: the display entry is
* assembled from several sources, and a row can stand for a connection whose
* catalog entry has since been withdrawn. Resolving that to null — rather than
* throwing inside a card render — is the difference between a missing verb and
* a blank connectors page.
*/
function defaultToolMethod(
app: AppDefinition | null | undefined,
methodKey?: string | null,
): ConnectionMethodDef | null {
if (!app || !Array.isArray(app.methods)) return null;
const methods = getAvailableConnectionMethods(app).filter(
(method) => (method.purpose ?? "tool") !== "channel",
);
return methodKey
? methods.find((candidate) => candidate.key === methodKey) ?? null
: getRecommendedConnectionMethod(methods);
}
export function connectionSetupStateForApp(
app: AppDefinition | null | undefined,
methodKey?: string | null,
): ConnectionSetupState | null {
return connectionSetupStateForMethod(defaultToolMethod(app, methodKey));
}
/**
* The gallery card's verb (PAP-659 C4).
*
* Only a method that genuinely wants a secret says so. "Add key" on a connector
* that actually wants a pasted URL would be worse than the generic verb, so a
* paste state without credential fields keeps "Connect" — the operator still
* lands on one screen with one field, which the verb does not need to restate.
*/
export function connectionSetupVerbForMethod(
method: ConnectionMethodDef | null | undefined,
): "Connect" | "Add key" {
if (!method) return "Connect";
const wantsSecret = (method.credentialFields?.length ?? 0) > 0;
return connectionSetupStateForMethod(method) === "paste" && wantsSecret ? "Add key" : "Connect";
}
export function connectionSetupVerbForApp(
app: AppDefinition | null | undefined,
methodKey?: string | null,
): "Connect" | "Add key" {
return connectionSetupVerbForMethod(defaultToolMethod(app, methodKey));
}