mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
Limit and deduplicate management selectors before lookups, hide foreign resources, and reuse captured run identity only for request-local capability reads. Re-resolve resources at commit-time checks and enforce proposed provider scope. Preserve rate-limit admission during the final test dispatch check. Co-Authored-By: Paperclip <noreply@paperclip.ing>
4852 lines
195 KiB
TypeScript
4852 lines
195 KiB
TypeScript
import type { ConnectionManagementContext } from "./connection-management.js";
|
|
import { connectionActorUserId } from "./tool-access/actor.js";
|
|
import { restConnectorFor } from "./connectors/rest-registry.js";
|
|
import { createConnectionCredentialValidation } from "./tool-access/connections/credential-validation.js";
|
|
import { createConnectionGrants } from "./tool-access/connections/grants.js";
|
|
import { createConnectionInstalls } from "./tool-access/connections/installs.js";
|
|
import { createConnectionRemoval } from "./tool-access/connections/removal.js";
|
|
import { createToolRemoteHttp } from "./tool-access/remote-http.js";
|
|
import { classifyRisk, normalizeToolDescriptor, type McpToolDescriptor } from "./tool-access/catalog/descriptors.js";
|
|
export { classifyRisk, isGmailToolPermanentlyBlocked, isGoogleWorkspaceToolAllowed, type McpToolDescriptor } from "./tool-access/catalog/descriptors.js";
|
|
import { toCatalogEntry } from "./tool-access/catalog/projection.js";
|
|
import { toConnection, toConnectionInstall } from "./tool-access/connections/projection.js";
|
|
import { isOAuthEndpointRejection } from "./tool-access/catalog/failures.js";
|
|
import { createCatalogDefaultAccess, toPolicy } from "./tool-access/catalog/default-access.js";
|
|
import { createToolCatalogDiscovery } from "./tool-access/catalog/discovery.js";
|
|
import { createConnectionHealth } from "./tool-access/catalog/health.js";
|
|
import { createToolAppAttention } from "./tool-access/catalog/attention.js";
|
|
import { createToolCatalogPublication } from "./tool-access/catalog/publication.js";
|
|
import { createToolCatalogReader } from "./tool-access/catalog/reader.js";
|
|
import { createAggregatorAccounts } from "./tool-access/catalog/aggregator-accounts.js";
|
|
import { actorBinding } from "./tool-access/actor.js";
|
|
import { toApplication } from "./tool-access/applications/projection.js";
|
|
import { groupedActions } from "./tool-access/catalog/action-summary.js";
|
|
import { createConnectionSetupPolicy, assertSupportedConnection } from "./tool-access/connections/setup-policy.js";
|
|
import { createOrganizationGrantStore } from "./tool-access/connections/organization-grant.js";
|
|
import { createPersonalConnectionIdentity } from "./tool-access/connections/personal-identity.js";
|
|
import { createOAuthStateStore } from "./tool-access/oauth/state.js";
|
|
import { createOAuthCatalogCompletion } from "./tool-access/oauth/catalog-completion.js";
|
|
import { createOAuthAuthorizationStart } from "./tool-access/oauth/authorization-start.js";
|
|
import { createDirectOAuthCallback } from "./tool-access/oauth/direct-callback.js";
|
|
import { createCloudOAuthCallback } from "./tool-access/oauth/cloud-callback.js";
|
|
import { createVercelOAuthCallback } from "./tool-access/oauth/vercel-callback.js";
|
|
import { createOAuthAccessFinalization } from "./tool-access/oauth/access-finalization.js";
|
|
import { createVercelCredentialService, vercelConnectHttpError } from "./tool-access/vercel-credentials.js";
|
|
import { createConnectionCredentialAccess } from "./tool-access/credential-access.js";
|
|
|
|
import { createOAuthCredentialStore, actorForSecret } from "./tool-access/oauth/credential-store.js";
|
|
import { managedConnectorProfile } from "./tool-access/oauth/client-settings.js";
|
|
import { createOAuthClientRegistration } from "./tool-access/oauth/client-registration.js";
|
|
import { createOAuthRefreshLeases } from "./tool-access/oauth/refresh-leases.js";
|
|
import { createLegacyOAuthRefresh } from "./tool-access/oauth/connection-refresh.js";
|
|
import { createOAuthGrantRefresh } from "./tool-access/oauth/grant-refresh.js";
|
|
import { createGitHubGrantContinuity } from "./tool-access/github-grant-continuity.js";
|
|
import { createToolRuntimeHealth } from "./tool-access/runtime/health.js";
|
|
import { createToolRuntimeSlots } from "./tool-access/runtime/slots.js";
|
|
import { createToolActivityService } from "./tool-access/activity/service.js";
|
|
import { createToolActionReviewQueue } from "./tool-access/activity/review-queue.js";
|
|
import { createToolAccessAudit } from "./tool-access/audit.js";
|
|
import { createConnectionTokenBroker } from "./tool-access/token-broker/service.js";
|
|
|
|
import { createToolProfileService } from "./tool-access/profiles/service.js";
|
|
import { createToolProfileResolver } from "./tool-access/profiles/resolution.js";
|
|
import { toProfile, toProfileEntry, toProfileBinding } from "./tool-access/profiles/projection.js";
|
|
import { lockManagedAppPolicies, lockToolAssignmentAgents } from "./tool-access/assignment-locks.js";
|
|
export { lockToolConnectionRuntimeInputs } from "./tool-access/assignment-locks.js";
|
|
import type { ActorInfo } from "./tool-access/types.js";
|
|
import { normalizeKey, connectionUid } from "./tool-access/identifiers.js";
|
|
import { createOAuthEndpointPolicy } from "./tool-access/oauth/endpoint-policy.js";
|
|
import { createOAuthDiscovery } from "./tool-access/oauth/discovery.js";
|
|
import { createOAuthClientProtocol } from "./tool-access/oauth/client.js";
|
|
|
|
import type { OAuthClientRegistrationSource } from "./tool-access/oauth/types.js";
|
|
export { OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, resolveOAuthClientIdMetadataDocumentUrl, oauthClientIdMetadataDocument } from "./tool-access/oauth/client-metadata.js";
|
|
import { connectionMethodFor, connectionMethodForConnection, credentialFieldsFor, normalizeConnectionMethodConfig, projectedConnectionToolInputSchema } from "./tool-access/connection-methods.js";
|
|
export {
|
|
normalizeConnectionMethodConfig,
|
|
projectedConnectionHeaders,
|
|
projectConnectionMethodToolArguments,
|
|
projectConnectionMethodToolInputSchema,
|
|
projectedConnectionToolArguments,
|
|
projectedConnectionToolInputSchema,
|
|
} from "./tool-access/connection-methods.js";
|
|
import { loadGitHubTokenRepositories } from "./tool-access/github-access-discovery.js";
|
|
export { loadGitHubGrantMetadata, loadGitHubTokenRepositories } from "./tool-access/github-access-discovery.js";
|
|
|
|
import { stableHash } from "./tool-catalog-hash.js";
|
|
|
|
import { NATIVE_PROVIDER_SLUGS, nativeProviderCatalogFor } from "./native-provider-catalogs.js";
|
|
import { connectorToolPolicy } from "./connectors/tool-policy.js";
|
|
|
|
import { notifyChatEndpointWork } from "./chat-work-notifications.js";
|
|
|
|
import { defaultConnectionAgentInstructions } from "@paperclipai/shared";
|
|
|
|
import { isBrowserUseConnection } from "./browser-use-client.js";
|
|
|
|
import { COGNEE_STDIO_TEMPLATE } from "./cognee-connection.js";
|
|
import { isRemoteMcpConnectorMethod } from "@paperclipai/shared";
|
|
|
|
import { githubBotRequest } from "./chat-github-client.js";
|
|
import { syncConnectionCredentialBindings } from "./connection-credential-bindings.js";
|
|
import {
|
|
emitConnectionCreated,
|
|
emitConnectionUpdated,
|
|
} from "./connector-telemetry.js";
|
|
import { canBrowseProjectRepositoryGrant, mergeProjectRepository } from "./project-repositories.js";
|
|
|
|
import { measureRunPhase } from "./execution-preparation-timing.js";
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import { and, asc, desc, eq, inArray, max, ne, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { agents, connectionGrantMembers, connectionGrants, companyMemberships, principalPermissionGrants, userSecretDefinitions, plugins, toolAccessAuditEvents, toolApplications, toolCatalogEntries, toolConnectionInstalls, toolConnections, toolStdioCommandTemplates, toolCallEvents, toolPolicies, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db";
|
|
import type { AppDefinition, ConnectionGrantKind, CreateToolApplication, CreateToolConnection, ConnectToolApp, ConnectToolAppResult, CreateToolStdioCommandTemplate, FinishToolApp, FinishToolAppResult, CreateToolProfileBindingForProfile, CreateToolProfileEntryForProfile, DeploymentExposure, DeploymentMode, ImportMcpJson, McpConnectionCredentialRef, McpJsonImportPreview, ToolApplication, ToolCatalogEntry, ToolCatalogRefreshResult, ToolConnection, ToolConnectionInstall, ToolConnectionHealthCheckResult, ToolConnectionAuthKind, ToolConnectionCredentialPolicy, ToolConnectionCredentialSource, ToolConnectionTransport, ToolAppMetadataPreflightResult, ToolExampleInstallResult, ToolExampleSmokeCheck, ToolExampleSmokeResult, ToolExampleSummary, ToolProfileBinding, ToolProfileEntry, ToolPolicyDecision, ToolStdioCommandTemplate, UpdateToolApplication, UpdateToolConnection, VercelConnectCredentialReference } from "@paperclipai/shared";
|
|
import { getAppDefinitionForUrl, getAvailableConnectionMethods, getConnectableAppDefinition, recommendedDefaultsForApp } from "@paperclipai/shared";
|
|
import {
|
|
checkMcpRemoteHeaderName,
|
|
checkMcpRemoteHeaderValue,
|
|
mcpRemoteHeaderNameFromConfigPath,
|
|
mcpRemoteHeaderRejectionMessage,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
badRequest,
|
|
conflict,
|
|
forbidden,
|
|
HttpError,
|
|
notFound,
|
|
unprocessable,
|
|
} from "../errors.js";
|
|
import { isUniqueViolation } from "../db-errors.js";
|
|
|
|
import { forgetMcpHttpSessions } from "./mcp-http.js";
|
|
import { type RemoteHttpEndpointLookup } from "./remote-http-endpoint-guard.js";
|
|
|
|
import {
|
|
REMOTE_URL_SECRET_CONFIG_PATH,
|
|
remoteUrlCredentialMatchesPublicUrl,
|
|
splitRemoteUrlCredential,
|
|
} from "./remote-url-credentials.js";
|
|
import { secretService } from "./secrets.js";
|
|
import { connectionCredentialConfigPath as credentialRefConfigPath, validateConnectionGrantSecretOwnership, writeConnectionCredential } from "./connection-credentials.js";
|
|
|
|
import type { ConfigureRailwaySsh, RailwaySshSetup } from "@paperclipai/shared";
|
|
import { generateRailwaySshKey, RAILWAY_SSH_SECRET_PATH, validateRailwayKnownHosts } from "./railway-ssh.js";
|
|
import { isRailwayConnection } from "./railway.js";
|
|
import { toolAccessPolicyService } from "./tool-access-policy.js";
|
|
|
|
import { googleChatToolDescription } from "./google-chat-tool-policy.js";
|
|
import { gmailArchiveToolDescription } from "./gmail-archive-tool-policy.js";
|
|
|
|
import { createToolRuntimeSupervisor } from "./tool-runtime-supervisor.js";
|
|
|
|
import { appWithPaperclipCloudConnectorAvailability, paperclipCloudConnectorCapabilitiesFromEnv, createPaperclipCloudConnector, isPaperclipCloudConnectorStrategy, paperclipCloudConnectorConfigFromEnv, type PaperclipCloudConnector } from "./paperclip-cloud-connector.js";
|
|
import { createVercelConnectClient, deriveVercelConnectSubject, vercelConnectIntegrationStatus, type VercelConnectClient } from "./vercel-connect.js";
|
|
|
|
type ToolAccessServiceOptions = {
|
|
deploymentMode?: DeploymentMode;
|
|
deploymentExposure?: DeploymentExposure;
|
|
trustedLocalStdioRuntimeHost?: string | null;
|
|
now?: () => Date;
|
|
/** How long persisted remote MCP action discovery remains fresh. */
|
|
catalogCacheTtlMs?: number;
|
|
/** Test seam for deciding whether an OAuth client metadata URL is publicly resolvable. */
|
|
oauthClientMetadataLookup?: RemoteHttpEndpointLookup;
|
|
/** Test seam for deterministic remote endpoint resolution. Production uses DNS. */
|
|
remoteHttpEndpointLookup?: RemoteHttpEndpointLookup;
|
|
/** Test seam for protocol fixtures. Production uses the DNS-pinned transport. */
|
|
remoteHttpRequest?: (url: string, init: RequestInit) => Promise<Response>;
|
|
/** Smaller reviewed catalog for deterministic discovery fixtures. */
|
|
composioAppToolkits?: readonly string[];
|
|
/** Test seam for the centrally registered Gmail OAuth broker. */
|
|
paperclipCloudConnector?: PaperclipCloudConnector | null;
|
|
/** @deprecated Use paperclipCloudConnector. */
|
|
paperclipIdGmailConnector?: PaperclipCloudConnector | null;
|
|
/** Test seam for Vercel Connect without live vendor traffic. */
|
|
vercelConnectClient?: VercelConnectClient | null;
|
|
};
|
|
|
|
const GOOGLE_SHEETS_SPREADSHEET_SCHEMA = {
|
|
type: "object",
|
|
properties: {
|
|
spreadsheetId: { type: "string", minLength: 1 },
|
|
},
|
|
required: ["spreadsheetId"],
|
|
};
|
|
|
|
const GOOGLE_SHEETS_RANGE_SCHEMA = {
|
|
type: "object",
|
|
properties: {
|
|
spreadsheetId: { type: "string", minLength: 1 },
|
|
range: { type: "string", minLength: 1, maxLength: 500 },
|
|
},
|
|
required: ["spreadsheetId", "range"],
|
|
};
|
|
|
|
const GOOGLE_SHEETS_VALUE_ROWS_SCHEMA = {
|
|
type: "array",
|
|
minItems: 1,
|
|
items: {
|
|
type: "array",
|
|
items: {
|
|
anyOf: [
|
|
{ type: "string" },
|
|
{ type: "number" },
|
|
{ type: "boolean" },
|
|
{ type: "null" },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
const GOOGLE_SHEETS_WRITE_VALUES_SCHEMA = {
|
|
type: "object",
|
|
properties: {
|
|
spreadsheetId: { type: "string", minLength: 1 },
|
|
range: { type: "string", minLength: 1, maxLength: 500 },
|
|
values: GOOGLE_SHEETS_VALUE_ROWS_SCHEMA,
|
|
valueInputOption: {
|
|
type: "string",
|
|
enum: ["RAW", "USER_ENTERED"],
|
|
default: "RAW",
|
|
},
|
|
},
|
|
required: ["spreadsheetId", "range", "values"],
|
|
};
|
|
|
|
function schemaHasInputProperties(schema: unknown): boolean {
|
|
if (!schema || typeof schema !== "object" || Array.isArray(schema))
|
|
return false;
|
|
const properties = (schema as Record<string, unknown>).properties;
|
|
return Boolean(
|
|
properties &&
|
|
typeof properties === "object" &&
|
|
!Array.isArray(properties) &&
|
|
Object.keys(properties).length > 0,
|
|
);
|
|
}
|
|
|
|
const APPROVED_STDIO_TEMPLATES: Record<
|
|
string,
|
|
{
|
|
name: string;
|
|
command?: string | null;
|
|
args?: string[];
|
|
envKeys?: string[];
|
|
tools: McpToolDescriptor[];
|
|
}
|
|
> = {
|
|
"paperclip.echo-calculator-time": {
|
|
name: "Paperclip Echo / Calculator / Time fixture",
|
|
tools: [
|
|
{
|
|
name: "echo",
|
|
description: "Return the provided message.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { message: { type: "string" } },
|
|
required: ["message"],
|
|
},
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "add",
|
|
description: "Add two numbers.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { a: { type: "number" }, b: { type: "number" } },
|
|
required: ["a", "b"],
|
|
},
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "now",
|
|
description: "Return the current server time.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "fail_with_code",
|
|
description: "Deterministically fail with a requested status code.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { code: { type: "number" } },
|
|
required: ["code"],
|
|
},
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
],
|
|
},
|
|
"paperclip.synthetic-todo-kv": {
|
|
name: "Paperclip Synthetic Todo / KV fixture",
|
|
tools: [
|
|
{
|
|
name: "list_items",
|
|
description: "List synthetic todo items.",
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "create_item",
|
|
description: "Create a synthetic todo item.",
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
{
|
|
name: "mark_done",
|
|
description: "Mark a synthetic todo item done.",
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
{
|
|
name: "delete_item",
|
|
description: "Delete a synthetic todo item.",
|
|
annotations: { destructiveHint: true },
|
|
},
|
|
{
|
|
name: "get_value",
|
|
description: "Read a synthetic KV value.",
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "set_value",
|
|
description: "Write a synthetic KV value.",
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
],
|
|
},
|
|
"paperclip.cognee-cloud": COGNEE_STDIO_TEMPLATE,
|
|
"paperclip.google-sheets": {
|
|
name: "Google Sheets",
|
|
command: "paperclip-google-sheets-mcp-server",
|
|
args: [],
|
|
envKeys: [
|
|
"GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON",
|
|
"GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON_PATH",
|
|
"GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS",
|
|
],
|
|
tools: [
|
|
{
|
|
name: "list_spreadsheets",
|
|
description:
|
|
"List the Google Sheets spreadsheets configured in this connection allowlist.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "get_spreadsheet_info",
|
|
description:
|
|
"Get spreadsheet metadata and sheet tab information for an allowlisted spreadsheet.",
|
|
inputSchema: GOOGLE_SHEETS_SPREADSHEET_SCHEMA,
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "read_values",
|
|
description: "Read cell values from an allowlisted spreadsheet range.",
|
|
inputSchema: GOOGLE_SHEETS_RANGE_SCHEMA,
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "search_rows",
|
|
description: "Search rows in an allowlisted spreadsheet range.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
spreadsheetId: { type: "string", minLength: 1 },
|
|
range: { type: "string", minLength: 1, maxLength: 500 },
|
|
query: { type: "string", minLength: 1 },
|
|
caseSensitive: { type: "boolean", default: false },
|
|
maxResults: {
|
|
type: "integer",
|
|
minimum: 1,
|
|
maximum: 500,
|
|
default: 50,
|
|
},
|
|
},
|
|
required: ["spreadsheetId", "range", "query"],
|
|
},
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: "append_rows",
|
|
description: "Append rows to an allowlisted spreadsheet range.",
|
|
inputSchema: GOOGLE_SHEETS_WRITE_VALUES_SCHEMA,
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
{
|
|
name: "update_values",
|
|
description: "Update values in an allowlisted spreadsheet range.",
|
|
inputSchema: GOOGLE_SHEETS_WRITE_VALUES_SCHEMA,
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
{
|
|
name: "add_sheet_tab",
|
|
description: "Add a sheet tab to an allowlisted spreadsheet.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
spreadsheetId: { type: "string", minLength: 1 },
|
|
title: { type: "string", minLength: 1, maxLength: 100 },
|
|
rowCount: { type: "integer", minimum: 1, maximum: 1000000 },
|
|
columnCount: { type: "integer", minimum: 1, maximum: 18278 },
|
|
},
|
|
required: ["spreadsheetId", "title"],
|
|
},
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
{
|
|
name: "clear_values",
|
|
description: "Clear values in an allowlisted spreadsheet range.",
|
|
inputSchema: GOOGLE_SHEETS_RANGE_SCHEMA,
|
|
annotations: { destructiveHint: true },
|
|
},
|
|
{
|
|
name: "delete_rows",
|
|
description: "Delete rows from an allowlisted spreadsheet tab.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
spreadsheetId: { type: "string", minLength: 1 },
|
|
sheetId: { type: "integer", minimum: 0 },
|
|
startIndex: { type: "integer", minimum: 0 },
|
|
endIndex: { type: "integer", minimum: 1 },
|
|
},
|
|
required: ["spreadsheetId", "sheetId", "startIndex", "endIndex"],
|
|
},
|
|
annotations: { destructiveHint: true },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const GOOGLE_SHEETS_GALLERY_KEY = "google-sheets";
|
|
const GOOGLE_SHEETS_TEMPLATE_ID = "paperclip.google-sheets";
|
|
const GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS_ENV =
|
|
"GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS";
|
|
|
|
type ToolExampleDefinition = {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
applicationKey: string;
|
|
applicationName: string;
|
|
applicationDescription: string;
|
|
connectionName: string;
|
|
templateId: keyof typeof APPROVED_STDIO_TEMPLATES;
|
|
profileKey: string;
|
|
profileName: string;
|
|
profileDescription: string;
|
|
};
|
|
|
|
const TOOL_EXAMPLES: ToolExampleDefinition[] = [
|
|
{
|
|
id: "safe-read-only-todo-kv",
|
|
title: "Safe read-only Todo / KV fixture",
|
|
description:
|
|
"Installs a deterministic local MCP fixture and grants only its read-only catalog entries.",
|
|
applicationKey: "paperclip.examples.safe-read-only-todo-kv",
|
|
applicationName: "Paperclip example: Safe read-only Todo / KV",
|
|
applicationDescription:
|
|
"Deterministic MCP fixture for first-run tool governance checks.",
|
|
connectionName: "Paperclip example: Safe read-only Todo / KV",
|
|
templateId: "paperclip.synthetic-todo-kv",
|
|
profileKey: "paperclip.examples.safe-read-only-todo-kv.profile",
|
|
profileName: "Example safe read-only tools",
|
|
profileDescription:
|
|
"Allows only the read-only tools from the Paperclip Todo / KV example fixture.",
|
|
},
|
|
];
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
if (value && typeof value === "object" && !Array.isArray(value))
|
|
return value as Record<string, unknown>;
|
|
return {};
|
|
}
|
|
|
|
export function googleSheetsRobotEmailFromEnv(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
):
|
|
| { available: true; robotEmail: string }
|
|
| { available: false; reason: string } {
|
|
const inlineOrPath = env.GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON?.trim();
|
|
const explicitPath = env.GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON_PATH?.trim();
|
|
if (!inlineOrPath && !explicitPath) {
|
|
return {
|
|
available: false,
|
|
reason: "Google Sheets is not available on this instance yet.",
|
|
};
|
|
}
|
|
|
|
try {
|
|
const raw = explicitPath
|
|
? readFileSync(explicitPath, "utf8")
|
|
: inlineOrPath!.startsWith("{")
|
|
? inlineOrPath!
|
|
: readFileSync(inlineOrPath!, "utf8");
|
|
const parsed = JSON.parse(raw) as { client_email?: unknown };
|
|
if (typeof parsed.client_email === "string" && parsed.client_email.trim()) {
|
|
return { available: true, robotEmail: parsed.client_email.trim() };
|
|
}
|
|
} catch {
|
|
return {
|
|
available: false,
|
|
reason: "Google Sheets is not available on this instance yet.",
|
|
};
|
|
}
|
|
return {
|
|
available: false,
|
|
reason: "Google Sheets is not available on this instance yet.",
|
|
};
|
|
}
|
|
|
|
function googleSheetsAllowedSpreadsheetIds(
|
|
configValues: Record<string, unknown> | undefined,
|
|
): string[] {
|
|
const raw = configValues?.allowedSpreadsheetIds;
|
|
const values = Array.isArray(raw)
|
|
? raw
|
|
: typeof raw === "string"
|
|
? raw.split(/[\n,]/g)
|
|
: [];
|
|
return Array.from(
|
|
new Set(values.map((value) => String(value).trim()).filter(Boolean)),
|
|
);
|
|
}
|
|
|
|
function isGoogleSheetsConnectionConfig(
|
|
configValues: Record<string, unknown> | undefined,
|
|
): boolean {
|
|
return (
|
|
configValues?.sourceTemplateKey === GOOGLE_SHEETS_GALLERY_KEY ||
|
|
configValues?.templateId === GOOGLE_SHEETS_TEMPLATE_ID
|
|
);
|
|
}
|
|
|
|
function normalizeGoogleSheetsConnectionConfig(
|
|
configValues: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
if (!isGoogleSheetsConnectionConfig(configValues)) return configValues;
|
|
const allowedSpreadsheetIds = googleSheetsAllowedSpreadsheetIds(configValues);
|
|
if (allowedSpreadsheetIds.length === 0) {
|
|
throw badRequest("Paste at least one Google Sheets link.");
|
|
}
|
|
return {
|
|
...configValues,
|
|
allowedSpreadsheetIds,
|
|
env: {
|
|
...asRecord(configValues.env),
|
|
[GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS_ENV]:
|
|
allowedSpreadsheetIds.join(","),
|
|
},
|
|
};
|
|
}
|
|
|
|
// Detects a Postgres foreign_key_violation (SQLSTATE 23503) raised by the
|
|
// tool_connections.application_id constraint — i.e. an application delete that lost the race to
|
|
// a concurrently-created connection now that the FK is ON DELETE RESTRICT. Walks the error and
|
|
// its `cause` since the driver may wrap the original pg error.
|
|
function isToolConnectionForeignKeyViolation(error: unknown): boolean {
|
|
const records: Record<string, unknown>[] = [];
|
|
let current: unknown = error;
|
|
for (
|
|
let depth = 0;
|
|
depth < 4 && current && typeof current === "object";
|
|
depth += 1
|
|
) {
|
|
const record = current as Record<string, unknown>;
|
|
records.push(record);
|
|
current = record.cause;
|
|
}
|
|
return records.some((record) => {
|
|
const code = typeof record.code === "string" ? record.code : null;
|
|
const constraint =
|
|
typeof record.constraint === "string"
|
|
? record.constraint
|
|
: typeof record.constraint_name === "string"
|
|
? record.constraint_name
|
|
: null;
|
|
const message = typeof record.message === "string" ? record.message : "";
|
|
return (
|
|
code === "23503" &&
|
|
(constraint ===
|
|
"tool_connections_application_id_tool_applications_id_fk" ||
|
|
/tool_connections/.test(constraint ?? "") ||
|
|
/tool_connections/.test(message))
|
|
);
|
|
});
|
|
}
|
|
|
|
function toCatalogEntryForConnection(
|
|
row: typeof toolCatalogEntries.$inferSelect,
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): ToolCatalogEntry {
|
|
const rawCatalogEntry = toCatalogEntry(row);
|
|
const catalogEntry = {
|
|
...rawCatalogEntry,
|
|
inputSchema: projectedConnectionToolInputSchema(
|
|
connection,
|
|
rawCatalogEntry.inputSchema ?? {},
|
|
row.toolName,
|
|
),
|
|
description: gmailArchiveToolDescription(
|
|
connection,
|
|
row.toolName,
|
|
googleChatToolDescription(connection, row.toolName, rawCatalogEntry.description),
|
|
),
|
|
};
|
|
if (
|
|
connection.transport === "local_stdio" &&
|
|
asRecord(connection.config).templateId === GOOGLE_SHEETS_TEMPLATE_ID &&
|
|
!schemaHasInputProperties(catalogEntry.inputSchema)
|
|
) {
|
|
const templateTool = APPROVED_STDIO_TEMPLATES[
|
|
GOOGLE_SHEETS_TEMPLATE_ID
|
|
].tools.find((tool) => tool.name === row.toolName);
|
|
if (schemaHasInputProperties(templateTool?.inputSchema)) {
|
|
return { ...catalogEntry, inputSchema: templateTool!.inputSchema! };
|
|
}
|
|
}
|
|
return catalogEntry;
|
|
}
|
|
|
|
function builtInStdioTemplate(
|
|
templateId: string,
|
|
): ToolStdioCommandTemplate | null {
|
|
const template = APPROVED_STDIO_TEMPLATES[templateId];
|
|
if (!template) return null;
|
|
return {
|
|
templateId,
|
|
name: template.name,
|
|
title: template.name,
|
|
description: null,
|
|
status: "active",
|
|
source: "built_in",
|
|
command: template.command ?? null,
|
|
args: template.args ?? [],
|
|
envKeys: template.envKeys ?? [],
|
|
tools: template.tools.map((tool) => ({
|
|
name: tool.name,
|
|
title: tool.title ?? null,
|
|
description: tool.description ?? null,
|
|
inputSchema: tool.inputSchema ?? { type: "object", properties: {} },
|
|
annotations: tool.annotations ?? {},
|
|
})),
|
|
};
|
|
}
|
|
|
|
function toStdioCommandTemplate(
|
|
row: typeof toolStdioCommandTemplates.$inferSelect,
|
|
): ToolStdioCommandTemplate {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
templateId: row.templateKey,
|
|
name: row.name,
|
|
title: row.name,
|
|
description: row.description,
|
|
status: row.status,
|
|
source: "admin",
|
|
command: row.command,
|
|
args: row.args ?? [],
|
|
envKeys: row.envKeys ?? [],
|
|
tools: (row.tools ?? [])
|
|
.map((tool) => normalizeToolDescriptor(tool))
|
|
.filter((tool): tool is McpToolDescriptor => Boolean(tool))
|
|
.map((tool) => ({
|
|
name: tool.name,
|
|
title: tool.title ?? null,
|
|
description: tool.description ?? null,
|
|
inputSchema: tool.inputSchema ?? { type: "object", properties: {} },
|
|
annotations: tool.annotations ?? {},
|
|
})),
|
|
createdByAgentId: row.createdByAgentId,
|
|
createdByUserId: row.createdByUserId,
|
|
disabledAt: row.disabledAt,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
function connectionSetupMutationFingerprint(
|
|
row: typeof toolConnections.$inferSelect,
|
|
): string {
|
|
return stableHash({
|
|
name: row.name,
|
|
transport: row.transport,
|
|
status: row.status,
|
|
enabled: row.enabled,
|
|
agentInstructions: row.agentInstructions,
|
|
config: row.config,
|
|
transportConfig: row.transportConfig,
|
|
credentialRefs: row.credentialRefs,
|
|
credentialSecretRefs: row.credentialSecretRefs,
|
|
credentialSource: row.credentialSource,
|
|
externalCredential: row.externalCredential,
|
|
credentialPolicy: row.credentialPolicy,
|
|
});
|
|
}
|
|
|
|
function readStdioTemplateId(config: Record<string, unknown>): string {
|
|
const templateId = config.templateId;
|
|
if (typeof templateId !== "string" || templateId.trim().length === 0) {
|
|
throw badRequest(
|
|
"Local stdio MCP connections must use an approved templateId",
|
|
);
|
|
}
|
|
return templateId.trim();
|
|
}
|
|
|
|
async function gitHubReadGrantAccess(db: Db, companyId: string, connectionId: string, userId: string | null, localTrusted: boolean) {
|
|
const [connection] = await db.select().from(toolConnections).where(and(eq(toolConnections.companyId, companyId), eq(toolConnections.id, connectionId)));
|
|
if (!connection || !connection.enabled || connection.status !== "active" || asRecord(connection.config).sourceTemplateKey !== "github") {
|
|
throw forbidden("GitHub connection is unavailable. Choose a connection you can use.");
|
|
}
|
|
const [membership] = userId ? await db.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, userId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.status, "active"))) : [];
|
|
if (!localTrusted && (!membership || membership.membershipRole === "viewer")) throw forbidden("GitHub access requires an active company member.");
|
|
const grants = await db.select().from(connectionGrants).where(and(eq(connectionGrants.companyId, companyId), eq(connectionGrants.connectionId, connectionId)));
|
|
const members = await db.select().from(connectionGrantMembers).where(eq(connectionGrantMembers.companyId, companyId));
|
|
const candidates = grants.filter(grant => !(grant.kind === "organization" && ["per_user", "per_agent"].includes(connection.credentialPolicy)) && canBrowseProjectRepositoryGrant({
|
|
grant, userId, activeMember: localTrusted || Boolean(membership), audience: members.filter(member => member.grantId === grant.id).map(member => member.subjectId),
|
|
}));
|
|
const allowed: typeof candidates = [];
|
|
for (const grant of candidates) {
|
|
const ref = grant.credentialSecretRefs.find(ref => ref.configPath === "oauth.access_token" || /authorization|token|api_key/i.test(ref.configPath));
|
|
if (!ref) continue;
|
|
try {
|
|
await validateConnectionGrantSecretOwnership(db, connection, grant, ref);
|
|
allowed.push(grant);
|
|
} catch (error) {
|
|
if (!(error instanceof HttpError && asRecord(error.details).code === "grant_credential_invalid")) throw error;
|
|
}
|
|
}
|
|
const legacyShared = !grants.length && connection.credentialPolicy === "shared";
|
|
if (!allowed.length && !legacyShared) throw forbidden("Choose a GitHub connection with an active authorization you can use.");
|
|
return { connection, allowed, legacyShared };
|
|
}
|
|
|
|
export function toolAccessService(
|
|
db: Db,
|
|
options: ToolAccessServiceOptions = {},
|
|
) {
|
|
const secrets = secretService(db);
|
|
const remoteHttp = createToolRemoteHttp(options);
|
|
const { allowPrivateRemoteEndpoints, assertRemoteHttpUrlAllowed, fetchRemoteHttpUrl, assertRemoteEndpointAllowed, assertRemoteConnectionEndpointsAllowed, requestRemoteHttpEndpoint } = remoteHttp;
|
|
const { assertOAuthEndpointUrl, safeOAuthEndpointUrl } = createOAuthEndpointPolicy(allowPrivateRemoteEndpoints);
|
|
const { oauthEndpointsForConnection, discoverOAuthEndpoints, preflightConnectionMetadata } = createOAuthDiscovery({
|
|
fetchRemoteHttpUrl, assertRemoteEndpointAllowed, assertOAuthEndpointUrl, safeOAuthEndpointUrl,
|
|
});
|
|
const { requestOAuthClientRegistration, exchangeOAuthToken } = createOAuthClientProtocol({
|
|
fetchRemoteHttpUrl, assertOAuthEndpointUrl,
|
|
});
|
|
const policySvc = toolAccessPolicyService(db);
|
|
const now = options.now ?? (() => new Date());
|
|
const assertSecretRefs = createConnectionCredentialValidation(db);
|
|
const connectionInstalls = createConnectionInstalls(db, { now, getConnectionRow, assertOptionalAgent });
|
|
const { resolveInstalledConnectionsForAgent, listConnectionInstalls } = connectionInstalls;
|
|
const audit = createToolAccessAudit(db);
|
|
const ensureDefaultOrganizationGrant = createOrganizationGrantStore(db);
|
|
const { mintConnectionTokenForAgent, loadBrokerRunContext } = createConnectionTokenBroker(db, { now, secrets, policySvc, audit, getConnectionRow, ensureDefaultOrganizationGrant });
|
|
const profileService = createToolProfileService(db, now);
|
|
const profileDetails = profileService.getProfile;
|
|
const profileResolver = createToolProfileResolver(db, { assertOptionalAgent, resolveInstalledConnectionsForAgent, toCatalogEntry });
|
|
const configuredCloudConnector =
|
|
options.paperclipCloudConnector ?? options.paperclipIdGmailConnector;
|
|
const connectorWasProvided =
|
|
options.paperclipCloudConnector !== undefined ||
|
|
options.paperclipIdGmailConnector !== undefined;
|
|
let cachedCloudConnector = configuredCloudConnector ?? null;
|
|
const currentCloudConnector = (): PaperclipCloudConnector | null => {
|
|
if (cachedCloudConnector || connectorWasProvided)
|
|
return cachedCloudConnector;
|
|
const config = paperclipCloudConnectorConfigFromEnv();
|
|
cachedCloudConnector = config
|
|
? createPaperclipCloudConnector({ config, now: () => now().getTime() })
|
|
: null;
|
|
return cachedCloudConnector;
|
|
};
|
|
async function appForConnectionSetup(app: AppDefinition): Promise<AppDefinition> {
|
|
if (!app.methods.some((method) => isPaperclipCloudConnectorStrategy(method.oauthStrategy))) {
|
|
return app;
|
|
}
|
|
const profiles = connectorWasProvided
|
|
? (await currentCloudConnector()?.getCapabilities() ?? [])
|
|
: await paperclipCloudConnectorCapabilitiesFromEnv();
|
|
return appWithPaperclipCloudConnectorAvailability(app, profiles);
|
|
}
|
|
const vercelConnect =
|
|
options.vercelConnectClient === undefined
|
|
? createVercelConnectClient()
|
|
: options.vercelConnectClient;
|
|
const runtimeSupervisor = createToolRuntimeSupervisor(db, options);
|
|
const connectionGrantsService = createConnectionGrants(db, { getConnectionRow, assertSecretRefs, vercelConnect });
|
|
const removeConnection = createConnectionRemoval(db, { options, secrets, runtimeSupervisor, vercelConnect, getConnectionRow });
|
|
const credentialStore = createOAuthCredentialStore(db, secrets);
|
|
const { syncCredentialBindings, annotateSavedOAuthClientSecret, oauthClientForConnection, createOrRotateOAuthSecret, getOAuthGrantRow, resolveOAuthGrantSecret } = credentialStore;
|
|
const ensureOAuthClient = createOAuthClientRegistration(db, { now, options, store: credentialStore, getConnectionRow, requestOAuthClientRegistration });
|
|
const refreshLeases = createOAuthRefreshLeases(db, now, getConnectionRow, getOAuthGrantRow);
|
|
const maybeRefreshOAuthCredentials = createLegacyOAuthRefresh(db, { now, secrets, store: credentialStore, leases: refreshLeases, getConnectionRow, exchangeOAuthToken });
|
|
const refreshOAuthGrantCredentials = createOAuthGrantRefresh(db, { now, store: credentialStore, leases: refreshLeases, currentCloudConnector, getConnectionRow, exchangeOAuthToken });
|
|
const { refreshManagedGitHubGrantAccess, sweepGitHubConnectionContinuity } = createGitHubGrantContinuity(db, { now, currentCloudConnector, refreshOAuthGrantCredentials, resolveOAuthGrantSecret });
|
|
const vercelCredentials = createVercelCredentialService(db, now, vercelConnect);
|
|
const { resolveVercelCredentialHeaders, vercelGrantForConnection } = vercelCredentials;
|
|
const credentialAccess = createConnectionCredentialAccess(db, { secrets, audit, vercel: vercelCredentials, resolveOAuthGrantSecret, maybeRefreshOAuthCredentials, refreshOAuthGrantCredentials });
|
|
const { vaultGrantForConnection, resolveCredentialHeaders, resolvedRemoteEndpoint } = credentialAccess;
|
|
const setupPolicy = createConnectionSetupPolicy(db);
|
|
const { assertConnectorConnectionSetupEnabled, assertExperimentalConnectorSetupEnabled } = setupPolicy;
|
|
const fixedPersonalIdentityForReconnect = createPersonalConnectionIdentity(db);
|
|
const runtimeSlots = createToolRuntimeSlots(db, runtimeSupervisor);
|
|
const { ensureRuntimeSlot } = runtimeSlots;
|
|
const defaultCatalogAccess = createCatalogDefaultAccess(db);
|
|
const { enableCatalogEntriesByDefault, upsertAskFirstPolicies } = defaultCatalogAccess;
|
|
const catalogDiscovery = createToolCatalogDiscovery(db, { now, audit, secrets, credentials: credentialAccess, vercel: vercelCredentials, vercelConnect, setupPolicy, requestRemoteHttpEndpoint, resolveOAuthGrantSecret, discoverOAuthEndpoints, localTools });
|
|
const connectionHealth = createConnectionHealth(db, { now, audit, getConnectionRow, discovery: catalogDiscovery, setupPolicy, resolveCredentialHeaders, refreshManagedGitHubGrantAccess, ensureRuntimeSlot, stdioTemplateId });
|
|
const { updateConnectionHealth, checkConnectionHealth, sweepConnectionHealth } = connectionHealth;
|
|
const refreshCatalog = createToolCatalogPublication(db, { now, audit, getConnectionRow, discoverTools: catalogDiscovery.discoverTools, assertConnectorConnectionSetupEnabled, updateConnectionHealth, vaultGrantForConnection, ensureRuntimeSlot, enableCatalogEntriesByDefault });
|
|
const catalogReader = createToolCatalogReader(db, options, now, getConnectionRow, refreshCatalog, toCatalogEntryForConnection);
|
|
const listAppsNeedingAttention = createToolAppAttention(db, now);
|
|
const aggregatorAccounts = createAggregatorAccounts(db, { options, secrets, audit, credentials: credentialAccess, getConnectionRow, requestRemoteHttpEndpoint, remoteTools: catalogDiscovery.remoteTools });
|
|
const oauthState = createOAuthStateStore(db);
|
|
const { peekOAuthState } = oauthState;
|
|
const finishOAuthCatalogWithRecommendedDefaults = createOAuthCatalogCompletion(db, finishGalleryAppConnection);
|
|
const startOAuth = createOAuthAuthorizationStart(db, { now, getConnectionRow, setupPolicy, fixedPersonalIdentityForReconnect, currentCloudConnector, vercelConnect, ensureOAuthClient, oauthEndpointsForConnection, assertOAuthEndpointUrl });
|
|
const completeOAuthCallback = createDirectOAuthCallback(db, { now, getConnectionRow, state: oauthState, store: credentialStore, setupPolicy, refreshCatalog, finishOAuthCatalogWithRecommendedDefaults, checkConnectionHealth, ensureDefaultOrganizationGrant, oauthEndpointsForConnection, exchangeOAuthToken });
|
|
const completePaperclipCloudConnectorCallback = createCloudOAuthCallback(db, { now, getConnectionRow, state: oauthState, store: credentialStore, refreshCatalog, finishOAuthCatalogWithRecommendedDefaults, currentCloudConnector });
|
|
const completeVercelConnectCallback = createVercelOAuthCallback(db, { now, getConnectionRow, state: oauthState, refreshCatalog, finishOAuthCatalogWithRecommendedDefaults, vercelConnect });
|
|
const finalizeOAuthAccess = createOAuthAccessFinalization(db, { getConnectionRow, secrets, store: credentialStore, assertAgentsInCompany, finishGalleryAppConnection });
|
|
const runtimeHealth = createToolRuntimeHealth(db, options, now, trustedRuntimeHost);
|
|
const toolActivity = createToolActivityService(db, getConnectionRow);
|
|
const toolActionReviewQueue = createToolActionReviewQueue(db);
|
|
|
|
function trustedRuntimeHost() {
|
|
return (
|
|
options.trustedLocalStdioRuntimeHost ??
|
|
process.env.PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST ??
|
|
process.env.PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST ??
|
|
null
|
|
);
|
|
}
|
|
|
|
function assertLocalStdioCanBeEnabled(
|
|
transport: ToolConnectionTransport,
|
|
enabled: boolean,
|
|
config: Record<string, unknown> = {},
|
|
) {
|
|
if (config.templateId === "paperclip.cognee-cloud") return;
|
|
if (
|
|
transport === "local_stdio" &&
|
|
enabled &&
|
|
options.deploymentMode === "authenticated" &&
|
|
options.deploymentExposure === "public" &&
|
|
!trustedRuntimeHost()
|
|
) {
|
|
throw unprocessable(
|
|
"Local stdio MCP connections cannot be enabled in authenticated public deployments without a trusted runtime host",
|
|
);
|
|
}
|
|
}
|
|
|
|
async function getAdminStdioTemplate(companyId: string, templateId: string) {
|
|
return db
|
|
.select()
|
|
.from(toolStdioCommandTemplates)
|
|
.where(
|
|
and(
|
|
eq(toolStdioCommandTemplates.companyId, companyId),
|
|
eq(toolStdioCommandTemplates.templateKey, templateId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function resolveStdioTemplate(
|
|
companyId: string,
|
|
configOrTemplateId: Record<string, unknown> | string,
|
|
) {
|
|
const templateId =
|
|
typeof configOrTemplateId === "string"
|
|
? configOrTemplateId.trim()
|
|
: readStdioTemplateId(configOrTemplateId);
|
|
const builtIn = builtInStdioTemplate(templateId);
|
|
if (builtIn) return builtIn;
|
|
const adminTemplate = await getAdminStdioTemplate(companyId, templateId);
|
|
if (!adminTemplate || adminTemplate.status !== "active") {
|
|
throw badRequest(
|
|
"Local stdio MCP connections must use an approved templateId",
|
|
);
|
|
}
|
|
return toStdioCommandTemplate(adminTemplate);
|
|
}
|
|
|
|
async function stdioTemplateId(
|
|
companyId: string,
|
|
config: Record<string, unknown>,
|
|
): Promise<string> {
|
|
return (await resolveStdioTemplate(companyId, config)).templateId;
|
|
}
|
|
|
|
async function assertApplication(companyId: string, applicationId: string) {
|
|
const [row] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(
|
|
and(
|
|
eq(toolApplications.id, applicationId),
|
|
eq(toolApplications.companyId, companyId),
|
|
),
|
|
);
|
|
if (!row) throw notFound("Tool application not found");
|
|
return row;
|
|
}
|
|
|
|
async function assertOptionalAgent(
|
|
companyId: string,
|
|
agentId: string | null | undefined,
|
|
label: string,
|
|
) {
|
|
if (!agentId) return;
|
|
const [row] = await db
|
|
.select({ id: agents.id })
|
|
.from(agents)
|
|
.where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)));
|
|
if (!row) throw unprocessable(`${label} must belong to the same company`);
|
|
}
|
|
|
|
async function assertOptionalPlugin(pluginId: string | null | undefined) {
|
|
if (!pluginId) return;
|
|
const [row] = await db
|
|
.select({ id: plugins.id })
|
|
.from(plugins)
|
|
.where(eq(plugins.id, pluginId));
|
|
if (!row) throw unprocessable("Tool application plugin was not found");
|
|
}
|
|
|
|
async function assertGoogleSheetsSpreadsheetOwnership(
|
|
companyId: string,
|
|
config: Record<string, unknown>,
|
|
options: { excludeConnectionId?: string } = {},
|
|
) {
|
|
if (!isGoogleSheetsConnectionConfig(config)) return;
|
|
const allowedSpreadsheetIds = googleSheetsAllowedSpreadsheetIds(config);
|
|
if (allowedSpreadsheetIds.length === 0) return;
|
|
const allowed = new Set(allowedSpreadsheetIds);
|
|
const rows = await db
|
|
.select({
|
|
id: toolConnections.id,
|
|
companyId: toolConnections.companyId,
|
|
config: toolConnections.config,
|
|
})
|
|
.from(toolConnections)
|
|
.where(ne(toolConnections.status, "archived"));
|
|
|
|
const conflictingSpreadsheetIds = new Set<string>();
|
|
for (const row of rows) {
|
|
if (row.id === options.excludeConnectionId || row.companyId === companyId)
|
|
continue;
|
|
if (!isGoogleSheetsConnectionConfig(row.config)) continue;
|
|
for (const spreadsheetId of googleSheetsAllowedSpreadsheetIds(
|
|
row.config,
|
|
)) {
|
|
if (allowed.has(spreadsheetId))
|
|
conflictingSpreadsheetIds.add(spreadsheetId);
|
|
}
|
|
}
|
|
|
|
if (conflictingSpreadsheetIds.size > 0) {
|
|
throw conflict(
|
|
"Google Sheets spreadsheet is already connected to another company.",
|
|
{
|
|
code: "google_sheets_spreadsheet_already_bound",
|
|
spreadsheetIds: Array.from(conflictingSpreadsheetIds).sort(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
async function assertCatalogEntry(
|
|
companyId: string,
|
|
catalogEntryId: string | null | undefined,
|
|
) {
|
|
if (!catalogEntryId) return;
|
|
const [row] = await db
|
|
.select({ id: toolCatalogEntries.id })
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.id, catalogEntryId),
|
|
eq(toolCatalogEntries.companyId, companyId),
|
|
),
|
|
);
|
|
if (!row)
|
|
throw unprocessable(
|
|
"Tool profile catalog entry selector must belong to the same company",
|
|
);
|
|
}
|
|
|
|
async function getConnectionRow(idOrUid: string, companyId?: string) {
|
|
const identifier =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
|
idOrUid,
|
|
)
|
|
? eq(toolConnections.id, idOrUid)
|
|
: eq(toolConnections.uid, idOrUid);
|
|
const where = companyId
|
|
? and(identifier, eq(toolConnections.companyId, companyId))
|
|
: identifier;
|
|
const [row] = await db.select().from(toolConnections).where(where);
|
|
if (!row) throw notFound("Tool connection not found");
|
|
return row;
|
|
}
|
|
|
|
async function localTools(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): Promise<McpToolDescriptor[]> {
|
|
const template = await resolveStdioTemplate(
|
|
connection.companyId,
|
|
connection.config,
|
|
);
|
|
return template.tools.map((tool) => ({
|
|
name: tool.name,
|
|
title: tool.title ?? null,
|
|
description: tool.description ?? null,
|
|
inputSchema: tool.inputSchema ?? { type: "object", properties: {} },
|
|
annotations: tool.annotations ?? {},
|
|
}));
|
|
}
|
|
|
|
async function annotateAiGrantHealth(connections: ToolConnection[]) {
|
|
const aiConnections = connections.filter(connection => connection.connectionPurpose === "ai");
|
|
if (!aiConnections.length) return;
|
|
const grants = await db.select({ connectionId: connectionGrants.connectionId, status: connectionGrants.status })
|
|
.from(connectionGrants).where(and(eq(connectionGrants.companyId, aiConnections[0].companyId),
|
|
inArray(connectionGrants.connectionId, aiConnections.map(connection => connection.id))));
|
|
for (const connection of aiConnections) {
|
|
const identities = grants.filter(grant => grant.connectionId === connection.id);
|
|
if (identities.length && identities.every(grant => grant.status === "revoked")) {
|
|
connection.healthStatus = "missing_secret";
|
|
connection.healthMessage = "This credential was revoked. Reconnect the account to restore access.";
|
|
connection.requiresReauthorization = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function annotateGitHubAuthorization(
|
|
connections: ToolConnection[],
|
|
viewerUserId?: string,
|
|
) {
|
|
const github = connections.filter(
|
|
(connection) =>
|
|
asRecord(connection.config).sourceTemplateKey === "github",
|
|
);
|
|
if (!github.length) return;
|
|
const grants = await db
|
|
.select({
|
|
connectionId: connectionGrants.connectionId,
|
|
status: connectionGrants.status,
|
|
kind: connectionGrants.kind,
|
|
subjectUserId: connectionGrants.subjectUserId,
|
|
})
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, github[0].companyId),
|
|
inArray(
|
|
connectionGrants.connectionId,
|
|
github.map((connection) => connection.id),
|
|
),
|
|
),
|
|
);
|
|
for (const connection of github) {
|
|
const userId = viewerUserId ?? connection.createdByUserId;
|
|
const eligible = grants.filter(
|
|
(grant) =>
|
|
grant.connectionId === connection.id &&
|
|
(connection.credentialPolicy !== "per_user" ||
|
|
(grant.kind === "user" && grant.subjectUserId === userId)),
|
|
);
|
|
connection.requiresReauthorization = !eligible.some(
|
|
(grant) => grant.status === "active",
|
|
);
|
|
}
|
|
}
|
|
|
|
function findExample(exampleId: string): ToolExampleDefinition {
|
|
const definition = TOOL_EXAMPLES.find(
|
|
(example) => example.id === exampleId,
|
|
);
|
|
if (!definition) throw notFound("Tool example not found");
|
|
return definition;
|
|
}
|
|
|
|
function localStdioInstallBlocker(): string | null {
|
|
return options.deploymentMode === "authenticated" &&
|
|
options.deploymentExposure === "public" &&
|
|
!trustedRuntimeHost()
|
|
? "Local stdio examples require a trusted MCP runtime host in authenticated public deployments."
|
|
: null;
|
|
}
|
|
|
|
function exampleToolSummaries(
|
|
definition: ToolExampleDefinition,
|
|
): ToolExampleSummary["fixture"]["tools"] {
|
|
return APPROVED_STDIO_TEMPLATES[definition.templateId].tools.map((tool) => {
|
|
const riskLevel = classifyRisk(tool);
|
|
return {
|
|
name: tool.name,
|
|
description: tool.description ?? null,
|
|
riskLevel,
|
|
readOnly: riskLevel === "read",
|
|
};
|
|
});
|
|
}
|
|
|
|
async function exampleRows(
|
|
companyId: string,
|
|
definition: ToolExampleDefinition,
|
|
) {
|
|
const [application] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(
|
|
and(
|
|
eq(toolApplications.companyId, companyId),
|
|
eq(toolApplications.applicationKey, definition.applicationKey),
|
|
),
|
|
);
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.companyId, companyId),
|
|
eq(toolConnections.name, definition.connectionName),
|
|
),
|
|
);
|
|
const [profile] = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(
|
|
and(
|
|
eq(toolProfiles.companyId, companyId),
|
|
eq(toolProfiles.profileKey, definition.profileKey),
|
|
),
|
|
);
|
|
const [profileBinding] = profile
|
|
? await db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, companyId),
|
|
eq(toolProfileBindings.profileId, profile.id),
|
|
eq(toolProfileBindings.targetType, "company"),
|
|
eq(toolProfileBindings.targetId, companyId),
|
|
),
|
|
)
|
|
: [];
|
|
const catalog = connection
|
|
? await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, companyId),
|
|
eq(toolCatalogEntries.connectionId, connection.id),
|
|
),
|
|
)
|
|
.orderBy(asc(toolCatalogEntries.toolName))
|
|
: [];
|
|
return {
|
|
application: application ?? null,
|
|
connection: connection ?? null,
|
|
profile: profile ?? null,
|
|
profileBinding: profileBinding ?? null,
|
|
catalog,
|
|
};
|
|
}
|
|
|
|
function exampleSummary(
|
|
definition: ToolExampleDefinition,
|
|
rows: Awaited<ReturnType<typeof exampleRows>>,
|
|
): ToolExampleSummary {
|
|
const blocker = localStdioInstallBlocker();
|
|
const tools = exampleToolSummaries(definition);
|
|
const installed = Boolean(
|
|
rows.application &&
|
|
rows.connection &&
|
|
rows.profile &&
|
|
rows.profileBinding &&
|
|
rows.connection.status !== "archived" &&
|
|
rows.profile.status !== "archived",
|
|
);
|
|
return {
|
|
id: definition.id,
|
|
title: definition.title,
|
|
description: definition.description,
|
|
fixture: {
|
|
transport: "local_stdio",
|
|
templateId: definition.templateId,
|
|
available: Boolean(APPROVED_STDIO_TEMPLATES[definition.templateId]),
|
|
tools,
|
|
},
|
|
safeDefaultProfile: {
|
|
profileKey: definition.profileKey,
|
|
name: definition.profileName,
|
|
defaultAction: "deny",
|
|
allowedToolNames: tools
|
|
.filter((tool) => tool.readOnly)
|
|
.map((tool) => tool.name),
|
|
},
|
|
install: {
|
|
installed,
|
|
canInstall: !blocker,
|
|
reason: blocker,
|
|
applicationId: rows.application?.id ?? null,
|
|
connectionId: rows.connection?.id ?? null,
|
|
profileId: rows.profile?.id ?? null,
|
|
profileBindingId: rows.profileBinding?.id ?? null,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function upsertExampleApplication(
|
|
companyId: string,
|
|
definition: ToolExampleDefinition,
|
|
existing: typeof toolApplications.$inferSelect | null,
|
|
) {
|
|
const metadata = {
|
|
...(existing?.metadata ?? {}),
|
|
source: "paperclip_example",
|
|
exampleId: definition.id,
|
|
safeDefault: true,
|
|
};
|
|
if (existing) {
|
|
const [updated] = await db
|
|
.update(toolApplications)
|
|
.set({
|
|
name: definition.applicationName,
|
|
description: definition.applicationDescription,
|
|
type: "mcp_stdio",
|
|
status: "active",
|
|
metadata,
|
|
archivedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolApplications.id, existing.id))
|
|
.returning();
|
|
return { row: updated, created: false };
|
|
}
|
|
const [created] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId,
|
|
applicationKey: definition.applicationKey,
|
|
name: definition.applicationName,
|
|
description: definition.applicationDescription,
|
|
type: "mcp_stdio",
|
|
status: "active",
|
|
metadata,
|
|
})
|
|
.returning();
|
|
return { row: created, created: true };
|
|
}
|
|
|
|
async function upsertExampleConnection(
|
|
companyId: string,
|
|
definition: ToolExampleDefinition,
|
|
applicationId: string,
|
|
existing: typeof toolConnections.$inferSelect | null,
|
|
) {
|
|
const config = {
|
|
templateId: definition.templateId,
|
|
exampleId: definition.id,
|
|
safeDefault: true,
|
|
quarantineNewEntries: true,
|
|
};
|
|
if (existing) {
|
|
const [updated] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
applicationId,
|
|
name: definition.connectionName,
|
|
transport: "local_stdio",
|
|
status: "active",
|
|
enabled: true,
|
|
config,
|
|
transportConfig: config,
|
|
credentialRefs: [],
|
|
credentialSecretRefs: [],
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, existing.id))
|
|
.returning();
|
|
await ensureDefaultOrganizationGrant(updated);
|
|
await syncCredentialBindings(updated);
|
|
await ensureRuntimeSlot(updated);
|
|
emitConnectionUpdated(updated, existing, "example");
|
|
return { row: updated, created: false };
|
|
}
|
|
const connectionId = randomUUID();
|
|
const [created] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
id: connectionId,
|
|
companyId,
|
|
applicationId,
|
|
name: definition.connectionName,
|
|
uid: connectionUid(
|
|
"paperclip",
|
|
definition.connectionName,
|
|
connectionId,
|
|
),
|
|
connectionKind: "managed",
|
|
transport: "local_stdio",
|
|
status: "active",
|
|
enabled: true,
|
|
config,
|
|
transportConfig: config,
|
|
credentialRefs: [],
|
|
credentialSecretRefs: [],
|
|
})
|
|
.returning();
|
|
await ensureDefaultOrganizationGrant(created);
|
|
await syncCredentialBindings(created);
|
|
await ensureRuntimeSlot(created);
|
|
emitConnectionCreated(created, "example");
|
|
return { row: created, created: true };
|
|
}
|
|
|
|
async function upsertExampleProfile(
|
|
companyId: string,
|
|
definition: ToolExampleDefinition,
|
|
existing: typeof toolProfiles.$inferSelect | null,
|
|
) {
|
|
const metadata = {
|
|
...(existing?.metadata ?? {}),
|
|
source: "paperclip_example",
|
|
exampleId: definition.id,
|
|
safeDefault: true,
|
|
};
|
|
if (existing) {
|
|
const [updated] = await db
|
|
.update(toolProfiles)
|
|
.set({
|
|
name: definition.profileName,
|
|
description: definition.profileDescription,
|
|
status: "active",
|
|
defaultAction: "deny",
|
|
metadata,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolProfiles.id, existing.id))
|
|
.returning();
|
|
return { row: updated, created: false };
|
|
}
|
|
const [created] = await db
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId,
|
|
profileKey: definition.profileKey,
|
|
name: definition.profileName,
|
|
description: definition.profileDescription,
|
|
status: "active",
|
|
defaultAction: "deny",
|
|
metadata,
|
|
})
|
|
.returning();
|
|
return { row: created, created: true };
|
|
}
|
|
|
|
async function syncExampleProfileEntries(
|
|
companyId: string,
|
|
profileId: string,
|
|
catalog: ToolCatalogEntry[],
|
|
): Promise<ToolProfileEntry[]> {
|
|
await db
|
|
.delete(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, companyId),
|
|
eq(toolProfileEntries.profileId, profileId),
|
|
),
|
|
);
|
|
const readEntries = catalog.filter(
|
|
(entry) => entry.riskLevel === "read" && entry.status === "active",
|
|
);
|
|
if (readEntries.length === 0) return [];
|
|
const rows = await db
|
|
.insert(toolProfileEntries)
|
|
.values(
|
|
readEntries.map((entry) => ({
|
|
companyId,
|
|
profileId,
|
|
selectorType: "catalog_entry" as const,
|
|
effect: "include" as const,
|
|
applicationId: entry.applicationId,
|
|
connectionId: entry.connectionId,
|
|
catalogEntryId: entry.id,
|
|
toolName: entry.toolName,
|
|
riskLevel: entry.riskLevel,
|
|
conditions: { source: "paperclip_example" },
|
|
})),
|
|
)
|
|
.returning();
|
|
return rows.map(toProfileEntry);
|
|
}
|
|
|
|
async function upsertExampleProfileBinding(
|
|
companyId: string,
|
|
profileId: string,
|
|
existing: typeof toolProfileBindings.$inferSelect | null,
|
|
actor?: ActorInfo,
|
|
): Promise<ToolProfileBinding> {
|
|
const metadata = {
|
|
...(existing?.metadata ?? {}),
|
|
source: "paperclip_example",
|
|
safeDefault: true,
|
|
};
|
|
if (existing) {
|
|
const [updated] = await db
|
|
.update(toolProfileBindings)
|
|
.set({ priority: 100, metadata, updatedAt: new Date() })
|
|
.where(eq(toolProfileBindings.id, existing.id))
|
|
.returning();
|
|
return toProfileBinding(updated);
|
|
}
|
|
const [created] = await db
|
|
.insert(toolProfileBindings)
|
|
.values({
|
|
companyId,
|
|
profileId,
|
|
targetType: "company",
|
|
targetId: companyId,
|
|
priority: 100,
|
|
metadata,
|
|
createdByAgentId:
|
|
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
|
createdByUserId:
|
|
connectionActorUserId(actor),
|
|
})
|
|
.returning();
|
|
return toProfileBinding(created);
|
|
}
|
|
|
|
async function exampleSmokeActor(companyId: string, actor?: ActorInfo) {
|
|
const [agent] = await db
|
|
.select({ id: agents.id })
|
|
.from(agents)
|
|
.where(eq(agents.companyId, companyId))
|
|
.limit(1);
|
|
if (agent) {
|
|
return {
|
|
actorType: "agent" as const,
|
|
actorId: agent.id,
|
|
agentId: agent.id,
|
|
};
|
|
}
|
|
const actorType =
|
|
actor?.actorType === "user" ? ("user" as const) : ("system" as const);
|
|
return {
|
|
actorType,
|
|
actorId: actor?.actorId ?? "example-smoke",
|
|
agentId: null,
|
|
};
|
|
}
|
|
|
|
function sampleArguments(toolName: string): Record<string, unknown> {
|
|
if (toolName === "get_value") return { key: "project" };
|
|
if (toolName === "set_value") return { key: "project", value: "paperclip" };
|
|
if (toolName === "create_item") return { title: "Smoke test item" };
|
|
if (toolName === "mark_done" || toolName === "delete_item")
|
|
return { id: "todo-1" };
|
|
return {};
|
|
}
|
|
|
|
async function runSmokeDecisionCheck(input: {
|
|
companyId: string;
|
|
actor: Awaited<ReturnType<typeof exampleSmokeActor>>;
|
|
connection: ToolConnection;
|
|
catalogEntry: ToolCatalogEntry;
|
|
expectedDecision: ToolPolicyDecision;
|
|
name: string;
|
|
}): Promise<ToolExampleSmokeCheck> {
|
|
const decisionInput = {
|
|
companyId: input.companyId,
|
|
actor: input.actor,
|
|
request: {
|
|
applicationId: input.connection.applicationId,
|
|
connectionId: input.connection.id,
|
|
catalogEntryId: input.catalogEntry.id,
|
|
toolName: input.catalogEntry.toolName,
|
|
arguments: sampleArguments(input.catalogEntry.toolName),
|
|
},
|
|
};
|
|
const decision = await policySvc.decide(decisionInput);
|
|
const auditResult = await policySvc.writeAudit(
|
|
decisionInput,
|
|
decision,
|
|
"policy_decision",
|
|
);
|
|
return {
|
|
name: input.name,
|
|
ok: decision.decision === input.expectedDecision,
|
|
toolName: input.catalogEntry.toolName,
|
|
expectedDecision: input.expectedDecision,
|
|
decision: decision.decision,
|
|
reasonCode: decision.reasonCode,
|
|
explanation: decision.explanation,
|
|
auditEventId: auditResult.legacyAuditEvent.id,
|
|
toolCallEventId: auditResult.toolCallEvent.id,
|
|
};
|
|
}
|
|
|
|
function defaultLinkName(link: string): string {
|
|
try {
|
|
const url = new URL(link);
|
|
const host = url.host.replace(/^www\./, "");
|
|
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, "");
|
|
return `${host}${path}`.slice(0, 160) || "MCP app";
|
|
} catch {
|
|
return "MCP app";
|
|
}
|
|
}
|
|
|
|
function linkCredentialFields(credentialValues: Record<string, string>) {
|
|
const fields: Array<{
|
|
label: string;
|
|
configPath: string;
|
|
required: boolean;
|
|
placement: "header";
|
|
key: string;
|
|
prefix: string | null;
|
|
}> = [];
|
|
if (credentialValues["credentials.authorization"]?.trim()) {
|
|
fields.push({
|
|
label: "App key",
|
|
configPath: "credentials.authorization",
|
|
required: false,
|
|
placement: "header",
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
});
|
|
}
|
|
for (const configPath of Object.keys(credentialValues).sort()) {
|
|
if (!configPath.startsWith("headers.")) continue;
|
|
const headerName = mcpRemoteHeaderNameFromConfigPath(configPath);
|
|
if (!headerName)
|
|
throw badRequest("Header names cannot be blank.", {
|
|
code: "mcp_header_rejected",
|
|
});
|
|
// The API schema already rejected unsafe headers, but this is the last
|
|
// point before a name becomes a real outbound request header — and the
|
|
// normalized paste-config path lands here too — so re-check rather than
|
|
// trust the caller.
|
|
const nameCheck = checkMcpRemoteHeaderName(headerName);
|
|
if (!nameCheck.ok) {
|
|
throw badRequest(
|
|
mcpRemoteHeaderRejectionMessage(headerName, nameCheck.reason!),
|
|
{
|
|
code: "mcp_header_rejected",
|
|
headerName,
|
|
},
|
|
);
|
|
}
|
|
const valueCheck = checkMcpRemoteHeaderValue(
|
|
credentialValues[configPath] ?? "",
|
|
);
|
|
if (!valueCheck.ok) {
|
|
throw badRequest(
|
|
mcpRemoteHeaderRejectionMessage(headerName, valueCheck.reason!),
|
|
{
|
|
code: "mcp_header_rejected",
|
|
headerName,
|
|
},
|
|
);
|
|
}
|
|
fields.push({
|
|
label: headerName,
|
|
configPath,
|
|
required: true,
|
|
placement: "header",
|
|
key: headerName,
|
|
prefix: null,
|
|
});
|
|
}
|
|
return fields;
|
|
}
|
|
|
|
function nextAvailableConnectionName(
|
|
requestedName: string,
|
|
existingNames: readonly string[],
|
|
): string {
|
|
const base = requestedName.trim() || "Custom app";
|
|
const used = new Set(
|
|
existingNames.map((candidate) => candidate.trim().toLocaleLowerCase()),
|
|
);
|
|
const unsuffixed = base.slice(0, 160);
|
|
if (!used.has(unsuffixed.toLocaleLowerCase())) return unsuffixed;
|
|
for (let index = 2; index < 10_000; index += 1) {
|
|
const suffix = ` (${index})`;
|
|
const candidate = `${base.slice(0, 160 - suffix.length).trimEnd()}${suffix}`;
|
|
if (!used.has(candidate.toLocaleLowerCase())) return candidate;
|
|
}
|
|
return `${base.slice(0, 151).trimEnd()} (${randomUUID().slice(0, 6)})`;
|
|
}
|
|
|
|
async function connectGalleryApp(
|
|
companyId: string,
|
|
input: ConnectToolApp,
|
|
actor?: ActorInfo,
|
|
): Promise<ConnectToolAppResult> {
|
|
await actor?.managementContext?.reauthorize();
|
|
const definition = input.galleryKey
|
|
? getConnectableAppDefinition(input.galleryKey)
|
|
: null;
|
|
if (input.galleryKey && !definition)
|
|
throw notFound("Tool app gallery entry not found");
|
|
|
|
const galleryEntry = definition ? await appForConnectionSetup(definition) : null;
|
|
|
|
let existingApplication: typeof toolApplications.$inferSelect | null = null;
|
|
let requestedResumeConnection: typeof toolConnections.$inferSelect | null =
|
|
null;
|
|
const requestedConnectionId =
|
|
input.resumeConnectionId ?? input.reconnectConnectionId;
|
|
if (requestedConnectionId) {
|
|
const [connection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, requestedConnectionId),
|
|
eq(toolConnections.companyId, companyId),
|
|
),
|
|
);
|
|
if (!connection) throw notFound("Incomplete app connection not found");
|
|
assertSupportedConnection(connection);
|
|
if (input.resumeConnectionId && connection.status !== "draft") {
|
|
throw conflict("Only an incomplete app connection can resume setup", {
|
|
code: "connection_setup_not_incomplete",
|
|
});
|
|
}
|
|
if (
|
|
input.applicationId &&
|
|
input.applicationId !== connection.applicationId
|
|
) {
|
|
throw badRequest("The app and draft connection do not match");
|
|
}
|
|
const [application] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(
|
|
and(
|
|
eq(toolApplications.id, connection.applicationId),
|
|
eq(toolApplications.companyId, companyId),
|
|
),
|
|
);
|
|
if (!application) throw notFound("App not found");
|
|
const source =
|
|
asRecord(connection.config).sourceTemplateKey ??
|
|
asRecord(connection.transportConfig).sourceTemplateKey ??
|
|
asRecord(application.metadata).sourceTemplateKey ??
|
|
asRecord(application.metadata).source;
|
|
if (
|
|
input.reconnectConnectionId &&
|
|
((galleryEntry && source !== galleryEntry.slug) ||
|
|
(!galleryEntry &&
|
|
typeof source === "string" &&
|
|
getConnectableAppDefinition(source)))
|
|
) {
|
|
throw badRequest("Reconnect must preserve the configured provider");
|
|
}
|
|
requestedResumeConnection = connection;
|
|
existingApplication = application;
|
|
} else if (input.applicationId) {
|
|
const [row] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(
|
|
and(
|
|
eq(toolApplications.id, input.applicationId),
|
|
eq(toolApplications.companyId, companyId),
|
|
),
|
|
);
|
|
if (!row) throw notFound("App not found");
|
|
existingApplication = row;
|
|
} else {
|
|
// Removal and interrupted setup intentionally retain their
|
|
// application/connection rows. A fresh gallery click has no
|
|
// applicationId, so recover that retained identity by its company-unique
|
|
// name and source instead of inserting a duplicate application that the
|
|
// unique index rejects. Active applications are excluded: connecting a
|
|
// second account still requires its own name/application identity.
|
|
const requestedName =
|
|
input.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? "");
|
|
const recoverableApplicationStatuses = galleryEntry
|
|
? (["draft", "archived"] as const)
|
|
: (["archived"] as const);
|
|
const [recoverableApplication] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(
|
|
and(
|
|
eq(toolApplications.companyId, companyId),
|
|
eq(toolApplications.name, requestedName),
|
|
inArray(toolApplications.status, recoverableApplicationStatuses),
|
|
),
|
|
)
|
|
.orderBy(desc(toolApplications.updatedAt))
|
|
.limit(1);
|
|
const recoverableSource = recoverableApplication?.metadata
|
|
? (recoverableApplication.metadata.sourceTemplateKey ??
|
|
recoverableApplication.metadata.galleryKey ??
|
|
recoverableApplication.metadata.source)
|
|
: null;
|
|
const requestedSource =
|
|
galleryEntry?.slug ?? (input.link ? "link" : null);
|
|
if (
|
|
recoverableApplication &&
|
|
requestedSource &&
|
|
recoverableSource === requestedSource
|
|
) {
|
|
existingApplication = recoverableApplication;
|
|
}
|
|
}
|
|
|
|
await assertConnectorConnectionSetupEnabled(requestedResumeConnection ?? {}, existingApplication?.applicationKey);
|
|
const requestedName =
|
|
input.name ??
|
|
existingApplication?.name ??
|
|
galleryEntry?.name ??
|
|
defaultLinkName(input.link ?? "");
|
|
// Compatibility for the original Sheets robot flow, whose clients predate
|
|
// method selection and identify the method by its spreadsheet allowlist.
|
|
const inferredMethodKey =
|
|
!input.connectionMethodKey &&
|
|
galleryEntry?.slug === "google-sheets" &&
|
|
Array.isArray(input.configValues?.allowedSpreadsheetIds)
|
|
? "local"
|
|
: input.connectionMethodKey;
|
|
if (!galleryEntry && input.connectionMethodKey)
|
|
throw badRequest("Connection method selection requires a gallery app");
|
|
if (
|
|
galleryEntry &&
|
|
getAvailableConnectionMethods(galleryEntry).filter(
|
|
(candidate) =>
|
|
candidate.purpose !== "channel" && candidate.purpose !== "ai"
|
|
&& candidate.transport !== "chat_sdk" && candidate.transport !== "runtime_auth",
|
|
).length > 1 &&
|
|
!inferredMethodKey
|
|
) {
|
|
throw badRequest("Choose a connection method for this app");
|
|
}
|
|
const method = galleryEntry
|
|
? connectionMethodFor(galleryEntry, inferredMethodKey)
|
|
: null;
|
|
const remoteMcpConnector = isRemoteMcpConnectorMethod(galleryEntry?.slug, method?.key);
|
|
await assertExperimentalConnectorSetupEnabled(galleryEntry?.slug, Boolean(
|
|
input.reconnectConnectionId && requestedResumeConnection?.status !== "draft"
|
|
&& requestedResumeConnection?.config.sourceTemplateKey === galleryEntry?.slug,
|
|
));
|
|
if (galleryEntry && input.link) {
|
|
const acceptsProviderGeneratedUrl =
|
|
method?.transport === "mcp_remote" &&
|
|
method.auth === "none" &&
|
|
!method.defaults?.serverUrl &&
|
|
!method.defaults?.serverUrlTemplate;
|
|
if (!acceptsProviderGeneratedUrl && !remoteMcpConnector) {
|
|
throw badRequest(
|
|
`${galleryEntry.name} does not accept a provider-generated connection URL`,
|
|
);
|
|
}
|
|
if (!(remoteMcpConnector && galleryEntry.slug === "executor") && !getAppDefinitionForUrl(input.link, [galleryEntry])) {
|
|
throw badRequest(
|
|
`That connection URL does not belong to ${galleryEntry.name}`,
|
|
);
|
|
}
|
|
}
|
|
if (requestedResumeConnection && galleryEntry) {
|
|
const storedConnectionSource =
|
|
requestedResumeConnection.config?.sourceTemplateKey ??
|
|
requestedResumeConnection.transportConfig?.sourceTemplateKey;
|
|
const storedApplicationSource =
|
|
existingApplication?.metadata?.sourceTemplateKey ??
|
|
existingApplication?.metadata?.galleryKey;
|
|
if (
|
|
storedConnectionSource !== galleryEntry.slug &&
|
|
storedApplicationSource !== galleryEntry.slug
|
|
) {
|
|
throw badRequest(
|
|
"The selected provider does not match this incomplete connection",
|
|
);
|
|
}
|
|
}
|
|
// Reconnect is not a second identity decision. Removed connections retain
|
|
// their row precisely so the next credential can be attached to the same
|
|
// identity and history. Resolve that retained row before interpreting the
|
|
// request so a client default cannot silently turn a personal connection
|
|
// into an organization connection (or vice versa).
|
|
const canResumeInterruptedDraft =
|
|
Boolean(requestedResumeConnection) ||
|
|
(!input.applicationId &&
|
|
Boolean(galleryEntry) &&
|
|
existingApplication?.status === "draft");
|
|
const retainedConnectionStatuses = canResumeInterruptedDraft
|
|
? (["draft", "archived"] as const)
|
|
: (["archived"] as const);
|
|
const [recoveredConnection] =
|
|
!requestedResumeConnection && existingApplication
|
|
? await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.companyId, companyId),
|
|
eq(toolConnections.applicationId, existingApplication.id),
|
|
inArray(toolConnections.status, retainedConnectionStatuses),
|
|
),
|
|
)
|
|
.orderBy(desc(toolConnections.updatedAt))
|
|
.limit(1)
|
|
: [undefined];
|
|
let retainedConnection = requestedResumeConnection ?? recoveredConnection;
|
|
const transport = method?.transport ?? "mcp_remote";
|
|
const applicationType = transport === "rest_api" ? "rest_api"
|
|
: transport === "mcp_remote" ? "mcp_http"
|
|
: transport === "local_stdio" ? "mcp_stdio" : null;
|
|
if ((retainedConnection && retainedConnection.transport !== transport)
|
|
|| (existingApplication && applicationType && existingApplication.type !== applicationType)) {
|
|
if (requestedResumeConnection || input.applicationId) {
|
|
throw conflict("Create a separate app connection to change its connection method transport.", {
|
|
code: "connection_transport_change_not_supported",
|
|
});
|
|
}
|
|
// A new gallery connection may use another transport for the same app.
|
|
// Do not revive an archived MCP identity as REST (or the reverse): the
|
|
// application's type and any sibling connections belong to that identity.
|
|
retainedConnection = undefined;
|
|
existingApplication = null;
|
|
}
|
|
let applicationName = existingApplication?.name ?? requestedName;
|
|
let name = retainedConnection?.name ?? requestedName;
|
|
if (!existingApplication) {
|
|
const applicationNames = await db
|
|
.select({ name: toolApplications.name })
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.companyId, companyId));
|
|
applicationName = nextAvailableConnectionName(
|
|
requestedName,
|
|
applicationNames.map((row) => row.name),
|
|
);
|
|
name = applicationName;
|
|
} else if (!retainedConnection) {
|
|
const connectionNames = await db
|
|
.select({ name: toolConnections.name })
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.companyId, companyId),
|
|
eq(toolConnections.applicationId, existingApplication.id),
|
|
),
|
|
);
|
|
name = nextAvailableConnectionName(
|
|
requestedName,
|
|
connectionNames.map((row) => row.name),
|
|
);
|
|
}
|
|
const previousGrantKind: ConnectionGrantKind | null = retainedConnection
|
|
? retainedConnection.credentialPolicy === "per_user"
|
|
? "user"
|
|
: retainedConnection.credentialPolicy === "per_agent"
|
|
? "agent"
|
|
: "organization"
|
|
: null;
|
|
// An explicit resume/application reconnect continues the retained identity.
|
|
// A fresh gallery connect may still reuse an archived row for stable history
|
|
// and company-unique naming, but an explicit Access choice is a new identity
|
|
// decision and must replace the archived policy. Without this distinction,
|
|
// removing a dedicated-agent connection and reconnecting the default personal
|
|
// account leaves `per_agent` behind and the OAuth callback cannot persist its
|
|
// user grant.
|
|
const retainsIdentity = Boolean(
|
|
retainedConnection &&
|
|
(retainedConnection.status === "draft" ||
|
|
requestedResumeConnection ||
|
|
input.applicationId),
|
|
);
|
|
const retainedGrantKind = retainsIdentity ? previousGrantKind : null;
|
|
// The route can authorize an explicit resume before entering the service,
|
|
// but name/source recovery happens here. Do not let a caller submit a
|
|
// personal grant choice to pass the route and then inherit an implicitly
|
|
// recovered organization identity. Local implicit mode is already the
|
|
// unrestricted instance operator; every authenticated user must still hold
|
|
// current connection-manager authority before this retained row is touched.
|
|
if (
|
|
previousGrantKind &&
|
|
input.grantKind &&
|
|
previousGrantKind !== input.grantKind &&
|
|
Boolean(connectionActorUserId(actor)) &&
|
|
actor?.actorSource !== "local_implicit"
|
|
) {
|
|
const actorUserId = connectionActorUserId(actor);
|
|
const [membership] = actorUserId
|
|
? await db
|
|
.select({
|
|
membershipRole: companyMemberships.membershipRole,
|
|
})
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, actorUserId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
)
|
|
.limit(1)
|
|
: [];
|
|
const roleCanManage =
|
|
membership?.membershipRole === "owner" ||
|
|
membership?.membershipRole === "admin";
|
|
const [explicitManagerGrant] =
|
|
roleCanManage || !actorUserId
|
|
? []
|
|
: await db
|
|
.select({
|
|
id: principalPermissionGrants.id,
|
|
})
|
|
.from(principalPermissionGrants)
|
|
.where(
|
|
and(
|
|
eq(principalPermissionGrants.companyId, companyId),
|
|
eq(principalPermissionGrants.principalType, "user"),
|
|
eq(principalPermissionGrants.principalId, actorUserId),
|
|
eq(
|
|
principalPermissionGrants.permissionKey,
|
|
"tools:manage_connections",
|
|
),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!roleCanManage && !explicitManagerGrant) {
|
|
throw forbidden(
|
|
"Only a company owner, admin, or member with connection-manager permission can change this connection's credential identity.",
|
|
);
|
|
}
|
|
}
|
|
const requestedGrantKind =
|
|
retainedGrantKind ?? input.grantKind ?? (method?.grantKinds?.length === 1 ? method.grantKinds[0]! : "organization");
|
|
if (method?.grantKinds && !method.grantKinds.includes(requestedGrantKind)) {
|
|
throw badRequest(
|
|
`${galleryEntry?.name ?? "This app"} supports only ${method.grantKinds.join(" or ")} credentials`,
|
|
);
|
|
}
|
|
const dedicatedAgentId =
|
|
requestedGrantKind === "agent" ? (input.subjectAgentId ?? null) : null;
|
|
if (dedicatedAgentId) {
|
|
const [subjectAgent] = await db
|
|
.select({ id: agents.id })
|
|
.from(agents)
|
|
.where(
|
|
and(eq(agents.id, dedicatedAgentId), eq(agents.companyId, companyId)),
|
|
)
|
|
.limit(1);
|
|
if (!subjectAgent)
|
|
throw badRequest(
|
|
"Dedicated GitHub identity requires an agent in this company",
|
|
);
|
|
}
|
|
const credentialSource: ToolConnectionCredentialSource =
|
|
input.credentialSource ?? "paperclip_vault";
|
|
if (
|
|
retainedConnection &&
|
|
retainedConnection.credentialSource !== credentialSource
|
|
) {
|
|
throw conflict(
|
|
"Changing credential source requires a new app connection",
|
|
{
|
|
code: "credential_source_migration_not_supported",
|
|
},
|
|
);
|
|
}
|
|
let externalCredential: VercelConnectCredentialReference | null = null;
|
|
if (credentialSource === "vercel_connect") {
|
|
const integration = vercelConnectIntegrationStatus();
|
|
if (!integration.enabled || !integration.configured || !vercelConnect) {
|
|
throw unprocessable(
|
|
"Vercel Connect setup is not available on this Paperclip instance",
|
|
{
|
|
code: "vercel_connect_unavailable",
|
|
},
|
|
);
|
|
}
|
|
if (
|
|
!galleryEntry ||
|
|
!method ||
|
|
method.transport !== "mcp_remote" ||
|
|
method.auth === "none"
|
|
) {
|
|
throw badRequest(
|
|
"Vercel Connect is available only for reviewed remote MCP app methods",
|
|
);
|
|
}
|
|
const reviewed = method.credentialSources?.vercelConnect;
|
|
if (!reviewed) {
|
|
throw unprocessable(
|
|
`${galleryEntry.name} has not been reviewed for Vercel Connect`,
|
|
{
|
|
code: "vercel_connect_method_not_reviewed",
|
|
},
|
|
);
|
|
}
|
|
const expectedPrincipalMode = method.auth === "oauth" ? "user" : "app";
|
|
if (!reviewed.principalModes.includes(expectedPrincipalMode)) {
|
|
throw unprocessable(
|
|
"This connector principal mode has not been reviewed for this app",
|
|
{
|
|
code: "vercel_connect_principal_not_reviewed",
|
|
},
|
|
);
|
|
}
|
|
if (
|
|
expectedPrincipalMode === "app" &&
|
|
requestedGrantKind !== "organization"
|
|
) {
|
|
throw badRequest(
|
|
"App-subject Vercel connectors can only back an organization identity",
|
|
);
|
|
}
|
|
let metadata;
|
|
try {
|
|
metadata = await vercelConnect.getConnectorMetadata(
|
|
input.vercelConnect!.connector,
|
|
);
|
|
} catch (error) {
|
|
throw vercelConnectHttpError(error);
|
|
}
|
|
const service = metadata.service.trim().toLowerCase();
|
|
if (
|
|
!reviewed.services.map((value) => value.toLowerCase()).includes(service)
|
|
) {
|
|
throw badRequest(
|
|
`That Vercel connector is for ${metadata.service}, not ${galleryEntry.name}`,
|
|
{
|
|
code: "vercel_connect_service_mismatch",
|
|
},
|
|
);
|
|
}
|
|
if (expectedPrincipalMode === "app") {
|
|
const [connectorInUse] = await db
|
|
.select({ id: toolConnections.id })
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.credentialSource, "vercel_connect"),
|
|
ne(toolConnections.status, "archived"),
|
|
sql`${toolConnections.externalCredential}->>'connectorUid' = ${metadata.uid}`,
|
|
...(retainedConnection
|
|
? [ne(toolConnections.id, retainedConnection.id)]
|
|
: []),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (connectorInUse) {
|
|
throw conflict(
|
|
"App-subject Vercel connectors are dedicated to one Paperclip connection. Create or attach a separate connector in Vercel.",
|
|
{
|
|
code: "vercel_connect_app_connector_in_use",
|
|
},
|
|
);
|
|
}
|
|
}
|
|
externalCredential = {
|
|
provider: "vercel_connect",
|
|
connectorId: metadata.id,
|
|
connectorUid: metadata.uid,
|
|
service: metadata.service,
|
|
connectorType: metadata.type,
|
|
principalMode: expectedPrincipalMode,
|
|
headerName: reviewed.header.name,
|
|
headerPrefix: reviewed.header.prefix ?? null,
|
|
scopes: [...reviewed.scopes],
|
|
};
|
|
}
|
|
const isGoogleSheetsRobotMethod =
|
|
galleryEntry?.slug === GOOGLE_SHEETS_GALLERY_KEY &&
|
|
method?.key === "local";
|
|
const normalizedMethodConfig =
|
|
isGoogleSheetsRobotMethod || !method
|
|
? null
|
|
: normalizeConnectionMethodConfig(method, input.configValues ?? asRecord(asRecord(retainedConnection?.config).methodConfig));
|
|
const remoteUrlCredential =
|
|
transport === "mcp_remote" && input.link
|
|
? splitRemoteUrlCredential(input.link)
|
|
: null;
|
|
const baseConfig =
|
|
(transport === "mcp_remote" || transport === "rest_api")
|
|
? {
|
|
url:
|
|
(remoteMcpConnector ? remoteUrlCredential?.publicUrl : undefined) ??
|
|
normalizedMethodConfig?.url ??
|
|
method?.defaults?.serverUrl ??
|
|
remoteUrlCredential?.publicUrl ??
|
|
input.link ??
|
|
"",
|
|
}
|
|
: { templateId: method?.defaults?.templateKey };
|
|
let config: Record<string, unknown> = galleryEntry
|
|
? {
|
|
...baseConfig,
|
|
sourceTemplateKey: galleryEntry.slug,
|
|
connectionMethodKey: method?.key,
|
|
methodConfig: normalizedMethodConfig?.values ?? {},
|
|
...(method?.defaults?.mcpSessionRequired === true
|
|
? { mcpSessionRequired: true }
|
|
: {}),
|
|
// Grant-backed setup keeps the full discovered catalog selectable;
|
|
// the wizard projects the app's action defaults into policies at
|
|
// finish time instead of using catalog quarantine as access state.
|
|
quarantineNewEntries: connectorToolPolicy(galleryEntry.slug).quarantineNewEntries,
|
|
...(remoteMcpConnector ? {
|
|
mcpSessionRequired: true,
|
|
mcpAuthMode: input.authMode ?? "auto",
|
|
mcpPreserveAccess: Boolean(retainedConnection && (retainedConnection.status === "active" || asRecord(retainedConnection.config).mcpPreserveAccess === true)),
|
|
} : {}),
|
|
...(galleryEntry.slug === "posthog" ? { safeDefault: true } : {}),
|
|
// Telem.AI attributes each search to the Paperclip company, agent,
|
|
// run and issue, so its catalog connection forwards those context
|
|
// headers by default (the gateway still drops empty values). A
|
|
// reconnect keeps the policy the operator saved on the connection.
|
|
...(galleryEntry.slug === "telem"
|
|
? {
|
|
headerPolicy: asRecord(retainedConnection?.config).headerPolicy ?? {
|
|
metadata: {
|
|
forward: ["company_id", "issue_id", "agent_id", "run_id", "project_id", "correlation_id"],
|
|
},
|
|
},
|
|
}
|
|
: {}),
|
|
}
|
|
: { ...baseConfig, quarantineNewEntries: false, unverifiedServer: true };
|
|
if (method && isPaperclipCloudConnectorStrategy(method.oauthStrategy)) {
|
|
const connectorProfile = method.connectorProfile;
|
|
const profile = managedConnectorProfile(connectorProfile);
|
|
if (!profile)
|
|
throw badRequest("This app has an invalid managed connector profile");
|
|
config.oauth = {
|
|
strategy: method.oauthStrategy,
|
|
provider: profile.provider,
|
|
connectorProfile: profile.id,
|
|
resource: method.defaults?.serverUrl,
|
|
scopes: [...profile.scopes],
|
|
};
|
|
config.quarantineNewEntries = true;
|
|
}
|
|
const acceptsCustomerOAuthClient =
|
|
remoteMcpConnector || (method?.auth === "oauth" && method.ownershipModes.includes("customer"));
|
|
if (galleryEntry && input.oauthClient && !acceptsCustomerOAuthClient) {
|
|
throw badRequest(
|
|
`${galleryEntry.name} does not accept customer-owned OAuth client credentials`,
|
|
);
|
|
}
|
|
// A pasted URL or an explicitly customer-owned curated method may arrive
|
|
// with a client the operator preregistered in the provider's console. Record
|
|
// the client id now; the secret becomes an encrypted Paperclip secret below.
|
|
if (input.oauthClient) {
|
|
config.oauth = {
|
|
clientId: input.oauthClient.clientId.trim(),
|
|
clientRegistrationSource:
|
|
"manual" satisfies OAuthClientRegistrationSource,
|
|
clientCompanyId: companyId,
|
|
};
|
|
}
|
|
if (isGoogleSheetsRobotMethod) {
|
|
const availability = googleSheetsRobotEmailFromEnv();
|
|
if (!availability.available) {
|
|
throw unprocessable(availability.reason, {
|
|
code: "google_sheets_unavailable",
|
|
});
|
|
}
|
|
const allowedSpreadsheetIds = googleSheetsAllowedSpreadsheetIds(
|
|
input.configValues,
|
|
);
|
|
if (allowedSpreadsheetIds.length === 0) {
|
|
throw badRequest("Paste at least one Google Sheets link.");
|
|
}
|
|
config.allowedSpreadsheetIds = allowedSpreadsheetIds;
|
|
config.robotEmail = availability.robotEmail;
|
|
config.env = {
|
|
[GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS_ENV]:
|
|
allowedSpreadsheetIds.join(","),
|
|
};
|
|
config = normalizeGoogleSheetsConnectionConfig(config);
|
|
await assertGoogleSheetsSpreadsheetOwnership(companyId, config);
|
|
}
|
|
if (transport === "mcp_remote")
|
|
await assertRemoteConnectionEndpointsAllowed(config);
|
|
if (transport === "local_stdio") await stdioTemplateId(companyId, config);
|
|
assertLocalStdioCanBeEnabled(transport, false);
|
|
|
|
const credentialValues = input.credentialValues ?? {};
|
|
// A curated method declares its auth kind. A pasted URL declares one only
|
|
// under Advanced authentication; on the simple path it starts from what the
|
|
// operator supplied and is upgraded to `oauth` when discovery proves the
|
|
// endpoint needs sign-in (see `remoteTools` and `startOAuth`).
|
|
const genericAuthKind: ToolConnectionAuthKind =
|
|
(remoteMcpConnector ? undefined : method?.auth) ??
|
|
(input.authMode === "oauth" || input.oauthClient
|
|
? "oauth"
|
|
: input.authMode === "bearer" || input.authMode === "custom_headers"
|
|
? "api_key"
|
|
: input.authMode === "none"
|
|
? "none"
|
|
: Object.keys(credentialValues).length > 0
|
|
? "api_key"
|
|
: "none");
|
|
const credentialSecretRefs: CreateToolConnection["credentialSecretRefs"] =
|
|
[];
|
|
const credentialRefs: McpConnectionCredentialRef[] = [];
|
|
const createdSecretIds: string[] = [];
|
|
const createdDefinitionIds: string[] = [];
|
|
// Personal consent belongs to the authenticated responsible user. A delegated
|
|
// agent is recorded separately and never becomes the account owner.
|
|
const personalIdentityUserId = requestedGrantKind === "user" ? connectionActorUserId(actor) : null;
|
|
if (requestedGrantKind === "user" && !personalIdentityUserId) {
|
|
throw badRequest(
|
|
"Connecting an app as yourself requires a signed-in user",
|
|
);
|
|
}
|
|
const retainedPersonalIdentity =
|
|
retainedConnection?.credentialPolicy === "per_user"
|
|
? await fixedPersonalIdentityForReconnect(
|
|
retainedConnection,
|
|
personalIdentityUserId ?? undefined,
|
|
actor,
|
|
)
|
|
: null;
|
|
const retainedConfig = asRecord(retainedConnection?.config);
|
|
const retainedMethodKey = retainedConfig.connectionMethodKey;
|
|
const retainedSource =
|
|
retainedConfig.sourceTemplateKey ??
|
|
asRecord(retainedConnection?.transportConfig).sourceTemplateKey;
|
|
// Setup forms never receive stored secret values. Treat an omitted value as
|
|
// "keep the existing secret" only while resuming the exact same curated
|
|
// provider and method. This prevents a retry from detaching a client secret
|
|
// or API key, without carrying credentials across a method/provider change.
|
|
const canRetainCredentialMaterial = Boolean(
|
|
retainedConnection &&
|
|
previousGrantKind === requestedGrantKind &&
|
|
galleryEntry &&
|
|
retainedSource === galleryEntry.slug &&
|
|
retainedMethodKey === method?.key &&
|
|
(!remoteMcpConnector || (baseConfig.url === retainedConfig.url && genericAuthKind === retainedConnection.authKind)),
|
|
);
|
|
const retainedCredentialSecretRefs = canRetainCredentialMaterial
|
|
? (retainedPersonalIdentity?.grant?.credentialSecretRefs ??
|
|
retainedConnection?.credentialSecretRefs ??
|
|
[])
|
|
: [];
|
|
const credentialPolicy: ToolConnectionCredentialPolicy =
|
|
requestedGrantKind === "user"
|
|
? "per_user"
|
|
: requestedGrantKind === "agent"
|
|
? "per_agent"
|
|
: "shared";
|
|
const connectionOwnership = isPaperclipCloudConnectorStrategy(
|
|
method?.oauthStrategy,
|
|
)
|
|
? "platform_shared"
|
|
: "customer";
|
|
let applicationRow: typeof toolApplications.$inferSelect | null = null;
|
|
let connectionRow: typeof toolConnections.$inferSelect | null = null;
|
|
let revivedConnectionPrevious: typeof toolConnections.$inferSelect | null =
|
|
retainedConnection ?? null;
|
|
let revivedGrantMutation: {
|
|
previous: typeof connectionGrants.$inferSelect | null;
|
|
current: typeof connectionGrants.$inferSelect;
|
|
} | null = null;
|
|
let personalPublicSetupEstablished = false;
|
|
|
|
try {
|
|
const credentialFields =
|
|
credentialSource === "vercel_connect"
|
|
? []
|
|
: galleryEntry && !remoteMcpConnector
|
|
? credentialFieldsFor(galleryEntry, method?.key)
|
|
: linkCredentialFields({ ...Object.fromEntries(retainedCredentialSecretRefs.filter((ref) => ref.configPath.startsWith("headers.") || ref.configPath === "credentials.authorization").map((ref) => [ref.configPath, "retained"])), ...credentialValues });
|
|
for (const field of credentialFields) {
|
|
const value = credentialValues[field.configPath];
|
|
const retainedSecretRef = retainedCredentialSecretRefs.find(
|
|
(ref) => ref.configPath === field.configPath,
|
|
);
|
|
if (!value && retainedSecretRef) {
|
|
credentialSecretRefs.push(retainedSecretRef);
|
|
if (field.placement === "header" && field.key) {
|
|
credentialRefs.push({
|
|
name: field.configPath,
|
|
secretId: retainedSecretRef.secretId,
|
|
version: retainedSecretRef.versionSelector ?? "latest",
|
|
placement: "header",
|
|
key: field.key,
|
|
prefix: field.prefix ?? null,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
if (!value && field.required !== false) {
|
|
throw badRequest(`Missing credential value for ${field.configPath}`);
|
|
}
|
|
if (!value) continue;
|
|
const written = await db.transaction((tx) => writeConnectionCredential(tx, {
|
|
companyId, connectionName: name, configPath: field.configPath,
|
|
label: field.label, value, ownerUserId: personalIdentityUserId,
|
|
actor: actorForSecret(actor),
|
|
}));
|
|
const { secret } = written;
|
|
if (written.definitionId) createdDefinitionIds.push(written.definitionId);
|
|
createdSecretIds.push(secret.id);
|
|
credentialSecretRefs.push({
|
|
secretId: secret.id,
|
|
versionSelector: "latest",
|
|
configPath: field.configPath,
|
|
required: field.required ?? true,
|
|
label: field.label,
|
|
});
|
|
if (field.placement === "header" && field.key) {
|
|
credentialRefs.push({
|
|
name: field.configPath,
|
|
secretId: secret.id,
|
|
version: "latest",
|
|
placement: "header",
|
|
key: field.key,
|
|
prefix: field.prefix ?? null,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!remoteUrlCredential?.secretUrl && canRetainCredentialMaterial && baseConfig.url === retainedConfig.url) {
|
|
const retainedUrl = retainedCredentialSecretRefs.find((ref) => ref.configPath === REMOTE_URL_SECRET_CONFIG_PATH);
|
|
if (retainedUrl) {
|
|
credentialSecretRefs.push(retainedUrl);
|
|
credentialRefs.push({ name: REMOTE_URL_SECRET_CONFIG_PATH, secretId: retainedUrl.secretId, version: retainedUrl.versionSelector ?? "latest", placement: "url", key: "url", prefix: null });
|
|
}
|
|
}
|
|
if (remoteUrlCredential?.secretUrl) {
|
|
const written = await db.transaction((tx) => writeConnectionCredential(tx, {
|
|
companyId, connectionName: name, configPath: REMOTE_URL_SECRET_CONFIG_PATH,
|
|
label: "MCP server URL", value: remoteUrlCredential.secretUrl!,
|
|
ownerUserId: personalIdentityUserId, actor: actorForSecret(actor),
|
|
}));
|
|
const { secret } = written;
|
|
if (written.definitionId) createdDefinitionIds.push(written.definitionId);
|
|
createdSecretIds.push(secret.id);
|
|
credentialSecretRefs.push({
|
|
secretId: secret.id,
|
|
versionSelector: "latest",
|
|
configPath: REMOTE_URL_SECRET_CONFIG_PATH,
|
|
required: true,
|
|
label: "MCP server URL",
|
|
});
|
|
credentialRefs.push({
|
|
name: REMOTE_URL_SECRET_CONFIG_PATH,
|
|
secretId: secret.id,
|
|
version: "latest",
|
|
placement: "url",
|
|
key: "url",
|
|
prefix: null,
|
|
});
|
|
}
|
|
|
|
// A preregistered OAuth client secret is not a request header — it is only
|
|
// ever sent to the token endpoint — so it gets a secret ref with no
|
|
// credential ref, keeping it out of `projectedConnectionHeaders`.
|
|
if (input.oauthClient?.clientSecret) {
|
|
const secret = await secrets.create(
|
|
companyId,
|
|
{
|
|
name: `${name} OAuth client secret ${randomUUID().slice(0, 8)}`,
|
|
key: `tool_app.${randomUUID()}.oauth_client_secret`,
|
|
provider: "local_encrypted",
|
|
value: input.oauthClient.clientSecret,
|
|
description: `OAuth client secret for ${name}.`,
|
|
},
|
|
actorForSecret(actor),
|
|
);
|
|
createdSecretIds.push(secret.id);
|
|
credentialSecretRefs.push({
|
|
secretId: secret.id,
|
|
versionSelector: "latest",
|
|
configPath: "oauth.client_secret",
|
|
required: false,
|
|
label: "OAuth client secret",
|
|
});
|
|
} else if (input.oauthClient) {
|
|
const retainedOAuth = asRecord(retainedConfig.oauth);
|
|
const clientIdUnchanged =
|
|
retainedOAuth.clientId === input.oauthClient.clientId.trim();
|
|
const retainedClientSecretRef = clientIdUnchanged
|
|
? retainedCredentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.client_secret",
|
|
)
|
|
: undefined;
|
|
if (retainedClientSecretRef)
|
|
credentialSecretRefs.push(retainedClientSecretRef);
|
|
}
|
|
|
|
const safeApplicationDescription =
|
|
galleryEntry?.description ??
|
|
`Connected app at ${remoteUrlCredential?.publicUrl ?? input.link}`;
|
|
if (existingApplication) {
|
|
if (existingApplication.status !== "active") {
|
|
[applicationRow] = await db
|
|
.update(toolApplications)
|
|
.set({
|
|
status: "draft",
|
|
archivedAt: null,
|
|
...(!galleryEntry
|
|
? { description: safeApplicationDescription }
|
|
: {}),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolApplications.id, existingApplication.id))
|
|
.returning();
|
|
} else {
|
|
applicationRow = existingApplication;
|
|
}
|
|
} else {
|
|
// Name selection is optimistic because multiple setup requests can
|
|
// legitimately begin at the same time. The company/name unique index
|
|
// is the authority: if another request wins after our read, refresh the
|
|
// names and retry with the next suffix instead of surfacing a conflict
|
|
// the user never asked to resolve.
|
|
for (let attempt = 0; attempt < 10 && !applicationRow; attempt += 1) {
|
|
try {
|
|
[applicationRow] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId,
|
|
applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`,
|
|
name: applicationName,
|
|
description: safeApplicationDescription,
|
|
type: transport === "rest_api" && (["browser-use-cloud", ...NATIVE_PROVIDER_SLUGS].includes(galleryEntry?.slug ?? "") || Boolean(restConnectorFor({ transport, config }))) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
|
status: "draft",
|
|
metadata: galleryEntry
|
|
? {
|
|
sourceTemplateKey: galleryEntry.slug,
|
|
galleryKey: galleryEntry.slug,
|
|
}
|
|
: { source: "link" },
|
|
})
|
|
.returning();
|
|
} catch (error) {
|
|
if (!isUniqueViolation(error, "tool_applications_company_name_uq"))
|
|
throw error;
|
|
const applicationNames = await db
|
|
.select({ name: toolApplications.name })
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.companyId, companyId));
|
|
applicationName = nextAvailableConnectionName(
|
|
requestedName,
|
|
applicationNames.map((row) => row.name),
|
|
);
|
|
name = applicationName;
|
|
}
|
|
}
|
|
if (!applicationRow) {
|
|
throw conflict(
|
|
"Paperclip could not allocate a unique connection name",
|
|
{
|
|
code: "tool_access_name_allocation_exhausted",
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
await assertSecretRefs(companyId, [
|
|
...credentialRefs,
|
|
...credentialSecretRefs,
|
|
]);
|
|
// Reconnecting an app revives its most recent archived connection instead
|
|
// of inserting a fresh row: keeps the connection id (and its activity
|
|
// history) stable and avoids the unique (company, name) constraint.
|
|
// A personal credential never becomes the connection's shared secret: the
|
|
// row carries the header shape only, and the secret refs go to the user
|
|
// grant below. `ensureDefaultOrganizationGrant` copies this list, so
|
|
// leaving it empty is what keeps the secret off an organization grant.
|
|
const connectionCredentialSecretRefs =
|
|
personalIdentityUserId || dedicatedAgentId ? [] : credentialSecretRefs;
|
|
if (revivedConnectionPrevious) {
|
|
forgetMcpHttpSessions(revivedConnectionPrevious.id);
|
|
[connectionRow] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
name,
|
|
authKind: genericAuthKind,
|
|
transport,
|
|
status: "draft",
|
|
enabled: false,
|
|
agentInstructions: input.agentInstructions !== undefined
|
|
? input.agentInstructions
|
|
: retainedConnection ? retainedConnection.agentInstructions
|
|
: defaultConnectionAgentInstructions(galleryEntry?.agentInstructions),
|
|
config,
|
|
transportConfig: config,
|
|
credentialRefs,
|
|
credentialSecretRefs: connectionCredentialSecretRefs,
|
|
credentialSource,
|
|
externalCredential,
|
|
credentialPolicy,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, revivedConnectionPrevious.id))
|
|
.returning();
|
|
} else {
|
|
const connectionId = randomUUID();
|
|
[connectionRow] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
id: connectionId,
|
|
companyId,
|
|
applicationId: applicationRow.id,
|
|
name,
|
|
uid: connectionUid(
|
|
applicationRow.applicationKey ?? applicationRow.name,
|
|
name,
|
|
connectionId,
|
|
),
|
|
connectionKind: "managed",
|
|
ownership: connectionOwnership,
|
|
authKind: genericAuthKind,
|
|
credentialSource,
|
|
externalCredential,
|
|
transport,
|
|
status: "draft",
|
|
enabled: false,
|
|
agentInstructions: input.agentInstructions !== undefined
|
|
? input.agentInstructions
|
|
: retainedConnection ? retainedConnection.agentInstructions
|
|
: defaultConnectionAgentInstructions(galleryEntry?.agentInstructions),
|
|
config,
|
|
transportConfig: config,
|
|
credentialRefs,
|
|
credentialSecretRefs: connectionCredentialSecretRefs,
|
|
credentialPolicy,
|
|
createdByAgentId:
|
|
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
|
createdByUserId:
|
|
connectionActorUserId(actor),
|
|
})
|
|
.returning();
|
|
}
|
|
if (revivedConnectionPrevious) {
|
|
emitConnectionUpdated(
|
|
connectionRow,
|
|
revivedConnectionPrevious,
|
|
"gallery",
|
|
);
|
|
} else {
|
|
emitConnectionCreated(connectionRow, "gallery");
|
|
}
|
|
if (personalIdentityUserId) {
|
|
// "Just me" (PAP-17835 seam #4). The credential is committed straight to
|
|
// the caller's own grant; the connection row keeps only the header
|
|
// *shape* in `credentialRefs` (which the gateway reads for placement and
|
|
// then resolves against the acting user's grant) and no shared secret
|
|
// refs at all. Two consequences the design requires:
|
|
// - `ensureDefaultOrganizationGrant` is skipped, so no organization
|
|
// grant is created first and later "moved" to a user grant. It stays
|
|
// absent until someone explicitly connects an organization identity,
|
|
// which is what makes "Organization identity · Not connected"
|
|
// truthful rather than a silent fallback.
|
|
// - the credential never lands in `connection.credentialSecretRefs`,
|
|
// which is what an organization grant would have copied.
|
|
//
|
|
// OAuth is the exception: no credential exists yet at connect time, and
|
|
// the callback upserts this same (connection, user, subject) grant with
|
|
// the tokens. Pre-creating an empty `active` grant there would render as
|
|
// "Connected" with nothing behind it, so the grant is left to the
|
|
// callback and only the organization grant is suppressed.
|
|
if (credentialSecretRefs.length > 0) {
|
|
let changedGrant: typeof connectionGrants.$inferSelect;
|
|
let previousGrant: typeof connectionGrants.$inferSelect | null = null;
|
|
if (retainedPersonalIdentity?.grant) {
|
|
const [grantSnapshot] = await db
|
|
.select({
|
|
grant: connectionGrants,
|
|
// Preserve PostgreSQL microseconds for the optimistic update;
|
|
// a JavaScript Date truncates them and falsely reports a race.
|
|
updatedAtVersion: sql<string>`${connectionGrants.updatedAt}::text`,
|
|
})
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.id, retainedPersonalIdentity.grant.id))
|
|
.limit(1);
|
|
const currentGrant = grantSnapshot?.grant;
|
|
if (!currentGrant)
|
|
throw conflict(
|
|
"The personal credential changed during setup. Please try again.",
|
|
);
|
|
previousGrant = currentGrant;
|
|
[changedGrant] = await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
credentialSecretRefs,
|
|
status: "active",
|
|
revokedAt: null,
|
|
revokedByAgentId: null,
|
|
revokedByUserId: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, currentGrant.id),
|
|
sql`${connectionGrants.updatedAt} = ${grantSnapshot!.updatedAtVersion}::timestamptz`,
|
|
),
|
|
)
|
|
.returning();
|
|
if (!changedGrant)
|
|
throw conflict(
|
|
"The personal credential changed during setup. Please try again.",
|
|
);
|
|
} else {
|
|
[changedGrant] = await db
|
|
.insert(connectionGrants)
|
|
.values({
|
|
companyId,
|
|
connectionId: connectionRow.id,
|
|
kind: "user",
|
|
subjectUserId: personalIdentityUserId,
|
|
credentialSecretRefs,
|
|
status: "active",
|
|
isDefault: false,
|
|
createdByUserId: personalIdentityUserId,
|
|
})
|
|
.returning();
|
|
if (!changedGrant)
|
|
throw new Error("Failed to create personal connection grant");
|
|
}
|
|
if (revivedConnectionPrevious) {
|
|
revivedGrantMutation = {
|
|
previous: previousGrant,
|
|
current: changedGrant,
|
|
};
|
|
}
|
|
await db.insert(toolAccessAuditEvents).values({
|
|
companyId,
|
|
connectionId: connectionRow.id,
|
|
actorType: "user",
|
|
actorId: personalIdentityUserId,
|
|
action: retainedPersonalIdentity?.grant
|
|
? "connection_grant.updated"
|
|
: "connection_grant.created",
|
|
outcome: "success",
|
|
reasonCode: retainedPersonalIdentity?.grant
|
|
? "personal_identity_reconnected"
|
|
: "personal_identity_created",
|
|
details: {
|
|
kind: "user",
|
|
credentialSecretRefCount: credentialSecretRefs.length,
|
|
},
|
|
});
|
|
}
|
|
} else if (dedicatedAgentId) {
|
|
// Managed OAuth creates the credential-bearing grant in the callback.
|
|
// Keep the connection free of organization secrets from the outset.
|
|
} else {
|
|
const organizationGrant = await ensureDefaultOrganizationGrant(
|
|
connectionRow,
|
|
db,
|
|
revivedConnectionPrevious
|
|
? (mutation) => {
|
|
revivedGrantMutation = mutation;
|
|
}
|
|
: undefined,
|
|
);
|
|
if (credentialSource === "vercel_connect") {
|
|
const derived = deriveVercelConnectSubject({
|
|
credential: externalCredential!,
|
|
connectionId: connectionRow.id,
|
|
companyId,
|
|
grantKind: "organization",
|
|
});
|
|
const updatedOrganizationGrant = await db.transaction(async (tx) => {
|
|
const [lockedGrant] = await tx
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.id, organizationGrant.id))
|
|
.limit(1)
|
|
.for("update");
|
|
if (
|
|
!lockedGrant ||
|
|
lockedGrant.updatedAt.getTime() !==
|
|
organizationGrant.updatedAt.getTime()
|
|
) {
|
|
throw conflict(
|
|
"The organization credential changed during setup. Please try again.",
|
|
);
|
|
}
|
|
const [updated] = await tx
|
|
.update(connectionGrants)
|
|
.set({
|
|
externalCredential: {
|
|
provider: "vercel_connect",
|
|
subjectType: externalCredential!.principalMode,
|
|
...(derived.subjectId
|
|
? { subjectId: derived.subjectId }
|
|
: {}),
|
|
},
|
|
credentialSecretRefs: [],
|
|
updatedAt: now(),
|
|
})
|
|
.where(eq(connectionGrants.id, organizationGrant.id))
|
|
.returning();
|
|
return updated;
|
|
});
|
|
if (!updatedOrganizationGrant) {
|
|
throw conflict(
|
|
"The organization credential changed during setup. Please try again.",
|
|
);
|
|
}
|
|
if (revivedGrantMutation) {
|
|
revivedGrantMutation = {
|
|
previous: revivedGrantMutation.previous,
|
|
current: updatedOrganizationGrant,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
await syncCredentialBindings(
|
|
connectionRow,
|
|
personalIdentityUserId || dedicatedAgentId ? credentialSecretRefs : [],
|
|
);
|
|
await ensureRuntimeSlot(connectionRow);
|
|
|
|
if (input.saveDraft && remoteMcpConnector) {
|
|
return { connectionId: connectionRow.id, application: toApplication(applicationRow), connection: toConnection(connectionRow),
|
|
catalog: [], actions: { readOnly: [], canMakeChanges: [] }, suggestedDefaults: { access: "all_agents", askFirstRiskLevels: [] } };
|
|
}
|
|
if (galleryEntry && method?.auth === "oauth") {
|
|
const suggestedDefaults = recommendedDefaultsForApp(
|
|
galleryEntry,
|
|
method.key,
|
|
);
|
|
return {
|
|
connectionId: connectionRow.id,
|
|
application: toApplication(applicationRow),
|
|
connection: toConnection(connectionRow),
|
|
catalog: [],
|
|
actions: { readOnly: [], canMakeChanges: [] },
|
|
suggestedDefaults: dedicatedAgentId
|
|
? { ...suggestedDefaults, access: { agentIds: [dedicatedAgentId] } }
|
|
: suggestedDefaults,
|
|
auth: { kind: "oauth", startUrl: null },
|
|
};
|
|
}
|
|
|
|
// A pasted MCP URL starts with unknown auth. For a personal connection,
|
|
// there is deliberately no empty active grant yet, so probe this one
|
|
// creator-owned draft without credentials. A 401 can then discover OAuth
|
|
// and leave grant creation to the callback; a public endpoint gets an
|
|
// empty personal grant below so later catalog refreshes use the same
|
|
// identity policy.
|
|
const unauthenticatedPersonalProbe = Boolean(
|
|
(!galleryEntry || remoteMcpConnector) &&
|
|
genericAuthKind === "none" &&
|
|
credentialSecretRefs.length === 0 &&
|
|
personalIdentityUserId &&
|
|
!retainedPersonalIdentity?.grant,
|
|
);
|
|
let health: ToolConnectionHealthCheckResult;
|
|
try {
|
|
health = await checkConnectionHealth(connectionRow.id, actor, {
|
|
allowUnauthenticatedProbe: unauthenticatedPersonalProbe,
|
|
});
|
|
} catch (error) {
|
|
if (
|
|
(!galleryEntry || remoteMcpConnector) &&
|
|
input.authMode !== "none" &&
|
|
input.authMode !== "bearer" &&
|
|
input.authMode !== "custom_headers" &&
|
|
error instanceof HttpError &&
|
|
asRecord(error.details).code === "oauth_challenge"
|
|
) {
|
|
const [oauthConnection] = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.id, connectionRow.id));
|
|
const endpoints = await discoverOAuthEndpoints(oauthConnection).catch(
|
|
(discoveryError: unknown) => {
|
|
// "This server advertised an address Paperclip refuses to open" is a
|
|
// refusal, not a failed discovery: keep it instead of collapsing it
|
|
// into the generic sign-in-required error.
|
|
if (isOAuthEndpointRejection(discoveryError))
|
|
throw discoveryError;
|
|
return null;
|
|
},
|
|
);
|
|
if (!endpoints) throw error;
|
|
return {
|
|
connectionId: oauthConnection.id,
|
|
application: toApplication(applicationRow),
|
|
connection: toConnection(oauthConnection),
|
|
catalog: [],
|
|
actions: { readOnly: [], canMakeChanges: [] },
|
|
suggestedDefaults: {
|
|
access: "all_agents",
|
|
askFirstRiskLevels: [],
|
|
},
|
|
// The endpoint asked for authorization and discovery found a real
|
|
// authorization server, so the wizard can offer "Sign in to
|
|
// continue" instead of a dead end. The caller starts the flow and
|
|
// fills in `startUrl`/`registrationSource`; it only needs the issuer
|
|
// and resource here to show which server the operator is about to
|
|
// trust.
|
|
auth: {
|
|
kind: "oauth",
|
|
startUrl: null,
|
|
issuer: endpoints.issuer ?? null,
|
|
resource: endpoints.resource ?? null,
|
|
},
|
|
};
|
|
}
|
|
throw error;
|
|
}
|
|
if (unauthenticatedPersonalProbe) {
|
|
const personalConnectionId = connectionRow.id;
|
|
const grant = await db.transaction(async (tx) => {
|
|
const [createdGrant] = await tx
|
|
.insert(connectionGrants)
|
|
.values({
|
|
companyId,
|
|
connectionId: personalConnectionId,
|
|
kind: "user",
|
|
subjectUserId: personalIdentityUserId!,
|
|
credentialSecretRefs: [],
|
|
status: "active",
|
|
isDefault: false,
|
|
createdByUserId: personalIdentityUserId!,
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning();
|
|
if (createdGrant) {
|
|
await tx.insert(toolAccessAuditEvents).values({
|
|
companyId,
|
|
connectionId: personalConnectionId,
|
|
actorType: "user",
|
|
actorId: personalIdentityUserId!,
|
|
action: "connection_grant.created",
|
|
outcome: "success",
|
|
reasonCode: "personal_identity_created",
|
|
details: { kind: "user", credentialSecretRefCount: 0 },
|
|
});
|
|
}
|
|
return createdGrant;
|
|
});
|
|
if (!grant) {
|
|
// Another setup attempt may have created this user's grant while
|
|
// both probes were in flight. Reuse only an active personal grant;
|
|
// never overwrite credentials, revive a revoked grant, or claim its
|
|
// audit/rollback ownership in this attempt.
|
|
const existingGrant = await vaultGrantForConnection(connectionRow, actor);
|
|
if (existingGrant?.kind !== "user" || existingGrant.subjectUserId !== personalIdentityUserId) {
|
|
throw conflict("The personal credential changed during setup. Please try again.");
|
|
}
|
|
}
|
|
// A successful public probe establishes this identity, like OAuth
|
|
// consent does. Keep its draft and grant if catalog/default discovery
|
|
// later fails: another retry may already be using the committed grant.
|
|
personalPublicSetupEstablished = true;
|
|
}
|
|
const restoreDraftDefaults = Boolean(revivedConnectionPrevious);
|
|
const refreshOptions = {
|
|
enableAllByDefault: restoreDraftDefaults && !remoteMcpConnector,
|
|
restoreDraftDefaults,
|
|
};
|
|
const catalogConnectionId = connectionRow.id;
|
|
// Keep the established public identity outside this transaction while
|
|
// making catalog, defaults, and their audits one all-or-nothing step.
|
|
const refresh: ToolCatalogRefreshResult = personalPublicSetupEstablished
|
|
? await db.transaction(async (tx): Promise<ToolCatalogRefreshResult> =>
|
|
toolAccessService(tx as unknown as Db, options).refreshCatalog(
|
|
catalogConnectionId,
|
|
actor,
|
|
refreshOptions,
|
|
),
|
|
)
|
|
: await refreshCatalog(catalogConnectionId, actor, refreshOptions);
|
|
const [application] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, applicationRow.id));
|
|
return {
|
|
connectionId: refresh.connection.id,
|
|
application: toApplication(application),
|
|
connection: refresh.connection,
|
|
catalog: refresh.catalog,
|
|
actions: groupedActions(refresh.catalog),
|
|
suggestedDefaults: galleryEntry
|
|
? recommendedDefaultsForApp(galleryEntry, method?.key)
|
|
: {
|
|
access: "all_agents",
|
|
askFirstRiskLevels: [],
|
|
},
|
|
};
|
|
} catch (error) {
|
|
if (personalPublicSetupEstablished) throw error;
|
|
let identityRollbackError: unknown = null;
|
|
let preserveConcurrentRevival = false;
|
|
if (connectionRow && revivedConnectionPrevious) {
|
|
const attemptedConnection = connectionRow;
|
|
try {
|
|
await db.transaction(async (tx) => {
|
|
if (revivedGrantMutation) await tx.select({ id: connectionGrants.id }).from(connectionGrants)
|
|
.where(and(eq(connectionGrants.id, revivedGrantMutation.current.id), eq(connectionGrants.companyId, companyId)))
|
|
.for("update");
|
|
const [latestConnection] = await tx
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, revivedConnectionPrevious.id),
|
|
eq(toolConnections.companyId, companyId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.for("update");
|
|
// Health/catalog failures from this setup may update only health
|
|
// fields and `updatedAt`, so compare the identity/configuration
|
|
// fields this attempt owned. If another request changed any of
|
|
// those fields, its newer connection state is authoritative.
|
|
const connectionMutationIsStillCurrent = Boolean(
|
|
latestConnection &&
|
|
connectionSetupMutationFingerprint(latestConnection) ===
|
|
connectionSetupMutationFingerprint(attemptedConnection),
|
|
);
|
|
if (connectionMutationIsStillCurrent) {
|
|
await tx
|
|
.update(toolConnections)
|
|
.set({
|
|
agentInstructions: revivedConnectionPrevious.agentInstructions,
|
|
name: revivedConnectionPrevious.name,
|
|
transport: revivedConnectionPrevious.transport,
|
|
status: revivedConnectionPrevious.status,
|
|
enabled: revivedConnectionPrevious.enabled,
|
|
config: revivedConnectionPrevious.config,
|
|
transportConfig: revivedConnectionPrevious.transportConfig,
|
|
credentialRefs: revivedConnectionPrevious.credentialRefs,
|
|
credentialSecretRefs:
|
|
revivedConnectionPrevious.credentialSecretRefs,
|
|
credentialSource: revivedConnectionPrevious.credentialSource,
|
|
externalCredential:
|
|
revivedConnectionPrevious.externalCredential,
|
|
credentialPolicy: revivedConnectionPrevious.credentialPolicy,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, revivedConnectionPrevious.id));
|
|
} else {
|
|
preserveConcurrentRevival = true;
|
|
}
|
|
|
|
if (connectionMutationIsStillCurrent && revivedGrantMutation) {
|
|
const { previous, current } = revivedGrantMutation;
|
|
const [latestGrant] = await tx
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.id, current.id))
|
|
.limit(1)
|
|
.for("update");
|
|
const mutationIsStillCurrent = Boolean(
|
|
latestGrant &&
|
|
latestGrant.updatedAt.getTime() === current.updatedAt.getTime(),
|
|
);
|
|
// A grant manager may have changed this grant while provider
|
|
// setup was in flight. Restore/delete only the exact version this
|
|
// attempt wrote; a newer version is authoritative and remains
|
|
// untouched.
|
|
if (previous && mutationIsStillCurrent) {
|
|
await tx
|
|
.update(connectionGrants)
|
|
.set({
|
|
kind: previous.kind,
|
|
subjectUserId: previous.subjectUserId,
|
|
subjectAgentId: previous.subjectAgentId,
|
|
providerTenant: previous.providerTenant,
|
|
credentialSecretRefs: previous.credentialSecretRefs,
|
|
externalCredential: previous.externalCredential,
|
|
status: previous.status,
|
|
isDefault: previous.isDefault,
|
|
createdByAgentId: previous.createdByAgentId,
|
|
createdByUserId: previous.createdByUserId,
|
|
revokedAt: previous.revokedAt,
|
|
revokedByAgentId: previous.revokedByAgentId,
|
|
revokedByUserId: previous.revokedByUserId,
|
|
lastUsedAt: previous.lastUsedAt,
|
|
updatedAt: previous.updatedAt,
|
|
})
|
|
.where(eq(connectionGrants.id, current.id));
|
|
} else if (!previous && mutationIsStillCurrent) {
|
|
await tx
|
|
.delete(connectionGrants)
|
|
.where(eq(connectionGrants.id, current.id));
|
|
}
|
|
}
|
|
});
|
|
} catch (rollbackError) {
|
|
identityRollbackError = rollbackError;
|
|
// The attempted identity and its grants may no longer agree. Keep the
|
|
// connection unusable until a manager explicitly reconnects it, and
|
|
// surface the restoration failure instead of returning only the
|
|
// original provider error.
|
|
try {
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
status: "draft",
|
|
enabled: false,
|
|
healthStatus: "error",
|
|
healthMessage:
|
|
"Connection identity restoration failed. Reconnect this app to continue.",
|
|
lastError: "connection_identity_rollback_failed",
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, revivedConnectionPrevious.id),
|
|
eq(toolConnections.companyId, companyId),
|
|
),
|
|
);
|
|
} catch (quarantineError) {
|
|
identityRollbackError = new AggregateError(
|
|
[rollbackError, quarantineError],
|
|
"Connection identity rollback and quarantine both failed",
|
|
);
|
|
}
|
|
}
|
|
} else if (connectionRow) {
|
|
await db
|
|
.delete(toolConnections)
|
|
.where(eq(toolConnections.id, connectionRow.id))
|
|
.catch(() => undefined);
|
|
}
|
|
if (
|
|
!preserveConcurrentRevival &&
|
|
applicationRow &&
|
|
!existingApplication
|
|
) {
|
|
await db
|
|
.delete(toolApplications)
|
|
.where(eq(toolApplications.id, applicationRow.id))
|
|
.catch(() => undefined);
|
|
} else if (
|
|
!preserveConcurrentRevival &&
|
|
existingApplication &&
|
|
applicationRow &&
|
|
applicationRow.status !== existingApplication.status
|
|
) {
|
|
await db
|
|
.update(toolApplications)
|
|
.set({
|
|
status: existingApplication.status,
|
|
archivedAt: existingApplication.archivedAt,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolApplications.id, existingApplication.id))
|
|
.catch(() => undefined);
|
|
}
|
|
if (!preserveConcurrentRevival) {
|
|
for (const secretId of createdSecretIds) {
|
|
await secrets.remove(secretId).catch(() => undefined);
|
|
}
|
|
for (const definitionId of createdDefinitionIds) {
|
|
await db.delete(userSecretDefinitions).where(eq(userSecretDefinitions.id, definitionId)).catch(() => undefined);
|
|
}
|
|
}
|
|
if (identityRollbackError) {
|
|
throw new HttpError(
|
|
500,
|
|
"Connection setup failed and its prior identity could not be restored.",
|
|
{
|
|
code: "connection_identity_rollback_failed",
|
|
},
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function assertCatalogEntriesForConnection(
|
|
companyId: string,
|
|
connectionId: string,
|
|
catalogEntryIds: string[],
|
|
): Promise<Array<typeof toolCatalogEntries.$inferSelect>> {
|
|
const uniqueIds = [...new Set(catalogEntryIds)];
|
|
if (uniqueIds.length === 0) return [];
|
|
const rows = await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, companyId),
|
|
eq(toolCatalogEntries.connectionId, connectionId),
|
|
inArray(toolCatalogEntries.id, uniqueIds),
|
|
),
|
|
);
|
|
if (rows.length !== uniqueIds.length) {
|
|
throw unprocessable(
|
|
"All selected catalog entries must belong to this app connection",
|
|
);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
async function assertAgentsInCompany(companyId: string, agentIds: string[]) {
|
|
if (agentIds.length === 0) return;
|
|
const rows = await db
|
|
.select({ id: agents.id })
|
|
.from(agents)
|
|
.where(
|
|
and(
|
|
eq(agents.companyId, companyId),
|
|
inArray(agents.id, [...new Set(agentIds)]),
|
|
),
|
|
);
|
|
if (rows.length !== new Set(agentIds).size) {
|
|
throw unprocessable(
|
|
"All app access agent ids must belong to the same company",
|
|
);
|
|
}
|
|
}
|
|
|
|
async function finishGalleryAppConnection(
|
|
companyId: string,
|
|
connectionId: string,
|
|
input: FinishToolApp,
|
|
actor?: ActorInfo,
|
|
): Promise<FinishToolAppResult> {
|
|
await actor?.managementContext?.reauthorize();
|
|
const connection = await getConnectionRow(connectionId, companyId);
|
|
await assertConnectorConnectionSetupEnabled(connection);
|
|
if (connection.status === "archived")
|
|
throw conflict("Archived app connections cannot be finished");
|
|
if (connection.config.mcpPreserveAccess === true && isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) {
|
|
const [profile] = await db.select().from(toolProfiles).where(and(
|
|
eq(toolProfiles.companyId, companyId), eq(toolProfiles.profileKey, `app:${connection.id}`),
|
|
)).limit(1);
|
|
if (!profile) throw conflict("The saved connection permissions could not be found");
|
|
const config = { ...connection.config };
|
|
delete config.mcpPreserveAccess;
|
|
const updated = await db.transaction(async (tx) => {
|
|
await tx.select({ id: toolApplications.id }).from(toolApplications)
|
|
.where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId)))
|
|
.for("update");
|
|
const [row] = await tx.update(toolConnections).set({ config, transportConfig: config, status: "active", enabled: true, ...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}), updatedAt: new Date() })
|
|
.where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, companyId))).returning();
|
|
await tx.update(toolApplications).set({ status: "active", updatedAt: new Date() }).where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId)));
|
|
return row;
|
|
});
|
|
const details = await profileDetails(profile.id, companyId);
|
|
const policies = (await db.select().from(toolPolicies).where(eq(toolPolicies.companyId, companyId))).filter((policy) => asRecord(policy.config).connectionId === connection.id);
|
|
return { connection: toConnection(updated), profile: toProfile(profile), profileEntries: details.entries, profileBindings: details.bindings, policies: policies.map(toPolicy) };
|
|
}
|
|
const enabledIds = [
|
|
...new Set([
|
|
...input.enabledCatalogEntryIds,
|
|
...input.askFirstCatalogEntryIds,
|
|
]),
|
|
];
|
|
const requestedReviewedIds = input.reviewedCatalogEntryIds ?? [];
|
|
const reviewedIds = [...new Set(requestedReviewedIds)];
|
|
if (reviewedIds.length !== requestedReviewedIds.length) {
|
|
throw badRequest(
|
|
"Action review decisions must not contain duplicate catalogEntryId values",
|
|
);
|
|
}
|
|
const enabledRows = await assertCatalogEntriesForConnection(
|
|
companyId,
|
|
connection.id,
|
|
enabledIds,
|
|
);
|
|
const askFirstRows = await assertCatalogEntriesForConnection(
|
|
companyId,
|
|
connection.id,
|
|
input.askFirstCatalogEntryIds,
|
|
);
|
|
if (enabledRows.some((entry) => entry.status === "disabled" && !(entry.quarantineReason === "mcp_tool_removed" && isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)))) {
|
|
throw badRequest("Disabled actions cannot be enabled");
|
|
}
|
|
if (reviewedIds.length > 0) {
|
|
await assertCatalogEntriesForConnection(
|
|
companyId,
|
|
connection.id,
|
|
reviewedIds,
|
|
);
|
|
const quarantinedRows = await db
|
|
.select({ id: toolCatalogEntries.id })
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, companyId),
|
|
eq(toolCatalogEntries.connectionId, connection.id),
|
|
eq(toolCatalogEntries.status, "quarantined"),
|
|
),
|
|
);
|
|
const reviewedIdSet = new Set(reviewedIds);
|
|
if (
|
|
quarantinedRows.length !== reviewedIdSet.size ||
|
|
quarantinedRows.some((entry) => !reviewedIdSet.has(entry.id))
|
|
) {
|
|
throw badRequest(
|
|
"Action review decisions must cover every currently quarantined action exactly once",
|
|
);
|
|
}
|
|
}
|
|
if (input.access !== "all_agents")
|
|
await assertAgentsInCompany(companyId, input.access.agentIds);
|
|
|
|
const entries: CreateToolProfileEntryForProfile[] = enabledRows.map(
|
|
(entry) => ({
|
|
selectorType: "catalog_entry",
|
|
effect: "include",
|
|
catalogEntryId: entry.id,
|
|
connectionId: connection.id,
|
|
applicationId: connection.applicationId,
|
|
}),
|
|
);
|
|
const profileKey = `app:${connection.id}`;
|
|
const bindingInputs: CreateToolProfileBindingForProfile[] =
|
|
input.access === "all_agents"
|
|
? [
|
|
{
|
|
targetType: "company",
|
|
targetId: companyId,
|
|
priority: 100,
|
|
metadata: { source: "app_gallery_finish" },
|
|
},
|
|
]
|
|
: [...new Set(input.access.agentIds)].map((agentId) => ({
|
|
targetType: "agent" as const,
|
|
targetId: agentId,
|
|
priority: 100,
|
|
metadata: { source: "app_gallery_finish" },
|
|
}));
|
|
const transactionResult = await db.transaction(async (tx) => {
|
|
await tx.select({ id: toolApplications.id }).from(toolApplications)
|
|
.where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId)))
|
|
.for("update");
|
|
const [lockedConnection] = await tx
|
|
.select({ id: toolConnections.id, status: toolConnections.status, enabled: toolConnections.enabled })
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, connectionId),
|
|
eq(toolConnections.companyId, companyId),
|
|
),
|
|
)
|
|
.for("update");
|
|
if (!lockedConnection) throw notFound("Tool connection not found");
|
|
const [existingProfile] = await tx
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(
|
|
and(
|
|
eq(toolProfiles.companyId, companyId),
|
|
eq(toolProfiles.profileKey, profileKey),
|
|
),
|
|
)
|
|
.limit(1).for("no key update");
|
|
await lockManagedAppPolicies(tx, companyId, connection.id);
|
|
const existingBindings = existingProfile ? await tx.select().from(toolProfileBindings)
|
|
.where(and(eq(toolProfileBindings.companyId, companyId), eq(toolProfileBindings.profileId, existingProfile.id))) : [];
|
|
const existingInstalls = await tx.select().from(toolConnectionInstalls)
|
|
.where(and(eq(toolConnectionInstalls.companyId, companyId), eq(toolConnectionInstalls.connectionId, connection.id)));
|
|
const companyTargets = lockedConnection.status !== "active" || !lockedConnection.enabled || (existingProfile && existingProfile.status !== "active")
|
|
? [{ targetType: "company", targetId: companyId }] : [];
|
|
await lockToolAssignmentAgents(tx, companyId, [...existingBindings, ...bindingInputs, ...existingInstalls, ...companyTargets]);
|
|
await actor?.managementContext?.reauthorize();
|
|
// A renamed connection can leave its old profile name in use. Resolve
|
|
// names against profiles too, including when finalizing an installed draft.
|
|
const otherProfiles = await tx
|
|
.select({ name: toolProfiles.name })
|
|
.from(toolProfiles)
|
|
.where(and(
|
|
eq(toolProfiles.companyId, companyId),
|
|
ne(toolProfiles.profileKey, profileKey),
|
|
));
|
|
const profileName = nextAvailableConnectionName(
|
|
connection.name,
|
|
otherProfiles.map((profile) => profile.name),
|
|
);
|
|
let profileId: string;
|
|
if (existingProfile) {
|
|
if (input.preserveExistingAccess) {
|
|
for (const prior of existingBindings)
|
|
if (
|
|
!bindingInputs.some(
|
|
(binding) =>
|
|
binding.targetType === prior.targetType &&
|
|
binding.targetId === prior.targetId,
|
|
)
|
|
)
|
|
bindingInputs.push({
|
|
targetType: prior.targetType,
|
|
targetId: prior.targetId,
|
|
priority: prior.priority,
|
|
metadata: prior.metadata,
|
|
});
|
|
const priorEntries = await tx
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.profileId, existingProfile.id));
|
|
for (const prior of priorEntries)
|
|
if (
|
|
!entries.some(
|
|
(entry) =>
|
|
entry.catalogEntryId &&
|
|
entry.catalogEntryId === prior.catalogEntryId,
|
|
)
|
|
)
|
|
entries.push({
|
|
selectorType: prior.selectorType,
|
|
effect: prior.effect,
|
|
applicationId: prior.applicationId,
|
|
connectionId: prior.connectionId,
|
|
catalogEntryId: prior.catalogEntryId,
|
|
toolName: prior.toolName,
|
|
riskLevel: prior.riskLevel,
|
|
conditions: prior.conditions,
|
|
});
|
|
}
|
|
await tx
|
|
.delete(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, companyId),
|
|
eq(toolProfileBindings.profileId, existingProfile.id),
|
|
),
|
|
);
|
|
await tx
|
|
.delete(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, companyId),
|
|
eq(toolProfileEntries.profileId, existingProfile.id),
|
|
),
|
|
);
|
|
if (entries.length > 0) {
|
|
await tx.insert(toolProfileEntries).values(
|
|
entries.map((entry) => ({
|
|
companyId,
|
|
profileId: existingProfile.id,
|
|
selectorType: entry.selectorType,
|
|
effect: entry.effect ?? "include",
|
|
applicationId: entry.applicationId ?? null,
|
|
connectionId: entry.connectionId ?? null,
|
|
catalogEntryId: entry.catalogEntryId ?? null,
|
|
toolName: entry.toolName ?? null,
|
|
riskLevel: entry.riskLevel ?? null,
|
|
conditions: entry.conditions ?? null,
|
|
})),
|
|
);
|
|
}
|
|
const [updated] = await tx
|
|
.update(toolProfiles)
|
|
.set({
|
|
name: profileName,
|
|
description: `Access profile for ${connection.name}.`,
|
|
status: "active",
|
|
defaultAction: "deny",
|
|
metadata: {
|
|
source: "app_gallery_finish",
|
|
connectionId: connection.id,
|
|
},
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolProfiles.id, existingProfile.id))
|
|
.returning();
|
|
profileId = updated.id;
|
|
} else {
|
|
const [created] = await tx
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId,
|
|
profileKey,
|
|
name: profileName,
|
|
description: `Access profile for ${connection.name}.`,
|
|
status: "active",
|
|
defaultAction: "deny",
|
|
metadata: {
|
|
source: "app_gallery_finish",
|
|
connectionId: connection.id,
|
|
},
|
|
})
|
|
.returning();
|
|
if (entries.length > 0) {
|
|
await tx.insert(toolProfileEntries).values(
|
|
entries.map((entry) => ({
|
|
companyId,
|
|
profileId: created.id,
|
|
selectorType: entry.selectorType,
|
|
effect: entry.effect ?? "include",
|
|
applicationId: entry.applicationId ?? null,
|
|
connectionId: entry.connectionId ?? null,
|
|
catalogEntryId: entry.catalogEntryId ?? null,
|
|
toolName: entry.toolName ?? null,
|
|
riskLevel: entry.riskLevel ?? null,
|
|
conditions: entry.conditions ?? null,
|
|
})),
|
|
);
|
|
}
|
|
profileId = created.id;
|
|
}
|
|
|
|
const profileBindings: ToolProfileBinding[] = [];
|
|
// These connectors expose one agent-access choice. Commit installation
|
|
// reach and permission bindings together, including an empty selection.
|
|
if (isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) {
|
|
const desired = new Map(bindingInputs.flatMap((binding) => binding.targetType === "company" || binding.targetType === "agent"
|
|
? [[`${binding.targetType}:${binding.targetId}`, { targetType: binding.targetType, targetId: binding.targetId }] as const] : []));
|
|
const removed = existingInstalls.filter((install) => !desired.has(`${install.targetType}:${install.targetId}`));
|
|
const added = [...desired.values()].filter((binding) => !existingInstalls.some((install) => install.targetType === binding.targetType && install.targetId === binding.targetId));
|
|
if (removed.length) await tx.delete(toolConnectionInstalls).where(inArray(toolConnectionInstalls.id, removed.map((install) => install.id)));
|
|
if (added.length) await tx.insert(toolConnectionInstalls).values(added.map((binding) => ({
|
|
companyId, connectionId: connection.id, targetType: binding.targetType, targetId: binding.targetId,
|
|
createdByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
|
createdByUserId: connectionActorUserId(actor),
|
|
})));
|
|
if (added.length || removed.length) await tx.insert(toolAccessAuditEvents).values({
|
|
companyId, connectionId: connection.id, actorType: actor?.actorType ?? "system", actorId: actor?.actorId ?? null,
|
|
action: "connection_installs.changed", outcome: "success", reasonCode: "installs_changed",
|
|
details: { added: added.map(({ targetType, targetId }) => ({ targetType, targetId })), removed: removed.map(({ targetType, targetId }) => ({ targetType, targetId })) },
|
|
});
|
|
}
|
|
for (const bindingInput of bindingInputs) {
|
|
const [binding] = await tx
|
|
.insert(toolProfileBindings)
|
|
.values({
|
|
companyId,
|
|
profileId,
|
|
targetType: bindingInput.targetType,
|
|
targetId: bindingInput.targetId,
|
|
priority: bindingInput.priority ?? 100,
|
|
metadata: bindingInput.metadata ?? {},
|
|
createdByAgentId:
|
|
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
|
createdByUserId:
|
|
connectionActorUserId(actor),
|
|
})
|
|
.returning();
|
|
profileBindings.push(toProfileBinding(binding));
|
|
}
|
|
|
|
const reviewedAt = new Date();
|
|
if (reviewedIds.length > 0) {
|
|
await tx
|
|
.update(toolCatalogEntries)
|
|
.set({
|
|
status: "active",
|
|
reviewedAt,
|
|
reviewedByAgentId:
|
|
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
|
reviewedByUserId:
|
|
connectionActorUserId(actor),
|
|
quarantinedAt: null,
|
|
quarantineReason: null,
|
|
updatedAt: reviewedAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, companyId),
|
|
eq(toolCatalogEntries.connectionId, connection.id),
|
|
inArray(toolCatalogEntries.id, reviewedIds),
|
|
eq(toolCatalogEntries.status, "quarantined"),
|
|
),
|
|
);
|
|
}
|
|
if (enabledIds.length > 0) {
|
|
await tx
|
|
.update(toolCatalogEntries)
|
|
.set({
|
|
status: "active",
|
|
reviewedAt,
|
|
reviewedByAgentId:
|
|
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
|
reviewedByUserId:
|
|
connectionActorUserId(actor),
|
|
quarantinedAt: null,
|
|
quarantineReason: null,
|
|
updatedAt: reviewedAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, companyId),
|
|
inArray(toolCatalogEntries.id, enabledIds),
|
|
ne(toolCatalogEntries.status, "quarantined"),
|
|
ne(toolCatalogEntries.status, "disabled"),
|
|
),
|
|
);
|
|
}
|
|
|
|
const policies = await upsertAskFirstPolicies(
|
|
{
|
|
companyId,
|
|
connection,
|
|
askFirstEntries: askFirstRows,
|
|
disableStale: !input.preserveExistingAccess,
|
|
actor,
|
|
},
|
|
tx,
|
|
);
|
|
const [updatedConnection] = await tx
|
|
.update(toolConnections)
|
|
.set({ status: "active", enabled: true, ...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}), updatedAt: new Date() })
|
|
.where(eq(toolConnections.id, connection.id))
|
|
.returning();
|
|
await tx
|
|
.update(toolApplications)
|
|
.set({ status: "active", updatedAt: new Date() })
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
|
|
return { profileId, profileBindings, policies, updatedConnection };
|
|
});
|
|
emitConnectionUpdated(
|
|
transactionResult.updatedConnection,
|
|
connection,
|
|
"gallery",
|
|
);
|
|
|
|
const details = await profileDetails(
|
|
transactionResult.profileId,
|
|
companyId,
|
|
);
|
|
return {
|
|
connection: toConnection(transactionResult.updatedConnection),
|
|
profile: {
|
|
id: details.id,
|
|
companyId: details.companyId,
|
|
profileKey: details.profileKey,
|
|
name: details.name,
|
|
description: details.description,
|
|
status: details.status,
|
|
defaultAction: details.defaultAction,
|
|
newToolsReviewedAt: details.newToolsReviewedAt,
|
|
metadata: details.metadata,
|
|
createdAt: details.createdAt,
|
|
updatedAt: details.updatedAt,
|
|
},
|
|
profileEntries: details.entries,
|
|
profileBindings: transactionResult.profileBindings,
|
|
policies: transactionResult.policies,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Replace the credential(s) on an existing connection and re-run the health
|
|
* check — the "Replace key" / reconnect flow (M7, PAP-10859). Rotates the
|
|
* secret in place when a ref already exists so the connection keeps its
|
|
* profile, policies, and catalog; creates a fresh secret only when the field
|
|
* had none (e.g. a link connection added a key after the fact).
|
|
*/
|
|
async function reconnectGalleryApp(
|
|
connectionId: string,
|
|
companyId: string,
|
|
input: { credentialValues: Record<string, string> },
|
|
actor?: ActorInfo,
|
|
): Promise<ToolConnectionHealthCheckResult> {
|
|
await actor?.managementContext?.reauthorize();
|
|
const connection = await getConnectionRow(connectionId, companyId);
|
|
assertSupportedConnection(connection);
|
|
await assertConnectorConnectionSetupEnabled(connection);
|
|
await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.status !== "draft");
|
|
if (connection.status === "archived")
|
|
throw conflict("Archived app connections cannot be reconnected");
|
|
if (connection.credentialSource === "vercel_connect") {
|
|
throw conflict(
|
|
"Manage this connector in Vercel Connect, then run a Paperclip health check to verify it.",
|
|
{
|
|
code: "vercel_connect_managed_externally",
|
|
manageUrl: vercelConnectIntegrationStatus().manageUrl,
|
|
},
|
|
);
|
|
}
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const galleryEntry = sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)
|
|
: null;
|
|
const storedCredentialFields = connection.credentialRefs
|
|
.filter((ref) => ref.placement === "header" || ref.placement === "url")
|
|
.map((ref) => ({
|
|
label: ref.placement === "url" ? "MCP server URL" : ref.prefix === "Bearer " ? "App key" : ref.key ?? ref.name,
|
|
configPath: credentialRefConfigPath(ref),
|
|
helpUrl: "",
|
|
required: false,
|
|
placement: ref.placement,
|
|
key: ref.key,
|
|
prefix: ref.prefix,
|
|
}));
|
|
const galleryCredentialFields = galleryEntry
|
|
? credentialFieldsFor(galleryEntry, connectionMethodForConnection(galleryEntry, connection).key)
|
|
: [];
|
|
const credentialFields = galleryCredentialFields.length > 0
|
|
? galleryCredentialFields
|
|
: storedCredentialFields.length > 0 ? storedCredentialFields : [
|
|
{
|
|
label: "App key",
|
|
configPath: "credentials.authorization",
|
|
helpUrl: "",
|
|
required: false,
|
|
placement: "header" as const,
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
},
|
|
];
|
|
|
|
const providedFields = credentialFields.filter(
|
|
(field) =>
|
|
(input.credentialValues[field.configPath]?.trim().length ?? 0) > 0,
|
|
);
|
|
if (providedFields.length === 0)
|
|
throw badRequest("Enter a replacement credential to reconnect this app");
|
|
|
|
const personalIdentity = await fixedPersonalIdentityForReconnect(
|
|
connection,
|
|
undefined,
|
|
actor,
|
|
);
|
|
const credentialSecretRefs = [
|
|
...(personalIdentity?.grant?.credentialSecretRefs ??
|
|
connection.credentialSecretRefs),
|
|
];
|
|
const credentialRefs: McpConnectionCredentialRef[] = [
|
|
...(connection.credentialRefs ?? []),
|
|
];
|
|
|
|
const updated = await db.transaction(async (tx) => {
|
|
await tx.select({ id: connectionGrants.id }).from(connectionGrants)
|
|
.where(and(eq(connectionGrants.companyId, companyId), eq(connectionGrants.connectionId, connection.id)))
|
|
.orderBy(connectionGrants.id).for("update");
|
|
await tx.select({ id: toolApplications.id }).from(toolApplications)
|
|
.where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId)))
|
|
.for("update");
|
|
await tx.select({ id: toolConnections.id }).from(toolConnections)
|
|
.where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, companyId)))
|
|
.for("update");
|
|
for (const field of providedFields) {
|
|
const value = input.credentialValues[field.configPath]!.trim();
|
|
if (field.placement === "url" && !remoteUrlCredentialMatchesPublicUrl(String(connection.config.url ?? ""), value)) {
|
|
throw badRequest("The replacement server URL must use the same endpoint as this connection.", {
|
|
code: "mcp_remote_url_credential_mismatch",
|
|
});
|
|
}
|
|
const existing = credentialSecretRefs.find(
|
|
(ref) => ref.configPath === field.configPath,
|
|
);
|
|
const { secret } = await writeConnectionCredential(tx, {
|
|
companyId, connectionName: connection.name, configPath: field.configPath,
|
|
label: field.label, value, ownerUserId: personalIdentity?.subjectUserId,
|
|
existingRef: existing, actor: actorForSecret(actor),
|
|
});
|
|
const nextRef = { secretId: secret.id, versionSelector: "latest" as const,
|
|
configPath: field.configPath, required: field.required ?? true, label: field.label };
|
|
const refIndex = credentialSecretRefs.findIndex((ref) => ref.configPath === field.configPath);
|
|
if (refIndex >= 0) credentialSecretRefs[refIndex] = nextRef;
|
|
else credentialSecretRefs.push(nextRef);
|
|
if ((field.placement === "header" && field.key) || field.placement === "url") {
|
|
const nextCredentialRef = {
|
|
name: field.configPath,
|
|
secretId: secret.id,
|
|
version: "latest",
|
|
placement: field.placement,
|
|
key: field.key ?? "url",
|
|
prefix: field.prefix ?? null,
|
|
} satisfies McpConnectionCredentialRef;
|
|
const existingCredentialRefIndex = credentialRefs.findIndex(
|
|
(ref) => credentialRefConfigPath(ref) === field.configPath,
|
|
);
|
|
if (existingCredentialRefIndex >= 0)
|
|
credentialRefs[existingCredentialRefIndex] = nextCredentialRef;
|
|
else credentialRefs.push(nextCredentialRef);
|
|
}
|
|
}
|
|
|
|
const updatedAt = new Date();
|
|
if (personalIdentity) {
|
|
const grantValues = {
|
|
credentialSecretRefs,
|
|
status: "active" as const,
|
|
revokedAt: null,
|
|
revokedByAgentId: null,
|
|
revokedByUserId: null,
|
|
updatedAt,
|
|
};
|
|
if (personalIdentity.grant) {
|
|
await tx
|
|
.update(connectionGrants)
|
|
.set(grantValues)
|
|
.where(eq(connectionGrants.id, personalIdentity.grant.id));
|
|
} else {
|
|
await tx.insert(connectionGrants).values({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: personalIdentity.subjectUserId,
|
|
...grantValues,
|
|
isDefault: false,
|
|
createdByUserId: personalIdentity.subjectUserId,
|
|
});
|
|
}
|
|
}
|
|
const [nextConnection] = await tx
|
|
.update(toolConnections)
|
|
.set({
|
|
credentialRefs,
|
|
// Personal reconnect rotates the existing user's grant. The
|
|
// connection-level organization slot stays exactly as it was.
|
|
credentialSecretRefs: personalIdentity
|
|
? connection.credentialSecretRefs
|
|
: credentialSecretRefs,
|
|
lastError: null,
|
|
updatedAt,
|
|
})
|
|
.where(eq(toolConnections.id, connection.id))
|
|
.returning();
|
|
await syncConnectionCredentialBindings(tx, nextConnection,
|
|
personalIdentity ? credentialSecretRefs : []);
|
|
return nextConnection;
|
|
});
|
|
const health = await checkConnectionHealth(updated.id, actor);
|
|
const refresh = await refreshCatalog(updated.id, actor, {
|
|
enableAllByDefault: true,
|
|
});
|
|
return { ...health, connection: refresh.connection };
|
|
}
|
|
|
|
async function preflightGalleryAppMetadata(
|
|
galleryKey: string,
|
|
methodKey?: string | null,
|
|
): Promise<ToolAppMetadataPreflightResult> {
|
|
const app = getConnectableAppDefinition(galleryKey);
|
|
if (!app || app.availability?.available === false)
|
|
throw notFound("App not found");
|
|
const method = connectionMethodFor(app, methodKey);
|
|
await assertExperimentalConnectorSetupEnabled(app.slug);
|
|
if (method.transport !== "mcp_remote" || !method.defaults?.serverUrl) {
|
|
throw unprocessable(
|
|
"This app method does not use a hosted remote MCP endpoint",
|
|
);
|
|
}
|
|
|
|
const serverUrl = await assertRemoteHttpUrlAllowed(
|
|
method.defaults.serverUrl,
|
|
);
|
|
return preflightConnectionMetadata(app, method, method.defaults, serverUrl, () => options.now?.() ?? new Date());
|
|
}
|
|
|
|
return {
|
|
/** Identity-only verification: no bot installation/repository access required. */
|
|
verifyPersonalGitHubIdentity: async (companyId: string, connectionId: string, userId: string) => {
|
|
const [connection] = await db.select().from(toolConnections).where(and(eq(toolConnections.companyId, companyId), eq(toolConnections.id, connectionId), eq(toolConnections.enabled, true)));
|
|
if (!connection || (connection.config.sourceTemplateKey !== "github" && connection.transportConfig?.sourceTemplateKey !== "github")) throw notFound("GitHub connection not found");
|
|
const [ownGrant] = await db.select().from(connectionGrants).where(and(eq(connectionGrants.companyId, companyId), eq(connectionGrants.connectionId, connectionId), eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, userId), eq(connectionGrants.status, "active")));
|
|
if (!ownGrant) throw forbidden("Connect your own GitHub account first. Shared and agent connections cannot prove your identity.");
|
|
const actor = { actorType: "user" as const, actorId: userId };
|
|
const grant = await refreshOAuthGrantCredentials({ companyId, connectionId, grantId: ownGrant.id, actor });
|
|
const accessRef = grant.credentialSecretRefs.find(ref => ref.configPath === "oauth.access_token");
|
|
if (!accessRef) throw unprocessable("Reconnect your personal GitHub connection");
|
|
const { value } = await resolveOAuthGrantSecret(connection, grant, accessRef, actor, undefined);
|
|
const identity = await githubBotRequest<{ id?: number; login?: string; avatar_url?: string }>(fetch, value, "/user");
|
|
if (!Number.isSafeInteger(identity.id) || !identity.id || !identity.login || !/^[a-zA-Z0-9][a-zA-Z0-9-]{0,38}$/.test(identity.login)) throw unprocessable("GitHub returned an invalid account identity");
|
|
const [current] = await db.select({ id: connectionGrants.id }).from(connectionGrants).where(and(eq(connectionGrants.id, grant.id), eq(connectionGrants.companyId, companyId), eq(connectionGrants.subjectUserId, userId), eq(connectionGrants.status, "active")));
|
|
if (!current) throw forbidden("Your GitHub connection was revoked. Connect it again.");
|
|
return { githubUserId: String(identity.id), login: identity.login, avatarUrl: identity.avatar_url ?? null, connectionId, grantId: grant.id };
|
|
},
|
|
preflightGalleryAppMetadata,
|
|
approvedStdioTemplates: async (
|
|
companyId: string,
|
|
): Promise<ToolStdioCommandTemplate[]> => {
|
|
const adminTemplates = await db
|
|
.select()
|
|
.from(toolStdioCommandTemplates)
|
|
.where(eq(toolStdioCommandTemplates.companyId, companyId))
|
|
.orderBy(asc(toolStdioCommandTemplates.templateKey));
|
|
return [
|
|
...Object.keys(APPROVED_STDIO_TEMPLATES)
|
|
.sort()
|
|
.map((templateId) => builtInStdioTemplate(templateId)!),
|
|
...adminTemplates.map(toStdioCommandTemplate),
|
|
];
|
|
},
|
|
|
|
createStdioCommandTemplate: async (
|
|
companyId: string,
|
|
input: CreateToolStdioCommandTemplate,
|
|
actor?: ActorInfo,
|
|
): Promise<ToolStdioCommandTemplate> => {
|
|
await actor?.managementContext?.reauthorize();
|
|
if (builtInStdioTemplate(input.templateId)) {
|
|
throw conflict(
|
|
"A built-in stdio template already uses this templateId",
|
|
);
|
|
}
|
|
const existing = await getAdminStdioTemplate(companyId, input.templateId);
|
|
if (existing)
|
|
throw conflict("A stdio command template already uses this templateId");
|
|
const tools = input.tools
|
|
.map((tool) => normalizeToolDescriptor(tool))
|
|
.filter((tool): tool is McpToolDescriptor => Boolean(tool));
|
|
const [row] = await db
|
|
.insert(toolStdioCommandTemplates)
|
|
.values({
|
|
companyId,
|
|
templateKey: input.templateId,
|
|
name: input.name,
|
|
description: input.description ?? null,
|
|
status: "active",
|
|
command: input.command,
|
|
args: input.args,
|
|
envKeys: input.envKeys,
|
|
tools,
|
|
createdByAgentId:
|
|
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
|
createdByUserId:
|
|
connectionActorUserId(actor),
|
|
})
|
|
.returning();
|
|
return toStdioCommandTemplate(row);
|
|
},
|
|
|
|
disableStdioCommandTemplate: async (
|
|
companyId: string,
|
|
templateId: string,
|
|
): Promise<ToolStdioCommandTemplate> => {
|
|
if (builtInStdioTemplate(templateId))
|
|
throw unprocessable("Built-in stdio templates cannot be disabled");
|
|
const existing = await getAdminStdioTemplate(companyId, templateId);
|
|
if (!existing) throw notFound("Stdio command template not found");
|
|
if (existing.status === "disabled")
|
|
return toStdioCommandTemplate(existing);
|
|
const at = now();
|
|
const [row] = await db
|
|
.update(toolStdioCommandTemplates)
|
|
.set({ status: "disabled", disabledAt: at, updatedAt: at })
|
|
.where(
|
|
and(
|
|
eq(toolStdioCommandTemplates.companyId, companyId),
|
|
eq(toolStdioCommandTemplates.templateKey, templateId),
|
|
),
|
|
)
|
|
.returning();
|
|
return toStdioCommandTemplate(row);
|
|
},
|
|
...aggregatorAccounts,
|
|
|
|
connectGalleryApp,
|
|
|
|
finishGalleryAppConnection,
|
|
|
|
reconnectGalleryApp,
|
|
|
|
storeConnectorOAuthSecret: createOrRotateOAuthSecret,
|
|
resolveConnectorOAuthGrantSecret: resolveOAuthGrantSecret,
|
|
startOAuth,
|
|
|
|
startAuthorizationForAgent: async (input: {
|
|
companyId: string;
|
|
connectionId: string;
|
|
agentId: string;
|
|
runId: string;
|
|
subjectUserId: string;
|
|
scopes?: string[];
|
|
returnTo?: string;
|
|
redirectUri: string;
|
|
managementContext?: ConnectionManagementContext;
|
|
}) => {
|
|
await input.managementContext?.reauthorize();
|
|
const runContext = await loadBrokerRunContext(input);
|
|
const connection = await getConnectionRow(
|
|
input.connectionId,
|
|
input.companyId,
|
|
);
|
|
if (
|
|
!runContext.responsibleUserId ||
|
|
runContext.responsibleUserId !== input.subjectUserId
|
|
) {
|
|
throw new HttpError(
|
|
403,
|
|
"The agent run cannot start authorization for the requested user",
|
|
{
|
|
code: "subject_not_permitted",
|
|
connection: { uid: connection.uid },
|
|
subject: { type: "user", userId: input.subjectUserId },
|
|
},
|
|
);
|
|
}
|
|
return startOAuth(input.companyId, connection.id, {
|
|
redirectUri: input.redirectUri,
|
|
actor: { actorType: "agent", actorId: input.agentId, ...input.managementContext?.audit, managementContext: input.managementContext },
|
|
subjectUserId: input.subjectUserId,
|
|
scopes: input.scopes,
|
|
returnTo: input.returnTo,
|
|
issueId: runContext.issueId ?? undefined,
|
|
});
|
|
},
|
|
|
|
peekOAuthState,
|
|
|
|
completePaperclipCloudConnectorCallback,
|
|
|
|
completeVercelConnectCallback,
|
|
|
|
completeOAuthCallback,
|
|
refreshOAuthGrantCredentials,
|
|
finalizeOAuthAccess,
|
|
|
|
listExamples: async (companyId: string): Promise<ToolExampleSummary[]> => {
|
|
return Promise.all(
|
|
TOOL_EXAMPLES.map(async (definition) => {
|
|
const rows = await exampleRows(companyId, definition);
|
|
return exampleSummary(definition, rows);
|
|
}),
|
|
);
|
|
},
|
|
|
|
installExample: async (
|
|
companyId: string,
|
|
exampleId: string,
|
|
actor?: ActorInfo,
|
|
): Promise<ToolExampleInstallResult> => {
|
|
const definition = findExample(exampleId);
|
|
const blocker = localStdioInstallBlocker();
|
|
if (blocker) throw unprocessable(blocker);
|
|
assertLocalStdioCanBeEnabled("local_stdio", true);
|
|
await stdioTemplateId(companyId, { templateId: definition.templateId });
|
|
const before = await exampleRows(companyId, definition);
|
|
const application = await upsertExampleApplication(
|
|
companyId,
|
|
definition,
|
|
before.application,
|
|
);
|
|
const connection = await upsertExampleConnection(
|
|
companyId,
|
|
definition,
|
|
application.row.id,
|
|
before.connection,
|
|
);
|
|
const refresh = await refreshCatalog(connection.row.id, actor);
|
|
let catalog = refresh.catalog;
|
|
const safeReadEntryIds = catalog
|
|
.filter((entry) => entry.riskLevel === "read")
|
|
.map((entry) => entry.id);
|
|
if (safeReadEntryIds.length > 0) {
|
|
const reviewedAt = new Date();
|
|
await db
|
|
.update(toolCatalogEntries)
|
|
.set({
|
|
status: "active",
|
|
reviewedAt,
|
|
reviewedByAgentId:
|
|
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
|
reviewedByUserId:
|
|
connectionActorUserId(actor),
|
|
quarantinedAt: null,
|
|
quarantineReason: null,
|
|
updatedAt: reviewedAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, companyId),
|
|
inArray(toolCatalogEntries.id, safeReadEntryIds),
|
|
),
|
|
);
|
|
catalog = catalog.map((entry) =>
|
|
safeReadEntryIds.includes(entry.id)
|
|
? {
|
|
...entry,
|
|
status: "active",
|
|
reviewedAt,
|
|
quarantinedAt: null,
|
|
quarantineReason: null,
|
|
updatedAt: reviewedAt,
|
|
}
|
|
: entry,
|
|
);
|
|
}
|
|
const profile = await upsertExampleProfile(
|
|
companyId,
|
|
definition,
|
|
before.profile,
|
|
);
|
|
const profileEntries = await syncExampleProfileEntries(
|
|
companyId,
|
|
profile.row.id,
|
|
catalog,
|
|
);
|
|
const profileBinding = await upsertExampleProfileBinding(
|
|
companyId,
|
|
profile.row.id,
|
|
before.profileBinding,
|
|
actor,
|
|
);
|
|
const after = await exampleRows(companyId, definition);
|
|
return {
|
|
example: exampleSummary(definition, after),
|
|
created:
|
|
application.created ||
|
|
connection.created ||
|
|
profile.created ||
|
|
!before.profileBinding,
|
|
application: toApplication(application.row),
|
|
connection: refresh.connection,
|
|
profile: toProfile(profile.row),
|
|
profileEntries,
|
|
profileBinding,
|
|
catalog,
|
|
};
|
|
},
|
|
|
|
smokeExample: async (
|
|
companyId: string,
|
|
exampleId: string,
|
|
actor?: ActorInfo,
|
|
): Promise<ToolExampleSmokeResult> => {
|
|
const definition = findExample(exampleId);
|
|
const rows = await exampleRows(companyId, definition);
|
|
if (!rows.connection || !rows.profile || !rows.profileBinding) {
|
|
throw conflict("Install this tool example before running smoke checks");
|
|
}
|
|
const catalog =
|
|
rows.catalog.length > 0
|
|
? rows.catalog.map(toCatalogEntry)
|
|
: (await refreshCatalog(rows.connection.id, actor)).catalog;
|
|
const readEntry = catalog.find(
|
|
(entry) => entry.riskLevel === "read" && entry.status === "active",
|
|
);
|
|
const deniedEntry = catalog.find(
|
|
(entry) =>
|
|
entry.riskLevel === "write" || entry.riskLevel === "destructive",
|
|
);
|
|
if (!readEntry || !deniedEntry) {
|
|
throw unprocessable(
|
|
"Example smoke requires at least one read tool and one denied write/destructive tool",
|
|
);
|
|
}
|
|
const smokeActor = await exampleSmokeActor(companyId, actor);
|
|
const connection = toConnection(rows.connection);
|
|
const allowCheck = await runSmokeDecisionCheck({
|
|
companyId,
|
|
actor: smokeActor,
|
|
connection,
|
|
catalogEntry: readEntry,
|
|
expectedDecision: "allow",
|
|
name: "allow_read_tool",
|
|
});
|
|
const denyCheck = await runSmokeDecisionCheck({
|
|
companyId,
|
|
actor: smokeActor,
|
|
connection,
|
|
catalogEntry: deniedEntry,
|
|
expectedDecision: "deny",
|
|
name: "deny_write_tool",
|
|
});
|
|
const auditCheck: ToolExampleSmokeCheck = {
|
|
name: "audit_written",
|
|
ok: Boolean(
|
|
allowCheck.auditEventId &&
|
|
allowCheck.toolCallEventId &&
|
|
denyCheck.auditEventId &&
|
|
denyCheck.toolCallEventId,
|
|
),
|
|
details: {
|
|
auditEventIds: [allowCheck.auditEventId, denyCheck.auditEventId],
|
|
toolCallEventIds: [
|
|
allowCheck.toolCallEventId,
|
|
denyCheck.toolCallEventId,
|
|
],
|
|
},
|
|
};
|
|
const checks = [allowCheck, denyCheck, auditCheck];
|
|
return {
|
|
exampleId: definition.id,
|
|
ok: checks.every((check) => check.ok),
|
|
actor: smokeActor,
|
|
connection,
|
|
profile: toProfile(rows.profile),
|
|
checks,
|
|
};
|
|
},
|
|
|
|
listApplications: async (companyId: string): Promise<ToolApplication[]> => {
|
|
const rows = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.companyId, companyId))
|
|
.orderBy(desc(toolApplications.updatedAt));
|
|
return rows.map(toApplication);
|
|
},
|
|
|
|
createApplication: async (
|
|
companyId: string,
|
|
input: CreateToolApplication,
|
|
managementContext?: ConnectionManagementContext,
|
|
): Promise<ToolApplication> => {
|
|
await assertOptionalPlugin(input.pluginId);
|
|
await assertOptionalAgent(
|
|
companyId,
|
|
input.ownerAgentId,
|
|
"Tool application owner agent",
|
|
);
|
|
await managementContext?.reauthorize();
|
|
const [row] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId,
|
|
applicationKey: input.applicationKey ?? normalizeKey(input.name),
|
|
name: input.name,
|
|
description: input.description ?? null,
|
|
type: input.type,
|
|
status: input.status ?? "active",
|
|
pluginId: input.pluginId ?? null,
|
|
ownerAgentId: input.ownerAgentId ?? null,
|
|
ownerUserId: input.ownerUserId ?? null,
|
|
metadata: input.metadata ?? {},
|
|
})
|
|
.returning();
|
|
return toApplication(row);
|
|
},
|
|
|
|
getApplication: async (
|
|
applicationId: string,
|
|
companyId?: string,
|
|
): Promise<ToolApplication> => {
|
|
const where = companyId
|
|
? and(
|
|
eq(toolApplications.id, applicationId),
|
|
eq(toolApplications.companyId, companyId),
|
|
)
|
|
: eq(toolApplications.id, applicationId);
|
|
const [row] = await db.select().from(toolApplications).where(where);
|
|
if (!row) throw notFound("Tool application not found");
|
|
return toApplication(row);
|
|
},
|
|
|
|
updateApplication: async (
|
|
applicationId: string,
|
|
input: UpdateToolApplication,
|
|
managementContext?: ConnectionManagementContext,
|
|
): Promise<ToolApplication> => {
|
|
const [existing] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, applicationId));
|
|
if (!existing) throw notFound("Tool application not found");
|
|
await assertOptionalPlugin(input.pluginId);
|
|
await assertOptionalAgent(
|
|
existing.companyId,
|
|
input.ownerAgentId,
|
|
"Tool application owner agent",
|
|
);
|
|
if (input.name && input.name !== existing.name) {
|
|
const [duplicate] = await db
|
|
.select({ id: toolApplications.id })
|
|
.from(toolApplications)
|
|
.where(
|
|
and(
|
|
eq(toolApplications.companyId, existing.companyId),
|
|
eq(toolApplications.name, input.name),
|
|
ne(toolApplications.id, applicationId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (duplicate) {
|
|
throw conflict("A tool access record with that name already exists", {
|
|
code: "tool_access_name_conflict",
|
|
});
|
|
}
|
|
}
|
|
await managementContext?.reauthorize();
|
|
const [row] = await db
|
|
.update(toolApplications)
|
|
.set({
|
|
name: input.name ?? existing.name,
|
|
description: input.description ?? existing.description,
|
|
status: input.status ?? existing.status,
|
|
pluginId: input.pluginId ?? existing.pluginId,
|
|
ownerAgentId: input.ownerAgentId ?? existing.ownerAgentId,
|
|
ownerUserId: input.ownerUserId ?? existing.ownerUserId,
|
|
metadata: input.metadata ?? existing.metadata,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolApplications.id, applicationId))
|
|
.returning();
|
|
return toApplication(row);
|
|
},
|
|
|
|
deleteApplication: async (
|
|
applicationId: string,
|
|
managementContext?: ConnectionManagementContext,
|
|
): Promise<ToolApplication> => {
|
|
const [existing] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, applicationId));
|
|
if (!existing) throw notFound("Tool application not found");
|
|
// Guard: never orphan connections. The caller must remove the connections
|
|
// or archive the application instead — there is no force-cascade in v1.
|
|
const linkedConnections = await db
|
|
.select({ id: toolConnections.id })
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.applicationId, applicationId));
|
|
if (linkedConnections.length > 0) {
|
|
throw conflict(
|
|
"This application still has connections. Remove its connections or archive the application instead of deleting it.",
|
|
{ connectionCount: linkedConnections.length },
|
|
);
|
|
}
|
|
// The pre-check above gives a friendly 409 in the common case, but it cannot close the
|
|
// race where a connection is created in the gap before this delete runs. The FK is now
|
|
// ON DELETE RESTRICT, so such a delete fails closed with a foreign_key_violation instead
|
|
// of silently cascading the new connection away. Translate that into the same 409 so the
|
|
// endpoint keeps its contract instead of surfacing a 500.
|
|
await managementContext?.reauthorize();
|
|
let row: typeof toolApplications.$inferSelect | undefined;
|
|
try {
|
|
[row] = await db
|
|
.delete(toolApplications)
|
|
.where(eq(toolApplications.id, applicationId))
|
|
.returning();
|
|
} catch (error) {
|
|
if (isToolConnectionForeignKeyViolation(error)) {
|
|
throw conflict(
|
|
"This application still has connections. Remove its connections or archive the application instead of deleting it.",
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
if (!row) throw notFound("Tool application not found");
|
|
return toApplication(row);
|
|
},
|
|
|
|
// Repository discovery uses credential audiences, not connection-management
|
|
// visibility. An administrator cannot browse another user's private repos.
|
|
listProjectRepositories: async (companyId: string, userId: string | null, localTrusted = false) =>
|
|
toolAccessService(db).listGitHubRepositories(companyId, userId, localTrusted),
|
|
|
|
// Return identifiers, never credentials, so each attempted read can recheck its grant.
|
|
githubReadConnectionIds: async (companyId: string, userId: string | null, localTrusted = false): Promise<string[]> => {
|
|
const connections = await db.select().from(toolConnections).where(and(eq(toolConnections.companyId, companyId), eq(toolConnections.enabled, true))).orderBy(desc(toolConnections.updatedAt));
|
|
const ids: string[] = [];
|
|
for (const connection of connections) {
|
|
if (connection.status !== "active" || asRecord(connection.config).sourceTemplateKey !== "github") continue;
|
|
try {
|
|
await gitHubReadGrantAccess(db, companyId, connection.id, userId, localTrusted);
|
|
ids.push(connection.id);
|
|
} catch (error) {
|
|
// Only ineligible grants are skipped; database/service failures must surface.
|
|
if (!(error instanceof Error && "status" in error && error.status === 403)) throw error;
|
|
}
|
|
}
|
|
return ids;
|
|
},
|
|
|
|
githubReadGrantIds: async (companyId: string, connectionId: string, userId: string | null, localTrusted = false): Promise<Array<string | null>> => {
|
|
const { allowed, legacyShared } = await gitHubReadGrantAccess(db, companyId, connectionId, userId, localTrusted);
|
|
return legacyShared ? [null] : allowed.map(grant => grant.id);
|
|
},
|
|
|
|
// Server-side reads share the same grant audience and credential lifecycle as discovery.
|
|
githubReadHeaders: async (companyId: string, connectionId: string, userId: string | null, localTrusted = false, forceRefresh = false, grantId?: string | null): Promise<Record<string, string>> => {
|
|
const { connection, allowed, legacyShared } = await gitHubReadGrantAccess(db, companyId, connectionId, userId, localTrusted);
|
|
const actor: ActorInfo = { actorType: "user", actorId: userId ?? "board" };
|
|
if (legacyShared && !grantId) return resolveCredentialHeaders(connection, actor);
|
|
let grant = grantId ? allowed.find(candidate => candidate.id === grantId) : allowed.length === 1 ? allowed[0] : undefined;
|
|
if (!grant) throw forbidden("Choose an active GitHub authorization you can use.");
|
|
if (asRecord(asRecord(connection.config).oauth).connectorProfile === "github.code") {
|
|
grant = await refreshOAuthGrantCredentials({ companyId, connectionId, grantId: grant.id, actor, forceRefresh });
|
|
}
|
|
const ref = grant.credentialSecretRefs.find(ref => ref.configPath === "oauth.access_token" || /authorization|token|api_key/i.test(ref.configPath));
|
|
if (!ref) throw unprocessable("Reconnect GitHub to read repository files.");
|
|
const secret = await resolveOAuthGrantSecret(connection, grant, ref, actor, undefined);
|
|
return { Authorization: `Bearer ${secret.value}` };
|
|
},
|
|
|
|
listGitHubRepositories: async (
|
|
companyId: string,
|
|
userId: string | null,
|
|
localTrusted = false,
|
|
) => measureRunPhase("connectors.github.catalog", async () => {
|
|
const [connections, grants, members, memberships] = await Promise.all([
|
|
measureRunPhase("connectors.github.connections", () => db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.companyId, companyId),
|
|
eq(toolConnections.enabled, true),
|
|
),
|
|
)),
|
|
measureRunPhase("connectors.github.grants", () => db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.companyId, companyId))),
|
|
measureRunPhase("connectors.github.grant_members", () => db
|
|
.select()
|
|
.from(connectionGrantMembers)
|
|
.where(eq(connectionGrantMembers.companyId, companyId))),
|
|
userId
|
|
? measureRunPhase("connectors.github.membership", () => db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, userId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
))
|
|
: Promise.resolve([]),
|
|
]);
|
|
const repositories = new Map<
|
|
string,
|
|
import("@paperclipai/shared").ProjectRepository
|
|
>();
|
|
const usableConnections: Array<{ id: string; name: string }> = [];
|
|
let connectionCount = 0;
|
|
let failedConnectionCount = 0;
|
|
for (const connection of connections) {
|
|
if (
|
|
connection.status !== "active" ||
|
|
asRecord(connection.config).sourceTemplateKey !== "github"
|
|
)
|
|
continue;
|
|
const connectionGrants = grants.filter(
|
|
(grant) => grant.connectionId === connection.id,
|
|
);
|
|
const availableGrants = connectionGrants.filter(
|
|
(grant) =>
|
|
!(
|
|
grant.kind === "organization" &&
|
|
["per_user", "per_agent"].includes(connection.credentialPolicy)
|
|
) &&
|
|
canBrowseProjectRepositoryGrant({
|
|
grant,
|
|
userId,
|
|
activeMember: localTrusted || memberships.length > 0,
|
|
audience: members
|
|
.filter((member) => member.grantId === grant.id)
|
|
.map((member) => member.subjectId),
|
|
}),
|
|
);
|
|
// Legacy shared PAT connections predate grants. Never fall back when a
|
|
// grant exists but is revoked, private, or outside the caller's audience.
|
|
const legacyShared =
|
|
connectionGrants.length === 0 &&
|
|
connection.credentialPolicy === "shared" &&
|
|
(localTrusted || (!!userId && memberships.length > 0));
|
|
if (!availableGrants.length && !legacyShared) continue;
|
|
connectionCount += 1;
|
|
usableConnections.push({ id: connection.id, name: connection.name });
|
|
const actor: ActorInfo = {
|
|
actorType: "user",
|
|
actorId: userId ?? "board",
|
|
};
|
|
let failed = false;
|
|
for (const initialGrant of legacyShared ? [null] : availableGrants) {
|
|
try {
|
|
let rows: Array<{
|
|
id: string;
|
|
fullName: string;
|
|
private?: boolean;
|
|
}>;
|
|
if (
|
|
initialGrant &&
|
|
asRecord(asRecord(connection.config).oauth).connectorProfile ===
|
|
"github.code"
|
|
) {
|
|
const grant = await refreshManagedGitHubGrantAccess(
|
|
connection,
|
|
initialGrant,
|
|
actor,
|
|
);
|
|
rows = grant.providerTenant?.github?.repositories ?? [];
|
|
} else {
|
|
const headers = await measureRunPhase("connectors.github.credentials", () => initialGrant
|
|
? (async () => {
|
|
const ref = initialGrant.credentialSecretRefs.find(
|
|
(ref) =>
|
|
ref.configPath === "oauth.access_token" ||
|
|
/authorization|token|api_key/i.test(ref.configPath),
|
|
);
|
|
if (!ref)
|
|
throw unprocessable(
|
|
"Reconnect GitHub to load repositories",
|
|
);
|
|
const secret = await resolveOAuthGrantSecret(
|
|
connection,
|
|
initialGrant,
|
|
ref,
|
|
actor,
|
|
undefined,
|
|
);
|
|
return { Authorization: `Bearer ${secret.value}` };
|
|
})()
|
|
: resolveCredentialHeaders(connection, actor));
|
|
rows = await measureRunPhase("connectors.github.repositories", () => loadGitHubTokenRepositories(headers));
|
|
}
|
|
for (const row of rows) {
|
|
mergeProjectRepository(repositories, row, connection.name, connection.id);
|
|
}
|
|
} catch {
|
|
// Credential/provider errors may contain secrets. Only expose an
|
|
// aggregate failure; successful connections remain usable.
|
|
failed = true;
|
|
}
|
|
}
|
|
if (failed) failedConnectionCount += 1;
|
|
}
|
|
return {
|
|
repositories: [...repositories.values()].sort((a, b) =>
|
|
a.fullName.localeCompare(b.fullName),
|
|
),
|
|
connections: usableConnections,
|
|
connectionCount,
|
|
failedConnectionCount,
|
|
};
|
|
}),
|
|
|
|
listConnections: async (
|
|
companyId: string,
|
|
viewerUserId?: string,
|
|
): Promise<ToolConnection[]> => {
|
|
const rows = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.companyId, companyId))
|
|
.orderBy(desc(toolConnections.updatedAt));
|
|
const connections = rows.map(toConnection);
|
|
if (connections.length === 0) return connections;
|
|
const installRows = await db
|
|
.select()
|
|
.from(toolConnectionInstalls)
|
|
.where(eq(toolConnectionInstalls.companyId, companyId))
|
|
.orderBy(
|
|
asc(toolConnectionInstalls.targetType),
|
|
asc(toolConnectionInstalls.targetId),
|
|
);
|
|
const installsByConnection = new Map<string, ToolConnectionInstall[]>();
|
|
for (const row of installRows) {
|
|
const installs = installsByConnection.get(row.connectionId) ?? [];
|
|
installs.push(toConnectionInstall(row));
|
|
installsByConnection.set(row.connectionId, installs);
|
|
}
|
|
for (const connection of connections)
|
|
connection.installs = installsByConnection.get(connection.id) ?? [];
|
|
// Enrich with "last used" = most recent tool-call event per connection so the
|
|
// prosumer Apps list can surface a staleness signal without an N+1 fan-out.
|
|
const lastUsedRows = await db
|
|
.select({
|
|
connectionId: toolCallEvents.connectionId,
|
|
lastUsedAt: max(toolCallEvents.createdAt),
|
|
})
|
|
.from(toolCallEvents)
|
|
.where(
|
|
and(
|
|
eq(toolCallEvents.companyId, companyId),
|
|
inArray(
|
|
toolCallEvents.connectionId,
|
|
connections.map((connection) => connection.id),
|
|
),
|
|
),
|
|
)
|
|
.groupBy(toolCallEvents.connectionId);
|
|
const lastUsedByConnection = new Map(
|
|
lastUsedRows.map((row) => [row.connectionId, row.lastUsedAt]),
|
|
);
|
|
for (const connection of connections) {
|
|
connection.lastUsedAt = lastUsedByConnection.get(connection.id) ?? null;
|
|
}
|
|
await annotateAiGrantHealth(connections);
|
|
await annotateGitHubAuthorization(connections, viewerUserId);
|
|
await annotateSavedOAuthClientSecret(connections, viewerUserId);
|
|
return connections;
|
|
},
|
|
|
|
createConnection: async (
|
|
companyId: string,
|
|
input: CreateToolConnection,
|
|
actor?: ActorInfo,
|
|
): Promise<ToolConnection> => {
|
|
await actor?.managementContext?.reauthorize();
|
|
let applicationId = input.applicationId;
|
|
let applicationNamespace = input.applicationName ?? input.name;
|
|
const transport = input.transport;
|
|
if (!transport) throw badRequest("Tool connection transport is required");
|
|
const config = normalizeGoogleSheetsConnectionConfig(
|
|
input.config ?? input.transportConfig ?? {},
|
|
);
|
|
await assertConnectorConnectionSetupEnabled({ config, applicationId, companyId });
|
|
// Validate company-scoped references before touching a caller-supplied
|
|
// network endpoint. Besides failing fast, this keeps cross-company
|
|
// authorization errors from being masked by DNS or SSRF validation.
|
|
await assertSecretRefs(companyId, [
|
|
...(input.credentialRefs ?? []),
|
|
...(input.credentialSecretRefs ?? []),
|
|
]);
|
|
if (transport === "mcp_remote")
|
|
await assertRemoteConnectionEndpointsAllowed(config);
|
|
if (transport === "local_stdio") await stdioTemplateId(companyId, config);
|
|
assertLocalStdioCanBeEnabled(transport, input.enabled ?? false, config);
|
|
await assertGoogleSheetsSpreadsheetOwnership(companyId, config);
|
|
if (applicationId) {
|
|
const app = await assertApplication(companyId, applicationId);
|
|
applicationNamespace = app.applicationKey ?? app.name;
|
|
await assertConnectorConnectionSetupEnabled({ config }, applicationNamespace);
|
|
if (
|
|
(transport === "mcp_remote" && app.type !== "mcp_http") ||
|
|
(transport === "local_stdio" && app.type !== "mcp_stdio")
|
|
) {
|
|
throw unprocessable(
|
|
"Connection transport must match application type",
|
|
);
|
|
}
|
|
} else {
|
|
const [app] = await db
|
|
.insert(toolApplications)
|
|
.values({
|
|
companyId,
|
|
applicationKey: normalizeKey(input.applicationName ?? input.name),
|
|
name: input.applicationName ?? input.name,
|
|
type: isBrowserUseConnection({ transport, config }) || nativeProviderCatalogFor({ transport, config }) || restConnectorFor({ transport, config }) ? "rest_api" : transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
|
status: "active",
|
|
metadata: {},
|
|
})
|
|
.returning();
|
|
applicationId = app.id;
|
|
}
|
|
await actor?.managementContext?.reauthorize();
|
|
const connectionId = randomUUID();
|
|
const binding = actorBinding(actor);
|
|
const [row] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
id: connectionId,
|
|
companyId,
|
|
applicationId,
|
|
name: input.name,
|
|
uid: connectionUid(applicationNamespace, input.name, connectionId),
|
|
connectionKind: input.connectionKind ?? "managed",
|
|
ownership: input.ownership ?? "customer",
|
|
transport,
|
|
authKind: input.authKind ?? "none",
|
|
credentialPolicy:
|
|
input.credentialPolicy ??
|
|
(input.authKind === "oauth" ? "per_user" : "shared"),
|
|
status: input.status ?? "draft",
|
|
enabled: input.enabled ?? false,
|
|
agentInstructions: input.agentInstructions !== undefined ? input.agentInstructions : defaultConnectionAgentInstructions(
|
|
typeof config.sourceTemplateKey === "string" ? getConnectableAppDefinition(config.sourceTemplateKey)?.agentInstructions : null,
|
|
),
|
|
config,
|
|
transportConfig: isGoogleSheetsConnectionConfig(config)
|
|
? config
|
|
: (input.transportConfig ?? config),
|
|
credentialRefs: input.credentialRefs ?? [],
|
|
credentialSecretRefs: input.credentialSecretRefs ?? [],
|
|
createdByAgentId:
|
|
binding.actorType === "agent" ? binding.actorId : null,
|
|
createdByUserId: connectionActorUserId(actor),
|
|
})
|
|
.returning();
|
|
await ensureDefaultOrganizationGrant(row);
|
|
await syncCredentialBindings(row);
|
|
await ensureRuntimeSlot(row);
|
|
emitConnectionCreated(row, "api");
|
|
return toConnection(row);
|
|
},
|
|
|
|
getConnection: async (
|
|
connectionId: string,
|
|
companyId?: string,
|
|
viewerUserId?: string,
|
|
): Promise<ToolConnection> => {
|
|
const connection = toConnection(
|
|
await getConnectionRow(connectionId, companyId),
|
|
);
|
|
connection.installs = await listConnectionInstalls(
|
|
connection.id,
|
|
connection.companyId,
|
|
);
|
|
await annotateAiGrantHealth([connection]);
|
|
await annotateGitHubAuthorization([connection], viewerUserId);
|
|
await annotateSavedOAuthClientSecret([connection], viewerUserId);
|
|
return connection;
|
|
},
|
|
...connectionGrantsService,
|
|
...toolActivity,
|
|
|
|
listConnectionInstalls,
|
|
putConnectionInstalls: connectionInstalls.putConnectionInstalls,
|
|
|
|
updateConnection: async (
|
|
connectionId: string,
|
|
input: UpdateToolConnection,
|
|
managementContext?: ConnectionManagementContext,
|
|
): Promise<ToolConnection> => {
|
|
const existing = await getConnectionRow(connectionId);
|
|
if (existing.connectionPurpose === "ai" && (input.config || input.transportConfig || input.credentialRefs || input.credentialSecretRefs || (input.credentialPolicy && input.credentialPolicy !== existing.credentialPolicy))) throw badRequest("Use AI account reconnect to change credentials. Provider, sign-in method, and ownership cannot be changed.");
|
|
const config = normalizeGoogleSheetsConnectionConfig(
|
|
input.config ?? input.transportConfig ?? existing.config,
|
|
);
|
|
if (input.config?.methodConfig !== undefined && typeof config.sourceTemplateKey === "string") {
|
|
const app = getConnectableAppDefinition(config.sourceTemplateKey);
|
|
if (app) normalizeConnectionMethodConfig(connectionMethodForConnection(app, { ...existing, config }), asRecord(config.methodConfig));
|
|
}
|
|
if (existing.transport === "mcp_remote")
|
|
await assertRemoteConnectionEndpointsAllowed(config);
|
|
if (existing.transport === "local_stdio")
|
|
await stdioTemplateId(existing.companyId, config);
|
|
assertLocalStdioCanBeEnabled(
|
|
existing.transport,
|
|
input.enabled ?? existing.enabled,
|
|
config,
|
|
);
|
|
await assertGoogleSheetsSpreadsheetOwnership(existing.companyId, config, {
|
|
excludeConnectionId: existing.id,
|
|
});
|
|
await assertSecretRefs(existing.companyId, [
|
|
...(input.credentialRefs ?? existing.credentialRefs),
|
|
...(input.credentialSecretRefs ?? existing.credentialSecretRefs),
|
|
]);
|
|
await managementContext?.reauthorize();
|
|
const write = (database: Db) => database
|
|
.update(toolConnections)
|
|
.set({
|
|
name: input.name ?? existing.name,
|
|
status: input.status ?? existing.status,
|
|
enabled: input.enabled ?? existing.enabled,
|
|
...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}),
|
|
config,
|
|
transportConfig: isGoogleSheetsConnectionConfig(config)
|
|
? config
|
|
: (input.transportConfig ?? config),
|
|
credentialRefs: input.credentialRefs ?? existing.credentialRefs,
|
|
credentialSecretRefs:
|
|
input.credentialSecretRefs ?? existing.credentialSecretRefs,
|
|
credentialPolicy: input.credentialPolicy ?? existing.credentialPolicy,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, connectionId))
|
|
.returning();
|
|
const [row] = existing.transport === "chat_sdk"
|
|
? await db.transaction(async tx => {
|
|
await notifyChatEndpointWork(tx, existing.id);
|
|
const rows = await write(tx as unknown as Db);
|
|
// Credential resolution must never observe the binding rebuild's
|
|
// delete/insert gap after a configuration wake.
|
|
await syncCredentialBindings(rows[0]!, [], tx);
|
|
return rows;
|
|
})
|
|
: await write(db);
|
|
if (existing.transport !== "chat_sdk") await syncCredentialBindings(row);
|
|
await ensureRuntimeSlot(row);
|
|
emitConnectionUpdated(row, existing, "api");
|
|
return toConnection(row);
|
|
},
|
|
|
|
archiveConnection: removeConnection,
|
|
|
|
checkHealth: checkConnectionHealth,
|
|
|
|
refreshCatalog,
|
|
|
|
configureRailwaySsh: async (connectionId: string, companyId: string, input: ConfigureRailwaySsh, actor: ActorInfo): Promise<RailwaySshSetup | null> => {
|
|
await actor.managementContext?.reauthorize();
|
|
const knownHosts = input.action === "enable" ? validateRailwayKnownHosts(input.knownHosts) : "";
|
|
const setup = await db.transaction(async (tx) => {
|
|
const [grant] = await tx.select().from(connectionGrants).where(and(eq(connectionGrants.id, input.grantId), eq(connectionGrants.connectionId, connectionId), eq(connectionGrants.companyId, companyId))).for("update");
|
|
const [connection] = await tx.select().from(toolConnections).where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))).for("update");
|
|
if (!connection || !isRailwayConnection(connection) || (connection.status !== "active" && input.action !== "remove")) throw unprocessable("Connect Railway before configuring container access.");
|
|
if (!grant || (grant.status !== "active" && input.action !== "remove")) throw unprocessable("Select an active Railway authorization.");
|
|
if (grant.kind === "user" && connectionActorUserId(actor) !== grant.subjectUserId) throw forbidden("Only this authorization's owner can configure its SSH key.");
|
|
const existing = asRecord(connection.config.railwaySsh);
|
|
if (existing.grantId && existing.grantId !== grant.id) throw conflict("Remove the existing container key before choosing another authorization.");
|
|
const currentRef = grant.credentialSecretRefs.find((ref) => ref.configPath === RAILWAY_SSH_SECRET_PATH);
|
|
let next: RailwaySshSetup | null = null;
|
|
let refs = grant.credentialSecretRefs;
|
|
if (input.action === "remove") {
|
|
if (currentRef) await secretService(tx).remove(currentRef.secretId);
|
|
refs = refs.filter((ref) => ref.configPath !== RAILWAY_SSH_SECRET_PATH);
|
|
} else if (input.action === "prepare") {
|
|
if (currentRef && typeof existing.publicKey === "string") return existing as unknown as RailwaySshSetup;
|
|
const key = await generateRailwaySshKey();
|
|
const ref = await createOrRotateOAuthSecret({ companyId, connection, configPath: RAILWAY_SSH_SECRET_PATH, label: "Railway container SSH key", value: key.privateKey, existingRefs: [], ownerUserId: grant.kind === "user" ? grant.subjectUserId ?? undefined : undefined, actor }, { dbClient: tx, secretClient: secretService(tx) });
|
|
refs = [...refs.filter((ref) => ref.configPath !== RAILWAY_SSH_SECRET_PATH), ref];
|
|
next = { grantId: grant.id, publicKey: key.publicKey, knownHosts: "", enabled: false };
|
|
} else {
|
|
if (!currentRef || typeof existing.publicKey !== "string") throw unprocessable("Generate and register a container key first.");
|
|
next = { grantId: grant.id, publicKey: existing.publicKey, knownHosts, enabled: true };
|
|
}
|
|
await tx.update(connectionGrants).set({ credentialSecretRefs: refs, updatedAt: now() }).where(and(eq(connectionGrants.id, grant.id), eq(connectionGrants.companyId, companyId)));
|
|
const config = { ...connection.config, railwaySsh: next };
|
|
const [updated] = await tx.update(toolConnections).set({ config, transportConfig: config, updatedAt: now() }).where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))).returning();
|
|
await syncCredentialBindings(updated, [], tx);
|
|
return next;
|
|
});
|
|
await audit({ companyId, connectionId, action: "tool_connection.railway_ssh_updated", outcome: "success", actor, details: { action: input.action, grantId: input.grantId, enabled: setup?.enabled ?? false } });
|
|
return setup;
|
|
},
|
|
|
|
listAppsNeedingAttention,
|
|
|
|
sweepConnectionHealth,
|
|
|
|
sweepGitHubConnectionContinuity,
|
|
...catalogReader,
|
|
...toolActionReviewQueue,
|
|
...profileService,
|
|
...profileResolver,
|
|
mintConnectionTokenForAgent,
|
|
...runtimeSlots.api,
|
|
|
|
getRuntimeHealth: runtimeHealth,
|
|
|
|
previewMcpJsonImport: async (
|
|
input: ImportMcpJson,
|
|
): Promise<McpJsonImportPreview> => {
|
|
let raw: unknown;
|
|
try {
|
|
raw =
|
|
typeof input.mcpJson === "string"
|
|
? (JSON.parse(input.mcpJson) as unknown)
|
|
: input.mcpJson;
|
|
} catch {
|
|
throw badRequest("mcp.json must be valid JSON");
|
|
}
|
|
const mcpServers = asRecord(asRecord(raw).mcpServers);
|
|
const drafts = Object.entries(mcpServers).map(([name, rawServer]) => {
|
|
const server = asRecord(rawServer);
|
|
const warnings: string[] = [];
|
|
if (
|
|
typeof server.url === "string" ||
|
|
typeof server.endpoint === "string"
|
|
) {
|
|
const headers = asRecord(server.headers);
|
|
const credentialFields = Object.keys(headers)
|
|
.sort()
|
|
.map((key) => {
|
|
warnings.push(
|
|
`Header ${key} will be stored as a Paperclip secret before activation.`,
|
|
);
|
|
return {
|
|
configPath: `headers.${key}`,
|
|
label: key,
|
|
placement: "header" as const,
|
|
key,
|
|
prefix: null,
|
|
required: true,
|
|
};
|
|
});
|
|
return {
|
|
name,
|
|
transport: "mcp_remote" as const,
|
|
status: "draft" as const,
|
|
config: { url: server.url ?? server.endpoint },
|
|
credentialRefs: [] as McpConnectionCredentialRef[],
|
|
credentialFields,
|
|
warnings,
|
|
};
|
|
}
|
|
if (typeof server.command === "string") {
|
|
warnings.push(
|
|
"Imported stdio commands stay draft-only unless mapped to an approved Paperclip template.",
|
|
);
|
|
return {
|
|
name,
|
|
transport: "local_stdio" as const,
|
|
status: "draft" as const,
|
|
config: {
|
|
importedCommand: server.command,
|
|
importedArgs: Array.isArray(server.args) ? server.args : [],
|
|
},
|
|
credentialRefs: [],
|
|
credentialFields: [],
|
|
warnings,
|
|
};
|
|
}
|
|
warnings.push("Unsupported MCP server entry.");
|
|
return {
|
|
name,
|
|
transport: "mcp_remote" as const,
|
|
status: "draft" as const,
|
|
config: {},
|
|
credentialRefs: [],
|
|
credentialFields: [],
|
|
warnings,
|
|
};
|
|
});
|
|
if (drafts.length === 0)
|
|
throw badRequest("mcp.json must include an mcpServers object");
|
|
return { drafts };
|
|
},
|
|
|
|
assertConnectionCompany: async (
|
|
connectionId: string,
|
|
companyId: string,
|
|
) => {
|
|
const connection = await getConnectionRow(connectionId, companyId);
|
|
return toConnection(connection);
|
|
},
|
|
|
|
ensureNoDuplicateNameError: (error: unknown) => {
|
|
const maybeRecord =
|
|
typeof error === "object" && error !== null
|
|
? (error as Record<string, unknown>)
|
|
: null;
|
|
const cause = maybeRecord?.cause;
|
|
const maybeCause =
|
|
typeof cause === "object" && cause !== null
|
|
? (cause as Record<string, unknown>)
|
|
: null;
|
|
const message = [
|
|
error instanceof Error ? error.message : String(error),
|
|
maybeRecord && typeof maybeRecord.detail === "string"
|
|
? maybeRecord.detail
|
|
: null,
|
|
maybeCause instanceof Error ? maybeCause.message : null,
|
|
maybeCause && typeof maybeCause.detail === "string"
|
|
? maybeCause.detail
|
|
: null,
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
const code =
|
|
maybeRecord && typeof maybeRecord.code === "string"
|
|
? maybeRecord.code
|
|
: maybeCause && typeof maybeCause.code === "string"
|
|
? maybeCause.code
|
|
: null;
|
|
const constraint =
|
|
maybeRecord && typeof maybeRecord.constraint === "string"
|
|
? maybeRecord.constraint
|
|
: maybeRecord && typeof maybeRecord.constraint_name === "string"
|
|
? maybeRecord.constraint_name
|
|
: maybeCause && typeof maybeCause.constraint === "string"
|
|
? maybeCause.constraint
|
|
: maybeCause && typeof maybeCause.constraint_name === "string"
|
|
? maybeCause.constraint_name
|
|
: null;
|
|
if (
|
|
code === "23505" ||
|
|
constraint?.includes("tool_applications") ||
|
|
/duplicate key value|unique constraint|tool_applications_company_id_name_unique/i.test(
|
|
message,
|
|
)
|
|
) {
|
|
throw conflict("A tool access record with that name already exists", {
|
|
code: "tool_access_name_conflict",
|
|
});
|
|
}
|
|
throw error;
|
|
},
|
|
};
|
|
}
|