Files
PaperClipAI/packages/shared/src/app-definitions.ts
T
DottaandPaperclip 4219ebbb80 feat(connections): add unified Google Workspace connector
Use one OAuth grant and action profile for the nine existing Google services. Respect partial consent at discovery and dispatch, preserve legacy connections, and keep the review visibility hold.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-08 05:52:27 -05:00

280 lines
9.6 KiB
TypeScript

import { APP_DEFINITIONS } from "./app-definitions.generated.js";
import { SELF_SERVE_MCP_CANDIDATES } from "./self-serve-mcp-research.js";
import type { AppDefinition, ConnectionMethodDef, FieldDef } from "./types/app-definition.js";
import type { ToolConnectionOwnership } from "./types/tool-access.js";
export const CONNECTABLE_APP_SLUGS = new Set([
"anthropic", "openai", "openrouter", "xai", "google", "bedrock",
"responses-api", "messages-api", "chat-completions-api", "local",
"agentmail",
"browser-use-cloud",
"cognee",
...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug),
"enterpret",
"zapier",
"arcade",
"executor",
"slack",
"notion",
"railway",
"posthog",
"linear",
"google-sheets",
"context7",
"shopify",
"composio",
"gmail",
"google-drive",
"google-docs",
"google-slides",
"google-calendar",
"google-chat",
"google-people",
"google-workspace-search",
"google-workspace",
"github",
"github-code-review-bot",
"discord",
"microsoft-teams",
"telegram",
"imessage-photon",
]);
export const CONNECTABLE_APP_DEFINITIONS = APP_DEFINITIONS.filter((app) =>
CONNECTABLE_APP_SLUGS.has(app.slug)
);
/**
* Definitions retained for existing connections and later verification, but
* intentionally withheld from the customer-facing store. Keeping visibility
* separate from recognition avoids breaking saved connections when a provider
* is pulled from Browse or reserved for a future first-party experience.
*/
export const APP_STORE_HIDDEN_SLUGS = new Set([
"beehiiv",
"bitly",
"brex",
"candid",
"coda",
"context7",
"egnyte",
"embat",
"kernel",
"local-falcon",
"make",
"manufact",
"oreilly",
"planetscale",
"razorpay",
"sanity",
"similarweb",
"ticket-tailor",
"ticktick",
"xero",
]);
export const APP_STORE_DEFINITIONS = CONNECTABLE_APP_DEFINITIONS.filter((app) =>
!APP_STORE_HIDDEN_SLUGS.has(app.slug)
);
export const DEFAULT_OWNERSHIP_AVAILABILITY: Record<ToolConnectionOwnership, boolean> = {
platform_shared: false,
platform_provisioned: false,
customer: true,
dcr: true,
};
export function getConnectableAppDefinition(slug: string): AppDefinition | null {
return CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === slug) ?? null;
}
export function getAppStoreDefinition(slug: string): AppDefinition | null {
return APP_STORE_DEFINITIONS.find((app) => app.slug === slug) ?? null;
}
export function isAppStoreVisibleSlug(slug: string | null | undefined): boolean {
return Boolean(slug && !APP_STORE_HIDDEN_SLUGS.has(slug) && CONNECTABLE_APP_SLUGS.has(slug));
}
function wildcardPatternToRegExp(pattern: string): RegExp {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
return new RegExp(`^${escaped}$`, "i");
}
export function getAppDefinitionForUrl(
link: string,
definitions: readonly AppDefinition[] = CONNECTABLE_APP_DEFINITIONS,
): AppDefinition | null {
let normalized: string;
try {
normalized = new URL(link.trim()).toString();
} catch {
return null;
}
return definitions.find((app) =>
app.urlPatterns.some((pattern) => wildcardPatternToRegExp(pattern).test(normalized))
) ?? null;
}
export function getAvailableConnectionMethods(app: AppDefinition): ConnectionMethodDef[] {
const availability = app.ownershipAvailability ?? DEFAULT_OWNERSHIP_AVAILABILITY;
return app.methods.filter((method) =>
method.ownershipModes.some((ownership) => availability[ownership] !== false)
);
}
/**
* Pick the method that gives a new connection the app's useful write surface.
*
* Google Workspace publishes separate read and write capability groups. The
* read method is intentionally listed first for documentation, but treating
* array order as a product default silently created read-only connections and
* left every write action Off. Capability metadata is the durable signal; apps
* without an explicit write/draft capability retain their declared order.
*/
export function getRecommendedConnectionMethod(
methods: readonly ConnectionMethodDef[],
): ConnectionMethodDef | null {
const recommendedCapability = (candidates: readonly ConnectionMethodDef[]) => candidates.find((method) => {
const capabilityKey = method.capabilityProfile?.key;
return capabilityKey === "write" || capabilityKey === "draft";
});
const managedMethods = methods.filter((method) =>
method.oauthStrategy === "paperclip_cloud_connector"
|| method.oauthStrategy === "paperclip_id_connector"
);
// Prefer the permissions needed for agent work, then the simplest sign-in.
// Explicit read-only selections pass their own capability group here.
return recommendedCapability(managedMethods)
?? recommendedCapability(methods)
?? managedMethods[0]
?? methods[0]
?? null;
}
export function getAvailableConnectionMethod(
app: AppDefinition,
methodKey?: string | null,
): ConnectionMethodDef | null {
const methods = getAvailableConnectionMethods(app);
return methodKey
? methods.find((method) => method.key === methodKey) ?? null
: getRecommendedConnectionMethod(methods);
}
export function connectionMethodSupportsAutomaticOAuth(method: ConnectionMethodDef | null | undefined): boolean {
return method?.auth === "oauth" && (
(method.oauthStrategy === "paperclip_cloud_connector" || method.oauthStrategy === "paperclip_id_connector")
|| method.ownershipModes.includes("dcr")
);
}
export function connectionMethodAcceptsCustomerOAuthClient(method: ConnectionMethodDef | null | undefined): boolean {
return method?.auth === "oauth"
&& !method.oauthStrategy
&& method.ownershipModes.includes("customer");
}
export function connectionMethodSupportsCatalogSetup(method: ConnectionMethodDef | null | undefined): boolean {
if (!method) return false;
if (method.transport === "runtime_auth") return Boolean(method.ai);
if (method.auth === "none" || method.auth === "api_key") return true;
return connectionMethodSupportsAutomaticOAuth(method)
|| connectionMethodAcceptsCustomerOAuthClient(method);
}
export function connectionMethodRequiresConfiguration(method: ConnectionMethodDef | null | undefined): boolean {
if (!method) return false;
const visibleTenantFields = method.tenantFields?.filter((field) => !field.hidden) ?? [];
const visibleExtensionFields = method.extensionFields?.filter((field) => !field.hidden) ?? [];
return Boolean(
method.credentialFields?.length
|| visibleTenantFields.length
|| visibleExtensionFields.length
|| method.configRequirements?.atLeastOneOf?.length
// "Use your own OAuth app" is an advanced alternative when DCR/CIMD is
// available, not a required setup field. Only customer-client-only methods
// must stop on the configuration screen.
|| (
connectionMethodAcceptsCustomerOAuthClient(method)
&& !connectionMethodSupportsAutomaticOAuth(method)
),
);
}
export function appSupportsCatalogSetup(app: AppDefinition | null | undefined): boolean {
return Boolean(app && getAvailableConnectionMethods(app).some(connectionMethodSupportsCatalogSetup));
}
export function isConnectableAppSlug(slug: string | null | undefined): boolean {
return Boolean(slug && CONNECTABLE_APP_SLUGS.has(slug));
}
export function appSupportsAutomaticOAuth(app: AppDefinition | null | undefined): boolean {
return Boolean(app && getAvailableConnectionMethods(app).some(connectionMethodSupportsAutomaticOAuth));
}
export function appAcceptsCustomerOAuthClient(app: AppDefinition | null | undefined): boolean {
return Boolean(app && getAvailableConnectionMethods(app).some(connectionMethodAcceptsCustomerOAuthClient));
}
export function credentialConfigPath(field: FieldDef, method?: ConnectionMethodDef | null): string {
if (method?.transport === "local_stdio" && method.keyPlacement?.location === "env") return `env.${field.key}`;
return `credentials.${field.key}`;
}
/** Older credential references used bare names; current references use paths. */
export function connectionCredentialConfigPath(ref: { name: string }): string {
return /^(credentials|headers|oauth|remote)\./.test(ref.name)
? ref.name
: `credentials.${ref.name}`;
}
export function resolveConnectionMethodServerUrl(
method: ConnectionMethodDef,
configValues: Record<string, string | boolean>,
): string | null {
const template = method.defaults?.serverUrlTemplate;
if (!template) return method.defaults?.serverUrl ?? null;
let missingValue = false;
const resolved = template.replace(/\{([a-zA-Z0-9_-]+)\}/g, (_placeholder, key: string) => {
const value = configValues[key];
if (value === undefined || String(value).trim().length === 0) {
missingValue = true;
return "";
}
return encodeURIComponent(String(value).trim());
});
if (missingValue) return null;
try {
return new URL(resolved).toString();
} catch {
return null;
}
}
export function recommendedDefaultsForApp(app: AppDefinition, methodKey?: string | null): Record<string, unknown> {
// Keep the parameters in the public contract: callers resolve defaults for a
// concrete app/method. Connection finalization remains a configure-authorized,
// audited operation, and Ask first stays available as an operator-selected
// policy for any action after the connection is made.
void methodKey;
// Enterpret's run_graph_query self-reports readOnlyHint but executes Cypher
// and has not been established as read-only. Classify it as write (server
// classifyRisk) and require Ask first so new token connections do not ship
// that action as Allowed.
if (app.slug === "enterpret") {
return {
access: "all_agents",
askFirstRiskLevels: ["write", "destructive"],
};
}
return {
access: "all_agents",
askFirstRiskLevels: [],
};
}