mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent runs need credentials for a specific provider and sign-in method. > - Connections already owns accounts, grants, and access permissions. > - AI authentication should use those same boundaries. > - This pull request adds the storage, API, adoption, and runtime foundation. > - Legacy agents keep their authentication until they explicitly adopt a managed connection. ## Linked Issues or Issue Description **Problem or motivation** AI credentials are configured separately from Connections. Agents cannot consistently reuse a responsible user's account or a permitted shared account. **Proposed solution** Manage AI accounts with the existing Connections grants and permissions. Keep model and harness selection independent from credential selection. Preserve legacy authentication until validated adoption. **Alternatives considered** A separate credential registry would duplicate ownership and access policy. Automatic fallback would risk using the wrong account. **Roadmap alignment** This extends the shipped Apps, multi-user, secrets, and agent-runtime capabilities. The maintainer requested the feature and reviewed the UI. Related groundwork: #11899 (connection permissions), #10910 (connection wizard), #11692 (Claude subscription profiles), and #11854 (Codex account rotation). ## What Changed - Add AI-purpose/runtime-auth contracts and an additive, idempotent migration. - Add Claude, OpenAI, OpenRouter, and Grok provider capabilities and catalog entries. - Store credentials on grants. Resolve responsible-user defaults or explicit permitted grants. - Isolate managed credentials and provider sessions across accounts. Block missing credentials without ambient fallback. - Keep imported legacy secrets unchanged during reconnect. Use independent local Codex/Grok sign-in attempts for rotating credentials. - Add authorization, migration, concurrent refresh, retry, cancellation, and legacy-compatibility tests. This is part 1 of a two-PR stack. The app UI follows in #13248. Merge the foundation first. ## Verification - Updated against master `04e364236`, preserving upstream provider login and connector workflows. - Full workspace typecheck, production build, Storybook build, and token gates passed on the integrated branch. Final local-login changes passed 59 focused tests; new-agent and inbox regression suites passed 63 tests. - Browser checks verified automatic local Claude account detection, resumable Codex login commands, retry, focus restoration, and desktop/phone layouts. Commands create their isolated directory before invoking the CLI. - All current-head CI checks passed on `2a996560a`, including all server/workspace tests, browser shards, runner verification, typecheck, build, and canary dry run. Greptile reviewed that commit at 5/5 with no unresolved threads. Earlier local full-suite attempts hit the Mac PostgreSQL shared-memory limit; the complete suites passed in CI. - Renumbered the additive AI migration to `0276` after upstream migrations and regenerated its snapshot. Existing legacy agents retain their configuration. - Added local login status checks, owner-scoped retry, managed OpenCode remote homes, credential-aware model discovery, and task connection-repair delivery. ## Risks - Managed credential failures intentionally block execution. They do not restore legacy fallback. - Preview-era copied Codex/Grok subscriptions require independent reconnect. - The integrated branch has live provider acceptance coverage. This update verifies local Claude detection and Codex API-key task repair; it does not add a new subscription authorization/refresh or Daytona stress pass. - Runtime-auth connections must stay excluded from tool and channel handling. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, code execution, and browser testing. The exact runtime model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
20484 lines
696 KiB
TypeScript
20484 lines
696 KiB
TypeScript
import { connectionPurposeTransportSchema } from "@paperclipai/shared";
|
|
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 {
|
|
initializeMcpHttpSession,
|
|
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 { 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 {
|
|
ComposioApiError,
|
|
createComposioClient,
|
|
type ComposioClient,
|
|
} from "./composio.js";
|
|
import {
|
|
composioChildConfig,
|
|
createComposioSessionManager,
|
|
} from "./composio-session-manager.js";
|
|
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 Composio without live vendor traffic. */
|
|
composioClientFactory?: (apiKey: string) => ComposioClient;
|
|
/** 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 COMPOSIO_GALLERY_KEY = "composio";
|
|
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;
|
|
if (!app) return {};
|
|
const method = connectionMethodForConnection(app, connection);
|
|
return (
|
|
normalizeConnectionMethodConfig(
|
|
method,
|
|
asRecord(connection.config.methodConfig),
|
|
).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);
|
|
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,
|
|
config: row.config ?? {},
|
|
transportConfig: row.transportConfig ?? {},
|
|
credentialRefs: row.credentialRefs ?? [],
|
|
credentialSecretRefs: row.credentialSecretRefs ?? [],
|
|
healthStatus: row.healthStatus,
|
|
healthMessage: 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, "-");
|
|
}
|
|
|
|
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);
|
|
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 === "composio_api_key_rejected") return 422;
|
|
if (failure.code.endsWith("_endpoint_rejected")) return 422;
|
|
return 502;
|
|
}
|
|
|
|
function sanitizeHttpFailure(error: unknown): {
|
|
status: ToolConnectionHealthStatus;
|
|
message: string;
|
|
code: string;
|
|
} {
|
|
if (error instanceof ComposioApiError) {
|
|
return {
|
|
status: "error",
|
|
message: error.message,
|
|
code:
|
|
error.status === 401 || error.status === 403
|
|
? "composio_api_key_rejected"
|
|
: "composio_request_failed",
|
|
};
|
|
}
|
|
if (error instanceof HttpError) {
|
|
const code = asRecord(error.details).code;
|
|
if (code === "composio_connected_account_inactive") {
|
|
return { status: "degraded", 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 composioSessions = createComposioSessionManager(db, {
|
|
composioClientFactory: options.composioClientFactory,
|
|
now: options.now,
|
|
});
|
|
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: 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,
|
|
removalOptions: { confirmComposioChildren?: boolean } = {},
|
|
): Promise<ToolConnectionRemovalResult> {
|
|
const connection = await getConnectionRow(connectionId, companyId);
|
|
const now = new Date();
|
|
const binding = actorBinding(actor);
|
|
|
|
if (isComposioConnection(connection)) {
|
|
const children = (await existingComposioChildren(connection)).filter(
|
|
(child) => child.status !== "archived",
|
|
);
|
|
if (
|
|
children.length > 0 &&
|
|
removalOptions.confirmComposioChildren !== true
|
|
) {
|
|
throw conflict(
|
|
"Deleting this Composio connection also removes its connected services. Confirm child removal to continue.",
|
|
{
|
|
code: "composio_child_removal_confirmation_required",
|
|
childConnectionCount: children.length,
|
|
},
|
|
);
|
|
}
|
|
for (const child of children) {
|
|
await removeConnection(child.id, child.companyId, actor, {
|
|
confirmComposioChildren: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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[]> {
|
|
const composioChild = composioChildConfig(connection);
|
|
const composioSession = composioChild
|
|
? await composioSessions.ensureSession(connection.id)
|
|
: null;
|
|
let headers = composioSession?.headers ??
|
|
credentialHeaders ?? {
|
|
...projectedConnectionHeaders(connection),
|
|
...(await resolveCredentialHeaders(connection, actor)),
|
|
};
|
|
const endpoint =
|
|
composioSession?.url ?? (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.
|
|
const listRequestBody = JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh",
|
|
method: "tools/list",
|
|
params: {},
|
|
});
|
|
const sendRemote = (init: RequestInit) =>
|
|
requestRemoteHttpEndpoint(new URL(endpoint), init);
|
|
const sendToolsList = (requestHeaders: Record<string, string>) =>
|
|
sendRemote({
|
|
method: "POST",
|
|
// MCP Streamable HTTP requires advertising that we accept both a JSON body
|
|
// and an SSE stream; spec-compliant servers 406 without it (see mcp-http.ts).
|
|
headers: mcpHttpRequestHeaders(requestHeaders),
|
|
body: listRequestBody,
|
|
});
|
|
let usedInitializedSession = connection.config.mcpSessionRequired === true;
|
|
let response: Response;
|
|
if (usedInitializedSession) {
|
|
const sessionHeaders = await initializeMcpHttpSession({
|
|
send: sendRemote,
|
|
headers,
|
|
requestId: "paperclip-catalog-refresh",
|
|
});
|
|
response = await sendToolsList(sessionHeaders);
|
|
} 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 (
|
|
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 && composioChild) {
|
|
const refreshed = await composioSessions.ensureSession(connection.id, {
|
|
force: true,
|
|
});
|
|
response = await requestRemoteHttpEndpoint(new URL(refreshed.url), {
|
|
method: "POST",
|
|
headers: mcpHttpRequestHeaders(refreshed.headers),
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: "paperclip-catalog-refresh-retry",
|
|
method: "tools/list",
|
|
params: {},
|
|
}),
|
|
});
|
|
}
|
|
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) {
|
|
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 new HttpError(502, "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 payload = parseMcpHttpResponseBody(
|
|
await response.text(),
|
|
response.headers.get("content-type"),
|
|
);
|
|
const result = asRecord(asRecord(payload).result);
|
|
const payloadTools = asRecord(payload).tools;
|
|
const tools: unknown[] = Array.isArray(result.tools)
|
|
? result.tools
|
|
: Array.isArray(payloadTools)
|
|
? payloadTools
|
|
: [];
|
|
return tools
|
|
.map((tool) => normalizeToolDescriptor(tool))
|
|
.filter((tool): tool is McpToolDescriptor => Boolean(tool));
|
|
}
|
|
|
|
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 isComposioConnection(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
): boolean {
|
|
return (
|
|
asRecord(connection.config).sourceTemplateKey === COMPOSIO_GALLERY_KEY
|
|
);
|
|
}
|
|
|
|
async function composioClientForParent(
|
|
parent: typeof toolConnections.$inferSelect,
|
|
) {
|
|
if (!isComposioConnection(parent) || parent.transport !== "rest_api") {
|
|
throw unprocessable(
|
|
"This connection is not a parent Composio connection.",
|
|
{ code: "not_composio_parent" },
|
|
);
|
|
}
|
|
const headers = await resolveCredentialHeaders(parent);
|
|
const apiKey = Object.entries(headers).find(
|
|
([name]) => name.toLowerCase() === "x-api-key",
|
|
)?.[1];
|
|
if (!apiKey)
|
|
throw unprocessable("The Composio API key secret is missing.", {
|
|
code: "secret_missing",
|
|
});
|
|
return (
|
|
options.composioClientFactory?.(apiKey) ??
|
|
createComposioClient({ apiKey })
|
|
);
|
|
}
|
|
|
|
async function existingComposioChildren(
|
|
parent: typeof toolConnections.$inferSelect,
|
|
) {
|
|
const rows = await db
|
|
.select()
|
|
.from(toolConnections)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.companyId, parent.companyId),
|
|
eq(toolConnections.applicationId, parent.applicationId),
|
|
),
|
|
);
|
|
return rows.filter(
|
|
(row) => composioChildConfig(row)?.parentConnectionId === parent.id,
|
|
);
|
|
}
|
|
|
|
async function assertComposioConnectedAccountActive(
|
|
child: typeof toolConnections.$inferSelect,
|
|
) {
|
|
const childConfig = composioChildConfig(child);
|
|
if (!childConfig) return;
|
|
const parent = await getConnectionRow(
|
|
childConfig.parentConnectionId,
|
|
child.companyId,
|
|
);
|
|
const client = await composioClientForParent(parent);
|
|
const accounts = await client.listConnectedAccounts({
|
|
toolkitSlugs: [childConfig.toolkitSlug],
|
|
userIds: [`paperclip:${child.companyId}`],
|
|
limit: 100,
|
|
});
|
|
const account = childConfig.connectedAccountId
|
|
? accounts.items.find(
|
|
(candidate) => candidate.id === childConfig.connectedAccountId,
|
|
)
|
|
: accounts.items.find(
|
|
(candidate) => candidate.toolkit.slug === childConfig.toolkitSlug,
|
|
);
|
|
if (account?.status.toUpperCase() === "ACTIVE") return;
|
|
const status = account?.status.trim().toUpperCase() || "MISSING";
|
|
throw unprocessable(
|
|
`Composio reports the ${childConfig.toolkitSlug} connected account as ${status}. Reconnect it in Composio.`,
|
|
{
|
|
code: "composio_connected_account_inactive",
|
|
connectedAccountStatus: status,
|
|
},
|
|
);
|
|
}
|
|
|
|
async function disableComposioChildren(
|
|
parent: typeof toolConnections.$inferSelect,
|
|
) {
|
|
const children = await existingComposioChildren(parent);
|
|
for (const child of children) {
|
|
if (child.status === "archived") continue;
|
|
const config = asRecord(child.config);
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
enabled: false,
|
|
config: child.enabled
|
|
? { ...config, disabledByComposioParent: true }
|
|
: config,
|
|
updatedAt: now(),
|
|
})
|
|
.where(eq(toolConnections.id, child.id));
|
|
}
|
|
}
|
|
|
|
async function restoreComposioChildren(
|
|
parent: typeof toolConnections.$inferSelect,
|
|
) {
|
|
const children = await existingComposioChildren(parent);
|
|
const restorable = children.filter(
|
|
(child) =>
|
|
child.status !== "archived" &&
|
|
asRecord(child.config).disabledByComposioParent === true,
|
|
);
|
|
if (restorable.length === 0) return;
|
|
|
|
let accounts: Awaited<
|
|
ReturnType<ComposioClient["listConnectedAccounts"]>
|
|
>["items"] = [];
|
|
try {
|
|
const client = await composioClientForParent(parent);
|
|
accounts = (
|
|
await client.listConnectedAccounts({
|
|
userIds: [`paperclip:${parent.companyId}`],
|
|
limit: 1000,
|
|
})
|
|
).items;
|
|
} catch {
|
|
// Fail closed while Composio is unavailable. A later resume or reconnect
|
|
// can retry without exposing a child whose account state is unknown.
|
|
return;
|
|
}
|
|
|
|
for (const child of restorable) {
|
|
const childConfig = composioChildConfig(child)!;
|
|
const account = childConfig.connectedAccountId
|
|
? accounts.find(
|
|
(candidate) => candidate.id === childConfig.connectedAccountId,
|
|
)
|
|
: accounts.find(
|
|
(candidate) => candidate.toolkit.slug === childConfig.toolkitSlug,
|
|
);
|
|
const config = { ...asRecord(child.config) };
|
|
delete config.disabledByComposioParent;
|
|
const active = account?.status.toUpperCase() === "ACTIVE";
|
|
await db
|
|
.update(toolConnections)
|
|
.set({
|
|
enabled: active,
|
|
config: active
|
|
? config
|
|
: { ...config, disabledByComposioParent: true },
|
|
healthStatus: active ? "unchecked" : "degraded",
|
|
healthMessage: active
|
|
? null
|
|
: `Composio reports the ${childConfig.toolkitSlug} connected account as ${account?.status.toUpperCase() ?? "MISSING"}. Reconnect it in Composio.`,
|
|
updatedAt: now(),
|
|
})
|
|
.where(eq(toolConnections.id, child.id));
|
|
}
|
|
}
|
|
|
|
async function syncComposioChild(
|
|
parent: typeof toolConnections.$inferSelect,
|
|
account: { id: string; status: string; toolkit: { slug: string } },
|
|
toolkitName: string,
|
|
actor?: ActorInfo,
|
|
) {
|
|
if (account.status.toUpperCase() !== "ACTIVE") return null;
|
|
const children = await existingComposioChildren(parent);
|
|
const existing = children.find((candidate) => {
|
|
const config = composioChildConfig(candidate);
|
|
return (
|
|
config?.toolkitSlug === account.toolkit.slug &&
|
|
candidate.status !== "archived"
|
|
);
|
|
});
|
|
if (existing) {
|
|
const config = composioChildConfig(existing)!;
|
|
if (config.connectedAccountId !== account.id) {
|
|
const nextConfig = {
|
|
...existing.config,
|
|
connectedAccountId: account.id,
|
|
};
|
|
const [updated] = await db
|
|
.update(toolConnections)
|
|
.set({
|
|
config: nextConfig,
|
|
transportConfig: {
|
|
...existing.transportConfig,
|
|
connectedAccountId: account.id,
|
|
composioSessions: {},
|
|
},
|
|
updatedAt: now(),
|
|
})
|
|
.where(eq(toolConnections.id, existing.id))
|
|
.returning();
|
|
return updated;
|
|
}
|
|
return existing;
|
|
}
|
|
const connectionId = randomUUID();
|
|
const binding = actorBinding(actor);
|
|
const config = {
|
|
provider: "composio",
|
|
parentConnectionId: parent.id,
|
|
toolkitSlug: account.toolkit.slug,
|
|
connectedAccountId: account.id,
|
|
};
|
|
const [created] = await db
|
|
.insert(toolConnections)
|
|
.values({
|
|
id: connectionId,
|
|
companyId: parent.companyId,
|
|
applicationId: parent.applicationId,
|
|
name: `${toolkitName} (via Composio)`,
|
|
uid: connectionUid(
|
|
`composio:${parent.id}`,
|
|
account.toolkit.slug,
|
|
connectionId,
|
|
),
|
|
connectionKind: "managed",
|
|
ownership: parent.ownership,
|
|
transport: "mcp_remote",
|
|
authKind: "none",
|
|
credentialPolicy: "shared",
|
|
status: "active",
|
|
enabled: true,
|
|
config,
|
|
transportConfig: { ...config, composioSessions: {} },
|
|
credentialRefs: [],
|
|
credentialSecretRefs: [],
|
|
createdByAgentId:
|
|
binding.actorType === "agent" ? binding.actorId : null,
|
|
createdByUserId: binding.actorType === "user" ? binding.actorId : null,
|
|
})
|
|
.returning();
|
|
if (!created)
|
|
throw new Error("Failed to create Composio toolkit connection");
|
|
await ensureDefaultOrganizationGrant(created);
|
|
await syncCredentialBindings(created);
|
|
await ensureRuntimeSlot(created);
|
|
await audit({
|
|
companyId: created.companyId,
|
|
connectionId: created.id,
|
|
action: "composio.child_created",
|
|
outcome: "success",
|
|
actor,
|
|
details: {
|
|
parentConnectionId: parent.id,
|
|
toolkitSlug: account.toolkit.slug,
|
|
},
|
|
});
|
|
return created;
|
|
}
|
|
|
|
async function syncComposioToolkit(
|
|
parent: typeof toolConnections.$inferSelect,
|
|
toolkitSlug: string,
|
|
actor?: ActorInfo,
|
|
) {
|
|
const client = await composioClientForParent(parent);
|
|
const userId = `paperclip:${parent.companyId}`;
|
|
const [toolkits, accounts] = await Promise.all([
|
|
client.listToolkits({ limit: 1000 }),
|
|
client.listConnectedAccounts({
|
|
toolkitSlugs: [toolkitSlug],
|
|
userIds: [userId],
|
|
limit: 100,
|
|
}),
|
|
]);
|
|
const toolkit = toolkits.items.find((item) => item.slug === toolkitSlug);
|
|
if (!toolkit) throw notFound("Composio toolkit not found");
|
|
const account =
|
|
accounts.items.find(
|
|
(item) =>
|
|
item.toolkit.slug === toolkitSlug &&
|
|
item.status.toUpperCase() === "ACTIVE",
|
|
) ??
|
|
accounts.items.find((item) => item.toolkit.slug === toolkitSlug) ??
|
|
null;
|
|
const child = account
|
|
? await syncComposioChild(parent, account, toolkit.name, actor)
|
|
: null;
|
|
if (child)
|
|
await refreshCatalog(child.id, actor, { enableAllByDefault: true });
|
|
return {
|
|
toolkit,
|
|
account,
|
|
child: child ? toConnection(await getConnectionRow(child.id)) : null,
|
|
};
|
|
}
|
|
|
|
async function validateComposioConnection(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
) {
|
|
const client = await composioClientForParent(connection);
|
|
await client.validateApiKey();
|
|
}
|
|
|
|
async function listComposioServices(
|
|
parentConnectionId: string,
|
|
actor?: ActorInfo,
|
|
) {
|
|
const parent = await getConnectionRow(parentConnectionId);
|
|
const client = await composioClientForParent(parent);
|
|
const userId = `paperclip:${parent.companyId}`;
|
|
const [toolkits, accounts] = await Promise.all([
|
|
client.listToolkits({ limit: 1000 }),
|
|
client.listConnectedAccounts({ userIds: [userId], limit: 1000 }),
|
|
]);
|
|
const children = await existingComposioChildren(parent);
|
|
const childByToolkit = new Map(
|
|
children
|
|
.filter((child) => child.status !== "archived")
|
|
.map((child) => [composioChildConfig(child)?.toolkitSlug, child]),
|
|
);
|
|
const services = [];
|
|
for (const toolkit of toolkits.items) {
|
|
const toolkitAccounts = accounts.items.filter(
|
|
(account) => account.toolkit.slug === toolkit.slug,
|
|
);
|
|
const account =
|
|
toolkitAccounts.find(
|
|
(candidate) => candidate.status.toUpperCase() === "ACTIVE",
|
|
) ??
|
|
toolkitAccounts[0] ??
|
|
null;
|
|
let child = childByToolkit.get(toolkit.slug) ?? null;
|
|
if (account?.status.toUpperCase() === "ACTIVE" && !child) {
|
|
child = await syncComposioChild(parent, account, toolkit.name, actor);
|
|
if (child)
|
|
await refreshCatalog(child.id, actor, { enableAllByDefault: true });
|
|
}
|
|
services.push({
|
|
toolkit,
|
|
status:
|
|
account?.status.toUpperCase() === "ACTIVE"
|
|
? "connected"
|
|
: account
|
|
? "pending"
|
|
: "not_connected",
|
|
connectedAccountId: account?.id ?? null,
|
|
connectedAccountStatus: account?.status ?? null,
|
|
childConnectionId: child?.id ?? null,
|
|
});
|
|
}
|
|
return { parentConnectionId: parent.id, userId, services };
|
|
}
|
|
|
|
async function startComposioServiceConnect(
|
|
parentConnectionId: string,
|
|
toolkitSlug: string,
|
|
input: { authConfigId?: string; callbackUrl?: string },
|
|
) {
|
|
const parent = await getConnectionRow(parentConnectionId);
|
|
const client = await composioClientForParent(parent);
|
|
let authConfigId = input.authConfigId?.trim();
|
|
if (!authConfigId) {
|
|
const configs = await client.listAuthConfigs({
|
|
toolkitSlugs: [toolkitSlug],
|
|
showDisabled: false,
|
|
limit: 100,
|
|
});
|
|
authConfigId = configs.items.find(
|
|
(config) =>
|
|
config.toolkit.slug === toolkitSlug && config.status !== "DISABLED",
|
|
)?.id;
|
|
}
|
|
if (!authConfigId)
|
|
throw unprocessable(
|
|
"This Composio toolkit has no enabled auth configuration.",
|
|
{ code: "composio_auth_config_missing" },
|
|
);
|
|
const link = await client.createConnectLink({
|
|
authConfigId,
|
|
userId: `paperclip:${parent.companyId}`,
|
|
alias: `paperclip-${parent.companyId}-${toolkitSlug}`,
|
|
...(input.callbackUrl ? { callbackUrl: input.callbackUrl } : {}),
|
|
});
|
|
return { toolkitSlug, authConfigId, ...link };
|
|
}
|
|
|
|
async function disconnectComposioService(
|
|
parentConnectionId: string,
|
|
toolkitSlug: string,
|
|
actor?: ActorInfo,
|
|
) {
|
|
const parent = await getConnectionRow(parentConnectionId);
|
|
const client = await composioClientForParent(parent);
|
|
const accounts = await client.listConnectedAccounts({
|
|
toolkitSlugs: [toolkitSlug],
|
|
userIds: [`paperclip:${parent.companyId}`],
|
|
limit: 100,
|
|
});
|
|
for (const account of accounts.items.filter(
|
|
(candidate) => candidate.toolkit.slug === toolkitSlug,
|
|
)) {
|
|
await client.deleteConnectedAccount(account.id);
|
|
}
|
|
const children = await existingComposioChildren(parent);
|
|
const removedChildIds: string[] = [];
|
|
for (const child of children) {
|
|
if (
|
|
composioChildConfig(child)?.toolkitSlug !== toolkitSlug ||
|
|
child.status === "archived"
|
|
)
|
|
continue;
|
|
await removeConnection(child.id, child.companyId, actor);
|
|
removedChildIds.push(child.id);
|
|
}
|
|
await audit({
|
|
companyId: parent.companyId,
|
|
connectionId: parent.id,
|
|
action: "composio.service_disconnected",
|
|
outcome: "success",
|
|
actor,
|
|
details: {
|
|
toolkitSlug,
|
|
connectedAccountCount: accounts.items.length,
|
|
removedChildCount: removedChildIds.length,
|
|
},
|
|
});
|
|
return {
|
|
toolkitSlug,
|
|
disconnectedAccountIds: accounts.items.map((account) => account.id),
|
|
removedChildIds,
|
|
};
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
async function discoverTools(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
credentialHeaders?: Record<string, string>,
|
|
actor?: ActorInfo,
|
|
): Promise<McpToolDescriptor[]> {
|
|
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 (isComposioConnection(connection)) {
|
|
await validateComposioConnection(connection);
|
|
return [];
|
|
}
|
|
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,
|
|
): Promise<ToolConnectionHealthCheckResult> {
|
|
const connection = await getConnectionRow(connectionId);
|
|
if (connection.connectionPurpose === "ai") return { connection: toConnection(connection), runtimeSlot: null };
|
|
try {
|
|
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") {
|
|
await assertComposioConnectedAccountActive(connection);
|
|
const credentialHeaders =
|
|
connection.credentialSource === "vercel_connect"
|
|
? await resolveCredentialHeaders(connection, actor, {
|
|
forceRefresh: true,
|
|
})
|
|
: undefined;
|
|
await remoteTools(connection, credentialHeaders, actor);
|
|
} else if (isComposioConnection(connection)) {
|
|
await validateComposioConnection(connection);
|
|
} else {
|
|
await resolveCredentialHeaders(connection);
|
|
await stdioTemplateId(connection.companyId, connection.config);
|
|
}
|
|
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."
|
|
: isComposioConnection(connection)
|
|
? "Composio accepted the API key and returned its toolkits."
|
|
: 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]),
|
|
);
|
|
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 &&
|
|
shouldQuarantineNewEntries(connection) &&
|
|
(connection.status === "active" ||
|
|
sourceTemplateKey === "posthog" ||
|
|
refreshOptions.quarantineManagedOAuthDraft === true);
|
|
const safeDefault = asRecord(connection.config).safeDefault === true;
|
|
for (const descriptor of descriptors) {
|
|
const riskLevel = classifyRisk(descriptor, 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 googlePermanentlyBlocked = Boolean(
|
|
googleProfile &&
|
|
!isGoogleWorkspaceToolAllowed(googleProfile, descriptor),
|
|
);
|
|
const status = googlePermanentlyBlocked
|
|
? "disabled"
|
|
: shouldQuarantine
|
|
? "quarantined"
|
|
: existing?.status === "disabled"
|
|
? "disabled"
|
|
: quarantineOnRefresh && existing?.status === "quarantined"
|
|
? "quarantined"
|
|
: "active";
|
|
if (shouldQuarantine && !googlePermanentlyBlocked) 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 = refreshOptions.enableAllByDefault
|
|
? { ...connection.config, quarantineNewEntries: false }
|
|
: connection.config;
|
|
const normalizedTransportConfig = 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",
|
|
);
|
|
if (!refreshOptions.skipDefaultProfileSync) {
|
|
await enableCatalogEntriesByDefault({
|
|
connection: updatedConnection,
|
|
newCatalogEntryIds: refreshOptions.enableAllByDefault
|
|
? 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,
|
|
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",
|
|
) {
|
|
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";
|
|
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 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");
|
|
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;
|
|
if (galleryEntry && input.link) {
|
|
const acceptsProviderGeneratedUrl =
|
|
method?.transport === "mcp_remote" &&
|
|
method.auth === "none" &&
|
|
!method.defaults?.serverUrl &&
|
|
!method.defaults?.serverUrlTemplate;
|
|
if (!acceptsProviderGeneratedUrl) {
|
|
throw badRequest(
|
|
`${galleryEntry.name} does not accept a provider-generated connection URL`,
|
|
);
|
|
}
|
|
if (!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:
|
|
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: false,
|
|
...(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 =
|
|
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 =
|
|
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,
|
|
);
|
|
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;
|
|
|
|
try {
|
|
const credentialFields =
|
|
credentialSource === "vercel_connect"
|
|
? []
|
|
: galleryEntry
|
|
? credentialFieldsFor(galleryEntry, method?.key)
|
|
: linkCredentialFields(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) {
|
|
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) {
|
|
[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 (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 },
|
|
};
|
|
}
|
|
|
|
let health: ToolConnectionHealthCheckResult;
|
|
try {
|
|
health = await checkConnectionHealth(connectionRow.id, actor);
|
|
} catch (error) {
|
|
if (
|
|
!galleryEntry &&
|
|
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 (galleryEntry?.slug === COMPOSIO_GALLERY_KEY) {
|
|
const [application] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, applicationRow.id));
|
|
return {
|
|
connectionId: health.connection.id,
|
|
application: toApplication(application),
|
|
connection: health.connection,
|
|
catalog: [],
|
|
actions: { readOnly: [], canMakeChanges: [] },
|
|
suggestedDefaults: recommendedDefaultsForApp(
|
|
galleryEntry,
|
|
method?.key,
|
|
),
|
|
};
|
|
}
|
|
const restoreDraftDefaults = Boolean(revivedConnectionPrevious);
|
|
const refresh = await refreshCatalog(connectionRow.id, actor, {
|
|
enableAllByDefault: restoreDraftDefaults,
|
|
restoreDraftDefaults,
|
|
});
|
|
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) {
|
|
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");
|
|
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")) {
|
|
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[] = [];
|
|
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"),
|
|
),
|
|
);
|
|
}
|
|
|
|
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);
|
|
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);
|
|
if (
|
|
isComposioConnection(updated) &&
|
|
updated.enabled &&
|
|
updated.status === "active"
|
|
) {
|
|
await restoreComposioChildren(updated);
|
|
}
|
|
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);
|
|
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 access: FinishToolApp["access"] = deferTaskAccess
|
|
? { agentIds: [] }
|
|
: installs.length === 0
|
|
? 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 && 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);
|
|
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 {
|
|
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;
|
|
},
|
|
|
|
listComposioServices,
|
|
|
|
startComposioServiceConnect,
|
|
|
|
pollComposioService: async (
|
|
parentConnectionId: string,
|
|
toolkitSlug: string,
|
|
actor?: ActorInfo,
|
|
) => {
|
|
const parent = await getConnectionRow(parentConnectionId);
|
|
return syncComposioToolkit(parent, toolkitSlug, actor);
|
|
},
|
|
|
|
disconnectComposioService,
|
|
|
|
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);
|
|
if (
|
|
isComposioConnection(row) &&
|
|
(input.enabled !== undefined || input.status !== undefined)
|
|
) {
|
|
if (!row.enabled || row.status !== "active")
|
|
await disableComposioChildren(row);
|
|
else await restoreComposioChildren(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);
|
|
if (isComposioConnection(row)) {
|
|
if (row.enabled) await restoreComposioChildren(row);
|
|
else await disableComposioChildren(row);
|
|
}
|
|
return toConnection(row);
|
|
},
|
|
|
|
archiveConnection: removeConnection,
|
|
|
|
checkHealth: checkConnectionHealth,
|
|
|
|
refreshCatalog,
|
|
|
|
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;
|
|
},
|
|
};
|
|
}
|