mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Recognize Slack's exact disabled-app response and return actionable setup instructions from catalog and health routes. Bound response parsing and keep unknown upstream errors reportable without exposing provider settings links. Verified 361 focused tests, server typecheck, and authenticated discovery. The three route regressions fail before this change and pass afterward. Co-Authored-By: Paperclip <noreply@paperclip.ing>
20286 lines
705 KiB
TypeScript
20286 lines
705 KiB
TypeScript
import { isRemoteMcpConnectorMethod, connectionPurposeTransportSchema } from "@paperclipai/shared";
|
|
import { instanceSettingsService } from "./instance-settings.js";
|
|
import { githubBotRequest } from "./chat-github-client.js";
|
|
import { syncConnectionCredentialBindings } from "./connection-credential-bindings.js";
|
|
import { canBrowseProjectRepositoryGrant, mergeProjectRepository } from "./project-repositories.js";
|
|
import { captureRunIdentity } from "./run-identity.js";
|
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import {
|
|
and,
|
|
asc,
|
|
desc,
|
|
eq,
|
|
gte,
|
|
inArray,
|
|
isNotNull,
|
|
isNull,
|
|
lt,
|
|
lte,
|
|
max,
|
|
ne,
|
|
or,
|
|
sql,
|
|
} from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
agents,
|
|
connectionGrantMembers,
|
|
connectionGrantDelegations,
|
|
connectionGrants,
|
|
connectionTokenIssuances,
|
|
authUsers,
|
|
companies,
|
|
companyMemberships,
|
|
companySecretBindings,
|
|
companySecrets,
|
|
principalPermissionGrants,
|
|
userSecretDefinitions,
|
|
heartbeatRuns,
|
|
issues,
|
|
issueThreadInteractions,
|
|
plugins,
|
|
projects,
|
|
routines,
|
|
toolAccessAuditEvents,
|
|
toolApplications,
|
|
toolActionRequests,
|
|
toolCatalogEntries,
|
|
toolConnectionInstalls,
|
|
toolConnections,
|
|
toolOauthStates,
|
|
toolStdioCommandTemplates,
|
|
toolCallEvents,
|
|
toolInvocations,
|
|
toolPolicies,
|
|
toolGatewaySessions,
|
|
toolMcpGateways,
|
|
toolMcpGatewayTokens,
|
|
toolProfileBindings,
|
|
toolProfileEntries,
|
|
toolProfiles,
|
|
toolRuntimeMetricCounters,
|
|
toolRuntimeSlots,
|
|
} from "@paperclipai/db";
|
|
import type {
|
|
AppDefinition,
|
|
ConnectionGrantKind,
|
|
ConnectionMethodDef,
|
|
ConnectionTokenIssuanceOutcome,
|
|
ConnectionTokenIssuancePath,
|
|
ConnectionTokenRequest,
|
|
ConnectionTokenResponse,
|
|
CreateToolApplication,
|
|
CreateToolConnection,
|
|
ConnectToolApp,
|
|
ConnectToolAppResult,
|
|
CreateToolStdioCommandTemplate,
|
|
FinishToolApp,
|
|
FinishToolAppResult,
|
|
FinalizeOAuthAccess,
|
|
CreateToolProfileBindingForProfile,
|
|
CreateToolProfileEntryForProfile,
|
|
CreateToolProfileWithEntries,
|
|
DeleteToolProfile,
|
|
DeploymentExposure,
|
|
DeploymentMode,
|
|
DuplicateToolProfile,
|
|
ImportMcpJson,
|
|
McpConnectionCredentialRef,
|
|
McpJsonImportPreview,
|
|
ToolApplication,
|
|
ToolCatalogEntry,
|
|
ToolCatalogRefreshResult,
|
|
ToolConnection,
|
|
ToolConnectionInstall,
|
|
ToolConnectionInstallSnapshot,
|
|
ToolConnectionRemovalResult,
|
|
ToolConnectionRemovalSummary,
|
|
ToolConnectionHealthCheckResult,
|
|
ToolConnectionHealthStatus,
|
|
ToolConnectionAuthKind,
|
|
ToolConnectionCredentialPolicy,
|
|
ToolConnectionCredentialSource,
|
|
ToolConnectionTransport,
|
|
ToolCredentialSecretRef,
|
|
ToolOAuthStartResult,
|
|
ToolAppsAttentionResponse,
|
|
ToolAppMetadataPreflightResult,
|
|
ToolActionRequest,
|
|
ToolActionRequestListItem,
|
|
ToolActionRequestStatus,
|
|
ToolConnectionActivityResponse,
|
|
ToolAppConnectionActionSummary,
|
|
ToolExampleInstallResult,
|
|
ToolExampleSmokeCheck,
|
|
ToolExampleSmokeResult,
|
|
ToolExampleSummary,
|
|
ToolCallEvent,
|
|
ToolInvocation,
|
|
ToolProfile,
|
|
ToolProfileBinding,
|
|
ToolProfileEffectiveSummary,
|
|
ToolProfileEntry,
|
|
ToolProfileNewToolReviewItem,
|
|
ToolProfileNewToolsReview,
|
|
ToolProfileNewToolsReviewResult,
|
|
ToolProfileSummary,
|
|
ToolProfileWithDetails,
|
|
ToolPolicyDecision,
|
|
ToolPolicy,
|
|
ToolRiskLevel,
|
|
ToolRuntimeAlertRecommendation,
|
|
ToolRuntimeHealthSummary,
|
|
ToolRunDecision,
|
|
ToolRunDecisionLookup,
|
|
ToolRuntimeSlot,
|
|
ToolStdioCommandTemplate,
|
|
ReviewToolProfileNewTools,
|
|
UpdateToolApplication,
|
|
UpdateToolConnection,
|
|
PutToolConnectionInstalls,
|
|
UpdateToolProfileEntry,
|
|
UpdateToolProfileWithEntries,
|
|
UnbindToolProfileBinding,
|
|
VercelConnectCredentialReference,
|
|
VercelConnectGrantReference,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
CLASS3_STATIC_LEASE_ALLOWLIST,
|
|
GITHUB_CONNECTOR_PROFILES,
|
|
GOOGLE_WORKSPACE_CONNECTOR_PROFILES,
|
|
connectionIntentPayloadSchema,
|
|
credentialConfigPath,
|
|
getAppDefinitionForUrl,
|
|
getAvailableConnectionMethod,
|
|
getAvailableConnectionMethods,
|
|
getConnectableAppDefinition,
|
|
isGitHubConnectorProfileId,
|
|
isGoogleWorkspaceConnectorProfileId,
|
|
isToolConnectionAttentionHealth,
|
|
recommendedDefaultsForApp,
|
|
resolveConnectionMethodServerUrl,
|
|
type GitHubConnectorProfileId,
|
|
type GoogleWorkspaceConnectorProfileId,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
checkMcpRemoteHeaderName,
|
|
checkMcpRemoteHeaderValue,
|
|
mcpRemoteHeaderNameFromConfigPath,
|
|
mcpRemoteHeaderRejectionMessage,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
checkOAuthEndpointUrl,
|
|
oauthEndpointUrlRejectionMessage,
|
|
type OAuthEndpointKind,
|
|
type OAuthEndpointUrlRejection,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
badRequest,
|
|
conflict,
|
|
forbidden,
|
|
HttpError,
|
|
notFound,
|
|
unprocessable,
|
|
} from "../errors.js";
|
|
import { isUniqueViolation } from "../db-errors.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { logActivity } from "./activity-log.js";
|
|
import { isSlackMcpAccessDisabledResponse } from "./slack-mcp-error.js";
|
|
import {
|
|
initializeMcpHttpSession,
|
|
McpHttpInitializationError,
|
|
getMcpHttpSession,
|
|
forgetMcpHttpSessions,
|
|
readMcpHttpResponse,
|
|
mcpHttpRequestHeaders,
|
|
parseMcpHttpResponseBody,
|
|
} from "./mcp-http.js";
|
|
import {
|
|
assertPublicRemoteHttpEndpoint,
|
|
parseRemoteHttpEndpoint,
|
|
type RemoteHttpEndpointLookup,
|
|
} from "./remote-http-endpoint-guard.js";
|
|
import {
|
|
guardedRemoteHttpFetch,
|
|
type GuardedRemoteHttpFetchOptions,
|
|
} from "./remote-http-fetch.js";
|
|
import {
|
|
REMOTE_URL_SECRET_CONFIG_PATH,
|
|
remoteUrlCredentialMatchesPublicUrl,
|
|
splitRemoteUrlCredential,
|
|
} from "./remote-url-credentials.js";
|
|
import { secretService } from "./secrets.js";
|
|
import { agentmailApi } from "./agentmail-api.js";
|
|
import type { ConfigureRailwaySsh, RailwaySshSetup } from "@paperclipai/shared";
|
|
import { generateRailwaySshKey, RAILWAY_SSH_SECRET_PATH, validateRailwayKnownHosts } from "./railway-ssh.js";
|
|
import { createRailwayClient, discoverRailwayWorkspace, isRailwayConnection, isRailwayEndpoint, isRailwayToolBlocked, normalizeRailwayToolName, RAILWAY_TOOLS, RAILWAY_TOOL_PREFIX, railwayRisk, RailwayError } from "./railway.js";
|
|
import { toolAccessPolicyService } from "./tool-access-policy.js";
|
|
import {
|
|
readSignedToolArgumentsPayload,
|
|
TOOL_ACTION_REQUEST_SIGNING_GRACE_MS,
|
|
} from "./tool-content-guards.js";
|
|
import {
|
|
effectiveToolProfileBindings,
|
|
narrowestScopeBindings,
|
|
profileIdsInBindingOrder,
|
|
} from "./tool-profile-binding-precedence.js";
|
|
import {
|
|
recordToolRuntimeAuditWriteFailure,
|
|
TOOL_RUNTIME_AUDIT_WRITE_FAILURE_METRIC,
|
|
} from "./tool-runtime-metrics.js";
|
|
import {
|
|
createToolRuntimeSupervisor,
|
|
ToolRuntimeSupervisorError,
|
|
} from "./tool-runtime-supervisor.js";
|
|
import { listConnectionLifecycleEvents } from "./tool-connection-activity.js";
|
|
import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE } from "@paperclipai/shared";
|
|
import {
|
|
appWithPaperclipCloudConnectorAvailability,
|
|
paperclipCloudConnectorCapabilitiesFromEnv,
|
|
createPaperclipCloudConnector,
|
|
isPaperclipCloudConnectorStrategy,
|
|
paperclipCloudConnectorConfigFromEnv,
|
|
PaperclipCloudConnectorError,
|
|
type PaperclipCloudConnector,
|
|
} from "./paperclip-cloud-connector.js";
|
|
import {
|
|
createVercelConnectClient,
|
|
deriveVercelConnectSubject,
|
|
vercelConnectCallbackUrl,
|
|
vercelConnectIntegrationStatus,
|
|
vercelGrantReference,
|
|
vercelTokenRequest,
|
|
VercelConnectClientError,
|
|
type VercelConnectClient,
|
|
} from "./vercel-connect.js";
|
|
|
|
type ActorInfo = {
|
|
actorType?: "agent" | "user" | "system" | "plugin";
|
|
actorId?: string | null;
|
|
sessionId?: string | null;
|
|
actorSource?:
|
|
| "local_implicit"
|
|
| "session"
|
|
| "board_key"
|
|
| "agent_key"
|
|
| "agent_jwt"
|
|
| "cloud_tenant";
|
|
};
|
|
|
|
const ACTIVE_BROKER_RUN_STATUSES = new Set(["running"]);
|
|
const REMOTE_HTTP_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
const MAX_REMOTE_HTTP_REDIRECTS = 5;
|
|
const MAX_OAUTH_DCR_CLIENT_ID_LENGTH = 4_096;
|
|
const MAX_OAUTH_DCR_CLIENT_SECRET_LENGTH = 16_384;
|
|
const OAUTH_REFRESH_LEASE_MS = 120_000;
|
|
const OAUTH_REFRESH_LEASE_WAIT_MS = 30_000;
|
|
const OAUTH_REFRESH_LEASE_POLL_MS = 25;
|
|
|
|
/**
|
|
* Upstream OAuth error redaction (PAP-17108).
|
|
*
|
|
* A generic remote MCP connection points at an arbitrary authorization server,
|
|
* so everything that server says about a failure is attacker-chosen: `error`,
|
|
* `error_description`, `error_uri`, and the response body. Paperclip surfaces
|
|
* connection failures to the operator through API responses, board UI copy,
|
|
* audit rows and logs, so reflecting any of that text would let a hostile
|
|
* provider plant secrets, ANSI escapes, or instructions ("paste your recovery
|
|
* key here") into Paperclip's own voice.
|
|
*
|
|
* The rule is therefore: the operator only ever reads text Paperclip authored.
|
|
* The provider's `error` code survives — as a *label* in structured `details`,
|
|
* never in a message — and only when it is one of the codes the RFCs define,
|
|
* because a label is still untrusted input. Everything else is dropped, and an
|
|
* unrecognized code collapses to `unrecognized` rather than being echoed.
|
|
*
|
|
* `error_description` and the response body are never read at all: no call site
|
|
* below parses them, which is what keeps a future edit from quietly
|
|
* reintroducing the reflection.
|
|
*/
|
|
const OAUTH_PROVIDER_ERROR_CODES = new Set([
|
|
// RFC 6749 §4.1.2.1 — authorization endpoint (the callback-denial path).
|
|
"access_denied",
|
|
"invalid_request",
|
|
"invalid_scope",
|
|
"server_error",
|
|
"temporarily_unavailable",
|
|
"unauthorized_client",
|
|
"unsupported_response_type",
|
|
// RFC 6749 §5.2 — token endpoint (authorization-code and refresh exchanges).
|
|
"invalid_client",
|
|
"invalid_grant",
|
|
"unsupported_grant_type",
|
|
// RFC 7591 §3.2.2 — dynamic client registration.
|
|
"invalid_client_metadata",
|
|
"invalid_redirect_uri",
|
|
"invalid_software_statement",
|
|
"unapproved_software_statement",
|
|
// OpenID Connect Core §3.1.2.6 — interactive re-authentication prompts.
|
|
"account_selection_required",
|
|
"consent_required",
|
|
"interaction_required",
|
|
"login_required",
|
|
]);
|
|
|
|
/** What an `error` that is absent, malformed, or off the allowlist becomes. */
|
|
const UNRECOGNIZED_OAUTH_PROVIDER_ERROR = "unrecognized";
|
|
const MAX_OAUTH_PROVIDER_ERROR_LENGTH = 64;
|
|
const OAUTH_PROVIDER_ERROR_PATTERN = /^[a-z0-9_-]+$/;
|
|
|
|
/**
|
|
* Stable, Paperclip-authored operator copy for each allowlisted provider error.
|
|
* Deliberately keyed on the code alone: the calling context is already carried
|
|
* by the Paperclip `code` in `details`, so one table serves the callback,
|
|
* token-exchange and registration paths without any of them composing a message
|
|
* out of provider text.
|
|
*/
|
|
const OAUTH_PROVIDER_ERROR_MESSAGES: Record<string, string> = {
|
|
access_denied: "The authorization server denied the request.",
|
|
account_selection_required:
|
|
"The authorization server needs an account to be selected. Try connecting again.",
|
|
consent_required:
|
|
"The authorization server needs consent to be granted. Try connecting again.",
|
|
interaction_required:
|
|
"The authorization server needs to be signed in to interactively. Try connecting again.",
|
|
invalid_client: "The authorization server rejected Paperclip's OAuth client.",
|
|
invalid_client_metadata:
|
|
"The authorization server rejected Paperclip's client registration details.",
|
|
invalid_grant:
|
|
"The authorization server rejected the authorization code or refresh token.",
|
|
invalid_redirect_uri:
|
|
"The authorization server rejected Paperclip's callback URL.",
|
|
invalid_request:
|
|
"The authorization server rejected the request as malformed.",
|
|
invalid_scope: "The authorization server rejected the requested permissions.",
|
|
invalid_software_statement:
|
|
"The authorization server rejected Paperclip's client registration details.",
|
|
login_required:
|
|
"The authorization server needs to be signed in to. Try connecting again.",
|
|
server_error:
|
|
"The authorization server reported an internal error. Try again shortly.",
|
|
temporarily_unavailable:
|
|
"The authorization server is temporarily unavailable. Try again shortly.",
|
|
unapproved_software_statement:
|
|
"The authorization server rejected Paperclip's client registration details.",
|
|
unauthorized_client:
|
|
"The authorization server refused to authorize Paperclip's OAuth client.",
|
|
unsupported_grant_type:
|
|
"The authorization server does not support the grant Paperclip uses.",
|
|
unsupported_response_type:
|
|
"The authorization server does not support the sign-in flow Paperclip uses.",
|
|
};
|
|
|
|
/**
|
|
* Reduce a provider-supplied `error` to a bounded, allowlisted label safe to
|
|
* keep in structured `details`. Returns `null` only when the provider sent no
|
|
* `error` at all, so the caller can tell "silent failure" from "said something
|
|
* Paperclip does not recognize".
|
|
*/
|
|
function normalizeOAuthProviderError(value: unknown): string | null {
|
|
if (typeof value !== "string" || value.length === 0) return null;
|
|
// Bound length and character class before the allowlist even though
|
|
// membership implies both: these limits are what keeps the label safe if the
|
|
// allowlist above ever grows a pattern-matched entry.
|
|
if (value.length > MAX_OAUTH_PROVIDER_ERROR_LENGTH)
|
|
return UNRECOGNIZED_OAUTH_PROVIDER_ERROR;
|
|
if (!OAUTH_PROVIDER_ERROR_PATTERN.test(value))
|
|
return UNRECOGNIZED_OAUTH_PROVIDER_ERROR;
|
|
return OAUTH_PROVIDER_ERROR_CODES.has(value)
|
|
? value
|
|
: UNRECOGNIZED_OAUTH_PROVIDER_ERROR;
|
|
}
|
|
|
|
/** Paperclip's own message for a provider failure, never the provider's. */
|
|
function oauthProviderErrorMessage(
|
|
providerError: string | null,
|
|
fallback: string,
|
|
): string {
|
|
if (!providerError) return fallback;
|
|
return OAUTH_PROVIDER_ERROR_MESSAGES[providerError] ?? fallback;
|
|
}
|
|
|
|
/**
|
|
* Where this deployment publishes its Client ID Metadata Document. The document's
|
|
* own URL is the `client_id` Paperclip presents, so this path is a stable part of
|
|
* the deployment's public contract with every authorization server that has seen
|
|
* it — changing it invalidates existing CIMD registrations.
|
|
*/
|
|
export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH =
|
|
"/api/tools/oauth/client-metadata";
|
|
|
|
/**
|
|
* Resolve the URL Paperclip would use as a CIMD client id, but only when its
|
|
* hostname is not known to resolve into a private network.
|
|
*
|
|
* An authorization server fetches this URL from outside Paperclip's network and
|
|
* will normally apply an SSRF guard. Tailscale/MagicDNS names are HTTPS but
|
|
* resolve into 100.64.0.0/10, so presenting one as a client id can only produce
|
|
* an `invalid_client` response. A local DNS failure remains inconclusive because
|
|
* split-horizon public DNS may still let the authorization server resolve it.
|
|
*/
|
|
export async function resolveOAuthClientIdMetadataDocumentUrl(
|
|
redirectUri: string,
|
|
lookup?: RemoteHttpEndpointLookup,
|
|
): Promise<string | null> {
|
|
try {
|
|
const parsed = new URL(redirectUri);
|
|
if (parsed.protocol !== "https:") return null;
|
|
const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
const isLoopback =
|
|
hostname === "localhost" ||
|
|
hostname.endsWith(".localhost") ||
|
|
hostname === "::1" ||
|
|
/^127(?:\.\d{1,3}){3}$/.test(hostname);
|
|
if (isLoopback) return null;
|
|
const metadataUrl = new URL(
|
|
OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH,
|
|
parsed.origin,
|
|
).toString();
|
|
try {
|
|
await assertPublicRemoteHttpEndpoint(
|
|
new URL(metadataUrl),
|
|
{ allowPrivateNetwork: false, lookup },
|
|
(message, code) => Object.assign(new Error(message), { code }),
|
|
);
|
|
} catch (error) {
|
|
if (
|
|
error instanceof Error &&
|
|
"code" in error &&
|
|
error.code === "remote_http_private_endpoint"
|
|
) {
|
|
return null;
|
|
}
|
|
}
|
|
return metadataUrl;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Paperclip's client metadata for CIMD (RFC 7591 metadata, served rather than
|
|
* registered). Only the callback for this deployment appears in it, so an
|
|
* authorization server that fetches it can see exactly one legal redirect target.
|
|
*/
|
|
export function oauthClientIdMetadataDocument(input: {
|
|
clientId: string;
|
|
redirectUri: string;
|
|
}): Record<string, unknown> {
|
|
return {
|
|
client_id: input.clientId,
|
|
client_name: `Paperclip (${new URL(input.redirectUri).host})`,
|
|
client_uri: new URL("/", input.clientId).toString(),
|
|
redirect_uris: [input.redirectUri],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
application_type: "web",
|
|
};
|
|
}
|
|
|
|
type OAuthProviderEndpoints = {
|
|
provider: string;
|
|
scopes: string[];
|
|
authorizationUrl: string;
|
|
tokenUrl: string;
|
|
registrationUrl?: string | null;
|
|
codeChallengeMethodsSupported?: string[];
|
|
tokenEndpointAuthMethodsSupported?: string[];
|
|
grantTypesSupported?: string[];
|
|
grantType?: "authorization_code" | "client_credentials";
|
|
metadataUrl?: string | null;
|
|
/**
|
|
* Canonical authorization-server issuer, when discovery found one. Registered
|
|
* client material is bound to it, `iss` on the callback is validated against
|
|
* it, and reconnect/refresh reuse it instead of re-deriving a provider key.
|
|
*/
|
|
issuer?: string | null;
|
|
/**
|
|
* RFC 8707 resource indicator: the MCP endpoint the token is for. Sent on both
|
|
* authorization and token requests so the authorization server can audience-
|
|
* restrict the access token to this server rather than to everything Paperclip
|
|
* has ever connected.
|
|
*/
|
|
resource?: string | null;
|
|
/** The authorization server advertised support for Client ID Metadata Documents. */
|
|
clientIdMetadataDocumentSupported?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Where an OAuth client came from, in the preference order the current MCP
|
|
* client-registration guidance recommends (PAP-17087).
|
|
*/
|
|
type OAuthClientRegistrationSource =
|
|
"preconfigured" | "cimd" | "dcr" | "manual";
|
|
|
|
/**
|
|
* RFC 8414 §3.1 requires the well-known path to be *inserted between* the
|
|
* issuer host and its path component, but a large amount of deployed software
|
|
* only serves the naive suffix form. OpenID Connect Discovery 1.0 in turn
|
|
* specifies the suffix form. Try all of them for an issuer that has a path, and
|
|
* the plain origin form when it doesn't.
|
|
*/
|
|
function wellKnownMetadataUrls(issuer: string): string[] {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(issuer);
|
|
} catch {
|
|
return [];
|
|
}
|
|
const suffixes = ["oauth-authorization-server", "openid-configuration"];
|
|
const path = parsed.pathname.replace(/\/+$/, "");
|
|
const urls: string[] = [];
|
|
for (const suffix of suffixes) {
|
|
if (path) {
|
|
// RFC 8414: https://host/.well-known/<suffix><path>
|
|
urls.push(
|
|
new URL(`/.well-known/${suffix}${path}`, parsed.origin).toString(),
|
|
);
|
|
// OIDC Discovery / widely deployed: https://host<path>/.well-known/<suffix>
|
|
urls.push(
|
|
new URL(`${path}/.well-known/${suffix}`, parsed.origin).toString(),
|
|
);
|
|
}
|
|
urls.push(new URL(`/.well-known/${suffix}`, parsed.origin).toString());
|
|
}
|
|
return [...new Set(urls)];
|
|
}
|
|
|
|
/**
|
|
* Protected-resource metadata lives at `/.well-known/oauth-protected-resource`
|
|
* with the resource's path appended (RFC 9728 §3.1). Probe the path-aware form
|
|
* first so a multi-tenant host that serves several MCP servers resolves to the
|
|
* right one, then fall back to the origin form.
|
|
*/
|
|
function protectedResourceMetadataUrls(endpoint: URL): string[] {
|
|
const path = endpoint.pathname.replace(/\/+$/, "");
|
|
const urls: string[] = [];
|
|
if (path)
|
|
urls.push(
|
|
new URL(
|
|
`/.well-known/oauth-protected-resource${path}`,
|
|
endpoint.origin,
|
|
).toString(),
|
|
);
|
|
urls.push(
|
|
new URL(
|
|
"/.well-known/oauth-protected-resource",
|
|
endpoint.origin,
|
|
).toString(),
|
|
);
|
|
return [...new Set(urls)];
|
|
}
|
|
|
|
/**
|
|
* Canonical RFC 8707 resource indicator for an MCP endpoint: origin + path, with
|
|
* query, fragment and any trailing slash removed. Vendor query parameters belong
|
|
* to the connection config, not to the token audience.
|
|
*/
|
|
function canonicalResourceIndicator(endpoint: string): string | null {
|
|
try {
|
|
const parsed = new URL(endpoint);
|
|
const path = parsed.pathname.replace(/\/+$/, "");
|
|
return `${parsed.origin}${path}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Two issuers are the same authorization server only when scheme, host, port and
|
|
* path match exactly (a trailing slash is not significant). Used for `iss`
|
|
* validation on the callback and for detecting that a stored registration is
|
|
* bound to a different server than the one we just discovered.
|
|
*/
|
|
function sameOAuthIssuer(
|
|
a: string | null | undefined,
|
|
b: string | null | undefined,
|
|
): boolean {
|
|
if (!a || !b) return false;
|
|
try {
|
|
const left = new URL(a);
|
|
const right = new URL(b);
|
|
return (
|
|
left.protocol === right.protocol &&
|
|
left.host === right.host &&
|
|
left.pathname.replace(/\/+$/, "") === right.pathname.replace(/\/+$/, "")
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const oauthRegistrationFlights = new Map<string, Promise<unknown>>();
|
|
|
|
async function singleFlight<T>(
|
|
flights: Map<string, Promise<unknown>>,
|
|
key: string,
|
|
operation: () => Promise<T>,
|
|
): Promise<T> {
|
|
const existing = flights.get(key) as Promise<T> | undefined;
|
|
if (existing) return existing;
|
|
const pending = operation();
|
|
flights.set(key, pending);
|
|
try {
|
|
return await pending;
|
|
} finally {
|
|
if (flights.get(key) === pending) flights.delete(key);
|
|
}
|
|
}
|
|
|
|
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>;
|
|
/** 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;
|
|
};
|
|
|
|
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
type ToolAccessMutationDb = Pick<
|
|
Db | DbTransaction,
|
|
"select" | "insert" | "update" | "delete"
|
|
>;
|
|
|
|
export type McpToolDescriptor = {
|
|
name: string;
|
|
title?: string | null;
|
|
description?: string | null;
|
|
inputSchema?: Record<string, unknown>;
|
|
annotations?: Record<string, unknown>;
|
|
};
|
|
|
|
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.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";
|
|
const CONNECTION_TOKEN_MINT_TOOL_NAME = "connection_token.mint";
|
|
|
|
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 connectionMethodFor(app: AppDefinition, methodKey?: string | null) {
|
|
const normalizedMethodKey =
|
|
app.slug === "gmail" && methodKey === "paperclip-id-oauth"
|
|
? "paperclip-draft"
|
|
: methodKey;
|
|
// Stored managed connections must remain recognizable for callback, refresh,
|
|
// and revoke even though static definitions omit instance availability. New
|
|
// setup passes a definition filtered by signed profiles before reaching here;
|
|
// the broker independently enforces availability on authorization and refresh.
|
|
const availableMethods = new Set(getAvailableConnectionMethods(app));
|
|
const toolMethods = app.methods.filter(
|
|
(candidate) =>
|
|
candidate.purpose !== "channel" && candidate.transport !== "chat_sdk"
|
|
&& (availableMethods.has(candidate) || isPaperclipCloudConnectorStrategy(candidate.oauthStrategy)),
|
|
);
|
|
const method = normalizedMethodKey
|
|
? (toolMethods.find((candidate) => candidate.key === normalizedMethodKey) ??
|
|
null)
|
|
: getAvailableConnectionMethod({ ...app, methods: toolMethods }, null);
|
|
if (!method)
|
|
throw unprocessable(
|
|
"This app does not have an available connection method",
|
|
);
|
|
return method;
|
|
}
|
|
|
|
function connectionMethodForConnection(
|
|
app: AppDefinition,
|
|
connection: typeof toolConnections.$inferSelect,
|
|
) {
|
|
const methodKey =
|
|
typeof connection.config.connectionMethodKey === "string"
|
|
? connection.config.connectionMethodKey
|
|
: null;
|
|
return connectionMethodFor(app, methodKey);
|
|
}
|
|
|
|
function credentialFieldsFor(app: AppDefinition, methodKey?: string | null) {
|
|
const method = connectionMethodFor(app, methodKey);
|
|
return (method.credentialFields ?? []).map((field) => ({
|
|
label: field.label,
|
|
configPath: credentialConfigPath(field),
|
|
helpUrl: method.consoleLinks?.keys ?? method.consoleLinks?.docs ?? "",
|
|
required: field.required,
|
|
placement:
|
|
method.keyPlacement?.location === "header"
|
|
? ("header" as const)
|
|
: undefined,
|
|
key: method.keyPlacement?.name,
|
|
prefix: method.keyPlacement?.prefix,
|
|
}));
|
|
}
|
|
|
|
function credentialRefConfigPath(ref: { name: string }): string {
|
|
return ref.name.startsWith("credentials.")
|
|
? ref.name
|
|
: `credentials.${ref.name}`;
|
|
}
|
|
|
|
export function normalizeConnectionMethodConfig(
|
|
method: ConnectionMethodDef,
|
|
configValues: Record<string, unknown> | undefined,
|
|
): {
|
|
values: Record<string, string | boolean>;
|
|
url?: string;
|
|
headers?: Record<string, string>;
|
|
} {
|
|
const fields = [
|
|
...(method.tenantFields ?? []),
|
|
...(method.extensionFields ?? []),
|
|
];
|
|
const allowedKeys = new Set(fields.map((field) => field.key));
|
|
for (const key of Object.keys(configValues ?? {})) {
|
|
if (!allowedKeys.has(key))
|
|
throw badRequest(`Unknown connection setting: ${key}`);
|
|
}
|
|
|
|
const values: Record<string, string | boolean> = {};
|
|
for (const field of fields) {
|
|
const raw = configValues?.[field.key] ?? field.defaultValue;
|
|
if (field.type === "checkbox") {
|
|
if (raw !== undefined && typeof raw !== "boolean")
|
|
throw badRequest(`${field.label} must be true or false`);
|
|
if (raw !== undefined) values[field.key] = raw;
|
|
continue;
|
|
}
|
|
if (raw !== undefined && typeof raw !== "string")
|
|
throw badRequest(`${field.label} must be text`);
|
|
let value = raw?.trim() ?? "";
|
|
if (field.transport?.format === "csv") {
|
|
value = Array.from(
|
|
new Set(
|
|
value
|
|
.split(/[\n,]/g)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean),
|
|
),
|
|
).join(",");
|
|
}
|
|
if (field.required && !value)
|
|
throw badRequest(`Missing connection setting: ${field.label}`);
|
|
if (!value) continue;
|
|
if (
|
|
field.validation?.maxLength &&
|
|
value.length > field.validation.maxLength
|
|
) {
|
|
throw badRequest(
|
|
`${field.label} must be at most ${field.validation.maxLength} characters`,
|
|
);
|
|
}
|
|
if (
|
|
field.validation?.pattern &&
|
|
!new RegExp(field.validation.pattern).test(value)
|
|
) {
|
|
throw badRequest(`${field.label} has an invalid value`);
|
|
}
|
|
if (
|
|
field.type === "select" &&
|
|
!field.options?.some((option) => option.value === value)
|
|
) {
|
|
throw badRequest(`${field.label} has an invalid option`);
|
|
}
|
|
values[field.key] = value;
|
|
}
|
|
for (const keys of method.configRequirements?.atLeastOneOf
|
|
? [method.configRequirements.atLeastOneOf]
|
|
: []) {
|
|
if (
|
|
!keys.some(
|
|
(key) => typeof values[key] === "string" && values[key].length > 0,
|
|
)
|
|
) {
|
|
throw badRequest(`Provide at least one of: ${keys.join(", ")}`);
|
|
}
|
|
}
|
|
|
|
const resolvedServerUrl = resolveConnectionMethodServerUrl(method, values);
|
|
if (method.defaults?.serverUrlTemplate && !resolvedServerUrl) {
|
|
throw badRequest(
|
|
"Missing or invalid connection settings for the server URL",
|
|
);
|
|
}
|
|
const endpoint = resolvedServerUrl ? new URL(resolvedServerUrl) : null;
|
|
if (endpoint && endpoint.protocol !== "https:") {
|
|
throw badRequest("Connection server URL must use HTTPS");
|
|
}
|
|
const headers: Record<string, string> = {};
|
|
for (const field of fields) {
|
|
const transport = field.transport;
|
|
const value = values[field.key];
|
|
if (
|
|
!transport ||
|
|
value === undefined ||
|
|
(value === false && transport.omitFalse)
|
|
)
|
|
continue;
|
|
const serialized = typeof value === "boolean" ? String(value) : value;
|
|
if (transport.location === "query")
|
|
endpoint?.searchParams.set(transport.name, serialized);
|
|
else {
|
|
const nameCheck = checkMcpRemoteHeaderName(transport.name);
|
|
const valueCheck = checkMcpRemoteHeaderValue(serialized);
|
|
if (!nameCheck.ok || !valueCheck.ok) {
|
|
throw badRequest(
|
|
mcpRemoteHeaderRejectionMessage(
|
|
transport.name,
|
|
nameCheck.reason ?? valueCheck.reason!,
|
|
),
|
|
);
|
|
}
|
|
headers[transport.name] = serialized;
|
|
}
|
|
}
|
|
return {
|
|
values,
|
|
...(endpoint ? { url: endpoint.toString() } : {}),
|
|
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
};
|
|
}
|
|
|
|
export function projectedConnectionHeaders(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): Record<string, string> {
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const app = sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)
|
|
: null;
|
|
const headers: Record<string, string> = {};
|
|
if (app) {
|
|
const method = connectionMethodForConnection(app, connection);
|
|
Object.assign(headers, normalizeConnectionMethodConfig(
|
|
method,
|
|
asRecord(connection.config.methodConfig),
|
|
).headers);
|
|
}
|
|
if (
|
|
connection.transport === "mcp_remote" &&
|
|
(sourceTemplateKey === "github" || connection.transportConfig?.sourceTemplateKey === "github")
|
|
) {
|
|
// GitHub excludes Actions from its default catalog. Project this on every
|
|
// discovery and invocation so existing connections gain the toolset too.
|
|
headers["X-MCP-Toolsets"] = "default,actions";
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function mergeManagedToolArguments(
|
|
supplied: Record<string, unknown>,
|
|
managed: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const merged = { ...supplied };
|
|
for (const [key, value] of Object.entries(managed)) {
|
|
const suppliedValue = merged[key];
|
|
merged[key] =
|
|
asRecord(value) === value && asRecord(suppliedValue) === suppliedValue
|
|
? mergeManagedToolArguments(
|
|
suppliedValue as Record<string, unknown>,
|
|
value as Record<string, unknown>,
|
|
)
|
|
: value;
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
export function projectConnectionMethodToolArguments(
|
|
method: ConnectionMethodDef,
|
|
parameters: unknown,
|
|
): Record<string, unknown> {
|
|
const supplied = asRecord(parameters);
|
|
const managed = method.defaults?.toolArgumentDefaults;
|
|
return managed ? mergeManagedToolArguments(supplied, managed) : supplied;
|
|
}
|
|
|
|
function stripManagedToolArgumentSchema(
|
|
schema: Record<string, unknown>,
|
|
managed: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const properties = asRecord(schema.properties);
|
|
if (Object.keys(properties).length === 0) return schema;
|
|
const nextProperties = { ...properties };
|
|
for (const [key, managedValue] of Object.entries(managed)) {
|
|
const propertySchema = asRecord(nextProperties[key]);
|
|
const managedRecord = asRecord(managedValue);
|
|
if (
|
|
Object.keys(propertySchema).length === 0 ||
|
|
Object.keys(managedRecord).length === 0
|
|
) {
|
|
delete nextProperties[key];
|
|
continue;
|
|
}
|
|
const projectedProperty = stripManagedToolArgumentSchema(
|
|
propertySchema,
|
|
managedRecord,
|
|
);
|
|
if (Object.keys(asRecord(projectedProperty.properties)).length === 0)
|
|
delete nextProperties[key];
|
|
else nextProperties[key] = projectedProperty;
|
|
}
|
|
const nextSchema: Record<string, unknown> = {
|
|
...schema,
|
|
properties: nextProperties,
|
|
};
|
|
if (Array.isArray(schema.required)) {
|
|
const required = schema.required.filter(
|
|
(key): key is string => typeof key === "string" && key in nextProperties,
|
|
);
|
|
if (required.length > 0) nextSchema.required = required;
|
|
else delete nextSchema.required;
|
|
}
|
|
return nextSchema;
|
|
}
|
|
|
|
export function projectConnectionMethodToolInputSchema(
|
|
method: ConnectionMethodDef,
|
|
inputSchema: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const managed = method.defaults?.toolArgumentDefaults;
|
|
return managed
|
|
? stripManagedToolArgumentSchema(inputSchema, managed)
|
|
: inputSchema;
|
|
}
|
|
|
|
export function projectedConnectionToolArguments(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
parameters: unknown,
|
|
): Record<string, unknown> {
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const app = sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)
|
|
: null;
|
|
if (!app) return asRecord(parameters);
|
|
return projectConnectionMethodToolArguments(
|
|
connectionMethodForConnection(app, connection),
|
|
parameters,
|
|
);
|
|
}
|
|
|
|
export function projectedConnectionToolInputSchema(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
inputSchema: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const app = sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)
|
|
: null;
|
|
if (!app) return inputSchema;
|
|
return projectConnectionMethodToolInputSchema(
|
|
connectionMethodForConnection(app, connection),
|
|
inputSchema,
|
|
);
|
|
}
|
|
|
|
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 numberValue(value: unknown): number | null {
|
|
const parsed = typeof value === "number" ? value : Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function percent(numerator: number, denominator: number): number {
|
|
if (denominator <= 0) return 0;
|
|
return Math.round((numerator / denominator) * 1000) / 10;
|
|
}
|
|
|
|
function percentile(values: number[], p: number): number | null {
|
|
if (values.length === 0) return null;
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
const index = Math.min(
|
|
sorted.length - 1,
|
|
Math.max(0, Math.ceil((p / 100) * sorted.length) - 1),
|
|
);
|
|
return sorted[index] ?? null;
|
|
}
|
|
|
|
function normalizeKey(input: string) {
|
|
return (
|
|
input
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._:-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 160) || "tool"
|
|
);
|
|
}
|
|
|
|
function connectionUid(namespace: string, name: string, connectionId: string) {
|
|
return `${normalizeKey(namespace)}/${normalizeKey(name)}-${connectionId.slice(0, 8)}`;
|
|
}
|
|
|
|
/**
|
|
* The key namespace `connectGalleryApp`, `reconnectGalleryApp` and
|
|
* `createOrRotateOAuthSecret` mint for credentials a connection owns outright.
|
|
* Nothing else writes this prefix, which is what lets removal tell a dedicated
|
|
* app credential apart from a secret the operator manages by hand — see
|
|
* `classifyConnectionSecrets`. Matched as a prefix on purpose: `secrets.remove`
|
|
* suffixes `__deleted__<id>` onto the key before it deletes the row, so a
|
|
* removal that is retried after a provider failure must still recognise it.
|
|
*/
|
|
const CONNECTION_OWNED_SECRET_KEY_PREFIX = "tool_app.";
|
|
|
|
/** Token fields a legacy row may have inlined into `config.oauth`. */
|
|
const INLINE_OAUTH_TOKEN_FIELDS = [
|
|
"access_token",
|
|
"refresh_token",
|
|
"accessToken",
|
|
"refreshToken",
|
|
"client_secret",
|
|
"clientSecret",
|
|
];
|
|
|
|
/**
|
|
* Drop inline OAuth token material from a connection config, keeping the
|
|
* non-secret identity (client id, issuer, registration source) a later
|
|
* reconnect reuses. Current code stores tokens as secret refs, never in the
|
|
* config; this exists so a row written by an older build cannot keep a usable
|
|
* token after the operator removed the app.
|
|
*/
|
|
function withoutInlineOAuthTokens(
|
|
config: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const oauth = config.oauth;
|
|
if (!oauth || typeof oauth !== "object" || Array.isArray(oauth))
|
|
return config;
|
|
const next = { ...(oauth as Record<string, unknown>) };
|
|
let changed = false;
|
|
for (const field of INLINE_OAUTH_TOKEN_FIELDS) {
|
|
if (field in next) {
|
|
delete next[field];
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed ? { ...config, oauth: next } : config;
|
|
}
|
|
|
|
function actorBinding(actor: ActorInfo | undefined) {
|
|
return {
|
|
actorType: actor?.actorType ?? null,
|
|
actorId: actor?.actorId ?? null,
|
|
sessionId:
|
|
typeof actor?.sessionId === "string" && actor.sessionId.trim().length > 0
|
|
? actor.sessionId
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function oauthActorType(value: string | null): ActorInfo["actorType"] | null {
|
|
return value === "agent" ||
|
|
value === "user" ||
|
|
value === "system" ||
|
|
value === "plugin"
|
|
? value
|
|
: null;
|
|
}
|
|
|
|
function assertSameOAuthActor(
|
|
stateRow: typeof toolOauthStates.$inferSelect,
|
|
actor: ActorInfo | undefined,
|
|
) {
|
|
const expected = {
|
|
actorType: oauthActorType(stateRow.createdByActorType),
|
|
actorId: stateRow.createdByActorId,
|
|
sessionId: stateRow.createdBySessionId,
|
|
};
|
|
const actual = actorBinding(actor);
|
|
if (!expected.actorType || !expected.actorId) {
|
|
throw forbidden(
|
|
"OAuth sign-in state is not bound to an authenticated board session",
|
|
);
|
|
}
|
|
if (
|
|
expected.actorType !== actual.actorType ||
|
|
expected.actorId !== actual.actorId
|
|
) {
|
|
throw forbidden(
|
|
"OAuth sign-in must be completed by the user who started it",
|
|
);
|
|
}
|
|
if (expected.sessionId && expected.sessionId !== actual.sessionId) {
|
|
throw forbidden(
|
|
"OAuth sign-in must be completed from the same authenticated session",
|
|
);
|
|
}
|
|
}
|
|
|
|
function toApplication(
|
|
row: typeof toolApplications.$inferSelect,
|
|
): ToolApplication {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
applicationKey: row.applicationKey ?? undefined,
|
|
name: row.name,
|
|
description: row.description,
|
|
type: row.type,
|
|
status: row.status,
|
|
pluginId: row.pluginId,
|
|
ownerAgentId: row.ownerAgentId,
|
|
ownerUserId: row.ownerUserId,
|
|
metadata: row.metadata ?? null,
|
|
archivedAt: row.archivedAt,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
function assertClass3ToolCredentialRefAllowed(ref: {
|
|
configPath?: string | null;
|
|
projectionClass?: string | null;
|
|
projectionAllowlistKey?: string | null;
|
|
}) {
|
|
const projectionClass = ref.projectionClass ?? "unclassified";
|
|
if (projectionClass !== "class_3_static_lease") return;
|
|
if (!ref.configPath?.trim() || !ref.projectionAllowlistKey?.trim()) {
|
|
throw unprocessable(
|
|
"Class-3 static lease tool credentials require an allowlist key and config path",
|
|
{
|
|
code: "class_3_static_lease_allowlist_required",
|
|
targetType: "tool_connection",
|
|
configPath: ref.configPath ?? null,
|
|
},
|
|
);
|
|
}
|
|
const allowed = CLASS3_STATIC_LEASE_ALLOWLIST.some(
|
|
(entry) =>
|
|
entry.key === ref.projectionAllowlistKey &&
|
|
entry.targetType === "tool_connection" &&
|
|
entry.configPath === ref.configPath,
|
|
);
|
|
if (!allowed) {
|
|
throw unprocessable(
|
|
"Class-3 static lease tool credential is outside the approved allowlist",
|
|
{
|
|
code: "class_3_static_lease_not_allowed",
|
|
allowlistKey: ref.projectionAllowlistKey,
|
|
targetType: "tool_connection",
|
|
configPath: ref.configPath,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
function toConnection(row: typeof toolConnections.$inferSelect): ToolConnection {
|
|
connectionPurposeTransportSchema.parse(row);
|
|
const retired = isRetiredComposioConnection(row);
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
applicationId: row.applicationId,
|
|
name: row.name,
|
|
uid: row.uid,
|
|
connectionKind: row.connectionKind,
|
|
connectionPurpose: row.connectionPurpose,
|
|
ownership: row.ownership,
|
|
transport: row.transport,
|
|
authKind: row.authKind,
|
|
credentialSource: row.credentialSource,
|
|
externalCredential: row.externalCredential ?? null,
|
|
credentialPolicy: row.credentialPolicy,
|
|
status: row.status,
|
|
enabled: row.enabled && !retired,
|
|
config: row.config ?? {},
|
|
transportConfig: row.transportConfig ?? {},
|
|
credentialRefs: row.credentialRefs ?? [],
|
|
credentialSecretRefs: row.credentialSecretRefs ?? [],
|
|
healthStatus: retired ? "error" : row.healthStatus,
|
|
healthMessage: retired ? RETIRED_COMPOSIO_MESSAGE : row.healthMessage,
|
|
healthCheckedAt: row.healthCheckedAt,
|
|
lastHealthAt: row.lastHealthAt,
|
|
lastCatalogRefreshAt: row.lastCatalogRefreshAt,
|
|
lastError: row.lastError,
|
|
createdByAgentId: row.createdByAgentId,
|
|
createdByUserId: row.createdByUserId,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
function redactedVercelGrant(
|
|
value: VercelConnectGrantReference | null | undefined,
|
|
): Omit<VercelConnectGrantReference, "subjectId"> | null {
|
|
if (!value) return null;
|
|
const { subjectId: _subjectId, ...redacted } = value;
|
|
return redacted;
|
|
}
|
|
|
|
function toConnectionGrant(row: typeof connectionGrants.$inferSelect) {
|
|
return {
|
|
...row,
|
|
externalCredential: redactedVercelGrant(row.externalCredential),
|
|
};
|
|
}
|
|
|
|
function toConnectionInstall(
|
|
row: typeof toolConnectionInstalls.$inferSelect,
|
|
): ToolConnectionInstall {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
connectionId: row.connectionId,
|
|
targetType: row.targetType,
|
|
targetId: row.targetId,
|
|
createdByAgentId: row.createdByAgentId,
|
|
createdByUserId: row.createdByUserId,
|
|
createdAt: row.createdAt,
|
|
};
|
|
}
|
|
|
|
function toCatalogEntry(
|
|
row: typeof toolCatalogEntries.$inferSelect,
|
|
): ToolCatalogEntry {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
applicationId: row.applicationId,
|
|
connectionId: row.connectionId,
|
|
entryKind: row.entryKind,
|
|
name: row.name,
|
|
toolName: row.toolName,
|
|
title: row.title,
|
|
description: row.description,
|
|
inputSchema: row.inputSchema ?? {},
|
|
outputSchema: row.outputSchema ?? null,
|
|
annotations: row.annotations ?? {},
|
|
riskLevel: row.riskLevel,
|
|
isReadOnly: row.isReadOnly,
|
|
isWrite: row.isWrite,
|
|
isDestructive: row.isDestructive,
|
|
status: row.status,
|
|
addedAt: row.firstSeenAt,
|
|
version: row.version,
|
|
versionHash: row.versionHash,
|
|
schemaHash: row.schemaHash,
|
|
firstSeenAt: row.firstSeenAt,
|
|
lastSeenAt: row.lastSeenAt,
|
|
reviewedAt: row.reviewedAt,
|
|
reviewedByAgentId: row.reviewedByAgentId,
|
|
reviewedByUserId: row.reviewedByUserId,
|
|
quarantinedAt: row.quarantinedAt,
|
|
quarantineReason: row.quarantineReason,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
function toCatalogEntryForConnection(
|
|
row: typeof toolCatalogEntries.$inferSelect,
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): ToolCatalogEntry {
|
|
const rawCatalogEntry = toCatalogEntry(row);
|
|
const catalogEntry = {
|
|
...rawCatalogEntry,
|
|
inputSchema: projectedConnectionToolInputSchema(
|
|
connection,
|
|
rawCatalogEntry.inputSchema ?? {},
|
|
),
|
|
};
|
|
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 toRuntimeSlot(
|
|
row: typeof toolRuntimeSlots.$inferSelect,
|
|
): ToolRuntimeSlot {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
applicationId: row.applicationId,
|
|
connectionId: row.connectionId,
|
|
projectWorkspaceId: row.projectWorkspaceId,
|
|
executionWorkspaceId: row.executionWorkspaceId,
|
|
issueId: row.issueId,
|
|
ownerScopeType: row.ownerScopeType,
|
|
ownerScopeId: row.ownerScopeId,
|
|
runtimeKind: row.runtimeKind,
|
|
slotKey: row.slotKey,
|
|
status: row.status,
|
|
reuseKey: row.reuseKey,
|
|
workspaceScope: row.workspaceScope,
|
|
credentialScopeHash: row.credentialScopeHash,
|
|
provider: row.provider,
|
|
providerRef: row.providerRef,
|
|
processId: row.processId,
|
|
commandTemplateKey: row.commandTemplateKey,
|
|
healthStatus: row.healthStatus,
|
|
healthMessage: row.healthMessage,
|
|
lastHealthCheckAt: row.lastHealthCheckAt,
|
|
lastStartedAt: row.lastStartedAt,
|
|
startedAt: row.startedAt,
|
|
stoppedAt: row.stoppedAt,
|
|
lastUsedAt: row.lastUsedAt,
|
|
idleExpiresAt: row.idleExpiresAt,
|
|
idleDeadlineAt: row.idleDeadlineAt,
|
|
lastError: row.lastError,
|
|
metadata: row.metadata ?? null,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
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 toToolInvocation(
|
|
row: typeof toolInvocations.$inferSelect,
|
|
): ToolInvocation {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
idempotencyKey: row.idempotencyKey,
|
|
actorType: row.actorType as ToolInvocation["actorType"],
|
|
actorId: row.actorId,
|
|
agentId: row.agentId,
|
|
issueId: row.issueId,
|
|
runId: row.runId,
|
|
applicationId: row.applicationId,
|
|
connectionId: row.connectionId,
|
|
catalogEntryId: row.catalogEntryId,
|
|
toolName: row.toolName,
|
|
argumentsHash: row.argumentsHash,
|
|
argumentsSummary: row.argumentsSummary ?? null,
|
|
policyDecision: row.policyDecision,
|
|
matchedPolicyIds: row.matchedPolicyIds,
|
|
approvalState: row.approvalState,
|
|
status: row.status,
|
|
upstreamRequestId: row.upstreamRequestId,
|
|
resultHash: row.resultHash,
|
|
resultSummary: row.resultSummary ?? null,
|
|
resultSizeBytes: row.resultSizeBytes,
|
|
resultArtifactId: row.resultArtifactId,
|
|
errorCode: row.errorCode,
|
|
errorMessage: row.errorMessage,
|
|
startedAt: row.startedAt,
|
|
completedAt: row.completedAt,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
function toToolActionRequest(
|
|
row: typeof toolActionRequests.$inferSelect,
|
|
): ToolActionRequest {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
invocationId: row.invocationId,
|
|
issueId: row.issueId,
|
|
interactionId: row.interactionId,
|
|
approvalId: row.approvalId,
|
|
status: row.status,
|
|
canonicalArgumentsHash: row.canonicalArgumentsHash,
|
|
canonicalArgumentsSummary: row.canonicalArgumentsSummary,
|
|
signedArguments: row.signedArguments,
|
|
previewMarkdown: row.previewMarkdown,
|
|
requestedByAgentId: row.requestedByAgentId,
|
|
requestedByUserId: row.requestedByUserId,
|
|
resolvedByAgentId: row.resolvedByAgentId,
|
|
resolvedByUserId: row.resolvedByUserId,
|
|
decidedByAgentId: row.decidedByAgentId,
|
|
decidedByUserId: row.decidedByUserId,
|
|
decidedAt: row.decidedAt,
|
|
expiresAt: row.expiresAt,
|
|
resolvedAt: row.resolvedAt,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
function toToolCallEvent(
|
|
row: typeof toolCallEvents.$inferSelect,
|
|
): ToolCallEvent {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
eventType: row.eventType,
|
|
actorType: row.actorType as ToolCallEvent["actorType"],
|
|
actorId: row.actorId,
|
|
agentId: row.agentId,
|
|
runId: row.runId,
|
|
issueId: row.issueId,
|
|
applicationId: row.applicationId,
|
|
connectionId: row.connectionId,
|
|
catalogEntryId: row.catalogEntryId,
|
|
invocationId: row.invocationId,
|
|
actionRequestId: row.actionRequestId,
|
|
runtimeSlotId: row.runtimeSlotId,
|
|
toolName: row.toolName,
|
|
decision: row.decision,
|
|
matchedPolicyIds: row.matchedPolicyIds,
|
|
reasonCode: row.reasonCode,
|
|
outcome: row.outcome,
|
|
latencyMs: row.latencyMs,
|
|
argumentsSummary: row.argumentsSummary ?? null,
|
|
requestHash: row.requestHash,
|
|
requestSummary: row.requestSummary ?? null,
|
|
resultHash: row.resultHash,
|
|
resultSummary: row.resultSummary ?? null,
|
|
resultSizeBytes: row.resultSizeBytes,
|
|
redactionPlan: row.redactionPlan ?? null,
|
|
rateLimitState: row.rateLimitState ?? null,
|
|
metadata: row.metadata ?? null,
|
|
errorCode: row.errorCode,
|
|
errorMessage: row.errorMessage,
|
|
createdAt: row.createdAt,
|
|
};
|
|
}
|
|
|
|
function userFallbackName(userId: string): string {
|
|
if (userId === "local-board") return "Board";
|
|
return userId;
|
|
}
|
|
|
|
function denialReasonForDecision(
|
|
invocation: typeof toolInvocations.$inferSelect,
|
|
latestAuditEvent: typeof toolCallEvents.$inferSelect | null,
|
|
) {
|
|
if (
|
|
invocation.status === "denied" ||
|
|
invocation.status === "rate_limited" ||
|
|
invocation.status === "failed" ||
|
|
invocation.status === "timed_out"
|
|
) {
|
|
return (
|
|
invocation.errorMessage ??
|
|
invocation.errorCode ??
|
|
latestAuditEvent?.reasonCode ??
|
|
null
|
|
);
|
|
}
|
|
if (
|
|
latestAuditEvent?.outcome === "denied" ||
|
|
latestAuditEvent?.outcome === "failure" ||
|
|
latestAuditEvent?.outcome === "timeout"
|
|
) {
|
|
return latestAuditEvent.errorMessage ?? latestAuditEvent.reasonCode ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function toProfile(row: typeof toolProfiles.$inferSelect): ToolProfile {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
profileKey: row.profileKey,
|
|
name: row.name,
|
|
description: row.description,
|
|
status: row.status,
|
|
defaultAction: row.defaultAction,
|
|
newToolsReviewedAt: row.newToolsReviewedAt,
|
|
metadata: row.metadata ?? null,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
function toProfileEntry(
|
|
row: typeof toolProfileEntries.$inferSelect,
|
|
): ToolProfileEntry {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
profileId: row.profileId,
|
|
selectorType: row.selectorType,
|
|
effect: row.effect,
|
|
applicationId: row.applicationId,
|
|
connectionId: row.connectionId,
|
|
catalogEntryId: row.catalogEntryId,
|
|
toolName: row.toolName,
|
|
riskLevel: row.riskLevel,
|
|
conditions: row.conditions ?? null,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
function toProfileBinding(
|
|
row: typeof toolProfileBindings.$inferSelect,
|
|
): ToolProfileBinding {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
profileId: row.profileId,
|
|
targetType: row.targetType,
|
|
targetId: row.targetId,
|
|
priority: row.priority,
|
|
metadata: row.metadata ?? null,
|
|
createdByAgentId: row.createdByAgentId,
|
|
createdByUserId: row.createdByUserId,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
function toPolicy(row: typeof toolPolicies.$inferSelect): ToolPolicy {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
name: row.name,
|
|
description: row.description,
|
|
policyType: row.policyType,
|
|
priority: row.priority,
|
|
enabled: row.enabled,
|
|
selectors: row.selectors ?? {},
|
|
conditions: row.conditions ?? null,
|
|
config: row.config ?? null,
|
|
createdByAgentId: row.createdByAgentId,
|
|
createdByUserId: row.createdByUserId,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
function profileEntryMatchesCatalog(
|
|
entry: typeof toolProfileEntries.$inferSelect,
|
|
catalogEntry: typeof toolCatalogEntries.$inferSelect,
|
|
): boolean {
|
|
if (entry.selectorType === "application")
|
|
return entry.applicationId === catalogEntry.applicationId;
|
|
if (entry.selectorType === "connection")
|
|
return entry.connectionId === catalogEntry.connectionId;
|
|
if (entry.selectorType === "catalog_entry")
|
|
return entry.catalogEntryId === catalogEntry.id;
|
|
if (entry.selectorType === "tool_name")
|
|
return entry.toolName === catalogEntry.toolName;
|
|
if (entry.selectorType === "risk_level")
|
|
return entry.riskLevel === catalogEntry.riskLevel;
|
|
return false;
|
|
}
|
|
|
|
function summarizeProfile(input: {
|
|
profile: typeof toolProfiles.$inferSelect;
|
|
entries: Array<typeof toolProfileEntries.$inferSelect>;
|
|
bindings: Array<typeof toolProfileBindings.$inferSelect>;
|
|
catalog: Array<typeof toolCatalogEntries.$inferSelect>;
|
|
agentIds: string[];
|
|
}): ToolProfileSummary {
|
|
const includes = input.entries.filter((entry) => entry.effect === "include");
|
|
const excludes = input.entries.filter((entry) => entry.effect === "exclude");
|
|
const allowedCatalogIds = new Set<string>();
|
|
const allowedApplicationIds = new Set<string>();
|
|
const excludedCatalogIds = new Set<string>();
|
|
|
|
for (const catalogEntry of input.catalog) {
|
|
const excluded = excludes.some((entry) =>
|
|
profileEntryMatchesCatalog(entry, catalogEntry),
|
|
);
|
|
if (excluded) excludedCatalogIds.add(catalogEntry.id);
|
|
if (excluded) continue;
|
|
const included = includes.some((entry) =>
|
|
profileEntryMatchesCatalog(entry, catalogEntry),
|
|
);
|
|
if (input.profile.defaultAction === "allow" || included) {
|
|
allowedCatalogIds.add(catalogEntry.id);
|
|
if (catalogEntry.applicationId)
|
|
allowedApplicationIds.add(catalogEntry.applicationId);
|
|
}
|
|
}
|
|
|
|
const isCompanyDefault = input.bindings.some(
|
|
(binding) =>
|
|
binding.targetType === "company" &&
|
|
binding.targetId === input.profile.companyId,
|
|
);
|
|
const appliesToAgents = new Set<string>();
|
|
if (isCompanyDefault) {
|
|
for (const agentId of input.agentIds) appliesToAgents.add(agentId);
|
|
} else {
|
|
const companyAgentIds = new Set(input.agentIds);
|
|
for (const binding of input.bindings) {
|
|
if (
|
|
binding.targetType === "agent" &&
|
|
companyAgentIds.has(binding.targetId)
|
|
) {
|
|
appliesToAgents.add(binding.targetId);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
accessMode:
|
|
input.profile.defaultAction === "allow" ? "all_except" : "selected",
|
|
allowedToolCount: allowedCatalogIds.size,
|
|
allowedApplicationCount: allowedApplicationIds.size,
|
|
excludedToolCount: excludedCatalogIds.size,
|
|
totalToolCount: input.catalog.length,
|
|
assignmentCount: input.bindings.length,
|
|
appliesToAgentCount: appliesToAgents.size,
|
|
isCompanyDefault,
|
|
};
|
|
}
|
|
|
|
function profileCoversCatalogScope(input: {
|
|
entry: typeof toolProfileEntries.$inferSelect;
|
|
catalogEntry: typeof toolCatalogEntries.$inferSelect;
|
|
catalogById: Map<string, typeof toolCatalogEntries.$inferSelect>;
|
|
}): boolean {
|
|
if (input.entry.effect !== "include") return false;
|
|
if (input.entry.selectorType === "application")
|
|
return input.entry.applicationId === input.catalogEntry.applicationId;
|
|
if (input.entry.selectorType === "connection")
|
|
return input.entry.connectionId === input.catalogEntry.connectionId;
|
|
if (
|
|
input.entry.selectorType !== "catalog_entry" ||
|
|
!input.entry.catalogEntryId
|
|
)
|
|
return false;
|
|
const scopedEntry = input.catalogById.get(input.entry.catalogEntryId);
|
|
if (!scopedEntry) return false;
|
|
if (scopedEntry.connectionId === input.catalogEntry.connectionId) return true;
|
|
return Boolean(
|
|
scopedEntry.applicationId &&
|
|
scopedEntry.applicationId === input.catalogEntry.applicationId,
|
|
);
|
|
}
|
|
|
|
function pendingNewToolsForProfile(input: {
|
|
profile: typeof toolProfiles.$inferSelect;
|
|
entries: Array<typeof toolProfileEntries.$inferSelect>;
|
|
catalog: Array<typeof toolCatalogEntries.$inferSelect>;
|
|
applicationsById?: Map<string, typeof toolApplications.$inferSelect>;
|
|
connectionsById?: Map<string, typeof toolConnections.$inferSelect>;
|
|
}): ToolProfileNewToolReviewItem[] {
|
|
if (
|
|
input.profile.status !== "active" ||
|
|
input.profile.defaultAction !== "deny"
|
|
)
|
|
return [];
|
|
const watermark = input.profile.newToolsReviewedAt ?? input.profile.createdAt;
|
|
const catalogById = new Map(input.catalog.map((entry) => [entry.id, entry]));
|
|
const scopedIncludes = input.entries.filter(
|
|
(entry) =>
|
|
entry.effect === "include" &&
|
|
(entry.selectorType === "application" ||
|
|
entry.selectorType === "connection" ||
|
|
entry.selectorType === "catalog_entry"),
|
|
);
|
|
if (scopedIncludes.length === 0) return [];
|
|
|
|
return input.catalog
|
|
.filter(
|
|
(catalogEntry) =>
|
|
catalogEntry.status === "active" ||
|
|
catalogEntry.status === "quarantined",
|
|
)
|
|
.filter((catalogEntry) => catalogEntry.firstSeenAt > watermark)
|
|
.filter((catalogEntry) =>
|
|
scopedIncludes.some((entry) =>
|
|
profileCoversCatalogScope({ entry, catalogEntry, catalogById }),
|
|
),
|
|
)
|
|
.filter(
|
|
(catalogEntry) =>
|
|
!input.entries.some((entry) =>
|
|
profileEntryMatchesCatalog(entry, catalogEntry),
|
|
),
|
|
)
|
|
.map((catalogEntry) => ({
|
|
catalogEntryId: catalogEntry.id,
|
|
applicationId: catalogEntry.applicationId,
|
|
applicationName: catalogEntry.applicationId
|
|
? (input.applicationsById?.get(catalogEntry.applicationId)?.name ??
|
|
null)
|
|
: null,
|
|
connectionId: catalogEntry.connectionId,
|
|
connectionName:
|
|
input.connectionsById?.get(catalogEntry.connectionId)?.name ?? null,
|
|
toolName: catalogEntry.toolName,
|
|
title: catalogEntry.title,
|
|
description: catalogEntry.description,
|
|
capability: catalogEntry.riskLevel,
|
|
riskLevel: catalogEntry.riskLevel,
|
|
addedAt: catalogEntry.firstSeenAt,
|
|
firstSeenAt: catalogEntry.firstSeenAt,
|
|
}));
|
|
}
|
|
|
|
function buildProfileDetails(input: {
|
|
profile: typeof toolProfiles.$inferSelect;
|
|
entries: Array<typeof toolProfileEntries.$inferSelect>;
|
|
bindings: Array<typeof toolProfileBindings.$inferSelect>;
|
|
catalog: Array<typeof toolCatalogEntries.$inferSelect>;
|
|
agentIds: string[];
|
|
applicationsById?: Map<string, typeof toolApplications.$inferSelect>;
|
|
connectionsById?: Map<string, typeof toolConnections.$inferSelect>;
|
|
}): ToolProfileWithDetails {
|
|
const pendingNewTools = pendingNewToolsForProfile({
|
|
profile: input.profile,
|
|
entries: input.entries,
|
|
catalog: input.catalog,
|
|
applicationsById: input.applicationsById,
|
|
connectionsById: input.connectionsById,
|
|
});
|
|
return {
|
|
...toProfile(input.profile),
|
|
newToolsPendingCount: pendingNewTools.length,
|
|
entries: input.entries.map(toProfileEntry),
|
|
bindings: input.bindings.map(toProfileBinding),
|
|
summary: summarizeProfile(input),
|
|
};
|
|
}
|
|
|
|
function stableHash(value: unknown): string {
|
|
return createHash("sha256")
|
|
.update(JSON.stringify(value, Object.keys(flattenKeys(value)).sort()))
|
|
.digest("hex");
|
|
}
|
|
|
|
function connectionSetupMutationFingerprint(
|
|
row: typeof toolConnections.$inferSelect,
|
|
): string {
|
|
return stableHash({
|
|
name: row.name,
|
|
transport: row.transport,
|
|
status: row.status,
|
|
enabled: row.enabled,
|
|
config: row.config,
|
|
transportConfig: row.transportConfig,
|
|
credentialRefs: row.credentialRefs,
|
|
credentialSecretRefs: row.credentialSecretRefs,
|
|
credentialSource: row.credentialSource,
|
|
externalCredential: row.externalCredential,
|
|
credentialPolicy: row.credentialPolicy,
|
|
});
|
|
}
|
|
|
|
function flattenKeys(
|
|
value: unknown,
|
|
keys: Record<string, true> = {},
|
|
): Record<string, true> {
|
|
if (value && typeof value === "object") {
|
|
for (const [key, nested] of Object.entries(
|
|
value as Record<string, unknown>,
|
|
)) {
|
|
keys[key] = true;
|
|
flattenKeys(nested, keys);
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
function normalizeToolDescriptor(tool: unknown): McpToolDescriptor | null {
|
|
const record = asRecord(tool);
|
|
if (typeof record.name !== "string" || record.name.trim().length === 0)
|
|
return null;
|
|
return {
|
|
name: record.name.trim(),
|
|
title: typeof record.title === "string" ? record.title : null,
|
|
description:
|
|
typeof record.description === "string" ? record.description : null,
|
|
inputSchema: asRecord(record.inputSchema ?? record.input_schema),
|
|
annotations: asRecord(record.annotations),
|
|
};
|
|
}
|
|
|
|
// Match a verb anywhere it forms a name segment, not just at the leading edge.
|
|
// Real MCP servers namespace and style tool names many ways:
|
|
// "github:create_issue", "notion:update_page", "slack:postMessage", "set_value".
|
|
// A leading-anchor regex (/^(create|...)/) misses every namespaced/camelCase
|
|
// form and silently classifies writes as read-only. We normalise camelCase to
|
|
// snake_case first so "postMessage" -> "post_message", then match the verb when
|
|
// it is delimiter- or word-bounded. This mirrors the gateway classifier in
|
|
// tool-gateway.ts (inferToolRisk) so the two stay consistent.
|
|
function verbMatches(toolName: string, verbs: string): boolean {
|
|
const normalized = toolName
|
|
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
.toLowerCase();
|
|
return new RegExp(`\\b(${verbs})\\b|(^|[:._-])(${verbs})([:._-]|$)`).test(
|
|
normalized,
|
|
);
|
|
}
|
|
|
|
const NOTION_READ_TOOLS = new Set([
|
|
"notion-fetch",
|
|
"notion-get-async-task",
|
|
"notion-get-comments",
|
|
"notion-get-teams",
|
|
"notion-get-users",
|
|
"notion-query-data-sources",
|
|
"notion-query-database-view",
|
|
"notion-query-meeting-notes",
|
|
"notion-search",
|
|
]);
|
|
|
|
const NOTION_WRITE_TOOLS = new Set([
|
|
"notion-convert-page-to-skill",
|
|
"notion-create-comment",
|
|
"notion-create-database",
|
|
"notion-create-folder",
|
|
"notion-create-pages",
|
|
"notion-create-view",
|
|
"notion-duplicate-page",
|
|
"notion-move-pages",
|
|
"notion-update-data-source",
|
|
"notion-update-page",
|
|
"notion-update-view",
|
|
]);
|
|
|
|
const SHOPIFY_DESTRUCTIVE_TOOLS = new Set([
|
|
"cancel-cart",
|
|
"cancel-checkout",
|
|
"complete-checkout",
|
|
]);
|
|
|
|
function normalizedProviderToolName(toolName: string): string {
|
|
return toolName
|
|
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
.toLowerCase()
|
|
.replace(/[:._-]+/g, "-");
|
|
}
|
|
|
|
// Aggregators can add arbitrarily powerful tools without changing their MCP
|
|
// endpoint. Only these reviewed, exact capabilities are read-only. Unknown or
|
|
// renamed actions remain enabled by the usual access rules, but have write risk.
|
|
const AGGREGATOR_READ_TOOLS = new Map<string, ReadonlySet<string>>([
|
|
["executor", new Set(["skills"])],
|
|
["composio", new Set(["COMPOSIO_SEARCH_TOOLS", "COMPOSIO_GET_TOOL_SCHEMAS", "COMPOSIO_SEARCH_SKILLS", "COMPOSIO_USE_SKILL"])],
|
|
["arcade", new Set(["Github.GetRepository"])],
|
|
["zapier", new Set()],
|
|
]);
|
|
|
|
export function classifyRisk(
|
|
tool: McpToolDescriptor,
|
|
sourceTemplateKey?: string | null,
|
|
): ToolRiskLevel {
|
|
const annotations = tool.annotations ?? {};
|
|
if (annotations.destructiveHint === true || annotations.destructive === true)
|
|
return "destructive";
|
|
const normalizedToolName = normalizedProviderToolName(tool.name);
|
|
const reviewedReads = AGGREGATOR_READ_TOOLS.get(sourceTemplateKey ?? "");
|
|
if (reviewedReads) {
|
|
if (verbMatches(tool.name, "delete|remove|destroy|unpublish")) return "destructive";
|
|
if (annotations.readOnlyHint === false || annotations.writeHint === true) return "write";
|
|
return reviewedReads.has(tool.name) ? "read" : "write";
|
|
}
|
|
if (sourceTemplateKey === "railway") {
|
|
const reviewed = railwayRisk(normalizedToolName);
|
|
return reviewed === "read" && (annotations.readOnlyHint === false || annotations.writeHint === true) ? "write" : reviewed;
|
|
}
|
|
if (sourceTemplateKey === "posthog" && normalizedToolName === "exec")
|
|
return "destructive";
|
|
if (
|
|
sourceTemplateKey === "shopify" &&
|
|
SHOPIFY_DESTRUCTIVE_TOOLS.has(normalizedToolName)
|
|
)
|
|
return "destructive";
|
|
// Notion's hosted MCP catalog contains mutations whose names do not use one
|
|
// of the generic create/update/delete verbs (move, duplicate, and convert).
|
|
// Keep all reviewed tools explicit so provider changes are visible in code,
|
|
// while an annotation may still escalate a known read to a write.
|
|
if (
|
|
sourceTemplateKey === "notion" &&
|
|
NOTION_WRITE_TOOLS.has(normalizedToolName)
|
|
)
|
|
return "write";
|
|
if (annotations.readOnlyHint === false || annotations.writeHint === true)
|
|
return "write";
|
|
if (
|
|
sourceTemplateKey === "notion" &&
|
|
NOTION_READ_TOOLS.has(normalizedToolName)
|
|
)
|
|
return "read";
|
|
if (verbMatches(tool.name, "delete|remove|destroy|unpublish"))
|
|
return "destructive";
|
|
if (
|
|
verbMatches(
|
|
tool.name,
|
|
"create|update|write|set|send|publish|post|mutate|mark|archive",
|
|
)
|
|
)
|
|
return "write";
|
|
// PostHog exposes a broad and evolving catalog. Unknown tools must never be
|
|
// silently treated as reads; provider annotations can opt known reads in.
|
|
if (sourceTemplateKey === "posthog")
|
|
return annotations.readOnlyHint === true ? "read" : "write";
|
|
return "read";
|
|
}
|
|
|
|
export function isGmailToolPermanentlyBlocked(
|
|
tool: McpToolDescriptor,
|
|
): boolean {
|
|
const riskLevel = classifyRisk(tool, "gmail");
|
|
return (
|
|
verbMatches(
|
|
tool.name,
|
|
"send|trash|spam|delete|remove|destroy|execute|run",
|
|
) ||
|
|
(normalizedProviderToolName(tool.name).includes("label") &&
|
|
riskLevel !== "read")
|
|
);
|
|
}
|
|
|
|
const GOOGLE_WORKSPACE_READ_TOOLS: Record<string, ReadonlySet<string>> = {
|
|
gmail: new Set([
|
|
"get-message",
|
|
"get-thread",
|
|
"get-draft",
|
|
"list-drafts",
|
|
"list-labels",
|
|
"search-threads",
|
|
"list-threads",
|
|
"search-messages",
|
|
]),
|
|
"google-drive": new Set([
|
|
"download-file-content",
|
|
"get-file-metadata",
|
|
"get-file-permissions",
|
|
"list-recent-files",
|
|
"read-file-content",
|
|
"search-files",
|
|
]),
|
|
"google-docs": new Set(["read-doc"]),
|
|
"google-sheets": new Set(["get-values", "get-spreadsheet"]),
|
|
"google-slides": new Set(["read-presentation"]),
|
|
"google-calendar": new Set([
|
|
"get-event",
|
|
"list-calendars",
|
|
"list-events",
|
|
"search-events",
|
|
"suggest-time",
|
|
]),
|
|
"google-chat": new Set([
|
|
"search-conversations",
|
|
"list-messages",
|
|
"search-messages",
|
|
]),
|
|
"google-people": new Set([
|
|
"search-directory-people",
|
|
"search-contacts",
|
|
"get-user-profile",
|
|
]),
|
|
"google-workspace-search": new Set(["search-corpus"]),
|
|
};
|
|
|
|
function googleWorkspaceToolLeafName(name: string): string {
|
|
return (name.split(/[.:/]/).pop() ?? name)
|
|
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
.replace(/_/g, "-")
|
|
.toLowerCase();
|
|
}
|
|
|
|
export function isGoogleWorkspaceToolAllowed(
|
|
profileId: GoogleWorkspaceConnectorProfileId,
|
|
tool: McpToolDescriptor,
|
|
): boolean {
|
|
const profile = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profileId];
|
|
const toolName = googleWorkspaceToolLeafName(tool.name);
|
|
const readTools =
|
|
GOOGLE_WORKSPACE_READ_TOOLS[profile.appSlug] ?? new Set<string>();
|
|
return (
|
|
readTools.has(toolName) ||
|
|
profile.writeTools.some(
|
|
(writeTool) => googleWorkspaceToolLeafName(writeTool) === toolName,
|
|
)
|
|
);
|
|
}
|
|
|
|
type ManagedConnectorProfileId =
|
|
GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId;
|
|
|
|
function managedConnectorProfile(value: string | undefined): {
|
|
id: ManagedConnectorProfileId;
|
|
provider: "google" | "github";
|
|
scopes: readonly string[];
|
|
} | null {
|
|
if (value && isGoogleWorkspaceConnectorProfileId(value)) {
|
|
return {
|
|
id: value,
|
|
provider: "google",
|
|
scopes: GOOGLE_WORKSPACE_CONNECTOR_PROFILES[value].scopes,
|
|
};
|
|
}
|
|
if (value && isGitHubConnectorProfileId(value)) {
|
|
return {
|
|
id: value,
|
|
provider: "github",
|
|
scopes: GITHUB_CONNECTOR_PROFILES[value].scopes,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function loadGitHubTokenRepositories(
|
|
headers: Record<string, string>,
|
|
request: typeof fetch = fetch,
|
|
) {
|
|
const repositories: Array<{
|
|
id: string;
|
|
fullName: string;
|
|
private?: boolean;
|
|
}> = [];
|
|
for (let page = 1; ; page += 1) {
|
|
const response = await request(
|
|
`https://api.github.com/user/repos?per_page=100&page=${page}`,
|
|
{
|
|
headers: {
|
|
...headers,
|
|
accept: "application/vnd.github+json",
|
|
"user-agent": "Paperclip",
|
|
"x-github-api-version": "2022-11-28",
|
|
},
|
|
signal: AbortSignal.timeout(15_000),
|
|
},
|
|
);
|
|
if (!response.ok)
|
|
throw unprocessable(
|
|
"Could not load GitHub repositories. Reconnect GitHub and try again.",
|
|
);
|
|
const rows: unknown = await response.json();
|
|
if (!Array.isArray(rows))
|
|
throw unprocessable("GitHub returned invalid repositories");
|
|
for (const row of rows) {
|
|
if (
|
|
!recordValue(row) ||
|
|
!githubId(row.id) ||
|
|
typeof row.full_name !== "string" ||
|
|
!/^[A-Za-z0-9][A-Za-z0-9-]*\/(?!\.{1,2}$)[A-Za-z0-9_.-]+$/.test(
|
|
row.full_name,
|
|
)
|
|
) {
|
|
throw unprocessable("GitHub returned invalid repository metadata");
|
|
}
|
|
repositories.push({
|
|
id: githubId(row.id)!,
|
|
fullName: row.full_name,
|
|
...(typeof row.private === "boolean" ? { private: row.private } : {}),
|
|
});
|
|
}
|
|
if (!/;\s*rel="next"/.test(response.headers.get("link") ?? ""))
|
|
return repositories;
|
|
if (!rows.length) throw unprocessable("GitHub returned invalid pagination");
|
|
}
|
|
}
|
|
|
|
export async function loadGitHubGrantMetadata(
|
|
accessToken: string,
|
|
request: typeof fetch = fetch,
|
|
appSlug?: string,
|
|
): Promise<{
|
|
userId: string;
|
|
login: string;
|
|
avatarUrl?: string;
|
|
installationCount: number;
|
|
repositoryCount: number;
|
|
repositorySelection: "all" | "selected" | "mixed" | "none";
|
|
installationIds: string[];
|
|
installationOwnerLogins: string[];
|
|
repositories: Array<{
|
|
id: string;
|
|
fullName: string;
|
|
installationId: string;
|
|
private?: boolean;
|
|
}>;
|
|
installationUrl: string;
|
|
managementUrl: string;
|
|
appSlug?: string;
|
|
accessRevision: string;
|
|
lastAccessRefreshAt: string;
|
|
webhookHealth: "pending";
|
|
}> {
|
|
let resolvedAppSlug = appSlug;
|
|
const accessRefreshStartedAt = new Date().toISOString();
|
|
const github = async (
|
|
path: string,
|
|
): Promise<{ data: Record<string, unknown>; hasNext: boolean }> => {
|
|
const response = await request(`https://api.github.com${path}`, {
|
|
headers: {
|
|
accept: "application/vnd.github+json",
|
|
authorization: `Bearer ${accessToken}`,
|
|
"user-agent": "Paperclip",
|
|
"x-github-api-version": "2022-11-28",
|
|
},
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
if (!response.ok) {
|
|
throw unprocessable(
|
|
"GitHub could not verify this account's installation access",
|
|
{
|
|
code:
|
|
response.status === 401
|
|
? "oauth_reauthorization_required"
|
|
: "github_access_check_failed",
|
|
},
|
|
);
|
|
}
|
|
const value = (await response.json()) as unknown;
|
|
if (!recordValue(value))
|
|
throw unprocessable("GitHub returned invalid account metadata", {
|
|
code: "github_bad_response",
|
|
});
|
|
return {
|
|
data: value,
|
|
hasNext: /;\s*rel="next"/.test(response.headers.get("link") ?? ""),
|
|
};
|
|
};
|
|
const list = async (
|
|
path: string,
|
|
key: string,
|
|
): Promise<Record<string, unknown>[]> => {
|
|
const items: Record<string, unknown>[] = [];
|
|
for (let page = 1; ; page += 1) {
|
|
const { data, hasNext } = await github(
|
|
`${path}?per_page=100&page=${page}`,
|
|
);
|
|
const batch = data[key];
|
|
if (
|
|
!Array.isArray(batch) ||
|
|
!batch.every(recordValue) ||
|
|
(hasNext && batch.length === 0)
|
|
) {
|
|
throw unprocessable("GitHub returned invalid access metadata", {
|
|
code: "github_bad_response",
|
|
});
|
|
}
|
|
items.push(...batch);
|
|
if (!hasNext) return items;
|
|
}
|
|
};
|
|
const { data: user } = await github("/user");
|
|
const userId = githubId(user.id);
|
|
const login = typeof user.login === "string" ? user.login : null;
|
|
if (!userId || !login)
|
|
throw unprocessable("GitHub returned invalid account metadata", {
|
|
code: "github_bad_response",
|
|
});
|
|
const installations = await list("/user/installations", "installations");
|
|
const installationIds: string[] = [];
|
|
const owners = new Set<string>();
|
|
const selections = new Set<"all" | "selected">();
|
|
const managementUrls = new Set<string>();
|
|
const repositories = new Map<
|
|
string,
|
|
{ id: string; fullName: string; installationId: string; private?: boolean }
|
|
>();
|
|
for (const installation of installations) {
|
|
const installationId = githubId(installation.id);
|
|
if (!installationId) continue;
|
|
installationIds.push(installationId);
|
|
// Older grants predate the broker's appSlug field. GitHub's installation
|
|
// response identifies this token's app without choosing an environment.
|
|
if (
|
|
!resolvedAppSlug &&
|
|
typeof installation.app_slug === "string" &&
|
|
/^[a-z0-9-]{1,100}$/.test(installation.app_slug)
|
|
) {
|
|
resolvedAppSlug = installation.app_slug;
|
|
}
|
|
if (
|
|
installation.repository_selection === "all" ||
|
|
installation.repository_selection === "selected"
|
|
) {
|
|
selections.add(installation.repository_selection);
|
|
}
|
|
const account = recordValue(installation.account)
|
|
? installation.account
|
|
: null;
|
|
if (typeof account?.login === "string") owners.add(account.login);
|
|
const managementUrl = githubInstallationManagementUrl(
|
|
installation.html_url,
|
|
);
|
|
if (managementUrl) managementUrls.add(managementUrl);
|
|
for (const repository of await list(
|
|
`/user/installations/${installationId}/repositories`,
|
|
"repositories",
|
|
)) {
|
|
const id = githubId(repository.id);
|
|
const fullName =
|
|
typeof repository.full_name === "string" ? repository.full_name : "";
|
|
if (
|
|
!id ||
|
|
!/^[A-Za-z0-9][A-Za-z0-9-]*\/(?!\.{1,2}$)[A-Za-z0-9_.-]+$/.test(
|
|
fullName,
|
|
)
|
|
) {
|
|
throw unprocessable("GitHub returned invalid repository metadata", {
|
|
code: "github_bad_response",
|
|
});
|
|
}
|
|
repositories.set(id, {
|
|
id,
|
|
fullName,
|
|
installationId,
|
|
...(typeof repository.private === "boolean"
|
|
? { private: repository.private }
|
|
: {}),
|
|
});
|
|
}
|
|
}
|
|
const repositoryCount = repositories.size;
|
|
if (installationIds.length === 0 || repositoryCount === 0) {
|
|
const installationUrl = resolvedAppSlug
|
|
? `https://github.com/apps/${resolvedAppSlug}/installations/new`
|
|
: "https://github.com/settings/installations";
|
|
throw unprocessable(
|
|
"GitHub access is required. Install Paperclip and grant at least one repository before refreshing access.",
|
|
{
|
|
code: "github_installation_required",
|
|
installationUrl,
|
|
managementUrl: "https://github.com/settings/installations",
|
|
},
|
|
);
|
|
}
|
|
const installationUrl = resolvedAppSlug
|
|
? `https://github.com/apps/${resolvedAppSlug}/installations/new`
|
|
: "https://github.com/settings/installations";
|
|
return {
|
|
userId,
|
|
login,
|
|
...(typeof user.avatar_url === "string"
|
|
? { avatarUrl: user.avatar_url }
|
|
: {}),
|
|
installationCount: installationIds.length,
|
|
repositoryCount,
|
|
repositorySelection:
|
|
selections.size > 1
|
|
? "mixed"
|
|
: (selections.values().next().value ?? "none"),
|
|
installationIds,
|
|
installationOwnerLogins: [...owners],
|
|
repositories: [...repositories.values()].sort((a, b) =>
|
|
a.fullName.localeCompare(b.fullName),
|
|
),
|
|
installationUrl,
|
|
managementUrl:
|
|
managementUrls.size === 1
|
|
? managementUrls.values().next().value!
|
|
: "https://github.com/settings/installations",
|
|
...(resolvedAppSlug ? { appSlug: resolvedAppSlug } : {}),
|
|
accessRevision: randomUUID(),
|
|
lastAccessRefreshAt: accessRefreshStartedAt,
|
|
webhookHealth: "pending",
|
|
};
|
|
}
|
|
|
|
function githubInstallationManagementUrl(value: unknown): string | null {
|
|
if (typeof value !== "string" || value.length > 2_000) return null;
|
|
try {
|
|
const url = new URL(value);
|
|
if (
|
|
url.protocol !== "https:" ||
|
|
url.hostname.toLowerCase() !== "github.com"
|
|
)
|
|
return null;
|
|
return url.pathname.includes("/settings/installations/")
|
|
? url.toString()
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function githubId(value: unknown): string | null {
|
|
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0)
|
|
return String(value);
|
|
if (typeof value === "string" && /^[1-9][0-9]{0,30}$/.test(value))
|
|
return value;
|
|
return null;
|
|
}
|
|
|
|
function recordValue(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function descriptorHash(
|
|
tool: McpToolDescriptor,
|
|
riskLevel: ToolRiskLevel,
|
|
): string {
|
|
return stableHash({
|
|
name: tool.name,
|
|
title: tool.title ?? null,
|
|
description: tool.description ?? null,
|
|
inputSchema: tool.inputSchema ?? {},
|
|
annotations: tool.annotations ?? {},
|
|
riskLevel,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Did this error come from the OAuth endpoint gate (PAP-17099)? Such a refusal
|
|
* is Paperclip's own decision about an unsafe address, so it must keep its code
|
|
* and its 422 instead of being folded into a generic upstream failure.
|
|
*/
|
|
function originOf(value: string | null | undefined): string | null {
|
|
if (!value) return null;
|
|
try {
|
|
return new URL(value).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isOAuthEndpointRejection(error: unknown): boolean {
|
|
if (!(error instanceof HttpError)) return false;
|
|
const code = asRecord(error.details).code;
|
|
return typeof code === "string" && code.endsWith("_endpoint_rejected");
|
|
}
|
|
|
|
function healthFailureHttpStatus(failure: {
|
|
status: ToolConnectionHealthStatus;
|
|
code: string;
|
|
}): number {
|
|
if (failure.status === "missing_secret") return 422;
|
|
if (failure.code === "oauth_challenge") return 422;
|
|
if (failure.code === "slack_mcp_access_disabled") return 422;
|
|
if (failure.code === "user_authorization_required") return 422;
|
|
if (failure.code === "composio_broker_retired") return 422;
|
|
if (failure.code === "tool_connection_transport_unsupported") return 422;
|
|
if (failure.code.endsWith("_endpoint_rejected")) return 422;
|
|
return 502;
|
|
}
|
|
|
|
function unsupportedToolConnectionTransport() {
|
|
return unprocessable(
|
|
"This connection has no supported tool integration. Add a supported account or MCP connection from Connectors.",
|
|
{ code: "tool_connection_transport_unsupported" },
|
|
);
|
|
}
|
|
|
|
function sanitizeHttpFailure(error: unknown): {
|
|
status: ToolConnectionHealthStatus;
|
|
message: string;
|
|
code: string;
|
|
} {
|
|
if (error instanceof HttpError) {
|
|
const code = asRecord(error.details).code;
|
|
if (code === "slack_mcp_access_disabled") {
|
|
return { status: "error", message: error.message, code };
|
|
}
|
|
if (code === "user_authorization_required") {
|
|
return { status: "error", message: error.message, code };
|
|
}
|
|
if (code === "tool_connection_transport_unsupported") {
|
|
return { status: "error", message: error.message, code };
|
|
}
|
|
if (code === "composio_broker_retired") {
|
|
return { status: "error", message: error.message, code };
|
|
}
|
|
if (typeof code === "string" && code.startsWith("remote_http_")) {
|
|
return { status: "error", message: error.message, code };
|
|
}
|
|
if (isOAuthEndpointRejection(error)) {
|
|
return { status: "error", message: error.message, code: String(code) };
|
|
}
|
|
if (code === "oauth_challenge") {
|
|
return {
|
|
status: "error",
|
|
message: "This app needs you to sign in.",
|
|
code: "oauth_challenge",
|
|
};
|
|
}
|
|
if (code === "oauth_refresh_missing") {
|
|
return {
|
|
status: "failed",
|
|
message: "OAuth credentials have expired and need to be reconnected.",
|
|
code: "oauth_refresh_missing",
|
|
};
|
|
}
|
|
if (code === "oauth_reauthorization_required") {
|
|
return {
|
|
status: "error",
|
|
message: "OAuth authorization expired. Reconnect this app to continue.",
|
|
code: "oauth_reauthorization_required",
|
|
};
|
|
}
|
|
if (typeof code === "string" && code.startsWith("vercel_connect_")) {
|
|
return {
|
|
status:
|
|
code === "vercel_connect_unavailable" ||
|
|
code === "vercel_connect_auth_failed" ||
|
|
code === "vercel_connect_installation_required"
|
|
? "degraded"
|
|
: "error",
|
|
message: error.message,
|
|
code,
|
|
};
|
|
}
|
|
if (
|
|
code === "oauth_refresh_in_progress" ||
|
|
code === "oauth_refresh_superseded" ||
|
|
code === "oauth_refresh_outcome_unknown"
|
|
) {
|
|
return {
|
|
status: "error",
|
|
message: error.message,
|
|
code,
|
|
};
|
|
}
|
|
if (
|
|
code === "binding_missing" ||
|
|
code === "secret_deleted" ||
|
|
code === "secret_inactive" ||
|
|
code === "version_missing"
|
|
) {
|
|
return {
|
|
status: "missing_secret",
|
|
message: "A configured credential secret could not be resolved.",
|
|
code: String(code),
|
|
};
|
|
}
|
|
if (error.status === 404 && /secret/i.test(error.message)) {
|
|
return {
|
|
status: "missing_secret",
|
|
message: "A configured credential secret could not be resolved.",
|
|
code: "secret_missing",
|
|
};
|
|
}
|
|
return { status: "error", message: error.message, code: "paperclip_error" };
|
|
}
|
|
if (error instanceof Error) {
|
|
return {
|
|
status: "error",
|
|
message: error.message.slice(0, 240),
|
|
code: "runtime_error",
|
|
};
|
|
}
|
|
return {
|
|
status: "error",
|
|
message: "Connection check failed.",
|
|
code: "runtime_error",
|
|
};
|
|
}
|
|
|
|
function remoteEndpoint(config: Record<string, unknown>): string {
|
|
const value = config.url ?? config.endpoint ?? config.remoteUrl;
|
|
const parsed = parseRemoteHttpEndpoint(value, (message, code) =>
|
|
badRequest(message, { code }),
|
|
);
|
|
return parsed.toString();
|
|
}
|
|
|
|
function vercelConnectResourcesFor(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): string[] {
|
|
const resource = new URL(remoteEndpoint(connection.config));
|
|
// OAuth resource indicators name the protected MCP resource, not the
|
|
// connection's transport-only filtering query. PostHog uses this canonical
|
|
// URL to select its MCP consent scope set.
|
|
resource.search = "";
|
|
resource.hash = "";
|
|
return [resource.toString()];
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
export function toolAccessService(
|
|
db: Db,
|
|
options: ToolAccessServiceOptions = {},
|
|
) {
|
|
const secrets = secretService(db);
|
|
|
|
async function resolvedRemoteEndpoint(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
actor?: ActorInfo,
|
|
): Promise<string> {
|
|
const publicEndpoint = remoteEndpoint(connection.config);
|
|
const ref = connection.credentialRefs.find(
|
|
(candidate) => candidate.placement === "url",
|
|
);
|
|
if (!ref) return publicEndpoint;
|
|
let value: string;
|
|
try {
|
|
value = await secrets.resolveSecretValue(
|
|
connection.companyId,
|
|
ref.secretId,
|
|
ref.version ?? "latest",
|
|
{
|
|
consumerType: "tool_connection",
|
|
consumerId: connection.id,
|
|
configPath: REMOTE_URL_SECRET_CONFIG_PATH,
|
|
actorType: actor?.actorType ?? "system",
|
|
actorId: actor?.actorId ?? null,
|
|
},
|
|
);
|
|
} catch {
|
|
throw unprocessable(
|
|
"A configured credential secret could not be resolved.",
|
|
{
|
|
code: "mcp_remote_missing_secret",
|
|
connectionId: connection.id,
|
|
credential: REMOTE_URL_SECRET_CONFIG_PATH,
|
|
},
|
|
);
|
|
}
|
|
if (!remoteUrlCredentialMatchesPublicUrl(publicEndpoint, value)) {
|
|
throw unprocessable(
|
|
"The stored MCP URL credential no longer matches this connection.",
|
|
{
|
|
code: "mcp_remote_url_credential_mismatch",
|
|
connectionId: connection.id,
|
|
},
|
|
);
|
|
}
|
|
return parseRemoteHttpEndpoint(value, (message, code) =>
|
|
badRequest(message, { code }),
|
|
).toString();
|
|
}
|
|
const policySvc = toolAccessPolicyService(db);
|
|
const now = options.now ?? (() => new Date());
|
|
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);
|
|
}
|
|
let nextGitHubContinuitySweepAt = 0;
|
|
const vercelConnect =
|
|
options.vercelConnectClient === undefined
|
|
? createVercelConnectClient()
|
|
: options.vercelConnectClient;
|
|
const runtimeSupervisor = createToolRuntimeSupervisor(db, options);
|
|
// These maps remove duplicate work inside one service instance. OAuth also
|
|
// uses the database refresh lease below as its cross-process boundary.
|
|
const oauthRefreshFlights = new Map<string, Promise<unknown>>();
|
|
const oauthGrantRefreshFlights = new Map<string, Promise<unknown>>();
|
|
const catalogRefreshFlights = new Map<string, Promise<unknown>>();
|
|
const catalogCacheTtlMs = Math.max(
|
|
0,
|
|
options.catalogCacheTtlMs ?? 15 * 60 * 1000,
|
|
);
|
|
|
|
function vercelConnectHttpError(error: unknown): HttpError {
|
|
if (error instanceof VercelConnectClientError) {
|
|
return new HttpError(error.status, error.message, { code: error.code });
|
|
}
|
|
return new HttpError(
|
|
502,
|
|
"Vercel Connect could not complete the credential request.",
|
|
{
|
|
code: "vercel_connect_request_failed",
|
|
},
|
|
);
|
|
}
|
|
|
|
function vercelCredentialFor(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): VercelConnectCredentialReference {
|
|
if (
|
|
connection.credentialSource !== "vercel_connect" ||
|
|
!connection.externalCredential
|
|
) {
|
|
throw unprocessable("This connection does not use Vercel Connect", {
|
|
code: "vercel_connect_not_configured",
|
|
});
|
|
}
|
|
return connection.externalCredential;
|
|
}
|
|
|
|
async function resolveVercelCredentialHeaders(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
grant: typeof connectionGrants.$inferSelect,
|
|
options: { forceRefresh?: boolean } = {},
|
|
): Promise<Record<string, string>> {
|
|
if (!vercelConnect)
|
|
throw vercelConnectHttpError(
|
|
new VercelConnectClientError("vercel_connect_unavailable", 503),
|
|
);
|
|
const credential = vercelCredentialFor(connection);
|
|
const derived = deriveVercelConnectSubject({
|
|
credential,
|
|
connectionId: connection.id,
|
|
companyId: connection.companyId,
|
|
grantKind: grant.kind,
|
|
subjectUserId: grant.subjectUserId,
|
|
});
|
|
const request = vercelTokenRequest({
|
|
credential,
|
|
grant,
|
|
connectionId: connection.id,
|
|
companyId: connection.companyId,
|
|
resources: vercelConnectResourcesFor(connection),
|
|
});
|
|
try {
|
|
const token = await vercelConnect.getToken(request, options);
|
|
if (
|
|
token.connector.id !== credential.connectorId &&
|
|
token.connector.uid !== credential.connectorUid
|
|
) {
|
|
throw new VercelConnectClientError(
|
|
"vercel_connect_request_failed",
|
|
502,
|
|
);
|
|
}
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
externalCredential: vercelGrantReference({
|
|
credential,
|
|
token,
|
|
subjectId: derived.subjectId,
|
|
verifiedAt: now(),
|
|
}),
|
|
status: "active",
|
|
revokedAt: null,
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
),
|
|
);
|
|
return {
|
|
[credential.headerName]: `${credential.headerPrefix ?? ""}${token.token}`,
|
|
};
|
|
} catch (error) {
|
|
if (
|
|
error instanceof VercelConnectClientError &&
|
|
error.code === "vercel_connect_authorization_required"
|
|
) {
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
status: "needs_reauthorization",
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
),
|
|
);
|
|
}
|
|
throw vercelConnectHttpError(error);
|
|
}
|
|
}
|
|
|
|
async function vercelGrantForConnection(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
actor?: ActorInfo,
|
|
) {
|
|
const actorUserId =
|
|
actor?.actorType === "user" ? (actor.actorId ?? null) : null;
|
|
if (actorUserId) {
|
|
const [personal] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.subjectUserId, actorUserId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (personal) return personal;
|
|
}
|
|
const [organization] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "organization"),
|
|
eq(connectionGrants.isDefault, true),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!organization) {
|
|
throw conflict("This Vercel Connect identity has not been authorized", {
|
|
code: "vercel_connect_authorization_required",
|
|
});
|
|
}
|
|
return organization;
|
|
}
|
|
|
|
function allowPrivateRemoteEndpoints() {
|
|
return (
|
|
options.deploymentMode !== "authenticated" ||
|
|
options.deploymentExposure !== "public"
|
|
);
|
|
}
|
|
|
|
async function assertRemoteHttpUrlAllowed(value: string): Promise<string> {
|
|
const endpoint = parseRemoteHttpEndpoint(value, (message, code) =>
|
|
badRequest(message, { code }),
|
|
);
|
|
await assertPublicRemoteHttpEndpoint(
|
|
endpoint,
|
|
{
|
|
allowPrivateNetwork: allowPrivateRemoteEndpoints(),
|
|
lookup: options.remoteHttpEndpointLookup,
|
|
},
|
|
(message, code) => badRequest(message, { code }),
|
|
);
|
|
return endpoint.toString();
|
|
}
|
|
|
|
function remoteHttpFetchOptions(): GuardedRemoteHttpFetchOptions {
|
|
return {
|
|
allowPrivateNetwork: allowPrivateRemoteEndpoints(),
|
|
lookup: options.remoteHttpEndpointLookup,
|
|
error: (message, code) => badRequest(message, { code }),
|
|
};
|
|
}
|
|
|
|
async function requestRemoteHttpEndpoint(
|
|
endpoint: URL,
|
|
init: RequestInit,
|
|
): Promise<Response> {
|
|
return options.remoteHttpRequest
|
|
? options.remoteHttpRequest(endpoint.toString(), {
|
|
...init,
|
|
redirect: "manual",
|
|
})
|
|
: guardedRemoteHttpFetch(endpoint, init, remoteHttpFetchOptions());
|
|
}
|
|
|
|
/**
|
|
* Fetch an operator-supplied remote URL with the egress guard bound to the
|
|
* connection itself.
|
|
*
|
|
* `guardedRemoteHttpFetch` resolves the hostname once and dials the approved
|
|
* address, so a name server that answers public-then-private cannot move the
|
|
* connection onto a loopback or metadata address after validation
|
|
* (PAP-17098). Redirects stay manual and run the full guard again on the next
|
|
* hop, because a `Location` is just as attacker-controlled as the first URL.
|
|
*/
|
|
async function fetchRemoteHttpUrl(
|
|
value: string,
|
|
init: RequestInit = {},
|
|
): Promise<Response> {
|
|
let currentUrl = value;
|
|
const method = (init.method ?? "GET").toUpperCase();
|
|
for (
|
|
let redirectCount = 0;
|
|
redirectCount <= MAX_REMOTE_HTTP_REDIRECTS;
|
|
redirectCount += 1
|
|
) {
|
|
const endpoint = parseRemoteHttpEndpoint(currentUrl, (message, code) =>
|
|
badRequest(message, { code }),
|
|
);
|
|
const response = await requestRemoteHttpEndpoint(endpoint, init);
|
|
const location = REMOTE_HTTP_REDIRECT_STATUSES.has(response.status)
|
|
? (response.headers?.get?.("location") ?? null)
|
|
: null;
|
|
if (!location) return response;
|
|
if (method !== "GET" && method !== "HEAD") {
|
|
throw new HttpError(
|
|
502,
|
|
"Remote OAuth endpoint redirected unexpectedly",
|
|
{ code: "oauth_redirect_rejected" },
|
|
);
|
|
}
|
|
if (redirectCount >= MAX_REMOTE_HTTP_REDIRECTS) {
|
|
throw new HttpError(
|
|
502,
|
|
"Remote OAuth endpoint redirected too many times",
|
|
{ code: "oauth_redirect_limit" },
|
|
);
|
|
}
|
|
currentUrl = new URL(location, endpoint).toString();
|
|
}
|
|
throw new HttpError(
|
|
502,
|
|
"Remote OAuth endpoint redirected too many times",
|
|
{ code: "oauth_redirect_limit" },
|
|
);
|
|
}
|
|
|
|
async function assertRemoteEndpointAllowed(
|
|
config: Record<string, unknown>,
|
|
): Promise<string> {
|
|
return assertRemoteHttpUrlAllowed(remoteEndpoint(config));
|
|
}
|
|
|
|
function normalizeTokenBrokerAllowedHost(value: string): string | null {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
try {
|
|
const parsed = new URL(
|
|
trimmed.includes("://") ? trimmed : `http://${trimmed}`,
|
|
);
|
|
return (
|
|
parsed.hostname
|
|
.replace(/^\[|\]$/g, "")
|
|
.replace(/\.$/, "")
|
|
.toLowerCase() || null
|
|
);
|
|
} catch {
|
|
// Invalid allowlist entries grant no access. The configured broker URL is
|
|
// still evaluated under the public-only policy below.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function tokenBrokerAllowedPrivateHosts(): Set<string> {
|
|
const configured = (process.env.PAPERCLIP_TOKEN_BROKER_ALLOWED_HOSTS ?? "")
|
|
.split(/[,\s]+/)
|
|
.map(normalizeTokenBrokerAllowedHost)
|
|
.filter((host): host is string => host !== null);
|
|
const pagesApiHost = normalizeTokenBrokerAllowedHost(
|
|
process.env.PAPERCLIP_PAGES_API_URL ?? "",
|
|
);
|
|
if (pagesApiHost) configured.push(pagesApiHost);
|
|
return new Set(configured);
|
|
}
|
|
|
|
function tokenBrokerAllowsPrivateNetwork(endpoint: URL): boolean {
|
|
const hostname = endpoint.hostname
|
|
.replace(/^\[|\]$/g, "")
|
|
.replace(/\.$/, "")
|
|
.toLowerCase();
|
|
return tokenBrokerAllowedPrivateHosts().has(hostname);
|
|
}
|
|
|
|
function tokenBrokerHttpFetchOptions(
|
|
endpoint: URL,
|
|
): GuardedRemoteHttpFetchOptions {
|
|
return {
|
|
allowPrivateNetwork: tokenBrokerAllowsPrivateNetwork(endpoint),
|
|
error: (message, code) => badRequest(message, { code }),
|
|
};
|
|
}
|
|
|
|
async function assertTokenBrokerHttpUrlAllowed(
|
|
value: string,
|
|
): Promise<string> {
|
|
const endpoint = parseRemoteHttpEndpoint(value, (message, code) =>
|
|
badRequest(message, { code }),
|
|
);
|
|
await assertPublicRemoteHttpEndpoint(
|
|
endpoint,
|
|
{ allowPrivateNetwork: tokenBrokerAllowsPrivateNetwork(endpoint) },
|
|
(message, code) => badRequest(message, { code }),
|
|
);
|
|
return endpoint.toString();
|
|
}
|
|
|
|
async function assertConfiguredTokenBrokerEndpointsAllowed(
|
|
config: Record<string, unknown>,
|
|
): Promise<void> {
|
|
for (const url of configuredTokenBrokerExchangeUrls(config)) {
|
|
await assertTokenBrokerHttpUrlAllowed(url);
|
|
}
|
|
}
|
|
|
|
async function assertRemoteConnectionEndpointsAllowed(
|
|
config: Record<string, unknown>,
|
|
): Promise<string> {
|
|
const endpoint = await assertRemoteEndpointAllowed(config);
|
|
await assertConfiguredTokenBrokerEndpointsAllowed(config);
|
|
return endpoint;
|
|
}
|
|
|
|
/**
|
|
* OAuth endpoint scheme/transport gate (PAP-17099).
|
|
*
|
|
* Every OAuth endpoint Paperclip acts on is attacker-influenced: discovered
|
|
* metadata, a `WWW-Authenticate` hint, a pasted config, or a gallery default.
|
|
* The authorization endpoint is the sharpest one because it is handed to the
|
|
* operator's browser as a top-level navigation, so `javascript:`/`data:` there
|
|
* would run in the board's origin. `checkOAuthEndpointUrl` is the single place
|
|
* that decides; loopback `http:` is accepted only under the same
|
|
* local-development policy that governs private remote endpoints, and
|
|
* Paperclip's own origin is exempt from the transport rule because a
|
|
* first-party endpoint (the smoke-lab fixture) is served exactly as the board
|
|
* itself is.
|
|
*/
|
|
function oauthEndpointRejected(
|
|
kind: OAuthEndpointKind,
|
|
reason: OAuthEndpointUrlRejection,
|
|
): HttpError {
|
|
return new HttpError(422, oauthEndpointUrlRejectionMessage(kind, reason), {
|
|
code: `oauth_${kind}_endpoint_rejected`,
|
|
reason,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Origins that are Paperclip itself: this deployment's configured public URL,
|
|
* plus the callback origin of the request in hand when there is one. Only the
|
|
* plaintext-transport rule is relaxed for these.
|
|
*/
|
|
function firstPartyOrigins(candidate?: string | null): string[] {
|
|
const configured =
|
|
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
|
|
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
|
|
process.env.BETTER_AUTH_URL?.trim() ||
|
|
process.env.BETTER_AUTH_BASE_URL?.trim() ||
|
|
null;
|
|
return [originOf(candidate), originOf(configured)].filter(
|
|
(origin): origin is string => Boolean(origin),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Origins for which the plaintext-transport rule is relaxed when checking
|
|
* `value`. Adds the smoke-lab fixture's own origin, because that provider is
|
|
* mounted on this deployment's own routes — `assertNotSmokeLabOAuthEndpoints`
|
|
* is what stops any other connection from claiming those paths — and a smoke
|
|
* run may be driven against a deployment served over plaintext HTTP.
|
|
*/
|
|
function insecureTransportExemptions(
|
|
value: unknown,
|
|
candidate?: string | null,
|
|
): string[] {
|
|
const origins = firstPartyOrigins(candidate);
|
|
if (typeof value === "string" && isSmokeLabOAuthUrl(value)) {
|
|
const origin = originOf(value);
|
|
if (origin) origins.push(origin);
|
|
}
|
|
return origins;
|
|
}
|
|
|
|
/** Throws unless `value` is an endpoint Paperclip may use (and navigate to). */
|
|
function assertOAuthEndpointUrl(
|
|
kind: OAuthEndpointKind,
|
|
value: unknown,
|
|
options: { firstPartyOrigin?: string | null } = {},
|
|
): string {
|
|
const check = checkOAuthEndpointUrl(value, {
|
|
allowInsecureLoopback: allowPrivateRemoteEndpoints(),
|
|
allowInsecureOrigins: insecureTransportExemptions(
|
|
value,
|
|
options.firstPartyOrigin,
|
|
),
|
|
});
|
|
if (!check.ok) throw oauthEndpointRejected(kind, check.reason);
|
|
return check.url;
|
|
}
|
|
|
|
/**
|
|
* Discovery variant: an unusable endpoint is dropped rather than thrown, so a
|
|
* second advertised authorization server (or a later metadata candidate) still
|
|
* gets a chance. Rejections are recorded in `rejections`; discovery raises the
|
|
* first one only if it ends up with nothing safe to use, so the operator sees
|
|
* *why* instead of a bare "does not advertise OAuth sign in".
|
|
*/
|
|
function safeOAuthEndpointUrl(
|
|
kind: OAuthEndpointKind,
|
|
value: unknown,
|
|
rejections: HttpError[],
|
|
firstPartyOrigin?: string | null,
|
|
): string | null {
|
|
if (value === null || value === undefined || value === "") return null;
|
|
const check = checkOAuthEndpointUrl(value, {
|
|
allowInsecureLoopback: allowPrivateRemoteEndpoints(),
|
|
allowInsecureOrigins: insecureTransportExemptions(
|
|
value,
|
|
firstPartyOrigin,
|
|
),
|
|
});
|
|
if (check.ok) return check.url;
|
|
if (check.reason !== "missing")
|
|
rejections.push(oauthEndpointRejected(kind, check.reason));
|
|
return null;
|
|
}
|
|
|
|
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,
|
|
) {
|
|
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;
|
|
}
|
|
|
|
function shouldQuarantineNewEntries(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): boolean {
|
|
return asRecord(connection.config).quarantineNewEntries === true;
|
|
}
|
|
|
|
function isAttentionHealthStatus(
|
|
status: ToolConnectionHealthStatus,
|
|
): boolean {
|
|
return isToolConnectionAttentionHealth(status);
|
|
}
|
|
|
|
async function audit(input: {
|
|
companyId: string;
|
|
connectionId?: string | null;
|
|
catalogEntryId?: string | null;
|
|
action: string;
|
|
outcome: "success" | "failure";
|
|
reasonCode?: string | null;
|
|
details?: Record<string, unknown>;
|
|
actor?: ActorInfo;
|
|
}) {
|
|
try {
|
|
await db.insert(toolAccessAuditEvents).values({
|
|
companyId: input.companyId,
|
|
connectionId: input.connectionId ?? null,
|
|
catalogEntryId: input.catalogEntryId ?? null,
|
|
actorType: input.actor?.actorType ?? "system",
|
|
actorId: input.actor?.actorId ?? null,
|
|
action: input.action,
|
|
outcome: input.outcome,
|
|
reasonCode: input.reasonCode ?? null,
|
|
details: input.details ?? {},
|
|
});
|
|
} catch (error) {
|
|
await recordToolRuntimeAuditWriteFailure(db, input.companyId);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function readConfigString(
|
|
record: Record<string, unknown>,
|
|
key: string,
|
|
): string | null {
|
|
const value = record[key];
|
|
return typeof value === "string" && value.trim().length > 0
|
|
? value.trim()
|
|
: null;
|
|
}
|
|
|
|
function readConfigStringArray(value: unknown): string[] {
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
.filter((item): item is string => typeof item === "string")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
if (typeof value === "string")
|
|
return value
|
|
.split(/\s+/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
return [];
|
|
}
|
|
|
|
function normalizeConnectionTokenScopes(
|
|
scope: ConnectionTokenRequest["scope"],
|
|
): string[] {
|
|
if (Array.isArray(scope))
|
|
return [...new Set(scope.map((item) => item.trim()).filter(Boolean))];
|
|
if (typeof scope === "string")
|
|
return [
|
|
...new Set(
|
|
scope
|
|
.split(/\s+/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean),
|
|
),
|
|
];
|
|
return [];
|
|
}
|
|
|
|
function tokenBrokerConfigFromConnectionConfig(
|
|
config: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const broker = asRecord(config.tokenBroker);
|
|
if (Object.keys(broker).length > 0) return broker;
|
|
return asRecord(config.broker);
|
|
}
|
|
|
|
function tokenBrokerConfig(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): Record<string, unknown> {
|
|
return tokenBrokerConfigFromConnectionConfig(asRecord(connection.config));
|
|
}
|
|
|
|
function configuredTokenBrokerExchangeUrls(
|
|
config: Record<string, unknown>,
|
|
): string[] {
|
|
const broker = tokenBrokerConfigFromConnectionConfig(config);
|
|
return [
|
|
...new Set(
|
|
[
|
|
readConfigString(broker, "tokenUrl"),
|
|
readConfigString(broker, "exchangeTokenUrl"),
|
|
readConfigString(config, "tokenExchangeUrl"),
|
|
readConfigString(config, "pagesTokenExchangeUrl"),
|
|
].filter((url): url is string => url !== null),
|
|
),
|
|
];
|
|
}
|
|
|
|
function connectionTokenBrokerEnabled(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): boolean {
|
|
const config = asRecord(connection.config);
|
|
const tokenBroker = asRecord(config.tokenBroker);
|
|
if (Object.keys(tokenBroker).length > 0)
|
|
return tokenBroker.enabled === true;
|
|
const broker = asRecord(config.broker);
|
|
if (Object.keys(broker).length > 0) return broker.enabled === true;
|
|
return false;
|
|
}
|
|
|
|
function isPagesTokenConnection(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
application?: typeof toolApplications.$inferSelect | null,
|
|
) {
|
|
const config = asRecord(connection.config);
|
|
const broker = tokenBrokerConfig(connection);
|
|
const applicationKey = application?.applicationKey ?? "";
|
|
return Boolean(
|
|
applicationKey === "paperclip-pages" ||
|
|
applicationKey === "paperclip.pages" ||
|
|
applicationKey === "pages.paperclip" ||
|
|
readConfigString(config, "connectionType") === "pages" ||
|
|
readConfigString(config, "service") === "pages" ||
|
|
readConfigString(broker, "connectionType") === "pages" ||
|
|
readConfigString(broker, "service") === "pages" ||
|
|
asRecord(config.pages).enabled === true,
|
|
);
|
|
}
|
|
|
|
async function getConnectionApplication(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
) {
|
|
const [application] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
return application ?? null;
|
|
}
|
|
|
|
function inferConnectionTokenPath(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
application?: typeof toolApplications.$inferSelect | null,
|
|
): ConnectionTokenIssuancePath {
|
|
const broker = tokenBrokerConfig(connection);
|
|
const configuredPath =
|
|
readConfigString(broker, "path") ??
|
|
readConfigString(asRecord(connection.config), "tokenPath");
|
|
if (
|
|
configuredPath === "exchange" ||
|
|
configuredPath === "oauth_access" ||
|
|
configuredPath === "static"
|
|
)
|
|
return configuredPath;
|
|
if (isPagesTokenConnection(connection, application)) return "exchange";
|
|
if (
|
|
readConfigString(broker, "tokenUrl") ||
|
|
readConfigString(asRecord(connection.config), "tokenExchangeUrl")
|
|
)
|
|
return "exchange";
|
|
return "static";
|
|
}
|
|
|
|
function parentScopesForConnection(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): string[] {
|
|
const config = asRecord(connection.config);
|
|
const broker = tokenBrokerConfig(connection);
|
|
const configured = [
|
|
...readConfigStringArray(broker.parentScopes),
|
|
...readConfigStringArray(broker.scopes),
|
|
...readConfigStringArray(config.parentScopes),
|
|
...readConfigStringArray(asRecord(config.oauth).scopes),
|
|
...readConfigStringArray(asRecord(config.oauth).scope),
|
|
];
|
|
const namespaceAllowlist = readConfigStringArray(
|
|
config.namespaceAllowlist,
|
|
).map((namespace) => `pages:publish:ns/${namespace}`);
|
|
return [...new Set([...configured, ...namespaceAllowlist])];
|
|
}
|
|
|
|
function defaultScopesForConnection(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): string[] {
|
|
const broker = tokenBrokerConfig(connection);
|
|
return [
|
|
...new Set([
|
|
...readConfigStringArray(broker.defaultScopes),
|
|
...readConfigStringArray(asRecord(connection.config).defaultScopes),
|
|
]),
|
|
];
|
|
}
|
|
|
|
function assertScopeSubset(input: {
|
|
requestedScope: string[];
|
|
parentScopes: string[];
|
|
}) {
|
|
if (input.requestedScope.length === 0) return;
|
|
const parent = new Set(input.parentScopes);
|
|
if (
|
|
parent.size === 0 ||
|
|
input.requestedScope.some((scope) => !parent.has(scope))
|
|
) {
|
|
throw forbidden(
|
|
"Requested token scope exceeds the connection parent scope",
|
|
);
|
|
}
|
|
}
|
|
|
|
function requestedTtlSeconds(
|
|
body: ConnectionTokenRequest,
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): number {
|
|
const broker = tokenBrokerConfig(connection);
|
|
const configured = Number(
|
|
broker.defaultTtlSeconds ?? broker.ttlSeconds ?? 900,
|
|
);
|
|
const requested = Number(body.requestedTtlSeconds ?? configured);
|
|
const finite =
|
|
Number.isFinite(requested) && requested > 0 ? Math.trunc(requested) : 900;
|
|
return Math.max(1, Math.min(900, finite));
|
|
}
|
|
|
|
function sha256Hex(value: string): string {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function bearerTokenHash(token: string): string {
|
|
return sha256Hex(token);
|
|
}
|
|
|
|
function runSnapshotString(
|
|
snapshot: Record<string, unknown>,
|
|
...keys: string[]
|
|
): string | null {
|
|
for (const key of keys) {
|
|
const value = snapshot[key];
|
|
if (typeof value === "string" && value.trim().length > 0) return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function loadBrokerRunContext(input: {
|
|
companyId: string;
|
|
agentId: string;
|
|
runId: string;
|
|
}) {
|
|
const [initialRun] = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, input.runId));
|
|
const run = initialRun?.activeIdentityContextId
|
|
? (await captureRunIdentity(db, input)).run
|
|
: initialRun;
|
|
if (
|
|
!run ||
|
|
run.companyId !== input.companyId ||
|
|
run.agentId !== input.agentId
|
|
) {
|
|
throw forbidden(
|
|
"Agent run context does not match the authenticated actor",
|
|
);
|
|
}
|
|
if (!ACTIVE_BROKER_RUN_STATUSES.has(run.status)) {
|
|
throw forbidden("Agent run is not active");
|
|
}
|
|
const snapshot = asRecord(run.contextSnapshot);
|
|
const paperclipIssue = asRecord(snapshot.paperclipIssue);
|
|
const responsibleUserId = run.activeIdentityContextId
|
|
? run.responsibleUserId
|
|
: (runSnapshotString(
|
|
snapshot,
|
|
"responsibleUserId",
|
|
"responsible_user_id",
|
|
) ??
|
|
runSnapshotString(
|
|
paperclipIssue,
|
|
"responsibleUserId",
|
|
"responsible_user_id",
|
|
) ??
|
|
run.responsibleUserId);
|
|
if (!responsibleUserId) {
|
|
throw forbidden(
|
|
"Agent run has no responsible user for delegated connection access",
|
|
);
|
|
}
|
|
const responsibleMembership = await db
|
|
.select({
|
|
status: companyMemberships.status,
|
|
membershipRole: companyMemberships.membershipRole,
|
|
})
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, run.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, responsibleUserId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
!responsibleMembership ||
|
|
responsibleMembership.status !== "active" ||
|
|
!responsibleMembership.membershipRole ||
|
|
responsibleMembership.membershipRole === "viewer"
|
|
) {
|
|
throw forbidden(
|
|
"Responsible user is no longer authorized for company write access",
|
|
);
|
|
}
|
|
return {
|
|
run,
|
|
issueId:
|
|
runSnapshotString(snapshot, "issueId") ??
|
|
runSnapshotString(paperclipIssue, "id"),
|
|
projectId:
|
|
runSnapshotString(snapshot, "projectId") ??
|
|
runSnapshotString(paperclipIssue, "projectId"),
|
|
routineId: runSnapshotString(snapshot, "routineId"),
|
|
responsibleUserId,
|
|
};
|
|
}
|
|
|
|
async function lockAuthorizedBrokerResponsibleMembership(
|
|
input: {
|
|
companyId: string;
|
|
responsibleUserId: string;
|
|
},
|
|
tx: DbTransaction,
|
|
) {
|
|
const membership = await tx
|
|
.select({
|
|
id: companyMemberships.id,
|
|
status: companyMemberships.status,
|
|
membershipRole: companyMemberships.membershipRole,
|
|
})
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, input.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, input.responsibleUserId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
!membership ||
|
|
membership.status !== "active" ||
|
|
!membership.membershipRole ||
|
|
membership.membershipRole === "viewer"
|
|
) {
|
|
throw new HttpError(
|
|
403,
|
|
"Responsible user is no longer authorized for company write access",
|
|
{
|
|
code: "responsible_user_unauthorized",
|
|
},
|
|
);
|
|
}
|
|
return membership;
|
|
}
|
|
|
|
async function recordConnectionTokenIssuance(
|
|
input: {
|
|
companyId: string;
|
|
applicationId: string | null;
|
|
connectionId: string;
|
|
agentId: string;
|
|
runId: string | null;
|
|
issueId: string | null;
|
|
projectId: string | null;
|
|
responsibleUserId: string | null;
|
|
path: ConnectionTokenIssuancePath;
|
|
requestedScope: string[];
|
|
issuedScope: string[];
|
|
ttlSeconds: number | null;
|
|
expiresAt: Date | null;
|
|
tokenHash: string | null;
|
|
outcome: ConnectionTokenIssuanceOutcome;
|
|
errorCode?: string | null;
|
|
metadata?: Record<string, unknown>;
|
|
},
|
|
dbClient: ToolAccessMutationDb = db,
|
|
) {
|
|
await dbClient.insert(connectionTokenIssuances).values({
|
|
companyId: input.companyId,
|
|
applicationId: input.applicationId,
|
|
connectionId: input.connectionId,
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
issueId: input.issueId,
|
|
projectId: input.projectId,
|
|
responsibleUserId: input.responsibleUserId,
|
|
path: input.path,
|
|
requestedScope: input.requestedScope,
|
|
issuedScope: input.issuedScope,
|
|
ttlSeconds: input.ttlSeconds,
|
|
expiresAt: input.expiresAt,
|
|
tokenHash: input.tokenHash,
|
|
outcome: input.outcome,
|
|
errorCode: input.errorCode ?? null,
|
|
metadata: input.metadata ?? {},
|
|
});
|
|
}
|
|
|
|
async function auditConnectionTokenIssuance(input: {
|
|
companyId: string;
|
|
connectionId: string;
|
|
agentId: string;
|
|
runId: string;
|
|
path: ConnectionTokenIssuancePath;
|
|
outcome: ConnectionTokenIssuanceOutcome;
|
|
reasonCode?: string | null;
|
|
details?: Record<string, unknown>;
|
|
}) {
|
|
const success = input.outcome === "success";
|
|
await audit({
|
|
companyId: input.companyId,
|
|
connectionId: input.connectionId,
|
|
action: success ? "connection_token.minted" : "connection_token.denied",
|
|
outcome: success ? "success" : "failure",
|
|
reasonCode: input.reasonCode ?? null,
|
|
actor: { actorType: "agent", actorId: input.agentId },
|
|
details: {
|
|
path: input.path,
|
|
outcome: input.outcome,
|
|
runId: input.runId,
|
|
...(input.details ?? {}),
|
|
},
|
|
});
|
|
await logActivity(db, {
|
|
companyId: input.companyId,
|
|
actorType: "agent",
|
|
actorId: input.agentId,
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
action: success ? "connection_token.minted" : "connection_token.denied",
|
|
entityType: "tool_connection",
|
|
entityId: input.connectionId,
|
|
details: {
|
|
path: input.path,
|
|
outcome: input.outcome,
|
|
reasonCode: input.reasonCode ?? null,
|
|
...(input.details ?? {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function enforceDefaultConnectionTokenRateLimit(input: {
|
|
connection: typeof toolConnections.$inferSelect;
|
|
agentId: string;
|
|
path: ConnectionTokenIssuancePath;
|
|
}) {
|
|
const broker = tokenBrokerConfig(input.connection);
|
|
const configured = Number(broker.rateLimitPerHour ?? 30);
|
|
const limit =
|
|
Number.isFinite(configured) && configured > 0
|
|
? Math.trunc(configured)
|
|
: 30;
|
|
const since = new Date(now().getTime() - 60 * 60 * 1000);
|
|
const [row] = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(connectionTokenIssuances)
|
|
.where(
|
|
and(
|
|
eq(connectionTokenIssuances.companyId, input.connection.companyId),
|
|
eq(connectionTokenIssuances.connectionId, input.connection.id),
|
|
eq(connectionTokenIssuances.agentId, input.agentId),
|
|
eq(connectionTokenIssuances.outcome, "success"),
|
|
gte(connectionTokenIssuances.createdAt, since),
|
|
),
|
|
);
|
|
const count = Number(row?.count ?? 0);
|
|
if (count >= limit) {
|
|
throw new HttpError(429, "Connection token mint rate limit exceeded", {
|
|
code: "rate_limited",
|
|
path: input.path,
|
|
limit,
|
|
windowSeconds: 3600,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function hasExplicitConnectionTokenMintProfileGrant(input: {
|
|
companyId: string;
|
|
agentId: string;
|
|
issueId: string | null;
|
|
projectId: string | null;
|
|
routineId: string | null;
|
|
}) {
|
|
const bindings = await db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(eq(toolProfileBindings.companyId, input.companyId));
|
|
const matchingBindings = bindings.filter((binding) => {
|
|
if (binding.targetType === "company")
|
|
return binding.targetId === input.companyId;
|
|
if (binding.targetType === "agent")
|
|
return binding.targetId === input.agentId;
|
|
if (binding.targetType === "issue")
|
|
return Boolean(input.issueId && binding.targetId === input.issueId);
|
|
if (binding.targetType === "project")
|
|
return Boolean(input.projectId && binding.targetId === input.projectId);
|
|
if (binding.targetType === "routine")
|
|
return Boolean(input.routineId && binding.targetId === input.routineId);
|
|
return false;
|
|
});
|
|
const profileIds = profileIdsInBindingOrder(
|
|
narrowestScopeBindings(matchingBindings),
|
|
);
|
|
if (profileIds.length === 0) return false;
|
|
const profiles = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(
|
|
and(
|
|
eq(toolProfiles.companyId, input.companyId),
|
|
inArray(toolProfiles.id, profileIds),
|
|
),
|
|
);
|
|
const activeProfileIds = profiles
|
|
.filter((profile) => profile.status === "active")
|
|
.map((profile) => profile.id);
|
|
if (activeProfileIds.length === 0) return false;
|
|
const entries = await db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, input.companyId),
|
|
inArray(toolProfileEntries.profileId, activeProfileIds),
|
|
),
|
|
);
|
|
return activeProfileIds.some((profileId) => {
|
|
const profileEntries = entries.filter(
|
|
(entry) => entry.profileId === profileId,
|
|
);
|
|
const exactBrokerEntries = profileEntries.filter(
|
|
(entry) =>
|
|
entry.selectorType === "tool_name" &&
|
|
entry.toolName === CONNECTION_TOKEN_MINT_TOOL_NAME &&
|
|
Object.keys(asRecord(entry.conditions)).length === 0,
|
|
);
|
|
if (exactBrokerEntries.some((entry) => entry.effect === "exclude"))
|
|
return false;
|
|
return exactBrokerEntries.some((entry) => entry.effect === "include");
|
|
});
|
|
}
|
|
|
|
function accessContextForBroker(input: {
|
|
connection: typeof toolConnections.$inferSelect;
|
|
agentId: string;
|
|
runId: string;
|
|
issueId: string | null;
|
|
actorSource?: ActorInfo["actorType"] | null;
|
|
configPath: string;
|
|
}) {
|
|
return {
|
|
consumerType: "tool_connection" as const,
|
|
consumerId: input.connection.id,
|
|
configPath: input.configPath,
|
|
actorType: "agent" as const,
|
|
actorId: input.agentId,
|
|
actorSource: "agent_jwt" as const,
|
|
issueId: input.issueId,
|
|
heartbeatRunId: input.runId,
|
|
};
|
|
}
|
|
|
|
function findBrokerCredentialRef(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
) {
|
|
const broker = tokenBrokerConfig(connection);
|
|
const configuredPath =
|
|
readConfigString(broker, "parentCredentialConfigPath") ??
|
|
readConfigString(broker, "credentialConfigPath") ??
|
|
readConfigString(broker, "secretConfigPath");
|
|
const configuredName =
|
|
readConfigString(broker, "parentCredentialName") ??
|
|
readConfigString(broker, "credentialName");
|
|
const secretCandidates = connection.credentialSecretRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
ref.configPath !== "oauth.refresh_token" &&
|
|
ref.configPath !== "oauth.client_secret",
|
|
);
|
|
const secretRef = configuredPath
|
|
? connection.credentialSecretRefs.find(
|
|
(ref) => ref.configPath === configuredPath,
|
|
)
|
|
: (secretCandidates.find(
|
|
(ref) => ref.configPath === "credentials.deploy_token",
|
|
) ??
|
|
secretCandidates.find(
|
|
(ref) => ref.configPath === "pages.deploy_token",
|
|
) ??
|
|
secretCandidates[0]);
|
|
if (secretRef)
|
|
return {
|
|
kind: "secret_ref" as const,
|
|
ref: secretRef,
|
|
configPath: secretRef.configPath,
|
|
};
|
|
const credentialRef = configuredName
|
|
? connection.credentialRefs.find((ref) => ref.name === configuredName)
|
|
: connection.credentialRefs[0];
|
|
if (credentialRef)
|
|
return {
|
|
kind: "credential_ref" as const,
|
|
ref: credentialRef,
|
|
configPath: `credentials.${credentialRef.name}`,
|
|
};
|
|
return null;
|
|
}
|
|
|
|
async function resolveBrokerParentCredential(
|
|
input: {
|
|
connection: typeof toolConnections.$inferSelect;
|
|
agentId: string;
|
|
runId: string;
|
|
issueId: string | null;
|
|
},
|
|
secretClient: ReturnType<typeof secretService> = secrets,
|
|
) {
|
|
const ref = findBrokerCredentialRef(input.connection);
|
|
if (!ref) {
|
|
throw unprocessable(
|
|
"Connection token exchange requires a vault-backed parent credential",
|
|
{
|
|
code: "parent_credential_missing",
|
|
},
|
|
);
|
|
}
|
|
if (ref.kind === "secret_ref") {
|
|
return secretClient.resolveSecretValue(
|
|
input.connection.companyId,
|
|
ref.ref.secretId,
|
|
ref.ref.versionSelector ?? "latest",
|
|
{
|
|
accessContext: accessContextForBroker({
|
|
...input,
|
|
configPath: ref.configPath,
|
|
}),
|
|
bindingContext: accessContextForBroker({
|
|
...input,
|
|
configPath: ref.configPath,
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
return secretClient.resolveSecretValue(
|
|
input.connection.companyId,
|
|
ref.ref.secretId,
|
|
ref.ref.version ?? "latest",
|
|
{
|
|
accessContext: accessContextForBroker({
|
|
...input,
|
|
configPath: ref.configPath,
|
|
}),
|
|
bindingContext: accessContextForBroker({
|
|
...input,
|
|
configPath: ref.configPath,
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
|
|
function exchangeTokenUrl(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
isPages: boolean,
|
|
): string {
|
|
const broker = tokenBrokerConfig(connection);
|
|
const config = asRecord(connection.config);
|
|
const url =
|
|
readConfigString(broker, "tokenUrl") ??
|
|
readConfigString(broker, "exchangeTokenUrl") ??
|
|
readConfigString(config, "tokenExchangeUrl") ??
|
|
readConfigString(config, "pagesTokenExchangeUrl");
|
|
if (url) return url;
|
|
const pagesApiBase = process.env.PAPERCLIP_PAGES_API_URL?.trim();
|
|
if (isPages && pagesApiBase)
|
|
return new URL(
|
|
"/v1/tokens/exchange",
|
|
pagesApiBase.endsWith("/") ? pagesApiBase : `${pagesApiBase}/`,
|
|
).toString();
|
|
throw unprocessable("Connection token exchange URL is not configured", {
|
|
code: "exchange_url_missing",
|
|
});
|
|
}
|
|
|
|
function pagesNamespaceFromScope(scope: string[]): string | null {
|
|
const first = scope[0];
|
|
if (!first) return null;
|
|
const match = first.match(/^pages:publish:ns\/([^/\s]+)$/);
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
async function mintExchangeConnectionToken(
|
|
input: {
|
|
connection: typeof toolConnections.$inferSelect;
|
|
application: typeof toolApplications.$inferSelect | null;
|
|
agentId: string;
|
|
runId: string;
|
|
issueId: string | null;
|
|
responsibleUserId: string | null;
|
|
scope: string[];
|
|
ttlSeconds: number;
|
|
},
|
|
secretClient: ReturnType<typeof secretService> = secrets,
|
|
) {
|
|
const isPages = isPagesTokenConnection(input.connection, input.application);
|
|
const parentToken = await resolveBrokerParentCredential(
|
|
input,
|
|
secretClient,
|
|
);
|
|
const broker = tokenBrokerConfig(input.connection);
|
|
const protocol =
|
|
readConfigString(broker, "protocol") ??
|
|
readConfigString(broker, "exchangeProtocol") ??
|
|
(isPages ? "pages" : "generic");
|
|
const url = exchangeTokenUrl(input.connection, isPages);
|
|
const actor = {
|
|
type: "agent",
|
|
id: input.agentId,
|
|
runId: input.runId,
|
|
...(input.responsibleUserId
|
|
? { onBehalfOf: `user:${input.responsibleUserId}` }
|
|
: {}),
|
|
};
|
|
let response: Response;
|
|
if (protocol === "rfc8693") {
|
|
const body = new URLSearchParams();
|
|
body.set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange");
|
|
body.set("subject_token", parentToken);
|
|
body.set(
|
|
"subject_token_type",
|
|
readConfigString(broker, "subjectTokenType") ??
|
|
"urn:ietf:params:oauth:token-type:access_token",
|
|
);
|
|
body.set("scope", input.scope.join(" "));
|
|
const audience = readConfigString(broker, "audience");
|
|
if (audience) body.set("audience", audience);
|
|
body.set(
|
|
"requested_token_type",
|
|
readConfigString(broker, "requestedTokenType") ??
|
|
"urn:ietf:params:oauth:token-type:access_token",
|
|
);
|
|
body.set(
|
|
"actor_token",
|
|
Buffer.from(JSON.stringify(actor)).toString("base64url"),
|
|
);
|
|
body.set(
|
|
"actor_token_type",
|
|
readConfigString(broker, "actorTokenType") ??
|
|
"urn:ietf:params:oauth:token-type:jwt",
|
|
);
|
|
const endpoint = parseRemoteHttpEndpoint(url, (message, code) =>
|
|
badRequest(message, { code }),
|
|
);
|
|
response = await guardedRemoteHttpFetch(
|
|
endpoint,
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body,
|
|
},
|
|
tokenBrokerHttpFetchOptions(endpoint),
|
|
);
|
|
} else {
|
|
const namespace = isPages ? pagesNamespaceFromScope(input.scope) : null;
|
|
const body =
|
|
isPages && namespace
|
|
? {
|
|
namespace,
|
|
ttlSeconds: input.ttlSeconds,
|
|
actions: ["publish"],
|
|
actor,
|
|
}
|
|
: {
|
|
scope: input.scope,
|
|
ttlSeconds: input.ttlSeconds,
|
|
actor,
|
|
audience: readConfigString(broker, "audience"),
|
|
};
|
|
const endpoint = parseRemoteHttpEndpoint(url, (message, code) =>
|
|
badRequest(message, { code }),
|
|
);
|
|
response = await guardedRemoteHttpFetch(
|
|
endpoint,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${parentToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
},
|
|
tokenBrokerHttpFetchOptions(endpoint),
|
|
);
|
|
}
|
|
const payload = (await response.json().catch(() => ({}))) as unknown;
|
|
const record = asRecord(payload);
|
|
if (!response.ok) {
|
|
const code =
|
|
typeof record.code === "string"
|
|
? record.code
|
|
: typeof record.error === "string"
|
|
? record.error
|
|
: "upstream_error";
|
|
throw new HttpError(
|
|
response.status === 401 || response.status === 403 ? 409 : 502,
|
|
"Connection token exchange failed",
|
|
{
|
|
code:
|
|
code === "parent_revoked" ? "credential_revoked" : "upstream_error",
|
|
upstreamCode: code,
|
|
upstreamStatus: response.status,
|
|
upstreamRequestId:
|
|
typeof record.requestId === "string" ? record.requestId : null,
|
|
},
|
|
);
|
|
}
|
|
const token =
|
|
typeof record.token === "string"
|
|
? record.token
|
|
: typeof record.access_token === "string"
|
|
? record.access_token
|
|
: null;
|
|
if (!token)
|
|
throw new HttpError(
|
|
502,
|
|
"Connection token exchange did not return a token",
|
|
{ code: "upstream_token_missing" },
|
|
);
|
|
const expiresIn =
|
|
typeof record.expires_in === "number"
|
|
? record.expires_in
|
|
: Number(record.expires_in);
|
|
const expiresAt =
|
|
typeof record.expiresAt === "string" &&
|
|
Number.isFinite(Date.parse(record.expiresAt))
|
|
? new Date(record.expiresAt)
|
|
: typeof record.expires_at === "string" &&
|
|
Number.isFinite(Date.parse(record.expires_at))
|
|
? new Date(record.expires_at)
|
|
: new Date(
|
|
now().getTime() +
|
|
Math.min(
|
|
input.ttlSeconds,
|
|
Number.isFinite(expiresIn) && expiresIn > 0
|
|
? expiresIn
|
|
: input.ttlSeconds,
|
|
) *
|
|
1000,
|
|
);
|
|
const responseScope =
|
|
readConfigStringArray(record.scope).length > 0
|
|
? readConfigStringArray(record.scope)
|
|
: input.scope;
|
|
return {
|
|
token,
|
|
tokenType:
|
|
typeof record.token_type === "string" ? record.token_type : "Bearer",
|
|
expiresAt,
|
|
scope: responseScope,
|
|
};
|
|
}
|
|
|
|
function runtimeAlert(
|
|
input: ToolRuntimeAlertRecommendation,
|
|
): ToolRuntimeAlertRecommendation {
|
|
return input;
|
|
}
|
|
|
|
function buildRuntimeAlerts(input: {
|
|
stuckStartingSlots: number;
|
|
stuckRunningSlots: number;
|
|
timeoutRate: number;
|
|
timeoutCount: number;
|
|
failureRate: number;
|
|
failureCount: number;
|
|
capacityDeferrals: number;
|
|
restartAttempts: number;
|
|
restartSuppressions: number;
|
|
degradedConnections: number;
|
|
disabledConnections: number;
|
|
missingSecretFailures: number;
|
|
auditWriteFailures: number;
|
|
}): ToolRuntimeAlertRecommendation[] {
|
|
const runbookSection = "doc/MCP-RUNTIME-OPERATIONS.md";
|
|
const timeoutSeverity =
|
|
input.timeoutCount >= 10 || input.timeoutRate >= 25
|
|
? "critical"
|
|
: input.timeoutCount >= 3 && input.timeoutRate >= 10
|
|
? "warning"
|
|
: "warning";
|
|
const failureSeverity =
|
|
input.failureCount >= 10 || input.failureRate >= 25
|
|
? "critical"
|
|
: input.failureCount >= 5 && input.failureRate >= 10
|
|
? "warning"
|
|
: "warning";
|
|
const restartSeverity =
|
|
input.restartSuppressions > 0 ? "critical" : "warning";
|
|
return [
|
|
runtimeAlert({
|
|
name: "mcp_runtime_stuck_starting_slot",
|
|
severity: "critical",
|
|
status: input.stuckStartingSlots > 0 ? "firing" : "ok",
|
|
threshold: "Any starting slot older than 5 minutes.",
|
|
observed: `${input.stuckStartingSlots} stuck starting slot(s).`,
|
|
description:
|
|
"A local stdio runtime slot is stuck before it reaches running state.",
|
|
firstResponderAction:
|
|
"Inspect the slot health/logs, stop the slot, restart it once, then disable the connection if the slot sticks again.",
|
|
runbookSection,
|
|
}),
|
|
runtimeAlert({
|
|
name: "mcp_runtime_stuck_running_slot",
|
|
severity: "critical",
|
|
status: input.stuckRunningSlots > 0 ? "firing" : "ok",
|
|
threshold: "Any running slot with no progress for 5 minutes.",
|
|
observed: `${input.stuckRunningSlots} stuck running slot(s).`,
|
|
description:
|
|
"A runtime slot is running but has not recorded progress inside the supervisor stuck-slot window.",
|
|
firstResponderAction:
|
|
"Inspect recent audit events and active tool calls; restart the slot only after confirming no healthy call is still in progress.",
|
|
runbookSection,
|
|
}),
|
|
runtimeAlert({
|
|
name: "mcp_runtime_high_timeout_rate",
|
|
severity: timeoutSeverity,
|
|
status:
|
|
input.timeoutCount >= 3 && input.timeoutRate >= 10 ? "firing" : "ok",
|
|
threshold:
|
|
"Warning at >=3 timeouts and >=10% timeout rate in 1 hour; critical at >=10 timeouts or >=25%.",
|
|
observed: `${input.timeoutCount} timeout(s), ${input.timeoutRate}% timeout rate.`,
|
|
description:
|
|
"Tool gateway calls are timing out or being runtime-deferred at an elevated rate.",
|
|
firstResponderAction:
|
|
"Check upstream MCP health, Paperclip runtime capacity, and recent gateway audit failures before retrying workloads.",
|
|
runbookSection,
|
|
}),
|
|
runtimeAlert({
|
|
name: "mcp_runtime_high_error_rate",
|
|
severity: failureSeverity,
|
|
status:
|
|
input.failureCount >= 5 && input.failureRate >= 10 ? "firing" : "ok",
|
|
threshold:
|
|
"Warning at >=5 failures and >=10% failure rate in 1 hour; critical at >=10 failures or >=25%.",
|
|
observed: `${input.failureCount} failure(s), ${input.failureRate}% failure rate.`,
|
|
description:
|
|
"Tool gateway calls are failing after policy authorization.",
|
|
firstResponderAction:
|
|
"Group audit failures by reasonCode, then fix credentials/config or disable the affected connection.",
|
|
runbookSection,
|
|
}),
|
|
runtimeAlert({
|
|
name: "mcp_runtime_capacity_deferrals_repeated",
|
|
severity: input.capacityDeferrals >= 10 ? "critical" : "warning",
|
|
status: input.capacityDeferrals >= 3 ? "firing" : "ok",
|
|
threshold:
|
|
"Warning at >=3 capacity deferrals in 1 hour; critical at >=10.",
|
|
observed: `${input.capacityDeferrals} capacity deferral(s) in 1 hour.`,
|
|
description:
|
|
"The runtime supervisor is refusing local stdio work because company or host slot capacity is exhausted.",
|
|
firstResponderAction:
|
|
"Stop idle/stale slots, lower noisy workloads, or raise slot caps only after confirming host capacity.",
|
|
runbookSection,
|
|
}),
|
|
runtimeAlert({
|
|
name: "mcp_runtime_restart_storm",
|
|
severity: restartSeverity,
|
|
status:
|
|
input.restartSuppressions > 0 || input.restartAttempts >= 3
|
|
? "firing"
|
|
: "ok",
|
|
threshold:
|
|
"Warning at >=3 restarts in 1 hour; critical on any restart suppression.",
|
|
observed: `${input.restartAttempts} restart attempt(s), ${input.restartSuppressions} suppression(s).`,
|
|
description:
|
|
"Runtime slots are restarting repeatedly or have hit restart-storm suppression.",
|
|
firstResponderAction:
|
|
"Stop the affected slot, inspect stderr/audit reason codes, and keep the connection disabled until the template/upstream is fixed.",
|
|
runbookSection,
|
|
}),
|
|
runtimeAlert({
|
|
name: "mcp_runtime_connection_health_degraded",
|
|
severity: input.degradedConnections > 0 ? "critical" : "warning",
|
|
status:
|
|
input.degradedConnections > 0 || input.disabledConnections > 0
|
|
? "firing"
|
|
: "ok",
|
|
threshold:
|
|
"Any active enabled connection with degraded/failed/missing-secret health, or any disabled enabled-path connection.",
|
|
observed: `${input.degradedConnections} degraded connection(s), ${input.disabledConnections} disabled connection(s).`,
|
|
description:
|
|
"A configured MCP connection is not healthy or has been disabled.",
|
|
firstResponderAction:
|
|
"Run a connection health check, refresh catalog after recovery, or keep the connection disabled and route agents to alternatives.",
|
|
runbookSection,
|
|
}),
|
|
runtimeAlert({
|
|
name: "mcp_runtime_missing_secret_failures",
|
|
severity: input.missingSecretFailures >= 3 ? "critical" : "warning",
|
|
status: input.missingSecretFailures > 0 ? "firing" : "ok",
|
|
threshold:
|
|
"Warning on any missing-secret failure; critical at >=3 in 1 hour.",
|
|
observed: `${input.missingSecretFailures} missing-secret failure(s) in 1 hour.`,
|
|
description:
|
|
"A connection or tool call needed a bound secret that could not be resolved.",
|
|
firstResponderAction:
|
|
"Check secret bindings and provider health without printing secret values; rotate or rebind missing secrets.",
|
|
runbookSection,
|
|
}),
|
|
runtimeAlert({
|
|
name: "mcp_runtime_audit_write_failures",
|
|
severity: "critical",
|
|
status: input.auditWriteFailures > 0 ? "firing" : "ok",
|
|
threshold: "Any audit write failure.",
|
|
observed: `${input.auditWriteFailures} audit write failure(s) in 1 hour.`,
|
|
description:
|
|
"Tool gateway audit writes failed, reducing incident traceability.",
|
|
firstResponderAction:
|
|
"Treat as a control-plane incident: check database writes, activity log writes, and retry only after audit durability is restored.",
|
|
runbookSection,
|
|
}),
|
|
];
|
|
}
|
|
|
|
async function runtimeHealth(
|
|
companyId: string,
|
|
): Promise<ToolRuntimeHealthSummary> {
|
|
const generatedAt = now();
|
|
const windowStartedAt = new Date(generatedAt.getTime() - 60 * 60 * 1000);
|
|
const stuckSlotMs = 5 * 60 * 1000;
|
|
const [
|
|
slots,
|
|
connections,
|
|
auditRows,
|
|
callEvents,
|
|
auditWriteFailureCounterRows,
|
|
] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(toolRuntimeSlots)
|
|
.where(eq(toolRuntimeSlots.companyId, companyId)),
|
|
db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.companyId, companyId)),
|
|
db
|
|
.select()
|
|
.from(toolAccessAuditEvents)
|
|
.where(
|
|
and(
|
|
eq(toolAccessAuditEvents.companyId, companyId),
|
|
gte(toolAccessAuditEvents.createdAt, windowStartedAt),
|
|
),
|
|
)
|
|
.orderBy(desc(toolAccessAuditEvents.createdAt)),
|
|
db
|
|
.select()
|
|
.from(toolCallEvents)
|
|
.where(
|
|
and(
|
|
eq(toolCallEvents.companyId, companyId),
|
|
gte(toolCallEvents.createdAt, windowStartedAt),
|
|
),
|
|
)
|
|
.orderBy(desc(toolCallEvents.createdAt)),
|
|
db
|
|
.select({
|
|
count: sql<number>`coalesce(sum(${toolRuntimeMetricCounters.count}), 0)::int`,
|
|
})
|
|
.from(toolRuntimeMetricCounters)
|
|
.where(
|
|
and(
|
|
eq(toolRuntimeMetricCounters.companyId, companyId),
|
|
eq(
|
|
toolRuntimeMetricCounters.metric,
|
|
TOOL_RUNTIME_AUDIT_WRITE_FAILURE_METRIC,
|
|
),
|
|
gte(toolRuntimeMetricCounters.bucketStartAt, windowStartedAt),
|
|
),
|
|
),
|
|
]);
|
|
const activeSlots = slots.filter(
|
|
(slot) =>
|
|
slot.status === "starting" ||
|
|
slot.status === "running" ||
|
|
slot.status === "idle",
|
|
);
|
|
const staleActiveSlots = activeSlots.filter((slot) => {
|
|
const lastProgressAt =
|
|
slot.lastUsedAt ?? slot.startedAt ?? slot.updatedAt;
|
|
return generatedAt.getTime() - lastProgressAt.getTime() > stuckSlotMs;
|
|
});
|
|
const callTerminalEvents = callEvents.filter(
|
|
(event) =>
|
|
event.eventType === "call_completed" ||
|
|
event.eventType === "call_failed" ||
|
|
event.eventType === "call_denied",
|
|
);
|
|
const toolCallsLastHour = callTerminalEvents.length;
|
|
const toolTimeoutsLastHour = callTerminalEvents.filter(
|
|
(event) => event.outcome === "timeout",
|
|
).length;
|
|
const toolFailuresLastHour = callTerminalEvents.filter(
|
|
(event) => event.outcome === "failure",
|
|
).length;
|
|
const durations = auditRows
|
|
.map((row) => numberValue(asRecord(row.details).durationMs))
|
|
.filter((value): value is number => value !== null && value >= 0);
|
|
const capacityDeferrals = auditRows.filter(
|
|
(row) =>
|
|
row.action === "runtime_deferred" ||
|
|
row.reasonCode === "runtime_company_capacity_exhausted" ||
|
|
row.reasonCode === "runtime_host_capacity_exhausted",
|
|
).length;
|
|
const restartAttempts = auditRows.filter(
|
|
(row) =>
|
|
row.action === "runtime_started" && row.reasonCode !== "lazy_start",
|
|
).length;
|
|
const restartSuppressions = auditRows.filter(
|
|
(row) =>
|
|
row.action === "runtime_restart_suppressed" ||
|
|
row.reasonCode === "runtime_restart_suppressed",
|
|
).length;
|
|
const idleEvictions = auditRows.filter(
|
|
(row) =>
|
|
row.action === "runtime_stopped" &&
|
|
row.reasonCode === "idle_ttl_expired",
|
|
).length;
|
|
const missingSecretFailures = auditRows.filter(
|
|
(row) =>
|
|
row.reasonCode === "missing_secret" ||
|
|
(row.outcome === "failure" && row.reasonCode?.includes("secret")),
|
|
).length;
|
|
const legacyAuditWriteFailures = auditRows.filter(
|
|
(row) =>
|
|
row.action === "runtime_audit_write_failed" ||
|
|
row.reasonCode === "audit_write_failed",
|
|
).length;
|
|
const auditWriteFailuresMetric =
|
|
Number(auditWriteFailureCounterRows[0]?.count ?? 0) +
|
|
legacyAuditWriteFailures;
|
|
const enabledPathConnections = connections.filter(
|
|
(connection) => connection.status === "active" && connection.enabled,
|
|
);
|
|
const activeConnections = enabledPathConnections.length;
|
|
const disabledConnections = connections.filter(
|
|
(connection) => connection.status === "disabled",
|
|
).length;
|
|
const degradedConnections = enabledPathConnections.filter((connection) =>
|
|
["degraded", "failed", "error", "missing_secret"].includes(
|
|
connection.healthStatus,
|
|
),
|
|
).length;
|
|
const metrics = {
|
|
windowStartedAt,
|
|
windowEndedAt: generatedAt,
|
|
activeSlots: activeSlots.length,
|
|
startingSlots: slots.filter((slot) => slot.status === "starting").length,
|
|
runningSlots: slots.filter((slot) => slot.status === "running").length,
|
|
idleSlots: slots.filter((slot) => slot.status === "idle").length,
|
|
failedSlots: slots.filter(
|
|
(slot) => slot.status === "failed" || slot.status === "error",
|
|
).length,
|
|
stoppedSlots: slots.filter(
|
|
(slot) => slot.status === "stopped" || slot.status === "disabled",
|
|
).length,
|
|
stuckStartingSlots: staleActiveSlots.filter(
|
|
(slot) => slot.status === "starting",
|
|
).length,
|
|
stuckRunningSlots: staleActiveSlots.filter(
|
|
(slot) => slot.status === "running",
|
|
).length,
|
|
capacityDeferralsLastHour: capacityDeferrals,
|
|
restartAttemptsLastHour: restartAttempts,
|
|
restartSuppressionsLastHour: restartSuppressions,
|
|
idleEvictionsLastHour: idleEvictions,
|
|
toolCallsLastHour,
|
|
toolTimeoutsLastHour,
|
|
toolFailuresLastHour,
|
|
timeoutRateLastHour: percent(toolTimeoutsLastHour, toolCallsLastHour),
|
|
failureRateLastHour: percent(toolFailuresLastHour, toolCallsLastHour),
|
|
averageToolLatencyMsLastHour:
|
|
durations.length > 0
|
|
? Math.round(
|
|
durations.reduce((sum, value) => sum + value, 0) /
|
|
durations.length,
|
|
)
|
|
: null,
|
|
p95ToolLatencyMsLastHour: percentile(durations, 95),
|
|
missingSecretFailuresLastHour: missingSecretFailures,
|
|
auditWriteFailuresLastHour: auditWriteFailuresMetric,
|
|
activeConnections,
|
|
disabledConnections,
|
|
degradedConnections,
|
|
remoteHttpConnections: connections.filter(
|
|
(connection) =>
|
|
connection.status !== "archived" &&
|
|
connection.transport === "mcp_remote",
|
|
).length,
|
|
localStdioConnections: connections.filter(
|
|
(connection) =>
|
|
connection.status !== "archived" &&
|
|
connection.transport === "local_stdio",
|
|
).length,
|
|
};
|
|
const recommendations = buildRuntimeAlerts({
|
|
stuckStartingSlots: metrics.stuckStartingSlots,
|
|
stuckRunningSlots: metrics.stuckRunningSlots,
|
|
timeoutRate: metrics.timeoutRateLastHour,
|
|
timeoutCount: metrics.toolTimeoutsLastHour,
|
|
failureRate: metrics.failureRateLastHour,
|
|
failureCount: metrics.toolFailuresLastHour,
|
|
capacityDeferrals,
|
|
restartAttempts,
|
|
restartSuppressions,
|
|
degradedConnections,
|
|
disabledConnections,
|
|
missingSecretFailures,
|
|
auditWriteFailures: metrics.auditWriteFailuresLastHour,
|
|
});
|
|
const firing = recommendations.filter((alert) => alert.status === "firing");
|
|
const status = firing.some((alert) => alert.severity === "critical")
|
|
? "critical"
|
|
: firing.length > 0
|
|
? "degraded"
|
|
: "ok";
|
|
const deploymentMode = options.deploymentMode ?? "local_trusted";
|
|
const deploymentExposure = options.deploymentExposure ?? "private";
|
|
const localStdioSupported =
|
|
deploymentMode === "local_trusted" || Boolean(trustedRuntimeHost());
|
|
return {
|
|
status,
|
|
generatedAt,
|
|
runbookPath: "doc/MCP-RUNTIME-OPERATIONS.md",
|
|
metrics,
|
|
supportMatrix: {
|
|
remoteHttp: {
|
|
supported: true,
|
|
note: "mcp_remote MCP connections are supported in hosted cloud and local deployments.",
|
|
},
|
|
localStdio: {
|
|
supported: localStdioSupported,
|
|
note: localStdioSupported
|
|
? "local_stdio is available for local trusted mode or through the configured trusted MCP runtime host."
|
|
: `local_stdio should stay disabled for ${deploymentMode}/${deploymentExposure}; use mcp_remote or configure a trusted runtime worker.`,
|
|
},
|
|
},
|
|
alerts: firing,
|
|
recommendations,
|
|
};
|
|
}
|
|
|
|
async function runtimeSlotById(
|
|
companyId: string,
|
|
slotId: string,
|
|
): Promise<ToolRuntimeSlot> {
|
|
const [row] = await db
|
|
.select()
|
|
.from(toolRuntimeSlots)
|
|
.where(
|
|
and(
|
|
eq(toolRuntimeSlots.companyId, companyId),
|
|
eq(toolRuntimeSlots.id, slotId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!row) throw notFound("Runtime slot not found");
|
|
return toRuntimeSlot(row);
|
|
}
|
|
|
|
function runtimeSupervisorHttpError(error: ToolRuntimeSupervisorError) {
|
|
return new HttpError(error.status, error.message, {
|
|
code: error.reasonCode,
|
|
...error.details,
|
|
});
|
|
}
|
|
|
|
async function controlRuntimeSlot(input: {
|
|
companyId: string;
|
|
slotId: string;
|
|
action: "stop" | "restart";
|
|
actor?: ActorInfo;
|
|
}): Promise<ToolRuntimeSlot> {
|
|
try {
|
|
if (input.action === "stop") {
|
|
await runtimeSupervisor.stopSlot({
|
|
companyId: input.companyId,
|
|
slotId: input.slotId,
|
|
reason: "operator_stop",
|
|
});
|
|
} else {
|
|
await runtimeSupervisor.restartSlot({
|
|
companyId: input.companyId,
|
|
slotId: input.slotId,
|
|
});
|
|
}
|
|
const slot = await runtimeSlotById(input.companyId, input.slotId);
|
|
await logActivity(db, {
|
|
companyId: input.companyId,
|
|
actorType: input.actor?.actorType ?? "system",
|
|
actorId: input.actor?.actorId ?? "tool-access-service",
|
|
action:
|
|
input.action === "stop"
|
|
? "tool_runtime_slot.operator_stopped"
|
|
: "tool_runtime_slot.operator_restarted",
|
|
entityType: "tool_runtime_slot",
|
|
entityId: input.slotId,
|
|
details: {
|
|
runtimeKind: slot.runtimeKind,
|
|
status: slot.status,
|
|
slotKey: slot.slotKey,
|
|
},
|
|
});
|
|
return slot;
|
|
} catch (error) {
|
|
if (error instanceof ToolRuntimeSupervisorError) {
|
|
throw runtimeSupervisorHttpError(error);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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 assertSecretRefs(
|
|
companyId: string,
|
|
refs: Array<{
|
|
secretId: string;
|
|
configPath?: string | null;
|
|
projectionClass?: string | null;
|
|
projectionAllowlistKey?: string | null;
|
|
}>,
|
|
) {
|
|
if (refs.length === 0) return;
|
|
for (const ref of refs) {
|
|
assertClass3ToolCredentialRefAllowed(ref);
|
|
}
|
|
const secretIds = [...new Set(refs.map((ref) => ref.secretId))];
|
|
for (const secretId of secretIds) {
|
|
const [secret] = await db
|
|
.select({ id: companySecrets.id })
|
|
.from(companySecrets)
|
|
.where(
|
|
and(
|
|
eq(companySecrets.id, secretId),
|
|
eq(companySecrets.companyId, companyId),
|
|
),
|
|
);
|
|
if (!secret)
|
|
throw unprocessable(
|
|
"Tool connection credential secrets must belong to the same company",
|
|
);
|
|
}
|
|
}
|
|
|
|
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 assertTargetExists(
|
|
companyId: string,
|
|
targetType: CreateToolProfileBindingForProfile["targetType"],
|
|
targetId: string,
|
|
) {
|
|
if (targetType === "company") {
|
|
if (targetId !== companyId)
|
|
throw unprocessable(
|
|
"Company profile bindings must target the same company id",
|
|
);
|
|
return;
|
|
}
|
|
if (targetType === "agent") {
|
|
const [row] = await db
|
|
.select({ id: agents.id })
|
|
.from(agents)
|
|
.where(and(eq(agents.id, targetId), eq(agents.companyId, companyId)));
|
|
if (!row)
|
|
throw unprocessable(
|
|
"Tool profile agent binding target must belong to the same company",
|
|
);
|
|
return;
|
|
}
|
|
if (targetType === "project") {
|
|
const [row] = await db
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(
|
|
and(eq(projects.id, targetId), eq(projects.companyId, companyId)),
|
|
);
|
|
if (!row)
|
|
throw unprocessable(
|
|
"Tool profile project binding target must belong to the same company",
|
|
);
|
|
return;
|
|
}
|
|
if (targetType === "routine") {
|
|
const [row] = await db
|
|
.select({ id: routines.id })
|
|
.from(routines)
|
|
.where(
|
|
and(eq(routines.id, targetId), eq(routines.companyId, companyId)),
|
|
);
|
|
if (!row)
|
|
throw unprocessable(
|
|
"Tool profile routine binding target must belong to the same company",
|
|
);
|
|
return;
|
|
}
|
|
if (targetType === "issue") {
|
|
const [row] = await db
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(and(eq(issues.id, targetId), eq(issues.companyId, companyId)));
|
|
if (!row)
|
|
throw unprocessable(
|
|
"Tool profile issue binding target must belong to the same company",
|
|
);
|
|
return;
|
|
}
|
|
if (targetType === "gateway") {
|
|
const [row] = await db
|
|
.select({ id: toolMcpGateways.id })
|
|
.from(toolMcpGateways)
|
|
.where(
|
|
and(
|
|
eq(toolMcpGateways.id, targetId),
|
|
eq(toolMcpGateways.companyId, companyId),
|
|
),
|
|
);
|
|
if (!row)
|
|
throw unprocessable(
|
|
"Tool profile gateway binding target must belong to the same company",
|
|
);
|
|
}
|
|
}
|
|
|
|
async function appProfileForConnection(
|
|
dbClient: Pick<Db, "select" | "insert" | "delete">,
|
|
connection: typeof toolConnections.$inferSelect,
|
|
) {
|
|
const profileKey = `app:${connection.id}`;
|
|
let [profile] = await dbClient
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(
|
|
and(
|
|
eq(toolProfiles.companyId, connection.companyId),
|
|
eq(toolProfiles.profileKey, profileKey),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!profile) {
|
|
const [sameName] = await dbClient
|
|
.select({ id: toolProfiles.id })
|
|
.from(toolProfiles)
|
|
.where(
|
|
and(
|
|
eq(toolProfiles.companyId, connection.companyId),
|
|
eq(toolProfiles.name, connection.name),
|
|
),
|
|
)
|
|
.limit(1);
|
|
const profileName = sameName
|
|
? `${connection.name} (${connection.id.replace(/-/g, "").slice(0, 8)})`
|
|
: connection.name;
|
|
[profile] = await dbClient
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId: connection.companyId,
|
|
profileKey,
|
|
name: profileName,
|
|
description: `Access profile for ${connection.name}.`,
|
|
status: "active",
|
|
defaultAction: "deny",
|
|
metadata: {
|
|
source: "tool_connection_install",
|
|
connectionId: connection.id,
|
|
},
|
|
})
|
|
.returning();
|
|
}
|
|
// Installation controls where a connection is exposed, not which actions
|
|
// it grants. The app wizard's catalog-entry includes are the authority for
|
|
// action selection, so remove the legacy connection-wide include that used
|
|
// to silently turn every installed action on.
|
|
await dbClient
|
|
.delete(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, connection.companyId),
|
|
eq(toolProfileEntries.profileId, profile.id),
|
|
eq(toolProfileEntries.selectorType, "connection"),
|
|
eq(toolProfileEntries.effect, "include"),
|
|
eq(toolProfileEntries.connectionId, connection.id),
|
|
),
|
|
);
|
|
return profile;
|
|
}
|
|
|
|
async function enableCatalogEntriesByDefault(input: {
|
|
connection: typeof toolConnections.$inferSelect;
|
|
newCatalogEntryIds: string[];
|
|
activeCatalogEntryIds: string[];
|
|
restoreDraftDefaults?: boolean;
|
|
actor?: ActorInfo;
|
|
}) {
|
|
// Catalog discovery also runs while the setup wizard is still a draft.
|
|
// Access is not granted until the operator finishes that wizard, so a
|
|
// draft refresh must never manufacture a profile or company-wide binding.
|
|
// Active legacy connections may still need the managed profile created on
|
|
// their first refresh. A reconnect is the one draft exception: removal
|
|
// deliberately clears the old selections, so reconnecting restores the
|
|
// documented defaults without activating the connection itself.
|
|
if (input.connection.status !== "active" && !input.restoreDraftDefaults)
|
|
return;
|
|
const profileKey = `app:${input.connection.id}`;
|
|
let [profile] = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(
|
|
and(
|
|
eq(toolProfiles.companyId, input.connection.companyId),
|
|
eq(toolProfiles.profileKey, profileKey),
|
|
),
|
|
)
|
|
.limit(1);
|
|
const createdProfile = !profile;
|
|
const resetToRecommendedDefaults = !profile || profile.status !== "active";
|
|
if (!profile) {
|
|
const [sameName] = await db
|
|
.select({ id: toolProfiles.id })
|
|
.from(toolProfiles)
|
|
.where(
|
|
and(
|
|
eq(toolProfiles.companyId, input.connection.companyId),
|
|
eq(toolProfiles.name, input.connection.name),
|
|
),
|
|
)
|
|
.limit(1);
|
|
const profileName = sameName
|
|
? `${input.connection.name} (${input.connection.id.replace(/-/g, "").slice(0, 8)})`
|
|
: input.connection.name;
|
|
[profile] = await db
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId: input.connection.companyId,
|
|
profileKey,
|
|
name: profileName,
|
|
description: `Access profile for ${input.connection.name}.`,
|
|
status: "active",
|
|
defaultAction: "deny",
|
|
metadata: {
|
|
source: "app_gallery_finish",
|
|
connectionId: input.connection.id,
|
|
},
|
|
})
|
|
.returning();
|
|
await db.insert(toolProfileBindings).values({
|
|
companyId: input.connection.companyId,
|
|
profileId: profile.id,
|
|
targetType: "company",
|
|
targetId: input.connection.companyId,
|
|
priority: 100,
|
|
metadata: { source: "app_gallery_finish" },
|
|
createdByAgentId:
|
|
input.actor?.actorType === "agent"
|
|
? (input.actor.actorId ?? null)
|
|
: null,
|
|
createdByUserId:
|
|
input.actor?.actorType === "user"
|
|
? (input.actor.actorId ?? null)
|
|
: null,
|
|
});
|
|
} else if (resetToRecommendedDefaults) {
|
|
// Removing an app may retain its profile row when another record still
|
|
// references it (for example, an MCP gateway). Reconnecting revives the
|
|
// same connection id, so restore that retained profile as a fresh
|
|
// all-agents assignment instead of leaving every read action Off.
|
|
[profile] = await db
|
|
.update(toolProfiles)
|
|
.set({
|
|
name: input.connection.name,
|
|
description: `Access profile for ${input.connection.name}.`,
|
|
status: "active",
|
|
defaultAction: "deny",
|
|
metadata: {
|
|
source: "app_gallery_finish",
|
|
connectionId: input.connection.id,
|
|
},
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolProfiles.id, profile.id))
|
|
.returning();
|
|
await db
|
|
.delete(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, input.connection.companyId),
|
|
eq(toolProfileBindings.profileId, profile.id),
|
|
),
|
|
);
|
|
await db
|
|
.delete(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, input.connection.companyId),
|
|
eq(toolProfileEntries.profileId, profile.id),
|
|
),
|
|
);
|
|
await db.insert(toolProfileBindings).values({
|
|
companyId: input.connection.companyId,
|
|
profileId: profile.id,
|
|
targetType: "company",
|
|
targetId: input.connection.companyId,
|
|
priority: 100,
|
|
metadata: { source: "app_gallery_finish" },
|
|
createdByAgentId:
|
|
input.actor?.actorType === "agent"
|
|
? (input.actor.actorId ?? null)
|
|
: null,
|
|
createdByUserId:
|
|
input.actor?.actorType === "user"
|
|
? (input.actor.actorId ?? null)
|
|
: null,
|
|
});
|
|
}
|
|
|
|
if (resetToRecommendedDefaults) {
|
|
// Old setup defaults created app-scoped Ask first policies for writes.
|
|
// They outrank profile allows, so a revived connection must explicitly
|
|
// retire them to make every action Allowed. Policies outside this app's
|
|
// managed profile are intentionally untouched.
|
|
await upsertAskFirstPolicies({
|
|
companyId: input.connection.companyId,
|
|
connection: input.connection,
|
|
askFirstEntries: [],
|
|
actor: input.actor,
|
|
});
|
|
}
|
|
|
|
// A new or revived connection starts with every discovered action enabled.
|
|
// Later refreshes extend an active managed profile only for genuinely new
|
|
// actions, so an action the operator deliberately turned off remains off.
|
|
const candidateIds = [
|
|
...new Set(
|
|
createdProfile || resetToRecommendedDefaults
|
|
? input.activeCatalogEntryIds
|
|
: input.newCatalogEntryIds,
|
|
),
|
|
];
|
|
if (candidateIds.length === 0) return;
|
|
const existingEntries = await db
|
|
.select({ catalogEntryId: toolProfileEntries.catalogEntryId })
|
|
.from(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, input.connection.companyId),
|
|
eq(toolProfileEntries.profileId, profile.id),
|
|
inArray(toolProfileEntries.catalogEntryId, candidateIds),
|
|
),
|
|
);
|
|
const configuredIds = new Set(
|
|
existingEntries.flatMap((entry) =>
|
|
entry.catalogEntryId ? [entry.catalogEntryId] : [],
|
|
),
|
|
);
|
|
const entryIds = candidateIds.filter((id) => !configuredIds.has(id));
|
|
if (entryIds.length === 0) return;
|
|
await db.insert(toolProfileEntries).values(
|
|
entryIds.map((catalogEntryId) => ({
|
|
companyId: input.connection.companyId,
|
|
profileId: profile.id,
|
|
selectorType: "catalog_entry" as const,
|
|
effect: "include" as const,
|
|
applicationId: input.connection.applicationId,
|
|
connectionId: input.connection.id,
|
|
catalogEntryId,
|
|
})),
|
|
);
|
|
}
|
|
|
|
async function listConnectionInstalls(
|
|
connectionId: string,
|
|
companyId?: string,
|
|
): Promise<ToolConnectionInstall[]> {
|
|
const connection = await getConnectionRow(connectionId, companyId);
|
|
const rows = await db
|
|
.select()
|
|
.from(toolConnectionInstalls)
|
|
.where(
|
|
and(
|
|
eq(toolConnectionInstalls.companyId, connection.companyId),
|
|
eq(toolConnectionInstalls.connectionId, connection.id),
|
|
),
|
|
)
|
|
.orderBy(
|
|
asc(toolConnectionInstalls.targetType),
|
|
asc(toolConnectionInstalls.targetId),
|
|
);
|
|
return rows.map(toConnectionInstall);
|
|
}
|
|
|
|
async function resolveInstalledConnectionsForAgent(
|
|
companyId: string,
|
|
agentId: string,
|
|
): Promise<ToolConnection[]> {
|
|
await assertOptionalAgent(
|
|
companyId,
|
|
agentId,
|
|
"Tool connection install agent",
|
|
);
|
|
const installRows = await db
|
|
.select()
|
|
.from(toolConnectionInstalls)
|
|
.where(
|
|
and(
|
|
eq(toolConnectionInstalls.companyId, companyId),
|
|
sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${agentId}))`,
|
|
),
|
|
);
|
|
if (installRows.length === 0) return [];
|
|
const connectionIds = [
|
|
...new Set(installRows.map((install) => install.connectionId)),
|
|
];
|
|
const rows = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.companyId, companyId),
|
|
inArray(toolConnections.id, connectionIds),
|
|
),
|
|
)
|
|
.orderBy(asc(toolConnections.name));
|
|
return rows.map((row) => ({
|
|
...toConnection(row),
|
|
installs: installRows
|
|
.filter((install) => install.connectionId === row.id)
|
|
.map(toConnectionInstall),
|
|
}));
|
|
}
|
|
|
|
async function assertProfileEntryInput(
|
|
companyId: string,
|
|
input: CreateToolProfileEntryForProfile,
|
|
) {
|
|
if (input.selectorType === "application" && !input.applicationId) {
|
|
throw badRequest("Application profile entries require applicationId");
|
|
}
|
|
if (input.selectorType === "connection" && !input.connectionId) {
|
|
throw badRequest("Connection profile entries require connectionId");
|
|
}
|
|
if (input.selectorType === "catalog_entry" && !input.catalogEntryId) {
|
|
throw badRequest("Catalog-entry profile entries require catalogEntryId");
|
|
}
|
|
if (input.selectorType === "tool_name" && !input.toolName) {
|
|
throw badRequest("Tool-name profile entries require toolName");
|
|
}
|
|
if (input.selectorType === "risk_level" && !input.riskLevel) {
|
|
throw badRequest("Risk-level profile entries require riskLevel");
|
|
}
|
|
if (input.applicationId)
|
|
await assertApplication(companyId, input.applicationId);
|
|
if (input.connectionId)
|
|
await getConnectionRow(input.connectionId, companyId);
|
|
if (input.catalogEntryId)
|
|
await assertCatalogEntry(companyId, input.catalogEntryId);
|
|
}
|
|
|
|
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 ensureDefaultOrganizationGrant(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
dbClient: ToolAccessMutationDb = db,
|
|
onMutation?: (mutation: {
|
|
previous: typeof connectionGrants.$inferSelect | null;
|
|
current: typeof connectionGrants.$inferSelect;
|
|
}) => void,
|
|
) {
|
|
const [existing] = await dbClient
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "organization"),
|
|
eq(connectionGrants.isDefault, true),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (existing) {
|
|
// OAuth connections create their organization grant before the browser
|
|
// callback has issued any credentials. Reconnects can rotate those
|
|
// credentials later as well. Keep the execution grant synchronized with
|
|
// the connection so the gateway projects the current secrets instead of
|
|
// sending an unauthenticated request after an apparently successful
|
|
// setup. Reconnecting is also the explicit recovery path for a revoked
|
|
// shared identity, so it is correct to reactivate that default grant here.
|
|
const [updated] = await dbClient
|
|
.update(connectionGrants)
|
|
.set({
|
|
credentialSecretRefs: isRailwayConnection(connection)
|
|
? [...connection.credentialSecretRefs, ...existing.credentialSecretRefs.filter((ref) => ref.configPath === RAILWAY_SSH_SECRET_PATH && !connection.credentialSecretRefs.some((candidate) => candidate.configPath === ref.configPath))]
|
|
: connection.credentialSecretRefs,
|
|
status: "active",
|
|
revokedAt: null,
|
|
revokedByAgentId: null,
|
|
revokedByUserId: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(connectionGrants.id, existing.id))
|
|
.returning();
|
|
if (!updated)
|
|
throw new Error("Failed to update default connection grant");
|
|
onMutation?.({ previous: existing, current: updated });
|
|
return updated;
|
|
}
|
|
const [created] = await dbClient
|
|
.insert(connectionGrants)
|
|
.values({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
kind: "organization",
|
|
credentialSecretRefs: connection.credentialSecretRefs,
|
|
status: "active",
|
|
isDefault: true,
|
|
})
|
|
.returning();
|
|
if (!created) throw new Error("Failed to create default connection grant");
|
|
onMutation?.({ previous: null, current: created });
|
|
return created;
|
|
}
|
|
|
|
async function getProfileRow(profileId: string, companyId?: string) {
|
|
const where = companyId
|
|
? and(
|
|
eq(toolProfiles.id, profileId),
|
|
eq(toolProfiles.companyId, companyId),
|
|
)
|
|
: eq(toolProfiles.id, profileId);
|
|
const [row] = await db.select().from(toolProfiles).where(where);
|
|
if (!row) throw notFound("Tool profile not found");
|
|
return row;
|
|
}
|
|
|
|
async function profileDetails(
|
|
profileId: string,
|
|
companyId?: string,
|
|
): Promise<ToolProfileWithDetails> {
|
|
const profile = await getProfileRow(profileId, companyId);
|
|
const [
|
|
entries,
|
|
bindings,
|
|
catalog,
|
|
companyAgents,
|
|
applications,
|
|
connections,
|
|
] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, profile.companyId),
|
|
eq(toolProfileEntries.profileId, profile.id),
|
|
),
|
|
)
|
|
.orderBy(asc(toolProfileEntries.createdAt)),
|
|
db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, profile.companyId),
|
|
eq(toolProfileBindings.profileId, profile.id),
|
|
),
|
|
)
|
|
.orderBy(
|
|
asc(toolProfileBindings.priority),
|
|
asc(toolProfileBindings.createdAt),
|
|
),
|
|
db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, profile.companyId),
|
|
eq(toolCatalogEntries.status, "active"),
|
|
),
|
|
),
|
|
db
|
|
.select({ id: agents.id })
|
|
.from(agents)
|
|
.where(eq(agents.companyId, profile.companyId)),
|
|
db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.companyId, profile.companyId)),
|
|
db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.companyId, profile.companyId)),
|
|
]);
|
|
return buildProfileDetails({
|
|
profile,
|
|
entries,
|
|
bindings,
|
|
catalog,
|
|
agentIds: companyAgents.map((agent) => agent.id),
|
|
applicationsById: new Map(
|
|
applications.map((application) => [application.id, application]),
|
|
),
|
|
connectionsById: new Map(
|
|
connections.map((connection) => [connection.id, connection]),
|
|
),
|
|
});
|
|
}
|
|
|
|
async function listProfileNewTools(
|
|
profileId: string,
|
|
companyId?: string,
|
|
): Promise<ToolProfileNewToolsReview> {
|
|
const profile = await getProfileRow(profileId, companyId);
|
|
const [entries, catalog, applications, connections] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, profile.companyId),
|
|
eq(toolProfileEntries.profileId, profile.id),
|
|
),
|
|
)
|
|
.orderBy(asc(toolProfileEntries.createdAt)),
|
|
db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, profile.companyId),
|
|
eq(toolCatalogEntries.status, "active"),
|
|
),
|
|
)
|
|
.orderBy(asc(toolCatalogEntries.toolName)),
|
|
db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.companyId, profile.companyId)),
|
|
db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.companyId, profile.companyId)),
|
|
]);
|
|
const tools = pendingNewToolsForProfile({
|
|
profile,
|
|
entries,
|
|
catalog,
|
|
applicationsById: new Map(
|
|
applications.map((application) => [application.id, application]),
|
|
),
|
|
connectionsById: new Map(
|
|
connections.map((connection) => [connection.id, connection]),
|
|
),
|
|
});
|
|
return {
|
|
profileId: profile.id,
|
|
reviewedAt: profile.newToolsReviewedAt,
|
|
pendingCount: tools.length,
|
|
tools,
|
|
};
|
|
}
|
|
|
|
async function reviewProfileNewTools(
|
|
profileId: string,
|
|
input: ReviewToolProfileNewTools,
|
|
actor?: ActorInfo,
|
|
): Promise<ToolProfileNewToolsReviewResult> {
|
|
const profile = await getProfileRow(profileId);
|
|
const review = await listProfileNewTools(profile.id, profile.companyId);
|
|
if (review.tools.length === 0)
|
|
throw badRequest("No new tools are pending review for this profile");
|
|
|
|
const decisionIds = input.decisions.map(
|
|
(decision) => decision.catalogEntryId,
|
|
);
|
|
if (new Set(decisionIds).size !== decisionIds.length) {
|
|
throw badRequest(
|
|
"New-tools review decisions must not contain duplicate catalogEntryId values",
|
|
);
|
|
}
|
|
const pendingIds = new Set(review.tools.map((tool) => tool.catalogEntryId));
|
|
if (
|
|
decisionIds.length !== pendingIds.size ||
|
|
decisionIds.some((id) => !pendingIds.has(id))
|
|
) {
|
|
throw badRequest(
|
|
"New-tools review decisions must cover every currently pending tool exactly once",
|
|
);
|
|
}
|
|
|
|
const toolById = new Map(
|
|
review.tools.map((tool) => [tool.catalogEntryId, tool]),
|
|
);
|
|
const allowTools = input.decisions
|
|
.filter((decision) => decision.decision === "allow")
|
|
.map((decision) => toolById.get(decision.catalogEntryId))
|
|
.filter(Boolean) as ToolProfileNewToolReviewItem[];
|
|
const nowAt = now();
|
|
let createdEntries: ToolProfileEntry[] = [];
|
|
if (allowTools.length > 0) {
|
|
const rows = await db
|
|
.insert(toolProfileEntries)
|
|
.values(
|
|
allowTools.map((tool) => ({
|
|
companyId: profile.companyId,
|
|
profileId: profile.id,
|
|
selectorType: "catalog_entry" as const,
|
|
effect: "include" as const,
|
|
applicationId: tool.applicationId,
|
|
connectionId: tool.connectionId,
|
|
catalogEntryId: tool.catalogEntryId,
|
|
})),
|
|
)
|
|
.returning();
|
|
createdEntries = rows.map(toProfileEntry);
|
|
}
|
|
|
|
await db
|
|
.update(toolCatalogEntries)
|
|
.set({
|
|
reviewedAt: nowAt,
|
|
reviewedByAgentId:
|
|
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
|
reviewedByUserId:
|
|
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
|
|
updatedAt: nowAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, profile.companyId),
|
|
inArray(toolCatalogEntries.id, decisionIds),
|
|
),
|
|
);
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ newToolsReviewedAt: nowAt, updatedAt: nowAt })
|
|
.where(eq(toolProfiles.id, profile.id));
|
|
|
|
return {
|
|
profile: await profileDetails(profile.id, profile.companyId),
|
|
reviewedAt: nowAt,
|
|
allowedCount: allowTools.length,
|
|
keptBlockedCount: input.decisions.length - allowTools.length,
|
|
entriesCreated: createdEntries,
|
|
reviewedCatalogEntryIds: decisionIds,
|
|
};
|
|
}
|
|
|
|
async function createProfileEntries(
|
|
companyId: string,
|
|
profileId: string,
|
|
entries: CreateToolProfileEntryForProfile[],
|
|
) {
|
|
for (const entry of entries) {
|
|
await assertProfileEntryInput(companyId, entry);
|
|
}
|
|
if (entries.length === 0) return;
|
|
await db.insert(toolProfileEntries).values(
|
|
entries.map((entry) => ({
|
|
companyId,
|
|
profileId,
|
|
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,
|
|
})),
|
|
);
|
|
}
|
|
|
|
async function replaceProfileEntries(
|
|
companyId: string,
|
|
profileId: string,
|
|
entries: CreateToolProfileEntryForProfile[],
|
|
) {
|
|
for (const entry of entries) {
|
|
await assertProfileEntryInput(companyId, entry);
|
|
}
|
|
await db
|
|
.delete(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, companyId),
|
|
eq(toolProfileEntries.profileId, profileId),
|
|
),
|
|
);
|
|
await createProfileEntries(companyId, profileId, entries);
|
|
}
|
|
|
|
/**
|
|
* @param grantSecretRefs Secret refs held by a grant rather than the connection
|
|
* row. A personal credential lives only on its user grant (PAP-17835), so it
|
|
* would otherwise have no `company_secret_bindings` row and drop out of
|
|
* secret projection and removal teardown.
|
|
*/
|
|
async function syncCredentialBindings(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
grantSecretRefs: ToolCredentialSecretRef[] = [],
|
|
dbClient: ToolAccessMutationDb = db,
|
|
) {
|
|
await dbClient
|
|
.delete(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.companyId, connection.companyId),
|
|
eq(companySecretBindings.targetType, "tool_connection"),
|
|
eq(companySecretBindings.targetId, connection.id),
|
|
),
|
|
);
|
|
// A metadata edit or pause/resume must retain declarations for every
|
|
// active personal/dedicated grant, not just connection-owned credentials.
|
|
const activeGrants = await dbClient
|
|
.select({ refs: connectionGrants.credentialSecretRefs })
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.status, "active"),
|
|
),
|
|
);
|
|
const rawBindings = [
|
|
...connection.credentialRefs.map((ref) => ({
|
|
secretId: ref.secretId,
|
|
configPath: credentialRefConfigPath(ref),
|
|
projectionClass: "unclassified",
|
|
projectionAllowlistKey: null,
|
|
required: true,
|
|
label: null,
|
|
})),
|
|
...[
|
|
...connection.credentialSecretRefs,
|
|
...grantSecretRefs,
|
|
...activeGrants.flatMap((grant) => grant.refs),
|
|
].map((ref) => ({
|
|
secretId: ref.secretId,
|
|
configPath: ref.configPath,
|
|
projectionClass: ref.projectionClass ?? "unclassified",
|
|
projectionAllowlistKey: ref.projectionAllowlistKey ?? null,
|
|
required: ref.required ?? true,
|
|
label: ref.label ?? null,
|
|
})),
|
|
];
|
|
// Organization grants can mirror connection-owned credentials, and more
|
|
// than one personal grant can reference the same client registration.
|
|
// Binding rows are unique per secret/config path, so collapse those mirrors
|
|
// before replacing the durable projection declarations.
|
|
const bindings = [
|
|
...new Map(
|
|
rawBindings.map((ref) => [`${ref.secretId}:${ref.configPath}`, ref]),
|
|
).values(),
|
|
];
|
|
const secretRows =
|
|
bindings.length > 0
|
|
? await dbClient
|
|
.select({
|
|
id: companySecrets.id,
|
|
scope: companySecrets.scope,
|
|
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
|
|
})
|
|
.from(companySecrets)
|
|
.where(
|
|
and(
|
|
eq(companySecrets.companyId, connection.companyId),
|
|
inArray(companySecrets.id, [
|
|
...new Set(bindings.map((ref) => ref.secretId)),
|
|
]),
|
|
),
|
|
)
|
|
: [];
|
|
const secretById = new Map(secretRows.map((row) => [row.id, row]));
|
|
const definitionIds = [
|
|
...new Set(
|
|
secretRows.flatMap((row) =>
|
|
row.userSecretDefinitionId ? [row.userSecretDefinitionId] : [],
|
|
),
|
|
),
|
|
];
|
|
const definitions =
|
|
definitionIds.length > 0
|
|
? await dbClient
|
|
.select({
|
|
id: userSecretDefinitions.id,
|
|
key: userSecretDefinitions.key,
|
|
})
|
|
.from(userSecretDefinitions)
|
|
.where(
|
|
and(
|
|
eq(userSecretDefinitions.companyId, connection.companyId),
|
|
inArray(userSecretDefinitions.id, definitionIds),
|
|
),
|
|
)
|
|
: [];
|
|
const definitionKeyById = new Map(
|
|
definitions.map((row) => [row.id, row.key]),
|
|
);
|
|
const userDeclarations = [
|
|
...new Map(
|
|
bindings
|
|
.flatMap((ref) => {
|
|
const secret = secretById.get(ref.secretId);
|
|
const definitionKey =
|
|
secret?.scope === "user" && secret.userSecretDefinitionId
|
|
? definitionKeyById.get(secret.userSecretDefinitionId)
|
|
: null;
|
|
return definitionKey
|
|
? [
|
|
{
|
|
definitionKey,
|
|
configPath: ref.configPath,
|
|
envKey: ref.configPath,
|
|
versionSelector: "latest" as const,
|
|
required: ref.required,
|
|
label: ref.label,
|
|
},
|
|
]
|
|
: [];
|
|
})
|
|
.map((ref) => [`${ref.definitionKey}:${ref.configPath}`, ref]),
|
|
).values(),
|
|
];
|
|
await secrets.syncUserSecretDeclarationsForTarget(
|
|
connection.companyId,
|
|
{ targetType: "tool_connection", targetId: connection.id },
|
|
userDeclarations,
|
|
{ replaceAll: true, db: dbClient },
|
|
);
|
|
const companyBindings = bindings.filter(
|
|
(ref) => secretById.get(ref.secretId)?.scope !== "user",
|
|
);
|
|
if (companyBindings.length === 0) return;
|
|
await dbClient.insert(companySecretBindings).values(
|
|
companyBindings.map((ref) => ({
|
|
companyId: connection.companyId,
|
|
secretId: ref.secretId,
|
|
targetType: "tool_connection" as const,
|
|
targetId: connection.id,
|
|
configPath: ref.configPath,
|
|
required: ref.required,
|
|
label: ref.label,
|
|
projectionClass: ref.projectionClass,
|
|
projectionAllowlistKey: ref.projectionAllowlistKey,
|
|
})),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Split the secrets a connection points at into the ones it owns outright and
|
|
* the ones another consumer still depends on.
|
|
*
|
|
* Removing an app is a credential revocation boundary (PAP-17119), but it must
|
|
* never destroy a secret the operator manages by hand or shares with another
|
|
* target. Two independent tests have to agree before a secret is destroyed:
|
|
*
|
|
* 1. Provenance — the key sits in the `tool_app.` namespace only the
|
|
* connect/reconnect/OAuth paths mint, and the row is a company-scoped
|
|
* Paperclip secret rather than a per-user credential.
|
|
* 2. Exclusivity — nothing outside this connection references it: no
|
|
* `company_secret_bindings` row from another target, and no other
|
|
* connection or connection grant naming the same secret id.
|
|
*
|
|
* A secret failing either test is reported as retained; removal still drops
|
|
* this connection's binding and ref, so the connection loses the credential
|
|
* either way. Retaining a secret nobody can reach is a leak of an unused row;
|
|
* deleting one another target still resolves is an outage, so the ambiguous
|
|
* case fails towards retention.
|
|
*/
|
|
async function classifyConnectionSecrets(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
secretIds: string[],
|
|
): Promise<{ owned: string[]; retained: string[] }> {
|
|
const unique = [
|
|
...new Set(
|
|
secretIds.filter((id) => typeof id === "string" && id.length > 0),
|
|
),
|
|
];
|
|
if (unique.length === 0) return { owned: [], retained: [] };
|
|
|
|
const secretRows = await db
|
|
.select({
|
|
id: companySecrets.id,
|
|
key: companySecrets.key,
|
|
scope: companySecrets.scope,
|
|
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
|
|
})
|
|
.from(companySecrets)
|
|
.where(
|
|
and(
|
|
eq(companySecrets.companyId, connection.companyId),
|
|
inArray(companySecrets.id, unique),
|
|
),
|
|
);
|
|
const byId = new Map(secretRows.map((row) => [row.id, row]));
|
|
|
|
const referencedElsewhere = new Set<string>();
|
|
const foreignBindings = await db
|
|
.select({ secretId: companySecretBindings.secretId })
|
|
.from(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.companyId, connection.companyId),
|
|
inArray(companySecretBindings.secretId, unique),
|
|
sql`not (${companySecretBindings.targetType} = 'tool_connection' and ${companySecretBindings.targetId} = ${connection.id})`,
|
|
),
|
|
);
|
|
for (const row of foreignBindings) referencedElsewhere.add(row.secretId);
|
|
|
|
// Bindings are the authority, but read the sibling refs too: a row written
|
|
// before `syncCredentialBindings` existed — or by hand — can reference a
|
|
// secret with no binding to prove it.
|
|
const siblingConnections = await db
|
|
.select({
|
|
credentialRefs: toolConnections.credentialRefs,
|
|
credentialSecretRefs: toolConnections.credentialSecretRefs,
|
|
})
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.companyId, connection.companyId),
|
|
ne(toolConnections.id, connection.id),
|
|
),
|
|
);
|
|
for (const row of siblingConnections) {
|
|
for (const ref of row.credentialRefs ?? [])
|
|
referencedElsewhere.add(ref.secretId);
|
|
for (const ref of row.credentialSecretRefs ?? [])
|
|
referencedElsewhere.add(ref.secretId);
|
|
}
|
|
const siblingGrants = await db
|
|
.select({ credentialSecretRefs: connectionGrants.credentialSecretRefs })
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
ne(connectionGrants.connectionId, connection.id),
|
|
),
|
|
);
|
|
for (const row of siblingGrants) {
|
|
for (const ref of row.credentialSecretRefs ?? [])
|
|
referencedElsewhere.add(ref.secretId);
|
|
}
|
|
|
|
const owned: string[] = [];
|
|
const retained: string[] = [];
|
|
for (const secretId of unique) {
|
|
const row = byId.get(secretId);
|
|
// No row means an earlier pass of this same removal already deleted it.
|
|
// Hand it back as owned so a retry re-runs the (idempotent) revocation
|
|
// instead of reporting a credential this connection never shared.
|
|
if (!row) {
|
|
owned.push(secretId);
|
|
continue;
|
|
}
|
|
const dedicated =
|
|
row.scope === "company" &&
|
|
row.userSecretDefinitionId === null &&
|
|
row.key.startsWith(CONNECTION_OWNED_SECRET_KEY_PREFIX);
|
|
if (dedicated && !referencedElsewhere.has(secretId)) owned.push(secretId);
|
|
else retained.push(secretId);
|
|
}
|
|
return { owned, retained };
|
|
}
|
|
|
|
/**
|
|
* Remove an app: a credential-revoking teardown, not a status flip (PAP-17119).
|
|
*
|
|
* Order is the security property. Every database-side access path closes
|
|
* first — grants, installs, the app-managed profile, gateway tokens minted
|
|
* against it, outstanding OAuth state, the catalog, and the connection itself
|
|
* — so the app is already undispatchable before the first call out to a secret
|
|
* provider. Secret revocation runs last, and each secret's ref survives until
|
|
* that secret is gone, so a provider that errors leaves the operation failed
|
|
* closed and resumable: the credential is already unresolvable (its row is
|
|
* marked deleted first), and retrying the same removal finishes the job.
|
|
*
|
|
* What stays behind is deliberate: the connection and application rows, their
|
|
* ids, names and activity keep working so a later reconnect reuses the same
|
|
* identity — but with no credential, no install and no profile, so
|
|
* reconnecting has to ask for fresh authentication and rebuild access.
|
|
*/
|
|
async function removeConnection(
|
|
connectionId: string,
|
|
companyId?: string,
|
|
actor?: ActorInfo,
|
|
): Promise<ToolConnectionRemovalResult> {
|
|
const connection = await getConnectionRow(connectionId, companyId);
|
|
forgetMcpHttpSessions(connection.id);
|
|
const now = new Date();
|
|
const binding = actorBinding(actor);
|
|
|
|
// Grants are read before they are revoked: a retried removal must still see
|
|
// the credential refs of a grant an earlier pass already marked revoked.
|
|
const grantRows = await db
|
|
.select({
|
|
id: connectionGrants.id,
|
|
status: connectionGrants.status,
|
|
kind: connectionGrants.kind,
|
|
subjectUserId: connectionGrants.subjectUserId,
|
|
credentialSecretRefs: connectionGrants.credentialSecretRefs,
|
|
externalCredential: connectionGrants.externalCredential,
|
|
})
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
),
|
|
);
|
|
const grantsToRevoke = grantRows.filter((row) => row.status !== "revoked");
|
|
if (grantsToRevoke.length > 0) {
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
status: "revoked",
|
|
isDefault: false,
|
|
revokedAt: now,
|
|
revokedByAgentId:
|
|
binding.actorType === "agent" ? binding.actorId : null,
|
|
revokedByUserId:
|
|
binding.actorType === "user" ? binding.actorId : null,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
inArray(
|
|
connectionGrants.id,
|
|
grantsToRevoke.map((row) => row.id),
|
|
),
|
|
);
|
|
}
|
|
let externalCredentialCleanup: ToolConnectionRemovalSummary["externalCredentialCleanup"] =
|
|
null;
|
|
if (
|
|
connection.credentialSource === "vercel_connect" &&
|
|
connection.externalCredential
|
|
) {
|
|
let attempted = 0;
|
|
let revoked = 0;
|
|
let failures = 0;
|
|
let appSubjectCleanup: "not_applicable" | "manage_in_vercel" =
|
|
"not_applicable";
|
|
for (const grant of grantRows) {
|
|
const request = vercelTokenRequest({
|
|
credential: connection.externalCredential,
|
|
grant,
|
|
connectionId: connection.id,
|
|
companyId: connection.companyId,
|
|
resources: vercelConnectResourcesFor(connection),
|
|
});
|
|
vercelConnect?.evict(request);
|
|
if (grant.externalCredential?.subjectType === "app") {
|
|
appSubjectCleanup = "manage_in_vercel";
|
|
continue;
|
|
}
|
|
if (grant.externalCredential?.subjectType !== "user") continue;
|
|
attempted += 1;
|
|
try {
|
|
if (!vercelConnect) throw new Error("Vercel Connect unavailable");
|
|
await vercelConnect.revoke(request);
|
|
revoked += 1;
|
|
} catch {
|
|
failures += 1;
|
|
}
|
|
}
|
|
externalCredentialCleanup = {
|
|
provider: "vercel_connect",
|
|
userSubjectsAttempted: attempted,
|
|
userSubjectsRevoked: revoked,
|
|
userSubjectFailures: failures,
|
|
appSubjectCleanup,
|
|
manageUrl: vercelConnectIntegrationStatus().manageUrl,
|
|
};
|
|
}
|
|
|
|
// Bindings are how a credential reaches a runtime, so they go before the
|
|
// provider round-trip rather than after it. They are also not needed to
|
|
// finish the job: the refs on the connection row are what a resumed removal
|
|
// reads to find the secrets it still owes a revocation.
|
|
const removedSecretBindings = await db
|
|
.delete(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.companyId, connection.companyId),
|
|
eq(companySecretBindings.targetType, "tool_connection"),
|
|
eq(companySecretBindings.targetId, connection.id),
|
|
),
|
|
)
|
|
.returning({ id: companySecretBindings.id });
|
|
|
|
const removedInstalls = await db
|
|
.delete(toolConnectionInstalls)
|
|
.where(
|
|
and(
|
|
eq(toolConnectionInstalls.companyId, connection.companyId),
|
|
eq(toolConnectionInstalls.connectionId, connection.id),
|
|
),
|
|
)
|
|
.returning({ id: toolConnectionInstalls.id });
|
|
|
|
// The app-managed profile exists only to carry this connection's action
|
|
// selection, so it goes with the connection. Operator-authored profiles that
|
|
// happen to mention the connection are left alone — the archived connection
|
|
// is denied by the policy engine regardless.
|
|
const [appProfile] = await db
|
|
.select({ id: toolProfiles.id })
|
|
.from(toolProfiles)
|
|
.where(
|
|
and(
|
|
eq(toolProfiles.companyId, connection.companyId),
|
|
eq(toolProfiles.profileKey, `app:${connection.id}`),
|
|
),
|
|
)
|
|
.limit(1);
|
|
let appProfileOutcome: ToolConnectionRemovalSummary["appProfile"] =
|
|
"absent";
|
|
let appProfileEntriesRemoved = 0;
|
|
let appProfileBindingsRemoved = 0;
|
|
let gatewayTokensRevoked = 0;
|
|
let gatewaySessionsRevoked = 0;
|
|
if (appProfile) {
|
|
appProfileEntriesRemoved = (
|
|
await db
|
|
.delete(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, connection.companyId),
|
|
eq(toolProfileEntries.profileId, appProfile.id),
|
|
),
|
|
)
|
|
.returning({ id: toolProfileEntries.id })
|
|
).length;
|
|
appProfileBindingsRemoved = (
|
|
await db
|
|
.delete(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, connection.companyId),
|
|
eq(toolProfileBindings.profileId, appProfile.id),
|
|
),
|
|
)
|
|
.returning({ id: toolProfileBindings.id })
|
|
).length;
|
|
|
|
const gatewayRows = await db
|
|
.select({ id: toolMcpGateways.id })
|
|
.from(toolMcpGateways)
|
|
.where(
|
|
and(
|
|
eq(toolMcpGateways.companyId, connection.companyId),
|
|
eq(toolMcpGateways.profileId, appProfile.id),
|
|
),
|
|
);
|
|
if (gatewayRows.length === 0) {
|
|
await db.delete(toolProfiles).where(eq(toolProfiles.id, appProfile.id));
|
|
appProfileOutcome = "deleted";
|
|
} else {
|
|
// `tool_mcp_gateways.profile_id` is ON DELETE RESTRICT, so a gateway
|
|
// pointing here keeps the row alive. Archive it instead — the policy
|
|
// engine only consults `active` profiles, and it has no entries left —
|
|
// and revoke the tokens those gateways already handed out, which are the
|
|
// one credential a caller could still present.
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ status: "archived", defaultAction: "deny", updatedAt: now })
|
|
.where(eq(toolProfiles.id, appProfile.id));
|
|
appProfileOutcome = "archived";
|
|
const revokedTokens = await db
|
|
.update(toolMcpGatewayTokens)
|
|
.set({ revokedAt: now, updatedAt: now })
|
|
.where(
|
|
and(
|
|
eq(toolMcpGatewayTokens.companyId, connection.companyId),
|
|
inArray(
|
|
toolMcpGatewayTokens.gatewayId,
|
|
gatewayRows.map((row) => row.id),
|
|
),
|
|
isNull(toolMcpGatewayTokens.revokedAt),
|
|
),
|
|
)
|
|
.returning({ id: toolMcpGatewayTokens.id });
|
|
gatewayTokensRevoked = revokedTokens.length;
|
|
if (revokedTokens.length > 0) {
|
|
gatewaySessionsRevoked = (
|
|
await db
|
|
.update(toolGatewaySessions)
|
|
.set({ revokedAt: now, updatedAt: now })
|
|
.where(
|
|
and(
|
|
eq(toolGatewaySessions.companyId, connection.companyId),
|
|
inArray(
|
|
toolGatewaySessions.gatewayTokenId,
|
|
revokedTokens.map((row) => row.id),
|
|
),
|
|
isNull(toolGatewaySessions.revokedAt),
|
|
),
|
|
)
|
|
.returning({ id: toolGatewaySessions.id })
|
|
).length;
|
|
}
|
|
}
|
|
}
|
|
|
|
// A local runtime already holds the injected credential inside a live child
|
|
// process, so archiving rows is not enough — the process itself is an access
|
|
// path. Stopping is best effort on purpose: if the supervisor cannot be
|
|
// reached, revoking the credential anyway (so nothing can start again) beats
|
|
// abandoning the teardown, and the warning says which slot was left running.
|
|
let runtimeSlotsStopped = 0;
|
|
const runtimeSlotRows = await db
|
|
.select({ id: toolRuntimeSlots.id, status: toolRuntimeSlots.status })
|
|
.from(toolRuntimeSlots)
|
|
.where(
|
|
and(
|
|
eq(toolRuntimeSlots.companyId, connection.companyId),
|
|
eq(toolRuntimeSlots.connectionId, connection.id),
|
|
),
|
|
);
|
|
for (const slot of runtimeSlotRows) {
|
|
if (slot.status === "stopped") continue;
|
|
try {
|
|
await runtimeSupervisor.stopSlot({
|
|
companyId: connection.companyId,
|
|
slotId: slot.id,
|
|
reason: "connection_removed",
|
|
});
|
|
runtimeSlotsStopped += 1;
|
|
} catch (error) {
|
|
logger.warn(
|
|
{
|
|
err: error,
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
slotId: slot.id,
|
|
},
|
|
"tool connection removal could not stop a runtime slot",
|
|
);
|
|
}
|
|
}
|
|
|
|
// The catalog stays as history, but `removed` is the status that stops a
|
|
// standing trust rule from auto-allowing one of these actions again.
|
|
const removedCatalogEntries = await db
|
|
.update(toolCatalogEntries)
|
|
.set({ status: "removed", updatedAt: now })
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, connection.companyId),
|
|
eq(toolCatalogEntries.connectionId, connection.id),
|
|
ne(toolCatalogEntries.status, "removed"),
|
|
),
|
|
)
|
|
.returning({ id: toolCatalogEntries.id });
|
|
|
|
// An authorization already in flight would otherwise come back and mint a
|
|
// fresh token for an app the operator just removed.
|
|
const discardedOAuthStates = await db
|
|
.delete(toolOauthStates)
|
|
.where(
|
|
and(
|
|
eq(toolOauthStates.companyId, connection.companyId),
|
|
eq(toolOauthStates.connectionId, connection.id),
|
|
),
|
|
)
|
|
.returning({ state: toolOauthStates.state });
|
|
|
|
// Token-derived material in the issuance ledger is not an access path —
|
|
// nothing validates against it — but there is no reason to keep a hash of a
|
|
// credential the operator asked us to revoke. Path, outcome, actor and time
|
|
// stay, so the usage history survives.
|
|
const clearedIssuanceHashes = await db
|
|
.update(connectionTokenIssuances)
|
|
.set({ tokenHash: null })
|
|
.where(
|
|
and(
|
|
eq(connectionTokenIssuances.companyId, connection.companyId),
|
|
eq(connectionTokenIssuances.connectionId, connection.id),
|
|
sql`${connectionTokenIssuances.tokenHash} is not null`,
|
|
),
|
|
)
|
|
.returning({ id: connectionTokenIssuances.id });
|
|
|
|
const archived = await db.transaction(async (tx) => {
|
|
const [updatedConnection] = await tx
|
|
.update(toolConnections)
|
|
.set({ status: "archived", enabled: false, updatedAt: now })
|
|
.where(eq(toolConnections.id, connection.id))
|
|
.returning();
|
|
if (!updatedConnection) throw notFound("Tool connection not found");
|
|
|
|
const remainingConnections = await tx
|
|
.select({ id: toolConnections.id })
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.applicationId, updatedConnection.applicationId),
|
|
ne(toolConnections.status, "archived"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
|
|
let applicationArchived = false;
|
|
if (remainingConnections.length === 0) {
|
|
const [application] = await tx
|
|
.update(toolApplications)
|
|
.set({ status: "archived", archivedAt: now, updatedAt: now })
|
|
.where(
|
|
and(
|
|
eq(toolApplications.id, updatedConnection.applicationId),
|
|
ne(toolApplications.status, "archived"),
|
|
),
|
|
)
|
|
.returning({ id: toolApplications.id });
|
|
applicationArchived = Boolean(application);
|
|
}
|
|
|
|
return { connection: updatedConnection, applicationArchived };
|
|
});
|
|
|
|
// Only now, with every access path closed, revoke the credentials. Each
|
|
// `secrets.remove` marks the row deleted before it calls the provider, so a
|
|
// provider error leaves an unresolvable secret and a resumable removal
|
|
// rather than a half-open app.
|
|
const candidateSecretIds = [
|
|
...connection.credentialRefs.map((ref) => ref.secretId),
|
|
...connection.credentialSecretRefs.map((ref) => ref.secretId),
|
|
...grantRows.flatMap((grant) =>
|
|
(grant.credentialSecretRefs ?? []).map((ref) => ref.secretId),
|
|
),
|
|
];
|
|
const { owned, retained } = await classifyConnectionSecrets(
|
|
connection,
|
|
candidateSecretIds,
|
|
);
|
|
let secretsRevoked = 0;
|
|
for (const secretId of owned) {
|
|
const removed = await secrets.remove(secretId);
|
|
if (removed) secretsRevoked += 1;
|
|
}
|
|
|
|
const credentialRefsCleared =
|
|
connection.credentialRefs.length + connection.credentialSecretRefs.length;
|
|
const [cleared] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
credentialRefs: [],
|
|
credentialSecretRefs: [],
|
|
config: withoutInlineOAuthTokens(connection.config),
|
|
transportConfig: withoutInlineOAuthTokens(connection.transportConfig),
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(toolConnections.id, connection.id))
|
|
.returning();
|
|
if (
|
|
grantRows.some((grant) => (grant.credentialSecretRefs ?? []).length > 0)
|
|
) {
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ credentialSecretRefs: [], updatedAt: now })
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
),
|
|
);
|
|
}
|
|
|
|
return {
|
|
connection: toConnection(cleared ?? archived.connection),
|
|
removal: {
|
|
secretsRevoked,
|
|
secretsRetainedShared: retained.length,
|
|
credentialRefsCleared,
|
|
secretBindingsRemoved: removedSecretBindings.length,
|
|
grantsRevoked: grantsToRevoke.length,
|
|
installsRemoved: removedInstalls.length,
|
|
appProfile: appProfileOutcome,
|
|
appProfileEntriesRemoved,
|
|
appProfileBindingsRemoved,
|
|
catalogEntriesMarkedRemoved: removedCatalogEntries.length,
|
|
oauthStatesDiscarded: discardedOAuthStates.length,
|
|
tokenIssuanceHashesCleared: clearedIssuanceHashes.length,
|
|
runtimeSlotsStopped,
|
|
gatewayTokensRevoked,
|
|
gatewaySessionsRevoked,
|
|
applicationArchived: archived.applicationArchived,
|
|
externalCredentialCleanup,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function ensureRuntimeSlot(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): Promise<ToolRuntimeSlot | null> {
|
|
if (connection.transport !== "local_stdio") return null;
|
|
const slotKey = `mcp:${connection.companyId}:${connection.id}`;
|
|
const [existing] = await db
|
|
.select()
|
|
.from(toolRuntimeSlots)
|
|
.where(
|
|
and(
|
|
eq(toolRuntimeSlots.companyId, connection.companyId),
|
|
eq(toolRuntimeSlots.slotKey, slotKey),
|
|
),
|
|
);
|
|
if (existing) return toRuntimeSlot(existing);
|
|
const [created] = await db
|
|
.insert(toolRuntimeSlots)
|
|
.values({
|
|
companyId: connection.companyId,
|
|
applicationId: connection.applicationId,
|
|
connectionId: connection.id,
|
|
slotKey,
|
|
ownerScopeType: "connection",
|
|
ownerScopeId: connection.id,
|
|
runtimeKind: "local_stdio",
|
|
status: "stopped",
|
|
provider: "paperclip",
|
|
providerRef: `template:${String(connection.config.templateId)}`,
|
|
commandTemplateKey: String(connection.config.templateId),
|
|
healthStatus: "unchecked",
|
|
metadata: { templateId: connection.config.templateId },
|
|
})
|
|
.returning();
|
|
return toRuntimeSlot(created);
|
|
}
|
|
|
|
async function vaultGrantForConnection(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
actor?: ActorInfo,
|
|
): Promise<typeof connectionGrants.$inferSelect | null> {
|
|
const actorUserId =
|
|
actor?.actorType === "user" ? (actor.actorId ?? null) : null;
|
|
if (actorUserId) {
|
|
const [personal] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.subjectUserId, actorUserId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (personal) {
|
|
if (personal.status !== "active") {
|
|
throw unprocessable("OAuth authorization must be reconnected", {
|
|
code: "oauth_reauthorization_required",
|
|
setupUrl: connectionSetupUrl(connection),
|
|
reconnectUrl: connectionReconnectUrl(connection),
|
|
});
|
|
}
|
|
return personal;
|
|
}
|
|
}
|
|
if (!actorUserId && connection.credentialPolicy === "per_user") {
|
|
// A personal-only connection is intentionally fixed to one identity.
|
|
// Background health/catalog checks have no acting user, but may safely
|
|
// exercise that sole owner-bound grant without turning it into a shared
|
|
// credential or making it available to a different caller.
|
|
const personalGrants = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.status, "active"),
|
|
),
|
|
)
|
|
.limit(2);
|
|
if (personalGrants.length === 1) return personalGrants[0]!;
|
|
}
|
|
const [organization] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "organization"),
|
|
eq(connectionGrants.isDefault, true),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (organization?.status === "active") return organization;
|
|
if (connection.credentialPolicy === "per_user") {
|
|
throw unprocessable(
|
|
"This connection needs the current user's authorization",
|
|
{
|
|
code: "user_authorization_required",
|
|
setupUrl: connectionSetupUrl(connection),
|
|
reconnectUrl: connectionReconnectUrl(connection),
|
|
},
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function resolveCredentialHeaders(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
actor?: ActorInfo,
|
|
options: { forceRefresh?: boolean } = {},
|
|
): Promise<Record<string, string>> {
|
|
if (connection.credentialSource === "vercel_connect") {
|
|
const grant = await vercelGrantForConnection(connection, actor);
|
|
return resolveVercelCredentialHeaders(connection, grant, options);
|
|
}
|
|
let grant: typeof connectionGrants.$inferSelect | null = null;
|
|
try {
|
|
if (
|
|
connection.authKind === "oauth" ||
|
|
connection.credentialPolicy !== "shared"
|
|
) {
|
|
grant = await vaultGrantForConnection(connection, actor);
|
|
}
|
|
if (connection.authKind === "oauth" && grant) {
|
|
grant = await refreshOAuthGrantCredentials({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
forceRefresh: options.forceRefresh,
|
|
actor,
|
|
});
|
|
} else {
|
|
connection = await maybeRefreshOAuthCredentials(connection, actor);
|
|
}
|
|
} catch (error) {
|
|
const scope = credentialScope(connection);
|
|
await audit({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
action: "tool_connection.credential_resolution",
|
|
outcome: "failure",
|
|
reasonCode:
|
|
error instanceof HttpError
|
|
? String(asRecord(error.details).code ?? "oauth_refresh_failed")
|
|
: "oauth_refresh_failed",
|
|
details: {
|
|
credentialCount: connection.credentialRefs.length,
|
|
credentialSecretRefCount: connection.credentialSecretRefs.length,
|
|
credentialScopeType: scope.type,
|
|
credentialScopeHash: scope.hash,
|
|
setupUrl: connectionSetupUrl(connection),
|
|
reconnectUrl: connectionReconnectUrl(connection),
|
|
},
|
|
});
|
|
throw error;
|
|
}
|
|
const headers: Record<string, string> = {};
|
|
const scope = credentialScope(connection);
|
|
for (const ref of connection.credentialRefs) {
|
|
let value: string;
|
|
const configPath = credentialRefConfigPath(ref);
|
|
try {
|
|
const grantRef = grant?.credentialSecretRefs.find(
|
|
(candidate) => candidate.configPath === configPath,
|
|
);
|
|
value =
|
|
grantRef && grant
|
|
? (
|
|
await resolveOAuthGrantSecret(
|
|
connection,
|
|
grant,
|
|
grantRef,
|
|
actor,
|
|
undefined,
|
|
)
|
|
).value
|
|
: await secrets.resolveSecretValue(
|
|
connection.companyId,
|
|
ref.secretId,
|
|
ref.version ?? "latest",
|
|
{
|
|
consumerType: "tool_connection",
|
|
consumerId: connection.id,
|
|
configPath,
|
|
actorType: "system",
|
|
},
|
|
);
|
|
} catch (error) {
|
|
await audit({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
action: "tool_connection.credential_resolution",
|
|
outcome: "failure",
|
|
reasonCode:
|
|
error instanceof HttpError
|
|
? String(
|
|
asRecord(error.details).code ?? "secret_resolution_failed",
|
|
)
|
|
: "secret_resolution_failed",
|
|
details: {
|
|
credentialCount: connection.credentialRefs.length,
|
|
credentialScopeType: scope.type,
|
|
credentialScopeHash: scope.hash,
|
|
},
|
|
});
|
|
throw error;
|
|
}
|
|
if (ref.placement === "header") {
|
|
headers[ref.key] = `${ref.prefix ?? ""}${value}`;
|
|
}
|
|
}
|
|
const oauthAccessRef = grant?.credentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.access_token",
|
|
);
|
|
if (oauthAccessRef && headers.Authorization === undefined) {
|
|
headers.Authorization = `Bearer ${
|
|
(
|
|
await resolveOAuthGrantSecret(
|
|
connection,
|
|
grant!,
|
|
oauthAccessRef,
|
|
actor,
|
|
undefined,
|
|
)
|
|
).value
|
|
}`;
|
|
}
|
|
if (
|
|
connection.credentialRefs.length > 0 ||
|
|
connection.credentialSecretRefs.length > 0 ||
|
|
Object.keys(oauthConfig(connection)).length > 0
|
|
) {
|
|
await audit({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
action: "tool_connection.credential_resolution",
|
|
outcome: "success",
|
|
details: {
|
|
credentialCount: connection.credentialRefs.length,
|
|
credentialSecretRefCount: connection.credentialSecretRefs.length,
|
|
credentialScopeType: scope.type,
|
|
credentialScopeHash: scope.hash,
|
|
},
|
|
});
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
async function remoteTools(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
credentialHeaders?: Record<string, string>,
|
|
actor?: ActorInfo,
|
|
): Promise<McpToolDescriptor[]> {
|
|
assertSupportedConnection(connection);
|
|
let headers = credentialHeaders ?? {
|
|
...projectedConnectionHeaders(connection),
|
|
...(await resolveCredentialHeaders(connection, actor)),
|
|
};
|
|
const endpoint = await resolvedRemoteEndpoint(connection, actor);
|
|
// Pinned to the address the guard approved: `config.url` is operator-supplied,
|
|
// so a second DNS resolution here would reopen the rebinding window that
|
|
// PAP-17098 closed for the OAuth endpoints.
|
|
let listRequestId = "paperclip-catalog-refresh";
|
|
let sessionHeaders = headers;
|
|
const sendRemote = (init: RequestInit) => requestRemoteHttpEndpoint(new URL(endpoint), init);
|
|
const sendToolsList = (requestHeaders: Record<string, string>, cursor?: string) => {
|
|
sessionHeaders = requestHeaders;
|
|
return sendRemote({ method: "POST", headers: mcpHttpRequestHeaders(requestHeaders),
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: listRequestId, method: "tools/list", params: cursor ? { cursor } : {} }) });
|
|
};
|
|
let usedInitializedSession = connection.config.mcpSessionRequired === true;
|
|
let response: Response;
|
|
if (usedInitializedSession) {
|
|
try {
|
|
sessionHeaders = await getMcpHttpSession({ send: sendRemote, headers,
|
|
scope: `${connection.id}:catalog:${actor?.actorType}:${actor?.actorId}:${endpoint}`,
|
|
requestId: listRequestId });
|
|
response = await sendToolsList(sessionHeaders);
|
|
} catch (error) {
|
|
if (!(error instanceof McpHttpInitializationError) || !error.response) throw error;
|
|
response = error.response;
|
|
}
|
|
} else {
|
|
response = await sendToolsList(headers);
|
|
// `tools/list` is read-only, so a 400 can safely be retried after the MCP
|
|
// initialize handshake. Stateful servers such as Supabase require the
|
|
// returned Mcp-Session-Id on every non-initialization request.
|
|
if (response.status === 400) {
|
|
try {
|
|
const sessionHeaders = await initializeMcpHttpSession({
|
|
send: sendRemote,
|
|
headers,
|
|
requestId: "paperclip-catalog-refresh",
|
|
});
|
|
response = await sendToolsList(sessionHeaders);
|
|
usedInitializedSession = response.ok;
|
|
} catch {
|
|
// Preserve the original HTTP failure below when this was not an MCP
|
|
// session requirement after all.
|
|
}
|
|
}
|
|
}
|
|
if (response.status === 404 && new Headers(sessionHeaders).has("mcp-session-id")) {
|
|
// MCP uses 404 for an expired server session. Discovery is read-only, so
|
|
// discard the stale session and retry it once with a new handshake.
|
|
forgetMcpHttpSessions(connection.id);
|
|
await response.body?.cancel().catch(() => undefined);
|
|
sessionHeaders = await getMcpHttpSession({ send: sendRemote, headers,
|
|
scope: `${connection.id}:catalog:${actor?.actorType}:${actor?.actorId}:${endpoint}`,
|
|
requestId: listRequestId });
|
|
response = await sendToolsList(sessionHeaders);
|
|
if (response.status === 404) forgetMcpHttpSessions(connection.id);
|
|
}
|
|
if (
|
|
usedInitializedSession &&
|
|
connection.config.mcpSessionRequired !== true
|
|
) {
|
|
const nextConfig = { ...connection.config, mcpSessionRequired: true };
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, connection.id),
|
|
eq(toolConnections.companyId, connection.companyId),
|
|
),
|
|
);
|
|
}
|
|
if (
|
|
response.status === 401 &&
|
|
connection.credentialSource === "vercel_connect"
|
|
) {
|
|
const grant = await vercelGrantForConnection(connection, actor);
|
|
const credential = vercelCredentialFor(connection);
|
|
const request = vercelTokenRequest({
|
|
credential,
|
|
grant,
|
|
connectionId: connection.id,
|
|
companyId: connection.companyId,
|
|
resources: vercelConnectResourcesFor(connection),
|
|
});
|
|
vercelConnect?.evict(request);
|
|
headers = {
|
|
...projectedConnectionHeaders(connection),
|
|
...(await resolveVercelCredentialHeaders(connection, grant, {
|
|
forceRefresh: true,
|
|
})),
|
|
};
|
|
response = await sendToolsList(headers);
|
|
}
|
|
if (
|
|
response.status === 401 &&
|
|
connection.authKind === "oauth" &&
|
|
connection.credentialSource === "paperclip_vault"
|
|
) {
|
|
headers = {
|
|
...projectedConnectionHeaders(connection),
|
|
...(await resolveCredentialHeaders(connection, actor, {
|
|
forceRefresh: true,
|
|
})),
|
|
};
|
|
response = await sendToolsList(headers);
|
|
if (
|
|
response.status === 401 &&
|
|
isPaperclipCloudConnectorStrategy(oauthConfig(connection).strategy)
|
|
) {
|
|
const grant = await vaultGrantForConnection(connection, actor);
|
|
if (grant) {
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ status: "needs_reauthorization", updatedAt: now() })
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (!response.ok) {
|
|
if (await isSlackMcpAccessDisabledResponse(endpoint, response)) {
|
|
throw unprocessable(
|
|
"Slack MCP access is disabled for this app. Ask the Slack app owner to enable MCP access, then refresh this connection.",
|
|
{ code: "slack_mcp_access_disabled", setupUrl: connectionSetupUrl(connection) },
|
|
);
|
|
}
|
|
const authenticate = response.headers.get("www-authenticate") ?? "";
|
|
if (
|
|
response.status === 401 &&
|
|
/bearer|oauth|authorization/i.test(authenticate)
|
|
) {
|
|
const endpoints = await discoverOAuthEndpoints(
|
|
connection,
|
|
authenticate,
|
|
);
|
|
if (endpoints) {
|
|
const nextConfig = {
|
|
...connection.config,
|
|
oauth: {
|
|
...oauthConfig(connection),
|
|
provider: endpoints.provider,
|
|
authorizationUrl: endpoints.authorizationUrl,
|
|
tokenUrl: endpoints.tokenUrl,
|
|
registrationUrl: endpoints.registrationUrl ?? null,
|
|
metadataUrl: endpoints.metadataUrl ?? null,
|
|
scopes: endpoints.scopes,
|
|
codeChallengeMethodsSupported:
|
|
endpoints.codeChallengeMethodsSupported ?? [],
|
|
tokenEndpointAuthMethodsSupported:
|
|
endpoints.tokenEndpointAuthMethodsSupported ?? [],
|
|
grantType: endpoints.grantType ?? "authorization_code",
|
|
issuer: endpoints.issuer ?? null,
|
|
resource: endpoints.resource ?? null,
|
|
clientIdMetadataDocumentSupported:
|
|
endpoints.clientIdMetadataDocumentSupported === true,
|
|
discoveredAt: new Date().toISOString(),
|
|
},
|
|
};
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
// Record the discovered auth kind now, so a URL-only connection that
|
|
// is waiting on sign-in reads as OAuth everywhere rather than
|
|
// falling back to `authKind: none` semantics.
|
|
authKind: "oauth",
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, connection.id));
|
|
}
|
|
throw unprocessable("This app needs you to sign in.", {
|
|
code: "oauth_challenge",
|
|
status: response.status,
|
|
setupUrl: connectionSetupUrl(connection),
|
|
reconnectUrl: connectionReconnectUrl(connection),
|
|
oauthSupported: Boolean(endpoints),
|
|
});
|
|
}
|
|
throw new HttpError(502, `Remote app returned HTTP ${response.status}`, {
|
|
status: response.status,
|
|
});
|
|
}
|
|
const descriptors: McpToolDescriptor[] = [];
|
|
const seenCursors = new Set<string>();
|
|
for (let page = 0; ; page += 1) {
|
|
const payload = await readMcpHttpResponse(response, listRequestId);
|
|
const record = asRecord(payload);
|
|
if (record.error) throw new HttpError(502, "Remote MCP tool discovery failed", { code: "mcp_catalog_error" });
|
|
const result = asRecord(record.result);
|
|
if (!Array.isArray(result.tools)) throw new HttpError(502, "Remote MCP returned an invalid tool catalog", { code: "mcp_catalog_invalid" });
|
|
descriptors.push(...result.tools.map((tool) => normalizeToolDescriptor(tool)).filter((tool): tool is McpToolDescriptor => Boolean(tool)));
|
|
const cursor = typeof result.nextCursor === "string" ? result.nextCursor : null;
|
|
if (!cursor) break;
|
|
if (seenCursors.has(cursor) || page >= 99 || descriptors.length > 20_000) throw new HttpError(502, "Remote MCP tool catalog pagination did not finish", { code: "mcp_catalog_pagination" });
|
|
seenCursors.add(cursor);
|
|
listRequestId = `paperclip-catalog-refresh-${page + 1}`;
|
|
response = await sendToolsList(sessionHeaders, cursor);
|
|
if (!response.ok) throw new HttpError(502, "Remote MCP catalog page could not be read", { status: response.status });
|
|
}
|
|
if (!isRailwayConnection(connection)) return descriptors;
|
|
if (descriptors.some((tool) => normalizeRailwayToolName(tool.name).startsWith(RAILWAY_TOOL_PREFIX))) {
|
|
throw unprocessable("Railway advertised a reserved Paperclip action name. Refresh is blocked pending review.", { code: "railway_tool_name_collision" });
|
|
}
|
|
let apiStatus = "available";
|
|
let apiMessage = "Direct Railway service, log, and deployment tools are available.";
|
|
try {
|
|
const railwayOptions = {
|
|
authorization: headers.Authorization ?? "",
|
|
request: (url: string, init: RequestInit) => requestRemoteHttpEndpoint(new URL(url), init),
|
|
signal: AbortSignal.timeout(15_000),
|
|
};
|
|
const workspaceId = await discoverRailwayWorkspace(railwayOptions);
|
|
await createRailwayClient(railwayOptions).probe(workspaceId);
|
|
} catch (error) {
|
|
apiStatus = "unavailable";
|
|
apiMessage = error instanceof RailwayError ? error.message : "Railway API access could not be verified. Refresh actions or reconnect Railway.";
|
|
}
|
|
// API interoperability is verified with the actual credential; the presence
|
|
// of an OAuth token alone never enables the additional capability surface.
|
|
const nextConfig = { ...connection.config, railwayApiStatus: apiStatus, railwayApiMessage: apiMessage };
|
|
await db.update(toolConnections).set({ config: nextConfig, transportConfig: nextConfig, updatedAt: now() }).where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, connection.companyId)));
|
|
connection.config = nextConfig;
|
|
connection.transportConfig = nextConfig;
|
|
return apiStatus === "available" ? [...descriptors, ...RAILWAY_TOOLS] : descriptors;
|
|
}
|
|
|
|
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 ?? {},
|
|
}));
|
|
}
|
|
|
|
function isAgentMailConnection(connection: typeof toolConnections.$inferSelect) {
|
|
return connection.transport === "rest_api" && connection.config.provider === "agentmail";
|
|
}
|
|
|
|
async function validateAgentMailConnection(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
) {
|
|
const configPath = connection.config.emailCredential
|
|
? "credentials.controlKey"
|
|
: "credentials.apiKey";
|
|
const ref = connection.credentialSecretRefs.find(
|
|
(candidate) => candidate.configPath === configPath,
|
|
);
|
|
if (!ref) {
|
|
throw unprocessable("Reconnect AgentMail to restore its API key", {
|
|
code: "missing_secret",
|
|
});
|
|
}
|
|
const key = await secrets.resolveSecretValue(
|
|
connection.companyId,
|
|
ref.secretId,
|
|
ref.versionSelector ?? "latest",
|
|
{
|
|
consumerType: "tool_connection",
|
|
consumerId: connection.id,
|
|
configPath,
|
|
actorType: "system",
|
|
actorId: null,
|
|
},
|
|
);
|
|
await agentmailApi(key).whoami();
|
|
}
|
|
|
|
function assertSupportedConnection(connection: typeof toolConnections.$inferSelect) {
|
|
if (isRetiredComposioConnection(connection)) {
|
|
throw unprocessable(RETIRED_COMPOSIO_MESSAGE, { code: "composio_broker_retired" });
|
|
}
|
|
}
|
|
|
|
async function discoverTools(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
credentialHeaders?: Record<string, string>,
|
|
actor?: ActorInfo,
|
|
): Promise<McpToolDescriptor[]> {
|
|
assertSupportedConnection(connection);
|
|
if (connection.connectionPurpose === "ai") throw unprocessable("AI connections provide runtime authentication, not tool actions");
|
|
if (isAgentMailConnection(connection)) {
|
|
await validateAgentMailConnection(connection);
|
|
return [];
|
|
}
|
|
if (connection.transport === "mcp_remote")
|
|
return remoteTools(connection, credentialHeaders, actor);
|
|
if (connection.transport !== "local_stdio") {
|
|
throw unsupportedToolConnectionTransport();
|
|
}
|
|
await resolveCredentialHeaders(connection);
|
|
return localTools(connection);
|
|
}
|
|
|
|
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",
|
|
);
|
|
}
|
|
}
|
|
|
|
async function updateConnectionHealth(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
status: ToolConnectionHealthStatus,
|
|
message: string | null,
|
|
) {
|
|
const now = new Date();
|
|
const [updated] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
healthStatus: status,
|
|
healthMessage: message,
|
|
healthCheckedAt: now,
|
|
lastHealthAt: now,
|
|
lastError: status === "ok" ? null : message,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(toolConnections.id, connection.id))
|
|
.returning();
|
|
if (connection.transport === "local_stdio") {
|
|
await db
|
|
.update(toolRuntimeSlots)
|
|
.set({
|
|
healthStatus: status,
|
|
healthMessage: message,
|
|
lastHealthCheckAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(toolRuntimeSlots.connectionId, connection.id));
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
async function checkConnectionHealth(
|
|
connectionId: string,
|
|
actor?: ActorInfo,
|
|
options: { allowUnauthenticatedProbe?: boolean } = {},
|
|
): Promise<ToolConnectionHealthCheckResult> {
|
|
const connection = await getConnectionRow(connectionId);
|
|
if (connection.connectionPurpose === "ai") return { connection: toConnection(connection), runtimeSlot: null };
|
|
try {
|
|
assertSupportedConnection(connection);
|
|
const config = asRecord(connection.config);
|
|
const oauth = asRecord(config.oauth);
|
|
if (
|
|
config.sourceTemplateKey === "github" &&
|
|
oauth.connectorProfile === "github.code"
|
|
) {
|
|
const activeGrants = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.status, "active"),
|
|
),
|
|
);
|
|
const actorGrant =
|
|
actor?.actorType === "user"
|
|
? activeGrants.find(
|
|
(grant) =>
|
|
grant.kind === "user" &&
|
|
grant.subjectUserId === actor.actorId,
|
|
)
|
|
: null;
|
|
const grantsToCheck =
|
|
connection.credentialPolicy === "per_user" &&
|
|
actor?.actorType === "user"
|
|
? actorGrant
|
|
? [actorGrant]
|
|
: []
|
|
: actorGrant
|
|
? [actorGrant]
|
|
: activeGrants;
|
|
if (grantsToCheck.length === 0)
|
|
throw unprocessable("GitHub authorization must be connected", {
|
|
code: "oauth_reauthorization_required",
|
|
});
|
|
for (const grant of grantsToCheck)
|
|
await refreshManagedGitHubGrantAccess(connection, grant, actor);
|
|
} else if (isAgentMailConnection(connection)) {
|
|
await validateAgentMailConnection(connection);
|
|
} else if (connection.transport === "mcp_remote") {
|
|
const canProbeWithoutAuthorization =
|
|
options.allowUnauthenticatedProbe === true &&
|
|
connection.status === "draft" &&
|
|
connection.authKind === "none" &&
|
|
connection.credentialPolicy === "per_user" &&
|
|
actor?.actorType === "user" &&
|
|
actor.actorId === connection.createdByUserId;
|
|
const credentialHeaders =
|
|
connection.credentialSource === "vercel_connect"
|
|
? await resolveCredentialHeaders(connection, actor, {
|
|
forceRefresh: true,
|
|
})
|
|
: canProbeWithoutAuthorization
|
|
? {}
|
|
: undefined;
|
|
await remoteTools(connection, credentialHeaders, actor);
|
|
} else if (connection.transport === "local_stdio") {
|
|
await resolveCredentialHeaders(connection);
|
|
await stdioTemplateId(connection.companyId, connection.config);
|
|
} else {
|
|
throw unsupportedToolConnectionTransport();
|
|
}
|
|
const updated = await updateConnectionHealth(
|
|
connection,
|
|
"ok",
|
|
config.sourceTemplateKey === "github" &&
|
|
oauth.connectorProfile === "github.code"
|
|
? "GitHub account, installation, and repository access are available."
|
|
: isAgentMailConnection(connection)
|
|
? "AgentMail API key is connected."
|
|
: connection.transport === "local_stdio"
|
|
? "Approved stdio template is ready."
|
|
: "Remote MCP server responded to tools/list.",
|
|
);
|
|
const runtimeSlot = await ensureRuntimeSlot(updated);
|
|
await audit({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
action: "tool_connection.health_check",
|
|
outcome: "success",
|
|
actor,
|
|
details: { transport: connection.transport },
|
|
});
|
|
return { connection: toConnection(updated), runtimeSlot };
|
|
} catch (error) {
|
|
if (
|
|
error instanceof HttpError &&
|
|
asRecord(error.details).code === "github_access_changed"
|
|
)
|
|
throw error;
|
|
const failure = sanitizeHttpFailure(error);
|
|
const updated = await updateConnectionHealth(
|
|
connection,
|
|
failure.status,
|
|
failure.message,
|
|
);
|
|
const runtimeSlot =
|
|
connection.transport === "local_stdio"
|
|
? await ensureRuntimeSlot(updated)
|
|
: null;
|
|
await audit({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
action: "tool_connection.health_check",
|
|
outcome: "failure",
|
|
reasonCode: failure.code,
|
|
actor,
|
|
details: { status: failure.status, transport: connection.transport },
|
|
});
|
|
throw new HttpError(healthFailureHttpStatus(failure), failure.message, {
|
|
code: failure.code,
|
|
connection: toConnection(updated),
|
|
runtimeSlot,
|
|
setupUrl: connectionSetupUrl(connection),
|
|
reconnectUrl: connectionReconnectUrl(connection),
|
|
});
|
|
}
|
|
}
|
|
|
|
async function refreshCatalog(
|
|
connectionId: string,
|
|
actor?: ActorInfo,
|
|
refreshOptions: {
|
|
enableAllByDefault?: boolean;
|
|
restoreDraftDefaults?: boolean;
|
|
/** Keep first managed OAuth discovery quarantined without changing generic draft refresh semantics. */
|
|
quarantineManagedOAuthDraft?: boolean;
|
|
skipDefaultProfileSync?: boolean;
|
|
credentialHeaders?: Record<string, string>;
|
|
} = {},
|
|
): Promise<ToolCatalogRefreshResult> {
|
|
const connection = await getConnectionRow(connectionId);
|
|
if (connection.connectionPurpose === "ai") throw unprocessable("AI connections do not have a tool catalog");
|
|
const refreshedAt = now();
|
|
let descriptors: McpToolDescriptor[];
|
|
try {
|
|
descriptors = await discoverTools(
|
|
connection,
|
|
refreshOptions.credentialHeaders,
|
|
actor,
|
|
);
|
|
} catch (error) {
|
|
if (
|
|
error instanceof HttpError &&
|
|
asRecord(error.details).code === "github_access_changed"
|
|
)
|
|
throw error;
|
|
const failure = sanitizeHttpFailure(error);
|
|
const updated = await updateConnectionHealth(
|
|
connection,
|
|
failure.status,
|
|
failure.message,
|
|
);
|
|
await audit({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
action: "tool_connection.catalog_refresh",
|
|
outcome: "failure",
|
|
reasonCode: failure.code,
|
|
details: { status: failure.status },
|
|
actor,
|
|
});
|
|
throw new HttpError(healthFailureHttpStatus(failure), failure.message, {
|
|
code: failure.code,
|
|
setupUrl: connectionSetupUrl(connection),
|
|
reconnectUrl: connectionReconnectUrl(connection),
|
|
});
|
|
}
|
|
|
|
const existingRows = await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(eq(toolCatalogEntries.connectionId, connection.id));
|
|
const existingByName = new Map(
|
|
existingRows.map((entry) => [entry.toolName, entry]),
|
|
);
|
|
if (isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) {
|
|
const discoveredNames = new Set(descriptors.map((descriptor) => descriptor.name));
|
|
const removedIds = existingRows.filter((entry) => !discoveredNames.has(entry.toolName) && entry.status !== "disabled").map((entry) => entry.id);
|
|
if (removedIds.length) await db.update(toolCatalogEntries)
|
|
.set({ status: "disabled", quarantineReason: "mcp_tool_removed", updatedAt: refreshedAt })
|
|
.where(and(eq(toolCatalogEntries.companyId, connection.companyId), eq(toolCatalogEntries.connectionId, connection.id), inArray(toolCatalogEntries.id, removedIds)));
|
|
}
|
|
// Retired native actions are absent from discovery, but old catalog rows
|
|
// still need to show as disabled. Gateway denial also applies before refresh.
|
|
const blockedRailwayEntryIds = isRailwayEndpoint(connection.config.url)
|
|
? existingRows.filter((entry) => isRailwayToolBlocked(entry.toolName)).map((entry) => entry.id)
|
|
: [];
|
|
if (blockedRailwayEntryIds.length > 0) {
|
|
await db.update(toolCatalogEntries).set({ status: "disabled", updatedAt: refreshedAt })
|
|
.where(and(eq(toolCatalogEntries.connectionId, connection.id), inArray(toolCatalogEntries.id, blockedRailwayEntryIds)));
|
|
}
|
|
const updatedEntries: ToolCatalogEntry[] = [];
|
|
let quarantinedCount = 0;
|
|
const sourceTemplateKey =
|
|
typeof asRecord(connection.config).sourceTemplateKey === "string"
|
|
? String(asRecord(connection.config).sourceTemplateKey)
|
|
: null;
|
|
const sourceApp = sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)
|
|
: null;
|
|
const sourceMethod = sourceApp
|
|
? connectionMethodForConnection(sourceApp, connection)
|
|
: null;
|
|
const sourceCapabilityKey = sourceMethod?.capabilityProfile?.key;
|
|
const googleProfileValue =
|
|
sourceMethod?.connectorProfile ??
|
|
sourceApp?.methods.find(
|
|
(candidate) =>
|
|
candidate.connectorProfile &&
|
|
candidate.capabilityProfile?.key === sourceCapabilityKey,
|
|
)?.connectorProfile;
|
|
const googleProfile =
|
|
googleProfileValue &&
|
|
isGoogleWorkspaceConnectorProfileId(googleProfileValue)
|
|
? googleProfileValue
|
|
: null;
|
|
const quarantineOnRefresh =
|
|
(!refreshOptions.enableAllByDefault || (isRailwayEndpoint(connection.config.url) && existingRows.length > 0)) &&
|
|
shouldQuarantineNewEntries(connection) &&
|
|
(connection.status === "active" ||
|
|
(isRailwayEndpoint(connection.config.url) && existingRows.length > 0) ||
|
|
sourceTemplateKey === "posthog" ||
|
|
refreshOptions.quarantineManagedOAuthDraft === true);
|
|
const safeDefault = asRecord(connection.config).safeDefault === true;
|
|
for (const descriptor of descriptors) {
|
|
const riskLevel = classifyRisk(descriptor, isRailwayEndpoint(connection.config.url) ? "railway" : sourceTemplateKey);
|
|
const hash = descriptorHash(descriptor, riskLevel);
|
|
const schemaHash = stableHash(descriptor.inputSchema ?? {});
|
|
const existing = existingByName.get(descriptor.name);
|
|
const changed =
|
|
existing &&
|
|
(existing.versionHash !== hash || existing.schemaHash !== schemaHash);
|
|
const shouldQuarantine =
|
|
quarantineOnRefresh &&
|
|
(!existing || changed) &&
|
|
existing?.status !== "disabled" &&
|
|
(!safeDefault || riskLevel !== "read");
|
|
const providerPermanentlyBlocked = Boolean(
|
|
googleProfile &&
|
|
!isGoogleWorkspaceToolAllowed(googleProfile, descriptor),
|
|
) || (isRailwayEndpoint(connection.config.url) && isRailwayToolBlocked(descriptor.name));
|
|
const status = providerPermanentlyBlocked
|
|
? "disabled"
|
|
: shouldQuarantine
|
|
? "quarantined"
|
|
: existing?.status === "disabled" && existing.quarantineReason !== "mcp_tool_removed"
|
|
? "disabled"
|
|
: quarantineOnRefresh && existing?.status === "quarantined"
|
|
? "quarantined"
|
|
: "active";
|
|
if (shouldQuarantine && !providerPermanentlyBlocked) quarantinedCount += 1;
|
|
|
|
if (existing) {
|
|
const [updated] = await db
|
|
.update(toolCatalogEntries)
|
|
.set({
|
|
title: descriptor.title ?? null,
|
|
description: descriptor.description ?? null,
|
|
inputSchema: descriptor.inputSchema ?? {},
|
|
annotations: descriptor.annotations ?? {},
|
|
riskLevel,
|
|
isReadOnly: riskLevel === "read",
|
|
isWrite: riskLevel === "write",
|
|
isDestructive: riskLevel === "destructive",
|
|
status,
|
|
versionHash: hash,
|
|
schemaHash,
|
|
lastSeenAt: refreshedAt,
|
|
quarantinedAt:
|
|
status === "quarantined"
|
|
? shouldQuarantine
|
|
? refreshedAt
|
|
: existing.quarantinedAt
|
|
: null,
|
|
quarantineReason:
|
|
status === "quarantined"
|
|
? shouldQuarantine
|
|
? "pending_review"
|
|
: existing.quarantineReason
|
|
: null,
|
|
updatedAt: refreshedAt,
|
|
})
|
|
.where(eq(toolCatalogEntries.id, existing.id))
|
|
.returning();
|
|
updatedEntries.push(toCatalogEntry(updated));
|
|
} else {
|
|
const [created] = await db
|
|
.insert(toolCatalogEntries)
|
|
.values({
|
|
companyId: connection.companyId,
|
|
applicationId: connection.applicationId,
|
|
connectionId: connection.id,
|
|
name: descriptor.name,
|
|
toolName: descriptor.name,
|
|
entryKind: "tool",
|
|
title: descriptor.title ?? null,
|
|
description: descriptor.description ?? null,
|
|
inputSchema: descriptor.inputSchema ?? {},
|
|
annotations: descriptor.annotations ?? {},
|
|
riskLevel,
|
|
isReadOnly: riskLevel === "read",
|
|
isWrite: riskLevel === "write",
|
|
isDestructive: riskLevel === "destructive",
|
|
status,
|
|
versionHash: hash,
|
|
schemaHash,
|
|
firstSeenAt: refreshedAt,
|
|
lastSeenAt: refreshedAt,
|
|
quarantinedAt: shouldQuarantine ? refreshedAt : null,
|
|
quarantineReason: shouldQuarantine ? "pending_review" : null,
|
|
})
|
|
.returning();
|
|
updatedEntries.push(toCatalogEntry(created));
|
|
}
|
|
}
|
|
|
|
const normalizedConfig = isRailwayEndpoint(connection.config.url)
|
|
? { ...connection.config, quarantineNewEntries: true }
|
|
: refreshOptions.enableAllByDefault
|
|
? { ...connection.config, quarantineNewEntries: false }
|
|
: connection.config;
|
|
const normalizedTransportConfig = isRailwayEndpoint(connection.config.url)
|
|
? { ...connection.transportConfig, quarantineNewEntries: true }
|
|
: refreshOptions.enableAllByDefault
|
|
? { ...connection.transportConfig, quarantineNewEntries: false }
|
|
: connection.transportConfig;
|
|
const [updatedConnection] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: normalizedConfig,
|
|
transportConfig: normalizedTransportConfig,
|
|
healthStatus: "ok",
|
|
healthMessage: isAgentMailConnection(connection)
|
|
? "AgentMail API key is connected."
|
|
: "Tool catalog refreshed.",
|
|
healthCheckedAt: refreshedAt,
|
|
lastHealthAt: refreshedAt,
|
|
lastCatalogRefreshAt: refreshedAt,
|
|
lastError: null,
|
|
updatedAt: refreshedAt,
|
|
})
|
|
.where(eq(toolConnections.id, connection.id))
|
|
.returning();
|
|
|
|
if (connection.transport === "local_stdio") {
|
|
await ensureRuntimeSlot(updatedConnection);
|
|
await db
|
|
.update(toolRuntimeSlots)
|
|
.set({
|
|
healthStatus: "ok",
|
|
healthMessage: "Approved stdio template is ready.",
|
|
lastHealthCheckAt: refreshedAt,
|
|
updatedAt: refreshedAt,
|
|
})
|
|
.where(eq(toolRuntimeSlots.connectionId, connection.id));
|
|
}
|
|
|
|
const activeEntries = updatedEntries.filter(
|
|
(entry) => entry.status === "active",
|
|
);
|
|
const preserveMcpAccess = connection.config.mcpPreserveAccess === true
|
|
&& isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey);
|
|
if (!refreshOptions.skipDefaultProfileSync || preserveMcpAccess) {
|
|
await enableCatalogEntriesByDefault({
|
|
connection: updatedConnection,
|
|
newCatalogEntryIds: refreshOptions.enableAllByDefault && !isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)
|
|
? activeEntries.map((entry) => entry.id)
|
|
: activeEntries
|
|
.filter((entry) => {
|
|
const previous = existingByName.get(entry.toolName);
|
|
return !previous || previous.status === "quarantined";
|
|
})
|
|
.map((entry) => entry.id),
|
|
activeCatalogEntryIds: activeEntries.map((entry) => entry.id),
|
|
restoreDraftDefaults: refreshOptions.restoreDraftDefaults || preserveMcpAccess,
|
|
actor,
|
|
});
|
|
}
|
|
await audit({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
action: "tool_connection.catalog_refresh",
|
|
outcome: "success",
|
|
details: { discoveredCount: descriptors.length, quarantinedCount },
|
|
actor,
|
|
});
|
|
|
|
return {
|
|
connection: toConnection(updatedConnection),
|
|
catalog: updatedEntries,
|
|
discoveredCount: descriptors.length,
|
|
quarantinedCount,
|
|
};
|
|
}
|
|
|
|
async function listAppsNeedingAttention(
|
|
companyId: string,
|
|
): Promise<ToolAppsAttentionResponse> {
|
|
const generatedAt = now();
|
|
const [
|
|
connections,
|
|
quarantinedEntries,
|
|
pendingActionRequests,
|
|
invocations,
|
|
profiles,
|
|
profileEntries,
|
|
activeCatalog,
|
|
] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.companyId, companyId),
|
|
ne(toolConnections.status, "archived"),
|
|
),
|
|
),
|
|
db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, companyId),
|
|
eq(toolCatalogEntries.status, "quarantined"),
|
|
),
|
|
),
|
|
db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.companyId, companyId),
|
|
eq(toolActionRequests.status, "pending"),
|
|
isNotNull(toolActionRequests.signedArguments),
|
|
),
|
|
),
|
|
db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(eq(toolInvocations.companyId, companyId)),
|
|
db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.companyId, companyId)),
|
|
db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.companyId, companyId)),
|
|
db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, companyId),
|
|
eq(toolCatalogEntries.status, "active"),
|
|
),
|
|
),
|
|
]);
|
|
const quarantinedCountByConnection = new Map<string, number>();
|
|
for (const entry of quarantinedEntries) {
|
|
quarantinedCountByConnection.set(
|
|
entry.connectionId,
|
|
(quarantinedCountByConnection.get(entry.connectionId) ?? 0) + 1,
|
|
);
|
|
}
|
|
const invocationConnectionById = new Map(
|
|
invocations.map((invocation) => [invocation.id, invocation.connectionId]),
|
|
);
|
|
const pendingActionRequestCountByConnection = new Map<string, number>();
|
|
for (const request of pendingActionRequests) {
|
|
const connectionId = invocationConnectionById.get(request.invocationId);
|
|
if (!connectionId) continue;
|
|
pendingActionRequestCountByConnection.set(
|
|
connectionId,
|
|
(pendingActionRequestCountByConnection.get(connectionId) ?? 0) + 1,
|
|
);
|
|
}
|
|
const entriesByProfile = new Map<
|
|
string,
|
|
Array<typeof toolProfileEntries.$inferSelect>
|
|
>();
|
|
for (const entry of profileEntries) {
|
|
const list = entriesByProfile.get(entry.profileId) ?? [];
|
|
list.push(entry);
|
|
entriesByProfile.set(entry.profileId, list);
|
|
}
|
|
const connectionsById = new Map(
|
|
connections.map((connection) => [connection.id, connection]),
|
|
);
|
|
const pendingProfilesByConnection = new Map<
|
|
string,
|
|
Map<
|
|
string,
|
|
{ profileId: string; profileName: string; pendingCount: number }
|
|
>
|
|
>();
|
|
for (const profile of profiles) {
|
|
const tools = pendingNewToolsForProfile({
|
|
profile,
|
|
entries: entriesByProfile.get(profile.id) ?? [],
|
|
catalog: activeCatalog,
|
|
connectionsById,
|
|
});
|
|
for (const tool of tools) {
|
|
const profileCounts =
|
|
pendingProfilesByConnection.get(tool.connectionId) ?? new Map();
|
|
const existing = profileCounts.get(profile.id) ?? {
|
|
profileId: profile.id,
|
|
profileName: profile.name,
|
|
pendingCount: 0,
|
|
};
|
|
existing.pendingCount += 1;
|
|
profileCounts.set(profile.id, existing);
|
|
pendingProfilesByConnection.set(tool.connectionId, profileCounts);
|
|
}
|
|
}
|
|
const apps = connections.flatMap((connection) => {
|
|
const healthNeedsAttention = isAttentionHealthStatus(
|
|
connection.healthStatus,
|
|
);
|
|
const quarantinedCatalogEntryCount =
|
|
quarantinedCountByConnection.get(connection.id) ?? 0;
|
|
const pendingActionRequestCount =
|
|
pendingActionRequestCountByConnection.get(connection.id) ?? 0;
|
|
const newToolsPendingProfiles = [
|
|
...(pendingProfilesByConnection.get(connection.id)?.values() ?? []),
|
|
].sort(
|
|
(a, b) =>
|
|
b.pendingCount - a.pendingCount ||
|
|
a.profileName.localeCompare(b.profileName),
|
|
);
|
|
const newToolsPendingReviewCount = newToolsPendingProfiles.reduce(
|
|
(sum, profile) => sum + profile.pendingCount,
|
|
0,
|
|
);
|
|
const reasons = [
|
|
...(healthNeedsAttention ? ["health" as const] : []),
|
|
...(quarantinedCatalogEntryCount > 0
|
|
? ["quarantined_catalog_entries" as const]
|
|
: []),
|
|
...(pendingActionRequestCount > 0
|
|
? ["pending_action_requests" as const]
|
|
: []),
|
|
...(newToolsPendingReviewCount > 0
|
|
? ["profile_new_tools" as const]
|
|
: []),
|
|
];
|
|
return reasons.length > 0
|
|
? [
|
|
{
|
|
connection: toConnection(connection),
|
|
healthNeedsAttention,
|
|
quarantinedCatalogEntryCount,
|
|
pendingActionRequestCount,
|
|
newToolsPendingReviewCount,
|
|
newToolsPendingProfiles,
|
|
reasons,
|
|
},
|
|
]
|
|
: [];
|
|
});
|
|
return {
|
|
generatedAt,
|
|
apps,
|
|
totals: {
|
|
connections: apps.length,
|
|
health: apps.filter((app) => app.healthNeedsAttention).length,
|
|
quarantinedCatalogEntries: apps.reduce(
|
|
(sum, app) => sum + app.quarantinedCatalogEntryCount,
|
|
0,
|
|
),
|
|
pendingActionRequests: apps.reduce(
|
|
(sum, app) => sum + app.pendingActionRequestCount,
|
|
0,
|
|
),
|
|
newToolsPendingReview: apps.reduce(
|
|
(sum, app) => sum + app.newToolsPendingReviewCount,
|
|
0,
|
|
),
|
|
newToolsPendingProfiles: apps.reduce(
|
|
(sum, app) => sum + app.newToolsPendingProfiles.length,
|
|
0,
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function sweepConnectionHealth(
|
|
input: { staleAfterMs?: number; limit?: number } = {},
|
|
) {
|
|
const generatedAt = now();
|
|
const staleAfterMs = input.staleAfterMs ?? 15 * 60 * 1000;
|
|
const limit = input.limit ?? 25;
|
|
const cutoff = new Date(generatedAt.getTime() - staleAfterMs);
|
|
// Legacy plugin backfills use a remote transport as a placeholder, but
|
|
// their tools run in the plugin worker and have no remote MCP endpoint.
|
|
// Select only due IDs in SQL so each scheduler tick does not decode every
|
|
// active connection's config and credential metadata.
|
|
const due = await db
|
|
.select({ id: toolConnections.id })
|
|
.from(toolConnections)
|
|
.innerJoin(toolApplications, and(
|
|
eq(toolApplications.id, toolConnections.applicationId),
|
|
eq(toolApplications.companyId, toolConnections.companyId),
|
|
))
|
|
.where(
|
|
and(
|
|
eq(toolConnections.enabled, true),
|
|
eq(toolConnections.status, "active"),
|
|
ne(toolConnections.transport, "chat_sdk"),
|
|
ne(toolConnections.transport, "runtime_auth"),
|
|
ne(toolApplications.type, "paperclip_plugin"),
|
|
or(isNull(toolConnections.healthCheckedAt), lte(toolConnections.healthCheckedAt, cutoff)),
|
|
),
|
|
)
|
|
.orderBy(
|
|
sql`${toolConnections.healthCheckedAt} asc nulls first`,
|
|
asc(toolConnections.createdAt),
|
|
asc(toolConnections.id),
|
|
)
|
|
.limit(limit);
|
|
let healthy = 0;
|
|
let failed = 0;
|
|
const failedConnectionIds: string[] = [];
|
|
for (const connection of due) {
|
|
try {
|
|
await checkConnectionHealth(connection.id, {
|
|
actorType: "system",
|
|
actorId: "tool_health_sweep",
|
|
});
|
|
healthy += 1;
|
|
} catch {
|
|
failed += 1;
|
|
failedConnectionIds.push(connection.id);
|
|
}
|
|
}
|
|
return {
|
|
checked: due.length,
|
|
healthy,
|
|
failed,
|
|
failedConnectionIds,
|
|
};
|
|
}
|
|
|
|
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);
|
|
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);
|
|
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:
|
|
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
|
|
})
|
|
.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 actionSummary(
|
|
entry: ToolCatalogEntry,
|
|
): ToolAppConnectionActionSummary {
|
|
return {
|
|
catalogEntryId: entry.id,
|
|
toolName: entry.toolName,
|
|
title: entry.title,
|
|
description: entry.description,
|
|
riskLevel: entry.riskLevel,
|
|
isReadOnly: entry.isReadOnly,
|
|
isWrite: entry.isWrite,
|
|
isDestructive: entry.isDestructive,
|
|
status: entry.status,
|
|
};
|
|
}
|
|
|
|
function groupedActions(
|
|
catalog: ToolCatalogEntry[],
|
|
): ConnectToolAppResult["actions"] {
|
|
const readOnly: ToolAppConnectionActionSummary[] = [];
|
|
const canMakeChanges: ToolAppConnectionActionSummary[] = [];
|
|
for (const entry of catalog) {
|
|
const summary = actionSummary(entry);
|
|
if (
|
|
entry.isReadOnly &&
|
|
entry.riskLevel === "read" &&
|
|
!entry.isWrite &&
|
|
!entry.isDestructive
|
|
) {
|
|
readOnly.push(summary);
|
|
} else {
|
|
canMakeChanges.push(summary);
|
|
}
|
|
}
|
|
return { readOnly, canMakeChanges };
|
|
}
|
|
|
|
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 actorForSecret(
|
|
actor?: ActorInfo,
|
|
): { userId?: string | null; agentId?: string | null } | undefined {
|
|
if (actor?.actorType === "user") return { userId: actor.actorId ?? null };
|
|
if (actor?.actorType === "agent") return { agentId: actor.actorId ?? null };
|
|
return undefined;
|
|
}
|
|
|
|
function oauthEnvName(
|
|
provider: string,
|
|
suffix: "CLIENT_ID" | "CLIENT_SECRET",
|
|
) {
|
|
return `PAPERCLIP_TOOL_OAUTH_${provider.replace(/[^a-z0-9]+/gi, "_").toUpperCase()}_${suffix}`;
|
|
}
|
|
|
|
function oauthClientConfig(provider: string) {
|
|
const clientIdEnv = oauthEnvName(provider, "CLIENT_ID");
|
|
const clientSecretEnv = oauthEnvName(provider, "CLIENT_SECRET");
|
|
return {
|
|
clientIdEnv,
|
|
clientSecretEnv,
|
|
clientId:
|
|
process.env[clientIdEnv] ??
|
|
process.env.PAPERCLIP_TOOL_OAUTH_CLIENT_ID ??
|
|
null,
|
|
clientSecret:
|
|
process.env[clientSecretEnv] ??
|
|
process.env.PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET ??
|
|
null,
|
|
};
|
|
}
|
|
|
|
function isSmokeLabOAuthFixture(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
) {
|
|
const config = asRecord(connection.config);
|
|
const oauth = oauthConfig(connection);
|
|
return (
|
|
config.smokeLabFixture === "oauth-http" && oauth.smokeLabFixture === true
|
|
);
|
|
}
|
|
|
|
function smokeLabOAuthEndpoints(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
redirectUri?: string,
|
|
): OAuthProviderEndpoints | null {
|
|
if (!isSmokeLabOAuthFixture(connection) || !redirectUri) return null;
|
|
let origin: string;
|
|
try {
|
|
origin = new URL(redirectUri).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
const oauthBasePath = `/api/companies/${encodeURIComponent(connection.companyId)}/smoke-lab/oauth`;
|
|
return {
|
|
provider: "smoke_lab",
|
|
scopes: normalizeOauthScopes(oauthConfig(connection).scopes),
|
|
authorizationUrl: new URL(
|
|
`${oauthBasePath}/authorize`,
|
|
origin,
|
|
).toString(),
|
|
tokenUrl: new URL(`${oauthBasePath}/token`, origin).toString(),
|
|
metadataUrl: null,
|
|
grantType: "authorization_code",
|
|
};
|
|
}
|
|
|
|
function configuredOAuthClientForConnection(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
provider: string,
|
|
) {
|
|
if (isSmokeLabOAuthFixture(connection) && provider === "smoke_lab") {
|
|
return {
|
|
clientIdEnv: "SMOKE_LAB_FIXED_CLIENT_ID",
|
|
clientSecretEnv: "SMOKE_LAB_FIXED_CLIENT_SECRET",
|
|
clientId: "paperclip-smoke-lab",
|
|
clientSecret: null,
|
|
};
|
|
}
|
|
return oauthClientConfig(provider);
|
|
}
|
|
|
|
async function oauthClientForConnection(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
provider: string,
|
|
actor?: ActorInfo,
|
|
) {
|
|
const configured = configuredOAuthClientForConnection(connection, provider);
|
|
if (configured.clientId) return configured;
|
|
const oauth = oauthConfig(connection);
|
|
const clientId =
|
|
typeof oauth.clientId === "string" && oauth.clientId.trim()
|
|
? oauth.clientId.trim()
|
|
: null;
|
|
if (!clientId) return configured;
|
|
// CIMD clients and public DCR clients have no token-endpoint secret. A DCR
|
|
// authorization server may instead issue a confidential client (for
|
|
// example, `client_secret_basic`); in that case the registration secret is
|
|
// encrypted like every other provider credential and resolved only for the
|
|
// token endpoint.
|
|
const registrationSource = oauth.clientRegistrationSource;
|
|
const publicRegisteredClient =
|
|
registrationSource === "cimd" ||
|
|
(registrationSource === "dcr" &&
|
|
oauth.clientTokenEndpointAuthMethod === "none");
|
|
let credentialSecretRefs = connection.credentialSecretRefs;
|
|
if (
|
|
connection.credentialPolicy === "per_user" &&
|
|
actor?.actorType === "user" &&
|
|
actor.actorId &&
|
|
!credentialSecretRefs.some(
|
|
(ref) => ref.configPath === "oauth.client_secret",
|
|
)
|
|
) {
|
|
const [personalGrant] = await db
|
|
.select({
|
|
credentialSecretRefs: connectionGrants.credentialSecretRefs,
|
|
})
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.subjectUserId, actor.actorId),
|
|
eq(connectionGrants.status, "active"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
credentialSecretRefs =
|
|
personalGrant?.credentialSecretRefs ?? credentialSecretRefs;
|
|
}
|
|
const clientSecretRef = publicRegisteredClient
|
|
? undefined
|
|
: credentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.client_secret",
|
|
);
|
|
const clientSecret = clientSecretRef
|
|
? await secrets.resolveSecretValue(
|
|
connection.companyId,
|
|
clientSecretRef.secretId,
|
|
clientSecretRef.versionSelector ?? "latest",
|
|
{
|
|
consumerType: "tool_connection",
|
|
consumerId: connection.id,
|
|
configPath: "oauth.client_secret",
|
|
actorType: actor?.actorType ?? "system",
|
|
actorId: actor?.actorId ?? null,
|
|
},
|
|
)
|
|
: null;
|
|
return {
|
|
clientIdEnv: null,
|
|
clientSecretEnv: null,
|
|
clientId,
|
|
clientSecret,
|
|
};
|
|
}
|
|
|
|
function base64UrlSha256(input: string) {
|
|
return createHash("sha256").update(input).digest("base64url");
|
|
}
|
|
|
|
function randomOauthToken(bytes = 32) {
|
|
return randomBytes(bytes).toString("base64url");
|
|
}
|
|
|
|
function oauthConfig(connection: typeof toolConnections.$inferSelect) {
|
|
const oauth = asRecord(connection.config).oauth
|
|
? asRecord(asRecord(connection.config).oauth)
|
|
: {};
|
|
const {
|
|
access_token: _accessToken,
|
|
refresh_token: _refreshToken,
|
|
accessToken: _camelAccessToken,
|
|
refreshToken: _camelRefreshToken,
|
|
...metadata
|
|
} = oauth;
|
|
return metadata;
|
|
}
|
|
|
|
function connectionSetupUrl(connection: typeof toolConnections.$inferSelect) {
|
|
return `/apps/${connection.id}/permissions`;
|
|
}
|
|
|
|
function connectionReconnectUrl(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
) {
|
|
return `/apps/${connection.id}/permissions`;
|
|
}
|
|
|
|
function credentialScope(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
actor?: ActorInfo,
|
|
) {
|
|
const configured = asRecord(oauthConfig(connection).credentialScope);
|
|
const type =
|
|
typeof configured.type === "string"
|
|
? configured.type
|
|
: typeof configured.targetType === "string"
|
|
? configured.targetType
|
|
: actor?.actorType === "agent"
|
|
? "agent"
|
|
: actor?.actorType === "user"
|
|
? "user"
|
|
: "company";
|
|
const id =
|
|
typeof configured.id === "string"
|
|
? configured.id
|
|
: typeof configured.targetId === "string"
|
|
? configured.targetId
|
|
: (actor?.actorId ?? connection.companyId);
|
|
return {
|
|
type,
|
|
id,
|
|
hash: stableHash({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
type,
|
|
id,
|
|
}),
|
|
};
|
|
}
|
|
|
|
function normalizeOauthScopes(value: unknown): string[] {
|
|
if (Array.isArray(value))
|
|
return value.filter(
|
|
(item): item is string =>
|
|
typeof item === "string" && item.trim().length > 0,
|
|
);
|
|
if (typeof value === "string")
|
|
return value
|
|
.split(/\s+/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
return [];
|
|
}
|
|
|
|
function isSmokeLabOAuthUrl(value: string | null | undefined) {
|
|
if (!value) return false;
|
|
try {
|
|
return /\/smoke-lab\/oauth(?:\/|$)/.test(new URL(value).pathname);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function assertNotSmokeLabOAuthEndpoints(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
endpoints: OAuthProviderEndpoints,
|
|
) {
|
|
const blockedUrl = [
|
|
endpoints.authorizationUrl,
|
|
endpoints.tokenUrl,
|
|
endpoints.metadataUrl,
|
|
].find(isSmokeLabOAuthUrl);
|
|
if (blockedUrl && !isSmokeLabOAuthFixture(connection)) {
|
|
throw unprocessable(
|
|
"Smoke Lab OAuth provider cannot be used for tool app sign-in",
|
|
);
|
|
}
|
|
}
|
|
|
|
function oauthProviderForConnection(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
metadataUrl?: string | null,
|
|
): string {
|
|
const oauth = oauthConfig(connection);
|
|
if (typeof oauth.provider === "string" && oauth.provider.trim())
|
|
return oauth.provider.trim();
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey.trim()
|
|
: "";
|
|
if (sourceTemplateKey) return sourceTemplateKey;
|
|
const url = metadataUrl ?? remoteEndpoint(connection.config);
|
|
try {
|
|
return (
|
|
new URL(url).hostname
|
|
.replace(/[^a-z0-9]+/gi, "_")
|
|
.replace(/^_+|_+$/g, "")
|
|
.toLowerCase() || "generic"
|
|
);
|
|
} catch {
|
|
return "generic";
|
|
}
|
|
}
|
|
|
|
function parseWwwAuthenticateParams(value: string): Record<string, string> {
|
|
const params: Record<string, string> = {};
|
|
const input = value.replace(/^\s*Bearer\s+/i, "");
|
|
const re = /([a-zA-Z_][a-zA-Z0-9_-]*)=(?:"([^"]*)"|([^,\s]+))/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = re.exec(input))) {
|
|
params[match[1]!.toLowerCase()] = match[2] ?? match[3] ?? "";
|
|
}
|
|
return params;
|
|
}
|
|
|
|
function challengeOAuthHints(wwwAuthenticate: string) {
|
|
const params = parseWwwAuthenticateParams(wwwAuthenticate);
|
|
return {
|
|
metadataUrl:
|
|
params.resource_metadata ??
|
|
params.resource_metadata_url ??
|
|
params.metadata_url ??
|
|
null,
|
|
authorizationUrl:
|
|
params.authorization_uri ?? params.authorization_url ?? null,
|
|
tokenUrl: params.token_uri ?? params.token_url ?? null,
|
|
scope: params.scope ?? null,
|
|
};
|
|
}
|
|
|
|
function oauthSecretRef(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
configPath:
|
|
"oauth.access_token" | "oauth.refresh_token" | "oauth.client_secret" | "railway.ssh_private_key",
|
|
) {
|
|
return (
|
|
connection.credentialSecretRefs.find(
|
|
(ref) => ref.configPath === configPath,
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
function oauthExpiresAtMs(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): number | null {
|
|
const expiresAt = oauthConfig(connection).expiresAt;
|
|
if (typeof expiresAt !== "string") return null;
|
|
const ms = Date.parse(expiresAt);
|
|
return Number.isFinite(ms) ? ms : null;
|
|
}
|
|
|
|
async function fetchJsonRecord(
|
|
url: string,
|
|
): Promise<Record<string, unknown> | null> {
|
|
try {
|
|
const response = await fetchRemoteHttpUrl(url);
|
|
if (!response.ok) return null;
|
|
return asRecord((await response.json()) as unknown) ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Candidate authorization-server metadata URLs advertised by protected-resource
|
|
* metadata, paired with the issuer that advertised them so the caller can bind
|
|
* the resulting client material to a canonical issuer.
|
|
*/
|
|
function authServerMetadataUrls(
|
|
metadata: Record<string, unknown>,
|
|
): Array<{ issuer: string; metadataUrl: string }> {
|
|
const candidates: Array<{ issuer: string; metadataUrl: string }> = [];
|
|
const issuers: string[] = [];
|
|
if (Array.isArray(metadata.authorization_servers)) {
|
|
for (const server of metadata.authorization_servers) {
|
|
if (typeof server === "string" && server.trim())
|
|
issuers.push(server.trim());
|
|
}
|
|
}
|
|
if (typeof metadata.issuer === "string" && metadata.issuer.trim())
|
|
issuers.push(metadata.issuer.trim());
|
|
for (const issuer of [...new Set(issuers)]) {
|
|
for (const metadataUrl of wellKnownMetadataUrls(issuer)) {
|
|
candidates.push({ issuer, metadataUrl });
|
|
}
|
|
}
|
|
const seen = new Set<string>();
|
|
return candidates.filter((candidate) => {
|
|
if (seen.has(candidate.metadataUrl)) return false;
|
|
seen.add(candidate.metadataUrl);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async function endpointsFromMetadataUrl(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
metadataUrl: string,
|
|
rejections: HttpError[] = [],
|
|
firstPartyOrigin?: string | null,
|
|
): Promise<OAuthProviderEndpoints | null> {
|
|
const metadata = await fetchJsonRecord(metadataUrl);
|
|
if (!metadata) return null;
|
|
// Every endpoint below is a string the remote server chose, so none of them
|
|
// is adopted before `safeOAuthEndpointUrl` has vetted its scheme and host.
|
|
let authorizationUrl = safeOAuthEndpointUrl(
|
|
"authorization",
|
|
metadata.authorization_endpoint,
|
|
rejections,
|
|
firstPartyOrigin,
|
|
);
|
|
let tokenUrl = safeOAuthEndpointUrl(
|
|
"token",
|
|
metadata.token_endpoint,
|
|
rejections,
|
|
firstPartyOrigin,
|
|
);
|
|
let registrationUrl = safeOAuthEndpointUrl(
|
|
"registration",
|
|
metadata.registration_endpoint,
|
|
rejections,
|
|
firstPartyOrigin,
|
|
);
|
|
let scopes = normalizeOauthScopes(metadata.scopes_supported);
|
|
let codeChallengeMethodsSupported = normalizeOauthScopes(
|
|
metadata.code_challenge_methods_supported,
|
|
);
|
|
let tokenEndpointAuthMethodsSupported = normalizeOauthScopes(
|
|
metadata.token_endpoint_auth_methods_supported,
|
|
);
|
|
let grantTypesSupported = normalizeOauthScopes(
|
|
metadata.grant_types_supported,
|
|
);
|
|
let clientIdMetadataDocumentSupported =
|
|
metadata.client_id_metadata_document_supported === true;
|
|
// A document that carries the authorization endpoint itself *is* the
|
|
// authorization-server metadata, so its own `issuer` is the canonical one.
|
|
// Otherwise this was protected-resource metadata and the issuer comes from
|
|
// whichever advertised authorization server answered.
|
|
let issuer =
|
|
authorizationUrl &&
|
|
typeof metadata.issuer === "string" &&
|
|
metadata.issuer.trim()
|
|
? metadata.issuer.trim()
|
|
: null;
|
|
const resource =
|
|
typeof metadata.resource === "string" && metadata.resource.trim()
|
|
? metadata.resource.trim()
|
|
: null;
|
|
for (const candidate of authServerMetadataUrls(metadata)) {
|
|
const authMetadata = await fetchJsonRecord(candidate.metadataUrl);
|
|
if (!authMetadata) continue;
|
|
const candidateAuthorizationUrl = safeOAuthEndpointUrl(
|
|
"authorization",
|
|
authMetadata.authorization_endpoint,
|
|
rejections,
|
|
firstPartyOrigin,
|
|
);
|
|
const candidateTokenUrl = safeOAuthEndpointUrl(
|
|
"token",
|
|
authMetadata.token_endpoint,
|
|
rejections,
|
|
firstPartyOrigin,
|
|
);
|
|
if (!candidateAuthorizationUrl && !candidateTokenUrl) continue;
|
|
// RFC 8414 §3.3: the metadata document's `issuer` must match the issuer we
|
|
// used to build the discovery URL, or the document is not authoritative.
|
|
const advertisedIssuer =
|
|
typeof authMetadata.issuer === "string" && authMetadata.issuer.trim()
|
|
? authMetadata.issuer.trim()
|
|
: null;
|
|
if (
|
|
advertisedIssuer &&
|
|
!sameOAuthIssuer(advertisedIssuer, candidate.issuer)
|
|
)
|
|
continue;
|
|
authorizationUrl = authorizationUrl ?? candidateAuthorizationUrl;
|
|
tokenUrl = tokenUrl ?? candidateTokenUrl;
|
|
registrationUrl =
|
|
registrationUrl ??
|
|
safeOAuthEndpointUrl(
|
|
"registration",
|
|
authMetadata.registration_endpoint,
|
|
rejections,
|
|
firstPartyOrigin,
|
|
);
|
|
issuer = issuer ?? advertisedIssuer ?? candidate.issuer;
|
|
if (scopes.length === 0)
|
|
scopes = normalizeOauthScopes(authMetadata.scopes_supported);
|
|
if (codeChallengeMethodsSupported.length === 0) {
|
|
codeChallengeMethodsSupported = normalizeOauthScopes(
|
|
authMetadata.code_challenge_methods_supported,
|
|
);
|
|
}
|
|
if (tokenEndpointAuthMethodsSupported.length === 0) {
|
|
tokenEndpointAuthMethodsSupported = normalizeOauthScopes(
|
|
authMetadata.token_endpoint_auth_methods_supported,
|
|
);
|
|
}
|
|
if (grantTypesSupported.length === 0) {
|
|
grantTypesSupported = normalizeOauthScopes(
|
|
authMetadata.grant_types_supported,
|
|
);
|
|
}
|
|
if (!clientIdMetadataDocumentSupported) {
|
|
clientIdMetadataDocumentSupported =
|
|
authMetadata.client_id_metadata_document_supported === true;
|
|
}
|
|
if (authorizationUrl && tokenUrl) break;
|
|
}
|
|
if (!authorizationUrl || !tokenUrl) return null;
|
|
return {
|
|
provider: oauthProviderForConnection(connection, metadataUrl),
|
|
scopes,
|
|
authorizationUrl,
|
|
tokenUrl,
|
|
registrationUrl,
|
|
codeChallengeMethodsSupported,
|
|
tokenEndpointAuthMethodsSupported,
|
|
grantTypesSupported,
|
|
metadataUrl,
|
|
issuer,
|
|
resource,
|
|
clientIdMetadataDocumentSupported,
|
|
};
|
|
}
|
|
|
|
async function discoverOAuthEndpoints(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
challenge?: string | null,
|
|
firstPartyOrigin?: string | null,
|
|
): Promise<OAuthProviderEndpoints | null> {
|
|
const oauth = oauthConfig(connection);
|
|
const hints = challenge ? challengeOAuthHints(challenge) : null;
|
|
// A configured endpoint was pasted by an operator or persisted from an
|
|
// earlier discovery, and a hint came straight out of the endpoint's
|
|
// `WWW-Authenticate` header. Neither is more trusted than metadata, so both
|
|
// go through the same gate; an unusable one is dropped so full metadata
|
|
// discovery still runs below.
|
|
const rejections: HttpError[] = [];
|
|
const configuredAuthorizationUrl = safeOAuthEndpointUrl(
|
|
"authorization",
|
|
typeof oauth.authorizationUrl === "string"
|
|
? oauth.authorizationUrl
|
|
: (hints?.authorizationUrl ?? null),
|
|
rejections,
|
|
firstPartyOrigin,
|
|
);
|
|
const configuredTokenUrl = safeOAuthEndpointUrl(
|
|
"token",
|
|
typeof oauth.tokenUrl === "string"
|
|
? oauth.tokenUrl
|
|
: (hints?.tokenUrl ?? null),
|
|
rejections,
|
|
firstPartyOrigin,
|
|
);
|
|
const provider = oauthProviderForConnection(
|
|
connection,
|
|
typeof oauth.metadataUrl === "string"
|
|
? oauth.metadataUrl
|
|
: hints?.metadataUrl,
|
|
);
|
|
const scopes =
|
|
normalizeOauthScopes(oauth.scopes).length > 0
|
|
? normalizeOauthScopes(oauth.scopes)
|
|
: normalizeOauthScopes(oauth.scope).length > 0
|
|
? normalizeOauthScopes(oauth.scope)
|
|
: normalizeOauthScopes(hints?.scope);
|
|
const grantType =
|
|
oauth.grantType === "client_credentials" ||
|
|
oauth.clientCredentials === true
|
|
? ("client_credentials" as const)
|
|
: ("authorization_code" as const);
|
|
// The resource indicator is the MCP endpoint itself, independent of which
|
|
// authorization server ends up serving it.
|
|
const configuredResource =
|
|
typeof oauth.resource === "string" && oauth.resource.trim()
|
|
? oauth.resource.trim()
|
|
: canonicalResourceIndicator(remoteEndpoint(connection.config));
|
|
if (configuredAuthorizationUrl && configuredTokenUrl) {
|
|
return {
|
|
provider,
|
|
scopes,
|
|
authorizationUrl: configuredAuthorizationUrl,
|
|
tokenUrl: configuredTokenUrl,
|
|
registrationUrl: safeOAuthEndpointUrl(
|
|
"registration",
|
|
oauth.registrationUrl,
|
|
rejections,
|
|
firstPartyOrigin,
|
|
),
|
|
codeChallengeMethodsSupported: normalizeOauthScopes(
|
|
oauth.codeChallengeMethodsSupported,
|
|
),
|
|
tokenEndpointAuthMethodsSupported: normalizeOauthScopes(
|
|
oauth.tokenEndpointAuthMethodsSupported,
|
|
),
|
|
grantType,
|
|
metadataUrl:
|
|
typeof oauth.metadataUrl === "string"
|
|
? oauth.metadataUrl
|
|
: (hints?.metadataUrl ?? null),
|
|
issuer:
|
|
typeof oauth.issuer === "string" && oauth.issuer.trim()
|
|
? oauth.issuer.trim()
|
|
: null,
|
|
resource: configuredResource,
|
|
clientIdMetadataDocumentSupported:
|
|
oauth.clientIdMetadataDocumentSupported === true,
|
|
};
|
|
}
|
|
|
|
const metadataCandidates = [
|
|
typeof oauth.metadataUrl === "string" ? oauth.metadataUrl : null,
|
|
hints?.metadataUrl ?? null,
|
|
].filter((value): value is string => Boolean(value));
|
|
if (metadataCandidates.length === 0) {
|
|
const endpoint = new URL(
|
|
await assertRemoteEndpointAllowed(connection.config),
|
|
);
|
|
metadataCandidates.push(...protectedResourceMetadataUrls(endpoint));
|
|
// The MCP server may double as its own authorization server, in which case
|
|
// it serves authorization-server metadata directly at (or under) its path.
|
|
metadataCandidates.push(...wellKnownMetadataUrls(endpoint.toString()));
|
|
}
|
|
for (const metadataUrl of [...new Set(metadataCandidates)]) {
|
|
const endpoints = await endpointsFromMetadataUrl(
|
|
connection,
|
|
metadataUrl,
|
|
rejections,
|
|
firstPartyOrigin,
|
|
);
|
|
if (endpoints) {
|
|
return {
|
|
...endpoints,
|
|
scopes: scopes.length > 0 ? scopes : endpoints.scopes,
|
|
grantType,
|
|
resource: endpoints.resource ?? configuredResource,
|
|
};
|
|
}
|
|
}
|
|
// Nothing usable was found *and* something was refused on the way: report the
|
|
// refusal rather than the generic "does not advertise OAuth sign in", so the
|
|
// operator learns the server offered an unsafe address.
|
|
if (rejections.length > 0) throw rejections[0];
|
|
return null;
|
|
}
|
|
|
|
async function oauthProviderEndpoints(
|
|
app: AppDefinition,
|
|
methodKey?: string | null,
|
|
): Promise<OAuthProviderEndpoints> {
|
|
const method = connectionMethodFor(app, methodKey);
|
|
if (method.auth !== "oauth")
|
|
throw unprocessable("This app does not support sign in");
|
|
let authorizationUrl = method.defaults?.authorizationEndpoint ?? null;
|
|
let tokenUrl = method.defaults?.tokenEndpoint ?? null;
|
|
const metadataUrl = method.defaults?.metadataUrl ?? null;
|
|
if ((!authorizationUrl || !tokenUrl) && metadataUrl) {
|
|
const response = await fetchRemoteHttpUrl(metadataUrl);
|
|
if (!response.ok)
|
|
throw new HttpError(
|
|
502,
|
|
"OAuth provider metadata could not be loaded",
|
|
{ code: "oauth_metadata_failed" },
|
|
);
|
|
const metadata = asRecord((await response.json()) as unknown);
|
|
authorizationUrl =
|
|
authorizationUrl ??
|
|
(typeof metadata.authorization_endpoint === "string"
|
|
? metadata.authorization_endpoint
|
|
: null);
|
|
tokenUrl =
|
|
tokenUrl ??
|
|
(typeof metadata.token_endpoint === "string"
|
|
? metadata.token_endpoint
|
|
: null);
|
|
}
|
|
if (!authorizationUrl || !tokenUrl) {
|
|
throw unprocessable(
|
|
"OAuth provider endpoints are not configured for this app",
|
|
);
|
|
}
|
|
// A gallery default is Paperclip's own data, but it is still a URL that ends
|
|
// up as a browser navigation, and the metadata branch above reads the same
|
|
// untrusted document a generic connection does. Both go through the gate.
|
|
return {
|
|
provider: app.slug,
|
|
scopes: method.defaults?.scopesHint ?? [],
|
|
authorizationUrl: assertOAuthEndpointUrl(
|
|
"authorization",
|
|
authorizationUrl,
|
|
),
|
|
tokenUrl: assertOAuthEndpointUrl("token", tokenUrl),
|
|
grantType: "authorization_code",
|
|
metadataUrl,
|
|
};
|
|
}
|
|
|
|
async function oauthEndpointsForConnection(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
challenge?: string | null,
|
|
redirectUri?: string,
|
|
): Promise<OAuthProviderEndpoints> {
|
|
const smokeLabEndpoints = smokeLabOAuthEndpoints(connection, redirectUri);
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const galleryEntry = sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)
|
|
: null;
|
|
const galleryMethod = galleryEntry
|
|
? connectionMethodForConnection(galleryEntry, connection)
|
|
: null;
|
|
const hasCompleteGalleryEndpointHints = Boolean(
|
|
galleryMethod?.defaults?.authorizationEndpoint &&
|
|
galleryMethod.defaults.tokenEndpoint,
|
|
);
|
|
// The smoke-lab fixture's endpoints are first-party and complete, so
|
|
// discovery is not just unnecessary there, it must not run: an unreachable
|
|
// fixture endpoint would fail the whole callback.
|
|
const firstPartyOrigin = originOf(redirectUri);
|
|
const discovered =
|
|
!smokeLabEndpoints &&
|
|
connection.transport === "mcp_remote" &&
|
|
!hasCompleteGalleryEndpointHints
|
|
? await discoverOAuthEndpoints(connection, challenge, firstPartyOrigin)
|
|
: null;
|
|
const endpoints =
|
|
smokeLabEndpoints ??
|
|
discovered ??
|
|
(galleryEntry && galleryMethod?.auth === "oauth"
|
|
? await oauthProviderEndpoints(galleryEntry, galleryMethod.key)
|
|
: await discoverOAuthEndpoints(
|
|
connection,
|
|
challenge,
|
|
firstPartyOrigin,
|
|
));
|
|
if (!endpoints)
|
|
throw unprocessable(
|
|
"This app connection does not advertise OAuth sign in",
|
|
);
|
|
assertNotSmokeLabOAuthEndpoints(connection, endpoints);
|
|
return endpoints;
|
|
}
|
|
|
|
async function oauthGalleryEntryForConnection(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
) {
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
if (!sourceTemplateKey)
|
|
throw unprocessable(
|
|
"This app connection was not created from the app gallery",
|
|
);
|
|
const galleryEntry = getConnectableAppDefinition(sourceTemplateKey);
|
|
if (
|
|
!galleryEntry ||
|
|
connectionMethodForConnection(galleryEntry, connection).auth !== "oauth"
|
|
) {
|
|
throw unprocessable("This app connection does not use sign in");
|
|
}
|
|
return galleryEntry;
|
|
}
|
|
|
|
async function createOrRotateOAuthSecret(
|
|
input: {
|
|
companyId: string;
|
|
connection: typeof toolConnections.$inferSelect;
|
|
configPath:
|
|
"oauth.access_token" | "oauth.refresh_token" | "oauth.client_secret" | "railway.ssh_private_key";
|
|
label: string;
|
|
value: string;
|
|
actor?: ActorInfo;
|
|
existingRefs?: typeof connectionGrants.$inferSelect.credentialSecretRefs;
|
|
ownerUserId?: string;
|
|
},
|
|
context?: {
|
|
dbClient: ToolAccessMutationDb;
|
|
secretClient: ReturnType<typeof secretService>;
|
|
},
|
|
) {
|
|
const dbClient = context?.dbClient ?? db;
|
|
const secretClient = context?.secretClient ?? secrets;
|
|
const existing =
|
|
input.existingRefs === undefined
|
|
? oauthSecretRef(input.connection, input.configPath)
|
|
: input.existingRefs.find((ref) => ref.configPath === input.configPath);
|
|
if (existing) {
|
|
await secretClient.rotate(
|
|
existing.secretId,
|
|
{ value: input.value },
|
|
actorForSecret(input.actor),
|
|
);
|
|
return existing;
|
|
}
|
|
if (input.ownerUserId) {
|
|
const definitionKey = `tool_oauth.${input.connection.id}.${input.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`;
|
|
let [definition] = await dbClient
|
|
.select()
|
|
.from(userSecretDefinitions)
|
|
.where(
|
|
and(
|
|
eq(userSecretDefinitions.companyId, input.companyId),
|
|
eq(userSecretDefinitions.key, definitionKey),
|
|
isNull(userSecretDefinitions.deletedAt),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!definition) {
|
|
[definition] = await dbClient
|
|
.insert(userSecretDefinitions)
|
|
.values({
|
|
companyId: input.companyId,
|
|
key: definitionKey,
|
|
name: `${input.connection.name} ${input.label}`,
|
|
description: `Personal OAuth ${input.label.toLowerCase()} for ${input.connection.name}.`,
|
|
provider: "local_encrypted",
|
|
managedMode: "paperclip_managed",
|
|
createdByAgentId:
|
|
input.actor?.actorType === "agent" ? input.actor.actorId : null,
|
|
createdByUserId:
|
|
input.actor?.actorType === "user" ? input.actor.actorId : null,
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning();
|
|
if (!definition) {
|
|
[definition] = await dbClient
|
|
.select()
|
|
.from(userSecretDefinitions)
|
|
.where(
|
|
and(
|
|
eq(userSecretDefinitions.companyId, input.companyId),
|
|
eq(userSecretDefinitions.key, definitionKey),
|
|
isNull(userSecretDefinitions.deletedAt),
|
|
),
|
|
)
|
|
.limit(1);
|
|
}
|
|
}
|
|
if (!definition)
|
|
throw new Error("Failed to create personal OAuth secret definition");
|
|
const [existingUserValue] = await dbClient
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(
|
|
and(
|
|
eq(companySecrets.companyId, input.companyId),
|
|
eq(companySecrets.scope, "user"),
|
|
eq(companySecrets.ownerUserId, input.ownerUserId),
|
|
eq(companySecrets.userSecretDefinitionId, definition.id),
|
|
ne(companySecrets.status, "deleted"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (existingUserValue) {
|
|
// A removed/revoked grant can predate credential cleanup and therefore
|
|
// lose its ref while its deterministic owner value remains. Reconnect
|
|
// is explicit fresh consent, so revive that owner-bound value and
|
|
// rotate it instead of colliding with the one-value-per-definition
|
|
// constraint.
|
|
if (existingUserValue.status !== "active") {
|
|
await secretClient.updateCurrentUserSecretValue(
|
|
input.companyId,
|
|
input.ownerUserId,
|
|
existingUserValue.id,
|
|
{ status: "active" },
|
|
actorForSecret(input.actor),
|
|
);
|
|
}
|
|
const secret = await secretClient.rotateCurrentUserSecretValue(
|
|
input.companyId,
|
|
input.ownerUserId,
|
|
existingUserValue.id,
|
|
{ value: input.value },
|
|
actorForSecret(input.actor),
|
|
);
|
|
return {
|
|
secretId: secret.id,
|
|
versionSelector: "latest" as const,
|
|
configPath: input.configPath,
|
|
required: input.configPath === "oauth.access_token",
|
|
label: input.label,
|
|
};
|
|
}
|
|
const secret = await secretClient.createCurrentUserSecretValue(
|
|
input.companyId,
|
|
input.ownerUserId,
|
|
{
|
|
definitionId: definition.id,
|
|
value: input.value,
|
|
},
|
|
actorForSecret(input.actor),
|
|
);
|
|
return {
|
|
secretId: secret.id,
|
|
versionSelector: "latest" as const,
|
|
configPath: input.configPath,
|
|
required: input.configPath === "oauth.access_token",
|
|
label: input.label,
|
|
};
|
|
}
|
|
const secret = await secretClient.create(
|
|
input.companyId,
|
|
{
|
|
name: `${input.connection.name} ${input.label} ${randomUUID().slice(0, 8)}`,
|
|
key: `tool_app.${randomUUID()}.${input.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`,
|
|
provider: "local_encrypted",
|
|
value: input.value,
|
|
description: `OAuth ${input.label.toLowerCase()} for ${input.connection.name}.`,
|
|
},
|
|
actorForSecret(input.actor),
|
|
);
|
|
return {
|
|
secretId: secret.id,
|
|
versionSelector: "latest" as const,
|
|
configPath: input.configPath,
|
|
required: input.configPath === "oauth.access_token",
|
|
label: input.label,
|
|
};
|
|
}
|
|
|
|
function assertOAuthRedirectConstraints(
|
|
app: AppDefinition | null,
|
|
redirectUri: string,
|
|
) {
|
|
if (app?.redirectConstraints !== "https-or-loopback-http") return;
|
|
let redirect: URL;
|
|
try {
|
|
redirect = new URL(redirectUri);
|
|
} catch {
|
|
throw unprocessable("OAuth callback URL is invalid", {
|
|
code: "oauth_redirect_uri_invalid",
|
|
});
|
|
}
|
|
const hostname = redirect.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
const isLoopback =
|
|
hostname === "localhost" ||
|
|
hostname.endsWith(".localhost") ||
|
|
hostname === "::1" ||
|
|
/^127(?:\.\d{1,3}){3}$/.test(hostname);
|
|
if (
|
|
redirect.protocol === "https:" ||
|
|
(redirect.protocol === "http:" && isLoopback)
|
|
)
|
|
return;
|
|
throw unprocessable(
|
|
"This provider requires an HTTPS or loopback origin. Configure TLS before connecting.",
|
|
{
|
|
code: "oauth_redirect_origin_unsupported",
|
|
redirectConstraints: app.redirectConstraints,
|
|
docsPath: "docs/deploy",
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Validate RFC 9207 `iss` on the authorization callback.
|
|
*
|
|
* When an authorization server returns `iss`, it must name the same issuer the
|
|
* authorization request was bound to. A mismatch means the code came back from a
|
|
* different server than the one we sent the user to — the mix-up attack RFC 9207
|
|
* exists to stop — so the code is refused rather than exchanged. An absent `iss`
|
|
* is tolerated: it is optional, and many deployed servers omit it.
|
|
*/
|
|
function assertOAuthCallbackIssuer(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
endpoints: OAuthProviderEndpoints,
|
|
iss: string | null | undefined,
|
|
) {
|
|
const returnedIssuer = typeof iss === "string" ? iss.trim() : "";
|
|
if (!returnedIssuer) return;
|
|
const oauth = oauthConfig(connection);
|
|
const expectedIssuer =
|
|
typeof oauth.expectedIssuer === "string" && oauth.expectedIssuer.trim()
|
|
? oauth.expectedIssuer.trim()
|
|
: (endpoints.issuer ?? null);
|
|
if (!expectedIssuer) return;
|
|
if (sameOAuthIssuer(returnedIssuer, expectedIssuer)) return;
|
|
throw badRequest(
|
|
"Sign-in came back from an unexpected server. Start the connection again.",
|
|
{
|
|
code: "oauth_issuer_mismatch",
|
|
},
|
|
);
|
|
}
|
|
|
|
function invalidOAuthDcrResponse(field: string, reason: string): HttpError {
|
|
return new HttpError(
|
|
502,
|
|
"OAuth provider returned incompatible dynamic client metadata",
|
|
{
|
|
code: "oauth_dcr_response_invalid",
|
|
field,
|
|
reason,
|
|
},
|
|
);
|
|
}
|
|
|
|
function parseOAuthDcrString(
|
|
record: Record<string, unknown>,
|
|
field: "client_id" | "client_secret",
|
|
input: { required: boolean; maxLength: number },
|
|
): string | null {
|
|
const value = record[field];
|
|
if (value === undefined || value === null) {
|
|
if (input.required) throw invalidOAuthDcrResponse(field, "missing");
|
|
return null;
|
|
}
|
|
if (
|
|
typeof value !== "string" ||
|
|
value.length === 0 ||
|
|
value.length > input.maxLength
|
|
) {
|
|
throw invalidOAuthDcrResponse(field, "invalid_string");
|
|
}
|
|
if (field === "client_id" && value.trim() !== value) {
|
|
throw invalidOAuthDcrResponse(field, "invalid_string");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function assertOAuthDcrArray(
|
|
record: Record<string, unknown>,
|
|
field: "redirect_uris" | "grant_types" | "response_types",
|
|
expected: string[],
|
|
options: { allowAdditional?: boolean; allowOmitted?: boolean } = {},
|
|
) {
|
|
if (record[field] === undefined) {
|
|
if (options.allowOmitted) return;
|
|
throw invalidOAuthDcrResponse(field, "missing");
|
|
}
|
|
const value = record[field];
|
|
if (
|
|
!Array.isArray(value) ||
|
|
value.length < expected.length ||
|
|
value.length > 32 ||
|
|
value.some(
|
|
(entry) =>
|
|
typeof entry !== "string" ||
|
|
entry.length === 0 ||
|
|
entry.length > 2_048,
|
|
)
|
|
) {
|
|
throw invalidOAuthDcrResponse(field, "invalid_array");
|
|
}
|
|
const actual = new Set(value);
|
|
if (
|
|
expected.some((entry) => !actual.has(entry)) ||
|
|
(!options.allowAdditional && actual.size !== expected.length)
|
|
) {
|
|
throw invalidOAuthDcrResponse(field, "registered_value_mismatch");
|
|
}
|
|
}
|
|
|
|
function parseOAuthDcrTimestamp(
|
|
record: Record<string, unknown>,
|
|
field: string,
|
|
): number | null {
|
|
const value = record[field];
|
|
if (value === undefined || value === null) return null;
|
|
if (
|
|
typeof value !== "number" ||
|
|
!Number.isSafeInteger(value) ||
|
|
value < 0
|
|
) {
|
|
throw invalidOAuthDcrResponse(field, "invalid_timestamp");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
type OAuthTokenEndpointAuthMethod =
|
|
"none" | "client_secret_basic" | "client_secret_post";
|
|
|
|
function selectOAuthDcrTokenEndpointAuthMethod(
|
|
supported: string[] | undefined,
|
|
): OAuthTokenEndpointAuthMethod {
|
|
if (!supported?.length || supported.includes("none")) return "none";
|
|
// When a confidential client is required, preserve the provider's advertised
|
|
// ordering. Miro advertises both methods but only completes its MCP exchange
|
|
// for the first one (`client_secret_post`), while Supabase and Hugging Face
|
|
// advertise `client_secret_basic` first. Treating Basic as a global preference
|
|
// creates a valid-looking registration that fails only after user consent.
|
|
for (const method of supported) {
|
|
if (method === "client_secret_basic" || method === "client_secret_post")
|
|
return method;
|
|
}
|
|
throw unprocessable(
|
|
"OAuth provider does not support a compatible dynamic client authentication method",
|
|
{
|
|
code: "oauth_dcr_client_auth_unsupported",
|
|
supportedMethods: supported,
|
|
},
|
|
);
|
|
}
|
|
|
|
function storedOAuthTokenEndpointAuthMethod(
|
|
oauth: Record<string, unknown>,
|
|
clientSecret: string | null | undefined,
|
|
): OAuthTokenEndpointAuthMethod {
|
|
const method = oauth.clientTokenEndpointAuthMethod;
|
|
if (
|
|
method === "none" ||
|
|
method === "client_secret_basic" ||
|
|
method === "client_secret_post"
|
|
) {
|
|
return method;
|
|
}
|
|
const advertised = Array.isArray(oauth.tokenEndpointAuthMethodsSupported)
|
|
? oauth.tokenEndpointAuthMethodsSupported.filter(
|
|
(value): value is string => typeof value === "string",
|
|
)
|
|
: [];
|
|
// Manual OAuth clients do not carry a DCR-selected method. Follow the
|
|
// authorization server's advertised preference when it offers a
|
|
// confidential-client method; Xero, for example, documents Basic auth and
|
|
// rejects an otherwise valid code when the secret is posted in the body.
|
|
if (clientSecret && advertised.includes("client_secret_basic"))
|
|
return "client_secret_basic";
|
|
if (clientSecret && advertised.includes("client_secret_post"))
|
|
return "client_secret_post";
|
|
// Existing manually configured and preconfigured clients predate the
|
|
// persisted method field and already use client_secret_post successfully.
|
|
return clientSecret ? "client_secret_post" : "none";
|
|
}
|
|
|
|
async function registerOAuthClient(input: {
|
|
connection: typeof toolConnections.$inferSelect;
|
|
endpoints: OAuthProviderEndpoints;
|
|
redirectUri: string;
|
|
actor?: ActorInfo;
|
|
}) {
|
|
if (!input.endpoints.registrationUrl) {
|
|
throw unprocessable(
|
|
"OAuth provider does not advertise dynamic client registration",
|
|
{
|
|
code: "oauth_dcr_not_supported",
|
|
},
|
|
);
|
|
}
|
|
if (
|
|
input.endpoints.codeChallengeMethodsSupported?.length &&
|
|
!input.endpoints.codeChallengeMethodsSupported.includes("S256")
|
|
) {
|
|
throw unprocessable(
|
|
"OAuth provider does not support the required PKCE S256 method",
|
|
{
|
|
code: "oauth_pkce_s256_required",
|
|
},
|
|
);
|
|
}
|
|
const tokenEndpointAuthMethod = selectOAuthDcrTokenEndpointAuthMethod(
|
|
input.endpoints.tokenEndpointAuthMethodsSupported,
|
|
);
|
|
|
|
const host = new URL(input.redirectUri).host;
|
|
const requestedMetadata = {
|
|
client_name: `Paperclip (${host})`,
|
|
redirect_uris: [input.redirectUri],
|
|
grant_types: [
|
|
"authorization_code",
|
|
...(!input.endpoints.grantTypesSupported?.length ||
|
|
input.endpoints.grantTypesSupported.includes("refresh_token")
|
|
? ["refresh_token"]
|
|
: []),
|
|
],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: tokenEndpointAuthMethod,
|
|
// RFC 7591: Paperclip's callback is a server-side HTTPS endpoint, so this
|
|
// is a `web` client, not a `native` one. Some authorization servers reject
|
|
// an https redirect URI when the default (`web`) is left implicit, and
|
|
// others apply native-client redirect rules without it.
|
|
application_type: "web",
|
|
};
|
|
const response = await fetchRemoteHttpUrl(
|
|
assertOAuthEndpointUrl("registration", input.endpoints.registrationUrl),
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(requestedMetadata),
|
|
},
|
|
);
|
|
const record = asRecord(
|
|
(await response.json().catch(() => ({}))) as unknown,
|
|
);
|
|
if (!response.ok) {
|
|
const providerError = normalizeOAuthProviderError(record.error);
|
|
throw new HttpError(
|
|
502,
|
|
oauthProviderErrorMessage(
|
|
providerError,
|
|
"OAuth dynamic client registration failed",
|
|
),
|
|
{
|
|
code: "oauth_dynamic_client_registration_failed",
|
|
providerError,
|
|
status: response.status,
|
|
},
|
|
);
|
|
}
|
|
const clientId = parseOAuthDcrString(record, "client_id", {
|
|
required: true,
|
|
maxLength: MAX_OAUTH_DCR_CLIENT_ID_LENGTH,
|
|
})!;
|
|
const clientSecret = parseOAuthDcrString(record, "client_secret", {
|
|
required: tokenEndpointAuthMethod !== "none",
|
|
maxLength: MAX_OAUTH_DCR_CLIENT_SECRET_LENGTH,
|
|
});
|
|
// Some authorization servers add provider-owned metadata to the registered
|
|
// client. Paperclip still uses only the exact redirect, grant and response
|
|
// types it requested, so accept bounded supersets while requiring every
|
|
// requested value to remain present. Hugging Face, for example, adds the
|
|
// device-code grant to an otherwise valid authorization-code registration.
|
|
assertOAuthDcrArray(
|
|
record,
|
|
"redirect_uris",
|
|
requestedMetadata.redirect_uris,
|
|
{ allowAdditional: true },
|
|
);
|
|
// RFC 7591 registration responses do not consistently echo every accepted
|
|
// request field. Supabase, for example, returns only the client material and
|
|
// redirect URIs. Redirect binding remains mandatory; omitted grant/response
|
|
// metadata inherits the values Paperclip requested. Additional provider-owned
|
|
// values do not widen Paperclip's behavior because they are never persisted as
|
|
// a flow choice or sent in authorization/token requests.
|
|
assertOAuthDcrArray(record, "grant_types", requestedMetadata.grant_types, {
|
|
allowAdditional: true,
|
|
allowOmitted: true,
|
|
});
|
|
assertOAuthDcrArray(
|
|
record,
|
|
"response_types",
|
|
requestedMetadata.response_types,
|
|
{
|
|
allowAdditional: true,
|
|
allowOmitted: true,
|
|
},
|
|
);
|
|
if (
|
|
record.token_endpoint_auth_method !== undefined &&
|
|
record.token_endpoint_auth_method !==
|
|
requestedMetadata.token_endpoint_auth_method
|
|
) {
|
|
throw invalidOAuthDcrResponse(
|
|
"token_endpoint_auth_method",
|
|
"registered_value_mismatch",
|
|
);
|
|
}
|
|
const clientIdIssuedAt = parseOAuthDcrTimestamp(
|
|
record,
|
|
"client_id_issued_at",
|
|
);
|
|
const returnedClientSecretExpiresAt = parseOAuthDcrTimestamp(
|
|
record,
|
|
"client_secret_expires_at",
|
|
);
|
|
// A few public-client registrars (including Mixpanel) return the RFC 7591
|
|
// `0` sentinel even though they issued no secret. It carries no credential
|
|
// lifetime in that case, so normalize it away. A positive expiry without a
|
|
// secret is still contradictory and remains a hard failure.
|
|
if (
|
|
returnedClientSecretExpiresAt !== null &&
|
|
returnedClientSecretExpiresAt > 0 &&
|
|
clientSecret === null
|
|
) {
|
|
throw invalidOAuthDcrResponse(
|
|
"client_secret_expires_at",
|
|
"client_secret_missing",
|
|
);
|
|
}
|
|
const clientSecretExpiresAt = clientSecret
|
|
? returnedClientSecretExpiresAt
|
|
: null;
|
|
const existingClientSecretRef = oauthSecretRef(
|
|
input.connection,
|
|
"oauth.client_secret",
|
|
);
|
|
const nextCredentialSecretRefs =
|
|
input.connection.credentialSecretRefs.filter(
|
|
(ref) => ref.configPath !== "oauth.client_secret",
|
|
);
|
|
if (clientSecret) {
|
|
const clientSecretRef = await createOrRotateOAuthSecret({
|
|
companyId: input.connection.companyId,
|
|
connection: input.connection,
|
|
configPath: "oauth.client_secret",
|
|
label: "OAuth client secret",
|
|
value: clientSecret,
|
|
actor: input.actor,
|
|
});
|
|
nextCredentialSecretRefs.push(clientSecretRef);
|
|
} else if (
|
|
existingClientSecretRef &&
|
|
oauthConfig(input.connection).clientId === clientId
|
|
) {
|
|
nextCredentialSecretRefs.push(existingClientSecretRef);
|
|
}
|
|
|
|
const oauth = oauthConfig(input.connection);
|
|
const nextConfig = {
|
|
...input.connection.config,
|
|
oauth: {
|
|
...oauth,
|
|
provider: input.endpoints.provider,
|
|
authorizationUrl: input.endpoints.authorizationUrl,
|
|
tokenUrl: input.endpoints.tokenUrl,
|
|
registrationUrl: input.endpoints.registrationUrl,
|
|
metadataUrl: input.endpoints.metadataUrl ?? null,
|
|
scopes: input.endpoints.scopes,
|
|
codeChallengeMethodsSupported:
|
|
input.endpoints.codeChallengeMethodsSupported ?? [],
|
|
tokenEndpointAuthMethodsSupported:
|
|
input.endpoints.tokenEndpointAuthMethodsSupported ?? [],
|
|
issuer: input.endpoints.issuer ?? oauth.issuer ?? null,
|
|
resource: input.endpoints.resource ?? oauth.resource ?? null,
|
|
clientId,
|
|
clientRegistrationSource: "dcr" satisfies OAuthClientRegistrationSource,
|
|
clientTokenEndpointAuthMethod: tokenEndpointAuthMethod,
|
|
clientRedirectUri: input.redirectUri,
|
|
// Registered client material is only valid for the issuer/resource pair
|
|
// it was minted against. `assertOAuthClientBinding` re-registers when any
|
|
// of these move, so a re-pointed endpoint can never silently reuse a
|
|
// client another authorization server issued.
|
|
clientIssuer: input.endpoints.issuer ?? null,
|
|
clientResource: input.endpoints.resource ?? null,
|
|
clientCompanyId: input.connection.companyId,
|
|
clientIdIssuedAt,
|
|
clientSecretExpiresAt,
|
|
},
|
|
};
|
|
const [updated] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
ownership: "dcr",
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
credentialSecretRefs: nextCredentialSecretRefs,
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, input.connection.id),
|
|
eq(toolConnections.companyId, input.connection.companyId),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!updated) throw notFound("Tool connection not found");
|
|
await syncCredentialBindings(updated);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Adopt a Client ID Metadata Document as this connection's client: the
|
|
* `client_id` *is* the https URL of Paperclip's published client metadata, so
|
|
* there is nothing to register with the authorization server. Still recorded on
|
|
* the connection so the issuer/resource/callback binding is enforced on reuse
|
|
* exactly like a dynamically registered client.
|
|
*/
|
|
async function adoptClientIdMetadataDocument(input: {
|
|
connection: typeof toolConnections.$inferSelect;
|
|
endpoints: OAuthProviderEndpoints;
|
|
redirectUri: string;
|
|
clientId: string;
|
|
}) {
|
|
const oauth = oauthConfig(input.connection);
|
|
const nextConfig = {
|
|
...input.connection.config,
|
|
oauth: {
|
|
...oauth,
|
|
provider: input.endpoints.provider,
|
|
authorizationUrl: input.endpoints.authorizationUrl,
|
|
tokenUrl: input.endpoints.tokenUrl,
|
|
registrationUrl: input.endpoints.registrationUrl ?? null,
|
|
metadataUrl: input.endpoints.metadataUrl ?? null,
|
|
scopes: input.endpoints.scopes,
|
|
codeChallengeMethodsSupported:
|
|
input.endpoints.codeChallengeMethodsSupported ?? [],
|
|
tokenEndpointAuthMethodsSupported:
|
|
input.endpoints.tokenEndpointAuthMethodsSupported ?? [],
|
|
clientIdMetadataDocumentSupported: true,
|
|
issuer: input.endpoints.issuer ?? oauth.issuer ?? null,
|
|
resource: input.endpoints.resource ?? oauth.resource ?? null,
|
|
clientId: input.clientId,
|
|
clientRegistrationSource:
|
|
"cimd" satisfies OAuthClientRegistrationSource,
|
|
clientTokenEndpointAuthMethod: "none",
|
|
clientRedirectUri: input.redirectUri,
|
|
clientIssuer: input.endpoints.issuer ?? null,
|
|
clientResource: input.endpoints.resource ?? null,
|
|
clientCompanyId: input.connection.companyId,
|
|
},
|
|
};
|
|
// A CIMD client has no secret. Drop any leftover one so a stale credential
|
|
// from an earlier registration can never be replayed against a new issuer.
|
|
const nextCredentialSecretRefs =
|
|
input.connection.credentialSecretRefs.filter(
|
|
(ref) => ref.configPath !== "oauth.client_secret",
|
|
);
|
|
const [updated] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
authKind: "oauth",
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
credentialSecretRefs: nextCredentialSecretRefs,
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, input.connection.id),
|
|
eq(toolConnections.companyId, input.connection.companyId),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!updated) throw notFound("Tool connection not found");
|
|
await syncCredentialBindings(updated);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* How the client already stored on this connection was obtained. Anything
|
|
* unrecognised (including connections written before this field existed) reads
|
|
* as `manual`, which is the conservative answer: Paperclip will not silently
|
|
* re-register over client material it cannot prove it minted.
|
|
*/
|
|
function storedOAuthClientRegistrationSource(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): OAuthClientRegistrationSource {
|
|
const source = oauthConfig(connection).clientRegistrationSource;
|
|
return source === "cimd" || source === "dcr" || source === "preconfigured"
|
|
? source
|
|
: "manual";
|
|
}
|
|
|
|
/**
|
|
* Is the client material already stored on this connection still valid for the
|
|
* authorization server, MCP resource, callback URI and company we are about to
|
|
* use it with? Client credentials are minted against exactly one such tuple;
|
|
* reusing them across a moved binding would let a re-pointed endpoint borrow
|
|
* another server's registration.
|
|
*/
|
|
function oauthClientBindingMatches(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
endpoints: OAuthProviderEndpoints,
|
|
redirectUri: string,
|
|
clientIdMetadataDocumentUrl: string | null,
|
|
): boolean {
|
|
const oauth = oauthConfig(connection);
|
|
if (typeof oauth.clientId !== "string" || !oauth.clientId.trim())
|
|
return false;
|
|
const source =
|
|
typeof oauth.clientRegistrationSource === "string"
|
|
? oauth.clientRegistrationSource
|
|
: null;
|
|
// Older interrupted setup flows could accidentally round-trip a DCR client
|
|
// through the customer-client form and relabel it `manual`. Ownership is the
|
|
// durable proof that Paperclip minted that client. Force a fresh registration
|
|
// instead of preserving the damaged binding forever.
|
|
if (source === "manual" && connection.ownership === "dcr") return false;
|
|
// A URL client id that now resolves only to a private network is unusable by
|
|
// an external authorization server. Treat the stored binding as stale so a
|
|
// retry can replace it with a dynamically registered client.
|
|
if (source === "cimd" && oauth.clientId !== clientIdMetadataDocumentUrl)
|
|
return false;
|
|
// A manually preregistered client was registered by the operator against
|
|
// Paperclip's callback, so it has no recorded callback until first use.
|
|
const redirectMatches =
|
|
source === "manual"
|
|
? oauth.clientRedirectUri === undefined ||
|
|
oauth.clientRedirectUri === null ||
|
|
oauth.clientRedirectUri === redirectUri
|
|
: oauth.clientRedirectUri === redirectUri;
|
|
if (!redirectMatches) return false;
|
|
if (
|
|
typeof oauth.clientCompanyId === "string" &&
|
|
oauth.clientCompanyId !== connection.companyId
|
|
)
|
|
return false;
|
|
if (
|
|
endpoints.issuer &&
|
|
typeof oauth.clientIssuer === "string" &&
|
|
oauth.clientIssuer &&
|
|
!sameOAuthIssuer(oauth.clientIssuer, endpoints.issuer)
|
|
) {
|
|
return false;
|
|
}
|
|
if (
|
|
endpoints.resource &&
|
|
typeof oauth.clientResource === "string" &&
|
|
oauth.clientResource &&
|
|
oauth.clientResource !== endpoints.resource
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Record the issuer/resource/callback/company a client is bound to, the first
|
|
* time that client is actually used.
|
|
*
|
|
* A dynamically registered or CIMD client is stamped at registration. A client
|
|
* the operator pasted in has no binding until now — without this, its
|
|
* `clientRedirectUri` would stay unset forever and `oauthClientBindingMatches`
|
|
* would keep accepting it for *any* callback, which is exactly the drift the
|
|
* binding check exists to catch.
|
|
*/
|
|
async function stampOAuthClientBinding(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
endpoints: OAuthProviderEndpoints,
|
|
redirectUri: string,
|
|
): Promise<typeof toolConnections.$inferSelect> {
|
|
const oauth = oauthConfig(connection);
|
|
const nextBinding = {
|
|
clientRedirectUri: redirectUri,
|
|
clientIssuer:
|
|
typeof oauth.clientIssuer === "string" && oauth.clientIssuer
|
|
? oauth.clientIssuer
|
|
: (endpoints.issuer ?? null),
|
|
clientResource:
|
|
typeof oauth.clientResource === "string" && oauth.clientResource
|
|
? oauth.clientResource
|
|
: (endpoints.resource ?? null),
|
|
clientCompanyId:
|
|
typeof oauth.clientCompanyId === "string" && oauth.clientCompanyId
|
|
? oauth.clientCompanyId
|
|
: connection.companyId,
|
|
};
|
|
const unchanged = Object.entries(nextBinding).every(
|
|
([key, value]) => oauth[key] === value,
|
|
);
|
|
if (unchanged) return connection;
|
|
const nextConfig = {
|
|
...connection.config,
|
|
oauth: { ...oauth, ...nextBinding },
|
|
};
|
|
const [updated] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, connection.id),
|
|
eq(toolConnections.companyId, connection.companyId),
|
|
),
|
|
)
|
|
.returning();
|
|
return updated ?? connection;
|
|
}
|
|
|
|
/**
|
|
* May Paperclip mint client material for this connection without an operator
|
|
* pasting client credentials?
|
|
*
|
|
* A curated app opts in through its `ownershipModes`. A generic remote MCP
|
|
* connection may register once — and only once — protected-resource and
|
|
* authorization-server discovery actually produced a metadata document; an
|
|
* endpoint that merely returned a 401 does not earn a registration.
|
|
*/
|
|
function canRegisterOAuthClientDynamically(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
endpoints: OAuthProviderEndpoints,
|
|
galleryEntry: AppDefinition | null,
|
|
): boolean {
|
|
if (galleryEntry) {
|
|
return connectionMethodForConnection(
|
|
galleryEntry,
|
|
connection,
|
|
).ownershipModes.includes("dcr");
|
|
}
|
|
return (
|
|
connection.transport === "mcp_remote" && Boolean(endpoints.metadataUrl)
|
|
);
|
|
}
|
|
|
|
async function ensureOAuthClient(input: {
|
|
connection: typeof toolConnections.$inferSelect;
|
|
endpoints: OAuthProviderEndpoints;
|
|
redirectUri: string;
|
|
galleryEntry: AppDefinition | null;
|
|
actor?: ActorInfo;
|
|
}) {
|
|
// 1. A client the deployment preconfigured for this issuer wins outright.
|
|
const configured = configuredOAuthClientForConnection(
|
|
input.connection,
|
|
input.endpoints.provider,
|
|
);
|
|
if (configured.clientId) {
|
|
return {
|
|
connection: input.connection,
|
|
client: configured,
|
|
source: "preconfigured" as const,
|
|
};
|
|
}
|
|
const metadataDocumentUrl = input.endpoints
|
|
.clientIdMetadataDocumentSupported
|
|
? await resolveOAuthClientIdMetadataDocumentUrl(
|
|
input.redirectUri,
|
|
options.oauthClientMetadataLookup,
|
|
)
|
|
: null;
|
|
// 2. Client material already bound to this issuer/resource/callback/company.
|
|
if (
|
|
oauthClientBindingMatches(
|
|
input.connection,
|
|
input.endpoints,
|
|
input.redirectUri,
|
|
metadataDocumentUrl,
|
|
)
|
|
) {
|
|
const bound = await stampOAuthClientBinding(
|
|
input.connection,
|
|
input.endpoints,
|
|
input.redirectUri,
|
|
);
|
|
return {
|
|
connection: bound,
|
|
client: await oauthClientForConnection(
|
|
bound,
|
|
input.endpoints.provider,
|
|
input.actor,
|
|
),
|
|
source: storedOAuthClientRegistrationSource(bound),
|
|
};
|
|
}
|
|
const oauth = oauthConfig(input.connection);
|
|
if (
|
|
oauth.clientRegistrationSource === "manual" &&
|
|
input.connection.ownership !== "dcr" &&
|
|
typeof oauth.clientId === "string" &&
|
|
oauth.clientId.trim()
|
|
) {
|
|
// Paperclip cannot re-register on the operator's behalf: the credentials
|
|
// came from a console this deployment does not control.
|
|
throw unprocessable(
|
|
"This connection's sign-in details no longer match the server it points at. Re-enter the client ID and secret to continue.",
|
|
{ code: "oauth_manual_client_rebinding_required" },
|
|
);
|
|
}
|
|
if (
|
|
!canRegisterOAuthClientDynamically(
|
|
input.connection,
|
|
input.endpoints,
|
|
input.galleryEntry,
|
|
)
|
|
) {
|
|
throw unprocessable(
|
|
`OAuth client id is not configured for ${input.endpoints.provider}`,
|
|
{
|
|
code: "oauth_client_registration_unavailable",
|
|
},
|
|
);
|
|
}
|
|
|
|
const key = `${input.connection.id}:${input.redirectUri}`;
|
|
return singleFlight(oauthRegistrationFlights, key, async () => {
|
|
const latest = await getConnectionRow(
|
|
input.connection.id,
|
|
input.connection.companyId,
|
|
);
|
|
const latestConfigured = configuredOAuthClientForConnection(
|
|
latest,
|
|
input.endpoints.provider,
|
|
);
|
|
if (latestConfigured.clientId) {
|
|
return {
|
|
connection: latest,
|
|
client: latestConfigured,
|
|
source: "preconfigured" as const,
|
|
};
|
|
}
|
|
if (
|
|
oauthClientBindingMatches(
|
|
latest,
|
|
input.endpoints,
|
|
input.redirectUri,
|
|
metadataDocumentUrl,
|
|
)
|
|
) {
|
|
const bound = await stampOAuthClientBinding(
|
|
latest,
|
|
input.endpoints,
|
|
input.redirectUri,
|
|
);
|
|
return {
|
|
connection: bound,
|
|
client: await oauthClientForConnection(
|
|
bound,
|
|
input.endpoints.provider,
|
|
input.actor,
|
|
),
|
|
source: storedOAuthClientRegistrationSource(bound),
|
|
};
|
|
}
|
|
// 3. Client ID Metadata Documents: no registration call at all, so prefer
|
|
// them over DCR when the authorization server advertises support.
|
|
if (metadataDocumentUrl) {
|
|
const adopted = await adoptClientIdMetadataDocument({
|
|
connection: latest,
|
|
endpoints: input.endpoints,
|
|
redirectUri: input.redirectUri,
|
|
clientId: metadataDocumentUrl,
|
|
});
|
|
return {
|
|
connection: adopted,
|
|
client: await oauthClientForConnection(
|
|
adopted,
|
|
input.endpoints.provider,
|
|
input.actor,
|
|
),
|
|
source: "cimd" as const,
|
|
};
|
|
}
|
|
// 4. Dynamic client registration.
|
|
if (!input.endpoints.registrationUrl) {
|
|
throw unprocessable(
|
|
"This server needs sign-in details you create yourself. Add a client ID and secret under Advanced authentication.",
|
|
{ code: "oauth_manual_client_required" },
|
|
);
|
|
}
|
|
const registered = await registerOAuthClient({
|
|
connection: latest,
|
|
endpoints: input.endpoints,
|
|
redirectUri: input.redirectUri,
|
|
actor: input.actor,
|
|
});
|
|
return {
|
|
connection: registered,
|
|
client: await oauthClientForConnection(
|
|
registered,
|
|
input.endpoints.provider,
|
|
input.actor,
|
|
),
|
|
source: "dcr" as const,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function exchangeOAuthToken(input: {
|
|
tokenUrl: string;
|
|
clientId: string;
|
|
clientSecret?: string | null;
|
|
tokenEndpointAuthMethod?: OAuthTokenEndpointAuthMethod;
|
|
grantType?: "authorization_code" | "refresh_token" | "client_credentials";
|
|
scopes?: string[];
|
|
redirectUri?: string | null;
|
|
codeVerifier?: string | null;
|
|
code?: string | null;
|
|
refreshToken?: string | null;
|
|
/** RFC 8707 resource indicator: the MCP server this token is for. */
|
|
resource?: string | null;
|
|
}) {
|
|
const body = new URLSearchParams();
|
|
if (input.grantType === "client_credentials") {
|
|
body.set("grant_type", "client_credentials");
|
|
if (input.scopes && input.scopes.length > 0)
|
|
body.set("scope", input.scopes.join(" "));
|
|
} else if (input.refreshToken) {
|
|
body.set("grant_type", "refresh_token");
|
|
body.set("refresh_token", input.refreshToken);
|
|
} else {
|
|
body.set("grant_type", "authorization_code");
|
|
body.set("code", input.code ?? "");
|
|
body.set("redirect_uri", input.redirectUri ?? "");
|
|
body.set("code_verifier", input.codeVerifier ?? "");
|
|
}
|
|
const tokenEndpointAuthMethod =
|
|
input.tokenEndpointAuthMethod ??
|
|
(input.clientSecret ? "client_secret_post" : "none");
|
|
if (tokenEndpointAuthMethod !== "client_secret_basic")
|
|
body.set("client_id", input.clientId);
|
|
if (tokenEndpointAuthMethod === "client_secret_post") {
|
|
if (!input.clientSecret) {
|
|
throw unprocessable(
|
|
"OAuth client secret is missing for client_secret_post authentication",
|
|
{
|
|
code: "oauth_client_secret_missing",
|
|
},
|
|
);
|
|
}
|
|
body.set("client_secret", input.clientSecret);
|
|
}
|
|
// RFC 8707: repeat the resource indicator on the token request so the
|
|
// authorization server audience-restricts the access token (and any refresh
|
|
// exchange) to this MCP server.
|
|
if (input.resource) body.set("resource", input.resource);
|
|
|
|
// The token URL can come from a connection row written before the endpoint
|
|
// gate existed, so a client secret / authorization code never leaves
|
|
// Paperclip without re-checking the transport it would leave over.
|
|
const tokenUrl = assertOAuthEndpointUrl("token", input.tokenUrl, {
|
|
// Paperclip's own callback origin, so a first-party token endpoint keeps
|
|
// working on a deployment that is itself served over plaintext HTTP.
|
|
firstPartyOrigin: originOf(input.redirectUri),
|
|
});
|
|
const headers: Record<string, string> = {
|
|
"content-type": "application/x-www-form-urlencoded",
|
|
};
|
|
if (tokenEndpointAuthMethod === "client_secret_basic") {
|
|
if (!input.clientSecret) {
|
|
throw unprocessable(
|
|
"OAuth client secret is missing for client_secret_basic authentication",
|
|
{
|
|
code: "oauth_client_secret_missing",
|
|
},
|
|
);
|
|
}
|
|
const formEncode = (value: string) =>
|
|
new URLSearchParams({ value }).toString().slice("value=".length);
|
|
headers.Authorization = `Basic ${Buffer.from(
|
|
`${formEncode(input.clientId)}:${formEncode(input.clientSecret)}`,
|
|
"utf8",
|
|
).toString("base64")}`;
|
|
}
|
|
const response = await fetchRemoteHttpUrl(tokenUrl, {
|
|
method: "POST",
|
|
headers,
|
|
body,
|
|
});
|
|
const payload = (await response.json().catch(() => ({}))) as unknown;
|
|
const record = asRecord(payload);
|
|
if (!response.ok || record.ok === false) {
|
|
const providerError = normalizeOAuthProviderError(record.error);
|
|
const message = oauthProviderErrorMessage(
|
|
providerError,
|
|
"OAuth token exchange failed",
|
|
);
|
|
if (
|
|
input.grantType === "refresh_token" &&
|
|
providerError === "invalid_grant"
|
|
) {
|
|
throw new HttpError(
|
|
422,
|
|
"OAuth authorization has expired. Reconnect this app to continue.",
|
|
{
|
|
code: "oauth_reauthorization_required",
|
|
providerError,
|
|
status: response.status,
|
|
},
|
|
);
|
|
}
|
|
throw new HttpError(502, message, {
|
|
code: "oauth_token_exchange_failed",
|
|
providerError,
|
|
status: response.status,
|
|
});
|
|
}
|
|
const accessToken =
|
|
typeof record.access_token === "string" ? record.access_token : null;
|
|
if (!accessToken)
|
|
throw new HttpError(
|
|
502,
|
|
"OAuth provider did not return an access token",
|
|
{ code: "oauth_access_token_missing" },
|
|
);
|
|
const expiresIn =
|
|
typeof record.expires_in === "number"
|
|
? record.expires_in
|
|
: Number(record.expires_in);
|
|
return {
|
|
accessToken,
|
|
refreshToken:
|
|
typeof record.refresh_token === "string" ? record.refresh_token : null,
|
|
expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : null,
|
|
scope: typeof record.scope === "string" ? record.scope : null,
|
|
tokenType:
|
|
typeof record.token_type === "string" ? record.token_type : "Bearer",
|
|
raw: record,
|
|
};
|
|
}
|
|
|
|
function withoutOAuthRefreshLease(oauth: Record<string, unknown>) {
|
|
const { refreshLease: _refreshLease, ...rest } = oauth;
|
|
return rest;
|
|
}
|
|
|
|
function oauthRefreshLeaseId(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): string | null {
|
|
const lease = asRecord(oauthConfig(connection).refreshLease);
|
|
return typeof lease.id === "string" && lease.id ? lease.id : null;
|
|
}
|
|
|
|
async function clearOAuthRefreshLease(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
leaseId: string,
|
|
) {
|
|
const latest = await getConnectionRow(connection.id, connection.companyId);
|
|
if (oauthRefreshLeaseId(latest) !== leaseId) return latest;
|
|
const nextConfig = {
|
|
...latest.config,
|
|
oauth: withoutOAuthRefreshLease(oauthConfig(latest)),
|
|
};
|
|
const [updated] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, latest.id),
|
|
eq(toolConnections.companyId, latest.companyId),
|
|
sql`${toolConnections.config} -> 'oauth' -> 'refreshLease' ->> 'id' = ${leaseId}`,
|
|
),
|
|
)
|
|
.returning();
|
|
return updated ?? getConnectionRow(connection.id, connection.companyId);
|
|
}
|
|
|
|
async function acquireOAuthRefreshLease(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): Promise<{
|
|
connection: typeof toolConnections.$inferSelect;
|
|
leaseId: string | null;
|
|
}> {
|
|
const waitDeadline = Date.now() + OAUTH_REFRESH_LEASE_WAIT_MS;
|
|
while (true) {
|
|
const latest = await getConnectionRow(
|
|
connection.id,
|
|
connection.companyId,
|
|
);
|
|
const latestExpiresAtMs = oauthExpiresAtMs(latest);
|
|
if (latestExpiresAtMs && latestExpiresAtMs > Date.now() + 60_000) {
|
|
return { connection: latest, leaseId: null };
|
|
}
|
|
|
|
const oauth = oauthConfig(latest);
|
|
const currentLease = asRecord(oauth.refreshLease);
|
|
const currentLeaseExpiresAt =
|
|
typeof currentLease.expiresAt === "string"
|
|
? Date.parse(currentLease.expiresAt)
|
|
: Number.NaN;
|
|
const currentLeaseId =
|
|
typeof currentLease.id === "string" && currentLease.id
|
|
? currentLease.id
|
|
: null;
|
|
const leaseIsActive =
|
|
currentLeaseId !== null &&
|
|
Number.isFinite(currentLeaseExpiresAt) &&
|
|
currentLeaseExpiresAt > Date.now();
|
|
if (!currentLeaseId) {
|
|
const leaseId = randomUUID();
|
|
const claimedAt = now();
|
|
const nextConfig = {
|
|
...latest.config,
|
|
oauth: {
|
|
...withoutOAuthRefreshLease(oauth),
|
|
refreshLease: {
|
|
id: leaseId,
|
|
expiresAt: new Date(
|
|
Date.now() + OAUTH_REFRESH_LEASE_MS,
|
|
).toISOString(),
|
|
},
|
|
},
|
|
};
|
|
const [claimed] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
updatedAt: claimedAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, latest.id),
|
|
eq(toolConnections.companyId, latest.companyId),
|
|
sql`${toolConnections.config} = ${JSON.stringify(latest.config)}::jsonb`,
|
|
sql`${toolConnections.config} #>> '{oauth,refreshLease,id}' is null`,
|
|
),
|
|
)
|
|
.returning();
|
|
if (claimed) return { connection: claimed, leaseId };
|
|
}
|
|
|
|
if (currentLeaseId && !leaseIsActive) {
|
|
throw new HttpError(
|
|
422,
|
|
"The previous OAuth refresh did not finish. Reconnect this app before retrying.",
|
|
{
|
|
code: "oauth_refresh_outcome_unknown",
|
|
setupUrl: connectionSetupUrl(latest),
|
|
reconnectUrl: connectionReconnectUrl(latest),
|
|
},
|
|
);
|
|
}
|
|
|
|
if (Date.now() >= waitDeadline) {
|
|
throw conflict("OAuth credential refresh is already in progress", {
|
|
code: "oauth_refresh_in_progress",
|
|
retryable: true,
|
|
});
|
|
}
|
|
await new Promise((resolve) =>
|
|
setTimeout(resolve, OAUTH_REFRESH_LEASE_POLL_MS),
|
|
);
|
|
}
|
|
}
|
|
|
|
async function markOAuthReauthorizationRequired(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
guard: {
|
|
leaseId: string;
|
|
refreshSecretId: string;
|
|
refreshTokenVersion: number;
|
|
},
|
|
) {
|
|
const oauth = oauthConfig(connection);
|
|
const nextCredentialSecretRefs = connection.credentialSecretRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
ref.configPath !== "oauth.refresh_token",
|
|
);
|
|
const nextCredentialRefs = connection.credentialRefs.filter(
|
|
(ref) => ref.name !== "oauth.access_token",
|
|
);
|
|
const nextConfig = {
|
|
...connection.config,
|
|
oauth: {
|
|
...withoutOAuthRefreshLease(oauth),
|
|
expiresAt: null,
|
|
reauthorizationRequiredAt: now().toISOString(),
|
|
},
|
|
};
|
|
const [updated] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
status: "draft",
|
|
enabled: false,
|
|
healthStatus: "error",
|
|
healthMessage:
|
|
"OAuth authorization expired. Reconnect this app to continue.",
|
|
lastError: "oauth_reauthorization_required",
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
credentialSecretRefs: nextCredentialSecretRefs,
|
|
credentialRefs: nextCredentialRefs,
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, connection.id),
|
|
eq(toolConnections.companyId, connection.companyId),
|
|
sql`${toolConnections.config} -> 'oauth' -> 'refreshLease' ->> 'id' = ${guard.leaseId}`,
|
|
sql`exists (
|
|
select 1 from ${companySecrets}
|
|
where ${companySecrets.id} = ${guard.refreshSecretId}
|
|
and ${companySecrets.companyId} = ${connection.companyId}
|
|
and ${companySecrets.latestVersion} = ${guard.refreshTokenVersion}
|
|
)`,
|
|
),
|
|
)
|
|
.returning();
|
|
if (updated) await syncCredentialBindings(updated);
|
|
return updated ?? null;
|
|
}
|
|
|
|
async function refreshOAuthCredentials(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
leaseId: string,
|
|
actor?: ActorInfo,
|
|
accessContext?: {
|
|
actorSource?:
|
|
| "local_implicit"
|
|
| "session"
|
|
| "board_key"
|
|
| "agent_key"
|
|
| "agent_jwt"
|
|
| "cloud_tenant";
|
|
issueId?: string | null;
|
|
heartbeatRunId?: string | null;
|
|
},
|
|
): Promise<typeof toolConnections.$inferSelect> {
|
|
const oauth = oauthConfig(connection);
|
|
if (
|
|
typeof oauth.tokenUrl !== "string" ||
|
|
typeof oauth.provider !== "string"
|
|
)
|
|
return connection;
|
|
const expiresAtMs = oauthExpiresAtMs(connection);
|
|
if (expiresAtMs && expiresAtMs > Date.now() + 60_000) return connection;
|
|
const grantType =
|
|
oauth.grantType === "client_credentials" ||
|
|
oauth.clientCredentials === true
|
|
? ("client_credentials" as const)
|
|
: ("refresh_token" as const);
|
|
const refreshRef = oauthSecretRef(connection, "oauth.refresh_token");
|
|
if (grantType !== "client_credentials" && !refreshRef) {
|
|
throw new HttpError(
|
|
422,
|
|
"OAuth credentials have expired and no refresh token is available",
|
|
{
|
|
code: "oauth_refresh_missing",
|
|
setupUrl: connectionSetupUrl(connection),
|
|
reconnectUrl: connectionReconnectUrl(connection),
|
|
},
|
|
);
|
|
}
|
|
const client = await oauthClientForConnection(
|
|
connection,
|
|
oauth.provider,
|
|
actor,
|
|
);
|
|
if (!client.clientId)
|
|
throw unprocessable(
|
|
`OAuth client id is not configured for ${oauth.provider}`,
|
|
);
|
|
const [refreshSecret] = refreshRef
|
|
? await db
|
|
.select({ latestVersion: companySecrets.latestVersion })
|
|
.from(companySecrets)
|
|
.where(
|
|
and(
|
|
eq(companySecrets.id, refreshRef.secretId),
|
|
eq(companySecrets.companyId, connection.companyId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
: [undefined];
|
|
const refreshTokenVersion = refreshSecret?.latestVersion ?? null;
|
|
const refreshToken =
|
|
refreshRef && refreshTokenVersion !== null
|
|
? await secrets.resolveSecretValue(
|
|
connection.companyId,
|
|
refreshRef.secretId,
|
|
refreshTokenVersion,
|
|
{
|
|
consumerType: "tool_connection",
|
|
consumerId: connection.id,
|
|
configPath: "oauth.refresh_token",
|
|
actorType: actor?.actorType ?? "system",
|
|
actorId: actor?.actorId ?? null,
|
|
actorSource: accessContext?.actorSource,
|
|
issueId: accessContext?.issueId,
|
|
heartbeatRunId: accessContext?.heartbeatRunId,
|
|
},
|
|
)
|
|
: null;
|
|
let token: Awaited<ReturnType<typeof exchangeOAuthToken>>;
|
|
try {
|
|
token = await exchangeOAuthToken({
|
|
tokenUrl: oauth.tokenUrl,
|
|
clientId: client.clientId,
|
|
clientSecret: client.clientSecret,
|
|
tokenEndpointAuthMethod: storedOAuthTokenEndpointAuthMethod(
|
|
oauth,
|
|
client.clientSecret,
|
|
),
|
|
grantType,
|
|
scopes:
|
|
normalizeOauthScopes(oauth.scopes).length > 0
|
|
? normalizeOauthScopes(oauth.scopes)
|
|
: normalizeOauthScopes(oauth.scope),
|
|
refreshToken,
|
|
// Refreshing must stay bound to the same MCP server the original grant
|
|
// named, or the authorization server may widen the token's audience.
|
|
resource:
|
|
typeof oauth.resource === "string" && oauth.resource
|
|
? oauth.resource
|
|
: null,
|
|
});
|
|
} catch (error) {
|
|
if (
|
|
error instanceof HttpError &&
|
|
asRecord(error.details).code === "oauth_reauthorization_required"
|
|
) {
|
|
const marked =
|
|
refreshRef && refreshTokenVersion !== null
|
|
? await markOAuthReauthorizationRequired(connection, {
|
|
leaseId,
|
|
refreshSecretId: refreshRef.secretId,
|
|
refreshTokenVersion,
|
|
})
|
|
: null;
|
|
if (!marked) {
|
|
const latest = await getConnectionRow(
|
|
connection.id,
|
|
connection.companyId,
|
|
);
|
|
const latestExpiresAtMs = oauthExpiresAtMs(latest);
|
|
if (latestExpiresAtMs && latestExpiresAtMs > Date.now() + 60_000)
|
|
return latest;
|
|
throw conflict(
|
|
"OAuth credentials changed while refresh was in progress. Retry the request.",
|
|
{
|
|
code: "oauth_refresh_superseded",
|
|
retryable: true,
|
|
},
|
|
);
|
|
}
|
|
throw new HttpError(error.status, error.message, {
|
|
...asRecord(error.details),
|
|
setupUrl: connectionSetupUrl(connection),
|
|
reconnectUrl: connectionReconnectUrl(connection),
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
// Rotating providers invalidate the submitted refresh token immediately.
|
|
// Persist its replacement before the new access token can be returned to a
|
|
// caller, so a crash cannot leave the grant with only the consumed token.
|
|
let nextRefreshRef: Awaited<
|
|
ReturnType<typeof createOrRotateOAuthSecret>
|
|
> | null = null;
|
|
if (token.refreshToken) {
|
|
nextRefreshRef = await createOrRotateOAuthSecret({
|
|
companyId: connection.companyId,
|
|
connection,
|
|
configPath: "oauth.refresh_token",
|
|
label: "OAuth refresh token",
|
|
value: token.refreshToken,
|
|
actor,
|
|
});
|
|
}
|
|
const accessRef = await createOrRotateOAuthSecret({
|
|
companyId: connection.companyId,
|
|
connection,
|
|
configPath: "oauth.access_token",
|
|
label: "OAuth access token",
|
|
value: token.accessToken,
|
|
actor,
|
|
});
|
|
const nextCredentialSecretRefs = [
|
|
...connection.credentialSecretRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
(!nextRefreshRef || ref.configPath !== "oauth.refresh_token"),
|
|
),
|
|
accessRef,
|
|
...(nextRefreshRef ? [nextRefreshRef] : []),
|
|
];
|
|
const expiresAt = token.expiresIn
|
|
? new Date(Date.now() + token.expiresIn * 1000).toISOString()
|
|
: null;
|
|
const nextConfig = {
|
|
...connection.config,
|
|
oauth: {
|
|
...withoutOAuthRefreshLease(oauth),
|
|
grantType:
|
|
grantType === "client_credentials"
|
|
? grantType
|
|
: (oauth.grantType ?? "authorization_code"),
|
|
expiresAt,
|
|
scope: token.scope ?? oauth.scope ?? null,
|
|
tokenType: token.tokenType,
|
|
refreshedAt: new Date().toISOString(),
|
|
},
|
|
providerMetadata: {
|
|
...asRecord(connection.config.providerMetadata),
|
|
oauth: {
|
|
expiresAt,
|
|
scope: token.scope ?? oauth.scope ?? null,
|
|
tokenType: token.tokenType,
|
|
},
|
|
},
|
|
};
|
|
const [updated] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
credentialSecretRefs: nextCredentialSecretRefs,
|
|
credentialRefs: [
|
|
...connection.credentialRefs.filter(
|
|
(ref) => ref.name !== "oauth.access_token",
|
|
),
|
|
{
|
|
name: "oauth.access_token",
|
|
secretId: accessRef.secretId,
|
|
version: "latest" as const,
|
|
placement: "header" as const,
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
},
|
|
],
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, connection.id),
|
|
eq(toolConnections.companyId, connection.companyId),
|
|
sql`${toolConnections.config} -> 'oauth' -> 'refreshLease' ->> 'id' = ${leaseId}`,
|
|
),
|
|
)
|
|
.returning();
|
|
if (!updated) {
|
|
throw conflict(
|
|
"OAuth credentials changed while refresh was in progress. Retry the request.",
|
|
{
|
|
code: "oauth_refresh_superseded",
|
|
retryable: true,
|
|
},
|
|
);
|
|
}
|
|
const previousBindingKeys = new Set(
|
|
connection.credentialSecretRefs.map(
|
|
(ref) => `${ref.secretId}:${ref.configPath}`,
|
|
),
|
|
);
|
|
const nextBindingKeys = new Set(
|
|
nextCredentialSecretRefs.map(
|
|
(ref) => `${ref.secretId}:${ref.configPath}`,
|
|
),
|
|
);
|
|
const bindingsChanged =
|
|
previousBindingKeys.size !== nextBindingKeys.size ||
|
|
[...previousBindingKeys].some((key) => !nextBindingKeys.has(key));
|
|
if (bindingsChanged) await syncCredentialBindings(updated);
|
|
return updated;
|
|
}
|
|
|
|
function oauthGrantConfig(grant: typeof connectionGrants.$inferSelect) {
|
|
return asRecord(asRecord(grant.providerTenant).oauth);
|
|
}
|
|
|
|
function withoutOAuthGrantRefreshLease(oauth: Record<string, unknown>) {
|
|
const { refreshLease: _refreshLease, ...rest } = oauth;
|
|
return rest;
|
|
}
|
|
|
|
function oauthGrantExpiresAtMs(
|
|
grant: typeof connectionGrants.$inferSelect,
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): number | null {
|
|
const grantExpiresAt = oauthGrantConfig(grant).accessTokenExpiresAt;
|
|
const value =
|
|
typeof grantExpiresAt === "string"
|
|
? grantExpiresAt
|
|
: oauthConfig(connection).expiresAt;
|
|
if (typeof value !== "string") return null;
|
|
const ms = Date.parse(value);
|
|
return Number.isFinite(ms) ? ms : null;
|
|
}
|
|
|
|
async function getOAuthGrantRow(input: {
|
|
companyId: string;
|
|
connectionId: string;
|
|
grantId: string;
|
|
}) {
|
|
const [grant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, input.grantId),
|
|
eq(connectionGrants.companyId, input.companyId),
|
|
eq(connectionGrants.connectionId, input.connectionId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!grant) throw notFound("Connection authorization not found");
|
|
return grant;
|
|
}
|
|
|
|
async function resolveOAuthGrantSecret(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
grant: typeof connectionGrants.$inferSelect,
|
|
ref: ToolCredentialSecretRef,
|
|
actor: ActorInfo | undefined,
|
|
accessContext:
|
|
{ issueId?: string | null; heartbeatRunId?: string | null } | undefined,
|
|
) {
|
|
const [secret] = await db
|
|
.select({
|
|
scope: companySecrets.scope,
|
|
ownerUserId: companySecrets.ownerUserId,
|
|
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
|
|
latestVersion: companySecrets.latestVersion,
|
|
})
|
|
.from(companySecrets)
|
|
.where(
|
|
and(
|
|
eq(companySecrets.id, ref.secretId),
|
|
eq(companySecrets.companyId, connection.companyId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!secret) throw notFound("OAuth credential secret not found");
|
|
const consumerContext = {
|
|
consumerType: "tool_connection" as const,
|
|
consumerId: connection.id,
|
|
configPath: ref.configPath,
|
|
actorType: actor?.actorType ?? ("system" as const),
|
|
actorId: actor?.actorId ?? null,
|
|
responsibleUserId: grant.subjectUserId,
|
|
issueId: accessContext?.issueId,
|
|
heartbeatRunId: accessContext?.heartbeatRunId,
|
|
};
|
|
if (secret.scope !== "user") {
|
|
return {
|
|
value: await secrets.resolveSecretValue(
|
|
connection.companyId,
|
|
ref.secretId,
|
|
ref.versionSelector ?? "latest",
|
|
consumerContext,
|
|
),
|
|
latestVersion: secret.latestVersion,
|
|
};
|
|
}
|
|
if (
|
|
grant.kind !== "user" ||
|
|
!grant.subjectUserId ||
|
|
secret.ownerUserId !== grant.subjectUserId ||
|
|
!secret.userSecretDefinitionId
|
|
) {
|
|
throw unprocessable("Personal authorization has an invalid credential", {
|
|
code: "grant_credential_invalid",
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
credential: ref.configPath,
|
|
});
|
|
}
|
|
const resolved = await secrets.resolveUserSecretValue(
|
|
connection.companyId,
|
|
{
|
|
definitionId: secret.userSecretDefinitionId,
|
|
responsibleUserId: grant.subjectUserId,
|
|
version: ref.versionSelector ?? "latest",
|
|
required: ref.required ?? true,
|
|
},
|
|
consumerContext,
|
|
);
|
|
if (!resolved)
|
|
throw unprocessable("Personal OAuth credential is not configured", {
|
|
code: "user_secret_missing",
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
credential: ref.configPath,
|
|
});
|
|
return { value: resolved.value, latestVersion: secret.latestVersion };
|
|
}
|
|
|
|
async function clearOAuthGrantRefreshLease(
|
|
grant: typeof connectionGrants.$inferSelect,
|
|
leaseId: string,
|
|
) {
|
|
const latest = await getOAuthGrantRow({
|
|
companyId: grant.companyId,
|
|
connectionId: grant.connectionId,
|
|
grantId: grant.id,
|
|
});
|
|
const oauth = oauthGrantConfig(latest);
|
|
if (asRecord(oauth.refreshLease).id !== leaseId) return latest;
|
|
const providerTenant = {
|
|
...(latest.providerTenant ?? {}),
|
|
oauth: withoutOAuthGrantRefreshLease(oauth),
|
|
};
|
|
const [updated] = await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
providerTenant,
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, latest.id),
|
|
eq(connectionGrants.companyId, latest.companyId),
|
|
sql`${connectionGrants.providerTenant} -> 'oauth' -> 'refreshLease' ->> 'id' = ${leaseId}`,
|
|
),
|
|
)
|
|
.returning();
|
|
return (
|
|
updated ??
|
|
getOAuthGrantRow({
|
|
companyId: grant.companyId,
|
|
connectionId: grant.connectionId,
|
|
grantId: grant.id,
|
|
})
|
|
);
|
|
}
|
|
|
|
async function acquireOAuthGrantRefreshLease(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
grant: typeof connectionGrants.$inferSelect,
|
|
forceRefresh: boolean,
|
|
): Promise<{
|
|
grant: typeof connectionGrants.$inferSelect;
|
|
leaseId: string | null;
|
|
}> {
|
|
const waitDeadline = Date.now() + OAUTH_REFRESH_LEASE_WAIT_MS;
|
|
const initialUpdatedAt = grant.updatedAt.getTime();
|
|
while (true) {
|
|
const latest = await getOAuthGrantRow({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
grantId: grant.id,
|
|
});
|
|
if (latest.status !== "active") {
|
|
throw unprocessable("OAuth authorization must be reconnected", {
|
|
code: "oauth_reauthorization_required",
|
|
setupUrl: connectionSetupUrl(connection),
|
|
reconnectUrl: connectionReconnectUrl(connection),
|
|
});
|
|
}
|
|
const expiresAtMs = oauthGrantExpiresAtMs(latest, connection);
|
|
if (
|
|
(!forceRefresh &&
|
|
(expiresAtMs === null || expiresAtMs > Date.now() + 60_000)) ||
|
|
(forceRefresh &&
|
|
latest.updatedAt.getTime() > initialUpdatedAt &&
|
|
!asRecord(oauthGrantConfig(latest).refreshLease).id)
|
|
) {
|
|
return { grant: latest, leaseId: null };
|
|
}
|
|
|
|
const oauth = oauthGrantConfig(latest);
|
|
const currentLease = asRecord(oauth.refreshLease);
|
|
const currentLeaseId =
|
|
typeof currentLease.id === "string" && currentLease.id
|
|
? currentLease.id
|
|
: null;
|
|
const currentLeaseExpiresAt =
|
|
typeof currentLease.expiresAt === "string"
|
|
? Date.parse(currentLease.expiresAt)
|
|
: Number.NaN;
|
|
const leaseIsActive =
|
|
currentLeaseId !== null &&
|
|
Number.isFinite(currentLeaseExpiresAt) &&
|
|
currentLeaseExpiresAt > Date.now();
|
|
if (!currentLeaseId) {
|
|
const leaseId = randomUUID();
|
|
const providerTenant = {
|
|
...(latest.providerTenant ?? {}),
|
|
oauth: {
|
|
...withoutOAuthGrantRefreshLease(oauth),
|
|
refreshLease: {
|
|
id: leaseId,
|
|
expiresAt: new Date(
|
|
Date.now() + OAUTH_REFRESH_LEASE_MS,
|
|
).toISOString(),
|
|
},
|
|
},
|
|
};
|
|
const [claimed] = await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
providerTenant,
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, latest.id),
|
|
eq(connectionGrants.companyId, latest.companyId),
|
|
eq(connectionGrants.status, "active"),
|
|
sql`${connectionGrants.providerTenant} #>> '{oauth,refreshLease,id}' is null`,
|
|
),
|
|
)
|
|
.returning();
|
|
if (claimed) return { grant: claimed, leaseId };
|
|
}
|
|
if (currentLeaseId && !leaseIsActive) {
|
|
throw unprocessable(
|
|
"The previous OAuth refresh did not finish. Reconnect this app before retrying.",
|
|
{
|
|
code: "oauth_refresh_outcome_unknown",
|
|
setupUrl: connectionSetupUrl(connection),
|
|
reconnectUrl: connectionReconnectUrl(connection),
|
|
},
|
|
);
|
|
}
|
|
if (Date.now() >= waitDeadline) {
|
|
throw conflict("OAuth credential refresh is already in progress", {
|
|
code: "oauth_refresh_in_progress",
|
|
retryable: true,
|
|
});
|
|
}
|
|
await new Promise((resolve) =>
|
|
setTimeout(resolve, OAUTH_REFRESH_LEASE_POLL_MS),
|
|
);
|
|
}
|
|
}
|
|
|
|
async function refreshOAuthGrantCredentials(input: {
|
|
companyId: string;
|
|
connectionId: string;
|
|
grantId: string;
|
|
forceRefresh?: boolean;
|
|
actor?: ActorInfo;
|
|
issueId?: string | null;
|
|
heartbeatRunId?: string | null;
|
|
}): Promise<typeof connectionGrants.$inferSelect> {
|
|
const connection = await getConnectionRow(
|
|
input.connectionId,
|
|
input.companyId,
|
|
);
|
|
const initialGrant = await getOAuthGrantRow(input);
|
|
const oauth = oauthConfig(connection);
|
|
const oauthProvider =
|
|
typeof oauth.provider === "string" ? oauth.provider : null;
|
|
const oauthTokenUrl =
|
|
typeof oauth.tokenUrl === "string" ? oauth.tokenUrl : null;
|
|
if (
|
|
connection.authKind === "oauth" &&
|
|
connection.credentialSource === "paperclip_vault" &&
|
|
isPaperclipCloudConnectorStrategy(oauth.strategy)
|
|
) {
|
|
const grantOauth = oauthGrantConfig(initialGrant);
|
|
const expiresAt =
|
|
typeof grantOauth.accessTokenExpiresAt === "string"
|
|
? Date.parse(grantOauth.accessTokenExpiresAt)
|
|
: Number.NaN;
|
|
const refreshedAt =
|
|
typeof grantOauth.refreshedAt === "string"
|
|
? Date.parse(grantOauth.refreshedAt)
|
|
: Number.NaN;
|
|
const rotationDue =
|
|
Number.isFinite(expiresAt) &&
|
|
(!Number.isFinite(refreshedAt) ||
|
|
refreshedAt <= Date.now() - 30 * 24 * 60 * 60_000);
|
|
const refreshDue =
|
|
Number.isFinite(expiresAt) && expiresAt <= Date.now() + 60 * 60_000;
|
|
// A GitHub App can deliberately issue a non-expiring ghu_ token. Its
|
|
// continuity is checked against /user below; only an expiring token pair
|
|
// enters this rotation path.
|
|
if (!input.forceRefresh && !refreshDue && !rotationDue)
|
|
return initialGrant;
|
|
if (!Number.isFinite(expiresAt)) return initialGrant;
|
|
|
|
return singleFlight(
|
|
oauthGrantRefreshFlights,
|
|
initialGrant.id,
|
|
async () => {
|
|
const lease = await acquireOAuthGrantRefreshLease(
|
|
connection,
|
|
initialGrant,
|
|
true,
|
|
);
|
|
if (!lease.leaseId) return lease.grant;
|
|
try {
|
|
const grant = lease.grant;
|
|
const profile = managedConnectorProfile(
|
|
typeof oauth.connectorProfile === "string"
|
|
? oauth.connectorProfile
|
|
: undefined,
|
|
);
|
|
const cloudConnector = currentCloudConnector();
|
|
const connectorSubject =
|
|
grant.kind === "agent" && grant.subjectAgentId
|
|
? `agent:${grant.subjectAgentId}`
|
|
: grant.kind === "user" && grant.subjectUserId
|
|
? grant.subjectUserId
|
|
: typeof oauth.connectorSubjectUserId === "string"
|
|
? oauth.connectorSubjectUserId
|
|
: null;
|
|
const accessRef = grant.credentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.access_token",
|
|
);
|
|
const refreshRef = grant.credentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.refresh_token",
|
|
);
|
|
if (
|
|
!profile ||
|
|
!cloudConnector ||
|
|
!connectorSubject ||
|
|
!accessRef ||
|
|
!refreshRef
|
|
) {
|
|
throw unprocessable("Managed authorization must be reconnected", {
|
|
code: "oauth_reauthorization_required",
|
|
setupUrl: connectionSetupUrl(connection),
|
|
reconnectUrl: connectionReconnectUrl(connection),
|
|
});
|
|
}
|
|
const refreshSecret = await resolveOAuthGrantSecret(
|
|
connection,
|
|
grant,
|
|
refreshRef,
|
|
input.actor,
|
|
input,
|
|
);
|
|
let credentials;
|
|
try {
|
|
credentials = await cloudConnector.refresh({
|
|
subject: connectorSubject,
|
|
companyId: connection.companyId,
|
|
profile: profile.id,
|
|
refreshToken: refreshSecret.value,
|
|
});
|
|
} catch (error) {
|
|
if (
|
|
error instanceof PaperclipCloudConnectorError &&
|
|
error.code === "REAUTHORIZATION_REQUIRED"
|
|
) {
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ status: "needs_reauthorization", updatedAt: now() })
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, grant.companyId),
|
|
),
|
|
);
|
|
throw unprocessable(
|
|
"Managed authorization must be reconnected",
|
|
{
|
|
code: "oauth_reauthorization_required",
|
|
setupUrl: connectionSetupUrl(connection),
|
|
reconnectUrl: connectionReconnectUrl(connection),
|
|
},
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
const credentialActor: ActorInfo | undefined =
|
|
grant.kind === "user" && grant.subjectUserId
|
|
? { actorType: "user", actorId: grant.subjectUserId }
|
|
: input.actor;
|
|
const providerTenant = {
|
|
...(grant.providerTenant ?? {}),
|
|
oauth: {
|
|
...withoutOAuthGrantRefreshLease(oauthGrantConfig(grant)),
|
|
strategy: "paperclip_cloud_connector",
|
|
accessTokenExpiresAt: credentials.accessTokenExpiresAt,
|
|
scopes: credentials.scopes,
|
|
tokenType: credentials.tokenType,
|
|
refreshedAt: now().toISOString(),
|
|
...(credentials.refreshTokenExpiresAt
|
|
? { refreshTokenExpiresAt: credentials.refreshTokenExpiresAt }
|
|
: {}),
|
|
},
|
|
};
|
|
const updated = await db.transaction(async (tx) => {
|
|
const txSecrets = secretService(tx);
|
|
await txSecrets.rotate(
|
|
accessRef.secretId,
|
|
{ value: credentials.accessToken },
|
|
actorForSecret(credentialActor),
|
|
);
|
|
if (credentials.refreshToken) {
|
|
await txSecrets.rotate(
|
|
refreshRef.secretId,
|
|
{ value: credentials.refreshToken },
|
|
actorForSecret(credentialActor),
|
|
);
|
|
}
|
|
const [committed] = await tx
|
|
.update(connectionGrants)
|
|
.set({
|
|
providerTenant,
|
|
status: "active",
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, grant.companyId),
|
|
eq(connectionGrants.status, "active"),
|
|
sql`${connectionGrants.providerTenant} -> 'oauth' -> 'refreshLease' ->> 'id' = ${lease.leaseId}`,
|
|
),
|
|
)
|
|
.returning();
|
|
return committed;
|
|
});
|
|
if (!updated)
|
|
throw conflict(
|
|
"OAuth credentials changed while refresh was in progress",
|
|
{
|
|
code: "oauth_refresh_superseded",
|
|
retryable: true,
|
|
},
|
|
);
|
|
return updated;
|
|
} finally {
|
|
await clearOAuthGrantRefreshLease(lease.grant, lease.leaseId).catch(
|
|
() => undefined,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
if (
|
|
connection.authKind !== "oauth" ||
|
|
connection.credentialSource !== "paperclip_vault" ||
|
|
!oauthTokenUrl ||
|
|
!oauthProvider
|
|
) {
|
|
return initialGrant;
|
|
}
|
|
const expiresAtMs = oauthGrantExpiresAtMs(initialGrant, connection);
|
|
if (
|
|
!input.forceRefresh &&
|
|
(expiresAtMs === null || expiresAtMs > Date.now() + 60_000)
|
|
) {
|
|
return initialGrant;
|
|
}
|
|
|
|
return singleFlight(oauthGrantRefreshFlights, initialGrant.id, async () => {
|
|
const lease = await acquireOAuthGrantRefreshLease(
|
|
connection,
|
|
initialGrant,
|
|
input.forceRefresh === true,
|
|
);
|
|
if (!lease.leaseId) return lease.grant;
|
|
try {
|
|
const grant = lease.grant;
|
|
const refreshRef = grant.credentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.refresh_token",
|
|
);
|
|
if (!refreshRef) {
|
|
throw unprocessable(
|
|
"OAuth credentials have expired and no refresh token is available",
|
|
{
|
|
code: "oauth_refresh_missing",
|
|
setupUrl: connectionSetupUrl(connection),
|
|
reconnectUrl: connectionReconnectUrl(connection),
|
|
},
|
|
);
|
|
}
|
|
const refreshSecret = await resolveOAuthGrantSecret(
|
|
connection,
|
|
grant,
|
|
refreshRef,
|
|
input.actor,
|
|
input,
|
|
);
|
|
const credentialActor: ActorInfo | undefined =
|
|
grant.kind === "user" && grant.subjectUserId
|
|
? { actorType: "user", actorId: grant.subjectUserId }
|
|
: input.actor;
|
|
const client = await oauthClientForConnection(
|
|
connection,
|
|
oauthProvider,
|
|
credentialActor,
|
|
);
|
|
if (!client.clientId)
|
|
throw unprocessable(
|
|
`OAuth client id is not configured for ${oauthProvider}`,
|
|
);
|
|
const grantOauth = oauthGrantConfig(grant);
|
|
let token: Awaited<ReturnType<typeof exchangeOAuthToken>>;
|
|
try {
|
|
token = await exchangeOAuthToken({
|
|
tokenUrl: oauthTokenUrl,
|
|
clientId: client.clientId,
|
|
clientSecret: client.clientSecret,
|
|
tokenEndpointAuthMethod: storedOAuthTokenEndpointAuthMethod(
|
|
oauth,
|
|
client.clientSecret,
|
|
),
|
|
grantType: "refresh_token",
|
|
scopes:
|
|
normalizeOauthScopes(grantOauth.scopes).length > 0
|
|
? normalizeOauthScopes(grantOauth.scopes)
|
|
: normalizeOauthScopes(oauth.scopes).length > 0
|
|
? normalizeOauthScopes(oauth.scopes)
|
|
: normalizeOauthScopes(oauth.scope),
|
|
refreshToken: refreshSecret.value,
|
|
resource:
|
|
typeof oauth.resource === "string" && oauth.resource
|
|
? oauth.resource
|
|
: null,
|
|
});
|
|
} catch (error) {
|
|
if (
|
|
error instanceof HttpError &&
|
|
asRecord(error.details).code === "oauth_reauthorization_required"
|
|
) {
|
|
const retainedCredentialSecretRefs =
|
|
grant.credentialSecretRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
ref.configPath !== "oauth.refresh_token",
|
|
);
|
|
const providerTenant = {
|
|
...(grant.providerTenant ?? {}),
|
|
oauth: {
|
|
...withoutOAuthGrantRefreshLease(grantOauth),
|
|
accessTokenExpiresAt: undefined,
|
|
},
|
|
};
|
|
const [marked] = await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
status: "needs_reauthorization",
|
|
providerTenant,
|
|
credentialSecretRefs: retainedCredentialSecretRefs,
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, grant.companyId),
|
|
eq(connectionGrants.status, "active"),
|
|
sql`${connectionGrants.providerTenant} -> 'oauth' -> 'refreshLease' ->> 'id' = ${lease.leaseId}`,
|
|
sql`exists (
|
|
select 1 from ${companySecrets}
|
|
where ${companySecrets.id} = ${refreshRef.secretId}
|
|
and ${companySecrets.companyId} = ${connection.companyId}
|
|
and ${companySecrets.latestVersion} = ${refreshSecret.latestVersion}
|
|
)`,
|
|
),
|
|
)
|
|
.returning();
|
|
if (!marked) {
|
|
const latest = await getOAuthGrantRow(input);
|
|
const latestExpiresAt = oauthGrantExpiresAtMs(latest, connection);
|
|
if (
|
|
latest.status === "active" &&
|
|
latestExpiresAt &&
|
|
latestExpiresAt > Date.now() + 60_000
|
|
)
|
|
return latest;
|
|
throw conflict(
|
|
"OAuth credentials changed while refresh was in progress. Retry the request.",
|
|
{
|
|
code: "oauth_refresh_superseded",
|
|
retryable: true,
|
|
},
|
|
);
|
|
}
|
|
if (marked.kind === "organization") {
|
|
const latestConnection = await getConnectionRow(
|
|
connection.id,
|
|
connection.companyId,
|
|
);
|
|
const [reauthorizationRequired] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
status: "draft",
|
|
enabled: false,
|
|
credentialSecretRefs:
|
|
latestConnection.credentialSecretRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
ref.configPath !== "oauth.refresh_token",
|
|
),
|
|
credentialRefs: latestConnection.credentialRefs.filter(
|
|
(ref) =>
|
|
ref.name !== "oauth.access_token" &&
|
|
ref.name !== "oauth.refresh_token",
|
|
),
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, latestConnection.id),
|
|
eq(toolConnections.companyId, latestConnection.companyId),
|
|
),
|
|
)
|
|
.returning();
|
|
await syncCredentialBindings(reauthorizationRequired);
|
|
} else {
|
|
const activeGrantRefs = await db
|
|
.select({
|
|
refs: connectionGrants.credentialSecretRefs,
|
|
})
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.status, "active"),
|
|
),
|
|
);
|
|
await syncCredentialBindings(
|
|
connection,
|
|
activeGrantRefs.flatMap((row) => row.refs),
|
|
);
|
|
}
|
|
throw new HttpError(error.status, error.message, {
|
|
...asRecord(error.details),
|
|
setupUrl: connectionSetupUrl(connection),
|
|
reconnectUrl: connectionReconnectUrl(connection),
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
let nextRefreshRef: ToolCredentialSecretRef | null = null;
|
|
if (token.refreshToken) {
|
|
nextRefreshRef = await createOrRotateOAuthSecret({
|
|
companyId: connection.companyId,
|
|
connection,
|
|
configPath: "oauth.refresh_token",
|
|
label: "OAuth refresh token",
|
|
value: token.refreshToken,
|
|
actor: credentialActor,
|
|
existingRefs: grant.credentialSecretRefs,
|
|
ownerUserId:
|
|
grant.kind === "user"
|
|
? (grant.subjectUserId ?? undefined)
|
|
: undefined,
|
|
});
|
|
}
|
|
const accessRef = await createOrRotateOAuthSecret({
|
|
companyId: connection.companyId,
|
|
connection,
|
|
configPath: "oauth.access_token",
|
|
label: "OAuth access token",
|
|
value: token.accessToken,
|
|
actor: credentialActor,
|
|
existingRefs: grant.credentialSecretRefs,
|
|
ownerUserId:
|
|
grant.kind === "user"
|
|
? (grant.subjectUserId ?? undefined)
|
|
: undefined,
|
|
});
|
|
const nextCredentialSecretRefs = [
|
|
...grant.credentialSecretRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
(!nextRefreshRef || ref.configPath !== "oauth.refresh_token"),
|
|
),
|
|
accessRef,
|
|
...(nextRefreshRef ? [nextRefreshRef] : []),
|
|
];
|
|
const expiresAt = token.expiresIn
|
|
? new Date(Date.now() + token.expiresIn * 1000).toISOString()
|
|
: null;
|
|
const providerTenant = {
|
|
...(grant.providerTenant ?? {}),
|
|
oauth: {
|
|
...withoutOAuthGrantRefreshLease(grantOauth),
|
|
strategy:
|
|
typeof grantOauth.strategy === "string"
|
|
? grantOauth.strategy
|
|
: "direct_oauth",
|
|
accessTokenExpiresAt: expiresAt ?? undefined,
|
|
scopes: normalizeOauthScopes(
|
|
token.scope ?? grantOauth.scopes ?? oauth.scopes ?? oauth.scope,
|
|
),
|
|
tokenType: token.tokenType,
|
|
refreshedAt: now().toISOString(),
|
|
},
|
|
};
|
|
const [updated] = await db
|
|
.update(connectionGrants)
|
|
.set({
|
|
providerTenant,
|
|
credentialSecretRefs: nextCredentialSecretRefs,
|
|
status: "active",
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, grant.companyId),
|
|
eq(connectionGrants.status, "active"),
|
|
sql`${connectionGrants.providerTenant} -> 'oauth' -> 'refreshLease' ->> 'id' = ${lease.leaseId}`,
|
|
),
|
|
)
|
|
.returning();
|
|
if (!updated) {
|
|
throw conflict(
|
|
"OAuth credentials changed while refresh was in progress. Retry the request.",
|
|
{
|
|
code: "oauth_refresh_superseded",
|
|
retryable: true,
|
|
},
|
|
);
|
|
}
|
|
if (grant.kind === "organization") {
|
|
const latestConnection = await getConnectionRow(
|
|
connection.id,
|
|
connection.companyId,
|
|
);
|
|
const latestOauth = oauthConfig(latestConnection);
|
|
const nextConfig = {
|
|
...latestConnection.config,
|
|
oauth: {
|
|
...withoutOAuthRefreshLease(latestOauth),
|
|
expiresAt,
|
|
scope: token.scope ?? latestOauth.scope ?? null,
|
|
tokenType: token.tokenType,
|
|
refreshedAt: now().toISOString(),
|
|
},
|
|
providerMetadata: {
|
|
...asRecord(latestConnection.config.providerMetadata),
|
|
oauth: {
|
|
expiresAt,
|
|
scope: token.scope ?? latestOauth.scope ?? null,
|
|
tokenType: token.tokenType,
|
|
},
|
|
},
|
|
};
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, latestConnection.id),
|
|
eq(toolConnections.companyId, latestConnection.companyId),
|
|
),
|
|
);
|
|
}
|
|
const previousKeys = new Set(
|
|
grant.credentialSecretRefs.map(
|
|
(ref) => `${ref.secretId}:${ref.configPath}`,
|
|
),
|
|
);
|
|
const nextKeys = new Set(
|
|
nextCredentialSecretRefs.map(
|
|
(ref) => `${ref.secretId}:${ref.configPath}`,
|
|
),
|
|
);
|
|
if (
|
|
previousKeys.size !== nextKeys.size ||
|
|
[...previousKeys].some((key) => !nextKeys.has(key))
|
|
) {
|
|
const activeGrantRefs = await db
|
|
.select({
|
|
refs: connectionGrants.credentialSecretRefs,
|
|
})
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.status, "active"),
|
|
),
|
|
);
|
|
await syncCredentialBindings(
|
|
connection,
|
|
activeGrantRefs.flatMap((row) => row.refs),
|
|
);
|
|
}
|
|
return updated;
|
|
} finally {
|
|
await clearOAuthGrantRefreshLease(lease.grant, lease.leaseId).catch(
|
|
() => undefined,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function maybeRefreshOAuthCredentials(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
actor?: ActorInfo,
|
|
accessContext?: {
|
|
actorSource?:
|
|
| "local_implicit"
|
|
| "session"
|
|
| "board_key"
|
|
| "agent_key"
|
|
| "agent_jwt"
|
|
| "cloud_tenant";
|
|
issueId?: string | null;
|
|
heartbeatRunId?: string | null;
|
|
},
|
|
): Promise<typeof toolConnections.$inferSelect> {
|
|
const oauth = oauthConfig(connection);
|
|
if (
|
|
typeof oauth.tokenUrl !== "string" ||
|
|
typeof oauth.provider !== "string"
|
|
)
|
|
return connection;
|
|
const expiresAtMs = oauthExpiresAtMs(connection);
|
|
if (expiresAtMs && expiresAtMs > Date.now() + 60_000) return connection;
|
|
return singleFlight(oauthRefreshFlights, connection.id, async () => {
|
|
const lease = await acquireOAuthRefreshLease(connection);
|
|
if (!lease.leaseId) return lease.connection;
|
|
try {
|
|
return await refreshOAuthCredentials(
|
|
lease.connection,
|
|
lease.leaseId,
|
|
actor,
|
|
accessContext,
|
|
);
|
|
} finally {
|
|
await clearOAuthRefreshLease(lease.connection, lease.leaseId).catch(
|
|
() => undefined,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function refreshManagedGitHubGrantAccess(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
initialGrant: typeof connectionGrants.$inferSelect,
|
|
actor?: ActorInfo,
|
|
) {
|
|
try {
|
|
return await refreshManagedGitHubGrantAccessOnce(
|
|
connection,
|
|
initialGrant,
|
|
actor,
|
|
);
|
|
} catch (error) {
|
|
if (
|
|
!(error instanceof HttpError) ||
|
|
asRecord(error.details).code !== "github_access_changed"
|
|
)
|
|
throw error;
|
|
const [current] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, initialGrant.id),
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
),
|
|
);
|
|
if (!current || current.status !== "active") throw error;
|
|
return refreshManagedGitHubGrantAccessOnce(connection, current, actor);
|
|
}
|
|
}
|
|
|
|
async function refreshManagedGitHubGrantAccessOnce(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
initialGrant: typeof connectionGrants.$inferSelect,
|
|
actor?: ActorInfo,
|
|
) {
|
|
let grant = await refreshOAuthGrantCredentials({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
grantId: initialGrant.id,
|
|
actor,
|
|
});
|
|
const resolveAccessToken = async () => {
|
|
const accessRef = grant.credentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.access_token",
|
|
);
|
|
if (!accessRef)
|
|
throw unprocessable("GitHub authorization must be reconnected", {
|
|
code: "oauth_reauthorization_required",
|
|
});
|
|
return (
|
|
await resolveOAuthGrantSecret(
|
|
connection,
|
|
grant,
|
|
accessRef,
|
|
actor,
|
|
undefined,
|
|
)
|
|
).value;
|
|
};
|
|
let metadata;
|
|
let accessToken = await resolveAccessToken();
|
|
try {
|
|
metadata = await loadGitHubGrantMetadata(
|
|
accessToken,
|
|
fetch,
|
|
grant.providerTenant?.github?.appSlug,
|
|
);
|
|
} catch (error) {
|
|
const providerCode =
|
|
error instanceof HttpError ? asRecord(error.details).code : null;
|
|
if (providerCode !== "oauth_reauthorization_required") throw error;
|
|
// GitHub may invalidate an access token before its recorded expiry. If an
|
|
// expiring token pair exists, rotate it once under the same durable lease
|
|
// and CAS path, then repeat /user before surfacing reconnect-required.
|
|
grant = await refreshOAuthGrantCredentials({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
grantId: initialGrant.id,
|
|
forceRefresh: true,
|
|
actor,
|
|
});
|
|
accessToken = await resolveAccessToken();
|
|
try {
|
|
metadata = await loadGitHubGrantMetadata(
|
|
accessToken,
|
|
fetch,
|
|
grant.providerTenant?.github?.appSlug,
|
|
);
|
|
} catch (retryError) {
|
|
const retryCode =
|
|
retryError instanceof HttpError
|
|
? asRecord(retryError.details).code
|
|
: null;
|
|
if (retryCode === "oauth_reauthorization_required") {
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ status: "needs_reauthorization", updatedAt: now() })
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, grant.companyId),
|
|
),
|
|
);
|
|
}
|
|
throw retryError;
|
|
}
|
|
}
|
|
const { updated, previousGitHub } = await db.transaction(async (tx) => {
|
|
const [currentGrant] = await tx
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, grant.companyId),
|
|
),
|
|
)
|
|
.for("update")
|
|
.limit(1);
|
|
if (!currentGrant || currentGrant.status === "revoked")
|
|
throw notFound("GitHub authorization not found");
|
|
const previousGitHub = currentGrant.providerTenant?.github;
|
|
const initialGitHub = grant.providerTenant?.github;
|
|
// No lock is held during provider requests. Reject a snapshot if another
|
|
// refresh or webhook changed access while those requests were in flight.
|
|
if (
|
|
previousGitHub?.accessRevision !== initialGitHub?.accessRevision ||
|
|
previousGitHub?.lastWebhookAt !== initialGitHub?.lastWebhookAt ||
|
|
previousGitHub?.lastAccessRefreshAt !==
|
|
initialGitHub?.lastAccessRefreshAt
|
|
) {
|
|
throw conflict("GitHub access changed during refresh. Try again.", {
|
|
code: "github_access_changed",
|
|
});
|
|
}
|
|
const providerTenant = {
|
|
...(currentGrant.providerTenant ?? {}),
|
|
github: {
|
|
...metadata,
|
|
...(previousGitHub?.lastWebhookAt
|
|
? { lastWebhookAt: previousGitHub.lastWebhookAt }
|
|
: {}),
|
|
webhookHealth:
|
|
previousGitHub?.webhookHealth ?? metadata.webhookHealth,
|
|
},
|
|
};
|
|
const [updated] = await tx
|
|
.update(connectionGrants)
|
|
.set({
|
|
providerTenant,
|
|
status: "active",
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, grant.companyId),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!updated) throw notFound("GitHub authorization not found");
|
|
return { updated, previousGitHub };
|
|
});
|
|
|
|
const cloudConnector = currentCloudConnector();
|
|
const subject =
|
|
updated.kind === "agent" && updated.subjectAgentId
|
|
? `agent:${updated.subjectAgentId}`
|
|
: updated.kind === "user" && updated.subjectUserId
|
|
? updated.subjectUserId
|
|
: null;
|
|
if (cloudConnector && subject) {
|
|
const previous = new Set(previousGitHub?.installationIds ?? []);
|
|
const current = new Set(metadata.installationIds);
|
|
await Promise.all([
|
|
...metadata.installationIds.map((installationId) =>
|
|
cloudConnector.setWebhookBinding({
|
|
subject,
|
|
companyId: connection.companyId,
|
|
id: `${updated.id}_${installationId}`,
|
|
installationId,
|
|
connectionId: connection.id,
|
|
grantId: updated.id,
|
|
active: true,
|
|
accessToken,
|
|
}),
|
|
),
|
|
...[...previous]
|
|
.filter((installationId) => !current.has(installationId))
|
|
.map((installationId) =>
|
|
cloudConnector.setWebhookBinding({
|
|
subject,
|
|
companyId: connection.companyId,
|
|
id: `${updated.id}_${installationId}`,
|
|
installationId,
|
|
connectionId: connection.id,
|
|
grantId: updated.id,
|
|
active: false,
|
|
}),
|
|
),
|
|
]);
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
async function sweepGitHubConnectionContinuity() {
|
|
if (now().getTime() < nextGitHubContinuitySweepAt) {
|
|
return { checked: 0, due: 0, refreshed: 0, failed: 0 };
|
|
}
|
|
nextGitHubContinuitySweepAt = now().getTime() + 60 * 60_000;
|
|
const cutoff = now().getTime() - 30 * 24 * 60 * 60_000;
|
|
const rows = await db
|
|
.select({ grant: connectionGrants, connection: toolConnections })
|
|
.from(connectionGrants)
|
|
.innerJoin(
|
|
toolConnections,
|
|
and(
|
|
eq(toolConnections.id, connectionGrants.connectionId),
|
|
eq(toolConnections.companyId, connectionGrants.companyId),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.status, "active"),
|
|
eq(toolConnections.status, "active"),
|
|
eq(toolConnections.enabled, true),
|
|
),
|
|
);
|
|
const due = rows.filter(({ grant, connection }) => {
|
|
const config = asRecord(connection.config);
|
|
const oauth = asRecord(config.oauth);
|
|
if (
|
|
config.sourceTemplateKey !== "github" ||
|
|
oauth.connectorProfile !== "github.code" ||
|
|
!grant.providerTenant?.github
|
|
)
|
|
return false;
|
|
const expiresAt = grant.providerTenant.oauth?.accessTokenExpiresAt;
|
|
const refreshedAt = grant.providerTenant.oauth?.refreshedAt
|
|
? Date.parse(grant.providerTenant.oauth.refreshedAt)
|
|
: Number.NaN;
|
|
const accessCheckedAt = grant.providerTenant.github.lastAccessRefreshAt
|
|
? Date.parse(grant.providerTenant.github.lastAccessRefreshAt)
|
|
: Number.NaN;
|
|
if (typeof expiresAt === "string") {
|
|
const expiry = Date.parse(expiresAt);
|
|
return (
|
|
!Number.isFinite(expiry) ||
|
|
expiry <= now().getTime() + 60 * 60_000 ||
|
|
!Number.isFinite(refreshedAt) ||
|
|
refreshedAt <= cutoff
|
|
);
|
|
}
|
|
return !Number.isFinite(accessCheckedAt) || accessCheckedAt <= cutoff;
|
|
});
|
|
let refreshed = 0;
|
|
let failed = 0;
|
|
for (const row of due) {
|
|
try {
|
|
await refreshManagedGitHubGrantAccess(row.connection, row.grant, {
|
|
actorType: "system",
|
|
actorId: "github-connection-continuity",
|
|
});
|
|
refreshed += 1;
|
|
} catch {
|
|
failed += 1;
|
|
}
|
|
}
|
|
return { checked: rows.length, due: due.length, refreshed, failed };
|
|
}
|
|
|
|
function policyNameForApp(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
entry: typeof toolCatalogEntries.$inferSelect,
|
|
) {
|
|
const base = `Ask first ${connection.id.slice(0, 8)} ${entry.toolName}`;
|
|
return base.length <= 160 ? base : base.slice(0, 160);
|
|
}
|
|
|
|
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 assertMcpAggregatorSetupEnabled(provider: unknown, method: unknown) {
|
|
if (isRemoteMcpConnectorMethod(provider, method)
|
|
&& !(await instanceSettingsService(db).getExperimental()).enableMcpAggregators) {
|
|
throw forbidden("Enable MCP aggregators in Settings → Experimental to set up this connection", {
|
|
code: "mcp_aggregators_disabled",
|
|
});
|
|
}
|
|
}
|
|
|
|
async function connectGalleryApp(
|
|
companyId: string,
|
|
input: ConnectToolApp,
|
|
actor?: ActorInfo,
|
|
): Promise<ConnectToolAppResult> {
|
|
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;
|
|
}
|
|
}
|
|
|
|
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.transport !== "chat_sdk",
|
|
).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 assertMcpAggregatorSetupEnabled(galleryEntry?.slug, method?.key);
|
|
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];
|
|
const retainedConnection = requestedResumeConnection ?? recoveredConnection;
|
|
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 &&
|
|
actor?.actorType === "user" &&
|
|
actor.actorSource !== "local_implicit"
|
|
) {
|
|
const actorUserId = actor.actorId;
|
|
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 ?? "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 transport = method?.transport ?? "mcp_remote";
|
|
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);
|
|
const remoteUrlCredential =
|
|
transport === "mcp_remote" && input.link
|
|
? splitRemoteUrlCredential(input.link)
|
|
: null;
|
|
const baseConfig =
|
|
transport === "mcp_remote"
|
|
? {
|
|
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 ?? {},
|
|
// 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: galleryEntry.slug === "railway",
|
|
...(remoteMcpConnector ? {
|
|
mcpSessionRequired: true,
|
|
mcpAuthMode: input.authMode ?? "auto",
|
|
mcpPreserveAccess: Boolean(retainedConnection && (retainedConnection.status === "active" || asRecord(retainedConnection.config).mcpPreserveAccess === true)),
|
|
} : {}),
|
|
...(galleryEntry.slug === "posthog" ? { safeDefault: true } : {}),
|
|
}
|
|
: { ...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[] = [];
|
|
// "Just me" needs a named board user to own the consent. An agent actor
|
|
// cannot hold a personal identity, and silently falling back to a shared
|
|
// credential is exactly the mis-scoping the design forbids, so refuse.
|
|
const personalIdentityUserId =
|
|
requestedGrantKind === "user"
|
|
? actor?.actorType === "user" && actor.actorId
|
|
? actor.actorId
|
|
: null
|
|
: 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 secret = await secrets.create(
|
|
companyId,
|
|
{
|
|
name: `${name} ${field.label} ${randomUUID().slice(0, 8)}`,
|
|
key: `tool_app.${randomUUID()}.${field.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`,
|
|
provider: "local_encrypted",
|
|
value,
|
|
description: `Credential for ${name} (${field.configPath}).`,
|
|
},
|
|
actorForSecret(actor),
|
|
);
|
|
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 secret = await secrets.create(
|
|
companyId,
|
|
{
|
|
name: `${name} MCP server URL ${randomUUID().slice(0, 8)}`,
|
|
key: `tool_app.${randomUUID()}.remote_url`,
|
|
provider: "local_encrypted",
|
|
value: remoteUrlCredential.secretUrl,
|
|
description: `Credential-bearing MCP server URL for ${name}.`,
|
|
},
|
|
actorForSecret(actor),
|
|
);
|
|
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 === "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,
|
|
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,
|
|
config,
|
|
transportConfig: config,
|
|
credentialRefs,
|
|
credentialSecretRefs: connectionCredentialSecretRefs,
|
|
credentialPolicy,
|
|
createdByAgentId:
|
|
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
|
createdByUserId:
|
|
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
|
|
})
|
|
.returning();
|
|
}
|
|
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 [currentGrant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.id, retainedPersonalIdentity.grant.id))
|
|
.limit(1);
|
|
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),
|
|
eq(connectionGrants.updatedAt, currentGrant.updatedAt),
|
|
),
|
|
)
|
|
.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) => {
|
|
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({
|
|
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);
|
|
}
|
|
}
|
|
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 upsertAskFirstPolicies(
|
|
input: {
|
|
companyId: string;
|
|
connection: typeof toolConnections.$inferSelect;
|
|
askFirstEntries: Array<typeof toolCatalogEntries.$inferSelect>;
|
|
actor?: ActorInfo;
|
|
disableStale?: boolean;
|
|
},
|
|
dbClient: ToolAccessMutationDb = db,
|
|
): Promise<ToolPolicy[]> {
|
|
const existingPolicies = await dbClient
|
|
.select()
|
|
.from(toolPolicies)
|
|
.where(
|
|
and(
|
|
eq(toolPolicies.companyId, input.companyId),
|
|
eq(toolPolicies.policyType, "require_approval"),
|
|
),
|
|
);
|
|
const managedPolicies = existingPolicies.filter((policy) => {
|
|
const config = asRecord(policy.config);
|
|
return (
|
|
config.source === "app_gallery_finish" &&
|
|
config.connectionId === input.connection.id
|
|
);
|
|
});
|
|
const policiesByCatalogEntryId = new Map<
|
|
string,
|
|
typeof toolPolicies.$inferSelect
|
|
>();
|
|
for (const policy of managedPolicies) {
|
|
const config = asRecord(policy.config);
|
|
if (typeof config.catalogEntryId === "string") {
|
|
policiesByCatalogEntryId.set(config.catalogEntryId, policy);
|
|
}
|
|
}
|
|
const askFirstIds = new Set(input.askFirstEntries.map((entry) => entry.id));
|
|
const results: ToolPolicy[] = [];
|
|
for (const entry of input.askFirstEntries) {
|
|
const config = {
|
|
source: "app_gallery_finish",
|
|
connectionId: input.connection.id,
|
|
catalogEntryId: entry.id,
|
|
};
|
|
const existing = policiesByCatalogEntryId.get(entry.id);
|
|
if (existing) {
|
|
const [updated] = await dbClient
|
|
.update(toolPolicies)
|
|
.set({
|
|
name: policyNameForApp(input.connection, entry),
|
|
description: `Ask first before running ${entry.toolName}.`,
|
|
enabled: true,
|
|
selectors: { catalogEntryId: entry.id },
|
|
config,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolPolicies.id, existing.id))
|
|
.returning();
|
|
results.push(toPolicy(updated));
|
|
} else {
|
|
const [created] = await dbClient
|
|
.insert(toolPolicies)
|
|
.values({
|
|
companyId: input.companyId,
|
|
name: policyNameForApp(input.connection, entry),
|
|
description: `Ask first before running ${entry.toolName}.`,
|
|
policyType: "require_approval",
|
|
priority: 50,
|
|
enabled: true,
|
|
selectors: { catalogEntryId: entry.id },
|
|
config,
|
|
createdByAgentId:
|
|
input.actor?.actorType === "agent"
|
|
? (input.actor.actorId ?? null)
|
|
: null,
|
|
createdByUserId:
|
|
input.actor?.actorType === "user"
|
|
? (input.actor.actorId ?? null)
|
|
: null,
|
|
})
|
|
.returning();
|
|
results.push(toPolicy(created));
|
|
}
|
|
}
|
|
if (input.disableStale !== false) {
|
|
const stalePolicies = managedPolicies.filter((policy) => {
|
|
const config = asRecord(policy.config);
|
|
return (
|
|
typeof config.catalogEntryId === "string" &&
|
|
!askFirstIds.has(config.catalogEntryId)
|
|
);
|
|
});
|
|
for (const policy of stalePolicies) {
|
|
await dbClient
|
|
.update(toolPolicies)
|
|
.set({ enabled: false, updatedAt: new Date() })
|
|
.where(eq(toolPolicies.id, policy.id));
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async function finishGalleryAppConnection(
|
|
companyId: string,
|
|
connectionId: string,
|
|
input: FinishToolApp,
|
|
actor?: ActorInfo,
|
|
): Promise<FinishToolAppResult> {
|
|
const connection = await getConnectionRow(connectionId, companyId);
|
|
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) => {
|
|
const [row] = await tx.update(toolConnections).set({ config, transportConfig: config, status: "active", enabled: true, 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: toolConnections.id })
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, connectionId),
|
|
eq(toolConnections.companyId, companyId),
|
|
),
|
|
)
|
|
.for("update");
|
|
const [existingProfile] = await tx
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(
|
|
and(
|
|
eq(toolProfiles.companyId, companyId),
|
|
eq(toolProfiles.profileKey, profileKey),
|
|
),
|
|
)
|
|
.limit(1);
|
|
let profileId: string;
|
|
if (existingProfile) {
|
|
if (input.preserveExistingAccess) {
|
|
const priorBindings = await tx
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(eq(toolProfileBindings.profileId, existingProfile.id));
|
|
for (const prior of priorBindings)
|
|
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: connection.name,
|
|
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: connection.name,
|
|
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 existingInstalls = await tx.select().from(toolConnectionInstalls).where(and(
|
|
eq(toolConnectionInstalls.companyId, companyId), eq(toolConnectionInstalls.connectionId, connection.id),
|
|
));
|
|
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: actor?.actorType === "user" ? (actor.actorId ?? null) : null,
|
|
})));
|
|
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:
|
|
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
|
|
})
|
|
.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:
|
|
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
|
|
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:
|
|
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
|
|
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, 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 };
|
|
});
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve the one user identity a personal-only connection is allowed to
|
|
* refresh. The connection creator is authoritative for new rows; retained
|
|
* grants cover older or agent-created rows. Reconnect may rotate that
|
|
* identity's credential, but it may never create a different user's identity
|
|
* on the same connection.
|
|
*/
|
|
async function fixedPersonalIdentityForReconnect(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
requestedSubjectUserId: string | undefined,
|
|
actor?: ActorInfo,
|
|
): Promise<{
|
|
subjectUserId: string;
|
|
grant: typeof connectionGrants.$inferSelect | null;
|
|
} | null> {
|
|
if (connection.credentialPolicy !== "per_user") return null;
|
|
|
|
const personalGrants = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "user"),
|
|
),
|
|
)
|
|
.orderBy(desc(connectionGrants.updatedAt));
|
|
const creatorGrant = connection.createdByUserId
|
|
? (personalGrants.find(
|
|
(grant) => grant.subjectUserId === connection.createdByUserId,
|
|
) ?? null)
|
|
: null;
|
|
const retainedGrant =
|
|
creatorGrant ??
|
|
personalGrants.find((grant) => grant.credentialSecretRefs.length > 0) ??
|
|
personalGrants[0] ??
|
|
null;
|
|
const fixedSubjectUserId =
|
|
connection.createdByUserId ?? retainedGrant?.subjectUserId ?? null;
|
|
const binding = actorBinding(actor);
|
|
const actorUserId = binding.actorType === "user" ? binding.actorId : null;
|
|
const subjectUserId = requestedSubjectUserId ?? actorUserId;
|
|
|
|
if (!subjectUserId) {
|
|
throw forbidden(
|
|
"Reconnect this personal connection as the user it belongs to",
|
|
);
|
|
}
|
|
if (actorUserId && subjectUserId !== actorUserId) {
|
|
throw forbidden(
|
|
"Board users may only reconnect their own personal connection",
|
|
);
|
|
}
|
|
if (fixedSubjectUserId && subjectUserId !== fixedSubjectUserId) {
|
|
throw forbidden(
|
|
"Only the existing personal identity can reconnect this connection",
|
|
);
|
|
}
|
|
|
|
return {
|
|
subjectUserId,
|
|
grant:
|
|
personalGrants.find((grant) => grant.subjectUserId === subjectUserId) ??
|
|
null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
const connection = await getConnectionRow(connectionId, companyId);
|
|
assertSupportedConnection(connection);
|
|
await assertMcpAggregatorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey);
|
|
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 credentialFields = galleryEntry
|
|
? credentialFieldsFor(
|
|
galleryEntry,
|
|
connectionMethodForConnection(galleryEntry, connection).key,
|
|
)
|
|
: [
|
|
{
|
|
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("Paste a new key to reconnect this app");
|
|
|
|
const personalIdentity = await fixedPersonalIdentityForReconnect(
|
|
connection,
|
|
undefined,
|
|
actor,
|
|
);
|
|
const credentialSecretRefs = [
|
|
...(personalIdentity?.grant?.credentialSecretRefs ??
|
|
connection.credentialSecretRefs),
|
|
];
|
|
const credentialRefs: McpConnectionCredentialRef[] = [
|
|
...(connection.credentialRefs ?? []),
|
|
];
|
|
|
|
for (const field of providedFields) {
|
|
const value = input.credentialValues[field.configPath]!.trim();
|
|
const existing = credentialSecretRefs.find(
|
|
(ref) => ref.configPath === field.configPath,
|
|
);
|
|
if (existing) {
|
|
await secrets.rotate(
|
|
existing.secretId,
|
|
{ value },
|
|
actorForSecret(actor),
|
|
);
|
|
continue;
|
|
}
|
|
const secret = await secrets.create(
|
|
companyId,
|
|
{
|
|
name: `${connection.name} ${field.label} ${randomUUID().slice(0, 8)}`,
|
|
key: `tool_app.${randomUUID()}.${field.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`,
|
|
provider: "local_encrypted",
|
|
value,
|
|
description: `Credential for ${connection.name} (${field.configPath}).`,
|
|
},
|
|
actorForSecret(actor),
|
|
);
|
|
credentialSecretRefs.push({
|
|
secretId: secret.id,
|
|
versionSelector: "latest",
|
|
configPath: field.configPath,
|
|
required: field.required ?? true,
|
|
label: field.label,
|
|
});
|
|
if (field.placement === "header" && field.key) {
|
|
const nextCredentialRef = {
|
|
name: field.configPath,
|
|
secretId: secret.id,
|
|
version: "latest",
|
|
placement: "header",
|
|
key: field.key,
|
|
prefix: field.prefix ?? null,
|
|
} satisfies McpConnectionCredentialRef;
|
|
const existingCredentialRefIndex = credentialRefs.findIndex(
|
|
(ref) => ref.name === field.configPath,
|
|
);
|
|
if (existingCredentialRefIndex >= 0)
|
|
credentialRefs[existingCredentialRefIndex] = nextCredentialRef;
|
|
else credentialRefs.push(nextCredentialRef);
|
|
}
|
|
}
|
|
|
|
const updated = await db.transaction(async (tx) => {
|
|
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();
|
|
return nextConnection;
|
|
});
|
|
await syncCredentialBindings(
|
|
updated,
|
|
personalIdentity ? credentialSecretRefs : [],
|
|
);
|
|
const health = await checkConnectionHealth(updated.id, actor);
|
|
const refresh = await refreshCatalog(updated.id, actor, {
|
|
enableAllByDefault: true,
|
|
});
|
|
return { ...health, connection: refresh.connection };
|
|
}
|
|
|
|
async function startOAuth(
|
|
companyId: string,
|
|
connectionId: string,
|
|
input: {
|
|
redirectUri: string;
|
|
actor: ActorInfo;
|
|
subjectUserId?: string;
|
|
subjectAgentId?: string;
|
|
scopes?: string[];
|
|
returnTo?: string;
|
|
issueId?: string;
|
|
interactionId?: string;
|
|
},
|
|
): Promise<ToolOAuthStartResult> {
|
|
let connection = await getConnectionRow(connectionId, companyId);
|
|
assertSupportedConnection(connection);
|
|
await assertMcpAggregatorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey);
|
|
if (connection.status === "archived")
|
|
throw conflict("Archived app connections cannot start sign in");
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const galleryEntry = sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)
|
|
: null;
|
|
assertOAuthRedirectConstraints(galleryEntry, input.redirectUri);
|
|
const galleryMethod = galleryEntry
|
|
? connectionMethodForConnection(galleryEntry, connection)
|
|
: null;
|
|
const requestedScopes = (() => {
|
|
if (!galleryMethod) return input.scopes ?? null;
|
|
const allowed = normalizeOauthScopes(galleryMethod.defaults?.scopesHint);
|
|
if (!input.scopes) return allowed;
|
|
const requested = normalizeOauthScopes(input.scopes);
|
|
const widened = requested.filter((scope) => !allowed.includes(scope));
|
|
if (widened.length > 0) {
|
|
throw badRequest(
|
|
`Requested OAuth scopes are not allowed for ${galleryEntry?.name ?? "this app"}`,
|
|
{
|
|
code: "oauth_scope_widening_rejected",
|
|
scopes: widened,
|
|
},
|
|
);
|
|
}
|
|
return requested;
|
|
})();
|
|
const starterBinding = actorBinding(input.actor);
|
|
const fixedPersonalIdentity = await fixedPersonalIdentityForReconnect(
|
|
connection,
|
|
input.subjectUserId,
|
|
input.actor,
|
|
);
|
|
const authorizationSubjectUserId =
|
|
fixedPersonalIdentity?.subjectUserId ?? input.subjectUserId;
|
|
const authorizationSubjectAgentId = input.subjectAgentId;
|
|
if (authorizationSubjectAgentId) {
|
|
const [subjectAgent] = await db
|
|
.select({ id: agents.id })
|
|
.from(agents)
|
|
.where(
|
|
and(
|
|
eq(agents.id, authorizationSubjectAgentId),
|
|
eq(agents.companyId, companyId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!subjectAgent)
|
|
throw badRequest(
|
|
"Dedicated identity requires an agent in this company",
|
|
);
|
|
if (connection.credentialPolicy !== "per_agent") {
|
|
throw badRequest(
|
|
"This connection is not configured for a dedicated agent identity",
|
|
);
|
|
}
|
|
}
|
|
const intentLink = input.interactionId
|
|
? await db
|
|
.select({
|
|
id: issueThreadInteractions.id,
|
|
issueId: issueThreadInteractions.issueId,
|
|
addresseeUserId: issueThreadInteractions.addresseeUserId,
|
|
kind: issueThreadInteractions.kind,
|
|
status: issueThreadInteractions.status,
|
|
})
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, input.interactionId),
|
|
eq(issueThreadInteractions.companyId, companyId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null)
|
|
: null;
|
|
if (
|
|
input.interactionId &&
|
|
(!intentLink ||
|
|
intentLink.kind !== "connection_intent" ||
|
|
intentLink.status !== "pending" ||
|
|
starterBinding.actorType !== "user" ||
|
|
starterBinding.actorId !== intentLink.addresseeUserId)
|
|
) {
|
|
throw forbidden(
|
|
"Only the addressed user can authorize this connection request",
|
|
);
|
|
}
|
|
if (connection.credentialSource === "vercel_connect") {
|
|
if (!vercelConnect)
|
|
throw vercelConnectHttpError(
|
|
new VercelConnectClientError("vercel_connect_unavailable", 503),
|
|
);
|
|
const credential = vercelCredentialFor(connection);
|
|
if (
|
|
connection.authKind !== "oauth" ||
|
|
credential.principalMode !== "user"
|
|
) {
|
|
throw badRequest(
|
|
"This Vercel connector does not use browser authorization",
|
|
);
|
|
}
|
|
const binding = starterBinding;
|
|
if (!binding.actorType || !binding.actorId) {
|
|
throw forbidden(
|
|
"Vercel Connect authorization requires an authenticated actor",
|
|
);
|
|
}
|
|
const grantKind: ConnectionGrantKind = authorizationSubjectUserId
|
|
? "user"
|
|
: "organization";
|
|
const derived = deriveVercelConnectSubject({
|
|
credential,
|
|
connectionId: connection.id,
|
|
companyId,
|
|
grantKind,
|
|
subjectUserId: authorizationSubjectUserId,
|
|
});
|
|
const state = randomOauthToken();
|
|
let authorization;
|
|
try {
|
|
authorization = await vercelConnect.startAuthorization(
|
|
{
|
|
connector: credential.connectorUid,
|
|
subject: derived.subject,
|
|
scopes: credential.scopes,
|
|
resources: vercelConnectResourcesFor(connection),
|
|
},
|
|
vercelConnectCallbackUrl(input.redirectUri, state),
|
|
);
|
|
} catch (error) {
|
|
throw vercelConnectHttpError(error);
|
|
}
|
|
const remoteExpiry = authorization.expiresAt
|
|
? new Date(authorization.expiresAt)
|
|
: null;
|
|
const expiresAt =
|
|
remoteExpiry && Number.isFinite(remoteExpiry.getTime())
|
|
? new Date(
|
|
Math.min(
|
|
remoteExpiry.getTime(),
|
|
now().getTime() + 10 * 60 * 1000,
|
|
),
|
|
)
|
|
: new Date(now().getTime() + 10 * 60 * 1000);
|
|
await db
|
|
.delete(toolOauthStates)
|
|
.where(lt(toolOauthStates.expiresAt, now()));
|
|
await db.insert(toolOauthStates).values({
|
|
state,
|
|
companyId,
|
|
connectionId: connection.id,
|
|
// Vercel owns its verifier. This marker only dispatches the consumed
|
|
// one-time state to the Vercel callback completion path.
|
|
codeVerifier: "vercel-connect",
|
|
createdByActorType: binding.actorType,
|
|
createdByActorId: binding.actorId,
|
|
createdBySessionId: binding.sessionId,
|
|
subjectUserId: authorizationSubjectUserId,
|
|
requestedScopes: [...credential.scopes],
|
|
returnTo: input.returnTo,
|
|
issueId: intentLink?.issueId ?? input.issueId,
|
|
interactionId: intentLink?.id,
|
|
expiresAt,
|
|
});
|
|
return {
|
|
connectionId: connection.id,
|
|
provider: `vercel-connect:${credential.service}`,
|
|
authorizationUrl: authorization.url,
|
|
expiresAt: expiresAt.toISOString(),
|
|
issuer: "https://vercel.com/connect",
|
|
resource: galleryMethod?.defaults?.serverUrl ?? null,
|
|
registrationSource: null,
|
|
};
|
|
}
|
|
if (
|
|
galleryMethod &&
|
|
isPaperclipCloudConnectorStrategy(galleryMethod.oauthStrategy)
|
|
) {
|
|
const connectorProfile = galleryMethod.connectorProfile;
|
|
const managedProfile = managedConnectorProfile(connectorProfile);
|
|
if (!managedProfile)
|
|
throw badRequest("This app has an invalid managed connector profile");
|
|
const providerName = galleryEntry?.name ?? "Google Workspace";
|
|
const cloudConnector = currentCloudConnector();
|
|
if (!cloudConnector) {
|
|
throw unprocessable(
|
|
`${providerName} connections through Paperclip are not available on this instance yet`,
|
|
{
|
|
code: "paperclip_cloud_connector_unavailable",
|
|
},
|
|
);
|
|
}
|
|
const binding = starterBinding;
|
|
if (!binding.actorType || !binding.actorId) {
|
|
throw forbidden(
|
|
`${providerName} sign-in requires an authenticated actor`,
|
|
);
|
|
}
|
|
const subjectUserId = authorizationSubjectAgentId
|
|
? null
|
|
: (authorizationSubjectUserId ??
|
|
(binding.actorType === "user" ? binding.actorId : null));
|
|
if (!subjectUserId && !authorizationSubjectAgentId) {
|
|
throw forbidden(
|
|
`Agent-started ${providerName} sign-in requires an authorized identity`,
|
|
);
|
|
}
|
|
if (
|
|
!authorizationSubjectAgentId &&
|
|
binding.actorType === "user" &&
|
|
subjectUserId !== binding.actorId
|
|
) {
|
|
throw forbidden(
|
|
`Board users may only authorize their own ${providerName} identity`,
|
|
);
|
|
}
|
|
await db
|
|
.delete(toolOauthStates)
|
|
.where(lt(toolOauthStates.expiresAt, now()));
|
|
const state = randomOauthToken();
|
|
const returnUri = new URL(input.redirectUri);
|
|
returnUri.pathname = "/api/tools/oauth/cloud-connector/callback";
|
|
returnUri.search = "";
|
|
returnUri.hash = "";
|
|
const session = await cloudConnector.startAuthorization({
|
|
subject: authorizationSubjectAgentId
|
|
? `agent:${authorizationSubjectAgentId}`
|
|
: subjectUserId!,
|
|
companyId,
|
|
profile: managedProfile.id,
|
|
returnUri: returnUri.toString(),
|
|
returnState: state,
|
|
});
|
|
const remoteExpiry = new Date(session.expiresAt);
|
|
const expiresAt = Number.isFinite(remoteExpiry.getTime())
|
|
? new Date(
|
|
Math.min(remoteExpiry.getTime(), now().getTime() + 10 * 60 * 1000),
|
|
)
|
|
: new Date(now().getTime() + 10 * 60 * 1000);
|
|
await db.insert(toolOauthStates).values({
|
|
state,
|
|
companyId,
|
|
connectionId: connection.id,
|
|
// Paperclip Cloud owns PKCE for this flow. The local state row remains the
|
|
// single-use browser correlator and never stores broker token material.
|
|
codeVerifier: "paperclip-cloud-connector",
|
|
createdByActorType: binding.actorType,
|
|
createdByActorId: binding.actorId,
|
|
createdBySessionId: binding.sessionId,
|
|
subjectUserId,
|
|
subjectAgentId: authorizationSubjectAgentId,
|
|
requestedScopes: [...managedProfile.scopes],
|
|
returnTo: input.returnTo,
|
|
issueId: intentLink?.issueId ?? input.issueId,
|
|
interactionId: intentLink?.id,
|
|
expiresAt,
|
|
});
|
|
return {
|
|
connectionId: connection.id,
|
|
provider: managedProfile.provider,
|
|
authorizationUrl: session.authorizationUrl,
|
|
expiresAt: expiresAt.toISOString(),
|
|
...(session.handoff ? { handoff: session.handoff } : {}),
|
|
issuer:
|
|
managedProfile.provider === "github"
|
|
? "https://github.com"
|
|
: "https://accounts.google.com",
|
|
resource: galleryMethod.defaults?.serverUrl ?? null,
|
|
registrationSource: null,
|
|
};
|
|
}
|
|
const endpoints = await oauthEndpointsForConnection(
|
|
connection,
|
|
null,
|
|
input.redirectUri,
|
|
);
|
|
if (endpoints.grantType === "client_credentials") {
|
|
throw unprocessable(
|
|
"This app uses shared machine credentials and does not need browser sign in",
|
|
);
|
|
}
|
|
const resolvedClient = await ensureOAuthClient({
|
|
connection,
|
|
endpoints,
|
|
redirectUri: input.redirectUri,
|
|
galleryEntry,
|
|
actor: input.actor,
|
|
});
|
|
connection = resolvedClient.connection;
|
|
const client = resolvedClient.client;
|
|
if (!client.clientId)
|
|
throw unprocessable(
|
|
`OAuth client id is not configured for ${endpoints.provider}`,
|
|
);
|
|
|
|
await db
|
|
.delete(toolOauthStates)
|
|
.where(lt(toolOauthStates.expiresAt, new Date()));
|
|
|
|
const state = randomOauthToken();
|
|
const codeVerifier = randomOauthToken(48);
|
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
|
const binding = starterBinding;
|
|
if (!binding.actorType || !binding.actorId) {
|
|
throw forbidden("OAuth sign-in requires an authenticated board session");
|
|
}
|
|
await db.insert(toolOauthStates).values({
|
|
state,
|
|
companyId,
|
|
connectionId: connection.id,
|
|
codeVerifier,
|
|
createdByActorType: binding.actorType,
|
|
createdByActorId: binding.actorId,
|
|
createdBySessionId: binding.sessionId,
|
|
subjectUserId: authorizationSubjectUserId,
|
|
requestedScopes: requestedScopes ?? undefined,
|
|
returnTo: input.returnTo,
|
|
issueId: intentLink?.issueId ?? input.issueId,
|
|
interactionId: intentLink?.id,
|
|
expiresAt,
|
|
});
|
|
|
|
// Last gate before this URL becomes a top-level browser navigation. Every
|
|
// producer above already validates, so reaching a rejection here means a new
|
|
// path was added without one — fail closed rather than hand the board an
|
|
// unvetted target.
|
|
const authorizationUrl = new URL(
|
|
assertOAuthEndpointUrl("authorization", endpoints.authorizationUrl, {
|
|
// Paperclip's own callback origin: a first-party authorization endpoint is
|
|
// served however this deployment is served, plaintext LAN host included.
|
|
firstPartyOrigin: originOf(input.redirectUri),
|
|
}),
|
|
);
|
|
authorizationUrl.searchParams.set("response_type", "code");
|
|
authorizationUrl.searchParams.set("client_id", client.clientId);
|
|
authorizationUrl.searchParams.set("redirect_uri", input.redirectUri);
|
|
authorizationUrl.searchParams.set("state", state);
|
|
authorizationUrl.searchParams.set(
|
|
"code_challenge",
|
|
base64UrlSha256(codeVerifier),
|
|
);
|
|
authorizationUrl.searchParams.set("code_challenge_method", "S256");
|
|
// RFC 8707: name the MCP server the resulting token is for, so an
|
|
// authorization server that serves several resources can audience-restrict it.
|
|
if (endpoints.resource)
|
|
authorizationUrl.searchParams.set("resource", endpoints.resource);
|
|
// Curated definitions are an allowlist, not a suggestion. Never copy every
|
|
// scope advertised by discovery into a provider consent screen: a curated
|
|
// method either sends its reviewed hint or omits scope entirely. Generic
|
|
// MCP URLs retain discovery-first behavior because Paperclip has no manifest
|
|
// against which it could safely judge the caller's requested scope.
|
|
const authorizationScopes = galleryMethod
|
|
? (requestedScopes ?? [])
|
|
: (input.scopes ?? endpoints.scopes);
|
|
if (authorizationScopes.length > 0)
|
|
authorizationUrl.searchParams.set("scope", authorizationScopes.join(" "));
|
|
const reviewedAuthorizationParams =
|
|
galleryMethod?.defaults?.oauthAuthorizationParams;
|
|
if (reviewedAuthorizationParams?.access_type)
|
|
authorizationUrl.searchParams.set(
|
|
"access_type",
|
|
reviewedAuthorizationParams.access_type,
|
|
);
|
|
if (reviewedAuthorizationParams?.prompt)
|
|
authorizationUrl.searchParams.set(
|
|
"prompt",
|
|
reviewedAuthorizationParams.prompt,
|
|
);
|
|
|
|
if (
|
|
authorizationSubjectUserId &&
|
|
input.issueId &&
|
|
binding.actorType === "agent"
|
|
) {
|
|
const idempotencyKey = `connection-authorization:${connection.id}:${authorizationSubjectUserId}`;
|
|
// Provider label for the card's copy. The gallery definition's name when we
|
|
// have one, else the connection's own name — never a secret name or ref.
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const providerName =
|
|
(sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)?.name
|
|
: null) ?? connection.name;
|
|
const [requestingAgent] = binding.actorId
|
|
? await db
|
|
.select({ name: agents.name })
|
|
.from(agents)
|
|
.where(
|
|
and(
|
|
eq(agents.id, binding.actorId),
|
|
eq(agents.companyId, companyId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
: [undefined];
|
|
const payload = {
|
|
version: 1 as const,
|
|
prompt: `Connect your ${providerName} to continue`,
|
|
acceptLabel: `Connect ${providerName}`,
|
|
rejectLabel: "Not now",
|
|
detailsMarkdown:
|
|
"Authorization is required before this agent can act on your behalf.",
|
|
// Presentation metadata so the card can compose its own copy instead of
|
|
// parsing the title string (PAP-17835 seam #6). The interaction kind and
|
|
// the server-addressed audience are unchanged.
|
|
connectionAuthorization: {
|
|
version: 1 as const,
|
|
providerName,
|
|
connectionName:
|
|
connection.name === providerName ? null : connection.name,
|
|
requestingAgentName: requestingAgent?.name ?? null,
|
|
},
|
|
target: {
|
|
type: "custom" as const,
|
|
key: `connection:${connection.uid}:user:${authorizationSubjectUserId}`,
|
|
revisionId: state,
|
|
label: `Connect ${providerName}`,
|
|
href: authorizationUrl.toString(),
|
|
},
|
|
};
|
|
const [existingInteraction] = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.companyId, companyId),
|
|
eq(issueThreadInteractions.issueId, input.issueId),
|
|
eq(issueThreadInteractions.idempotencyKey, idempotencyKey),
|
|
),
|
|
)
|
|
.limit(1);
|
|
const [interaction] = existingInteraction
|
|
? await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "pending",
|
|
requestedResolverPolicy: "human_only",
|
|
effectiveResolverPolicy: "human_only",
|
|
resolverPolicyProvenance: "explicit",
|
|
effectiveResolverPolicySource: "requested",
|
|
addresseeUserId: authorizationSubjectUserId,
|
|
payload,
|
|
result: null,
|
|
resolvedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issueThreadInteractions.id, existingInteraction.id))
|
|
.returning()
|
|
: await db
|
|
.insert(issueThreadInteractions)
|
|
.values({
|
|
companyId,
|
|
issueId: input.issueId,
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "none",
|
|
requestedResolverPolicy: "human_only",
|
|
effectiveResolverPolicy: "human_only",
|
|
resolverPolicyProvenance: "explicit",
|
|
effectiveResolverPolicySource: "requested",
|
|
addresseeUserId: authorizationSubjectUserId,
|
|
idempotencyKey,
|
|
sourceRunId:
|
|
binding.actorType === "agent"
|
|
? (input.actor.sessionId ?? null)
|
|
: null,
|
|
title: `Connect your ${providerName} to continue`,
|
|
summary: `${requestingAgent?.name ?? "An agent"} needs your ${providerName} identity for work running as you.`,
|
|
createdByAgentId:
|
|
binding.actorType === "agent" ? binding.actorId : null,
|
|
payload,
|
|
})
|
|
.returning();
|
|
if (interaction) {
|
|
await db
|
|
.update(toolOauthStates)
|
|
.set({ interactionId: interaction.id })
|
|
.where(eq(toolOauthStates.state, state));
|
|
}
|
|
}
|
|
|
|
const nextConfig = {
|
|
...connection.config,
|
|
oauth: {
|
|
...oauthConfig(connection),
|
|
provider: endpoints.provider,
|
|
authorizationUrl: endpoints.authorizationUrl,
|
|
tokenUrl: endpoints.tokenUrl,
|
|
registrationUrl: endpoints.registrationUrl ?? null,
|
|
metadataUrl: endpoints.metadataUrl ?? null,
|
|
// Curated apps persist only the reviewed scopes attached to this OAuth
|
|
// state. Discovery metadata can advertise a provider's entire scope
|
|
// universe and must never silently become Paperclip's requested set.
|
|
scopes: galleryMethod ? (requestedScopes ?? []) : endpoints.scopes,
|
|
codeChallengeMethodsSupported:
|
|
endpoints.codeChallengeMethodsSupported ?? [],
|
|
tokenEndpointAuthMethodsSupported:
|
|
endpoints.tokenEndpointAuthMethodsSupported ?? [],
|
|
grantType: "authorization_code",
|
|
clientIdEnv: client.clientIdEnv,
|
|
clientSecretEnv: client.clientSecret ? client.clientSecretEnv : null,
|
|
credentialScope: credentialScope(connection, input.actor),
|
|
// Persist the issuer and resource this authorization run is bound to.
|
|
// `iss` on the callback is validated against `expectedIssuer`, and
|
|
// refresh/reconnect/revoke reuse the same pair rather than re-deriving it.
|
|
issuer: endpoints.issuer ?? oauthConfig(connection).issuer ?? null,
|
|
expectedIssuer: endpoints.issuer ?? null,
|
|
resource:
|
|
endpoints.resource ?? oauthConfig(connection).resource ?? null,
|
|
clientIdMetadataDocumentSupported:
|
|
endpoints.clientIdMetadataDocumentSupported === true,
|
|
},
|
|
};
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
// A generic URL connection starts life as `authKind: none`. Once it has
|
|
// completed OAuth discovery and client resolution it is an OAuth
|
|
// connection, and refresh, reconnect, revoke and diagnostics must all
|
|
// treat it as one.
|
|
authKind: "oauth",
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, connection.id));
|
|
|
|
return {
|
|
connectionId: connection.id,
|
|
provider: endpoints.provider,
|
|
authorizationUrl: authorizationUrl.toString(),
|
|
expiresAt: expiresAt.toISOString(),
|
|
issuer: endpoints.issuer ?? null,
|
|
resource: endpoints.resource ?? null,
|
|
registrationSource: resolvedClient.source,
|
|
};
|
|
}
|
|
|
|
async function peekOAuthState(state: string) {
|
|
const [row] = await db
|
|
.select({
|
|
companyId: toolOauthStates.companyId,
|
|
connectionId: toolOauthStates.connectionId,
|
|
subjectUserId: toolOauthStates.subjectUserId,
|
|
subjectAgentId: toolOauthStates.subjectAgentId,
|
|
returnTo: toolOauthStates.returnTo,
|
|
issueId: toolOauthStates.issueId,
|
|
interactionId: toolOauthStates.interactionId,
|
|
})
|
|
.from(toolOauthStates)
|
|
.where(eq(toolOauthStates.state, state))
|
|
.limit(1);
|
|
return row ?? null;
|
|
}
|
|
|
|
/** Bind a callback to its initiating actor without consuming retryable state. */
|
|
async function validateOAuthState(
|
|
state: string,
|
|
actor: ActorInfo | undefined,
|
|
) {
|
|
const [stateRow] = await db
|
|
.select()
|
|
.from(toolOauthStates)
|
|
.where(eq(toolOauthStates.state, state))
|
|
.limit(1);
|
|
if (!stateRow)
|
|
throw badRequest("OAuth state was not found or has already been used");
|
|
if (stateRow.expiresAt.getTime() <= Date.now())
|
|
throw badRequest("OAuth state has expired");
|
|
if (stateRow.subjectUserId) {
|
|
if (
|
|
actor?.actorType !== "user" ||
|
|
actor.actorId !== stateRow.subjectUserId
|
|
) {
|
|
throw forbidden(
|
|
"OAuth callback user does not match the requested subject",
|
|
);
|
|
}
|
|
} else {
|
|
assertSameOAuthActor(stateRow, actor);
|
|
}
|
|
return stateRow;
|
|
}
|
|
|
|
/**
|
|
* Answer a terminal authorization callback exactly once. Actor validation
|
|
* happens before the atomic delete, so an unbound callback cannot consume a
|
|
* valid flow and concurrent callbacks cannot both complete it.
|
|
*/
|
|
async function consumeOAuthState(
|
|
state: string,
|
|
actor: ActorInfo | undefined,
|
|
) {
|
|
const stateRow = await validateOAuthState(state, actor);
|
|
const [consumed] = await db
|
|
.delete(toolOauthStates)
|
|
.where(eq(toolOauthStates.state, state))
|
|
.returning();
|
|
if (!consumed)
|
|
throw badRequest("OAuth state was not found or has already been used");
|
|
return consumed;
|
|
}
|
|
|
|
/**
|
|
* End the board's "Connect your account" prompt when the user declines in the
|
|
* provider's window (PAP-17109). Without this the card stays `pending`, so the
|
|
* board keeps offering an authorization link for a flow the user just refused
|
|
* and the requesting agent never learns the answer.
|
|
*
|
|
* Scoped to a still-`pending` row: a board user who already answered the card
|
|
* directly keeps their own answer.
|
|
*/
|
|
async function rejectPendingOAuthInteraction(
|
|
stateRow: typeof toolOauthStates.$inferSelect,
|
|
actor: ActorInfo | undefined,
|
|
) {
|
|
if (!stateRow.interactionId) return;
|
|
const now = new Date();
|
|
const linked = await db
|
|
.select({
|
|
kind: issueThreadInteractions.kind,
|
|
payload: issueThreadInteractions.payload,
|
|
})
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, stateRow.interactionId),
|
|
eq(issueThreadInteractions.companyId, stateRow.companyId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (linked?.kind === "connection_intent") {
|
|
const connectionIntentPayload = connectionIntentPayloadSchema.parse(
|
|
linked.payload,
|
|
);
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
payload: { ...connectionIntentPayload, phase: "needs_retry" },
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, stateRow.interactionId),
|
|
eq(issueThreadInteractions.companyId, stateRow.companyId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "rejected",
|
|
result: {
|
|
version: 1,
|
|
outcome: "rejected",
|
|
// Paperclip's own words: the provider's explanation is untrusted and
|
|
// this reason is rendered in the thread (PAP-17108).
|
|
reason:
|
|
"Authorization was declined or cancelled in the provider's window",
|
|
},
|
|
resolvedByUserId: actor?.actorType === "user" ? actor.actorId : null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, stateRow.interactionId),
|
|
eq(issueThreadInteractions.companyId, stateRow.companyId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function finishOAuthCatalogWithRecommendedDefaults(input: {
|
|
connection: typeof toolConnections.$inferSelect;
|
|
catalog: ToolCatalogEntry[];
|
|
suggestedDefaults: ConnectToolAppResult["suggestedDefaults"];
|
|
activateQuarantined?: boolean;
|
|
actor?: ActorInfo;
|
|
interactionId?: string | null;
|
|
}) {
|
|
const linkedInteraction = input.interactionId
|
|
? await db
|
|
.select({ kind: issueThreadInteractions.kind })
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, input.interactionId),
|
|
eq(issueThreadInteractions.companyId, input.connection.companyId),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null)
|
|
: null;
|
|
// A task callback only prepares the catalog. The intent completion transaction
|
|
// validates current ownership and adds the requesting agent's access.
|
|
const deferTaskAccess = linkedInteraction?.kind === "connection_intent";
|
|
const installs = await db
|
|
.select()
|
|
.from(toolConnectionInstalls)
|
|
.where(
|
|
and(
|
|
eq(toolConnectionInstalls.companyId, input.connection.companyId),
|
|
eq(toolConnectionInstalls.connectionId, input.connection.id),
|
|
),
|
|
);
|
|
const companyInstall = installs.some(
|
|
(install) => install.targetType === "company",
|
|
);
|
|
const agentIds = installs
|
|
.filter((install) => install.targetType === "agent")
|
|
.map((install) => install.targetId);
|
|
const suggestedAccess = input.suggestedDefaults.access;
|
|
const suggestedAccessRecord = asRecord(suggestedAccess);
|
|
const suggestedAgentIds = Array.isArray(suggestedAccessRecord.agentIds)
|
|
? suggestedAccessRecord.agentIds.filter(
|
|
(agentId): agentId is string => typeof agentId === "string",
|
|
)
|
|
: [];
|
|
const normalizedSuggestedAccess: FinishToolApp["access"] =
|
|
suggestedAccess === "all_agents"
|
|
? "all_agents"
|
|
: suggestedAgentIds.length > 0
|
|
? { agentIds: suggestedAgentIds }
|
|
: "all_agents";
|
|
const remoteMcpAccess = isRemoteMcpConnectorMethod(input.connection.config.sourceTemplateKey, input.connection.config.connectionMethodKey);
|
|
const access: FinishToolApp["access"] = deferTaskAccess
|
|
? { agentIds: [] }
|
|
: installs.length === 0
|
|
? remoteMcpAccess ? { agentIds: [] } : normalizedSuggestedAccess
|
|
: companyInstall
|
|
? "all_agents"
|
|
: { agentIds };
|
|
const askFirstRiskLevels = new Set(
|
|
Array.isArray(input.suggestedDefaults.askFirstRiskLevels)
|
|
? input.suggestedDefaults.askFirstRiskLevels.filter(
|
|
(riskLevel): riskLevel is string => typeof riskLevel === "string",
|
|
)
|
|
: [],
|
|
);
|
|
const enabledCatalog = input.catalog.filter(
|
|
(entry) =>
|
|
entry.status === "active" ||
|
|
(input.activateQuarantined === true && entry.status === "quarantined"),
|
|
);
|
|
const finished = await finishGalleryAppConnection(
|
|
input.connection.companyId,
|
|
input.connection.id,
|
|
{
|
|
enabledCatalogEntryIds: enabledCatalog.map((entry) => entry.id),
|
|
askFirstCatalogEntryIds: enabledCatalog
|
|
.filter((entry) => askFirstRiskLevels.has(entry.riskLevel))
|
|
.map((entry) => entry.id),
|
|
reviewedCatalogEntryIds:
|
|
input.activateQuarantined === true
|
|
? enabledCatalog
|
|
.filter((entry) => entry.status === "quarantined")
|
|
.map((entry) => entry.id)
|
|
: undefined,
|
|
access,
|
|
preserveExistingAccess: deferTaskAccess,
|
|
},
|
|
input.actor,
|
|
);
|
|
if (!deferTaskAccess && !remoteMcpAccess && installs.length === 0) {
|
|
const installTargets =
|
|
access === "all_agents"
|
|
? [
|
|
{
|
|
targetType: "company" as const,
|
|
targetId: input.connection.companyId,
|
|
},
|
|
]
|
|
: [...new Set(access.agentIds)].map((agentId) => ({
|
|
targetType: "agent" as const,
|
|
targetId: agentId,
|
|
}));
|
|
if (installTargets.length > 0) {
|
|
await db
|
|
.insert(toolConnectionInstalls)
|
|
.values(
|
|
installTargets.map((target) => ({
|
|
companyId: input.connection.companyId,
|
|
connectionId: input.connection.id,
|
|
...target,
|
|
createdByAgentId:
|
|
input.actor?.actorType === "agent"
|
|
? (input.actor.actorId ?? null)
|
|
: null,
|
|
createdByUserId:
|
|
input.actor?.actorType === "user"
|
|
? (input.actor.actorId ?? null)
|
|
: null,
|
|
})),
|
|
)
|
|
.onConflictDoNothing();
|
|
}
|
|
}
|
|
return finished;
|
|
}
|
|
|
|
async function completePaperclipCloudConnectorCallback(input: {
|
|
state: string;
|
|
claimId?: string | null;
|
|
error?: string | null;
|
|
actor?: ActorInfo;
|
|
}): Promise<ConnectToolAppResult> {
|
|
// Keep the local state live until the sealed claim is in the durable vault.
|
|
// The broker binds repeat claim requests to this stable state value, so a
|
|
// transient broker, database, or secret-store failure can retry safely.
|
|
const stateRow = await validateOAuthState(input.state, input.actor);
|
|
let connection = await getConnectionRow(
|
|
stateRow.connectionId,
|
|
stateRow.companyId,
|
|
);
|
|
// The connection lifecycle, not the incidental presence of its app profile,
|
|
// distinguishes setup from reauthorization. New connections and connections
|
|
// revived after removal are drafts until this callback completes. A profile
|
|
// may legitimately survive removal because an MCP gateway retains it, or be
|
|
// intentionally archived on an otherwise active connection; neither case
|
|
// should invert whether recommended defaults are rebuilt.
|
|
const shouldFinalizeManagedDefaults = connection.status === "draft";
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const galleryEntry = sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)
|
|
: null;
|
|
const method = galleryEntry
|
|
? connectionMethodForConnection(galleryEntry, connection)
|
|
: null;
|
|
const providerName = galleryEntry?.name ?? "Google Workspace";
|
|
if (input.error) {
|
|
const consumedState = await consumeOAuthState(input.state, input.actor);
|
|
await rejectPendingOAuthInteraction(consumedState, input.actor);
|
|
throw new HttpError(
|
|
400,
|
|
`${providerName} authorization did not complete. Start a new ${providerName} connection to try again.`,
|
|
{
|
|
code:
|
|
input.error === "access_denied"
|
|
? "oauth_authorization_denied"
|
|
: "paperclip_cloud_connector_failed",
|
|
},
|
|
);
|
|
}
|
|
if (!input.claimId)
|
|
throw badRequest(
|
|
`${providerName} callback is missing a claim identifier`,
|
|
);
|
|
const cloudConnector = currentCloudConnector();
|
|
if (!cloudConnector) {
|
|
throw unprocessable(
|
|
`${providerName} connections through Paperclip are not available on this instance yet`,
|
|
{
|
|
code: "paperclip_cloud_connector_unavailable",
|
|
},
|
|
);
|
|
}
|
|
const subjectUserId = stateRow.subjectUserId;
|
|
const subjectAgentId = stateRow.subjectAgentId;
|
|
if (
|
|
!method ||
|
|
!isPaperclipCloudConnectorStrategy(method.oauthStrategy) ||
|
|
(!subjectUserId && !subjectAgentId)
|
|
) {
|
|
throw badRequest(
|
|
"OAuth state does not belong to a managed connector flow",
|
|
);
|
|
}
|
|
const connectorProfile = method.connectorProfile;
|
|
const profile = managedConnectorProfile(connectorProfile);
|
|
if (!profile) throw badRequest("Managed connector profile is invalid");
|
|
const connectorSubject = subjectAgentId
|
|
? `agent:${subjectAgentId}`
|
|
: subjectUserId!;
|
|
const credentials = await cloudConnector.claim({
|
|
subject: connectorSubject,
|
|
companyId: stateRow.companyId,
|
|
profile: profile.id,
|
|
claimId: input.claimId,
|
|
redemptionId: input.state,
|
|
});
|
|
const refreshToken = credentials.refreshToken;
|
|
if (profile.provider === "google" && !refreshToken) {
|
|
throw unprocessable(
|
|
`Google did not return offline access. Reconnect ${providerName} and grant the requested scopes.`,
|
|
{
|
|
code: "oauth_refresh_missing",
|
|
},
|
|
);
|
|
}
|
|
const githubMetadata =
|
|
profile.provider === "github"
|
|
? await loadGitHubGrantMetadata(
|
|
credentials.accessToken,
|
|
fetch,
|
|
credentials.appSlug,
|
|
)
|
|
: null;
|
|
const authorizingUserId =
|
|
subjectUserId ??
|
|
(stateRow.createdByActorType === "user"
|
|
? stateRow.createdByActorId
|
|
: null);
|
|
if (!authorizingUserId)
|
|
throw forbidden(
|
|
`A signed-in connection manager must authorize ${providerName}`,
|
|
);
|
|
|
|
await db.transaction(async (tx) => {
|
|
// Keep connector credential persistence serialized with membership
|
|
// suspension, downgrade, and removal. A successful claim is only durable
|
|
// while the initiating user still holds connection-management authority.
|
|
const [membership] = await tx
|
|
.select({
|
|
id: companyMemberships.id,
|
|
membershipRole: companyMemberships.membershipRole,
|
|
})
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, connection.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, authorizingUserId),
|
|
eq(companyMemberships.status, "active"),
|
|
ne(companyMemberships.membershipRole, "viewer"),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.for("update");
|
|
if (!membership) {
|
|
throw forbidden(
|
|
`Your company membership no longer permits connection changes. Restore non-viewer access before you connect ${providerName} again.`,
|
|
);
|
|
}
|
|
if (subjectAgentId) {
|
|
const roleCanManage =
|
|
membership.membershipRole === "owner" ||
|
|
membership.membershipRole === "admin";
|
|
const [explicitManagerGrant] = roleCanManage
|
|
? []
|
|
: await tx
|
|
.select({
|
|
id: principalPermissionGrants.id,
|
|
})
|
|
.from(principalPermissionGrants)
|
|
.where(
|
|
and(
|
|
eq(principalPermissionGrants.companyId, connection.companyId),
|
|
eq(principalPermissionGrants.principalType, "user"),
|
|
eq(principalPermissionGrants.principalId, authorizingUserId),
|
|
eq(
|
|
principalPermissionGrants.permissionKey,
|
|
"tools:manage_connections",
|
|
),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.for("update");
|
|
if (!roleCanManage && !explicitManagerGrant) {
|
|
throw forbidden(
|
|
"Only a connection manager can authorize a dedicated agent identity.",
|
|
);
|
|
}
|
|
}
|
|
const [consumedState] = await tx
|
|
.delete(toolOauthStates)
|
|
.where(
|
|
and(
|
|
eq(toolOauthStates.state, input.state),
|
|
gte(toolOauthStates.expiresAt, new Date()),
|
|
),
|
|
)
|
|
.returning({ state: toolOauthStates.state });
|
|
if (!consumedState)
|
|
throw badRequest(
|
|
"OAuth state was not found, expired, or has already been used",
|
|
);
|
|
const txSecrets = secretService(tx);
|
|
const txSecretContext = { dbClient: tx, secretClient: txSecrets };
|
|
const personalCredential = connection.credentialPolicy === "per_user";
|
|
const agentCredential = connection.credentialPolicy === "per_agent";
|
|
const grantKind: ConnectionGrantKind = agentCredential
|
|
? "agent"
|
|
: personalCredential
|
|
? "user"
|
|
: "organization";
|
|
const [existingCredentialGrant] = await tx
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, grantKind),
|
|
agentCredential
|
|
? eq(connectionGrants.subjectAgentId, subjectAgentId!)
|
|
: personalCredential
|
|
? eq(connectionGrants.subjectUserId, subjectUserId!)
|
|
: eq(connectionGrants.isDefault, true),
|
|
),
|
|
)
|
|
.limit(1);
|
|
const existingRefs =
|
|
existingCredentialGrant?.credentialSecretRefs ??
|
|
(personalCredential || agentCredential
|
|
? []
|
|
: connection.credentialSecretRefs);
|
|
const accessRef = await createOrRotateOAuthSecret(
|
|
{
|
|
companyId: connection.companyId,
|
|
connection,
|
|
configPath: "oauth.access_token",
|
|
label: `${providerName} access token`,
|
|
value: credentials.accessToken,
|
|
actor: input.actor,
|
|
existingRefs,
|
|
ownerUserId: personalCredential ? subjectUserId! : undefined,
|
|
},
|
|
txSecretContext,
|
|
);
|
|
const refreshRef = refreshToken
|
|
? await createOrRotateOAuthSecret(
|
|
{
|
|
companyId: connection.companyId,
|
|
connection,
|
|
configPath: "oauth.refresh_token",
|
|
label: `${providerName} refresh token`,
|
|
value: refreshToken,
|
|
actor: input.actor,
|
|
existingRefs,
|
|
ownerUserId: personalCredential ? subjectUserId! : undefined,
|
|
},
|
|
txSecretContext,
|
|
)
|
|
: null;
|
|
const credentialSecretRefs = [
|
|
...existingRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
ref.configPath !== "oauth.refresh_token",
|
|
),
|
|
accessRef,
|
|
...(refreshRef ? [refreshRef] : []),
|
|
];
|
|
const grantValues = {
|
|
providerTenant: {
|
|
name: githubMetadata?.login ?? providerName,
|
|
externalId: credentials.subject,
|
|
oauth: {
|
|
strategy: "paperclip_cloud_connector",
|
|
accessTokenExpiresAt: credentials.accessTokenExpiresAt,
|
|
scopes: credentials.scopes,
|
|
tokenType: credentials.tokenType,
|
|
...(credentials.refreshTokenExpiresAt
|
|
? { refreshTokenExpiresAt: credentials.refreshTokenExpiresAt }
|
|
: {}),
|
|
...(credentials.accessTokenExpiresAt
|
|
? { refreshedAt: now().toISOString() }
|
|
: {}),
|
|
},
|
|
...(githubMetadata ? { github: githubMetadata } : {}),
|
|
},
|
|
credentialSecretRefs,
|
|
status: "active" as const,
|
|
revokedAt: null,
|
|
revokedByAgentId: null,
|
|
revokedByUserId: null,
|
|
updatedAt: now(),
|
|
};
|
|
if (existingCredentialGrant) {
|
|
await tx
|
|
.update(connectionGrants)
|
|
.set(grantValues)
|
|
.where(eq(connectionGrants.id, existingCredentialGrant.id));
|
|
} else {
|
|
await tx.insert(connectionGrants).values({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
kind: grantKind,
|
|
subjectUserId: personalCredential ? subjectUserId : null,
|
|
subjectAgentId: agentCredential ? subjectAgentId : null,
|
|
...grantValues,
|
|
isDefault: grantKind === "organization",
|
|
createdByUserId: authorizingUserId,
|
|
});
|
|
}
|
|
const nextConfig = {
|
|
...connection.config,
|
|
oauth: {
|
|
...oauthConfig(connection),
|
|
strategy: "paperclip_cloud_connector",
|
|
provider: galleryEntry?.slug,
|
|
connectorProfile,
|
|
connectorSubjectUserId: subjectUserId,
|
|
connectorSubjectAgentId: subjectAgentId,
|
|
resource: method.defaults?.serverUrl,
|
|
scopes: [...profile.scopes],
|
|
},
|
|
};
|
|
[connection] = await tx
|
|
.update(toolConnections)
|
|
.set({
|
|
status: shouldFinalizeManagedDefaults ? "draft" : "active",
|
|
enabled: shouldFinalizeManagedDefaults ? false : true,
|
|
authKind: "oauth",
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
credentialRefs:
|
|
personalCredential || agentCredential
|
|
? connection.credentialRefs.filter(
|
|
(ref) => ref.name !== "oauth.access_token",
|
|
)
|
|
: [
|
|
...connection.credentialRefs.filter(
|
|
(ref) => ref.name !== "oauth.access_token",
|
|
),
|
|
{
|
|
name: "oauth.access_token",
|
|
secretId: accessRef.secretId,
|
|
version: "latest" as const,
|
|
placement: "header" as const,
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
},
|
|
],
|
|
credentialSecretRefs:
|
|
personalCredential || agentCredential
|
|
? connection.credentialSecretRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
ref.configPath !== "oauth.refresh_token",
|
|
)
|
|
: credentialSecretRefs,
|
|
updatedAt: now(),
|
|
})
|
|
.where(eq(toolConnections.id, connection.id))
|
|
.returning();
|
|
await tx
|
|
.update(toolApplications)
|
|
.set({
|
|
status: shouldFinalizeManagedDefaults ? "draft" : "active",
|
|
updatedAt: now(),
|
|
})
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
await syncCredentialBindings(
|
|
connection,
|
|
personalCredential || agentCredential ? credentialSecretRefs : [],
|
|
tx,
|
|
);
|
|
const linkedInteractionKind = stateRow.interactionId
|
|
? await tx
|
|
.select({ kind: issueThreadInteractions.kind })
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, stateRow.interactionId))
|
|
.limit(1)
|
|
.then((rows) => rows[0]?.kind ?? null)
|
|
: null;
|
|
if (
|
|
stateRow.interactionId &&
|
|
linkedInteractionKind === "request_confirmation"
|
|
) {
|
|
await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "accepted",
|
|
result: { version: 1, outcome: "accepted" },
|
|
resolvedByUserId: authorizingUserId,
|
|
resolvedAt: now(),
|
|
updatedAt: now(),
|
|
})
|
|
.where(eq(issueThreadInteractions.id, stateRow.interactionId));
|
|
}
|
|
});
|
|
if (githubMetadata) {
|
|
const [githubGrant] = await db
|
|
.select({ id: connectionGrants.id })
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
subjectAgentId
|
|
? and(
|
|
eq(connectionGrants.kind, "agent"),
|
|
eq(connectionGrants.subjectAgentId, subjectAgentId),
|
|
)
|
|
: and(
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.subjectUserId, subjectUserId!),
|
|
),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!githubGrant) throw new Error("GitHub grant was not persisted");
|
|
await Promise.all(
|
|
githubMetadata.installationIds.map((installationId) =>
|
|
cloudConnector.setWebhookBinding({
|
|
subject: connectorSubject,
|
|
companyId: connection.companyId,
|
|
id: `${githubGrant.id}_${installationId}`,
|
|
installationId,
|
|
connectionId: connection.id,
|
|
grantId: githubGrant.id,
|
|
active: true,
|
|
accessToken: credentials.accessToken,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
const refresh = await refreshCatalog(connection.id, input.actor, {
|
|
enableAllByDefault: false,
|
|
quarantineManagedOAuthDraft: shouldFinalizeManagedDefaults,
|
|
skipDefaultProfileSync: true,
|
|
credentialHeaders: { Authorization: `Bearer ${credentials.accessToken}` },
|
|
});
|
|
const recommended = recommendedDefaultsForApp(galleryEntry!, method.key);
|
|
const suggestedDefaults = subjectAgentId
|
|
? { ...recommended, access: { agentIds: [subjectAgentId] } as const }
|
|
: recommended;
|
|
const finished = shouldFinalizeManagedDefaults
|
|
? await finishOAuthCatalogWithRecommendedDefaults({
|
|
interactionId: stateRow.interactionId,
|
|
connection,
|
|
catalog: refresh.catalog,
|
|
suggestedDefaults,
|
|
activateQuarantined: true,
|
|
actor: input.actor,
|
|
})
|
|
: null;
|
|
const activatedCatalogEntryIds = new Set(
|
|
shouldFinalizeManagedDefaults
|
|
? refresh.catalog
|
|
.filter((entry) => entry.status === "quarantined")
|
|
.map((entry) => entry.id)
|
|
: [],
|
|
);
|
|
const catalog = refresh.catalog.map((entry) =>
|
|
activatedCatalogEntryIds.has(entry.id)
|
|
? { ...entry, status: "active" as const }
|
|
: entry,
|
|
);
|
|
const [application] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
return {
|
|
connectionId: connection.id,
|
|
application: toApplication(application),
|
|
connection: finished?.connection ?? refresh.connection,
|
|
catalog,
|
|
actions: groupedActions(catalog),
|
|
suggestedDefaults,
|
|
auth: null,
|
|
};
|
|
}
|
|
|
|
async function completeVercelConnectCallback(input: {
|
|
state: string;
|
|
error?: string | null;
|
|
actor?: ActorInfo;
|
|
}): Promise<ConnectToolAppResult> {
|
|
const stateRow = await consumeOAuthState(input.state, input.actor);
|
|
if (stateRow.codeVerifier !== "vercel-connect") {
|
|
throw badRequest("OAuth state does not belong to a Vercel Connect flow");
|
|
}
|
|
if (input.error) {
|
|
await rejectPendingOAuthInteraction(stateRow, input.actor);
|
|
throw new HttpError(
|
|
400,
|
|
"Vercel Connect authorization did not complete. Start a new authorization to try again.",
|
|
{
|
|
code:
|
|
input.error === "access_denied"
|
|
? "oauth_authorization_denied"
|
|
: "vercel_connect_authorization_required",
|
|
},
|
|
);
|
|
}
|
|
if (!vercelConnect)
|
|
throw vercelConnectHttpError(
|
|
new VercelConnectClientError("vercel_connect_unavailable", 503),
|
|
);
|
|
let connection = await getConnectionRow(
|
|
stateRow.connectionId,
|
|
stateRow.companyId,
|
|
);
|
|
const credential = vercelCredentialFor(connection);
|
|
if (
|
|
credential.principalMode !== "user" ||
|
|
connection.authKind !== "oauth"
|
|
) {
|
|
throw badRequest(
|
|
"Vercel Connect callback does not match this connection",
|
|
);
|
|
}
|
|
const grantKind: ConnectionGrantKind = stateRow.subjectUserId
|
|
? "user"
|
|
: "organization";
|
|
if (stateRow.subjectUserId) {
|
|
const [membership] = await db
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, connection.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, stateRow.subjectUserId),
|
|
eq(companyMemberships.status, "active"),
|
|
ne(companyMemberships.membershipRole, "viewer"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!membership) {
|
|
throw forbidden(
|
|
"Your company membership no longer permits connection changes. Restore non-viewer access before authorizing this connection.",
|
|
);
|
|
}
|
|
}
|
|
const [existingGrant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, grantKind),
|
|
grantKind === "user"
|
|
? eq(connectionGrants.subjectUserId, stateRow.subjectUserId!)
|
|
: eq(connectionGrants.isDefault, true),
|
|
),
|
|
)
|
|
.limit(1);
|
|
const derived = deriveVercelConnectSubject({
|
|
credential,
|
|
connectionId: connection.id,
|
|
companyId: connection.companyId,
|
|
grantKind,
|
|
subjectUserId: stateRow.subjectUserId,
|
|
});
|
|
const request = vercelTokenRequest({
|
|
credential,
|
|
connectionId: connection.id,
|
|
companyId: connection.companyId,
|
|
resources: vercelConnectResourcesFor(connection),
|
|
grant: {
|
|
kind: grantKind,
|
|
subjectUserId: stateRow.subjectUserId,
|
|
externalCredential: existingGrant?.externalCredential,
|
|
},
|
|
});
|
|
let token;
|
|
try {
|
|
token = await vercelConnect.getToken(request, { forceRefresh: true });
|
|
} catch (error) {
|
|
throw vercelConnectHttpError(error);
|
|
}
|
|
if (
|
|
token.connector.id !== credential.connectorId &&
|
|
token.connector.uid !== credential.connectorUid
|
|
) {
|
|
throw new HttpError(
|
|
502,
|
|
"Vercel Connect returned a token for a different connector.",
|
|
{
|
|
code: "vercel_connect_connector_mismatch",
|
|
},
|
|
);
|
|
}
|
|
const externalGrant = vercelGrantReference({
|
|
credential,
|
|
token,
|
|
subjectId: derived.subjectId,
|
|
verifiedAt: now(),
|
|
});
|
|
const grantValues = {
|
|
providerTenant: token.tenantId
|
|
? { name: credential.service, externalId: token.tenantId }
|
|
: { name: credential.service },
|
|
credentialSecretRefs: [],
|
|
externalCredential: externalGrant,
|
|
status: "active" as const,
|
|
isDefault: grantKind === "organization",
|
|
revokedAt: null,
|
|
revokedByAgentId: null,
|
|
revokedByUserId: null,
|
|
updatedAt: now(),
|
|
};
|
|
if (existingGrant) {
|
|
await db
|
|
.update(connectionGrants)
|
|
.set(grantValues)
|
|
.where(eq(connectionGrants.id, existingGrant.id));
|
|
} else {
|
|
await db.insert(connectionGrants).values({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
kind: grantKind,
|
|
subjectUserId: stateRow.subjectUserId,
|
|
...grantValues,
|
|
createdByUserId:
|
|
input.actor?.actorType === "user"
|
|
? (input.actor.actorId ?? null)
|
|
: null,
|
|
});
|
|
}
|
|
[connection] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
status: "active",
|
|
enabled: true,
|
|
credentialRefs: [],
|
|
credentialSecretRefs: [],
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, connection.id),
|
|
eq(toolConnections.companyId, connection.companyId),
|
|
),
|
|
)
|
|
.returning();
|
|
await db
|
|
.update(toolApplications)
|
|
.set({ status: "active", updatedAt: now() })
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const galleryEntry = sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)
|
|
: null;
|
|
if (!galleryEntry)
|
|
throw badRequest(
|
|
"Vercel Connect connection is missing its reviewed app definition",
|
|
);
|
|
const method = connectionMethodForConnection(galleryEntry, connection);
|
|
const refresh = await refreshCatalog(connection.id, input.actor, {
|
|
enableAllByDefault: true,
|
|
skipDefaultProfileSync: true,
|
|
credentialHeaders: {
|
|
...projectedConnectionHeaders(connection),
|
|
[credential.headerName]: `${credential.headerPrefix ?? ""}${token.token}`,
|
|
},
|
|
});
|
|
const suggestedDefaults = recommendedDefaultsForApp(
|
|
galleryEntry,
|
|
method.key,
|
|
);
|
|
const finished = await finishOAuthCatalogWithRecommendedDefaults({
|
|
interactionId: stateRow.interactionId,
|
|
connection,
|
|
catalog: refresh.catalog,
|
|
suggestedDefaults,
|
|
actor: input.actor,
|
|
});
|
|
const [application] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
return {
|
|
connectionId: connection.id,
|
|
application: toApplication(application),
|
|
connection: finished.connection,
|
|
catalog: refresh.catalog,
|
|
actions: groupedActions(refresh.catalog),
|
|
suggestedDefaults,
|
|
auth: null,
|
|
};
|
|
}
|
|
|
|
async function completeOAuthCallback(input: {
|
|
state: string;
|
|
code?: string | null;
|
|
/**
|
|
* RFC 6749 `error`. Untrusted, and deliberately the *only* thing read from a
|
|
* failed callback — `error_description` and `error_uri` are not accepted as
|
|
* input at all, so there is nothing for a hostile provider to reflect
|
|
* through (PAP-17108).
|
|
*/
|
|
error?: string | null;
|
|
redirectUri: string;
|
|
/** RFC 9207 `iss`, when the authorization server returns it. */
|
|
iss?: string | null;
|
|
actor?: ActorInfo;
|
|
}): Promise<ConnectToolAppResult> {
|
|
// Binding first, outcome second: the provider's report of a failure is only
|
|
// acted on once the callback is bound to a state Paperclip issued and to the
|
|
// actor that started the flow, so an unsolicited callback cannot drive any
|
|
// path here. Consuming the state up front is what makes a denial terminal —
|
|
// a refused request must not stay completable by a later code (PAP-17109).
|
|
const stateRow = await consumeOAuthState(input.state, input.actor);
|
|
if (input.error) {
|
|
await rejectPendingOAuthInteraction(stateRow, input.actor);
|
|
const providerError = normalizeOAuthProviderError(input.error);
|
|
throw new HttpError(
|
|
400,
|
|
oauthProviderErrorMessage(
|
|
providerError,
|
|
"The authorization server denied the request.",
|
|
),
|
|
{
|
|
code: "oauth_authorization_denied",
|
|
providerError,
|
|
},
|
|
);
|
|
}
|
|
// Neither a code nor an error is not a usable answer either. It still spends
|
|
// the request: the recovery is a fresh authorization, not a state left live
|
|
// waiting for a better callback.
|
|
if (!input.code) throw badRequest("OAuth callback is missing a code");
|
|
|
|
let connection = await getConnectionRow(
|
|
stateRow.connectionId,
|
|
stateRow.companyId,
|
|
);
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const galleryEntry = sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)
|
|
: null;
|
|
assertOAuthRedirectConstraints(galleryEntry, input.redirectUri);
|
|
const endpoints = await oauthEndpointsForConnection(
|
|
connection,
|
|
null,
|
|
input.redirectUri,
|
|
);
|
|
assertOAuthCallbackIssuer(connection, endpoints, input.iss);
|
|
const client = await oauthClientForConnection(
|
|
connection,
|
|
endpoints.provider,
|
|
input.actor,
|
|
);
|
|
if (!client.clientId)
|
|
throw unprocessable(
|
|
`OAuth client id is not configured for ${endpoints.provider}`,
|
|
);
|
|
|
|
const token = await exchangeOAuthToken({
|
|
tokenUrl: endpoints.tokenUrl,
|
|
clientId: client.clientId,
|
|
clientSecret: client.clientSecret,
|
|
tokenEndpointAuthMethod: storedOAuthTokenEndpointAuthMethod(
|
|
oauthConfig(connection),
|
|
client.clientSecret,
|
|
),
|
|
redirectUri: input.redirectUri,
|
|
codeVerifier: stateRow.codeVerifier,
|
|
code: input.code,
|
|
resource: endpoints.resource,
|
|
});
|
|
const connectedAt = now();
|
|
const expiresAt = token.expiresIn
|
|
? new Date(connectedAt.getTime() + token.expiresIn * 1000).toISOString()
|
|
: null;
|
|
if (stateRow.subjectUserId) {
|
|
let personalCredentialSecretRefs: typeof connectionGrants.$inferSelect.credentialSecretRefs =
|
|
[];
|
|
await db.transaction(async (tx) => {
|
|
// Serialize callback persistence with suspension/removal. Those paths
|
|
// lock this same membership row before sweeping personal credentials.
|
|
const [membership] = await tx
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, connection.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, stateRow.subjectUserId!),
|
|
eq(companyMemberships.status, "active"),
|
|
ne(companyMemberships.membershipRole, "viewer"),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.for("update");
|
|
if (!membership) {
|
|
throw forbidden(
|
|
"Your company membership no longer permits connection changes. Ask a company owner to restore non-viewer access before you authorize this connection again.",
|
|
);
|
|
}
|
|
const txSecrets = secretService(tx);
|
|
const txSecretContext = { dbClient: tx, secretClient: txSecrets };
|
|
|
|
const [existingUserGrant] = await tx
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.subjectUserId, stateRow.subjectUserId!),
|
|
),
|
|
)
|
|
.limit(1);
|
|
const subjectCredentialSecretRefs =
|
|
existingUserGrant?.credentialSecretRefs ?? [];
|
|
const accessRef = await createOrRotateOAuthSecret(
|
|
{
|
|
companyId: connection.companyId,
|
|
connection,
|
|
configPath: "oauth.access_token",
|
|
label: "OAuth access token",
|
|
value: token.accessToken,
|
|
actor: input.actor,
|
|
existingRefs: subjectCredentialSecretRefs,
|
|
ownerUserId: stateRow.subjectUserId!,
|
|
},
|
|
txSecretContext,
|
|
);
|
|
const nextCredentialSecretRefs = [
|
|
...subjectCredentialSecretRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
ref.configPath !== "oauth.refresh_token",
|
|
),
|
|
accessRef,
|
|
];
|
|
if (token.refreshToken) {
|
|
nextCredentialSecretRefs.push(
|
|
await createOrRotateOAuthSecret(
|
|
{
|
|
companyId: connection.companyId,
|
|
connection,
|
|
configPath: "oauth.refresh_token",
|
|
label: "OAuth refresh token",
|
|
value: token.refreshToken,
|
|
actor: input.actor,
|
|
existingRefs: subjectCredentialSecretRefs,
|
|
ownerUserId: stateRow.subjectUserId!,
|
|
},
|
|
txSecretContext,
|
|
),
|
|
);
|
|
} else {
|
|
const existingRefreshRef = subjectCredentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.refresh_token",
|
|
);
|
|
if (existingRefreshRef)
|
|
nextCredentialSecretRefs.push(existingRefreshRef);
|
|
}
|
|
|
|
const grantValues = {
|
|
providerTenant: {
|
|
...(existingUserGrant?.providerTenant ?? {}),
|
|
oauth: {
|
|
...asRecord(asRecord(existingUserGrant?.providerTenant).oauth),
|
|
strategy: "direct_oauth",
|
|
accessTokenExpiresAt: expiresAt ?? undefined,
|
|
scopes: normalizeOauthScopes(
|
|
token.scope ?? stateRow.requestedScopes,
|
|
),
|
|
tokenType: token.tokenType,
|
|
refreshedAt: connectedAt.toISOString(),
|
|
},
|
|
},
|
|
credentialSecretRefs: nextCredentialSecretRefs,
|
|
status: "active" as const,
|
|
revokedAt: null,
|
|
revokedByAgentId: null,
|
|
revokedByUserId: null,
|
|
updatedAt: new Date(),
|
|
};
|
|
if (existingUserGrant) {
|
|
await tx
|
|
.update(connectionGrants)
|
|
.set(grantValues)
|
|
.where(eq(connectionGrants.id, existingUserGrant.id));
|
|
} else {
|
|
await tx.insert(connectionGrants).values({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: stateRow.subjectUserId!,
|
|
...grantValues,
|
|
isDefault: false,
|
|
createdByUserId: stateRow.subjectUserId!,
|
|
});
|
|
}
|
|
personalCredentialSecretRefs = nextCredentialSecretRefs;
|
|
const nextConfig = {
|
|
...connection.config,
|
|
oauth: {
|
|
...withoutOAuthRefreshLease(oauthConfig(connection)),
|
|
provider: endpoints.provider,
|
|
authorizationUrl: endpoints.authorizationUrl,
|
|
tokenUrl: endpoints.tokenUrl,
|
|
metadataUrl: endpoints.metadataUrl ?? null,
|
|
scopes: galleryEntry
|
|
? normalizeOauthScopes(stateRow.requestedScopes)
|
|
: endpoints.scopes,
|
|
clientIdEnv: client.clientIdEnv,
|
|
clientSecretEnv: client.clientSecret
|
|
? client.clientSecretEnv
|
|
: null,
|
|
credentialScope: credentialScope(connection, input.actor),
|
|
issuer: endpoints.issuer ?? oauthConfig(connection).issuer ?? null,
|
|
resource:
|
|
endpoints.resource ?? oauthConfig(connection).resource ?? null,
|
|
expiresAt,
|
|
scope: token.scope,
|
|
tokenType: token.tokenType,
|
|
connectedAt: connectedAt.toISOString(),
|
|
},
|
|
providerMetadata: {
|
|
...asRecord(connection.config.providerMetadata),
|
|
oauth: {
|
|
expiresAt,
|
|
scope: token.scope,
|
|
tokenType: token.tokenType,
|
|
},
|
|
},
|
|
};
|
|
const [updatedConnection] = await tx
|
|
.update(toolConnections)
|
|
.set({
|
|
status: "active",
|
|
enabled: true,
|
|
authKind: "oauth",
|
|
credentialPolicy: connection.credentialPolicy,
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
// A personal-only connection keeps tokens exclusively on its user
|
|
// grant. Adding a personal identity to an existing shared/fallback
|
|
// connection must not erase that connection's organization token.
|
|
credentialRefs:
|
|
connection.credentialPolicy === "per_user"
|
|
? connection.credentialRefs.filter(
|
|
(ref) => ref.name !== "oauth.access_token",
|
|
)
|
|
: connection.credentialRefs,
|
|
credentialSecretRefs:
|
|
connection.credentialPolicy === "per_user"
|
|
? connection.credentialSecretRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
ref.configPath !== "oauth.refresh_token",
|
|
)
|
|
: connection.credentialSecretRefs,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, connection.id))
|
|
.returning();
|
|
if (!updatedConnection)
|
|
throw new Error("OAuth connection was not found");
|
|
connection = updatedConnection;
|
|
await tx
|
|
.update(toolApplications)
|
|
.set({
|
|
status: "active",
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
const linkedInteractionKind = stateRow.interactionId
|
|
? await tx
|
|
.select({ kind: issueThreadInteractions.kind })
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, stateRow.interactionId))
|
|
.limit(1)
|
|
.then((rows) => rows[0]?.kind ?? null)
|
|
: null;
|
|
if (
|
|
stateRow.interactionId &&
|
|
linkedInteractionKind === "request_confirmation"
|
|
) {
|
|
await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "accepted",
|
|
result: { version: 1, outcome: "accepted" },
|
|
resolvedByUserId: stateRow.subjectUserId!,
|
|
resolvedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, stateRow.interactionId),
|
|
eq(issueThreadInteractions.companyId, connection.companyId),
|
|
),
|
|
);
|
|
}
|
|
await syncCredentialBindings(
|
|
connection,
|
|
connection.credentialPolicy === "per_user"
|
|
? personalCredentialSecretRefs
|
|
: [],
|
|
tx,
|
|
);
|
|
});
|
|
|
|
// Personal OAuth used to return immediately after saving the grant. That
|
|
// left the connection draft/paused and its catalog empty, so the person
|
|
// who had just consented landed on a false "Nothing to test" state.
|
|
// Activate and discover with the just-issued token before returning.
|
|
const refresh = await refreshCatalog(connection.id, input.actor, {
|
|
enableAllByDefault: true,
|
|
skipDefaultProfileSync: true,
|
|
credentialHeaders: { Authorization: `Bearer ${token.accessToken}` },
|
|
});
|
|
const [application] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
if (!application)
|
|
throw new Error("OAuth connection application was not found");
|
|
const suggestedDefaults = galleryEntry
|
|
? recommendedDefaultsForApp(
|
|
galleryEntry,
|
|
connectionMethodForConnection(galleryEntry, connection).key,
|
|
)
|
|
: { access: "all_agents" as const, askFirstRiskLevels: [] };
|
|
const finished = await finishOAuthCatalogWithRecommendedDefaults({
|
|
interactionId: stateRow.interactionId,
|
|
connection,
|
|
catalog: refresh.catalog,
|
|
suggestedDefaults,
|
|
actor: input.actor,
|
|
});
|
|
return {
|
|
connectionId: refresh.connection.id,
|
|
application: toApplication(application),
|
|
connection: finished.connection,
|
|
catalog: refresh.catalog,
|
|
actions: groupedActions(refresh.catalog),
|
|
suggestedDefaults,
|
|
auth: null,
|
|
};
|
|
}
|
|
|
|
const organizationActorUserId =
|
|
stateRow.createdByActorType === "user" ? stateRow.createdByActorId : null;
|
|
if (!organizationActorUserId) {
|
|
throw forbidden(
|
|
"Organization OAuth completion requires the user who started sign-in",
|
|
);
|
|
}
|
|
await db.transaction(async (tx) => {
|
|
// Keep callback persistence serialized with membership suspension, role
|
|
// downgrade, and removal. Once this row is locked, authority cannot be
|
|
// revoked between the live check and the shared credential/grant writes.
|
|
const [membership] = await tx
|
|
.select({
|
|
id: companyMemberships.id,
|
|
membershipRole: companyMemberships.membershipRole,
|
|
})
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, connection.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, organizationActorUserId),
|
|
eq(companyMemberships.status, "active"),
|
|
ne(companyMemberships.membershipRole, "viewer"),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.for("update");
|
|
if (!membership) {
|
|
throw forbidden(
|
|
"Your company membership no longer permits connection changes. Ask a company owner to restore non-viewer access before you authorize this connection again.",
|
|
);
|
|
}
|
|
const roleCanManage =
|
|
membership.membershipRole === "owner" ||
|
|
membership.membershipRole === "admin";
|
|
const [explicitManagerGrant] = roleCanManage
|
|
? []
|
|
: await tx
|
|
.select({
|
|
id: principalPermissionGrants.id,
|
|
})
|
|
.from(principalPermissionGrants)
|
|
.where(
|
|
and(
|
|
eq(principalPermissionGrants.companyId, connection.companyId),
|
|
eq(principalPermissionGrants.principalType, "user"),
|
|
eq(
|
|
principalPermissionGrants.principalId,
|
|
organizationActorUserId,
|
|
),
|
|
eq(
|
|
principalPermissionGrants.permissionKey,
|
|
"tools:manage_connections",
|
|
),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.for("update");
|
|
if (!roleCanManage && !explicitManagerGrant) {
|
|
throw forbidden(
|
|
"Only a company owner, admin, or member with connection-manager permission can share credentials with the organization.",
|
|
);
|
|
}
|
|
const txSecrets = secretService(tx);
|
|
const txSecretContext = { dbClient: tx, secretClient: txSecrets };
|
|
|
|
const subjectCredentialSecretRefs = connection.credentialSecretRefs;
|
|
const accessRef = await createOrRotateOAuthSecret(
|
|
{
|
|
companyId: connection.companyId,
|
|
connection,
|
|
configPath: "oauth.access_token",
|
|
label: "OAuth access token",
|
|
value: token.accessToken,
|
|
actor: input.actor,
|
|
},
|
|
txSecretContext,
|
|
);
|
|
const nextCredentialSecretRefs = [
|
|
...subjectCredentialSecretRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
ref.configPath !== "oauth.refresh_token",
|
|
),
|
|
accessRef,
|
|
];
|
|
if (token.refreshToken) {
|
|
nextCredentialSecretRefs.push(
|
|
await createOrRotateOAuthSecret(
|
|
{
|
|
companyId: connection.companyId,
|
|
connection,
|
|
configPath: "oauth.refresh_token",
|
|
label: "OAuth refresh token",
|
|
value: token.refreshToken,
|
|
actor: input.actor,
|
|
},
|
|
txSecretContext,
|
|
),
|
|
);
|
|
} else {
|
|
const existingRefreshRef = subjectCredentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.refresh_token",
|
|
);
|
|
if (existingRefreshRef)
|
|
nextCredentialSecretRefs.push(existingRefreshRef);
|
|
}
|
|
const nextConfig = {
|
|
...connection.config,
|
|
oauth: {
|
|
...withoutOAuthRefreshLease(oauthConfig(connection)),
|
|
provider: endpoints.provider,
|
|
authorizationUrl: endpoints.authorizationUrl,
|
|
tokenUrl: endpoints.tokenUrl,
|
|
metadataUrl: endpoints.metadataUrl ?? null,
|
|
scopes: galleryEntry
|
|
? normalizeOauthScopes(stateRow.requestedScopes)
|
|
: endpoints.scopes,
|
|
clientIdEnv: client.clientIdEnv,
|
|
clientSecretEnv: client.clientSecret ? client.clientSecretEnv : null,
|
|
credentialScope: credentialScope(connection, input.actor),
|
|
// Keep the issuer and resource this grant was minted against so refresh,
|
|
// reconnect, revoke and diagnostics resolve the same authorization server
|
|
// instead of re-discovering one from a possibly-changed endpoint.
|
|
issuer: endpoints.issuer ?? oauthConfig(connection).issuer ?? null,
|
|
resource:
|
|
endpoints.resource ?? oauthConfig(connection).resource ?? null,
|
|
expiresAt,
|
|
scope: token.scope,
|
|
tokenType: token.tokenType,
|
|
connectedAt: connectedAt.toISOString(),
|
|
},
|
|
providerMetadata: {
|
|
...asRecord(connection.config.providerMetadata),
|
|
oauth: { expiresAt, scope: token.scope, tokenType: token.tokenType },
|
|
},
|
|
};
|
|
const [updatedConnection] = await tx
|
|
.update(toolConnections)
|
|
.set({
|
|
status: "active",
|
|
enabled: true,
|
|
authKind: "oauth",
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
credentialSecretRefs: nextCredentialSecretRefs,
|
|
credentialRefs: [
|
|
...connection.credentialRefs.filter(
|
|
(ref) => ref.name !== "oauth.access_token",
|
|
),
|
|
{
|
|
name: "oauth.access_token",
|
|
secretId: accessRef.secretId,
|
|
version: "latest" as const,
|
|
placement: "header" as const,
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
},
|
|
],
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, connection.id))
|
|
.returning();
|
|
connection = updatedConnection;
|
|
await tx
|
|
.update(toolApplications)
|
|
.set({ status: "active", updatedAt: new Date() })
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
// The organization grant is created before OAuth has any secrets to attach.
|
|
// Synchronize it after every successful callback/rotation so all real tool
|
|
// execution paths receive the credentials that setup and catalog discovery
|
|
// just proved.
|
|
await ensureDefaultOrganizationGrant(connection, tx);
|
|
await syncCredentialBindings(connection, [], tx);
|
|
});
|
|
|
|
await checkConnectionHealth(connection.id, input.actor);
|
|
const refresh = await refreshCatalog(connection.id, input.actor, {
|
|
enableAllByDefault: true,
|
|
skipDefaultProfileSync: true,
|
|
});
|
|
const [application] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
const suggestedDefaults = galleryEntry
|
|
? recommendedDefaultsForApp(
|
|
galleryEntry,
|
|
connectionMethodForConnection(galleryEntry, connection).key,
|
|
)
|
|
: {
|
|
access: "all_agents" as const,
|
|
askFirstRiskLevels: [],
|
|
};
|
|
const finished = await finishOAuthCatalogWithRecommendedDefaults({
|
|
interactionId: stateRow.interactionId,
|
|
connection,
|
|
catalog: refresh.catalog,
|
|
suggestedDefaults,
|
|
actor: input.actor,
|
|
});
|
|
return {
|
|
connectionId: refresh.connection.id,
|
|
application: toApplication(application),
|
|
connection: finished.connection,
|
|
catalog: refresh.catalog,
|
|
actions: groupedActions(refresh.catalog),
|
|
suggestedDefaults,
|
|
auth: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Finish the one decision that cannot safely be guessed for a browser OAuth
|
|
* connection: whether the consenting identity stays personal or becomes the
|
|
* company's shared identity. The provider callback always writes a fresh
|
|
* token to the consenting user's grant first. Only this explicit endpoint may
|
|
* promote it to company-scoped secrets.
|
|
*/
|
|
async function finalizeOAuthAccess(
|
|
companyId: string,
|
|
connectionId: string,
|
|
input: FinalizeOAuthAccess,
|
|
actor?: ActorInfo,
|
|
requestingAgentId?: string,
|
|
): Promise<FinishToolAppResult> {
|
|
if (requestingAgentId)
|
|
await assertAgentsInCompany(companyId, [requestingAgentId]);
|
|
let connection = await getConnectionRow(connectionId, companyId);
|
|
if (connection.authKind !== "oauth")
|
|
throw badRequest("This connection does not use browser sign-in");
|
|
if (connection.status === "archived")
|
|
throw conflict("Archived app connections cannot be finished");
|
|
const actorUserId = actor?.actorType === "user" ? actor.actorId : null;
|
|
if (!actorUserId)
|
|
throw badRequest("Finishing browser sign-in requires a signed-in user");
|
|
|
|
const [personalGrant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.subjectUserId, actorUserId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
|
|
if (connection.credentialSource === "vercel_connect") {
|
|
const [organizationGrant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "organization"),
|
|
eq(connectionGrants.isDefault, true),
|
|
),
|
|
)
|
|
.limit(1);
|
|
const selectedGrant =
|
|
input.grantKind === "user" ? personalGrant : organizationGrant;
|
|
if (
|
|
!selectedGrant ||
|
|
selectedGrant.status !== "active" ||
|
|
!selectedGrant.externalCredential
|
|
) {
|
|
throw conflict(
|
|
"The selected Vercel Connect identity is missing. Authorize this connection again.",
|
|
);
|
|
}
|
|
const expectedPolicy = input.grantKind === "user" ? "per_user" : "shared";
|
|
if (connection.credentialPolicy !== expectedPolicy) {
|
|
throw conflict(
|
|
"Vercel Connect identity scope is fixed when the connector is attached. Create a new connection to change it.",
|
|
);
|
|
}
|
|
} else if (input.grantKind === "user") {
|
|
if (
|
|
!personalGrant ||
|
|
personalGrant.status !== "active" ||
|
|
personalGrant.credentialSecretRefs.length === 0
|
|
) {
|
|
throw conflict(
|
|
"Your connected identity is missing. Connect this app again before choosing Just me.",
|
|
);
|
|
}
|
|
if (
|
|
connection.credentialPolicy === "shared" &&
|
|
connection.credentialSecretRefs.length > 0
|
|
) {
|
|
throw conflict("This connection already uses a company identity");
|
|
}
|
|
[connection] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
credentialPolicy: "per_user",
|
|
credentialRefs: connection.credentialRefs.filter(
|
|
(ref) => ref.name !== "oauth.access_token",
|
|
),
|
|
credentialSecretRefs: connection.credentialSecretRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
ref.configPath !== "oauth.refresh_token",
|
|
),
|
|
status: "active",
|
|
enabled: true,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, connection.id),
|
|
eq(toolConnections.companyId, companyId),
|
|
),
|
|
)
|
|
.returning();
|
|
await syncCredentialBindings(
|
|
connection,
|
|
personalGrant.credentialSecretRefs,
|
|
);
|
|
} else if (
|
|
connection.credentialPolicy !== "shared" ||
|
|
connection.credentialSecretRefs.length === 0
|
|
) {
|
|
if (
|
|
!personalGrant ||
|
|
personalGrant.status !== "active" ||
|
|
personalGrant.credentialSecretRefs.length === 0
|
|
) {
|
|
throw conflict(
|
|
"Your connected identity is missing. Connect this app again before sharing it.",
|
|
);
|
|
}
|
|
|
|
const personalSecretIds = personalGrant.credentialSecretRefs.map(
|
|
(ref) => ref.secretId,
|
|
);
|
|
const personalSecretRows = await db
|
|
.select({
|
|
id: companySecrets.id,
|
|
scope: companySecrets.scope,
|
|
ownerUserId: companySecrets.ownerUserId,
|
|
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
|
|
})
|
|
.from(companySecrets)
|
|
.where(
|
|
and(
|
|
eq(companySecrets.companyId, companyId),
|
|
inArray(companySecrets.id, personalSecretIds),
|
|
),
|
|
);
|
|
const personalSecretById = new Map(
|
|
personalSecretRows.map((row) => [row.id, row]),
|
|
);
|
|
const promotedRefs: ToolCredentialSecretRef[] = [];
|
|
try {
|
|
for (const ref of personalGrant.credentialSecretRefs) {
|
|
const secretRow = personalSecretById.get(ref.secretId);
|
|
if (
|
|
!secretRow ||
|
|
secretRow.scope !== "user" ||
|
|
secretRow.ownerUserId !== actorUserId ||
|
|
!secretRow.userSecretDefinitionId
|
|
) {
|
|
throw forbidden(
|
|
"Only your own connected identity can be shared with the company",
|
|
);
|
|
}
|
|
if (
|
|
ref.configPath !== "oauth.access_token" &&
|
|
ref.configPath !== "oauth.refresh_token" &&
|
|
ref.configPath !== "oauth.client_secret"
|
|
) {
|
|
throw badRequest(
|
|
"The connected identity contains an unsupported OAuth credential",
|
|
);
|
|
}
|
|
const resolved = await secrets.resolveUserSecretValue(
|
|
companyId,
|
|
{
|
|
definitionId: secretRow.userSecretDefinitionId,
|
|
responsibleUserId: actorUserId,
|
|
version: ref.versionSelector ?? "latest",
|
|
},
|
|
{
|
|
consumerType: "tool_connection",
|
|
consumerId: connection.id,
|
|
responsibleUserId: actorUserId,
|
|
actorType: "user",
|
|
actorId: actorUserId,
|
|
},
|
|
);
|
|
if (!resolved)
|
|
throw unprocessable("The connected identity could not be read");
|
|
const promoted = await createOrRotateOAuthSecret({
|
|
companyId,
|
|
connection,
|
|
configPath: ref.configPath,
|
|
label: ref.label ?? "OAuth credential",
|
|
value: resolved.value,
|
|
actor,
|
|
existingRefs: promotedRefs,
|
|
});
|
|
promotedRefs.push({ ...ref, ...promoted });
|
|
}
|
|
|
|
const accessRef = promotedRefs.find(
|
|
(ref) => ref.configPath === "oauth.access_token",
|
|
);
|
|
if (!accessRef)
|
|
throw unprocessable(
|
|
"The connected identity is missing its OAuth access token",
|
|
);
|
|
const connectionCredentialSecretRefs = [
|
|
...connection.credentialSecretRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
ref.configPath !== "oauth.refresh_token",
|
|
),
|
|
...promotedRefs,
|
|
];
|
|
const nowAt = new Date();
|
|
await db.transaction(async (tx) => {
|
|
const [existingOrganizationGrant] = await tx
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "organization"),
|
|
eq(connectionGrants.isDefault, true),
|
|
),
|
|
)
|
|
.limit(1);
|
|
let organizationGrantId: string;
|
|
if (existingOrganizationGrant) {
|
|
organizationGrantId = existingOrganizationGrant.id;
|
|
await tx
|
|
.update(connectionGrants)
|
|
.set({
|
|
providerTenant: personalGrant.providerTenant,
|
|
credentialSecretRefs: connectionCredentialSecretRefs,
|
|
status: "active",
|
|
revokedAt: null,
|
|
revokedByAgentId: null,
|
|
revokedByUserId: null,
|
|
updatedAt: nowAt,
|
|
})
|
|
.where(eq(connectionGrants.id, existingOrganizationGrant.id));
|
|
} else {
|
|
const [createdOrganizationGrant] = await tx
|
|
.insert(connectionGrants)
|
|
.values({
|
|
companyId,
|
|
connectionId: connection.id,
|
|
kind: "organization",
|
|
subjectUserId: null,
|
|
providerTenant: personalGrant.providerTenant,
|
|
credentialSecretRefs: connectionCredentialSecretRefs,
|
|
status: "active",
|
|
isDefault: true,
|
|
createdByUserId: actorUserId,
|
|
})
|
|
.returning({ id: connectionGrants.id });
|
|
organizationGrantId = createdOrganizationGrant.id;
|
|
}
|
|
// Empty audience rows are the canonical "everyone in the company".
|
|
await tx
|
|
.delete(connectionGrantMembers)
|
|
.where(
|
|
and(
|
|
eq(connectionGrantMembers.companyId, companyId),
|
|
eq(connectionGrantMembers.grantId, organizationGrantId),
|
|
),
|
|
);
|
|
await tx
|
|
.delete(connectionGrantDelegations)
|
|
.where(
|
|
and(
|
|
eq(connectionGrantDelegations.companyId, companyId),
|
|
eq(connectionGrantDelegations.grantId, personalGrant.id),
|
|
),
|
|
);
|
|
await tx
|
|
.update(connectionGrants)
|
|
.set({
|
|
credentialSecretRefs: [],
|
|
status: "revoked",
|
|
revokedAt: nowAt,
|
|
revokedByUserId: actorUserId,
|
|
updatedAt: nowAt,
|
|
})
|
|
.where(eq(connectionGrants.id, personalGrant.id));
|
|
[connection] = await tx
|
|
.update(toolConnections)
|
|
.set({
|
|
credentialPolicy: "shared",
|
|
credentialSecretRefs: connectionCredentialSecretRefs,
|
|
credentialRefs: [
|
|
{
|
|
name: "oauth.access_token",
|
|
secretId: accessRef.secretId,
|
|
version: "latest",
|
|
placement: "header",
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
},
|
|
],
|
|
status: "active",
|
|
enabled: true,
|
|
updatedAt: nowAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolConnections.id, connection.id),
|
|
eq(toolConnections.companyId, companyId),
|
|
),
|
|
)
|
|
.returning();
|
|
});
|
|
} catch (error) {
|
|
await Promise.all(
|
|
promotedRefs.map((ref) =>
|
|
secrets.remove(ref.secretId).catch(() => undefined),
|
|
),
|
|
);
|
|
throw error;
|
|
}
|
|
await syncCredentialBindings(connection);
|
|
// The source values are no longer referenced by either grant or
|
|
// connection. Removing them makes the promotion a move, not a copy.
|
|
for (const secretId of personalSecretIds) await secrets.remove(secretId);
|
|
}
|
|
|
|
const catalog = await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, companyId),
|
|
eq(toolCatalogEntries.connectionId, connection.id),
|
|
eq(toolCatalogEntries.status, "active"),
|
|
),
|
|
);
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const galleryEntry = sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)
|
|
: null;
|
|
const defaults = galleryEntry
|
|
? recommendedDefaultsForApp(
|
|
galleryEntry,
|
|
connectionMethodForConnection(galleryEntry, connection).key,
|
|
)
|
|
: { askFirstRiskLevels: [] };
|
|
const askFirstRiskLevels = new Set(
|
|
Array.isArray(defaults.askFirstRiskLevels)
|
|
? defaults.askFirstRiskLevels.filter(
|
|
(value): value is string => typeof value === "string",
|
|
)
|
|
: [],
|
|
);
|
|
const finished = await finishGalleryAppConnection(
|
|
companyId,
|
|
connection.id,
|
|
{
|
|
enabledCatalogEntryIds: catalog.map((entry) => entry.id),
|
|
askFirstCatalogEntryIds: catalog
|
|
.filter((entry) => askFirstRiskLevels.has(entry.riskLevel))
|
|
.map((entry) => entry.id),
|
|
access: requestingAgentId
|
|
? { agentIds: [requestingAgentId] }
|
|
: "all_agents",
|
|
preserveExistingAccess: Boolean(requestingAgentId),
|
|
},
|
|
actor,
|
|
);
|
|
await db
|
|
.insert(toolConnectionInstalls)
|
|
.values({
|
|
companyId,
|
|
connectionId: connection.id,
|
|
targetType: requestingAgentId ? "agent" : "company",
|
|
targetId: requestingAgentId ?? companyId,
|
|
createdByUserId: actorUserId,
|
|
})
|
|
.onConflictDoNothing();
|
|
return finished;
|
|
}
|
|
|
|
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 assertMcpAggregatorSetupEnabled(app.slug, method.key);
|
|
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,
|
|
);
|
|
const attempts: ToolAppMetadataPreflightResult["attempts"] = [];
|
|
const endpointResponse = await fetchRemoteHttpUrl(serverUrl, {
|
|
method: "GET",
|
|
headers: { Accept: "application/json, text/event-stream" },
|
|
});
|
|
attempts.push({
|
|
kind: "endpoint",
|
|
url: serverUrl,
|
|
status: endpointResponse.status,
|
|
ok: endpointResponse.ok,
|
|
contentType: endpointResponse.headers.get("content-type"),
|
|
});
|
|
|
|
if (method.auth !== "oauth") {
|
|
return {
|
|
galleryKey: app.slug,
|
|
methodKey: method.key,
|
|
serverUrl,
|
|
endpointReachable: endpointResponse.status < 500,
|
|
oauth: null,
|
|
attempts,
|
|
checkedAt: (options.now?.() ?? new Date()).toISOString(),
|
|
};
|
|
}
|
|
|
|
const endpoint = new URL(serverUrl);
|
|
const metadataQueue = [
|
|
method.defaults.discoveryUrl ?? null,
|
|
method.defaults.metadataUrl ?? null,
|
|
...protectedResourceMetadataUrls(endpoint),
|
|
...wellKnownMetadataUrls(endpoint.toString()),
|
|
].filter((url): url is string => Boolean(url));
|
|
const visited = new Set<string>();
|
|
let metadataFound = false;
|
|
let registrationAdvertised = false;
|
|
let clientIdMetadataDocumentSupported = false;
|
|
|
|
while (metadataQueue.length > 0 && visited.size < 16) {
|
|
const metadataUrl = metadataQueue.shift()!;
|
|
if (visited.has(metadataUrl)) continue;
|
|
visited.add(metadataUrl);
|
|
const response = await fetchRemoteHttpUrl(metadataUrl, {
|
|
method: "GET",
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
attempts.push({
|
|
kind: "oauth_metadata",
|
|
url: metadataUrl,
|
|
status: response.status,
|
|
ok: response.ok,
|
|
contentType: response.headers.get("content-type"),
|
|
});
|
|
if (!response.ok) continue;
|
|
let metadata: Record<string, unknown>;
|
|
try {
|
|
metadata = asRecord((await response.json()) as unknown);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const looksLikeOAuthMetadata = Boolean(
|
|
metadata.authorization_endpoint ||
|
|
metadata.token_endpoint ||
|
|
metadata.authorization_servers ||
|
|
metadata.resource,
|
|
);
|
|
if (!looksLikeOAuthMetadata) continue;
|
|
metadataFound = true;
|
|
registrationAdvertised ||=
|
|
typeof metadata.registration_endpoint === "string";
|
|
clientIdMetadataDocumentSupported ||=
|
|
metadata.client_id_metadata_document_supported === true;
|
|
for (const candidate of authServerMetadataUrls(metadata)) {
|
|
if (!visited.has(candidate.metadataUrl))
|
|
metadataQueue.push(candidate.metadataUrl);
|
|
}
|
|
}
|
|
|
|
return {
|
|
galleryKey: app.slug,
|
|
methodKey: method.key,
|
|
serverUrl,
|
|
endpointReachable: endpointResponse.status < 500,
|
|
oauth: {
|
|
metadataFound,
|
|
registrationAdvertised,
|
|
clientIdMetadataDocumentSupported,
|
|
},
|
|
attempts,
|
|
checkedAt: (options.now?.() ?? new Date()).toISOString(),
|
|
};
|
|
}
|
|
|
|
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> => {
|
|
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:
|
|
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
|
|
})
|
|
.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);
|
|
},
|
|
|
|
connectGalleryApp,
|
|
|
|
finishGalleryAppConnection,
|
|
|
|
reconnectGalleryApp,
|
|
|
|
startOAuth,
|
|
|
|
startAuthorizationForAgent: async (input: {
|
|
companyId: string;
|
|
connectionId: string;
|
|
agentId: string;
|
|
runId: string;
|
|
subjectUserId: string;
|
|
scopes?: string[];
|
|
returnTo?: string;
|
|
redirectUri: string;
|
|
}) => {
|
|
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 },
|
|
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:
|
|
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
|
|
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,
|
|
): Promise<ToolApplication> => {
|
|
await assertOptionalPlugin(input.pluginId);
|
|
await assertOptionalAgent(
|
|
companyId,
|
|
input.ownerAgentId,
|
|
"Tool application owner agent",
|
|
);
|
|
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,
|
|
): 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",
|
|
});
|
|
}
|
|
}
|
|
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,
|
|
): 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.
|
|
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,
|
|
) => {
|
|
const [connections, grants, members, memberships] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.companyId, companyId),
|
|
eq(toolConnections.enabled, true),
|
|
),
|
|
),
|
|
db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(eq(connectionGrants.companyId, companyId)),
|
|
db
|
|
.select()
|
|
.from(connectionGrantMembers)
|
|
.where(eq(connectionGrantMembers.companyId, companyId)),
|
|
userId
|
|
? 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
|
|
>();
|
|
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;
|
|
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 = initialGrant
|
|
? await (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}` };
|
|
})()
|
|
: await resolveCredentialHeaders(connection, actor);
|
|
rows = await loadGitHubTokenRepositories(headers);
|
|
}
|
|
for (const row of rows) {
|
|
mergeProjectRepository(repositories, row, connection.name);
|
|
}
|
|
} 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),
|
|
),
|
|
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);
|
|
return connections;
|
|
},
|
|
|
|
createConnection: async (
|
|
companyId: string,
|
|
input: CreateToolConnection,
|
|
actor?: ActorInfo,
|
|
): Promise<ToolConnection> => {
|
|
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 ?? {},
|
|
);
|
|
// 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);
|
|
await assertGoogleSheetsSpreadsheetOwnership(companyId, config);
|
|
if (applicationId) {
|
|
const app = await assertApplication(companyId, applicationId);
|
|
applicationNamespace = app.applicationKey ?? app.name;
|
|
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: transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
|
status: "active",
|
|
metadata: {},
|
|
})
|
|
.returning();
|
|
applicationId = app.id;
|
|
}
|
|
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,
|
|
config,
|
|
transportConfig: isGoogleSheetsConnectionConfig(config)
|
|
? config
|
|
: (input.transportConfig ?? config),
|
|
credentialRefs: input.credentialRefs ?? [],
|
|
credentialSecretRefs: input.credentialSecretRefs ?? [],
|
|
createdByAgentId:
|
|
binding.actorType === "agent" ? binding.actorId : null,
|
|
createdByUserId:
|
|
binding.actorType === "user" ? binding.actorId : null,
|
|
})
|
|
.returning();
|
|
await ensureDefaultOrganizationGrant(row);
|
|
await syncCredentialBindings(row);
|
|
await ensureRuntimeSlot(row);
|
|
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);
|
|
return connection;
|
|
},
|
|
|
|
listConnectionGrants: async (idOrUid: string, companyId?: string) => {
|
|
const connection = await getConnectionRow(idOrUid, companyId);
|
|
const grants = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
),
|
|
)
|
|
.orderBy(
|
|
desc(connectionGrants.isDefault),
|
|
desc(connectionGrants.updatedAt),
|
|
);
|
|
const grantIds = grants.map((grant) => grant.id);
|
|
const [members, delegations] =
|
|
grantIds.length === 0
|
|
? [[], []]
|
|
: await Promise.all([
|
|
db
|
|
.select()
|
|
.from(connectionGrantMembers)
|
|
.where(
|
|
and(
|
|
eq(connectionGrantMembers.companyId, connection.companyId),
|
|
inArray(connectionGrantMembers.grantId, grantIds),
|
|
),
|
|
),
|
|
db
|
|
.select()
|
|
.from(connectionGrantDelegations)
|
|
.where(
|
|
and(
|
|
eq(
|
|
connectionGrantDelegations.companyId,
|
|
connection.companyId,
|
|
),
|
|
inArray(connectionGrantDelegations.grantId, grantIds),
|
|
),
|
|
),
|
|
]);
|
|
return {
|
|
connection: { id: connection.id, uid: connection.uid },
|
|
grants: grants.map((grant) => ({
|
|
...toConnectionGrant(grant),
|
|
members: members.filter((member) => member.grantId === grant.id),
|
|
delegations: delegations.filter(
|
|
(delegation) => delegation.grantId === grant.id,
|
|
),
|
|
})),
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Company members eligible to appear in an organization grant's audience.
|
|
* The audience editor needs display names, and the client must not have to
|
|
* cross-reference a second endpoint to render "12 selected members".
|
|
*/
|
|
listConnectionAudienceMembers: async (companyId: string) => {
|
|
const rows = await db
|
|
.select({
|
|
userId: companyMemberships.principalId,
|
|
name: authUsers.name,
|
|
email: authUsers.email,
|
|
})
|
|
.from(companyMemberships)
|
|
.leftJoin(authUsers, eq(authUsers.id, companyMemberships.principalId))
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
);
|
|
return rows
|
|
.map((row) => ({
|
|
userId: row.userId,
|
|
name: row.name ?? null,
|
|
email: row.email ?? null,
|
|
}))
|
|
.sort((a, b) =>
|
|
(a.name ?? a.email ?? a.userId).localeCompare(
|
|
b.name ?? b.email ?? b.userId,
|
|
),
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Replace an organization grant's audience atomically (PAP-17835).
|
|
*
|
|
* An empty `memberUserIds` persists as zero rows, which the resolver already
|
|
* treats as "every organization member". Replacement is delete-then-insert
|
|
* inside one transaction so a partially-applied audience can never widen or
|
|
* narrow access, and every member id is checked against active company
|
|
* membership first so an audience cannot name an outsider. Existing audience
|
|
* members are locked too: membership cleanup deliberately retains a sole
|
|
* inactive audience row, and a concurrent empty replacement must not erase
|
|
* that fail-closed marker after cleanup wins. Once cleanup has retained that
|
|
* marker, callers must restore the member or replace it with an active named
|
|
* audience before widening access to the whole company.
|
|
*/
|
|
replaceConnectionGrantMembers: async (
|
|
idOrUid: string,
|
|
grantId: string,
|
|
memberUserIds: string[],
|
|
actor?: ActorInfo,
|
|
) => {
|
|
const connection = await getConnectionRow(idOrUid);
|
|
const [grant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grantId),
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!grant) throw notFound("Connection grant not found");
|
|
if (grant.kind !== "organization") {
|
|
throw badRequest(
|
|
"Only an organization identity has an audience; a personal identity belongs to its owner",
|
|
);
|
|
}
|
|
const requested = [
|
|
...new Set(memberUserIds.map((id) => id.trim()).filter(Boolean)),
|
|
];
|
|
const binding = actorBinding(actor);
|
|
const members = await db.transaction(async (tx) => {
|
|
// Serialize replacements before taking the current audience snapshot.
|
|
// Otherwise a second replacement could read the old rows, wait behind
|
|
// an in-flight replacement, and then miss locking a newly-added member.
|
|
await tx
|
|
.select({ id: connectionGrants.id })
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
),
|
|
)
|
|
.for("update");
|
|
const existingAudience = await tx
|
|
.select({ subjectId: connectionGrantMembers.subjectId })
|
|
.from(connectionGrantMembers)
|
|
.where(
|
|
and(
|
|
eq(connectionGrantMembers.companyId, connection.companyId),
|
|
eq(connectionGrantMembers.grantId, grant.id),
|
|
eq(connectionGrantMembers.subjectType, "user"),
|
|
),
|
|
);
|
|
const existingUserIds = [
|
|
...new Set(existingAudience.map((row) => row.subjectId)),
|
|
];
|
|
const membershipUserIds = [
|
|
...new Set([...existingUserIds, ...requested]),
|
|
];
|
|
if (membershipUserIds.length > 0) {
|
|
// Membership suspension/archive/removal takes the same row lock before
|
|
// sweeping grant audiences. Lock both the old and new audience so an
|
|
// empty replacement also serializes with cleanup of its existing sole
|
|
// member. Whichever transaction wins is then authoritative.
|
|
const memberships = await tx
|
|
.select({
|
|
principalId: companyMemberships.principalId,
|
|
status: companyMemberships.status,
|
|
})
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, connection.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
inArray(companyMemberships.principalId, membershipUserIds),
|
|
),
|
|
)
|
|
.orderBy(asc(companyMemberships.id))
|
|
.for("update");
|
|
const active = new Set(
|
|
memberships
|
|
.filter((row) => row.status === "active")
|
|
.map((row) => row.principalId),
|
|
);
|
|
const unknown = requested.filter((id) => !active.has(id));
|
|
if (unknown.length > 0) {
|
|
throw unprocessable(
|
|
"Every audience member must be an active member of this company",
|
|
{
|
|
code: "audience_member_not_in_company",
|
|
unknownUserIds: unknown,
|
|
},
|
|
);
|
|
}
|
|
const inactiveExisting = existingUserIds.filter(
|
|
(id) => !active.has(id),
|
|
);
|
|
if (requested.length === 0 && inactiveExisting.length > 0) {
|
|
throw conflict(
|
|
"Replace inactive audience members before widening access to the whole company",
|
|
{
|
|
code: "audience_widening_blocked",
|
|
inactiveUserIds: inactiveExisting,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
await tx
|
|
.delete(connectionGrantMembers)
|
|
.where(
|
|
and(
|
|
eq(connectionGrantMembers.companyId, connection.companyId),
|
|
eq(connectionGrantMembers.grantId, grant.id),
|
|
),
|
|
);
|
|
const inserted =
|
|
requested.length === 0
|
|
? []
|
|
: await tx
|
|
.insert(connectionGrantMembers)
|
|
.values(
|
|
requested.map((subjectId) => ({
|
|
companyId: connection.companyId,
|
|
grantId: grant.id,
|
|
subjectType: "user" as const,
|
|
subjectId,
|
|
})),
|
|
)
|
|
.returning();
|
|
await tx
|
|
.update(connectionGrants)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(connectionGrants.id, grant.id));
|
|
return inserted;
|
|
});
|
|
await db.insert(toolAccessAuditEvents).values({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
actorType: binding.actorType ?? "system",
|
|
actorId: binding.actorId,
|
|
action: "connection_grant.audience_replaced",
|
|
outcome: "success",
|
|
reasonCode: "audience_replaced",
|
|
details: {
|
|
grantId: grant.id,
|
|
memberCount: members.length,
|
|
memberUserIds: requested,
|
|
},
|
|
});
|
|
return { ...toConnectionGrant(grant), members };
|
|
},
|
|
|
|
createConnectionGrantDelegation: async (
|
|
idOrUid: string,
|
|
grantId: string,
|
|
agentId: string,
|
|
ownerUserId: string,
|
|
) => {
|
|
const connection = await getConnectionRow(idOrUid);
|
|
if (connection.connectionPurpose === "ai") {
|
|
throw badRequest("AI credentials use the connection's human access settings, not agent delegation");
|
|
}
|
|
return db.transaction(async (tx) => {
|
|
// Membership removal/suspension takes this same row lock before sweeping
|
|
// personal grants. Whichever operation wins is therefore authoritative:
|
|
// removal deletes a delegation committed first, while creation that runs
|
|
// second observes the inactive membership and fails closed.
|
|
const [membership] = await tx
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, connection.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, ownerUserId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
)
|
|
.for("update")
|
|
.limit(1);
|
|
if (!membership) {
|
|
throw forbidden(
|
|
"Only an active company member can delegate their personal grant",
|
|
);
|
|
}
|
|
|
|
const [grant] = await tx
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grantId),
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.subjectUserId, ownerUserId),
|
|
eq(connectionGrants.status, "active"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!grant)
|
|
throw forbidden(
|
|
"Only the active personal grant owner can create a delegation",
|
|
);
|
|
const [targetAgent] = await tx
|
|
.select({ id: agents.id })
|
|
.from(agents)
|
|
.where(
|
|
and(
|
|
eq(agents.id, agentId),
|
|
eq(agents.companyId, connection.companyId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!targetAgent) throw notFound("Agent not found");
|
|
const [existing] = await tx
|
|
.select()
|
|
.from(connectionGrantDelegations)
|
|
.where(
|
|
and(
|
|
eq(connectionGrantDelegations.grantId, grant.id),
|
|
eq(connectionGrantDelegations.agentId, agentId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (existing) return existing;
|
|
const [delegation] = await tx
|
|
.insert(connectionGrantDelegations)
|
|
.values({
|
|
companyId: connection.companyId,
|
|
grantId: grant.id,
|
|
agentId,
|
|
createdByUserId: ownerUserId,
|
|
})
|
|
.returning();
|
|
await tx.insert(toolAccessAuditEvents).values({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
actorType: "user",
|
|
actorId: ownerUserId,
|
|
action: "connection_grant.delegated",
|
|
outcome: "success",
|
|
reasonCode: "delegation_created",
|
|
details: { grantId: grant.id, delegationId: delegation!.id, agentId },
|
|
});
|
|
return delegation!;
|
|
});
|
|
},
|
|
|
|
revokeConnectionGrantDelegation: async (
|
|
idOrUid: string,
|
|
grantId: string,
|
|
delegationId: string,
|
|
actor?: ActorInfo,
|
|
) => {
|
|
const connection = await getConnectionRow(idOrUid);
|
|
const [delegation] = await db
|
|
.delete(connectionGrantDelegations)
|
|
.where(
|
|
and(
|
|
eq(connectionGrantDelegations.id, delegationId),
|
|
eq(connectionGrantDelegations.companyId, connection.companyId),
|
|
eq(connectionGrantDelegations.grantId, grantId),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!delegation) throw notFound("Connection grant delegation not found");
|
|
const binding = actorBinding(actor);
|
|
await db.insert(toolAccessAuditEvents).values({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
actorType: binding.actorType ?? "system",
|
|
actorId: binding.actorId,
|
|
action: "connection_grant.delegation_revoked",
|
|
outcome: "success",
|
|
reasonCode: "delegation_revoked",
|
|
details: { grantId, delegationId, agentId: delegation.agentId },
|
|
});
|
|
return delegation;
|
|
},
|
|
|
|
addConnectionInstallation: async (
|
|
idOrUid: string,
|
|
input: {
|
|
providerTenant?: { name?: string; externalId?: string };
|
|
credentialSecretRefs?: typeof connectionGrants.$inferInsert.credentialSecretRefs;
|
|
isDefault?: boolean;
|
|
},
|
|
actor?: ActorInfo,
|
|
) => {
|
|
const connection = await getConnectionRow(idOrUid);
|
|
await assertSecretRefs(
|
|
connection.companyId,
|
|
input.credentialSecretRefs ?? [],
|
|
);
|
|
if (input.isDefault) {
|
|
await db
|
|
.update(connectionGrants)
|
|
.set({ isDefault: false, updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "organization"),
|
|
),
|
|
);
|
|
}
|
|
const binding = actorBinding(actor);
|
|
const [grant] = await db
|
|
.insert(connectionGrants)
|
|
.values({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
kind: "organization",
|
|
providerTenant: input.providerTenant,
|
|
credentialSecretRefs: input.credentialSecretRefs ?? [],
|
|
status: "active",
|
|
isDefault: input.isDefault ?? false,
|
|
createdByAgentId:
|
|
binding.actorType === "agent" ? binding.actorId : null,
|
|
createdByUserId:
|
|
binding.actorType === "user" ? binding.actorId : null,
|
|
})
|
|
.returning();
|
|
if (!grant) throw new Error("Failed to create connection installation");
|
|
await db.insert(toolAccessAuditEvents).values({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
actorType: binding.actorType ?? "system",
|
|
actorId: binding.actorId,
|
|
action: "connection_grant.created",
|
|
outcome: "success",
|
|
reasonCode: "grant_created",
|
|
details: {
|
|
grantId: grant.id,
|
|
kind: grant.kind,
|
|
isDefault: grant.isDefault,
|
|
},
|
|
});
|
|
return toConnectionGrant(grant);
|
|
},
|
|
|
|
revokeConnectionGrant: async (
|
|
idOrUid: string,
|
|
grantId: string,
|
|
actor?: ActorInfo,
|
|
) => {
|
|
const connection = await getConnectionRow(idOrUid);
|
|
const binding = actorBinding(actor);
|
|
const [currentGrant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grantId),
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!currentGrant) throw notFound("Connection grant not found");
|
|
let providerRevocation = "not_applicable";
|
|
if (
|
|
connection.credentialSource === "vercel_connect" &&
|
|
connection.externalCredential
|
|
) {
|
|
const request = vercelTokenRequest({
|
|
credential: connection.externalCredential,
|
|
grant: currentGrant,
|
|
connectionId: connection.id,
|
|
companyId: connection.companyId,
|
|
resources: vercelConnectResourcesFor(connection),
|
|
});
|
|
vercelConnect?.evict(request);
|
|
if (currentGrant.externalCredential?.subjectType === "app") {
|
|
providerRevocation = "manage_in_vercel";
|
|
} else if (currentGrant.externalCredential?.subjectType === "user") {
|
|
try {
|
|
if (!vercelConnect) throw new Error("Vercel Connect unavailable");
|
|
await vercelConnect.revoke(request);
|
|
providerRevocation = "success";
|
|
} catch {
|
|
providerRevocation = "failed";
|
|
}
|
|
}
|
|
} else if (
|
|
isPaperclipCloudConnectorStrategy(oauthConfig(connection).strategy)
|
|
) {
|
|
// Google revocation is client-wide for a user. The managed Workspace
|
|
// profiles intentionally share one Paperclip-owned client, so revoking
|
|
// one token here could invalidate unrelated Gmail, Drive, and Calendar
|
|
// grants. A per-profile removal is therefore local-only. A future
|
|
// provider-level disconnect must warn that it removes every profile.
|
|
providerRevocation = "local_only_shared_client";
|
|
}
|
|
const grant = await db.transaction(async (tx) => {
|
|
const removedDelegations = await tx
|
|
.delete(connectionGrantDelegations)
|
|
.where(
|
|
and(
|
|
eq(connectionGrantDelegations.companyId, connection.companyId),
|
|
eq(connectionGrantDelegations.grantId, grantId),
|
|
),
|
|
)
|
|
.returning();
|
|
const [updated] = await tx
|
|
.update(connectionGrants)
|
|
.set({
|
|
status: "revoked",
|
|
isDefault: false,
|
|
revokedAt: new Date(),
|
|
revokedByAgentId:
|
|
binding.actorType === "agent" ? binding.actorId : null,
|
|
revokedByUserId:
|
|
binding.actorType === "user" ? binding.actorId : null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grantId),
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!updated) throw notFound("Connection grant not found");
|
|
if (removedDelegations.length > 0) {
|
|
await tx.insert(toolAccessAuditEvents).values(
|
|
removedDelegations.map((delegation) => ({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
actorType: binding.actorType ?? "system",
|
|
actorId: binding.actorId,
|
|
action: "connection_grant.delegation_revoked",
|
|
outcome: "success",
|
|
reasonCode: "grant_revoked",
|
|
details: {
|
|
grantId,
|
|
delegationId: delegation.id,
|
|
agentId: delegation.agentId,
|
|
},
|
|
})),
|
|
);
|
|
}
|
|
return updated;
|
|
});
|
|
if (!grant) throw notFound("Connection grant not found");
|
|
await db.insert(toolAccessAuditEvents).values({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
actorType: binding.actorType ?? "system",
|
|
actorId: binding.actorId,
|
|
action: "connection_grant.revoked",
|
|
outcome: "success",
|
|
reasonCode: "grant_revoked",
|
|
details: { grantId: grant.id, kind: grant.kind, providerRevocation },
|
|
});
|
|
return toConnectionGrant(grant);
|
|
},
|
|
|
|
getConnectionUsage: async (
|
|
idOrUid: string,
|
|
range: "7d" | "30d",
|
|
companyId?: string,
|
|
) => {
|
|
const connection = await getConnectionRow(idOrUid, companyId);
|
|
const days = range === "30d" ? 30 : 7;
|
|
const start = new Date();
|
|
start.setUTCHours(0, 0, 0, 0);
|
|
start.setUTCDate(start.getUTCDate() - days + 1);
|
|
const [issuances, invocations] = await Promise.all([
|
|
db
|
|
.select({
|
|
createdAt: connectionTokenIssuances.createdAt,
|
|
outcome: connectionTokenIssuances.outcome,
|
|
path: connectionTokenIssuances.path,
|
|
})
|
|
.from(connectionTokenIssuances)
|
|
.where(
|
|
and(
|
|
eq(connectionTokenIssuances.companyId, connection.companyId),
|
|
eq(connectionTokenIssuances.connectionId, connection.id),
|
|
gte(connectionTokenIssuances.createdAt, start),
|
|
),
|
|
),
|
|
db
|
|
.select({
|
|
createdAt: toolInvocations.createdAt,
|
|
riskLevel: toolInvocations.riskLevel,
|
|
})
|
|
.from(toolInvocations)
|
|
.where(
|
|
and(
|
|
eq(toolInvocations.companyId, connection.companyId),
|
|
eq(toolInvocations.connectionId, connection.id),
|
|
gte(toolInvocations.createdAt, start),
|
|
),
|
|
),
|
|
]);
|
|
const buckets = Array.from({ length: days }, (_, offset) => {
|
|
const date = new Date(start);
|
|
date.setUTCDate(start.getUTCDate() + offset);
|
|
return {
|
|
date: date.toISOString().slice(0, 10),
|
|
issuances: {
|
|
total: 0,
|
|
byOutcome: {} as Record<string, number>,
|
|
byPath: {} as Record<string, number>,
|
|
},
|
|
invocations: { total: 0, byRiskLevel: {} as Record<string, number> },
|
|
deliveries: { received: 0, forwarded: 0 },
|
|
};
|
|
});
|
|
const byDate = new Map(buckets.map((bucket) => [bucket.date, bucket]));
|
|
for (const row of issuances) {
|
|
const bucket = byDate.get(row.createdAt.toISOString().slice(0, 10));
|
|
if (!bucket) continue;
|
|
bucket.issuances.total += 1;
|
|
bucket.issuances.byOutcome[row.outcome] =
|
|
(bucket.issuances.byOutcome[row.outcome] ?? 0) + 1;
|
|
bucket.issuances.byPath[row.path] =
|
|
(bucket.issuances.byPath[row.path] ?? 0) + 1;
|
|
}
|
|
for (const row of invocations) {
|
|
const bucket = byDate.get(row.createdAt.toISOString().slice(0, 10));
|
|
if (!bucket) continue;
|
|
const riskLevel = row.riskLevel ?? "unknown";
|
|
bucket.invocations.total += 1;
|
|
bucket.invocations.byRiskLevel[riskLevel] =
|
|
(bucket.invocations.byRiskLevel[riskLevel] ?? 0) + 1;
|
|
}
|
|
return {
|
|
connection: { id: connection.id, uid: connection.uid },
|
|
range,
|
|
buckets,
|
|
};
|
|
},
|
|
|
|
listConnectionInstalls,
|
|
|
|
putConnectionInstalls: async (
|
|
connectionId: string,
|
|
input: PutToolConnectionInstalls,
|
|
actor?: ActorInfo,
|
|
): Promise<ToolConnectionInstallSnapshot> => {
|
|
const connection = await getConnectionRow(connectionId);
|
|
const requested = new Map(
|
|
input.installs.map((install) => [
|
|
`${install.targetType}:${install.targetId}`,
|
|
install,
|
|
]),
|
|
);
|
|
for (const install of requested.values()) {
|
|
if (install.targetType === "company") {
|
|
if (install.targetId !== connection.companyId)
|
|
throw unprocessable(
|
|
"Company installs must target the connection company",
|
|
);
|
|
} else {
|
|
await assertOptionalAgent(
|
|
connection.companyId,
|
|
install.targetId,
|
|
"Tool connection install agent",
|
|
);
|
|
}
|
|
}
|
|
const accessExtensions: Array<{
|
|
targetType: "company" | "agent";
|
|
targetId: string;
|
|
profileId: string;
|
|
}> = [];
|
|
await db.transaction(async (tx) => {
|
|
const existing = await tx
|
|
.select()
|
|
.from(toolConnectionInstalls)
|
|
.where(
|
|
and(
|
|
eq(toolConnectionInstalls.companyId, connection.companyId),
|
|
eq(toolConnectionInstalls.connectionId, connection.id),
|
|
),
|
|
);
|
|
const existingKeys = new Set(
|
|
existing.map(
|
|
(install) => `${install.targetType}:${install.targetId}`,
|
|
),
|
|
);
|
|
const removals = existing.filter(
|
|
(install) =>
|
|
!requested.has(`${install.targetType}:${install.targetId}`),
|
|
);
|
|
const removeIds = removals.map((install) => install.id);
|
|
if (removeIds.length > 0) {
|
|
await tx
|
|
.delete(toolConnectionInstalls)
|
|
.where(inArray(toolConnectionInstalls.id, removeIds));
|
|
// Uninstalling must also drop the binding this path created. Installing
|
|
// writes both an install row and a profile binding, so deleting only the
|
|
// install row leaves a binding that no surface can see or remove. The
|
|
// install row is the reach gate (`mintConnectionTokenForAgent` fails with
|
|
// `installation_required`, and the heartbeat only hands over installed
|
|
// connections), so a stale binding grants no reach on its own — but it
|
|
// still makes `finishApp`, which rebuilds `access` from the bindings,
|
|
// read a target the operator already removed.
|
|
//
|
|
// Only bindings tagged `source: "tool_connection_install"` are removed.
|
|
// A binding the operator authored through the access model carries a
|
|
// different source and must survive an uninstall.
|
|
const [installProfile] = await tx
|
|
.select({ id: toolProfiles.id })
|
|
.from(toolProfiles)
|
|
.where(
|
|
and(
|
|
eq(toolProfiles.companyId, connection.companyId),
|
|
eq(toolProfiles.profileKey, `app:${connection.id}`),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (installProfile) {
|
|
for (const install of removals) {
|
|
await tx
|
|
.delete(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, connection.companyId),
|
|
eq(toolProfileBindings.profileId, installProfile.id),
|
|
eq(toolProfileBindings.targetType, install.targetType),
|
|
eq(toolProfileBindings.targetId, install.targetId),
|
|
sql`${toolProfileBindings.metadata}->>'source' = 'tool_connection_install'`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
const additions = [...requested.entries()]
|
|
.filter(([key]) => !existingKeys.has(key))
|
|
.map(([, install]) => install);
|
|
if (additions.length > 0) {
|
|
await tx.insert(toolConnectionInstalls).values(
|
|
additions.map((install) => ({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
targetType: install.targetType,
|
|
targetId: install.targetId,
|
|
createdByAgentId:
|
|
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
|
createdByUserId:
|
|
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
|
|
})),
|
|
);
|
|
}
|
|
if (requested.size > 0 && connection.connectionPurpose !== "ai") {
|
|
const profile = await appProfileForConnection(tx, connection);
|
|
for (const install of requested.values()) {
|
|
const [binding] = await tx
|
|
.insert(toolProfileBindings)
|
|
.values({
|
|
companyId: connection.companyId,
|
|
profileId: profile.id,
|
|
targetType: install.targetType,
|
|
targetId: install.targetId,
|
|
priority: 100,
|
|
metadata: {
|
|
source: "tool_connection_install",
|
|
connectionId: connection.id,
|
|
},
|
|
createdByAgentId:
|
|
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
|
createdByUserId:
|
|
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning({ id: toolProfileBindings.id });
|
|
if (binding)
|
|
accessExtensions.push({
|
|
targetType: install.targetType,
|
|
targetId: install.targetId,
|
|
profileId: profile.id,
|
|
});
|
|
}
|
|
}
|
|
if (removeIds.length > 0 || additions.length > 0) {
|
|
const binding = actorBinding(actor);
|
|
await tx.insert(toolAccessAuditEvents).values({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
actorType: binding.actorType ?? "system",
|
|
actorId: binding.actorId,
|
|
action: "connection_installs.changed",
|
|
outcome: "success",
|
|
reasonCode: "installs_changed",
|
|
details: {
|
|
added: additions.map((install) => ({
|
|
targetType: install.targetType,
|
|
targetId: install.targetId,
|
|
})),
|
|
removed: existing
|
|
.filter((install) => removeIds.includes(install.id))
|
|
.map((install) => ({
|
|
targetType: install.targetType,
|
|
targetId: install.targetId,
|
|
})),
|
|
},
|
|
});
|
|
}
|
|
});
|
|
for (const extension of accessExtensions) {
|
|
await logActivity(db, {
|
|
companyId: connection.companyId,
|
|
actorType: actor?.actorType ?? "system",
|
|
actorId: actor?.actorId ?? "system",
|
|
action: "tool_connection.install_access_extended",
|
|
entityType: "tool_connection",
|
|
entityId: connection.id,
|
|
details: extension,
|
|
});
|
|
}
|
|
return {
|
|
connectionId: connection.id,
|
|
installs: await listConnectionInstalls(
|
|
connection.id,
|
|
connection.companyId,
|
|
),
|
|
};
|
|
},
|
|
|
|
updateConnection: async (
|
|
connectionId: string,
|
|
input: UpdateToolConnection,
|
|
): 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 (existing.transport === "mcp_remote")
|
|
await assertRemoteConnectionEndpointsAllowed(config);
|
|
if (existing.transport === "local_stdio")
|
|
await stdioTemplateId(existing.companyId, config);
|
|
assertLocalStdioCanBeEnabled(
|
|
existing.transport,
|
|
input.enabled ?? existing.enabled,
|
|
);
|
|
await assertGoogleSheetsSpreadsheetOwnership(existing.companyId, config, {
|
|
excludeConnectionId: existing.id,
|
|
});
|
|
await assertSecretRefs(existing.companyId, [
|
|
...(input.credentialRefs ?? existing.credentialRefs),
|
|
...(input.credentialSecretRefs ?? existing.credentialSecretRefs),
|
|
]);
|
|
const [row] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
name: input.name ?? existing.name,
|
|
status: input.status ?? existing.status,
|
|
enabled: input.enabled ?? existing.enabled,
|
|
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();
|
|
await syncCredentialBindings(row);
|
|
await ensureRuntimeSlot(row);
|
|
return toConnection(row);
|
|
},
|
|
|
|
archiveConnection: removeConnection,
|
|
|
|
checkHealth: checkConnectionHealth,
|
|
|
|
refreshCatalog,
|
|
|
|
configureRailwaySsh: async (connectionId: string, companyId: string, input: ConfigureRailwaySsh, actor: ActorInfo): Promise<RailwaySshSetup | null> => {
|
|
const knownHosts = input.action === "enable" ? validateRailwayKnownHosts(input.knownHosts) : "";
|
|
const setup = await db.transaction(async (tx) => {
|
|
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.");
|
|
const [grant] = await tx.select().from(connectionGrants).where(and(eq(connectionGrants.id, input.grantId), eq(connectionGrants.connectionId, connectionId), eq(connectionGrants.companyId, companyId))).for("update");
|
|
if (!grant || (grant.status !== "active" && input.action !== "remove")) throw unprocessable("Select an active Railway authorization.");
|
|
if (grant.kind === "user" && (actor.actorType !== "user" || actor.actorId !== 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,
|
|
|
|
listCatalog: async (
|
|
connectionId: string,
|
|
companyId?: string,
|
|
): Promise<ToolCatalogEntry[]> => {
|
|
const connection = await getConnectionRow(connectionId, companyId);
|
|
let rows = await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(eq(toolCatalogEntries.connectionId, connection.id))
|
|
.orderBy(desc(toolCatalogEntries.updatedAt));
|
|
const cacheExpired =
|
|
connection.transport === "mcp_remote" &&
|
|
connection.status !== "archived" &&
|
|
(rows.length === 0 ||
|
|
!connection.lastCatalogRefreshAt ||
|
|
connection.lastCatalogRefreshAt.getTime() <=
|
|
now().getTime() - catalogCacheTtlMs);
|
|
if (cacheExpired) {
|
|
try {
|
|
await singleFlight(catalogRefreshFlights, connection.id, () =>
|
|
refreshCatalog(connection.id, {
|
|
actorType: "system",
|
|
actorId: "tool_catalog_cache",
|
|
}),
|
|
);
|
|
rows = await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(eq(toolCatalogEntries.connectionId, connection.id))
|
|
.orderBy(desc(toolCatalogEntries.updatedAt));
|
|
} catch (error) {
|
|
// A stale catalog remains useful when the remote server is temporarily
|
|
// unavailable. Empty caches still fail so callers never mistake “no
|
|
// actions discovered” for a successful lookup.
|
|
if (rows.length === 0) throw error;
|
|
}
|
|
}
|
|
return rows.map((row) => toCatalogEntryForConnection(row, connection));
|
|
},
|
|
|
|
/** Recent tool-call events for one connection — drives App detail · Recent activity. */
|
|
listConnectionActivity: async (
|
|
connectionId: string,
|
|
companyId?: string,
|
|
limit = 20,
|
|
): Promise<ToolConnectionActivityResponse> => {
|
|
const connection = await getConnectionRow(connectionId, companyId);
|
|
const safeLimit = Math.max(1, Math.min(100, Math.floor(limit)));
|
|
const rows = await db
|
|
.select()
|
|
.from(toolCallEvents)
|
|
.where(
|
|
and(
|
|
eq(toolCallEvents.companyId, connection.companyId),
|
|
eq(toolCallEvents.connectionId, connection.id),
|
|
),
|
|
)
|
|
.orderBy(desc(toolCallEvents.createdAt))
|
|
.limit(safeLimit);
|
|
const events = rows.map(toToolCallEvent);
|
|
|
|
const issueIds = [
|
|
...new Set(rows.map((row) => row.issueId).filter(Boolean)),
|
|
] as string[];
|
|
const issueRows = issueIds.length
|
|
? await db
|
|
.select({
|
|
id: issues.id,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
})
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, connection.companyId),
|
|
inArray(issues.id, issueIds),
|
|
),
|
|
)
|
|
: [];
|
|
const issueMap = Object.fromEntries(
|
|
issueRows.map((issue) => [
|
|
issue.id,
|
|
{
|
|
identifier: issue.identifier ?? issue.id,
|
|
title: issue.title,
|
|
},
|
|
]),
|
|
);
|
|
|
|
const actionRequestIds = [
|
|
...new Set(rows.map((row) => row.actionRequestId).filter(Boolean)),
|
|
] as string[];
|
|
const requestRows = actionRequestIds.length
|
|
? await db
|
|
.select({
|
|
id: toolActionRequests.id,
|
|
status: toolActionRequests.status,
|
|
resolvedByAgentId: toolActionRequests.resolvedByAgentId,
|
|
resolvedByUserId: toolActionRequests.resolvedByUserId,
|
|
})
|
|
.from(toolActionRequests)
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.companyId, connection.companyId),
|
|
inArray(toolActionRequests.id, actionRequestIds),
|
|
),
|
|
)
|
|
: [];
|
|
|
|
const resolverAgentIds = [
|
|
...new Set(
|
|
requestRows.map((row) => row.resolvedByAgentId).filter(Boolean),
|
|
),
|
|
] as string[];
|
|
const resolverUserIds = [
|
|
...new Set(
|
|
requestRows.map((row) => row.resolvedByUserId).filter(Boolean),
|
|
),
|
|
] as string[];
|
|
const resolverAgents = resolverAgentIds.length
|
|
? await db
|
|
.select({ id: agents.id, name: agents.name })
|
|
.from(agents)
|
|
.where(
|
|
and(
|
|
eq(agents.companyId, connection.companyId),
|
|
inArray(agents.id, resolverAgentIds),
|
|
),
|
|
)
|
|
: [];
|
|
const resolverUsers = resolverUserIds.length
|
|
? await db
|
|
.select({
|
|
id: authUsers.id,
|
|
name: authUsers.name,
|
|
email: authUsers.email,
|
|
})
|
|
.from(authUsers)
|
|
.where(inArray(authUsers.id, resolverUserIds))
|
|
: [];
|
|
const resolverAgentNames = new Map(
|
|
resolverAgents.map((agent) => [agent.id, agent.name]),
|
|
);
|
|
const resolverUserNames = new Map(
|
|
resolverUsers.map((user) => [
|
|
user.id,
|
|
user.name?.trim() || user.email?.trim() || user.id,
|
|
]),
|
|
);
|
|
const actionRequestMap = Object.fromEntries(
|
|
requestRows.map((request) => [
|
|
request.id,
|
|
{
|
|
status: request.status,
|
|
resolverDisplayName: request.resolvedByAgentId
|
|
? (resolverAgentNames.get(request.resolvedByAgentId) ??
|
|
request.resolvedByAgentId)
|
|
: request.resolvedByUserId
|
|
? (resolverUserNames.get(request.resolvedByUserId) ??
|
|
userFallbackName(request.resolvedByUserId))
|
|
: null,
|
|
resolvedByAgentId: request.resolvedByAgentId,
|
|
resolvedByUserId: request.resolvedByUserId,
|
|
},
|
|
]),
|
|
);
|
|
|
|
const lifecycleEvents = await listConnectionLifecycleEvents(db, {
|
|
companyId: connection.companyId,
|
|
connectionIds: [connection.id],
|
|
limit: safeLimit,
|
|
});
|
|
|
|
return {
|
|
connectionId: connection.id,
|
|
events,
|
|
lifecycleEvents,
|
|
issues: issueMap,
|
|
actionRequests: actionRequestMap,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* List "Ask first" action requests for the review queue, enriched with the
|
|
* connection/app context the prosumer card renders. Defaults to pending.
|
|
*/
|
|
listActionRequests: async (
|
|
companyId: string,
|
|
status: ToolActionRequestStatus = "pending",
|
|
): Promise<ToolActionRequestListItem[]> => {
|
|
const requests = await db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.companyId, companyId),
|
|
eq(toolActionRequests.status, status),
|
|
),
|
|
)
|
|
.orderBy(desc(toolActionRequests.createdAt));
|
|
if (requests.length === 0) return [];
|
|
|
|
const invocationIds = [
|
|
...new Set(requests.map((request) => request.invocationId)),
|
|
];
|
|
const invocations = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(
|
|
and(
|
|
eq(toolInvocations.companyId, companyId),
|
|
inArray(toolInvocations.id, invocationIds),
|
|
),
|
|
);
|
|
const invocationById = new Map(
|
|
invocations.map((invocation) => [invocation.id, invocation]),
|
|
);
|
|
let visibleRequests = requests;
|
|
if (status === "pending") {
|
|
// A pending request that the creator has not signed yet is still being
|
|
// set up. The gateway creates the row (signedArguments = null) and signs
|
|
// it in a second step, so a review-queue read can observe the row inside
|
|
// that window. Hide such a request from the queue, but do not cancel it —
|
|
// cancelling here races the two-step create and makes the later approve
|
|
// fail with action_not_pending. Only cancel a request that carries a
|
|
// signature we cannot verify (secret rotation or tampering), or an
|
|
// unsigned row whose creator has exceeded the signing grace period.
|
|
const unsignedRequestIds = new Set<string>();
|
|
const invalidRequestIds: string[] = [];
|
|
for (const request of requests) {
|
|
const invocation = invocationById.get(request.invocationId);
|
|
if (!invocation) {
|
|
invalidRequestIds.push(request.id);
|
|
continue;
|
|
}
|
|
if (request.signedArguments === null) {
|
|
if (
|
|
Date.now() - request.createdAt.getTime() >=
|
|
TOOL_ACTION_REQUEST_SIGNING_GRACE_MS
|
|
) {
|
|
invalidRequestIds.push(request.id);
|
|
} else {
|
|
unsignedRequestIds.add(request.id);
|
|
}
|
|
continue;
|
|
}
|
|
let readable = false;
|
|
try {
|
|
readable = Boolean(
|
|
readSignedToolArgumentsPayload({
|
|
signedArguments: request.signedArguments,
|
|
invocationId: invocation.id,
|
|
toolName: invocation.toolName,
|
|
}),
|
|
);
|
|
} catch {
|
|
readable = false;
|
|
}
|
|
if (!readable) invalidRequestIds.push(request.id);
|
|
}
|
|
if (invalidRequestIds.length > 0) {
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
status: "cancelled",
|
|
resolvedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.companyId, companyId),
|
|
eq(toolActionRequests.status, "pending"),
|
|
inArray(toolActionRequests.id, invalidRequestIds),
|
|
),
|
|
);
|
|
}
|
|
const hiddenIds = new Set([
|
|
...invalidRequestIds,
|
|
...unsignedRequestIds,
|
|
]);
|
|
if (hiddenIds.size > 0) {
|
|
visibleRequests = requests.filter(
|
|
(request) => !hiddenIds.has(request.id),
|
|
);
|
|
}
|
|
}
|
|
if (visibleRequests.length === 0) return [];
|
|
|
|
const visibleInvocations = visibleRequests
|
|
.map((request) => invocationById.get(request.invocationId))
|
|
.filter(
|
|
(invocation): invocation is typeof toolInvocations.$inferSelect =>
|
|
Boolean(invocation),
|
|
);
|
|
const connectionIds = [
|
|
...new Set(
|
|
visibleInvocations
|
|
.map((invocation) => invocation.connectionId)
|
|
.filter(Boolean),
|
|
),
|
|
] as string[];
|
|
const connections = connectionIds.length
|
|
? await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(inArray(toolConnections.id, connectionIds))
|
|
: [];
|
|
const connectionById = new Map(
|
|
connections.map((connection) => [connection.id, connection]),
|
|
);
|
|
|
|
const applicationIds = [
|
|
...new Set(
|
|
connections
|
|
.map((connection) => connection.applicationId)
|
|
.filter(Boolean),
|
|
),
|
|
] as string[];
|
|
const applications = applicationIds.length
|
|
? await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(inArray(toolApplications.id, applicationIds))
|
|
: [];
|
|
const applicationById = new Map(
|
|
applications.map((application) => [application.id, application]),
|
|
);
|
|
|
|
const catalogEntryIds = [
|
|
...new Set(
|
|
visibleInvocations
|
|
.map((invocation) => invocation.catalogEntryId)
|
|
.filter(Boolean),
|
|
),
|
|
] as string[];
|
|
const catalogEntries = catalogEntryIds.length
|
|
? await db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(inArray(toolCatalogEntries.id, catalogEntryIds))
|
|
: [];
|
|
const catalogById = new Map(
|
|
catalogEntries.map((entry) => [entry.id, entry]),
|
|
);
|
|
|
|
return visibleRequests.map((request) => {
|
|
const invocation = invocationById.get(request.invocationId);
|
|
const connection = invocation?.connectionId
|
|
? connectionById.get(invocation.connectionId)
|
|
: undefined;
|
|
const application = connection?.applicationId
|
|
? applicationById.get(connection.applicationId)
|
|
: undefined;
|
|
const catalogEntry = invocation?.catalogEntryId
|
|
? catalogById.get(invocation.catalogEntryId)
|
|
: undefined;
|
|
return {
|
|
request: toToolActionRequest(request),
|
|
toolName: invocation?.toolName ?? catalogEntry?.toolName ?? "",
|
|
toolTitle: catalogEntry?.title ?? null,
|
|
connectionId: connection?.id ?? invocation?.connectionId ?? null,
|
|
connectionName: connection?.name ?? null,
|
|
applicationName: application?.name ?? null,
|
|
riskLevel: catalogEntry?.riskLevel ?? null,
|
|
requestedByAgentId: request.requestedByAgentId ?? null,
|
|
};
|
|
});
|
|
},
|
|
|
|
listProfiles: async (
|
|
companyId: string,
|
|
): Promise<ToolProfileWithDetails[]> => {
|
|
const profiles = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(eq(toolProfiles.companyId, companyId))
|
|
.orderBy(desc(toolProfiles.updatedAt));
|
|
if (profiles.length === 0) return [];
|
|
const profileIds = profiles.map((profile) => profile.id);
|
|
const [
|
|
entries,
|
|
bindings,
|
|
catalog,
|
|
companyAgents,
|
|
applications,
|
|
connections,
|
|
] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, companyId),
|
|
inArray(toolProfileEntries.profileId, profileIds),
|
|
),
|
|
)
|
|
.orderBy(asc(toolProfileEntries.createdAt)),
|
|
db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, companyId),
|
|
inArray(toolProfileBindings.profileId, profileIds),
|
|
),
|
|
)
|
|
.orderBy(
|
|
asc(toolProfileBindings.priority),
|
|
asc(toolProfileBindings.createdAt),
|
|
),
|
|
db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, companyId),
|
|
eq(toolCatalogEntries.status, "active"),
|
|
),
|
|
),
|
|
db
|
|
.select({ id: agents.id })
|
|
.from(agents)
|
|
.where(eq(agents.companyId, companyId)),
|
|
db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.companyId, companyId)),
|
|
db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(eq(toolConnections.companyId, companyId)),
|
|
]);
|
|
const entriesByProfile = new Map<
|
|
string,
|
|
Array<typeof toolProfileEntries.$inferSelect>
|
|
>();
|
|
const bindingsByProfile = new Map<
|
|
string,
|
|
Array<typeof toolProfileBindings.$inferSelect>
|
|
>();
|
|
for (const entry of entries) {
|
|
const list = entriesByProfile.get(entry.profileId) ?? [];
|
|
list.push(entry);
|
|
entriesByProfile.set(entry.profileId, list);
|
|
}
|
|
for (const binding of bindings) {
|
|
const list = bindingsByProfile.get(binding.profileId) ?? [];
|
|
list.push(binding);
|
|
bindingsByProfile.set(binding.profileId, list);
|
|
}
|
|
const agentIds = companyAgents.map((agent) => agent.id);
|
|
const applicationsById = new Map(
|
|
applications.map((application) => [application.id, application]),
|
|
);
|
|
const connectionsById = new Map(
|
|
connections.map((connection) => [connection.id, connection]),
|
|
);
|
|
return profiles.map((profile) =>
|
|
buildProfileDetails({
|
|
profile,
|
|
entries: entriesByProfile.get(profile.id) ?? [],
|
|
bindings: bindingsByProfile.get(profile.id) ?? [],
|
|
catalog,
|
|
agentIds,
|
|
applicationsById,
|
|
connectionsById,
|
|
}),
|
|
);
|
|
},
|
|
|
|
createProfile: async (
|
|
companyId: string,
|
|
input: CreateToolProfileWithEntries,
|
|
): Promise<ToolProfileWithDetails> => {
|
|
for (const entry of input.entries ?? []) {
|
|
await assertProfileEntryInput(companyId, entry);
|
|
}
|
|
const [row] = await db
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId,
|
|
profileKey: input.profileKey,
|
|
name: input.name,
|
|
description: input.description ?? null,
|
|
status: input.status ?? "active",
|
|
defaultAction: input.defaultAction ?? "deny",
|
|
metadata: input.metadata ?? {},
|
|
})
|
|
.returning();
|
|
await createProfileEntries(companyId, row.id, input.entries ?? []);
|
|
return profileDetails(row.id, companyId);
|
|
},
|
|
|
|
getProfile: profileDetails,
|
|
|
|
listProfileNewTools,
|
|
|
|
reviewProfileNewTools,
|
|
|
|
updateProfile: async (
|
|
profileId: string,
|
|
input: UpdateToolProfileWithEntries,
|
|
): Promise<ToolProfileWithDetails> => {
|
|
const existing = await getProfileRow(profileId);
|
|
if (input.entries) {
|
|
for (const entry of input.entries) {
|
|
await assertProfileEntryInput(existing.companyId, entry);
|
|
}
|
|
}
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({
|
|
profileKey: input.profileKey ?? existing.profileKey,
|
|
name: input.name ?? existing.name,
|
|
description: input.description ?? existing.description,
|
|
status: input.status ?? existing.status,
|
|
defaultAction: input.defaultAction ?? existing.defaultAction,
|
|
metadata: input.metadata ?? existing.metadata,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolProfiles.id, profileId));
|
|
if (input.entries) {
|
|
await replaceProfileEntries(
|
|
existing.companyId,
|
|
profileId,
|
|
input.entries,
|
|
);
|
|
}
|
|
return profileDetails(profileId, existing.companyId);
|
|
},
|
|
|
|
duplicateProfile: async (
|
|
profileId: string,
|
|
input: DuplicateToolProfile,
|
|
): Promise<ToolProfileWithDetails> => {
|
|
const existing = await getProfileRow(profileId);
|
|
const [entries, bindings] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, existing.companyId),
|
|
eq(toolProfileEntries.profileId, existing.id),
|
|
),
|
|
)
|
|
.orderBy(asc(toolProfileEntries.createdAt)),
|
|
db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, existing.companyId),
|
|
eq(toolProfileBindings.profileId, existing.id),
|
|
),
|
|
)
|
|
.orderBy(
|
|
asc(toolProfileBindings.priority),
|
|
asc(toolProfileBindings.createdAt),
|
|
),
|
|
]);
|
|
const [created] = await db
|
|
.insert(toolProfiles)
|
|
.values({
|
|
companyId: existing.companyId,
|
|
profileKey: normalizeKey(`${input.name}-${randomUUID().slice(0, 8)}`),
|
|
name: input.name,
|
|
description: existing.description,
|
|
status: "active",
|
|
defaultAction: existing.defaultAction,
|
|
newToolsReviewedAt: existing.newToolsReviewedAt,
|
|
metadata: existing.metadata ?? {},
|
|
})
|
|
.returning();
|
|
if (entries.length > 0) {
|
|
await db.insert(toolProfileEntries).values(
|
|
entries.map((entry) => ({
|
|
companyId: entry.companyId,
|
|
profileId: created.id,
|
|
selectorType: entry.selectorType,
|
|
effect: entry.effect,
|
|
applicationId: entry.applicationId,
|
|
connectionId: entry.connectionId,
|
|
catalogEntryId: entry.catalogEntryId,
|
|
toolName: entry.toolName,
|
|
riskLevel: entry.riskLevel,
|
|
conditions: entry.conditions,
|
|
})),
|
|
);
|
|
}
|
|
if (input.includeAssignments && bindings.length > 0) {
|
|
await db.insert(toolProfileBindings).values(
|
|
bindings.map((binding) => ({
|
|
companyId: binding.companyId,
|
|
profileId: created.id,
|
|
targetType: binding.targetType,
|
|
targetId: binding.targetId,
|
|
priority: binding.priority,
|
|
metadata: binding.metadata ?? {},
|
|
createdByAgentId: binding.createdByAgentId,
|
|
createdByUserId: binding.createdByUserId,
|
|
})),
|
|
);
|
|
}
|
|
return profileDetails(created.id, existing.companyId);
|
|
},
|
|
|
|
deleteProfile: async (
|
|
profileId: string,
|
|
input: DeleteToolProfile,
|
|
): Promise<{
|
|
profile: ToolProfile;
|
|
summary: ToolProfileSummary;
|
|
reassignedToProfileId: string | null;
|
|
reassignedBindingCount: number;
|
|
}> => {
|
|
const existing = await getProfileRow(profileId);
|
|
if (input.force && input.reassignToProfileId) {
|
|
throw badRequest(
|
|
"Use either force or reassignToProfileId when deleting a tool profile, not both",
|
|
);
|
|
}
|
|
const details = await profileDetails(existing.id, existing.companyId);
|
|
if (
|
|
details.summary.isCompanyDefault &&
|
|
!input.force &&
|
|
!input.reassignToProfileId
|
|
) {
|
|
throw unprocessable(
|
|
"Cannot delete the company default tool profile. Reassign the default profile or pass force=true to delete it.",
|
|
{ summary: details.summary },
|
|
);
|
|
}
|
|
|
|
let reassignedBindingCount = 0;
|
|
if (input.reassignToProfileId) {
|
|
if (input.reassignToProfileId === existing.id) {
|
|
throw badRequest(
|
|
"reassignToProfileId must reference a different tool profile",
|
|
);
|
|
}
|
|
const target = await getProfileRow(
|
|
input.reassignToProfileId,
|
|
existing.companyId,
|
|
);
|
|
if (target.status !== "active") {
|
|
throw unprocessable(
|
|
"Tool profile assignments can only be reassigned to an active profile",
|
|
);
|
|
}
|
|
const targetBindings = await db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, existing.companyId),
|
|
eq(toolProfileBindings.profileId, target.id),
|
|
),
|
|
);
|
|
const targetKeys = new Set(
|
|
targetBindings.map(
|
|
(binding) => `${binding.targetType}:${binding.targetId}`,
|
|
),
|
|
);
|
|
const copiedBindings = details.bindings.filter(
|
|
(binding) =>
|
|
!targetKeys.has(`${binding.targetType}:${binding.targetId}`),
|
|
);
|
|
if (copiedBindings.length > 0) {
|
|
await db.insert(toolProfileBindings).values(
|
|
copiedBindings.map((binding) => ({
|
|
companyId: binding.companyId,
|
|
profileId: target.id,
|
|
targetType: binding.targetType,
|
|
targetId: binding.targetId,
|
|
priority: binding.priority,
|
|
metadata: binding.metadata ?? {},
|
|
createdByAgentId: binding.createdByAgentId,
|
|
createdByUserId: binding.createdByUserId,
|
|
})),
|
|
);
|
|
reassignedBindingCount = copiedBindings.length;
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(toolProfiles.id, target.id));
|
|
}
|
|
}
|
|
|
|
const [deleted] = await db
|
|
.delete(toolProfiles)
|
|
.where(eq(toolProfiles.id, existing.id))
|
|
.returning();
|
|
if (!deleted) throw notFound("Tool profile not found");
|
|
return {
|
|
profile: toProfile(deleted),
|
|
summary: details.summary,
|
|
reassignedToProfileId: input.reassignToProfileId ?? null,
|
|
reassignedBindingCount,
|
|
};
|
|
},
|
|
|
|
addProfileEntry: async (
|
|
profileId: string,
|
|
input: CreateToolProfileEntryForProfile,
|
|
): Promise<ToolProfileEntry> => {
|
|
const profile = await getProfileRow(profileId);
|
|
await assertProfileEntryInput(profile.companyId, input);
|
|
const [row] = await db
|
|
.insert(toolProfileEntries)
|
|
.values({
|
|
companyId: profile.companyId,
|
|
profileId: profile.id,
|
|
selectorType: input.selectorType,
|
|
effect: input.effect ?? "include",
|
|
applicationId: input.applicationId ?? null,
|
|
connectionId: input.connectionId ?? null,
|
|
catalogEntryId: input.catalogEntryId ?? null,
|
|
toolName: input.toolName ?? null,
|
|
riskLevel: input.riskLevel ?? null,
|
|
conditions: input.conditions ?? null,
|
|
})
|
|
.returning();
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(toolProfiles.id, profile.id));
|
|
return toProfileEntry(row);
|
|
},
|
|
|
|
getProfileEntry: async (entryId: string): Promise<ToolProfileEntry> => {
|
|
const [row] = await db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.id, entryId));
|
|
if (!row) throw notFound("Tool profile entry not found");
|
|
return toProfileEntry(row);
|
|
},
|
|
|
|
updateProfileEntry: async (
|
|
entryId: string,
|
|
input: UpdateToolProfileEntry,
|
|
): Promise<ToolProfileEntry> => {
|
|
const [existing] = await db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.id, entryId));
|
|
if (!existing) throw notFound("Tool profile entry not found");
|
|
const next: CreateToolProfileEntryForProfile = {
|
|
selectorType: input.selectorType ?? existing.selectorType,
|
|
effect: input.effect ?? existing.effect,
|
|
applicationId: input.applicationId ?? existing.applicationId,
|
|
connectionId: input.connectionId ?? existing.connectionId,
|
|
catalogEntryId: input.catalogEntryId ?? existing.catalogEntryId,
|
|
toolName: input.toolName ?? existing.toolName,
|
|
riskLevel: input.riskLevel ?? existing.riskLevel,
|
|
conditions: input.conditions ?? existing.conditions,
|
|
};
|
|
await assertProfileEntryInput(existing.companyId, next);
|
|
const [row] = await db
|
|
.update(toolProfileEntries)
|
|
.set({
|
|
selectorType: next.selectorType,
|
|
effect: next.effect ?? "include",
|
|
applicationId: next.applicationId ?? null,
|
|
connectionId: next.connectionId ?? null,
|
|
catalogEntryId: next.catalogEntryId ?? null,
|
|
toolName: next.toolName ?? null,
|
|
riskLevel: next.riskLevel ?? null,
|
|
conditions: next.conditions ?? null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolProfileEntries.id, entryId))
|
|
.returning();
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(toolProfiles.id, existing.profileId));
|
|
return toProfileEntry(row);
|
|
},
|
|
|
|
deleteProfileEntry: async (entryId: string): Promise<ToolProfileEntry> => {
|
|
const [row] = await db
|
|
.delete(toolProfileEntries)
|
|
.where(eq(toolProfileEntries.id, entryId))
|
|
.returning();
|
|
if (!row) throw notFound("Tool profile entry not found");
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(toolProfiles.id, row.profileId));
|
|
return toProfileEntry(row);
|
|
},
|
|
|
|
bindProfile: async (
|
|
profileId: string,
|
|
input: CreateToolProfileBindingForProfile,
|
|
actor?: ActorInfo,
|
|
): Promise<ToolProfileBinding> => {
|
|
const profile = await getProfileRow(profileId);
|
|
await assertTargetExists(
|
|
profile.companyId,
|
|
input.targetType,
|
|
input.targetId,
|
|
);
|
|
const [row] = await db
|
|
.insert(toolProfileBindings)
|
|
.values({
|
|
companyId: profile.companyId,
|
|
profileId: profile.id,
|
|
targetType: input.targetType,
|
|
targetId: input.targetId,
|
|
priority: input.priority ?? 100,
|
|
metadata: input.metadata ?? {},
|
|
createdByAgentId:
|
|
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
|
createdByUserId:
|
|
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
|
|
})
|
|
.returning();
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(toolProfiles.id, profile.id));
|
|
return toProfileBinding(row);
|
|
},
|
|
|
|
unbindProfile: async (
|
|
profileId: string,
|
|
input: UnbindToolProfileBinding,
|
|
): Promise<{ unbound: number }> => {
|
|
const profile = await getProfileRow(profileId);
|
|
await assertTargetExists(
|
|
profile.companyId,
|
|
input.targetType,
|
|
input.targetId,
|
|
);
|
|
const rows = await db
|
|
.delete(toolProfileBindings)
|
|
.where(
|
|
and(
|
|
eq(toolProfileBindings.companyId, profile.companyId),
|
|
eq(toolProfileBindings.profileId, profile.id),
|
|
eq(toolProfileBindings.targetType, input.targetType),
|
|
eq(toolProfileBindings.targetId, input.targetId),
|
|
),
|
|
)
|
|
.returning({ id: toolProfileBindings.id });
|
|
if (rows.length > 0) {
|
|
await db
|
|
.update(toolProfiles)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(toolProfiles.id, profile.id));
|
|
}
|
|
return { unbound: rows.length };
|
|
},
|
|
|
|
getEffectiveProfilesForAgent: async (
|
|
companyId: string,
|
|
agentId: string,
|
|
): Promise<ToolProfileEffectiveSummary> => {
|
|
await assertOptionalAgent(
|
|
companyId,
|
|
agentId,
|
|
"Tool profile effective agent",
|
|
);
|
|
const allBindings = await db
|
|
.select()
|
|
.from(toolProfileBindings)
|
|
.where(eq(toolProfileBindings.companyId, companyId))
|
|
.orderBy(
|
|
asc(toolProfileBindings.priority),
|
|
asc(toolProfileBindings.createdAt),
|
|
);
|
|
const matchingBindings = allBindings.filter(
|
|
(binding) =>
|
|
(binding.targetType === "company" &&
|
|
binding.targetId === companyId) ||
|
|
(binding.targetType === "agent" && binding.targetId === agentId),
|
|
);
|
|
if (matchingBindings.length === 0) {
|
|
return {
|
|
agentId,
|
|
profiles: [],
|
|
entries: [],
|
|
bindings: [],
|
|
allowedTools: [],
|
|
allowedToolNames: [],
|
|
installedConnections: await resolveInstalledConnectionsForAgent(
|
|
companyId,
|
|
agentId,
|
|
),
|
|
};
|
|
}
|
|
const candidateProfileIds = profileIdsInBindingOrder(matchingBindings);
|
|
const candidateProfiles = await db
|
|
.select()
|
|
.from(toolProfiles)
|
|
.where(
|
|
and(
|
|
eq(toolProfiles.companyId, companyId),
|
|
inArray(toolProfiles.id, candidateProfileIds),
|
|
),
|
|
);
|
|
const bindings = effectiveToolProfileBindings(
|
|
matchingBindings,
|
|
candidateProfiles,
|
|
);
|
|
const profileIds = profileIdsInBindingOrder(bindings);
|
|
const profilesById = new Map(
|
|
candidateProfiles.map((profile) => [profile.id, profile]),
|
|
);
|
|
const activeProfiles = profileIds
|
|
.map((profileId) => profilesById.get(profileId) ?? null)
|
|
.filter((profile): profile is typeof toolProfiles.$inferSelect =>
|
|
Boolean(profile && profile.status === "active"),
|
|
);
|
|
if (activeProfiles.length === 0) {
|
|
return {
|
|
agentId,
|
|
profiles: [],
|
|
entries: [],
|
|
bindings: bindings.map(toProfileBinding),
|
|
allowedTools: [],
|
|
allowedToolNames: [],
|
|
installedConnections: await resolveInstalledConnectionsForAgent(
|
|
companyId,
|
|
agentId,
|
|
),
|
|
};
|
|
}
|
|
const activeProfileIds = activeProfiles.map((profile) => profile.id);
|
|
const [entries, catalog, companyAgents] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(toolProfileEntries)
|
|
.where(
|
|
and(
|
|
eq(toolProfileEntries.companyId, companyId),
|
|
inArray(toolProfileEntries.profileId, activeProfileIds),
|
|
),
|
|
)
|
|
.orderBy(asc(toolProfileEntries.createdAt)),
|
|
db
|
|
.select()
|
|
.from(toolCatalogEntries)
|
|
.where(
|
|
and(
|
|
eq(toolCatalogEntries.companyId, companyId),
|
|
eq(toolCatalogEntries.status, "active"),
|
|
),
|
|
)
|
|
.orderBy(asc(toolCatalogEntries.toolName)),
|
|
db
|
|
.select({ id: agents.id })
|
|
.from(agents)
|
|
.where(eq(agents.companyId, companyId)),
|
|
]);
|
|
const entriesByProfile = new Map<
|
|
string,
|
|
Array<typeof toolProfileEntries.$inferSelect>
|
|
>();
|
|
for (const entry of entries) {
|
|
const list = entriesByProfile.get(entry.profileId) ?? [];
|
|
list.push(entry);
|
|
entriesByProfile.set(entry.profileId, list);
|
|
}
|
|
const allowedCatalogIds = new Set<string>();
|
|
const allowedToolNames = new Set<string>();
|
|
for (const profile of activeProfiles) {
|
|
const profileEntries = entriesByProfile.get(profile.id) ?? [];
|
|
const includes = profileEntries.filter(
|
|
(entry) => entry.effect === "include",
|
|
);
|
|
const excludes = profileEntries.filter(
|
|
(entry) => entry.effect === "exclude",
|
|
);
|
|
for (const catalogEntry of catalog) {
|
|
if (
|
|
excludes.some((entry) =>
|
|
profileEntryMatchesCatalog(entry, catalogEntry),
|
|
)
|
|
)
|
|
continue;
|
|
if (
|
|
profile.defaultAction === "allow" ||
|
|
includes.some((entry) =>
|
|
profileEntryMatchesCatalog(entry, catalogEntry),
|
|
)
|
|
) {
|
|
allowedCatalogIds.add(catalogEntry.id);
|
|
allowedToolNames.add(catalogEntry.toolName);
|
|
}
|
|
}
|
|
for (const entry of includes.filter(
|
|
(item) => item.selectorType === "tool_name" && item.toolName,
|
|
)) {
|
|
const matchingExclude = excludes.some(
|
|
(item) =>
|
|
item.selectorType === "tool_name" &&
|
|
item.toolName === entry.toolName,
|
|
);
|
|
if (!matchingExclude) allowedToolNames.add(entry.toolName!);
|
|
}
|
|
}
|
|
const agentIds = companyAgents.map((agent) => agent.id);
|
|
const details: ToolProfileWithDetails[] = activeProfiles.map((profile) =>
|
|
buildProfileDetails({
|
|
profile,
|
|
entries: entriesByProfile.get(profile.id) ?? [],
|
|
bindings: bindings.filter(
|
|
(binding) => binding.profileId === profile.id,
|
|
),
|
|
catalog,
|
|
agentIds,
|
|
}),
|
|
);
|
|
const allowedTools = catalog
|
|
.filter((entry) => allowedCatalogIds.has(entry.id))
|
|
.map(toCatalogEntry);
|
|
return {
|
|
agentId,
|
|
profiles: details,
|
|
entries: entries.map(toProfileEntry),
|
|
bindings: bindings.map(toProfileBinding),
|
|
allowedTools,
|
|
allowedToolNames: [...allowedToolNames].sort((a, b) =>
|
|
a.localeCompare(b),
|
|
),
|
|
installedConnections: await resolveInstalledConnectionsForAgent(
|
|
companyId,
|
|
agentId,
|
|
),
|
|
};
|
|
},
|
|
|
|
mintConnectionTokenForAgent: async (input: {
|
|
connectionId: string;
|
|
companyId: string;
|
|
agentId: string;
|
|
runId: string;
|
|
body: ConnectionTokenRequest;
|
|
}): Promise<ConnectionTokenResponse> => {
|
|
const runContext = await loadBrokerRunContext({
|
|
companyId: input.companyId,
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
});
|
|
const connection = await getConnectionRow(
|
|
input.connectionId,
|
|
input.companyId,
|
|
);
|
|
if (connection.connectionPurpose === "ai") throw unprocessable("AI credentials are available only through the runtime resolver");
|
|
const application = await getConnectionApplication(connection);
|
|
const brokerEnabled = connectionTokenBrokerEnabled(connection);
|
|
const path = brokerEnabled
|
|
? inferConnectionTokenPath(connection, application)
|
|
: "static";
|
|
const requestedScope = normalizeConnectionTokenScopes(input.body.scope);
|
|
const parentScopes = parentScopesForConnection(connection);
|
|
const fallbackScopes = defaultScopesForConnection(connection);
|
|
const issuedScope =
|
|
requestedScope.length > 0
|
|
? requestedScope
|
|
: fallbackScopes.length > 0
|
|
? fallbackScopes
|
|
: parentScopes;
|
|
const ttlSeconds = requestedTtlSeconds(input.body, connection);
|
|
const attribution = {
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
issueId: runContext.issueId,
|
|
projectId: runContext.projectId,
|
|
responsibleUserId: runContext.responsibleUserId,
|
|
};
|
|
|
|
const recordFailure = async (
|
|
outcome: ConnectionTokenIssuanceOutcome,
|
|
errorCode: string,
|
|
details: Record<string, unknown> = {},
|
|
) => {
|
|
await recordConnectionTokenIssuance({
|
|
companyId: connection.companyId,
|
|
applicationId: connection.applicationId,
|
|
connectionId: connection.id,
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
issueId: runContext.issueId,
|
|
projectId: runContext.projectId,
|
|
responsibleUserId: runContext.responsibleUserId,
|
|
path,
|
|
requestedScope,
|
|
issuedScope,
|
|
ttlSeconds: outcome === "use_env_lease" ? null : ttlSeconds,
|
|
expiresAt: null,
|
|
tokenHash: null,
|
|
outcome,
|
|
errorCode,
|
|
metadata: details,
|
|
});
|
|
await auditConnectionTokenIssuance({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
path,
|
|
outcome,
|
|
reasonCode: errorCode,
|
|
details,
|
|
});
|
|
};
|
|
|
|
const fail = async (
|
|
status: number,
|
|
message: string,
|
|
outcome: ConnectionTokenIssuanceOutcome,
|
|
errorCode: string,
|
|
details: Record<string, unknown> = {},
|
|
): Promise<never> => {
|
|
await recordFailure(outcome, errorCode, details);
|
|
throw new HttpError(status, message, {
|
|
code: errorCode,
|
|
path,
|
|
...details,
|
|
});
|
|
};
|
|
|
|
const [install] = await db
|
|
.select({ id: toolConnectionInstalls.id })
|
|
.from(toolConnectionInstalls)
|
|
.where(
|
|
and(
|
|
eq(toolConnectionInstalls.companyId, connection.companyId),
|
|
eq(toolConnectionInstalls.connectionId, connection.id),
|
|
sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${connection.companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${input.agentId}))`,
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!install) {
|
|
await fail(
|
|
403,
|
|
`Connection ${connection.name} must be installed for this agent before it can mint a token`,
|
|
"denied",
|
|
"installation_required",
|
|
{
|
|
connection: {
|
|
id: connection.id,
|
|
uid: connection.uid,
|
|
name: connection.name,
|
|
},
|
|
agentId: input.agentId,
|
|
remediation: {
|
|
action: "install_connection",
|
|
targetType: "agent",
|
|
targetId: input.agentId,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
if (
|
|
runContext.run.activeIdentityContextId &&
|
|
(connection.config.sourceTemplateKey === "github" ||
|
|
connection.transportConfig?.sourceTemplateKey === "github")
|
|
) {
|
|
await fail(
|
|
409,
|
|
"Use managed git, gh, or GitHub tools for this run",
|
|
"denied",
|
|
"managed_github_invocation_required",
|
|
);
|
|
}
|
|
const requestedSubject = input.body.subject;
|
|
if (
|
|
requestedSubject?.type === "user" &&
|
|
requestedSubject.userId !== runContext.responsibleUserId
|
|
) {
|
|
await fail(
|
|
403,
|
|
"The agent run cannot act as the requested user",
|
|
"denied",
|
|
"subject_not_permitted",
|
|
{
|
|
connection: { uid: connection.uid },
|
|
subject: requestedSubject,
|
|
},
|
|
);
|
|
}
|
|
|
|
const actingUserId = runContext.responsibleUserId;
|
|
const autonomous =
|
|
runContext.run.invocationSource === "automation" ||
|
|
runContext.run.invocationSource === "timer";
|
|
let subject: { type: "app" } | { type: "user"; userId: string } =
|
|
connection.credentialPolicy === "shared" ||
|
|
connection.credentialPolicy === "per_agent" ||
|
|
!actingUserId
|
|
? { type: "app" as const }
|
|
: { type: "user" as const, userId: actingUserId };
|
|
let grant: typeof connectionGrants.$inferSelect | undefined;
|
|
if (connection.credentialPolicy === "per_agent") {
|
|
[grant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "agent"),
|
|
eq(connectionGrants.subjectAgentId, input.agentId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!grant) {
|
|
await fail(
|
|
409,
|
|
"This agent's dedicated authorization is required",
|
|
"denied",
|
|
"agent_authorization_required",
|
|
{
|
|
connection: { uid: connection.uid },
|
|
agentId: input.agentId,
|
|
remediation: {
|
|
action: "start_agent_authorization",
|
|
agentId: input.agentId,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
} else if (connection.credentialPolicy !== "shared" && actingUserId) {
|
|
// Installation targets express the grant owner's consent to agent use;
|
|
// the control-plane-resolved responsible user selects whose grant is in
|
|
// force. A separate standing delegation is required only when no such
|
|
// responsible user exists for unattended work.
|
|
const [membership] = await db
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, connection.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, actingUserId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!membership) {
|
|
await fail(
|
|
403,
|
|
"The personal grant owner is not an active company member",
|
|
"denied",
|
|
"grant_owner_membership_inactive",
|
|
{
|
|
connection: {
|
|
id: connection.id,
|
|
uid: connection.uid,
|
|
name: connection.name,
|
|
},
|
|
subject: { type: "user", userId: actingUserId },
|
|
remediation: { action: "restore_membership_or_reconnect" },
|
|
},
|
|
);
|
|
}
|
|
[grant] = await db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.subjectUserId, actingUserId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
} else if (connection.credentialPolicy === "per_user" && autonomous) {
|
|
const delegated = await db
|
|
.select({ grant: connectionGrants })
|
|
.from(connectionGrantDelegations)
|
|
.innerJoin(
|
|
connectionGrants,
|
|
and(
|
|
eq(connectionGrants.id, connectionGrantDelegations.grantId),
|
|
eq(
|
|
connectionGrants.companyId,
|
|
connectionGrantDelegations.companyId,
|
|
),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(connectionGrantDelegations.companyId, connection.companyId),
|
|
eq(connectionGrantDelegations.agentId, input.agentId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "user"),
|
|
),
|
|
)
|
|
.limit(2);
|
|
if (delegated.length > 1) {
|
|
await fail(
|
|
409,
|
|
"More than one delegated personal authorization matches this run",
|
|
"denied",
|
|
"subject_not_permitted",
|
|
{
|
|
connection: { uid: connection.uid },
|
|
agentId: input.agentId,
|
|
},
|
|
);
|
|
}
|
|
grant = delegated[0]?.grant;
|
|
if (grant?.subjectUserId)
|
|
subject = { type: "user", userId: grant.subjectUserId };
|
|
}
|
|
if (!grant && connection.credentialPolicy === "per_user") {
|
|
await fail(
|
|
409,
|
|
"User authorization is required",
|
|
"denied",
|
|
"user_authorization_required",
|
|
{
|
|
connection: { uid: connection.uid },
|
|
subject: actingUserId
|
|
? { type: "user", userId: actingUserId }
|
|
: { type: "app" },
|
|
remediation: { action: "start_authorization" },
|
|
},
|
|
);
|
|
}
|
|
if (!grant) {
|
|
grant = await ensureDefaultOrganizationGrant(connection);
|
|
}
|
|
|
|
if (
|
|
grant.kind === "user" &&
|
|
grant.subjectUserId &&
|
|
grant.subjectUserId !== actingUserId
|
|
) {
|
|
const [membership] = await db
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, connection.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, grant.subjectUserId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!membership) {
|
|
await fail(
|
|
403,
|
|
"The delegated personal grant owner is not an active company member",
|
|
"denied",
|
|
"grant_owner_membership_inactive",
|
|
{
|
|
connection: {
|
|
id: connection.id,
|
|
uid: connection.uid,
|
|
name: connection.name,
|
|
},
|
|
subject,
|
|
remediation: { action: "restore_membership_or_reconnect" },
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
if (grant.kind === "organization") {
|
|
const activeAudienceMember = actingUserId
|
|
? await db
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, connection.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, actingUserId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null)
|
|
: null;
|
|
const audience = await db
|
|
.select({ subjectId: connectionGrantMembers.subjectId })
|
|
.from(connectionGrantMembers)
|
|
.where(
|
|
and(
|
|
eq(connectionGrantMembers.companyId, connection.companyId),
|
|
eq(connectionGrantMembers.grantId, grant.id),
|
|
eq(connectionGrantMembers.subjectType, "user"),
|
|
),
|
|
);
|
|
if (
|
|
(actingUserId !== null && !activeAudienceMember) ||
|
|
(audience.length > 0 &&
|
|
(!actingUserId ||
|
|
!audience.some((member) => member.subjectId === actingUserId)))
|
|
) {
|
|
await fail(
|
|
403,
|
|
"The acting user is not in this grant's audience",
|
|
"denied",
|
|
"grant_audience_denied",
|
|
{
|
|
connection: { uid: connection.uid },
|
|
subject,
|
|
grantId: grant.id,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
if (grant.status !== "active") {
|
|
const code =
|
|
grant.status === "needs_reauthorization"
|
|
? "needs_reauthorization"
|
|
: "grant_revoked";
|
|
await fail(
|
|
409,
|
|
"The selected connection grant is not active",
|
|
"denied",
|
|
code,
|
|
{
|
|
connection: { uid: connection.uid },
|
|
subject,
|
|
grantId: grant.id,
|
|
remediation: { action: "reauthorize" },
|
|
},
|
|
);
|
|
}
|
|
const requestedScopeSelectors = new Set(requestedScope);
|
|
const matchingScopedRefs = grant.credentialSecretRefs.filter(
|
|
(ref) => ref.keyScope && requestedScopeSelectors.has(ref.keyScope),
|
|
);
|
|
const selectedCredentialSecretRefs =
|
|
matchingScopedRefs.length > 0
|
|
? grant.credentialSecretRefs.filter(
|
|
(ref) =>
|
|
!ref.keyScope || requestedScopeSelectors.has(ref.keyScope),
|
|
)
|
|
: grant.credentialSecretRefs.filter((ref) => !ref.keyScope);
|
|
const rotateBefore = Date.now() + 14 * 24 * 60 * 60 * 1000;
|
|
const expiringRef = selectedCredentialSecretRefs.find(
|
|
(ref) => ref.expiresAt && Date.parse(ref.expiresAt) <= rotateBefore,
|
|
);
|
|
if (expiringRef && connection.healthStatus !== "degraded") {
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
healthStatus: "degraded",
|
|
healthMessage: `Rotate ${expiringRef.label ?? expiringRef.configPath} before it expires.`,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, connection.id));
|
|
}
|
|
const credentialConnection = {
|
|
...connection,
|
|
credentialSecretRefs: selectedCredentialSecretRefs,
|
|
};
|
|
|
|
if (!connection.enabled || connection.status !== "active") {
|
|
await fail(
|
|
409,
|
|
"Connection is not active",
|
|
"denied",
|
|
"connection_not_active",
|
|
{
|
|
connectionStatus: connection.status,
|
|
enabled: connection.enabled,
|
|
},
|
|
);
|
|
}
|
|
if (
|
|
["failed", "error", "missing_secret"].includes(connection.healthStatus)
|
|
) {
|
|
await fail(
|
|
409,
|
|
"Connection credential needs attention",
|
|
"denied",
|
|
"credential_revoked",
|
|
{
|
|
healthStatus: connection.healthStatus,
|
|
healthMessage: connection.healthMessage ?? null,
|
|
},
|
|
);
|
|
}
|
|
if (!brokerEnabled) {
|
|
await fail(
|
|
403,
|
|
"Connection token broker is not enabled for this connection",
|
|
"denied",
|
|
"broker_not_enabled",
|
|
{
|
|
reason:
|
|
"Connections must explicitly opt in with tokenBroker.enabled before agents can request brokered tokens.",
|
|
},
|
|
);
|
|
}
|
|
try {
|
|
assertScopeSubset({ requestedScope: issuedScope, parentScopes });
|
|
} catch {
|
|
await fail(
|
|
403,
|
|
"Requested token scope exceeds the connection parent scope",
|
|
"denied",
|
|
"scope_exceeds_parent",
|
|
{
|
|
parentScopeCount: parentScopes.length,
|
|
},
|
|
);
|
|
}
|
|
|
|
const hasBrokerGrant = await hasExplicitConnectionTokenMintProfileGrant({
|
|
companyId: connection.companyId,
|
|
agentId: input.agentId,
|
|
issueId: runContext.issueId,
|
|
projectId: runContext.projectId,
|
|
routineId: runContext.routineId,
|
|
});
|
|
if (!hasBrokerGrant) {
|
|
await fail(
|
|
403,
|
|
"Connection token minting requires an explicit broker profile grant",
|
|
"denied",
|
|
"broker_mint_not_granted",
|
|
{
|
|
reason:
|
|
"A connection-level profile grant is not sufficient for connection_token.mint.",
|
|
},
|
|
);
|
|
}
|
|
|
|
const decisionInput = {
|
|
companyId: connection.companyId,
|
|
actor: {
|
|
actorType: "agent" as const,
|
|
actorId: input.agentId,
|
|
agentId: input.agentId,
|
|
},
|
|
runContext: {
|
|
heartbeatRunId: input.runId,
|
|
issueId: runContext.issueId,
|
|
projectId: runContext.projectId,
|
|
routineId: runContext.routineId,
|
|
},
|
|
request: {
|
|
applicationId: connection.applicationId,
|
|
connectionId: connection.id,
|
|
providerType: "connection_token_broker",
|
|
applicationKey: application?.applicationKey ?? null,
|
|
upstreamToolName: CONNECTION_TOKEN_MINT_TOOL_NAME,
|
|
riskLevel: "write",
|
|
toolName: CONNECTION_TOKEN_MINT_TOOL_NAME,
|
|
arguments: {
|
|
path,
|
|
scope: issuedScope,
|
|
requestedTtlSeconds: input.body.requestedTtlSeconds ?? null,
|
|
},
|
|
},
|
|
consumeRateLimit: true,
|
|
};
|
|
const decision = await policySvc.decide(decisionInput);
|
|
await policySvc.writeAudit(decisionInput, decision);
|
|
if (!decision.allowed) {
|
|
await fail(
|
|
decision.decision === "rate_limited" ? 429 : 403,
|
|
decision.explanation,
|
|
decision.decision === "rate_limited" ? "rate_limited" : "denied",
|
|
decision.reasonCode,
|
|
{
|
|
decision: decision.decision,
|
|
effectiveProfileIds: decision.effectiveProfileIds,
|
|
matchedPolicyIds: decision.matchedPolicyIds,
|
|
rateLimitState: decision.rateLimitState ?? null,
|
|
},
|
|
);
|
|
}
|
|
|
|
try {
|
|
await enforceDefaultConnectionTokenRateLimit({
|
|
connection,
|
|
agentId: input.agentId,
|
|
path,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof HttpError && error.status === 429) {
|
|
await fail(
|
|
429,
|
|
error.message,
|
|
"rate_limited",
|
|
"rate_limited",
|
|
asRecord(error.details),
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (path === "static") {
|
|
await recordFailure("use_env_lease", "use_env_lease", {
|
|
reason:
|
|
"Connection uses durable static credentials; broker token delivery is refused.",
|
|
});
|
|
return {
|
|
status: "use_env_lease",
|
|
code: "use_env_lease",
|
|
connectionId: connection.id,
|
|
connection: { id: connection.id, uid: connection.uid },
|
|
grantId: grant.id,
|
|
path: "static",
|
|
message:
|
|
"This connection uses static credentials. Use an audited environment lease projection instead.",
|
|
scope: issuedScope,
|
|
attribution,
|
|
};
|
|
}
|
|
if (path === "oauth_access") {
|
|
await fail(
|
|
422,
|
|
"OAuth access-token projection is disabled; configure a short-lived exchange mint path instead",
|
|
"denied",
|
|
"oauth_access_projection_disabled",
|
|
{
|
|
reason:
|
|
"The broker must not return stored upstream OAuth bearer tokens directly.",
|
|
},
|
|
);
|
|
}
|
|
|
|
const selectedGrant = grant;
|
|
try {
|
|
const mintResult = await db.transaction(async (tx) => {
|
|
await lockAuthorizedBrokerResponsibleMembership(
|
|
{
|
|
companyId: connection.companyId,
|
|
responsibleUserId: runContext.responsibleUserId,
|
|
},
|
|
tx,
|
|
);
|
|
const minted = await mintExchangeConnectionToken(
|
|
{
|
|
connection: credentialConnection,
|
|
application,
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
issueId: runContext.issueId,
|
|
responsibleUserId: runContext.responsibleUserId,
|
|
scope: issuedScope,
|
|
ttlSeconds,
|
|
},
|
|
secretService(tx),
|
|
);
|
|
const expiresAt = minted.expiresAt;
|
|
const mintedScope = "scope" in minted ? minted.scope : issuedScope;
|
|
const effectiveTtlSeconds = Math.max(
|
|
1,
|
|
Math.min(
|
|
900,
|
|
Math.ceil((expiresAt.getTime() - now().getTime()) / 1000),
|
|
),
|
|
);
|
|
const tokenHash = bearerTokenHash(minted.token);
|
|
await recordConnectionTokenIssuance(
|
|
{
|
|
companyId: connection.companyId,
|
|
applicationId: connection.applicationId,
|
|
connectionId: connection.id,
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
issueId: runContext.issueId,
|
|
projectId: runContext.projectId,
|
|
responsibleUserId: runContext.responsibleUserId,
|
|
path,
|
|
requestedScope,
|
|
issuedScope: mintedScope,
|
|
ttlSeconds: effectiveTtlSeconds,
|
|
expiresAt,
|
|
tokenHash,
|
|
outcome: "success",
|
|
metadata: { tokenRef: tokenHash, tokenType: minted.tokenType },
|
|
},
|
|
tx,
|
|
);
|
|
await tx
|
|
.update(connectionGrants)
|
|
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
|
|
.where(eq(connectionGrants.id, selectedGrant.id));
|
|
return {
|
|
minted,
|
|
expiresAt,
|
|
mintedScope,
|
|
effectiveTtlSeconds,
|
|
tokenHash,
|
|
};
|
|
});
|
|
await auditConnectionTokenIssuance({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
path,
|
|
outcome: "success",
|
|
details: {
|
|
ttlSeconds: mintResult.effectiveTtlSeconds,
|
|
scopeCount: mintResult.mintedScope.length,
|
|
tokenRef: mintResult.tokenHash,
|
|
},
|
|
});
|
|
return {
|
|
status: "minted",
|
|
connectionId: connection.id,
|
|
connection: { id: connection.id, uid: connection.uid },
|
|
grantId: selectedGrant.id,
|
|
providerTenantId: selectedGrant.providerTenant?.externalId,
|
|
path: "exchange",
|
|
token: mintResult.minted.token,
|
|
tokenType: mintResult.minted.tokenType,
|
|
expiresAt: mintResult.expiresAt.toISOString(),
|
|
ttlSeconds: mintResult.effectiveTtlSeconds,
|
|
scope: mintResult.mintedScope,
|
|
attribution,
|
|
};
|
|
} catch (error) {
|
|
const details =
|
|
error instanceof HttpError && asRecord(error.details).code
|
|
? asRecord(error.details)
|
|
: {};
|
|
const errorCode =
|
|
typeof details.code === "string" ? details.code : "mint_failed";
|
|
const outcome: ConnectionTokenIssuanceOutcome =
|
|
errorCode === "upstream_error" ||
|
|
errorCode === "upstream_token_missing"
|
|
? "upstream_error"
|
|
: "failure";
|
|
await recordFailure(outcome, errorCode, {
|
|
...details,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
listRuntimeSlots: async (companyId: string): Promise<ToolRuntimeSlot[]> => {
|
|
const rows = await db
|
|
.select()
|
|
.from(toolRuntimeSlots)
|
|
.where(eq(toolRuntimeSlots.companyId, companyId))
|
|
.orderBy(desc(toolRuntimeSlots.updatedAt));
|
|
return rows.map(toRuntimeSlot);
|
|
},
|
|
|
|
stopRuntimeSlot: (
|
|
companyId: string,
|
|
slotId: string,
|
|
actor?: ActorInfo,
|
|
): Promise<ToolRuntimeSlot> =>
|
|
controlRuntimeSlot({ companyId, slotId, action: "stop", actor }),
|
|
|
|
restartRuntimeSlot: (
|
|
companyId: string,
|
|
slotId: string,
|
|
actor?: ActorInfo,
|
|
): Promise<ToolRuntimeSlot> =>
|
|
controlRuntimeSlot({ companyId, slotId, action: "restart", actor }),
|
|
|
|
getRuntimeHealth: runtimeHealth,
|
|
|
|
getRunDecisionLookup: async (
|
|
companyId: string,
|
|
runId: string,
|
|
): Promise<ToolRunDecisionLookup> => {
|
|
const [run] = await db
|
|
.select({ id: heartbeatRuns.id })
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, runId),
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!run) throw notFound("Run not found");
|
|
|
|
const invocationRows = await db
|
|
.select()
|
|
.from(toolInvocations)
|
|
.where(
|
|
and(
|
|
eq(toolInvocations.companyId, companyId),
|
|
eq(toolInvocations.runId, runId),
|
|
),
|
|
)
|
|
.orderBy(desc(toolInvocations.createdAt));
|
|
const invocationIds = invocationRows.map((row) => row.id);
|
|
const [actionRequestRows, auditEventRows] =
|
|
invocationIds.length > 0
|
|
? await Promise.all([
|
|
db
|
|
.select()
|
|
.from(toolActionRequests)
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.companyId, companyId),
|
|
inArray(toolActionRequests.invocationId, invocationIds),
|
|
),
|
|
),
|
|
db
|
|
.select()
|
|
.from(toolCallEvents)
|
|
.where(
|
|
and(
|
|
eq(toolCallEvents.companyId, companyId),
|
|
eq(toolCallEvents.runId, runId),
|
|
inArray(toolCallEvents.invocationId, invocationIds),
|
|
),
|
|
)
|
|
.orderBy(desc(toolCallEvents.createdAt)),
|
|
])
|
|
: [[], []];
|
|
|
|
const actionRequestByInvocation = new Map(
|
|
actionRequestRows.map((row) => [row.invocationId, row]),
|
|
);
|
|
const auditEventsByInvocation = new Map<
|
|
string,
|
|
(typeof toolCallEvents.$inferSelect)[]
|
|
>();
|
|
for (const event of auditEventRows) {
|
|
if (!event.invocationId) continue;
|
|
const events = auditEventsByInvocation.get(event.invocationId) ?? [];
|
|
events.push(event);
|
|
auditEventsByInvocation.set(event.invocationId, events);
|
|
}
|
|
|
|
const decisions: ToolRunDecision[] = invocationRows.map((invocation) => {
|
|
const actionRequest =
|
|
actionRequestByInvocation.get(invocation.id) ?? null;
|
|
const auditEvents = auditEventsByInvocation.get(invocation.id) ?? [];
|
|
const latestAuditEvent = auditEvents[0] ?? null;
|
|
const apiInvocation = toToolInvocation(invocation);
|
|
const apiActionRequest = actionRequest
|
|
? toToolActionRequest(actionRequest)
|
|
: null;
|
|
const apiAuditEvents = auditEvents.map(toToolCallEvent);
|
|
const apiLatestAuditEvent = latestAuditEvent
|
|
? toToolCallEvent(latestAuditEvent)
|
|
: null;
|
|
const pendingAction =
|
|
actionRequest && actionRequest.status === "pending"
|
|
? {
|
|
actionRequestId: actionRequest.id,
|
|
issueId: actionRequest.issueId,
|
|
interactionId: actionRequest.interactionId,
|
|
approvalId: actionRequest.approvalId,
|
|
status: actionRequest.status,
|
|
previewMarkdown: actionRequest.previewMarkdown,
|
|
}
|
|
: null;
|
|
return {
|
|
invocation: apiInvocation,
|
|
actionRequest: apiActionRequest,
|
|
auditEvents: apiAuditEvents,
|
|
latestAuditEvent: apiLatestAuditEvent,
|
|
decision: latestAuditEvent?.decision ?? invocation.policyDecision,
|
|
outcome: latestAuditEvent?.outcome ?? null,
|
|
reasonCode: latestAuditEvent?.reasonCode ?? invocation.errorCode,
|
|
denialReason: denialReasonForDecision(invocation, latestAuditEvent),
|
|
pendingAction,
|
|
} satisfies ToolRunDecision;
|
|
});
|
|
|
|
return { runId, decisions };
|
|
},
|
|
|
|
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;
|
|
},
|
|
};
|
|
}
|